diff --git a/packages/vinext/src/build/inject-pregenerated-paths.ts b/packages/vinext/src/build/inject-pregenerated-paths.ts index 4ee33450f1..fde2830ceb 100644 --- a/packages/vinext/src/build/inject-pregenerated-paths.ts +++ b/packages/vinext/src/build/inject-pregenerated-paths.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "pathslash"; import { readPrerenderManifest } from "../server/prerender-manifest.js"; import { PREGENERATED_CONCRETE_PATHS_MODULE } from "../server/pregenerated-concrete-paths.js"; +import { readServerRuntimeOutputDirs } from "./server-manifest.js"; declare global { var __VINEXT_PREGENERATED_CONCRETE_PATHS: unknown; @@ -30,6 +31,7 @@ export function injectPregeneratedConcretePaths( serverOutputDir, path.dirname(applicationEntry), ...additionalRuntimeDirs, + ...readServerRuntimeOutputDirs(serverOutputDir, root), ])) { fs.mkdirSync(outputDir, { recursive: true }); fs.writeFileSync(path.join(outputDir, PREGENERATED_CONCRETE_PATHS_MODULE), runtimeModuleCode); diff --git a/packages/vinext/src/build/server-manifest.ts b/packages/vinext/src/build/server-manifest.ts index 4f9e5f4048..60566520b6 100644 --- a/packages/vinext/src/build/server-manifest.ts +++ b/packages/vinext/src/build/server-manifest.ts @@ -21,3 +21,17 @@ export function readPrerenderSecret(serverDir: string): string | undefined { const manifest = readJsonFile<{ prerenderSecret?: string }>(manifestPath); return manifest?.prerenderSecret; } + +/** + * Read every server output root that contains a deployable response-stage + * graph. Paths are stored relative to the project root so build artifacts stay + * relocatable, then resolved for post-build sidecar updates. + */ +export function readServerRuntimeOutputDirs(serverDir: string, root: string): string[] { + const manifestPath = path.join(serverDir, "vinext-server.json"); + const manifest = readJsonFile<{ runtimeOutputDirs?: unknown }>(manifestPath); + if (!Array.isArray(manifest?.runtimeOutputDirs)) return []; + return manifest.runtimeOutputDirs + .filter((outputDir): outputDir is string => typeof outputDir === "string") + .map((outputDir) => path.resolve(root, outputDir)); +} diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 3985f9965c..a393417208 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -228,6 +228,7 @@ import { takePagesClientAssetsBuildMetadata, writePagesClientAssetsModuleIfMissing, } from "./build/pages-client-assets-module.js"; +import { readPrerenderSecret, readServerRuntimeOutputDirs } from "./build/server-manifest.js"; import { createPreviewBuildCredentials, getPreviewBuildCredentials, @@ -1549,6 +1550,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }); const pagesClientAssetsOutputDirs = new Set(); + const resolvePagesClientAssetsOutputDir = (environmentName: string, outputDir: string): string => + !selectedMultiStageOutput && !hasAppDir && environmentName === "ssr" + ? path.dirname(outputDir) + : outputDir; let pagesClientAssetsModule: string | null = null; // Dev-only public route inventory. Vite's watcher keeps this synchronized, // so request handling can use O(1) membership checks without filesystem I/O. @@ -1579,6 +1584,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let rscActionOwnerRoutes: Awaited> | null = null; let rscActionOwnerSharedRoots: string[] = []; const serverEntryKindsByEnvironment = new Map>(); + const serverRuntimeOutputDirs = new Set(); function recordServerEntryLoad(environmentName: string | undefined, id: string): void { if (!environmentName) return; @@ -4554,10 +4560,10 @@ export const loadServerActionClient = ${ const buildRoot = this.environment.config.root ?? process.cwd(); const environmentOutDir = path.resolve(buildRoot, this.environment.config.build.outDir); - const sidecarDir = - !hasAppDir && this.environment.name === "ssr" - ? path.dirname(environmentOutDir) - : environmentOutDir; + const sidecarDir = resolvePagesClientAssetsOutputDir( + this.environment.name, + environmentOutDir, + ); let externalId = path.relative( environmentOutDir, path.join(sidecarDir, PAGES_CLIENT_ASSETS_MODULE), @@ -4567,6 +4573,22 @@ export const loadServerActionClient = ${ return { id: externalId, external: true }; }, }, + + outputOptions(output) { + const environment = this.environment; + if ( + !selectedMultiStageOutput || + !environment || + !isMultiStageServerEnvironment(environment) + ) { + return; + } + const buildRoot = environment.config.root ?? process.cwd(); + const outputDir = path.resolve(buildRoot, output.dir ?? environment.config.build.outDir); + pagesClientAssetsOutputDirs.add( + resolvePagesClientAssetsOutputDir(environment.name, outputDir), + ); + }, }, // CSS url() asset parity with Next.js. Build-only: dev CSS is untouched. // Apply the transient marker in every environment so CSS Modules receives @@ -4613,9 +4635,26 @@ export const loadServerActionClient = ${ 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. + // Vite resolves build.ssr=true for every server environment. The App + // Router's named `ssr` environment is still only its HTML renderer; it + // must never receive deployable request/response stage entries. + // Standalone `vite build --ssr` uses the sole `client` environment. + // Pages and adapter-owned server environments retain their own names. + buildStart() { + const entries = selectedMultiStageOutput?.entries; + const environment = this.environment; + if (!entries || !environment || !isMultiStageServerEnvironment(environment)) { + return; + } + + for (const [name, id] of [ + ["vinext-request-stage", entries.request], + ["vinext-response-stage", entries.response], + ] as const) { + this.emitFile({ id, name, preserveSignature: "strict", type: "chunk" }); + } + }, + outputOptions(output) { const environment = this.environment; if ( @@ -4625,6 +4664,16 @@ export const loadServerActionClient = ${ ) { return; } + if ( + output.format !== undefined && + output.format !== "es" && + output.format !== "esm" && + output.format !== "module" + ) { + throw new Error( + `[vinext] Multi-stage output requires an ES module format; ${JSON.stringify(output.format)} cannot represent independently deployable request and response entries.`, + ); + } return { ...output, chunkFileNames: createMultiStageChunkFileNames( @@ -7091,15 +7140,35 @@ export const loadServerActionClient = ${ const outDir = options.dir; if (!outDir) return; - const manifest = { prerenderSecret }; - const source = JSON.stringify(manifest); - fs.writeFileSync(path.join(outDir, "vinext-server.json"), source); - // Post-build discovery deliberately reads metadata from the // platform-independent server directory. An adapter may emit either - // router's executable graph elsewhere, so retain the adjacent copy - // above and also publish the canonical copy. + // router's executable graph elsewhere, so retain an adjacent copy + // and also publish the canonical copy. const canonicalServerDir = path.join(root, "dist", "server"); + if (selectedMultiStageOutput) { + if (readPrerenderSecret(canonicalServerDir) === prerenderSecret) { + for (const runtimeOutputDir of readServerRuntimeOutputDirs( + canonicalServerDir, + root, + )) { + serverRuntimeOutputDirs.add(runtimeOutputDir); + } + } + serverRuntimeOutputDirs.add(path.resolve(outDir)); + } + const manifest = { + prerenderSecret, + ...(serverRuntimeOutputDirs.size === 0 + ? {} + : { + runtimeOutputDirs: [...serverRuntimeOutputDirs] + .map((runtimeOutputDir) => path.relative(root, runtimeOutputDir) || ".") + .sort(), + }), + }; + const source = JSON.stringify(manifest); + fs.writeFileSync(path.join(outDir, "vinext-server.json"), source); + if (path.resolve(outDir) !== canonicalServerDir) { fs.mkdirSync(canonicalServerDir, { recursive: true }); fs.writeFileSync(path.join(canonicalServerDir, "vinext-server.json"), source); diff --git a/packages/vinext/src/plugins/action-owner-manifest.ts b/packages/vinext/src/plugins/action-owner-manifest.ts index ebbdf9be23..8f4e0ae2cd 100644 --- a/packages/vinext/src/plugins/action-owner-manifest.ts +++ b/packages/vinext/src/plugins/action-owner-manifest.ts @@ -36,6 +36,7 @@ export function createActionOwnerManifestPlugin(options: { let config: ResolvedConfig; let routeReachability: ActionOwnerRouteReachability = new Map(); let manifest: Record = {}; + const rscOutputDirs = new Set(); return { name: "vinext:action-owner-manifest", @@ -51,6 +52,11 @@ export function createActionOwnerManifestPlugin(options: { load(id) { if (id === RESOLVED_ACTION_OWNER_MANIFEST_ID) return "export default null"; }, + outputOptions(output) { + if (this.environment.name === "rsc" && output.dir) { + rscOutputDirs.add(output.dir); + } + }, async generateBundle() { const manager = await options.getManager(config); if (!manager) { @@ -95,14 +101,21 @@ export function createActionOwnerManifestPlugin(options: { buildApp: { order: "post", async handler(builder) { - const outDir = builder.config.environments.rsc.build.outDir; - await fs.promises.mkdir(outDir, { recursive: true }); - await fs.promises.writeFile( - path.join(outDir, ACTION_OWNER_MANIFEST_FILE), - `export default ${safeJsonStringify(manifest)};\n`, + if (rscOutputDirs.size === 0) { + rscOutputDirs.add(builder.config.environments.rsc.build.outDir); + } + await Promise.all( + [...rscOutputDirs].map(async (outDir) => { + await fs.promises.mkdir(outDir, { recursive: true }); + await fs.promises.writeFile( + path.join(outDir, ACTION_OWNER_MANIFEST_FILE), + `export default ${safeJsonStringify(manifest)};\n`, + ); + }), ); routeReachability = new Map(); manifest = {}; + rscOutputDirs.clear(); options.onComplete?.(); }, }, diff --git a/packages/vinext/src/server/app-rsc-vary.ts b/packages/vinext/src/server/app-rsc-vary.ts new file mode 100644 index 0000000000..40ece3966e --- /dev/null +++ b/packages/vinext/src/server/app-rsc-vary.ts @@ -0,0 +1,33 @@ +/** Request selectors that define a reusable full-route RSC response variant. */ +export const RSC_HEADER = "RSC"; +export const NEXT_ROUTER_STATE_TREE_HEADER = "Next-Router-State-Tree"; +export const NEXT_ROUTER_PREFETCH_HEADER = "Next-Router-Prefetch"; +export const NEXT_ROUTER_SEGMENT_PREFETCH_HEADER = "Next-Router-Segment-Prefetch"; +export const NEXT_URL_HEADER = "Next-Url"; +export const VINEXT_INTERCEPTION_CONTEXT_HEADER = "X-Vinext-Interception-Context"; +export const VINEXT_INTERCEPTION_ID_HEADER = "X-Vinext-Interception-Id"; +export const VINEXT_MOUNTED_SLOTS_HEADER = "X-Vinext-Mounted-Slots"; +export const VINEXT_RSC_RENDER_MODE_HEADER = "X-Vinext-Rsc-Render-Mode"; +export const VINEXT_RSC_STATE_FINGERPRINT_HEADER = "X-Vinext-Rsc-State-Fingerprint"; + +export const VINEXT_RSC_VARY_HEADER = [ + RSC_HEADER, + NEXT_ROUTER_STATE_TREE_HEADER, + NEXT_ROUTER_PREFETCH_HEADER, + NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, + NEXT_URL_HEADER, + VINEXT_INTERCEPTION_CONTEXT_HEADER, + VINEXT_INTERCEPTION_ID_HEADER, + VINEXT_MOUNTED_SLOTS_HEADER, + VINEXT_RSC_RENDER_MODE_HEADER, + VINEXT_RSC_STATE_FINGERPRINT_HEADER, +].join(", "); + +const VINEXT_RSC_VARY_FIELDS = new Set( + VINEXT_RSC_VARY_HEADER.split(",").map((name) => name.trim().toLowerCase()), +); + +/** Whether a normalized response `Vary` field is a framework RSC selector. */ +export function isVinextRscVaryField(name: string): boolean { + return VINEXT_RSC_VARY_FIELDS.has(name.trim().toLowerCase()); +} diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index bb4decefcc..cb0f86d278 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -17,8 +17,8 @@ import { VINEXT_CACHEABILITY_PROBE_HEADER, VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER, VINEXT_PRERENDER_SECRET_HEADER, - VINEXT_RSC_VARY_HEADER, } from "./headers.js"; +import { isVinextRscVaryField } from "./app-rsc-vary.js"; import { workerCapabilityMatches } from "./worker-prerender-discovery.js"; import { CACHEABILITY_ADMISSION_ISOLATE_BODY_LIMIT, @@ -61,10 +61,6 @@ type CacheabilityProbeResult = { version: 1; }; -const FRAMEWORK_CACHEABILITY_VARY_FIELDS = new Set( - VINEXT_RSC_VARY_HEADER.split(",").map((name) => name.trim().toLowerCase()), -); - function cacheabilityVaryRejectionReason( headers: Headers, state: RouteCacheabilityState, @@ -75,7 +71,7 @@ function cacheabilityVaryRejectionReason( .filter(Boolean); if (fields.includes("*")) return "response uses Vary: *"; if (state.responseVary === "verbatim") return null; - return fields.some((name) => !FRAMEWORK_CACHEABILITY_VARY_FIELDS.has(name)) + return fields.some((name) => !isVinextRscVaryField(name)) ? "response cache does not support custom Vary fields" : null; } diff --git a/packages/vinext/src/server/headers.ts b/packages/vinext/src/server/headers.ts index f9e91bbbe3..abd7e86774 100644 --- a/packages/vinext/src/server/headers.ts +++ b/packages/vinext/src/server/headers.ts @@ -84,9 +84,6 @@ export const VINEXT_RSC_MARKER_HEADER = "x-vinext-rsc"; /** URL-encoded JSON route params carried on RSC responses. */ export const VINEXT_PARAMS_HEADER = "X-Vinext-Params"; -/** Deduplicated, sorted list of mounted layout slots for cache keying. */ -export const VINEXT_MOUNTED_SLOTS_HEADER = "X-Vinext-Mounted-Slots"; - /** Per-page dynamic stale time in seconds for App Router RSC responses. */ export const VINEXT_DYNAMIC_STALE_TIME_HEADER = "X-Vinext-Dynamic-Stale-Time"; @@ -105,18 +102,6 @@ export const VINEXT_PRERENDER_RENDER_ERROR_HEADER = "x-vinext-prerender-render-e /** Internal marker persisted only inside metadata-route APP_ROUTE cache values. */ export const VINEXT_METADATA_ROUTE_CACHE_HEADER = "x-vinext-metadata-route-cache"; -/** Route interception context for parallel/intercepting routes. */ -export const VINEXT_INTERCEPTION_CONTEXT_HEADER = "X-Vinext-Interception-Context"; - -/** Exact interception declaration requested by supplemental refreshes. */ -export const VINEXT_INTERCEPTION_ID_HEADER = "X-Vinext-Interception-Id"; - -/** RSC render mode (e.g. "navigation", "prefetch"). */ -export const VINEXT_RSC_RENDER_MODE_HEADER = "X-Vinext-Rsc-Render-Mode"; - -/** Stable visible-router-state variant for RSC cache busting. */ -export const VINEXT_RSC_STATE_FINGERPRINT_HEADER = "X-Vinext-Rsc-State-Fingerprint"; - /** Disabled-by-default client hint describing already-held App Router payload entries. */ export const VINEXT_CLIENT_REUSE_MANIFEST_HEADER = "X-Vinext-Client-Reuse-Manifest"; @@ -141,9 +126,6 @@ export const VINEXT_RSC_REDIRECT_TYPE_HEADER = "X-Vinext-Rsc-Redirect-Type"; // RSC protocol headers // --------------------------------------------------------------------------- -/** Standard RSC header — value "1" indicates an RSC payload request. */ -export const RSC_HEADER = "RSC"; - /** Server Action invocation header (vinext/vite-rsc protocol). */ export const RSC_ACTION_HEADER = "x-rsc-action"; @@ -221,24 +203,21 @@ const MIDDLEWARE_REDIRECT_HEADER = "x-middleware-redirect"; // Next.js / RSC flight headers (forwarded through middleware) // --------------------------------------------------------------------------- -export const NEXT_ROUTER_STATE_TREE_HEADER = "Next-Router-State-Tree"; -export const NEXT_ROUTER_PREFETCH_HEADER = "Next-Router-Prefetch"; -export const NEXT_ROUTER_SEGMENT_PREFETCH_HEADER = "Next-Router-Segment-Prefetch"; -export const NEXT_URL_HEADER = "Next-Url"; - -/** Request selectors that define a reusable full-route RSC response variant. */ -export const VINEXT_RSC_VARY_HEADER = [ - RSC_HEADER, - NEXT_ROUTER_STATE_TREE_HEADER, +export { NEXT_ROUTER_PREFETCH_HEADER, NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, + NEXT_ROUTER_STATE_TREE_HEADER, NEXT_URL_HEADER, + RSC_HEADER, VINEXT_INTERCEPTION_CONTEXT_HEADER, VINEXT_INTERCEPTION_ID_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, VINEXT_RSC_RENDER_MODE_HEADER, VINEXT_RSC_STATE_FINGERPRINT_HEADER, -].join(", "); + VINEXT_RSC_VARY_HEADER, + isVinextRscVaryField, +} from "./app-rsc-vary.js"; + export const NEXT_REQUEST_ID_HEADER = "x-nextjs-request-id"; export const NEXT_HTML_REQUEST_ID_HEADER = "x-nextjs-html-request-id"; diff --git a/packages/vinext/src/server/multi-stage.ts b/packages/vinext/src/server/multi-stage.ts index 3d5ff93735..7bd23c4c10 100644 --- a/packages/vinext/src/server/multi-stage.ts +++ b/packages/vinext/src/server/multi-stage.ts @@ -1,5 +1,20 @@ import type { CacheabilityRepresentation } from "./cacheability-manifest.js"; +/** Whether a response-stage cache must reject a field it did not key itself by. */ +export function hasUnsupportedResponseStageVary( + headers: Headers, + keyedRequestHeaders: Iterable = [], +): boolean { + const keyedFields = new Set( + [...keyedRequestHeaders].map((name) => name.trim().toLowerCase()).filter(Boolean), + ); + return (headers.get("Vary") ?? "") + .split(",") + .map((name) => name.trim().toLowerCase()) + .filter(Boolean) + .some((name) => name === "*" || !keyedFields.has(name)); +} + /** * Transport-neutral cache intent passed from the request stage to an * adapter-owned response-stage transport. @@ -11,9 +26,12 @@ export type VinextResponseStageDispatchOptions = { * A shared transport must partition its baseline lookup by the request * method, complete request URL (including scheme, authority, exact path, and * query), plus the complete serialized stage props; each can affect handler - * selection or response bytes. It must additionally honor every response - * `Vary` field when storing and reusing a response, using the corresponding - * request-header values as variant identity. `Vary: *` must not be stored. + * selection or response bytes. Framework-managed selectors are already + * represented by that URL and the + * serialized props. A verbatim-capable transport must partition stored + * variants by every named `Vary` request header and never store `Vary: *`. + * Other transports must reject application-defined variance themselves or + * opt into completed-response admission and honor core's `no-store` policy. */ cache: "shared" | "bypass"; }; @@ -42,7 +60,16 @@ export type VinextResponseStageCacheability = { * query), plus the complete serialized props. An adapter that advertises * `responseVary: "verbatim"` must also partition stored variants by every * request header named in the returned `Vary` fields and reject `Vary: *` from - * storage. Adapters without that capability must not share varying responses. + * storage. Adapters without that capability must reject application-defined + * variance themselves, or opt into completed-response admission and honor + * core's resulting `no-store` policy. + * + * The transport is also the trust boundary for this metadata. It must + * authenticate both directions and integrity-protect the serialized props, + * dispatch options, and response so public callers cannot forge trusted route + * or cache-admission state. Platform bindings can provide that boundary + * directly; HTTP transports need equivalent authenticated, confidential + * transport for both request-stage and response-stage endpoints. */ export type VinextResponseStageTransport = ( request: Request, @@ -54,6 +81,11 @@ export type VinextResponseStageTransport = ( export type VinextMultiStageOutput = { /** Adapter-owned module that becomes the deployment entry when selected. */ entry: string; + /** Complete adapter-owned host entries for independently deployable stages. */ + entries?: { + request: string; + response: string; + }; type: "multi-stage"; /** Decide whether the current build host supports this adapter's transport. */ matchesBuild?: (build: { plugins: readonly { name?: string }[] }) => boolean; diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index a4db6f4d93..0c3bebe4e3 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -8,7 +8,8 @@ import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test"; +import { pathToFileURL } from "node:url"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test"; import { createBuilder, parseAst } from "vite"; import { augmentSsrManifestFromBundle as _augmentSsrManifestFromBundle } from "../packages/vinext/src/build/ssr-manifest.js"; import { @@ -38,6 +39,7 @@ import { computeClientRuntimeMetadata } from "../packages/vinext/src/utils/clien import { manifestFileWithBase } from "../packages/vinext/src/utils/manifest-paths.js"; import { asyncHooksStubPlugin as _asyncHooksStubPlugin } from "../packages/vinext/src/plugins/async-hooks-stub.js"; import { aliasEntriesToRecord } from "./helpers.js"; +import { injectPregeneratedConcretePaths } from "../packages/vinext/src/build/inject-pregenerated-paths.js"; // `stripServerExports` returns `{ code, map }`; these tests assert on the // transformed source, so unwrap to the code string (null is preserved). @@ -3981,21 +3983,476 @@ describe("createMultiStageChunkFileNames", () => { expect(calls).toBe(2); }); + it("emits independent stages without renaming resolved host inputs", async () => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + + for (const hostInput of ["virtual:host-main", ["virtual:host-main", "virtual:host-admin"]]) { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-http-stage-inputs-")); + try { + await fsp.symlink( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(root, "node_modules"), + "junction", + ); + await fsp.mkdir(path.join(root, "src"), { recursive: true }); + const files = { + "virtual:host-admin": path.join(root, "src/admin.ts"), + "virtual:host-main": path.join(root, "src/worker.ts"), + }; + await Promise.all([ + fsp.writeFile(files["virtual:host-main"], 'export const host = "main";\n'), + fsp.writeFile(files["virtual:host-admin"], 'export const host = "admin";\n'), + fsp.writeFile(path.join(root, "src/request-stage.ts"), "export default {};\n"), + fsp.writeFile(path.join(root, "src/response-stage.ts"), "export default {};\n"), + ]); + const entries = { + request: path.join(root, "src/request-stage.ts"), + response: path.join(root, "src/response-stage.ts"), + }; + const builder = await createBuilder({ + root, + configFile: false, + plugins: [ + { + name: "resolve-host-inputs", + enforce: "pre", + resolveId(id) { + return files[id as keyof typeof files]; + }, + }, + vinext({ + disableAppRouter: true, + cache: { + cdn: { + adapter: "/adapter/cache.js", + output: { entries, entry: entries.request, type: "multi-stage" }, + }, + }, + }), + ], + build: { + outDir: "dist", + rolldownOptions: { + input: hostInput, + output: { entryFileNames: "[name].js" }, + }, + ssr: true, + }, + logLevel: "silent", + }); + + await builder.buildApp(); + + const outputFiles = await fsp.readdir(path.join(root, "dist"), { recursive: true }); + const outputBasenames = outputFiles.map((file) => path.basename(file)); + expect(outputBasenames).toContain("worker.js"); + expect(outputBasenames.includes("admin.js")).toBe(Array.isArray(hostInput)); + expect( + outputBasenames.some((file) => /^vinext-request-stage-.+\.[cm]?js$/.test(file)), + outputBasenames.join("\n"), + ).toBe(true); + expect( + outputBasenames.some((file) => /^vinext-response-stage-.+\.[cm]?js$/.test(file)), + ).toBe(true); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + } + }, 30_000); + + it.each(["cjs", "commonjs", "iife", "umd"] as const)( + "rejects %s output before emitting independent stages", + async (format) => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-http-stage-format-")); + try { + await fsp.mkdir(path.join(root, "src"), { recursive: true }); + const hostEntry = path.join(root, "src/worker.ts"); + const entries = { + request: path.join(root, "src/request-stage.ts"), + response: path.join(root, "src/response-stage.ts"), + }; + await Promise.all([ + fsp.writeFile(hostEntry, 'export const host = "main";\n'), + fsp.writeFile(entries.request, "export default {};\n"), + fsp.writeFile(entries.response, "export default {};\n"), + ]); + const builder = await createBuilder({ + root, + configFile: false, + plugins: [ + vinext({ + disableAppRouter: true, + cache: { + cdn: { + adapter: "/adapter/cache.js", + output: { entries, entry: entries.request, type: "multi-stage" }, + }, + }, + }), + ], + build: { + outDir: "dist", + rolldownOptions: { input: hostEntry, output: { format } }, + ssr: true, + }, + logLevel: "silent", + }); + + await expect(builder.buildApp()).rejects.toThrow( + `[vinext] Multi-stage output requires an ES module format; ${JSON.stringify(format)} cannot represent independently deployable request and response entries.`, + ); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }, + ); + + it("keeps every App response-stage output self-contained across output arrays", async () => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-stage-output-array-")); + try { + await fsp.symlink( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(root, "node_modules"), + "junction", + ); + await Promise.all([ + fsp.mkdir(path.join(root, "app"), { recursive: true }), + fsp.mkdir(path.join(root, "stage"), { recursive: true }), + ]); + await Promise.all([ + fsp.writeFile(path.join(root, "package.json"), JSON.stringify({ type: "module" })), + fsp.writeFile( + path.join(root, "app/layout.tsx"), + "export default function Layout({ children }) { return {children}; }\n", + ), + fsp.writeFile( + path.join(root, "app/page.tsx"), + "export default function Page() { return
page
; }\n", + ), + fsp.writeFile( + path.join(root, "stage/adapter.ts"), + "export default function createAdapter() { return { ownsBackgroundRevalidation: false, async get() { return null; }, async set() {}, buildResponseHeaders() { return {}; }, async revalidateTag() {} }; }\n", + ), + fsp.writeFile( + path.join(root, "stage/request.ts"), + 'export const load = () => import("virtual:vinext-request-stage"); export default {};\n', + ), + fsp.writeFile( + path.join(root, "stage/response.ts"), + 'export const load = () => import("virtual:vinext-response-stage"); export default {};\n', + ), + ]); + + const requestEntry = path.join(root, "stage/request.ts"); + const responseEntry = path.join(root, "stage/response.ts"); + const outputDirs = ["common", "esm", "second"].map((name) => path.join(root, "dist", name)); + const builder = await createBuilder({ + root, + configFile: false, + plugins: [ + vinext({ + appDir: root, + rscOutDir: outputDirs[0], + cache: { + cdn: { + adapter: path.join(root, "stage/adapter.ts"), + output: { + entries: { request: requestEntry, response: responseEntry }, + entry: requestEntry, + type: "multi-stage", + }, + }, + }, + }), + ], + environments: { + rsc: { + build: { + manifest: true, + rolldownOptions: { + output: [ + { dir: outputDirs[1], format: "es" }, + { dir: outputDirs[2], format: "es" }, + ], + }, + }, + }, + }, + logLevel: "silent", + }); + + await builder.buildApp(); + + for (const [index, outputDir] of outputDirs.entries()) { + await expect( + fsp.readFile(path.join(outputDir, "vinext-client-assets.js"), "utf8"), + ).resolves.toContain("export default"); + const outputFiles = await fsp.readdir(outputDir, { recursive: true }); + const responseStageFile = outputFiles.find((file) => + /^vinext-response-stage-.+\.js$/.test(path.basename(file)), + ); + expect(responseStageFile).toBeDefined(); + const responseStage = (await import( + `${pathToFileURL(path.join(outputDir, responseStageFile!)).href}?output=${index}` + )) as { load(): Promise }; + await expect(responseStage.load()).resolves.toBeDefined(); + } + + const canonicalServerDir = path.join(root, "dist/server"); + await fsp.writeFile( + path.join(canonicalServerDir, "vinext-prerender.json"), + JSON.stringify({ + buildId: "test", + pregeneratedConcretePaths: [["/blog/:slug", ["/blog/post-a"]]], + }), + ); + injectPregeneratedConcretePaths( + root, + path.join(outputDirs[0], "index.js"), + canonicalServerDir, + [outputDirs[0]], + ); + for (const outputDir of outputDirs) { + await expect( + fsp.readFile(path.join(outputDir, "__vinext_pregenerated_concrete_paths.js"), "utf8"), + ).resolves.toContain("/blog/post-a"); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }, 30_000); + + it("keeps every Pages response-stage output self-contained across output arrays", async () => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-stage-output-array-")); + try { + await fsp.symlink( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(root, "node_modules"), + "junction", + ); + await Promise.all([ + fsp.mkdir(path.join(root, "pages"), { recursive: true }), + fsp.mkdir(path.join(root, "stage"), { recursive: true }), + ]); + await Promise.all([ + fsp.writeFile(path.join(root, "package.json"), JSON.stringify({ type: "module" })), + fsp.writeFile( + path.join(root, "pages/index.tsx"), + "export default function Page() { return
page
; }\n", + ), + fsp.writeFile( + path.join(root, "stage/adapter.ts"), + "export default function createAdapter() { return { ownsBackgroundRevalidation: false, async get() { return null; }, async set() {}, buildResponseHeaders() { return {}; }, async revalidateTag() {} }; }\n", + ), + fsp.writeFile( + path.join(root, "stage/request.ts"), + 'export const load = () => import("virtual:vinext-request-stage"); export default {};\n', + ), + fsp.writeFile( + path.join(root, "stage/response.ts"), + 'export const load = () => import("virtual:vinext-response-stage"); export default {};\n', + ), + ]); + + const requestEntry = path.join(root, "stage/request.ts"); + const responseEntry = path.join(root, "stage/response.ts"); + const outputDirs = ["esm", "second"].map((name) => path.join(root, "dist", name)); + const builder = await createBuilder({ + root, + configFile: false, + plugins: [ + vinext({ + disableAppRouter: true, + cache: { + cdn: { + adapter: path.join(root, "stage/adapter.ts"), + output: { + entries: { request: requestEntry, response: responseEntry }, + entry: requestEntry, + type: "multi-stage", + }, + }, + }, + }), + ], + build: { + outDir: path.join(root, "dist/common"), + rolldownOptions: { + input: requestEntry, + output: outputDirs.map((dir) => ({ dir, format: "es" as const })), + }, + ssr: true, + }, + logLevel: "silent", + }); + + await builder.buildApp(); + + for (const [index, outputDir] of outputDirs.entries()) { + await expect( + fsp.readFile(path.join(outputDir, "vinext-client-assets.js"), "utf8"), + ).resolves.toContain("export default"); + const outputFiles = await fsp.readdir(outputDir, { recursive: true }); + const responseStageFile = outputFiles.find((file) => + /^vinext-response-stage-.+\.js$/.test(path.basename(file)), + ); + expect(responseStageFile).toBeDefined(); + const responseStage = (await import( + `${pathToFileURL(path.join(outputDir, responseStageFile!)).href}?output=${index}` + )) as { load(): Promise }; + await expect(responseStage.load()).resolves.toBeDefined(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }, 30_000); + + it("keeps transform-only response-stage outputs self-contained across output arrays", async () => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-stage-host-output-array-")); + try { + await fsp.symlink( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(root, "node_modules"), + "junction", + ); + await Promise.all([ + fsp.mkdir(path.join(root, "app"), { recursive: true }), + fsp.mkdir(path.join(root, "stage"), { recursive: true }), + ]); + const hostEntry = path.join(root, "stage/host.ts"); + await Promise.all([ + fsp.writeFile(path.join(root, "package.json"), JSON.stringify({ type: "module" })), + fsp.writeFile( + path.join(root, "app/layout.tsx"), + "export default function Layout({ children }) { return {children}; }\n", + ), + fsp.writeFile( + path.join(root, "app/page.tsx"), + "export default function Page() { return
page
; }\n", + ), + fsp.writeFile( + path.join(root, "stage/adapter.ts"), + "export default function createAdapter() { return { ownsBackgroundRevalidation: false, async get() { return null; }, async set() {}, buildResponseHeaders() { return {}; }, async revalidateTag() {} }; }\n", + ), + fsp.writeFile( + hostEntry, + 'export const loadResponse = () => import("virtual:vinext-response-stage"); export default {};\n', + ), + ]); + + const outputDirs = ["common", "esm", "second"].map((name) => path.join(root, "dist", name)); + const builder = await createBuilder({ + root, + configFile: false, + plugins: [ + vinext({ + appDir: root, + rscOutDir: outputDirs[0], + cache: { + cdn: { + adapter: path.join(root, "stage/adapter.ts"), + output: { + entry: hostEntry, + transformHostEntry({ code, id }) { + return id === hostEntry ? code : null; + }, + type: "multi-stage", + }, + }, + }, + }), + ], + environments: { + rsc: { + build: { + manifest: true, + rolldownOptions: { + output: [ + { dir: outputDirs[1], format: "es" }, + { dir: outputDirs[2], format: "es" }, + ], + }, + }, + }, + }, + logLevel: "silent", + }); + + await builder.buildApp(); + + for (const [index, outputDir] of outputDirs.entries()) { + await expect( + fsp.readFile(path.join(outputDir, "vinext-client-assets.js"), "utf8"), + ).resolves.toContain("export default"); + const outputFiles = (await fsp.readdir(outputDir, { recursive: true })).filter((file) => + file.endsWith(".js"), + ); + const responseStageFile = ( + await Promise.all( + outputFiles.map(async (file) => [ + file, + await fsp.readFile(path.join(outputDir, file), "utf8"), + ]), + ) + ).find(([, source]) => source.includes("vinext-client-assets.js"))?.[0]; + expect(responseStageFile).toBeDefined(); + await expect( + import(`${pathToFileURL(path.join(outputDir, responseStageFile!)).href}?output=${index}`), + ).resolves.toBeDefined(); + } + + const canonicalServerDir = path.join(root, "dist/server"); + await fsp.writeFile( + path.join(canonicalServerDir, "vinext-prerender.json"), + JSON.stringify({ + buildId: "test", + pregeneratedConcretePaths: [["/blog/:slug", ["/blog/post-a"]]], + }), + ); + injectPregeneratedConcretePaths( + root, + path.join(outputDirs[0], "index.js"), + canonicalServerDir, + [outputDirs[0]], + ); + for (const outputDir of outputDirs) { + await expect( + fsp.readFile(path.join(outputDir, "__vinext_pregenerated_concrete_paths.js"), "utf8"), + ).resolves.toContain("/blog/post-a"); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }, 30_000); + 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.symlink( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(root, "node_modules"), + "junction", + ); 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 entries = { + request: path.join(root, "request-stage.ts"), + response: path.join(root, "response-stage.ts"), + }; const plugins = vinext({ appDir: root, cache: { cdn: { adapter: "/adapter/cache.js", - output: { entry: path.join(root, "adapter-entry.ts"), type: "multi-stage" }, + output: { entries, entry: entries.request, type: "multi-stage" }, }, }, }); @@ -4012,30 +4469,43 @@ describe("createMultiStageChunkFileNames", () => { { command: "build", mode: "production" }, ); + const ssrEmit = vi.fn(); const ssrContext = { + emitFile: ssrEmit, environment: { config: { build: { ssr: true } }, name: "ssr" }, }; + await (outputPlugin as any).buildStart.call(ssrContext); expect( await (outputPlugin as any).outputOptions.call(ssrContext, { chunkFileNames: "ssr/[name].js", }), ).toBeUndefined(); + expect(ssrEmit).not.toHaveBeenCalled(); + const customClientEmit = vi.fn(); const customClientContext = { + emitFile: customClientEmit, environment: { config: { build: { ssr: true }, consumer: "client" }, name: "browser-extension", }, }; + await (outputPlugin as any).buildStart.call(customClientContext); expect( await (outputPlugin as any).outputOptions.call(customClientContext, { chunkFileNames: "browser/[name].js", }), ).toBeUndefined(); + expect(customClientEmit).not.toHaveBeenCalled(); + const rscEmit = vi.fn(); const rscContext = { + emitFile: rscEmit, environment: { config: { build: { ssr: true } }, name: "rsc" }, }; + await (outputPlugin as any).buildStart.call(rscContext); + expect(rscEmit).toHaveBeenCalledTimes(2); + for (const directory of ["esm", "cjs"]) { const hostGroup = { name: `${directory}-host`, test: /host/ }; const output = await (outputPlugin as any).outputOptions.call(rscContext, { diff --git a/tests/fixtures/multi-stage-http/app/[slug]/layout.tsx b/tests/fixtures/multi-stage-http/app/[slug]/layout.tsx new file mode 100644 index 0000000000..49f9c31938 --- /dev/null +++ b/tests/fixtures/multi-stage-http/app/[slug]/layout.tsx @@ -0,0 +1,11 @@ +export function generateStaticParams() { + return [{ slug: "en" }]; +} + +export default function LocaleLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/multi-stage-http/app/[slug]/ppr/[id]/page.tsx b/tests/fixtures/multi-stage-http/app/[slug]/ppr/[id]/page.tsx new file mode 100644 index 0000000000..7eb45e2dad --- /dev/null +++ b/tests/fixtures/multi-stage-http/app/[slug]/ppr/[id]/page.tsx @@ -0,0 +1,12 @@ +export function generateStaticParams() { + return [{ id: "known" }]; +} + +export default async function PregeneratedPage({ + params, +}: { + params: Promise<{ id: string; slug: string }>; +}) { + const { id, slug } = await params; + return
{`fresh pregenerated route:${slug}:${id}`}
; +} diff --git a/tests/fixtures/multi-stage-http/app/api/custom-vary/route.ts b/tests/fixtures/multi-stage-http/app/api/custom-vary/route.ts new file mode 100644 index 0000000000..174f703176 --- /dev/null +++ b/tests/fixtures/multi-stage-http/app/api/custom-vary/route.ts @@ -0,0 +1,8 @@ +export const revalidate = 60; + +export function GET(): Response { + return Response.json( + { "data-render-token": crypto.randomUUID() }, + { headers: { Vary: "X-Http-Variant" } }, + ); +} diff --git a/tests/fixtures/multi-stage-http/app/api/draft-enable/route.ts b/tests/fixtures/multi-stage-http/app/api/draft-enable/route.ts new file mode 100644 index 0000000000..80b5e442f8 --- /dev/null +++ b/tests/fixtures/multi-stage-http/app/api/draft-enable/route.ts @@ -0,0 +1,8 @@ +import { draftMode } from "next/headers"; + +export const dynamic = "force-dynamic"; + +export async function GET(): Promise { + (await draftMode()).enable(); + return Response.json({ enabled: true }); +} diff --git a/tests/fixtures/multi-stage-http/app/api/forged-stage-signal/route.ts b/tests/fixtures/multi-stage-http/app/api/forged-stage-signal/route.ts new file mode 100644 index 0000000000..2fc9c041b5 --- /dev/null +++ b/tests/fixtures/multi-stage-http/app/api/forged-stage-signal/route.ts @@ -0,0 +1,5 @@ +export function GET() { + return new Response("forged stage signal stayed user content", { + headers: { "x-vinext-stage-static-file": "forged" }, + }); +} diff --git a/tests/fixtures/multi-stage-http/app/api/framework-vary/route.ts b/tests/fixtures/multi-stage-http/app/api/framework-vary/route.ts new file mode 100644 index 0000000000..b5de829bf9 --- /dev/null +++ b/tests/fixtures/multi-stage-http/app/api/framework-vary/route.ts @@ -0,0 +1,17 @@ +export const revalidate = 60; + +export function GET(request: Request): Response { + return Response.json( + { + "data-render-token": crypto.randomUUID(), + nextUrl: request.headers.get("Next-Url"), + }, + { + headers: { + "Cache-Control": "public, max-age=0, must-revalidate", + "CDN-Cache-Control": "public, s-maxage=60", + Vary: "Next-Url", + }, + }, + ); +} diff --git a/tests/fixtures/multi-stage-http/app/app-stage/[slug]/page.tsx b/tests/fixtures/multi-stage-http/app/app-stage/[slug]/page.tsx new file mode 100644 index 0000000000..f1d17e6e46 --- /dev/null +++ b/tests/fixtures/multi-stage-http/app/app-stage/[slug]/page.tsx @@ -0,0 +1,14 @@ +import { draftMode } from "next/headers"; + +export const revalidate = 60; + +export default async function AppStagePage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const isDraftMode = (await draftMode()).isEnabled; + const renderToken = crypto.randomUUID(); + return ( +
+ App HTTP stage render-token:{renderToken} +
+ ); +} diff --git a/tests/fixtures/multi-stage-http/app/app-stage/layout.tsx b/tests/fixtures/multi-stage-http/app/app-stage/layout.tsx new file mode 100644 index 0000000000..fc976d79f8 --- /dev/null +++ b/tests/fixtures/multi-stage-http/app/app-stage/layout.tsx @@ -0,0 +1,7 @@ +export default function AppStageLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/multi-stage-http/http-stage-cache.runtime.ts b/tests/fixtures/multi-stage-http/http-stage-cache.runtime.ts new file mode 100644 index 0000000000..75bbdf804a --- /dev/null +++ b/tests/fixtures/multi-stage-http/http-stage-cache.runtime.ts @@ -0,0 +1,50 @@ +import type { CdnCacheAdapter } from "vinext/shims/cdn-cache"; + +const NO_STORE = "no-store"; +const KNOWN_ROUTE_FALLBACK_KEY_SUFFIX = ":/en/ppr/[id]:html"; +export const KNOWN_ROUTE_FALLBACK_MARKER = "HTTP_STAGE_WRONG_FALLBACK_SHELL"; + +export const createHttpStageCacheAdapter = (): CdnCacheAdapter => ({ + ownsBackgroundRevalidation: false, + async get(key) { + if ( + process.env.VINEXT_HTTP_STAGE_FALLBACK_SHELL === "1" && + key.endsWith(KNOWN_ROUTE_FALLBACK_KEY_SUFFIX) + ) { + return { + cacheControl: { revalidate: 60 }, + lastModified: Date.now(), + value: { + headers: undefined, + html: `${KNOWN_ROUTE_FALLBACK_MARKER}`, + kind: "APP_PAGE", + postponed: undefined, + rscData: undefined, + status: undefined, + }, + }; + } + return null; + }, + async set() {}, + buildResponseHeaders({ cacheControl, tags }) { + if (!cacheControl || /\b(?:private|no-cache|no-store)\b/i.test(cacheControl)) { + return { + "Cache-Control": NO_STORE, + "Cache-Tag": null, + "CDN-Cache-Control": null, + }; + } + return { + "Cache-Control": "public, max-age=0, must-revalidate", + "Cache-Tag": tags?.join(",") ?? null, + "CDN-Cache-Control": cacheControl, + }; + }, + async revalidateTag(tags) { + const purge = Reflect.get(globalThis, Symbol.for("vinext.fixture.httpStageHostCachePurge")); + if (typeof purge === "function") purge(Array.isArray(tags) ? tags : [tags]); + }, +}); + +export default createHttpStageCacheAdapter; diff --git a/tests/fixtures/multi-stage-http/http-stage-cache.ts b/tests/fixtures/multi-stage-http/http-stage-cache.ts new file mode 100644 index 0000000000..b56e4e1981 --- /dev/null +++ b/tests/fixtures/multi-stage-http/http-stage-cache.ts @@ -0,0 +1,17 @@ +import { fileURLToPath } from "node:url"; + +export function httpStageCacheAdapter() { + const requestEntry = fileURLToPath(new URL("./http-stage-request.ts", import.meta.url)); + const responseEntry = fileURLToPath(new URL("./http-stage-response.ts", import.meta.url)); + return { + adapter: fileURLToPath(new URL("./http-stage-cache.runtime.ts", import.meta.url)), + output: { + entries: { request: requestEntry, response: responseEntry }, + entry: requestEntry, + matchesBuild({ plugins }: { plugins: readonly { name?: string }[] }) { + return plugins.some(({ name }) => name === "independent-http-stage-host"); + }, + type: "multi-stage" as const, + }, + }; +} diff --git a/tests/fixtures/multi-stage-http/http-stage-host-cache.ts b/tests/fixtures/multi-stage-http/http-stage-host-cache.ts new file mode 100644 index 0000000000..26fdbeefd8 --- /dev/null +++ b/tests/fixtures/multi-stage-http/http-stage-host-cache.ts @@ -0,0 +1,41 @@ +export type ResponseSnapshot = { + body: ArrayBuffer; + headers: Array<[string, string]>; + status: number; + statusText: string; +}; + +type HostCacheEntry = { + snapshot: Promise; + tags: Set; +}; + +const HTTP_STAGE_HOST_CACHE = Symbol.for("vinext.fixture.httpStageHostCache"); +export const HTTP_STAGE_HOST_CACHE_PURGE = Symbol.for("vinext.fixture.httpStageHostCachePurge"); +const globals = globalThis as unknown as Record; +const cache = (globals[HTTP_STAGE_HOST_CACHE] ??= new Map()) as Map< + string, + HostCacheEntry +>; + +export function getHostCacheEntry(key: string): Promise | undefined { + return cache.get(key)?.snapshot; +} + +export function setHostCacheEntry( + key: string, + snapshot: Promise, + tags: readonly string[], +): void { + cache.set(key, { snapshot, tags: new Set(tags) }); +} + +export function purgeHostCacheTags(tags: readonly string[]): void { + if (tags.length === 0) return; + const invalidated = new Set(tags); + for (const [key, entry] of cache) { + if ([...entry.tags].some((tag) => invalidated.has(tag))) cache.delete(key); + } +} + +globals[HTTP_STAGE_HOST_CACHE_PURGE] = purgeHostCacheTags; diff --git a/tests/fixtures/multi-stage-http/http-stage-node.ts b/tests/fixtures/multi-stage-http/http-stage-node.ts new file mode 100644 index 0000000000..c35cf22fe3 --- /dev/null +++ b/tests/fixtures/multi-stage-http/http-stage-node.ts @@ -0,0 +1,122 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +export type SerializedRequest = { + body: string | null; + headers: Array<[string, string]>; + method: string; + url: string; +}; + +async function readIncomingBody(request: IncomingMessage): Promise { + if (request.method === "GET" || request.method === "HEAD") return null; + const chunks: Uint8Array[] = []; + for await (const chunk of request) { + chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk); + } + if (chunks.length === 0) return null; + const length = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const body = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +function incomingHeaders(request: IncomingMessage): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item); + } else if (value !== undefined) { + headers.set(name, value); + } + } + return headers; +} + +export async function incomingMessageToRequest(request: IncomingMessage): Promise { + const host = request.headers.host ?? "127.0.0.1"; + const body = await readIncomingBody(request); + return new Request(new URL(request.url ?? "/", `http://${host}`), { + body, + headers: incomingHeaders(request), + method: request.method ?? "GET", + }); +} + +export async function serializeRequest(request: Request): Promise { + const body = + request.method === "GET" || request.method === "HEAD" + ? null + : Buffer.from(await request.arrayBuffer()).toString("base64"); + return { + body, + headers: [...request.headers], + method: request.method, + url: request.url, + }; +} + +export function deserializeRequest(request: SerializedRequest): Request { + return new Request(request.url, { + body: request.body === null ? null : Buffer.from(request.body, "base64"), + headers: request.headers, + method: request.method, + }); +} + +export async function writeNodeResponse( + response: Response, + nodeResponse: ServerResponse, +): Promise { + nodeResponse.statusCode = response.status; + nodeResponse.statusMessage = response.statusText; + for (const [name, value] of response.headers) { + if (name !== "set-cookie") nodeResponse.setHeader(name, value); + } + const setCookies = response.headers.getSetCookie(); + if (setCookies.length > 0) nodeResponse.setHeader("set-cookie", setCookies); + nodeResponse.flushHeaders(); + if (!response.body) { + nodeResponse.end(); + return; + } + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!nodeResponse.write(value)) { + await new Promise((resolve) => nodeResponse.once("drain", resolve)); + } + } + nodeResponse.end(); + } catch (error) { + nodeResponse.destroy(error instanceof Error ? error : new Error(String(error))); + } +} + +export function startNodeFetchServer(fetchHandler: (request: Request) => Promise): void { + const port = Number(process.env.PORT ?? "0"); + const server = createServer(async (request, response) => { + try { + await writeNodeResponse( + await fetchHandler(await incomingMessageToRequest(request)), + response, + ); + } catch (error) { + response.statusCode = 500; + response.end(error instanceof Error ? error.message : String(error)); + } + }); + server.listen(port, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing HTTP stage address"); + console.log(`VINEXT_HTTP_STAGE_READY:${address.port}`); + }); + const shutdown = () => server.close(() => process.exit(0)); + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} diff --git a/tests/fixtures/multi-stage-http/http-stage-request.ts b/tests/fixtures/multi-stage-http/http-stage-request.ts new file mode 100644 index 0000000000..7cc0e5b912 --- /dev/null +++ b/tests/fixtures/multi-stage-http/http-stage-request.ts @@ -0,0 +1,87 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { VinextResponseStageTransport } from "vinext/server/multi-stage"; +import { loadVinextRequestStage } from "vinext/server/request-stage"; +import { + deserializeRequest, + serializeRequest, + startNodeFetchServer, + type SerializedRequest, +} from "./http-stage-node"; + +type RequestStageEnvelope = { request: SerializedRequest }; + +function internalStageHeaders(): Record { + const token = process.env.VINEXT_STAGE_TOKEN; + if (!token) throw new Error("Missing VINEXT_STAGE_TOKEN"); + return { authorization: `Bearer ${token}`, "content-type": "application/json" }; +} + +function isAuthorizedStageRequest(request: Request): boolean { + const token = process.env.VINEXT_STAGE_TOKEN; + return token !== undefined && request.headers.get("authorization") === `Bearer ${token}`; +} + +const assets = { + async fetch(request: Request): Promise { + if (new URL(request.url).pathname !== "/stage-asset.txt") { + return new Response("Not Found", { status: 404 }); + } + const body = await readFile(path.join(process.cwd(), "public/stage-asset.txt")); + return new Response(request.method === "HEAD" ? null : body, { + headers: { + "content-length": String(body.byteLength), + "content-type": "text/plain; charset=utf-8", + }, + }); + }, +}; + +const gatewayFetch = async (transportRequest: Request): Promise => { + const isInternalPath = new URL(transportRequest.url).pathname === "/__vinext_request_stage"; + if (isInternalPath && !isAuthorizedStageRequest(transportRequest)) { + return new Response("Unauthorized", { status: 401 }); + } + const isInternalDispatch = transportRequest.method === "POST" && isInternalPath; + const request = isInternalDispatch + ? deserializeRequest(((await transportRequest.json()) as RequestStageEnvelope).request) + : transportRequest; + const responseOrigin = process.env.VINEXT_RESPONSE_STAGE_ORIGIN; + if (!responseOrigin) return new Response("Missing response-stage origin", { status: 500 }); + + const dispatchResponseStage: VinextResponseStageTransport = async ( + stageRequest, + props, + options, + ) => { + const response = await fetch(new URL("/__vinext_response_stage", responseOrigin), { + body: JSON.stringify({ + options, + props, + request: await serializeRequest(stageRequest), + }), + headers: internalStageHeaders(), + method: "POST", + }); + return response; + }; + + const { handleRequestStage } = await loadVinextRequestStage(); + const response = await handleRequestStage( + request, + {}, + { assets, hostRuntime: "node" }, + dispatchResponseStage, + ); + const headers = new Headers(response.headers); + headers.set("x-http-request-stage-pid", String(process.pid)); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +}; + +export default { fetch: gatewayFetch }; + +startNodeFetchServer(gatewayFetch); diff --git a/tests/fixtures/multi-stage-http/http-stage-response.ts b/tests/fixtures/multi-stage-http/http-stage-response.ts new file mode 100644 index 0000000000..8c77cac412 --- /dev/null +++ b/tests/fixtures/multi-stage-http/http-stage-response.ts @@ -0,0 +1,225 @@ +import type { VinextRequestStageTransport } from "vinext/server/multi-stage"; +import { loadVinextResponseStage } from "vinext/server/response-stage"; +import { + deserializeRequest, + serializeRequest, + startNodeFetchServer, + type SerializedRequest, +} from "./http-stage-node"; +import { + getHostCacheEntry, + setHostCacheEntry, + type ResponseSnapshot, +} from "./http-stage-host-cache"; + +type StageEnvelope = { + options: { cache: "shared" | "bypass" }; + props: unknown; + request: SerializedRequest; +}; + +function internalStageHeaders(): Record { + const token = process.env.VINEXT_STAGE_TOKEN; + if (!token) throw new Error("Missing VINEXT_STAGE_TOKEN"); + return { authorization: `Bearer ${token}`, "content-type": "application/json" }; +} + +function isAuthorizedStageRequest(request: Request): boolean { + const token = process.env.VINEXT_STAGE_TOKEN; + return token !== undefined && request.headers.get("authorization") === `Bearer ${token}`; +} + +const STREAM_DELAY_MS = 250; +// Keep the cache-hit entrypoint self-contained: importing framework runtime +// constants here would pull a shared response-stage chunk into its startup +// closure. These are the raw RSC selectors this example transport keys. +const KEYED_RESPONSE_VARY_FIELDS = [ + "RSC", + "Next-Router-State-Tree", + "Next-Router-Prefetch", + "Next-Router-Segment-Prefetch", + "Next-Url", + "X-Vinext-Interception-Context", + "X-Vinext-Interception-Id", + "X-Vinext-Mounted-Slots", + "X-Vinext-Rsc-Render-Mode", + "X-Vinext-Rsc-State-Fingerprint", +] as const; + +function responseCacheKey(request: Request, props: unknown): string { + const varyIdentity = KEYED_RESPONSE_VARY_FIELDS.map((name) => [name, request.headers.get(name)]); + return JSON.stringify([request.method, request.url, props, varyIdentity]); +} + +async function snapshotResponse(response: Response): Promise { + return { + body: await response.arrayBuffer(), + headers: [...response.headers], + status: response.status, + statusText: response.statusText, + }; +} + +function responseFromSnapshot(snapshot: ResponseSnapshot): Response { + return new Response(snapshot.body.slice(0), { + headers: snapshot.headers, + status: snapshot.status, + statusText: snapshot.statusText, + }); +} + +function withHostHeaders(response: Response, state: "BYPASS" | "HIT" | "MISS"): Response { + const headers = new Headers(response.headers); + headers.set("x-http-response-stage-pid", String(process.pid)); + headers.set("x-http-stage-cache", state); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + +function delayAfterFirstBytes(response: Response): Response { + if (!response.body) return response; + const reader = response.body.getReader(); + let delayedRemainder: Uint8Array | null = null; + let splitFirstChunk = false; + const stream = new ReadableStream({ + async pull(controller) { + if (delayedRemainder) { + await new Promise((resolve) => setTimeout(resolve, STREAM_DELAY_MS)); + controller.enqueue(delayedRemainder); + delayedRemainder = null; + return; + } + const next = await reader.read(); + if (next.done) { + reader.releaseLock(); + controller.close(); + return; + } + if (!splitFirstChunk && next.value.byteLength > 1) { + splitFirstChunk = true; + const split = Math.max(1, Math.floor(next.value.byteLength / 2)); + delayedRemainder = next.value.slice(split); + controller.enqueue(next.value.slice(0, split)); + return; + } + splitFirstChunk = true; + controller.enqueue(next.value); + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + reader.releaseLock(); + } + }, + }); + const headers = new Headers(response.headers); + headers.set("x-http-stage-stream-delay", String(STREAM_DELAY_MS)); + return new Response(stream, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + +function isSharedCacheable(response: Response): boolean { + const policy = + response.headers.get("cloudflare-cdn-cache-control") ?? + response.headers.get("cdn-cache-control") ?? + response.headers.get("cache-control"); + return ( + response.status >= 200 && + response.status < 400 && + policy !== null && + !/(?:^|,)\s*(?:no-store|no-cache|private)\b/i.test(policy) + ); +} + +function withPrivateHostPolicy(response: Response): Response { + const headers = new Headers(response.headers); + headers.set("Cache-Control", "no-store"); + headers.delete("Cache-Tag"); + headers.delete("CDN-Cache-Control"); + headers.delete("Cloudflare-CDN-Cache-Control"); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + +function responseCacheTags(response: Response): string[] { + return (response.headers.get("cache-tag") ?? "") + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); +} + +const responseStageFetch = async (transportRequest: Request): Promise => { + if (new URL(transportRequest.url).pathname !== "/__vinext_response_stage") { + return new Response("Not Found", { status: 404 }); + } + if (!isAuthorizedStageRequest(transportRequest)) { + return new Response("Unauthorized", { status: 401 }); + } + const envelope = (await transportRequest.json()) as StageEnvelope; + const request = deserializeRequest(envelope.request); + const cacheKey = responseCacheKey(request, envelope.props); + + if (envelope.options.cache === "shared") { + const snapshot = await getHostCacheEntry(cacheKey); + if (snapshot) return withHostHeaders(responseFromSnapshot(snapshot), "HIT"); + } + + const requestOrigin = process.env.VINEXT_REQUEST_STAGE_ORIGIN; + if (!requestOrigin) return new Response("Missing request-stage origin", { status: 500 }); + const dispatchRequestStage: VinextRequestStageTransport = async (stageRequest) => + fetch(new URL("/__vinext_request_stage", requestOrigin), { + body: JSON.stringify({ request: await serializeRequest(stageRequest) }), + headers: internalStageHeaders(), + method: "POST", + }); + const { handleResponseStage } = await loadVinextResponseStage(); + const rendered = await handleResponseStage( + request, + {}, + { hostRuntime: "node" }, + envelope.props, + dispatchRequestStage, + envelope.options, + ); + if (envelope.options.cache === "bypass" || !isSharedCacheable(rendered)) { + return withHostHeaders(rendered, "BYPASS"); + } + // A cache hit never needs response admission. Keep even this lightweight + // framework policy behind the miss path so the host entry remains a lazy + // proxy until it actually renders a response. + const { hasUnsupportedResponseStageVary } = await import("vinext/server/multi-stage"); + if (hasUnsupportedResponseStageVary(rendered.headers, KEYED_RESPONSE_VARY_FIELDS)) { + return withHostHeaders(withPrivateHostPolicy(rendered), "BYPASS"); + } + + if (!rendered.body) { + setHostCacheEntry( + cacheKey, + Promise.resolve(await snapshotResponse(rendered.clone())), + responseCacheTags(rendered), + ); + return withHostHeaders(rendered, "MISS"); + } + const [foregroundBody, cacheBody] = rendered.body.tee(); + const foreground = delayAfterFirstBytes(new Response(foregroundBody, rendered)); + setHostCacheEntry( + cacheKey, + snapshotResponse(new Response(cacheBody, rendered)).catch(() => null), + responseCacheTags(rendered), + ); + return withHostHeaders(foreground, "MISS"); +}; + +export default { fetch: responseStageFetch }; + +startNodeFetchServer(responseStageFetch); diff --git a/tests/fixtures/multi-stage-http/middleware.ts b/tests/fixtures/multi-stage-http/middleware.ts new file mode 100644 index 0000000000..d66bdd6b9d --- /dev/null +++ b/tests/fixtures/multi-stage-http/middleware.ts @@ -0,0 +1,19 @@ +import { NextResponse, type NextRequest } from "next/server"; + +export function middleware(request: NextRequest) { + const aliasMatch = request.nextUrl.pathname.match(/^\/alias-(.+)$/); + const response = aliasMatch + ? NextResponse.rewrite(new URL(`/${aliasMatch[1]}`, request.url)) + : NextResponse.next(); + if (request.nextUrl.pathname === "/stage-asset.txt") { + response.headers.set("content-encoding", "gzip"); + response.headers.set("content-length", "999"); + response.headers.set("content-type", "application/wrong"); + response.headers.set("transfer-encoding", "chunked"); + } + response.headers.set( + "x-http-stage-visitor", + request.headers.get("x-test-visitor") ?? "anonymous", + ); + return response; +} diff --git a/tests/fixtures/multi-stage-http/next.config.ts b/tests/fixtures/multi-stage-http/next.config.ts new file mode 100644 index 0000000000..a7626d2219 --- /dev/null +++ b/tests/fixtures/multi-stage-http/next.config.ts @@ -0,0 +1,3 @@ +import type { NextConfig } from "next"; + +export default { cacheComponents: true } satisfies NextConfig; diff --git a/tests/fixtures/multi-stage-http/package.json b/tests/fixtures/multi-stage-http/package.json new file mode 100644 index 0000000000..dae95155dd --- /dev/null +++ b/tests/fixtures/multi-stage-http/package.json @@ -0,0 +1,5 @@ +{ + "name": "multi-stage-http-fixture", + "private": true, + "type": "module" +} diff --git a/tests/fixtures/multi-stage-http/pages/[slug].tsx b/tests/fixtures/multi-stage-http/pages/[slug].tsx new file mode 100644 index 0000000000..6c89ed5dc7 --- /dev/null +++ b/tests/fixtures/multi-stage-http/pages/[slug].tsx @@ -0,0 +1,22 @@ +import type { GetStaticPaths, GetStaticProps } from "next"; + +type Props = { draftMode: boolean; renderToken: string; slug: string }; + +export default function StagePage({ draftMode, renderToken, slug }: Props) { + return ( +
+ HTTP stage render: {renderToken} +
+ ); +} + +export const getStaticPaths: GetStaticPaths = () => ({ fallback: "blocking", paths: [] }); + +export const getStaticProps: GetStaticProps = ({ draftMode, params }) => ({ + props: { + draftMode: draftMode === true, + renderToken: crypto.randomUUID(), + slug: String(params?.slug), + }, + revalidate: 60, +}); diff --git a/tests/fixtures/multi-stage-http/pages/api/echo.ts b/tests/fixtures/multi-stage-http/pages/api/echo.ts new file mode 100644 index 0000000000..fad6932b70 --- /dev/null +++ b/tests/fixtures/multi-stage-http/pages/api/echo.ts @@ -0,0 +1,9 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(request: NextApiRequest, response: NextApiResponse) { + response.setHeader("Cache-Control", "no-store"); + response.status(200).json({ + body: request.body, + renderToken: crypto.randomUUID(), + }); +} diff --git a/tests/fixtures/multi-stage-http/pages/api/revalidate.ts b/tests/fixtures/multi-stage-http/pages/api/revalidate.ts new file mode 100644 index 0000000000..d85a3aa096 --- /dev/null +++ b/tests/fixtures/multi-stage-http/pages/api/revalidate.ts @@ -0,0 +1,10 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(request: NextApiRequest, response: NextApiResponse) { + const path = typeof request.query.path === "string" ? request.query.path : "/"; + await response.revalidate( + path, + request.query.onlyGenerated === "1" ? { unstable_onlyGenerated: true } : undefined, + ); + response.status(200).json({ revalidated: true }); +} diff --git a/tests/fixtures/multi-stage-http/pages/index.tsx b/tests/fixtures/multi-stage-http/pages/index.tsx new file mode 100644 index 0000000000..f57fa66e44 --- /dev/null +++ b/tests/fixtures/multi-stage-http/pages/index.tsx @@ -0,0 +1,12 @@ +type Props = { renderToken: string }; + +export default function Home({ renderToken }: Props) { + return
HTTP stage render: {renderToken}
; +} + +export function getStaticProps() { + return { + props: { renderToken: crypto.randomUUID() }, + revalidate: 60, + }; +} diff --git a/tests/fixtures/multi-stage-http/public/stage-asset.txt b/tests/fixtures/multi-stage-http/public/stage-asset.txt new file mode 100644 index 0000000000..85af12072a --- /dev/null +++ b/tests/fixtures/multi-stage-http/public/stage-asset.txt @@ -0,0 +1 @@ +HTTP_STAGE_PUBLIC_ASSET diff --git a/tests/fixtures/multi-stage-http/vite.config.mts b/tests/fixtures/multi-stage-http/vite.config.mts new file mode 100644 index 0000000000..f2bc1e77b1 --- /dev/null +++ b/tests/fixtures/multi-stage-http/vite.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite-plus"; +import vinext from "../../../packages/vinext/dist/index.js"; +import { httpStageCacheAdapter } from "./http-stage-cache"; + +export default defineConfig({ + build: { manifest: true }, + plugins: [ + vinext({ cache: { cdn: httpStageCacheAdapter() } }), + { name: "independent-http-stage-host" }, + ], +}); diff --git a/tests/multi-stage-http.test.ts b/tests/multi-stage-http.test.ts new file mode 100644 index 0000000000..0c3689a41f --- /dev/null +++ b/tests/multi-stage-http.test.ts @@ -0,0 +1,675 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import { request as sendHttpRequest } from "node:http"; +import { createServer } from "node:net"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + createHttpStageCacheAdapter, + KNOWN_ROUTE_FALLBACK_MARKER, +} from "./fixtures/multi-stage-http/http-stage-cache.runtime"; + +const ROOT = process.cwd(); +const FIXTURE_ROOT = path.join(ROOT, "tests/fixtures/multi-stage-http"); +const VINEXT_CLI = path.join(ROOT, "packages/vinext/dist/cli.js"); +const READY_PREFIX = "VINEXT_HTTP_STAGE_READY:"; +const STAGE_TOKEN = "multi-stage-http-test-token"; + +type ManifestEntry = { + dynamicImports?: string[]; + file: string; + imports?: string[]; + isEntry?: boolean; + src?: string; +}; + +type StageServer = { + logs: string[]; + origin: string; + process: ChildProcess; +}; + +let requestServer: StageServer; +let responseServer: StageServer; +let manifest: Record; +let createdNodeModules = false; + +function spawnAndWait( + command: string, + args: string[], + env?: Record, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: FIXTURE_ROOT, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + const output: string[] = []; + child.stdout?.on("data", (chunk) => output.push(String(chunk))); + child.stderr?.on("data", (chunk) => output.push(String(chunk))); + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) resolve(output.join("")); + else reject(new Error(`${command} exited with code ${code}\n${output.join("")}`)); + }); + }); +} + +function startStageServer( + entry: string, + port: number, + env?: Record, +): Promise { + return new Promise((resolve, reject) => { + const logs: string[] = []; + const child = spawn(process.execPath, [entry], { + cwd: FIXTURE_ROOT, + env: { ...process.env, PORT: String(port), ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + const timeout = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error(`Timed out starting ${entry}\n${logs.join("")}`)); + }, 10_000); + let pending = ""; + const onOutput = (chunk: Buffer) => { + const text = String(chunk); + logs.push(text); + pending += text; + const match = pending.match(/VINEXT_HTTP_STAGE_READY:(\d+)/); + if (!match) return; + clearTimeout(timeout); + resolve({ logs, origin: `http://127.0.0.1:${match[1]}`, process: child }); + }; + child.stdout?.on("data", onOutput); + child.stderr?.on("data", onOutput); + child.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once("exit", (code) => { + clearTimeout(timeout); + if (!pending.includes(READY_PREFIX)) { + reject(new Error(`${entry} exited with code ${code}\n${logs.join("")}`)); + } + }); + }); +} + +function reservePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Missing reserved HTTP stage address")); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + +function fetchWithAuthority(url: string, authority: string): Promise { + return new Promise((resolve, reject) => { + const target = new URL(url); + const request = sendHttpRequest( + { + headers: { Host: authority }, + hostname: target.hostname, + path: target.pathname + target.search, + port: target.port, + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + response.once("error", reject); + response.once("end", () => { + const headers = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item); + } else if (value !== undefined) { + headers.set(name, value); + } + } + resolve( + new Response(Buffer.concat(chunks), { + headers, + status: response.statusCode, + statusText: response.statusMessage, + }), + ); + }); + }, + ); + request.once("error", reject); + request.end(); + }); +} + +async function stopStageServer(server: StageServer | undefined): Promise { + if (!server || server.process.exitCode !== null) return; + const exited = new Promise((resolve) => server.process.once("exit", () => resolve())); + server.process.kill("SIGTERM"); + await exited; +} + +function findEntry(sourceSuffix: string): [string, ManifestEntry] { + const match = Object.entries(manifest).find( + ([key, entry]) => + entry.isEntry && (key.endsWith(sourceSuffix) || entry.src?.endsWith(sourceSuffix)), + ); + if (!match) throw new Error(`Missing built entry for ${sourceSuffix}`); + return match; +} + +function readClosure(root: string): { code: string; keys: Set } { + const keys = new Set(); + const visit = (key: string) => { + if (keys.has(key)) return; + const entry = manifest[key]; + if (!entry) throw new Error(`Missing manifest entry ${key}`); + keys.add(key); + for (const imported of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) { + visit(imported); + } + }; + visit(root); + const serverRoot = path.join(FIXTURE_ROOT, "dist/server"); + return { + code: [...keys] + .map((key) => fs.readFileSync(path.join(serverRoot, manifest[key]!.file), "utf8")) + .join("\n"), + keys, + }; +} + +function readStaticClosure(root: string): { code: string; keys: Set } { + const keys = new Set(); + const visit = (key: string) => { + if (keys.has(key)) return; + const entry = manifest[key]; + if (!entry) throw new Error(`Missing manifest entry ${key}`); + keys.add(key); + for (const imported of entry.imports ?? []) visit(imported); + }; + visit(root); + const serverRoot = path.join(FIXTURE_ROOT, "dist/server"); + return { + code: [...keys] + .map((key) => fs.readFileSync(path.join(serverRoot, manifest[key]!.file), "utf8")) + .join("\n"), + keys, + }; +} + +function renderToken(body: string): string { + const token = + body.match(/data-render-token="([a-f0-9-]+)"/)?.[1] ?? + body.match(/"data-render-token":"([a-f0-9-]+)"/)?.[1]; + if (!token) throw new Error("Missing HTTP stage render token"); + return token; +} + +beforeAll(async () => { + const fixtureNodeModules = path.join(FIXTURE_ROOT, "node_modules"); + if (!fs.existsSync(fixtureNodeModules)) { + fs.symlinkSync(path.join(ROOT, "tests/fixtures/pages-basic/node_modules"), fixtureNodeModules); + createdNodeModules = true; + } + const buildOutput = await spawnAndWait(process.execPath, [ + VINEXT_CLI, + "build", + "--prerender-all", + ]); + const manifestPath = path.join(FIXTURE_ROOT, "dist/server/.vite/manifest.json"); + if (!fs.existsSync(manifestPath)) { + throw new Error(`HTTP stage build did not emit a server manifest\n${buildOutput}`); + } + manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record; + const [, responseEntry] = findEntry("http-stage-response.ts"); + const [, requestEntry] = findEntry("http-stage-request.ts"); + const [requestPort, responsePort] = await Promise.all([reservePort(), reservePort()]); + [requestServer, responseServer] = await Promise.all([ + startStageServer(path.join(FIXTURE_ROOT, "dist/server", requestEntry.file), requestPort, { + VINEXT_RESPONSE_STAGE_ORIGIN: `http://127.0.0.1:${responsePort}`, + VINEXT_STAGE_TOKEN: STAGE_TOKEN, + }), + startStageServer(path.join(FIXTURE_ROOT, "dist/server", responseEntry.file), responsePort, { + VINEXT_REQUEST_STAGE_ORIGIN: `http://127.0.0.1:${requestPort}`, + VINEXT_HTTP_STAGE_FALLBACK_SHELL: "1", + VINEXT_STAGE_TOKEN: STAGE_TOKEN, + }), + ]); +}, 60_000); + +afterAll(async () => { + await Promise.all([stopStageServer(requestServer), stopStageServer(responseServer)]); + if (createdNodeModules) fs.rmSync(path.join(FIXTURE_ROOT, "node_modules")); +}); + +describe("generic HTTP multi-stage transport", () => { + it("requires authentication on both internal stage endpoints", async () => { + const [requestResponse, responseResponse] = await Promise.all([ + fetch(`${requestServer.origin}/__vinext_request_stage`, { method: "POST" }), + fetch(`${responseServer.origin}/__vinext_response_stage`, { method: "POST" }), + ]); + + expect(requestResponse.status).toBe(401); + expect(responseResponse.status).toBe(401); + }); + + it("keeps renderer and user-route modules out of the request-stage dynamic closure", () => { + expect(findEntry("virtual:vinext-rsc-entry")).toBeTruthy(); + expect(findEntry("http-stage-request.ts")).toBeTruthy(); + expect(findEntry("http-stage-response.ts")).toBeTruthy(); + + const [requestKey] = findEntry("http-stage-request.ts"); + const { code, keys } = readClosure(requestKey); + + expect([...keys].some((key) => key.endsWith("pages/index.tsx"))).toBe(false); + expect([...keys].some((key) => key.endsWith("pages/api/echo.ts"))).toBe(false); + expect([...keys].some((key) => key.endsWith("app/app-stage/[slug]/page.tsx"))).toBe(false); + expect([...keys].some((key) => key.includes("vinext-pages-response-entry"))).toBe(false); + expect(code).not.toContain("react-dom/server"); + expect(code).not.toContain("react.client.reference"); + expect(code).not.toContain("App HTTP stage render-token:"); + }); + + it("keeps renderers, React, and user routes out of the response-stage static closure", () => { + const [responseKey] = findEntry("http-stage-response.ts"); + const { code, keys } = readStaticClosure(responseKey); + + expect([...keys].some((key) => key.startsWith("app/") || key.startsWith("pages/"))).toBe(false); + expect([...keys].some((key) => key.includes("virtual_vinext-response-stage"))).toBe(false); + expect(code).not.toContain("react-dom/server"); + expect(code).not.toContain("react.client.reference"); + expect(code).not.toContain("renderToReadableStream"); + expect(code).not.toContain("App HTTP stage render-token:"); + }); + + it("does not emit deployment stages into the App SSR renderer", () => { + const ssrManifest = JSON.parse( + fs.readFileSync(path.join(FIXTURE_ROOT, "dist/server/ssr/.vite/manifest.json"), "utf8"), + ) as Record; + expect( + Object.entries(ssrManifest).some( + ([key, entry]) => + key.includes("vinext-request-stage") || + key.includes("vinext-response-stage") || + entry.src?.includes("vinext-request-stage") || + entry.src?.includes("vinext-response-stage"), + ), + ).toBe(false); + }); + + it("streams and reuses a shared render below request-specific middleware", async () => { + const url = `${requestServer.origin}/stream-${Date.now()}`; + const first = await fetch(url, { headers: { "x-test-visitor": "visitor-a" } }); + const reader = first.body!.getReader(); + const firstChunk = await reader.read(); + const firstChunkAt = performance.now(); + const chunks = firstChunk.done ? [] : [firstChunk.value]; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + chunks.push(chunk.value); + } + const completedAt = performance.now(); + const firstBody = new TextDecoder().decode( + chunks.length === 1 ? chunks[0] : Uint8Array.from(chunks.flatMap((chunk) => [...chunk])), + ); + + expect(first.status, firstBody).toBe(200); + expect(first.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(first.headers.get("x-http-stage-visitor")).toBe("visitor-a"); + expect(first.headers.get("x-http-request-stage-pid")).not.toBe( + first.headers.get("x-http-response-stage-pid"), + ); + expect(firstChunkAt).toBeLessThan(completedAt); + expect(completedAt - firstChunkAt).toBeGreaterThanOrEqual(180); + + const second = await fetch(url, { headers: { "x-test-visitor": "visitor-b" } }); + const secondBody = await second.text(); + expect(second.status).toBe(200); + expect(second.headers.get("x-http-stage-cache")).toBe("HIT"); + expect(second.headers.get("x-http-stage-visitor")).toBe("visitor-b"); + expect(renderToken(secondBody)).toBe(renderToken(firstBody)); + }); + + it("reuses an App render below personalized middleware", async () => { + const url = `${requestServer.origin}/app-stage/app-${Date.now()}`; + const first = await fetch(url, { headers: { "x-test-visitor": "visitor-a" } }); + const firstBody = await first.text(); + const second = await fetch(url, { headers: { "x-test-visitor": "visitor-b" } }); + const secondBody = await second.text(); + + expect(first.status, firstBody).toBe(200); + expect(first.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(first.headers.get("x-http-stage-visitor")).toBe("visitor-a"); + expect(second.status, secondBody).toBe(200); + expect(second.headers.get("x-http-stage-cache")).toBe("HIT"); + expect(second.headers.get("x-http-stage-visitor")).toBe("visitor-b"); + expect(renderToken(secondBody)).toBe(renderToken(firstBody)); + }); + + it("bypasses shared App and Pages renders in draft mode", async () => { + const suffix = Date.now(); + const urls = [ + `${requestServer.origin}/app-stage/draft-${suffix}`, + `${requestServer.origin}/draft-${suffix}`, + ]; + + const anonymousTokens = new Map(); + for (const url of urls) { + const first = await fetch(url); + const firstBody = await first.text(); + const cached = await fetch(url); + const cachedBody = await cached.text(); + + expect(first.status, firstBody).toBe(200); + expect(first.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(firstBody).toContain('data-draft-mode="false"'); + expect(cached.headers.get("x-http-stage-cache")).toBe("HIT"); + expect(renderToken(cachedBody)).toBe(renderToken(firstBody)); + anonymousTokens.set(url, renderToken(firstBody)); + } + + const enableDraft = await fetch(`${requestServer.origin}/api/draft-enable`); + expect(enableDraft.status, await enableDraft.clone().text()).toBe(200); + expect(enableDraft.headers.get("x-http-stage-cache")).toBe("BYPASS"); + const draftCookie = enableDraft.headers + .getSetCookie() + .map((cookie) => cookie.split(";", 1)[0]) + .join("; "); + expect(draftCookie).toContain("__prerender_bypass="); + + for (const url of urls) { + const draft = await fetch(url, { headers: { cookie: draftCookie } }); + const draftBody = await draft.text(); + expect(draft.status, draftBody).toBe(200); + expect(draft.headers.get("x-http-stage-cache")).toBe("BYPASS"); + expect(draft.headers.get("cache-control")).toContain("no-store"); + expect(draftBody).toContain('data-draft-mode="true"'); + expect(renderToken(draftBody)).not.toBe(anonymousTokens.get(url)); + + const anonymous = await fetch(url); + const anonymousBody = await anonymous.text(); + expect(anonymous.headers.get("x-http-stage-cache")).toBe("HIT"); + expect(anonymousBody).toContain('data-draft-mode="false"'); + expect(renderToken(anonymousBody)).toBe(anonymousTokens.get(url)); + } + }); + + it("clears stale shared-cache policy when an admitted response becomes private", () => { + expect( + createHttpStageCacheAdapter().buildResponseHeaders({ + cacheControl: "no-store", + tags: ["stale-tag"], + }), + ).toEqual({ + "Cache-Control": "no-store", + "Cache-Tag": null, + "CDN-Cache-Control": null, + }); + }); + + it("bypasses host storage for an application-defined Vary field", async () => { + const url = `${requestServer.origin}/api/custom-vary`; + const first = await fetch(url, { headers: { "x-http-variant": "a" } }); + const firstBody = await first.text(); + const second = await fetch(url, { headers: { "x-http-variant": "b" } }); + const secondBody = await second.text(); + + for (const response of [first, second]) { + expect(response.status).toBe(200); + expect( + response.headers.get("x-http-stage-cache"), + JSON.stringify(Object.fromEntries(response.headers)), + ).toBe("BYPASS"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("cdn-cache-control")).toBeNull(); + expect(response.headers.get("cache-tag")).toBeNull(); + expect(response.headers.get("vary")?.toLowerCase()).toContain("x-http-variant"); + } + expect(renderToken(secondBody)).not.toBe(renderToken(firstBody)); + }); + + it("keys every framework-shaped Vary field by its raw request value", async () => { + const url = `${requestServer.origin}/api/framework-vary`; + const responses = []; + for (const nextUrl of ["/tenant-a", "/tenant-b", "/tenant-a"]) { + const response = await fetch(url, { headers: { "Next-Url": nextUrl } }); + responses.push({ + body: (await response.json()) as { "data-render-token": string; nextUrl: string }, + headers: response.headers, + }); + } + + expect( + responses.map(({ headers }) => headers.get("x-http-stage-cache")), + JSON.stringify(responses.map(({ headers }) => Object.fromEntries(headers))), + ).toEqual(["MISS", "MISS", "HIT"]); + expect(responses.map(({ body }) => body.nextUrl)).toEqual([ + "/tenant-a", + "/tenant-b", + "/tenant-a", + ]); + expect(responses[0]?.body["data-render-token"]).not.toBe( + responses[1]?.body["data-render-token"], + ); + expect(responses[2]?.body["data-render-token"]).toBe(responses[0]?.body["data-render-token"]); + }); + + it("preserves pregenerated-path guards in an independent response process", async () => { + const prerenderManifest = JSON.parse( + fs.readFileSync(path.join(FIXTURE_ROOT, "dist/server/vinext-prerender.json"), "utf8"), + ) as { pregeneratedConcretePaths: Array<[string, string[]]> }; + expect(prerenderManifest.pregeneratedConcretePaths).toContainEqual([ + "/:slug/ppr/:id", + ["/en/ppr/known"], + ]); + + const fallbackResponse = await fetch(`${requestServer.origin}/en/ppr/unknown`); + const fallbackBody = await fallbackResponse.text(); + expect(fallbackResponse.status, fallbackBody).toBe(200); + expect(fallbackBody).toContain(KNOWN_ROUTE_FALLBACK_MARKER); + + const response = await fetch(`${requestServer.origin}/en/ppr/known`); + const body = await response.text(); + + expect(response.status, body).toBe(200); + expect(response.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(body).toContain("fresh pregenerated route:en:known"); + expect(body).not.toContain(KNOWN_ROUTE_FALLBACK_MARKER); + }); + + it("keeps rewritten source identities distinct while reusing each one", async () => { + const slug = `rewrite-${Date.now()}`; + const rewritten = await fetch(`${requestServer.origin}/alias-${slug}`, { + headers: { "x-test-visitor": "alias" }, + }); + const rewrittenBody = await rewritten.text(); + const direct = await fetch(`${requestServer.origin}/${slug}`, { + headers: { "x-test-visitor": "direct" }, + }); + const directBody = await direct.text(); + + expect(rewritten.status, rewrittenBody).toBe(200); + expect(rewritten.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(rewritten.headers.get("x-http-stage-visitor")).toBe("alias"); + expect(direct.status, directBody).toBe(200); + expect(direct.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(direct.headers.get("x-http-stage-visitor")).toBe("direct"); + expect(renderToken(directBody)).not.toBe(renderToken(rewrittenBody)); + + const rewrittenHit = await fetch(`${requestServer.origin}/alias-${slug}`); + const directHit = await fetch(`${requestServer.origin}/${slug}`); + expect(rewrittenHit.headers.get("x-http-stage-cache")).toBe("HIT"); + expect(directHit.headers.get("x-http-stage-cache")).toBe("HIT"); + expect(renderToken(await rewrittenHit.text())).toBe(renderToken(rewrittenBody)); + expect(renderToken(await directHit.text())).toBe(renderToken(directBody)); + }); + + it("routes res.revalidate() back through the remote request stage", async () => { + const path = `/revalidate-${Date.now()}`; + const first = await fetch(`${requestServer.origin}${path}`); + const firstBody = await first.text(); + const cached = await fetch(`${requestServer.origin}${path}`); + const cachedBody = await cached.text(); + + expect(first.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(first.headers.get("cache-tag")).toContain(`_N_T_${path}`); + expect(cached.headers.get("x-http-stage-cache")).toBe("HIT"); + expect(renderToken(cachedBody)).toBe(renderToken(firstBody)); + + const revalidated = await fetch( + `${requestServer.origin}/api/revalidate?path=${encodeURIComponent(path)}`, + ); + expect(revalidated.status, await revalidated.clone().text()).toBe(200); + expect(revalidated.headers.get("x-http-stage-cache")).toBe("BYPASS"); + expect(await revalidated.json()).toEqual({ revalidated: true }); + + const refreshed = await fetch(`${requestServer.origin}${path}`); + const refreshedBody = await refreshed.text(); + expect(refreshed.status, refreshedBody).toBe(200); + expect(refreshed.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(renderToken(refreshedBody)).not.toBe(renderToken(firstBody)); + + const refreshedHit = await fetch(`${requestServer.origin}${path}`); + expect(refreshedHit.headers.get("x-http-stage-cache")).toBe("HIT"); + expect(renderToken(await refreshedHit.text())).toBe(renderToken(refreshedBody)); + }); + + it("partitions the host cache by the public request authority", async () => { + const path = `/tenant-${Date.now()}`; + const fetchTenant = (host: string) => + fetchWithAuthority(`${requestServer.origin}${path}`, host); + + const tenantA = await fetchTenant("tenant-a.example"); + const tenantB = await fetchTenant("tenant-b.example"); + const tenantAHit = await fetchTenant("tenant-a.example"); + const tenantBHit = await fetchTenant("tenant-b.example"); + + expect(tenantA.status).toBe(200); + expect(tenantB.status).toBe(200); + expect(tenantA.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(tenantB.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(tenantAHit.headers.get("x-http-stage-cache")).toBe("HIT"); + expect(tenantBHit.headers.get("x-http-stage-cache")).toBe("HIT"); + }); + + it("preserves Pages HEAD rendering without poisoning the GET representation", async () => { + const url = `${requestServer.origin}/head-${Date.now()}`; + const head = await fetch(url, { method: "HEAD" }); + expect(head.status).toBe(200); + expect(head.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(await head.text()).toBe(""); + + const get = await fetch(url); + expect(get.status).toBe(200); + expect(get.headers.get("x-http-stage-cache")).toBe("MISS"); + expect(renderToken(await get.text())).toBeTruthy(); + + const getHit = await fetch(url); + expect(getHit.headers.get("x-http-stage-cache")).toBe("HIT"); + }); + + it("sends bypass POST requests to the remote response process", async () => { + const post = async (visitor: string, value: string) => { + const response = await fetch(`${requestServer.origin}/api/echo`, { + body: JSON.stringify({ value }), + headers: { + "content-type": "application/json", + "x-test-visitor": visitor, + }, + method: "POST", + }); + const text = await response.text(); + let body: { + body: { value: string }; + renderToken: string; + }; + try { + body = JSON.parse(text) as typeof body; + } catch { + throw new Error(`Unexpected POST response (${response.status}): ${text}`); + } + return { + body, + headers: response.headers, + status: response.status, + }; + }; + + const first = await post("visitor-a", "first"); + const second = await post("visitor-b", "second"); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(first.headers.get("x-http-stage-cache")).toBe("BYPASS"); + expect(second.headers.get("x-http-stage-cache")).toBe("BYPASS"); + expect(first.headers.get("x-http-response-stage-pid")).toBeTruthy(); + expect(first.headers.get("x-http-request-stage-pid")).not.toBe( + first.headers.get("x-http-response-stage-pid"), + ); + expect(first.headers.get("x-http-stage-visitor")).toBe("visitor-a"); + expect(second.headers.get("x-http-stage-visitor")).toBe("visitor-b"); + expect(first.body.body).toEqual({ value: "first" }); + expect(second.body.body).toEqual({ value: "second" }); + expect(second.body.renderToken).not.toBe(first.body.renderToken); + }); + + it("resolves full-graph public file signals after an HTTP stage round trip", async () => { + const url = `${requestServer.origin}/stage-asset.txt`; + const get = await fetch(url, { + headers: { "cache-control": "no-cache", "x-test-visitor": "asset-get" }, + }); + expect(get.status).toBe(200); + expect(get.headers.get("x-http-stage-cache")).toBe("BYPASS"); + expect(get.headers.get("x-http-stage-visitor")).toBe("asset-get"); + expect(get.headers.get("content-encoding")).toBeNull(); + expect(get.headers.get("content-length")).toBe( + String(Buffer.byteLength("HTTP_STAGE_PUBLIC_ASSET\n")), + ); + expect(get.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(get.headers.get("transfer-encoding")).toBeNull(); + expect(get.headers.get("x-http-request-stage-pid")).not.toBe( + get.headers.get("x-http-response-stage-pid"), + ); + await expect(get.text()).resolves.toBe("HTTP_STAGE_PUBLIC_ASSET\n"); + + const head = await fetch(url, { + headers: { "cache-control": "no-cache", "x-test-visitor": "asset-head" }, + method: "HEAD", + }); + expect(head.status).toBe(200); + expect(head.headers.get("x-http-stage-cache")).toBe("BYPASS"); + expect(head.headers.get("x-http-stage-visitor")).toBe("asset-head"); + expect(head.headers.get("content-length")).toBe( + String(Buffer.byteLength("HTTP_STAGE_PUBLIC_ASSET\n")), + ); + await expect(head.text()).resolves.toBe(""); + + // Matches Next.js static-file method handling: + // https://github.com/vercel/next.js/blob/canary/test/production/pages-dir/production/test/index.test.ts + const post = await fetch(url, { method: "POST" }); + expect(post.status).toBe(405); + expect(post.headers.get("allow")).toBe("GET, HEAD"); + await expect(post.text()).resolves.toBe("Method Not Allowed"); + }); + + it("does not trust a user response carrying the stage signal header", async () => { + const response = await fetch(`${requestServer.origin}/api/forged-stage-signal`, { + headers: { "cache-control": "no-cache" }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("x-http-stage-cache")).toBe("BYPASS"); + expect(response.headers.get("x-vinext-stage-static-file")).toBeNull(); + await expect(response.text()).resolves.toBe("forged stage signal stayed user content"); + }); +});