From 189610f37281aafd2dbe844cc7eccf317e772aff Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 02:20:56 -0500 Subject: [PATCH] Add Node execution context handling --- packages/vinext/src/server/prod-server.ts | 48 ++++++++++++--- tests/app-router.test.ts | 73 +++++++++++++++++++++++ 2 files changed, 113 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index b13dee14..6fd2f075 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -47,6 +47,7 @@ import { normalizePath } from "./normalize-path.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { computeLazyChunks } from "../index.js"; import { manifestFileWithBase } from "../utils/manifest-paths.js"; +import type { ExecutionContextLike } from "../shims/request-context.js"; /** Convert a Node.js IncomingMessage into a ReadableStream for Web Request body. */ function readNodeStream(req: IncomingMessage): ReadableStream { @@ -508,11 +509,47 @@ interface AppRouterServerOptions { compress: boolean; } +interface WorkerAppRouterEntry { + fetch(request: Request, env?: unknown, ctx?: ExecutionContextLike): Promise | Response; +} + +function createNodeExecutionContext(): ExecutionContextLike { + return { + waitUntil(promise: Promise) { + // Node doesn't provide a Workers lifecycle, but we still attach a + // rejection handler so background waitUntil work doesn't surface as an + // unhandled rejection when a Worker-style entry is used with vinext start. + void Promise.resolve(promise).catch(() => {}); + }, + passThroughOnException() {}, + }; +} + +function resolveAppRouterHandler(entry: unknown): (request: Request) => Promise { + if (typeof entry === "function") { + return (request) => Promise.resolve(entry(request)); + } + + if (entry && typeof entry === "object" && "fetch" in entry) { + const workerEntry = entry as WorkerAppRouterEntry; + if (typeof workerEntry.fetch === "function") { + return (request) => + Promise.resolve(workerEntry.fetch(request, undefined, createNodeExecutionContext())); + } + } + + console.error( + "[vinext] App Router entry must export either a default handler function or a Worker-style default export with fetch()", + ); + process.exit(1); +} + /** * Start the App Router production server. * - * The RSC entry (dist/server/index.js) exports a default handler function: - * handler(request: Request) → Promise + * The App Router entry (dist/server/index.js) can export either: + * - a default handler function: handler(request: Request) → Promise + * - a Worker-style object: { fetch(request, env, ctx) → Promise } * * This handler already does everything: route matching, RSC rendering, * SSR HTML generation (via import("./ssr/index.js")), route handlers, @@ -541,12 +578,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { // Import the RSC handler (use file:// URL for reliable dynamic import) const rscModule = await import(pathToFileURL(rscEntryPath).href); - const rscHandler: (request: Request) => Promise = rscModule.default; - - if (typeof rscHandler !== "function") { - console.error("[vinext] RSC entry does not export a default handler function"); - process.exit(1); - } + const rscHandler = resolveAppRouterHandler(rscModule.default); const server = createServer(async (req, res) => { const url = req.url ?? "/"; diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index d7180d60..d75308e0 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1600,6 +1600,79 @@ describe("App Router Production server (startProdServer)", () => { }); }); +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"), + ` +export default { + 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", + }), + { headers: { "content-type": "application/json" } }, + ); + }, +}; +`, + ); + + 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; + + 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, + }); + } finally { + server.close(); + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); + + it("reports a clear error for unsupported app router entry shapes", async () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-prod-worker-invalid-")); + const serverDir = path.join(outDir, "server"); + fs.mkdirSync(serverDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, "package.json"), JSON.stringify({ type: "module" })); + fs.writeFileSync(path.join(serverDir, "index.js"), "export default {};\n"); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((( + code?: string | number | null, + ) => { + throw new Error(`process.exit(${code})`); + }) as never); + + try { + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + await expect(startProdServer({ port: 0, outDir, noCompression: true })).rejects.toThrow( + "process.exit(1)", + ); + expect(errorSpy).toHaveBeenCalledWith( + "[vinext] App Router entry must export either a default handler function or a Worker-style default export with fetch()", + ); + } finally { + errorSpy.mockRestore(); + exitSpy.mockRestore(); + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); +}); + // --------------------------------------------------------------------------- // Malformed percent-encoded URL regression tests — App Router dev server // (covers entries/app-rsc-entry.ts generated RSC handler decodeURIComponent)