Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions packages/vinext/src/plugins/ignore-dynamic-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" &&
Expand Down Expand Up @@ -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;
}
}
}

Expand Down
61 changes: 56 additions & 5 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -172,9 +174,54 @@ export function rememberCurrentServerEntryImportMtime(entryPath: string): void {
bareServerEntryMtimes.set(href, mtime);
}

type ServerEntryRequire = ReturnType<typeof createRequire>;

const serverEntryRequireStorage = new AsyncLocalStorage<ServerEntryRequire>();
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<T>(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<any> {
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. */
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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<void>((resolve) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>((resolve) => {
Expand Down
6 changes: 6 additions & 0 deletions tests/app-router-production-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,12 @@ describe("App Router Production server (startProdServer)", () => {
expect(html).toContain("<script");
});

it("bundles a static CommonJS request encoded with String.fromCharCode", async () => {
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];
Expand Down
86 changes: 64 additions & 22 deletions tests/app-router-worker-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});

Expand Down
34 changes: 34 additions & 0 deletions tests/dynamic-requests-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/app-basic/app/char-code-require/load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const loaded = require(String.fromCharCode(46, 47, 118, 97, 108, 117, 101));

export const charCodeRequireValue = loaded.charCodeRequireValue as string;
5 changes: 5 additions & 0 deletions tests/fixtures/app-basic/app/char-code-require/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { charCodeRequireValue } from "./load";

export default function CharCodeRequirePage() {
return <main>{charCodeRequireValue}</main>;
}
1 change: 1 addition & 0 deletions tests/fixtures/app-basic/app/char-code-require/value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const charCodeRequireValue = "loaded from a character-code require";
Loading
Loading