diff --git a/packages/vinext/package.json b/packages/vinext/package.json index dfa2834dc0..b2462b1c13 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -72,6 +72,14 @@ "types": "./dist/server/multi-stage.d.ts", "import": "./dist/server/multi-stage.js" }, + "./server/request-stage": { + "types": "./dist/server/request-stage.d.ts", + "import": "./dist/server/request-stage.js" + }, + "./server/response-stage": { + "types": "./dist/server/response-stage.d.ts", + "import": "./dist/server/response-stage.js" + }, "./server/app-router-entry": { "types": "./dist/server/app-router-entry.d.ts", "import": "./dist/server/app-router-entry.js" diff --git a/packages/vinext/src/build/client-build-config.ts b/packages/vinext/src/build/client-build-config.ts index 4d90db2552..2d2f1c501f 100644 --- a/packages/vinext/src/build/client-build-config.ts +++ b/packages/vinext/src/build/client-build-config.ts @@ -236,6 +236,23 @@ export function isRscFrameworkModule(id: string): boolean { return pkg !== null && (FRAMEWORK_PACKAGES as readonly string[]).includes(pkg); } +/** + * Keep virtual entry ids out of emitted RSC chunk filenames. + * + * Rolldown's entries-aware chunk names can contain the `\\0` virtual-id marker, + * which is not a portable module-specifier or filesystem name. Preserve every + * other character and remove both the printable and actual-NUL forms. + */ +export function sanitizeRscChunkFileName(name: string): string { + const withoutVirtualMarkers = name.replaceAll("\\0", ""); + const invalid = new Set(["<", ">", ":", '"', "/", "\\", "|", "?", "*"]); + let sanitized = ""; + for (const character of withoutVirtualMarkers) { + sanitized += character.charCodeAt(0) <= 31 || invalid.has(character) ? "_" : character; + } + return sanitized; +} + /** * Output config that isolates React (and the RSC flight runtime) into a * dedicated "framework" chunk in the RSC server build. See @@ -245,6 +262,7 @@ export function isRscFrameworkModule(id: string): boolean { */ export function createRscFrameworkChunkOutputConfig() { return { + sanitizeFileName: sanitizeRscChunkFileName, codeSplitting: { groups: [ { @@ -287,3 +305,67 @@ export function withBuildBundlerOptions( ): Partial { return { rolldownOptions: bundlerOptions }; } + +type VinextBuildOutput = Exclude< + NonNullable, + readonly unknown[] +>; +type VinextCodeSplittingConfig = Exclude, boolean>; +type ChunkFileNames = NonNullable; +type ChunkFileNameFunction = Exclude; + +/** + * Keep vinext modules partitioned by the stage entries that actually use them. + * Without an entry-aware catch-all, Rolldown may merge a small helper shared by + * request/response entries into a response-heavy chunk; importing that helper + * then evaluates React and renderer code on a request-stage cache hit. + */ +export function createMultiStageCodeSplittingConfig( + existing: VinextBuildOutput["codeSplitting"], +): VinextCodeSplittingConfig & { groups: NonNullable } { + const base = existing && typeof existing === "object" ? existing : {}; + return { + ...base, + groups: [ + { + name: "vinext-stage-runtime", + test: /(?:^|[/\\])(?:packages[/\\]vinext[/\\]src|(?:packages[/\\]vinext|node_modules[/\\](?:\.pnpm[/\\][^/\\]+[/\\]node_modules[/\\])?vinext)[/\\]dist)[/\\]/, + entriesAware: true, + }, + ...(base.groups ?? []), + ], + }; +} + +/** + * Keep router stage chunks beside the server entry so their generated + * `./vinext-client-assets.js` external continues to resolve. Other chunks keep + * the host's existing output pattern (or vinext's server-assets default). + */ +export function createMultiStageChunkFileNames( + assetsDir: string, + existing: VinextBuildOutput["chunkFileNames"], +): ChunkFileNameFunction { + return (chunk) => { + const name = sanitizeRscChunkFileName(chunk.name); + if ( + chunk.moduleIds?.some((id) => + /\/server\/app-ssr-entry\.[cm]?[jt]sx?$/.test(toSlash(id.split("?", 1)[0] ?? "")), + ) || + [ + "app-router-entry", + "pages-router-entry", + "app-response-stage-entry", + "pages-request-stage-entry", + "pages-response-stage-entry", + "virtual_vinext-rsc-entry", + "virtual_vinext-response-stage", + ].some((entryName) => name.includes(entryName)) + ) { + return `${name}-[hash].js`; + } + if (typeof existing === "function") return existing({ ...chunk, name }); + const pattern = existing ?? joinAssetFileNamePattern(assetsDir, "[name]-[hash].js"); + return pattern.replaceAll("[name]", name); + }; +} diff --git a/packages/vinext/src/cache/cache-adapters-virtual.ts b/packages/vinext/src/cache/cache-adapters-virtual.ts index 1a3859815f..9232556e51 100644 --- a/packages/vinext/src/cache/cache-adapters-virtual.ts +++ b/packages/vinext/src/cache/cache-adapters-virtual.ts @@ -15,6 +15,7 @@ * request. */ import { flattenPluginOptions } from "../utils/plugin-options.js"; +import type { VinextMultiStageOutput } from "../server/multi-stage.js"; /** * A serializable pointer to a cache adapter module — the shape of each `cache` @@ -69,6 +70,8 @@ export type CdnCacheAdapterCapabilities = { }; export type CacheAdapterBuildOutput = { + /** Omitted for finalizer-only outputs; staged outputs use `multi-stage`. */ + type?: undefined; /** Whether this adapter owns output for the resolved build platform. */ matchesBuild?: (build: { plugins: readonly { name?: string }[] }) => boolean; /** Finalize an emitted directory after other platform output hooks. */ @@ -87,8 +90,8 @@ export type CacheAdapterDescriptor = Record["assetsInlineLimit"] = 0; let hasCloudflarePlugin = false; + let selectedMultiStageOutput: VinextMultiStageOutput | undefined; + const isMultiStageServerEnvironment = (environment: { + config: { build: { ssr?: unknown }; consumer?: string }; + name: string; + }): boolean => { + if (environment.name === "client") return Boolean(environment.config.build.ssr); + return isServerEnvironment(environment) && (!hasAppDir || environment.name !== "ssr"); + }; let warnedInlineNextConfigOverride = false; let hasNitroPlugin = false; let resolvedServerExternalPackages: string[] = []; @@ -1549,10 +1569,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // one process never preprocess `composes` deps with another build's config. const sassComposesLoader = createSassAwareFileSystemLoader(); - // Build-time layout classification manifest, captured in the RSC virtual - // module's load hook and consumed in renderChunk to patch the generated - // `__VINEXT_CLASS` stub with a real dispatch table. - let rscClassificationManifest: RouteClassificationManifest | null = null; + // Build-time layout classification manifests, captured for each generated + // RSC virtual module and consumed in renderChunk to patch that module's + // `__VINEXT_CLASS` stub with a real dispatch table. Multi-stage outputs emit + // both the ordinary RSC graph and a response-only graph in the same build, + // so one mutable manifest would be consumed by whichever chunk rendered + // first and leave the other graph's classifier as the null stub. + const rscClassificationManifests = new Map(); let rscActionOwnerRoutes: Awaited> | null = null; let rscActionOwnerSharedRoots: string[] = []; const serverEntryKindsByEnvironment = new Map>(); @@ -2777,6 +2800,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { typeof p.name === "string" && (p.name === "vite-plugin-cloudflare" || p.name.startsWith("vite-plugin-cloudflare:")), ); + const configuredMultiStageOutput = options.cache?.cdn?.output; + selectedMultiStageOutput = + configuredMultiStageOutput?.type === "multi-stage" && + (configuredMultiStageOutput.matchesBuild?.({ + plugins: pluginsFlat as { name?: string }[], + }) ?? + true) + ? configuredMultiStageOutput + : undefined; hasNitroPlugin = pluginsFlat.some( (p: unknown) => p && @@ -3971,7 +4003,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // direct @vercel/og imports in metadata routes, and \0-prefixed // re-imports from @vitejs/plugin-rsc. filter: { - id: /(?:next\/|vinext\/(?:shims\/|server\/app-rsc-(?:combined-)?handler)|virtual:vinext-|@vercel\/og(?:\.js)?$)/, + id: /(?:next\/|vinext\/(?:shims\/|server\/(?:app-rsc-(?:combined-)?handler|app-router-entry|pages-router-entry))|virtual:vinext-|@vercel\/og(?:\.js)?$)/, }, handler(id, importer) { // Strip \0 prefix if present — @vitejs/plugin-rsc's generated @@ -4022,9 +4054,24 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Router-selected Cloudflare Worker entry facade if (cleanId === VIRTUAL_WORKER_ENTRY) return RESOLVED_WORKER_ENTRY; + if ( + selectedMultiStageOutput && + (cleanId === "vinext/server/app-router-entry" || + cleanId === "vinext/server/pages-router-entry") + ) { + return RESOLVED_WORKER_ENTRY; + } if (cleanId.endsWith("/" + VIRTUAL_WORKER_ENTRY)) { return RESOLVED_WORKER_ENTRY; } + if (cleanId === VIRTUAL_REQUEST_STAGE) return RESOLVED_REQUEST_STAGE; + if (cleanId.endsWith("/" + VIRTUAL_REQUEST_STAGE)) { + return RESOLVED_REQUEST_STAGE; + } + if (cleanId === VIRTUAL_RESPONSE_STAGE) return RESOLVED_RESPONSE_STAGE; + if (cleanId.endsWith("/" + VIRTUAL_RESPONSE_STAGE)) { + return RESOLVED_RESPONSE_STAGE; + } // Pages Router virtual modules if (cleanId === VIRTUAL_SERVER_ENTRY) return RESOLVED_SERVER_ENTRY; @@ -4134,11 +4181,26 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { filter: { id: /virtual:vinext-/ }, async handler(id) { if (id === RESOLVED_WORKER_ENTRY) { + if (selectedMultiStageOutput?.type === "multi-stage") { + return [ + `export { default } from ${JSON.stringify(selectedMultiStageOutput.entry)};`, + `export * from ${JSON.stringify(selectedMultiStageOutput.entry)};`, + "", + ].join("\n"); + } const entry = hasAppDir ? "vinext/server/app-router-entry" : "vinext/server/pages-router-entry"; return `export { default } from ${JSON.stringify(entry)};`; } + if (id === RESOLVED_REQUEST_STAGE) { + const entry = hasAppDir ? APP_REQUEST_STAGE_ENTRY : PAGES_REQUEST_STAGE_ENTRY; + return `export { handleRequestStage } from ${JSON.stringify(entry)};\n`; + } + if (id === RESOLVED_RESPONSE_STAGE) { + const entry = hasAppDir ? APP_RESPONSE_STAGE_ENTRY : PAGES_RESPONSE_STAGE_ENTRY; + return `export { handleResponseStage } from ${JSON.stringify(entry)};\n`; + } // Pages Router virtual modules if (id === RESOLVED_SERVER_ENTRY) { recordServerEntryLoad(this.environment?.name, id); @@ -4221,12 +4283,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Collect Layer 1 (segment config) classifications for all layouts. // Layer 2 (module graph) runs later in renderChunk once Rollup's // module info is available. - // Invariant: rscClassificationManifest must be built from the same - // `routes` value passed to generateRscEntry below so that layout - // indices in the manifest correspond 1:1 to the route.layouts arrays - // used during codegen. renderChunk clears this after patching. + // Invariant: each manifest must be built from the same `routes` + // value passed to its generator below so that layout indices in the + // manifest correspond 1:1 to the route.layouts arrays used during + // codegen. renderChunk consumes the manifest for that virtual module. if (id !== RESOLVED_APP_REQUEST_ENTRY) { - rscClassificationManifest = collectRouteClassificationManifest(routes); + rscClassificationManifests.set(id, collectRouteClassificationManifest(routes)); rscActionOwnerRoutes = this.environment.config.command === "build" && hasServerActions ? routes : null; rscActionOwnerSharedRoots = [globalErrorPath, globalNotFoundPath].filter( @@ -4394,12 +4456,22 @@ export const loadServerActionClient = ${ // pulling ModuleInfo from the wrong graph would give nonsense // results. if (this.environment?.name !== "rsc") return null; - if (!rscClassificationManifest) return null; // Cheap pre-filter: skip chunks that don't mention the stub at all // (e.g. the scan-phase chunk and every non-entry chunk). const hasClassificationStub = code.includes("__VINEXT_CLASS"); if (!hasClassificationStub) return null; + // Both generated App RSC graphs can be present in one multi-entry + // build. Associate the chunk with the virtual module that generated + // its route table so each graph receives (and consumes) its own + // manifest regardless of render order. + const rscEntryId = [RESOLVED_RSC_ENTRY, RESOLVED_APP_RESPONSE_ENTRY].find((id) => + chunk.moduleIds.includes(id), + ); + if (!rscEntryId) return null; + const rscClassificationManifest = rscClassificationManifests.get(rscEntryId); + if (!rscClassificationManifest) return null; + // Patching per-chunk (rather than scanning the whole bundle in // generateBundle) assumes the stub body and its per-route call sites // are emitted into the same chunk. That holds with current codegen: @@ -4446,11 +4518,11 @@ export const loadServerActionClient = ${ const nextCode = patchPlan.kind === "skip" ? code : patchPlan.code; if (patchPlan.kind === "skip") return null; - // Consume the manifest exactly once per RSC entry. Clearing here - // prevents a stale manifest from leaking into a subsequent build pass - // if the load hook is not re-triggered (e.g., in non-standard rebuild - // paths). - rscClassificationManifest = null; + // Consume the manifest exactly once for this generated RSC module. + // Keeping the sibling entry's manifest intact lets a multi-entry + // build patch both graphs while still preventing stale state from + // leaking into a later non-standard rebuild path. + rscClassificationManifests.delete(rscEntryId); // The patched body is longer than the stub, so any existing source // map would be stale. RSC entry source maps are not served or @@ -4520,6 +4592,48 @@ export const loadServerActionClient = ${ }, }, }, + { + name: "vinext:multi-stage-host-entry", + apply: "build", + + transform: { + // The adapter owns entry matching. Do not pre-filter by an import + // spelling here: host entries may reach vinext through an alias or an + // adapter-owned wrapper, and the callback receives both source and id + // specifically so it can recognize those layouts. + filter: { id: /virtual:|\.[cm]?[jt]sx?(?:\?|$)/ }, + handler(code, id) { + const transformed = selectedMultiStageOutput?.transformHostEntry?.({ code, id }); + return transformed == null ? null : { code: transformed, map: null }; + }, + }, + }, + { + name: "vinext:multi-stage-server-output", + apply: "build", + + // Vite calls this hook once for every output member, including an + // array-shaped host config. Apply stage isolation per resolved output + // without replacing its entry names or host-owned groups. + outputOptions(output) { + const environment = this.environment; + if ( + !selectedMultiStageOutput || + !environment || + !isMultiStageServerEnvironment(environment) + ) { + return; + } + return { + ...output, + chunkFileNames: createMultiStageChunkFileNames( + resolveAssetsDir(nextConfig.assetPrefix ?? ""), + output.chunkFileNames, + ), + codeSplitting: createMultiStageCodeSplittingConfig(output.codeSplitting), + }; + }, + }, { name: "vinext:css-url-assets-defaults", apply: "build", @@ -7491,7 +7605,10 @@ export const loadServerActionClient = ${ const loadedEntries = serverEntryKindsByEnvironment.get(this.environment?.name ?? ""); const isPrimaryServerOutput = Boolean( loadedEntries?.has(RESOLVED_RSC_ENTRY) || + loadedEntries?.has(RESOLVED_APP_REQUEST_ENTRY) || + loadedEntries?.has(RESOLVED_APP_RESPONSE_ENTRY) || loadedEntries?.has(RESOLVED_PAGES_REQUEST_ENTRY) || + loadedEntries?.has(RESOLVED_PAGES_RESPONSE_ENTRY) || (loadedEntries?.has(RESOLVED_SERVER_ENTRY) && !loadedEntries.has(RESOLVED_APP_SSR_ENTRY)), ); @@ -7612,3 +7729,8 @@ export type { // Export NextConfig type so next.config.ts files can import it from "vinext" // instead of "next". export type { NextConfig } from "./config/next-config.js"; +export type { + VinextMultiStageOutput, + VinextResponseStageDispatchOptions, + VinextResponseStageTransport, +} from "./server/multi-stage.js"; diff --git a/packages/vinext/src/server/fetch-handler.ts b/packages/vinext/src/server/fetch-handler.ts index 60e66d9b8a..6a1503294f 100644 --- a/packages/vinext/src/server/fetch-handler.ts +++ b/packages/vinext/src/server/fetch-handler.ts @@ -12,7 +12,10 @@ * for the current project at build time. */ +// Re-export the adapter-selected Worker facade. A single-stage output exposes +// only `default`; a multi-stage output may additionally expose named +// entrypoints which must remain top-level exports in the final Worker module. // @ts-expect-error -- virtual module resolved by vinext at build time -import handler from "virtual:vinext-worker-entry"; - -export default handler; +export { default } from "virtual:vinext-worker-entry"; +// @ts-expect-error -- virtual module resolved by vinext at build time +export * from "virtual:vinext-worker-entry"; diff --git a/packages/vinext/src/server/multi-stage.ts b/packages/vinext/src/server/multi-stage.ts index 2f594d11cb..3d5ff93735 100644 --- a/packages/vinext/src/server/multi-stage.ts +++ b/packages/vinext/src/server/multi-stage.ts @@ -63,7 +63,11 @@ export type VinextMultiStageOutput = { * Let the adapter finalize host-owned deployment output after it is written. * Core supplies paths only; the adapter owns every platform-specific detail. */ - finalizeBuildOutput?: (output: { outDir: string; root: string }) => Promise | void; + finalizeBuildOutput?: (output: { + outDir: string; + root: string; + isPrimaryServerOutput: boolean; + }) => Promise | void; }; /** Platform-neutral request-stage handler exposed to deployment adapters. */ diff --git a/packages/vinext/src/server/request-stage.ts b/packages/vinext/src/server/request-stage.ts new file mode 100644 index 0000000000..49f14d42bb --- /dev/null +++ b/packages/vinext/src/server/request-stage.ts @@ -0,0 +1,11 @@ +// oxlint-disable-next-line typescript/triple-slash-reference -- loads virtual-module types without a runtime import +/// + +import type { VinextRequestStageModule } from "./multi-stage.js"; + +/** Lazily load the router-specific request stage selected by vinext. */ +export function loadVinextRequestStage(): Promise< + VinextRequestStageModule +> { + return import("virtual:vinext-request-stage"); +} diff --git a/packages/vinext/src/server/response-stage.ts b/packages/vinext/src/server/response-stage.ts new file mode 100644 index 0000000000..6e0ee1b0b2 --- /dev/null +++ b/packages/vinext/src/server/response-stage.ts @@ -0,0 +1,11 @@ +// oxlint-disable-next-line typescript/triple-slash-reference -- loads virtual-module types without a runtime import +/// + +import type { VinextResponseStageModule } from "./multi-stage.js"; + +/** Lazily load the router-specific response stage selected by vinext. */ +export function loadVinextResponseStage(): Promise< + VinextResponseStageModule +> { + return import("virtual:vinext-response-stage"); +} diff --git a/packages/vinext/src/virtual-vinext-multi-stage.d.ts b/packages/vinext/src/virtual-vinext-multi-stage.d.ts new file mode 100644 index 0000000000..a81416575d --- /dev/null +++ b/packages/vinext/src/virtual-vinext-multi-stage.d.ts @@ -0,0 +1,7 @@ +declare module "virtual:vinext-request-stage" { + export const handleRequestStage: import("./server/multi-stage.js").VinextRequestStageHandler; +} + +declare module "virtual:vinext-response-stage" { + export const handleResponseStage: import("./server/multi-stage.js").VinextResponseStageHandler; +} diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 2fbdfc2fac..a4db6f4d93 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -21,7 +21,10 @@ import { createClientManualChunks, getClientTreeshakeConfig, createRscFrameworkChunkOutputConfig, + createMultiStageCodeSplittingConfig, + createMultiStageChunkFileNames, RSC_FRAMEWORK_CHUNK_TEST, + sanitizeRscChunkFileName, isRscFrameworkModule, } from "../packages/vinext/src/build/client-build-config.js"; import { @@ -3903,6 +3906,7 @@ describe("createRscFrameworkChunkOutputConfig", () => { expect(config).not.toHaveProperty("advancedChunks"); expect(config).not.toHaveProperty("manualChunks"); expect(config).toEqual({ + sanitizeFileName: sanitizeRscChunkFileName, codeSplitting: { groups: [ { @@ -3914,6 +3918,167 @@ describe("createRscFrameworkChunkOutputConfig", () => { }, }); }); + + it("removes virtual-id markers without changing ordinary chunk names", () => { + expect( + sanitizeRscChunkFileName( + "framework~\\0virtual_vinext-response-stage~\0virtual_vinext-request-stage.js", + ), + ).toBe("framework~virtual_vinext-response-stage~_virtual_vinext-request-stage.js"); + expect(sanitizeRscChunkFileName("bad:name\\chunk/part?.js")).toBe("bad_name_chunk_part_.js"); + expect(sanitizeRscChunkFileName("framework-a1b2c3.js")).toBe("framework-a1b2c3.js"); + }); +}); + +describe("createMultiStageChunkFileNames", () => { + it("keeps router stage chunks beside the server entry", () => { + const fileName = createMultiStageChunkFileNames("_next/static", undefined); + expect(fileName({ name: "app-router-entry" } as never)).toBe("app-router-entry-[hash].js"); + expect(fileName({ name: "pages-router-entry" } as never)).toBe("pages-router-entry-[hash].js"); + expect(fileName({ name: "app-response-stage-entry" } as never)).toBe( + "app-response-stage-entry-[hash].js", + ); + expect(fileName({ name: "pages-request-stage-entry" } as never)).toBe( + "pages-request-stage-entry-[hash].js", + ); + expect(fileName({ name: "pages-response-stage-entry" } as never)).toBe( + "pages-response-stage-entry-[hash].js", + ); + expect(fileName({ name: "_virtual_vinext-rsc-entry" } as never)).toBe( + "_virtual_vinext-rsc-entry-[hash].js", + ); + expect(fileName({ name: "_virtual_vinext-response-stage" } as never)).toBe( + "_virtual_vinext-response-stage-[hash].js", + ); + expect(fileName({ name: "vinext-stage-runtime~virtual_vinext-response-stage" } as never)).toBe( + "vinext-stage-runtime~virtual_vinext-response-stage-[hash].js", + ); + expect(fileName({ name: "request-runtime" } as never)).toBe( + "_next/static/request-runtime-[hash].js", + ); + expect(fileName({ name: "runtime~\\0virtual_stage" } as never)).toBe( + "_next/static/runtime~virtual_stage-[hash].js", + ); + expect( + fileName({ + moduleIds: ["/repo/packages/vinext/src/server/app-ssr-entry.ts"], + name: "vinext-stage-runtime~index", + } as never), + ).toBe("vinext-stage-runtime~index-[hash].js"); + }); + + it("preserves a host-provided chunk filename function", () => { + let calls = 0; + const existing = (chunk: { name: string }) => { + calls += 1; + return `host/${chunk.name}.js`; + }; + const fileName = createMultiStageChunkFileNames("_next/static", existing as never); + expect(fileName({ name: "ordinary" } as never)).toBe("host/ordinary.js"); + expect(fileName({ name: "runtime~\\0virtual_stage" } as never)).toBe( + "host/runtime~virtual_stage.js", + ); + expect(calls).toBe(2); + }); + + it("applies stage isolation to every server output but not the App SSR renderer", async () => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-stage-output-hooks-")); + try { + await fsp.mkdir(path.join(root, "app"), { recursive: true }); + await fsp.writeFile( + path.join(root, "app/page.tsx"), + "export default function Page() { return
page
; }\n", + ); + const plugins = vinext({ + appDir: root, + cache: { + cdn: { + adapter: "/adapter/cache.js", + output: { entry: path.join(root, "adapter-entry.ts"), type: "multi-stage" }, + }, + }, + }); + const configPlugin = plugins.find( + (plugin: any) => plugin.name === "vinext:config" && typeof plugin.config === "function", + ); + const outputPlugin = plugins.find( + (plugin: any) => plugin.name === "vinext:multi-stage-server-output", + ); + expect(configPlugin).toBeDefined(); + expect(outputPlugin).toBeDefined(); + await (configPlugin as any).config( + { build: {}, plugins: [], root }, + { command: "build", mode: "production" }, + ); + + const ssrContext = { + environment: { config: { build: { ssr: true } }, name: "ssr" }, + }; + expect( + await (outputPlugin as any).outputOptions.call(ssrContext, { + chunkFileNames: "ssr/[name].js", + }), + ).toBeUndefined(); + + const customClientContext = { + environment: { + config: { build: { ssr: true }, consumer: "client" }, + name: "browser-extension", + }, + }; + expect( + await (outputPlugin as any).outputOptions.call(customClientContext, { + chunkFileNames: "browser/[name].js", + }), + ).toBeUndefined(); + + const rscContext = { + environment: { config: { build: { ssr: true } }, name: "rsc" }, + }; + for (const directory of ["esm", "cjs"]) { + const hostGroup = { name: `${directory}-host`, test: /host/ }; + const output = await (outputPlugin as any).outputOptions.call(rscContext, { + chunkFileNames: `${directory}/[name].js`, + codeSplitting: { groups: [hostGroup] }, + entryFileNames: `${directory}/entry-[name].js`, + }); + expect(output.entryFileNames).toBe(`${directory}/entry-[name].js`); + expect(output.codeSplitting.groups).toContain(hostGroup); + expect(output.chunkFileNames({ name: "ordinary" })).toBe(`${directory}/ordinary.js`); + expect(output.chunkFileNames({ name: "app-router-entry" })).toBe( + "app-router-entry-[hash].js", + ); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("createMultiStageCodeSplittingConfig", () => { + it("keeps vinext stage chunks entry-aware without dropping host groups", () => { + const existing = { groups: [{ name: "host", test: /host/ }] }; + const config = createMultiStageCodeSplittingConfig(existing); + + const stageGroup = config.groups[0]; + expect(stageGroup).toMatchObject({ + entriesAware: true, + name: "vinext-stage-runtime", + }); + expect(stageGroup?.test).toBeInstanceOf(RegExp); + const test = stageGroup!.test as RegExp; + for (const id of [ + "/repo/packages/vinext/src/server/app-elements.ts", + "/repo/packages/vinext/dist/server/app-elements.js", + "/app/node_modules/vinext/dist/server/app-elements.js", + "/app/node_modules/.pnpm/vinext@1.0.0/node_modules/vinext/dist/server/app-elements.js", + ]) { + expect(test.test(id), id).toBe(true); + } + expect(test.test("/app/node_modules/not-vinext/dist/server/app-elements.js")).toBe(false); + expect(config.groups[1]).toBe(existing.groups[0]); + }); }); // ─── RSC framework package matching (single source of truth) ────────────────── diff --git a/tests/cache-adapters-config.test.ts b/tests/cache-adapters-config.test.ts index 95fcf76d14..fb2d7fd1a3 100644 --- a/tests/cache-adapters-config.test.ts +++ b/tests/cache-adapters-config.test.ts @@ -21,10 +21,15 @@ import { hasVerbatimResponseVary, VINEXT_CACHE_CONFIG_PLUGIN_PROPERTY, VIRTUAL_CACHE_ADAPTERS, + VIRTUAL_CDN_CACHE_ADAPTER, } from "../packages/vinext/src/cache/cache-adapters-virtual.js"; import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js"; import { generateServerEntry } from "../packages/vinext/src/entries/pages-server-entry.js"; -import { readAppRouterEntrySource, readPagesRouterEntrySource } from "./worker-entry-source.js"; +import { + readAppRequestStageEntrySource, + readAppRouterEntrySource, + readPagesRequestStageEntrySource, +} from "./worker-entry-source.js"; import { resolveNextConfig } from "../packages/vinext/src/config/next-config.js"; import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js"; import { kvDataAdapter } from "../packages/cloudflare/src/cache/kv-data-adapter.js"; @@ -41,6 +46,17 @@ describe("generateCacheAdaptersModule", () => { expect(VIRTUAL_CACHE_ADAPTERS).toBe("virtual:vinext-cache-adapters"); }); + it("emits a CDN-only registrar for request-stage graphs", () => { + expect(VIRTUAL_CDN_CACHE_ADAPTER).toBe("virtual:vinext-cdn-cache-adapter"); + const code = generateCdnCacheAdapterModule({ + cdn: { adapter: "my-cdn-adapter" }, + data: { adapter: "my-data-adapter" }, + }); + expect(code).toContain(`import __vinextCdnAdapterFactory from "my-cdn-adapter";`); + expect(code).not.toContain("my-data-adapter"); + expect(code).not.toContain("registerDataCacheHandler"); + }); + it("emits a no-op registrar when no adapters are configured", () => { for (const cache of [undefined, {}, { cdn: undefined, data: undefined }]) { const code = generateCacheAdaptersModule(cache); @@ -158,6 +174,20 @@ describe("findVinextCacheConfigInPlugins", () => { expect(await findVinextCacheConfigInPlugins(plugins)).toBe(cache); }); + it("preserves adapter-owned multi-stage output metadata", async () => { + const cache = { + cdn: { + adapter: "adapter", + output: { entry: "/adapter/worker.js", type: "multi-stage" as const }, + }, + }; + const plugins = [{ [VINEXT_CACHE_CONFIG_PLUGIN_PROPERTY]: cache }] as unknown as Parameters< + typeof findVinextCacheConfigInPlugins + >[0]; + + expect(await findVinextCacheConfigInPlugins(plugins)).toBe(cache); + }); + it("preserves promise-aware cache loading through the internal Vite wrapper", async () => { const cache = { data: { adapter: "adapter", options: { binding: "MY_KV" } } }; const vite = { @@ -282,7 +312,7 @@ describe("registration is wired into every router/runtime entry", () => { }); it("Pages Router worker entry registers with env", () => { - const code = readPagesRouterEntrySource(); + const code = readPagesRequestStageEntrySource(); const eagerCdnRegistration = "configuredCdnCacheAdapters.registerConfiguredCacheAdapters(env);"; const validateCdnRequest = "await validateCdnRequest(request)"; const lazyDataRegistration = code.match( @@ -297,6 +327,12 @@ describe("registration is wired into every router/runtime entry", () => { expect(lazyDataRegistration).toContain("adapters.registerConfiguredCacheAdapters(env);"); }); + it("App request stage cannot retain the configured data adapter module", () => { + const code = readAppRequestStageEntrySource(); + expect(code).toContain('from "virtual:vinext-cdn-cache-adapter"'); + expect(code).not.toContain('from "virtual:vinext-cache-adapters"'); + }); + it("App Router worker entry validates CDN routing after registering with env", () => { const code = readAppRouterEntrySource(); expect(code).toContain("registerConfiguredCacheAdapters(env"); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 04e32b71ba..130530c9c8 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -1693,6 +1693,8 @@ describe("readPagesRouterEntrySource", () => { expect(hasPackageExport(exportsMap, "./server/app-router-entry")).toBe(true); expect(hasPackageExport(exportsMap, "./server/app-rsc-combined-handler")).toBe(true); expect(hasPackageExport(exportsMap, "./server/pages-router-entry")).toBe(true); + expect(hasPackageExport(exportsMap, "./server/request-stage")).toBe(true); + expect(hasPackageExport(exportsMap, "./server/response-stage")).toBe(true); }); it("exports internal deploy dependencies consumed by @vinext/cloudflare", () => { diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index a3043431ab..107be05b77 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import fs from "node:fs"; import os from "node:os"; import vm from "node:vm"; +import { parseAst } from "vite"; import { describe, it, expect } from "vite-plus/test"; import { generateBrowserEntry, @@ -15,9 +16,17 @@ import { toLinkPrefetchRoutes, } from "../packages/vinext/src/entries/app-browser-entry.js"; import { buildAppRscManifestCode } from "../packages/vinext/src/entries/app-rsc-manifest.js"; -import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js"; +import { + generateAppRequestRscEntry, + generateAppResponseRscEntry, + generateRscEntry, +} from "../packages/vinext/src/entries/app-rsc-entry.js"; import { generateClientEntry } from "../packages/vinext/src/entries/pages-client-entry.js"; -import { generateServerEntry } from "../packages/vinext/src/entries/pages-server-entry.js"; +import { + generatePagesRequestEntry, + generatePagesResponseEntry, + generateServerEntry, +} from "../packages/vinext/src/entries/pages-server-entry.js"; import { resolveNextConfig } from "../packages/vinext/src/config/next-config.js"; import { buildAppRouteGraph } from "../packages/vinext/src/routing/app-route-graph.js"; import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js"; @@ -1111,6 +1120,92 @@ describe("App Router generated manifest construction", () => { // ── App Router entry template error paths ──────────────────────────── describe("App Router entry templates", () => { + it("generates a parseable module-free App request stage", () => { + const code = generateAppRequestRscEntry( + "/tmp/test/app", + minimalAppRoutes, + null, + [], + "/tmp/test/app/global-error.tsx", + "", + false, + { hasPagesDir: true }, + ); + + expect(() => parseAst(code)).not.toThrow(); + expect(code).not.toContain("/tmp/test/app/page.tsx"); + expect(code).not.toContain("/tmp/test/app/layout.tsx"); + expect(code).not.toContain("/tmp/test/app/global-error.tsx"); + expect(code).toContain( + 'import * as __pagesRequestEntry from "virtual:vinext-pages-request-entry"', + ); + expect(code).not.toContain("virtual:vinext-rsc-entry"); + expect(code).toContain( + 'import { createAppRscRequestHandler } from "vinext/server/app-rsc-handler"', + ); + expect(code).toContain('from "virtual:vinext-cdn-cache-adapter"'); + expect(code).not.toContain('from "virtual:vinext-cache-adapters"'); + expect(code).toContain('dispatchPagesResponseStage(stageRequest, "api")'); + expect(code).toContain( + 'dispatchPagesResponseStage(stageRequest, "page", dataKind, __pagesRequestEntry.hasRequestAwareDocument)', + ); + expect(code).toContain("buildId: process.env.__VINEXT_BUILD_ID ?? null"); + expect(code).toContain("return __dispatchAppRequestStage(request, ctx, dispatchResponseStage"); + expect(code).toContain("handleRequest: __requestHandler"); + expect(code).not.toContain('kind: "app-full-request"'); + expect(code).not.toContain("crypto.randomUUID()"); + expect(code).not.toContain('request.headers.get("upgrade")'); + expect(code).not.toContain("__usesFullRequestGraph"); + expect(code).not.toContain("|| __isMetadataPath(pathname)"); + }); + + it("preserves exact and generated metadata identities in the App request stage", () => { + const code = generateAppRequestRscEntry("/tmp/test/app", minimalAppRoutes, null, [ + { + type: "robots", + isDynamic: true, + filePath: "/tmp/test/app/robots.ts", + routePrefix: "", + routeSegments: [], + servedUrl: "/robots.txt", + contentType: "text/plain", + }, + { + type: "opengraph-image", + isDynamic: true, + filePath: "/tmp/test/app/blog/[slug]/opengraph-image.tsx", + routePrefix: "/blog/[slug]", + routeSegments: ["blog", "[slug]"], + servedUrl: "/blog/[slug]/opengraph-image", + contentType: "image/png", + }, + ]); + + expect(() => parseAst(code)).not.toThrow(); + expect(code).toContain('"patternParts":null,"servedUrl":"/robots.txt"'); + expect(code).toContain('"patternParts":["blog",":slug","opengraph-image"]'); + }); + + it("generates an App response graph without request handling or middleware", () => { + const code = generateAppResponseRscEntry( + "/tmp/test/app", + minimalAppRoutes, + "/tmp/test/middleware.ts", + [], + null, + "", + false, + ); + + expect(() => parseAst(code)).not.toThrow(); + expect(code).not.toContain("/tmp/test/middleware.ts"); + expect(code).not.toContain("createAppRscHandler"); + expect(code).toContain('import "virtual:vinext-pregenerated-concrete-paths";'); + expect(code).toContain("renderAppWorkerResponseStage as __renderAppWorkerResponseStage"); + expect(code).toContain("const __responseStageOptions = {"); + expect(code).toContain("__renderAppWorkerResponseStage(__responseStageOptions"); + }); + it("promotes interception-only RSC targets before not-found dispatch", () => { const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false); @@ -1231,7 +1326,7 @@ describe("App Router entry templates", () => { const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false); expect(code).toMatch( - /import \{ createAppRscHandler \} from ".*\/server\/app-rsc-combined-handler\.js";/, + /import \{ createAppRscHandler \} from "[^"]*app-rsc-combined-handler\.[jt]s";/, ); expect(code).toContain("const __appRscHandler = createAppRscHandler({"); expect(code).toContain("export default __appRscHandler;"); @@ -1713,12 +1808,106 @@ describe("Pages Router entry template", () => { expect(code).toContain('dataKind: "server"'); expect(code).toContain('pattern: "/plain",'); expect(code).toContain('dataKind: "none"'); + expect(code).toContain("return __getRuntimePagesDataKind(match.route.module, AppComponent);"); expect(code).not.toContain("typeof page_"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + it("keeps user page and API modules out of the Pages request-stage entry", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-request-stage-")); + const pagesDir = path.join(tmpDir, "pages"); + const middlewarePath = path.join(tmpDir, "middleware.ts"); + const instrumentationPath = path.join(tmpDir, "instrumentation.ts"); + + try { + fs.mkdirSync(path.join(pagesDir, "api"), { recursive: true }); + const pagePath = path.join(pagesDir, "index.tsx"); + const apiPath = path.join(pagesDir, "api", "hello.ts"); + const documentPath = path.join(pagesDir, "_document.tsx"); + fs.writeFileSync( + pagePath, + "export function getStaticProps() { return { props: {} }; } export default function Page() { return null; }", + ); + fs.writeFileSync(apiPath, "export default function handler() {};"); + // Next.js exposes req/res to custom Document getInitialProps for SSG + // renders because getStaticProps pages are not automatic exports. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/render.tsx + fs.writeFileSync( + documentPath, + "const Document = Object.assign(() => null, { getInitialProps: async () => ({ html: '' }) }); export default Document;", + ); + fs.writeFileSync(middlewarePath, "export function middleware() {};"); + fs.writeFileSync(instrumentationPath, "export function register() {};"); + + const code = await generatePagesRequestEntry( + pagesDir, + await resolveNextConfig({ generateBuildId: () => "split-build" }), + createValidFileMatcher(), + middlewarePath, + instrumentationPath, + ["/public.txt"], + ); + + expect(code).toContain('export const buildId = "split-build"'); + expect(code).toContain("export const hasRequestAwareDocument = true"); + expect(code).toContain('dataKind: "static"'); + expect(code).toContain('pattern: "/api/hello"'); + expect(code).toContain("export function matchApiRoute(url, request)"); + expect(code).toContain('export const publicFiles = new Set(["/public.txt"])'); + expect(code).toContain(JSON.stringify(middlewarePath)); + expect(code).not.toContain(JSON.stringify(pagePath)); + expect(code).not.toContain(JSON.stringify(apiPath)); + expect(code).not.toContain(JSON.stringify(documentPath)); + expect(code).not.toContain("react-dom/server.edge"); + expect(code).not.toContain("createPagesPageHandler"); + expect(code).not.toContain("handlePagesApiRoute"); + expect(code).toContain("await __ensureInstrumentationRegistered(_instrumentation)"); + expect(code).not.toContain("await _instrumentation.register()"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("keeps middleware out of the Pages response-stage entry", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-response-stage-")); + const pagesDir = path.join(tmpDir, "pages"); + const middlewarePath = path.join(tmpDir, "middleware.ts"); + const instrumentationPath = path.join(tmpDir, "instrumentation.ts"); + + try { + fs.mkdirSync(path.join(pagesDir, "api"), { recursive: true }); + const pagePath = path.join(pagesDir, "index.tsx"); + const apiPath = path.join(pagesDir, "api", "hello.ts"); + fs.writeFileSync(pagePath, "export default function Page() { return null; }"); + fs.writeFileSync(apiPath, "export default function handler() {};"); + fs.writeFileSync(middlewarePath, "throw new Error('middleware-canary');"); + fs.writeFileSync(instrumentationPath, "export function register() {};"); + + const code = await generatePagesResponseEntry( + pagesDir, + await resolveNextConfig({ generateBuildId: () => "split-build" }), + createValidFileMatcher(), + middlewarePath, + instrumentationPath, + ); + + expect(code).toContain(JSON.stringify(pagePath)); + expect(code).toContain(JSON.stringify(apiPath)); + expect(code).toContain("createPagesPageHandler"); + expect(code).toContain("handlePagesApiRoute"); + expect(code).toContain("export const hasMiddleware = true"); + expect(code).toContain("await __ensureInstrumentationRegistered(_instrumentation)"); + expect(code).not.toContain("await _instrumentation.register()"); + expect(code).not.toContain(JSON.stringify(middlewarePath)); + expect(code).not.toContain("runGeneratedMiddleware"); + expect(code).not.toContain("export async function runMiddleware"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + // Ported from Next.js: test/e2e/no-page-props/no-page-props.test.ts // https://github.com/vercel/next.js/blob/v16.3.0-canary.80/test/e2e/no-page-props/no-page-props.test.ts it("uses the framework error page in server and client entries when _error is absent", async () => { diff --git a/tests/fetch-handler.test.ts b/tests/fetch-handler.test.ts index b0c4a4c197..f5ea211f36 100644 --- a/tests/fetch-handler.test.ts +++ b/tests/fetch-handler.test.ts @@ -3,21 +3,32 @@ import os from "node:os"; import path from "node:path"; import { createServer, type ViteDevServer } from "vite-plus"; import { describe, expect, it } from "vite-plus/test"; -import vinext from "../packages/vinext/src/index.js"; +import { resolveRuntimeEntryModule } from "../packages/vinext/src/entries/runtime-entry-module.js"; +import vinext, { type VinextOptions } from "../packages/vinext/src/index.js"; -async function loadUnifiedFetchHandler(root: string): Promise { +async function loadVirtualModule( + root: string, + id: string, + options: { + cache?: VinextOptions["cache"]; + hostPluginName?: string; + } = {}, +): Promise { let server: ViteDevServer | undefined; try { server = await createServer({ root, configFile: false, - plugins: [vinext()], + plugins: [ + vinext({ cache: options.cache }), + ...(options.hostPluginName ? [{ name: options.hostPluginName }] : []), + ], server: { port: 0 }, logLevel: "silent", }); - const resolved = await server.pluginContainer.resolveId("virtual:vinext-worker-entry"); - expect(resolved?.id).toBe("\0virtual:vinext-worker-entry"); + const resolved = await server.pluginContainer.resolveId(id); + expect(resolved?.id).toBe(`\0${id}`); const loaded = await server.pluginContainer.load(resolved!.id); return typeof loaded === "string" ? loaded : ((loaded as { code?: string })?.code ?? ""); @@ -26,7 +37,14 @@ async function loadUnifiedFetchHandler(root: string): Promise { } } -describe("unified Cloudflare fetch handler", () => { +function loadUnifiedFetchHandler( + root: string, + options: Parameters[2] = {}, +): Promise { + return loadVirtualModule(root, "virtual:vinext-worker-entry", options); +} + +describe("unified Worker fetch handler", () => { it("delegates App Router apps to the App Router worker entry", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-fetch-handler-app-")); try { @@ -60,4 +78,117 @@ describe("unified Cloudflare fetch handler", () => { fs.rmSync(root, { recursive: true, force: true }); } }); + + it("lets a compatible adapter select a transport-neutral multi-stage facade", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-fetch-handler-stages-")); + try { + fs.mkdirSync(path.join(root, "app"), { recursive: true }); + fs.writeFileSync( + path.join(root, "app/page.tsx"), + "export default function Page() { return
app
; }\n", + ); + const entry = "/adapter/stage-gateway.js"; + const cache: VinextOptions["cache"] = { + cdn: { + adapter: "/adapter/cache.js", + output: { + entry, + matchesBuild: ({ plugins }) => + plugins.some(({ name }) => name === "independent-stage-host"), + type: "multi-stage", + }, + }, + }; + + await expect( + loadUnifiedFetchHandler(root, { cache, hostPluginName: "independent-stage-host" }), + ).resolves.toBe( + [ + `export { default } from ${JSON.stringify(entry)};`, + `export * from ${JSON.stringify(entry)};`, + "", + ].join("\n"), + ); + await expect(loadUnifiedFetchHandler(root, { cache })).resolves.toBe( + 'export { default } from "vinext/server/app-router-entry";', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.each(["app-router-entry", "pages-router-entry"])( + "routes a direct %s main through the selected facade", + async (entryName) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-direct-router-stages-")); + let server: ViteDevServer | undefined; + try { + fs.mkdirSync(path.join(root, "app"), { recursive: true }); + fs.writeFileSync( + path.join(root, "app/page.tsx"), + "export default function Page() { return
app
; }\n", + ); + server = await createServer({ + root, + configFile: false, + plugins: [ + vinext({ + cache: { + cdn: { + adapter: "/adapter/cache.js", + output: { entry: "/adapter/stage-gateway.js", type: "multi-stage" }, + }, + }, + }), + ], + server: { port: 0 }, + logLevel: "silent", + }); + + await expect( + server.pluginContainer.resolveId(`vinext/server/${entryName}`), + ).resolves.toMatchObject({ id: "\0virtual:vinext-worker-entry" }); + } finally { + await server?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.each([ + [ + "App", + "app", + "app/page.tsx", + "export default function Page() { return
app
; }\n", + "app-request-stage-independent-entry", + "app-response-stage-entry", + ], + [ + "Pages", + "pages", + "pages/index.tsx", + "export default function Page() { return
pages
; }\n", + "pages-request-stage-entry", + "pages-response-stage-entry", + ], + ])( + "exposes %s request and response stages as independent virtual entries", + async (_router, directory, file, source, requestEntry, responseEntry) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `vinext-${directory}-stages-`)); + try { + fs.mkdirSync(path.join(root, directory), { recursive: true }); + fs.writeFileSync(path.join(root, file), source); + + await expect(loadVirtualModule(root, "virtual:vinext-request-stage")).resolves.toBe( + `export { handleRequestStage } from ${JSON.stringify(resolveRuntimeEntryModule(requestEntry))};\n`, + ); + await expect(loadVirtualModule(root, "virtual:vinext-response-stage")).resolves.toBe( + `export { handleResponseStage } from ${JSON.stringify(resolveRuntimeEntryModule(responseEntry))};\n`, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); }); diff --git a/tests/worker-entry-source.ts b/tests/worker-entry-source.ts index f3fa809d22..aa1043d9ce 100644 --- a/tests/worker-entry-source.ts +++ b/tests/worker-entry-source.ts @@ -9,6 +9,21 @@ export function readAppRouterEntrySource(): string { ); } +export function readAppRequestStageEntrySource(): string { + const sourceUrl = new URL( + "../packages/vinext/src/server/app-request-stage-independent-entry.ts", + import.meta.url, + ); + if (fs.existsSync(sourceUrl)) return fs.readFileSync(sourceUrl, "utf-8"); + return fs.readFileSync( + new URL( + "../packages/vinext/src/server/app-request-stage-independent-entry.js", + import.meta.url, + ), + "utf-8", + ); +} + export function readPagesRouterEntrySource(): string { return readPagesRequestStageEntrySource(); } @@ -22,7 +37,7 @@ export function readPagesSingleEntrySource(): string { ); } -function readPagesRequestStageEntrySource(): string { +export function readPagesRequestStageEntrySource(): string { const sourceUrl = new URL( "../packages/vinext/src/server/pages-request-stage-entry.ts", import.meta.url,