From d265caa8046a56d497f2cd269dabfe6bc535d169 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 29 Jul 2026 11:19:22 +0100 Subject: [PATCH] fix(server): support Node production entry contracts --- .../src/plugins/ignore-dynamic-requests.ts | 56 ++++++++++-- packages/vinext/src/server/prod-server.ts | 61 +++++++++++-- tests/app-router-production-server.test.ts | 6 ++ tests/app-router-worker-entry.test.ts | 86 ++++++++++++++----- tests/dynamic-requests-build.test.ts | 34 ++++++++ .../app-basic/app/char-code-require/load.ts | 3 + .../app-basic/app/char-code-require/page.tsx | 5 ++ .../app-basic/app/char-code-require/value.ts | 1 + tests/prod-server-entry-import.test.ts | 35 ++++++++ 9 files changed, 255 insertions(+), 32 deletions(-) create mode 100644 tests/fixtures/app-basic/app/char-code-require/load.ts create mode 100644 tests/fixtures/app-basic/app/char-code-require/page.tsx create mode 100644 tests/fixtures/app-basic/app/char-code-require/value.ts diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 0d2e771f41..aad368a02b 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -97,6 +97,39 @@ function stringValue(node: AstRecord): string | null { return null; } +function stringFromCharCodeValue(value: unknown, scope: Scope): string | null { + const node = unwrapExpression(value); + if (node?.type !== "CallExpression") return null; + const callee = unwrapExpression(node.callee); + const object = callee?.type === "MemberExpression" ? unwrapExpression(callee.object) : null; + const property = callee?.type === "MemberExpression" ? unwrapExpression(callee.property) : null; + if ( + callee?.type !== "MemberExpression" || + callee.computed === true || + !isIdentifierNamed(object, "String") || + hasAstBinding(scope, "String") || + !isIdentifierNamed(property, "fromCharCode") + ) { + return null; + } + + let resolved = ""; + for (const argument of nodeArray(node.arguments)) { + const argumentNode = unwrapExpression(argument); + if ( + argumentNode?.type !== "Literal" || + typeof argumentNode.value !== "number" || + !Number.isInteger(argumentNode.value) || + argumentNode.value < 0 || + argumentNode.value > 0xffff + ) { + return null; + } + resolved += String.fromCharCode(argumentNode.value); + } + return resolved; +} + function isUnboundNumericGlobal(node: AstRecord, scope: Scope): boolean { return ( node.type === "Identifier" && @@ -848,12 +881,25 @@ function transformVeryDynamicRequests(code: string, id: string) { !hasAstBinding(scope, "require") && argumentsList.length === 1 && astNode(argumentsList[0])?.type !== "SpreadElement" && - !hasDynamicRequestIgnoreDirective(code, node, argumentsList[0] as AstRecord) && - !requestHasStaticPart(argumentsList[0], scope) + !hasDynamicRequestIgnoreDirective(code, node, argumentsList[0] as AstRecord) ) { - output.overwrite(node.start, node.end, dynamicRequireReplacement()); - changed = true; - return; + const resolvedRequest = stringFromCharCodeValue(argumentsList[0], scope); + const argument = astNode(argumentsList[0]); + if ( + resolvedRequest !== null && + resolvedRequest.replaceAll("\\", "/") !== "/" && + argument && + hasRange(argument) + ) { + output.overwrite(argument.start, argument.end, JSON.stringify(resolvedRequest)); + changed = true; + return; + } + if (!requestHasStaticPart(argumentsList[0], scope)) { + output.overwrite(node.start, node.end, dynamicRequireReplacement()); + changed = true; + return; + } } } diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index cdb7e05774..c79344389b 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -18,6 +18,8 @@ * - dist/server/ssr/index.js — SSR entry (imported by RSC entry at runtime) */ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { createRequire } from "node:module"; +import { AsyncLocalStorage } from "node:async_hooks"; import { Readable, pipeline } from "node:stream"; import { pathToFileURL } from "node:url"; import fs from "node:fs"; @@ -172,9 +174,54 @@ export function rememberCurrentServerEntryImportMtime(entryPath: string): void { bareServerEntryMtimes.set(href, mtime); } +type ServerEntryRequire = ReturnType; + +const serverEntryRequireStorage = new AsyncLocalStorage(); +const inheritedGlobalRequire = + typeof globalThis.require === "function" ? globalThis.require : undefined; + +function activeServerEntryRequire(): ServerEntryRequire { + const activeRequire = serverEntryRequireStorage.getStore() ?? inheritedGlobalRequire; + if (activeRequire) return activeRequire; + throw new Error("require() was called outside a Node production server entry context"); +} + +const serverEntryRequireDispatcher = new Proxy( + ((request: string) => activeServerEntryRequire()(request)) as ServerEntryRequire, + { + apply(_target, thisArg, argumentsList) { + return Reflect.apply(activeServerEntryRequire(), thisArg, argumentsList); + }, + get(_target, property) { + return Reflect.get(activeServerEntryRequire(), property); + }, + set(_target, property, value) { + return Reflect.set(activeServerEntryRequire(), property, value); + }, + }, +); + +function runWithServerEntryRequire(entryRequire: ServerEntryRequire, callback: () => T): T { + // Keep one process-global dispatcher installed for the lifetime of the Node + // adapter. The resolver itself is entry-scoped through AsyncLocalStorage; + // calls made by the embedding outside an entry context use the inherited + // resolver captured above, or intentionally throw the adapter-specific + // error from activeServerEntryRequire when no resolver existed. + globalThis.require = serverEntryRequireDispatcher; + return serverEntryRequireStorage.run(entryRequire, callback); +} + +function createServerEntryRequire(entryPath: string): ServerEntryRequire { + return createRequire(pathToFileURL(entryPath)); +} + // oxlint-disable-next-line typescript/no-explicit-any -- built entry modules are untyped, matching the previous inline `await import(...)` export async function importServerEntryModule(entryPath: string): Promise { - return import(resolveServerEntryImportUrl(entryPath)); + const entryRequire = createServerEntryRequire(entryPath); + return runWithServerEntryRequire( + entryRequire, + () => import(resolveServerEntryImportUrl(entryPath)), + ); } /** Convert a Node.js IncomingMessage into a ReadableStream for Web Request body. */ @@ -1316,7 +1363,7 @@ function resolveAppRouterHandler( if (entry && typeof entry === "object" && "fetch" in entry) { const workerEntry = entry as WorkerAppRouterEntry; if (typeof workerEntry.fetch === "function") { - return (request, ctx) => Promise.resolve(workerEntry.fetch(request, undefined, ctx)); + return (request, ctx) => Promise.resolve(workerEntry.fetch(request, process.env, ctx)); } } @@ -1503,6 +1550,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { // instance, and only cache-busts when this function runs again after a // rebuild to the same path (e.g. across test describe blocks). const rscModule = await importServerEntryModule(rscEntryPath); + const rscEntryRequire = createServerEntryRequire(rscEntryPath); const rscHandler = resolveAppRouterHandler(rscModule.default); // `assetPrefix` is embedded as a compile-time constant in the generated @@ -1562,7 +1610,9 @@ async function startAppRouterServer(options: AppRouterServerOptions) { // Seed the memory cache with pre-rendered routes so the first request to // any pre-rendered page is a cache HIT instead of a full re-render. const seedPrerenderedRoutes = resolveAppRouterPrerenderSeeder(rscModule); - const seededRoutes = await seedPrerenderedRoutes(path.dirname(rscEntryPath)); + const seededRoutes = await runWithServerEntryRequire(rscEntryRequire, () => + seedPrerenderedRoutes(path.dirname(rscEntryPath)), + ); if (seededRoutes > 0) { console.log( `[vinext] Seeded ${seededRoutes} pre-rendered route${seededRoutes !== 1 ? "s" : ""} into memory cache`, @@ -1772,7 +1822,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { }; const server = createServer((req, res) => { - void handleRequest(req, res); + void runWithServerEntryRequire(rscEntryRequire, () => handleRequest(req, res)); }); await new Promise((resolve) => { @@ -1842,6 +1892,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // module instance, and only cache-busts when this function runs again after // a rebuild to the same output path. const serverEntry = await importServerEntryModule(serverEntryPath); + const serverEntryRequire = createServerEntryRequire(serverEntryPath); const { renderPage, handleApiRoute: handleApi, @@ -2283,7 +2334,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { }; const server = createServer((req, res) => { - void handleRequest(req, res); + void runWithServerEntryRequire(serverEntryRequire, () => handleRequest(req, res)); }); await new Promise((resolve) => { diff --git a/tests/app-router-production-server.test.ts b/tests/app-router-production-server.test.ts index e1d5a41c90..f4337fb721 100644 --- a/tests/app-router-production-server.test.ts +++ b/tests/app-router-production-server.test.ts @@ -351,6 +351,12 @@ describe("App Router Production server (startProdServer)", () => { expect(html).toContain(" { + const res = await fetch(`${baseUrl}/char-code-require`); + expect(res.status).toBe(200); + expect(await res.text()).toContain("loaded from a character-code require"); + }); + it("serves static asset byte ranges from the identity representation", async () => { const html = await (await fetch(`${baseUrl}/`)).text(); const href = html.match(/["'](\/_next\/static\/[^"']+\.(?:js|css))["']/)?.[1]; diff --git a/tests/app-router-worker-entry.test.ts b/tests/app-router-worker-entry.test.ts index 73e74639ba..c64c1052b8 100644 --- a/tests/app-router-worker-entry.test.ts +++ b/tests/app-router-worker-entry.test.ts @@ -5,44 +5,86 @@ import { describe, expect, it, vi } from "vite-plus/test"; describe("App Router Production server worker entry compatibility", () => { it("accepts Worker-style default exports from dist/server/index.js", async () => { - const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-prod-worker-entry-")); - const serverDir = path.join(outDir, "server"); - fs.mkdirSync(serverDir, { recursive: true }); - fs.mkdirSync(path.join(outDir, "client"), { recursive: true }); - fs.writeFileSync(path.join(outDir, "package.json"), JSON.stringify({ type: "module" })); - fs.writeFileSync( - path.join(serverDir, "index.js"), - ` + const outDirs: string[] = []; + function writeWorkerEntry(value: string): string { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-prod-worker-entry-")); + outDirs.push(outDir); + const serverDir = path.join(outDir, "server"); + fs.mkdirSync(serverDir, { recursive: true }); + fs.mkdirSync(path.join(outDir, "client"), { recursive: true }); + fs.writeFileSync(path.join(outDir, "package.json"), JSON.stringify({ type: "module" })); + fs.writeFileSync( + path.join(serverDir, "entry-relative.cjs"), + `module.exports = { value: ${JSON.stringify(value)} };\n`, + ); + fs.writeFileSync( + path.join(serverDir, "index.js"), + ` +const importValue = globalThis.require("./entry-relative.cjs").value; + export default { - async fetch(request, _env, ctx) { + async fetch(request, env, ctx) { ctx?.waitUntil(Promise.resolve("background")); return new Response( JSON.stringify({ pathname: new URL(request.url).pathname, hasWaitUntil: typeof ctx?.waitUntil === "function", + envValue: env.VINEXT_WORKER_ENTRY_TEST, + importValue, + runtimeValue: globalThis.require("./entry-relative.cjs").value, }), { headers: { "content-type": "application/json" } }, ); }, }; `, - ); + ); + return outDir; + } - const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - const { server } = await startProdServer({ port: 0, outDir, noCompression: true }); - const addr = server.address(); - const port = typeof addr === "object" && addr ? addr.port : 0; + const previousRequire = Object.getOwnPropertyDescriptor(globalThis, "require"); + const previousEnv = process.env.VINEXT_WORKER_ENTRY_TEST; + Object.defineProperty(globalThis, "require", { + configurable: true, + value: () => ({ value: "wrong pre-existing resolver" }), + writable: true, + }); + process.env.VINEXT_WORKER_ENTRY_TEST = "passed through process.env"; + const servers: import("node:http").Server[] = []; try { - const res = await fetch(`http://localhost:${port}/worker-test`); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ - pathname: "/worker-test", - hasWaitUntil: true, - }); + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + const entries = ["first entry", "second entry"]; + const started = await Promise.all( + entries.map((value) => + startProdServer({ port: 0, outDir: writeWorkerEntry(value), noCompression: true }), + ), + ); + servers.push(...started.map(({ server }) => server)); + + for (const [{ port }, value] of started.map( + (server, index) => [server, entries[index]] as const, + )) { + const res = await fetch(`http://localhost:${port}/worker-test`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + pathname: "/worker-test", + hasWaitUntil: true, + envValue: "passed through process.env", + importValue: value, + runtimeValue: value, + }); + } } finally { - server.close(); - fs.rmSync(outDir, { recursive: true, force: true }); + for (const server of servers) server.close(); + if (previousRequire) { + Object.defineProperty(globalThis, "require", previousRequire); + } else { + Reflect.deleteProperty(globalThis, "require"); + } + if (previousEnv === undefined) delete process.env.VINEXT_WORKER_ENTRY_TEST; + else process.env.VINEXT_WORKER_ENTRY_TEST = previousEnv; + for (const outDir of outDirs) fs.rmSync(outDir, { recursive: true, force: true }); } }); diff --git a/tests/dynamic-requests-build.test.ts b/tests/dynamic-requests-build.test.ts index d85fb7c8bb..5cc671ca23 100644 --- a/tests/dynamic-requests-build.test.ts +++ b/tests/dynamic-requests-build.test.ts @@ -792,6 +792,40 @@ function withDeclaration(value = require(request)) { expect(transformed?.match(/Cannot find module as expression is too dynamic/g)).toHaveLength(2); }); + it("resolves static require requests encoded with String.fromCharCode", () => { + // Regression for the downstream patch in nodejs/nodejs.org@30ca20133337398e6707bb2cb21df450d6d9da04. + const transformed = _transformVeryDynamicRequests( + "const loaded = require(String.fromCharCode(46, 47, 118, 97, 108, 117, 101));", + "/app/load.js", + )?.code; + + expect(transformed).toContain('require("./value")'); + expect(transformed).not.toContain("MODULE_NOT_FOUND"); + }); + + it("does not evaluate shadowed or non-literal String.fromCharCode calls", () => { + const transformed = _transformVeryDynamicRequests( + `function load(String) { + return require(String.fromCharCode(46, 47, 118, 97, 108, 117, 101)); +} +require(String.fromCharCode(...codeUnits));`, + "/app/load.js", + )?.code; + + expect(transformed).not.toContain('require("./value")'); + expect(transformed?.match(/MODULE_NOT_FOUND/g)).toHaveLength(2); + }); + + it("matches literal require handling for empty and root character-code requests", () => { + const transformed = _transformVeryDynamicRequests( + "require(String.fromCharCode()); require(String.fromCharCode(47));", + "/app/load.js", + )?.code; + + expect(transformed).toContain('require("")'); + expect(transformed?.match(/MODULE_NOT_FOUND/g)).toHaveLength(1); + }); + it("serves guarded fully dynamic requests in pages and route handlers during development", async () => { await withTempDir(async (root) => { writeAppFixture(root); diff --git a/tests/fixtures/app-basic/app/char-code-require/load.ts b/tests/fixtures/app-basic/app/char-code-require/load.ts new file mode 100644 index 0000000000..70b3c9b304 --- /dev/null +++ b/tests/fixtures/app-basic/app/char-code-require/load.ts @@ -0,0 +1,3 @@ +const loaded = require(String.fromCharCode(46, 47, 118, 97, 108, 117, 101)); + +export const charCodeRequireValue = loaded.charCodeRequireValue as string; diff --git a/tests/fixtures/app-basic/app/char-code-require/page.tsx b/tests/fixtures/app-basic/app/char-code-require/page.tsx new file mode 100644 index 0000000000..a2db14498a --- /dev/null +++ b/tests/fixtures/app-basic/app/char-code-require/page.tsx @@ -0,0 +1,5 @@ +import { charCodeRequireValue } from "./load"; + +export default function CharCodeRequirePage() { + return
{charCodeRequireValue}
; +} diff --git a/tests/fixtures/app-basic/app/char-code-require/value.ts b/tests/fixtures/app-basic/app/char-code-require/value.ts new file mode 100644 index 0000000000..694fcba6b7 --- /dev/null +++ b/tests/fixtures/app-basic/app/char-code-require/value.ts @@ -0,0 +1 @@ +export const charCodeRequireValue = "loaded from a character-code require"; diff --git a/tests/prod-server-entry-import.test.ts b/tests/prod-server-entry-import.test.ts index 25704edbe0..ffc9a8c55d 100644 --- a/tests/prod-server-entry-import.test.ts +++ b/tests/prod-server-entry-import.test.ts @@ -36,6 +36,7 @@ import { */ describe("server entry import URL resolution", () => { const tmpDirs: string[] = []; + const originalRequire = Object.getOwnPropertyDescriptor(globalThis, "require"); function makeTmpDir(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-entry-import-")); @@ -44,6 +45,11 @@ describe("server entry import URL resolution", () => { } afterEach(() => { + if (originalRequire) { + Object.defineProperty(globalThis, "require", originalRequire); + } else { + Reflect.deleteProperty(globalThis, "require"); + } while (tmpDirs.length > 0) { fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true }); } @@ -153,4 +159,33 @@ describe("server entry import URL resolution", () => { expect(chunk.state).toBe(entry.state); expect(chunk.state.ready).toBe(true); }); + + it("rebinds require relative to each ESM server entry", async () => { + function writeEntry(value: string): string { + const dir = makeTmpDir(); + const entryPath = path.join(dir, "entry.mjs"); + fs.writeFileSync( + path.join(dir, "entry-relative.cjs"), + `module.exports = { value: ${JSON.stringify(value)} };\n`, + ); + fs.writeFileSync( + entryPath, + 'export const loaded = globalThis.require("./entry-relative.cjs").value;\n', + ); + return entryPath; + } + + Object.defineProperty(globalThis, "require", { + configurable: true, + value: () => ({ value: "wrong pre-existing resolver" }), + writable: true, + }); + + const [first, second] = await Promise.all([ + importServerEntryModule(writeEntry("first entry")), + importServerEntryModule(writeEntry("second entry")), + ]); + expect(first.loaded).toBe("first entry"); + expect(second.loaded).toBe("second entry"); + }); });