@@ -14,21 +14,26 @@ import {readPages} from "./navigation.js";
14
14
import { renderPreview } from "./render.js" ;
15
15
import type { CellResolver } from "./resolver.js" ;
16
16
import { makeCLIResolver } from "./resolver.js" ;
17
+ import { findLoader , runCommand } from "./dataloader.js" ;
18
+ import { getStats } from "./files.js" ;
17
19
18
20
const publicRoot = join ( dirname ( fileURLToPath ( import . meta. url ) ) , ".." , "public" ) ;
21
+ const cacheRoot = join ( dirname ( fileURLToPath ( import . meta. url ) ) , ".." , ".observablehq" , "cache" ) ;
19
22
20
23
class Server {
21
24
private _server : ReturnType < typeof createServer > ;
22
25
private _socketServer : WebSocketServer ;
23
26
readonly port : number ;
24
27
readonly hostname : string ;
25
28
readonly root : string ;
29
+ readonly cacheRoot : string ;
26
30
private _resolver : CellResolver | undefined ;
27
31
28
- constructor ( { port, hostname, root} : CommandContext ) {
32
+ constructor ( { port, hostname, root, cacheRoot } : CommandContext ) {
29
33
this . port = port ;
30
34
this . hostname = hostname ;
31
35
this . root = root ;
36
+ this . cacheRoot = cacheRoot ;
32
37
this . _server = createServer ( ) ;
33
38
this . _server . on ( "request" , this . _handleRequest ) ;
34
39
this . _socketServer = new WebSocketServer ( { server : this . _server } ) ;
@@ -52,7 +57,34 @@ class Server {
52
57
} else if ( pathname . startsWith ( "/_observablehq/" ) ) {
53
58
send ( req , pathname . slice ( "/_observablehq" . length ) , { root : publicRoot } ) . pipe ( res ) ;
54
59
} else if ( pathname . startsWith ( "/_file/" ) ) {
55
- send ( req , pathname . slice ( "/_file" . length ) , { root : this . root } ) . pipe ( res ) ;
60
+ const path = pathname . slice ( "/_file" . length ) ;
61
+ const filepath = join ( this . root , path ) ;
62
+ try {
63
+ await access ( filepath , constants . R_OK ) ;
64
+ send ( req , pathname . slice ( "/_file" . length ) , { root : this . root } ) . pipe ( res ) ;
65
+ } catch ( error ) {
66
+ if ( isNodeError ( error ) && error . code !== "ENOENT" ) {
67
+ throw error ;
68
+ }
69
+ }
70
+
71
+ // Look for a data loader for this file.
72
+ const { path : loaderPath , stats : loaderStat } = await findLoader ( this . root , path ) ;
73
+ if ( loaderStat ) {
74
+ const cachePath = join ( this . cacheRoot , filepath ) ;
75
+ const cacheStat = await getStats ( cachePath ) ;
76
+ if ( cacheStat && cacheStat . mtimeMs > loaderStat . mtimeMs ) {
77
+ send ( req , filepath , { root : this . cacheRoot } ) . pipe ( res ) ;
78
+ return ;
79
+ }
80
+ if ( ! ( loaderStat . mode & constants . S_IXUSR ) ) {
81
+ throw new HttpError ( "Data loader is not executable" , 404 ) ;
82
+ }
83
+ await runCommand ( loaderPath , cachePath ) ;
84
+ send ( req , filepath , { root : this . cacheRoot } ) . pipe ( res ) ;
85
+ return ;
86
+ }
87
+ throw new HttpError ( "Not found" , 404 ) ;
56
88
} else {
57
89
if ( normalize ( pathname ) . startsWith ( ".." ) ) throw new Error ( "Invalid path: " + pathname ) ;
58
90
let path = join ( this . root , pathname ) ;
@@ -120,11 +152,37 @@ class Server {
120
152
}
121
153
122
154
class FileWatchers {
123
- watchers : FSWatcher [ ] ;
155
+ watchers : FSWatcher [ ] = [ ] ;
156
+
157
+ constructor (
158
+ readonly root : string ,
159
+ readonly files : { name : string } [ ] ,
160
+ readonly cb : ( name : string ) => void
161
+ ) { }
162
+
163
+ async watchAll ( ) {
164
+ const fileset = [ ...new Set ( this . files . map ( ( { name} ) => name ) ) ] ;
165
+ for ( const name of fileset ) {
166
+ const watchPath = await FileWatchers . getWatchPath ( this . root , name ) ;
167
+ let prevState = await getStats ( watchPath ) ;
168
+ this . watchers . push (
169
+ watch ( watchPath , async ( ) => {
170
+ const newState = await getStats ( watchPath ) ;
171
+ // Ignore if the file was truncated or not modified.
172
+ if ( prevState ?. mtimeMs === newState ?. mtimeMs || newState ?. size === 0 ) return ;
173
+ prevState = newState ;
174
+ this . cb ( name ) ;
175
+ } )
176
+ ) ;
177
+ }
178
+ }
124
179
125
- constructor ( root : string , files : { name : string } [ ] , cb : ( name : string ) => void ) {
126
- const fileset = [ ...new Set ( files . map ( ( { name} ) => name ) ) ] ;
127
- this . watchers = fileset . map ( ( name ) => watch ( join ( root , name ) , async ( ) => cb ( name ) ) ) ;
180
+ static async getWatchPath ( root : string , name : string ) {
181
+ const path = join ( root , name ) ;
182
+ const stats = await getStats ( path ) ;
183
+ if ( stats ?. isFile ( ) ) return path ;
184
+ const { path : loaderPath , stats : loaderStat } = await findLoader ( root , name ) ;
185
+ return loaderStat ?. isFile ( ) ? loaderPath : path ;
128
186
}
129
187
130
188
close ( ) {
@@ -163,6 +221,7 @@ function handleWatch(socket: WebSocket, options: {root: string; resolver: CellRe
163
221
async function refreshMarkdown ( path : string ) : Promise < WatchListener < string > > {
164
222
let current = await readMarkdown ( path , root ) ;
165
223
attachmentWatcher = new FileWatchers ( root , current . parse . files , refreshAttachment ( current . parse ) ) ;
224
+ await attachmentWatcher . watchAll ( ) ;
166
225
return async function watcher ( event ) {
167
226
switch ( event ) {
168
227
case "rename" : {
@@ -245,6 +304,7 @@ interface CommandContext {
245
304
root : string ;
246
305
hostname : string ;
247
306
port : number ;
307
+ cacheRoot : string ;
248
308
}
249
309
250
310
function makeCommandContext ( ) : CommandContext {
@@ -272,7 +332,8 @@ function makeCommandContext(): CommandContext {
272
332
return {
273
333
root : normalize ( values . root ) . replace ( / \/ $ / , "" ) ,
274
334
hostname : values . hostname ?? process . env . HOSTNAME ?? "127.0.0.1" ,
275
- port : values . port ? + values . port : process . env . PORT ? + process . env . PORT : 3000
335
+ port : values . port ? + values . port : process . env . PORT ? + process . env . PORT : 3000 ,
336
+ cacheRoot
276
337
} ;
277
338
}
278
339
0 commit comments