Skip to content
22 changes: 20 additions & 2 deletions packages/vinext/src/build/static-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,10 @@ export async function staticExportApp(
options: AppStaticExportOptions,
): Promise<StaticExportResult> {
const { baseUrl, routes, server, outDir, config } = options;
const staticExportToken =
typeof (server as any).__vinextStaticExportToken === "string"
? ((server as any).__vinextStaticExportToken as string)
: null;
const result: StaticExportResult = {
pageCount: 0,
files: [],
Expand Down Expand Up @@ -709,7 +713,14 @@ export async function staticExportApp(
// Fetch each URL from the dev server and write HTML
for (const urlPath of urlsToRender) {
try {
const res = await fetch(`${baseUrl}${urlPath}`);
const res = await fetch(
`${baseUrl}${urlPath}`,
staticExportToken
? {
headers: { "x-vinext-static-export": staticExportToken },
}
: undefined,
);
if (!res.ok) {
result.errors.push({
route: urlPath,
Expand All @@ -736,7 +747,14 @@ export async function staticExportApp(

// Render 404 page
try {
const res = await fetch(`${baseUrl}/__nonexistent_page_for_404__`);
const res = await fetch(
`${baseUrl}/__nonexistent_page_for_404__`,
staticExportToken
? {
headers: { "x-vinext-static-export": staticExportToken },
}
: undefined,
);
if (res.status === 404) {
const html = await res.text();
if (html.length > 0) {
Expand Down
101 changes: 89 additions & 12 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ const requestPipelinePath = fileURLToPath(
const requestContextShimPath = fileURLToPath(
new URL("../shims/request-context.js", import.meta.url),
).replace(/\\/g, "/");
const middlewareRequestHeadersPath = fileURLToPath(
new URL("../server/middleware-request-headers.js", import.meta.url),
).replace(/\\/g, "/");
const preparedStatePath = fileURLToPath(
new URL("../server/app-router-prepared-state.js", import.meta.url),
).replace(/\\/g, "/");

/**
* Resolved config options relevant to App Router request handling.
Expand Down Expand Up @@ -257,6 +263,8 @@ ${instrumentationPath ? `import * as _instrumentation from ${JSON.stringify(inst
${effectiveMetaRoutes.length > 0 ? `import { sitemapToXml, robotsToText, manifestToJson } from ${JSON.stringify(fileURLToPath(new URL("../server/metadata-routes.js", import.meta.url)).replace(/\\/g, "/"))};` : ""}
import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from ${JSON.stringify(configMatchersPath)};
import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from ${JSON.stringify(requestPipelinePath)};
import { buildRequestHeadersFromMiddlewareResponse } from ${JSON.stringify(middlewareRequestHeadersPath)};
import { readAppRouterPreparedRequestState, sanitizeAppRouterPreparedRequestHeaders } from ${JSON.stringify(preparedStatePath)};
import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache";
import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)};
import { runWithFetchCache } from "vinext/fetch-cache";
Expand Down Expand Up @@ -1384,6 +1392,35 @@ export default async function handler(request, ctx) {
`
: ""
}
const __hostPreparedState = readAppRouterPreparedRequestState(request.headers);
let __configHeadersRequest = request;
if (__hostPreparedState.hasStateHeaders) {
const __sanitizedHeaders = sanitizeAppRouterPreparedRequestHeaders(request.headers);
const __sourceUrl = __hostPreparedState.sourceUrl
? new URL(__hostPreparedState.sourceUrl, request.url).href
: request.url;
const __targetUrl = __hostPreparedState.targetUrl
? new URL(__hostPreparedState.targetUrl, request.url).href
: request.url;
const __preparedRequestHeaders = __hostPreparedState.middlewareHeaders
? buildRequestHeadersFromMiddlewareResponse(
__sanitizedHeaders,
__hostPreparedState.middlewareHeaders,
) ?? __sanitizedHeaders
: __sanitizedHeaders;
__configHeadersRequest = new Request(__sourceUrl, {
method: request.method,
headers: __sanitizedHeaders,
});
request = new Request(__targetUrl, {
method: request.method,
headers: __preparedRequestHeaders,
body: request.body,
// @ts-expect-error -- duplex is required when reusing a streaming body
duplex: request.body ? "half" : undefined,
});
}
const __hostPrepared = __hostPreparedState.prepared;
// Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure
// per-request isolation for all state modules. Each runWith*() creates an
// ALS scope that propagates through all async continuations (including RSC
Expand All @@ -1399,18 +1436,23 @@ export default async function handler(request, ctx) {
_runWithCacheState(() =>
_runWithPrivateCache(() =>
runWithFetchCache(async () => {
const __reqCtx = requestContextFromRequest(request);
const __reqCtx = requestContextFromRequest(__configHeadersRequest);
// Per-request container for middleware state. Passed into
// _handleRequest which fills in .headers and .status;
// avoids module-level variables that race on Workers.
const _mwCtx = { headers: null, status: null };
const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx);
const _mwCtx = {
headers: __hostPreparedState.middlewareHeaders
? new Headers(__hostPreparedState.middlewareHeaders)
: null,
status: __hostPreparedState.rewriteStatus,
};
const response = await _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared);
// Apply custom headers from next.config.js to non-redirect responses.
// Skip redirects (3xx) because Response.redirect() creates immutable headers,
// and Next.js doesn't apply custom headers to redirects anyway.
if (response && response.headers && !(response.status >= 300 && response.status < 400)) {
if (__configHeaders.length) {
const url = new URL(request.url);
const url = new URL(__configHeadersRequest.url);
let pathname;
try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""}
Expand Down Expand Up @@ -1440,7 +1482,7 @@ export default async function handler(request, ctx) {
return ctx ? _runWithExecutionContext(ctx, _run) : _run();
}

async function _handleRequest(request, __reqCtx, _mwCtx, ctx) {
async function _handleRequest(request, __reqCtx, _mwCtx, ctx, __hostPrepared) {
const __reqStart = process.env.NODE_ENV !== "production" ? performance.now() : 0;
let __compileEnd;
let __renderEnd;
Expand Down Expand Up @@ -1484,7 +1526,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) {
if (__tsRedirect) return __tsRedirect;

// ── Apply redirects from next.config.js ───────────────────────────────
if (__configRedirects.length) {
if (!__hostPrepared && __configRedirects.length) {
// Strip .rsc suffix before matching redirect rules - RSC (client-side nav) requests
// arrive as /some/path.rsc but redirect patterns are defined without it (e.g.
// /some/path). Without this, soft-nav fetches bypass all config redirects.
Expand Down Expand Up @@ -1515,6 +1557,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) {
${
middlewarePath
? `
if (!__hostPrepared) {
// Run proxy/middleware if present and path matches.
// Validate exports match the file type (proxy.ts vs middleware.ts), matching Next.js behavior.
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts
Expand Down Expand Up @@ -1596,6 +1639,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) {
applyMiddlewareRequestHeaders(_mwCtx.headers);
processMiddlewareHeaders(_mwCtx.headers);
}
}
`
: ""
}
Expand All @@ -1604,12 +1648,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) {
// These run after middleware in the App Router execution order and should
// evaluate has/missing conditions against middleware-modified headers.
// When no middleware is present, this falls back to requestContextFromRequest.
const __postMwReqCtx = __buildPostMwRequestContext(request);
const __postMwReqCtx = __hostPrepared ? requestContextFromRequest(request) : __buildPostMwRequestContext(request);

// ── Apply beforeFiles rewrites from next.config.js ────────────────────
// In App Router execution order, beforeFiles runs after middleware so that
// has/missing conditions can evaluate against middleware-modified headers.
if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) {
if (!__hostPrepared && __configRewrites.beforeFiles && __configRewrites.beforeFiles.length) {
const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx);
if (__rewritten) {
if (isExternalUrl(__rewritten)) {
Expand Down Expand Up @@ -1873,7 +1917,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) {
}

// ── Apply afterFiles rewrites from next.config.js ──────────────────────
if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) {
if (!__hostPrepared && __configRewrites.afterFiles && __configRewrites.afterFiles.length) {
const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx);
if (__afterRewritten) {
if (isExternalUrl(__afterRewritten)) {
Expand All @@ -1888,7 +1932,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) {
let match = matchRoute(cleanPathname, routes);

// ── Fallback rewrites from next.config.js (if no route matched) ───────
if (!match && __configRewrites.fallback && __configRewrites.fallback.length) {
if (!__hostPrepared && !match && __configRewrites.fallback && __configRewrites.fallback.length) {
const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx);
if (__fallbackRewritten) {
if (isExternalUrl(__fallbackRewritten)) {
Expand Down Expand Up @@ -2708,8 +2752,41 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) {
// Check for draftMode Set-Cookie header (from draftMode().enable()/disable())
const draftCookie = getDraftModeCookieHeader();

setHeadersContext(null);
setNavigationContext(null);
// Keep request-scoped headers/navigation state alive until the HTML stream is
// fully consumed. Libraries like better-auth call cookies() from async hooks
// that run after handleSsr() returns, while the response is still streaming.
let __requestStateCleaned = false;
function __cleanupRequestState() {
if (__requestStateCleaned) return;
__requestStateCleaned = true;
setHeadersContext(null);
setNavigationContext(null);
}

const __htmlReader = htmlStream.getReader();
htmlStream = new ReadableStream({
async pull(controller) {
try {
const chunk = await __htmlReader.read();
if (chunk.done) {
controller.close();
__cleanupRequestState();
return;
}
controller.enqueue(chunk.value);
} catch (error) {
__cleanupRequestState();
controller.error(error);
}
},
async cancel(reason) {
try {
await __htmlReader.cancel(reason);
} finally {
__cleanupRequestState();
}
},
});

// Helper to attach draftMode cookie, middleware headers, font Link header, and rewrite status to a response
function attachMiddlewareContext(response) {
Expand Down
Loading
Loading