Skip to content

Commit 0e6919d

Browse files
mbostockFil
andauthored
node imports (#1156)
* checkpoint import from node_modules * it works! * minify * only resolve bare imports * coalesce imports; ignore errors * preload transitive dependencies * DRY parseImports * parseImports tests * fix windows? * add logging * test only windows * don’t resolve input * restore tests * build _node * fix import order * 30s timeout * adopt @rollup/plugin-node-resolve * more tests * fix file path resolution * restore tests * add missing dependency * adopt pkg-dir --------- Co-authored-by: Philippe Rivière <[email protected]>
1 parent ed2fed5 commit 0e6919d

File tree

25 files changed

+365
-54
lines changed

25 files changed

+365
-54
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
"test": "concurrently npm:test:mocha npm:test:tsc npm:test:lint npm:test:prettier",
2828
"test:coverage": "c8 --check-coverage --lines 80 --per-file yarn test:mocha",
2929
"test:build": "rimraf test/build && node build.js --sourcemap --outdir=test/build \"{src,test}/**/*.{ts,js,css}\" --ignore \"test/input/**\" --ignore \"test/output/**\" --ignore \"test/preview/dashboard/**\" --ignore \"**/*.d.ts\" && cp -r templates test/build",
30-
"test:mocha": "yarn test:build && rimraf --glob test/.observablehq/cache test/input/build/*/.observablehq/cache && cross-env OBSERVABLE_TELEMETRY_DISABLE=1 TZ=America/Los_Angeles mocha --timeout 5000 -p \"test/build/test/**/*-test.js\"",
31-
"test:mocha:serial": "yarn test:build && rimraf --glob test/.observablehq/cache test/input/build/*/.observablehq/cache && cross-env OBSERVABLE_TELEMETRY_DISABLE=1 TZ=America/Los_Angeles mocha --timeout 5000 \"test/build/test/**/*-test.js\"",
30+
"test:mocha": "yarn test:build && rimraf --glob test/.observablehq/cache test/input/build/*/.observablehq/cache && cross-env OBSERVABLE_TELEMETRY_DISABLE=1 TZ=America/Los_Angeles mocha --timeout 30000 -p \"test/build/test/**/*-test.js\"",
31+
"test:mocha:serial": "yarn test:build && rimraf --glob test/.observablehq/cache test/input/build/*/.observablehq/cache && cross-env OBSERVABLE_TELEMETRY_DISABLE=1 TZ=America/Los_Angeles mocha --timeout 30000 \"test/build/test/**/*-test.js\"",
3232
"test:lint": "eslint src test --max-warnings=0",
3333
"test:prettier": "prettier --check src test",
3434
"test:tsc": "tsc --noEmit",
@@ -77,6 +77,7 @@
7777
"mime": "^4.0.0",
7878
"minisearch": "^6.3.0",
7979
"open": "^10.1.0",
80+
"pkg-dir": "^8.0.0",
8081
"rollup": "^4.6.0",
8182
"rollup-plugin-esbuild": "^6.1.0",
8283
"semver": "^7.5.4",

src/build.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {transpileModule} from "./javascript/transpile.js";
1010
import type {Logger, Writer} from "./logger.js";
1111
import type {MarkdownPage} from "./markdown.js";
1212
import {parseMarkdown} from "./markdown.js";
13+
import {extractNodeSpecifier} from "./node.js";
1314
import {extractNpmSpecifier, populateNpmCache, resolveNpmImport} from "./npm.js";
1415
import {isPathImport, relativePath, resolvePath} from "./path.js";
1516
import {renderPage} from "./render.js";
@@ -175,10 +176,14 @@ export async function build(
175176
// these, too, but it would involve rewriting the files since populateNpmCache
176177
// doesn’t let you pass in a resolver.
177178
for (const path of globalImports) {
178-
if (!path.startsWith("/_npm/")) continue; // skip _observablehq
179-
effects.output.write(`${faint("copy")} npm:${extractNpmSpecifier(path)} ${faint("→")} `);
180-
const sourcePath = await populateNpmCache(root, path); // TODO effects
181-
await effects.copyFile(sourcePath, path);
179+
if (path.startsWith("/_npm/")) {
180+
effects.output.write(`${faint("copy")} npm:${extractNpmSpecifier(path)} ${faint("→")} `);
181+
const sourcePath = await populateNpmCache(root, path); // TODO effects
182+
await effects.copyFile(sourcePath, path);
183+
} else if (path.startsWith("/_node/")) {
184+
effects.output.write(`${faint("copy")} ${extractNodeSpecifier(path)} ${faint("→")} `);
185+
await effects.copyFile(join(root, ".observablehq", "cache", path), path);
186+
}
182187
}
183188

184189
// Copy over imported local modules, overriding import resolution so that

src/javascript/imports.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import {readFile} from "node:fs/promises";
2+
import {join} from "node:path/posix";
13
import type {Node} from "acorn";
24
import type {CallExpression} from "acorn";
35
import type {ExportAllDeclaration, ExportNamedDeclaration, ImportDeclaration, ImportExpression} from "acorn";
46
import {simple} from "acorn-walk";
57
import {isPathImport, relativePath, resolveLocalPath} from "../path.js";
8+
import {parseProgram} from "./parse.js";
69
import {getStringLiteralValue, isStringLiteral} from "./source.js";
710
import {syntaxError} from "./syntaxError.js";
811

@@ -59,6 +62,7 @@ export function hasImportDeclaration(body: Node): boolean {
5962
*/
6063
export function findImports(body: Node, path: string, input: string): ImportReference[] {
6164
const imports: ImportReference[] = [];
65+
const keys = new Set<string>();
6266

6367
simple(body, {
6468
ImportDeclaration: findImport,
@@ -68,6 +72,11 @@ export function findImports(body: Node, path: string, input: string): ImportRefe
6872
CallExpression: findImportMetaResolve
6973
});
7074

75+
function addImport(ref: ImportReference) {
76+
const key = `${ref.type}:${ref.method}:${ref.name}`;
77+
if (!keys.has(key)) keys.add(key), imports.push(ref);
78+
}
79+
7180
function findImport(node: ImportNode | ExportNode) {
7281
const source = node.source;
7382
if (!source || !isStringLiteral(source)) return;
@@ -76,9 +85,9 @@ export function findImports(body: Node, path: string, input: string): ImportRefe
7685
if (isPathImport(name)) {
7786
const localPath = resolveLocalPath(path, name);
7887
if (!localPath) throw syntaxError(`non-local import: ${name}`, node, input); // prettier-ignore
79-
imports.push({name: relativePath(path, localPath), type: "local", method});
88+
addImport({name: relativePath(path, localPath), type: "local", method});
8089
} else {
81-
imports.push({name, type: "global", method});
90+
addImport({name, type: "global", method});
8291
}
8392
}
8493

@@ -89,9 +98,9 @@ export function findImports(body: Node, path: string, input: string): ImportRefe
8998
if (isPathImport(name)) {
9099
const localPath = resolveLocalPath(path, name);
91100
if (!localPath) throw syntaxError(`non-local import: ${name}`, node, input); // prettier-ignore
92-
imports.push({name: relativePath(path, localPath), type: "local", method: "dynamic"});
101+
addImport({name: relativePath(path, localPath), type: "local", method: "dynamic"});
93102
} else {
94-
imports.push({name, type: "global", method: "dynamic"});
103+
addImport({name, type: "global", method: "dynamic"});
95104
}
96105
}
97106

@@ -109,3 +118,28 @@ export function isImportMetaResolve(node: CallExpression): boolean {
109118
node.arguments.length > 0
110119
);
111120
}
121+
122+
export function isJavaScript(path: string): boolean {
123+
return /\.(m|c)?js$/i.test(path);
124+
}
125+
126+
const parseImportsCache = new Map<string, Promise<ImportReference[]>>();
127+
128+
export async function parseImports(root: string, path: string): Promise<ImportReference[]> {
129+
if (!isJavaScript(path)) return []; // TODO traverse CSS, too
130+
const filePath = join(root, path);
131+
let promise = parseImportsCache.get(filePath);
132+
if (promise) return promise;
133+
promise = (async function () {
134+
try {
135+
const source = await readFile(filePath, "utf-8");
136+
const body = parseProgram(source);
137+
return findImports(body, path, source);
138+
} catch (error: any) {
139+
console.warn(`unable to fetch or parse ${path}: ${error.message}`);
140+
return [];
141+
}
142+
})();
143+
parseImportsCache.set(filePath, promise);
144+
return promise;
145+
}

src/node.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {existsSync} from "node:fs";
2+
import {copyFile, readFile, writeFile} from "node:fs/promises";
3+
import {createRequire} from "node:module";
4+
import op from "node:path";
5+
import {extname, join} from "node:path/posix";
6+
import {pathToFileURL} from "node:url";
7+
import {nodeResolve} from "@rollup/plugin-node-resolve";
8+
import {packageDirectory} from "pkg-dir";
9+
import type {AstNode, OutputChunk, Plugin, ResolveIdResult} from "rollup";
10+
import {rollup} from "rollup";
11+
import esbuild from "rollup-plugin-esbuild";
12+
import {prepareOutput, toOsPath} from "./files.js";
13+
import type {ImportReference} from "./javascript/imports.js";
14+
import {isJavaScript, parseImports} from "./javascript/imports.js";
15+
import {parseNpmSpecifier} from "./npm.js";
16+
import {isPathImport} from "./path.js";
17+
import {faint} from "./tty.js";
18+
19+
export async function resolveNodeImport(root: string, spec: string): Promise<string> {
20+
return resolveNodeImportInternal(op.join(root, ".observablehq", "cache", "_node"), root, spec);
21+
}
22+
23+
const bundlePromises = new Map<string, Promise<void>>();
24+
25+
async function resolveNodeImportInternal(cacheRoot: string, packageRoot: string, spec: string): Promise<string> {
26+
const {name, path = "."} = parseNpmSpecifier(spec);
27+
const require = createRequire(pathToFileURL(op.join(packageRoot, "/")));
28+
const pathResolution = require.resolve(spec);
29+
const packageResolution = await packageDirectory({cwd: op.dirname(pathResolution)});
30+
if (!packageResolution) throw new Error(`unable to resolve package.json: ${spec}`);
31+
const {version} = JSON.parse(await readFile(op.join(packageResolution, "package.json"), "utf-8"));
32+
const resolution = `${name}@${version}/${extname(path) ? path : path === "." ? "index.js" : `${path}.js`}`;
33+
const outputPath = op.join(cacheRoot, toOsPath(resolution));
34+
if (!existsSync(outputPath)) {
35+
let promise = bundlePromises.get(outputPath);
36+
if (!promise) {
37+
promise = (async () => {
38+
process.stdout.write(`${spec} ${faint("→")} ${resolution}\n`);
39+
await prepareOutput(outputPath);
40+
if (isJavaScript(pathResolution)) {
41+
await writeFile(outputPath, await bundle(spec, cacheRoot, packageResolution));
42+
} else {
43+
await copyFile(pathResolution, outputPath);
44+
}
45+
})();
46+
bundlePromises.set(outputPath, promise);
47+
promise.catch(() => {}).then(() => bundlePromises.delete(outputPath));
48+
}
49+
await promise;
50+
}
51+
return `/_node/${resolution}`;
52+
}
53+
54+
/**
55+
* Resolves the direct dependencies of the specified node import path, such as
56+
* "/_node/[email protected]/src/index.js", returning a set of node import paths.
57+
*/
58+
export async function resolveNodeImports(root: string, path: string): Promise<ImportReference[]> {
59+
if (!path.startsWith("/_node/")) throw new Error(`invalid node path: ${path}`);
60+
return parseImports(join(root, ".observablehq", "cache"), path);
61+
}
62+
63+
/**
64+
* Given a local npm path such as "/_node/[email protected]/src/index.js", returns
65+
* the corresponding npm specifier such as "[email protected]/src/index.js".
66+
*/
67+
export function extractNodeSpecifier(path: string): string {
68+
if (!path.startsWith("/_node/")) throw new Error(`invalid node path: ${path}`);
69+
return path.replace(/^\/_node\//, "");
70+
}
71+
72+
async function bundle(input: string, cacheRoot: string, packageRoot: string): Promise<string> {
73+
const bundle = await rollup({
74+
input,
75+
plugins: [
76+
nodeResolve({browser: true, rootDir: packageRoot}),
77+
importResolve(input, cacheRoot, packageRoot),
78+
esbuild({
79+
target: ["es2022", "chrome96", "firefox96", "safari16", "node18"],
80+
exclude: [], // don’t exclude node_modules
81+
minify: true
82+
})
83+
],
84+
onwarn(message, warn) {
85+
if (message.code === "CIRCULAR_DEPENDENCY") return;
86+
warn(message);
87+
}
88+
});
89+
try {
90+
const output = await bundle.generate({format: "es"});
91+
const code = output.output.find((o): o is OutputChunk => o.type === "chunk")!.code; // TODO don’t assume one chunk?
92+
return code;
93+
} finally {
94+
await bundle.close();
95+
}
96+
}
97+
98+
function importResolve(input: string, cacheRoot: string, packageRoot: string): Plugin {
99+
async function resolve(specifier: string | AstNode): Promise<ResolveIdResult> {
100+
return typeof specifier !== "string" || // AST node?
101+
isPathImport(specifier) || // relative path, e.g., ./foo.js
102+
/^\w+:/.test(specifier) || // windows file path, https: URL, etc.
103+
specifier === input // entry point
104+
? null // don’t do any additional resolution
105+
: {id: await resolveNodeImportInternal(cacheRoot, packageRoot, specifier), external: true}; // resolve bare import
106+
}
107+
return {
108+
name: "resolve-import",
109+
resolveId: resolve,
110+
resolveDynamicImport: resolve
111+
};
112+
}

src/npm.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {simple} from "acorn-walk";
66
import {rsort, satisfies} from "semver";
77
import {isEnoent} from "./error.js";
88
import type {ExportNode, ImportNode, ImportReference} from "./javascript/imports.js";
9-
import {findImports, isImportMetaResolve} from "./javascript/imports.js";
9+
import {isImportMetaResolve, parseImports} from "./javascript/imports.js";
1010
import {parseProgram} from "./javascript/parse.js";
1111
import type {StringLiteral} from "./javascript/source.js";
1212
import {getStringLiteralValue, isStringLiteral} from "./javascript/source.js";
@@ -252,30 +252,14 @@ export async function resolveNpmImport(root: string, specifier: string): Promise
252252
return `/_npm/${name}@${await resolveNpmVersion(root, {name, range})}/${path.replace(/\+esm$/, "_esm.js")}`;
253253
}
254254

255-
const npmImportsCache = new Map<string, Promise<ImportReference[]>>();
256-
257255
/**
258256
* Resolves the direct dependencies of the specified npm path, such as
259257
* "/_npm/[email protected]/_esm.js", returning the corresponding set of npm paths.
260258
*/
261259
export async function resolveNpmImports(root: string, path: string): Promise<ImportReference[]> {
262260
if (!path.startsWith("/_npm/")) throw new Error(`invalid npm path: ${path}`);
263-
let promise = npmImportsCache.get(path);
264-
if (promise) return promise;
265-
promise = (async function () {
266-
try {
267-
const filePath = await populateNpmCache(root, path);
268-
if (!/\.(m|c)?js$/i.test(path)) return []; // not JavaScript; TODO traverse CSS, too
269-
const source = await readFile(filePath, "utf-8");
270-
const body = parseProgram(source);
271-
return findImports(body, path, source);
272-
} catch (error: any) {
273-
console.warn(`unable to fetch or parse ${path}: ${error.message}`);
274-
return [];
275-
}
276-
})();
277-
npmImportsCache.set(path, promise);
278-
return promise;
261+
await populateNpmCache(root, path);
262+
return parseImports(join(root, ".observablehq", "cache"), path);
279263
}
280264

281265
/**

src/preview.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export class PreviewServer {
124124
} else if (pathname.startsWith("/_observablehq/") && pathname.endsWith(".css")) {
125125
const path = getClientPath(pathname.slice("/_observablehq/".length));
126126
end(req, res, await bundleStyles({path}), "text/css");
127+
} else if (pathname.startsWith("/_node/")) {
128+
send(req, pathname, {root: join(root, ".observablehq", "cache")}).pipe(res);
127129
} else if (pathname.startsWith("/_npm/")) {
128130
await populateNpmCache(root, pathname);
129131
send(req, pathname, {root: join(root, ".observablehq", "cache")}).pipe(res);

src/resolvers.ts

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {getImplicitDependencies, getImplicitDownloads} from "./libraries.js";
88
import {getImplicitFileImports, getImplicitInputImports} from "./libraries.js";
99
import {getImplicitStylesheets} from "./libraries.js";
1010
import type {MarkdownPage} from "./markdown.js";
11+
import {extractNodeSpecifier, resolveNodeImport, resolveNodeImports} from "./node.js";
1112
import {extractNpmSpecifier, populateNpmCache, resolveNpmImport, resolveNpmImports} from "./npm.js";
1213
import {isAssetPath, isPathImport, relativePath, resolveLocalPath, resolvePath} from "./path.js";
1314

@@ -169,39 +170,70 @@ export async function getResolvers(
169170
globalImports.add(i);
170171
}
171172

172-
// Resolve npm: imports.
173+
// Resolve npm: and bare imports.
173174
for (const i of globalImports) {
174175
if (i.startsWith("npm:") && !builtins.has(i)) {
175176
resolutions.set(i, await resolveNpmImport(root, i.slice("npm:".length)));
177+
} else if (!/^\w+:/.test(i)) {
178+
try {
179+
resolutions.set(i, await resolveNodeImport(root, i));
180+
} catch {
181+
// ignore error; allow the import to be resolved at runtime
182+
}
176183
}
177184
}
178185

179-
// Follow transitive imports of npm imports. This has the side-effect of
180-
// populating the npm cache.
181-
for (const value of resolutions.values()) {
182-
for (const i of await resolveNpmImports(root, value)) {
183-
if (i.type === "local") {
184-
const path = resolvePath(value, i.name);
185-
const specifier = `npm:${extractNpmSpecifier(path)}`;
186-
globalImports.add(specifier);
187-
resolutions.set(specifier, path);
186+
// Follow transitive imports of npm and bare imports. This has the side-effect
187+
// of populating the npm cache; the node import cache is already transitively
188+
// populated above.
189+
for (const [key, value] of resolutions) {
190+
if (key.startsWith("npm:")) {
191+
for (const i of await resolveNpmImports(root, value)) {
192+
if (i.type === "local") {
193+
const path = resolvePath(value, i.name);
194+
const specifier = `npm:${extractNpmSpecifier(path)}`;
195+
globalImports.add(specifier);
196+
resolutions.set(specifier, path);
197+
}
198+
}
199+
} else if (!/^\w+:/.test(key)) {
200+
for (const i of await resolveNodeImports(root, value)) {
201+
if (i.type === "local") {
202+
const path = resolvePath(value, i.name);
203+
const specifier = extractNodeSpecifier(path);
204+
globalImports.add(specifier);
205+
resolutions.set(specifier, path);
206+
}
188207
}
189208
}
190209
}
191210

192-
// Resolve transitive static npm: imports.
193-
const npmStaticResolutions = new Set<string>();
211+
// Resolve transitive static npm: and bare imports.
212+
const staticResolutions = new Map<string, string>();
194213
for (const i of staticImports) {
195-
const r = resolutions.get(i);
196-
if (r) npmStaticResolutions.add(r);
214+
if (i.startsWith("npm:") || !/^\w+:/.test(i)) {
215+
const r = resolutions.get(i);
216+
if (r) staticResolutions.set(i, r);
217+
}
197218
}
198-
for (const value of npmStaticResolutions) {
199-
for (const i of await resolveNpmImports(root, value)) {
200-
if (i.type === "local" && i.method === "static") {
201-
const path = resolvePath(value, i.name);
202-
const specifier = `npm:${extractNpmSpecifier(path)}`;
203-
staticImports.add(specifier);
204-
npmStaticResolutions.add(path);
219+
for (const [key, value] of staticResolutions) {
220+
if (key.startsWith("npm:")) {
221+
for (const i of await resolveNpmImports(root, value)) {
222+
if (i.type === "local" && i.method === "static") {
223+
const path = resolvePath(value, i.name);
224+
const specifier = `npm:${extractNpmSpecifier(path)}`;
225+
staticImports.add(specifier);
226+
staticResolutions.set(specifier, path);
227+
}
228+
}
229+
} else if (!/^\w+:/.test(key)) {
230+
for (const i of await resolveNodeImports(root, value)) {
231+
if (i.type === "local" && i.method === "static") {
232+
const path = resolvePath(value, i.name);
233+
const specifier = extractNodeSpecifier(path);
234+
staticImports.add(specifier);
235+
staticResolutions.set(specifier, path);
236+
}
205237
}
206238
}
207239
}

test/input/packages/node_modules/test-browser-condition/browser.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/input/packages/node_modules/test-browser-condition/default.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)