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} from "./javascript/module.js";
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 type { Params } from "./route.js" ;
2021import { isParameterized , requote , route } from "./route.js" ;
2122import { cyan , faint , green , red , yellow } from "./tty.js" ;
@@ -50,6 +51,9 @@ const defaultEffects: LoadEffects = {
5051export interface LoadOptions {
5152 /** Whether to use a stale cache; true when building. */
5253 useStale ?: boolean ;
54+
55+ /** An asset server for chained data loaders. */
56+ FILE_SERVER ?: string ;
5357}
5458
5559export interface LoaderOptions {
@@ -60,7 +64,7 @@ export interface LoaderOptions {
6064}
6165
6266export class LoaderResolver {
63- private readonly root : string ;
67+ readonly root : string ;
6468 private readonly interpreters : Map < string , string [ ] > ;
6569
6670 constructor ( { root, interpreters} : { root : string ; interpreters ?: Record < string , string [ ] | null > } ) {
@@ -303,7 +307,21 @@ export class LoaderResolver {
303307 const info = getFileInfo ( this . root , path ) ;
304308 if ( ! info ) return createHash ( "sha256" ) . digest ( "hex" ) ;
305309 const { hash} = info ;
306- return path === name ? hash : createHash ( "sha256" ) . update ( hash ) . update ( String ( info . mtimeMs ) ) . digest ( "hex" ) ;
310+ if ( path === name ) return hash ;
311+ const hash2 = createHash ( "sha256" ) . update ( hash ) . update ( String ( info . mtimeMs ) ) ;
312+ try {
313+ for ( const path of JSON . parse (
314+ readFileSync ( join ( this . root , ".observablehq" , "cache" , `${ name } __dependencies` ) , "utf-8" )
315+ ) ) {
316+ const info = getFileInfo ( this . root , this . getSourceFilePath ( path ) ) ;
317+ if ( info ) hash2 . update ( info . hash ) . update ( String ( info . mtimeMs ) ) ;
318+ }
319+ } catch ( error ) {
320+ if ( ! isEnoent ( error ) ) {
321+ throw error ;
322+ }
323+ }
324+ return hash2 . digest ( "hex" ) ;
307325 }
308326
309327 getSourceInfo ( name : string ) : FileInfo | undefined {
@@ -394,12 +412,37 @@ abstract class AbstractLoader implements Loader {
394412 const outputPath = join ( ".observablehq" , "cache" , this . targetPath ) ;
395413 const cachePath = join ( this . root , outputPath ) ;
396414 const loaderStat = await maybeStat ( loaderPath ) ;
397- const cacheStat = await maybeStat ( cachePath ) ;
398- if ( ! cacheStat ) effects . output . write ( faint ( "[missing] " ) ) ;
399- else if ( cacheStat . mtimeMs < loaderStat ! . mtimeMs ) {
400- if ( useStale ) return effects . output . write ( faint ( "[using stale] " ) ) , outputPath ;
401- else effects . output . write ( faint ( "[stale] " ) ) ;
402- } else return effects . output . write ( faint ( "[fresh] " ) ) , outputPath ;
415+ const paths = new Set ( [ cachePath ] ) ;
416+ try {
417+ for ( const path of JSON . parse ( await readFile ( `${ cachePath } __dependencies` , "utf-8" ) ) ) paths . add ( path ) ;
418+ } catch ( error ) {
419+ if ( ! isEnoent ( error ) ) {
420+ throw error ;
421+ }
422+ }
423+
424+ const FRESH = 0 ;
425+ const STALE = 1 ;
426+ const MISSING = 2 ;
427+ let status = FRESH ;
428+ for ( const path of paths ) {
429+ const cacheStat = await maybeStat ( path ) ;
430+ if ( ! cacheStat ) {
431+ status = MISSING ;
432+ break ;
433+ } else if ( cacheStat . mtimeMs < loaderStat ! . mtimeMs ) status = Math . max ( status , STALE ) ;
434+ }
435+ switch ( status ) {
436+ case FRESH :
437+ return effects . output . write ( faint ( "[fresh] " ) ) , outputPath ;
438+ case STALE :
439+ if ( useStale ) return effects . output . write ( faint ( "[using stale] " ) ) , outputPath ;
440+ effects . output . write ( faint ( "[stale] " ) ) ;
441+ break ;
442+ case MISSING :
443+ effects . output . write ( faint ( "[missing] " ) ) ;
444+ break ;
445+ }
403446 const tempPath = join ( this . root , ".observablehq" , "cache" , `${ this . targetPath } .${ process . pid } ` ) ;
404447 const errorPath = tempPath + ".err" ;
405448 const errorStat = await maybeStat ( errorPath ) ;
@@ -411,15 +454,37 @@ abstract class AbstractLoader implements Loader {
411454 await prepareOutput ( tempPath ) ;
412455 await prepareOutput ( cachePath ) ;
413456 const tempFd = await open ( tempPath , "w" ) ;
457+
458+ // Launch a server for chained data loaders. TODO configure host?
459+ const dependencies = new Set < string > ( ) ;
460+ const { server} = await preview ( { root : this . root , verbose : false , hostname : "127.0.0.1" , dependencies} ) ;
461+ const address = server . address ( ) ;
462+ if ( ! address || typeof address !== "object" )
463+ throw new Error ( "Couldn't launch server for chained data loaders!" ) ;
464+ const FILE_SERVER = `http://${ address . address } :${ address . port } /_file/` ;
465+
414466 try {
415- await this . exec ( tempFd . createWriteStream ( { highWaterMark : 1024 * 1024 } ) , { useStale} , effects ) ;
467+ await this . exec ( tempFd . createWriteStream ( { highWaterMark : 1024 * 1024 } ) , { useStale, FILE_SERVER } , effects ) ;
416468 await rename ( tempPath , cachePath ) ;
417469 } catch ( error ) {
418470 await rename ( tempPath , errorPath ) ;
419471 throw error ;
420472 } finally {
421473 await tempFd . close ( ) ;
422474 }
475+
476+ const cachedeps = `${ cachePath } __dependencies` ;
477+ if ( dependencies . size ) await writeFile ( cachedeps , JSON . stringify ( [ ...dependencies ] ) , "utf-8" ) ;
478+ else
479+ try {
480+ await rm ( cachedeps ) ;
481+ } catch ( error ) {
482+ if ( ! isEnoent ( error ) ) throw error ;
483+ }
484+
485+ // TODO: server.close() might be enough?
486+ await new Promise ( ( closed ) => server . close ( closed ) ) ;
487+
423488 return outputPath ;
424489 } ) ( ) ;
425490 command . finally ( ( ) => runningCommands . delete ( key ) ) . catch ( ( ) => { } ) ;
@@ -472,8 +537,12 @@ class CommandLoader extends AbstractLoader {
472537 this . args = args ;
473538 }
474539
475- async exec ( output : WriteStream ) : Promise < void > {
476- const subprocess = spawn ( this . command , this . args , { windowsHide : true , stdio : [ "ignore" , output , "inherit" ] } ) ;
540+ async exec ( output : WriteStream , { FILE_SERVER } ) : Promise < void > {
541+ const subprocess = spawn ( this . command , this . args , {
542+ windowsHide : true ,
543+ stdio : [ "ignore" , output , "inherit" ] ,
544+ env : { ...process . env , FILE_SERVER }
545+ } ) ;
477546 const code = await new Promise ( ( resolve , reject ) => {
478547 subprocess . on ( "error" , reject ) ;
479548 subprocess . on ( "close" , resolve ) ;
0 commit comments