@@ -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 ) ;
@@ -122,11 +154,37 @@ class Server {
122
154
}
123
155
124
156
class FileWatchers {
125
- watchers : FSWatcher [ ] ;
157
+ watchers : FSWatcher [ ] = [ ] ;
158
+
159
+ constructor (
160
+ readonly root : string ,
161
+ readonly files : { name : string } [ ] ,
162
+ readonly cb : ( name : string ) => void
163
+ ) { }
164
+
165
+ async watchAll ( ) {
166
+ const fileset = [ ...new Set ( this . files . map ( ( { name} ) => name ) ) ] ;
167
+ for ( const name of fileset ) {
168
+ const watchPath = await FileWatchers . getWatchPath ( this . root , name ) ;
169
+ let prevState = await getStats ( watchPath ) ;
170
+ this . watchers . push (
171
+ watch ( watchPath , async ( ) => {
172
+ const newState = await getStats ( watchPath ) ;
173
+ // Ignore if the file was truncated or not modified.
174
+ if ( prevState ?. mtimeMs === newState ?. mtimeMs || newState ?. size === 0 ) return ;
175
+ prevState = newState ;
176
+ this . cb ( name ) ;
177
+ } )
178
+ ) ;
179
+ }
180
+ }
126
181
127
- constructor ( root : string , files : { name : string } [ ] , cb : ( name : string ) => void ) {
128
- const fileset = [ ...new Set ( files . map ( ( { name} ) => name ) ) ] ;
129
- this . watchers = fileset . map ( ( name ) => watch ( join ( root , name ) , async ( ) => cb ( name ) ) ) ;
182
+ static async getWatchPath ( root : string , name : string ) {
183
+ const path = join ( root , name ) ;
184
+ const stats = await getStats ( path ) ;
185
+ if ( stats ?. isFile ( ) ) return path ;
186
+ const { path : loaderPath , stats : loaderStat } = await findLoader ( root , name ) ;
187
+ return loaderStat ?. isFile ( ) ? loaderPath : path ;
130
188
}
131
189
132
190
close ( ) {
@@ -165,6 +223,7 @@ function handleWatch(socket: WebSocket, options: {root: string; resolver: CellRe
165
223
async function refreshMarkdown ( path : string ) : Promise < WatchListener < string > > {
166
224
let current = await readMarkdown ( path , root ) ;
167
225
attachmentWatcher = new FileWatchers ( root , current . parse . files , refreshAttachment ( current . parse ) ) ;
226
+ await attachmentWatcher . watchAll ( ) ;
168
227
return async function watcher ( event ) {
169
228
switch ( event ) {
170
229
case "rename" : {
@@ -247,6 +306,7 @@ interface CommandContext {
247
306
root : string ;
248
307
hostname : string ;
249
308
port : number ;
309
+ cacheRoot : string ;
250
310
}
251
311
252
312
function makeCommandContext ( ) : CommandContext {
@@ -274,7 +334,8 @@ function makeCommandContext(): CommandContext {
274
334
return {
275
335
root : normalize ( values . root ) . replace ( / \/ $ / , "" ) ,
276
336
hostname : values . hostname ?? process . env . HOSTNAME ?? "127.0.0.1" ,
277
- port : values . port ? + values . port : process . env . PORT ? + process . env . PORT : 3000
337
+ port : values . port ? + values . port : process . env . PORT ? + process . env . PORT : 3000 ,
338
+ cacheRoot
278
339
} ;
279
340
}
280
341
0 commit comments