diff --git a/docs/guides/api-routes.md b/docs/guides/api-routes.md index 9b494eb7f4..e25af6082b 100644 --- a/docs/guides/api-routes.md +++ b/docs/guides/api-routes.md @@ -220,6 +220,35 @@ 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. 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. 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`. 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 +but not installed fails with `npm package "" is not installed in this +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 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.test.ts b/src/routing/api/module-loader/external-import-rewriter.test.ts index 2da0bd57e5..9d48f12bb0 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,33 @@ 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"));`); + assertEquals( + out, + `const _ = __vf_interopDefault(require("lodash"));`, + ); }); - it("rewrites a namespace import to 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 _ = require("lodash");`); + assertEquals( + out, + `const _ = __vf_namespace("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,28 +148,162 @@ describe("external-import-rewriter", () => { assertStringIncludes(out, "merge"); }); - it("rewrites a dynamic import to a resolved require promise", () => { - const out = rewriteCompiledBinaryUserDependencyImports( + it("rewrites a dynamic import to a lazy namespace promise", async () => { + const out = await rewriteCompiledBinaryUserDependencyImports( `const m = import("lodash");`, deps, ); - assertStringIncludes(out, `Promise.resolve(require("lodash"))`); + assertStringIncludes( + out, + `(async () => { await null; return __vf_namespace("lodash"); })()`, + ); }); - 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("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, + [ + '__vf_validateImportOptions({ with: { type: "json" } }, "lodash/data.json"); const data = __vf_interopDefault(require("lodash/data.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"), + ); + }); + + 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 = ((__vf_options) => (async () => { __vf_validateImportOptions(__vf_options, "lodash"); await null; return __vf_namespace("lodash"); })())({ with: { type: /[)]/.test("") ? "json" : "json" } });', + ); + }); + + 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("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 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__";'); + 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 = ((__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"), + ); + }); + + 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(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", @@ -170,7 +311,7 @@ describe("external-import-rewriter", () => { }], ]); - const out = rewriteCompiledBinaryUserDependencyImports( + const out = await rewriteCompiledBinaryUserDependencyImports( [ `import mod from "esm-only";`, `import util from "esm-only/utils.js";`, @@ -185,7 +326,49 @@ 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("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 = ((__vf_options) => (async () => { __vf_validateImportOptions(__vf_options, "lodash"); await null; return __vf_namespace("lodash"); })())({ with: { type: (() => { return /[)]/.test("") ? "json" : "json"; })() } });', + ); + }); + + 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", @@ -195,7 +378,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, @@ -388,14 +571,73 @@ 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, "__vf_namespace = function (id) {"); 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, "__vf2_namespace = function (id) {"); + // 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("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("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_"); + 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 00b9bd3931..1bde9b7fad 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,6 +51,46 @@ 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", + "validateImportOptions", + "options", + "tmp_", + "reexport_", +] as const; +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. + * + * 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 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. * @@ -59,24 +100,182 @@ 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; + * they carry `helperPrefix` (see chooseRouteHelperPrefix). */ -export function generateCompiledBinaryRequireShim(projectDir: string): string { - const builtinSet = JSON.stringify(NODE_BUILTINS); - const safeProjectDir = JSON.stringify(projectDir + "/package.json"); - const safeProjectRoot = JSON.stringify(pathHelper.resolve(projectDir)); +export function generateCompiledBinaryRequireShim( + projectDir: string, + helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, +): string { + // 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)), + }; + // 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; +} - return ` +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_builtinRequire = __vf_createRequire(${safeProjectDir}); -var __vf_builtinSet = new Set(${builtinSet}); -var __vf_projectRoot = ${safeProjectRoot}; +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 +// 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 __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 +// 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 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. +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 = __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_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 __vf_startsWith(id, ".") || __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, // when the project itself is opened via a symlink, the realpath'd resolved // 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); // Resolve symlinks so containment compares canonical paths. A module that does // not exist yet cannot be canonicalized, so fall back to its parent directory, @@ -88,33 +287,628 @@ 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); } -function __vf_assertContained(resolved) { - 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); +// The error names the id the code asked for, never the resolved host path. +function __vf_assertContained(resolved, id) { + 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 = __vf_endsWith(root, "/") ? root : root + "/"; + if (!__vf_startsWith(norm, prefix) && norm !== root) { + throw new Error("CJS loader blocked path outside project: " + JSON.stringify(id)); } } -function __vf_loadCjs(id, parentDir) { - if (id.startsWith("node:")) return __vf_builtinRequire(id); - if (__vf_builtinSet.has(id)) return __vf_builtinRequire(id); - var slashIdx = id.indexOf("/"); - 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 */ } +function __vf_packageName(id) { + 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" 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, allowBare) { + if (typeof target === "string") { + 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); + 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; + } + if (Array.isArray(target)) { + for (var j = 0; j < target.length; j++) { + var candidate = __vf_exportsTarget(target[j], star, allowBare); + 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, allowBare); + if (resolved !== void 0) return resolved; + } + return void 0; + } + return null; +} +// 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, allowBare); + } + 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 = __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 = __vf_slice(subpath, prefix.length, subpath.length - suffix.length); + } + } + 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, 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 = __vf_isFile(manifest) ? __vf_readManifest(manifest, name).exports : void 0; + if (exportsField === void 0 || exportsField === null) { + try { + // 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; + } + } + 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)); + blocked.code = "ERR_PACKAGE_PATH_NOT_EXPORTED"; + throw blocked; + } + 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; + } + 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. +// 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 (__vf_startsWith(id, "node:")) { + var unknown = new Error("Cannot find module " + JSON.stringify(id)); + unknown.code = "MODULE_NOT_FOUND"; + throw unknown; + } + if (__vf_startsWith(id, "#")) return __vf_resolvePackageImport(id, parentDir); + var name = __vf_packageName(id); + var installed = false; + // 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 */ } + if (exists) { + installed = true; + var found; + try { + found = __vf_resolveInPackage(id, dir, packageDir); + } catch (error) { + if (!error || error.code !== "MODULE_NOT_FOUND") throw error; + 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 (dir === __vf_projectRoot || __vf_dirname(dir) === dir) break; + } + // 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; +} +// 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_probeFile(path) { + // ".node" is probed so an extensionless native addon reaches the + // unsupported-addon error instead of a misleading MODULE_NOT_FOUND. + // 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]; + } + 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)) { + // 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(__vf_readText(manifest, id)).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); + 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), 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_isBuiltin(id) { + // Only real built-ins are reserved under node:, as in Node. + 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 + // "fs/plugin" resolves from an installed package named fs, as in Node. + 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 +// 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 (!__vf_isNodeModulesDir(current)) __vf_append(paths, __vf_resolve(current, "node_modules")); + if (current === __vf_projectRoot || __vf_dirname(current) === current) break; + } + return paths; +} +// 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)) return found; + var canonical = __vf_canonicalize(found); + __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_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; + // 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 { + // 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; } + } + 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 in code (not in strings, comments, templates or +// regex literals) are routed to a module-relative loader instead. +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) { + 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_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 = __vf_indexOf(code, "\\n", i); + i = lineEnd === -1 ? code.length : lineEnd; + continue; + } + if (c === "/" && code[i + 1] === "*") { + var blockEnd = __vf_indexOf(code, "*/", i + 2); + i = blockEnd === -1 ? code.length : blockEnd + 2; + continue; + } + return i; + } +} +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) { + 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. +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(__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 (__vf_indexOf(code, "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 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]; + if (c === " " || c === "\\t" || c === "\\n" || c === "\\r") { i++; continue; } + if (c === "/" && code[i + 1] === "/") { + var lineEnd = __vf_indexOf(code, "\\n", i); + i = lineEnd === -1 ? code.length : lineEnd; + continue; + } + if (c === "/" && code[i + 1] === "*") { + 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 === "}") __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] === "{") { __vf_append(stack, "\${"); 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 (__vf_isWordCode(__vf_charCodeAt(code, i))) { + var 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 += __vf_slice(code, copied, start) + helperName + "(" + JSON.stringify(call.spec); + copied = i = call.end; + previous = "value"; + 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"; + controlWord = word === "if" || word === "while" || word === "for" || word === "with"; + continue; + } + // "++" 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; + } + // "=>" 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 === "{") { + // 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 === "=>"; + var kind = block ? "block" : "object"; + if (block && expressionBody === stack.length + parens.length) { + kind = "expression"; + expressionBody = -1; + } + __vf_append(stack, kind); + previous = "{"; + } else if (c === "}") { + 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. + previous = __vf_removeLast(parens) === true ? ";" : ")"; + } else { + previous = c; + } + i++; + } + 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 = __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 (__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; + } + })(); +} +// 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 __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. +// 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 && __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); + var resolved; + if (__vf_isPathId(id)) { + resolved = __vf_resolveLocal(id, parentDir); } else { - resolved = __vf_builtinRequire.resolve(id); + resolved = __vf_resolveBare(id, parentDir); + } + 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 (__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 // the already-canonical project root are compared like with like, then assert @@ -124,34 +918,158 @@ 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); - if (resolved in __vf_cache) return __vf_cache[resolved]; - var code = Deno.readTextFileSync(resolved); - if (resolved.endsWith(".json")) { + __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) { + __vf_linkChild(parentMod, __vf_cache[resolved]); + return __vf_cache[resolved].exports; + } + var code = __vf_readText(resolved, id); + if (__vf_endsWith(resolved, ".json")) { var json = JSON.parse(code); - __vf_cache[resolved] = json; + var jsonMod = { exports: json, id: resolved, filename: resolved, loaded: true, children: [], parent: parentMod }; + __vf_cache[resolved] = jsonMod; + __vf_linkChild(parentMod, jsonMod); return json; } - var mod = { exports: {} }; - __vf_cache[resolved] = mod.exports; + // Node strips a leading hashbang from CommonJS sources; a Function body + // cannot contain one. Commenting it out keeps line numbers intact. + if (__vf_startsWith(code, "#!")) code = "//" + __vf_slice(code, 2); 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); - }; + // 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 (__vf_indexOf(code, 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); }; 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; + 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; + __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 { + // 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", importHelper, "return function (exports, require, module, __filename, __dirname) {\\n" + code + "\\n};"); + var fn = outer(globalThis, globalThis, void 0, cjsImport); + __vf_invoke(fn, mod.exports, mod.exports, childReq, mod, resolved, dir); + } catch (error) { + delete __vf_cache[resolved]; + if (parentMod) { + 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; + } + mod.loaded = true; return mod.exports; } -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(); }; -`.trim(); +function __vf_resolveId(id, parentDir) { + if (__vf_isPathId(id)) return __vf_resolveLocal(id, parentDir); + return __vf_resolveBare(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_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(); }; +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 = 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. +// The only supported type is "json", and only for a JSON module. +__vf_validateImportOptions = function (options, id) { + 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"); + } + 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; + } + } + } + if (type !== void 0 && type !== "json") { + throw new TypeError("Import attribute type " + JSON.stringify(type) + " is not supported"); + } + 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"); + } +}; +__vf_namespace = function (id) { + var key = __vf_isBuiltin(id) + ? "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); + var ns = Object.create(null); + 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") 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 + // throw a TypeError, as they do on a native namespace. + Object.defineProperty(ns, Symbol.toStringTag, { value: "Module" }); + Object.freeze(ns); + namespaces[key] = ns; + return ns; +}; +return routeRequire; +})(); +function __vf_interopDefault(m) { return m && m.__esModule && m.default !== void 0 ? m.default : m; } +`.trim(); export function getNodeExternalPackagesToResolve(userDeps: Map): string[] { return getNodeExternalPackagesToResolveForRoute(userDeps); @@ -331,12 +1249,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( @@ -442,6 +1361,7 @@ export async function rewriteExternalImports( projectDir: string, fs: SourceReader, userDeps: Map = new Map(), + helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, ): Promise { let transformed = code; @@ -464,12 +1384,18 @@ 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 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 = 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-helpers.test.ts b/src/routing/api/module-loader/loader-helpers.test.ts index d58a6aebd7..6cf69d7e14 100644 --- a/src/routing/api/module-loader/loader-helpers.test.ts +++ b/src/routing/api/module-loader/loader-helpers.test.ts @@ -41,6 +41,14 @@ 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 }`, + ); + 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 bfba9f40df..1d9d647e3b 100644 --- a/src/routing/api/module-loader/loader-helpers.ts +++ b/src/routing/api/module-loader/loader-helpers.ts @@ -46,21 +46,107 @@ export function resolveExportEntry(entry: unknown): string | undefined { return undefined; } -export function toCjsDestructureBindings(bindings: string): string { - const inner = bindings.trim().replace(/^\{\s*/, "").trimEnd().replace(/\}$/, ""); - if (!inner) return "{}"; +/** + * 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; +} - 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; +/** 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; +} + +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!; + }, + ); +} - return `{ ${converted.join(", ")} }`; +/** + * 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 identifier alias, as in the import itself. + */ +export function toCjsDestructureBindings(bindings: string): string { + const converted: string[] = []; + for (const specifier of parseModuleSpecifiers(bindings)) { + if (specifier.aliasIsString || (specifier.nameIsString && specifier.alias === undefined)) { + throw new Error(`Unsupported named import clause: ${bindings}`); + } + converted.push( + specifier.alias === undefined ? specifier.name : `${specifier.name}: ${specifier.alias}`, + ); + } + 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 f1e2ff2cc9..073c718a1c 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -15,7 +15,9 @@ import { bundledModuleScopeDiscriminator, bundlerForcesTypeScript, canDirectImportSpecifier, + chooseRouteHelperPrefix, generateCompiledBinaryRequireShim, + generateRouteRuntimeShim, getNodeExternalPackagesToResolve, getUserDependencies, isBareModuleSpecifier, @@ -24,6 +26,7 @@ import { loadModuleFromCode, lookupImportMapEntry, prepareHandlerModule, + prependRouteRequirePrologue, readDenoImportMap, resolveEsmUserDependencies, rewriteCompiledBinaryUserDependencyImports, @@ -1823,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";', @@ -1833,23 +1836,26 @@ 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"]]), ); 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\("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, + /\(async \(\) => \{ await null; return __vf_namespace\("my-lib\/subpath"\); \}\)\(\)/, + ); }); - 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";', @@ -1859,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([[ @@ -1895,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([[ @@ -1980,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([[ @@ -5444,8 +5450,9 @@ 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 assertIdx = shim.indexOf("__vf_assertContained(resolved)", elseIdx); + 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, id)", elseIdx); assertEquals( assertIdx > elseIdx, true, @@ -5461,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, @@ -5669,3 +5676,1629 @@ 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("route CommonJS require (#5471)", () => { + afterAll(async () => { + await __resetPoolForTests(); + const { stop } = await import("veryfront/extensions/bundler"); + await stop(); + }); + + 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()); + 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> { + 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) + .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 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:*", ...[...packages].flatMap((name) => [name, `${name}/*`])], + 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(toFileUrl(routePath).href), + loader: "js", + })); + }, + }], + }); + const bundled = result.outputFiles?.[0]?.text ?? ""; + const helperPrefix = chooseRouteHelperPrefix(bundled); + const rewritten = await rewriteCompiledBinaryUserDependencyImports( + bundled, + userDeps, + new Map(), + helperPrefix, + ); + const handlerPath = join(projectDir, "handler.mjs"); + await fs.writeTextFile( + handlerPath, + prependRouteRequirePrologue(rewritten, projectDir, true, helperPrefix), + ); + return await import(toFileUrl(handlerPath).href); + } + + 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 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 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 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("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("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 = () => 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( + [ + 'import load from "bridge-fixture";', + "export const GET = async () => new Response(await load());", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "esm child: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( + "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( + "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";', + "const String = null;", + "const parseInt = null;", + "export const shadowed = [String, parseInt];", + "export const GET = async () => new Response(await load());", + ].join("\n"), + files, + ); + assertEquals(await responseText(GET), "child:child"); + }, + ); + + 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( + "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("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({ + 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({ + 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({ + 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" }), + "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("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 () => { + 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 + // `__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"); + }); + + function thrownMessage(fn: () => unknown): string { + const error = assertThrows(fn); + 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); + + assertEquals( + thrownMessage(() => vfRequire("not-installed-pkg")), + 'npm package "not-installed-pkg" is not installed in this runtime\'s node_modules', + ); + }); + + 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', + ); + }); + + 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("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("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("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("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( + "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("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("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("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({ + "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, + `${ + generateCompiledBinaryRequireShim(projectDir) + }\nexport { __vf_validateImportOptions as validate };\n`, + ); + const { validate } = await import(toFileUrl(shimPath).href) as { + 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"); + // 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 () => { + 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("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({ + 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", + "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({ + 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); + + assertEquals( + 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 b707eb8509..14dc53e6b8 100644 --- a/src/routing/api/module-loader/loader.ts +++ b/src/routing/api/module-loader/loader.ts @@ -43,6 +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 { + chooseRouteHelperPrefix, + DEFAULT_ROUTE_HELPER_PREFIX, generateCompiledBinaryRequireShim, NODE_BUILTINS, readProjectDependencies, @@ -66,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, @@ -1533,6 +1536,72 @@ function resolveBundledImportMetaSpecifier( } } +/** + * 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(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"); +} + +/** + * 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, + helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, +): string { + if (compiledBinary) return generateCompiledBinaryRequireShim(projectDir, helperPrefix); + return [ + `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. + * + * `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( + rewrittenCode: string, + projectDir: string, + compiledBinary: boolean, + helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, +): string { + 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`; + return `${rewrittenCode.slice(0, eol + 1)}${prologue}\n${rewrittenCode.slice(eol + 1)}`; +} + function createRouteRuntimeShimPlugin(contents: string): Plugin { return { name: "vf-route-runtime-shim", @@ -2090,31 +2159,8 @@ 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(routeSourceUrl); const buildOptions = typescriptDecoratorOptions ? typeScriptBuildOptions(projectDir, typescriptDecoratorOptions, bundleTypeScript) @@ -2205,7 +2251,20 @@ function buildTranspiledModuleSource( const js = result.outputFiles?.[0]?.text ?? "export {}"; logger.debug(`transpiled size ${js.length} bytes`); - return await rewriteExternalImports(js, projectDir, sourceSnapshot, userDeps); + 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 208142d36d..78be77d3d3 100644 --- a/src/transforms/import-rewriter/core.test.ts +++ b/src/transforms/import-rewriter/core.test.ts @@ -144,11 +144,11 @@ 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"]]), ), - `Promise.resolve(require("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 178bde8147..0d255bca93 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -1,10 +1,19 @@ 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 { + JS_IDENTIFIER, + JS_STRING_LITERAL, + moduleExportNameValue, + type ModuleSpecifier, + parseModuleSpecifiers, resolveExportEntry as resolveRouteExportEntry, toCjsDestructureBindings, } from "#veryfront/routing/api/module-loader/loader-helpers.ts"; @@ -194,106 +203,344 @@ export function rewriteCompiledVeryfrontImportsForRoute(code: string): string { return transformed; } -export function rewriteCompiledUserDependencyImportsForRoute( - code: string, +/** 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})|(${SPECIFIER_LIST}))?$`, + "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}`, +); + +/** 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, + 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; + if (!defaultName && !namespaceName && !named) return null; + + // `{ 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 specifiers: ModuleSpecifier[]; + try { + specifiers = parseModuleSpecifiers(named); + } catch (_) { + // expected: an import clause this rewrite does not understand stays as written + return null; + } + for (const entry of specifiers) { + 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}`); + } + } + } + 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("; "); +} + +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, userDeps: Map, - esmDeps: Map = new Map(), +): { 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 (path !== name && !path.startsWith(`${name}/`)) continue; + if (best === null || name.length > best.length) best = name; + } + return best === null ? null : { name: best, subpath: path.slice(best.length), suffix }; +} + +/** + * 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 { - let transformed = code; + 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 = moduleExportNameValue(entry) === "default" + ? `${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}`, + ); +} - for (const name of userDeps.keys()) { - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +/** + * 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. + * + * 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(), + helperPrefix: string = DEFAULT_ROUTE_HELPER_PREFIX, +): Promise { + if (userDeps.size === 0) return code; + const { masked, imports, unmask } = await parseMaskedImports(code); + // 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) { + if (typeof imported.n !== "string") continue; + const dependency = matchUserDependency(imported.n, userDeps); + if (!dependency) continue; + const { name, subpath, suffix } = 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; + url = pathHelper.toFileUrl(target).href; + } + url += suffix; + // Static specifier spans exclude the quotes; dynamic ones include them. + edits.push({ + start: imported.s, + end: imported.e, + render: fixed(isDynamic ? toJsStringLiteral(url) : url), + }); + 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. + // 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. 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; + edits.push({ + start: imported.ss, + end: imported.se, + render: (rewriteRange) => { + const options = optionsStart > -1 + ? rewriteRange(optionsStart, optionsEnd).trim().replace(/,$/, "").trimEnd() + : ""; + return options + ? `((${optionsParam}) => (async () => { ${validate}(${optionsParam}, ${specifier}); ${load} })())(${options})` + : lazy; }, - ); - transformed = transformed.replace( - new RegExp(`import\\s*\\(\\s*["']${escaped}["']\\s*\\)`, "g"), - () => `import("${esm.entryUrl}")`, - ); + }); continue; } - transformed = transformed.replace( - new RegExp(`import\\s+(\\w+)\\s+from\\s+["']${escaped}["']`, "g"), - (_, localName) => `const ${localName} = __vf_interopDefault(require("${name}"))`, - ); - transformed = transformed.replace( - new RegExp(`import\\s+(\\{[^}]+\\})\\s+from\\s+["']${escaped}["']`, "g"), - (_, 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}")`, - ); - transformed = transformed.replace( - new RegExp( - `import\\s+(\\w+)\\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+(\\w+|\\*\\s+as\\s+\\w+|\\{[^}]+\\})\\s+from\\s+["']${escaped}(/[^"']+)["']`, - "g", - ), - (_, binding, subpath) => { - const trimmedBinding = String(binding).trim(); - if (trimmedBinding.startsWith("{")) { - return `const ${toCjsDestructureBindings(trimmedBinding)} = require("${name}${subpath}")`; - } - const name_ = trimmedBinding.startsWith("*") - ? trimmedBinding.replace(/\*\s+as\s+/, "") - : trimmedBinding; - return `const ${name_} = require("${name}${subpath}")`; - }, - ); - transformed = transformed.replace( - new RegExp(`import\\s*\\(\\s*["']${escaped}(/[^"']*)?["']\\s*\\)`, "g"), - (_, subpath) => `Promise.resolve(require("${name}${subpath || ""}"))`, - ); + 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. 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}); ` + : ""; + if (statement.startsWith("export")) { + const reexport = rewriteCjsReexport( + statement, + specifier, + imported.n, + helperPrefix, + reexports++, + ); + edits.push({ + start: imported.ss, + end: imported.se, + render: fixed(check + reexport), + static: true, + }); + continue; + } + if (SIDE_EFFECT_IMPORT.test(statement)) { + edits.push({ + start: imported.ss, + end: imported.se, + render: fixed(check + required), + static: true, + }); + continue; + } + const clause = statement.match(STATIC_FROM_IMPORT)?.[1]; + const rewritten = clause === undefined + ? null + : rewriteCjsImportClause(clause, specifier, helperPrefix); + if (rewritten !== null) { + edits.push({ + start: imported.ss, + end: imported.se, + render: fixed(check + rewritten), + static: true, + }); + } } - return transformed; + 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. + // 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(