From 81992113ae0ab046bd8d6d64842dcdc77e478643 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 5 Nov 2026 21:10:09 +0100 Subject: [PATCH 01/22] Define the compiled-binary CJS loader for rewritten route dependency imports Refs #5471 --- .../module-loader/external-import-rewriter.ts | 30 ++++++- src/routing/api/module-loader/loader.test.ts | 90 +++++++++++++++++++ src/routing/api/module-loader/loader.ts | 70 +++++++++------ 3 files changed, 161 insertions(+), 29 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 00b9bd3931..4d6c842f43 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -339,6 +339,28 @@ export function rewriteCompiledBinaryUserDependencyImports( return rewriteCompiledUserDependencyImportsForRoute(code, userDeps, esmDeps); } +/** + * Rewrite a bundled route module's user dependency imports for the compiled + * binary and prepend the CJS loader that the rewritten code calls. + * + * The rewritten `require(...)` and `__vf_interopDefault(...)` calls are added + * after bundling, so the loader must be added after bundling too: injected into + * the bundle, esbuild renamed its `require` and tree-shook the helper, and every + * CJS default import failed with a ReferenceError at request time. esbuild + * reserves top-level `require` in ESM output, so the prepended declaration + * cannot collide with route code, and esbuild's own `__require` fallback for + * bundled CJS picks it up as well. + */ +export function rewriteCompiledBinaryRouteModule( + code: string, + projectDir: string, + userDeps: Map, + esmDeps: Map = new Map(), +): string { + const rewritten = rewriteCompiledBinaryUserDependencyImports(code, userDeps, esmDeps); + return `${generateCompiledBinaryRequireShim(projectDir)}\n${rewritten}`; +} + export async function rewriteDenoNpmDependencyImports( code: string, projectDir: string, @@ -464,12 +486,12 @@ export async function rewriteExternalImports( // Rewrite user-installed npm dependencies. // In non-compiled Deno: use npm: specifiers (resolved by Deno's npm support). - // In compiled binaries: use the createRequire-based `require` shim (already - // injected as a collision-safe bundler symbol) to load CJS packages from node_modules, - // since npm: specifiers only work for packages embedded at compile time. + // In compiled binaries: load CJS packages from node_modules through the + // compiled-binary `require` loader, since npm: specifiers only work for + // packages embedded at compile time. if (isCompiledBinary()) { const esmDeps = await resolveEsmUserDependencies(projectDir, fs, userDeps); - transformed = rewriteCompiledBinaryUserDependencyImports(transformed, userDeps, esmDeps); + transformed = rewriteCompiledBinaryRouteModule(transformed, projectDir, userDeps, esmDeps); } else { transformed = await rewriteDenoNpmDependencyImports(transformed, projectDir, fs, userDeps); } diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index f1e2ff2cc9..9168ca9569 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -16,6 +16,7 @@ import { bundlerForcesTypeScript, canDirectImportSpecifier, generateCompiledBinaryRequireShim, + generateRouteRuntimeShim, getNodeExternalPackagesToResolve, getUserDependencies, isBareModuleSpecifier, @@ -26,6 +27,7 @@ import { prepareHandlerModule, readDenoImportMap, resolveEsmUserDependencies, + rewriteCompiledBinaryRouteModule, rewriteCompiledBinaryUserDependencyImports, rewriteCompiledBinaryVeryfrontImports, rewriteDenoNodeBuiltinImports, @@ -5669,3 +5671,91 @@ describe("isSpecifierResolutionError", () => { assertEquals(isSpecifierResolutionError("not a dependency"), false); }); }); + +// veryfront-code#5471: the compiled binary rewrites CJS user dependency imports +// to `require(...)` and `__vf_interopDefault(...)` after bundling. These tests +// run the same bundle-then-rewrite pipeline and evaluate the handler, so a +// rewritten reference the bundle does not define fails here, not in production. +describe("compiled-binary CJS user dependencies (#5471)", { + sanitizeResources: false, + sanitizeOps: false, +}, () => { + async function loadCompiledRoute(routeSource: string): Promise<() => Promise> { + const projectDir = Deno.realPathSync(await makeTempDir()); + const packageDir = join(projectDir, "node_modules", "cjs-fixture"); + await fs.mkdir(packageDir, { recursive: true }); + await fs.writeTextFile( + join(packageDir, "package.json"), + JSON.stringify({ name: "cjs-fixture", version: "1.0.0", main: "index.js" }), + ); + await fs.writeTextFile( + join(packageDir, "index.js"), + [ + 'const path = require("path");', + "class Pool { query() { return path.basename('/rows/ok'); } }", + "module.exports = { Pool, version: '1.0.0' };", + ].join("\n"), + ); + await fs.writeTextFile( + join(projectDir, "package.json"), + JSON.stringify({ dependencies: { "cjs-fixture": "1.0.0" } }), + ); + + const routePath = join(projectDir, "app", "api", "route.ts"); + const userDeps = new Map([["cjs-fixture", "1.0.0"]]); + const shimNamespace = "vf-test-route-runtime-shim"; + const { build } = await import("veryfront/extensions/bundler"); + const result = await build({ + bundle: true, + write: false, + format: "esm", + platform: "neutral", + target: "es2022", + inject: ["veryfront:route-runtime-shim"], + external: ["node:*", "cjs-fixture", "cjs-fixture/*"], + stdin: { contents: routeSource, loader: "ts", resolveDir: join(projectDir, "app", "api") }, + plugins: [{ + name: "vf-test-route-runtime-shim", + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^veryfront:route-runtime-shim$/ }, () => ({ + path: "veryfront:route-runtime-shim", + namespace: shimNamespace, + })); + pluginBuild.onLoad({ filter: /.*/, namespace: shimNamespace }, () => ({ + contents: generateRouteRuntimeShim(projectDir, toFileUrl(routePath).href, true), + loader: "js", + })); + }, + }], + }); + const bundled = result.outputFiles?.[0]?.text ?? ""; + const handlerPath = join(projectDir, "handler.mjs"); + await fs.writeTextFile( + handlerPath, + rewriteCompiledBinaryRouteModule(bundled, projectDir, userDeps), + ); + const module = await import(toFileUrl(handlerPath).href); + return module.GET; + } + + denoIt("runs a handler that default-imports a CJS package", async () => { + const GET = await loadCompiledRoute( + 'import pg from "cjs-fixture";\nexport const GET = () => new Response(new pg.Pool().query());', + ); + assertEquals(await (await GET()).text(), "ok"); + }); + + denoIt("runs a handler that mixes default and named CJS imports", async () => { + const GET = await loadCompiledRoute( + 'import pg, { version } from "cjs-fixture";\nexport const GET = () => new Response(new pg.Pool().query() + version);', + ); + assertEquals(await (await GET()).text(), "ok1.0.0"); + }); + + denoIt("runs a handler that dynamically imports a CJS package", async () => { + const GET = await loadCompiledRoute( + 'export const GET = async () => { const pg = await import("cjs-fixture"); return new Response(new pg.Pool().query()); };', + ); + assertEquals(await (await GET()).text(), "ok"); + }); +}); diff --git a/src/routing/api/module-loader/loader.ts b/src/routing/api/module-loader/loader.ts index b707eb8509..72c18985d1 100644 --- a/src/routing/api/module-loader/loader.ts +++ b/src/routing/api/module-loader/loader.ts @@ -43,7 +43,6 @@ import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { wrapWithCurrentContext } from "#veryfront/platform/adapters/fs/veryfront/multi-project-adapter.ts"; import { isWithinDirectory } from "#veryfront/security/path-validation.ts"; import { - generateCompiledBinaryRequireShim, NODE_BUILTINS, readProjectDependencies, rewriteExternalImports, @@ -71,6 +70,7 @@ export { loadVeryfrontExportsMap, resolveEsmUserDependencies, resolveNodePackageToFileUrl, + rewriteCompiledBinaryRouteModule, rewriteCompiledBinaryUserDependencyImports, rewriteCompiledBinaryVeryfrontImports, rewriteDenoNodeBuiltinImports, @@ -1533,6 +1533,45 @@ function resolveBundledImportMetaSpecifier( } } +/** + * Source of the module injected into every bundled route handler. + * + * Many npm packages use CJS require() for Node built-ins (e.g. require('fs')). + * When esbuild bundles CJS into ESM output, these become __require() shims that + * fail at runtime, so the non-compiled runtimes inject a createRequire-based + * `require` resolved from the project's node_modules. + * + * The compiled binary does not inject its CJS loader. User dependency imports + * are rewritten to `require(...)` and `__vf_interopDefault(...)` only after + * bundling, so esbuild never sees those references: it tree-shakes the helper + * and renames the injected `require`, leaving the rewritten handler with free + * identifiers. `rewriteExternalImports` prepends the loader to the bundled + * output instead, where esbuild's reserved top-level `require` cannot collide. + */ +export function generateRouteRuntimeShim( + projectDir: string, + routeSourceUrl: string, + compiledBinary: boolean, +): string { + const workerShim = [ + `const __vf_routeSourceUrl = ${JSON.stringify(routeSourceUrl)};`, + `const Worker = typeof globalThis.Worker === "function" ? class extends globalThis.Worker {`, + ` constructor(specifier, options) {`, + ` super(typeof specifier === "string" && (specifier.startsWith("./") || specifier.startsWith("../")) ? new URL(specifier, __vf_routeSourceUrl) : specifier, options);`, + ` }`, + `} : globalThis.Worker;`, + ].join("\n"); + if (compiledBinary) return [workerShim, "export { Worker };"].join("\n"); + + const safeProjectDir = JSON.stringify(projectDir + "/package.json"); + return [ + 'import { createRequire as __vf_createRequire } from "node:module";', + `var require = __vf_createRequire(${safeProjectDir});`, + workerShim, + "export { require, Worker };", + ].join("\n"); +} + function createRouteRuntimeShimPlugin(contents: string): Plugin { return { name: "vf-route-runtime-shim", @@ -2090,31 +2129,12 @@ function buildTranspiledModuleSource( const { build } = await import("veryfront/extensions/bundler"); - // Many npm packages use CJS require() for Node built-ins (e.g. require('fs')). - // When esbuild bundles CJS into ESM output, these become __require() shims that - // fail at runtime. Inject a createRequire-based shim so require() works in ESM. - // Use projectDir as the resolve base so require() finds the project's node_modules. - const safeProjectDir = JSON.stringify(projectDir + "/package.json"); - const requireShim = isDeno && isCompiledBinary() - ? generateCompiledBinaryRequireShim(projectDir) - : [ - 'import { createRequire as __vf_createRequire } from "node:module";', - `var require = __vf_createRequire(${safeProjectDir});`, - ].join("\n"); const routeSourceUrl = pathHelper.toFileUrl(resolvedPath).href; - const workerShim = [ - `const __vf_routeSourceUrl = ${JSON.stringify(routeSourceUrl)};`, - `const Worker = typeof globalThis.Worker === "function" ? class extends globalThis.Worker {`, - ` constructor(specifier, options) {`, - ` super(typeof specifier === "string" && (specifier.startsWith("./") || specifier.startsWith("../")) ? new URL(specifier, __vf_routeSourceUrl) : specifier, options);`, - ` }`, - `} : globalThis.Worker;`, - ].join("\n"); - const routeRuntimeShim = [ - requireShim, - workerShim, - "export { require, Worker };", - ].join("\n"); + const routeRuntimeShim = generateRouteRuntimeShim( + projectDir, + routeSourceUrl, + isDeno && isCompiledBinary(), + ); const buildOptions = typescriptDecoratorOptions ? typeScriptBuildOptions(projectDir, typescriptDecoratorOptions, bundleTypeScript) From 67372a62bfddb42a7c324d779889d3a2dc890aaa Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 5 Nov 2026 21:16:25 +0100 Subject: [PATCH 02/22] Prepend the route require prologue after bundling on every runtime esbuild reserves top-level require in ESM output, so the injected require never reached its __require helper on any runtime. Bundled project CommonJS could not require Node built-ins outside the compiled binary either. Refs #5471 --- .../module-loader/external-import-rewriter.ts | 29 ++------- src/routing/api/module-loader/loader.test.ts | 52 +++++++++++++--- src/routing/api/module-loader/loader.ts | 62 +++++++++---------- 3 files changed, 80 insertions(+), 63 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 4d6c842f43..e3a8a2ca1a 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -339,28 +339,6 @@ export function rewriteCompiledBinaryUserDependencyImports( return rewriteCompiledUserDependencyImportsForRoute(code, userDeps, esmDeps); } -/** - * Rewrite a bundled route module's user dependency imports for the compiled - * binary and prepend the CJS loader that the rewritten code calls. - * - * The rewritten `require(...)` and `__vf_interopDefault(...)` calls are added - * after bundling, so the loader must be added after bundling too: injected into - * the bundle, esbuild renamed its `require` and tree-shook the helper, and every - * CJS default import failed with a ReferenceError at request time. esbuild - * reserves top-level `require` in ESM output, so the prepended declaration - * cannot collide with route code, and esbuild's own `__require` fallback for - * bundled CJS picks it up as well. - */ -export function rewriteCompiledBinaryRouteModule( - code: string, - projectDir: string, - userDeps: Map, - esmDeps: Map = new Map(), -): string { - const rewritten = rewriteCompiledBinaryUserDependencyImports(code, userDeps, esmDeps); - return `${generateCompiledBinaryRequireShim(projectDir)}\n${rewritten}`; -} - export async function rewriteDenoNpmDependencyImports( code: string, projectDir: string, @@ -487,11 +465,12 @@ export async function rewriteExternalImports( // Rewrite user-installed npm dependencies. // In non-compiled Deno: use npm: specifiers (resolved by Deno's npm support). // In compiled binaries: load CJS packages from node_modules through the - // compiled-binary `require` loader, since npm: specifiers only work for - // packages embedded at compile time. + // compiled-binary `require` loader the module loader prepends after + // bundling, since npm: specifiers only work for packages embedded at + // compile time. if (isCompiledBinary()) { const esmDeps = await resolveEsmUserDependencies(projectDir, fs, userDeps); - transformed = rewriteCompiledBinaryRouteModule(transformed, projectDir, userDeps, esmDeps); + transformed = rewriteCompiledBinaryUserDependencyImports(transformed, userDeps, esmDeps); } else { transformed = await rewriteDenoNpmDependencyImports(transformed, projectDir, fs, userDeps); } diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 9168ca9569..d6cb834808 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -16,6 +16,7 @@ import { bundlerForcesTypeScript, canDirectImportSpecifier, generateCompiledBinaryRequireShim, + generateRouteRequirePrologue, generateRouteRuntimeShim, getNodeExternalPackagesToResolve, getUserDependencies, @@ -27,7 +28,6 @@ import { prepareHandlerModule, readDenoImportMap, resolveEsmUserDependencies, - rewriteCompiledBinaryRouteModule, rewriteCompiledBinaryUserDependencyImports, rewriteCompiledBinaryVeryfrontImports, rewriteDenoNodeBuiltinImports, @@ -5676,10 +5676,13 @@ describe("isSpecifierResolutionError", () => { // to `require(...)` and `__vf_interopDefault(...)` after bundling. These tests // run the same bundle-then-rewrite pipeline and evaluate the handler, so a // rewritten reference the bundle does not define fails here, not in production. -describe("compiled-binary CJS user dependencies (#5471)", { - sanitizeResources: false, - sanitizeOps: false, -}, () => { +describe("route CommonJS require (#5471)", () => { + afterAll(async () => { + await __resetPoolForTests(); + const { stop } = await import("veryfront/extensions/bundler"); + await stop(); + }); + async function loadCompiledRoute(routeSource: string): Promise<() => Promise> { const projectDir = Deno.realPathSync(await makeTempDir()); const packageDir = join(projectDir, "node_modules", "cjs-fixture"); @@ -5722,7 +5725,7 @@ describe("compiled-binary CJS user dependencies (#5471)", { namespace: shimNamespace, })); pluginBuild.onLoad({ filter: /.*/, namespace: shimNamespace }, () => ({ - contents: generateRouteRuntimeShim(projectDir, toFileUrl(routePath).href, true), + contents: generateRouteRuntimeShim(toFileUrl(routePath).href), loader: "js", })); }, @@ -5732,7 +5735,9 @@ describe("compiled-binary CJS user dependencies (#5471)", { const handlerPath = join(projectDir, "handler.mjs"); await fs.writeTextFile( handlerPath, - rewriteCompiledBinaryRouteModule(bundled, projectDir, userDeps), + `${generateRouteRequirePrologue(projectDir, true)}\n${ + rewriteCompiledBinaryUserDependencyImports(bundled, userDeps) + }`, ); const module = await import(toFileUrl(handlerPath).href); return module.GET; @@ -5758,4 +5763,37 @@ describe("compiled-binary CJS user dependencies (#5471)", { ); assertEquals(await (await GET()).text(), "ok"); }); + + denoIt("lets bundled project CommonJS require Node built-ins", async () => { + // Runs the real loader on this runtime. A virtual adapter forces the + // esbuild bundle path, which inlines the .cjs file behind esbuild's + // `__require`; that helper only sees a module-scope `require`. + const realDir = Deno.realPathSync(await makeTempDir()); + await fs.writeTextFile( + join(realDir, "helper.cjs"), + 'module.exports = { base: require("node:path").basename("/rows/ok") };', + ); + await fs.writeTextFile( + join(realDir, "handler.ts"), + 'import helper from "./helper.cjs";\nexport const GET = () => new Response(helper.base);', + ); + const virtualBase = join(await makeTempDir(), `vf-nonexistent-${Date.now()}`); + const toReal = (path: string): string => path.replace(virtualBase, realDir); + const virtualAdapter: RuntimeAdapter = { + ...adapter, + fs: { + ...adapter.fs, + readFile: (path: string) => fs.readTextFile(toReal(path)), + exists: (path: string) => fs.exists(toReal(path)), + }, + }; + + const route = await loadHandlerModule({ + projectDir: virtualBase, + modulePath: join(virtualBase, "handler.ts"), + adapter: virtualAdapter, + config: undefined, + }); + assertEquals(await getText(route), "ok"); + }); }); diff --git a/src/routing/api/module-loader/loader.ts b/src/routing/api/module-loader/loader.ts index 72c18985d1..cb32f451b4 100644 --- a/src/routing/api/module-loader/loader.ts +++ b/src/routing/api/module-loader/loader.ts @@ -43,6 +43,7 @@ import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { wrapWithCurrentContext } from "#veryfront/platform/adapters/fs/veryfront/multi-project-adapter.ts"; import { isWithinDirectory } from "#veryfront/security/path-validation.ts"; import { + generateCompiledBinaryRequireShim, NODE_BUILTINS, readProjectDependencies, rewriteExternalImports, @@ -70,7 +71,6 @@ export { loadVeryfrontExportsMap, resolveEsmUserDependencies, resolveNodePackageToFileUrl, - rewriteCompiledBinaryRouteModule, rewriteCompiledBinaryUserDependencyImports, rewriteCompiledBinaryVeryfrontImports, rewriteDenoNodeBuiltinImports, @@ -1534,41 +1534,43 @@ function resolveBundledImportMetaSpecifier( } /** - * Source of the module injected into every bundled route handler. - * - * Many npm packages use CJS require() for Node built-ins (e.g. require('fs')). - * When esbuild bundles CJS into ESM output, these become __require() shims that - * fail at runtime, so the non-compiled runtimes inject a createRequire-based - * `require` resolved from the project's node_modules. - * - * The compiled binary does not inject its CJS loader. User dependency imports - * are rewritten to `require(...)` and `__vf_interopDefault(...)` only after - * bundling, so esbuild never sees those references: it tree-shakes the helper - * and renames the injected `require`, leaving the rewritten handler with free - * identifiers. `rewriteExternalImports` prepends the loader to the bundled - * output instead, where esbuild's reserved top-level `require` cannot collide. + * Source of the module injected into every bundled route handler. Injection + * binds the route's free `Worker` identifier, renaming either side on a + * collision, so relative worker URLs resolve against the route source. */ -export function generateRouteRuntimeShim( - projectDir: string, - routeSourceUrl: string, - compiledBinary: boolean, -): string { - const workerShim = [ +export function generateRouteRuntimeShim(routeSourceUrl: string): string { + return [ `const __vf_routeSourceUrl = ${JSON.stringify(routeSourceUrl)};`, `const Worker = typeof globalThis.Worker === "function" ? class extends globalThis.Worker {`, ` constructor(specifier, options) {`, ` super(typeof specifier === "string" && (specifier.startsWith("./") || specifier.startsWith("../")) ? new URL(specifier, __vf_routeSourceUrl) : specifier, options);`, ` }`, `} : globalThis.Worker;`, + "export { Worker };", ].join("\n"); - if (compiledBinary) return [workerShim, "export { Worker };"].join("\n"); +} - const safeProjectDir = JSON.stringify(projectDir + "/package.json"); +/** + * Module-scope `require` prepended to a bundled route handler. + * + * Bundled CommonJS calls `require()` through esbuild's `__require`, which only + * finds a `require` binding in module scope, and the compiled binary rewrites + * user dependency imports to `require(...)` and `__vf_interopDefault(...)` + * after bundling. Neither can be served by an injected shim: esbuild reserves + * top-level `require` in ESM output, so it renamed the injected binding, and it + * tree-shook the interop helper nothing referenced yet. Prepending after + * bundling keeps both names as written, and the reserved name means route code + * cannot declare a colliding top-level `require`. + * + * The compiled binary needs its own CJS loader because its `createRequire` + * cannot load CJS files from disk; other runtimes resolve `require` from the + * project's node_modules with `createRequire`. + */ +export function generateRouteRequirePrologue(projectDir: string, compiledBinary: boolean): string { + if (compiledBinary) return generateCompiledBinaryRequireShim(projectDir); return [ 'import { createRequire as __vf_createRequire } from "node:module";', - `var require = __vf_createRequire(${safeProjectDir});`, - workerShim, - "export { require, Worker };", + `var require = __vf_createRequire(${JSON.stringify(projectDir + "/package.json")});`, ].join("\n"); } @@ -2130,11 +2132,7 @@ function buildTranspiledModuleSource( const { build } = await import("veryfront/extensions/bundler"); const routeSourceUrl = pathHelper.toFileUrl(resolvedPath).href; - const routeRuntimeShim = generateRouteRuntimeShim( - projectDir, - routeSourceUrl, - isDeno && isCompiledBinary(), - ); + const routeRuntimeShim = generateRouteRuntimeShim(routeSourceUrl); const buildOptions = typescriptDecoratorOptions ? typeScriptBuildOptions(projectDir, typescriptDecoratorOptions, bundleTypeScript) @@ -2225,7 +2223,9 @@ function buildTranspiledModuleSource( const js = result.outputFiles?.[0]?.text ?? "export {}"; logger.debug(`transpiled size ${js.length} bytes`); - return await rewriteExternalImports(js, projectDir, sourceSnapshot, userDeps); + const rewritten = await rewriteExternalImports(js, projectDir, sourceSnapshot, userDeps); + const prologue = generateRouteRequirePrologue(projectDir, isDeno && isCompiledBinary()); + return `${prologue}\n${rewritten}`; }, { "api.modulePath": modulePath, "api.projectDir": projectDir }, ); From ffb8fc06db10dd61666c51e855c5aa1e04f2c9f5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 5 Nov 2026 21:18:34 +0100 Subject: [PATCH 03/22] Name missing npm packages and native addons in the compiled CJS loader The resolver error carried the host project path, and a .node addon was evaluated as JavaScript. Document supported npm package patterns for API routes. Refs #5471 --- docs/guides/api-routes.md | 13 +++++++ .../module-loader/external-import-rewriter.ts | 12 ++++++- src/routing/api/module-loader/loader.test.ts | 35 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/guides/api-routes.md b/docs/guides/api-routes.md index 9b494eb7f4..deb842f4f7 100644 --- a/docs/guides/api-routes.md +++ b/docs/guides/api-routes.md @@ -220,6 +220,19 @@ export function GET() { } ``` +## npm packages + +API routes can import packages listed in the project's `package.json`. The +package must be installed in the project's `node_modules` where the server +runs. CommonJS packages support default, named, namespace and dynamic +`import()` forms. ES module packages load as ES modules. + +Packages that ship a native addon (`.node` file) are not supported. Loading one +fails with `native addon "" cannot be loaded`. A package that is listed +but not installed fails with `npm package "" is not installed in this +runtime's node_modules`. Both errors happen when the route module loads, so the +route returns a server error until the dependency is changed. + ## Chat endpoint The most common API route pattern in Veryfront connects a chat UI to an agent: diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index e3a8a2ca1a..6614c1a6f6 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -114,7 +114,17 @@ function __vf_loadCjs(id, parentDir) { } } } else { - resolved = __vf_builtinRequire.resolve(id); + try { + resolved = __vf_builtinRequire.resolve(id); + } catch (error) { + // The resolver's message carries the host project path in its require + // stack. Name only the package the route asked for. + if (!error || error.code !== "MODULE_NOT_FOUND") throw error; + throw new Error("npm package " + JSON.stringify(id) + " is not installed in this runtime's node_modules"); + } + } + if (resolved.endsWith(".node")) { + throw new Error("native addon " + JSON.stringify(id) + " cannot be loaded: native addons are not supported in this runtime"); } // VULN-FS-5: Canonicalize before asserting containment, so the candidate and // the already-canonical project root are compared like with like, then assert diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index d6cb834808..f67e56e2e8 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -5796,4 +5796,39 @@ describe("route CommonJS require (#5471)", () => { }); assertEquals(await getText(route), "ok"); }); + + denoIt("names an npm package that is not installed instead of a host path", async () => { + const projectDir = Deno.realPathSync(await makeTempDir()); + await fs.writeTextFile(join(projectDir, "package.json"), "{}"); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + const error = assertThrows(() => vfRequire("not-installed-pkg")); + assertEquals( + error instanceof Error ? error.message : String(error), + 'npm package "not-installed-pkg" is not installed in this runtime\'s node_modules', + ); + }); + + denoIt("rejects a native addon with a clear error", async () => { + const projectDir = Deno.realPathSync(await makeTempDir()); + const packageDir = join(projectDir, "node_modules", "native-fixture"); + await fs.mkdir(packageDir, { recursive: true }); + await fs.writeTextFile(join(projectDir, "package.json"), "{}"); + await fs.writeTextFile( + join(packageDir, "package.json"), + JSON.stringify({ name: "native-fixture", main: "index.js" }), + ); + await fs.writeTextFile( + join(packageDir, "index.js"), + 'module.exports = require("./addon.node");', + ); + await fs.writeTextFile(join(packageDir, "addon.node"), "\u007fELF"); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + const error = assertThrows(() => vfRequire("native-fixture")); + assertEquals( + error instanceof Error ? error.message : String(error), + 'native addon "./addon.node" cannot be loaded: native addons are not supported in this runtime', + ); + }); }); From 003421fed0287ce7f68cabdce5bb9a129fb18f0a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 5 Nov 2026 21:35:31 +0100 Subject: [PATCH 04/22] Address review: private loader scope, nested resolution, namespace interop - Keep compiled loader state in a private scope and fail the build when route code declares a remaining prologue binding. - Keep a bundled route hashbang on the first line; strip CJS hashbangs. - Resolve bare requires from the requiring module's directory. - Report an installed package with an unresolvable entry or subpath as such. - Give namespace and dynamic imports an ES module namespace with default. - Accept $ in rewritten import bindings. - Document lazy dynamic-import failures and exports-map limits. Refs #5471 --- docs/guides/api-routes.md | 15 +- .../external-import-rewriter.test.ts | 18 +- .../module-loader/external-import-rewriter.ts | 103 +++++++-- src/routing/api/module-loader/loader.test.ts | 207 +++++++++++++----- src/routing/api/module-loader/loader.ts | 25 ++- src/transforms/import-rewriter/core.test.ts | 2 +- .../import-rewriter/route-adapter.ts | 32 ++- 7 files changed, 313 insertions(+), 89 deletions(-) diff --git a/docs/guides/api-routes.md b/docs/guides/api-routes.md index deb842f4f7..e73fffd2d7 100644 --- a/docs/guides/api-routes.md +++ b/docs/guides/api-routes.md @@ -225,13 +225,22 @@ export function GET() { API routes can import packages listed in the project's `package.json`. The package must be installed in the project's `node_modules` where the server runs. CommonJS packages support default, named, namespace and dynamic -`import()` forms. ES module packages load as ES modules. +`import()` forms. A namespace import or a dynamic `import()` exposes the +package's `module.exports` as `default`, alongside its named properties. + +Import packages by their root name or by a file path inside the package. Subpath +names defined only in a package's `exports` map, or a package whose `exports` +field is a single string, may not resolve. Packages that ship a native addon (`.node` file) are not supported. Loading one fails with `native addon "" cannot be loaded`. A package that is listed but not installed fails with `npm package "" is not installed in this -runtime's node_modules`. Both errors happen when the route module loads, so the -route returns a server error until the dependency is changed. +runtime's node_modules`. + +A static `import` resolves when the route module loads, so either error makes +every request to the route fail until the dependency is changed. A dynamic +`import()` resolves when it runs: only requests that reach it fail, and the +handler can catch the rejected promise. ## Chat endpoint diff --git a/src/routing/api/module-loader/external-import-rewriter.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index 2da0bd57e5..5cdc1906d8 100644 --- a/src/routing/api/module-loader/external-import-rewriter.test.ts +++ b/src/routing/api/module-loader/external-import-rewriter.test.ts @@ -124,12 +124,12 @@ describe("external-import-rewriter", () => { assertEquals(out, `const _ = __vf_interopDefault(require("lodash"));`); }); - it("rewrites a namespace import to require", () => { + it("rewrites a namespace import to a namespace over require", () => { const out = rewriteCompiledBinaryUserDependencyImports( `import * as _ from "lodash";`, deps, ); - assertEquals(out, `const _ = require("lodash");`); + assertEquals(out, `const _ = __vf_namespace(require("lodash"));`); }); it("rewrites a named import to a destructuring require", () => { @@ -141,12 +141,15 @@ describe("external-import-rewriter", () => { assertStringIncludes(out, "merge"); }); - it("rewrites a dynamic import to a resolved require promise", () => { + it("rewrites a dynamic import to a lazy namespace promise", () => { const out = rewriteCompiledBinaryUserDependencyImports( `const m = import("lodash");`, deps, ); - assertStringIncludes(out, `Promise.resolve(require("lodash"))`); + assertStringIncludes( + out, + `Promise.resolve().then(() => __vf_namespace(require("lodash")))`, + ); }); it("rewrites a subpath import including the subpath in require", () => { @@ -388,12 +391,13 @@ describe("external-import-rewriter", () => { const shim = generateCompiledBinaryRequireShim("/srv/app"); // Declares the require shim and embeds the project package.json path. assertStringIncludes(shim, "__vf_createRequire"); - assertStringIncludes(shim, "/srv/app/package.json"); + assertStringIncludes(shim, '__vf_createRequire("/srv/app" + "/package.json")'); // Embeds the node builtin set used for routing builtin requires. assertStringIncludes(shim, JSON.stringify(NODE_BUILTINS)); // Defines a require function and an interop helper. - assertStringIncludes(shim, "var require = function(id)"); - assertStringIncludes(shim, "__vf_interopDefault"); + assertStringIncludes(shim, "var require = (function () {"); + assertStringIncludes(shim, "function __vf_interopDefault(m)"); + assertStringIncludes(shim, "function __vf_namespace(m)"); assert(shim.length > 0); }); }); diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 6614c1a6f6..e682a46b47 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -50,6 +50,20 @@ export async function readProjectDependencies( return await readProjectDependenciesForRoute(projectDir, fs); } +/** + * Top-level bindings the route `require` prologues declare. The prologue is + * prepended after esbuild has resolved collisions, so bundled route code must + * not declare any of these; `assertNoRouteRequirePrologueCollision` fails the + * build instead of emitting a module that cannot parse. + */ +export const ROUTE_REQUIRE_PROLOGUE_BINDINGS: readonly string[] = [ + "__vf_createRequire", + "__vf_dirname", + "__vf_resolve", + "__vf_interopDefault", + "__vf_namespace", +]; + /** * Generates a CJS module loader shim for compiled Deno binaries. * @@ -59,16 +73,21 @@ export async function readProjectDependencies( * limitation by using `Deno.readTextFileSync` to read CJS files and * `new Function` to evaluate them in a proper CJS wrapper with require, * exports, module, __filename, and __dirname bindings. + * + * The loader state lives in a private scope. Only `require`, the two helpers + * the compiled import rewrite calls and the `node:` imports are module-level + * (see ROUTE_REQUIRE_PROLOGUE_BINDINGS). */ export function generateCompiledBinaryRequireShim(projectDir: string): string { const builtinSet = JSON.stringify(NODE_BUILTINS); - const safeProjectDir = JSON.stringify(projectDir + "/package.json"); + const safeProjectDir = JSON.stringify(projectDir); const safeProjectRoot = JSON.stringify(pathHelper.resolve(projectDir)); return ` import { createRequire as __vf_createRequire } from "node:module"; import { dirname as __vf_dirname, resolve as __vf_resolve } from "node:path"; -var __vf_builtinRequire = __vf_createRequire(${safeProjectDir}); +var require = (function () { +var __vf_builtinRequire = __vf_createRequire(${safeProjectDir} + "/package.json"); var __vf_builtinSet = new Set(${builtinSet}); var __vf_projectRoot = ${safeProjectRoot}; // VULN-FS-5: Canonicalize the project root so containment checks using @@ -78,6 +97,7 @@ var __vf_projectRoot = ${safeProjectRoot}; // legitimate dependencies would be rejected. try { __vf_projectRoot = Deno.realPathSync(__vf_projectRoot); } catch (_) { /* expected: projectRoot may not exist at shim init in some environments */ } var __vf_cache = Object.create(null); +var __vf_parentRequires = Object.create(null); // Resolve symlinks so containment compares canonical paths. A module that does // not exist yet cannot be canonicalized, so fall back to its parent directory, // which is what carries a symlinked project root; only if that fails too is the @@ -99,6 +119,40 @@ function __vf_assertContained(resolved) { throw new Error("CJS loader blocked path outside project: " + resolved); } } +// Bare ids resolve from the requiring module's directory, as Node does, so a +// package's own non-hoisted node_modules wins over the project root's copy. +function __vf_parentRequire(parentDir) { + var cached = __vf_parentRequires[parentDir]; + if (cached) return cached; + cached = __vf_createRequire(__vf_resolve(parentDir, "__vf_parent.js")); + __vf_parentRequires[parentDir] = cached; + return cached; +} +function __vf_packageName(id) { + var parts = id.split("/"); + return id.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; +} +function __vf_packageInstalled(name, parentDir) { + for (var dir = __vf_resolve(parentDir); ; dir = __vf_dirname(dir)) { + try { Deno.statSync(__vf_resolve(dir, "node_modules", name)); return true; } catch (_) { /* expected: not installed at this level */ } + if (__vf_dirname(dir) === dir) return false; + } +} +function __vf_resolveBare(id, parentDir) { + try { + return __vf_parentRequire(parentDir).resolve(id); + } catch (error) { + // The resolver's message carries host paths in its require stack. Name + // only what the route asked for, and say "not installed" only when no + // node_modules on the lookup path has the package at all. + if (!error || error.code !== "MODULE_NOT_FOUND") throw error; + var name = __vf_packageName(id); + if (__vf_packageInstalled(name, parentDir)) { + throw new Error("npm package " + JSON.stringify(name) + " is installed but " + JSON.stringify(id) + " could not be resolved"); + } + throw new Error("npm package " + JSON.stringify(name) + " is not installed in this runtime's node_modules"); + } +} function __vf_loadCjs(id, parentDir) { if (id.startsWith("node:")) return __vf_builtinRequire(id); if (__vf_builtinSet.has(id)) return __vf_builtinRequire(id); @@ -114,14 +168,7 @@ function __vf_loadCjs(id, parentDir) { } } } else { - try { - resolved = __vf_builtinRequire.resolve(id); - } catch (error) { - // The resolver's message carries the host project path in its require - // stack. Name only the package the route asked for. - if (!error || error.code !== "MODULE_NOT_FOUND") throw error; - throw new Error("npm package " + JSON.stringify(id) + " is not installed in this runtime's node_modules"); - } + resolved = __vf_resolveBare(id, parentDir); } if (resolved.endsWith(".node")) { throw new Error("native addon " + JSON.stringify(id) + " cannot be loaded: native addons are not supported in this runtime"); @@ -142,13 +189,16 @@ function __vf_loadCjs(id, parentDir) { __vf_cache[resolved] = json; return json; } + // Node strips a leading hashbang from CommonJS sources; a Function body + // cannot contain one. Commenting it out keeps line numbers intact. + if (code.startsWith("#!")) code = "//" + code.slice(2); var mod = { exports: {} }; __vf_cache[resolved] = mod.exports; var dir = __vf_dirname(resolved); var childReq = function(childId) { return __vf_loadCjs(childId, dir); }; childReq.resolve = function(childId) { if (childId.startsWith(".") || childId.startsWith("/")) return __vf_resolve(dir, childId); - return __vf_builtinRequire.resolve(childId); + return __vf_resolveBare(childId, dir); }; childReq.ensure = function(mods, cb) { cb(); }; var fn = new Function("exports", "require", "module", "__filename", "__dirname", "global", "globalThis", "Worker", code); @@ -156,13 +206,38 @@ function __vf_loadCjs(id, parentDir) { __vf_cache[resolved] = mod.exports; return mod.exports; } +var routeRequire = function(id) { return __vf_loadCjs(id, ${safeProjectDir}); }; +routeRequire.resolve = function(id) { return __vf_resolveBare(id, ${safeProjectDir}); }; +routeRequire.ensure = function(mods, cb) { cb(); }; +return routeRequire; +})(); function __vf_interopDefault(m) { return m && m.__esModule && m.default !== undefined ? m.default : m; } -var require = function(id) { return __vf_loadCjs(id, ${JSON.stringify(projectDir)}); }; -require.resolve = function(id) { return __vf_builtinRequire.resolve(id); }; -require.ensure = function(mods, cb) { cb(); }; +function __vf_namespace(m) { + if (m && m.__esModule) return m; + var ns = Object.create(null); + if (m !== null && (typeof m === "object" || typeof m === "function")) { + for (var key in m) if (key !== "default") ns[key] = m[key]; + } + ns.default = m; + return ns; +} `.trim(); } +/** + * Fail the build when bundled route code declares a name the prepended + * `require` prologue also declares at module scope. + */ +export function assertNoRouteRequirePrologueCollision(bundledCode: string): void { + for (const name of ROUTE_REQUIRE_PROLOGUE_BINDINGS) { + if (new RegExp(`(^|[^\\w$])${name.replace(/\$/g, "\\$")}($|[^\\w$])`).test(bundledCode)) { + throw new Error( + `${name} is reserved for the Veryfront route module loader; rename it in the route or its imports`, + ); + } + } +} + export function getNodeExternalPackagesToResolve(userDeps: Map): string[] { return getNodeExternalPackagesToResolveForRoute(userDeps); } diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index f67e56e2e8..a9f38537ba 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -16,7 +16,6 @@ import { bundlerForcesTypeScript, canDirectImportSpecifier, generateCompiledBinaryRequireShim, - generateRouteRequirePrologue, generateRouteRuntimeShim, getNodeExternalPackagesToResolve, getUserDependencies, @@ -26,6 +25,7 @@ import { loadModuleFromCode, lookupImportMapEntry, prepareHandlerModule, + prependRouteRequirePrologue, readDenoImportMap, resolveEsmUserDependencies, rewriteCompiledBinaryUserDependencyImports, @@ -1842,13 +1842,16 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, assertMatch(rewritten, /const thing = __vf_interopDefault\(require\("my-lib"\)\)/); assertMatch(rewritten, /const \{ alpha: beta \} = require\("my-lib"\)/); - assertMatch(rewritten, /const namespace = require\("my-lib"\)/); + assertMatch(rewritten, /const namespace = __vf_namespace\(require\("my-lib"\)\)/); assertMatch( rewritten, /const __vf_tmp_combo = require\("my-lib"\); const combo = __vf_interopDefault\(__vf_tmp_combo\); const \{ gamma \} = __vf_tmp_combo/, ); - assertMatch(rewritten, /const widget = require\("my-lib\/subpath"\)/); - assertMatch(rewritten, /Promise\.resolve\(require\("my-lib\/subpath"\)\)/); + assertMatch(rewritten, /const widget = __vf_interopDefault\(require\("my-lib\/subpath"\)\)/); + assertMatch( + rewritten, + /Promise\.resolve\(\)\.then\(\(\) => __vf_namespace\(require\("my-lib\/subpath"\)\)\)/, + ); }); it("rewrites ESM-only user dependency imports to real file:// module URLs", () => { @@ -5446,7 +5449,8 @@ describe("generateCompiledBinaryRequireShim - static checks (VULN-FS-5)", () => // The original (vulnerable) layout called __vf_assertContained only inside // the relative/absolute branch. The fix moves the assertion to run after // both branches (i.e. AFTER the `} else { resolved = ...resolve(id); }`). - const elseIdx = shim.indexOf("__vf_builtinRequire.resolve(id)"); + const elseIdx = shim.indexOf("resolved = __vf_resolveBare(id, parentDir)"); + assert(elseIdx !== -1, "the shim must resolve bare ids through __vf_resolveBare"); const assertIdx = shim.indexOf("__vf_assertContained(resolved)", elseIdx); assertEquals( assertIdx > elseIdx, @@ -5683,29 +5687,43 @@ describe("route CommonJS require (#5471)", () => { await stop(); }); - async function loadCompiledRoute(routeSource: string): Promise<() => Promise> { + const CJS_FIXTURE: Record = { + "node_modules/cjs-fixture/package.json": JSON.stringify({ + name: "cjs-fixture", + version: "1.0.0", + main: "index.js", + }), + "node_modules/cjs-fixture/index.js": [ + 'const path = require("path");', + "class Pool { query() { return path.basename('/rows/ok'); } }", + "module.exports = { Pool, version: '1.0.0' };", + ].join("\n"), + }; + + async function writeProject(files: Record): Promise { const projectDir = Deno.realPathSync(await makeTempDir()); - const packageDir = join(projectDir, "node_modules", "cjs-fixture"); - await fs.mkdir(packageDir, { recursive: true }); - await fs.writeTextFile( - join(packageDir, "package.json"), - JSON.stringify({ name: "cjs-fixture", version: "1.0.0", main: "index.js" }), - ); - await fs.writeTextFile( - join(packageDir, "index.js"), - [ - 'const path = require("path");', - "class Pool { query() { return path.basename('/rows/ok'); } }", - "module.exports = { Pool, version: '1.0.0' };", - ].join("\n"), - ); - await fs.writeTextFile( - join(projectDir, "package.json"), - JSON.stringify({ dependencies: { "cjs-fixture": "1.0.0" } }), - ); + await fs.writeTextFile(join(projectDir, "package.json"), "{}"); + for (const [path, contents] of Object.entries(files)) { + const target = join(projectDir, path); + await fs.mkdir(join(target, ".."), { recursive: true }); + await fs.writeTextFile(target, contents); + } + return projectDir; + } + async function loadCompiledRoute( + routeSource: string, + files: Record = CJS_FIXTURE, + ): Promise<() => Promise> { + const projectDir = await writeProject(files); + const packages = new Set( + Object.keys(files) + .filter((path) => path.startsWith("node_modules/") && path.endsWith("/package.json")) + .map((path) => path.slice("node_modules/".length, -"/package.json".length)) + .filter((name) => !name.includes("/node_modules/")), + ); + const userDeps = new Map([...packages].map((name) => [name, "1.0.0"])); const routePath = join(projectDir, "app", "api", "route.ts"); - const userDeps = new Map([["cjs-fixture", "1.0.0"]]); const shimNamespace = "vf-test-route-runtime-shim"; const { build } = await import("veryfront/extensions/bundler"); const result = await build({ @@ -5715,7 +5733,7 @@ describe("route CommonJS require (#5471)", () => { platform: "neutral", target: "es2022", inject: ["veryfront:route-runtime-shim"], - external: ["node:*", "cjs-fixture", "cjs-fixture/*"], + external: ["node:*", ...[...packages].flatMap((name) => [name, `${name}/*`])], stdin: { contents: routeSource, loader: "ts", resolveDir: join(projectDir, "app", "api") }, plugins: [{ name: "vf-test-route-runtime-shim", @@ -5732,36 +5750,116 @@ describe("route CommonJS require (#5471)", () => { }], }); const bundled = result.outputFiles?.[0]?.text ?? ""; + const rewritten = rewriteCompiledBinaryUserDependencyImports(bundled, userDeps); const handlerPath = join(projectDir, "handler.mjs"); await fs.writeTextFile( handlerPath, - `${generateRouteRequirePrologue(projectDir, true)}\n${ - rewriteCompiledBinaryUserDependencyImports(bundled, userDeps) - }`, + prependRouteRequirePrologue(bundled, rewritten, projectDir, true), ); const module = await import(toFileUrl(handlerPath).href); return module.GET; } + async function responseText(GET: () => Promise): Promise { + return await (await GET()).text(); + } + denoIt("runs a handler that default-imports a CJS package", async () => { const GET = await loadCompiledRoute( 'import pg from "cjs-fixture";\nexport const GET = () => new Response(new pg.Pool().query());', ); - assertEquals(await (await GET()).text(), "ok"); + assertEquals(await responseText(GET), "ok"); }); denoIt("runs a handler that mixes default and named CJS imports", async () => { const GET = await loadCompiledRoute( 'import pg, { version } from "cjs-fixture";\nexport const GET = () => new Response(new pg.Pool().query() + version);', ); - assertEquals(await (await GET()).text(), "ok1.0.0"); + assertEquals(await responseText(GET), "ok1.0.0"); }); denoIt("runs a handler that dynamically imports a CJS package", async () => { const GET = await loadCompiledRoute( 'export const GET = async () => { const pg = await import("cjs-fixture"); return new Response(new pg.Pool().query()); };', ); - assertEquals(await (await GET()).text(), "ok"); + assertEquals(await responseText(GET), "ok"); + }); + + denoIt("rewrites default imports bound to a $ identifier", async () => { + const GET = await loadCompiledRoute( + 'import $ from "cjs-fixture";\nexport const GET = () => new Response(new $.Pool().query());', + ); + assertEquals(await responseText(GET), "ok"); + }); + + denoIt("gives namespace and dynamic imports a default export", async () => { + const files = { + "node_modules/fn-fixture/package.json": JSON.stringify({ + name: "fn-fixture", + main: "index.js", + }), + "node_modules/fn-fixture/index.js": "module.exports = function () { return 'fn'; };", + }; + const GET = await loadCompiledRoute( + [ + 'import * as ns from "fn-fixture";', + 'export const GET = async () => new Response(ns.default() + (await import("fn-fixture")).default());', + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "fnfn"); + }); + + denoIt("resolves a package's own nested dependency before the hoisted copy", async () => { + const files = { + "node_modules/outer/package.json": JSON.stringify({ name: "outer", main: "index.js" }), + "node_modules/outer/index.js": 'module.exports = require("inner");', + "node_modules/outer/node_modules/inner/package.json": JSON.stringify({ + name: "inner", + main: "index.js", + }), + "node_modules/outer/node_modules/inner/index.js": "module.exports = 'nested';", + "node_modules/inner/package.json": JSON.stringify({ name: "inner", main: "index.js" }), + "node_modules/inner/index.js": "module.exports = 'hoisted';", + }; + const GET = await loadCompiledRoute( + 'import outer from "outer";\nexport const GET = () => new Response(outer);', + files, + ); + assertEquals(await responseText(GET), "nested"); + }); + + denoIt("loads a CommonJS package whose entry starts with a hashbang", async () => { + const files = { + "node_modules/bin-fixture/package.json": JSON.stringify({ + name: "bin-fixture", + main: "cli.js", + }), + "node_modules/bin-fixture/cli.js": "#!/usr/bin/env node\nmodule.exports = 'bin';", + }; + const GET = await loadCompiledRoute( + 'import bin from "bin-fixture";\nexport const GET = () => new Response(bin);', + files, + ); + assertEquals(await responseText(GET), "bin"); + }); + + denoIt("keeps a route hashbang as the first line", async () => { + const GET = await loadCompiledRoute( + '#!/usr/bin/env node\nimport pg from "cjs-fixture";\nexport const GET = () => new Response(new pg.Pool().query());', + ); + assertEquals(await responseText(GET), "ok"); + }); + + denoIt("rejects route code that declares a prologue binding", async () => { + await assertRejects( + () => + loadCompiledRoute( + "const __vf_namespace = 1;\nexport const GET = () => new Response(String(__vf_namespace));", + ), + Error, + "__vf_namespace is reserved for the Veryfront route module loader", + ); }); denoIt("lets bundled project CommonJS require Node built-ins", async () => { @@ -5797,37 +5895,44 @@ describe("route CommonJS require (#5471)", () => { assertEquals(await getText(route), "ok"); }); + function thrownMessage(fn: () => unknown): string { + const error = assertThrows(fn); + return error instanceof Error ? error.message : String(error); + } + denoIt("names an npm package that is not installed instead of a host path", async () => { - const projectDir = Deno.realPathSync(await makeTempDir()); - await fs.writeTextFile(join(projectDir, "package.json"), "{}"); + const projectDir = await writeProject({}); const vfRequire = await importCompiledBinaryRequireShim(projectDir); - const error = assertThrows(() => vfRequire("not-installed-pkg")); assertEquals( - error instanceof Error ? error.message : String(error), + thrownMessage(() => vfRequire("not-installed-pkg")), 'npm package "not-installed-pkg" is not installed in this runtime\'s node_modules', ); }); - denoIt("rejects a native addon with a clear error", async () => { - const projectDir = Deno.realPathSync(await makeTempDir()); - const packageDir = join(projectDir, "node_modules", "native-fixture"); - await fs.mkdir(packageDir, { recursive: true }); - await fs.writeTextFile(join(projectDir, "package.json"), "{}"); - await fs.writeTextFile( - join(packageDir, "package.json"), - JSON.stringify({ name: "native-fixture", main: "index.js" }), - ); - await fs.writeTextFile( - join(packageDir, "index.js"), - 'module.exports = require("./addon.node");', + denoIt("does not call an installed package with a missing subpath uninstalled", async () => { + const projectDir = await writeProject(CJS_FIXTURE); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals( + thrownMessage(() => vfRequire("cjs-fixture/missing")), + 'npm package "cjs-fixture" is installed but "cjs-fixture/missing" could not be resolved', ); - await fs.writeTextFile(join(packageDir, "addon.node"), "\u007fELF"); + }); + + denoIt("rejects a native addon with a clear error", async () => { + const projectDir = await writeProject({ + "node_modules/native-fixture/package.json": JSON.stringify({ + name: "native-fixture", + main: "index.js", + }), + "node_modules/native-fixture/index.js": 'module.exports = require("./addon.node");', + "node_modules/native-fixture/addon.node": "\u007fELF", + }); const vfRequire = await importCompiledBinaryRequireShim(projectDir); - const error = assertThrows(() => vfRequire("native-fixture")); assertEquals( - error instanceof Error ? error.message : String(error), + thrownMessage(() => vfRequire("native-fixture")), 'native addon "./addon.node" cannot be loaded: native addons are not supported in this runtime', ); }); diff --git a/src/routing/api/module-loader/loader.ts b/src/routing/api/module-loader/loader.ts index cb32f451b4..543a58dd09 100644 --- a/src/routing/api/module-loader/loader.ts +++ b/src/routing/api/module-loader/loader.ts @@ -43,6 +43,7 @@ import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { wrapWithCurrentContext } from "#veryfront/platform/adapters/fs/veryfront/multi-project-adapter.ts"; import { isWithinDirectory } from "#veryfront/security/path-validation.ts"; import { + assertNoRouteRequirePrologueCollision, generateCompiledBinaryRequireShim, NODE_BUILTINS, readProjectDependencies, @@ -1574,6 +1575,27 @@ export function generateRouteRequirePrologue(projectDir: string, compiledBinary: ].join("\n"); } +/** + * Prepend the route `require` prologue to a bundled, rewritten handler. + * + * `bundledCode` is esbuild's output before the post-bundle rewrites; it is + * checked for names the prologue declares, which esbuild could not rename. A + * hashbang esbuild kept stays the first line, the only place it is valid. + */ +export function prependRouteRequirePrologue( + bundledCode: string, + rewrittenCode: string, + projectDir: string, + compiledBinary: boolean, +): string { + assertNoRouteRequirePrologueCollision(bundledCode); + const prologue = generateRouteRequirePrologue(projectDir, compiledBinary); + if (!rewrittenCode.startsWith("#!")) return `${prologue}\n${rewrittenCode}`; + const eol = rewrittenCode.indexOf("\n"); + if (eol === -1) return `${rewrittenCode}\n${prologue}\n`; + return `${rewrittenCode.slice(0, eol + 1)}${prologue}\n${rewrittenCode.slice(eol + 1)}`; +} + function createRouteRuntimeShimPlugin(contents: string): Plugin { return { name: "vf-route-runtime-shim", @@ -2224,8 +2246,7 @@ function buildTranspiledModuleSource( logger.debug(`transpiled size ${js.length} bytes`); const rewritten = await rewriteExternalImports(js, projectDir, sourceSnapshot, userDeps); - const prologue = generateRouteRequirePrologue(projectDir, isDeno && isCompiledBinary()); - return `${prologue}\n${rewritten}`; + return prependRouteRequirePrologue(js, rewritten, projectDir, isDeno && isCompiledBinary()); }, { "api.modulePath": modulePath, "api.projectDir": projectDir }, ); diff --git a/src/transforms/import-rewriter/core.test.ts b/src/transforms/import-rewriter/core.test.ts index 208142d36d..fe42b06ae8 100644 --- a/src/transforms/import-rewriter/core.test.ts +++ b/src/transforms/import-rewriter/core.test.ts @@ -148,7 +148,7 @@ describe("import rewrite compatibility golden tests", () => { `const m = import("lodash/merge");`, new Map([["lodash", "^4"]]), ), - `Promise.resolve(require("lodash/merge"))`, + `Promise.resolve().then(() => __vf_namespace(require("lodash/merge")))`, ); assertEquals( rewriteDenoNodeBuiltinImports(`import { readFile } from "fs";`), diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index 178bde8147..6bab7c5fbe 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -194,6 +194,9 @@ export function rewriteCompiledVeryfrontImportsForRoute(code: string): string { return transformed; } +/** A JavaScript identifier as esbuild emits local bindings (ASCII, `$` allowed). */ +const IDENT = "[\\w$]+"; + export function rewriteCompiledUserDependencyImportsForRoute( code: string, userDeps: Map, @@ -247,8 +250,11 @@ export function rewriteCompiledUserDependencyImportsForRoute( continue; } + // `IDENT` matches the local binding names esbuild emits, `$` included. + // Namespace and dynamic imports get an ES module namespace object, so + // `ns.default` is the CommonJS `module.exports` as under native interop. transformed = transformed.replace( - new RegExp(`import\\s+(\\w+)\\s+from\\s+["']${escaped}["']`, "g"), + new RegExp(`import\\s+(${IDENT})\\s+from\\s+["']${escaped}["']`, "g"), (_, localName) => `const ${localName} = __vf_interopDefault(require("${name}"))`, ); transformed = transformed.replace( @@ -256,12 +262,12 @@ export function rewriteCompiledUserDependencyImportsForRoute( (_, bindings) => `const ${toCjsDestructureBindings(bindings)} = require("${name}")`, ); transformed = transformed.replace( - new RegExp(`import\\s+\\*\\s+as\\s+(\\w+)\\s+from\\s+["']${escaped}["']`, "g"), - (_, localName) => `const ${localName} = require("${name}")`, + new RegExp(`import\\s+\\*\\s+as\\s+(${IDENT})\\s+from\\s+["']${escaped}["']`, "g"), + (_, localName) => `const ${localName} = __vf_namespace(require("${name}"))`, ); transformed = transformed.replace( new RegExp( - `import\\s+(\\w+)\\s*,\\s*(\\{[^}]+\\})\\s+from\\s+["']${escaped}["']`, + `import\\s+(${IDENT})\\s*,\\s*(\\{[^}]+\\})\\s+from\\s+["']${escaped}["']`, "g", ), (_, defaultName, bindings) => { @@ -273,23 +279,27 @@ export function rewriteCompiledUserDependencyImportsForRoute( ); transformed = transformed.replace( new RegExp( - `import\\s+(\\w+|\\*\\s+as\\s+\\w+|\\{[^}]+\\})\\s+from\\s+["']${escaped}(/[^"']+)["']`, + `import\\s+(${IDENT}|\\*\\s+as\\s+${IDENT}|\\{[^}]+\\})\\s+from\\s+["']${escaped}(/[^"']+)["']`, "g", ), (_, binding, subpath) => { const trimmedBinding = String(binding).trim(); + const required = `require("${name}${subpath}")`; if (trimmedBinding.startsWith("{")) { - return `const ${toCjsDestructureBindings(trimmedBinding)} = require("${name}${subpath}")`; + return `const ${toCjsDestructureBindings(trimmedBinding)} = ${required}`; + } + if (trimmedBinding.startsWith("*")) { + return `const ${trimmedBinding.replace(/\*\s+as\s+/, "")} = __vf_namespace(${required})`; } - const name_ = trimmedBinding.startsWith("*") - ? trimmedBinding.replace(/\*\s+as\s+/, "") - : trimmedBinding; - return `const ${name_} = require("${name}${subpath}")`; + return `const ${trimmedBinding} = __vf_interopDefault(${required})`; }, ); + // A dynamic import stays lazy and asynchronous: a load failure rejects the + // returned promise instead of throwing synchronously. transformed = transformed.replace( new RegExp(`import\\s*\\(\\s*["']${escaped}(/[^"']*)?["']\\s*\\)`, "g"), - (_, subpath) => `Promise.resolve(require("${name}${subpath || ""}"))`, + (_, subpath) => + `Promise.resolve().then(() => __vf_namespace(require("${name}${subpath || ""}")))`, ); } From 15f4a643624784b2c847a6fd528f80040805d08a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 5 Nov 2026 21:45:49 +0100 Subject: [PATCH 05/22] Address review: declaration-only collisions, full namespace interop, combined clauses - Reject only declarations of prologue names, not strings or properties. - Namespace default is the whole module.exports, __esModule included. - Keep code MODULE_NOT_FOUND on the sanitized resolution errors. - Rewrite every static import clause form, root or subpath, and side-effect imports. Refs #5471 --- .../module-loader/external-import-rewriter.ts | 34 +++++++-- src/routing/api/module-loader/loader.test.ts | 73 +++++++++++++++++++ .../import-rewriter/route-adapter.ts | 70 ++++++++---------- 3 files changed, 130 insertions(+), 47 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index e682a46b47..696e896e24 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -147,10 +147,13 @@ function __vf_resolveBare(id, parentDir) { // node_modules on the lookup path has the package at all. if (!error || error.code !== "MODULE_NOT_FOUND") throw error; var name = __vf_packageName(id); - if (__vf_packageInstalled(name, parentDir)) { - throw new Error("npm package " + JSON.stringify(name) + " is installed but " + JSON.stringify(id) + " could not be resolved"); - } - throw new Error("npm package " + JSON.stringify(name) + " is not installed in this runtime's node_modules"); + // Keep the code: packages probe optional dependencies with + // \`error.code === "MODULE_NOT_FOUND"\`. + var sanitized = new Error(__vf_packageInstalled(name, parentDir) + ? "npm package " + JSON.stringify(name) + " is installed but " + JSON.stringify(id) + " could not be resolved" + : "npm package " + JSON.stringify(name) + " is not installed in this runtime's node_modules"); + sanitized.code = "MODULE_NOT_FOUND"; + throw sanitized; } } function __vf_loadCjs(id, parentDir) { @@ -212,8 +215,9 @@ routeRequire.ensure = function(mods, cb) { cb(); }; return routeRequire; })(); function __vf_interopDefault(m) { return m && m.__esModule && m.default !== undefined ? m.default : m; } +// As with native CommonJS interop, \`default\` is the whole module.exports, also +// for modules marked __esModule. function __vf_namespace(m) { - if (m && m.__esModule) return m; var ns = Object.create(null); if (m !== null && (typeof m === "object" || typeof m === "function")) { for (var key in m) if (key !== "default") ns[key] = m[key]; @@ -226,11 +230,13 @@ function __vf_namespace(m) { /** * Fail the build when bundled route code declares a name the prepended - * `require` prologue also declares at module scope. + * `require` prologue also declares at module scope. Only declarations count: + * the name inside a string or as a property is not a binding and cannot + * collide. */ export function assertNoRouteRequirePrologueCollision(bundledCode: string): void { for (const name of ROUTE_REQUIRE_PROLOGUE_BINDINGS) { - if (new RegExp(`(^|[^\\w$])${name.replace(/\$/g, "\\$")}($|[^\\w$])`).test(bundledCode)) { + if (declaresBinding(bundledCode, name)) { throw new Error( `${name} is reserved for the Veryfront route module loader; rename it in the route or its imports`, ); @@ -238,6 +244,20 @@ export function assertNoRouteRequirePrologueCollision(bundledCode: string): void } } +function declaresBinding(code: string, name: string): boolean { + const id = name.replace(/\$/g, "\\$"); + const notIdent = "(?![\\w$])"; + const patterns = [ + // var/let/const/function/class declarations. + `(?:^|[^\\w$.])(?:var|let|const|function\\s*\\*?|class)\\s+${id}${notIdent}`, + // Destructuring declarations: const { a: name } = ..., const [name] = ... + `(?:^|[^\\w$.])(?:var|let|const)\\s*[{[][^=;]*?(?:^|[^\\w$.])${id}${notIdent}[^=;]*?[}\\]]\\s*=`, + // Import bindings: import name, import * as name, import { x as name }. + `(?:^|[^\\w$.])import\\s+[^'";]*?(?:^|[^\\w$.:])${id}${notIdent}[^'";]*?\\bfrom\\s*["']`, + ]; + return patterns.some((pattern) => new RegExp(pattern, "m").test(code)); +} + export function getNodeExternalPackagesToResolve(userDeps: Map): string[] { return getNodeExternalPackagesToResolveForRoute(userDeps); } diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index a9f38537ba..142b7fccc9 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -5862,6 +5862,79 @@ describe("route CommonJS require (#5471)", () => { ); }); + denoIt("allows the reserved names in strings and property access", async () => { + const GET = await loadCompiledRoute( + [ + 'import pg from "cjs-fixture";', + "const record = { __vf_namespace: 'prop' };", + "export const GET = () => new Response('__vf_namespace ' + record.__vf_namespace + ' ' + new pg.Pool().query());", + ].join("\n"), + ); + assertEquals(await responseText(GET), "__vf_namespace prop ok"); + }); + + denoIt("gives __esModule packages a namespace whose default is module.exports", async () => { + const files = { + "node_modules/flagged/package.json": JSON.stringify({ name: "flagged", main: "index.js" }), + "node_modules/flagged/index.js": [ + 'Object.defineProperty(exports, "__esModule", { value: true });', + "exports.default = 'inner';", + "exports.named = 'n';", + ].join("\n"), + }; + const GET = await loadCompiledRoute( + [ + 'import inner, * as ns from "flagged";', + "export const GET = async () => {", + ' const dynamic = await import("flagged");', + " return new Response([inner, ns.named, ns.default.named, dynamic.default.default].join(','));", + "};", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "inner,n,n,inner"); + }); + + denoIt("rewrites default-plus-named imports of a subpath and side-effect imports", async () => { + const files = { + "node_modules/combo/package.json": JSON.stringify({ name: "combo", main: "index.js" }), + "node_modules/combo/index.js": "module.exports = 'root';", + "node_modules/combo/sub.js": "module.exports = { helper: 'h', toString: () => 'sub' };", + "node_modules/combo/side.js": "globalThis.__comboSide = 'side';", + }; + const GET = await loadCompiledRoute( + [ + 'import "combo/side";', + 'import main, { helper } from "combo/sub";', + "export const GET = () => new Response(", + " [String(main), helper, (globalThis as Record).__comboSide].join(','),", + ");", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "sub,h,side"); + }); + + denoIt("keeps MODULE_NOT_FOUND on a missing optional dependency", async () => { + const files = { + "node_modules/optional-user/package.json": JSON.stringify({ + name: "optional-user", + main: "index.js", + }), + "node_modules/optional-user/index.js": [ + "let value;", + 'try { value = require("optional-native"); }', + "catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error; value = 'fallback'; }", + "module.exports = value;", + ].join("\n"), + }; + const GET = await loadCompiledRoute( + 'import value from "optional-user";\nexport const GET = () => new Response(value);', + files, + ); + assertEquals(await responseText(GET), "fallback"); + }); + denoIt("lets bundled project CommonJS require Node built-ins", async () => { // Runs the real loader on this runtime. A virtual adapter forces the // esbuild bundle path, which inlines the .cjs file behind esbuild's diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index 6bab7c5fbe..b6c60e4d57 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -197,6 +197,28 @@ export function rewriteCompiledVeryfrontImportsForRoute(code: string): string { /** A JavaScript identifier as esbuild emits local bindings (ASCII, `$` allowed). */ const IDENT = "[\\w$]+"; +const IMPORT_CLAUSE = new RegExp( + `^(?:(${IDENT})\\s*(?:,\\s*)?)?(?:\\*\\s*as\\s+(${IDENT})|(\\{[^}]*\\}))?$`, +); + +/** + * Rewrite one static import clause to CommonJS bindings over `required`, or + * return null when the clause is not a form this rewrite understands. + */ +function rewriteCjsImportClause(clause: string, required: string): string | null { + const parsed = clause.trim().match(IMPORT_CLAUSE); + if (!parsed) return null; + const [, defaultName, namespaceName, named] = parsed; + if (!defaultName && !namespaceName && !named) return null; + const bindings = [defaultName, namespaceName, named].filter(Boolean).length; + const source = bindings > 1 ? `__vf_tmp_${defaultName ?? namespaceName}` : required; + const statements = bindings > 1 ? [`const ${source} = ${required}`] : []; + if (defaultName) statements.push(`const ${defaultName} = __vf_interopDefault(${source})`); + if (namespaceName) statements.push(`const ${namespaceName} = __vf_namespace(${source})`); + if (named) statements.push(`const ${toCjsDestructureBindings(named)} = ${source}`); + return statements.join("; "); +} + export function rewriteCompiledUserDependencyImportsForRoute( code: string, userDeps: Map, @@ -250,49 +272,17 @@ export function rewriteCompiledUserDependencyImportsForRoute( continue; } - // `IDENT` matches the local binding names esbuild emits, `$` included. - // Namespace and dynamic imports get an ES module namespace object, so - // `ns.default` is the CommonJS `module.exports` as under native interop. - transformed = transformed.replace( - new RegExp(`import\\s+(${IDENT})\\s+from\\s+["']${escaped}["']`, "g"), - (_, localName) => `const ${localName} = __vf_interopDefault(require("${name}"))`, - ); + // Every static import clause form (default, named, namespace and their + // combinations) for the package root or a subpath. Namespace imports get + // an ES module namespace whose `default` is the CommonJS `module.exports`. transformed = transformed.replace( - new RegExp(`import\\s+(\\{[^}]+\\})\\s+from\\s+["']${escaped}["']`, "g"), - (_, bindings) => `const ${toCjsDestructureBindings(bindings)} = require("${name}")`, + new RegExp(`import\\s+([^'";]+?)\\s+from\\s+["']${escaped}(/[^"']+)?["']`, "g"), + (match, clause: string, subpath: string | undefined) => + rewriteCjsImportClause(clause, `require("${name}${subpath ?? ""}")`) ?? match, ); transformed = transformed.replace( - new RegExp(`import\\s+\\*\\s+as\\s+(${IDENT})\\s+from\\s+["']${escaped}["']`, "g"), - (_, localName) => `const ${localName} = __vf_namespace(require("${name}"))`, - ); - transformed = transformed.replace( - new RegExp( - `import\\s+(${IDENT})\\s*,\\s*(\\{[^}]+\\})\\s+from\\s+["']${escaped}["']`, - "g", - ), - (_, defaultName, bindings) => { - const tmp = `__vf_tmp_${defaultName}`; - return `const ${tmp} = require("${name}"); const ${defaultName} = __vf_interopDefault(${tmp}); const ${ - toCjsDestructureBindings(bindings) - } = ${tmp}`; - }, - ); - transformed = transformed.replace( - new RegExp( - `import\\s+(${IDENT}|\\*\\s+as\\s+${IDENT}|\\{[^}]+\\})\\s+from\\s+["']${escaped}(/[^"']+)["']`, - "g", - ), - (_, binding, subpath) => { - const trimmedBinding = String(binding).trim(); - const required = `require("${name}${subpath}")`; - if (trimmedBinding.startsWith("{")) { - return `const ${toCjsDestructureBindings(trimmedBinding)} = ${required}`; - } - if (trimmedBinding.startsWith("*")) { - return `const ${trimmedBinding.replace(/\*\s+as\s+/, "")} = __vf_namespace(${required})`; - } - return `const ${trimmedBinding} = __vf_interopDefault(${required})`; - }, + new RegExp(`import\\s*["']${escaped}(/[^"']+)?["']`, "g"), + (_, subpath: string | undefined) => `require("${name}${subpath ?? ""}")`, ); // A dynamic import stays lazy and asynchronous: a load failure rejects the // returned promise instead of throwing synchronously. From 1c7aaeeac597d736e2b683d434128b7fba316a3c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 5 Nov 2026 22:15:18 +0100 Subject: [PATCH 06/22] fix(api-routes): rewrite CJS imports from lexer spans with collision-free helpers - Rewrite compiled user dependency imports from parsed import statements, so import-like text in strings, templates and comments is untouched. - Prefix prologue helpers and temporaries with a prefix absent from the bundle, replacing the declaration check (and its regex escaping). - Cache one namespace object per CommonJS module. - Resolve relative and absolute require.resolve ids locally, checking containment before probing and naming a missing module by its id. Refs #5471 --- .../external-import-rewriter.test.ts | 62 +++++-- .../module-loader/external-import-rewriter.ts | 171 +++++++++++------- src/routing/api/module-loader/loader.test.ts | 89 +++++++-- src/routing/api/module-loader/loader.ts | 44 +++-- src/transforms/import-rewriter/core.test.ts | 2 +- .../import-rewriter/route-adapter.ts | 168 ++++++++++------- 6 files changed, 355 insertions(+), 181 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index 5cdc1906d8..0193a6d3d5 100644 --- a/src/routing/api/module-loader/external-import-rewriter.test.ts +++ b/src/routing/api/module-loader/external-import-rewriter.test.ts @@ -9,6 +9,7 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import type { FileInfo } from "#veryfront/platform/adapters/base.ts"; import type { FileSystem } from "#veryfront/platform/compat/fs.ts"; import { + chooseRouteHelperPrefix, generateCompiledBinaryRequireShim, getNodeExternalPackagesToResolve, NODE_BUILTINS, @@ -113,27 +114,27 @@ describe("external-import-rewriter", () => { }); }); - describe("rewriteCompiledBinaryUserDependencyImports", () => { + describe("rewriteCompiledBinaryUserDependencyImports", async () => { const deps = new Map([["lodash", "^4"]]); - it("rewrites a default import to interop require", () => { - const out = rewriteCompiledBinaryUserDependencyImports( + it("rewrites a default import to interop require", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( `import _ from "lodash";`, deps, ); assertEquals(out, `const _ = __vf_interopDefault(require("lodash"));`); }); - it("rewrites a namespace import to a namespace over require", () => { - const out = rewriteCompiledBinaryUserDependencyImports( + it("rewrites a namespace import to a namespace over require", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( `import * as _ from "lodash";`, deps, ); assertEquals(out, `const _ = __vf_namespace(require("lodash"));`); }); - it("rewrites a named import to a destructuring require", () => { - const out = rewriteCompiledBinaryUserDependencyImports( + it("rewrites a named import to a destructuring require", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( `import { merge } from "lodash";`, deps, ); @@ -141,8 +142,8 @@ describe("external-import-rewriter", () => { assertStringIncludes(out, "merge"); }); - it("rewrites a dynamic import to a lazy namespace promise", () => { - const out = rewriteCompiledBinaryUserDependencyImports( + it("rewrites a dynamic import to a lazy namespace promise", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( `const m = import("lodash");`, deps, ); @@ -152,20 +153,20 @@ describe("external-import-rewriter", () => { ); }); - it("rewrites a subpath import including the subpath in require", () => { - const out = rewriteCompiledBinaryUserDependencyImports( + it("rewrites a subpath import including the subpath in require", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( `import merge from "lodash/merge";`, deps, ); assertStringIncludes(out, `require("lodash/merge")`); }); - it("leaves imports for packages not in userDeps untouched", () => { + it("leaves imports for packages not in userDeps untouched", async () => { const code = `import x from "react";`; - assertEquals(rewriteCompiledBinaryUserDependencyImports(code, deps), code); + assertEquals(await rewriteCompiledBinaryUserDependencyImports(code, deps), code); }); - it("rewrites ESM-only dependency imports to resolved file URLs", () => { + it("rewrites ESM-only dependency imports to resolved file URLs", async () => { const esmDeps = new Map([ ["esm-only", { entryUrl: "file:///srv/app/node_modules/esm-only/dist/index.mjs", @@ -173,7 +174,7 @@ describe("external-import-rewriter", () => { }], ]); - const out = rewriteCompiledBinaryUserDependencyImports( + const out = await rewriteCompiledBinaryUserDependencyImports( [ `import mod from "esm-only";`, `import util from "esm-only/utils.js";`, @@ -188,7 +189,7 @@ describe("external-import-rewriter", () => { assertStringIncludes(out, `import("file:///srv/app/node_modules/esm-only/lazy.js")`); }); - it("leaves ESM subpath imports untouched when they escape the package directory", () => { + it("leaves ESM subpath imports untouched when they escape the package directory", async () => { const esmDeps = new Map([ ["esm-only", { entryUrl: "file:///srv/app/node_modules/esm-only/dist/index.mjs", @@ -198,7 +199,7 @@ describe("external-import-rewriter", () => { const code = `import secret from "esm-only/../../secret.js";`; assertEquals( - rewriteCompiledBinaryUserDependencyImports( + await rewriteCompiledBinaryUserDependencyImports( code, new Map([["esm-only", "^1"]]), esmDeps, @@ -397,9 +398,34 @@ describe("external-import-rewriter", () => { // Defines a require function and an interop helper. assertStringIncludes(shim, "var require = (function () {"); assertStringIncludes(shim, "function __vf_interopDefault(m)"); - assertStringIncludes(shim, "function __vf_namespace(m)"); + assertStringIncludes(shim, "var __vf_namespace = (function () {"); assert(shim.length > 0); }); + + it("renames only the module-level helpers to the chosen prefix", () => { + const shim = generateCompiledBinaryRequireShim("/srv/app", "__vf2_"); + assertStringIncludes( + shim, + 'import { createRequire as __vf2_createRequire } from "node:module";', + ); + assertStringIncludes(shim, "resolve as __vf2_resolve"); + assertStringIncludes(shim, "function __vf2_interopDefault(m)"); + assertStringIncludes(shim, "var __vf2_namespace = (function () {"); + // Private-scope names keep their own spelling. + assertStringIncludes(shim, "function __vf_resolveBare(id, parentDir)"); + assertEquals( + /\b__vf_(createRequire|dirname|resolve|interopDefault|namespace)\b/.test(shim), + false, + ); + }); + }); + + describe("chooseRouteHelperPrefix", () => { + it("keeps the default prefix unless the bundle mentions a helper name", () => { + assertEquals(chooseRouteHelperPrefix("const __vf_routeSourceUrl = 1;"), "__vf_"); + assertEquals(chooseRouteHelperPrefix("const x = '__vf_namespace';"), "__vf1_"); + assertEquals(chooseRouteHelperPrefix("__vf_tmp_a; __vf1_resolve;"), "__vf2_"); + }); }); describe("Node Veryfront package imports", () => { diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 696e896e24..e9afd07271 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -7,6 +7,7 @@ import { rewriteNpmImports } from "#veryfront/transforms/npm-import-rewrites.ts" import { resolveContainedPackagePath } from "#veryfront/transforms/import-rewriter/package-resolution.ts"; import { parseImports, replaceSpecifiers } from "#veryfront/transforms/esm/lexer.ts"; import { + DEFAULT_ROUTE_HELPER_PREFIX, getNodeExternalPackagesToResolveForRoute, NODE_BUILTINS as ROUTE_NODE_BUILTINS, readProjectDependenciesForRoute, @@ -50,19 +51,41 @@ export async function readProjectDependencies( return await readProjectDependenciesForRoute(projectDir, fs); } +export { DEFAULT_ROUTE_HELPER_PREFIX }; + +/** Module-level names the prologue and compiled import rewrite declare. */ +const ROUTE_HELPER_SUFFIXES = [ + "createRequire", + "dirname", + "resolve", + "interopDefault", + "namespace", + "tmp_", +] as const; +const ROUTE_HELPER_NAME = /\b__vf_(createRequire|dirname|resolve|interopDefault|namespace)\b/g; + /** - * Top-level bindings the route `require` prologues declare. The prologue is - * prepended after esbuild has resolved collisions, so bundled route code must - * not declare any of these; `assertNoRouteRequirePrologueCollision` fails the - * build instead of emitting a module that cannot parse. + * Choose the prefix for the route prologue's module-level helpers. + * + * The prologue is prepended after esbuild has resolved collisions, so its + * names must not appear in the bundled route at all. The first prefix none of + * whose helper names occurs anywhere in the bundle is used, which keeps the + * default whenever the route does not mention it. */ -export const ROUTE_REQUIRE_PROLOGUE_BINDINGS: readonly string[] = [ - "__vf_createRequire", - "__vf_dirname", - "__vf_resolve", - "__vf_interopDefault", - "__vf_namespace", -]; +export function chooseRouteHelperPrefix(bundledCode: string): string { + for (let index = 0;; index++) { + const prefix = index === 0 ? DEFAULT_ROUTE_HELPER_PREFIX : `__vf${index}_`; + if (!ROUTE_HELPER_SUFFIXES.some((suffix) => bundledCode.includes(prefix + suffix))) { + return prefix; + } + } +} + +/** Rename the prologue's module-level helpers from the default prefix. */ +export function withRouteHelperPrefix(code: string, helperPrefix: string): string { + if (helperPrefix === DEFAULT_ROUTE_HELPER_PREFIX) return code; + return code.replace(ROUTE_HELPER_NAME, (_, suffix: string) => helperPrefix + suffix); +} /** * Generates a CJS module loader shim for compiled Deno binaries. @@ -75,15 +98,19 @@ export const ROUTE_REQUIRE_PROLOGUE_BINDINGS: readonly string[] = [ * exports, module, __filename, and __dirname bindings. * * The loader state lives in a private scope. Only `require`, the two helpers - * the compiled import rewrite calls and the `node:` imports are module-level - * (see ROUTE_REQUIRE_PROLOGUE_BINDINGS). + * the compiled import rewrite calls and the `node:` imports are module-level; + * they carry `helperPrefix` (see chooseRouteHelperPrefix). */ -export function generateCompiledBinaryRequireShim(projectDir: string): string { +export function generateCompiledBinaryRequireShim( + projectDir: string, + helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, +): string { const builtinSet = JSON.stringify(NODE_BUILTINS); const safeProjectDir = JSON.stringify(projectDir); const safeProjectRoot = JSON.stringify(pathHelper.resolve(projectDir)); - return ` + return withRouteHelperPrefix( + ` import { createRequire as __vf_createRequire } from "node:module"; import { dirname as __vf_dirname, resolve as __vf_resolve } from "node:path"; var require = (function () { @@ -156,6 +183,27 @@ function __vf_resolveBare(id, parentDir) { throw sanitized; } } +// Relative and absolute ids resolve against the requiring module's directory, +// probing the extensions Node probes. A missing module is reported by the id +// the code asked for, not the host path it was looked up at. +function __vf_isFile(candidate) { + try { return Deno.statSync(candidate).isFile; } catch (_) { return false; } +} +function __vf_resolveLocal(id, parentDir) { + var base = __vf_resolve(parentDir, id); + // Refuse paths outside the project before probing them, so a missing-module + // error never tells route code whether a host file exists. + __vf_assertContained(__vf_canonicalize(base)); + var candidates = [base, base + ".js", base + ".cjs", base + ".json", base + "/index.js", base + "/index.cjs", base + "/index.json"]; + for (var i = 0; i < candidates.length; i++) { + if (!__vf_isFile(candidates[i])) continue; + __vf_assertContained(__vf_canonicalize(candidates[i])); + return candidates[i]; + } + var missing = new Error("Cannot find module " + JSON.stringify(id)); + missing.code = "MODULE_NOT_FOUND"; + throw missing; +} function __vf_loadCjs(id, parentDir) { if (id.startsWith("node:")) return __vf_builtinRequire(id); if (__vf_builtinSet.has(id)) return __vf_builtinRequire(id); @@ -163,13 +211,7 @@ function __vf_loadCjs(id, parentDir) { if (slashIdx > 0 && __vf_builtinSet.has(id.slice(0, slashIdx))) return __vf_builtinRequire(id); var resolved; if (id.startsWith(".") || id.startsWith("/")) { - resolved = __vf_resolve(parentDir, id); - if (!resolved.match(/\\.[a-zA-Z0-9]+$/)) { - var exts = [".js", ".cjs", ".json", "/index.js", "/index.cjs", "/index.json"]; - for (var i = 0; i < exts.length; i++) { - try { Deno.statSync(resolved + exts[i]); resolved += exts[i]; break; } catch (_) { /* expected: probing file extensions */ } - } - } + resolved = __vf_resolveLocal(id, parentDir); } else { resolved = __vf_resolveBare(id, parentDir); } @@ -199,63 +241,47 @@ function __vf_loadCjs(id, parentDir) { __vf_cache[resolved] = mod.exports; var dir = __vf_dirname(resolved); var childReq = function(childId) { return __vf_loadCjs(childId, dir); }; - childReq.resolve = function(childId) { - if (childId.startsWith(".") || childId.startsWith("/")) return __vf_resolve(dir, childId); - return __vf_resolveBare(childId, dir); - }; + childReq.resolve = function(childId) { return __vf_resolveId(childId, dir); }; childReq.ensure = function(mods, cb) { cb(); }; var fn = new Function("exports", "require", "module", "__filename", "__dirname", "global", "globalThis", "Worker", code); fn(mod.exports, childReq, mod, resolved, dir, globalThis, globalThis, undefined); __vf_cache[resolved] = mod.exports; return mod.exports; } +function __vf_resolveId(id, parentDir) { + if (id.startsWith(".") || id.startsWith("/")) return __vf_resolveLocal(id, parentDir); + return __vf_resolveBare(id, parentDir); +} var routeRequire = function(id) { return __vf_loadCjs(id, ${safeProjectDir}); }; -routeRequire.resolve = function(id) { return __vf_resolveBare(id, ${safeProjectDir}); }; +routeRequire.resolve = function(id) { return __vf_resolveId(id, ${safeProjectDir}); }; routeRequire.ensure = function(mods, cb) { cb(); }; return routeRequire; })(); function __vf_interopDefault(m) { return m && m.__esModule && m.default !== undefined ? m.default : m; } // As with native CommonJS interop, \`default\` is the whole module.exports, also // for modules marked __esModule. -function __vf_namespace(m) { - var ns = Object.create(null); - if (m !== null && (typeof m === "object" || typeof m === "function")) { - for (var key in m) if (key !== "default") ns[key] = m[key]; - } - ns.default = m; - return ns; -} -`.trim(); -} - -/** - * Fail the build when bundled route code declares a name the prepended - * `require` prologue also declares at module scope. Only declarations count: - * the name inside a string or as a property is not a binding and cannot - * collide. - */ -export function assertNoRouteRequirePrologueCollision(bundledCode: string): void { - for (const name of ROUTE_REQUIRE_PROLOGUE_BINDINGS) { - if (declaresBinding(bundledCode, name)) { - throw new Error( - `${name} is reserved for the Veryfront route module loader; rename it in the route or its imports`, - ); +// One namespace per module, so repeated imports of a package see the same +// object, as with ES module namespaces. +var __vf_namespace = (function () { + var objects = new WeakMap(); + var primitives = new Map(); + return function (m) { + var isObject = m !== null && (typeof m === "object" || typeof m === "function"); + var cache = isObject ? objects : primitives; + var cached = cache.get(m); + if (cached) return cached; + var ns = Object.create(null); + if (isObject) { + for (var key in m) if (key !== "default") ns[key] = m[key]; } - } -} - -function declaresBinding(code: string, name: string): boolean { - const id = name.replace(/\$/g, "\\$"); - const notIdent = "(?![\\w$])"; - const patterns = [ - // var/let/const/function/class declarations. - `(?:^|[^\\w$.])(?:var|let|const|function\\s*\\*?|class)\\s+${id}${notIdent}`, - // Destructuring declarations: const { a: name } = ..., const [name] = ... - `(?:^|[^\\w$.])(?:var|let|const)\\s*[{[][^=;]*?(?:^|[^\\w$.])${id}${notIdent}[^=;]*?[}\\]]\\s*=`, - // Import bindings: import name, import * as name, import { x as name }. - `(?:^|[^\\w$.])import\\s+[^'";]*?(?:^|[^\\w$.:])${id}${notIdent}[^'";]*?\\bfrom\\s*["']`, - ]; - return patterns.some((pattern) => new RegExp(pattern, "m").test(code)); + ns.default = m; + cache.set(m, ns); + return ns; + }; +})(); +`.trim(), + helperPrefix, + ); } export function getNodeExternalPackagesToResolve(userDeps: Map): string[] { @@ -436,12 +462,13 @@ export function rewriteCompiledBinaryVeryfrontImports(code: string): string { return rewriteCompiledVeryfrontImportsForRoute(code); } -export function rewriteCompiledBinaryUserDependencyImports( +export async function rewriteCompiledBinaryUserDependencyImports( code: string, userDeps: Map, esmDeps: Map = new Map(), -): string { - return rewriteCompiledUserDependencyImportsForRoute(code, userDeps, esmDeps); + helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, +): Promise { + return await rewriteCompiledUserDependencyImportsForRoute(code, userDeps, esmDeps, helperPrefix); } export async function rewriteDenoNpmDependencyImports( @@ -547,6 +574,7 @@ export async function rewriteExternalImports( projectDir: string, fs: SourceReader, userDeps: Map = new Map(), + helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, ): Promise { let transformed = code; @@ -575,7 +603,12 @@ export async function rewriteExternalImports( // compile time. if (isCompiledBinary()) { const esmDeps = await resolveEsmUserDependencies(projectDir, fs, userDeps); - transformed = rewriteCompiledBinaryUserDependencyImports(transformed, userDeps, esmDeps); + transformed = await rewriteCompiledBinaryUserDependencyImports( + transformed, + userDeps, + esmDeps, + helperPrefix, + ); } else { transformed = await rewriteDenoNpmDependencyImports(transformed, projectDir, fs, userDeps); } diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 142b7fccc9..2aa3254d41 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -15,6 +15,7 @@ import { bundledModuleScopeDiscriminator, bundlerForcesTypeScript, canDirectImportSpecifier, + chooseRouteHelperPrefix, generateCompiledBinaryRequireShim, generateRouteRuntimeShim, getNodeExternalPackagesToResolve, @@ -1825,7 +1826,7 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, assertMatch(rewritten, /import\("\.\/_vf_tool\.mjs"\)/); }); - it("rewrites compiled-binary user dependency imports to require-based shims", () => { + it("rewrites compiled-binary user dependency imports to require-based shims", async () => { const source = [ 'import thing from "my-lib";', 'import { alpha as beta } from "my-lib";', @@ -1835,7 +1836,7 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, 'const loaded = import("my-lib/subpath");', ].join("\n"); - const rewritten = rewriteCompiledBinaryUserDependencyImports( + const rewritten = await rewriteCompiledBinaryUserDependencyImports( source, new Map([["my-lib", "^1.0.0"]]), ); @@ -1854,7 +1855,7 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, ); }); - it("rewrites ESM-only user dependency imports to real file:// module URLs", () => { + it("rewrites ESM-only user dependency imports to real file:// module URLs", async () => { const source = [ 'import thing from "esm-lib";', 'import { alpha } from "esm-lib";', @@ -1864,7 +1865,7 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, 'const sub = import("esm-lib/subpath");', ].join("\n"); - const rewritten = rewriteCompiledBinaryUserDependencyImports( + const rewritten = await rewriteCompiledBinaryUserDependencyImports( source, new Map([["esm-lib", "^1.0.0"]]), new Map([[ @@ -1900,13 +1901,13 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, assertEquals(rewritten.includes('require("esm-lib")'), false); }); - it("keeps CJS deps on the require shim while ESM deps use file:// URLs", () => { + it("keeps CJS deps on the require shim while ESM deps use file:// URLs", async () => { const source = [ 'import esm from "esm-lib";', 'import cjs from "cjs-lib";', ].join("\n"); - const rewritten = rewriteCompiledBinaryUserDependencyImports( + const rewritten = await rewriteCompiledBinaryUserDependencyImports( source, new Map([["esm-lib", "^1.0.0"], ["cjs-lib", "^1.0.0"]]), new Map([[ @@ -1985,14 +1986,14 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, assertEquals(esmDeps.has("evil-lib"), false); }); - it("leaves ESM subpath imports that escape the package dir unrewritten", () => { + it("leaves ESM subpath imports that escape the package dir unrewritten", async () => { const source = [ 'import ok from "esm-lib/dist/ok.mjs";', 'import escape from "esm-lib/../../../../etc/passwd";', 'const dyn = import("esm-lib/../../secret");', ].join("\n"); - const rewritten = rewriteCompiledBinaryUserDependencyImports( + const rewritten = await rewriteCompiledBinaryUserDependencyImports( source, new Map([["esm-lib", "^1.0.0"]]), new Map([[ @@ -5750,11 +5751,17 @@ describe("route CommonJS require (#5471)", () => { }], }); const bundled = result.outputFiles?.[0]?.text ?? ""; - const rewritten = rewriteCompiledBinaryUserDependencyImports(bundled, userDeps); + const helperPrefix = chooseRouteHelperPrefix(bundled); + const rewritten = await rewriteCompiledBinaryUserDependencyImports( + bundled, + userDeps, + new Map(), + helperPrefix, + ); const handlerPath = join(projectDir, "handler.mjs"); await fs.writeTextFile( handlerPath, - prependRouteRequirePrologue(bundled, rewritten, projectDir, true), + prependRouteRequirePrologue(rewritten, projectDir, true, helperPrefix), ); const module = await import(toFileUrl(handlerPath).href); return module.GET; @@ -5851,15 +5858,46 @@ describe("route CommonJS require (#5471)", () => { assertEquals(await responseText(GET), "ok"); }); - denoIt("rejects route code that declares a prologue binding", async () => { - await assertRejects( - () => - loadCompiledRoute( - "const __vf_namespace = 1;\nexport const GET = () => new Response(String(__vf_namespace));", - ), - Error, - "__vf_namespace is reserved for the Veryfront route module loader", + denoIt("runs route code that declares the default helper names", async () => { + const GET = await loadCompiledRoute( + [ + 'import pg, { version } from "cjs-fixture";', + "const __vf_namespace = 'ns';", + "const __vf_interopDefault = 'interop';", + "const __vf_tmp_pg = 'tmp';", + "export const GET = () => new Response(", + " [__vf_namespace, __vf_interopDefault, __vf_tmp_pg, new pg.Pool().query(), version].join(','),", + ");", + ].join("\n"), + ); + assertEquals(await responseText(GET), "ns,interop,tmp,ok,1.0.0"); + }); + + denoIt("leaves import-like text in strings and template literals alone", async () => { + const GET = await loadCompiledRoute( + [ + 'import pg from "cjs-fixture";', + "const text = `", + 'import other from "cjs-fixture";', + "`;", + "export const GET = () => new Response(text.trim() + ' ' + new pg.Pool().query());", + ].join("\n"), + ); + assertEquals(await responseText(GET), 'import other from "cjs-fixture"; ok'); + }); + + denoIt("returns one namespace object per CommonJS module", async () => { + const GET = await loadCompiledRoute( + [ + 'import * as staticNs from "cjs-fixture";', + "export const GET = async () => {", + ' const first = await import("cjs-fixture");', + ' const second = await import("cjs-fixture");', + " return new Response(String(first === second && first === staticNs));", + "};", + ].join("\n"), ); + assertEquals(await responseText(GET), "true"); }); denoIt("allows the reserved names in strings and property access", async () => { @@ -5993,6 +6031,21 @@ describe("route CommonJS require (#5471)", () => { ); }); + denoIt("resolves relative ids locally and names a missing one by its id", async () => { + const projectDir = await writeProject({ "lib/helper.js": "module.exports = 1;" }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir) as unknown as { + resolve(id: string): string; + }; + + assertEquals( + Deno.realPathSync(vfRequire.resolve("./lib/helper")), + Deno.realPathSync(join(projectDir, "lib", "helper.js")), + ); + const error = assertThrows(() => vfRequire.resolve("./missing")) as Error & { code?: string }; + assertEquals(error.message, 'Cannot find module "./missing"'); + assertEquals(error.code, "MODULE_NOT_FOUND"); + }); + denoIt("rejects a native addon with a clear error", async () => { const projectDir = await writeProject({ "node_modules/native-fixture/package.json": JSON.stringify({ diff --git a/src/routing/api/module-loader/loader.ts b/src/routing/api/module-loader/loader.ts index 543a58dd09..14dc53e6b8 100644 --- a/src/routing/api/module-loader/loader.ts +++ b/src/routing/api/module-loader/loader.ts @@ -43,7 +43,8 @@ import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { wrapWithCurrentContext } from "#veryfront/platform/adapters/fs/veryfront/multi-project-adapter.ts"; import { isWithinDirectory } from "#veryfront/security/path-validation.ts"; import { - assertNoRouteRequirePrologueCollision, + chooseRouteHelperPrefix, + DEFAULT_ROUTE_HELPER_PREFIX, generateCompiledBinaryRequireShim, NODE_BUILTINS, readProjectDependencies, @@ -67,6 +68,7 @@ import { tryGetRegistryScopeId } from "#veryfront/cache/cache-key-builder.ts"; import { getProjectEnvSnapshot } from "#veryfront/server/project-env/storage.ts"; import { currentRequestContext } from "#veryfront/platform/request-context-access.ts"; export { + chooseRouteHelperPrefix, generateCompiledBinaryRequireShim, getNodeExternalPackagesToResolve, loadVeryfrontExportsMap, @@ -1567,29 +1569,33 @@ export function generateRouteRuntimeShim(routeSourceUrl: string): string { * cannot load CJS files from disk; other runtimes resolve `require` from the * project's node_modules with `createRequire`. */ -export function generateRouteRequirePrologue(projectDir: string, compiledBinary: boolean): string { - if (compiledBinary) return generateCompiledBinaryRequireShim(projectDir); +export function generateRouteRequirePrologue( + projectDir: string, + compiledBinary: boolean, + helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, +): string { + if (compiledBinary) return generateCompiledBinaryRequireShim(projectDir, helperPrefix); return [ - 'import { createRequire as __vf_createRequire } from "node:module";', - `var require = __vf_createRequire(${JSON.stringify(projectDir + "/package.json")});`, + `import { createRequire as ${helperPrefix}createRequire } from "node:module";`, + `var require = ${helperPrefix}createRequire(${JSON.stringify(projectDir + "/package.json")});`, ].join("\n"); } /** * Prepend the route `require` prologue to a bundled, rewritten handler. * - * `bundledCode` is esbuild's output before the post-bundle rewrites; it is - * checked for names the prologue declares, which esbuild could not rename. A - * hashbang esbuild kept stays the first line, the only place it is valid. + * `helperPrefix` is the prefix chosen for the bundle with + * chooseRouteHelperPrefix and also passed to the import rewrite, so the + * prologue's names never collide with route code. A hashbang esbuild kept + * stays the first line, the only place it is valid. */ export function prependRouteRequirePrologue( - bundledCode: string, rewrittenCode: string, projectDir: string, compiledBinary: boolean, + helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, ): string { - assertNoRouteRequirePrologueCollision(bundledCode); - const prologue = generateRouteRequirePrologue(projectDir, compiledBinary); + const prologue = generateRouteRequirePrologue(projectDir, compiledBinary, helperPrefix); if (!rewrittenCode.startsWith("#!")) return `${prologue}\n${rewrittenCode}`; const eol = rewrittenCode.indexOf("\n"); if (eol === -1) return `${rewrittenCode}\n${prologue}\n`; @@ -2245,8 +2251,20 @@ function buildTranspiledModuleSource( const js = result.outputFiles?.[0]?.text ?? "export {}"; logger.debug(`transpiled size ${js.length} bytes`); - const rewritten = await rewriteExternalImports(js, projectDir, sourceSnapshot, userDeps); - return prependRouteRequirePrologue(js, rewritten, projectDir, isDeno && isCompiledBinary()); + const helperPrefix = chooseRouteHelperPrefix(js); + const rewritten = await rewriteExternalImports( + js, + projectDir, + sourceSnapshot, + userDeps, + helperPrefix, + ); + return prependRouteRequirePrologue( + rewritten, + projectDir, + isDeno && isCompiledBinary(), + helperPrefix, + ); }, { "api.modulePath": modulePath, "api.projectDir": projectDir }, ); diff --git a/src/transforms/import-rewriter/core.test.ts b/src/transforms/import-rewriter/core.test.ts index fe42b06ae8..cd392bf4fe 100644 --- a/src/transforms/import-rewriter/core.test.ts +++ b/src/transforms/import-rewriter/core.test.ts @@ -144,7 +144,7 @@ describe("import rewrite compatibility golden tests", () => { `import { x } from "./_vf_agent.mjs";`, ); assertStringIncludes( - rewriteCompiledBinaryUserDependencyImports( + await rewriteCompiledBinaryUserDependencyImports( `const m = import("lodash/merge");`, new Map([["lodash", "^4"]]), ), diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index b6c60e4d57..542f8a96a5 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -1,7 +1,11 @@ import type { FileSystem } from "#veryfront/platform/compat/fs.ts"; import * as pathHelper from "#veryfront/compat/path"; import { serverLogger } from "#veryfront/utils"; -import { parseImports, replaceSpecifiers } from "#veryfront/transforms/esm/lexer.ts"; +import { + parseImports, + parseMaskedImports, + replaceSpecifiers, +} from "#veryfront/transforms/esm/lexer.ts"; import { resolveContainedPackagePath } from "./package-resolution.ts"; import { isWithinDirectory } from "#veryfront/security/path-validation.ts"; import { @@ -200,100 +204,140 @@ const IDENT = "[\\w$]+"; const IMPORT_CLAUSE = new RegExp( `^(?:(${IDENT})\\s*(?:,\\s*)?)?(?:\\*\\s*as\\s+(${IDENT})|(\\{[^}]*\\}))?$`, ); +const STATIC_FROM_IMPORT = /^import\s*([\s\S]*?)\s*from\s*["'][^"']*["']\s*$/; +const SIDE_EFFECT_IMPORT = /^import\s*["'][^"']*["']\s*$/; +const DYNAMIC_IMPORT_CLOSE = /^\s*\)/; + +/** Prefix of the helpers the compiled route rewrite calls; see the prologue. */ +export const DEFAULT_ROUTE_HELPER_PREFIX = "__vf_"; /** * Rewrite one static import clause to CommonJS bindings over `required`, or * return null when the clause is not a form this rewrite understands. */ -function rewriteCjsImportClause(clause: string, required: string): string | null { +function rewriteCjsImportClause( + clause: string, + required: string, + helperPrefix: string, +): string | null { const parsed = clause.trim().match(IMPORT_CLAUSE); if (!parsed) return null; const [, defaultName, namespaceName, named] = parsed; if (!defaultName && !namespaceName && !named) return null; const bindings = [defaultName, namespaceName, named].filter(Boolean).length; - const source = bindings > 1 ? `__vf_tmp_${defaultName ?? namespaceName}` : required; + const source = bindings > 1 ? `${helperPrefix}tmp_${defaultName ?? namespaceName}` : required; const statements = bindings > 1 ? [`const ${source} = ${required}`] : []; - if (defaultName) statements.push(`const ${defaultName} = __vf_interopDefault(${source})`); - if (namespaceName) statements.push(`const ${namespaceName} = __vf_namespace(${source})`); + if (defaultName) { + statements.push(`const ${defaultName} = ${helperPrefix}interopDefault(${source})`); + } + if (namespaceName) { + statements.push(`const ${namespaceName} = ${helperPrefix}namespace(${source})`); + } if (named) statements.push(`const ${toCjsDestructureBindings(named)} = ${source}`); return statements.join("; "); } -export function rewriteCompiledUserDependencyImportsForRoute( +/** The user dependency a specifier names (`pkg` or `pkg/sub`), longest first. */ +function matchUserDependency( + specifier: string, + userDeps: Map, +): { name: string; subpath: string } | null { + let best: string | null = null; + for (const name of userDeps.keys()) { + if (specifier !== name && !specifier.startsWith(`${name}/`)) continue; + if (best === null || name.length > best.length) best = name; + } + return best === null ? null : { name: best, subpath: specifier.slice(best.length) }; +} + +/** + * Rewrite a bundled compiled-binary route's user dependency imports. + * + * Import statements come from the module lexer, so text that only looks like + * an import (strings, template literals, comments) is never touched. CommonJS + * dependencies become `require(...)` bindings over the helpers the route + * prologue declares under `helperPrefix`; ESM-only dependencies keep their + * import and point at a contained file:// URL. + */ +export async function rewriteCompiledUserDependencyImportsForRoute( code: string, userDeps: Map, esmDeps: Map = new Map(), -): string { - let transformed = code; + helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, +): Promise { + if (userDeps.size === 0) return code; + const { masked, imports, unmask } = await parseMaskedImports(code); + const edits: { start: number; end: number; text: string }[] = []; - for (const name of userDeps.keys()) { - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + for (const imported of imports) { + if (typeof imported.n !== "string") continue; + const dependency = matchUserDependency(imported.n, userDeps); + if (!dependency) continue; + const { name, subpath } = dependency; + const isDynamic = imported.d > -1; // ESM-only dependencies are rewritten to real ES module file:// URLs so that // import.meta, top-level await, etc. work. They must NOT be transpiled to // CommonJS and evaluated via the `new Function` shim (see __vf_loadCjs). const esm = esmDeps.get(name); if (esm) { - // Resolve a subpath import to a contained file:// URL. The subpath comes - // from the handler source; reject any that escape the package directory - // (e.g. "pkg/../../secret") by leaving the import untouched so it fails to - // resolve rather than reading outside node_modules. - const subpathUrl = (subpath: string, original: string): string | null => { + let url = esm.entryUrl; + if (subpath) { + // Reject a subpath that escapes the package directory (e.g. + // "pkg/../../secret") by leaving the import untouched so it fails to + // resolve rather than reading outside node_modules. const target = resolveContainedPackagePath(esm.packageDir, "." + subpath); if (!target) { - logger.warn(`Skipping ESM subpath import that escapes package directory: ${original}`); - return null; + logger.warn(`Skipping ESM subpath import that escapes package directory: ${imported.n}`); + continue; } - return pathHelper.toFileUrl(target).href; - }; - - transformed = transformed.replace( - new RegExp(`from\\s+["']${escaped}(/[^"']+)["']`, "g"), - (match, subpath) => { - const url = subpathUrl(subpath, match); - return url ? `from "${url}"` : match; - }, - ); - transformed = transformed.replace( - new RegExp(`from\\s+["']${escaped}["']`, "g"), - () => `from "${esm.entryUrl}"`, - ); - transformed = transformed.replace( - new RegExp(`import\\s*\\(\\s*["']${escaped}(/[^"']+)["']\\s*\\)`, "g"), - (match, subpath) => { - const url = subpathUrl(subpath, match); - return url ? `import("${url}")` : match; - }, - ); - transformed = transformed.replace( - new RegExp(`import\\s*\\(\\s*["']${escaped}["']\\s*\\)`, "g"), - () => `import("${esm.entryUrl}")`, - ); + url = pathHelper.toFileUrl(target).href; + } + // Static specifier spans exclude the quotes; dynamic ones include them. + edits.push({ + start: imported.s, + end: imported.e, + text: isDynamic ? JSON.stringify(url) : url, + }); continue; } - // Every static import clause form (default, named, namespace and their - // combinations) for the package root or a subpath. Namespace imports get - // an ES module namespace whose `default` is the CommonJS `module.exports`. - transformed = transformed.replace( - new RegExp(`import\\s+([^'";]+?)\\s+from\\s+["']${escaped}(/[^"']+)?["']`, "g"), - (match, clause: string, subpath: string | undefined) => - rewriteCjsImportClause(clause, `require("${name}${subpath ?? ""}")`) ?? match, - ); - transformed = transformed.replace( - new RegExp(`import\\s*["']${escaped}(/[^"']+)?["']`, "g"), - (_, subpath: string | undefined) => `require("${name}${subpath ?? ""}")`, - ); - // A dynamic import stays lazy and asynchronous: a load failure rejects the - // returned promise instead of throwing synchronously. - transformed = transformed.replace( - new RegExp(`import\\s*\\(\\s*["']${escaped}(/[^"']*)?["']\\s*\\)`, "g"), - (_, subpath) => - `Promise.resolve().then(() => __vf_namespace(require("${name}${subpath || ""}")))`, - ); + const required = `require(${JSON.stringify(name + subpath)})`; + if (isDynamic) { + // A dynamic import stays lazy and asynchronous: a load failure rejects + // the returned promise instead of throwing synchronously. Imports with + // options (`import(x, {...})`) are left unchanged. + const close = masked.slice(imported.e).match(DYNAMIC_IMPORT_CLOSE); + if (!close) continue; + edits.push({ + start: imported.ss, + end: imported.e + close[0].length, + text: `Promise.resolve().then(() => ${helperPrefix}namespace(${required}))`, + }); + continue; + } + + const statement = masked.slice(imported.ss, imported.se); + if (SIDE_EFFECT_IMPORT.test(statement)) { + edits.push({ start: imported.ss, end: imported.se, text: required }); + continue; + } + const clause = statement.match(STATIC_FROM_IMPORT)?.[1]; + const rewritten = clause === undefined + ? null + : rewriteCjsImportClause(clause, required, helperPrefix); + if (rewritten !== null) { + edits.push({ start: imported.ss, end: imported.se, text: rewritten }); + } } - return transformed; + if (edits.length === 0) return code; + edits.sort((left, right) => right.start - left.start); + let transformed = masked; + for (const edit of edits) { + transformed = transformed.slice(0, edit.start) + edit.text + transformed.slice(edit.end); + } + return unmask(transformed); } export async function rewriteDenoNpmDependencyImportsForRoute( From 3383a23a38c8a30ddec349136f85bf0519433872 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 5 Nov 2026 22:49:30 +0100 Subject: [PATCH 07/22] fix(api-routes): match Node CommonJS loading more closely in the compiled loader - Resolve local directories through package.json main before index. - Drop a module whose evaluation threw from the cache, and run CommonJS code with module.exports as top-level this. - Treat every runtime built-in as a built-in. - Name the requested id, not the host path, when blocking a path. - Key namespaces by resolved module, not by exported value. - Rename helpers before embedding project paths in the shim. - Escape generated string literals and support string-literal named imports. Refs #5471 --- .../external-import-rewriter.test.ts | 24 ++- .../module-loader/external-import-rewriter.ts | 147 ++++++++++++------ .../api/module-loader/loader-helpers.test.ts | 4 + .../api/module-loader/loader-helpers.ts | 33 ++-- src/routing/api/module-loader/loader.test.ts | 110 ++++++++++++- src/transforms/import-rewriter/core.test.ts | 2 +- .../import-rewriter/route-adapter.ts | 37 ++++- 7 files changed, 281 insertions(+), 76 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index 0193a6d3d5..3beb013b4b 100644 --- a/src/routing/api/module-loader/external-import-rewriter.test.ts +++ b/src/routing/api/module-loader/external-import-rewriter.test.ts @@ -130,7 +130,7 @@ describe("external-import-rewriter", () => { `import * as _ from "lodash";`, deps, ); - assertEquals(out, `const _ = __vf_namespace(require("lodash"));`); + assertEquals(out, `const _ = __vf_namespace("lodash");`); }); it("rewrites a named import to a destructuring require", async () => { @@ -149,7 +149,7 @@ describe("external-import-rewriter", () => { ); assertStringIncludes( out, - `Promise.resolve().then(() => __vf_namespace(require("lodash")))`, + `Promise.resolve().then(() => __vf_namespace("lodash"))`, ); }); @@ -398,7 +398,7 @@ describe("external-import-rewriter", () => { // Defines a require function and an interop helper. assertStringIncludes(shim, "var require = (function () {"); assertStringIncludes(shim, "function __vf_interopDefault(m)"); - assertStringIncludes(shim, "var __vf_namespace = (function () {"); + assertStringIncludes(shim, "__vf_namespace = function (id) {"); assert(shim.length > 0); }); @@ -410,7 +410,7 @@ describe("external-import-rewriter", () => { ); assertStringIncludes(shim, "resolve as __vf2_resolve"); assertStringIncludes(shim, "function __vf2_interopDefault(m)"); - assertStringIncludes(shim, "var __vf2_namespace = (function () {"); + assertStringIncludes(shim, "__vf2_namespace = function (id) {"); // Private-scope names keep their own spelling. assertStringIncludes(shim, "function __vf_resolveBare(id, parentDir)"); assertEquals( @@ -420,6 +420,22 @@ describe("external-import-rewriter", () => { }); }); + describe("generateCompiledBinaryRequireShim literals", () => { + it("embeds a project path containing a helper name unchanged", () => { + const shim = generateCompiledBinaryRequireShim("/srv/__vf_resolve/app", "__vf1_"); + assertStringIncludes(shim, '__vf1_createRequire("/srv/__vf_resolve/app" + "/package.json")'); + assertStringIncludes(shim, 'var __vf_projectRoot = "/srv/__vf_resolve/app";'); + }); + + it("escapes characters unsafe in generated code", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + `import x from "lodash/a";`, + new Map([["lodash", "^4"]]), + ); + assertEquals(out, `const x = __vf_interopDefault(require("lodash/a\\u003Cb\\u003E"));`); + }); + }); + describe("chooseRouteHelperPrefix", () => { it("keeps the default prefix unless the bundle mentions a helper name", () => { assertEquals(chooseRouteHelperPrefix("const __vf_routeSourceUrl = 1;"), "__vf_"); diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index e9afd07271..2afe0c5798 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -105,18 +105,35 @@ export function generateCompiledBinaryRequireShim( projectDir: string, helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, ): string { - const builtinSet = JSON.stringify(NODE_BUILTINS); - const safeProjectDir = JSON.stringify(projectDir); - const safeProjectRoot = JSON.stringify(pathHelper.resolve(projectDir)); + // Helper names are renamed before the literals go in, so a project path that + // happens to contain a helper name is embedded unchanged. + const literals: Record = { + __VF_BUILTIN_LIST__: JSON.stringify(NODE_BUILTINS), + __VF_PROJECT_DIR__: JSON.stringify(projectDir), + __VF_PROJECT_ROOT__: JSON.stringify(pathHelper.resolve(projectDir)), + }; + let shim = withRouteHelperPrefix(COMPILED_BINARY_REQUIRE_SHIM_TEMPLATE, helperPrefix); + for (const [placeholder, literal] of Object.entries(literals)) { + shim = shim.split(placeholder).join(literal); + } + return shim; +} - return withRouteHelperPrefix( - ` +const COMPILED_BINARY_REQUIRE_SHIM_TEMPLATE = ` import { createRequire as __vf_createRequire } from "node:module"; import { dirname as __vf_dirname, resolve as __vf_resolve } from "node:path"; +var __vf_namespace; var require = (function () { -var __vf_builtinRequire = __vf_createRequire(${safeProjectDir} + "/package.json"); -var __vf_builtinSet = new Set(${builtinSet}); -var __vf_projectRoot = ${safeProjectRoot}; +var __vf_builtinRequire = __vf_createRequire(__VF_PROJECT_DIR__ + "/package.json"); +var __vf_builtinSet = new Set(__VF_BUILTIN_LIST__); +// Every built-in this runtime provides, not only the shared list. +try { + var __vf_runtimeBuiltins = __vf_builtinRequire("node:module").builtinModules; + for (var __vf_b = 0; __vf_b < __vf_runtimeBuiltins.length; __vf_b++) { + __vf_builtinSet.add(__vf_runtimeBuiltins[__vf_b].replace(/^node:/, "")); + } +} catch (_) { /* expected: fall back to the shared list */ } +var __vf_projectRoot = __VF_PROJECT_ROOT__; // VULN-FS-5: Canonicalize the project root so containment checks using // Deno.realPathSync(resolved) compare canonical-vs-canonical. Without this, // when the project itself is opened via a symlink, the realpath'd resolved @@ -139,11 +156,12 @@ function __vf_canonicalize(candidate) { } catch (_) { /* expected: parent may not exist either */ } return __vf_resolve(candidate); } -function __vf_assertContained(resolved) { +// The error names the id the code asked for, never the resolved host path. +function __vf_assertContained(resolved, id) { var norm = __vf_resolve(resolved).replace(/\\\\/g, "/"); var root = __vf_projectRoot.replace(/\\\\/g, "/"); if (!norm.startsWith(root + "/") && norm !== root) { - throw new Error("CJS loader blocked path outside project: " + resolved); + throw new Error("CJS loader blocked path outside project: " + JSON.stringify(id)); } } // Bare ids resolve from the requiring module's directory, as Node does, so a @@ -189,26 +207,53 @@ function __vf_resolveBare(id, parentDir) { function __vf_isFile(candidate) { try { return Deno.statSync(candidate).isFile; } catch (_) { return false; } } +function __vf_probeFile(path) { + var candidates = [path, path + ".js", path + ".cjs", path + ".json"]; + for (var i = 0; i < candidates.length; i++) { + if (__vf_isFile(candidates[i])) return candidates[i]; + } + return null; +} +function __vf_probeIndex(dir) { + return __vf_probeFile(__vf_resolve(dir, "index")); +} +// A directory resolves through its package.json "main", then its index file. +function __vf_probeDirectory(dir, id) { + var manifest = __vf_resolve(dir, "package.json"); + if (__vf_isFile(manifest)) { + var main; + try { main = JSON.parse(Deno.readTextFileSync(manifest)).main; } catch (_) { /* expected: unreadable manifest falls back to index */ } + if (typeof main === "string" && main) { + var entry = __vf_resolve(dir, main); + __vf_assertContained(__vf_canonicalize(entry), id); + var found = __vf_probeFile(entry) || __vf_probeIndex(entry); + if (found) return found; + } + } + return __vf_probeIndex(dir); +} function __vf_resolveLocal(id, parentDir) { var base = __vf_resolve(parentDir, id); // Refuse paths outside the project before probing them, so a missing-module // error never tells route code whether a host file exists. - __vf_assertContained(__vf_canonicalize(base)); - var candidates = [base, base + ".js", base + ".cjs", base + ".json", base + "/index.js", base + "/index.cjs", base + "/index.json"]; - for (var i = 0; i < candidates.length; i++) { - if (!__vf_isFile(candidates[i])) continue; - __vf_assertContained(__vf_canonicalize(candidates[i])); - return candidates[i]; + __vf_assertContained(__vf_canonicalize(base), id); + var found = __vf_probeFile(base) || __vf_probeDirectory(base, id); + if (found) { + __vf_assertContained(__vf_canonicalize(found), id); + return found; } var missing = new Error("Cannot find module " + JSON.stringify(id)); missing.code = "MODULE_NOT_FOUND"; throw missing; } -function __vf_loadCjs(id, parentDir) { - if (id.startsWith("node:")) return __vf_builtinRequire(id); - if (__vf_builtinSet.has(id)) return __vf_builtinRequire(id); +function __vf_isBuiltin(id) { + if (id.startsWith("node:")) return true; + if (__vf_builtinSet.has(id)) return true; var slashIdx = id.indexOf("/"); - if (slashIdx > 0 && __vf_builtinSet.has(id.slice(0, slashIdx))) return __vf_builtinRequire(id); + return slashIdx > 0 && __vf_builtinSet.has(id.slice(0, slashIdx)); +} +function __vf_loadCjs(id, parentDir) { + if (__vf_isBuiltin(id)) return __vf_builtinRequire(id); var resolved; if (id.startsWith(".") || id.startsWith("/")) { resolved = __vf_resolveLocal(id, parentDir); @@ -226,7 +271,7 @@ function __vf_loadCjs(id, parentDir) { // Canonicalizing first also keeps the guard against symlinked node_modules // entries pointing outside the root: the escape is only visible once resolved. resolved = __vf_canonicalize(resolved); - __vf_assertContained(resolved); + __vf_assertContained(resolved, id); if (resolved in __vf_cache) return __vf_cache[resolved]; var code = Deno.readTextFileSync(resolved); if (resolved.endsWith(".json")) { @@ -243,8 +288,15 @@ function __vf_loadCjs(id, parentDir) { var childReq = function(childId) { return __vf_loadCjs(childId, dir); }; childReq.resolve = function(childId) { return __vf_resolveId(childId, dir); }; childReq.ensure = function(mods, cb) { cb(); }; - var fn = new Function("exports", "require", "module", "__filename", "__dirname", "global", "globalThis", "Worker", code); - fn(mod.exports, childReq, mod, resolved, dir, globalThis, globalThis, undefined); + // As in Node, top-level \`this\` is module.exports, and a module whose + // evaluation throws is dropped from the cache so a retry fails again. + try { + var fn = new Function("exports", "require", "module", "__filename", "__dirname", "global", "globalThis", "Worker", code); + fn.call(mod.exports, mod.exports, childReq, mod, resolved, dir, globalThis, globalThis, undefined); + } catch (error) { + delete __vf_cache[resolved]; + throw error; + } __vf_cache[resolved] = mod.exports; return mod.exports; } @@ -252,37 +304,32 @@ function __vf_resolveId(id, parentDir) { if (id.startsWith(".") || id.startsWith("/")) return __vf_resolveLocal(id, parentDir); return __vf_resolveBare(id, parentDir); } -var routeRequire = function(id) { return __vf_loadCjs(id, ${safeProjectDir}); }; -routeRequire.resolve = function(id) { return __vf_resolveId(id, ${safeProjectDir}); }; +var routeRequire = function(id) { return __vf_loadCjs(id, __VF_PROJECT_DIR__); }; +routeRequire.resolve = function(id) { return __vf_resolveId(id, __VF_PROJECT_DIR__); }; routeRequire.ensure = function(mods, cb) { cb(); }; +// One namespace per resolved module, as with ES module namespaces: repeated +// imports of a module share it, and distinct modules never do. As with native +// CommonJS interop, \`default\` is the whole module.exports, also for modules +// marked __esModule. +var namespaces = new Map(); +__vf_namespace = function (id) { + var key = __vf_isBuiltin(id) + ? "builtin:" + id.replace(/^node:/, "") + : __vf_canonicalize(__vf_resolveId(id, __VF_PROJECT_DIR__)); + if (namespaces.has(key)) return namespaces.get(key); + var m = routeRequire(id); + var ns = Object.create(null); + if (m !== null && (typeof m === "object" || typeof m === "function")) { + for (var name in m) if (name !== "default") ns[name] = m[name]; + } + ns.default = m; + namespaces.set(key, ns); + return ns; +}; return routeRequire; })(); function __vf_interopDefault(m) { return m && m.__esModule && m.default !== undefined ? m.default : m; } -// As with native CommonJS interop, \`default\` is the whole module.exports, also -// for modules marked __esModule. -// One namespace per module, so repeated imports of a package see the same -// object, as with ES module namespaces. -var __vf_namespace = (function () { - var objects = new WeakMap(); - var primitives = new Map(); - return function (m) { - var isObject = m !== null && (typeof m === "object" || typeof m === "function"); - var cache = isObject ? objects : primitives; - var cached = cache.get(m); - if (cached) return cached; - var ns = Object.create(null); - if (isObject) { - for (var key in m) if (key !== "default") ns[key] = m[key]; - } - ns.default = m; - cache.set(m, ns); - return ns; - }; -})(); -`.trim(), - helperPrefix, - ); -} +`.trim(); export function getNodeExternalPackagesToResolve(userDeps: Map): string[] { return getNodeExternalPackagesToResolveForRoute(userDeps); diff --git a/src/routing/api/module-loader/loader-helpers.test.ts b/src/routing/api/module-loader/loader-helpers.test.ts index d58a6aebd7..6f217e73f8 100644 --- a/src/routing/api/module-loader/loader-helpers.test.ts +++ b/src/routing/api/module-loader/loader-helpers.test.ts @@ -41,6 +41,10 @@ describe("routing/api/module-loader helpers", () => { ); assertEquals(toCjsDestructureBindings("{ foo, bar }"), "{ foo, bar }"); assertEquals(toCjsDestructureBindings("{ }"), "{}"); + assertEquals( + toCjsDestructureBindings(`{ "a-b" as value, 'c,d' as other, e, }`), + `{ "a-b": value, 'c,d': other, e }`, + ); }); it("returns the expected file loaders and extensions", () => { diff --git a/src/routing/api/module-loader/loader-helpers.ts b/src/routing/api/module-loader/loader-helpers.ts index bfba9f40df..7a7a15544a 100644 --- a/src/routing/api/module-loader/loader-helpers.ts +++ b/src/routing/api/module-loader/loader-helpers.ts @@ -46,19 +46,32 @@ export function resolveExportEntry(entry: unknown): string | undefined { return undefined; } +const NAMED_IMPORT_ENTRY = + /\s*(?:("(?:[^"\\\r\n]|\\.)*"|'(?:[^'\\\r\n]|\\.)*')|([A-Za-z_$][\w$]*))(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*(?:,|$)/y; + +/** + * Convert a named import clause (`{ a, b as c, "d-e" as f }`) to a + * destructuring pattern (`{ a, b: c, "d-e": f }`). A string-literal name + * needs an alias, as in the import itself. + */ export function toCjsDestructureBindings(bindings: string): string { const inner = bindings.trim().replace(/^\{\s*/, "").trimEnd().replace(/\}$/, ""); - if (!inner) return "{}"; + if (!inner.trim()) return "{}"; - const converted = inner - .split(",") - .map((part) => part.trim()) - .filter(Boolean) - .map((part) => { - const aliasMatch = part.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/); - if (aliasMatch) return `${aliasMatch[1]}: ${aliasMatch[2]}`; - return part; - }); + const converted: string[] = []; + NAMED_IMPORT_ENTRY.lastIndex = 0; + while (inner.slice(NAMED_IMPORT_ENTRY.lastIndex).trim() !== "") { + const start = NAMED_IMPORT_ENTRY.lastIndex; + const entry = NAMED_IMPORT_ENTRY.exec(inner); + if (!entry || NAMED_IMPORT_ENTRY.lastIndex === start) { + throw new Error(`Unsupported named import clause: ${bindings}`); + } + const [, literal, name, alias] = entry; + if (literal !== undefined && alias === undefined) { + throw new Error(`Unsupported named import clause: ${bindings}`); + } + converted.push(alias === undefined ? name! : `${literal ?? name}: ${alias}`); + } return `{ ${converted.join(", ")} }`; } diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 2aa3254d41..3c884465b4 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -1843,7 +1843,7 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, assertMatch(rewritten, /const thing = __vf_interopDefault\(require\("my-lib"\)\)/); assertMatch(rewritten, /const \{ alpha: beta \} = require\("my-lib"\)/); - assertMatch(rewritten, /const namespace = __vf_namespace\(require\("my-lib"\)\)/); + assertMatch(rewritten, /const namespace = __vf_namespace\("my-lib"\)/); assertMatch( rewritten, /const __vf_tmp_combo = require\("my-lib"\); const combo = __vf_interopDefault\(__vf_tmp_combo\); const \{ gamma \} = __vf_tmp_combo/, @@ -1851,7 +1851,7 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, assertMatch(rewritten, /const widget = __vf_interopDefault\(require\("my-lib\/subpath"\)\)/); assertMatch( rewritten, - /Promise\.resolve\(\)\.then\(\(\) => __vf_namespace\(require\("my-lib\/subpath"\)\)\)/, + /Promise\.resolve\(\)\.then\(\(\) => __vf_namespace\("my-lib\/subpath"\)\)/, ); }); @@ -5452,7 +5452,7 @@ describe("generateCompiledBinaryRequireShim - static checks (VULN-FS-5)", () => // both branches (i.e. AFTER the `} else { resolved = ...resolve(id); }`). const elseIdx = shim.indexOf("resolved = __vf_resolveBare(id, parentDir)"); assert(elseIdx !== -1, "the shim must resolve bare ids through __vf_resolveBare"); - const assertIdx = shim.indexOf("__vf_assertContained(resolved)", elseIdx); + const assertIdx = shim.indexOf("__vf_assertContained(resolved, id)", elseIdx); assertEquals( assertIdx > elseIdx, true, @@ -5468,7 +5468,7 @@ describe("generateCompiledBinaryRequireShim - static checks (VULN-FS-5)", () => // already-canonical root, which rejects legitimate dependencies whenever // the project root is itself a symlink. const canonIdx = shim.indexOf("resolved = __vf_canonicalize(resolved)"); - const assertIdx = shim.indexOf("__vf_assertContained(resolved)", canonIdx); + const assertIdx = shim.indexOf("__vf_assertContained(resolved, id)", canonIdx); assertEquals( canonIdx !== -1 && assertIdx > canonIdx, true, @@ -6046,6 +6046,108 @@ describe("route CommonJS require (#5471)", () => { assertEquals(error.code, "MODULE_NOT_FOUND"); }); + denoIt("resolves a local directory through its package.json main", async () => { + const projectDir = await writeProject({ + "node_modules/dir-main/package.json": JSON.stringify({ name: "dir-main", main: "index.js" }), + "node_modules/dir-main/index.js": 'module.exports = require("./lib");', + "node_modules/dir-main/lib/package.json": JSON.stringify({ main: "./entry.js" }), + "node_modules/dir-main/lib/entry.js": "module.exports = 'main entry';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("dir-main"), "main entry"); + }); + + denoIt("drops a module whose evaluation threw from the cache", async () => { + const projectDir = await writeProject({ + "node_modules/retry/package.json": JSON.stringify({ name: "retry", main: "index.js" }), + "node_modules/retry/index.js": [ + 'try { require("./flaky"); } catch (_) { /* first attempt fails */ }', + 'module.exports = require("./flaky");', + ].join("\n"), + "node_modules/retry/flaky.js": [ + "if (!globalThis.__vfFlakyOnce) {", + " globalThis.__vfFlakyOnce = true;", + " exports.partial = true;", + " throw new Error('first');", + "}", + "module.exports = 'second';", + ].join("\n"), + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + try { + assertEquals(vfRequire("retry"), "second"); + } finally { + delete (globalThis as Record).__vfFlakyOnce; + } + }); + + denoIt("runs CommonJS code with module.exports as top-level this", async () => { + const projectDir = await writeProject({ + "node_modules/this-export/package.json": JSON.stringify({ + name: "this-export", + main: "index.js", + }), + "node_modules/this-export/index.js": '"use strict";\nthis.plugin = "via this";', + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals((vfRequire("this-export") as { plugin: string }).plugin, "via this"); + }); + + denoIt("loads runtime built-ins missing from the shared list", async () => { + const projectDir = await writeProject({ + "node_modules/uses-hooks/package.json": JSON.stringify({ + name: "uses-hooks", + main: "index.js", + }), + "node_modules/uses-hooks/index.js": + 'module.exports = typeof require("async_hooks").AsyncLocalStorage;', + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("uses-hooks"), "function"); + }); + + denoIt("names the requested id, not the host path, when blocking a path", async () => { + const projectDir = await writeProject({}); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + const message = thrownMessage(() => vfRequire("../outside/secret.js")); + assertEquals(message, 'CJS loader blocked path outside project: "../outside/secret.js"'); + }); + + denoIt("gives distinct modules with equal exports distinct namespaces", async () => { + const files = { + "node_modules/num-a/package.json": JSON.stringify({ name: "num-a", main: "index.js" }), + "node_modules/num-a/index.js": "module.exports = 42;", + "node_modules/num-b/package.json": JSON.stringify({ name: "num-b", main: "index.js" }), + "node_modules/num-b/index.js": "module.exports = 42;", + }; + const GET = await loadCompiledRoute( + [ + 'import * as a from "num-a";', + 'import * as b from "num-b";', + "export const GET = () => new Response(String(a !== b && a.default === b.default));", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "true"); + }); + + denoIt("rewrites string-literal named imports", async () => { + const files = { + "node_modules/dashed/package.json": JSON.stringify({ name: "dashed", main: "index.js" }), + "node_modules/dashed/index.js": "exports['a-b'] = 'dashed';", + }; + const GET = await loadCompiledRoute( + 'import { "a-b" as value } from "dashed";\nexport const GET = () => new Response(value);', + files, + ); + assertEquals(await responseText(GET), "dashed"); + }); + denoIt("rejects a native addon with a clear error", async () => { const projectDir = await writeProject({ "node_modules/native-fixture/package.json": JSON.stringify({ diff --git a/src/transforms/import-rewriter/core.test.ts b/src/transforms/import-rewriter/core.test.ts index cd392bf4fe..4d0b6dd196 100644 --- a/src/transforms/import-rewriter/core.test.ts +++ b/src/transforms/import-rewriter/core.test.ts @@ -148,7 +148,7 @@ describe("import rewrite compatibility golden tests", () => { `const m = import("lodash/merge");`, new Map([["lodash", "^4"]]), ), - `Promise.resolve().then(() => __vf_namespace(require("lodash/merge")))`, + `Promise.resolve().then(() => __vf_namespace("lodash/merge"))`, ); assertEquals( rewriteDenoNodeBuiltinImports(`import { readFile } from "fs";`), diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index 542f8a96a5..156a3eaa6c 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -217,9 +217,10 @@ export const DEFAULT_ROUTE_HELPER_PREFIX = "__vf_"; */ function rewriteCjsImportClause( clause: string, - required: string, + specifier: string, helperPrefix: string, ): string | null { + const required = `require(${specifier})`; const parsed = clause.trim().match(IMPORT_CLAUSE); if (!parsed) return null; const [, defaultName, namespaceName, named] = parsed; @@ -231,12 +232,33 @@ function rewriteCjsImportClause( statements.push(`const ${defaultName} = ${helperPrefix}interopDefault(${source})`); } if (namespaceName) { - statements.push(`const ${namespaceName} = ${helperPrefix}namespace(${source})`); + statements.push(`const ${namespaceName} = ${helperPrefix}namespace(${specifier})`); + } + if (named) { + let pattern: string; + try { + pattern = toCjsDestructureBindings(named); + } catch (_) { + // expected: an import clause this rewrite does not understand stays as written + return null; + } + statements.push(`const ${pattern} = ${source}`); } - if (named) statements.push(`const ${toCjsDestructureBindings(named)} = ${source}`); return statements.join("; "); } +const UNSAFE_LITERAL_CHARS: Readonly> = { + "<": "\\u003C", + ">": "\\u003E", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; + +/** A JavaScript string literal safe to embed anywhere in generated code. */ +function toJsStringLiteral(value: string): string { + return JSON.stringify(value).replace(/[<>\u2028\u2029]/g, (char) => UNSAFE_LITERAL_CHARS[char]!); +} + /** The user dependency a specifier names (`pkg` or `pkg/sub`), longest first. */ function matchUserDependency( specifier: string, @@ -297,12 +319,13 @@ export async function rewriteCompiledUserDependencyImportsForRoute( edits.push({ start: imported.s, end: imported.e, - text: isDynamic ? JSON.stringify(url) : url, + text: isDynamic ? toJsStringLiteral(url) : url, }); continue; } - const required = `require(${JSON.stringify(name + subpath)})`; + const specifier = toJsStringLiteral(name + subpath); + const required = `require(${specifier})`; if (isDynamic) { // A dynamic import stays lazy and asynchronous: a load failure rejects // the returned promise instead of throwing synchronously. Imports with @@ -312,7 +335,7 @@ export async function rewriteCompiledUserDependencyImportsForRoute( edits.push({ start: imported.ss, end: imported.e + close[0].length, - text: `Promise.resolve().then(() => ${helperPrefix}namespace(${required}))`, + text: `Promise.resolve().then(() => ${helperPrefix}namespace(${specifier}))`, }); continue; } @@ -325,7 +348,7 @@ export async function rewriteCompiledUserDependencyImportsForRoute( const clause = statement.match(STATIC_FROM_IMPORT)?.[1]; const rewritten = clause === undefined ? null - : rewriteCjsImportClause(clause, required, helperPrefix); + : rewriteCjsImportClause(clause, specifier, helperPrefix); if (rewritten !== null) { edits.push({ start: imported.ss, end: imported.se, text: rewritten }); } From ad567613336ca76b2e83adaa621fcb4854fdaa4b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 5 Nov 2026 23:10:28 +0100 Subject: [PATCH 08/22] fix(api-routes): cache module records and tighten local resolution in the compiled loader - Cache module records so a circular require sees a reassigned module.exports. - Fail on a malformed local package.json instead of falling back to index. - Probe .node so an extensionless native addon gets the addon error. - Name an .mjs file required from a CommonJS package, and document it. Refs #5471 --- docs/guides/api-routes.md | 4 +- .../module-loader/external-import-rewriter.ts | 24 +++++-- src/routing/api/module-loader/loader.test.ts | 63 +++++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/docs/guides/api-routes.md b/docs/guides/api-routes.md index e73fffd2d7..33ad6f9aa7 100644 --- a/docs/guides/api-routes.md +++ b/docs/guides/api-routes.md @@ -230,7 +230,9 @@ package's `module.exports` as `default`, alongside its named properties. Import packages by their root name or by a file path inside the package. Subpath names defined only in a package's `exports` map, or a package whose `exports` -field is a single string, may not resolve. +field is a single string, may not resolve. A package is treated as CommonJS or +ESM as a whole, so an `.mjs` file inside a CommonJS package fails with +`ES module file "" cannot be loaded with require`. Packages that ship a native addon (`.node` file) are not supported. Loading one fails with `native addon "" cannot be loaded`. A package that is listed diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 2afe0c5798..20d755f89c 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -208,7 +208,9 @@ function __vf_isFile(candidate) { try { return Deno.statSync(candidate).isFile; } catch (_) { return false; } } function __vf_probeFile(path) { - var candidates = [path, path + ".js", path + ".cjs", path + ".json"]; + // ".node" is probed so an extensionless native addon reaches the + // unsupported-addon error instead of a misleading MODULE_NOT_FOUND. + var candidates = [path, path + ".js", path + ".cjs", path + ".json", path + ".node"]; for (var i = 0; i < candidates.length; i++) { if (__vf_isFile(candidates[i])) return candidates[i]; } @@ -221,8 +223,14 @@ function __vf_probeIndex(dir) { function __vf_probeDirectory(dir, id) { var manifest = __vf_resolve(dir, "package.json"); if (__vf_isFile(manifest)) { + // As in Node, an unreadable or malformed manifest is an error, not a + // reason to fall back to index. var main; - try { main = JSON.parse(Deno.readTextFileSync(manifest)).main; } catch (_) { /* expected: unreadable manifest falls back to index */ } + try { + main = JSON.parse(Deno.readTextFileSync(manifest)).main; + } catch (_) { + throw new Error("invalid package.json for " + JSON.stringify(id)); + } if (typeof main === "string" && main) { var entry = __vf_resolve(dir, main); __vf_assertContained(__vf_canonicalize(entry), id); @@ -263,6 +271,9 @@ function __vf_loadCjs(id, parentDir) { if (resolved.endsWith(".node")) { throw new Error("native addon " + JSON.stringify(id) + " cannot be loaded: native addons are not supported in this runtime"); } + if (resolved.endsWith(".mjs")) { + throw new Error("ES module file " + JSON.stringify(id) + " cannot be loaded with require: import the package by a CommonJS entry"); + } // VULN-FS-5: Canonicalize before asserting containment, so the candidate and // the already-canonical project root are compared like with like, then assert // once. Checking the non-canonical path first rejected dependencies genuinely @@ -272,18 +283,20 @@ function __vf_loadCjs(id, parentDir) { // entries pointing outside the root: the escape is only visible once resolved. resolved = __vf_canonicalize(resolved); __vf_assertContained(resolved, id); - if (resolved in __vf_cache) return __vf_cache[resolved]; + // The cache holds module records, so a circular require sees a + // reassigned module.exports while the first module is still evaluating. + if (resolved in __vf_cache) return __vf_cache[resolved].exports; var code = Deno.readTextFileSync(resolved); if (resolved.endsWith(".json")) { var json = JSON.parse(code); - __vf_cache[resolved] = json; + __vf_cache[resolved] = { exports: json }; return json; } // Node strips a leading hashbang from CommonJS sources; a Function body // cannot contain one. Commenting it out keeps line numbers intact. if (code.startsWith("#!")) code = "//" + code.slice(2); var mod = { exports: {} }; - __vf_cache[resolved] = mod.exports; + __vf_cache[resolved] = mod; var dir = __vf_dirname(resolved); var childReq = function(childId) { return __vf_loadCjs(childId, dir); }; childReq.resolve = function(childId) { return __vf_resolveId(childId, dir); }; @@ -297,7 +310,6 @@ function __vf_loadCjs(id, parentDir) { delete __vf_cache[resolved]; throw error; } - __vf_cache[resolved] = mod.exports; return mod.exports; } function __vf_resolveId(id, parentDir) { diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 3c884465b4..b12dba676b 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -6148,6 +6148,69 @@ describe("route CommonJS require (#5471)", () => { assertEquals(await responseText(GET), "dashed"); }); + denoIt("shows a reassigned module.exports to a circular require", async () => { + const projectDir = await writeProject({ + "node_modules/cycle/package.json": JSON.stringify({ name: "cycle", main: "a.js" }), + "node_modules/cycle/a.js": [ + "module.exports = { name: 'a' };", + 'module.exports.seenByB = require("./b");', + ].join("\n"), + "node_modules/cycle/b.js": 'module.exports = require("./a").name;', + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals((vfRequire("cycle") as { seenByB: string }).seenByB, "a"); + }); + + denoIt("fails on a malformed local package.json instead of using index", async () => { + const projectDir = await writeProject({ + "node_modules/bad-manifest/package.json": JSON.stringify({ + name: "bad-manifest", + main: "index.js", + }), + "node_modules/bad-manifest/index.js": 'module.exports = require("./lib");', + "node_modules/bad-manifest/lib/package.json": "{ not json", + "node_modules/bad-manifest/lib/index.js": "module.exports = 'fallback';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals( + thrownMessage(() => vfRequire("bad-manifest")), + 'invalid package.json for "./lib"', + ); + }); + + denoIt("reports an extensionless native addon as a native addon", async () => { + const projectDir = await writeProject({ + "node_modules/bare-addon/package.json": JSON.stringify({ + name: "bare-addon", + main: "index.js", + }), + "node_modules/bare-addon/index.js": 'module.exports = require("./build/addon");', + "node_modules/bare-addon/build/addon.node": "\u007fELF", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals( + thrownMessage(() => vfRequire("bare-addon")), + 'native addon "./build/addon" cannot be loaded: native addons are not supported in this runtime', + ); + }); + + denoIt("names an ES module file required from a CommonJS package", async () => { + const projectDir = await writeProject({ + "node_modules/mixed/package.json": JSON.stringify({ name: "mixed", main: "index.js" }), + "node_modules/mixed/index.js": "module.exports = 'root';", + "node_modules/mixed/feature.mjs": "export default 'esm';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals( + thrownMessage(() => vfRequire("mixed/feature.mjs")), + 'ES module file "mixed/feature.mjs" cannot be loaded with require: import the package by a CommonJS entry', + ); + }); + denoIt("rejects a native addon with a clear error", async () => { const projectDir = await writeProject({ "node_modules/native-fixture/package.json": JSON.stringify({ From e3d39455749834e6861a85a8fbd4238db14db69c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 5 Nov 2026 23:45:02 +0100 Subject: [PATCH 09/22] fix(api-routes): handle more CommonJS import forms and module APIs - Give { default as x } imports the default interop, not .default. - Parse Unicode and escaped identifiers and string-literal names. - Rewrite imports with attributes and dynamic imports with options. - Rewrite named and namespace re-exports; reject export * clearly. - Provide module.require, filename, path and loaded to CommonJS code. - Classify Windows absolute ids as paths. - Resolve packages named like prefix-only Node built-ins from node_modules. - Keep other resolver errors' codes but not their host paths. Refs #5471 --- docs/guides/api-routes.md | 4 +- .../external-import-rewriter.test.ts | 57 ++++++ .../module-loader/external-import-rewriter.ts | 50 ++++- .../api/module-loader/loader-helpers.test.ts | 4 + .../api/module-loader/loader-helpers.ts | 76 ++++++-- src/routing/api/module-loader/loader.test.ts | 96 +++++++++- .../import-rewriter/route-adapter.ts | 181 +++++++++++++++--- 7 files changed, 416 insertions(+), 52 deletions(-) diff --git a/docs/guides/api-routes.md b/docs/guides/api-routes.md index 33ad6f9aa7..7a84d40ca7 100644 --- a/docs/guides/api-routes.md +++ b/docs/guides/api-routes.md @@ -232,7 +232,9 @@ Import packages by their root name or by a file path inside the package. Subpath names defined only in a package's `exports` map, or a package whose `exports` field is a single string, may not resolve. A package is treated as CommonJS or ESM as a whole, so an `.mjs` file inside a CommonJS package fails with -`ES module file "" cannot be loaded with require`. +`ES module file "" cannot be loaded with require`. Re-export named +values from a CommonJS package (`export { name } from "pkg"`); `export * from +"pkg"` is not supported, because its names are only known at run time. Packages that ship a native addon (`.node` file) are not supported. Loading one fails with `native addon "" cannot be loaded`. A package that is listed diff --git a/src/routing/api/module-loader/external-import-rewriter.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index 3beb013b4b..6443ed94fc 100644 --- a/src/routing/api/module-loader/external-import-rewriter.test.ts +++ b/src/routing/api/module-loader/external-import-rewriter.test.ts @@ -161,6 +161,49 @@ describe("external-import-rewriter", () => { assertStringIncludes(out, `require("lodash/merge")`); }); + it("rewrites a re-export into local bindings and an export list", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + `export { merge as join, default as lodash, "a-b" as dashed } from "lodash";`, + deps, + ); + assertEquals( + out, + 'const __vf_reexport_0 = require("lodash"); ' + + "const __vf_reexport_0_0 = __vf_reexport_0.merge; " + + "const __vf_reexport_0_1 = __vf_interopDefault(__vf_reexport_0); " + + 'const __vf_reexport_0_2 = __vf_reexport_0["a-b"]; ' + + "export { __vf_reexport_0_0 as join, __vf_reexport_0_1 as lodash, __vf_reexport_0_2 as dashed };", + ); + }); + + it("rewrites package imports that carry attributes", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + [ + `import data from "lodash/data.json" with { type: "json" };`, + `const lazy = import("lodash/data.json", { with: { type: "json" } });`, + ].join("\n"), + deps, + ); + assertEquals( + out, + [ + 'const data = __vf_interopDefault(require("lodash/data.json"));', + 'const lazy = Promise.resolve().then(() => __vf_namespace("lodash/data.json"));', + ].join("\n"), + ); + }); + + it("rewrites a named default import with interop", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + `import { default as lodash, merge } from "lodash";`, + deps, + ); + assertEquals( + out, + 'const __vf_tmp_lodash = require("lodash"); const lodash = __vf_interopDefault(__vf_tmp_lodash); const { merge } = __vf_tmp_lodash;', + ); + }); + it("leaves imports for packages not in userDeps untouched", async () => { const code = `import x from "react";`; assertEquals(await rewriteCompiledBinaryUserDependencyImports(code, deps), code); @@ -436,6 +479,20 @@ describe("external-import-rewriter", () => { }); }); + describe("generateCompiledBinaryRequireShim path ids", () => { + it("classifies ids with the platform's absolute-path check", () => { + // Windows drive-letter and UNC ids cannot be exercised on this host, so + // assert the loader classifies path ids through path.isAbsolute. + const shim = generateCompiledBinaryRequireShim("/srv/app"); + assertStringIncludes( + shim, + 'var __vf_isAbsolute = __vf_builtinRequire("node:path").isAbsolute;', + ); + assertStringIncludes(shim, "if (__vf_isPathId(id)) {"); + assertStringIncludes(shim, "if (__vf_isPathId(id)) return __vf_resolveLocal(id, parentDir);"); + }); + }); + describe("chooseRouteHelperPrefix", () => { it("keeps the default prefix unless the bundle mentions a helper name", () => { assertEquals(chooseRouteHelperPrefix("const __vf_routeSourceUrl = 1;"), "__vf_"); diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 20d755f89c..9db7e23050 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -61,6 +61,7 @@ const ROUTE_HELPER_SUFFIXES = [ "interopDefault", "namespace", "tmp_", + "reexport_", ] as const; const ROUTE_HELPER_NAME = /\b__vf_(createRequire|dirname|resolve|interopDefault|namespace)\b/g; @@ -126,13 +127,24 @@ var __vf_namespace; var require = (function () { var __vf_builtinRequire = __vf_createRequire(__VF_PROJECT_DIR__ + "/package.json"); var __vf_builtinSet = new Set(__VF_BUILTIN_LIST__); -// Every built-in this runtime provides, not only the shared list. +// Every built-in this runtime provides, not only the shared list. Node's +// prefix-only built-ins (node:test, node:sqlite, node:sea) have no bare form, +// so a package of that name still resolves from node_modules, even where the +// runtime lists the bare name. +var __vf_prefixOnlyBuiltins = new Set(["sea", "sqlite", "test", "test/reporters"]); try { var __vf_runtimeBuiltins = __vf_builtinRequire("node:module").builtinModules; for (var __vf_b = 0; __vf_b < __vf_runtimeBuiltins.length; __vf_b++) { - __vf_builtinSet.add(__vf_runtimeBuiltins[__vf_b].replace(/^node:/, "")); + var __vf_name = __vf_runtimeBuiltins[__vf_b]; + if (__vf_name.startsWith("node:") || __vf_prefixOnlyBuiltins.has(__vf_name)) continue; + __vf_builtinSet.add(__vf_name); } } catch (_) { /* expected: fall back to the shared list */ } +// Absolute ids include Windows drive-letter and UNC paths. +var __vf_isAbsolute = __vf_builtinRequire("node:path").isAbsolute; +function __vf_isPathId(id) { + return id.startsWith(".") || __vf_isAbsolute(id); +} var __vf_projectRoot = __VF_PROJECT_ROOT__; // VULN-FS-5: Canonicalize the project root so containment checks using // Deno.realPathSync(resolved) compare canonical-vs-canonical. Without this, @@ -183,15 +195,37 @@ function __vf_packageInstalled(name, parentDir) { if (__vf_dirname(dir) === dir) return false; } } +// A package named like a prefix-only built-in (test, sqlite, sea) is looked up +// in node_modules directly: the runtime's resolver may claim the bare name. +function __vf_resolvePrefixOnlyName(id, parentDir) { + for (var dir = __vf_resolve(parentDir); ; dir = __vf_dirname(dir)) { + var packageDir = __vf_resolve(dir, "node_modules", __vf_packageName(id)); + var exists = false; + try { Deno.statSync(packageDir); exists = true; } catch (_) { /* expected: not installed at this level */ } + // A relative id keeps host paths out of any error it raises. + if (exists) return __vf_resolveLocal("./node_modules/" + id, dir); + if (__vf_dirname(dir) === dir) break; + } + var missing = new Error("npm package " + JSON.stringify(__vf_packageName(id)) + " is not installed in this runtime's node_modules"); + missing.code = "MODULE_NOT_FOUND"; + throw missing; +} function __vf_resolveBare(id, parentDir) { + if (__vf_prefixOnlyBuiltins.has(__vf_packageName(id))) return __vf_resolvePrefixOnlyName(id, parentDir); try { return __vf_parentRequire(parentDir).resolve(id); } catch (error) { // The resolver's message carries host paths in its require stack. Name // only what the route asked for, and say "not installed" only when no // node_modules on the lookup path has the package at all. - if (!error || error.code !== "MODULE_NOT_FOUND") throw error; var name = __vf_packageName(id); + // Other resolver errors (an invalid package.json, a blocked subpath) keep + // their code but not their message, which names host paths. + if (!error || error.code !== "MODULE_NOT_FOUND") { + var failed = new Error("npm package " + JSON.stringify(name) + " could not be resolved" + (error && error.code ? " (" + error.code + ")" : "")); + if (error && error.code) failed.code = error.code; + throw failed; + } // Keep the code: packages probe optional dependencies with // \`error.code === "MODULE_NOT_FOUND"\`. var sanitized = new Error(__vf_packageInstalled(name, parentDir) @@ -263,7 +297,7 @@ function __vf_isBuiltin(id) { function __vf_loadCjs(id, parentDir) { if (__vf_isBuiltin(id)) return __vf_builtinRequire(id); var resolved; - if (id.startsWith(".") || id.startsWith("/")) { + if (__vf_isPathId(id)) { resolved = __vf_resolveLocal(id, parentDir); } else { resolved = __vf_resolveBare(id, parentDir); @@ -295,12 +329,13 @@ function __vf_loadCjs(id, parentDir) { // Node strips a leading hashbang from CommonJS sources; a Function body // cannot contain one. Commenting it out keeps line numbers intact. if (code.startsWith("#!")) code = "//" + code.slice(2); - var mod = { exports: {} }; - __vf_cache[resolved] = mod; var dir = __vf_dirname(resolved); var childReq = function(childId) { return __vf_loadCjs(childId, dir); }; childReq.resolve = function(childId) { return __vf_resolveId(childId, dir); }; childReq.ensure = function(mods, cb) { cb(); }; + // The CommonJS module API packages use: module.require, id, filename, path. + var mod = { exports: {}, id: resolved, filename: resolved, path: dir, loaded: false, children: [], paths: [], require: childReq }; + __vf_cache[resolved] = mod; // As in Node, top-level \`this\` is module.exports, and a module whose // evaluation throws is dropped from the cache so a retry fails again. try { @@ -310,10 +345,11 @@ function __vf_loadCjs(id, parentDir) { delete __vf_cache[resolved]; throw error; } + mod.loaded = true; return mod.exports; } function __vf_resolveId(id, parentDir) { - if (id.startsWith(".") || id.startsWith("/")) return __vf_resolveLocal(id, parentDir); + if (__vf_isPathId(id)) return __vf_resolveLocal(id, parentDir); return __vf_resolveBare(id, parentDir); } var routeRequire = function(id) { return __vf_loadCjs(id, __VF_PROJECT_DIR__); }; diff --git a/src/routing/api/module-loader/loader-helpers.test.ts b/src/routing/api/module-loader/loader-helpers.test.ts index 6f217e73f8..6cf69d7e14 100644 --- a/src/routing/api/module-loader/loader-helpers.test.ts +++ b/src/routing/api/module-loader/loader-helpers.test.ts @@ -45,6 +45,10 @@ describe("routing/api/module-loader helpers", () => { toCjsDestructureBindings(`{ "a-b" as value, 'c,d' as other, e, }`), `{ "a-b": value, 'c,d': other, e }`, ); + assertEquals( + toCjsDestructureBindings("{ café as c2, caf\\u00e9 }"), + "{ café: c2, caf\\u00e9 }", + ); }); it("returns the expected file loaders and extensions", () => { diff --git a/src/routing/api/module-loader/loader-helpers.ts b/src/routing/api/module-loader/loader-helpers.ts index 7a7a15544a..76199fc3b1 100644 --- a/src/routing/api/module-loader/loader-helpers.ts +++ b/src/routing/api/module-loader/loader-helpers.ts @@ -46,34 +46,74 @@ export function resolveExportEntry(entry: unknown): string | undefined { return undefined; } -const NAMED_IMPORT_ENTRY = - /\s*(?:("(?:[^"\\\r\n]|\\.)*"|'(?:[^'\\\r\n]|\\.)*')|([A-Za-z_$][\w$]*))(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*(?:,|$)/y; +/** + * A JavaScript identifier, including Unicode letters and `\u` escapes, as + * the source of a RegExp built with the `u` flag. + */ +export const JS_IDENTIFIER = "(?:[\\p{ID_Start}$_]|\\\\u[0-9a-fA-F]{4}|\\\\u\\{[0-9a-fA-F]+\\})" + + "(?:[\\p{ID_Continue}$\\u200C\\u200D]|\\\\u[0-9a-fA-F]{4}|\\\\u\\{[0-9a-fA-F]+\\})*"; + +/** A single- or double-quoted string literal, as the source of a RegExp. */ +export const JS_STRING_LITERAL = `"(?:[^"\\\\\\r\\n]|\\\\.)*"|'(?:[^'\\\\\\r\\n]|\\\\.)*'`; + +const SPECIFIER_ENTRY = new RegExp( + `\\s*(?:(${JS_STRING_LITERAL})|(${JS_IDENTIFIER}))` + + `(?:\\s+as\\s+(?:(${JS_STRING_LITERAL})|(${JS_IDENTIFIER})))?\\s*(?:,|$)`, + "uy", +); + +/** One `name as alias` entry of an import or export clause, as source text. */ +export interface ModuleSpecifier { + /** The name in the other module: an identifier or a string literal. */ + readonly name: string; + readonly nameIsString: boolean; + /** The local or exported name, if it differs: an identifier or a string literal. */ + readonly alias?: string; + readonly aliasIsString: boolean; +} + +/** Parse a `{ ... }` import or export clause. Throws on anything else. */ +export function parseModuleSpecifiers(clause: string): ModuleSpecifier[] { + const trimmed = clause.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + throw new Error(`Unsupported import or export clause: ${clause}`); + } + const inner = trimmed.slice(1, -1); + const specifiers: ModuleSpecifier[] = []; + const entry = new RegExp(SPECIFIER_ENTRY.source, SPECIFIER_ENTRY.flags); + while (inner.slice(entry.lastIndex).trim() !== "") { + const start = entry.lastIndex; + const match = entry.exec(inner); + if (!match || entry.lastIndex === start) { + throw new Error(`Unsupported import or export clause: ${clause}`); + } + const [, nameString, nameIdent, aliasString, aliasIdent] = match; + specifiers.push({ + name: (nameString ?? nameIdent)!, + nameIsString: nameString !== undefined, + alias: aliasString ?? aliasIdent, + aliasIsString: aliasString !== undefined, + }); + } + return specifiers; +} /** * Convert a named import clause (`{ a, b as c, "d-e" as f }`) to a * destructuring pattern (`{ a, b: c, "d-e": f }`). A string-literal name - * needs an alias, as in the import itself. + * needs an identifier alias, as in the import itself. */ export function toCjsDestructureBindings(bindings: string): string { - const inner = bindings.trim().replace(/^\{\s*/, "").trimEnd().replace(/\}$/, ""); - if (!inner.trim()) return "{}"; - const converted: string[] = []; - NAMED_IMPORT_ENTRY.lastIndex = 0; - while (inner.slice(NAMED_IMPORT_ENTRY.lastIndex).trim() !== "") { - const start = NAMED_IMPORT_ENTRY.lastIndex; - const entry = NAMED_IMPORT_ENTRY.exec(inner); - if (!entry || NAMED_IMPORT_ENTRY.lastIndex === start) { + for (const specifier of parseModuleSpecifiers(bindings)) { + if (specifier.aliasIsString || (specifier.nameIsString && specifier.alias === undefined)) { throw new Error(`Unsupported named import clause: ${bindings}`); } - const [, literal, name, alias] = entry; - if (literal !== undefined && alias === undefined) { - throw new Error(`Unsupported named import clause: ${bindings}`); - } - converted.push(alias === undefined ? name! : `${literal ?? name}: ${alias}`); + converted.push( + specifier.alias === undefined ? specifier.name : `${specifier.name}: ${specifier.alias}`, + ); } - - return `{ ${converted.join(", ")} }`; + return converted.length === 0 ? "{}" : `{ ${converted.join(", ")} }`; } export function getLoaderForFile(filePath: string): "tsx" | "jsx" | "ts" | "js" | "json" { diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index b12dba676b..45844c358e 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -5716,6 +5716,13 @@ describe("route CommonJS require (#5471)", () => { routeSource: string, files: Record = CJS_FIXTURE, ): Promise<() => Promise> { + return (await loadCompiledRouteModule(routeSource, files)).GET as () => Promise; + } + + async function loadCompiledRouteModule( + routeSource: string, + files: Record = CJS_FIXTURE, + ): Promise> { const projectDir = await writeProject(files); const packages = new Set( Object.keys(files) @@ -5763,8 +5770,7 @@ describe("route CommonJS require (#5471)", () => { handlerPath, prependRouteRequirePrologue(rewritten, projectDir, true, helperPrefix), ); - const module = await import(toFileUrl(handlerPath).href); - return module.GET; + return await import(toFileUrl(handlerPath).href); } async function responseText(GET: () => Promise): Promise { @@ -6211,6 +6217,92 @@ describe("route CommonJS require (#5471)", () => { ); }); + denoIt("rewrites imports bound to non-ASCII identifiers", async () => { + const GET = await loadCompiledRoute( + 'import café from "cjs-fixture";\nexport const GET = () => new Response(new café.Pool().query());', + ); + assertEquals(await responseText(GET), "ok"); + }); + + denoIt("rewrites static and dynamic package imports with attributes", async () => { + const files = { + "node_modules/json-pkg/package.json": JSON.stringify({ name: "json-pkg", main: "index.js" }), + "node_modules/json-pkg/index.js": "module.exports = {};", + "node_modules/json-pkg/data.json": JSON.stringify({ rows: 3 }), + }; + const GET = await loadCompiledRoute( + [ + 'import data from "json-pkg/data.json" with { type: "json" };', + "export const GET = async () => {", + ' const lazy = await import("json-pkg/data.json", { with: { type: "json" } });', + " return new Response(`${data.rows},${lazy.default.rows}`);", + "};", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "3,3"); + }); + + denoIt("rewrites named and namespace re-exports from a CommonJS package", async () => { + const module = await loadCompiledRouteModule( + [ + 'export { version, Pool as RowPool, default as pg } from "cjs-fixture";', + 'export * as fixture from "cjs-fixture";', + "export const GET = () => new Response('ok');", + ].join("\n"), + ); + assertEquals(module.version, "1.0.0"); + assertEquals(typeof module.RowPool, "function"); + assertEquals((module.pg as { version: string }).version, "1.0.0"); + assertEquals((module.fixture as { version: string }).version, "1.0.0"); + }); + + denoIt("rejects export * from a CommonJS package with a clear error", async () => { + await assertRejects( + () => loadCompiledRouteModule('export * from "cjs-fixture";\nexport const GET = () => null;'), + Error, + 'export * from CommonJS package "cjs-fixture" is not supported; export its names explicitly', + ); + }); + + denoIt("gives CommonJS modules module.require and module metadata", async () => { + const projectDir = await writeProject({ + "node_modules/mod-api/package.json": JSON.stringify({ name: "mod-api", main: "index.js" }), + "node_modules/mod-api/index.js": + 'module.exports = module.require("./child") + ":" + (module.filename === __filename) + ":" + module.loaded;', + "node_modules/mod-api/child.js": "module.exports = 'child';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("mod-api"), "child:true:false"); + }); + + denoIt("resolves a package named like a prefix-only Node built-in", async () => { + const projectDir = await writeProject({ + "node_modules/test/package.json": JSON.stringify({ name: "test", main: "index.js" }), + "node_modules/test/index.js": "module.exports = 'package named test';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("test"), "package named test"); + }); + + denoIt("hides host paths in other package resolution errors", async () => { + const projectDir = await writeProject({ + "node_modules/bad-config/package.json": "{ not json", + "node_modules/bad-config/index.js": "module.exports = 1;", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + const message = thrownMessage(() => vfRequire("bad-config")); + assertEquals( + message.startsWith('npm package "bad-config" could not be resolved'), + true, + message, + ); + assertEquals(message.includes(projectDir), false, message); + }); + denoIt("rejects a native addon with a clear error", async () => { const projectDir = await writeProject({ "node_modules/native-fixture/package.json": JSON.stringify({ diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index 156a3eaa6c..e0d9f5917f 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -9,6 +9,10 @@ import { import { resolveContainedPackagePath } from "./package-resolution.ts"; import { isWithinDirectory } from "#veryfront/security/path-validation.ts"; import { + JS_IDENTIFIER, + JS_STRING_LITERAL, + type ModuleSpecifier, + parseModuleSpecifiers, resolveExportEntry as resolveRouteExportEntry, toCjsDestructureBindings, } from "#veryfront/routing/api/module-loader/loader-helpers.ts"; @@ -198,15 +202,25 @@ export function rewriteCompiledVeryfrontImportsForRoute(code: string): string { return transformed; } -/** A JavaScript identifier as esbuild emits local bindings (ASCII, `$` allowed). */ -const IDENT = "[\\w$]+"; - const IMPORT_CLAUSE = new RegExp( - `^(?:(${IDENT})\\s*(?:,\\s*)?)?(?:\\*\\s*as\\s+(${IDENT})|(\\{[^}]*\\}))?$`, + `^(?:(${JS_IDENTIFIER})\\s*(?:,\\s*)?)?(?:\\*\\s*as\\s+(${JS_IDENTIFIER})|(\\{[^}]*\\}))?$`, + "u", +); +/** Import attributes (`with { type: "json" }`) and an optional semicolon. */ +const STATEMENT_TAIL = `(?:\\s*(?:with|assert)\\s*\\{[^}]*\\})?\\s*;?\\s*$`; +const FROM_SPECIFIER = `from\\s*["'][^"']*["']`; +const STATIC_FROM_IMPORT = new RegExp( + `^import\\s*([\\s\\S]*?)\\s*${FROM_SPECIFIER}${STATEMENT_TAIL}`, +); +const SIDE_EFFECT_IMPORT = new RegExp(`^import\\s*["'][^"']*["']${STATEMENT_TAIL}`); +const EXPORT_STAR = new RegExp(`^export\\s*\\*\\s*${FROM_SPECIFIER}${STATEMENT_TAIL}`); +const EXPORT_STAR_AS = new RegExp( + `^export\\s*\\*\\s*as\\s+(${JS_STRING_LITERAL}|${JS_IDENTIFIER})\\s*${FROM_SPECIFIER}${STATEMENT_TAIL}`, + "u", +); +const EXPORT_NAMED = new RegExp( + `^export\\s*(\\{[\\s\\S]*\\})\\s*${FROM_SPECIFIER}${STATEMENT_TAIL}`, ); -const STATIC_FROM_IMPORT = /^import\s*([\s\S]*?)\s*from\s*["'][^"']*["']\s*$/; -const SIDE_EFFECT_IMPORT = /^import\s*["'][^"']*["']\s*$/; -const DYNAMIC_IMPORT_CLOSE = /^\s*\)/; /** Prefix of the helpers the compiled route rewrite calls; see the prologue. */ export const DEFAULT_ROUTE_HELPER_PREFIX = "__vf_"; @@ -225,25 +239,51 @@ function rewriteCjsImportClause( if (!parsed) return null; const [, defaultName, namespaceName, named] = parsed; if (!defaultName && !namespaceName && !named) return null; - const bindings = [defaultName, namespaceName, named].filter(Boolean).length; - const source = bindings > 1 ? `${helperPrefix}tmp_${defaultName ?? namespaceName}` : required; - const statements = bindings > 1 ? [`const ${source} = ${required}`] : []; - if (defaultName) { - statements.push(`const ${defaultName} = ${helperPrefix}interopDefault(${source})`); - } - if (namespaceName) { - statements.push(`const ${namespaceName} = ${helperPrefix}namespace(${specifier})`); - } + + // `{ default as x }` is the default import under another name, so it takes + // the same interop as `import x`; the other names destructure. + const defaultAliases: string[] = []; + const otherNames: string[] = []; if (named) { - let pattern: string; + let specifiers: ModuleSpecifier[]; try { - pattern = toCjsDestructureBindings(named); + specifiers = parseModuleSpecifiers(named); } catch (_) { // expected: an import clause this rewrite does not understand stays as written return null; } - statements.push(`const ${pattern} = ${source}`); + for (const entry of specifiers) { + if (entry.name === "default" && !entry.nameIsString && entry.alias && !entry.aliasIsString) { + defaultAliases.push(entry.alias); + } else { + otherNames.push(entry.alias === undefined ? entry.name : `${entry.name} as ${entry.alias}`); + } + } } + const defaults = defaultName ? [defaultName, ...defaultAliases] : defaultAliases; + let pattern: string | undefined; + if (otherNames.length > 0) { + try { + pattern = toCjsDestructureBindings(`{ ${otherNames.join(", ")} }`); + } catch (_) { + // expected: an import clause this rewrite does not understand stays as written + return null; + } + } + + const bindings = defaults.length + (namespaceName ? 1 : 0) + (pattern ? 1 : 0); + if (bindings === 0) return required; + // More than one binding means a default or namespace binding is present. + const first = defaults[0] ?? namespaceName ?? ""; + const source = bindings > 1 ? `${helperPrefix}tmp_${first}` : required; + const statements = bindings > 1 ? [`const ${source} = ${required}`] : []; + for (const name of defaults) { + statements.push(`const ${name} = ${helperPrefix}interopDefault(${source})`); + } + if (namespaceName) { + statements.push(`const ${namespaceName} = ${helperPrefix}namespace(${specifier})`); + } + if (pattern) statements.push(`const ${pattern} = ${source}`); return statements.join("; "); } @@ -272,6 +312,90 @@ function matchUserDependency( return best === null ? null : { name: best, subpath: specifier.slice(best.length) }; } +/** + * Rewrite a re-export from a CommonJS dependency into local bindings and a + * local export list. Names are read once, when the route module loads, as + * with the import rewrite. `export *` cannot be rewritten: a CommonJS + * module's names are only known at run time. + */ +function rewriteCjsReexport( + statement: string, + specifier: string, + dependency: string, + helperPrefix: string, + index: number, +): string { + const local = `${helperPrefix}reexport_${index}`; + const starAs = statement.match(EXPORT_STAR_AS); + if (starAs) { + return `const ${local} = ${helperPrefix}namespace(${specifier}); export { ${local} as ${ + starAs[1] + } }`; + } + const named = statement.match(EXPORT_NAMED); + if (named) { + const statements = [`const ${local} = require(${specifier})`]; + const exported: string[] = []; + parseModuleSpecifiers(named[1]!).forEach((entry, position) => { + const binding = `${local}_${position}`; + const value = entry.name === "default" && !entry.nameIsString + ? `${helperPrefix}interopDefault(${local})` + : entry.nameIsString + ? `${local}[${entry.name}]` + : `${local}.${entry.name}`; + statements.push(`const ${binding} = ${value}`); + exported.push(`${binding} as ${entry.alias ?? entry.name}`); + }); + return `${statements.join("; ")}; export { ${exported.join(", ")} }`; + } + if (EXPORT_STAR.test(statement)) { + throw new Error( + `export * from CommonJS package ${ + JSON.stringify(dependency) + } is not supported; export its names explicitly`, + ); + } + throw new Error( + `Unsupported re-export from CommonJS package ${JSON.stringify(dependency)}: ${statement}`, + ); +} + +/** + * The index just past the `)` closing a dynamic `import(` whose specifier ends + * at `from`, skipping an options argument; -1 when it cannot be found. + */ +function findDynamicImportEnd(code: string, from: number): number { + let depth = 0; + for (let index = from; index < code.length; index++) { + const char = code[index]!; + if (char === '"' || char === "'" || char === "`") { + for (index++; index < code.length && code[index] !== char; index++) { + if (code[index] === "\\") index++; + } + continue; + } + if (char === "/" && code[index + 1] === "/") { + const end = code.indexOf("\n", index); + if (end === -1) return -1; + index = end; + continue; + } + if (char === "/" && code[index + 1] === "*") { + const end = code.indexOf("*/", index + 2); + if (end === -1) return -1; + index = end + 1; + continue; + } + if (char === "(" || char === "{" || char === "[") depth++; + else if (char === "}" || char === "]") depth--; + else if (char === ")") { + if (depth === 0) return index + 1; + depth--; + } + } + return -1; +} + /** * Rewrite a bundled compiled-binary route's user dependency imports. * @@ -290,6 +414,7 @@ export async function rewriteCompiledUserDependencyImportsForRoute( if (userDeps.size === 0) return code; const { masked, imports, unmask } = await parseMaskedImports(code); const edits: { start: number; end: number; text: string }[] = []; + let reexports = 0; for (const imported of imports) { if (typeof imported.n !== "string") continue; @@ -328,19 +453,27 @@ export async function rewriteCompiledUserDependencyImportsForRoute( const required = `require(${specifier})`; if (isDynamic) { // A dynamic import stays lazy and asynchronous: a load failure rejects - // the returned promise instead of throwing synchronously. Imports with - // options (`import(x, {...})`) are left unchanged. - const close = masked.slice(imported.e).match(DYNAMIC_IMPORT_CLOSE); - if (!close) continue; + // the returned promise instead of throwing synchronously. An options + // argument (import attributes) is consumed: require reads JSON itself. + const end = findDynamicImportEnd(masked, imported.e); + if (end === -1) continue; edits.push({ start: imported.ss, - end: imported.e + close[0].length, + end, text: `Promise.resolve().then(() => ${helperPrefix}namespace(${specifier}))`, }); continue; } const statement = masked.slice(imported.ss, imported.se); + if (statement.startsWith("export")) { + edits.push({ + start: imported.ss, + end: imported.se, + text: rewriteCjsReexport(statement, specifier, imported.n, helperPrefix, reexports++), + }); + continue; + } if (SIDE_EFFECT_IMPORT.test(statement)) { edits.push({ start: imported.ss, end: imported.se, text: required }); continue; From 50069a440dc41e9b9188754b60a4254adb4dc448 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 00:21:38 +0100 Subject: [PATCH 10/22] fix(routes): link CJS module parents, honor exports for prefix-only names, own-only namespaces (#5471) --- .../module-loader/external-import-rewriter.ts | 133 ++++++++++++++++-- src/routing/api/module-loader/loader.test.ts | 87 ++++++++++++ 2 files changed, 211 insertions(+), 9 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 9db7e23050..2bbc8fd860 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -195,6 +195,100 @@ function __vf_packageInstalled(name, parentDir) { if (__vf_dirname(dir) === dir) return false; } } +// Node's package "exports" resolution for require(), used where the runtime's +// resolver cannot be: a target is undefined when nothing matched, null when the +// package blocks the subpath. Conditions are tried in the object's key order. +var __vf_requireConditions = { require: true, node: true, "node-addons": true, default: true }; +function __vf_exportsTarget(target, star) { + if (typeof target === "string") { + if (!target.startsWith("./")) return null; + var value = star === undefined ? target : target.split("*").join(star); + var segments = value.slice(2).split("/"); + for (var i = 0; i < segments.length; i++) { + var segment = segments[i]; + if (segment === "" || segment === "." || segment === ".." || segment === "node_modules" || segment.indexOf("\\\\") !== -1) return null; + } + return value; + } + if (Array.isArray(target)) { + for (var j = 0; j < target.length; j++) { + var candidate = __vf_exportsTarget(target[j], star); + if (typeof candidate === "string") return candidate; + } + return null; + } + if (target && typeof target === "object") { + var conditions = Object.keys(target); + for (var k = 0; k < conditions.length; k++) { + if (!__vf_requireConditions[conditions[k]]) continue; + var resolved = __vf_exportsTarget(target[conditions[k]], star); + if (resolved !== undefined) return resolved; + } + return undefined; + } + return null; +} +function __vf_exportsSubpath(exportsField, subpath) { + var keys = exportsField && typeof exportsField === "object" && !Array.isArray(exportsField) ? Object.keys(exportsField) : []; + var dotKeys = keys.filter(function (key) { return key.startsWith("."); }).length; + if (dotKeys !== 0 && dotKeys !== keys.length) return null; + var map = dotKeys === 0 ? { ".": exportsField } : exportsField; + if (Object.prototype.hasOwnProperty.call(map, subpath) && subpath.indexOf("*") === -1) { + return __vf_exportsTarget(map[subpath], undefined); + } + var bestKey = null; + var bestPrefix = -1; + var bestStar; + var patterns = Object.keys(map); + for (var i = 0; i < patterns.length; i++) { + var key = patterns[i]; + var starAt = key.indexOf("*"); + if (starAt === -1 || key.indexOf("*", starAt + 1) !== -1) continue; + var prefix = key.slice(0, starAt); + var suffix = key.slice(starAt + 1); + if (subpath.length < key.length || !subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue; + if (prefix.length > bestPrefix || (prefix.length === bestPrefix && key.length > bestKey.length)) { + bestKey = key; + bestPrefix = prefix.length; + bestStar = subpath.slice(prefix.length, subpath.length - suffix.length); + } + } + return bestKey === null ? undefined : __vf_exportsTarget(map[bestKey], bestStar); +} +// Resolves id inside an installed package directory: through its "exports" +// map when it has one, as Node does, otherwise by plain file lookup. Errors +// name the id, never the host path. +function __vf_resolveInPackage(id, dir, packageDir) { + var name = __vf_packageName(id); + var manifest = __vf_resolve(packageDir, "package.json"); + var exportsField; + if (__vf_isFile(manifest)) { + try { + exportsField = JSON.parse(Deno.readTextFileSync(manifest)).exports; + } catch (_) { + throw new Error("invalid package.json for " + JSON.stringify(name)); + } + } + if (exportsField === undefined || exportsField === null) { + // A relative id keeps host paths out of any error it raises. + return __vf_resolveLocal("./node_modules/" + id, dir); + } + var subpath = "." + id.slice(name.length); + var target = __vf_exportsSubpath(exportsField, subpath); + if (typeof target !== "string") { + var blocked = new Error("npm package " + JSON.stringify(name) + " does not export " + JSON.stringify(subpath)); + blocked.code = "ERR_PACKAGE_PATH_NOT_EXPORTED"; + throw blocked; + } + var exported = __vf_resolve(packageDir, target); + __vf_assertContained(__vf_canonicalize(exported), id); + if (!__vf_isFile(exported)) { + var missing = new Error("Cannot find module " + JSON.stringify(id)); + missing.code = "MODULE_NOT_FOUND"; + throw missing; + } + return exported; +} // A package named like a prefix-only built-in (test, sqlite, sea) is looked up // in node_modules directly: the runtime's resolver may claim the bare name. function __vf_resolvePrefixOnlyName(id, parentDir) { @@ -202,8 +296,7 @@ function __vf_resolvePrefixOnlyName(id, parentDir) { var packageDir = __vf_resolve(dir, "node_modules", __vf_packageName(id)); var exists = false; try { Deno.statSync(packageDir); exists = true; } catch (_) { /* expected: not installed at this level */ } - // A relative id keeps host paths out of any error it raises. - if (exists) return __vf_resolveLocal("./node_modules/" + id, dir); + if (exists) return __vf_resolveInPackage(id, dir, packageDir); if (__vf_dirname(dir) === dir) break; } var missing = new Error("npm package " + JSON.stringify(__vf_packageName(id)) + " is not installed in this runtime's node_modules"); @@ -294,7 +387,12 @@ function __vf_isBuiltin(id) { var slashIdx = id.indexOf("/"); return slashIdx > 0 && __vf_builtinSet.has(id.slice(0, slashIdx)); } -function __vf_loadCjs(id, parentDir) { +// As in Node, every required module records the module that first required it +// as module.parent, and each requiring module lists it once in module.children. +function __vf_linkChild(parentMod, mod) { + if (parentMod && parentMod.children.indexOf(mod) === -1) parentMod.children.push(mod); +} +function __vf_loadCjs(id, parentDir, parentMod) { if (__vf_isBuiltin(id)) return __vf_builtinRequire(id); var resolved; if (__vf_isPathId(id)) { @@ -319,23 +417,30 @@ function __vf_loadCjs(id, parentDir) { __vf_assertContained(resolved, id); // The cache holds module records, so a circular require sees a // reassigned module.exports while the first module is still evaluating. - if (resolved in __vf_cache) return __vf_cache[resolved].exports; + if (resolved in __vf_cache) { + __vf_linkChild(parentMod, __vf_cache[resolved]); + return __vf_cache[resolved].exports; + } var code = Deno.readTextFileSync(resolved); if (resolved.endsWith(".json")) { var json = JSON.parse(code); - __vf_cache[resolved] = { exports: json }; + var jsonMod = { exports: json, id: resolved, filename: resolved, loaded: true, children: [], parent: parentMod }; + __vf_cache[resolved] = jsonMod; + __vf_linkChild(parentMod, jsonMod); return json; } // Node strips a leading hashbang from CommonJS sources; a Function body // cannot contain one. Commenting it out keeps line numbers intact. if (code.startsWith("#!")) code = "//" + code.slice(2); var dir = __vf_dirname(resolved); - var childReq = function(childId) { return __vf_loadCjs(childId, dir); }; + var mod; + var childReq = function(childId) { return __vf_loadCjs(childId, dir, mod); }; childReq.resolve = function(childId) { return __vf_resolveId(childId, dir); }; childReq.ensure = function(mods, cb) { cb(); }; // The CommonJS module API packages use: module.require, id, filename, path. - var mod = { exports: {}, id: resolved, filename: resolved, path: dir, loaded: false, children: [], paths: [], require: childReq }; + mod = { exports: {}, id: resolved, filename: resolved, path: dir, loaded: false, children: [], paths: [], parent: parentMod, require: childReq }; __vf_cache[resolved] = mod; + __vf_linkChild(parentMod, mod); // As in Node, top-level \`this\` is module.exports, and a module whose // evaluation throws is dropped from the cache so a retry fails again. try { @@ -343,6 +448,10 @@ function __vf_loadCjs(id, parentDir) { fn.call(mod.exports, mod.exports, childReq, mod, resolved, dir, globalThis, globalThis, undefined); } catch (error) { delete __vf_cache[resolved]; + if (parentMod) { + var childIdx = parentMod.children.indexOf(mod); + if (childIdx !== -1) parentMod.children.splice(childIdx, 1); + } throw error; } mod.loaded = true; @@ -352,7 +461,10 @@ function __vf_resolveId(id, parentDir) { if (__vf_isPathId(id)) return __vf_resolveLocal(id, parentDir); return __vf_resolveBare(id, parentDir); } -var routeRequire = function(id) { return __vf_loadCjs(id, __VF_PROJECT_DIR__); }; +// The route is the parent of the modules it requires, as the module a Node +// createRequire() call is anchored to would be. +var routeModule = { exports: {}, id: __VF_PROJECT_DIR__, filename: __VF_PROJECT_DIR__, path: __VF_PROJECT_DIR__, loaded: true, children: [], paths: [], parent: null }; +var routeRequire = function(id) { return __vf_loadCjs(id, __VF_PROJECT_DIR__, routeModule); }; routeRequire.resolve = function(id) { return __vf_resolveId(id, __VF_PROJECT_DIR__); }; routeRequire.ensure = function(mods, cb) { cb(); }; // One namespace per resolved module, as with ES module namespaces: repeated @@ -368,7 +480,10 @@ __vf_namespace = function (id) { var m = routeRequire(id); var ns = Object.create(null); if (m !== null && (typeof m === "object" || typeof m === "function")) { - for (var name in m) if (name !== "default") ns[name] = m[name]; + // Own properties only: native interop never exposes inherited ones, and + // reading them could run an inherited getter during import. + var names = Object.keys(m); + for (var i = 0; i < names.length; i++) if (names[i] !== "default") ns[names[i]] = m[names[i]]; } ns.default = m; namespaces.set(key, ns); diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 45844c358e..896c86d968 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -5823,6 +5823,33 @@ describe("route CommonJS require (#5471)", () => { assertEquals(await responseText(GET), "fnfn"); }); + denoIt("exposes only own properties as CommonJS named exports", async () => { + const files = { + "node_modules/proto-fixture/package.json": JSON.stringify({ + name: "proto-fixture", + main: "index.js", + }), + "node_modules/proto-fixture/index.js": [ + "const base = { inherited: 'proto' };", + "Object.defineProperty(base, 'throwing', { enumerable: true, get() { throw new Error('getter ran'); } });", + "const exported = Object.create(base);", + "exported.own = 'own';", + "module.exports = exported;", + ].join("\n"), + }; + const GET = await loadCompiledRoute( + [ + 'import * as ns from "proto-fixture";', + "export const GET = async () => {", + ' const dyn = await import("proto-fixture");', + ' return new Response([ns.own, "inherited" in ns, "throwing" in dyn].join(":"));', + "};", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "own:false:false"); + }); + denoIt("resolves a package's own nested dependency before the hoisted copy", async () => { const files = { "node_modules/outer/package.json": JSON.stringify({ name: "outer", main: "index.js" }), @@ -6017,6 +6044,12 @@ describe("route CommonJS require (#5471)", () => { return error instanceof Error ? error.message : String(error); } + function thrownError(fn: () => unknown): Error & { code?: string } { + const error = assertThrows(fn); + if (!(error instanceof Error)) throw new Error(`expected an Error, got ${String(error)}`); + return error; + } + denoIt("names an npm package that is not installed instead of a host path", async () => { const projectDir = await writeProject({}); const vfRequire = await importCompiledBinaryRequireShim(projectDir); @@ -6287,6 +6320,60 @@ describe("route CommonJS require (#5471)", () => { assertEquals(vfRequire("test"), "package named test"); }); + denoIt("applies the exports map of a package named like a prefix-only built-in", async () => { + const projectDir = await writeProject({ + "node_modules/test/package.json": JSON.stringify({ + name: "test", + exports: { + ".": { import: "./esm.mjs", require: "./main.js" }, + "./public": "./public.js", + "./features/*": "./features/*.js", + "./features/private": null, + }, + }), + "node_modules/test/main.js": "module.exports = 'main';", + "node_modules/test/public.js": "module.exports = 'public';", + "node_modules/test/private.js": "module.exports = 'private';", + "node_modules/test/features/a.js": "module.exports = 'feature a';", + "node_modules/test/features/private.js": "module.exports = 'blocked feature';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("test"), "main"); + assertEquals(vfRequire("test/public"), "public"); + assertEquals(vfRequire("test/features/a"), "feature a"); + for (const id of ["test/private.js", "test/features/private", "test/package.json"]) { + const error = thrownError(() => vfRequire(id)); + assertEquals(error.code, "ERR_PACKAGE_PATH_NOT_EXPORTED", id); + assertEquals(error.message.includes(projectDir), false, error.message); + } + }); + + denoIt("links CommonJS parent and child module records", async () => { + const projectDir = await writeProject({ + "node_modules/cli-pkg/package.json": JSON.stringify({ name: "cli-pkg", main: "index.js" }), + "node_modules/cli-pkg/index.js": [ + "const child = require('./child');", + "require('./child');", + "module.exports = {", + " topHasParent: Boolean(module.parent),", + " childParentIsTop: child.parent === module,", + " children: module.children.length,", + " childIsChild: module.children[0] === child.mod,", + "};", + ].join("\n"), + "node_modules/cli-pkg/child.js": "module.exports = { parent: module.parent, mod: module };", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("cli-pkg"), { + topHasParent: true, + childParentIsTop: true, + children: 1, + childIsChild: true, + }); + }); + denoIt("hides host paths in other package resolution errors", async () => { const projectDir = await writeProject({ "node_modules/bad-config/package.json": "{ not json", From c304f57389a8265ce024f4b96f52cafedf4b6d66 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 08:14:59 +0100 Subject: [PATCH 11/22] fix(routes): sealed CJS namespaces, parent.require, regex and brace-aware import scanning (#5471) --- .../external-import-rewriter.test.ts | 20 +++++++++ .../module-loader/external-import-rewriter.ts | 5 +++ src/routing/api/module-loader/loader.test.ts | 45 +++++++++++++++++++ .../import-rewriter/route-adapter.ts | 38 +++++++++++++++- 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index 6443ed94fc..35bac97234 100644 --- a/src/routing/api/module-loader/external-import-rewriter.test.ts +++ b/src/routing/api/module-loader/external-import-rewriter.test.ts @@ -193,6 +193,26 @@ describe("external-import-rewriter", () => { ); }); + it("rewrites a dynamic import whose attributes contain a regex literal", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + `const lazy = import("lodash", { with: { type: /[)]/.test("") ? "json" : "json" } });`, + deps, + ); + assertEquals( + out, + 'const lazy = Promise.resolve().then(() => __vf_namespace("lodash"));', + ); + }); + + it("rewrites a named import whose string-literal name contains a brace", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + `import { "}" as brace } from "lodash";`, + deps, + ); + assertStringIncludes(out, `require("lodash")`); + assertStringIncludes(out, `"}": brace`); + }); + it("rewrites a named default import with interop", async () => { const out = await rewriteCompiledBinaryUserDependencyImports( `import { default as lodash, merge } from "lodash";`, diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 2bbc8fd860..61173c7ad1 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -467,6 +467,7 @@ var routeModule = { exports: {}, id: __VF_PROJECT_DIR__, filename: __VF_PROJECT_ var routeRequire = function(id) { return __vf_loadCjs(id, __VF_PROJECT_DIR__, routeModule); }; routeRequire.resolve = function(id) { return __vf_resolveId(id, __VF_PROJECT_DIR__); }; routeRequire.ensure = function(mods, cb) { cb(); }; +routeModule.require = routeRequire; // One namespace per resolved module, as with ES module namespaces: repeated // imports of a module share it, and distinct modules never do. As with native // CommonJS interop, \`default\` is the whole module.exports, also for modules @@ -486,6 +487,10 @@ __vf_namespace = function (id) { for (var i = 0; i < names.length; i++) if (names[i] !== "default") ns[names[i]] = m[names[i]]; } ns.default = m; + // Module namespaces are sealed: strict-mode writes, additions and deletes + // throw a TypeError, as they do on a native namespace. + Object.defineProperty(ns, Symbol.toStringTag, { value: "Module" }); + Object.freeze(ns); namespaces.set(key, ns); return ns; }; diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 896c86d968..392fcbd21a 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -5850,6 +5850,36 @@ describe("route CommonJS require (#5471)", () => { assertEquals(await responseText(GET), "own:false:false"); }); + denoIt("gives CommonJS namespaces the immutability of module namespaces", async () => { + const files = { + "node_modules/frozen-fixture/package.json": JSON.stringify({ + name: "frozen-fixture", + main: "index.js", + }), + "node_modules/frozen-fixture/index.js": "module.exports = { value: 1 };", + }; + const GET = await loadCompiledRoute( + [ + 'import * as ns from "frozen-fixture";', + "const attempt = (fn) => { try { fn(); return 'ok'; } catch (error) { return error.name; } };", + "const write = (target, key) => { target[key] = 2; };", + "const remove = (target, key) => { delete target[key]; };", + "export const GET = async () => {", + ' const dyn = await import("frozen-fixture");', + " return new Response([", + " attempt(() => write(ns, 'value')),", + " attempt(() => write(dyn, 'added')),", + " attempt(() => remove(ns, 'default')),", + " ns.value,", + " Object.isExtensible(dyn),", + " ].join(':'));", + "};", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "TypeError:TypeError:TypeError:1:false"); + }); + denoIt("resolves a package's own nested dependency before the hoisted copy", async () => { const files = { "node_modules/outer/package.json": JSON.stringify({ name: "outer", main: "index.js" }), @@ -6374,6 +6404,21 @@ describe("route CommonJS require (#5471)", () => { }); }); + denoIt("lets a top-level dependency require through module.parent", async () => { + const projectDir = await writeProject({ + "node_modules/plugin-host/package.json": JSON.stringify({ + name: "plugin-host", + main: "index.js", + }), + "node_modules/plugin-host/index.js": "module.exports = module.parent.require('peer-dep');", + "node_modules/peer-dep/package.json": JSON.stringify({ name: "peer-dep", main: "index.js" }), + "node_modules/peer-dep/index.js": "module.exports = 'peer';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("plugin-host"), "peer"); + }); + denoIt("hides host paths in other package resolution errors", async () => { const projectDir = await writeProject({ "node_modules/bad-config/package.json": "{ not json", diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index e0d9f5917f..3b8d6a1e35 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -202,8 +202,10 @@ export function rewriteCompiledVeryfrontImportsForRoute(code: string): string { return transformed; } +/** A `{ ... }` specifier list; quoted names may contain braces. */ +const SPECIFIER_LIST = `\\{(?:${JS_STRING_LITERAL}|[^}"'])*\\}`; const IMPORT_CLAUSE = new RegExp( - `^(?:(${JS_IDENTIFIER})\\s*(?:,\\s*)?)?(?:\\*\\s*as\\s+(${JS_IDENTIFIER})|(\\{[^}]*\\}))?$`, + `^(?:(${JS_IDENTIFIER})\\s*(?:,\\s*)?)?(?:\\*\\s*as\\s+(${JS_IDENTIFIER})|(${SPECIFIER_LIST}))?$`, "u", ); /** Import attributes (`with { type: "json" }`) and an optional semicolon. */ @@ -360,18 +362,44 @@ function rewriteCjsReexport( ); } +/** Characters after which a `/` begins a regex literal rather than dividing. */ +const REGEX_PRECEDERS = new Set([..."(,=:[!&|?{};+-*%<>~^"]); + +/** The index just past the regex literal (and its flags) starting at `start`. */ +function findRegexLiteralEnd(code: string, start: number): number { + let inClass = false; + for (let index = start + 1; index < code.length; index++) { + const char = code[index]!; + if (char === "\n") return -1; + if (char === "\\") index++; + else if (char === "[") inClass = true; + else if (char === "]") inClass = false; + else if (char === "/" && !inClass) { + let end = index + 1; + while (end < code.length && /[a-z]/i.test(code[end]!)) end++; + return end; + } + } + return -1; +} + /** * The index just past the `)` closing a dynamic `import(` whose specifier ends * at `from`, skipping an options argument; -1 when it cannot be found. */ function findDynamicImportEnd(code: string, from: number): number { let depth = 0; + // The last significant character decides whether a `/` starts a regex + // literal or divides, as in the usual lexer heuristic. + let previous = "("; for (let index = from; index < code.length; index++) { const char = code[index]!; + if (/\s/.test(char)) continue; if (char === '"' || char === "'" || char === "`") { for (index++; index < code.length && code[index] !== char; index++) { if (code[index] === "\\") index++; } + previous = char; continue; } if (char === "/" && code[index + 1] === "/") { @@ -386,6 +414,14 @@ function findDynamicImportEnd(code: string, from: number): number { index = end + 1; continue; } + if (char === "/" && REGEX_PRECEDERS.has(previous)) { + const end = findRegexLiteralEnd(code, index); + if (end === -1) return -1; + index = end - 1; + previous = "/"; + continue; + } + previous = char; if (char === "(" || char === "{" || char === "[") depth++; else if (char === "}" || char === "]") depth--; else if (char === ")") { From a4e0d4038aa65d461801af486792ec735677c63e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 09:09:56 +0100 Subject: [PATCH 12/22] fix(routes): isolate CJS shim intrinsics, lazy namespace getters, builtin subpaths, module.paths, CJS dynamic import (#5471) --- docs/guides/api-routes.md | 3 + .../external-import-rewriter.test.ts | 31 +++- .../module-loader/external-import-rewriter.ts | 96 +++++++++--- .../api/module-loader/loader-helpers.ts | 33 +++++ src/routing/api/module-loader/loader.test.ts | 139 +++++++++++++++++- src/transforms/import-rewriter/core.test.ts | 2 +- .../import-rewriter/route-adapter.ts | 9 +- 7 files changed, 288 insertions(+), 25 deletions(-) diff --git a/docs/guides/api-routes.md b/docs/guides/api-routes.md index 7a84d40ca7..e25af6082b 100644 --- a/docs/guides/api-routes.md +++ b/docs/guides/api-routes.md @@ -235,6 +235,9 @@ ESM as a whole, so an `.mjs` file inside a CommonJS package fails with `ES module file "" cannot be loaded with require`. Re-export named values from a CommonJS package (`export { name } from "pkg"`); `export * from "pkg"` is not supported, because its names are only known at run time. +Inside a CommonJS package, `import("./file.mjs")` with a literal relative or +absolute path loads that file relative to the package; a computed specifier +does not. Packages that ship a native addon (`.node` file) are not supported. Loading one fails with `native addon "" cannot be loaded`. A package that is listed diff --git a/src/routing/api/module-loader/external-import-rewriter.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index 35bac97234..216722884e 100644 --- a/src/routing/api/module-loader/external-import-rewriter.test.ts +++ b/src/routing/api/module-loader/external-import-rewriter.test.ts @@ -149,7 +149,7 @@ describe("external-import-rewriter", () => { ); assertStringIncludes( out, - `Promise.resolve().then(() => __vf_namespace("lodash"))`, + `(async () => { await null; return __vf_namespace("lodash"); })()`, ); }); @@ -188,7 +188,7 @@ describe("external-import-rewriter", () => { out, [ 'const data = __vf_interopDefault(require("lodash/data.json"));', - 'const lazy = Promise.resolve().then(() => __vf_namespace("lodash/data.json"));', + 'const lazy = (async () => { await null; return __vf_namespace("lodash/data.json"); })();', ].join("\n"), ); }); @@ -200,7 +200,7 @@ describe("external-import-rewriter", () => { ); assertEquals( out, - 'const lazy = Promise.resolve().then(() => __vf_namespace("lodash"));', + 'const lazy = (async () => { await null; return __vf_namespace("lodash"); })();', ); }); @@ -213,6 +213,31 @@ describe("external-import-rewriter", () => { assertStringIncludes(out, `"}": brace`); }); + it("applies default interop to a quoted default name", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + [ + `import { "default" as value } from "lodash";`, + `export { "default" as reexported } from "lodash";`, + ].join("\n"), + deps, + ); + assertStringIncludes(out, `const value = __vf_interopDefault(`); + assertStringIncludes(out, `= __vf_interopDefault(__vf_reexport_0)`); + const escaped = await rewriteCompiledBinaryUserDependencyImports( + `import { \\u0064efault as value } from "lodash";`, + deps, + ); + assertStringIncludes(escaped, `const value = __vf_interopDefault(`); + }); + + it("rewrites a dynamic import without the Promise binding", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + `const m = import("lodash");`, + deps, + ); + assertEquals(out.includes("Promise"), false, out); + }); + it("rewrites a named default import with interop", async () => { const out = await rewriteCompiledBinaryUserDependencyImports( `import { default as lodash, merge } from "lodash";`, diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 61173c7ad1..c0d0a8fd60 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -125,6 +125,15 @@ import { createRequire as __vf_createRequire } from "node:module"; import { dirname as __vf_dirname, resolve as __vf_resolve } from "node:path"; var __vf_namespace; var require = (function () { +// Route code shares this module scope, so a top-level route binding named like +// an intrinsic (\`const Set = ...\`) would shadow it here, or still be +// uninitialized when this runs. These locals come from the global object, +// which is reached through syntax alone. +var __vf_global = (function () {}).constructor("return this")(); +var Array = __vf_global.Array, Deno = __vf_global.Deno, Error = __vf_global.Error; +var Function = __vf_global.Function, JSON = __vf_global.JSON, Map = __vf_global.Map; +var Object = __vf_global.Object, Set = __vf_global.Set, Symbol = __vf_global.Symbol; +var TypeError = __vf_global.TypeError, globalThis = __vf_global; var __vf_builtinRequire = __vf_createRequire(__VF_PROJECT_DIR__ + "/package.json"); var __vf_builtinSet = new Set(__VF_BUILTIN_LIST__); // Every built-in this runtime provides, not only the shared list. Node's @@ -202,7 +211,7 @@ var __vf_requireConditions = { require: true, node: true, "node-addons": true, d function __vf_exportsTarget(target, star) { if (typeof target === "string") { if (!target.startsWith("./")) return null; - var value = star === undefined ? target : target.split("*").join(star); + var value = star === void 0 ? target : target.split("*").join(star); var segments = value.slice(2).split("/"); for (var i = 0; i < segments.length; i++) { var segment = segments[i]; @@ -222,9 +231,9 @@ function __vf_exportsTarget(target, star) { for (var k = 0; k < conditions.length; k++) { if (!__vf_requireConditions[conditions[k]]) continue; var resolved = __vf_exportsTarget(target[conditions[k]], star); - if (resolved !== undefined) return resolved; + if (resolved !== void 0) return resolved; } - return undefined; + return void 0; } return null; } @@ -234,7 +243,7 @@ function __vf_exportsSubpath(exportsField, subpath) { if (dotKeys !== 0 && dotKeys !== keys.length) return null; var map = dotKeys === 0 ? { ".": exportsField } : exportsField; if (Object.prototype.hasOwnProperty.call(map, subpath) && subpath.indexOf("*") === -1) { - return __vf_exportsTarget(map[subpath], undefined); + return __vf_exportsTarget(map[subpath], void 0); } var bestKey = null; var bestPrefix = -1; @@ -253,7 +262,7 @@ function __vf_exportsSubpath(exportsField, subpath) { bestStar = subpath.slice(prefix.length, subpath.length - suffix.length); } } - return bestKey === null ? undefined : __vf_exportsTarget(map[bestKey], bestStar); + return bestKey === null ? void 0 : __vf_exportsTarget(map[bestKey], bestStar); } // Resolves id inside an installed package directory: through its "exports" // map when it has one, as Node does, otherwise by plain file lookup. Errors @@ -269,7 +278,7 @@ function __vf_resolveInPackage(id, dir, packageDir) { throw new Error("invalid package.json for " + JSON.stringify(name)); } } - if (exportsField === undefined || exportsField === null) { + if (exportsField === void 0 || exportsField === null) { // A relative id keeps host paths out of any error it raises. return __vf_resolveLocal("./node_modules/" + id, dir); } @@ -383,9 +392,48 @@ function __vf_resolveLocal(id, parentDir) { } function __vf_isBuiltin(id) { if (id.startsWith("node:")) return true; - if (__vf_builtinSet.has(id)) return true; - var slashIdx = id.indexOf("/"); - return slashIdx > 0 && __vf_builtinSet.has(id.slice(0, slashIdx)); + // Only the runtime's own built-in ids: "fs/promises" is a built-in, while + // "fs/plugin" resolves from an installed package named fs, as in Node. + return __vf_builtinSet.has(id); +} +// Node's module.paths: each ancestor's node_modules, stopping at the project +// root so host directories above it are never listed. +function __vf_nodeModulePaths(dir) { + var paths = []; + for (var current = dir; ; current = __vf_dirname(current)) { + if (!/(^|[\\/])node_modules$/.test(current)) paths.push(__vf_resolve(current, "node_modules")); + if (current === __vf_projectRoot || __vf_dirname(current) === current) break; + } + return paths; +} +// require.resolve(id, { paths }): id resolves from each listed directory in +// turn, and the result must stay inside the project. +function __vf_resolveWithOptions(id, parentDir, options) { + if (!options || options.paths === void 0) return __vf_resolveId(id, parentDir); + if (!Array.isArray(options.paths)) throw new TypeError("require.resolve options.paths must be an array"); + for (var i = 0; i < options.paths.length; i++) { + try { + var found = __vf_resolveId(id, __vf_resolve(parentDir, options.paths[i])); + if (!__vf_isBuiltin(id)) __vf_assertContained(__vf_canonicalize(found), id); + return found; + } catch (error) { + if (!error || error.code !== "MODULE_NOT_FOUND") throw error; + } + } + var missing = new Error("Cannot find module " + JSON.stringify(id)); + missing.code = "MODULE_NOT_FOUND"; + throw missing; +} +// A CommonJS module evaluated through Function would resolve its own +// import("./x.mjs") against this handler, not its file. Literal relative and +// absolute dynamic imports are routed here instead. +var __vf_cjsDynamicImport = /(^|[^.\\w$])import\\s*\\(\\s*(["'])(\\.{1,2}\\/[^"'\\\\\\r\\n]*|\\/[^"'\\\\\\r\\n]*)\\2\\s*\\)/g; +function __vf_importFrom(spec, parentDir) { + return (async function () { + var target = __vf_resolve(parentDir, spec); + __vf_assertContained(__vf_canonicalize(target), spec); + return await import(__vf_builtinRequire("node:url").pathToFileURL(target).href); + })(); } // As in Node, every required module records the module that first required it // as module.parent, and each requiring module lists it once in module.children. @@ -433,19 +481,23 @@ function __vf_loadCjs(id, parentDir, parentMod) { // cannot contain one. Commenting it out keeps line numbers intact. if (code.startsWith("#!")) code = "//" + code.slice(2); var dir = __vf_dirname(resolved); + code = code.replace(__vf_cjsDynamicImport, function (_match, lead, _quote, spec) { + return lead + "__vf_cjsImport(" + JSON.stringify(spec) + ")"; + }); + var cjsImport = function (spec) { return __vf_importFrom(spec, dir); }; var mod; var childReq = function(childId) { return __vf_loadCjs(childId, dir, mod); }; - childReq.resolve = function(childId) { return __vf_resolveId(childId, dir); }; + childReq.resolve = function(childId, options) { return __vf_resolveWithOptions(childId, dir, options); }; childReq.ensure = function(mods, cb) { cb(); }; // The CommonJS module API packages use: module.require, id, filename, path. - mod = { exports: {}, id: resolved, filename: resolved, path: dir, loaded: false, children: [], paths: [], parent: parentMod, require: childReq }; + mod = { exports: {}, id: resolved, filename: resolved, path: dir, loaded: false, children: [], paths: __vf_nodeModulePaths(dir), parent: parentMod, require: childReq }; __vf_cache[resolved] = mod; __vf_linkChild(parentMod, mod); // As in Node, top-level \`this\` is module.exports, and a module whose // evaluation throws is dropped from the cache so a retry fails again. try { - var fn = new Function("exports", "require", "module", "__filename", "__dirname", "global", "globalThis", "Worker", code); - fn.call(mod.exports, mod.exports, childReq, mod, resolved, dir, globalThis, globalThis, undefined); + var fn = new Function("exports", "require", "module", "__filename", "__dirname", "global", "globalThis", "Worker", "__vf_cjsImport", code); + fn.call(mod.exports, mod.exports, childReq, mod, resolved, dir, globalThis, globalThis, void 0, cjsImport); } catch (error) { delete __vf_cache[resolved]; if (parentMod) { @@ -463,9 +515,9 @@ function __vf_resolveId(id, parentDir) { } // The route is the parent of the modules it requires, as the module a Node // createRequire() call is anchored to would be. -var routeModule = { exports: {}, id: __VF_PROJECT_DIR__, filename: __VF_PROJECT_DIR__, path: __VF_PROJECT_DIR__, loaded: true, children: [], paths: [], parent: null }; +var routeModule = { exports: {}, id: __VF_PROJECT_DIR__, filename: __VF_PROJECT_DIR__, path: __VF_PROJECT_DIR__, loaded: true, children: [], paths: __vf_nodeModulePaths(__VF_PROJECT_DIR__), parent: null }; var routeRequire = function(id) { return __vf_loadCjs(id, __VF_PROJECT_DIR__, routeModule); }; -routeRequire.resolve = function(id) { return __vf_resolveId(id, __VF_PROJECT_DIR__); }; +routeRequire.resolve = function(id, options) { return __vf_resolveWithOptions(id, __VF_PROJECT_DIR__, options); }; routeRequire.ensure = function(mods, cb) { cb(); }; routeModule.require = routeRequire; // One namespace per resolved module, as with ES module namespaces: repeated @@ -483,8 +535,18 @@ __vf_namespace = function (id) { if (m !== null && (typeof m === "object" || typeof m === "function")) { // Own properties only: native interop never exposes inherited ones, and // reading them could run an inherited getter during import. + // Getters stay lazy: an export getter runs when the name is read, not + // while the namespace is built. var names = Object.keys(m); - for (var i = 0; i < names.length; i++) if (names[i] !== "default") ns[names[i]] = m[names[i]]; + for (var i = 0; i < names.length; i++) { + if (names[i] === "default") continue; + var descriptor = Object.getOwnPropertyDescriptor(m, names[i]); + if (descriptor && "value" in descriptor) { + Object.defineProperty(ns, names[i], { value: descriptor.value, enumerable: true, writable: true }); + } else { + Object.defineProperty(ns, names[i], { enumerable: true, get: (function (key) { return function () { return m[key]; }; })(names[i]) }); + } + } } ns.default = m; // Module namespaces are sealed: strict-mode writes, additions and deletes @@ -496,7 +558,7 @@ __vf_namespace = function (id) { }; return routeRequire; })(); -function __vf_interopDefault(m) { return m && m.__esModule && m.default !== undefined ? m.default : m; } +function __vf_interopDefault(m) { return m && m.__esModule && m.default !== void 0 ? m.default : m; } `.trim(); export function getNodeExternalPackagesToResolve(userDeps: Map): string[] { diff --git a/src/routing/api/module-loader/loader-helpers.ts b/src/routing/api/module-loader/loader-helpers.ts index 76199fc3b1..1d9d647e3b 100644 --- a/src/routing/api/module-loader/loader-helpers.ts +++ b/src/routing/api/module-loader/loader-helpers.ts @@ -98,6 +98,39 @@ export function parseModuleSpecifiers(clause: string): ModuleSpecifier[] { return specifiers; } +const SIMPLE_ESCAPES: Record = { + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t", + v: "\v", + "0": "\0", +}; + +/** + * The name a specifier's `name` denotes, with string-literal quotes removed + * and escapes decoded, so `default`, `"default"` and `\u0064efault` compare + * equal. + */ +export function moduleExportNameValue(specifier: ModuleSpecifier): string { + const source = specifier.nameIsString ? specifier.name.slice(1, -1) : specifier.name; + return source.replace( + /\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2})|(\r\n|[\s\S]))/g, + (_match, codePoint?: string, unit?: string, byte?: string, other?: string) => { + const hex = codePoint ?? unit ?? byte; + if (hex !== undefined) return String.fromCodePoint(parseInt(hex, 16)); + if ( + other === "\n" || other === "\r" || other === "\r\n" || other === "\u2028" || + other === "\u2029" + ) { + return ""; + } + return SIMPLE_ESCAPES[other!] ?? other!; + }, + ); +} + /** * Convert a named import clause (`{ a, b as c, "d-e" as f }`) to a * destructuring pattern (`{ a, b: c, "d-e": f }`). A string-literal name diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 392fcbd21a..458b752026 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -1851,7 +1851,7 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, assertMatch(rewritten, /const widget = __vf_interopDefault\(require\("my-lib\/subpath"\)\)/); assertMatch( rewritten, - /Promise\.resolve\(\)\.then\(\(\) => __vf_namespace\("my-lib\/subpath"\)\)/, + /\(async \(\) => \{ await null; return __vf_namespace\("my-lib\/subpath"\); \}\)\(\)/, ); }); @@ -5850,6 +5850,103 @@ describe("route CommonJS require (#5471)", () => { assertEquals(await responseText(GET), "own:false:false"); }); + denoIt("keeps the prologue working when the route shadows intrinsics", async () => { + const files = { + "node_modules/shadow-fixture/package.json": JSON.stringify({ + name: "shadow-fixture", + main: "index.js", + }), + "node_modules/shadow-fixture/index.js": "module.exports = { value: 'v' };", + }; + const GET = await loadCompiledRoute( + [ + 'import fixture from "shadow-fixture";', + 'import * as ns from "shadow-fixture";', + "const Set = 'route set';", + "const Map = 'route map';", + "const Object = 'route object';", + "const Promise = null;", + "const Error = 'route error';", + "const JSON = 'route json';", + "export const GET = async () => {", + ' const dyn = await import("shadow-fixture");', + " return new Response([fixture.value, ns.value, dyn.value, Set, Map, Object, Promise, Error, JSON].join(':'));", + "};", + ].join("\n"), + files, + ); + assertEquals( + await responseText(GET), + "v:v:v:route set:route map:route object::route error:route json", + ); + }); + + denoIt("does not run export getters while building a namespace", async () => { + const files = { + "node_modules/lazy-fixture/package.json": JSON.stringify({ + name: "lazy-fixture", + main: "index.js", + }), + "node_modules/lazy-fixture/index.js": [ + "let ready = false;", + "Object.defineProperty(exports, 'lazy', { enumerable: true, get() { if (!ready) throw new Error('not ready'); return 'lazy'; } });", + "exports.ready = () => { ready = true; };", + ].join("\n"), + }; + const GET = await loadCompiledRoute( + [ + 'import * as ns from "lazy-fixture";', + "export const GET = async () => {", + ' const dyn = await import("lazy-fixture");', + " ns.ready();", + " return new Response(ns.lazy + ':' + dyn.lazy);", + "};", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "lazy:lazy"); + }); + + denoIt("applies default interop to a quoted default name", async () => { + const files = { + "node_modules/num-fixture/package.json": JSON.stringify({ + name: "num-fixture", + main: "index.js", + }), + "node_modules/num-fixture/index.js": "module.exports = 42;", + }; + const route = await loadCompiledRouteModule( + [ + 'import { "default" as value } from "num-fixture";', + 'export { "default" as reexported } from "num-fixture";', + "export const GET = async () => new Response(String(value));", + ].join("\n"), + files, + ); + assertEquals(await responseText(route.GET as () => Promise), "42"); + assertEquals(route.reexported, 42); + }); + + denoIt("resolves a dynamic import inside a CommonJS module relative to that module", async () => { + const files = { + "node_modules/bridge-fixture/package.json": JSON.stringify({ + name: "bridge-fixture", + main: "index.js", + }), + "node_modules/bridge-fixture/index.js": + "module.exports = () => import('./child.mjs').then((m) => m.value);", + "node_modules/bridge-fixture/child.mjs": "export const value = 'esm child';", + }; + const GET = await loadCompiledRoute( + [ + 'import load from "bridge-fixture";', + "export const GET = async () => new Response(await load());", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "esm child"); + }); + denoIt("gives CommonJS namespaces the immutability of module namespaces", async () => { const files = { "node_modules/frozen-fixture/package.json": JSON.stringify({ @@ -6404,6 +6501,46 @@ describe("route CommonJS require (#5471)", () => { }); }); + denoIt( + "resolves a package subpath that is not a built-in subpath from node_modules", + async () => { + const projectDir = await writeProject({ + "node_modules/fs/package.json": JSON.stringify({ name: "fs", main: "index.js" }), + "node_modules/fs/plugin.js": "module.exports = 'fs package plugin';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("fs/plugin"), "fs package plugin"); + assertEquals(typeof (vfRequire("fs/promises") as { readFile: unknown }).readFile, "function"); + }, + ); + + denoIt("gives modules their node_modules lookup paths and honors resolve paths", async () => { + const projectDir = await writeProject({ + "node_modules/plugin-loader/package.json": JSON.stringify({ + name: "plugin-loader", + main: "index.js", + }), + "node_modules/plugin-loader/index.js": [ + "const path = require('node:path');", + "module.exports = {", + " paths: module.paths,", + " viaPaths: path.basename(path.dirname(require.resolve('./plugin', { paths: [path.join(__dirname, 'plugins')] }))),", + "};", + ].join("\n"), + "node_modules/plugin-loader/plugins/plugin.js": "module.exports = 1;", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + const result = vfRequire("plugin-loader") as { paths: string[]; viaPaths: string }; + assertEquals( + result.paths[0], + join(projectDir, "node_modules", "plugin-loader", "node_modules"), + ); + assertEquals(result.paths.includes(join(projectDir, "node_modules")), true); + assertEquals(result.viaPaths, "plugins"); + }); + denoIt("lets a top-level dependency require through module.parent", async () => { const projectDir = await writeProject({ "node_modules/plugin-host/package.json": JSON.stringify({ diff --git a/src/transforms/import-rewriter/core.test.ts b/src/transforms/import-rewriter/core.test.ts index 4d0b6dd196..78be77d3d3 100644 --- a/src/transforms/import-rewriter/core.test.ts +++ b/src/transforms/import-rewriter/core.test.ts @@ -148,7 +148,7 @@ describe("import rewrite compatibility golden tests", () => { `const m = import("lodash/merge");`, new Map([["lodash", "^4"]]), ), - `Promise.resolve().then(() => __vf_namespace("lodash/merge"))`, + `(async () => { await null; return __vf_namespace("lodash/merge"); })()`, ); assertEquals( rewriteDenoNodeBuiltinImports(`import { readFile } from "fs";`), diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index 3b8d6a1e35..32e2d1d9f4 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -11,6 +11,7 @@ import { isWithinDirectory } from "#veryfront/security/path-validation.ts"; import { JS_IDENTIFIER, JS_STRING_LITERAL, + moduleExportNameValue, type ModuleSpecifier, parseModuleSpecifiers, resolveExportEntry as resolveRouteExportEntry, @@ -255,7 +256,7 @@ function rewriteCjsImportClause( return null; } for (const entry of specifiers) { - if (entry.name === "default" && !entry.nameIsString && entry.alias && !entry.aliasIsString) { + if (moduleExportNameValue(entry) === "default" && entry.alias && !entry.aliasIsString) { defaultAliases.push(entry.alias); } else { otherNames.push(entry.alias === undefined ? entry.name : `${entry.name} as ${entry.alias}`); @@ -340,7 +341,7 @@ function rewriteCjsReexport( const exported: string[] = []; parseModuleSpecifiers(named[1]!).forEach((entry, position) => { const binding = `${local}_${position}`; - const value = entry.name === "default" && !entry.nameIsString + const value = moduleExportNameValue(entry) === "default" ? `${helperPrefix}interopDefault(${local})` : entry.nameIsString ? `${local}[${entry.name}]` @@ -496,7 +497,9 @@ export async function rewriteCompiledUserDependencyImportsForRoute( edits.push({ start: imported.ss, end, - text: `Promise.resolve().then(() => ${helperPrefix}namespace(${specifier}))`, + // An async arrow returns an intrinsic promise whatever route code binds + // to `Promise`, and the await defers loading as import() does. + text: `(async () => { await null; return ${helperPrefix}namespace(${specifier}); })()`, }); continue; } From 2e9196b55619ed82fe8dd542cf86a0c3021dfb59 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 09:49:31 +0100 Subject: [PATCH 13/22] fix(routes): lexer-bounded dynamic imports, terminated rewrites, URL suffixes, string-safe CJS import scan (#5471) --- .../external-import-rewriter.test.ts | 42 +++++++ .../module-loader/external-import-rewriter.ts | 97 +++++++++++++++- src/routing/api/module-loader/loader.test.ts | 26 +++++ .../import-rewriter/route-adapter.ts | 104 ++++-------------- 4 files changed, 184 insertions(+), 85 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index 216722884e..d791ed3df1 100644 --- a/src/routing/api/module-loader/external-import-rewriter.test.ts +++ b/src/routing/api/module-loader/external-import-rewriter.test.ts @@ -277,6 +277,48 @@ describe("external-import-rewriter", () => { assertStringIncludes(out, `import("file:///srv/app/node_modules/esm-only/lazy.js")`); }); + it("keeps query and hash suffixes off the package file path", async () => { + const esmDeps = new Map([ + ["esm-only", { + entryUrl: "file:///srv/app/node_modules/esm-only/dist/index.mjs", + packageDir: "/srv/app/node_modules/esm-only", + }], + ]); + const out = await rewriteCompiledBinaryUserDependencyImports( + [ + `import raw from "esm-only/file.js?raw#v";`, + `import entry from "esm-only?raw";`, + `import cjs from "lodash/file.js?raw#v";`, + ].join("\n"), + new Map([["esm-only", "^1"], ["lodash", "^4"]]), + esmDeps, + ); + + assertStringIncludes(out, `from "file:///srv/app/node_modules/esm-only/file.js?raw#v"`); + assertStringIncludes(out, `from "file:///srv/app/node_modules/esm-only/dist/index.mjs?raw"`); + assertStringIncludes(out, `require("lodash/file.js")`); + }); + + it("terminates rewritten statements so the next line cannot continue them", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + [`import "lodash"`, `import _ from "lodash"`, "(() => {})();"].join("\n"), + deps, + ); + assertStringIncludes(out, 'require("lodash");\n'); + assertStringIncludes(out, 'const _ = __vf_interopDefault(require("lodash"));\n'); + }); + + it("finds the end of a dynamic import whose options use a regex after a keyword", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + `const lazy = import("lodash", { with: { type: (() => { return /[)]/.test("") ? "json" : "json"; })() } });`, + deps, + ); + assertEquals( + out, + 'const lazy = (async () => { await null; return __vf_namespace("lodash"); })();', + ); + }); + it("leaves ESM subpath imports untouched when they escape the package directory", async () => { const esmDeps = new Map([ ["esm-only", { diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index c0d0a8fd60..2d55cb70db 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -426,8 +426,97 @@ function __vf_resolveWithOptions(id, parentDir, options) { } // A CommonJS module evaluated through Function would resolve its own // import("./x.mjs") against this handler, not its file. Literal relative and -// absolute dynamic imports are routed here instead. -var __vf_cjsDynamicImport = /(^|[^.\\w$])import\\s*\\(\\s*(["'])(\\.{1,2}\\/[^"'\\\\\\r\\n]*|\\/[^"'\\\\\\r\\n]*)\\2\\s*\\)/g; +// absolute dynamic imports in code (not in strings, comments, templates or +// regex literals) are routed to a module-relative loader instead. +var __vf_regexAfterWord = { "return": 1, "typeof": 1, "instanceof": 1, "in": 1, "of": 1, "new": 1, "delete": 1, "void": 1, "throw": 1, "case": 1, "do": 1, "else": 1, "yield": 1, "await": 1 }; +var __vf_relativeImportCall = /import\\s*\\(\\s*(["'])((?:\\.{1,2}\\/|\\/)[^"'\\\\\\r\\n]*)\\1\\s*\\)/y; +function __vf_skipQuoted(code, i, quote) { + for (i++; i < code.length && code[i] !== quote; i++) { + if (code[i] === "\\\\") i++; + } + return i + 1; +} +function __vf_skipRegex(code, i) { + var inClass = false; + for (i++; i < code.length; i++) { + var c = code[i]; + if (c === "\\\\") i++; + else if (c === "\\n") return i; + else if (c === "[") inClass = true; + else if (c === "]") inClass = false; + else if (c === "/" && !inClass) return i + 1; + } + return i; +} +function __vf_rewriteCjsDynamicImports(code) { + if (code.indexOf("import") === -1) return code; + var out = ""; + var copied = 0; + // "" or a punctuator: the last significant token; "word" and "value" mean an + // identifier or literal ended, after which "/" divides. + var previous = ""; + var stack = []; + var i = 0; + while (i < code.length) { + var c = code[i]; + if (c === " " || c === "\\t" || c === "\\n" || c === "\\r") { i++; continue; } + if (c === "/" && code[i + 1] === "/") { + var lineEnd = code.indexOf("\\n", i); + i = lineEnd === -1 ? code.length : lineEnd; + continue; + } + if (c === "/" && code[i + 1] === "*") { + var blockEnd = code.indexOf("*/", i + 2); + i = blockEnd === -1 ? code.length : blockEnd + 2; + continue; + } + if (c === "'" || c === '"') { i = __vf_skipQuoted(code, i, c); previous = "value"; continue; } + if (c === "\`" || (c === "}" && stack[stack.length - 1] === "\${")) { + // A template, or its continuation after a substitution. + if (c === "}") stack.pop(); + var substitution = false; + for (i++; i < code.length; i++) { + if (code[i] === "\\\\") { i++; continue; } + if (code[i] === "\`") { i++; break; } + if (code[i] === "$" && code[i + 1] === "{") { stack.push("\${"); i += 2; substitution = true; break; } + } + previous = substitution ? "(" : "value"; + continue; + } + if (c === "/") { + if (previous === "word" || previous === "value" || previous === ")" || previous === "]") { + i++; + previous = "/"; + } else { + i = __vf_skipRegex(code, i); + previous = "value"; + } + continue; + } + if (/[A-Za-z0-9_$]/.test(c) || c.charCodeAt(0) > 127) { + var start = i; + while (i < code.length && (/[A-Za-z0-9_$]/.test(code[i]) || code.charCodeAt(i) > 127)) i++; + var word = code.slice(start, i); + if (word === "import" && previous !== ".") { + __vf_relativeImportCall.lastIndex = start; + var call = __vf_relativeImportCall.exec(code); + if (call) { + out += code.slice(copied, start) + "__vf_cjsImport(" + JSON.stringify(call[2]) + ")"; + copied = i = __vf_relativeImportCall.lastIndex; + previous = ")"; + continue; + } + } + previous = __vf_regexAfterWord[word] === 1 ? "(" : "word"; + continue; + } + if (c === "{") stack.push("{"); + else if (c === "}") stack.pop(); + previous = c; + i++; + } + return copied === 0 ? code : out + code.slice(copied); +} function __vf_importFrom(spec, parentDir) { return (async function () { var target = __vf_resolve(parentDir, spec); @@ -481,9 +570,7 @@ function __vf_loadCjs(id, parentDir, parentMod) { // cannot contain one. Commenting it out keeps line numbers intact. if (code.startsWith("#!")) code = "//" + code.slice(2); var dir = __vf_dirname(resolved); - code = code.replace(__vf_cjsDynamicImport, function (_match, lead, _quote, spec) { - return lead + "__vf_cjsImport(" + JSON.stringify(spec) + ")"; - }); + code = __vf_rewriteCjsDynamicImports(code); var cjsImport = function (spec) { return __vf_importFrom(spec, dir); }; var mod; var childReq = function(childId) { return __vf_loadCjs(childId, dir, mod); }; diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 458b752026..f12b452f67 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -5947,6 +5947,32 @@ describe("route CommonJS require (#5471)", () => { assertEquals(await responseText(GET), "esm child"); }); + denoIt("leaves import-like text in CommonJS strings and comments alone", async () => { + const files = { + "node_modules/text-fixture/package.json": JSON.stringify({ + name: "text-fixture", + main: "index.js", + }), + "node_modules/text-fixture/index.js": [ + "// import('./child.mjs') in a comment", + "const re = /import('.\\/x.mjs')/;", + "const tpl = `import('./child.mjs') ${\"import('./child.mjs')\"}`;", + "module.exports = [\"import('./child.mjs')\", 'import(\"./child.mjs\")', tpl, String(re.source.length > 0)].join('|');", + ].join("\n"), + }; + const GET = await loadCompiledRoute( + [ + 'import text from "text-fixture";', + "export const GET = async () => new Response(text);", + ].join("\n"), + files, + ); + assertEquals( + await responseText(GET), + "import('./child.mjs')|import(\"./child.mjs\")|import('./child.mjs') import('./child.mjs')|true", + ); + }); + denoIt("gives CommonJS namespaces the immutability of module namespaces", async () => { const files = { "node_modules/frozen-fixture/package.json": JSON.stringify({ diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index 32e2d1d9f4..bdc29481f6 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -306,13 +306,17 @@ function toJsStringLiteral(value: string): string { function matchUserDependency( specifier: string, userDeps: Map, -): { name: string; subpath: string } | null { +): { name: string; subpath: string; suffix: string } | null { + // A query or hash is part of the module URL, not of the file path. + const suffixAt = specifier.search(/[?#]/); + const path = suffixAt === -1 ? specifier : specifier.slice(0, suffixAt); + const suffix = suffixAt === -1 ? "" : specifier.slice(suffixAt); let best: string | null = null; for (const name of userDeps.keys()) { - if (specifier !== name && !specifier.startsWith(`${name}/`)) continue; + if (path !== name && !path.startsWith(`${name}/`)) continue; if (best === null || name.length > best.length) best = name; } - return best === null ? null : { name: best, subpath: specifier.slice(best.length) }; + return best === null ? null : { name: best, subpath: path.slice(best.length), suffix }; } /** @@ -363,76 +367,6 @@ function rewriteCjsReexport( ); } -/** Characters after which a `/` begins a regex literal rather than dividing. */ -const REGEX_PRECEDERS = new Set([..."(,=:[!&|?{};+-*%<>~^"]); - -/** The index just past the regex literal (and its flags) starting at `start`. */ -function findRegexLiteralEnd(code: string, start: number): number { - let inClass = false; - for (let index = start + 1; index < code.length; index++) { - const char = code[index]!; - if (char === "\n") return -1; - if (char === "\\") index++; - else if (char === "[") inClass = true; - else if (char === "]") inClass = false; - else if (char === "/" && !inClass) { - let end = index + 1; - while (end < code.length && /[a-z]/i.test(code[end]!)) end++; - return end; - } - } - return -1; -} - -/** - * The index just past the `)` closing a dynamic `import(` whose specifier ends - * at `from`, skipping an options argument; -1 when it cannot be found. - */ -function findDynamicImportEnd(code: string, from: number): number { - let depth = 0; - // The last significant character decides whether a `/` starts a regex - // literal or divides, as in the usual lexer heuristic. - let previous = "("; - for (let index = from; index < code.length; index++) { - const char = code[index]!; - if (/\s/.test(char)) continue; - if (char === '"' || char === "'" || char === "`") { - for (index++; index < code.length && code[index] !== char; index++) { - if (code[index] === "\\") index++; - } - previous = char; - continue; - } - if (char === "/" && code[index + 1] === "/") { - const end = code.indexOf("\n", index); - if (end === -1) return -1; - index = end; - continue; - } - if (char === "/" && code[index + 1] === "*") { - const end = code.indexOf("*/", index + 2); - if (end === -1) return -1; - index = end + 1; - continue; - } - if (char === "/" && REGEX_PRECEDERS.has(previous)) { - const end = findRegexLiteralEnd(code, index); - if (end === -1) return -1; - index = end - 1; - previous = "/"; - continue; - } - previous = char; - if (char === "(" || char === "{" || char === "[") depth++; - else if (char === "}" || char === "]") depth--; - else if (char === ")") { - if (depth === 0) return index + 1; - depth--; - } - } - return -1; -} - /** * Rewrite a bundled compiled-binary route's user dependency imports. * @@ -450,14 +384,14 @@ export async function rewriteCompiledUserDependencyImportsForRoute( ): Promise { if (userDeps.size === 0) return code; const { masked, imports, unmask } = await parseMaskedImports(code); - const edits: { start: number; end: number; text: string }[] = []; + const edits: { start: number; end: number; text: string; static?: boolean }[] = []; let reexports = 0; for (const imported of imports) { if (typeof imported.n !== "string") continue; const dependency = matchUserDependency(imported.n, userDeps); if (!dependency) continue; - const { name, subpath } = dependency; + const { name, subpath, suffix } = dependency; const isDynamic = imported.d > -1; // ESM-only dependencies are rewritten to real ES module file:// URLs so that @@ -477,6 +411,7 @@ export async function rewriteCompiledUserDependencyImportsForRoute( } url = pathHelper.toFileUrl(target).href; } + url += suffix; // Static specifier spans exclude the quotes; dynamic ones include them. edits.push({ start: imported.s, @@ -486,17 +421,19 @@ export async function rewriteCompiledUserDependencyImportsForRoute( continue; } + // CommonJS modules are cached by file, so a query or hash selects nothing. const specifier = toJsStringLiteral(name + subpath); const required = `require(${specifier})`; if (isDynamic) { // A dynamic import stays lazy and asynchronous: a load failure rejects // the returned promise instead of throwing synchronously. An options // argument (import attributes) is consumed: require reads JSON itself. - const end = findDynamicImportEnd(masked, imported.e); - if (end === -1) continue; + // The lexer reports the end of the whole import(...) expression, + // tokenizing strings, comments and regex literals in the options. + if (imported.se <= imported.e) continue; edits.push({ start: imported.ss, - end, + end: imported.se, // An async arrow returns an intrinsic promise whatever route code binds // to `Promise`, and the await defers loading as import() does. text: `(async () => { await null; return ${helperPrefix}namespace(${specifier}); })()`, @@ -510,11 +447,12 @@ export async function rewriteCompiledUserDependencyImportsForRoute( start: imported.ss, end: imported.se, text: rewriteCjsReexport(statement, specifier, imported.n, helperPrefix, reexports++), + static: true, }); continue; } if (SIDE_EFFECT_IMPORT.test(statement)) { - edits.push({ start: imported.ss, end: imported.se, text: required }); + edits.push({ start: imported.ss, end: imported.se, text: required, static: true }); continue; } const clause = statement.match(STATIC_FROM_IMPORT)?.[1]; @@ -522,11 +460,17 @@ export async function rewriteCompiledUserDependencyImportsForRoute( ? null : rewriteCjsImportClause(clause, specifier, helperPrefix); if (rewritten !== null) { - edits.push({ start: imported.ss, end: imported.se, text: rewritten }); + edits.push({ start: imported.ss, end: imported.se, text: rewritten, static: true }); } } if (edits.length === 0) return code; + // A replaced declaration ended where ASI applied, but a replacement + // expression would let a following line starting with ( [ or a template + // continue it, so every static rewrite ends in its own semicolon. + for (const edit of edits) { + if (edit.static && masked[edit.end] !== ";" && !edit.text.endsWith(";")) edit.text += ";"; + } edits.sort((left, right) => right.start - left.start); let transformed = masked; for (const edit of edits) { From fae2d59237fe49e89849d05a8b7c8a36df958e65 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 10:27:21 +0100 Subject: [PATCH 14/22] fix(routes): contain require.resolve, canonical lookup paths, CJS import suffixes, collision-free wrapper (#5471) --- .../module-loader/external-import-rewriter.ts | 45 +++++++++----- src/routing/api/module-loader/loader.test.ts | 60 ++++++++++++++++++- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 2d55cb70db..adf27a3ef3 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -397,25 +397,33 @@ function __vf_isBuiltin(id) { return __vf_builtinSet.has(id); } // Node's module.paths: each ancestor's node_modules, stopping at the project -// root so host directories above it are never listed. +// root so host directories above it are never listed. The directory is +// canonicalized first, so the walk meets the canonical root even when the +// project was opened through a symlink. function __vf_nodeModulePaths(dir) { var paths = []; - for (var current = dir; ; current = __vf_dirname(current)) { - if (!/(^|[\\/])node_modules$/.test(current)) paths.push(__vf_resolve(current, "node_modules")); + var start = __vf_canonicalize(dir); + try { __vf_assertContained(start, dir); } catch (_) { return paths; } + for (var current = start; ; current = __vf_dirname(current)) { + if (!/(^|[\\\\/])node_modules$/.test(current)) paths.push(__vf_resolve(current, "node_modules")); if (current === __vf_projectRoot || __vf_dirname(current) === current) break; } return paths; } -// require.resolve(id, { paths }): id resolves from each listed directory in -// turn, and the result must stay inside the project. +// require.resolve(id) and require.resolve(id, { paths }): with paths, id +// resolves from each listed directory in turn. Either way a module result must +// stay inside the project, as loading it would, so no host path leaks out. +function __vf_resolveContained(id, parentDir) { + var found = __vf_resolveId(id, parentDir); + if (!__vf_isBuiltin(id) && !__vf_isBuiltin(found)) __vf_assertContained(__vf_canonicalize(found), id); + return found; +} function __vf_resolveWithOptions(id, parentDir, options) { - if (!options || options.paths === void 0) return __vf_resolveId(id, parentDir); + if (!options || options.paths === void 0) return __vf_resolveContained(id, parentDir); if (!Array.isArray(options.paths)) throw new TypeError("require.resolve options.paths must be an array"); for (var i = 0; i < options.paths.length; i++) { try { - var found = __vf_resolveId(id, __vf_resolve(parentDir, options.paths[i])); - if (!__vf_isBuiltin(id)) __vf_assertContained(__vf_canonicalize(found), id); - return found; + return __vf_resolveContained(id, __vf_resolve(parentDir, options.paths[i])); } catch (error) { if (!error || error.code !== "MODULE_NOT_FOUND") throw error; } @@ -519,9 +527,13 @@ function __vf_rewriteCjsDynamicImports(code) { } function __vf_importFrom(spec, parentDir) { return (async function () { - var target = __vf_resolve(parentDir, spec); + // A query or hash belongs to the module URL, not to the file path. + var suffixAt = spec.search(/[?#]/); + var path = suffixAt === -1 ? spec : spec.slice(0, suffixAt); + var target = __vf_resolve(parentDir, path); __vf_assertContained(__vf_canonicalize(target), spec); - return await import(__vf_builtinRequire("node:url").pathToFileURL(target).href); + var url = __vf_builtinRequire("node:url").pathToFileURL(target).href; + return await import(suffixAt === -1 ? url : url + spec.slice(suffixAt)); })(); } // As in Node, every required module records the module that first required it @@ -583,8 +595,13 @@ function __vf_loadCjs(id, parentDir, parentMod) { // As in Node, top-level \`this\` is module.exports, and a module whose // evaluation throws is dropped from the cache so a retry fails again. try { - var fn = new Function("exports", "require", "module", "__filename", "__dirname", "global", "globalThis", "Worker", "__vf_cjsImport", code); - fn.call(mod.exports, mod.exports, childReq, mod, resolved, dir, globalThis, globalThis, void 0, cjsImport); + // The wrapper's own parameters are Node's five. global, globalThis, + // Worker and the dynamic-import helper come from an enclosing function, + // so a package's own top-level \`const Worker\` shadows them instead of + // colliding with a parameter. + var outer = new Function("global", "globalThis", "Worker", "__vf_cjsImport", "return function (exports, require, module, __filename, __dirname) {\\n" + code + "\\n};"); + var fn = outer(globalThis, globalThis, void 0, cjsImport); + fn.call(mod.exports, mod.exports, childReq, mod, resolved, dir); } catch (error) { delete __vf_cache[resolved]; if (parentMod) { @@ -602,7 +619,7 @@ function __vf_resolveId(id, parentDir) { } // The route is the parent of the modules it requires, as the module a Node // createRequire() call is anchored to would be. -var routeModule = { exports: {}, id: __VF_PROJECT_DIR__, filename: __VF_PROJECT_DIR__, path: __VF_PROJECT_DIR__, loaded: true, children: [], paths: __vf_nodeModulePaths(__VF_PROJECT_DIR__), parent: null }; +var routeModule = { exports: {}, id: __VF_PROJECT_DIR__, filename: __VF_PROJECT_DIR__, path: __VF_PROJECT_DIR__, loaded: true, children: [], paths: __vf_nodeModulePaths(__vf_projectRoot), parent: null }; var routeRequire = function(id) { return __vf_loadCjs(id, __VF_PROJECT_DIR__, routeModule); }; routeRequire.resolve = function(id, options) { return __vf_resolveWithOptions(id, __VF_PROJECT_DIR__, options); }; routeRequire.ensure = function(mods, cb) { cb(); }; diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index f12b452f67..098b7140bf 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -5934,7 +5934,7 @@ describe("route CommonJS require (#5471)", () => { main: "index.js", }), "node_modules/bridge-fixture/index.js": - "module.exports = () => import('./child.mjs').then((m) => m.value);", + "module.exports = () => Promise.all([import('./child.mjs'), import('./child.mjs?variant#x')]).then(([a, b]) => a.value + ':' + b.value);", "node_modules/bridge-fixture/child.mjs": "export const value = 'esm child';", }; const GET = await loadCompiledRoute( @@ -5944,7 +5944,7 @@ describe("route CommonJS require (#5471)", () => { ].join("\n"), files, ); - assertEquals(await responseText(GET), "esm child"); + assertEquals(await responseText(GET), "esm child:esm child"); }); denoIt("leaves import-like text in CommonJS strings and comments alone", async () => { @@ -6567,6 +6567,62 @@ describe("route CommonJS require (#5471)", () => { assertEquals(result.viaPaths, "plugins"); }); + denoIt("loads a CommonJS package that declares wrapper-like top-level names", async () => { + const projectDir = await writeProject({ + "node_modules/names-fixture/package.json": JSON.stringify({ + name: "names-fixture", + main: "index.js", + }), + "node_modules/names-fixture/index.js": [ + "const Worker = 'w';", + "const global = 'g';", + "const globalThis = 't';", + "const __vf_cjsImport = 'i';", + "module.exports = [Worker, global, globalThis, __vf_cjsImport].join('');", + ].join("\n"), + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("names-fixture"), "wgti"); + }); + + denoIt("keeps default require.resolve results inside the project", async () => { + const outerDir = await writeProject({ + "node_modules/outside-pkg/package.json": JSON.stringify({ + name: "outside-pkg", + main: "index.js", + }), + "node_modules/outside-pkg/index.js": "module.exports = 1;", + "app/package.json": "{}", + "app/node_modules/probe/package.json": JSON.stringify({ name: "probe", main: "index.js" }), + "app/node_modules/probe/index.js": [ + "try { module.exports = require.resolve('outside-pkg'); }", + "catch (error) { module.exports = 'blocked: ' + error.message; }", + ].join("\n"), + }); + const vfRequire = await importCompiledBinaryRequireShim(join(outerDir, "app")); + + const result = String(vfRequire("probe")); + assertEquals(result.startsWith("blocked: "), true, result); + assertEquals(result.includes(outerDir), false, result); + }); + + denoIt("stops route lookup paths at a project opened through a symlink", async () => { + const projectDir = await writeProject({ + "node_modules/paths-fixture/package.json": JSON.stringify({ + name: "paths-fixture", + main: "index.js", + }), + "node_modules/paths-fixture/index.js": "module.exports = module.parent.paths;", + }); + const linkDir = Deno.realPathSync(await makeTempDir()); + const linkedProject = join(linkDir, "linked-project"); + Deno.symlinkSync(projectDir, linkedProject); + const vfRequire = await importCompiledBinaryRequireShim(linkedProject); + + assertEquals(vfRequire("paths-fixture"), [join(projectDir, "node_modules")]); + }); + denoIt("lets a top-level dependency require through module.parent", async () => { const projectDir = await writeProject({ "node_modules/plugin-host/package.json": JSON.stringify({ From 4896c2a8ea32b4959cef0fae17310f9acce39de3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 11:16:44 +0100 Subject: [PATCH 15/22] fix(routes): CJS import options and helper naming, captured statics, BOM, sanitized reads, root projects, Node extension order (#5471) --- .../module-loader/external-import-rewriter.ts | 86 +++++++--- src/routing/api/module-loader/loader.test.ts | 150 ++++++++++++++++++ 2 files changed, 217 insertions(+), 19 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index adf27a3ef3..ff53fc1036 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -130,10 +130,23 @@ var require = (function () { // uninitialized when this runs. These locals come from the global object, // which is reached through syntax alone. var __vf_global = (function () {}).constructor("return this")(); -var Array = __vf_global.Array, Deno = __vf_global.Deno, Error = __vf_global.Error; -var Function = __vf_global.Function, JSON = __vf_global.JSON, Map = __vf_global.Map; -var Object = __vf_global.Object, Set = __vf_global.Set, Symbol = __vf_global.Symbol; -var TypeError = __vf_global.TypeError, globalThis = __vf_global; +var Deno = __vf_global.Deno, Error = __vf_global.Error, Function = __vf_global.Function; +var Map = __vf_global.Map, Set = __vf_global.Set, TypeError = __vf_global.TypeError; +var globalThis = __vf_global; +// The static methods the loader calls are captured now, before route code +// runs, so a later \`Object.freeze = ...\` cannot change how it behaves. +var __vf_Object = __vf_global.Object; +var Object = { + create: __vf_Object.create, + defineProperty: __vf_Object.defineProperty, + freeze: __vf_Object.freeze, + getOwnPropertyDescriptor: __vf_Object.getOwnPropertyDescriptor, + keys: __vf_Object.keys, + prototype: { hasOwnProperty: __vf_Object.prototype.hasOwnProperty }, +}; +var Array = { isArray: __vf_global.Array.isArray }; +var JSON = { parse: __vf_global.JSON.parse, stringify: __vf_global.JSON.stringify }; +var Symbol = { toStringTag: __vf_global.Symbol.toStringTag }; var __vf_builtinRequire = __vf_createRequire(__VF_PROJECT_DIR__ + "/package.json"); var __vf_builtinSet = new Set(__VF_BUILTIN_LIST__); // Every built-in this runtime provides, not only the shared list. Node's @@ -181,7 +194,9 @@ function __vf_canonicalize(candidate) { function __vf_assertContained(resolved, id) { var norm = __vf_resolve(resolved).replace(/\\\\/g, "/"); var root = __vf_projectRoot.replace(/\\\\/g, "/"); - if (!norm.startsWith(root + "/") && norm !== root) { + // A project at a filesystem root ("/", "C:/") already ends in a separator. + var prefix = root.endsWith("/") ? root : root + "/"; + if (!norm.startsWith(prefix) && norm !== root) { throw new Error("CJS loader blocked path outside project: " + JSON.stringify(id)); } } @@ -273,7 +288,7 @@ function __vf_resolveInPackage(id, dir, packageDir) { var exportsField; if (__vf_isFile(manifest)) { try { - exportsField = JSON.parse(Deno.readTextFileSync(manifest)).exports; + exportsField = JSON.parse(__vf_readText(manifest, name)).exports; } catch (_) { throw new Error("invalid package.json for " + JSON.stringify(name)); } @@ -346,7 +361,8 @@ function __vf_isFile(candidate) { function __vf_probeFile(path) { // ".node" is probed so an extensionless native addon reaches the // unsupported-addon error instead of a misleading MODULE_NOT_FOUND. - var candidates = [path, path + ".js", path + ".cjs", path + ".json", path + ".node"]; + // Node's order for an extensionless request; ".cjs" is never added. + var candidates = [path, path + ".js", path + ".json", path + ".node"]; for (var i = 0; i < candidates.length; i++) { if (__vf_isFile(candidates[i])) return candidates[i]; } @@ -363,7 +379,7 @@ function __vf_probeDirectory(dir, id) { // reason to fall back to index. var main; try { - main = JSON.parse(Deno.readTextFileSync(manifest)).main; + main = JSON.parse(__vf_readText(manifest, id)).main; } catch (_) { throw new Error("invalid package.json for " + JSON.stringify(id)); } @@ -419,6 +435,8 @@ function __vf_resolveContained(id, parentDir) { return found; } function __vf_resolveWithOptions(id, parentDir, options) { + // Built-ins resolve to themselves, whatever search paths are given. + if (__vf_isBuiltin(id)) return id; if (!options || options.paths === void 0) return __vf_resolveContained(id, parentDir); if (!Array.isArray(options.paths)) throw new TypeError("require.resolve options.paths must be an array"); for (var i = 0; i < options.paths.length; i++) { @@ -437,7 +455,9 @@ function __vf_resolveWithOptions(id, parentDir, options) { // absolute dynamic imports in code (not in strings, comments, templates or // regex literals) are routed to a module-relative loader instead. var __vf_regexAfterWord = { "return": 1, "typeof": 1, "instanceof": 1, "in": 1, "of": 1, "new": 1, "delete": 1, "void": 1, "throw": 1, "case": 1, "do": 1, "else": 1, "yield": 1, "await": 1 }; -var __vf_relativeImportCall = /import\\s*\\(\\s*(["'])((?:\\.{1,2}\\/|\\/)[^"'\\\\\\r\\n]*)\\1\\s*\\)/y; +// Only "import(" and the specifier are replaced, so an options argument and +// the closing parenthesis stay as written and reach the helper. +var __vf_relativeImportCall = /import\\s*\\(\\s*(["'])((?:\\.{1,2}\\/|\\/)[^"'\\\\\\r\\n]*)\\1/y; function __vf_skipQuoted(code, i, quote) { for (i++; i < code.length && code[i] !== quote; i++) { if (code[i] === "\\\\") i++; @@ -456,7 +476,7 @@ function __vf_skipRegex(code, i) { } return i; } -function __vf_rewriteCjsDynamicImports(code) { +function __vf_rewriteCjsDynamicImports(code, helperName) { if (code.indexOf("import") === -1) return code; var out = ""; var copied = 0; @@ -509,9 +529,9 @@ function __vf_rewriteCjsDynamicImports(code) { __vf_relativeImportCall.lastIndex = start; var call = __vf_relativeImportCall.exec(code); if (call) { - out += code.slice(copied, start) + "__vf_cjsImport(" + JSON.stringify(call[2]) + ")"; + out += code.slice(copied, start) + helperName + "(" + JSON.stringify(call[2]); copied = i = __vf_relativeImportCall.lastIndex; - previous = ")"; + previous = "value"; continue; } } @@ -525,17 +545,41 @@ function __vf_rewriteCjsDynamicImports(code) { } return copied === 0 ? code : out + code.slice(copied); } -function __vf_importFrom(spec, parentDir) { +function __vf_importFrom(spec, parentDir, options) { return (async function () { // A query or hash belongs to the module URL, not to the file path. var suffixAt = spec.search(/[?#]/); var path = suffixAt === -1 ? spec : spec.slice(0, suffixAt); var target = __vf_resolve(parentDir, path); __vf_assertContained(__vf_canonicalize(target), spec); - var url = __vf_builtinRequire("node:url").pathToFileURL(target).href; - return await import(suffixAt === -1 ? url : url + spec.slice(suffixAt)); + var toUrl = __vf_builtinRequire("node:url").pathToFileURL; + var url = toUrl(target).href + (suffixAt === -1 ? "" : spec.slice(suffixAt)); + try { + return options === void 0 ? await import(url) : await import(url, options); + } catch (error) { + // A load failure for this file names its host path; report the + // specifier the package wrote instead. + var message = error && typeof error.message === "string" ? error.message : ""; + if (message.indexOf(__vf_projectRoot) === -1 && message.indexOf(toUrl(__vf_projectRoot).href) === -1) throw error; + var failed = new Error("cannot import " + JSON.stringify(spec) + " from a CommonJS module" + (error && error.code ? " (" + error.code + ")" : "")); + if (error && error.code) failed.code = error.code; + throw failed; + } })(); } +// Reads a module or manifest as Node does: a UTF-8 byte-order mark is dropped, +// and a read failure names the requested id, never the host path. +function __vf_readText(path, id) { + var text; + try { + text = Deno.readTextFileSync(path); + } catch (error) { + var failed = new Error("cannot read module " + JSON.stringify(id) + (error && error.code ? " (" + error.code + ")" : "")); + if (error && error.code) failed.code = error.code; + throw failed; + } + return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; +} // As in Node, every required module records the module that first required it // as module.parent, and each requiring module lists it once in module.children. function __vf_linkChild(parentMod, mod) { @@ -570,7 +614,7 @@ function __vf_loadCjs(id, parentDir, parentMod) { __vf_linkChild(parentMod, __vf_cache[resolved]); return __vf_cache[resolved].exports; } - var code = Deno.readTextFileSync(resolved); + var code = __vf_readText(resolved, id); if (resolved.endsWith(".json")) { var json = JSON.parse(code); var jsonMod = { exports: json, id: resolved, filename: resolved, loaded: true, children: [], parent: parentMod }; @@ -582,8 +626,12 @@ function __vf_loadCjs(id, parentDir, parentMod) { // cannot contain one. Commenting it out keeps line numbers intact. if (code.startsWith("#!")) code = "//" + code.slice(2); var dir = __vf_dirname(resolved); - code = __vf_rewriteCjsDynamicImports(code); - var cjsImport = function (spec) { return __vf_importFrom(spec, dir); }; + // The helper's name is one the package's source never mentions, so no + // binding of the package can shadow it at a rewritten import. + var importHelper = "__vf_cjsImport"; + while (code.indexOf(importHelper) !== -1) importHelper += "_"; + code = __vf_rewriteCjsDynamicImports(code, importHelper); + var cjsImport = function (spec, options) { return __vf_importFrom(spec, dir, options); }; var mod; var childReq = function(childId) { return __vf_loadCjs(childId, dir, mod); }; childReq.resolve = function(childId, options) { return __vf_resolveWithOptions(childId, dir, options); }; @@ -599,7 +647,7 @@ function __vf_loadCjs(id, parentDir, parentMod) { // Worker and the dynamic-import helper come from an enclosing function, // so a package's own top-level \`const Worker\` shadows them instead of // colliding with a parameter. - var outer = new Function("global", "globalThis", "Worker", "__vf_cjsImport", "return function (exports, require, module, __filename, __dirname) {\\n" + code + "\\n};"); + var outer = new Function("global", "globalThis", "Worker", importHelper, "return function (exports, require, module, __filename, __dirname) {\\n" + code + "\\n};"); var fn = outer(globalThis, globalThis, void 0, cjsImport); fn.call(mod.exports, mod.exports, childReq, mod, resolved, dir); } catch (error) { diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 098b7140bf..2dbb06ea4e 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -5973,6 +5973,69 @@ describe("route CommonJS require (#5471)", () => { ); }); + denoIt( + "forwards options and survives a shadowing binding in CommonJS dynamic imports", + async () => { + const files = { + "node_modules/opts-fixture/package.json": JSON.stringify({ + name: "opts-fixture", + main: "index.js", + }), + "node_modules/opts-fixture/index.js": [ + "const __vf_cjsImport = null;", + "module.exports = async () => {", + " const data = await import('./data.json', { with: { type: 'json' } });", + " let missing;", + " try { await import('./missing.mjs'); } catch (error) { missing = String(error && error.message); }", + " return [data.default.ok, missing].join('|');", + "};", + ].join("\n"), + "node_modules/opts-fixture/data.json": JSON.stringify({ ok: "json" }), + }; + const route = await loadCompiledRouteModule( + [ + 'import load from "opts-fixture";', + "export const GET = async () => new Response(await load());", + ].join("\n"), + files, + ); + const text = await responseText(route.GET as () => Promise); + const [data, missing] = text.split("|"); + assertEquals(data, "json"); + assertEquals(missing!.includes("missing.mjs"), true, missing); + assertEquals(missing!.includes("/node_modules/opts-fixture"), false, missing); + }, + ); + + denoIt("seals namespaces with intrinsics captured before route code runs", async () => { + const files = { + "node_modules/patch-fixture/package.json": JSON.stringify({ + name: "patch-fixture", + main: "index.js", + }), + "node_modules/patch-fixture/index.js": "module.exports = { value: 1 };", + }; + // The route replaces Object.freeze for the whole process; put it back. + const originalFreeze = Object.freeze; + try { + const GET = await loadCompiledRoute( + [ + "Object.freeze = (value) => value;", + "const write = (target, key) => { target[key] = 2; };", + "export const GET = async () => {", + ' const dyn = await import("patch-fixture");', + " try { write(dyn, 'value'); return new Response('writable'); }", + " catch (error) { return new Response(error.name); }", + "};", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "TypeError"); + } finally { + Object.freeze = originalFreeze; + } + }); + denoIt("gives CommonJS namespaces the immutability of module namespaces", async () => { const files = { "node_modules/frozen-fixture/package.json": JSON.stringify({ @@ -6623,6 +6686,93 @@ describe("route CommonJS require (#5471)", () => { assertEquals(vfRequire("paths-fixture"), [join(projectDir, "node_modules")]); }); + denoIt("resolves built-ins before custom resolve paths", async () => { + const projectDir = await writeProject({}); + const vfRequire = await importCompiledBinaryRequireShim(projectDir) as + & ((id: string) => unknown) + & { + resolve: (id: string, options?: { paths: string[] }) => string; + }; + + assertEquals(vfRequire.resolve("fs", { paths: [] }), "fs"); + assertEquals(vfRequire.resolve("node:fs", { paths: [] }), "node:fs"); + }); + + denoIt("strips a byte-order mark from JSON and package manifests", async () => { + const bom = "\uFEFF"; + const projectDir = await writeProject({ + // The package root goes through the runtime's resolver; the BOMs below + // reach the loader's own JSON and manifest reads. + "node_modules/bom-fixture/package.json": JSON.stringify({ + name: "bom-fixture", + main: "index.js", + }), + "node_modules/bom-fixture/index.js": + "module.exports = [require('./data.json').ok, require('./dir')].join(':');", + "node_modules/bom-fixture/data.json": bom + JSON.stringify({ ok: "json" }), + "node_modules/bom-fixture/dir/package.json": bom + JSON.stringify({ main: "entry.js" }), + "node_modules/bom-fixture/dir/entry.js": "module.exports = 'dir';", + "node_modules/test/package.json": bom + + JSON.stringify({ name: "test", exports: "./main.js" }), + "node_modules/test/main.js": "module.exports = 'test';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("bom-fixture"), "json:dir"); + assertEquals(vfRequire("test"), "test"); + }); + + denoIt("probes only Node's extensions for an extensionless require", async () => { + const projectDir = await writeProject({ + "node_modules/ext-fixture/package.json": JSON.stringify({ + name: "ext-fixture", + main: "index.js", + }), + "node_modules/ext-fixture/index.js": [ + "let onlyCjs;", + "try { onlyCjs = require('./only'); } catch (error) { onlyCjs = error.code; }", + "module.exports = [typeof require('./both'), onlyCjs].join(':');", + ].join("\n"), + "node_modules/ext-fixture/both.cjs": "module.exports = 'cjs';", + "node_modules/ext-fixture/both.json": "{}", + "node_modules/ext-fixture/only.cjs": "module.exports = 'cjs';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("ext-fixture"), "object:MODULE_NOT_FOUND"); + }); + + denoIt("hides host paths when a resolved module cannot be read", async () => { + const projectDir = await writeProject({ + "node_modules/locked-fixture/package.json": JSON.stringify({ + name: "locked-fixture", + main: "index.js", + }), + "node_modules/locked-fixture/index.js": "module.exports = 1;", + }); + Deno.chmodSync(join(projectDir, "node_modules/locked-fixture/index.js"), 0o000); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + const message = thrownMessage(() => vfRequire("locked-fixture")); + assertEquals(message.includes(projectDir), false, message); + assertEquals(message.includes("locked-fixture"), true, message); + }); + + denoIt("loads modules inside a project at the filesystem root", async () => { + const dir = Deno.realPathSync(await makeTempDir()); + await fs.writeTextFile(join(dir, "abs.js"), "module.exports = 'from root project';"); + const shimPath = join(dir, "vf-root-shim.mjs"); + await fs.writeTextFile( + shimPath, + `${generateCompiledBinaryRequireShim("/")}\nexport { require as vfRequire };\n`, + ); + const { vfRequire } = await import(toFileUrl(shimPath).href) as { + vfRequire: (id: string) => unknown; + }; + + assertEquals(vfRequire(join(dir, "abs.js")), "from root project"); + }); + denoIt("lets a top-level dependency require through module.parent", async () => { const projectDir = await writeProject({ "node_modules/plugin-host/package.json": JSON.stringify({ From ef0bddc11739efc3c4b5a7e368b0663ed71581a2 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 12:00:37 +0100 Subject: [PATCH 16/22] fix(routes): comment/escape-aware CJS import scan, one-pass shim placeholders, evaluated import options (#5471) --- .../external-import-rewriter.test.ts | 28 ++++++++- .../module-loader/external-import-rewriter.ts | 58 ++++++++++++++++--- src/routing/api/module-loader/loader.test.ts | 27 +++++++++ .../import-rewriter/route-adapter.ts | 11 +++- 4 files changed, 109 insertions(+), 15 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index d791ed3df1..55a6ff9f6c 100644 --- a/src/routing/api/module-loader/external-import-rewriter.test.ts +++ b/src/routing/api/module-loader/external-import-rewriter.test.ts @@ -188,7 +188,7 @@ describe("external-import-rewriter", () => { out, [ 'const data = __vf_interopDefault(require("lodash/data.json"));', - 'const lazy = (async () => { await null; return __vf_namespace("lodash/data.json"); })();', + 'const lazy = (({ with: { type: "json" } }), (async () => { await null; return __vf_namespace("lodash/data.json"); })());', ].join("\n"), ); }); @@ -200,7 +200,7 @@ describe("external-import-rewriter", () => { ); assertEquals( out, - 'const lazy = (async () => { await null; return __vf_namespace("lodash"); })();', + 'const lazy = (({ with: { type: /[)]/.test("") ? "json" : "json" } }), (async () => { await null; return __vf_namespace("lodash"); })());', ); }); @@ -238,6 +238,28 @@ describe("external-import-rewriter", () => { assertEquals(out.includes("Promise"), false, out); }); + it("embeds a project path that contains a placeholder name unchanged", () => { + const shim = generateCompiledBinaryRequireShim("/tmp/__VF_PROJECT_ROOT__"); + assertStringIncludes(shim, 'var __vf_projectRoot = "/tmp/__VF_PROJECT_ROOT__";'); + assertEquals(shim.includes('"/tmp/"/tmp'), false); + }); + + it("keeps dynamic-import options evaluated before the lazy load", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + [`const a = import("lodash", (seen = true, {}));`, `const b = import("lodash",);`].join( + "\n", + ), + deps, + ); + assertEquals( + out, + [ + 'const a = (((seen = true, {})), (async () => { await null; return __vf_namespace("lodash"); })());', + 'const b = (async () => { await null; return __vf_namespace("lodash"); })();', + ].join("\n"), + ); + }); + it("rewrites a named default import with interop", async () => { const out = await rewriteCompiledBinaryUserDependencyImports( `import { default as lodash, merge } from "lodash";`, @@ -315,7 +337,7 @@ describe("external-import-rewriter", () => { ); assertEquals( out, - 'const lazy = (async () => { await null; return __vf_namespace("lodash"); })();', + 'const lazy = (({ with: { type: (() => { return /[)]/.test("") ? "json" : "json"; })() } }), (async () => { await null; return __vf_namespace("lodash"); })());', ); }); diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index ff53fc1036..7053cde444 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -113,10 +113,12 @@ export function generateCompiledBinaryRequireShim( __VF_PROJECT_DIR__: JSON.stringify(projectDir), __VF_PROJECT_ROOT__: JSON.stringify(pathHelper.resolve(projectDir)), }; - let shim = withRouteHelperPrefix(COMPILED_BINARY_REQUIRE_SHIM_TEMPLATE, helperPrefix); - for (const [placeholder, literal] of Object.entries(literals)) { - shim = shim.split(placeholder).join(literal); - } + // One pass, so a literal that happens to contain a placeholder name is + // never substituted again. + const shim = withRouteHelperPrefix(COMPILED_BINARY_REQUIRE_SHIM_TEMPLATE, helperPrefix).replace( + /__VF_(?:BUILTIN_LIST|PROJECT_DIR|PROJECT_ROOT)__/g, + (placeholder) => literals[placeholder]!, + ); return shim; } @@ -457,7 +459,6 @@ function __vf_resolveWithOptions(id, parentDir, options) { var __vf_regexAfterWord = { "return": 1, "typeof": 1, "instanceof": 1, "in": 1, "of": 1, "new": 1, "delete": 1, "void": 1, "throw": 1, "case": 1, "do": 1, "else": 1, "yield": 1, "await": 1 }; // Only "import(" and the specifier are replaced, so an options argument and // the closing parenthesis stay as written and reach the helper. -var __vf_relativeImportCall = /import\\s*\\(\\s*(["'])((?:\\.{1,2}\\/|\\/)[^"'\\\\\\r\\n]*)\\1/y; function __vf_skipQuoted(code, i, quote) { for (i++; i < code.length && code[i] !== quote; i++) { if (code[i] === "\\\\") i++; @@ -476,6 +477,46 @@ function __vf_skipRegex(code, i) { } return i; } +function __vf_skipTrivia(code, i) { + for (;;) { + var c = code[i]; + if (c === " " || c === "\\t" || c === "\\n" || c === "\\r") { i++; continue; } + if (c === "/" && code[i + 1] === "/") { + var lineEnd = code.indexOf("\\n", i); + i = lineEnd === -1 ? code.length : lineEnd; + continue; + } + if (c === "/" && code[i + 1] === "*") { + var blockEnd = code.indexOf("*/", i + 2); + i = blockEnd === -1 ? code.length : blockEnd + 2; + continue; + } + return i; + } +} +var __vf_simpleEscapes = { b: "\\b", f: "\\f", n: "\\n", r: "\\r", t: "\\t", v: "\\v", "0": "\\0" }; +// The value of a string literal's body, with its escapes decoded. +function __vf_decodeStringBody(body) { + return body.replace(/\\\\(?:u\\{([0-9a-fA-F]+)\\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2})|(\\r\\n|[\\s\\S]))/g, function (_match, codePoint, unit, byte, other) { + var hex = codePoint || unit || byte; + if (hex) return String.fromCodePoint(parseInt(hex, 16)); + if (other === "\\n" || other === "\\r" || other === "\\r\\n" || other === "
" || other === "
") return ""; + return __vf_simpleEscapes[other] !== void 0 ? __vf_simpleEscapes[other] : other; + }); +} +// At an "import" token: the end of "import ( " when the string is a +// relative or absolute path, with comments allowed between the tokens, or null. +function __vf_matchRelativeImport(code, afterWord) { + var open = __vf_skipTrivia(code, afterWord); + if (code[open] !== "(") return null; + var quoteAt = __vf_skipTrivia(code, open + 1); + var quote = code[quoteAt]; + if (quote !== "'" && quote !== '"') return null; + var end = __vf_skipQuoted(code, quoteAt, quote); + var spec = __vf_decodeStringBody(code.slice(quoteAt + 1, end - 1)); + if (!/^(?:\\.{1,2}\\/|\\/)/.test(spec)) return null; + return { end: end, spec: spec }; +} function __vf_rewriteCjsDynamicImports(code, helperName) { if (code.indexOf("import") === -1) return code; var out = ""; @@ -526,11 +567,10 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { while (i < code.length && (/[A-Za-z0-9_$]/.test(code[i]) || code.charCodeAt(i) > 127)) i++; var word = code.slice(start, i); if (word === "import" && previous !== ".") { - __vf_relativeImportCall.lastIndex = start; - var call = __vf_relativeImportCall.exec(code); + var call = __vf_matchRelativeImport(code, i); if (call) { - out += code.slice(copied, start) + helperName + "(" + JSON.stringify(call[2]); - copied = i = __vf_relativeImportCall.lastIndex; + out += code.slice(copied, start) + helperName + "(" + JSON.stringify(call.spec); + copied = i = call.end; previous = "value"; continue; } diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 2dbb06ea4e..d99d85d6fe 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -6036,6 +6036,33 @@ describe("route CommonJS require (#5471)", () => { } }); + denoIt( + "routes commented and escaped CommonJS dynamic imports relative to the module", + async () => { + const files = { + "node_modules/escape-fixture/package.json": JSON.stringify({ + name: "escape-fixture", + main: "index.js", + }), + "node_modules/escape-fixture/index.js": [ + "module.exports = () => Promise.all([", + " import(/* webpackIgnore: true */ './child.mjs'),", + ' import(".\\u002fchild.mjs" /* trailing */),', + "]).then((mods) => mods.map((m) => m.value).join(':'));", + ].join("\n"), + "node_modules/escape-fixture/child.mjs": "export const value = 'child';", + }; + const GET = await loadCompiledRoute( + [ + 'import load from "escape-fixture";', + "export const GET = async () => new Response(await load());", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "child:child"); + }, + ); + denoIt("gives CommonJS namespaces the immutability of module namespaces", async () => { const files = { "node_modules/frozen-fixture/package.json": JSON.stringify({ diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index bdc29481f6..1a455a6a6d 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -431,12 +431,17 @@ export async function rewriteCompiledUserDependencyImportsForRoute( // The lexer reports the end of the whole import(...) expression, // tokenizing strings, comments and regex literals in the options. if (imported.se <= imported.e) continue; + // An async arrow returns an intrinsic promise whatever route code binds + // to `Promise`, and the await defers loading as import() does. + const lazy = `(async () => { await null; return ${helperPrefix}namespace(${specifier}); })()`; + // The options argument is still evaluated, first, as import() would. + const options = imported.a > -1 + ? masked.slice(imported.a, imported.se - 1).trim().replace(/,$/, "").trimEnd() + : ""; edits.push({ start: imported.ss, end: imported.se, - // An async arrow returns an intrinsic promise whatever route code binds - // to `Promise`, and the await defers loading as import() does. - text: `(async () => { await null; return ${helperPrefix}namespace(${specifier}); })()`, + text: options ? `((${options}), ${lazy})` : lazy, }); continue; } From 3077b9343b0a93725f0ce0511ff4b1c622ef680d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 12:39:38 +0100 Subject: [PATCH 17/22] fix(routes): control-flow regex scan, import option validation, require.cache/resolve.paths, node: built-in check, captured decoder intrinsics (#5471) --- .../external-import-rewriter.test.ts | 8 +- .../module-loader/external-import-rewriter.ts | 61 +++++++++++- src/routing/api/module-loader/loader.test.ts | 93 +++++++++++++++++++ .../import-rewriter/route-adapter.ts | 8 +- 4 files changed, 161 insertions(+), 9 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index 55a6ff9f6c..6530d67a4d 100644 --- a/src/routing/api/module-loader/external-import-rewriter.test.ts +++ b/src/routing/api/module-loader/external-import-rewriter.test.ts @@ -188,7 +188,7 @@ describe("external-import-rewriter", () => { out, [ 'const data = __vf_interopDefault(require("lodash/data.json"));', - 'const lazy = (({ with: { type: "json" } }), (async () => { await null; return __vf_namespace("lodash/data.json"); })());', + 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options); return __vf_namespace("lodash/data.json"); })())({ with: { type: "json" } });', ].join("\n"), ); }); @@ -200,7 +200,7 @@ describe("external-import-rewriter", () => { ); assertEquals( out, - 'const lazy = (({ with: { type: /[)]/.test("") ? "json" : "json" } }), (async () => { await null; return __vf_namespace("lodash"); })());', + 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options); return __vf_namespace("lodash"); })())({ with: { type: /[)]/.test("") ? "json" : "json" } });', ); }); @@ -254,7 +254,7 @@ describe("external-import-rewriter", () => { assertEquals( out, [ - 'const a = (((seen = true, {})), (async () => { await null; return __vf_namespace("lodash"); })());', + 'const a = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options); return __vf_namespace("lodash"); })())((seen = true, {}));', 'const b = (async () => { await null; return __vf_namespace("lodash"); })();', ].join("\n"), ); @@ -337,7 +337,7 @@ describe("external-import-rewriter", () => { ); assertEquals( out, - 'const lazy = (({ with: { type: (() => { return /[)]/.test("") ? "json" : "json"; })() } }), (async () => { await null; return __vf_namespace("lodash"); })());', + 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options); return __vf_namespace("lodash"); })())({ with: { type: (() => { return /[)]/.test("") ? "json" : "json"; })() } });', ); }); diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 7053cde444..438fc734c0 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -60,10 +60,13 @@ const ROUTE_HELPER_SUFFIXES = [ "resolve", "interopDefault", "namespace", + "validateImportOptions", + "options", "tmp_", "reexport_", ] as const; -const ROUTE_HELPER_NAME = /\b__vf_(createRequire|dirname|resolve|interopDefault|namespace)\b/g; +const ROUTE_HELPER_NAME = + /\b__vf_(createRequire|dirname|resolve|interopDefault|namespace|validateImportOptions)\b/g; /** * Choose the prefix for the route prologue's module-level helpers. @@ -126,6 +129,7 @@ const COMPILED_BINARY_REQUIRE_SHIM_TEMPLATE = ` import { createRequire as __vf_createRequire } from "node:module"; import { dirname as __vf_dirname, resolve as __vf_resolve } from "node:path"; var __vf_namespace; +var __vf_validateImportOptions; var require = (function () { // Route code shares this module scope, so a top-level route binding named like // an intrinsic (\`const Set = ...\`) would shadow it here, or still be @@ -149,6 +153,8 @@ var Object = { var Array = { isArray: __vf_global.Array.isArray }; var JSON = { parse: __vf_global.JSON.parse, stringify: __vf_global.JSON.stringify }; var Symbol = { toStringTag: __vf_global.Symbol.toStringTag }; +var String = { fromCodePoint: __vf_global.String.fromCodePoint }; +var parseInt = __vf_global.parseInt, SyntaxError = __vf_global.SyntaxError; var __vf_builtinRequire = __vf_createRequire(__VF_PROJECT_DIR__ + "/package.json"); var __vf_builtinSet = new Set(__VF_BUILTIN_LIST__); // Every built-in this runtime provides, not only the shared list. Node's @@ -330,6 +336,11 @@ function __vf_resolvePrefixOnlyName(id, parentDir) { throw missing; } function __vf_resolveBare(id, parentDir) { + if (id.startsWith("node:")) { + var unknown = new Error("Cannot find module " + JSON.stringify(id)); + unknown.code = "MODULE_NOT_FOUND"; + throw unknown; + } if (__vf_prefixOnlyBuiltins.has(__vf_packageName(id))) return __vf_resolvePrefixOnlyName(id, parentDir); try { return __vf_parentRequire(parentDir).resolve(id); @@ -409,7 +420,11 @@ function __vf_resolveLocal(id, parentDir) { throw missing; } function __vf_isBuiltin(id) { - if (id.startsWith("node:")) return true; + // Only real built-ins are reserved under node:, as in Node. + if (id.startsWith("node:")) { + var bare = id.slice(5); + return __vf_builtinSet.has(bare) || __vf_prefixOnlyBuiltins.has(bare); + } // Only the runtime's own built-in ids: "fs/promises" is a built-in, while // "fs/plugin" resolves from an installed package named fs, as in Node. return __vf_builtinSet.has(id); @@ -436,6 +451,13 @@ function __vf_resolveContained(id, parentDir) { if (!__vf_isBuiltin(id) && !__vf_isBuiltin(found)) __vf_assertContained(__vf_canonicalize(found), id); return found; } +// require.resolve.paths(request): null for a built-in, the requiring +// directory for a relative request, otherwise its node_modules lookup paths. +function __vf_resolvePaths(request, parentDir) { + if (__vf_isBuiltin(request)) return null; + if (__vf_isPathId(request)) return [__vf_canonicalize(parentDir)]; + return __vf_nodeModulePaths(parentDir); +} function __vf_resolveWithOptions(id, parentDir, options) { // Built-ins resolve to themselves, whatever search paths are given. if (__vf_isBuiltin(id)) return id; @@ -525,6 +547,8 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { // identifier or literal ended, after which "/" divides. var previous = ""; var stack = []; + var parens = []; + var controlWord = false; var i = 0; while (i < code.length) { var c = code[i]; @@ -576,11 +600,16 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { } } previous = __vf_regexAfterWord[word] === 1 ? "(" : "word"; + controlWord = word === "if" || word === "while" || word === "for" || word === "with"; continue; } + // A ")" closing an if/while/for/with condition starts a statement, where + // "/" begins a regex literal; any other ")" ends a value. + if (c === "(") parens.push(controlWord); + controlWord = false; if (c === "{") stack.push("{"); else if (c === "}") stack.pop(); - previous = c; + previous = c === ")" && parens.pop() === true ? "(" : c; i++; } return copied === 0 ? code : out + code.slice(copied); @@ -676,6 +705,8 @@ function __vf_loadCjs(id, parentDir, parentMod) { var childReq = function(childId) { return __vf_loadCjs(childId, dir, mod); }; childReq.resolve = function(childId, options) { return __vf_resolveWithOptions(childId, dir, options); }; childReq.ensure = function(mods, cb) { cb(); }; + childReq.cache = __vf_cache; + childReq.resolve.paths = function(request) { return __vf_resolvePaths(request, dir); }; // The CommonJS module API packages use: module.require, id, filename, path. mod = { exports: {}, id: resolved, filename: resolved, path: dir, loaded: false, children: [], paths: __vf_nodeModulePaths(dir), parent: parentMod, require: childReq }; __vf_cache[resolved] = mod; @@ -711,12 +742,36 @@ var routeModule = { exports: {}, id: __VF_PROJECT_DIR__, filename: __VF_PROJECT_ var routeRequire = function(id) { return __vf_loadCjs(id, __VF_PROJECT_DIR__, routeModule); }; routeRequire.resolve = function(id, options) { return __vf_resolveWithOptions(id, __VF_PROJECT_DIR__, options); }; routeRequire.ensure = function(mods, cb) { cb(); }; +routeRequire.cache = __vf_cache; +routeRequire.resolve.paths = function(request) { return __vf_resolvePaths(request, __vf_projectRoot); }; routeModule.require = routeRequire; // One namespace per resolved module, as with ES module namespaces: repeated // imports of a module share it, and distinct modules never do. As with native // CommonJS interop, \`default\` is the whole module.exports, also for modules // marked __esModule. var namespaces = new Map(); +// import() rejects options that are not an object, a "with" value that is not +// an object, a non-string attribute value, and attributes it does not support. +__vf_validateImportOptions = function (options) { + if (options === void 0) return; + if (options === null || (typeof options !== "object" && typeof options !== "function")) { + throw new TypeError("The second argument to import() must be an object"); + } + var attributes = options.with; + if (attributes === void 0) return; + if (attributes === null || (typeof attributes !== "object" && typeof attributes !== "function")) { + throw new TypeError("The 'with' option of import() must be an object"); + } + var keys = Object.keys(attributes); + for (var i = 0; i < keys.length; i++) { + if (typeof attributes[keys[i]] !== "string") { + throw new TypeError("Import attribute " + JSON.stringify(keys[i]) + " must be a string"); + } + if (keys[i] !== "type") { + throw new SyntaxError("Unsupported import attribute " + JSON.stringify(keys[i])); + } + } +}; __vf_namespace = function (id) { var key = __vf_isBuiltin(id) ? "builtin:" + id.replace(/^node:/, "") diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index d99d85d6fe..6dc957506a 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -6055,6 +6055,9 @@ describe("route CommonJS require (#5471)", () => { const GET = await loadCompiledRoute( [ 'import load from "escape-fixture";', + "const String = null;", + "const parseInt = null;", + "export const shadowed = [String, parseInt];", "export const GET = async () => new Response(await load());", ].join("\n"), files, @@ -6800,6 +6803,96 @@ describe("route CommonJS require (#5471)", () => { assertEquals(vfRequire(join(dir, "abs.js")), "from root project"); }); + denoIt("leaves regex literals after control-flow conditions alone", async () => { + const projectDir = await writeProject({ + "node_modules/ctl-fixture/package.json": JSON.stringify({ + name: "ctl-fixture", + main: "index.js", + }), + "node_modules/ctl-fixture/index.js": [ + "const enabled = true;", + "let source = '';", + 'if (enabled) source = /import(".\\/child.mjs")/.source;', + 'if (enabled) /import(".\\/x.mjs")/.test("");', + "module.exports = source;", + ].join("\n"), + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("ctl-fixture"), 'import(".\\/child.mjs")'); + }); + + denoIt("gives require functions cache and resolve.paths", async () => { + const projectDir = await writeProject({ + "node_modules/meta-fixture/package.json": JSON.stringify({ + name: "meta-fixture", + main: "index.js", + }), + "node_modules/meta-fixture/index.js": [ + "module.exports = {", + " cached: require.cache[__filename] === module,", + " relative: require.resolve.paths('./x'),", + " bare: require.resolve.paths('peer'),", + " builtin: require.resolve.paths('fs'),", + "};", + ].join("\n"), + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir) as + & ((id: string) => unknown) + & { + cache: Record; + resolve: { paths: (id: string) => string[] | null }; + }; + + const meta = vfRequire("meta-fixture") as { + cached: boolean; + relative: string[]; + bare: string[]; + builtin: null; + }; + const packageDir = join(projectDir, "node_modules", "meta-fixture"); + assertEquals(meta.cached, true); + assertEquals(meta.relative, [packageDir]); + assertEquals(meta.bare[0], join(packageDir, "node_modules")); + assertEquals(meta.builtin, null); + assertEquals(typeof vfRequire.cache, "object"); + assertEquals(vfRequire.resolve.paths("peer"), [join(projectDir, "node_modules")]); + }); + + denoIt("does not resolve unknown node: built-ins", async () => { + const projectDir = await writeProject({}); + const vfRequire = await importCompiledBinaryRequireShim(projectDir) as + & ((id: string) => unknown) + & { + resolve: (id: string) => string; + }; + + assertEquals(thrownError(() => vfRequire.resolve("node:notreal")).code, "MODULE_NOT_FOUND"); + assertEquals(vfRequire.resolve("node:fs"), "node:fs"); + }); + + denoIt("validates dynamic-import options like import()", async () => { + const projectDir = await writeProject({}); + const shimPath = join(projectDir, "vf-validate-shim.mjs"); + await fs.writeTextFile( + shimPath, + `${ + generateCompiledBinaryRequireShim(projectDir) + }\nexport { __vf_validateImportOptions as validate };\n`, + ); + const { validate } = await import(toFileUrl(shimPath).href) as { + validate: (options: unknown) => void; + }; + + validate(undefined); + validate({}); + validate({ with: { type: "json" } }); + assertEquals(thrownError(() => validate(null)).name, "TypeError"); + assertEquals(thrownError(() => validate({ with: 1 })).name, "TypeError"); + assertEquals(thrownError(() => validate({ with: { type: 1 } })).name, "TypeError"); + assertEquals(thrownError(() => validate({ with: { other: "x" } })).name, "SyntaxError"); + }); + denoIt("lets a top-level dependency require through module.parent", async () => { const projectDir = await writeProject({ "node_modules/plugin-host/package.json": JSON.stringify({ diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index 1a455a6a6d..a31a8274a7 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -434,14 +434,18 @@ export async function rewriteCompiledUserDependencyImportsForRoute( // An async arrow returns an intrinsic promise whatever route code binds // to `Promise`, and the await defers loading as import() does. const lazy = `(async () => { await null; return ${helperPrefix}namespace(${specifier}); })()`; - // The options argument is still evaluated, first, as import() would. const options = imported.a > -1 ? masked.slice(imported.a, imported.se - 1).trim().replace(/,$/, "").trimEnd() : ""; + // The options argument is evaluated first, as import() would, and + // validated when the load starts, so bad options reject the promise. + const optionsParam = `${helperPrefix}options`; edits.push({ start: imported.ss, end: imported.se, - text: options ? `((${options}), ${lazy})` : lazy, + text: options + ? `((${optionsParam}) => (async () => { await null; ${helperPrefix}validateImportOptions(${optionsParam}); return ${helperPrefix}namespace(${specifier}); })())(${options})` + : lazy, }); continue; } From 53da92471677441811359fa2732400d5113b2153 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 13:19:36 +0100 Subject: [PATCH 18/22] fix(routes): private #import, attribute type checks, nested import edits, canonical resolve, prototype-free tables (#5471) --- .../external-import-rewriter.test.ts | 31 +++++-- .../module-loader/external-import-rewriter.ts | 66 ++++++++++---- src/routing/api/module-loader/loader.test.ts | 89 +++++++++++++++++- .../import-rewriter/route-adapter.ts | 90 ++++++++++++++----- 4 files changed, 231 insertions(+), 45 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index 6530d67a4d..dcf8ca4443 100644 --- a/src/routing/api/module-loader/external-import-rewriter.test.ts +++ b/src/routing/api/module-loader/external-import-rewriter.test.ts @@ -187,8 +187,8 @@ describe("external-import-rewriter", () => { assertEquals( out, [ - 'const data = __vf_interopDefault(require("lodash/data.json"));', - 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options); return __vf_namespace("lodash/data.json"); })())({ with: { type: "json" } });', + '__vf_validateImportOptions({ with: { type: "json" } }, "lodash/data.json"); const data = __vf_interopDefault(require("lodash/data.json"));', + 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options, "lodash/data.json"); return __vf_namespace("lodash/data.json"); })())({ with: { type: "json" } });', ].join("\n"), ); }); @@ -200,7 +200,7 @@ describe("external-import-rewriter", () => { ); assertEquals( out, - 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options); return __vf_namespace("lodash"); })())({ with: { type: /[)]/.test("") ? "json" : "json" } });', + 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options, "lodash"); return __vf_namespace("lodash"); })())({ with: { type: /[)]/.test("") ? "json" : "json" } });', ); }); @@ -238,6 +238,27 @@ describe("external-import-rewriter", () => { assertEquals(out.includes("Promise"), false, out); }); + it("rewrites a dynamic import nested in another import's options", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + `const a = import("lodash", (import("lodash/inner"), {}));`, + deps, + ); + assertEquals(out.includes('import("lodash'), false, out); + assertStringIncludes(out, '__vf_namespace("lodash/inner")'); + assertStringIncludes(out, '__vf_namespace("lodash")'); + }); + + it("checks static import attributes when the route module loads", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( + `import data from "lodash/data.json" with { type: "json" };`, + deps, + ); + assertStringIncludes( + out, + '__vf_validateImportOptions({ with: { type: "json" } }, "lodash/data.json");', + ); + }); + it("embeds a project path that contains a placeholder name unchanged", () => { const shim = generateCompiledBinaryRequireShim("/tmp/__VF_PROJECT_ROOT__"); assertStringIncludes(shim, 'var __vf_projectRoot = "/tmp/__VF_PROJECT_ROOT__";'); @@ -254,7 +275,7 @@ describe("external-import-rewriter", () => { assertEquals( out, [ - 'const a = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options); return __vf_namespace("lodash"); })())((seen = true, {}));', + 'const a = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options, "lodash"); return __vf_namespace("lodash"); })())((seen = true, {}));', 'const b = (async () => { await null; return __vf_namespace("lodash"); })();', ].join("\n"), ); @@ -337,7 +358,7 @@ describe("external-import-rewriter", () => { ); assertEquals( out, - 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options); return __vf_namespace("lodash"); })())({ with: { type: (() => { return /[)]/.test("") ? "json" : "json"; })() } });', + 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options, "lodash"); return __vf_namespace("lodash"); })())({ with: { type: (() => { return /[)]/.test("") ? "json" : "json"; })() } });', ); }); diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 438fc734c0..dfb890af25 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -137,7 +137,7 @@ var require = (function () { // which is reached through syntax alone. var __vf_global = (function () {}).constructor("return this")(); var Deno = __vf_global.Deno, Error = __vf_global.Error, Function = __vf_global.Function; -var Map = __vf_global.Map, Set = __vf_global.Set, TypeError = __vf_global.TypeError; +var TypeError = __vf_global.TypeError; var globalThis = __vf_global; // The static methods the loader calls are captured now, before route code // runs, so a later \`Object.freeze = ...\` cannot change how it behaves. @@ -156,18 +156,25 @@ var Symbol = { toStringTag: __vf_global.Symbol.toStringTag }; var String = { fromCodePoint: __vf_global.String.fromCodePoint }; var parseInt = __vf_global.parseInt, SyntaxError = __vf_global.SyntaxError; var __vf_builtinRequire = __vf_createRequire(__VF_PROJECT_DIR__ + "/package.json"); -var __vf_builtinSet = new Set(__VF_BUILTIN_LIST__); +// Lookup tables are null-prototype objects rather than Set or Map, so route or +// package code that replaces a collection prototype method cannot change them. +function __vf_tableOf(names) { + var table = Object.create(null); + for (var i = 0; i < names.length; i++) table[names[i]] = true; + return table; +} +var __vf_builtinSet = __vf_tableOf(__VF_BUILTIN_LIST__); // Every built-in this runtime provides, not only the shared list. Node's // prefix-only built-ins (node:test, node:sqlite, node:sea) have no bare form, // so a package of that name still resolves from node_modules, even where the // runtime lists the bare name. -var __vf_prefixOnlyBuiltins = new Set(["sea", "sqlite", "test", "test/reporters"]); +var __vf_prefixOnlyBuiltins = __vf_tableOf(["sea", "sqlite", "test", "test/reporters"]); try { var __vf_runtimeBuiltins = __vf_builtinRequire("node:module").builtinModules; for (var __vf_b = 0; __vf_b < __vf_runtimeBuiltins.length; __vf_b++) { var __vf_name = __vf_runtimeBuiltins[__vf_b]; - if (__vf_name.startsWith("node:") || __vf_prefixOnlyBuiltins.has(__vf_name)) continue; - __vf_builtinSet.add(__vf_name); + if (__vf_name.startsWith("node:") || __vf_prefixOnlyBuiltins[__vf_name] === true) continue; + __vf_builtinSet[__vf_name] = true; } } catch (_) { /* expected: fall back to the shared list */ } // Absolute ids include Windows drive-letter and UNC paths. @@ -341,7 +348,7 @@ function __vf_resolveBare(id, parentDir) { unknown.code = "MODULE_NOT_FOUND"; throw unknown; } - if (__vf_prefixOnlyBuiltins.has(__vf_packageName(id))) return __vf_resolvePrefixOnlyName(id, parentDir); + if (__vf_prefixOnlyBuiltins[__vf_packageName(id)] === true) return __vf_resolvePrefixOnlyName(id, parentDir); try { return __vf_parentRequire(parentDir).resolve(id); } catch (error) { @@ -423,11 +430,11 @@ function __vf_isBuiltin(id) { // Only real built-ins are reserved under node:, as in Node. if (id.startsWith("node:")) { var bare = id.slice(5); - return __vf_builtinSet.has(bare) || __vf_prefixOnlyBuiltins.has(bare); + return __vf_builtinSet[bare] === true || __vf_prefixOnlyBuiltins[bare] === true; } // Only the runtime's own built-in ids: "fs/promises" is a built-in, while // "fs/plugin" resolves from an installed package named fs, as in Node. - return __vf_builtinSet.has(id); + return __vf_builtinSet[id] === true; } // Node's module.paths: each ancestor's node_modules, stopping at the project // root so host directories above it are never listed. The directory is @@ -446,10 +453,13 @@ function __vf_nodeModulePaths(dir) { // require.resolve(id) and require.resolve(id, { paths }): with paths, id // resolves from each listed directory in turn. Either way a module result must // stay inside the project, as loading it would, so no host path leaks out. +// The canonical path is returned, the same key require.cache uses. function __vf_resolveContained(id, parentDir) { var found = __vf_resolveId(id, parentDir); - if (!__vf_isBuiltin(id) && !__vf_isBuiltin(found)) __vf_assertContained(__vf_canonicalize(found), id); - return found; + if (__vf_isBuiltin(id) || __vf_isBuiltin(found)) return found; + var canonical = __vf_canonicalize(found); + __vf_assertContained(canonical, id); + return canonical; } // require.resolve.paths(request): null for a built-in, the requiring // directory for a relative request, otherwise its node_modules lookup paths. @@ -590,7 +600,8 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { var start = i; while (i < code.length && (/[A-Za-z0-9_$]/.test(code[i]) || code.charCodeAt(i) > 127)) i++; var word = code.slice(start, i); - if (word === "import" && previous !== ".") { + // Not a property (obj.import) or a private name (this.#import). + if (word === "import" && previous !== "." && previous !== "#") { var call = __vf_matchRelativeImport(code, i); if (call) { out += code.slice(copied, start) + helperName + "(" + JSON.stringify(call.spec); @@ -651,8 +662,14 @@ function __vf_readText(path, id) { } // As in Node, every required module records the module that first required it // as module.parent, and each requiring module lists it once in module.children. +// Index loops instead of Array prototype methods, which package code can replace. +function __vf_childIndex(parentMod, mod) { + var children = parentMod.children; + for (var i = 0; i < children.length; i++) if (children[i] === mod) return i; + return -1; +} function __vf_linkChild(parentMod, mod) { - if (parentMod && parentMod.children.indexOf(mod) === -1) parentMod.children.push(mod); + if (parentMod && __vf_childIndex(parentMod, mod) === -1) parentMod.children[parentMod.children.length] = mod; } function __vf_loadCjs(id, parentDir, parentMod) { if (__vf_isBuiltin(id)) return __vf_builtinRequire(id); @@ -724,8 +741,12 @@ function __vf_loadCjs(id, parentDir, parentMod) { } catch (error) { delete __vf_cache[resolved]; if (parentMod) { - var childIdx = parentMod.children.indexOf(mod); - if (childIdx !== -1) parentMod.children.splice(childIdx, 1); + var childIdx = __vf_childIndex(parentMod, mod); + if (childIdx !== -1) { + var siblings = parentMod.children; + for (var k = childIdx; k < siblings.length - 1; k++) siblings[k] = siblings[k + 1]; + siblings.length = siblings.length - 1; + } } throw error; } @@ -749,10 +770,11 @@ routeModule.require = routeRequire; // imports of a module share it, and distinct modules never do. As with native // CommonJS interop, \`default\` is the whole module.exports, also for modules // marked __esModule. -var namespaces = new Map(); +var namespaces = Object.create(null); // import() rejects options that are not an object, a "with" value that is not // an object, a non-string attribute value, and attributes it does not support. -__vf_validateImportOptions = function (options) { +// The only supported type is "json", and only for a JSON module. +__vf_validateImportOptions = function (options, id) { if (options === void 0) return; if (options === null || (typeof options !== "object" && typeof options !== "function")) { throw new TypeError("The second argument to import() must be an object"); @@ -771,12 +793,20 @@ __vf_validateImportOptions = function (options) { throw new SyntaxError("Unsupported import attribute " + JSON.stringify(keys[i])); } } + var type = attributes.type; + if (type === void 0) return; + if (type !== "json") { + throw new TypeError("Import attribute type " + JSON.stringify(type) + " is not supported"); + } + if (id !== void 0 && (__vf_isBuiltin(id) || !__vf_resolveId(id, __VF_PROJECT_DIR__).endsWith(".json"))) { + throw new TypeError("Module " + JSON.stringify(id) + " is not a JSON module, but was imported with type json"); + } }; __vf_namespace = function (id) { var key = __vf_isBuiltin(id) ? "builtin:" + id.replace(/^node:/, "") : __vf_canonicalize(__vf_resolveId(id, __VF_PROJECT_DIR__)); - if (namespaces.has(key)) return namespaces.get(key); + if (key in namespaces) return namespaces[key]; var m = routeRequire(id); var ns = Object.create(null); if (m !== null && (typeof m === "object" || typeof m === "function")) { @@ -800,7 +830,7 @@ __vf_namespace = function (id) { // throw a TypeError, as they do on a native namespace. Object.defineProperty(ns, Symbol.toStringTag, { value: "Module" }); Object.freeze(ns); - namespaces.set(key, ns); + namespaces[key] = ns; return ns; }; return routeRequire; diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 6dc957506a..a1ef82554f 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -6066,6 +6066,34 @@ describe("route CommonJS require (#5471)", () => { }, ); + denoIt("caches namespaces without the mutable Map prototype", async () => { + const files = { + "node_modules/map-fixture/package.json": JSON.stringify({ + name: "map-fixture", + main: "index.js", + }), + "node_modules/map-fixture/index.js": + "Map.prototype.set = null; module.exports = { value: 'v' };", + }; + const originalSet = Map.prototype.set; + try { + const GET = await loadCompiledRoute( + [ + "export const GET = async () => {", + ' const ns = await import("map-fixture");', + " return new Response(ns.value);", + "};", + ].join("\n"), + files, + ); + const text = await responseText(GET); + Map.prototype.set = originalSet; + assertEquals(text, "v"); + } finally { + Map.prototype.set = originalSet; + } + }); + denoIt("gives CommonJS namespaces the immutability of module namespaces", async () => { const files = { "node_modules/frozen-fixture/package.json": JSON.stringify({ @@ -6872,7 +6900,14 @@ describe("route CommonJS require (#5471)", () => { }); denoIt("validates dynamic-import options like import()", async () => { - const projectDir = await writeProject({}); + const projectDir = await writeProject({ + "node_modules/typed-fixture/package.json": JSON.stringify({ + name: "typed-fixture", + main: "index.js", + }), + "node_modules/typed-fixture/index.js": "module.exports = { js: true };", + "node_modules/typed-fixture/data.json": JSON.stringify({ ok: "json" }), + }); const shimPath = join(projectDir, "vf-validate-shim.mjs"); await fs.writeTextFile( shimPath, @@ -6881,18 +6916,68 @@ describe("route CommonJS require (#5471)", () => { }\nexport { __vf_validateImportOptions as validate };\n`, ); const { validate } = await import(toFileUrl(shimPath).href) as { - validate: (options: unknown) => void; + validate: (options: unknown, id?: string) => void; }; validate(undefined); validate({}); validate({ with: { type: "json" } }); + assertEquals(thrownError(() => validate({ with: { type: "javascript" } })).name, "TypeError"); + validate({ with: { type: "json" } }, "typed-fixture/data.json"); + assertEquals( + thrownError(() => validate({ with: { type: "json" } }, "typed-fixture")).name, + "TypeError", + ); + assertEquals( + thrownError(() => validate({ with: { type: "json" } }, "node:fs")).name, + "TypeError", + ); assertEquals(thrownError(() => validate(null)).name, "TypeError"); assertEquals(thrownError(() => validate({ with: 1 })).name, "TypeError"); assertEquals(thrownError(() => validate({ with: { type: 1 } })).name, "TypeError"); assertEquals(thrownError(() => validate({ with: { other: "x" } })).name, "SyntaxError"); }); + denoIt("leaves a private method named #import alone", async () => { + const projectDir = await writeProject({ + "node_modules/private-fixture/package.json": JSON.stringify({ + name: "private-fixture", + main: "index.js", + }), + "node_modules/private-fixture/index.js": [ + "class Loader {", + " #import(spec) { return 'private ' + spec; }", + " run() { return this.#import('./child.mjs'); }", + "}", + "module.exports = new Loader().run();", + ].join("\n"), + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("private-fixture"), "private ./child.mjs"); + }); + + denoIt("returns the canonical path require.cache uses from require.resolve", async () => { + const projectDir = await writeProject({ + "node_modules/link-fixture/package.json": JSON.stringify({ + name: "link-fixture", + main: "index.js", + }), + "node_modules/link-fixture/real.js": "module.exports = 'real';", + "node_modules/link-fixture/index.js": [ + "require('./link');", + "module.exports = require.resolve('./link') in require.cache;", + ].join("\n"), + }); + Deno.symlinkSync( + join(projectDir, "node_modules/link-fixture/real.js"), + join(projectDir, "node_modules/link-fixture/link.js"), + ); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("link-fixture"), true); + }); + denoIt("lets a top-level dependency require through module.parent", async () => { const projectDir = await writeProject({ "node_modules/plugin-host/package.json": JSON.stringify({ diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index a31a8274a7..b45ce1c664 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -367,6 +367,17 @@ function rewriteCjsReexport( ); } +/** + * The `{ ... }` attributes of a static import whose lexer attribute index is + * `start`, or "" when it has none. + */ +function staticImportAttributes(masked: string, start: number, end: number): string { + if (start < 0) return ""; + const text = masked.slice(start, end).trim().replace(/;$/, "").trimEnd() + .replace(/^(?:with|assert)\s*/, ""); + return text.startsWith("{") && text.endsWith("}") ? text : ""; +} + /** * Rewrite a bundled compiled-binary route's user dependency imports. * @@ -384,7 +395,11 @@ export async function rewriteCompiledUserDependencyImportsForRoute( ): Promise { if (userDeps.size === 0) return code; const { masked, imports, unmask } = await parseMaskedImports(code); - const edits: { start: number; end: number; text: string; static?: boolean }[] = []; + // An edit renders its replacement; a dynamic import's options may contain + // further imports, so its render receives the rewrite of a nested range. + type Render = (rewriteRange: (start: number, end: number) => string) => string; + const edits: { start: number; end: number; render: Render; static?: boolean }[] = []; + const fixed = (text: string): Render => () => text; let reexports = 0; for (const imported of imports) { @@ -416,7 +431,7 @@ export async function rewriteCompiledUserDependencyImportsForRoute( edits.push({ start: imported.s, end: imported.e, - text: isDynamic ? toJsStringLiteral(url) : url, + render: fixed(isDynamic ? toJsStringLiteral(url) : url), }); continue; } @@ -434,34 +449,56 @@ export async function rewriteCompiledUserDependencyImportsForRoute( // An async arrow returns an intrinsic promise whatever route code binds // to `Promise`, and the await defers loading as import() does. const lazy = `(async () => { await null; return ${helperPrefix}namespace(${specifier}); })()`; - const options = imported.a > -1 - ? masked.slice(imported.a, imported.se - 1).trim().replace(/,$/, "").trimEnd() - : ""; // The options argument is evaluated first, as import() would, and // validated when the load starts, so bad options reject the promise. const optionsParam = `${helperPrefix}options`; + const optionsStart = imported.a; + const optionsEnd = imported.se - 1; edits.push({ start: imported.ss, end: imported.se, - text: options - ? `((${optionsParam}) => (async () => { await null; ${helperPrefix}validateImportOptions(${optionsParam}); return ${helperPrefix}namespace(${specifier}); })())(${options})` - : lazy, + render: (rewriteRange) => { + const options = optionsStart > -1 + ? rewriteRange(optionsStart, optionsEnd).trim().replace(/,$/, "").trimEnd() + : ""; + return options + ? `((${optionsParam}) => (async () => { await null; ${helperPrefix}validateImportOptions(${optionsParam}, ${specifier}); return ${helperPrefix}namespace(${specifier}); })())(${options})` + : lazy; + }, }); continue; } const statement = masked.slice(imported.ss, imported.se); + // Import attributes are checked when the route module loads, as a native + // import checks them before the route runs. + const attributes = staticImportAttributes(masked, imported.a, imported.se); + const check = attributes + ? `${helperPrefix}validateImportOptions({ with: ${attributes} }, ${specifier}); ` + : ""; if (statement.startsWith("export")) { + const reexport = rewriteCjsReexport( + statement, + specifier, + imported.n, + helperPrefix, + reexports++, + ); edits.push({ start: imported.ss, end: imported.se, - text: rewriteCjsReexport(statement, specifier, imported.n, helperPrefix, reexports++), + render: fixed(check + reexport), static: true, }); continue; } if (SIDE_EFFECT_IMPORT.test(statement)) { - edits.push({ start: imported.ss, end: imported.se, text: required, static: true }); + edits.push({ + start: imported.ss, + end: imported.se, + render: fixed(check + required), + static: true, + }); continue; } const clause = statement.match(STATIC_FROM_IMPORT)?.[1]; @@ -469,7 +506,12 @@ export async function rewriteCompiledUserDependencyImportsForRoute( ? null : rewriteCjsImportClause(clause, specifier, helperPrefix); if (rewritten !== null) { - edits.push({ start: imported.ss, end: imported.se, text: rewritten, static: true }); + edits.push({ + start: imported.ss, + end: imported.se, + render: fixed(check + rewritten), + static: true, + }); } } @@ -477,15 +519,23 @@ export async function rewriteCompiledUserDependencyImportsForRoute( // A replaced declaration ended where ASI applied, but a replacement // expression would let a following line starting with ( [ or a template // continue it, so every static rewrite ends in its own semicolon. - for (const edit of edits) { - if (edit.static && masked[edit.end] !== ";" && !edit.text.endsWith(";")) edit.text += ";"; - } - edits.sort((left, right) => right.start - left.start); - let transformed = masked; - for (const edit of edits) { - transformed = transformed.slice(0, edit.start) + edit.text + transformed.slice(edit.end); - } - return unmask(transformed); + // Edits are applied front to back. One nested in another (an import inside + // a dynamic import's options) is skipped at the outer level and applied + // when the outer edit renders that range, so no two edits overlap. + edits.sort((left, right) => left.start - right.start); + const rewriteRange = (start: number, end: number): string => { + let out = ""; + let cursor = start; + for (const edit of edits) { + if (edit.start < cursor || edit.end > end) continue; + let text = edit.render(rewriteRange); + if (edit.static && masked[edit.end] !== ";" && !text.endsWith(";")) text += ";"; + out += masked.slice(cursor, edit.start) + text; + cursor = edit.end; + } + return out + masked.slice(cursor, end); + }; + return unmask(rewriteRange(0, masked.length)); } export async function rewriteDenoNpmDependencyImportsForRoute( From e224578e452578f3447f4103cfc68db01cf964a4 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 13:58:31 +0100 Subject: [PATCH 19/22] fix(routing): resolve relative require.resolve paths from cwd; read / after postfix ++ as division (#5471) --- .../module-loader/external-import-rewriter.ts | 12 +++++- src/routing/api/module-loader/loader.test.ts | 42 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index dfb890af25..bbc194bd84 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -475,7 +475,8 @@ function __vf_resolveWithOptions(id, parentDir, options) { if (!Array.isArray(options.paths)) throw new TypeError("require.resolve options.paths must be an array"); for (var i = 0; i < options.paths.length; i++) { try { - return __vf_resolveContained(id, __vf_resolve(parentDir, options.paths[i])); + // As in Node, a relative entry is taken from the working directory. + return __vf_resolveContained(id, __vf_resolve(options.paths[i])); } catch (error) { if (!error || error.code !== "MODULE_NOT_FOUND") throw error; } @@ -616,6 +617,15 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { } // A ")" closing an if/while/for/with condition starts a statement, where // "/" begins a regex literal; any other ")" ends a value. + // "++" and "--" after a value are postfix and end that value, so a "/" + // after them divides; before a value they are prefix operators. + if ((c === "+" || c === "-") && code[i + 1] === c) { + var afterValue = previous === "word" || previous === "value" || previous === ")" || previous === "]"; + previous = afterValue ? "value" : c; + controlWord = false; + i += 2; + continue; + } if (c === "(") parens.push(controlWord); controlWord = false; if (c === "{") stack.push("{"); diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index a1ef82554f..87e6419bf1 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -6066,6 +6066,29 @@ describe("route CommonJS require (#5471)", () => { }, ); + denoIt("reads a slash after a postfix increment as division", async () => { + const files = { + "node_modules/postfix-fixture/package.json": JSON.stringify({ + name: "postfix-fixture", + main: "index.js", + }), + "node_modules/postfix-fixture/index.js": [ + "let p, i = 1;", + 'i++ / (p = import(".\\u002fchild.mjs"), 1) / 2;', + "module.exports = () => p.then((m) => m.value);", + ].join("\n"), + "node_modules/postfix-fixture/child.mjs": "export const value = 'postfix child';", + }; + const GET = await loadCompiledRoute( + [ + 'import load from "postfix-fixture";', + "export const GET = async () => new Response(await load());", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "postfix child"); + }); + denoIt("caches namespaces without the mutable Map prototype", async () => { const files = { "node_modules/map-fixture/package.json": JSON.stringify({ @@ -6978,6 +7001,25 @@ describe("route CommonJS require (#5471)", () => { assertEquals(vfRequire("link-fixture"), true); }); + denoIt("resolves relative custom resolve paths from the working directory", async () => { + const projectDir = await writeProject({ + "fixtures/node_modules/plugin/package.json": JSON.stringify({ + name: "plugin", + main: "index.js", + }), + "fixtures/node_modules/plugin/index.js": "module.exports = 1;", + "node_modules/loader/package.json": JSON.stringify({ name: "loader", main: "index.js" }), + "node_modules/loader/index.js": [ + "const path = require('node:path');", + "const fixtures = path.relative(process.cwd(), path.join(__dirname, '..', '..', 'fixtures'));", + "module.exports = require.resolve('plugin', { paths: [fixtures] });", + ].join("\n"), + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("loader"), join(projectDir, "fixtures/node_modules/plugin/index.js")); + }); + denoIt("lets a top-level dependency require through module.parent", async () => { const projectDir = await writeProject({ "node_modules/plugin-host/package.json": JSON.stringify({ From 76f26858ed1c49fdbb653f25a23a9f9df703e17a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 14:19:35 +0100 Subject: [PATCH 20/22] fix(routing): keep the CJS loader independent of replaceable built-in prototypes (#5471) Capture String, Function and URL prototype methods as uncurried functions, drop regex and Symbol-dispatching string methods, grow arrays by index, and resolve bare packages and module-relative file URLs without the runtime's node:module and node:url helpers, which call replaceable prototype methods. --- .../module-loader/external-import-rewriter.ts | 364 ++++++++++++------ src/routing/api/module-loader/loader.test.ts | 74 ++++ 2 files changed, 310 insertions(+), 128 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index bbc194bd84..3462e2ceed 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -136,7 +136,8 @@ var require = (function () { // uninitialized when this runs. These locals come from the global object, // which is reached through syntax alone. var __vf_global = (function () {}).constructor("return this")(); -var Deno = __vf_global.Deno, Error = __vf_global.Error, Function = __vf_global.Function; +var __vf_Deno = __vf_global.Deno; +var Error = __vf_global.Error, Function = __vf_global.Function; var TypeError = __vf_global.TypeError; var globalThis = __vf_global; // The static methods the loader calls are captured now, before route code @@ -155,6 +156,65 @@ var JSON = { parse: __vf_global.JSON.parse, stringify: __vf_global.JSON.stringif var Symbol = { toStringTag: __vf_global.Symbol.toStringTag }; var String = { fromCodePoint: __vf_global.String.fromCodePoint }; var parseInt = __vf_global.parseInt, SyntaxError = __vf_global.SyntaxError; +// Prototype methods are captured too, as plain functions taking the receiver +// first, so route or package code that replaces String, Array, RegExp or +// Function prototype methods cannot change how the loader behaves. The loader +// uses no regular expressions and no string methods that dispatch through +// Symbol.split, Symbol.replace or Symbol.search, which any string would reach. +var __vf_FunctionPrototype = Function.prototype; +var __vf_uncurry = __vf_FunctionPrototype.bind.bind(__vf_FunctionPrototype.call); +var __vf_invoke = __vf_uncurry(__vf_FunctionPrototype.call); +var __vf_bindTo = __vf_uncurry(__vf_FunctionPrototype.bind); +var __vf_StringPrototype = __vf_global.String.prototype; +var __vf_slice = __vf_uncurry(__vf_StringPrototype.slice); +var __vf_indexOf = __vf_uncurry(__vf_StringPrototype.indexOf); +var __vf_startsWith = __vf_uncurry(__vf_StringPrototype.startsWith); +var __vf_endsWith = __vf_uncurry(__vf_StringPrototype.endsWith); +var __vf_charCodeAt = __vf_uncurry(__vf_StringPrototype.charCodeAt); +var __vf_hasOwn = __vf_uncurry(__vf_Object.prototype.hasOwnProperty); +var __vf_hrefOf = __vf_uncurry(__vf_Object.getOwnPropertyDescriptor(__vf_global.URL.prototype, "href").get); +var Deno = { + readTextFileSync: __vf_bindTo(__vf_Deno.readTextFileSync, __vf_Deno), + realPathSync: __vf_bindTo(__vf_Deno.realPathSync, __vf_Deno), + statSync: __vf_bindTo(__vf_Deno.statSync, __vf_Deno), +}; +// Arrays are grown and shrunk by index, not with push or pop. +function __vf_append(list, value) { + list[list.length] = value; +} +function __vf_removeLast(list) { + if (list.length === 0) return void 0; + var last = list[list.length - 1]; + list.length = list.length - 1; + return last; +} +// Every occurrence of a literal search string replaced, without a regex. +function __vf_replaceAll(text, search, replacement) { + var out = ""; + var from = 0; + for (var at = __vf_indexOf(text, search); at !== -1; at = __vf_indexOf(text, search, from)) { + out += __vf_slice(text, from, at) + replacement; + from = at + search.length; + } + return out + __vf_slice(text, from); +} +function __vf_forwardSlashes(path) { + return __vf_replaceAll(path, "\\\\", "/"); +} +// The first index of a query or hash mark, or -1. +function __vf_suffixIndex(spec) { + var query = __vf_indexOf(spec, "?"); + var hash = __vf_indexOf(spec, "#"); + if (query === -1) return hash; + return hash === -1 || query < hash ? query : hash; +} +function __vf_isWordCode(code) { + return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || + code === 95 || code === 36 || code > 127; +} +function __vf_isHexCode(code) { + return (code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102); +} var __vf_builtinRequire = __vf_createRequire(__VF_PROJECT_DIR__ + "/package.json"); // Lookup tables are null-prototype objects rather than Set or Map, so route or // package code that replaces a collection prototype method cannot change them. @@ -173,14 +233,14 @@ try { var __vf_runtimeBuiltins = __vf_builtinRequire("node:module").builtinModules; for (var __vf_b = 0; __vf_b < __vf_runtimeBuiltins.length; __vf_b++) { var __vf_name = __vf_runtimeBuiltins[__vf_b]; - if (__vf_name.startsWith("node:") || __vf_prefixOnlyBuiltins[__vf_name] === true) continue; + if (__vf_startsWith(__vf_name, "node:") || __vf_prefixOnlyBuiltins[__vf_name] === true) continue; __vf_builtinSet[__vf_name] = true; } } catch (_) { /* expected: fall back to the shared list */ } // Absolute ids include Windows drive-letter and UNC paths. var __vf_isAbsolute = __vf_builtinRequire("node:path").isAbsolute; function __vf_isPathId(id) { - return id.startsWith(".") || __vf_isAbsolute(id); + return __vf_startsWith(id, ".") || __vf_isAbsolute(id); } var __vf_projectRoot = __VF_PROJECT_ROOT__; // VULN-FS-5: Canonicalize the project root so containment checks using @@ -189,8 +249,34 @@ var __vf_projectRoot = __VF_PROJECT_ROOT__; // module path has a different prefix than the non-canonical projectRoot and // legitimate dependencies would be rejected. try { __vf_projectRoot = Deno.realPathSync(__vf_projectRoot); } catch (_) { /* expected: projectRoot may not exist at shim init in some environments */ } +// File URLs for module-relative import() are built from the project root's URL, +// taken now, so no URL helper runs while route code may have replaced the +// prototype methods such helpers call. +var __vf_URL = __vf_global.URL; +var __vf_separator = __vf_builtinRequire("node:path").sep; +var __vf_projectRootHref = __vf_hrefOf(__vf_builtinRequire("node:url").pathToFileURL(__vf_projectRoot)); +var __vf_urlEscapes = Object.create(null); +__vf_urlEscapes["%"] = "%25"; +__vf_urlEscapes["\\\\"] = "%5C"; +__vf_urlEscapes["\\n"] = "%0A"; +__vf_urlEscapes["\\r"] = "%0D"; +__vf_urlEscapes["\\t"] = "%09"; +__vf_urlEscapes["?"] = "%3F"; +__vf_urlEscapes["#"] = "%23"; +// The file URL of a canonical path already checked to be inside the project, +// escaped as pathToFileURL escapes it. +function __vf_fileUrl(contained) { + var relative = __vf_slice(contained, __vf_projectRoot.length); + var encoded = ""; + for (var i = 0; i < relative.length; i++) { + var c = relative[i]; + if (c === __vf_separator) encoded += "/"; + else encoded += __vf_urlEscapes[c] !== void 0 ? __vf_urlEscapes[c] : c; + } + if (encoded !== "" && encoded[0] !== "/" && !__vf_endsWith(__vf_projectRootHref, "/")) encoded = "/" + encoded; + return __vf_hrefOf(new __vf_URL(__vf_projectRootHref + encoded)); +} var __vf_cache = Object.create(null); -var __vf_parentRequires = Object.create(null); // Resolve symlinks so containment compares canonical paths. A module that does // not exist yet cannot be canonicalized, so fall back to its parent directory, // which is what carries a symlinked project root; only if that fails too is the @@ -201,51 +287,39 @@ function __vf_canonicalize(candidate) { } catch (_) { /* expected: candidate may not exist */ } try { var parent = __vf_dirname(candidate); - return Deno.realPathSync(parent) + candidate.slice(parent.length); + return Deno.realPathSync(parent) + __vf_slice(candidate, parent.length); } catch (_) { /* expected: parent may not exist either */ } return __vf_resolve(candidate); } // The error names the id the code asked for, never the resolved host path. function __vf_assertContained(resolved, id) { - var norm = __vf_resolve(resolved).replace(/\\\\/g, "/"); - var root = __vf_projectRoot.replace(/\\\\/g, "/"); + var norm = __vf_forwardSlashes(__vf_resolve(resolved)); + var root = __vf_forwardSlashes(__vf_projectRoot); // A project at a filesystem root ("/", "C:/") already ends in a separator. - var prefix = root.endsWith("/") ? root : root + "/"; - if (!norm.startsWith(prefix) && norm !== root) { + var prefix = __vf_endsWith(root, "/") ? root : root + "/"; + if (!__vf_startsWith(norm, prefix) && norm !== root) { throw new Error("CJS loader blocked path outside project: " + JSON.stringify(id)); } } -// Bare ids resolve from the requiring module's directory, as Node does, so a -// package's own non-hoisted node_modules wins over the project root's copy. -function __vf_parentRequire(parentDir) { - var cached = __vf_parentRequires[parentDir]; - if (cached) return cached; - cached = __vf_createRequire(__vf_resolve(parentDir, "__vf_parent.js")); - __vf_parentRequires[parentDir] = cached; - return cached; -} function __vf_packageName(id) { - var parts = id.split("/"); - return id.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; -} -function __vf_packageInstalled(name, parentDir) { - for (var dir = __vf_resolve(parentDir); ; dir = __vf_dirname(dir)) { - try { Deno.statSync(__vf_resolve(dir, "node_modules", name)); return true; } catch (_) { /* expected: not installed at this level */ } - if (__vf_dirname(dir) === dir) return false; - } + var slash = __vf_indexOf(id, "/"); + if (slash !== -1 && __vf_startsWith(id, "@")) slash = __vf_indexOf(id, "/", slash + 1); + return slash === -1 ? id : __vf_slice(id, 0, slash); } -// Node's package "exports" resolution for require(), used where the runtime's -// resolver cannot be: a target is undefined when nothing matched, null when the +// Node's package "exports" resolution for require(): a target is undefined when nothing matched, null when the // package blocks the subpath. Conditions are tried in the object's key order. -var __vf_requireConditions = { require: true, node: true, "node-addons": true, default: true }; +var __vf_requireConditions = __vf_tableOf(["require", "node", "node-addons", "default"]); function __vf_exportsTarget(target, star) { if (typeof target === "string") { - if (!target.startsWith("./")) return null; - var value = star === void 0 ? target : target.split("*").join(star); - var segments = value.slice(2).split("/"); - for (var i = 0; i < segments.length; i++) { - var segment = segments[i]; - if (segment === "" || segment === "." || segment === ".." || segment === "node_modules" || segment.indexOf("\\\\") !== -1) return null; + if (!__vf_startsWith(target, "./")) return null; + var value = star === void 0 ? target : __vf_replaceAll(target, "*", star); + if (__vf_indexOf(value, "\\\\") !== -1) return null; + for (var from = 2; ; ) { + var slash = __vf_indexOf(value, "/", from); + var segment = __vf_slice(value, from, slash === -1 ? value.length : slash); + if (segment === "" || segment === "." || segment === ".." || segment === "node_modules") return null; + if (slash === -1) break; + from = slash + 1; } return value; } @@ -269,10 +343,11 @@ function __vf_exportsTarget(target, star) { } function __vf_exportsSubpath(exportsField, subpath) { var keys = exportsField && typeof exportsField === "object" && !Array.isArray(exportsField) ? Object.keys(exportsField) : []; - var dotKeys = keys.filter(function (key) { return key.startsWith("."); }).length; + var dotKeys = 0; + for (var d = 0; d < keys.length; d++) if (__vf_startsWith(keys[d], ".")) dotKeys++; if (dotKeys !== 0 && dotKeys !== keys.length) return null; var map = dotKeys === 0 ? { ".": exportsField } : exportsField; - if (Object.prototype.hasOwnProperty.call(map, subpath) && subpath.indexOf("*") === -1) { + if (__vf_hasOwn(map, subpath) && __vf_indexOf(subpath, "*") === -1) { return __vf_exportsTarget(map[subpath], void 0); } var bestKey = null; @@ -281,15 +356,15 @@ function __vf_exportsSubpath(exportsField, subpath) { var patterns = Object.keys(map); for (var i = 0; i < patterns.length; i++) { var key = patterns[i]; - var starAt = key.indexOf("*"); - if (starAt === -1 || key.indexOf("*", starAt + 1) !== -1) continue; - var prefix = key.slice(0, starAt); - var suffix = key.slice(starAt + 1); - if (subpath.length < key.length || !subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue; + var starAt = __vf_indexOf(key, "*"); + if (starAt === -1 || __vf_indexOf(key, "*", starAt + 1) !== -1) continue; + var prefix = __vf_slice(key, 0, starAt); + var suffix = __vf_slice(key, starAt + 1); + if (subpath.length < key.length || !__vf_startsWith(subpath, prefix) || !__vf_endsWith(subpath, suffix)) continue; if (prefix.length > bestPrefix || (prefix.length === bestPrefix && key.length > bestKey.length)) { bestKey = key; bestPrefix = prefix.length; - bestStar = subpath.slice(prefix.length, subpath.length - suffix.length); + bestStar = __vf_slice(subpath, prefix.length, subpath.length - suffix.length); } } return bestKey === null ? void 0 : __vf_exportsTarget(map[bestKey], bestStar); @@ -305,14 +380,16 @@ function __vf_resolveInPackage(id, dir, packageDir) { try { exportsField = JSON.parse(__vf_readText(manifest, name)).exports; } catch (_) { - throw new Error("invalid package.json for " + JSON.stringify(name)); + var invalid = new Error("npm package " + JSON.stringify(name) + " could not be resolved (ERR_INVALID_PACKAGE_CONFIG)"); + invalid.code = "ERR_INVALID_PACKAGE_CONFIG"; + throw invalid; } } if (exportsField === void 0 || exportsField === null) { // A relative id keeps host paths out of any error it raises. return __vf_resolveLocal("./node_modules/" + id, dir); } - var subpath = "." + id.slice(name.length); + var subpath = "." + __vf_slice(id, name.length); var target = __vf_exportsSubpath(exportsField, subpath); if (typeof target !== "string") { var blocked = new Error("npm package " + JSON.stringify(name) + " does not export " + JSON.stringify(subpath)); @@ -328,49 +405,39 @@ function __vf_resolveInPackage(id, dir, packageDir) { } return exported; } -// A package named like a prefix-only built-in (test, sqlite, sea) is looked up -// in node_modules directly: the runtime's resolver may claim the bare name. -function __vf_resolvePrefixOnlyName(id, parentDir) { - for (var dir = __vf_resolve(parentDir); ; dir = __vf_dirname(dir)) { - var packageDir = __vf_resolve(dir, "node_modules", __vf_packageName(id)); - var exists = false; - try { Deno.statSync(packageDir); exists = true; } catch (_) { /* expected: not installed at this level */ } - if (exists) return __vf_resolveInPackage(id, dir, packageDir); - if (__vf_dirname(dir) === dir) break; - } - var missing = new Error("npm package " + JSON.stringify(__vf_packageName(id)) + " is not installed in this runtime's node_modules"); - missing.code = "MODULE_NOT_FOUND"; - throw missing; -} +// Bare ids resolve from the requiring module's directory, as Node does, so a +// package's own non-hoisted node_modules wins over the project root's copy. +// The loader walks node_modules itself instead of asking the runtime's +// resolver, which route code could change through built-in prototypes and +// which may claim names of prefix-only built-ins (test, sqlite, sea). function __vf_resolveBare(id, parentDir) { - if (id.startsWith("node:")) { + if (__vf_startsWith(id, "node:")) { var unknown = new Error("Cannot find module " + JSON.stringify(id)); unknown.code = "MODULE_NOT_FOUND"; throw unknown; } - if (__vf_prefixOnlyBuiltins[__vf_packageName(id)] === true) return __vf_resolvePrefixOnlyName(id, parentDir); - try { - return __vf_parentRequire(parentDir).resolve(id); - } catch (error) { - // The resolver's message carries host paths in its require stack. Name - // only what the route asked for, and say "not installed" only when no - // node_modules on the lookup path has the package at all. - var name = __vf_packageName(id); - // Other resolver errors (an invalid package.json, a blocked subpath) keep - // their code but not their message, which names host paths. - if (!error || error.code !== "MODULE_NOT_FOUND") { - var failed = new Error("npm package " + JSON.stringify(name) + " could not be resolved" + (error && error.code ? " (" + error.code + ")" : "")); - if (error && error.code) failed.code = error.code; - throw failed; + var name = __vf_packageName(id); + for (var dir = __vf_resolve(parentDir); ; dir = __vf_dirname(dir)) { + var packageDir = __vf_resolve(dir, "node_modules", name); + var exists = false; + try { Deno.statSync(packageDir); exists = true; } catch (_) { /* expected: not installed at this level */ } + if (exists) { + try { + return __vf_resolveInPackage(id, dir, packageDir); + } catch (error) { + if (!error || error.code !== "MODULE_NOT_FOUND") throw error; + // Keep the code: packages probe optional dependencies with + // \`error.code === "MODULE_NOT_FOUND"\`. + var unresolved = new Error("npm package " + JSON.stringify(name) + " is installed but " + JSON.stringify(id) + " could not be resolved"); + unresolved.code = "MODULE_NOT_FOUND"; + throw unresolved; + } } - // Keep the code: packages probe optional dependencies with - // \`error.code === "MODULE_NOT_FOUND"\`. - var sanitized = new Error(__vf_packageInstalled(name, parentDir) - ? "npm package " + JSON.stringify(name) + " is installed but " + JSON.stringify(id) + " could not be resolved" - : "npm package " + JSON.stringify(name) + " is not installed in this runtime's node_modules"); - sanitized.code = "MODULE_NOT_FOUND"; - throw sanitized; + if (__vf_dirname(dir) === dir) break; } + var missing = new Error("npm package " + JSON.stringify(name) + " is not installed in this runtime's node_modules"); + missing.code = "MODULE_NOT_FOUND"; + throw missing; } // Relative and absolute ids resolve against the requiring module's directory, // probing the extensions Node probes. A missing module is reported by the id @@ -428,8 +495,8 @@ function __vf_resolveLocal(id, parentDir) { } function __vf_isBuiltin(id) { // Only real built-ins are reserved under node:, as in Node. - if (id.startsWith("node:")) { - var bare = id.slice(5); + if (__vf_startsWith(id, "node:")) { + var bare = __vf_slice(id, 5); return __vf_builtinSet[bare] === true || __vf_prefixOnlyBuiltins[bare] === true; } // Only the runtime's own built-in ids: "fs/promises" is a built-in, while @@ -440,12 +507,17 @@ function __vf_isBuiltin(id) { // root so host directories above it are never listed. The directory is // canonicalized first, so the walk meets the canonical root even when the // project was opened through a symlink. +function __vf_isNodeModulesDir(dir) { + if (!__vf_endsWith(dir, "node_modules")) return false; + var before = dir[dir.length - 13]; + return dir.length === 12 || before === "/" || before === "\\\\"; +} function __vf_nodeModulePaths(dir) { var paths = []; var start = __vf_canonicalize(dir); try { __vf_assertContained(start, dir); } catch (_) { return paths; } for (var current = start; ; current = __vf_dirname(current)) { - if (!/(^|[\\\\/])node_modules$/.test(current)) paths.push(__vf_resolve(current, "node_modules")); + if (!__vf_isNodeModulesDir(current)) __vf_append(paths, __vf_resolve(current, "node_modules")); if (current === __vf_projectRoot || __vf_dirname(current) === current) break; } return paths; @@ -489,7 +561,7 @@ function __vf_resolveWithOptions(id, parentDir, options) { // import("./x.mjs") against this handler, not its file. Literal relative and // absolute dynamic imports in code (not in strings, comments, templates or // regex literals) are routed to a module-relative loader instead. -var __vf_regexAfterWord = { "return": 1, "typeof": 1, "instanceof": 1, "in": 1, "of": 1, "new": 1, "delete": 1, "void": 1, "throw": 1, "case": 1, "do": 1, "else": 1, "yield": 1, "await": 1 }; +var __vf_regexAfterWord = __vf_tableOf(["return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await"]); // Only "import(" and the specifier are replaced, so an options argument and // the closing parenthesis stay as written and reach the helper. function __vf_skipQuoted(code, i, quote) { @@ -515,27 +587,64 @@ function __vf_skipTrivia(code, i) { var c = code[i]; if (c === " " || c === "\\t" || c === "\\n" || c === "\\r") { i++; continue; } if (c === "/" && code[i + 1] === "/") { - var lineEnd = code.indexOf("\\n", i); + var lineEnd = __vf_indexOf(code, "\\n", i); i = lineEnd === -1 ? code.length : lineEnd; continue; } if (c === "/" && code[i + 1] === "*") { - var blockEnd = code.indexOf("*/", i + 2); + var blockEnd = __vf_indexOf(code, "*/", i + 2); i = blockEnd === -1 ? code.length : blockEnd + 2; continue; } return i; } } -var __vf_simpleEscapes = { b: "\\b", f: "\\f", n: "\\n", r: "\\r", t: "\\t", v: "\\v", "0": "\\0" }; -// The value of a string literal's body, with its escapes decoded. +var __vf_simpleEscapes = Object.create(null); +__vf_simpleEscapes.b = "\\b"; +__vf_simpleEscapes.f = "\\f"; +__vf_simpleEscapes.n = "\\n"; +__vf_simpleEscapes.r = "\\r"; +__vf_simpleEscapes.t = "\\t"; +__vf_simpleEscapes.v = "\\v"; +__vf_simpleEscapes["0"] = "\\0"; +// The end of a run of at most max hex digits starting at start. +function __vf_hexEnd(text, start, max) { + var end = start; + while (end < text.length && end - start < max && __vf_isHexCode(__vf_charCodeAt(text, end))) end++; + return end; +} +// The value of a string literal's body, with its escapes decoded. A malformed +// escape keeps its character, as the specifier check only needs the prefix. function __vf_decodeStringBody(body) { - return body.replace(/\\\\(?:u\\{([0-9a-fA-F]+)\\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2})|(\\r\\n|[\\s\\S]))/g, function (_match, codePoint, unit, byte, other) { - var hex = codePoint || unit || byte; - if (hex) return String.fromCodePoint(parseInt(hex, 16)); - if (other === "\\n" || other === "\\r" || other === "\\r\\n" || other === "
" || other === "
") return ""; - return __vf_simpleEscapes[other] !== void 0 ? __vf_simpleEscapes[other] : other; - }); + var out = ""; + for (var i = 0; i < body.length; i++) { + var c = body[i]; + if (c !== "\\\\") { out += c; continue; } + if (++i >= body.length) { out += c; break; } + var next = body[i]; + var end; + if (next === "u" && body[i + 1] === "{") { + end = __vf_hexEnd(body, i + 2, body.length); + if (end > i + 2 && body[end] === "}") { + out += String.fromCodePoint(parseInt(__vf_slice(body, i + 2, end), 16)); + i = end; + continue; + } + } else if (next === "u" || next === "x") { + var digits = next === "u" ? 4 : 2; + end = __vf_hexEnd(body, i + 1, digits); + if (end === i + 1 + digits) { + out += String.fromCodePoint(parseInt(__vf_slice(body, i + 1, end), 16)); + i = end - 1; + continue; + } + } + // A line continuation adds nothing to the value. + if (next === "\\r" && body[i + 1] === "\\n") { i++; continue; } + if (next === "\\n" || next === "\\r" || next === "\\u2028" || next === "\\u2029") continue; + out += __vf_simpleEscapes[next] !== void 0 ? __vf_simpleEscapes[next] : next; + } + return out; } // At an "import" token: the end of "import ( " when the string is a // relative or absolute path, with comments allowed between the tokens, or null. @@ -546,12 +655,12 @@ function __vf_matchRelativeImport(code, afterWord) { var quote = code[quoteAt]; if (quote !== "'" && quote !== '"') return null; var end = __vf_skipQuoted(code, quoteAt, quote); - var spec = __vf_decodeStringBody(code.slice(quoteAt + 1, end - 1)); - if (!/^(?:\\.{1,2}\\/|\\/)/.test(spec)) return null; + var spec = __vf_decodeStringBody(__vf_slice(code, quoteAt + 1, end - 1)); + if (!__vf_startsWith(spec, "./") && !__vf_startsWith(spec, "../") && !__vf_startsWith(spec, "/")) return null; return { end: end, spec: spec }; } function __vf_rewriteCjsDynamicImports(code, helperName) { - if (code.indexOf("import") === -1) return code; + if (__vf_indexOf(code, "import") === -1) return code; var out = ""; var copied = 0; // "" or a punctuator: the last significant token; "word" and "value" mean an @@ -565,24 +674,24 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { var c = code[i]; if (c === " " || c === "\\t" || c === "\\n" || c === "\\r") { i++; continue; } if (c === "/" && code[i + 1] === "/") { - var lineEnd = code.indexOf("\\n", i); + var lineEnd = __vf_indexOf(code, "\\n", i); i = lineEnd === -1 ? code.length : lineEnd; continue; } if (c === "/" && code[i + 1] === "*") { - var blockEnd = code.indexOf("*/", i + 2); + var blockEnd = __vf_indexOf(code, "*/", i + 2); i = blockEnd === -1 ? code.length : blockEnd + 2; continue; } if (c === "'" || c === '"') { i = __vf_skipQuoted(code, i, c); previous = "value"; continue; } if (c === "\`" || (c === "}" && stack[stack.length - 1] === "\${")) { // A template, or its continuation after a substitution. - if (c === "}") stack.pop(); + if (c === "}") __vf_removeLast(stack); var substitution = false; for (i++; i < code.length; i++) { if (code[i] === "\\\\") { i++; continue; } if (code[i] === "\`") { i++; break; } - if (code[i] === "$" && code[i + 1] === "{") { stack.push("\${"); i += 2; substitution = true; break; } + if (code[i] === "$" && code[i + 1] === "{") { __vf_append(stack, "\${"); i += 2; substitution = true; break; } } previous = substitution ? "(" : "value"; continue; @@ -597,21 +706,21 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { } continue; } - if (/[A-Za-z0-9_$]/.test(c) || c.charCodeAt(0) > 127) { + if (__vf_isWordCode(__vf_charCodeAt(code, i))) { var start = i; - while (i < code.length && (/[A-Za-z0-9_$]/.test(code[i]) || code.charCodeAt(i) > 127)) i++; - var word = code.slice(start, i); + while (i < code.length && __vf_isWordCode(__vf_charCodeAt(code, i))) i++; + var word = __vf_slice(code, start, i); // Not a property (obj.import) or a private name (this.#import). if (word === "import" && previous !== "." && previous !== "#") { var call = __vf_matchRelativeImport(code, i); if (call) { - out += code.slice(copied, start) + helperName + "(" + JSON.stringify(call.spec); + out += __vf_slice(code, copied, start) + helperName + "(" + JSON.stringify(call.spec); copied = i = call.end; previous = "value"; continue; } } - previous = __vf_regexAfterWord[word] === 1 ? "(" : "word"; + previous = __vf_regexAfterWord[word] === true ? "(" : "word"; controlWord = word === "if" || word === "while" || word === "for" || word === "with"; continue; } @@ -626,31 +735,30 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { i += 2; continue; } - if (c === "(") parens.push(controlWord); + if (c === "(") __vf_append(parens, controlWord); controlWord = false; - if (c === "{") stack.push("{"); - else if (c === "}") stack.pop(); - previous = c === ")" && parens.pop() === true ? "(" : c; + if (c === "{") __vf_append(stack, "{"); + else if (c === "}") __vf_removeLast(stack); + previous = c === ")" && __vf_removeLast(parens) === true ? "(" : c; i++; } - return copied === 0 ? code : out + code.slice(copied); + return copied === 0 ? code : out + __vf_slice(code, copied); } function __vf_importFrom(spec, parentDir, options) { return (async function () { // A query or hash belongs to the module URL, not to the file path. - var suffixAt = spec.search(/[?#]/); - var path = suffixAt === -1 ? spec : spec.slice(0, suffixAt); - var target = __vf_resolve(parentDir, path); - __vf_assertContained(__vf_canonicalize(target), spec); - var toUrl = __vf_builtinRequire("node:url").pathToFileURL; - var url = toUrl(target).href + (suffixAt === -1 ? "" : spec.slice(suffixAt)); + var suffixAt = __vf_suffixIndex(spec); + var path = suffixAt === -1 ? spec : __vf_slice(spec, 0, suffixAt); + var target = __vf_canonicalize(__vf_resolve(parentDir, path)); + __vf_assertContained(target, spec); + var url = __vf_fileUrl(target) + (suffixAt === -1 ? "" : __vf_slice(spec, suffixAt)); try { return options === void 0 ? await import(url) : await import(url, options); } catch (error) { // A load failure for this file names its host path; report the // specifier the package wrote instead. var message = error && typeof error.message === "string" ? error.message : ""; - if (message.indexOf(__vf_projectRoot) === -1 && message.indexOf(toUrl(__vf_projectRoot).href) === -1) throw error; + if (__vf_indexOf(message, __vf_projectRoot) === -1 && __vf_indexOf(message, __vf_projectRootHref) === -1) throw error; var failed = new Error("cannot import " + JSON.stringify(spec) + " from a CommonJS module" + (error && error.code ? " (" + error.code + ")" : "")); if (error && error.code) failed.code = error.code; throw failed; @@ -668,7 +776,7 @@ function __vf_readText(path, id) { if (error && error.code) failed.code = error.code; throw failed; } - return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; + return __vf_charCodeAt(text, 0) === 0xFEFF ? __vf_slice(text, 1) : text; } // As in Node, every required module records the module that first required it // as module.parent, and each requiring module lists it once in module.children. @@ -689,10 +797,10 @@ function __vf_loadCjs(id, parentDir, parentMod) { } else { resolved = __vf_resolveBare(id, parentDir); } - if (resolved.endsWith(".node")) { + if (__vf_endsWith(resolved, ".node")) { throw new Error("native addon " + JSON.stringify(id) + " cannot be loaded: native addons are not supported in this runtime"); } - if (resolved.endsWith(".mjs")) { + if (__vf_endsWith(resolved, ".mjs")) { throw new Error("ES module file " + JSON.stringify(id) + " cannot be loaded with require: import the package by a CommonJS entry"); } // VULN-FS-5: Canonicalize before asserting containment, so the candidate and @@ -711,7 +819,7 @@ function __vf_loadCjs(id, parentDir, parentMod) { return __vf_cache[resolved].exports; } var code = __vf_readText(resolved, id); - if (resolved.endsWith(".json")) { + if (__vf_endsWith(resolved, ".json")) { var json = JSON.parse(code); var jsonMod = { exports: json, id: resolved, filename: resolved, loaded: true, children: [], parent: parentMod }; __vf_cache[resolved] = jsonMod; @@ -720,12 +828,12 @@ function __vf_loadCjs(id, parentDir, parentMod) { } // Node strips a leading hashbang from CommonJS sources; a Function body // cannot contain one. Commenting it out keeps line numbers intact. - if (code.startsWith("#!")) code = "//" + code.slice(2); + if (__vf_startsWith(code, "#!")) code = "//" + __vf_slice(code, 2); var dir = __vf_dirname(resolved); // The helper's name is one the package's source never mentions, so no // binding of the package can shadow it at a rewritten import. var importHelper = "__vf_cjsImport"; - while (code.indexOf(importHelper) !== -1) importHelper += "_"; + while (__vf_indexOf(code, importHelper) !== -1) importHelper += "_"; code = __vf_rewriteCjsDynamicImports(code, importHelper); var cjsImport = function (spec, options) { return __vf_importFrom(spec, dir, options); }; var mod; @@ -747,7 +855,7 @@ function __vf_loadCjs(id, parentDir, parentMod) { // colliding with a parameter. var outer = new Function("global", "globalThis", "Worker", importHelper, "return function (exports, require, module, __filename, __dirname) {\\n" + code + "\\n};"); var fn = outer(globalThis, globalThis, void 0, cjsImport); - fn.call(mod.exports, mod.exports, childReq, mod, resolved, dir); + __vf_invoke(fn, mod.exports, mod.exports, childReq, mod, resolved, dir); } catch (error) { delete __vf_cache[resolved]; if (parentMod) { @@ -808,13 +916,13 @@ __vf_validateImportOptions = function (options, id) { if (type !== "json") { throw new TypeError("Import attribute type " + JSON.stringify(type) + " is not supported"); } - if (id !== void 0 && (__vf_isBuiltin(id) || !__vf_resolveId(id, __VF_PROJECT_DIR__).endsWith(".json"))) { + if (id !== void 0 && (__vf_isBuiltin(id) || !__vf_endsWith(__vf_resolveId(id, __VF_PROJECT_DIR__), ".json"))) { throw new TypeError("Module " + JSON.stringify(id) + " is not a JSON module, but was imported with type json"); } }; __vf_namespace = function (id) { var key = __vf_isBuiltin(id) - ? "builtin:" + id.replace(/^node:/, "") + ? "builtin:" + (__vf_startsWith(id, "node:") ? __vf_slice(id, 5) : id) : __vf_canonicalize(__vf_resolveId(id, __VF_PROJECT_DIR__)); if (key in namespaces) return namespaces[key]; var m = routeRequire(id); diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 87e6419bf1..8430b8d1c7 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -6089,6 +6089,80 @@ describe("route CommonJS require (#5471)", () => { assertEquals(await responseText(GET), "postfix child"); }); + denoIt("loads CommonJS packages while built-in prototype methods are replaced", async () => { + const files = { + "node_modules/trap-fixture/package.json": JSON.stringify({ + name: "trap-fixture", + exports: { ".": "./index.js", "./lib/*": "./lib/*.js" }, + }), + "node_modules/trap-fixture/index.js": [ + "var part = require('trap-fixture/lib/part');", + "module.exports = function () {", + " return import('.\\u002fchild.mjs').then(function (m) { return m.value + ':' + part; });", + "};", + ].join("\n"), + "node_modules/trap-fixture/lib/part.js": "module.exports = 'part';", + "node_modules/trap-fixture/child.mjs": "export const value = 'child';", + }; + // The route replaces these for the whole process; every one is put back. + const patched: Array<[Record, PropertyKey]> = [ + ...["slice", "startsWith", "endsWith", "indexOf", "split", "replace", "search", "charCodeAt"] + .map(( + name, + ): [Record, PropertyKey] => [String.prototype as never, name]), + ...["push", "pop", "join", "filter", "slice"] + .map(( + name, + ): [Record, PropertyKey] => [Array.prototype as never, name]), + [RegExp.prototype as never, "test"], + [RegExp.prototype as never, "exec"], + [RegExp.prototype as never, Symbol.replace], + [Function.prototype as never, "call"], + ]; + const originals = patched.map(([target, key]) => target[key]); + const globals = globalThis as unknown as { __trapTargets: unknown }; + globals.__trapTargets = patched; + try { + const GET = await loadCompiledRoute( + [ + "export const GET = async () => {", + " const traps = globalThis.__trapTargets;", + " let armed = false;", + " for (const [target, key] of traps) {", + " const original = target[key];", + " target[key] = function (...args) {", + " if (armed) throw new Error('replaced ' + String(key) + ' was called');", + " return Reflect.apply(original, this, args);", + " };", + " }", + // The window is synchronous, so no other code can run while the + // replacements are armed: loading, resolution through the exports + // map, the nested require and the start of the module-relative + // import() all run inside it. + " let pending;", + " armed = true;", + " try {", + ' const load = require("trap-fixture");', + " pending = load();", + " } catch (error) {", + " pending = Promise.resolve(error.message);", + " } finally {", + " armed = false;", + " }", + " return new Response(await pending);", + "};", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "child:part"); + } finally { + patched.forEach(([target, key], index) => { + target[key] = originals[index]; + }); + delete (globalThis as { __trapTargets?: unknown }).__trapTargets; + } + }); + denoIt("caches namespaces without the mutable Map prototype", async () => { const files = { "node_modules/map-fixture/package.json": JSON.stringify({ From 3346cd70c0120dce9be8d0eb6b5fbade07b380ca Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 14:57:05 +0100 Subject: [PATCH 21/22] fix(routing): object-literal division, package imports and hoisted fallback in the CJS loader (#5471) --- .../module-loader/external-import-rewriter.ts | 168 +++++++++++++----- src/routing/api/module-loader/loader.test.ts | 88 +++++++++ 2 files changed, 209 insertions(+), 47 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 3462e2ceed..7ee23bf81e 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -306,13 +306,18 @@ function __vf_packageName(id) { if (slash !== -1 && __vf_startsWith(id, "@")) slash = __vf_indexOf(id, "/", slash + 1); return slash === -1 ? id : __vf_slice(id, 0, slash); } -// Node's package "exports" resolution for require(): a target is undefined when nothing matched, null when the -// package blocks the subpath. Conditions are tried in the object's key order. +// Node's package "exports" and "imports" resolution for require(): a target is +// undefined when nothing matched, null when the package blocks the subpath. +// Conditions are tried in the object's key order. Only an "imports" target may +// name another package instead of a file inside the package. var __vf_requireConditions = __vf_tableOf(["require", "node", "node-addons", "default"]); -function __vf_exportsTarget(target, star) { +function __vf_exportsTarget(target, star, allowBare) { if (typeof target === "string") { - if (!__vf_startsWith(target, "./")) return null; var value = star === void 0 ? target : __vf_replaceAll(target, "*", star); + if (!__vf_startsWith(target, "./")) { + var bare = allowBare && !__vf_startsWith(target, ".") && !__vf_startsWith(target, "/") && __vf_indexOf(target, ":") === -1; + return bare ? value : null; + } if (__vf_indexOf(value, "\\\\") !== -1) return null; for (var from = 2; ; ) { var slash = __vf_indexOf(value, "/", from); @@ -325,7 +330,7 @@ function __vf_exportsTarget(target, star) { } if (Array.isArray(target)) { for (var j = 0; j < target.length; j++) { - var candidate = __vf_exportsTarget(target[j], star); + var candidate = __vf_exportsTarget(target[j], star, allowBare); if (typeof candidate === "string") return candidate; } return null; @@ -334,21 +339,18 @@ function __vf_exportsTarget(target, star) { var conditions = Object.keys(target); for (var k = 0; k < conditions.length; k++) { if (!__vf_requireConditions[conditions[k]]) continue; - var resolved = __vf_exportsTarget(target[conditions[k]], star); + var resolved = __vf_exportsTarget(target[conditions[k]], star, allowBare); if (resolved !== void 0) return resolved; } return void 0; } return null; } -function __vf_exportsSubpath(exportsField, subpath) { - var keys = exportsField && typeof exportsField === "object" && !Array.isArray(exportsField) ? Object.keys(exportsField) : []; - var dotKeys = 0; - for (var d = 0; d < keys.length; d++) if (__vf_startsWith(keys[d], ".")) dotKeys++; - if (dotKeys !== 0 && dotKeys !== keys.length) return null; - var map = dotKeys === 0 ? { ".": exportsField } : exportsField; +// The target of the key in map that matches subpath exactly, or else of its +// most specific "*" pattern. +function __vf_matchSubpath(map, subpath, allowBare) { if (__vf_hasOwn(map, subpath) && __vf_indexOf(subpath, "*") === -1) { - return __vf_exportsTarget(map[subpath], void 0); + return __vf_exportsTarget(map[subpath], void 0, allowBare); } var bestKey = null; var bestPrefix = -1; @@ -367,28 +369,55 @@ function __vf_exportsSubpath(exportsField, subpath) { bestStar = __vf_slice(subpath, prefix.length, subpath.length - suffix.length); } } - return bestKey === null ? void 0 : __vf_exportsTarget(map[bestKey], bestStar); + return bestKey === null ? void 0 : __vf_exportsTarget(map[bestKey], bestStar, allowBare); +} +function __vf_exportsSubpath(exportsField, subpath) { + var keys = exportsField && typeof exportsField === "object" && !Array.isArray(exportsField) ? Object.keys(exportsField) : []; + var dotKeys = 0; + for (var d = 0; d < keys.length; d++) if (__vf_startsWith(keys[d], ".")) dotKeys++; + if (dotKeys !== 0 && dotKeys !== keys.length) return null; + return __vf_matchSubpath(dotKeys === 0 ? { ".": exportsField } : exportsField, subpath, false); +} +// A package.json's parsed contents, or an error naming the package, not the +// host path, when it cannot be read or parsed. +function __vf_readManifest(manifest, name) { + try { + return JSON.parse(__vf_readText(manifest, name)); + } catch (_) { + var invalid = new Error("npm package " + JSON.stringify(name) + " could not be resolved (ERR_INVALID_PACKAGE_CONFIG)"); + invalid.code = "ERR_INVALID_PACKAGE_CONFIG"; + throw invalid; + } +} +// A file a package's "exports" or "imports" map points to, which must exist +// inside the project. +function __vf_mappedFile(packageDir, target, id) { + var file = __vf_resolve(packageDir, target); + __vf_assertContained(__vf_canonicalize(file), id); + if (!__vf_isFile(file)) { + var missing = new Error("Cannot find module " + JSON.stringify(id)); + missing.code = "MODULE_NOT_FOUND"; + throw missing; + } + return file; } // Resolves id inside an installed package directory: through its "exports" -// map when it has one, as Node does, otherwise by plain file lookup. Errors -// name the id, never the host path. +// map when it has one, as Node does, otherwise by plain file lookup, which +// gives null when nothing matches so the lookup goes on to the next +// node_modules. Errors name the id, never the host path. function __vf_resolveInPackage(id, dir, packageDir) { var name = __vf_packageName(id); var manifest = __vf_resolve(packageDir, "package.json"); - var exportsField; - if (__vf_isFile(manifest)) { + var exportsField = __vf_isFile(manifest) ? __vf_readManifest(manifest, name).exports : void 0; + if (exportsField === void 0 || exportsField === null) { try { - exportsField = JSON.parse(__vf_readText(manifest, name)).exports; - } catch (_) { - var invalid = new Error("npm package " + JSON.stringify(name) + " could not be resolved (ERR_INVALID_PACKAGE_CONFIG)"); - invalid.code = "ERR_INVALID_PACKAGE_CONFIG"; - throw invalid; + // A relative id keeps host paths out of any error it raises. + return __vf_resolveLocal("./node_modules/" + id, dir); + } catch (error) { + if (error && error.code === "MODULE_NOT_FOUND") return null; + throw error; } } - if (exportsField === void 0 || exportsField === null) { - // A relative id keeps host paths out of any error it raises. - return __vf_resolveLocal("./node_modules/" + id, dir); - } var subpath = "." + __vf_slice(id, name.length); var target = __vf_exportsSubpath(exportsField, subpath); if (typeof target !== "string") { @@ -396,14 +425,30 @@ function __vf_resolveInPackage(id, dir, packageDir) { blocked.code = "ERR_PACKAGE_PATH_NOT_EXPORTED"; throw blocked; } - var exported = __vf_resolve(packageDir, target); - __vf_assertContained(__vf_canonicalize(exported), id); - if (!__vf_isFile(exported)) { - var missing = new Error("Cannot find module " + JSON.stringify(id)); - missing.code = "MODULE_NOT_FOUND"; - throw missing; + return __vf_mappedFile(packageDir, target, id); +} +// A "#name" id resolves through the "imports" map of the package that contains +// the requiring module: the nearest package.json at or above its directory, +// inside the project. +function __vf_resolvePackageImport(id, parentDir) { + for (var dir = __vf_canonicalize(parentDir); ; dir = __vf_dirname(dir)) { + try { __vf_assertContained(dir, id); } catch (_) { break; } + var manifest = __vf_resolve(dir, "package.json"); + if (__vf_isFile(manifest)) { + var importsField = __vf_readManifest(manifest, id).imports; + var target = importsField && typeof importsField === "object" && !Array.isArray(importsField) + ? __vf_matchSubpath(importsField, id, true) + : void 0; + if (typeof target === "string") { + return __vf_startsWith(target, "./") ? __vf_mappedFile(dir, target, id) : __vf_resolveBare(target, dir); + } + break; + } + if (__vf_dirname(dir) === dir) break; } - return exported; + var undefinedImport = new Error("package import " + JSON.stringify(id) + " is not defined by the requiring package"); + undefinedImport.code = "ERR_PACKAGE_IMPORT_NOT_DEFINED"; + throw undefinedImport; } // Bare ids resolve from the requiring module's directory, as Node does, so a // package's own non-hoisted node_modules wins over the project root's copy. @@ -416,26 +461,34 @@ function __vf_resolveBare(id, parentDir) { unknown.code = "MODULE_NOT_FOUND"; throw unknown; } + if (__vf_startsWith(id, "#")) return __vf_resolvePackageImport(id, parentDir); var name = __vf_packageName(id); + var installed = false; for (var dir = __vf_resolve(parentDir); ; dir = __vf_dirname(dir)) { var packageDir = __vf_resolve(dir, "node_modules", name); var exists = false; try { Deno.statSync(packageDir); exists = true; } catch (_) { /* expected: not installed at this level */ } if (exists) { + installed = true; + var found; try { - return __vf_resolveInPackage(id, dir, packageDir); + found = __vf_resolveInPackage(id, dir, packageDir); } catch (error) { if (!error || error.code !== "MODULE_NOT_FOUND") throw error; - // Keep the code: packages probe optional dependencies with - // \`error.code === "MODULE_NOT_FOUND"\`. - var unresolved = new Error("npm package " + JSON.stringify(name) + " is installed but " + JSON.stringify(id) + " could not be resolved"); - unresolved.code = "MODULE_NOT_FOUND"; - throw unresolved; + found = void 0; } + if (typeof found === "string") return found; + // As in Node, a copy without the requested file defers to the next + // node_modules up; a copy whose "exports" target is missing does not. + if (found === void 0) break; } if (__vf_dirname(dir) === dir) break; } - var missing = new Error("npm package " + JSON.stringify(name) + " is not installed in this runtime's node_modules"); + // Keep the code: packages probe optional dependencies with + // \`error.code === "MODULE_NOT_FOUND"\`. + var missing = new Error(installed + ? "npm package " + JSON.stringify(name) + " is installed but " + JSON.stringify(id) + " could not be resolved" + : "npm package " + JSON.stringify(name) + " is not installed in this runtime's node_modules"); missing.code = "MODULE_NOT_FOUND"; throw missing; } @@ -720,12 +773,12 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { continue; } } - previous = __vf_regexAfterWord[word] === true ? "(" : "word"; + // "do" and "else" start a statement; the other listed words take an + // expression. + previous = word === "do" || word === "else" ? ";" : __vf_regexAfterWord[word] === true ? "(" : "word"; controlWord = word === "if" || word === "while" || word === "for" || word === "with"; continue; } - // A ")" closing an if/while/for/with condition starts a statement, where - // "/" begins a regex literal; any other ")" ends a value. // "++" and "--" after a value are postfix and end that value, so a "/" // after them divides; before a value they are prefix operators. if ((c === "+" || c === "-") && code[i + 1] === c) { @@ -735,11 +788,32 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { i += 2; continue; } + // "=>" is followed by a function body or an expression, never a value + // that "/" could divide. + if (c === "=" && code[i + 1] === ">") { + previous = "=>"; + controlWord = false; + i += 2; + continue; + } if (c === "(") __vf_append(parens, controlWord); controlWord = false; - if (c === "{") __vf_append(stack, "{"); - else if (c === "}") __vf_removeLast(stack); - previous = c === ")" && __vf_removeLast(parens) === true ? "(" : c; + if (c === "{") { + // A brace where a statement may start opens a block; anywhere else it + // opens an object literal, which ends a value when it closes. + var block = previous === "" || previous === ";" || previous === "{" || previous === "}" || previous === ")" || + previous === "]" || previous === "word" || previous === "value" || previous === "=>"; + __vf_append(stack, block ? "block" : "object"); + previous = "{"; + } else if (c === "}") { + previous = __vf_removeLast(stack) === "object" ? "value" : "}"; + } else if (c === ")") { + // A ")" closing an if/while/for/with condition starts a statement, where + // "/" begins a regex literal; any other ")" ends a value. + previous = __vf_removeLast(parens) === true ? ";" : ")"; + } else { + previous = c; + } i++; } return copied === 0 ? code : out + __vf_slice(code, copied); diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 8430b8d1c7..3611e03a87 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -6089,6 +6089,94 @@ describe("route CommonJS require (#5471)", () => { assertEquals(await responseText(GET), "postfix child"); }); + denoIt( + "reads a slash after an object literal as division and after a block as a regex", + async () => { + const files = { + "node_modules/brace-fixture/package.json": JSON.stringify({ + name: "brace-fixture", + main: "index.js", + }), + "node_modules/brace-fixture/index.js": [ + "let p;", + "if (true) {}", + "/import('.\\/missing.mjs')/.test('');", + 'const ratio = { value: 1 } / (p = import(".\\u002fchild.mjs"), 1);', + "module.exports = () => p.then((m) => m.value);", + ].join("\n"), + "node_modules/brace-fixture/child.mjs": "export const value = 'brace child';", + }; + const GET = await loadCompiledRoute( + [ + 'import load from "brace-fixture";', + "export const GET = async () => new Response(await load());", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "brace child"); + }, + ); + + denoIt("resolves package imports through the requiring package's imports map", async () => { + const projectDir = await writeProject({ + "node_modules/alias-fixture/package.json": JSON.stringify({ + name: "alias-fixture", + main: "index.js", + imports: { + "#dep": { require: "./lib/dep.js", default: "./lib/missing.js" }, + "#peer": "peer-fixture", + "#util/*": "./lib/util/*.js", + }, + }), + "node_modules/alias-fixture/index.js": [ + "module.exports = [", + " require('#dep'),", + " require('#peer'),", + " require('#util/name'),", + " require.resolve('#dep') === require.resolve('./lib/dep.js'),", + "].join(':');", + ].join("\n"), + "node_modules/alias-fixture/lib/dep.js": "module.exports = 'dep';", + "node_modules/alias-fixture/lib/util/name.js": "module.exports = 'util';", + "node_modules/peer-fixture/package.json": JSON.stringify({ + name: "peer-fixture", + main: "index.js", + }), + "node_modules/peer-fixture/index.js": "module.exports = 'peer';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("alias-fixture"), "dep:peer:util:true"); + assertEquals( + thrownError(() => vfRequire("#undefined")).code, + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + ); + }); + + denoIt("continues to an ancestor node_modules when a nearer copy lacks the file", async () => { + const projectDir = await writeProject({ + "node_modules/outer-fixture/package.json": JSON.stringify({ + name: "outer-fixture", + main: "index.js", + }), + "node_modules/outer-fixture/index.js": "module.exports = require('shared-fixture/feature');", + "node_modules/outer-fixture/node_modules/shared-fixture/package.json": JSON.stringify({ + name: "shared-fixture", + main: "index.js", + }), + "node_modules/outer-fixture/node_modules/shared-fixture/index.js": + "module.exports = 'nested';", + "node_modules/shared-fixture/package.json": JSON.stringify({ + name: "shared-fixture", + main: "index.js", + }), + "node_modules/shared-fixture/feature.js": "module.exports = 'hoisted feature';", + }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + assertEquals(vfRequire("outer-fixture"), "hoisted feature"); + }); + denoIt("loads CommonJS packages while built-in prototype methods are replaced", async () => { const files = { "node_modules/trap-fixture/package.json": JSON.stringify({ From a118734177f3c87a16f25b66474429735b92a9ee Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 6 Nov 2026 15:32:41 +0100 Subject: [PATCH 22/22] fix(routing): read import options before the await, bound package lookup to the project, absolute resolve ids, expression braces (#5471) --- .../external-import-rewriter.test.ts | 23 +++-- .../module-loader/external-import-rewriter.ts | 91 ++++++++++++++----- src/routing/api/module-loader/loader.test.ts | 72 +++++++++++++++ .../import-rewriter/route-adapter.ts | 17 ++-- 4 files changed, 165 insertions(+), 38 deletions(-) diff --git a/src/routing/api/module-loader/external-import-rewriter.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index dcf8ca4443..9d48f12bb0 100644 --- a/src/routing/api/module-loader/external-import-rewriter.test.ts +++ b/src/routing/api/module-loader/external-import-rewriter.test.ts @@ -122,7 +122,10 @@ describe("external-import-rewriter", () => { `import _ from "lodash";`, deps, ); - assertEquals(out, `const _ = __vf_interopDefault(require("lodash"));`); + assertEquals( + out, + `const _ = __vf_interopDefault(require("lodash"));`, + ); }); it("rewrites a namespace import to a namespace over require", async () => { @@ -130,7 +133,10 @@ describe("external-import-rewriter", () => { `import * as _ from "lodash";`, deps, ); - assertEquals(out, `const _ = __vf_namespace("lodash");`); + assertEquals( + out, + `const _ = __vf_namespace("lodash");`, + ); }); it("rewrites a named import to a destructuring require", async () => { @@ -188,7 +194,7 @@ describe("external-import-rewriter", () => { out, [ '__vf_validateImportOptions({ with: { type: "json" } }, "lodash/data.json"); const data = __vf_interopDefault(require("lodash/data.json"));', - 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options, "lodash/data.json"); return __vf_namespace("lodash/data.json"); })())({ with: { type: "json" } });', + 'const lazy = ((__vf_options) => (async () => { __vf_validateImportOptions(__vf_options, "lodash/data.json"); await null; return __vf_namespace("lodash/data.json"); })())({ with: { type: "json" } });', ].join("\n"), ); }); @@ -200,7 +206,7 @@ describe("external-import-rewriter", () => { ); assertEquals( out, - 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options, "lodash"); return __vf_namespace("lodash"); })())({ with: { type: /[)]/.test("") ? "json" : "json" } });', + 'const lazy = ((__vf_options) => (async () => { __vf_validateImportOptions(__vf_options, "lodash"); await null; return __vf_namespace("lodash"); })())({ with: { type: /[)]/.test("") ? "json" : "json" } });', ); }); @@ -275,7 +281,7 @@ describe("external-import-rewriter", () => { assertEquals( out, [ - 'const a = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options, "lodash"); return __vf_namespace("lodash"); })())((seen = true, {}));', + 'const a = ((__vf_options) => (async () => { __vf_validateImportOptions(__vf_options, "lodash"); await null; return __vf_namespace("lodash"); })())((seen = true, {}));', 'const b = (async () => { await null; return __vf_namespace("lodash"); })();', ].join("\n"), ); @@ -358,7 +364,7 @@ describe("external-import-rewriter", () => { ); assertEquals( out, - 'const lazy = ((__vf_options) => (async () => { await null; __vf_validateImportOptions(__vf_options, "lodash"); return __vf_namespace("lodash"); })())({ with: { type: (() => { return /[)]/.test("") ? "json" : "json"; })() } });', + 'const lazy = ((__vf_options) => (async () => { __vf_validateImportOptions(__vf_options, "lodash"); await null; return __vf_namespace("lodash"); })())({ with: { type: (() => { return /[)]/.test("") ? "json" : "json"; })() } });', ); }); @@ -605,7 +611,10 @@ describe("external-import-rewriter", () => { `import x from "lodash/a";`, new Map([["lodash", "^4"]]), ); - assertEquals(out, `const x = __vf_interopDefault(require("lodash/a\\u003Cb\\u003E"));`); + assertEquals( + out, + `const x = __vf_interopDefault(require("lodash/a\\u003Cb\\u003E"));`, + ); }); }); diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 7ee23bf81e..1bde9b7fad 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -464,7 +464,10 @@ function __vf_resolveBare(id, parentDir) { if (__vf_startsWith(id, "#")) return __vf_resolvePackageImport(id, parentDir); var name = __vf_packageName(id); var installed = false; - for (var dir = __vf_resolve(parentDir); ; dir = __vf_dirname(dir)) { + // The walk stays inside the project: a host package above its root is never + // inspected, so whether one exists cannot change the result. + for (var dir = __vf_canonicalize(parentDir); ; dir = __vf_dirname(dir)) { + try { __vf_assertContained(dir, id); } catch (_) { break; } var packageDir = __vf_resolve(dir, "node_modules", name); var exists = false; try { Deno.statSync(packageDir); exists = true; } catch (_) { /* expected: not installed at this level */ } @@ -482,7 +485,7 @@ function __vf_resolveBare(id, parentDir) { // node_modules up; a copy whose "exports" target is missing does not. if (found === void 0) break; } - if (__vf_dirname(dir) === dir) break; + if (dir === __vf_projectRoot || __vf_dirname(dir) === dir) break; } // Keep the code: packages probe optional dependencies with // \`error.code === "MODULE_NOT_FOUND"\`. @@ -586,17 +589,25 @@ function __vf_resolveContained(id, parentDir) { __vf_assertContained(canonical, id); return canonical; } +// Node's test for a request relative to the requiring directory: ".", "..", +// or one starting "./" or "../". An absolute request is not relative. +function __vf_isRelativeRequest(request) { + if (request[0] !== ".") return false; + var second = request[1]; + return request.length === 1 || second === "." || second === "/" || second === "\\\\"; +} // require.resolve.paths(request): null for a built-in, the requiring // directory for a relative request, otherwise its node_modules lookup paths. function __vf_resolvePaths(request, parentDir) { if (__vf_isBuiltin(request)) return null; - if (__vf_isPathId(request)) return [__vf_canonicalize(parentDir)]; + if (__vf_isRelativeRequest(request)) return [__vf_canonicalize(parentDir)]; return __vf_nodeModulePaths(parentDir); } function __vf_resolveWithOptions(id, parentDir, options) { // Built-ins resolve to themselves, whatever search paths are given. if (__vf_isBuiltin(id)) return id; - if (!options || options.paths === void 0) return __vf_resolveContained(id, parentDir); + // An absolute id names its file whatever search paths are given. + if (!options || options.paths === void 0 || __vf_isAbsolute(id)) return __vf_resolveContained(id, parentDir); if (!Array.isArray(options.paths)) throw new TypeError("require.resolve options.paths must be an array"); for (var i = 0; i < options.paths.length; i++) { try { @@ -722,6 +733,10 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { var stack = []; var parens = []; var controlWord = false; + // The nesting depth of a function or class expression whose body brace has + // not been seen yet, or -1. Its body is a block, but its closing brace ends + // a value, after which "/" divides. + var expressionBody = -1; var i = 0; while (i < code.length) { var c = code[i]; @@ -773,6 +788,18 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { continue; } } + // "async function" keeps the context before "async", which decides + // whether the function is a declaration or an expression. + if (word === "async") { + var after = __vf_skipTrivia(code, i); + if (__vf_slice(code, after, after + 8) === "function" && !__vf_isWordCode(__vf_charCodeAt(code, after + 8))) continue; + } + // Where a statement may start, "function" and "class" declare; anywhere + // else they begin an expression. + if ((word === "function" || word === "class") && + !(previous === "" || previous === ";" || previous === "{" || previous === "}")) { + expressionBody = stack.length + parens.length; + } // "do" and "else" start a statement; the other listed words take an // expression. previous = word === "do" || word === "else" ? ";" : __vf_regexAfterWord[word] === true ? "(" : "word"; @@ -803,10 +830,16 @@ function __vf_rewriteCjsDynamicImports(code, helperName) { // opens an object literal, which ends a value when it closes. var block = previous === "" || previous === ";" || previous === "{" || previous === "}" || previous === ")" || previous === "]" || previous === "word" || previous === "value" || previous === "=>"; - __vf_append(stack, block ? "block" : "object"); + var kind = block ? "block" : "object"; + if (block && expressionBody === stack.length + parens.length) { + kind = "expression"; + expressionBody = -1; + } + __vf_append(stack, kind); previous = "{"; } else if (c === "}") { - previous = __vf_removeLast(stack) === "object" ? "value" : "}"; + var closed = __vf_removeLast(stack); + previous = closed === "object" || closed === "expression" ? "value" : "}"; } else if (c === ")") { // A ")" closing an if/while/for/with condition starts a statement, where // "/" begins a regex literal; any other ")" ends a value. @@ -967,30 +1000,38 @@ var namespaces = Object.create(null); // an object, a non-string attribute value, and attributes it does not support. // The only supported type is "json", and only for a JSON module. __vf_validateImportOptions = function (options, id) { - if (options === void 0) return; - if (options === null || (typeof options !== "object" && typeof options !== "function")) { - throw new TypeError("The second argument to import() must be an object"); - } - var attributes = options.with; - if (attributes === void 0) return; - if (attributes === null || (typeof attributes !== "object" && typeof attributes !== "function")) { - throw new TypeError("The 'with' option of import() must be an object"); - } - var keys = Object.keys(attributes); - for (var i = 0; i < keys.length; i++) { - if (typeof attributes[keys[i]] !== "string") { - throw new TypeError("Import attribute " + JSON.stringify(keys[i]) + " must be a string"); + var type; + if (options !== void 0) { + if (options === null || (typeof options !== "object" && typeof options !== "function")) { + throw new TypeError("The second argument to import() must be an object"); } - if (keys[i] !== "type") { - throw new SyntaxError("Unsupported import attribute " + JSON.stringify(keys[i])); + var attributes = options.with; + if (attributes !== void 0) { + if (attributes === null || (typeof attributes !== "object" && typeof attributes !== "function")) { + throw new TypeError("The 'with' option of import() must be an object"); + } + // Each attribute is read once, as import() reads it. + var keys = Object.keys(attributes); + for (var i = 0; i < keys.length; i++) { + var value = attributes[keys[i]]; + if (typeof value !== "string") { + throw new TypeError("Import attribute " + JSON.stringify(keys[i]) + " must be a string"); + } + if (keys[i] !== "type") { + throw new SyntaxError("Unsupported import attribute " + JSON.stringify(keys[i])); + } + type = value; + } } } - var type = attributes.type; - if (type === void 0) return; - if (type !== "json") { + if (type !== void 0 && type !== "json") { throw new TypeError("Import attribute type " + JSON.stringify(type) + " is not supported"); } - if (id !== void 0 && (__vf_isBuiltin(id) || !__vf_endsWith(__vf_resolveId(id, __VF_PROJECT_DIR__), ".json"))) { + if (id === void 0 || type === void 0) return; + // Type "json" only loads a JSON module. A JSON module without the + // attribute is not refused: the bundler drops attributes from external + // imports, so the route may well have written it. + if (type === "json" && (__vf_isBuiltin(id) || !__vf_endsWith(__vf_resolveId(id, __VF_PROJECT_DIR__), ".json"))) { throw new TypeError("Module " + JSON.stringify(id) + " is not a JSON module, but was imported with type json"); } }; diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index 3611e03a87..073c718a1c 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -6177,6 +6177,65 @@ describe("route CommonJS require (#5471)", () => { assertEquals(vfRequire("outer-fixture"), "hoisted feature"); }); + denoIt("reads a slash after a function or class expression as division", async () => { + const files = { + "node_modules/expr-fixture/package.json": JSON.stringify({ + name: "expr-fixture", + main: "index.js", + }), + "node_modules/expr-fixture/index.js": [ + "let p, q;", + 'const a = class {} / (p = import(".\\u002fchild.mjs"), 1);', + 'const b = function () { return 1; } / (q = import(".\\u002fchild.mjs"), 1);', + "async function declared() {}", + "/import('.\\/missing.mjs')/.test('');", + "function plain() {}", + "/import('.\\/missing.mjs')/.test('');", + "module.exports = () => Promise.all([p, q]).then((m) => m[0].value + ':' + m[1].value);", + ].join("\n"), + "node_modules/expr-fixture/child.mjs": "export const value = 'expr';", + }; + const GET = await loadCompiledRoute( + [ + 'import load from "expr-fixture";', + "export const GET = async () => new Response(await load());", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "expr:expr"); + }); + + denoIt("never looks up packages above the project root", async () => { + const outerDir = Deno.realPathSync(await makeTempDir()); + const projectDir = join(outerDir, "project"); + await fs.mkdir(join(outerDir, "node_modules/host-only"), { recursive: true }); + await fs.writeTextFile(join(outerDir, "node_modules/host-only/package.json"), "{ not json"); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeTextFile(join(projectDir, "package.json"), "{}"); + const vfRequire = await importCompiledBinaryRequireShim(projectDir); + + const error = thrownError(() => vfRequire("host-only")); + assertEquals(error.code, "MODULE_NOT_FOUND"); + assertEquals( + error.message, + 'npm package "host-only" is not installed in this runtime\'s node_modules', + ); + }); + + denoIt("resolves an absolute id whatever search paths are given", async () => { + const projectDir = await writeProject({ "lib/file.js": "module.exports = 1;" }); + const vfRequire = await importCompiledBinaryRequireShim(projectDir) as unknown as { + resolve: ((id: string, options?: { paths?: string[] }) => string) & { + paths: (request: string) => string[] | null; + }; + }; + const file = join(projectDir, "lib/file.js"); + + assertEquals(vfRequire.resolve(file, { paths: [] }), file); + assertEquals(vfRequire.resolve.paths(file), [join(projectDir, "node_modules")]); + assertEquals(vfRequire.resolve.paths("./lib/file.js"), [projectDir]); + }); + denoIt("loads CommonJS packages while built-in prototype methods are replaced", async () => { const files = { "node_modules/trap-fixture/package.json": JSON.stringify({ @@ -7121,6 +7180,19 @@ describe("route CommonJS require (#5471)", () => { assertEquals(thrownError(() => validate({ with: 1 })).name, "TypeError"); assertEquals(thrownError(() => validate({ with: { type: 1 } })).name, "TypeError"); assertEquals(thrownError(() => validate({ with: { other: "x" } })).name, "SyntaxError"); + // The bundler drops attributes, so a JSON module without one still loads. + validate(undefined, "typed-fixture/data.json"); + // Attributes are read once each. + let reads = 0; + validate({ + with: { + get type() { + reads++; + return "json"; + }, + }, + }, "typed-fixture/data.json"); + assertEquals(reads, 1); }); denoIt("leaves a private method named #import alone", async () => { diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index b45ce1c664..0d255bca93 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -447,10 +447,13 @@ export async function rewriteCompiledUserDependencyImportsForRoute( // tokenizing strings, comments and regex literals in the options. if (imported.se <= imported.e) continue; // An async arrow returns an intrinsic promise whatever route code binds - // to `Promise`, and the await defers loading as import() does. - const lazy = `(async () => { await null; return ${helperPrefix}namespace(${specifier}); })()`; - // The options argument is evaluated first, as import() would, and - // validated when the load starts, so bad options reject the promise. + // to `Promise`, and the await defers loading as import() does. An + // options argument is evaluated first and read before the await, as + // import() reads it before returning; a failed check rejects the + // promise. + const validate = `${helperPrefix}validateImportOptions`; + const load = `await null; return ${helperPrefix}namespace(${specifier});`; + const lazy = `(async () => { ${load} })()`; const optionsParam = `${helperPrefix}options`; const optionsStart = imported.a; const optionsEnd = imported.se - 1; @@ -462,7 +465,7 @@ export async function rewriteCompiledUserDependencyImportsForRoute( ? rewriteRange(optionsStart, optionsEnd).trim().replace(/,$/, "").trimEnd() : ""; return options - ? `((${optionsParam}) => (async () => { await null; ${helperPrefix}validateImportOptions(${optionsParam}, ${specifier}); return ${helperPrefix}namespace(${specifier}); })())(${options})` + ? `((${optionsParam}) => (async () => { ${validate}(${optionsParam}, ${specifier}); ${load} })())(${options})` : lazy; }, }); @@ -471,7 +474,9 @@ export async function rewriteCompiledUserDependencyImportsForRoute( const statement = masked.slice(imported.ss, imported.se); // Import attributes are checked when the route module loads, as a native - // import checks them before the route runs. + // import checks them before the route runs. A missing `type: "json"` is not + // refused: the bundler drops attributes from external imports, so their + // absence here does not mean the route left them out. const attributes = staticImportAttributes(masked, imported.a, imported.se); const check = attributes ? `${helperPrefix}validateImportOptions({ with: ${attributes} }, ${specifier}); `