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