11import  { createHash }  from  "node:crypto" ; 
22import  type  { FSWatcher ,  WatchListener ,  WriteStream }  from  "node:fs" ; 
3- import  { createReadStream ,  existsSync ,  statSync ,  watch }  from  "node:fs" ; 
4- import  { open ,  readFile ,  rename ,  unlink }  from  "node:fs/promises" ; 
3+ import  { createReadStream ,  existsSync ,  readFileSync ,   statSync ,  watch }  from  "node:fs" ; 
4+ import  { open ,  readFile ,  rename ,  rm ,   unlink ,   writeFile }  from  "node:fs/promises" ; 
55import  { dirname ,  extname ,  join }  from  "node:path/posix" ; 
66import  { createGunzip }  from  "node:zlib" ; 
77import  { spawn }  from  "cross-spawn" ; 
88import  JSZip  from  "jszip" ; 
99import  { extract }  from  "tar-stream" ; 
10- import  { enoent }  from  "./error.js" ; 
10+ import  { enoent ,   isEnoent }  from  "./error.js" ; 
1111import  { maybeStat ,  prepareOutput ,  visitFiles }  from  "./files.js" ; 
1212import  { FileWatchers }  from  "./fileWatchers.js" ; 
1313import  { formatByteSize }  from  "./format.js" ; 
@@ -16,6 +16,7 @@ import {findModule, getFileInfo, getLocalModuleHash, getModuleHash} from "./java
1616import  type  { Logger ,  Writer }  from  "./logger.js" ; 
1717import  type  { MarkdownPage ,  ParseOptions }  from  "./markdown.js" ; 
1818import  { parseMarkdown }  from  "./markdown.js" ; 
19+ import  { preview }  from  "./preview.js" ; 
1920import  { getModuleResolver ,  resolveImportPath }  from  "./resolvers.js" ; 
2021import  type  { Params }  from  "./route.js" ; 
2122import  { isParameterized ,  requote ,  route }  from  "./route.js" ; 
@@ -51,6 +52,9 @@ const defaultEffects: LoadEffects = {
5152export  interface  LoadOptions  { 
5253  /** Whether to use a stale cache; true when building. */ 
5354  useStale ?: boolean ; 
55+ 
56+   /** An asset server for chained data loaders. */ 
57+   FILE_SERVER ?: string ; 
5458} 
5559
5660export  interface  LoaderOptions  { 
@@ -61,7 +65,7 @@ export interface LoaderOptions {
6165} 
6266
6367export  class  LoaderResolver  { 
64-   private   readonly  root : string ; 
68+   readonly  root : string ; 
6569  private  readonly  interpreters : Map < string ,  string [ ] > ; 
6670
6771  constructor ( { root,  interpreters} : { root : string ;  interpreters ?: Record < string ,  string [ ]  |  null > } )  { 
@@ -304,7 +308,21 @@ export class LoaderResolver {
304308    const  info  =  getFileInfo ( this . root ,  path ) ; 
305309    if  ( ! info )  return  createHash ( "sha256" ) . digest ( "hex" ) ; 
306310    const  { hash}  =  info ; 
307-     return  path  ===  name  ? hash  : createHash ( "sha256" ) . update ( hash ) . update ( String ( info . mtimeMs ) ) . digest ( "hex" ) ; 
311+     if  ( path  ===  name )  return  hash ; 
312+     const  hash2  =  createHash ( "sha256" ) . update ( hash ) . update ( String ( info . mtimeMs ) ) ; 
313+     try  { 
314+       for  ( const  path  of  JSON . parse ( 
315+         readFileSync ( join ( this . root ,  ".observablehq" ,  "cache" ,  `${ name }  __dependencies` ) ,  "utf-8" ) 
316+       ) )  { 
317+         const  info  =  getFileInfo ( this . root ,  this . getSourceFilePath ( path ) ) ; 
318+         if  ( info )  hash2 . update ( info . hash ) . update ( String ( info . mtimeMs ) ) ; 
319+       } 
320+     }  catch  ( error )  { 
321+       if  ( ! isEnoent ( error ) )  { 
322+         throw  error ; 
323+       } 
324+     } 
325+     return  hash2 . digest ( "hex" ) ; 
308326  } 
309327
310328  getOutputFileHash ( name : string ) : string  { 
@@ -417,12 +435,37 @@ abstract class AbstractLoader implements Loader {
417435        const  outputPath  =  join ( ".observablehq" ,  "cache" ,  this . targetPath ) ; 
418436        const  cachePath  =  join ( this . root ,  outputPath ) ; 
419437        const  loaderStat  =  await  maybeStat ( loaderPath ) ; 
420-         const  cacheStat  =  await  maybeStat ( cachePath ) ; 
421-         if  ( ! cacheStat )  effects . output . write ( faint ( "[missing] " ) ) ; 
422-         else  if  ( cacheStat . mtimeMs  <  loaderStat ! . mtimeMs )  { 
423-           if  ( useStale )  return  effects . output . write ( faint ( "[using stale] " ) ) ,  outputPath ; 
424-           else  effects . output . write ( faint ( "[stale] " ) ) ; 
425-         }  else  return  effects . output . write ( faint ( "[fresh] " ) ) ,  outputPath ; 
438+         const  paths  =  new  Set ( [ cachePath ] ) ; 
439+         try  { 
440+           for  ( const  path  of  JSON . parse ( await  readFile ( `${ cachePath }  __dependencies` ,  "utf-8" ) ) )  paths . add ( path ) ; 
441+         }  catch  ( error )  { 
442+           if  ( ! isEnoent ( error ) )  { 
443+             throw  error ; 
444+           } 
445+         } 
446+ 
447+         const  FRESH  =  0 ; 
448+         const  STALE  =  1 ; 
449+         const  MISSING  =  2 ; 
450+         let  status  =  FRESH ; 
451+         for  ( const  path  of  paths )  { 
452+           const  cacheStat  =  await  maybeStat ( path ) ; 
453+           if  ( ! cacheStat )  { 
454+             status  =  MISSING ; 
455+             break ; 
456+           }  else  if  ( cacheStat . mtimeMs  <  loaderStat ! . mtimeMs )  status  =  Math . max ( status ,  STALE ) ; 
457+         } 
458+         switch  ( status )  { 
459+           case  FRESH :
460+             return  effects . output . write ( faint ( "[fresh] " ) ) ,  outputPath ; 
461+           case  STALE :
462+             if  ( useStale )  return  effects . output . write ( faint ( "[using stale] " ) ) ,  outputPath ; 
463+             effects . output . write ( faint ( "[stale] " ) ) ; 
464+             break ; 
465+           case  MISSING :
466+             effects . output . write ( faint ( "[missing] " ) ) ; 
467+             break ; 
468+         } 
426469        const  tempPath  =  join ( this . root ,  ".observablehq" ,  "cache" ,  `${ this . targetPath }  .${ process . pid }  ` ) ; 
427470        const  errorPath  =  tempPath  +  ".err" ; 
428471        const  errorStat  =  await  maybeStat ( errorPath ) ; 
@@ -434,15 +477,37 @@ abstract class AbstractLoader implements Loader {
434477        await  prepareOutput ( tempPath ) ; 
435478        await  prepareOutput ( cachePath ) ; 
436479        const  tempFd  =  await  open ( tempPath ,  "w" ) ; 
480+ 
481+         // Launch a server for chained data loaders. TODO configure host? 
482+         const  dependencies  =  new  Set < string > ( ) ; 
483+         const  { server}  =  await  preview ( { root : this . root ,  verbose : false ,  hostname : "127.0.0.1" ,  dependencies} ) ; 
484+         const  address  =  server . address ( ) ; 
485+         if  ( ! address  ||  typeof  address  !==  "object" ) 
486+           throw  new  Error ( "Couldn't launch server for chained data loaders!" ) ; 
487+         const  FILE_SERVER  =  `http://${ address . address }  :${ address . port }  /_file/` ; 
488+ 
437489        try  { 
438-           await  this . exec ( tempFd . createWriteStream ( { highWaterMark : 1024  *  1024 } ) ,  { useStale} ,  effects ) ; 
490+           await  this . exec ( tempFd . createWriteStream ( { highWaterMark : 1024  *  1024 } ) ,  { useStale,   FILE_SERVER } ,  effects ) ; 
439491          await  rename ( tempPath ,  cachePath ) ; 
440492        }  catch  ( error )  { 
441493          await  rename ( tempPath ,  errorPath ) ; 
442494          throw  error ; 
443495        }  finally  { 
444496          await  tempFd . close ( ) ; 
445497        } 
498+ 
499+         const  cachedeps  =  `${ cachePath }  __dependencies` ; 
500+         if  ( dependencies . size )  await  writeFile ( cachedeps ,  JSON . stringify ( [ ...dependencies ] ) ,  "utf-8" ) ; 
501+         else 
502+           try  { 
503+             await  rm ( cachedeps ) ; 
504+           }  catch  ( error )  { 
505+             if  ( ! isEnoent ( error ) )  throw  error ; 
506+           } 
507+ 
508+         // TODO: server.close() might be enough? 
509+         await  new  Promise ( ( closed )  =>  server . close ( closed ) ) ; 
510+ 
446511        return  outputPath ; 
447512      } ) ( ) ; 
448513      command . finally ( ( )  =>  runningCommands . delete ( key ) ) . catch ( ( )  =>  { } ) ; 
@@ -495,8 +560,12 @@ class CommandLoader extends AbstractLoader {
495560    this . args  =  args ; 
496561  } 
497562
498-   async  exec ( output : WriteStream ) : Promise < void >  { 
499-     const  subprocess  =  spawn ( this . command ,  this . args ,  { windowsHide : true ,  stdio : [ "ignore" ,  output ,  "inherit" ] } ) ; 
563+   async  exec ( output : WriteStream ,  { FILE_SERVER } ) : Promise < void >  { 
564+     const  subprocess  =  spawn ( this . command ,  this . args ,  { 
565+       windowsHide : true , 
566+       stdio : [ "ignore" ,  output ,  "inherit" ] , 
567+       env : { ...process . env ,  FILE_SERVER } 
568+     } ) ; 
500569    const  code  =  await  new  Promise ( ( resolve ,  reject )  =>  { 
501570      subprocess . on ( "error" ,  reject ) ; 
502571      subprocess . on ( "close" ,  resolve ) ; 
0 commit comments