@@ -14,21 +14,26 @@ import {readPages} from "./navigation.js";
1414import { renderPreview } from "./render.js" ;
1515import type { CellResolver } from "./resolver.js" ;
1616import { makeCLIResolver } from "./resolver.js" ;
17+ import { findLoader , runCommand } from "./dataloader.js" ;
18+ import { getStats } from "./files.js" ;
1719
1820const publicRoot = join ( dirname ( fileURLToPath ( import . meta. url ) ) , ".." , "public" ) ;
21+ const cacheRoot = join ( dirname ( fileURLToPath ( import . meta. url ) ) , ".." , ".observablehq" , "cache" ) ;
1922
2023class Server {
2124 private _server : ReturnType < typeof createServer > ;
2225 private _socketServer : WebSocketServer ;
2326 readonly port : number ;
2427 readonly hostname : string ;
2528 readonly root : string ;
29+ readonly cacheRoot : string ;
2630 private _resolver : CellResolver | undefined ;
2731
28- constructor ( { port, hostname, root} : CommandContext ) {
32+ constructor ( { port, hostname, root, cacheRoot } : CommandContext ) {
2933 this . port = port ;
3034 this . hostname = hostname ;
3135 this . root = root ;
36+ this . cacheRoot = cacheRoot ;
3237 this . _server = createServer ( ) ;
3338 this . _server . on ( "request" , this . _handleRequest ) ;
3439 this . _socketServer = new WebSocketServer ( { server : this . _server } ) ;
@@ -52,7 +57,34 @@ class Server {
5257 } else if ( pathname . startsWith ( "/_observablehq/" ) ) {
5358 send ( req , pathname . slice ( "/_observablehq" . length ) , { root : publicRoot } ) . pipe ( res ) ;
5459 } 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 ) ;
5688 } else {
5789 if ( normalize ( pathname ) . startsWith ( ".." ) ) throw new Error ( "Invalid path: " + pathname ) ;
5890 let path = join ( this . root , pathname ) ;
@@ -122,11 +154,37 @@ class Server {
122154}
123155
124156class 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+ }
126181
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 ;
130188 }
131189
132190 close ( ) {
@@ -165,6 +223,7 @@ function handleWatch(socket: WebSocket, options: {root: string; resolver: CellRe
165223 async function refreshMarkdown ( path : string ) : Promise < WatchListener < string > > {
166224 let current = await readMarkdown ( path , root ) ;
167225 attachmentWatcher = new FileWatchers ( root , current . parse . files , refreshAttachment ( current . parse ) ) ;
226+ await attachmentWatcher . watchAll ( ) ;
168227 return async function watcher ( event ) {
169228 switch ( event ) {
170229 case "rename" : {
@@ -247,6 +306,7 @@ interface CommandContext {
247306 root : string ;
248307 hostname : string ;
249308 port : number ;
309+ cacheRoot : string ;
250310}
251311
252312function makeCommandContext ( ) : CommandContext {
@@ -274,7 +334,8 @@ function makeCommandContext(): CommandContext {
274334 return {
275335 root : normalize ( values . root ) . replace ( / \/ $ / , "" ) ,
276336 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
278339 } ;
279340}
280341
0 commit comments