Skip to content

Commit e715830

Browse files
Data loader support (#89)
* First cut and data loader support * Respect async on close() * Switch back to watch instead of watchFile * Fix type error * Update tests * Add build support for data loaders * Move data loader cache to .observablehq/cache * Add .sh extension
1 parent c18ca40 commit e715830

File tree

8 files changed

+161
-22
lines changed

8 files changed

+161
-22
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.DS_Store
2+
.observablehq
23
dist/
34
node_modules/
45
test/output/*-changed.*

src/build.ts

+14-8
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import {access, constants, copyFile, mkdir, readFile, writeFile} from "node:fs/promises";
1+
import {access, constants, copyFile, readFile, writeFile} from "node:fs/promises";
22
import {basename, dirname, join, normalize, relative} from "node:path";
33
import {cwd} from "node:process";
44
import {fileURLToPath} from "node:url";
55
import {parseArgs} from "node:util";
6-
import {visitFiles, visitMarkdownFiles} from "./files.js";
6+
import {getStats, prepareOutput, visitFiles, visitMarkdownFiles} from "./files.js";
77
import {readPages} from "./navigation.js";
88
import {renderServerless} from "./render.js";
99
import {makeCLIResolver} from "./resolver.js";
10+
import {findLoader, runCommand} from "./dataloader.js";
1011

1112
const EXTRA_FILES = new Map([["node_modules/@observablehq/runtime/dist/runtime.js", "_observablehq/runtime.js"]]);
1213

@@ -52,6 +53,17 @@ async function build(context: CommandContext) {
5253
for (const file of files) {
5354
const sourcePath = join(sourceRoot, file);
5455
const outputPath = join(outputRoot, "_file", file);
56+
const stats = await getStats(sourcePath);
57+
if (!stats) {
58+
const {path} = await findLoader("", sourcePath);
59+
if (!path) {
60+
console.error("missing referenced file", sourcePath);
61+
continue;
62+
}
63+
console.log("generate", path, "→", outputPath);
64+
await runCommand(path, outputPath);
65+
continue;
66+
}
5567
console.log("copy", sourcePath, "→", outputPath);
5668
await prepareOutput(outputPath);
5769
await copyFile(sourcePath, outputPath);
@@ -67,12 +79,6 @@ async function build(context: CommandContext) {
6779
}
6880
}
6981

70-
async function prepareOutput(outputPath: string): Promise<void> {
71-
const outputDir = dirname(outputPath);
72-
if (outputDir === ".") return;
73-
await mkdir(outputDir, {recursive: true});
74-
}
75-
7682
const USAGE = `Usage: observable build [--root dir] [--output dir]`;
7783

7884
interface CommandContext {

src/dataloader.ts

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {open} from "node:fs/promises";
2+
import {spawn} from "node:child_process";
3+
import {join} from "node:path";
4+
import {getStats, prepareOutput} from "./files.js";
5+
import {renameSync, unlinkSync} from "node:fs";
6+
7+
const runningCommands = new Map<string, Promise<void>>();
8+
9+
export async function runCommand(commandPath: string, outputPath: string) {
10+
if (runningCommands.has(commandPath)) return runningCommands.get(commandPath);
11+
const command = new Promise<void>((resolve, reject) => {
12+
const outputTempPath = outputPath + ".tmp";
13+
prepareOutput(outputTempPath).then(() =>
14+
open(outputTempPath, "w").then((cacheFd) => {
15+
const cacheFileStream = cacheFd.createWriteStream({highWaterMark: 1024 * 1024});
16+
try {
17+
const subprocess = spawn(commandPath, [], {
18+
argv0: commandPath,
19+
//cwd: dirname(commandPath), // TODO: Need to change commandPath to be relative this?
20+
windowsHide: true,
21+
stdio: ["ignore", "pipe", "inherit"]
22+
// timeout: // time in ms
23+
// signal: // abort signal
24+
});
25+
subprocess.stdout.on("data", (data) => cacheFileStream.write(data));
26+
subprocess.on("error", (error) => console.error(`${commandPath}: ${error.message}`));
27+
subprocess.on("close", (code) => {
28+
cacheFd.close().then(() => {
29+
if (code === 0) {
30+
renameSync(outputTempPath, outputPath);
31+
} else {
32+
unlinkSync(outputTempPath);
33+
}
34+
resolve();
35+
}, reject);
36+
});
37+
} catch (error) {
38+
reject(error);
39+
} finally {
40+
runningCommands.delete(commandPath);
41+
}
42+
})
43+
);
44+
});
45+
runningCommands.set(commandPath, command);
46+
return command;
47+
}
48+
49+
export async function findLoader(root: string, name: string) {
50+
// TODO: It may be more efficient use fs.readdir
51+
for (const ext of [".js", ".ts", ".sh"]) {
52+
const path = join(root, name) + ext;
53+
const stats = await getStats(path);
54+
if (stats) return {path, stats};
55+
}
56+
return {};
57+
}

src/files.ts

+18-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import type {Stats} from "node:fs";
12
import {accessSync, constants, statSync} from "node:fs";
2-
import {readdir, stat} from "node:fs/promises";
3-
import {extname, join, normalize, relative} from "node:path";
3+
import {mkdir, readdir, stat} from "node:fs/promises";
4+
import {dirname, extname, join, normalize, relative} from "node:path";
45
import {isNodeError} from "./error.js";
56

67
// A file is local if it exists in the root folder or a subfolder.
@@ -50,3 +51,18 @@ export async function* visitFiles(root: string): AsyncGenerator<string> {
5051
}
5152
}
5253
}
54+
55+
export async function getStats(path: string): Promise<Stats | undefined> {
56+
try {
57+
return await stat(path);
58+
} catch (error) {
59+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
60+
}
61+
return;
62+
}
63+
64+
export async function prepareOutput(outputPath: string): Promise<void> {
65+
const outputDir = dirname(outputPath);
66+
if (outputDir === ".") return;
67+
await mkdir(outputDir, {recursive: true});
68+
}

src/javascript.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {Parser, tokTypes, type Options} from "acorn";
22
import mime from "mime";
3-
import {isLocalFile} from "./files.js";
43
import {findAwaits} from "./javascript/awaits.js";
54
import {findDeclarations} from "./javascript/declarations.js";
65
import {findFeatures} from "./javascript/features.js";
@@ -43,13 +42,12 @@ export interface ParseOptions {
4342
}
4443

4544
export function transpileJavaScript(input: string, options: ParseOptions): Transpile {
46-
const {root, id} = options;
45+
const {id} = options;
4746
try {
4847
const node = parseJavaScript(input, options);
4948
const databases = node.features.filter((f) => f.type === "DatabaseClient").map((f) => ({name: f.name}));
5049
const files = node.features
5150
.filter((f) => f.type === "FileAttachment")
52-
.filter((f) => isLocalFile(f.name, root))
5351
.map((f) => ({name: f.name, mimeType: mime.getType(f.name)}));
5452
const inputs = Array.from(new Set<string>(node.references.map((r) => r.name)));
5553
const output = new Sourcemap(input);

src/preview.ts

+68-7
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,26 @@ import {readPages} from "./navigation.js";
1414
import {renderPreview} from "./render.js";
1515
import type {CellResolver} from "./resolver.js";
1616
import {makeCLIResolver} from "./resolver.js";
17+
import {findLoader, runCommand} from "./dataloader.js";
18+
import {getStats} from "./files.js";
1719

1820
const publicRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "public");
21+
const cacheRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".observablehq", "cache");
1922

2023
class Server {
2124
private _server: ReturnType<typeof createServer>;
2225
private _socketServer: WebSocketServer;
2326
readonly port: number;
2427
readonly hostname: string;
2528
readonly root: string;
29+
readonly cacheRoot: string;
2630
private _resolver: CellResolver | undefined;
2731

28-
constructor({port, hostname, root}: CommandContext) {
32+
constructor({port, hostname, root, cacheRoot}: CommandContext) {
2933
this.port = port;
3034
this.hostname = hostname;
3135
this.root = root;
36+
this.cacheRoot = cacheRoot;
3237
this._server = createServer();
3338
this._server.on("request", this._handleRequest);
3439
this._socketServer = new WebSocketServer({server: this._server});
@@ -52,7 +57,34 @@ class Server {
5257
} else if (pathname.startsWith("/_observablehq/")) {
5358
send(req, pathname.slice("/_observablehq".length), {root: publicRoot}).pipe(res);
5459
} else if (pathname.startsWith("/_file/")) {
55-
send(req, pathname.slice("/_file".length), {root: this.root}).pipe(res);
60+
const path = pathname.slice("/_file".length);
61+
const filepath = join(this.root, path);
62+
try {
63+
await access(filepath, constants.R_OK);
64+
send(req, pathname.slice("/_file".length), {root: this.root}).pipe(res);
65+
} catch (error) {
66+
if (isNodeError(error) && error.code !== "ENOENT") {
67+
throw error;
68+
}
69+
}
70+
71+
// Look for a data loader for this file.
72+
const {path: loaderPath, stats: loaderStat} = await findLoader(this.root, path);
73+
if (loaderStat) {
74+
const cachePath = join(this.cacheRoot, filepath);
75+
const cacheStat = await getStats(cachePath);
76+
if (cacheStat && cacheStat.mtimeMs > loaderStat.mtimeMs) {
77+
send(req, filepath, {root: this.cacheRoot}).pipe(res);
78+
return;
79+
}
80+
if (!(loaderStat.mode & constants.S_IXUSR)) {
81+
throw new HttpError("Data loader is not executable", 404);
82+
}
83+
await runCommand(loaderPath, cachePath);
84+
send(req, filepath, {root: this.cacheRoot}).pipe(res);
85+
return;
86+
}
87+
throw new HttpError("Not found", 404);
5688
} else {
5789
if (normalize(pathname).startsWith("..")) throw new Error("Invalid path: " + pathname);
5890
let path = join(this.root, pathname);
@@ -122,11 +154,37 @@ class Server {
122154
}
123155

124156
class FileWatchers {
125-
watchers: FSWatcher[];
157+
watchers: FSWatcher[] = [];
158+
159+
constructor(
160+
readonly root: string,
161+
readonly files: {name: string}[],
162+
readonly cb: (name: string) => void
163+
) {}
164+
165+
async watchAll() {
166+
const fileset = [...new Set(this.files.map(({name}) => name))];
167+
for (const name of fileset) {
168+
const watchPath = await FileWatchers.getWatchPath(this.root, name);
169+
let prevState = await getStats(watchPath);
170+
this.watchers.push(
171+
watch(watchPath, async () => {
172+
const newState = await getStats(watchPath);
173+
// Ignore if the file was truncated or not modified.
174+
if (prevState?.mtimeMs === newState?.mtimeMs || newState?.size === 0) return;
175+
prevState = newState;
176+
this.cb(name);
177+
})
178+
);
179+
}
180+
}
126181

127-
constructor(root: string, files: {name: string}[], cb: (name: string) => void) {
128-
const fileset = [...new Set(files.map(({name}) => name))];
129-
this.watchers = fileset.map((name) => watch(join(root, name), async () => cb(name)));
182+
static async getWatchPath(root: string, name: string) {
183+
const path = join(root, name);
184+
const stats = await getStats(path);
185+
if (stats?.isFile()) return path;
186+
const {path: loaderPath, stats: loaderStat} = await findLoader(root, name);
187+
return loaderStat?.isFile() ? loaderPath : path;
130188
}
131189

132190
close() {
@@ -165,6 +223,7 @@ function handleWatch(socket: WebSocket, options: {root: string; resolver: CellRe
165223
async function refreshMarkdown(path: string): Promise<WatchListener<string>> {
166224
let current = await readMarkdown(path, root);
167225
attachmentWatcher = new FileWatchers(root, current.parse.files, refreshAttachment(current.parse));
226+
await attachmentWatcher.watchAll();
168227
return async function watcher(event) {
169228
switch (event) {
170229
case "rename": {
@@ -247,6 +306,7 @@ interface CommandContext {
247306
root: string;
248307
hostname: string;
249308
port: number;
309+
cacheRoot: string;
250310
}
251311

252312
function makeCommandContext(): CommandContext {
@@ -274,7 +334,8 @@ function makeCommandContext(): CommandContext {
274334
return {
275335
root: normalize(values.root).replace(/\/$/, ""),
276336
hostname: values.hostname ?? process.env.HOSTNAME ?? "127.0.0.1",
277-
port: values.port ? +values.port : process.env.PORT ? +process.env.PORT : 3000
337+
port: values.port ? +values.port : process.env.PORT ? +process.env.PORT : 3000,
338+
cacheRoot
278339
};
279340
}
280341

test/output/dynamic-import.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
define({id: "0", outputs: ["foo"], body: async () => {
1+
define({id: "0", outputs: ["foo"], files: [{"name":"./bar.js","mimeType":"application/javascript"}], body: async () => {
22
const foo = await import("/_file/bar.js");
33
return {foo};
44
}});

test/output/static-import.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
define({id: "0", inputs: ["display"], outputs: ["foo"], body: async (display) => {
1+
define({id: "0", inputs: ["display"], outputs: ["foo"], files: [{"name":"./bar.js","mimeType":"application/javascript"}], body: async (display) => {
22
const {foo} = await import("/_file/bar.js");
33

44
display(foo);

0 commit comments

Comments
 (0)