Skip to content

Commit aa55380

Browse files
committed
a $FILE_SERVER that tracks dependencies in the cache
(rebased after #1662)
1 parent e467a6b commit aa55380

16 files changed

+221
-43
lines changed

src/fileWatchers.ts

+46-28
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {FSWatcher} from "node:fs";
2-
import {watch} from "node:fs";
2+
import {readFileSync, watch} from "node:fs";
3+
import {join} from "node:path/posix";
34
import {isEnoent} from "./error.js";
45
import {maybeStat} from "./files.js";
56
import type {LoaderResolver} from "./loader.js";
@@ -11,38 +12,55 @@ export class FileWatchers {
1112
static async of(loaders: LoaderResolver, path: string, names: Iterable<string>, callback: (name: string) => void) {
1213
const that = new FileWatchers();
1314
const {watchers} = that;
15+
const {root} = loaders;
1416
for (const name of names) {
15-
const watchPath = loaders.getWatchPath(resolvePath(path, name));
16-
if (!watchPath) continue;
17-
let currentStat = await maybeStat(watchPath);
18-
let watcher: FSWatcher;
19-
const index = watchers.length;
17+
const path0 = resolvePath(path, name);
18+
const paths = new Set([path0]);
2019
try {
21-
watcher = watch(watchPath, async function watched(type) {
22-
// Re-initialize the watcher on the original path on rename.
23-
if (type === "rename") {
24-
watcher.close();
25-
try {
26-
watcher = watchers[index] = watch(watchPath, watched);
27-
} catch (error) {
28-
if (!isEnoent(error)) throw error;
29-
console.error(`file no longer exists: ${watchPath}`);
20+
for (const path of JSON.parse(
21+
readFileSync(join(root, ".observablehq", "cache", `${path0}__dependencies`), "utf-8")
22+
))
23+
paths.add(path);
24+
} catch (error) {
25+
if (!isEnoent(error)) {
26+
throw error;
27+
}
28+
}
29+
30+
for (const path of paths) {
31+
const watchPath = loaders.getWatchPath(path);
32+
if (!watchPath) continue;
33+
console.warn(watchPath, name);
34+
let currentStat = await maybeStat(watchPath);
35+
let watcher: FSWatcher;
36+
const index = watchers.length;
37+
try {
38+
watcher = watch(watchPath, async function watched(type) {
39+
// Re-initialize the watcher on the original path on rename.
40+
if (type === "rename") {
41+
watcher.close();
42+
try {
43+
watcher = watchers[index] = watch(watchPath, watched);
44+
} catch (error) {
45+
if (!isEnoent(error)) throw error;
46+
console.error(`file no longer exists: ${watchPath}`);
47+
return;
48+
}
49+
setTimeout(() => watched("change"), 100); // delay to avoid a possibly-empty file
3050
return;
3151
}
32-
setTimeout(() => watched("change"), 100); // delay to avoid a possibly-empty file
33-
return;
34-
}
35-
const newStat = await maybeStat(watchPath);
36-
// Ignore if the file was truncated or not modified.
37-
if (currentStat?.mtimeMs === newStat?.mtimeMs || newStat?.size === 0) return;
38-
currentStat = newStat;
39-
callback(name);
40-
});
41-
} catch (error) {
42-
if (!isEnoent(error)) throw error;
43-
continue;
52+
const newStat = await maybeStat(watchPath);
53+
// Ignore if the file was truncated or not modified.
54+
if (currentStat?.mtimeMs === newStat?.mtimeMs || newStat?.size === 0) return;
55+
currentStat = newStat;
56+
callback(name);
57+
});
58+
} catch (error) {
59+
if (!isEnoent(error)) throw error;
60+
continue;
61+
}
62+
watchers[index] = watcher;
4463
}
45-
watchers[index] = watcher;
4664
}
4765
return that;
4866
}

src/loader.ts

+83-14
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import {createHash} from "node:crypto";
22
import 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";
55
import {dirname, extname, join} from "node:path/posix";
66
import {createGunzip} from "node:zlib";
77
import {spawn} from "cross-spawn";
88
import JSZip from "jszip";
99
import {extract} from "tar-stream";
10-
import {enoent} from "./error.js";
10+
import {enoent, isEnoent} from "./error.js";
1111
import {maybeStat, prepareOutput, visitFiles} from "./files.js";
1212
import {FileWatchers} from "./fileWatchers.js";
1313
import {formatByteSize} from "./format.js";
@@ -16,6 +16,7 @@ import {findModule, getFileInfo, getLocalModuleHash, getModuleHash} from "./java
1616
import type {Logger, Writer} from "./logger.js";
1717
import type {MarkdownPage, ParseOptions} from "./markdown.js";
1818
import {parseMarkdown} from "./markdown.js";
19+
import {preview} from "./preview.js";
1920
import {getModuleResolver, resolveImportPath} from "./resolvers.js";
2021
import type {Params} from "./route.js";
2122
import {isParameterized, requote, route} from "./route.js";
@@ -51,6 +52,9 @@ const defaultEffects: LoadEffects = {
5152
export 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

5660
export interface LoaderOptions {
@@ -61,7 +65,7 @@ export interface LoaderOptions {
6165
}
6266

6367
export 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);

src/preview.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export interface PreviewOptions {
4545
port?: number;
4646
origins?: string[];
4747
verbose?: boolean;
48+
dependencies?: Set<string>;
4849
}
4950

5051
export async function preview(options: PreviewOptions): Promise<PreviewServer> {
@@ -58,19 +59,22 @@ export class PreviewServer {
5859
private readonly _server: ReturnType<typeof createServer>;
5960
private readonly _socketServer: WebSocketServer;
6061
private readonly _verbose: boolean;
62+
private readonly dependencies: Set<string> | undefined;
6163

6264
private constructor({
6365
config,
6466
root,
6567
origins = [],
6668
server,
67-
verbose
69+
verbose,
70+
dependencies
6871
}: {
6972
config?: string;
7073
root?: string;
7174
origins?: string[];
7275
server: Server;
7376
verbose: boolean;
77+
dependencies?: Set<string>;
7478
}) {
7579
this._config = config;
7680
this._root = root;
@@ -80,6 +84,7 @@ export class PreviewServer {
8084
this._server.on("request", this._handleRequest);
8185
this._socketServer = new WebSocketServer({server: this._server});
8286
this._socketServer.on("connection", this._handleConnection);
87+
this.dependencies = dependencies;
8388
}
8489

8590
static async start({verbose = true, hostname, port, open, ...options}: PreviewOptions) {
@@ -172,6 +177,7 @@ export class PreviewServer {
172177
}
173178
throw enoent(path);
174179
} else if (pathname.startsWith("/_file/")) {
180+
if (this.dependencies) this.dependencies.add(pathname.slice("/_file".length));
175181
send(req, await loaders.loadFile(pathname.slice("/_file".length)), {root}).pipe(res);
176182
} else {
177183
if ((pathname = normalize(pathname)).startsWith("..")) throw new Error("Invalid path: " + pathname);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
echo '{"x": 3}'

test/input/build/chain/chain.json.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log(JSON.stringify(process.env.address, null, 2));

test/input/build/chain/chain.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Chained data loaders
2+
3+
```js
4+
FileAttachment("chain1.json").json()
5+
```
6+
7+
```js
8+
FileAttachment("chain2.csv").csv({typed: true})
9+
```

test/input/build/chain/chain1.json.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const {FILE_SERVER} = process.env;
2+
const {x} = await fetch(`${FILE_SERVER}chain-source.json`).then((response) => response.json());
3+
console.log(JSON.stringify({x, "x^2": x * x}, null, 2));

test/input/build/chain/chain2.csv.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const {FILE_SERVER} = process.env;
2+
const {x} = await fetch(`${FILE_SERVER}chain-source.json`).then((response) => response.json());
3+
console.log(`name,value\nx,${x}\nx^2,${x * x}`);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"x": 3,
3+
"x^2": 9
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
name,value
2+
x,3
3+
x^2,9

test/output/build/chain/_npm/[email protected]/cd372fb8.js

Whitespace-only changes.

test/output/build/chain/_observablehq/client.00000001.js

Whitespace-only changes.

test/output/build/chain/_observablehq/runtime.00000002.js

Whitespace-only changes.

test/output/build/chain/_observablehq/stdlib.00000003.js

Whitespace-only changes.

test/output/build/chain/_observablehq/theme-air,near-midnight.00000004.css

Whitespace-only changes.

test/output/build/chain/chain.html

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<!DOCTYPE html>
2+
<meta charset="utf-8">
3+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
4+
<meta name="generator" content="Observable Framework v1.0.0-test">
5+
<title>Chained data loaders</title>
6+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
7+
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&amp;display=swap" crossorigin>
8+
<link rel="preload" as="style" href="./_observablehq/theme-air,near-midnight.00000004.css">
9+
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&amp;display=swap" crossorigin>
10+
<link rel="stylesheet" type="text/css" href="./_observablehq/theme-air,near-midnight.00000004.css">
11+
<link rel="modulepreload" href="./_observablehq/client.00000001.js">
12+
<link rel="modulepreload" href="./_observablehq/runtime.00000002.js">
13+
<link rel="modulepreload" href="./_observablehq/stdlib.00000003.js">
14+
<link rel="modulepreload" href="./_npm/[email protected]/cd372fb8.js">
15+
<script type="module">
16+
17+
import {define} from "./_observablehq/client.00000001.js";
18+
import {registerFile} from "./_observablehq/stdlib.00000003.js";
19+
20+
registerFile("./chain1.json", {"name":"./chain1.json","mimeType":"application/json","path":"./_file/chain1.550fb08c.json","lastModified":/* ts */1706742000000,"size":25});
21+
registerFile("./chain2.csv", {"name":"./chain2.csv","mimeType":"text/csv","path":"./_file/chain2.b1220d22.csv","lastModified":/* ts */1706742000000,"size":21});
22+
23+
define({id: "7ecb71dd", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
24+
display(await(
25+
FileAttachment("./chain1.json").json()
26+
))
27+
}});
28+
29+
define({id: "f6c957f2", inputs: ["FileAttachment","display"], body: async (FileAttachment,display) => {
30+
display(await(
31+
FileAttachment("./chain2.csv").csv({typed: true})
32+
))
33+
}});
34+
35+
</script>
36+
<input id="observablehq-sidebar-toggle" type="checkbox" title="Toggle sidebar">
37+
<label id="observablehq-sidebar-backdrop" for="observablehq-sidebar-toggle"></label>
38+
<nav id="observablehq-sidebar">
39+
<ol>
40+
<label id="observablehq-sidebar-close" for="observablehq-sidebar-toggle"></label>
41+
<li class="observablehq-link"><a href="./">Home</a></li>
42+
</ol>
43+
<ol>
44+
<li class="observablehq-link observablehq-link-active"><a href="./chain">Chained data loaders</a></li>
45+
</ol>
46+
</nav>
47+
<script>{/* redacted init script */}</script>
48+
<aside id="observablehq-toc" data-selector="h1:not(:first-of-type)[id], h2:first-child[id], :not(h1) + h2[id]">
49+
<nav>
50+
</nav>
51+
</aside>
52+
<div id="observablehq-center">
53+
<main id="observablehq-main" class="observablehq">
54+
<h1 id="chained-data-loaders" tabindex="-1"><a class="observablehq-header-anchor" href="#chained-data-loaders">Chained data loaders</a></h1>
55+
<div class="observablehq observablehq--block"><observablehq-loading></observablehq-loading><!--:7ecb71dd:--></div>
56+
<div class="observablehq observablehq--block"><observablehq-loading></observablehq-loading><!--:f6c957f2:--></div>
57+
</main>
58+
<footer id="observablehq-footer">
59+
<div>Built with <a href="https://observablehq.com/" target="_blank" rel="noopener noreferrer">Observable</a> on <a title="2024-01-10T16:00:00">Jan 10, 2024</a>.</div>
60+
</footer>
61+
</div>

0 commit comments

Comments
 (0)