Skip to content

Commit b92e77c

Browse files
committed
a $FILE_SERVER that tracks dependencies in the cache
1 parent 18cee9b commit b92e77c

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} from "./javascript/module.js";
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 type {Params} from "./route.js";
2021
import {isParameterized, requote, route} from "./route.js";
2122
import {cyan, faint, green, red, yellow} from "./tty.js";
@@ -50,6 +51,9 @@ const defaultEffects: LoadEffects = {
5051
export 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

5559
export interface LoaderOptions {
@@ -60,7 +64,7 @@ export interface LoaderOptions {
6064
}
6165

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

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) {
@@ -171,6 +176,7 @@ export class PreviewServer {
171176
}
172177
throw enoent(path);
173178
} else if (pathname.startsWith("/_file/")) {
179+
if (this.dependencies) this.dependencies.add(pathname.slice("/_file".length));
174180
send(req, await loaders.loadFile(pathname.slice("/_file".length)), {root}).pipe(res);
175181
} else {
176182
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)