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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/vinext/src/build/inject-pregenerated-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions packages/vinext/src/build/server-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
95 changes: 82 additions & 13 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1549,6 +1550,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
},
});
const pagesClientAssetsOutputDirs = new Set<string>();
const resolvePagesClientAssetsOutputDir = (environmentName: string, outputDir: string): string =>
Comment thread
james-elicx marked this conversation as resolved.
!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.
Expand Down Expand Up @@ -1579,6 +1584,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
let rscActionOwnerRoutes: Awaited<ReturnType<typeof appRouter>> | null = null;
let rscActionOwnerSharedRoots: string[] = [];
const serverEntryKindsByEnvironment = new Map<string, Set<string>>();
const serverRuntimeOutputDirs = new Set<string>();

function recordServerEntryLoad(environmentName: string | undefined, id: string): void {
if (!environmentName) return;
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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" });
Comment thread
james-elicx marked this conversation as resolved.
Comment thread
james-elicx marked this conversation as resolved.
}
},

outputOptions(output) {
const environment = this.environment;
if (
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
23 changes: 18 additions & 5 deletions packages/vinext/src/plugins/action-owner-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function createActionOwnerManifestPlugin(options: {
let config: ResolvedConfig;
let routeReachability: ActionOwnerRouteReachability = new Map();
let manifest: Record<string, string[]> = {};
const rscOutputDirs = new Set<string>();

return {
name: "vinext:action-owner-manifest",
Expand All @@ -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) {
Expand Down Expand Up @@ -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?.();
},
},
Expand Down
33 changes: 33 additions & 0 deletions packages/vinext/src/server/app-rsc-vary.ts
Original file line number Diff line number Diff line change
@@ -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());
}
8 changes: 2 additions & 6 deletions packages/vinext/src/server/cacheability-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down
35 changes: 7 additions & 28 deletions packages/vinext/src/server/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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";

Expand All @@ -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";

Expand Down Expand Up @@ -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";

Expand Down
Loading
Loading