From 0e6919d2a308d9e12aff43af8f6770add1bf302d Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 28 Mar 2024 10:53:01 -0700 Subject: [PATCH] node imports (#1156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- package.json | 5 +- src/build.ts | 13 +- src/javascript/imports.ts | 42 ++++++- src/node.ts | 112 ++++++++++++++++++ src/npm.ts | 22 +--- src/preview.ts | 2 + src/resolvers.ts | 74 ++++++++---- .../test-browser-condition/browser.js | 1 + .../test-browser-condition/default.js | 1 + .../test-browser-condition/package.json | 9 ++ .../test-browser-field/browser.js | 1 + .../node_modules/test-browser-field/main.js | 1 + .../test-browser-field/package.json | 7 ++ .../node_modules/test-browser-map/browser.js | 1 + .../node_modules/test-browser-map/main.js | 1 + .../test-browser-map/package.json | 9 ++ .../test-import-condition/default.cjs | 1 + .../test-import-condition/import.mjs | 1 + .../test-import-condition/package.json | 8 ++ .../test-shorthand-export/index.js | 1 + .../test-shorthand-export/package.json | 6 + test/input/packages/package.json | 10 ++ test/node-test.ts | 71 +++++++++++ test/{javascript => }/npm-test.ts | 8 +- yarn.lock | 12 ++ 25 files changed, 365 insertions(+), 54 deletions(-) create mode 100644 src/node.ts create mode 100644 test/input/packages/node_modules/test-browser-condition/browser.js create mode 100644 test/input/packages/node_modules/test-browser-condition/default.js create mode 100644 test/input/packages/node_modules/test-browser-condition/package.json create mode 100644 test/input/packages/node_modules/test-browser-field/browser.js create mode 100644 test/input/packages/node_modules/test-browser-field/main.js create mode 100644 test/input/packages/node_modules/test-browser-field/package.json create mode 100644 test/input/packages/node_modules/test-browser-map/browser.js create mode 100644 test/input/packages/node_modules/test-browser-map/main.js create mode 100644 test/input/packages/node_modules/test-browser-map/package.json create mode 100644 test/input/packages/node_modules/test-import-condition/default.cjs create mode 100644 test/input/packages/node_modules/test-import-condition/import.mjs create mode 100644 test/input/packages/node_modules/test-import-condition/package.json create mode 100644 test/input/packages/node_modules/test-shorthand-export/index.js create mode 100644 test/input/packages/node_modules/test-shorthand-export/package.json create mode 100644 test/input/packages/package.json create mode 100644 test/node-test.ts rename test/{javascript => }/npm-test.ts (97%) diff --git a/package.json b/package.json index d9e23fec9..3f6444fa6 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "test": "concurrently npm:test:mocha npm:test:tsc npm:test:lint npm:test:prettier", "test:coverage": "c8 --check-coverage --lines 80 --per-file yarn test:mocha", "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", - "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\"", - "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\"", + "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\"", + "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\"", "test:lint": "eslint src test --max-warnings=0", "test:prettier": "prettier --check src test", "test:tsc": "tsc --noEmit", @@ -77,6 +77,7 @@ "mime": "^4.0.0", "minisearch": "^6.3.0", "open": "^10.1.0", + "pkg-dir": "^8.0.0", "rollup": "^4.6.0", "rollup-plugin-esbuild": "^6.1.0", "semver": "^7.5.4", diff --git a/src/build.ts b/src/build.ts index de974f52f..5b94604f9 100644 --- a/src/build.ts +++ b/src/build.ts @@ -10,6 +10,7 @@ import {transpileModule} from "./javascript/transpile.js"; import type {Logger, Writer} from "./logger.js"; import type {MarkdownPage} from "./markdown.js"; import {parseMarkdown} from "./markdown.js"; +import {extractNodeSpecifier} from "./node.js"; import {extractNpmSpecifier, populateNpmCache, resolveNpmImport} from "./npm.js"; import {isPathImport, relativePath, resolvePath} from "./path.js"; import {renderPage} from "./render.js"; @@ -175,10 +176,14 @@ export async function build( // these, too, but it would involve rewriting the files since populateNpmCache // doesn’t let you pass in a resolver. for (const path of globalImports) { - if (!path.startsWith("/_npm/")) continue; // skip _observablehq - effects.output.write(`${faint("copy")} npm:${extractNpmSpecifier(path)} ${faint("→")} `); - const sourcePath = await populateNpmCache(root, path); // TODO effects - await effects.copyFile(sourcePath, path); + if (path.startsWith("/_npm/")) { + effects.output.write(`${faint("copy")} npm:${extractNpmSpecifier(path)} ${faint("→")} `); + const sourcePath = await populateNpmCache(root, path); // TODO effects + await effects.copyFile(sourcePath, path); + } else if (path.startsWith("/_node/")) { + effects.output.write(`${faint("copy")} ${extractNodeSpecifier(path)} ${faint("→")} `); + await effects.copyFile(join(root, ".observablehq", "cache", path), path); + } } // Copy over imported local modules, overriding import resolution so that diff --git a/src/javascript/imports.ts b/src/javascript/imports.ts index 961f94b3f..8080015fb 100644 --- a/src/javascript/imports.ts +++ b/src/javascript/imports.ts @@ -1,8 +1,11 @@ +import {readFile} from "node:fs/promises"; +import {join} from "node:path/posix"; import type {Node} from "acorn"; import type {CallExpression} from "acorn"; import type {ExportAllDeclaration, ExportNamedDeclaration, ImportDeclaration, ImportExpression} from "acorn"; import {simple} from "acorn-walk"; import {isPathImport, relativePath, resolveLocalPath} from "../path.js"; +import {parseProgram} from "./parse.js"; import {getStringLiteralValue, isStringLiteral} from "./source.js"; import {syntaxError} from "./syntaxError.js"; @@ -59,6 +62,7 @@ export function hasImportDeclaration(body: Node): boolean { */ export function findImports(body: Node, path: string, input: string): ImportReference[] { const imports: ImportReference[] = []; + const keys = new Set(); simple(body, { ImportDeclaration: findImport, @@ -68,6 +72,11 @@ export function findImports(body: Node, path: string, input: string): ImportRefe CallExpression: findImportMetaResolve }); + function addImport(ref: ImportReference) { + const key = `${ref.type}:${ref.method}:${ref.name}`; + if (!keys.has(key)) keys.add(key), imports.push(ref); + } + function findImport(node: ImportNode | ExportNode) { const source = node.source; if (!source || !isStringLiteral(source)) return; @@ -76,9 +85,9 @@ export function findImports(body: Node, path: string, input: string): ImportRefe if (isPathImport(name)) { const localPath = resolveLocalPath(path, name); if (!localPath) throw syntaxError(`non-local import: ${name}`, node, input); // prettier-ignore - imports.push({name: relativePath(path, localPath), type: "local", method}); + addImport({name: relativePath(path, localPath), type: "local", method}); } else { - imports.push({name, type: "global", method}); + addImport({name, type: "global", method}); } } @@ -89,9 +98,9 @@ export function findImports(body: Node, path: string, input: string): ImportRefe if (isPathImport(name)) { const localPath = resolveLocalPath(path, name); if (!localPath) throw syntaxError(`non-local import: ${name}`, node, input); // prettier-ignore - imports.push({name: relativePath(path, localPath), type: "local", method: "dynamic"}); + addImport({name: relativePath(path, localPath), type: "local", method: "dynamic"}); } else { - imports.push({name, type: "global", method: "dynamic"}); + addImport({name, type: "global", method: "dynamic"}); } } @@ -109,3 +118,28 @@ export function isImportMetaResolve(node: CallExpression): boolean { node.arguments.length > 0 ); } + +export function isJavaScript(path: string): boolean { + return /\.(m|c)?js$/i.test(path); +} + +const parseImportsCache = new Map>(); + +export async function parseImports(root: string, path: string): Promise { + if (!isJavaScript(path)) return []; // TODO traverse CSS, too + const filePath = join(root, path); + let promise = parseImportsCache.get(filePath); + if (promise) return promise; + promise = (async function () { + try { + const source = await readFile(filePath, "utf-8"); + const body = parseProgram(source); + return findImports(body, path, source); + } catch (error: any) { + console.warn(`unable to fetch or parse ${path}: ${error.message}`); + return []; + } + })(); + parseImportsCache.set(filePath, promise); + return promise; +} diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 000000000..e4c0e6555 --- /dev/null +++ b/src/node.ts @@ -0,0 +1,112 @@ +import {existsSync} from "node:fs"; +import {copyFile, readFile, writeFile} from "node:fs/promises"; +import {createRequire} from "node:module"; +import op from "node:path"; +import {extname, join} from "node:path/posix"; +import {pathToFileURL} from "node:url"; +import {nodeResolve} from "@rollup/plugin-node-resolve"; +import {packageDirectory} from "pkg-dir"; +import type {AstNode, OutputChunk, Plugin, ResolveIdResult} from "rollup"; +import {rollup} from "rollup"; +import esbuild from "rollup-plugin-esbuild"; +import {prepareOutput, toOsPath} from "./files.js"; +import type {ImportReference} from "./javascript/imports.js"; +import {isJavaScript, parseImports} from "./javascript/imports.js"; +import {parseNpmSpecifier} from "./npm.js"; +import {isPathImport} from "./path.js"; +import {faint} from "./tty.js"; + +export async function resolveNodeImport(root: string, spec: string): Promise { + return resolveNodeImportInternal(op.join(root, ".observablehq", "cache", "_node"), root, spec); +} + +const bundlePromises = new Map>(); + +async function resolveNodeImportInternal(cacheRoot: string, packageRoot: string, spec: string): Promise { + const {name, path = "."} = parseNpmSpecifier(spec); + const require = createRequire(pathToFileURL(op.join(packageRoot, "/"))); + const pathResolution = require.resolve(spec); + const packageResolution = await packageDirectory({cwd: op.dirname(pathResolution)}); + if (!packageResolution) throw new Error(`unable to resolve package.json: ${spec}`); + const {version} = JSON.parse(await readFile(op.join(packageResolution, "package.json"), "utf-8")); + const resolution = `${name}@${version}/${extname(path) ? path : path === "." ? "index.js" : `${path}.js`}`; + const outputPath = op.join(cacheRoot, toOsPath(resolution)); + if (!existsSync(outputPath)) { + let promise = bundlePromises.get(outputPath); + if (!promise) { + promise = (async () => { + process.stdout.write(`${spec} ${faint("→")} ${resolution}\n`); + await prepareOutput(outputPath); + if (isJavaScript(pathResolution)) { + await writeFile(outputPath, await bundle(spec, cacheRoot, packageResolution)); + } else { + await copyFile(pathResolution, outputPath); + } + })(); + bundlePromises.set(outputPath, promise); + promise.catch(() => {}).then(() => bundlePromises.delete(outputPath)); + } + await promise; + } + return `/_node/${resolution}`; +} + +/** + * Resolves the direct dependencies of the specified node import path, such as + * "/_node/d3-array@3.2.4/src/index.js", returning a set of node import paths. + */ +export async function resolveNodeImports(root: string, path: string): Promise { + if (!path.startsWith("/_node/")) throw new Error(`invalid node path: ${path}`); + return parseImports(join(root, ".observablehq", "cache"), path); +} + +/** + * Given a local npm path such as "/_node/d3-array@3.2.4/src/index.js", returns + * the corresponding npm specifier such as "d3-array@3.2.4/src/index.js". + */ +export function extractNodeSpecifier(path: string): string { + if (!path.startsWith("/_node/")) throw new Error(`invalid node path: ${path}`); + return path.replace(/^\/_node\//, ""); +} + +async function bundle(input: string, cacheRoot: string, packageRoot: string): Promise { + const bundle = await rollup({ + input, + plugins: [ + nodeResolve({browser: true, rootDir: packageRoot}), + importResolve(input, cacheRoot, packageRoot), + esbuild({ + target: ["es2022", "chrome96", "firefox96", "safari16", "node18"], + exclude: [], // don’t exclude node_modules + minify: true + }) + ], + onwarn(message, warn) { + if (message.code === "CIRCULAR_DEPENDENCY") return; + warn(message); + } + }); + try { + const output = await bundle.generate({format: "es"}); + const code = output.output.find((o): o is OutputChunk => o.type === "chunk")!.code; // TODO don’t assume one chunk? + return code; + } finally { + await bundle.close(); + } +} + +function importResolve(input: string, cacheRoot: string, packageRoot: string): Plugin { + async function resolve(specifier: string | AstNode): Promise { + return typeof specifier !== "string" || // AST node? + isPathImport(specifier) || // relative path, e.g., ./foo.js + /^\w+:/.test(specifier) || // windows file path, https: URL, etc. + specifier === input // entry point + ? null // don’t do any additional resolution + : {id: await resolveNodeImportInternal(cacheRoot, packageRoot, specifier), external: true}; // resolve bare import + } + return { + name: "resolve-import", + resolveId: resolve, + resolveDynamicImport: resolve + }; +} diff --git a/src/npm.ts b/src/npm.ts index 45cb91abb..7ded8c60d 100644 --- a/src/npm.ts +++ b/src/npm.ts @@ -6,7 +6,7 @@ import {simple} from "acorn-walk"; import {rsort, satisfies} from "semver"; import {isEnoent} from "./error.js"; import type {ExportNode, ImportNode, ImportReference} from "./javascript/imports.js"; -import {findImports, isImportMetaResolve} from "./javascript/imports.js"; +import {isImportMetaResolve, parseImports} from "./javascript/imports.js"; import {parseProgram} from "./javascript/parse.js"; import type {StringLiteral} from "./javascript/source.js"; import {getStringLiteralValue, isStringLiteral} from "./javascript/source.js"; @@ -252,30 +252,14 @@ export async function resolveNpmImport(root: string, specifier: string): Promise return `/_npm/${name}@${await resolveNpmVersion(root, {name, range})}/${path.replace(/\+esm$/, "_esm.js")}`; } -const npmImportsCache = new Map>(); - /** * Resolves the direct dependencies of the specified npm path, such as * "/_npm/d3@7.8.5/_esm.js", returning the corresponding set of npm paths. */ export async function resolveNpmImports(root: string, path: string): Promise { if (!path.startsWith("/_npm/")) throw new Error(`invalid npm path: ${path}`); - let promise = npmImportsCache.get(path); - if (promise) return promise; - promise = (async function () { - try { - const filePath = await populateNpmCache(root, path); - if (!/\.(m|c)?js$/i.test(path)) return []; // not JavaScript; TODO traverse CSS, too - const source = await readFile(filePath, "utf-8"); - const body = parseProgram(source); - return findImports(body, path, source); - } catch (error: any) { - console.warn(`unable to fetch or parse ${path}: ${error.message}`); - return []; - } - })(); - npmImportsCache.set(path, promise); - return promise; + await populateNpmCache(root, path); + return parseImports(join(root, ".observablehq", "cache"), path); } /** diff --git a/src/preview.ts b/src/preview.ts index f8a9e2e34..636767099 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -124,6 +124,8 @@ export class PreviewServer { } else if (pathname.startsWith("/_observablehq/") && pathname.endsWith(".css")) { const path = getClientPath(pathname.slice("/_observablehq/".length)); end(req, res, await bundleStyles({path}), "text/css"); + } else if (pathname.startsWith("/_node/")) { + send(req, pathname, {root: join(root, ".observablehq", "cache")}).pipe(res); } else if (pathname.startsWith("/_npm/")) { await populateNpmCache(root, pathname); send(req, pathname, {root: join(root, ".observablehq", "cache")}).pipe(res); diff --git a/src/resolvers.ts b/src/resolvers.ts index a34ebe945..db4b77340 100644 --- a/src/resolvers.ts +++ b/src/resolvers.ts @@ -8,6 +8,7 @@ import {getImplicitDependencies, getImplicitDownloads} from "./libraries.js"; import {getImplicitFileImports, getImplicitInputImports} from "./libraries.js"; import {getImplicitStylesheets} from "./libraries.js"; import type {MarkdownPage} from "./markdown.js"; +import {extractNodeSpecifier, resolveNodeImport, resolveNodeImports} from "./node.js"; import {extractNpmSpecifier, populateNpmCache, resolveNpmImport, resolveNpmImports} from "./npm.js"; import {isAssetPath, isPathImport, relativePath, resolveLocalPath, resolvePath} from "./path.js"; @@ -169,39 +170,70 @@ export async function getResolvers( globalImports.add(i); } - // Resolve npm: imports. + // Resolve npm: and bare imports. for (const i of globalImports) { if (i.startsWith("npm:") && !builtins.has(i)) { resolutions.set(i, await resolveNpmImport(root, i.slice("npm:".length))); + } else if (!/^\w+:/.test(i)) { + try { + resolutions.set(i, await resolveNodeImport(root, i)); + } catch { + // ignore error; allow the import to be resolved at runtime + } } } - // Follow transitive imports of npm imports. This has the side-effect of - // populating the npm cache. - for (const value of resolutions.values()) { - for (const i of await resolveNpmImports(root, value)) { - if (i.type === "local") { - const path = resolvePath(value, i.name); - const specifier = `npm:${extractNpmSpecifier(path)}`; - globalImports.add(specifier); - resolutions.set(specifier, path); + // Follow transitive imports of npm and bare imports. This has the side-effect + // of populating the npm cache; the node import cache is already transitively + // populated above. + for (const [key, value] of resolutions) { + if (key.startsWith("npm:")) { + for (const i of await resolveNpmImports(root, value)) { + if (i.type === "local") { + const path = resolvePath(value, i.name); + const specifier = `npm:${extractNpmSpecifier(path)}`; + globalImports.add(specifier); + resolutions.set(specifier, path); + } + } + } else if (!/^\w+:/.test(key)) { + for (const i of await resolveNodeImports(root, value)) { + if (i.type === "local") { + const path = resolvePath(value, i.name); + const specifier = extractNodeSpecifier(path); + globalImports.add(specifier); + resolutions.set(specifier, path); + } } } } - // Resolve transitive static npm: imports. - const npmStaticResolutions = new Set(); + // Resolve transitive static npm: and bare imports. + const staticResolutions = new Map(); for (const i of staticImports) { - const r = resolutions.get(i); - if (r) npmStaticResolutions.add(r); + if (i.startsWith("npm:") || !/^\w+:/.test(i)) { + const r = resolutions.get(i); + if (r) staticResolutions.set(i, r); + } } - for (const value of npmStaticResolutions) { - for (const i of await resolveNpmImports(root, value)) { - if (i.type === "local" && i.method === "static") { - const path = resolvePath(value, i.name); - const specifier = `npm:${extractNpmSpecifier(path)}`; - staticImports.add(specifier); - npmStaticResolutions.add(path); + for (const [key, value] of staticResolutions) { + if (key.startsWith("npm:")) { + for (const i of await resolveNpmImports(root, value)) { + if (i.type === "local" && i.method === "static") { + const path = resolvePath(value, i.name); + const specifier = `npm:${extractNpmSpecifier(path)}`; + staticImports.add(specifier); + staticResolutions.set(specifier, path); + } + } + } else if (!/^\w+:/.test(key)) { + for (const i of await resolveNodeImports(root, value)) { + if (i.type === "local" && i.method === "static") { + const path = resolvePath(value, i.name); + const specifier = extractNodeSpecifier(path); + staticImports.add(specifier); + staticResolutions.set(specifier, path); + } } } } diff --git a/test/input/packages/node_modules/test-browser-condition/browser.js b/test/input/packages/node_modules/test-browser-condition/browser.js new file mode 100644 index 000000000..c508c66ce --- /dev/null +++ b/test/input/packages/node_modules/test-browser-condition/browser.js @@ -0,0 +1 @@ +export const name = "test-browser-condition:browser"; diff --git a/test/input/packages/node_modules/test-browser-condition/default.js b/test/input/packages/node_modules/test-browser-condition/default.js new file mode 100644 index 000000000..54a69edfb --- /dev/null +++ b/test/input/packages/node_modules/test-browser-condition/default.js @@ -0,0 +1 @@ +export const name = "test-browser-condition:default"; diff --git a/test/input/packages/node_modules/test-browser-condition/package.json b/test/input/packages/node_modules/test-browser-condition/package.json new file mode 100644 index 000000000..4b4bc9858 --- /dev/null +++ b/test/input/packages/node_modules/test-browser-condition/package.json @@ -0,0 +1,9 @@ +{ + "type": "module", + "name": "test-browser-condition", + "version": "1.0.0", + "exports": { + "browser": "./browser.js", + "default": "./default.js" + } +} diff --git a/test/input/packages/node_modules/test-browser-field/browser.js b/test/input/packages/node_modules/test-browser-field/browser.js new file mode 100644 index 000000000..1f133ea4c --- /dev/null +++ b/test/input/packages/node_modules/test-browser-field/browser.js @@ -0,0 +1 @@ +export const name = "test-browser-field:browser"; diff --git a/test/input/packages/node_modules/test-browser-field/main.js b/test/input/packages/node_modules/test-browser-field/main.js new file mode 100644 index 000000000..9eb75e987 --- /dev/null +++ b/test/input/packages/node_modules/test-browser-field/main.js @@ -0,0 +1 @@ +export const name = "test-browser-field:main"; diff --git a/test/input/packages/node_modules/test-browser-field/package.json b/test/input/packages/node_modules/test-browser-field/package.json new file mode 100644 index 000000000..62cf3d080 --- /dev/null +++ b/test/input/packages/node_modules/test-browser-field/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "name": "test-browser-field", + "version": "1.0.0", + "main": "./main.js", + "browser": "./browser.js" +} diff --git a/test/input/packages/node_modules/test-browser-map/browser.js b/test/input/packages/node_modules/test-browser-map/browser.js new file mode 100644 index 000000000..f6a841dbc --- /dev/null +++ b/test/input/packages/node_modules/test-browser-map/browser.js @@ -0,0 +1 @@ +export const name = "test-browser-map:browser"; diff --git a/test/input/packages/node_modules/test-browser-map/main.js b/test/input/packages/node_modules/test-browser-map/main.js new file mode 100644 index 000000000..5eebd2895 --- /dev/null +++ b/test/input/packages/node_modules/test-browser-map/main.js @@ -0,0 +1 @@ +export const name = "test-browser-map:main"; diff --git a/test/input/packages/node_modules/test-browser-map/package.json b/test/input/packages/node_modules/test-browser-map/package.json new file mode 100644 index 000000000..64724ad51 --- /dev/null +++ b/test/input/packages/node_modules/test-browser-map/package.json @@ -0,0 +1,9 @@ +{ + "type": "module", + "name": "test-browser-map", + "version": "1.0.0", + "main": "./main.js", + "browser": { + "./main": "./browser.js" + } +} diff --git a/test/input/packages/node_modules/test-import-condition/default.cjs b/test/input/packages/node_modules/test-import-condition/default.cjs new file mode 100644 index 000000000..046b6f0dc --- /dev/null +++ b/test/input/packages/node_modules/test-import-condition/default.cjs @@ -0,0 +1 @@ +exports.name = "test-import-condition:default"; diff --git a/test/input/packages/node_modules/test-import-condition/import.mjs b/test/input/packages/node_modules/test-import-condition/import.mjs new file mode 100644 index 000000000..166ffd7f5 --- /dev/null +++ b/test/input/packages/node_modules/test-import-condition/import.mjs @@ -0,0 +1 @@ +export const name = "test-import-condition:import"; diff --git a/test/input/packages/node_modules/test-import-condition/package.json b/test/input/packages/node_modules/test-import-condition/package.json new file mode 100644 index 000000000..b70a1f58e --- /dev/null +++ b/test/input/packages/node_modules/test-import-condition/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-import-condition", + "version": "1.0.0", + "exports": { + "import": "./import.mjs", + "default": "./default.cjs" + } +} diff --git a/test/input/packages/node_modules/test-shorthand-export/index.js b/test/input/packages/node_modules/test-shorthand-export/index.js new file mode 100644 index 000000000..aa9180b6c --- /dev/null +++ b/test/input/packages/node_modules/test-shorthand-export/index.js @@ -0,0 +1 @@ +export const name = "test-shorthand-export"; diff --git a/test/input/packages/node_modules/test-shorthand-export/package.json b/test/input/packages/node_modules/test-shorthand-export/package.json new file mode 100644 index 000000000..0f1a654ea --- /dev/null +++ b/test/input/packages/node_modules/test-shorthand-export/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "name": "test-shorthand-export", + "version": "1.0.0", + "exports": "./index.js" +} diff --git a/test/input/packages/package.json b/test/input/packages/package.json new file mode 100644 index 000000000..ee7376964 --- /dev/null +++ b/test/input/packages/package.json @@ -0,0 +1,10 @@ +{ + "type": "module", + "dependencies": { + "test-browser-condition": "^1.0.0", + "test-browser-field": "^1.0.0", + "test-browser-map": "^1.0.0", + "test-import-condition": "^1.0.0", + "test-shorthand-export": "^1.0.0" + } +} diff --git a/test/node-test.ts b/test/node-test.ts new file mode 100644 index 000000000..6b1ad2f08 --- /dev/null +++ b/test/node-test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert"; +import {existsSync} from "node:fs"; +import {rm} from "node:fs/promises"; +import {extractNodeSpecifier, resolveNodeImport, resolveNodeImports} from "../src/node.js"; + +describe("resolveNodeImport(root, spec)", () => { + const importRoot = "../../input/packages/.observablehq/cache"; + before(async () => { + await rm("docs/.observablehq/cache/_node", {recursive: true, force: true}); + await rm("test/input/packages/.observablehq/cache", {recursive: true, force: true}); + }); + it("resolves the version of a direct dependency", async () => { + assert.deepStrictEqual(await resolveNodeImport("docs", "d3-array"), "/_node/d3-array@3.2.4/index.js"); + assert.deepStrictEqual(await resolveNodeImport("docs", "mime"), "/_node/mime@4.0.1/index.js"); + }); + it("allows entry points", async () => { + assert.deepStrictEqual(await resolveNodeImport("docs", "mime/lite"), "/_node/mime@4.0.1/lite.js"); + }); + it("allows non-javascript entry points", async () => { + assert.deepStrictEqual(await resolveNodeImport("docs", "glob/package.json"), "/_node/glob@10.3.10/package.json"); + }); + it("does not allow version ranges", async () => { + await assert.rejects(() => resolveNodeImport("docs", "mime@4"), /Cannot find module/); + }); + it("bundles a package with a shorthand export", async () => { + assert.strictEqual(await resolveNodeImport("test/input/packages", "test-shorthand-export"), "/_node/test-shorthand-export@1.0.0/index.js"); // prettier-ignore + assert.strictEqual((await import(`${importRoot}/_node/test-shorthand-export@1.0.0/index.js`)).name, "test-shorthand-export"); // prettier-ignore + }); + it("bundles a package with an import conditional export", async () => { + assert.strictEqual(await resolveNodeImport("test/input/packages", "test-import-condition"), "/_node/test-import-condition@1.0.0/index.js"); // prettier-ignore + assert.strictEqual((await import(`${importRoot}/_node/test-import-condition@1.0.0/index.js`)).name, "test-import-condition:import"); // prettier-ignore + }); + it("bundles a package with a browser field", async () => { + assert.strictEqual(await resolveNodeImport("test/input/packages", "test-browser-field"), "/_node/test-browser-field@1.0.0/index.js"); // prettier-ignore + assert.strictEqual((await import(`${importRoot}/_node/test-browser-field@1.0.0/index.js`)).name, "test-browser-field:browser"); // prettier-ignore + }); + it("bundles a package with a browser map", async () => { + assert.strictEqual(await resolveNodeImport("test/input/packages", "test-browser-map"), "/_node/test-browser-map@1.0.0/index.js"); // prettier-ignore + assert.strictEqual((await import(`${importRoot}/_node/test-browser-map@1.0.0/index.js`)).name, "test-browser-map:browser"); // prettier-ignore + }); + it("bundles a package with a browser conditional export", async () => { + assert.strictEqual(await resolveNodeImport("test/input/packages", "test-browser-condition"), "/_node/test-browser-condition@1.0.0/index.js"); // prettier-ignore + assert.strictEqual((await import(`${importRoot}/_node/test-browser-condition@1.0.0/index.js`)).name, "test-browser-condition:browser"); // prettier-ignore + }); +}); + +describe("resolveNodeImports(root, path)", () => { + before(async () => { + if (existsSync("docs/.observablehq/cache/_node")) { + await rm("docs/.observablehq/cache/_node", {recursive: true}); + } + }); + it("resolves the imports of a dependency", async () => { + assert.deepStrictEqual(await resolveNodeImports("docs", await resolveNodeImport("docs", "d3-array")), [ + { + method: "static", + name: "../internmap@2.0.3/index.js", + type: "local" + } + ]); + }); + it("ignores non-JavaScript paths", async () => { + assert.deepStrictEqual(await resolveNodeImports("docs", await resolveNodeImport("docs", "glob/package.json")), []); + }); +}); + +describe("extractNodeSpecifier(path)", () => { + it("returns the node specifier from the given path", () => { + assert.strictEqual(extractNodeSpecifier("/_node/d3-array@3.2.4/index.js"), "d3-array@3.2.4/index.js"); + }); +}); diff --git a/test/javascript/npm-test.ts b/test/npm-test.ts similarity index 97% rename from test/javascript/npm-test.ts rename to test/npm-test.ts index 53326ebcf..ec874b007 100644 --- a/test/javascript/npm-test.ts +++ b/test/npm-test.ts @@ -1,8 +1,8 @@ import assert from "node:assert"; -import {extractNpmSpecifier, getDependencyResolver, rewriteNpmImports} from "../../src/npm.js"; -import {fromJsDelivrPath} from "../../src/npm.js"; -import {relativePath} from "../../src/path.js"; -import {mockJsDelivr} from "../mocks/jsdelivr.js"; +import {extractNpmSpecifier, getDependencyResolver, rewriteNpmImports} from "../src/npm.js"; +import {fromJsDelivrPath} from "../src/npm.js"; +import {relativePath} from "../src/path.js"; +import {mockJsDelivr} from "./mocks/jsdelivr.js"; describe("getDependencyResolver(root, path, input)", () => { mockJsDelivr(); diff --git a/yarn.lock b/yarn.lock index abe8ed633..f8da13c48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1813,6 +1813,11 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +find-up-simple@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/find-up-simple/-/find-up-simple-1.0.0.tgz#21d035fde9fdbd56c8f4d2f63f32fd93a1cfc368" + integrity sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw== + find-up@5.0.0, find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" @@ -2955,6 +2960,13 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +pkg-dir@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-8.0.0.tgz#8f3de8ba83d46b72a05c80bfd4e579f060fa91e2" + integrity sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ== + dependencies: + find-up-simple "^1.0.0" + possible-typed-array-names@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f"