From 31f0065908c7a3e7f7fb5247195b8efa29c0ebe9 Mon Sep 17 00:00:00 2001
From: Divanshu Chauhan
Date: Tue, 10 Mar 2026 22:21:15 -0700
Subject: [PATCH 1/6] refactor: consolidate 5 nested ALS scopes into unified
request context
Every App Router request previously ran through 5-6 nested
AsyncLocalStorage.run() calls (headers, navigation, cache-state,
private-cache, fetch-cache, plus optional execution-context). Each
ALS scope push/pop has measurable cost on Workers isolates.
Introduce a single UnifiedRequestContext that holds all per-request
state in one flat object, backed by one ALS instance. Each shim module
checks isInsideUnifiedScope() first and reads its sub-fields from the
unified store, falling back to its own standalone ALS when outside
(SSR environment, Pages Router, tests).
- Create unified-request-context.ts with createRequestContext(),
runWithRequestContext(), getRequestContext(), isInsideUnifiedScope()
- Rewire _getState() in headers, navigation-state, cache, cache-runtime,
fetch-cache, and request-context to dual-path (unified first, own ALS
fallback)
- Make each runWith*() a no-op inside unified scope
- Replace 5-deep nesting in app-rsc-entry.ts (main handler + ISR regen)
with single _runWithUnifiedCtx() call
- Export ensureFetchPatch() from fetch-cache for standalone patch install
- Add 17 unit tests for unified context (isolation, nesting, concurrency)
---
packages/vinext/src/entries/app-rsc-entry.ts | 219 +++++++-------
packages/vinext/src/index.ts | 1 +
packages/vinext/src/shims/cache-runtime.ts | 15 +
packages/vinext/src/shims/cache.ts | 14 +-
packages/vinext/src/shims/fetch-cache.ts | 15 +
packages/vinext/src/shims/headers.ts | 52 ++--
packages/vinext/src/shims/navigation-state.ts | 22 +-
packages/vinext/src/shims/request-context.ts | 8 +
.../src/shims/unified-request-context.ts | 129 ++++++++
tests/unified-request-context.test.ts | 283 ++++++++++++++++++
10 files changed, 594 insertions(+), 164 deletions(-)
create mode 100644 packages/vinext/src/shims/unified-request-context.ts
create mode 100644 tests/unified-request-context.test.ts
diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index 099e9fca..8c4cc8b7 100644
--- a/packages/vinext/src/entries/app-rsc-entry.ts
+++ b/packages/vinext/src/entries/app-rsc-entry.ts
@@ -252,7 +252,7 @@ import {
import { AsyncLocalStorage } from "node:async_hooks";
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -262,13 +262,13 @@ ${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 { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache";
-import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)};
-import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache";
+import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache";
+import { getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)};
+import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache";
import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(routeTriePath)};
-import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime";
// Import server-only state module to register ALS-backed accessors.
-import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state";
+import "vinext/navigation-state";
+import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context";
import { reportRequestError as _reportRequestError } from "vinext/instrumentation";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
@@ -722,7 +722,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: statusCode,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -876,7 +876,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: 200,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -1398,60 +1398,50 @@ export default async function handler(request, ctx) {
`
: ""
}
- // 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
- // streaming), preventing state leakage between concurrent requests on
- // Cloudflare Workers and other concurrent runtimes.
- //
- // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so
- // that KVCacheHandler._putInBackground can register background KV puts with
- // ctx.waitUntil() without needing ctx passed at construction time.
+ // Wrap the entire request in a single unified ALS scope for per-request
+ // isolation. All state modules (headers, navigation, cache, fetch-cache,
+ // execution-context) read from this store via isInsideUnifiedScope().
const headersCtx = headersContextFromRequest(request);
- const _run = () => runWithHeadersContext(headersCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- const __reqCtx = requestContextFromRequest(request);
- // 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);
- // 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);
- let pathname;
- try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
- ${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""}
- const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
- for (const h of extraHeaders) {
- // Use append() for headers where multiple values must coexist
- // (Vary, Set-Cookie). Using set() on these would destroy
- // existing values like "Vary: RSC, Accept" which are critical
- // for correct CDN caching behavior.
- const lk = h.key.toLowerCase();
- if (lk === "vary" || lk === "set-cookie") {
- response.headers.append(h.key, h.value);
- } else if (!response.headers.has(lk)) {
- // Middleware headers take precedence: skip config keys already
- // set by middleware so middleware headers always win.
- response.headers.set(h.key, h.value);
- }
- }
- }
- }
- return response;
- })
- )
- )
- )
- );
- return ctx ? _runWithExecutionContext(ctx, _run) : _run();
+ const __uCtx = _createUnifiedCtx({
+ headersContext: headersCtx,
+ executionContext: ctx ?? null,
+ });
+ return _runWithUnifiedCtx(__uCtx, async () => {
+ _ensureFetchPatch();
+ const __reqCtx = requestContextFromRequest(request);
+ // 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);
+ // 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);
+ 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) || "/";` : ""}
+ const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
+ for (const h of extraHeaders) {
+ // Use append() for headers where multiple values must coexist
+ // (Vary, Set-Cookie). Using set() on these would destroy
+ // existing values like "Vary: RSC, Accept" which are critical
+ // for correct CDN caching behavior.
+ const lk = h.key.toLowerCase();
+ if (lk === "vary" || lk === "set-cookie") {
+ response.headers.append(h.key, h.value);
+ } else if (!response.headers.has(lk)) {
+ // Middleware headers take precedence: skip config keys already
+ // set by middleware so middleware headers always win.
+ response.headers.set(h.key, h.value);
+ }
+ }
+ }
+ }
+ return response;
+ });
}
async function _handleRequest(request, __reqCtx, _mwCtx) {
@@ -1711,7 +1701,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
// Set navigation context for Server Components.
- // Note: Headers context is already set by runWithHeadersContext in the handler wrapper.
+ // Note: Headers context is already set by runWithRequestContext in the handler wrapper.
setNavigationContext({
pathname: cleanPathname,
searchParams: url.searchParams,
@@ -1851,7 +1841,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// Collect cookies set during the action synchronously (before stream is consumed).
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
const actionPendingCookies = getAndClearPendingCookies();
const actionDraftCookie = getDraftModeCookieHeader();
@@ -2208,57 +2198,54 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// user request — to prevent user-specific cookies/auth headers from leaking
// into content that is cached and served to all subsequent users.
const __revalHeadCtx = { headers: new Headers(), cookies: new Map() };
- const __revalResult = await runWithHeadersContext(__revalHeadCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
- const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
- const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
- const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
- // Tee RSC stream: one for SSR, one to capture rscData
- const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
- // Capture rscData bytes in parallel with SSR
- const __rscDataPromise = (async () => {
- const __rscReader = __revalRscForCapture.getReader();
- const __rscChunks = [];
- let __rscTotal = 0;
- for (;;) {
- const { done, value } = await __rscReader.read();
- if (done) break;
- __rscChunks.push(value);
- __rscTotal += value.byteLength;
- }
- const __rscBuf = new Uint8Array(__rscTotal);
- let __rscOff = 0;
- for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
- return __rscBuf.buffer;
- })();
- const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
- const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
- const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
- setHeadersContext(null);
- setNavigationContext(null);
- // Collect the full HTML string from the stream
- const __revalReader = __revalHtmlStream.getReader();
- const __revalDecoder = new TextDecoder();
- const __revalChunks = [];
- for (;;) {
- const { done, value } = await __revalReader.read();
- if (done) break;
- __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
- }
- __revalChunks.push(__revalDecoder.decode());
- const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
- const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
- })
- )
- )
- )
- );
+ const __revalUCtx = _createUnifiedCtx({
+ headersContext: __revalHeadCtx,
+ executionContext: _getRequestExecutionContext(),
+ });
+ const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => {
+ _ensureFetchPatch();
+ setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
+ const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
+ const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
+ const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
+ // Tee RSC stream: one for SSR, one to capture rscData
+ const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
+ // Capture rscData bytes in parallel with SSR
+ const __rscDataPromise = (async () => {
+ const __rscReader = __revalRscForCapture.getReader();
+ const __rscChunks = [];
+ let __rscTotal = 0;
+ for (;;) {
+ const { done, value } = await __rscReader.read();
+ if (done) break;
+ __rscChunks.push(value);
+ __rscTotal += value.byteLength;
+ }
+ const __rscBuf = new Uint8Array(__rscTotal);
+ let __rscOff = 0;
+ for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
+ return __rscBuf.buffer;
+ })();
+ const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
+ const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
+ const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ setHeadersContext(null);
+ setNavigationContext(null);
+ // Collect the full HTML string from the stream
+ const __revalReader = __revalHtmlStream.getReader();
+ const __revalDecoder = new TextDecoder();
+ const __revalChunks = [];
+ for (;;) {
+ const { done, value } = await __revalReader.read();
+ if (done) break;
+ __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
+ }
+ __revalChunks.push(__revalDecoder.decode());
+ const __freshHtml = __revalChunks.join("");
+ const __freshRscData = await __rscDataPromise;
+ const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
+ return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ });
// Write HTML and RSC to their own keys independently — no races
await Promise.all([
__isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags),
@@ -2367,7 +2354,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError });
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
return new Response(interceptStream, {
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -2602,7 +2589,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// NOTE: Do NOT clear headers/navigation context here!
// The RSC stream is consumed lazily - components render when chunks are read.
// If we clear context now, headers()/cookies() will fail during rendering.
- // Context will be cleared when the next request starts (via runWithHeadersContext).
+ // Context will be cleared when the next request starts (via runWithRequestContext).
const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
// Include matched route params so the client can hydrate useParams()
if (params && Object.keys(params).length > 0) {
diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts
index 414dcb97..c20dd9db 100644
--- a/packages/vinext/src/index.ts
+++ b/packages/vinext/src/index.ts
@@ -1033,6 +1033,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
"vinext/fetch-cache": path.join(shimsDir, "fetch-cache"),
"vinext/cache-runtime": path.join(shimsDir, "cache-runtime"),
"vinext/navigation-state": path.join(shimsDir, "navigation-state"),
+ "vinext/unified-request-context": path.join(shimsDir, "unified-request-context"),
"vinext/router-state": path.join(shimsDir, "router-state"),
"vinext/head-state": path.join(shimsDir, "head-state"),
"vinext/instrumentation": path.resolve(__dirname, "server", "instrumentation"),
diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts
index 17fe1752..87765fa2 100644
--- a/packages/vinext/src/shims/cache-runtime.ts
+++ b/packages/vinext/src/shims/cache-runtime.ts
@@ -35,6 +35,7 @@ import {
_registerCacheContextAccessor,
type CacheLifeConfig,
} from "./cache.js";
+import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// Cache execution context — AsyncLocalStorage for cacheLife/cacheTag
@@ -239,6 +240,13 @@ const _privateFallbackState = (_g[_PRIVATE_FALLBACK_KEY] ??= {
} satisfies PrivateCacheState) as PrivateCacheState;
function _getPrivateState(): PrivateCacheState {
+ if (isInsideUnifiedScope()) {
+ const ctx = getRequestContext();
+ if (ctx._privateCache === null) {
+ ctx._privateCache = new Map();
+ }
+ return { cache: ctx._privateCache };
+ }
return _privateAls.getStore() ?? _privateFallbackState;
}
@@ -248,6 +256,9 @@ function _getPrivateState(): PrivateCacheState {
* on concurrent runtimes.
*/
export function runWithPrivateCache(fn: () => T | Promise): T | Promise {
+ if (isInsideUnifiedScope()) {
+ return fn();
+ }
const state: PrivateCacheState = {
cache: new Map(),
};
@@ -259,6 +270,10 @@ export function runWithPrivateCache(fn: () => T | Promise): T | Promise
* Only needed when not using runWithPrivateCache() (legacy path).
*/
export function clearPrivateCache(): void {
+ if (isInsideUnifiedScope()) {
+ getRequestContext()._privateCache = new Map();
+ return;
+ }
const state = _privateAls.getStore();
if (state) {
state.cache = new Map();
diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts
index e213a4f0..68945481 100644
--- a/packages/vinext/src/shims/cache.ts
+++ b/packages/vinext/src/shims/cache.ts
@@ -20,6 +20,7 @@
import { markDynamicUsage as _markDynamic } from "./headers.js";
import { AsyncLocalStorage } from "node:async_hooks";
import { fnv1a64 } from "../utils/hash.js";
+import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// Lazy accessor for cache context — avoids circular imports with cache-runtime.
@@ -398,6 +399,9 @@ const _cacheFallbackState = (_g[_FALLBACK_KEY] ??= {
} satisfies CacheState) as CacheState;
function _getCacheState(): CacheState {
+ if (isInsideUnifiedScope()) {
+ return getRequestContext() as unknown as CacheState;
+ }
return _cacheAls.getStore() ?? _cacheFallbackState;
}
@@ -408,6 +412,9 @@ function _getCacheState(): CacheState {
* @internal
*/
export function _runWithCacheState(fn: () => T | Promise): T | Promise {
+ if (isInsideUnifiedScope()) {
+ return fn();
+ }
const state: CacheState = {
requestScopedCacheLife: null,
};
@@ -420,12 +427,7 @@ export function _runWithCacheState(fn: () => T | Promise): T | Promise
* @internal
*/
export function _initRequestScopedCacheState(): void {
- const state = _cacheAls.getStore();
- if (state) {
- state.requestScopedCacheLife = null;
- } else {
- _cacheFallbackState.requestScopedCacheLife = null;
- }
+ _getCacheState().requestScopedCacheLife = null;
}
/**
diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts
index 95aa9e0f..1a17ff1c 100644
--- a/packages/vinext/src/shims/fetch-cache.ts
+++ b/packages/vinext/src/shims/fetch-cache.ts
@@ -22,6 +22,7 @@
import { getCacheHandler, type CachedFetchValue } from "./cache.js";
import { getRequestExecutionContext } from "./request-context.js";
import { AsyncLocalStorage } from "node:async_hooks";
+import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// Cache key generation
@@ -438,6 +439,9 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= {
} satisfies FetchCacheState) as FetchCacheState;
function _getState(): FetchCacheState {
+ if (isInsideUnifiedScope()) {
+ return getRequestContext() as unknown as FetchCacheState;
+ }
return _als.getStore() ?? _fallbackState;
}
@@ -737,9 +741,20 @@ export function withFetchCache(): () => void {
*/
export async function runWithFetchCache(fn: () => Promise): Promise {
_ensurePatchInstalled();
+ if (isInsideUnifiedScope()) {
+ return fn();
+ }
return _als.run({ currentRequestTags: [] }, fn);
}
+/**
+ * Install the patched fetch without creating an ALS scope.
+ * Used by the unified request context which manages its own scope.
+ */
+export function ensureFetchPatch(): void {
+ _ensurePatchInstalled();
+}
+
/**
* Get the original (unpatched) fetch function.
* Useful for internal code that should bypass caching.
diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts
index 055cc2b0..3a9daac7 100644
--- a/packages/vinext/src/shims/headers.ts
+++ b/packages/vinext/src/shims/headers.ts
@@ -11,6 +11,7 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { buildRequestHeadersFromMiddlewareResponse } from "../server/middleware-request-headers.js";
import { parseCookieHeader } from "./internal/parse-cookie-header.js";
+import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// Request context
@@ -57,8 +58,10 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= {
} satisfies VinextHeadersShimState) as VinextHeadersShimState;
function _getState(): VinextHeadersShimState {
- const state = _als.getStore();
- return state ?? _fallbackState;
+ if (isInsideUnifiedScope()) {
+ return getRequestContext() as unknown as VinextHeadersShimState;
+ }
+ return _als.getStore() ?? _fallbackState;
}
/**
@@ -171,36 +174,16 @@ export function getHeadersContext(): HeadersContext | null {
}
export function setHeadersContext(ctx: HeadersContext | null): void {
+ const state = _getState();
if (ctx !== null) {
- // For backward compatibility, set context on the current ALS store
- // if one exists, otherwise update the fallback. Callers should
- // migrate to runWithHeadersContext() for new-request setup.
- const existing = _als.getStore();
- if (existing) {
- existing.headersContext = ctx;
- existing.dynamicUsageDetected = false;
- existing.pendingSetCookies = [];
- existing.draftModeCookieHeader = null;
- existing.phase = "render";
- } else {
- _fallbackState.headersContext = ctx;
- _fallbackState.dynamicUsageDetected = false;
- _fallbackState.pendingSetCookies = [];
- _fallbackState.draftModeCookieHeader = null;
- _fallbackState.phase = "render";
- }
- return;
- }
-
- // End of request cleanup: keep the store (so consumeDynamicUsage and
- // cookie flushing can still run), but clear the request headers/cookies.
- const state = _als.getStore();
- if (state) {
- state.headersContext = null;
+ state.headersContext = ctx;
+ state.dynamicUsageDetected = false;
+ state.pendingSetCookies = [];
+ state.draftModeCookieHeader = null;
state.phase = "render";
} else {
- _fallbackState.headersContext = null;
- _fallbackState.phase = "render";
+ state.headersContext = null;
+ state.phase = "render";
}
}
@@ -218,6 +201,17 @@ export function runWithHeadersContext(
ctx: HeadersContext,
fn: () => T | Promise,
): T | Promise {
+ if (isInsideUnifiedScope()) {
+ // Inside unified scope — update the unified store directly, no extra ALS.
+ const uCtx = getRequestContext();
+ uCtx.headersContext = ctx as unknown;
+ uCtx.dynamicUsageDetected = false;
+ uCtx.pendingSetCookies = [];
+ uCtx.draftModeCookieHeader = null;
+ uCtx.phase = "render";
+ return fn();
+ }
+
const state: VinextHeadersShimState = {
headersContext: ctx,
dynamicUsageDetected: false,
diff --git a/packages/vinext/src/shims/navigation-state.ts b/packages/vinext/src/shims/navigation-state.ts
index 8e63e2d0..5b3e962f 100644
--- a/packages/vinext/src/shims/navigation-state.ts
+++ b/packages/vinext/src/shims/navigation-state.ts
@@ -13,6 +13,7 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { _registerStateAccessors, type NavigationContext } from "./navigation.js";
+import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// ALS setup — same pattern as headers.ts
@@ -35,6 +36,9 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= {
} satisfies NavigationState) as NavigationState;
function _getState(): NavigationState {
+ if (isInsideUnifiedScope()) {
+ return getRequestContext() as unknown as NavigationState;
+ }
return _als.getStore() ?? _fallbackState;
}
@@ -44,6 +48,9 @@ function _getState(): NavigationState {
* useServerInsertedHTML callbacks on concurrent runtimes.
*/
export function runWithNavigationContext(fn: () => T | Promise): T | Promise {
+ if (isInsideUnifiedScope()) {
+ return fn();
+ }
const state: NavigationState = {
serverContext: null,
serverInsertedHTMLCallbacks: [],
@@ -61,13 +68,7 @@ _registerStateAccessors({
},
setServerContext(ctx: NavigationContext | null): void {
- const state = _als.getStore();
- if (state) {
- state.serverContext = ctx;
- } else {
- // No ALS scope — fallback for environments without als.run() wrapping.
- _fallbackState.serverContext = ctx;
- }
+ _getState().serverContext = ctx;
},
getInsertedHTMLCallbacks(): Array<() => unknown> {
@@ -75,11 +76,6 @@ _registerStateAccessors({
},
clearInsertedHTMLCallbacks(): void {
- const state = _als.getStore();
- if (state) {
- state.serverInsertedHTMLCallbacks = [];
- } else {
- _fallbackState.serverInsertedHTMLCallbacks = [];
- }
+ _getState().serverInsertedHTMLCallbacks = [];
},
});
diff --git a/packages/vinext/src/shims/request-context.ts b/packages/vinext/src/shims/request-context.ts
index a8714271..f1e7c9f8 100644
--- a/packages/vinext/src/shims/request-context.ts
+++ b/packages/vinext/src/shims/request-context.ts
@@ -22,6 +22,7 @@
*/
import { AsyncLocalStorage } from "node:async_hooks";
+import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// ExecutionContext interface
@@ -64,6 +65,10 @@ export function runWithExecutionContext(
ctx: ExecutionContextLike,
fn: () => T | Promise,
): T | Promise {
+ if (isInsideUnifiedScope()) {
+ getRequestContext().executionContext = ctx;
+ return fn();
+ }
return _als.run(ctx, fn);
}
@@ -75,6 +80,9 @@ export function runWithExecutionContext(
* complete before the Worker isolate is torn down.
*/
export function getRequestExecutionContext(): ExecutionContextLike | null {
+ if (isInsideUnifiedScope()) {
+ return getRequestContext().executionContext as ExecutionContextLike | null;
+ }
// getStore() returns undefined when called outside an ALS scope;
// normalise to null for a consistent return type.
return _als.getStore() ?? null;
diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts
new file mode 100644
index 00000000..fd330ec9
--- /dev/null
+++ b/packages/vinext/src/shims/unified-request-context.ts
@@ -0,0 +1,129 @@
+/**
+ * Unified per-request context backed by a single AsyncLocalStorage.
+ *
+ * Consolidates the 5–6 nested ALS scopes that previously wrapped every
+ * App Router request (headers, navigation, cache-state, private-cache,
+ * fetch-cache, execution-context) into one flat store.
+ *
+ * Each shim module checks `isInsideUnifiedScope()` and reads its sub-fields
+ * from the unified store, falling back to its own standalone ALS when
+ * outside (SSR environment, Pages Router, tests).
+ */
+
+import { AsyncLocalStorage } from "node:async_hooks";
+
+// ---------------------------------------------------------------------------
+// Unified context shape
+// ---------------------------------------------------------------------------
+
+/**
+ * Flat union of all per-request state previously spread across
+ * VinextHeadersShimState, NavigationState, CacheState, PrivateCacheState,
+ * FetchCacheState, and ExecutionContextLike.
+ *
+ * Each field group is documented with its source shim module.
+ */
+export interface UnifiedRequestContext {
+ // ── headers.ts (VinextHeadersShimState) ────────────────────────────
+ /** The request's headers/cookies context, or null before setup. */
+ headersContext: unknown;
+ /** Set to true when a component calls connection/cookies/headers/noStore. */
+ dynamicUsageDetected: boolean;
+ /** Accumulated Set-Cookie header strings from cookies().set()/delete(). */
+ pendingSetCookies: string[];
+ /** Set-Cookie header from draftMode().enable()/disable(). */
+ draftModeCookieHeader: string | null;
+ /** Current request phase — determines cookie mutability. */
+ phase: "render" | "action" | "route-handler";
+
+ // ── navigation-state.ts (NavigationState) ──────────────────────────
+ /** Server-side navigation context (pathname, searchParams, params). */
+ serverContext: unknown;
+ /** useServerInsertedHTML callbacks for CSS-in-JS etc. */
+ serverInsertedHTMLCallbacks: Array<() => unknown>;
+
+ // ── cache.ts (CacheState) ──────────────────────────────────────────
+ /** Request-scoped cacheLife config from page-level "use cache". */
+ requestScopedCacheLife: unknown;
+
+ // ── cache-runtime.ts (PrivateCacheState) — lazy ────────────────────
+ /** Per-request cache for "use cache: private". Null until first access. */
+ _privateCache: Map | null;
+
+ // ── fetch-cache.ts (FetchCacheState) ───────────────────────────────
+ /** Tags collected from fetch() calls during this render pass. */
+ currentRequestTags: string[];
+
+ // ── request-context.ts ─────────────────────────────────────────────
+ /** Cloudflare Workers ExecutionContext, or null on Node.js dev. */
+ executionContext: unknown;
+}
+
+// ---------------------------------------------------------------------------
+// ALS setup — stored on globalThis via Symbol.for so all Vite environments
+// (RSC/SSR/client) share the same instance.
+// ---------------------------------------------------------------------------
+
+const _ALS_KEY = Symbol.for("vinext.unifiedRequestContext.als");
+const _FALLBACK_KEY = Symbol.for("vinext.unifiedRequestContext.fallback");
+const _g = globalThis as unknown as Record;
+const _als = (_g[_ALS_KEY] ??=
+ new AsyncLocalStorage()) as AsyncLocalStorage;
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Create a fresh `UnifiedRequestContext` with defaults for all fields.
+ * Pass partial overrides for the fields you need to pre-populate.
+ */
+export function createRequestContext(opts?: Partial): UnifiedRequestContext {
+ return {
+ headersContext: null,
+ dynamicUsageDetected: false,
+ pendingSetCookies: [],
+ draftModeCookieHeader: null,
+ phase: "render",
+ serverContext: null,
+ serverInsertedHTMLCallbacks: [],
+ requestScopedCacheLife: null,
+ _privateCache: null,
+ currentRequestTags: [],
+ executionContext: null,
+ ...opts,
+ };
+}
+
+/** Module-level fallback for environments without ALS wrapping (dev, tests). */
+const _fallbackState = (_g[_FALLBACK_KEY] ??= createRequestContext()) as UnifiedRequestContext;
+
+/**
+ * Run `fn` within a unified request context scope.
+ * All shim modules will read/write their state from `ctx` for the
+ * duration of the call, including async continuations.
+ */
+export function runWithRequestContext(
+ ctx: UnifiedRequestContext,
+ fn: () => T | Promise,
+): T | Promise {
+ return _als.run(ctx, fn);
+}
+
+/**
+ * Get the current unified request context.
+ * Returns the ALS store when inside a `runWithRequestContext()` scope,
+ * or the module-level fallback otherwise.
+ */
+export function getRequestContext(): UnifiedRequestContext {
+ return _als.getStore() ?? _fallbackState;
+}
+
+/**
+ * Check whether the current execution is inside a `runWithRequestContext()` scope.
+ * Shim modules use this to decide whether to read from the unified store
+ * or fall back to their own standalone ALS.
+ */
+export function isInsideUnifiedScope(): boolean {
+ return _als.getStore() != null;
+}
diff --git a/tests/unified-request-context.test.ts b/tests/unified-request-context.test.ts
new file mode 100644
index 00000000..35cbf660
--- /dev/null
+++ b/tests/unified-request-context.test.ts
@@ -0,0 +1,283 @@
+import { describe, it, expect } from "vitest";
+import {
+ createRequestContext,
+ runWithRequestContext,
+ getRequestContext,
+ isInsideUnifiedScope,
+} from "../packages/vinext/src/shims/unified-request-context.js";
+
+describe("unified-request-context", () => {
+ describe("isInsideUnifiedScope", () => {
+ it("returns false outside any scope", () => {
+ expect(isInsideUnifiedScope()).toBe(false);
+ });
+
+ it("returns true inside a runWithRequestContext scope", () => {
+ const ctx = createRequestContext();
+ runWithRequestContext(ctx, () => {
+ expect(isInsideUnifiedScope()).toBe(true);
+ });
+ });
+ });
+
+ describe("getRequestContext", () => {
+ it("returns fallback with default values outside any scope", () => {
+ const ctx = getRequestContext();
+ expect(ctx).toBeDefined();
+ expect(ctx.headersContext).toBeNull();
+ expect(ctx.dynamicUsageDetected).toBe(false);
+ expect(ctx.pendingSetCookies).toEqual([]);
+ expect(ctx.draftModeCookieHeader).toBeNull();
+ expect(ctx.phase).toBe("render");
+ expect(ctx.serverContext).toBeNull();
+ expect(ctx.serverInsertedHTMLCallbacks).toEqual([]);
+ expect(ctx.requestScopedCacheLife).toBeNull();
+ expect(ctx._privateCache).toBeNull();
+ expect(ctx.currentRequestTags).toEqual([]);
+ expect(ctx.executionContext).toBeNull();
+ });
+ });
+
+ describe("runWithRequestContext", () => {
+ it("makes all fields accessible inside the scope", () => {
+ const headers = new Headers({ "x-test": "1" });
+ const cookies = new Map([["session", "abc"]]);
+ const fakeCtx = { waitUntil: () => {} };
+
+ const reqCtx = createRequestContext({
+ headersContext: { headers, cookies },
+ executionContext: fakeCtx,
+ });
+
+ runWithRequestContext(reqCtx, () => {
+ const ctx = getRequestContext();
+ expect((ctx.headersContext as any).headers.get("x-test")).toBe("1");
+ expect((ctx.headersContext as any).cookies.get("session")).toBe("abc");
+ expect(ctx.executionContext).toBe(fakeCtx);
+ expect(ctx.dynamicUsageDetected).toBe(false);
+ expect(ctx.phase).toBe("render");
+ expect(ctx.pendingSetCookies).toEqual([]);
+ expect(ctx.currentRequestTags).toEqual([]);
+ expect(ctx._privateCache).toBeNull();
+ });
+ });
+
+ it("returns the value from fn (sync)", () => {
+ const ctx = createRequestContext();
+ const result = runWithRequestContext(ctx, () => 42);
+ expect(result).toBe(42);
+ });
+
+ it("returns the value from fn (async)", async () => {
+ const ctx = createRequestContext();
+ const result = await runWithRequestContext(ctx, async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ return 99;
+ });
+ expect(result).toBe(99);
+ });
+
+ it("scope is exited after fn completes", async () => {
+ const ctx = createRequestContext({
+ headersContext: { headers: new Headers(), cookies: new Map() },
+ });
+
+ await runWithRequestContext(ctx, async () => {
+ expect(isInsideUnifiedScope()).toBe(true);
+ });
+
+ expect(isInsideUnifiedScope()).toBe(false);
+ });
+ });
+
+ describe("concurrent isolation", () => {
+ it("20 parallel requests each see their own headers/navigation/tags", async () => {
+ const results = await Promise.all(
+ Array.from({ length: 20 }, (_, i) => {
+ const reqCtx = createRequestContext({
+ headersContext: {
+ headers: new Headers({ "x-id": String(i) }),
+ cookies: new Map(),
+ },
+ currentRequestTags: [`tag-${i}`],
+ serverContext: { pathname: `/path-${i}` },
+ });
+ return runWithRequestContext(reqCtx, async () => {
+ // Simulate async work with varying delays
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 10));
+ const ctx = getRequestContext();
+ return {
+ headerId: (ctx.headersContext as any)?.headers?.get("x-id"),
+ tag: ctx.currentRequestTags[0],
+ pathname: (ctx.serverContext as any)?.pathname,
+ };
+ });
+ }),
+ );
+
+ for (let i = 0; i < 20; i++) {
+ expect(results[i].headerId).toBe(String(i));
+ expect(results[i].tag).toBe(`tag-${i}`);
+ expect(results[i].pathname).toBe(`/path-${i}`);
+ }
+ });
+
+ it("mutations in one scope don't leak to another", async () => {
+ const ctxA = createRequestContext();
+ const ctxB = createRequestContext();
+
+ const pA = runWithRequestContext(ctxA, async () => {
+ getRequestContext().dynamicUsageDetected = true;
+ getRequestContext().pendingSetCookies.push("a=1");
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ return {
+ dynamic: getRequestContext().dynamicUsageDetected,
+ cookies: [...getRequestContext().pendingSetCookies],
+ };
+ });
+
+ const pB = runWithRequestContext(ctxB, async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ return {
+ dynamic: getRequestContext().dynamicUsageDetected,
+ cookies: [...getRequestContext().pendingSetCookies],
+ };
+ });
+
+ const [a, b] = await Promise.all([pA, pB]);
+ expect(a.dynamic).toBe(true);
+ expect(a.cookies).toEqual(["a=1"]);
+ expect(b.dynamic).toBe(false);
+ expect(b.cookies).toEqual([]);
+ });
+ });
+
+ describe("privateCache lazy initialization", () => {
+ it("is null by default", () => {
+ const ctx = createRequestContext();
+ expect(ctx._privateCache).toBeNull();
+ });
+
+ it("stays null until explicitly set", () => {
+ const ctx = createRequestContext();
+ runWithRequestContext(ctx, () => {
+ expect(getRequestContext()._privateCache).toBeNull();
+ });
+ });
+ });
+
+ describe("nested scopes", () => {
+ it("inner runWithRequestContext overrides outer, restores on exit", () => {
+ const outerCtx = createRequestContext({
+ headersContext: {
+ headers: new Headers({ "x-id": "outer" }),
+ cookies: new Map(),
+ },
+ });
+ const innerCtx = createRequestContext({
+ headersContext: {
+ headers: new Headers({ "x-id": "inner" }),
+ cookies: new Map(),
+ },
+ });
+
+ runWithRequestContext(outerCtx, () => {
+ expect((getRequestContext().headersContext as any).headers.get("x-id")).toBe("outer");
+
+ runWithRequestContext(innerCtx, () => {
+ expect((getRequestContext().headersContext as any).headers.get("x-id")).toBe("inner");
+ });
+
+ // Outer scope restored
+ expect((getRequestContext().headersContext as any).headers.get("x-id")).toBe("outer");
+ });
+ });
+ });
+
+ describe("executionContext", () => {
+ it("is null by default", () => {
+ const ctx = createRequestContext();
+ runWithRequestContext(ctx, () => {
+ expect(getRequestContext().executionContext).toBeNull();
+ });
+ });
+
+ it("is accessible when provided", () => {
+ const calls: Promise[] = [];
+ const fakeCtx = {
+ waitUntil(p: Promise) {
+ calls.push(p);
+ },
+ };
+ const ctx = createRequestContext({ executionContext: fakeCtx });
+ runWithRequestContext(ctx, () => {
+ const ec = getRequestContext().executionContext as any;
+ expect(ec).toBe(fakeCtx);
+ ec.waitUntil(Promise.resolve("done"));
+ });
+ expect(calls).toHaveLength(1);
+ });
+ });
+
+ describe("sub-state field access", () => {
+ it("each sub-state getter returns correct sub-fields", () => {
+ const reqCtx = createRequestContext({
+ headersContext: { headers: new Headers(), cookies: new Map() },
+ dynamicUsageDetected: true,
+ pendingSetCookies: ["a=b"],
+ draftModeCookieHeader: "c=d",
+ phase: "action",
+ serverContext: { pathname: "/test" },
+ serverInsertedHTMLCallbacks: [() => "html"],
+ requestScopedCacheLife: { stale: 10, revalidate: 20 },
+ currentRequestTags: ["tag1"],
+ executionContext: { waitUntil: () => {} },
+ });
+
+ runWithRequestContext(reqCtx, () => {
+ const ctx = getRequestContext();
+ expect(ctx.dynamicUsageDetected).toBe(true);
+ expect(ctx.pendingSetCookies).toEqual(["a=b"]);
+ expect(ctx.draftModeCookieHeader).toBe("c=d");
+ expect(ctx.phase).toBe("action");
+ expect((ctx.serverContext as any).pathname).toBe("/test");
+ expect(ctx.serverInsertedHTMLCallbacks).toHaveLength(1);
+ expect(ctx.requestScopedCacheLife).toEqual({
+ stale: 10,
+ revalidate: 20,
+ });
+ expect(ctx.currentRequestTags).toEqual(["tag1"]);
+ expect(ctx.executionContext).not.toBeNull();
+ });
+ });
+ });
+
+ describe("createRequestContext", () => {
+ it("creates context with all defaults", () => {
+ const ctx = createRequestContext();
+ expect(ctx.headersContext).toBeNull();
+ expect(ctx.dynamicUsageDetected).toBe(false);
+ expect(ctx.pendingSetCookies).toEqual([]);
+ expect(ctx.draftModeCookieHeader).toBeNull();
+ expect(ctx.phase).toBe("render");
+ expect(ctx.serverContext).toBeNull();
+ expect(ctx.serverInsertedHTMLCallbacks).toEqual([]);
+ expect(ctx.requestScopedCacheLife).toBeNull();
+ expect(ctx._privateCache).toBeNull();
+ expect(ctx.currentRequestTags).toEqual([]);
+ expect(ctx.executionContext).toBeNull();
+ });
+
+ it("merges partial overrides", () => {
+ const ctx = createRequestContext({
+ phase: "action",
+ dynamicUsageDetected: true,
+ });
+ expect(ctx.phase).toBe("action");
+ expect(ctx.dynamicUsageDetected).toBe(true);
+ // Other fields get defaults
+ expect(ctx.headersContext).toBeNull();
+ expect(ctx.currentRequestTags).toEqual([]);
+ });
+ });
+});
From 3d81a101d79d3395e31098f3d1ce40a1feb5bd99 Mon Sep 17 00:00:00 2001
From: Divanshu Chauhan
Date: Tue, 10 Mar 2026 22:31:18 -0700
Subject: [PATCH 2/6] test: update entry-templates snapshots for unified ALS
context
Snapshots were stale after the unified request context refactor changed
the generated entry code (new imports, removed 5-deep nesting).
---
.../entry-templates.test.ts.snap | 1314 ++++++++---------
1 file changed, 618 insertions(+), 696 deletions(-)
diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap
index c853dc15..a17ef826 100644
--- a/tests/__snapshots__/entry-templates.test.ts.snap
+++ b/tests/__snapshots__/entry-templates.test.ts.snap
@@ -344,7 +344,7 @@ import {
import { AsyncLocalStorage } from "node:async_hooks";
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -354,13 +354,13 @@ import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, merge
import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js";
import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js";
-import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache";
-import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
-import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache";
+import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache";
+import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
+import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache";
import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js";
-import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime";
// Import server-only state module to register ALS-backed accessors.
-import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state";
+import "vinext/navigation-state";
+import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context";
import { reportRequestError as _reportRequestError } from "vinext/instrumentation";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
@@ -866,7 +866,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: statusCode,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -999,7 +999,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: 200,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -1664,60 +1664,50 @@ async function __readFormDataWithLimit(request, maxBytes) {
export default async function handler(request, ctx) {
- // 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
- // streaming), preventing state leakage between concurrent requests on
- // Cloudflare Workers and other concurrent runtimes.
- //
- // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so
- // that KVCacheHandler._putInBackground can register background KV puts with
- // ctx.waitUntil() without needing ctx passed at construction time.
+ // Wrap the entire request in a single unified ALS scope for per-request
+ // isolation. All state modules (headers, navigation, cache, fetch-cache,
+ // execution-context) read from this store via isInsideUnifiedScope().
const headersCtx = headersContextFromRequest(request);
- const _run = () => runWithHeadersContext(headersCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- const __reqCtx = requestContextFromRequest(request);
- // 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);
- // 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);
- let pathname;
- try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
-
- const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
- for (const h of extraHeaders) {
- // Use append() for headers where multiple values must coexist
- // (Vary, Set-Cookie). Using set() on these would destroy
- // existing values like "Vary: RSC, Accept" which are critical
- // for correct CDN caching behavior.
- const lk = h.key.toLowerCase();
- if (lk === "vary" || lk === "set-cookie") {
- response.headers.append(h.key, h.value);
- } else if (!response.headers.has(lk)) {
- // Middleware headers take precedence: skip config keys already
- // set by middleware so middleware headers always win.
- response.headers.set(h.key, h.value);
- }
- }
- }
- }
- return response;
- })
- )
- )
- )
- );
- return ctx ? _runWithExecutionContext(ctx, _run) : _run();
+ const __uCtx = _createUnifiedCtx({
+ headersContext: headersCtx,
+ executionContext: ctx ?? null,
+ });
+ return _runWithUnifiedCtx(__uCtx, async () => {
+ _ensureFetchPatch();
+ const __reqCtx = requestContextFromRequest(request);
+ // 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);
+ // 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);
+ let pathname;
+ try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+
+ const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
+ for (const h of extraHeaders) {
+ // Use append() for headers where multiple values must coexist
+ // (Vary, Set-Cookie). Using set() on these would destroy
+ // existing values like "Vary: RSC, Accept" which are critical
+ // for correct CDN caching behavior.
+ const lk = h.key.toLowerCase();
+ if (lk === "vary" || lk === "set-cookie") {
+ response.headers.append(h.key, h.value);
+ } else if (!response.headers.has(lk)) {
+ // Middleware headers take precedence: skip config keys already
+ // set by middleware so middleware headers always win.
+ response.headers.set(h.key, h.value);
+ }
+ }
+ }
+ }
+ return response;
+ });
}
async function _handleRequest(request, __reqCtx, _mwCtx) {
@@ -1884,7 +1874,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
// Set navigation context for Server Components.
- // Note: Headers context is already set by runWithHeadersContext in the handler wrapper.
+ // Note: Headers context is already set by runWithRequestContext in the handler wrapper.
setNavigationContext({
pathname: cleanPathname,
searchParams: url.searchParams,
@@ -2024,7 +2014,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// Collect cookies set during the action synchronously (before stream is consumed).
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
const actionPendingCookies = getAndClearPendingCookies();
const actionDraftCookie = getDraftModeCookieHeader();
@@ -2381,57 +2371,54 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// user request — to prevent user-specific cookies/auth headers from leaking
// into content that is cached and served to all subsequent users.
const __revalHeadCtx = { headers: new Headers(), cookies: new Map() };
- const __revalResult = await runWithHeadersContext(__revalHeadCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
- const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
- const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
- const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
- // Tee RSC stream: one for SSR, one to capture rscData
- const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
- // Capture rscData bytes in parallel with SSR
- const __rscDataPromise = (async () => {
- const __rscReader = __revalRscForCapture.getReader();
- const __rscChunks = [];
- let __rscTotal = 0;
- for (;;) {
- const { done, value } = await __rscReader.read();
- if (done) break;
- __rscChunks.push(value);
- __rscTotal += value.byteLength;
- }
- const __rscBuf = new Uint8Array(__rscTotal);
- let __rscOff = 0;
- for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
- return __rscBuf.buffer;
- })();
- const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
- const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
- const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
- setHeadersContext(null);
- setNavigationContext(null);
- // Collect the full HTML string from the stream
- const __revalReader = __revalHtmlStream.getReader();
- const __revalDecoder = new TextDecoder();
- const __revalChunks = [];
- for (;;) {
- const { done, value } = await __revalReader.read();
- if (done) break;
- __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
- }
- __revalChunks.push(__revalDecoder.decode());
- const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
- const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
- })
- )
- )
- )
- );
+ const __revalUCtx = _createUnifiedCtx({
+ headersContext: __revalHeadCtx,
+ executionContext: _getRequestExecutionContext(),
+ });
+ const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => {
+ _ensureFetchPatch();
+ setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
+ const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
+ const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
+ const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
+ // Tee RSC stream: one for SSR, one to capture rscData
+ const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
+ // Capture rscData bytes in parallel with SSR
+ const __rscDataPromise = (async () => {
+ const __rscReader = __revalRscForCapture.getReader();
+ const __rscChunks = [];
+ let __rscTotal = 0;
+ for (;;) {
+ const { done, value } = await __rscReader.read();
+ if (done) break;
+ __rscChunks.push(value);
+ __rscTotal += value.byteLength;
+ }
+ const __rscBuf = new Uint8Array(__rscTotal);
+ let __rscOff = 0;
+ for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
+ return __rscBuf.buffer;
+ })();
+ const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
+ const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
+ const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ setHeadersContext(null);
+ setNavigationContext(null);
+ // Collect the full HTML string from the stream
+ const __revalReader = __revalHtmlStream.getReader();
+ const __revalDecoder = new TextDecoder();
+ const __revalChunks = [];
+ for (;;) {
+ const { done, value } = await __revalReader.read();
+ if (done) break;
+ __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
+ }
+ __revalChunks.push(__revalDecoder.decode());
+ const __freshHtml = __revalChunks.join("");
+ const __freshRscData = await __rscDataPromise;
+ const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
+ return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ });
// Write HTML and RSC to their own keys independently — no races
await Promise.all([
__isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags),
@@ -2540,7 +2527,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError });
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
return new Response(interceptStream, {
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -2775,7 +2762,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// NOTE: Do NOT clear headers/navigation context here!
// The RSC stream is consumed lazily - components render when chunks are read.
// If we clear context now, headers()/cookies() will fail during rendering.
- // Context will be cleared when the next request starts (via runWithHeadersContext).
+ // Context will be cleared when the next request starts (via runWithRequestContext).
const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
// Include matched route params so the client can hydrate useParams()
if (params && Object.keys(params).length > 0) {
@@ -3116,7 +3103,7 @@ import {
import { AsyncLocalStorage } from "node:async_hooks";
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -3126,13 +3113,13 @@ import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, merge
import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js";
import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js";
-import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache";
-import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
-import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache";
+import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache";
+import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
+import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache";
import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js";
-import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime";
// Import server-only state module to register ALS-backed accessors.
-import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state";
+import "vinext/navigation-state";
+import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context";
import { reportRequestError as _reportRequestError } from "vinext/instrumentation";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
@@ -3638,7 +3625,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: statusCode,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -3771,7 +3758,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: 200,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -4436,60 +4423,50 @@ async function __readFormDataWithLimit(request, maxBytes) {
export default async function handler(request, ctx) {
- // 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
- // streaming), preventing state leakage between concurrent requests on
- // Cloudflare Workers and other concurrent runtimes.
- //
- // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so
- // that KVCacheHandler._putInBackground can register background KV puts with
- // ctx.waitUntil() without needing ctx passed at construction time.
+ // Wrap the entire request in a single unified ALS scope for per-request
+ // isolation. All state modules (headers, navigation, cache, fetch-cache,
+ // execution-context) read from this store via isInsideUnifiedScope().
const headersCtx = headersContextFromRequest(request);
- const _run = () => runWithHeadersContext(headersCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- const __reqCtx = requestContextFromRequest(request);
- // 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);
- // 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);
- let pathname;
- try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
- if (pathname.startsWith("/base")) pathname = pathname.slice("/base".length) || "/";
- const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
- for (const h of extraHeaders) {
- // Use append() for headers where multiple values must coexist
- // (Vary, Set-Cookie). Using set() on these would destroy
- // existing values like "Vary: RSC, Accept" which are critical
- // for correct CDN caching behavior.
- const lk = h.key.toLowerCase();
- if (lk === "vary" || lk === "set-cookie") {
- response.headers.append(h.key, h.value);
- } else if (!response.headers.has(lk)) {
- // Middleware headers take precedence: skip config keys already
- // set by middleware so middleware headers always win.
- response.headers.set(h.key, h.value);
- }
- }
- }
- }
- return response;
- })
- )
- )
- )
- );
- return ctx ? _runWithExecutionContext(ctx, _run) : _run();
+ const __uCtx = _createUnifiedCtx({
+ headersContext: headersCtx,
+ executionContext: ctx ?? null,
+ });
+ return _runWithUnifiedCtx(__uCtx, async () => {
+ _ensureFetchPatch();
+ const __reqCtx = requestContextFromRequest(request);
+ // 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);
+ // 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);
+ let pathname;
+ try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+ if (pathname.startsWith("/base")) pathname = pathname.slice("/base".length) || "/";
+ const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
+ for (const h of extraHeaders) {
+ // Use append() for headers where multiple values must coexist
+ // (Vary, Set-Cookie). Using set() on these would destroy
+ // existing values like "Vary: RSC, Accept" which are critical
+ // for correct CDN caching behavior.
+ const lk = h.key.toLowerCase();
+ if (lk === "vary" || lk === "set-cookie") {
+ response.headers.append(h.key, h.value);
+ } else if (!response.headers.has(lk)) {
+ // Middleware headers take precedence: skip config keys already
+ // set by middleware so middleware headers always win.
+ response.headers.set(h.key, h.value);
+ }
+ }
+ }
+ }
+ return response;
+ });
}
async function _handleRequest(request, __reqCtx, _mwCtx) {
@@ -4659,7 +4636,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
// Set navigation context for Server Components.
- // Note: Headers context is already set by runWithHeadersContext in the handler wrapper.
+ // Note: Headers context is already set by runWithRequestContext in the handler wrapper.
setNavigationContext({
pathname: cleanPathname,
searchParams: url.searchParams,
@@ -4799,7 +4776,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// Collect cookies set during the action synchronously (before stream is consumed).
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
const actionPendingCookies = getAndClearPendingCookies();
const actionDraftCookie = getDraftModeCookieHeader();
@@ -5156,57 +5133,54 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// user request — to prevent user-specific cookies/auth headers from leaking
// into content that is cached and served to all subsequent users.
const __revalHeadCtx = { headers: new Headers(), cookies: new Map() };
- const __revalResult = await runWithHeadersContext(__revalHeadCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
- const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
- const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
- const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
- // Tee RSC stream: one for SSR, one to capture rscData
- const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
- // Capture rscData bytes in parallel with SSR
- const __rscDataPromise = (async () => {
- const __rscReader = __revalRscForCapture.getReader();
- const __rscChunks = [];
- let __rscTotal = 0;
- for (;;) {
- const { done, value } = await __rscReader.read();
- if (done) break;
- __rscChunks.push(value);
- __rscTotal += value.byteLength;
- }
- const __rscBuf = new Uint8Array(__rscTotal);
- let __rscOff = 0;
- for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
- return __rscBuf.buffer;
- })();
- const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
- const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
- const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
- setHeadersContext(null);
- setNavigationContext(null);
- // Collect the full HTML string from the stream
- const __revalReader = __revalHtmlStream.getReader();
- const __revalDecoder = new TextDecoder();
- const __revalChunks = [];
- for (;;) {
- const { done, value } = await __revalReader.read();
- if (done) break;
- __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
- }
- __revalChunks.push(__revalDecoder.decode());
- const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
- const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
- })
- )
- )
- )
- );
+ const __revalUCtx = _createUnifiedCtx({
+ headersContext: __revalHeadCtx,
+ executionContext: _getRequestExecutionContext(),
+ });
+ const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => {
+ _ensureFetchPatch();
+ setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
+ const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
+ const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
+ const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
+ // Tee RSC stream: one for SSR, one to capture rscData
+ const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
+ // Capture rscData bytes in parallel with SSR
+ const __rscDataPromise = (async () => {
+ const __rscReader = __revalRscForCapture.getReader();
+ const __rscChunks = [];
+ let __rscTotal = 0;
+ for (;;) {
+ const { done, value } = await __rscReader.read();
+ if (done) break;
+ __rscChunks.push(value);
+ __rscTotal += value.byteLength;
+ }
+ const __rscBuf = new Uint8Array(__rscTotal);
+ let __rscOff = 0;
+ for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
+ return __rscBuf.buffer;
+ })();
+ const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
+ const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
+ const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ setHeadersContext(null);
+ setNavigationContext(null);
+ // Collect the full HTML string from the stream
+ const __revalReader = __revalHtmlStream.getReader();
+ const __revalDecoder = new TextDecoder();
+ const __revalChunks = [];
+ for (;;) {
+ const { done, value } = await __revalReader.read();
+ if (done) break;
+ __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
+ }
+ __revalChunks.push(__revalDecoder.decode());
+ const __freshHtml = __revalChunks.join("");
+ const __freshRscData = await __rscDataPromise;
+ const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
+ return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ });
// Write HTML and RSC to their own keys independently — no races
await Promise.all([
__isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags),
@@ -5315,7 +5289,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError });
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
return new Response(interceptStream, {
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -5550,7 +5524,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// NOTE: Do NOT clear headers/navigation context here!
// The RSC stream is consumed lazily - components render when chunks are read.
// If we clear context now, headers()/cookies() will fail during rendering.
- // Context will be cleared when the next request starts (via runWithHeadersContext).
+ // Context will be cleared when the next request starts (via runWithRequestContext).
const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
// Include matched route params so the client can hydrate useParams()
if (params && Object.keys(params).length > 0) {
@@ -5891,7 +5865,7 @@ import {
import { AsyncLocalStorage } from "node:async_hooks";
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -5901,13 +5875,13 @@ import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, merge
import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js";
import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js";
-import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache";
-import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
-import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache";
+import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache";
+import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
+import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache";
import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js";
-import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime";
// Import server-only state module to register ALS-backed accessors.
-import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state";
+import "vinext/navigation-state";
+import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context";
import { reportRequestError as _reportRequestError } from "vinext/instrumentation";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
@@ -6422,7 +6396,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: statusCode,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -6568,7 +6542,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: 200,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -7241,60 +7215,50 @@ async function __readFormDataWithLimit(request, maxBytes) {
export default async function handler(request, ctx) {
- // 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
- // streaming), preventing state leakage between concurrent requests on
- // Cloudflare Workers and other concurrent runtimes.
- //
- // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so
- // that KVCacheHandler._putInBackground can register background KV puts with
- // ctx.waitUntil() without needing ctx passed at construction time.
+ // Wrap the entire request in a single unified ALS scope for per-request
+ // isolation. All state modules (headers, navigation, cache, fetch-cache,
+ // execution-context) read from this store via isInsideUnifiedScope().
const headersCtx = headersContextFromRequest(request);
- const _run = () => runWithHeadersContext(headersCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- const __reqCtx = requestContextFromRequest(request);
- // 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);
- // 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);
- let pathname;
- try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
-
- const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
- for (const h of extraHeaders) {
- // Use append() for headers where multiple values must coexist
- // (Vary, Set-Cookie). Using set() on these would destroy
- // existing values like "Vary: RSC, Accept" which are critical
- // for correct CDN caching behavior.
- const lk = h.key.toLowerCase();
- if (lk === "vary" || lk === "set-cookie") {
- response.headers.append(h.key, h.value);
- } else if (!response.headers.has(lk)) {
- // Middleware headers take precedence: skip config keys already
- // set by middleware so middleware headers always win.
- response.headers.set(h.key, h.value);
- }
- }
- }
- }
- return response;
- })
- )
- )
- )
- );
- return ctx ? _runWithExecutionContext(ctx, _run) : _run();
+ const __uCtx = _createUnifiedCtx({
+ headersContext: headersCtx,
+ executionContext: ctx ?? null,
+ });
+ return _runWithUnifiedCtx(__uCtx, async () => {
+ _ensureFetchPatch();
+ const __reqCtx = requestContextFromRequest(request);
+ // 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);
+ // 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);
+ let pathname;
+ try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+
+ const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
+ for (const h of extraHeaders) {
+ // Use append() for headers where multiple values must coexist
+ // (Vary, Set-Cookie). Using set() on these would destroy
+ // existing values like "Vary: RSC, Accept" which are critical
+ // for correct CDN caching behavior.
+ const lk = h.key.toLowerCase();
+ if (lk === "vary" || lk === "set-cookie") {
+ response.headers.append(h.key, h.value);
+ } else if (!response.headers.has(lk)) {
+ // Middleware headers take precedence: skip config keys already
+ // set by middleware so middleware headers always win.
+ response.headers.set(h.key, h.value);
+ }
+ }
+ }
+ }
+ return response;
+ });
}
async function _handleRequest(request, __reqCtx, _mwCtx) {
@@ -7461,7 +7425,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
// Set navigation context for Server Components.
- // Note: Headers context is already set by runWithHeadersContext in the handler wrapper.
+ // Note: Headers context is already set by runWithRequestContext in the handler wrapper.
setNavigationContext({
pathname: cleanPathname,
searchParams: url.searchParams,
@@ -7601,7 +7565,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// Collect cookies set during the action synchronously (before stream is consumed).
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
const actionPendingCookies = getAndClearPendingCookies();
const actionDraftCookie = getDraftModeCookieHeader();
@@ -7958,57 +7922,54 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// user request — to prevent user-specific cookies/auth headers from leaking
// into content that is cached and served to all subsequent users.
const __revalHeadCtx = { headers: new Headers(), cookies: new Map() };
- const __revalResult = await runWithHeadersContext(__revalHeadCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
- const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
- const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
- const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
- // Tee RSC stream: one for SSR, one to capture rscData
- const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
- // Capture rscData bytes in parallel with SSR
- const __rscDataPromise = (async () => {
- const __rscReader = __revalRscForCapture.getReader();
- const __rscChunks = [];
- let __rscTotal = 0;
- for (;;) {
- const { done, value } = await __rscReader.read();
- if (done) break;
- __rscChunks.push(value);
- __rscTotal += value.byteLength;
- }
- const __rscBuf = new Uint8Array(__rscTotal);
- let __rscOff = 0;
- for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
- return __rscBuf.buffer;
- })();
- const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
- const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
- const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
- setHeadersContext(null);
- setNavigationContext(null);
- // Collect the full HTML string from the stream
- const __revalReader = __revalHtmlStream.getReader();
- const __revalDecoder = new TextDecoder();
- const __revalChunks = [];
- for (;;) {
- const { done, value } = await __revalReader.read();
- if (done) break;
- __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
- }
- __revalChunks.push(__revalDecoder.decode());
- const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
- const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
- })
- )
- )
- )
- );
+ const __revalUCtx = _createUnifiedCtx({
+ headersContext: __revalHeadCtx,
+ executionContext: _getRequestExecutionContext(),
+ });
+ const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => {
+ _ensureFetchPatch();
+ setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
+ const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
+ const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
+ const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
+ // Tee RSC stream: one for SSR, one to capture rscData
+ const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
+ // Capture rscData bytes in parallel with SSR
+ const __rscDataPromise = (async () => {
+ const __rscReader = __revalRscForCapture.getReader();
+ const __rscChunks = [];
+ let __rscTotal = 0;
+ for (;;) {
+ const { done, value } = await __rscReader.read();
+ if (done) break;
+ __rscChunks.push(value);
+ __rscTotal += value.byteLength;
+ }
+ const __rscBuf = new Uint8Array(__rscTotal);
+ let __rscOff = 0;
+ for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
+ return __rscBuf.buffer;
+ })();
+ const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
+ const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
+ const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ setHeadersContext(null);
+ setNavigationContext(null);
+ // Collect the full HTML string from the stream
+ const __revalReader = __revalHtmlStream.getReader();
+ const __revalDecoder = new TextDecoder();
+ const __revalChunks = [];
+ for (;;) {
+ const { done, value } = await __revalReader.read();
+ if (done) break;
+ __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
+ }
+ __revalChunks.push(__revalDecoder.decode());
+ const __freshHtml = __revalChunks.join("");
+ const __freshRscData = await __rscDataPromise;
+ const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
+ return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ });
// Write HTML and RSC to their own keys independently — no races
await Promise.all([
__isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags),
@@ -8117,7 +8078,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError });
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
return new Response(interceptStream, {
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -8352,7 +8313,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// NOTE: Do NOT clear headers/navigation context here!
// The RSC stream is consumed lazily - components render when chunks are read.
// If we clear context now, headers()/cookies() will fail during rendering.
- // Context will be cleared when the next request starts (via runWithHeadersContext).
+ // Context will be cleared when the next request starts (via runWithRequestContext).
const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
// Include matched route params so the client can hydrate useParams()
if (params && Object.keys(params).length > 0) {
@@ -8701,7 +8662,7 @@ import {
import { AsyncLocalStorage } from "node:async_hooks";
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -8711,13 +8672,13 @@ import * as _instrumentation from "/tmp/test/instrumentation.ts";
import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js";
import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js";
-import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache";
-import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
-import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache";
+import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache";
+import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
+import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache";
import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js";
-import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime";
// Import server-only state module to register ALS-backed accessors.
-import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state";
+import "vinext/navigation-state";
+import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context";
import { reportRequestError as _reportRequestError } from "vinext/instrumentation";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
@@ -9252,7 +9213,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: statusCode,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -9385,7 +9346,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: 200,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -10053,60 +10014,50 @@ export default async function handler(request, ctx) {
// This is a no-op after the first call (guarded by __instrumentationInitialized).
await __ensureInstrumentation();
- // 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
- // streaming), preventing state leakage between concurrent requests on
- // Cloudflare Workers and other concurrent runtimes.
- //
- // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so
- // that KVCacheHandler._putInBackground can register background KV puts with
- // ctx.waitUntil() without needing ctx passed at construction time.
+ // Wrap the entire request in a single unified ALS scope for per-request
+ // isolation. All state modules (headers, navigation, cache, fetch-cache,
+ // execution-context) read from this store via isInsideUnifiedScope().
const headersCtx = headersContextFromRequest(request);
- const _run = () => runWithHeadersContext(headersCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- const __reqCtx = requestContextFromRequest(request);
- // 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);
- // 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);
- let pathname;
- try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
-
- const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
- for (const h of extraHeaders) {
- // Use append() for headers where multiple values must coexist
- // (Vary, Set-Cookie). Using set() on these would destroy
- // existing values like "Vary: RSC, Accept" which are critical
- // for correct CDN caching behavior.
- const lk = h.key.toLowerCase();
- if (lk === "vary" || lk === "set-cookie") {
- response.headers.append(h.key, h.value);
- } else if (!response.headers.has(lk)) {
- // Middleware headers take precedence: skip config keys already
- // set by middleware so middleware headers always win.
- response.headers.set(h.key, h.value);
- }
- }
- }
- }
- return response;
- })
- )
- )
- )
- );
- return ctx ? _runWithExecutionContext(ctx, _run) : _run();
+ const __uCtx = _createUnifiedCtx({
+ headersContext: headersCtx,
+ executionContext: ctx ?? null,
+ });
+ return _runWithUnifiedCtx(__uCtx, async () => {
+ _ensureFetchPatch();
+ const __reqCtx = requestContextFromRequest(request);
+ // 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);
+ // 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);
+ let pathname;
+ try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+
+ const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
+ for (const h of extraHeaders) {
+ // Use append() for headers where multiple values must coexist
+ // (Vary, Set-Cookie). Using set() on these would destroy
+ // existing values like "Vary: RSC, Accept" which are critical
+ // for correct CDN caching behavior.
+ const lk = h.key.toLowerCase();
+ if (lk === "vary" || lk === "set-cookie") {
+ response.headers.append(h.key, h.value);
+ } else if (!response.headers.has(lk)) {
+ // Middleware headers take precedence: skip config keys already
+ // set by middleware so middleware headers always win.
+ response.headers.set(h.key, h.value);
+ }
+ }
+ }
+ }
+ return response;
+ });
}
async function _handleRequest(request, __reqCtx, _mwCtx) {
@@ -10273,7 +10224,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
// Set navigation context for Server Components.
- // Note: Headers context is already set by runWithHeadersContext in the handler wrapper.
+ // Note: Headers context is already set by runWithRequestContext in the handler wrapper.
setNavigationContext({
pathname: cleanPathname,
searchParams: url.searchParams,
@@ -10413,7 +10364,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// Collect cookies set during the action synchronously (before stream is consumed).
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
const actionPendingCookies = getAndClearPendingCookies();
const actionDraftCookie = getDraftModeCookieHeader();
@@ -10770,57 +10721,54 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// user request — to prevent user-specific cookies/auth headers from leaking
// into content that is cached and served to all subsequent users.
const __revalHeadCtx = { headers: new Headers(), cookies: new Map() };
- const __revalResult = await runWithHeadersContext(__revalHeadCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
- const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
- const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
- const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
- // Tee RSC stream: one for SSR, one to capture rscData
- const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
- // Capture rscData bytes in parallel with SSR
- const __rscDataPromise = (async () => {
- const __rscReader = __revalRscForCapture.getReader();
- const __rscChunks = [];
- let __rscTotal = 0;
- for (;;) {
- const { done, value } = await __rscReader.read();
- if (done) break;
- __rscChunks.push(value);
- __rscTotal += value.byteLength;
- }
- const __rscBuf = new Uint8Array(__rscTotal);
- let __rscOff = 0;
- for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
- return __rscBuf.buffer;
- })();
- const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
- const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
- const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
- setHeadersContext(null);
- setNavigationContext(null);
- // Collect the full HTML string from the stream
- const __revalReader = __revalHtmlStream.getReader();
- const __revalDecoder = new TextDecoder();
- const __revalChunks = [];
- for (;;) {
- const { done, value } = await __revalReader.read();
- if (done) break;
- __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
- }
- __revalChunks.push(__revalDecoder.decode());
- const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
- const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
- })
- )
- )
- )
- );
+ const __revalUCtx = _createUnifiedCtx({
+ headersContext: __revalHeadCtx,
+ executionContext: _getRequestExecutionContext(),
+ });
+ const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => {
+ _ensureFetchPatch();
+ setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
+ const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
+ const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
+ const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
+ // Tee RSC stream: one for SSR, one to capture rscData
+ const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
+ // Capture rscData bytes in parallel with SSR
+ const __rscDataPromise = (async () => {
+ const __rscReader = __revalRscForCapture.getReader();
+ const __rscChunks = [];
+ let __rscTotal = 0;
+ for (;;) {
+ const { done, value } = await __rscReader.read();
+ if (done) break;
+ __rscChunks.push(value);
+ __rscTotal += value.byteLength;
+ }
+ const __rscBuf = new Uint8Array(__rscTotal);
+ let __rscOff = 0;
+ for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
+ return __rscBuf.buffer;
+ })();
+ const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
+ const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
+ const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ setHeadersContext(null);
+ setNavigationContext(null);
+ // Collect the full HTML string from the stream
+ const __revalReader = __revalHtmlStream.getReader();
+ const __revalDecoder = new TextDecoder();
+ const __revalChunks = [];
+ for (;;) {
+ const { done, value } = await __revalReader.read();
+ if (done) break;
+ __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
+ }
+ __revalChunks.push(__revalDecoder.decode());
+ const __freshHtml = __revalChunks.join("");
+ const __freshRscData = await __rscDataPromise;
+ const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
+ return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ });
// Write HTML and RSC to their own keys independently — no races
await Promise.all([
__isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags),
@@ -10929,7 +10877,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError });
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
return new Response(interceptStream, {
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -11164,7 +11112,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// NOTE: Do NOT clear headers/navigation context here!
// The RSC stream is consumed lazily - components render when chunks are read.
// If we clear context now, headers()/cookies() will fail during rendering.
- // Context will be cleared when the next request starts (via runWithHeadersContext).
+ // Context will be cleared when the next request starts (via runWithRequestContext).
const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
// Include matched route params so the client can hydrate useParams()
if (params && Object.keys(params).length > 0) {
@@ -11505,7 +11453,7 @@ import {
import { AsyncLocalStorage } from "node:async_hooks";
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -11515,13 +11463,13 @@ import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, merge
import { sitemapToXml, robotsToText, manifestToJson } from "/packages/vinext/src/server/metadata-routes.js";
import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js";
import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js";
-import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache";
-import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
-import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache";
+import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache";
+import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
+import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache";
import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js";
-import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime";
// Import server-only state module to register ALS-backed accessors.
-import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state";
+import "vinext/navigation-state";
+import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context";
import { reportRequestError as _reportRequestError } from "vinext/instrumentation";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
@@ -12034,7 +11982,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: statusCode,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -12167,7 +12115,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: 200,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -12832,60 +12780,50 @@ async function __readFormDataWithLimit(request, maxBytes) {
export default async function handler(request, ctx) {
- // 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
- // streaming), preventing state leakage between concurrent requests on
- // Cloudflare Workers and other concurrent runtimes.
- //
- // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so
- // that KVCacheHandler._putInBackground can register background KV puts with
- // ctx.waitUntil() without needing ctx passed at construction time.
+ // Wrap the entire request in a single unified ALS scope for per-request
+ // isolation. All state modules (headers, navigation, cache, fetch-cache,
+ // execution-context) read from this store via isInsideUnifiedScope().
const headersCtx = headersContextFromRequest(request);
- const _run = () => runWithHeadersContext(headersCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- const __reqCtx = requestContextFromRequest(request);
- // 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);
- // 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);
- let pathname;
- try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
-
- const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
- for (const h of extraHeaders) {
- // Use append() for headers where multiple values must coexist
- // (Vary, Set-Cookie). Using set() on these would destroy
- // existing values like "Vary: RSC, Accept" which are critical
- // for correct CDN caching behavior.
- const lk = h.key.toLowerCase();
- if (lk === "vary" || lk === "set-cookie") {
- response.headers.append(h.key, h.value);
- } else if (!response.headers.has(lk)) {
- // Middleware headers take precedence: skip config keys already
- // set by middleware so middleware headers always win.
- response.headers.set(h.key, h.value);
- }
- }
- }
- }
- return response;
- })
- )
- )
- )
- );
- return ctx ? _runWithExecutionContext(ctx, _run) : _run();
+ const __uCtx = _createUnifiedCtx({
+ headersContext: headersCtx,
+ executionContext: ctx ?? null,
+ });
+ return _runWithUnifiedCtx(__uCtx, async () => {
+ _ensureFetchPatch();
+ const __reqCtx = requestContextFromRequest(request);
+ // 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);
+ // 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);
+ let pathname;
+ try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+
+ const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
+ for (const h of extraHeaders) {
+ // Use append() for headers where multiple values must coexist
+ // (Vary, Set-Cookie). Using set() on these would destroy
+ // existing values like "Vary: RSC, Accept" which are critical
+ // for correct CDN caching behavior.
+ const lk = h.key.toLowerCase();
+ if (lk === "vary" || lk === "set-cookie") {
+ response.headers.append(h.key, h.value);
+ } else if (!response.headers.has(lk)) {
+ // Middleware headers take precedence: skip config keys already
+ // set by middleware so middleware headers always win.
+ response.headers.set(h.key, h.value);
+ }
+ }
+ }
+ }
+ return response;
+ });
}
async function _handleRequest(request, __reqCtx, _mwCtx) {
@@ -13052,7 +12990,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
// Set navigation context for Server Components.
- // Note: Headers context is already set by runWithHeadersContext in the handler wrapper.
+ // Note: Headers context is already set by runWithRequestContext in the handler wrapper.
setNavigationContext({
pathname: cleanPathname,
searchParams: url.searchParams,
@@ -13192,7 +13130,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// Collect cookies set during the action synchronously (before stream is consumed).
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
const actionPendingCookies = getAndClearPendingCookies();
const actionDraftCookie = getDraftModeCookieHeader();
@@ -13549,57 +13487,54 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// user request — to prevent user-specific cookies/auth headers from leaking
// into content that is cached and served to all subsequent users.
const __revalHeadCtx = { headers: new Headers(), cookies: new Map() };
- const __revalResult = await runWithHeadersContext(__revalHeadCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
- const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
- const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
- const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
- // Tee RSC stream: one for SSR, one to capture rscData
- const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
- // Capture rscData bytes in parallel with SSR
- const __rscDataPromise = (async () => {
- const __rscReader = __revalRscForCapture.getReader();
- const __rscChunks = [];
- let __rscTotal = 0;
- for (;;) {
- const { done, value } = await __rscReader.read();
- if (done) break;
- __rscChunks.push(value);
- __rscTotal += value.byteLength;
- }
- const __rscBuf = new Uint8Array(__rscTotal);
- let __rscOff = 0;
- for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
- return __rscBuf.buffer;
- })();
- const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
- const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
- const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
- setHeadersContext(null);
- setNavigationContext(null);
- // Collect the full HTML string from the stream
- const __revalReader = __revalHtmlStream.getReader();
- const __revalDecoder = new TextDecoder();
- const __revalChunks = [];
- for (;;) {
- const { done, value } = await __revalReader.read();
- if (done) break;
- __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
- }
- __revalChunks.push(__revalDecoder.decode());
- const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
- const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
- })
- )
- )
- )
- );
+ const __revalUCtx = _createUnifiedCtx({
+ headersContext: __revalHeadCtx,
+ executionContext: _getRequestExecutionContext(),
+ });
+ const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => {
+ _ensureFetchPatch();
+ setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
+ const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
+ const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
+ const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
+ // Tee RSC stream: one for SSR, one to capture rscData
+ const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
+ // Capture rscData bytes in parallel with SSR
+ const __rscDataPromise = (async () => {
+ const __rscReader = __revalRscForCapture.getReader();
+ const __rscChunks = [];
+ let __rscTotal = 0;
+ for (;;) {
+ const { done, value } = await __rscReader.read();
+ if (done) break;
+ __rscChunks.push(value);
+ __rscTotal += value.byteLength;
+ }
+ const __rscBuf = new Uint8Array(__rscTotal);
+ let __rscOff = 0;
+ for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
+ return __rscBuf.buffer;
+ })();
+ const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
+ const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
+ const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ setHeadersContext(null);
+ setNavigationContext(null);
+ // Collect the full HTML string from the stream
+ const __revalReader = __revalHtmlStream.getReader();
+ const __revalDecoder = new TextDecoder();
+ const __revalChunks = [];
+ for (;;) {
+ const { done, value } = await __revalReader.read();
+ if (done) break;
+ __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
+ }
+ __revalChunks.push(__revalDecoder.decode());
+ const __freshHtml = __revalChunks.join("");
+ const __freshRscData = await __rscDataPromise;
+ const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
+ return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ });
// Write HTML and RSC to their own keys independently — no races
await Promise.all([
__isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags),
@@ -13708,7 +13643,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError });
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
return new Response(interceptStream, {
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -13943,7 +13878,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// NOTE: Do NOT clear headers/navigation context here!
// The RSC stream is consumed lazily - components render when chunks are read.
// If we clear context now, headers()/cookies() will fail during rendering.
- // Context will be cleared when the next request starts (via runWithHeadersContext).
+ // Context will be cleared when the next request starts (via runWithRequestContext).
const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
// Include matched route params so the client can hydrate useParams()
if (params && Object.keys(params).length > 0) {
@@ -14284,7 +14219,7 @@ import {
import { AsyncLocalStorage } from "node:async_hooks";
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -14294,13 +14229,13 @@ import * as middlewareModule from "/tmp/test/middleware.ts";
import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "/packages/vinext/src/config/config-matchers.js";
import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from "/packages/vinext/src/server/request-pipeline.js";
-import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache";
-import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
-import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache";
+import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache";
+import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
+import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache";
import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js";
-import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime";
// Import server-only state module to register ALS-backed accessors.
-import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state";
+import "vinext/navigation-state";
+import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context";
import { reportRequestError as _reportRequestError } from "vinext/instrumentation";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
@@ -14806,7 +14741,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: statusCode,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -14939,7 +14874,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
// that run during stream consumption to see null headers/navigation context and throw,
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
// with "context from NextIntlClientProvider was not found").
- // Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
+ // Context is cleared naturally when the ALS scope from runWithRequestContext unwinds.
return new Response(rscStream, {
status: 200,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -15800,60 +15735,50 @@ async function __readFormDataWithLimit(request, maxBytes) {
export default async function handler(request, ctx) {
- // 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
- // streaming), preventing state leakage between concurrent requests on
- // Cloudflare Workers and other concurrent runtimes.
- //
- // runWithExecutionContext stores the Workers ExecutionContext (ctx) in ALS so
- // that KVCacheHandler._putInBackground can register background KV puts with
- // ctx.waitUntil() without needing ctx passed at construction time.
+ // Wrap the entire request in a single unified ALS scope for per-request
+ // isolation. All state modules (headers, navigation, cache, fetch-cache,
+ // execution-context) read from this store via isInsideUnifiedScope().
const headersCtx = headersContextFromRequest(request);
- const _run = () => runWithHeadersContext(headersCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- const __reqCtx = requestContextFromRequest(request);
- // 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);
- // 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);
- let pathname;
- try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
-
- const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
- for (const h of extraHeaders) {
- // Use append() for headers where multiple values must coexist
- // (Vary, Set-Cookie). Using set() on these would destroy
- // existing values like "Vary: RSC, Accept" which are critical
- // for correct CDN caching behavior.
- const lk = h.key.toLowerCase();
- if (lk === "vary" || lk === "set-cookie") {
- response.headers.append(h.key, h.value);
- } else if (!response.headers.has(lk)) {
- // Middleware headers take precedence: skip config keys already
- // set by middleware so middleware headers always win.
- response.headers.set(h.key, h.value);
- }
- }
- }
- }
- return response;
- })
- )
- )
- )
- );
- return ctx ? _runWithExecutionContext(ctx, _run) : _run();
+ const __uCtx = _createUnifiedCtx({
+ headersContext: headersCtx,
+ executionContext: ctx ?? null,
+ });
+ return _runWithUnifiedCtx(__uCtx, async () => {
+ _ensureFetchPatch();
+ const __reqCtx = requestContextFromRequest(request);
+ // 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);
+ // 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);
+ let pathname;
+ try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+
+ const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
+ for (const h of extraHeaders) {
+ // Use append() for headers where multiple values must coexist
+ // (Vary, Set-Cookie). Using set() on these would destroy
+ // existing values like "Vary: RSC, Accept" which are critical
+ // for correct CDN caching behavior.
+ const lk = h.key.toLowerCase();
+ if (lk === "vary" || lk === "set-cookie") {
+ response.headers.append(h.key, h.value);
+ } else if (!response.headers.has(lk)) {
+ // Middleware headers take precedence: skip config keys already
+ // set by middleware so middleware headers always win.
+ response.headers.set(h.key, h.value);
+ }
+ }
+ }
+ }
+ return response;
+ });
}
async function _handleRequest(request, __reqCtx, _mwCtx) {
@@ -16102,7 +16027,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
// Set navigation context for Server Components.
- // Note: Headers context is already set by runWithHeadersContext in the handler wrapper.
+ // Note: Headers context is already set by runWithRequestContext in the handler wrapper.
setNavigationContext({
pathname: cleanPathname,
searchParams: url.searchParams,
@@ -16242,7 +16167,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// Collect cookies set during the action synchronously (before stream is consumed).
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
const actionPendingCookies = getAndClearPendingCookies();
const actionDraftCookie = getDraftModeCookieHeader();
@@ -16599,57 +16524,54 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// user request — to prevent user-specific cookies/auth headers from leaking
// into content that is cached and served to all subsequent users.
const __revalHeadCtx = { headers: new Headers(), cookies: new Map() };
- const __revalResult = await runWithHeadersContext(__revalHeadCtx, () =>
- _runWithNavigationContext(() =>
- _runWithCacheState(() =>
- _runWithPrivateCache(() =>
- runWithFetchCache(async () => {
- setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
- const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
- const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
- const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
- // Tee RSC stream: one for SSR, one to capture rscData
- const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
- // Capture rscData bytes in parallel with SSR
- const __rscDataPromise = (async () => {
- const __rscReader = __revalRscForCapture.getReader();
- const __rscChunks = [];
- let __rscTotal = 0;
- for (;;) {
- const { done, value } = await __rscReader.read();
- if (done) break;
- __rscChunks.push(value);
- __rscTotal += value.byteLength;
- }
- const __rscBuf = new Uint8Array(__rscTotal);
- let __rscOff = 0;
- for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
- return __rscBuf.buffer;
- })();
- const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
- const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
- const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
- setHeadersContext(null);
- setNavigationContext(null);
- // Collect the full HTML string from the stream
- const __revalReader = __revalHtmlStream.getReader();
- const __revalDecoder = new TextDecoder();
- const __revalChunks = [];
- for (;;) {
- const { done, value } = await __revalReader.read();
- if (done) break;
- __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
- }
- __revalChunks.push(__revalDecoder.decode());
- const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
- const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
- })
- )
- )
- )
- );
+ const __revalUCtx = _createUnifiedCtx({
+ headersContext: __revalHeadCtx,
+ executionContext: _getRequestExecutionContext(),
+ });
+ const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => {
+ _ensureFetchPatch();
+ setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params });
+ const __revalElement = await buildPageElement(route, params, undefined, url.searchParams);
+ const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
+ const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
+ // Tee RSC stream: one for SSR, one to capture rscData
+ const [__revalRscForSsr, __revalRscForCapture] = __revalRscStream.tee();
+ // Capture rscData bytes in parallel with SSR
+ const __rscDataPromise = (async () => {
+ const __rscReader = __revalRscForCapture.getReader();
+ const __rscChunks = [];
+ let __rscTotal = 0;
+ for (;;) {
+ const { done, value } = await __rscReader.read();
+ if (done) break;
+ __rscChunks.push(value);
+ __rscTotal += value.byteLength;
+ }
+ const __rscBuf = new Uint8Array(__rscTotal);
+ let __rscOff = 0;
+ for (const c of __rscChunks) { __rscBuf.set(c, __rscOff); __rscOff += c.byteLength; }
+ return __rscBuf.buffer;
+ })();
+ const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
+ const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
+ const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ setHeadersContext(null);
+ setNavigationContext(null);
+ // Collect the full HTML string from the stream
+ const __revalReader = __revalHtmlStream.getReader();
+ const __revalDecoder = new TextDecoder();
+ const __revalChunks = [];
+ for (;;) {
+ const { done, value } = await __revalReader.read();
+ if (done) break;
+ __revalChunks.push(__revalDecoder.decode(value, { stream: true }));
+ }
+ __revalChunks.push(__revalDecoder.decode());
+ const __freshHtml = __revalChunks.join("");
+ const __freshRscData = await __rscDataPromise;
+ const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
+ return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ });
// Write HTML and RSC to their own keys independently — no races
await Promise.all([
__isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags),
@@ -16758,7 +16680,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError });
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
// by the client, and async server components that run during consumption need the
- // context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
+ // context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
return new Response(interceptStream, {
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
@@ -16993,7 +16915,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// NOTE: Do NOT clear headers/navigation context here!
// The RSC stream is consumed lazily - components render when chunks are read.
// If we clear context now, headers()/cookies() will fail during rendering.
- // Context will be cleared when the next request starts (via runWithHeadersContext).
+ // Context will be cleared when the next request starts (via runWithRequestContext).
const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
// Include matched route params so the client can hydrate useParams()
if (params && Object.keys(params).length > 0) {
From bd5a8c0952fafb066436e8aa01ad4afdd0378e65 Mon Sep 17 00:00:00 2001
From: Divanshu Chauhan
Date: Wed, 11 Mar 2026 10:11:45 -0700
Subject: [PATCH 3/6] test: update app router waitUntil assertion
---
tests/app-router.test.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts
index 444e2912..523e3948 100644
--- a/tests/app-router.test.ts
+++ b/tests/app-router.test.ts
@@ -3428,9 +3428,9 @@ describe("generateRscEntry ISR code generation", () => {
expect(code).toContain('"X-Vinext-Cache": "STALE"');
});
- it("generated code uses ctx.waitUntil for background cache write", () => {
+ it("generated code uses request execution context for background cache write", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes);
- expect(code).toContain("ctx.waitUntil");
+ expect(code).toContain("_getRequestExecutionContext()?.waitUntil");
});
it("generated code tees the RSC stream to capture rscData for cache", () => {
From d708201c27ddf8f23f198a17bff4b7dc580e80d3 Mon Sep 17 00:00:00 2001
From: Divanshu Chauhan
Date: Wed, 11 Mar 2026 10:36:10 -0700
Subject: [PATCH 4/6] refactor: unify request ALS across router flows
---
packages/vinext/src/entries/app-rsc-entry.ts | 4 +-
.../vinext/src/entries/pages-server-entry.ts | 23 +-
.../vinext/src/server/app-router-entry.ts | 2 +-
packages/vinext/src/server/dev-server.ts | 1148 ++++++++---------
packages/vinext/src/shims/cache-runtime.ts | 14 +-
packages/vinext/src/shims/cache.ts | 14 +-
packages/vinext/src/shims/fetch-cache.ts | 14 +-
packages/vinext/src/shims/head-state.ts | 22 +
packages/vinext/src/shims/headers.ts | 37 +-
packages/vinext/src/shims/navigation-state.ts | 19 +-
packages/vinext/src/shims/request-context.ts | 15 +-
packages/vinext/src/shims/router-state.ts | 22 +
.../src/shims/unified-request-context.ts | 57 +-
.../entry-templates.test.ts.snap | 47 +-
tests/fetch-cache.test.ts | 32 +
tests/request-context.test.ts | 21 +
tests/shims.test.ts | 5 +-
tests/unified-request-context.test.ts | 157 +++
18 files changed, 987 insertions(+), 666 deletions(-)
diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index 8c4cc8b7..9e82ffda 100644
--- a/packages/vinext/src/entries/app-rsc-entry.ts
+++ b/packages/vinext/src/entries/app-rsc-entry.ts
@@ -1404,7 +1404,7 @@ export default async function handler(request, ctx) {
const headersCtx = headersContextFromRequest(request);
const __uCtx = _createUnifiedCtx({
headersContext: headersCtx,
- executionContext: ctx ?? null,
+ executionContext: ctx ?? _getRequestExecutionContext() ?? null,
});
return _runWithUnifiedCtx(__uCtx, async () => {
_ensureFetchPatch();
@@ -1413,7 +1413,7 @@ export default async function handler(request, ctx) {
// _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 response = await _handleRequest(request, __reqCtx, _mwCtx);
// 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.
diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts
index fb452f29..2663ad3a 100644
--- a/packages/vinext/src/entries/pages-server-entry.ts
+++ b/packages/vinext/src/entries/pages-server-entry.ts
@@ -259,11 +259,10 @@ import { resetSSRHead, getSSRHeadHTML } from "next/head";
import { flushPreloads } from "next/dynamic";
import { setSSRContext, wrapWithRouterContext } from "next/router";
import { getCacheHandler } from "next/cache";
-import { runWithFetchCache } from "vinext/fetch-cache";
-import { _runWithCacheState } from "next/cache";
-import { runWithPrivateCache } from "vinext/cache-runtime";
-import { runWithRouterState } from "vinext/router-state";
-import { runWithHeadState } from "vinext/head-state";
+import { ensureFetchPatch } from "vinext/fetch-cache";
+import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context";
+import "vinext/router-state";
+import "vinext/head-state";
import { safeJsonStringify } from "vinext/html";
import { decode as decodeQueryString } from "node:querystring";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
@@ -711,11 +710,9 @@ async function _renderPage(request, url, manifest) {
}
const { route, params } = match;
- return runWithRouterState(() =>
- runWithHeadState(() =>
- _runWithCacheState(() =>
- runWithPrivateCache(() =>
- runWithFetchCache(async () => {
+ const __uCtx = _createUnifiedCtx();
+ return _runWithUnifiedCtx(__uCtx, async () => {
+ ensureFetchPatch();
try {
if (typeof setSSRContext === "function") {
setSSRContext({
@@ -1002,11 +999,7 @@ async function _renderPage(request, url, manifest) {
console.error("[vinext] SSR error:", e);
return new Response("Internal Server Error", { status: 500 });
}
- }) // end runWithFetchCache
- ) // end runWithPrivateCache
- ) // end _runWithCacheState
- ) // end runWithHeadState
- ); // end runWithRouterState
+ });
}
export async function handleApiRoute(request, url) {
diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts
index 45595803..adbbc488 100644
--- a/packages/vinext/src/server/app-router-entry.ts
+++ b/packages/vinext/src/server/app-router-entry.ts
@@ -48,7 +48,7 @@ export default {
// Delegate to RSC handler (which decodes + normalizes the pathname itself),
// wrapping in the ExecutionContext ALS scope so downstream code can reach
// ctx.waitUntil() without having ctx threaded through every call site.
- const handleFn = () => rscHandler(request);
+ const handleFn = () => rscHandler(request, ctx);
const result = await (ctx ? runWithExecutionContext(ctx, handleFn) : handleFn());
if (result instanceof Response) {
diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts
index fafad865..7d252c55 100644
--- a/packages/vinext/src/server/dev-server.ts
+++ b/packages/vinext/src/server/dev-server.ts
@@ -13,13 +13,12 @@ import {
getRevalidateDuration,
} from "./isr-cache.js";
import type { CachedPagesValue } from "../shims/cache.js";
-import { runWithFetchCache } from "../shims/fetch-cache.js";
-import { _runWithCacheState } from "../shims/cache.js";
-import { runWithPrivateCache } from "../shims/cache-runtime.js";
+import { ensureFetchPatch } from "../shims/fetch-cache.js";
+import { createRequestContext, runWithRequestContext } from "../shims/unified-request-context.js";
// Import server-only state modules to register ALS-backed accessors.
// These modules must be imported before any rendering occurs.
-import { runWithRouterState } from "../shims/router-state.js";
-import { runWithHeadState } from "../shims/head-state.js";
+import "../shims/router-state.js";
+import "../shims/head-state.js";
import { reportRequestError } from "./instrumentation.js";
import { safeJsonStringify } from "./html.js";
import { parseQueryString as parseQuery } from "../utils/query.js";
@@ -348,439 +347,415 @@ export function createSSRHandler(
const { route, params } = match;
- // Wrap the entire request in nested AsyncLocalStorage.run() scopes to
- // ensure per-request isolation for all state modules.
- return runWithRouterState(
- () =>
- runWithHeadState(
- () =>
- _runWithCacheState(
- () =>
- runWithPrivateCache(
- () =>
- runWithFetchCache(async () => {
- try {
- // Set SSR context for the router shim so useRouter() returns
- // the correct URL and params during server-side rendering.
- const routerShim = await server.ssrLoadModule("next/router");
- if (typeof routerShim.setSSRContext === "function") {
- routerShim.setSSRContext({
- pathname: patternToNextFormat(route.pattern),
- query: { ...params, ...parseQuery(url) },
- asPath: url,
- locale: locale ?? i18nConfig?.defaultLocale,
- locales: i18nConfig?.locales,
- defaultLocale: i18nConfig?.defaultLocale,
- });
- }
-
- // Set globalThis locale info for Link component locale prop support during SSR
- if (i18nConfig) {
- globalThis.__VINEXT_LOCALE__ = locale ?? i18nConfig.defaultLocale;
- globalThis.__VINEXT_LOCALES__ = i18nConfig.locales;
- globalThis.__VINEXT_DEFAULT_LOCALE__ = i18nConfig.defaultLocale;
- }
-
- // Load the page module through Vite's SSR pipeline
- // This gives us HMR and transform support for free
- const pageModule = await server.ssrLoadModule(route.filePath);
- // Mark end of compile phase: everything from here is rendering.
- _compileEnd = now();
-
- // Get the page component (default export)
- const PageComponent = pageModule.default;
- if (!PageComponent) {
- console.error(`[vinext] Page ${route.filePath} has no default export`);
- res.statusCode = 500;
- res.end("Page has no default export");
- return;
- }
-
- // Collect page props via data fetching methods
- let pageProps: Record = {};
- let isrRevalidateSeconds: number | null = null;
-
- // Handle getStaticPaths for dynamic routes: validate the path
- // and respect fallback: false (return 404 for unlisted paths).
- if (typeof pageModule.getStaticPaths === "function" && route.isDynamic) {
- const pathsResult = await pageModule.getStaticPaths({
- locales: i18nConfig?.locales ?? [],
- defaultLocale: i18nConfig?.defaultLocale ?? "",
- });
- const fallback = pathsResult?.fallback ?? false;
-
- if (fallback === false) {
- // Only allow paths explicitly listed in getStaticPaths
- const paths: Array<{ params: Record }> =
- pathsResult?.paths ?? [];
- const isValidPath = paths.some(
- (p: { params: Record }) => {
- return Object.entries(p.params).every(([key, val]) => {
- const actual = params[key];
- if (Array.isArray(val)) {
- return (
- Array.isArray(actual) && val.join("/") === actual.join("/")
- );
- }
- return String(val) === String(actual);
- });
- },
- );
-
- if (!isValidPath) {
- await renderErrorPage(
- server,
- req,
- res,
- url,
- pagesDir,
- 404,
- routerShim.wrapWithRouterContext,
- matcher,
- );
- return;
- }
- }
- // fallback: true or "blocking" — always SSR on-demand.
- // In dev mode, Next.js does the same (no fallback shell).
- // In production, both modes SSR on-demand with caching.
- // The difference is that fallback:true could serve a shell first,
- // but since we always have data available via SSR, we render fully.
- }
-
- // Headers set by getServerSideProps for explicit forwarding to
- // streamPageToResponse. Without this, they survive only through
- // Node.js writeHead() implicitly merging setHeader() calls, which
- // would silently break if streamPageToResponse is refactored.
- const gsspExtraHeaders: Record = {};
-
- if (typeof pageModule.getServerSideProps === "function") {
- // Snapshot existing headers so we can detect what gSSP adds.
- const headersBeforeGSSP = new Set(Object.keys(res.getHeaders()));
-
- const context = {
- params,
- req,
- res,
- query: parseQuery(url),
- resolvedUrl: localeStrippedUrl,
- locale: locale ?? i18nConfig?.defaultLocale,
- locales: i18nConfig?.locales,
- defaultLocale: i18nConfig?.defaultLocale,
- };
- const result = await pageModule.getServerSideProps(context);
- // If gSSP called res.end() directly (short-circuit pattern),
- // the response is already sent. Do not continue rendering.
- // Note: middleware headers are already on `res` (middleware runs
- // before this handler in the connect chain), so they are included
- // in the short-circuited response. The prod path achieves the same
- // result via the worker entry merging middleware headers after
- // renderPage() returns.
- if (res.writableEnded) {
- return;
- }
- if (result && "props" in result) {
- pageProps = result.props;
- }
- if (result && "redirect" in result) {
- const { redirect } = result;
- const status = redirect.statusCode ?? (redirect.permanent ? 308 : 307);
- // Sanitize destination to prevent open redirect via protocol-relative URLs.
- // Also normalize backslashes — browsers treat \ as / in URL contexts.
- let dest = redirect.destination;
- if (!dest.startsWith("http://") && !dest.startsWith("https://")) {
- dest = dest.replace(/^[\\/]+/, "/");
- }
- res.writeHead(status, {
- Location: dest,
- });
- res.end();
- return;
- }
- if (result && "notFound" in result && result.notFound) {
- await renderErrorPage(
- server,
- req,
- res,
- url,
- pagesDir,
- 404,
- routerShim.wrapWithRouterContext,
- );
- return;
- }
- // Preserve any status code set by gSSP (e.g. res.statusCode = 201).
- // This takes precedence over the default 200 but not over middleware status.
- if (!statusCode && res.statusCode !== 200) {
- statusCode = res.statusCode;
- }
-
- // Capture headers newly set by gSSP and forward them explicitly.
- // Remove from `res` to prevent duplication when writeHead() merges.
- const headersAfterGSSP = res.getHeaders();
- for (const [key, val] of Object.entries(headersAfterGSSP)) {
- if (headersBeforeGSSP.has(key) || val == null) continue;
- res.removeHeader(key);
- if (Array.isArray(val)) {
- gsspExtraHeaders[key] = val.map(String);
- } else {
- gsspExtraHeaders[key] = String(val);
- }
- }
- }
- // Collect font preloads early so ISR cached responses can include
- // the Link header (font preloads are module-level state that persists
- // across requests after the font modules are first loaded).
- let earlyFontLinkHeader = "";
- try {
- const earlyPreloads: Array<{ href: string; type: string }> = [];
- const fontGoogleEarly = await server.ssrLoadModule("next/font/google");
- if (typeof fontGoogleEarly.getSSRFontPreloads === "function") {
- earlyPreloads.push(...fontGoogleEarly.getSSRFontPreloads());
- }
- const fontLocalEarly = await server.ssrLoadModule("next/font/local");
- if (typeof fontLocalEarly.getSSRFontPreloads === "function") {
- earlyPreloads.push(...fontLocalEarly.getSSRFontPreloads());
- }
- if (earlyPreloads.length > 0) {
- earlyFontLinkHeader = earlyPreloads
- .map(
- (p) =>
- `<${p.href}>; rel=preload; as=font; type=${p.type}; crossorigin`,
- )
- .join(", ");
- }
- } catch {
- // Font modules not loaded yet — skip
- }
-
- if (typeof pageModule.getStaticProps === "function") {
- // Check ISR cache before calling getStaticProps
- const cacheKey = isrCacheKey(
- "pages",
- url.split("?")[0],
- // __VINEXT_BUILD_ID is a compile-time define — undefined in dev,
- // which is fine: dev doesn't need cross-deploy cache isolation.
- process.env.__VINEXT_BUILD_ID,
- );
- const cached = await isrGet(cacheKey);
-
- if (cached && !cached.isStale && cached.value.value?.kind === "PAGES") {
- // Fresh cache hit — serve directly
- const cachedPage = cached.value.value as CachedPagesValue;
- const cachedHtml = cachedPage.html;
- const transformedHtml = await server.transformIndexHtml(
- url,
- cachedHtml,
- );
- const revalidateSecs = getRevalidateDuration(cacheKey) ?? 60;
- const hitHeaders: Record = {
- "Content-Type": "text/html",
- "X-Vinext-Cache": "HIT",
- "Cache-Control": `s-maxage=${revalidateSecs}, stale-while-revalidate`,
- };
- if (earlyFontLinkHeader) hitHeaders["Link"] = earlyFontLinkHeader;
- res.writeHead(200, hitHeaders);
- res.end(transformedHtml);
- return;
- }
-
- if (cached && cached.isStale && cached.value.value?.kind === "PAGES") {
- // Stale hit — serve stale immediately, trigger background regen
- const cachedPage = cached.value.value as CachedPagesValue;
- const cachedHtml = cachedPage.html;
- const transformedHtml = await server.transformIndexHtml(
- url,
- cachedHtml,
- );
-
- // Trigger background regeneration: re-run getStaticProps and
- // update the cache so the next request is a HIT with fresh data.
- triggerBackgroundRegeneration(cacheKey, async () => {
- const freshResult = await pageModule.getStaticProps({ params });
- if (freshResult && "props" in freshResult) {
- const revalidate =
- typeof freshResult.revalidate === "number"
- ? freshResult.revalidate
- : 0;
- if (revalidate > 0) {
- await isrSet(
- cacheKey,
- buildPagesCacheValue(cachedHtml, freshResult.props),
- revalidate,
- );
- }
- }
- });
-
- const revalidateSecs = getRevalidateDuration(cacheKey) ?? 60;
- const staleHeaders: Record = {
- "Content-Type": "text/html",
- "X-Vinext-Cache": "STALE",
- "Cache-Control": `s-maxage=${revalidateSecs}, stale-while-revalidate`,
- };
- if (earlyFontLinkHeader) staleHeaders["Link"] = earlyFontLinkHeader;
- res.writeHead(200, staleHeaders);
- res.end(transformedHtml);
- return;
- }
-
- // Cache miss — call getStaticProps normally
- const context = {
- params,
- locale: locale ?? i18nConfig?.defaultLocale,
- locales: i18nConfig?.locales,
- defaultLocale: i18nConfig?.defaultLocale,
- };
- const result = await pageModule.getStaticProps(context);
- if (result && "props" in result) {
- pageProps = result.props;
- }
- if (result && "redirect" in result) {
- const { redirect } = result;
- const status = redirect.statusCode ?? (redirect.permanent ? 308 : 307);
- // Sanitize destination to prevent open redirect via protocol-relative URLs.
- // Also normalize backslashes — browsers treat \ as / in URL contexts.
- let dest = redirect.destination;
- if (!dest.startsWith("http://") && !dest.startsWith("https://")) {
- dest = dest.replace(/^[\\/]+/, "/");
- }
- res.writeHead(status, {
- Location: dest,
- });
- res.end();
- return;
- }
- if (result && "notFound" in result && result.notFound) {
- await renderErrorPage(
- server,
- req,
- res,
- url,
- pagesDir,
- 404,
- routerShim.wrapWithRouterContext,
- );
- return;
- }
-
- // Extract revalidate period for ISR caching after render
- if (typeof result?.revalidate === "number" && result.revalidate > 0) {
- isrRevalidateSeconds = result.revalidate;
- }
- }
-
- // Try to load _app.tsx if it exists
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let AppComponent: any = null;
- const appPath = path.join(pagesDir, "_app");
- if (findFileWithExtensions(appPath, matcher)) {
- try {
- const appModule = await server.ssrLoadModule(appPath);
- AppComponent = appModule.default ?? null;
- } catch {
- // _app exists but failed to load
- }
- }
-
- // React and ReactDOMServer are imported at the top level as native Node
- // modules. They must NOT go through Vite's SSR module runner because
- // React is CJS and the ESModulesEvaluator doesn't define `module`.
- const createElement = React.createElement;
- let element: React.ReactElement;
-
- // wrapWithRouterContext wraps the element in RouterContext.Provider so that
- // next/compat/router's useRouter() returns the real router.
- const wrapWithRouterContext = routerShim.wrapWithRouterContext;
-
- if (AppComponent) {
- element = createElement(AppComponent, {
- Component: PageComponent,
- pageProps,
- });
- } else {
- element = createElement(PageComponent, pageProps);
- }
-
- if (wrapWithRouterContext) {
- element = wrapWithRouterContext(element);
- }
-
- // Reset SSR head collector before rendering so tags are captured
- const headShim = await server.ssrLoadModule("next/head");
- if (typeof headShim.resetSSRHead === "function") {
- headShim.resetSSRHead();
- }
-
- // Flush any pending dynamic() preloads so components are ready
- const dynamicShim = await server.ssrLoadModule("next/dynamic");
- if (typeof dynamicShim.flushPreloads === "function") {
- await dynamicShim.flushPreloads();
- }
-
- // Collect any tags that were rendered during data fetching
- // (shell head tags — Suspense children's head tags arrive late,
- // matching Next.js behavior)
-
- // Collect SSR font links (Google Fonts tags) and font class styles
- let fontHeadHTML = "";
- const allFontStyles: string[] = [];
- const allFontPreloads: Array<{ href: string; type: string }> = [];
- try {
- const fontGoogle = await server.ssrLoadModule("next/font/google");
- if (typeof fontGoogle.getSSRFontLinks === "function") {
- const fontUrls = fontGoogle.getSSRFontLinks();
- for (const fontUrl of fontUrls) {
- const safeFontUrl = fontUrl
- .replace(/&/g, "&")
- .replace(/"/g, """);
- fontHeadHTML += `\n `;
- }
- }
- if (typeof fontGoogle.getSSRFontStyles === "function") {
- allFontStyles.push(...fontGoogle.getSSRFontStyles());
- }
- // Collect preloads from self-hosted Google fonts
- if (typeof fontGoogle.getSSRFontPreloads === "function") {
- allFontPreloads.push(...fontGoogle.getSSRFontPreloads());
- }
- } catch {
- // next/font/google not used — skip
- }
- try {
- const fontLocal = await server.ssrLoadModule("next/font/local");
- if (typeof fontLocal.getSSRFontStyles === "function") {
- allFontStyles.push(...fontLocal.getSSRFontStyles());
- }
- // Collect preloads from local font files
- if (typeof fontLocal.getSSRFontPreloads === "function") {
- allFontPreloads.push(...fontLocal.getSSRFontPreloads());
- }
- } catch {
- // next/font/local not used — skip
- }
- // Emit for all collected font files (Google + local)
- for (const { href, type } of allFontPreloads) {
- // Escape href/type to prevent HTML attribute injection (defense-in-depth;
- // Vite-resolved asset paths should never contain special chars).
- const safeHref = href.replace(/&/g, "&").replace(/"/g, """);
- const safeType = type.replace(/&/g, "&").replace(/"/g, """);
- fontHeadHTML += `\n `;
- }
- if (allFontStyles.length > 0) {
- fontHeadHTML += `\n `;
- }
-
- // Convert absolute file paths to Vite-servable URLs (relative to root)
- const viteRoot = server.config.root;
- const pageModuleUrl = "/" + path.relative(viteRoot, route.filePath);
- const appModuleUrl = AppComponent
- ? "/" + path.relative(viteRoot, path.join(pagesDir, "_app"))
- : null;
-
- // Hydration entry: inline script that imports the page and hydrates.
- // Stores the React root and page loader for client-side navigation.
- const hydrationScript = `
+ // Wrap the entire request in a single unified AsyncLocalStorage scope.
+ const __uCtx = createRequestContext();
+ return runWithRequestContext(__uCtx, async () => {
+ ensureFetchPatch();
+ try {
+ // Set SSR context for the router shim so useRouter() returns
+ // the correct URL and params during server-side rendering.
+ const routerShim = await server.ssrLoadModule("next/router");
+ if (typeof routerShim.setSSRContext === "function") {
+ routerShim.setSSRContext({
+ pathname: patternToNextFormat(route.pattern),
+ query: { ...params, ...parseQuery(url) },
+ asPath: url,
+ locale: locale ?? i18nConfig?.defaultLocale,
+ locales: i18nConfig?.locales,
+ defaultLocale: i18nConfig?.defaultLocale,
+ });
+ }
+
+ // Set globalThis locale info for Link component locale prop support during SSR
+ if (i18nConfig) {
+ globalThis.__VINEXT_LOCALE__ = locale ?? i18nConfig.defaultLocale;
+ globalThis.__VINEXT_LOCALES__ = i18nConfig.locales;
+ globalThis.__VINEXT_DEFAULT_LOCALE__ = i18nConfig.defaultLocale;
+ }
+
+ // Load the page module through Vite's SSR pipeline
+ // This gives us HMR and transform support for free
+ const pageModule = await server.ssrLoadModule(route.filePath);
+ // Mark end of compile phase: everything from here is rendering.
+ _compileEnd = now();
+
+ // Get the page component (default export)
+ const PageComponent = pageModule.default;
+ if (!PageComponent) {
+ console.error(`[vinext] Page ${route.filePath} has no default export`);
+ res.statusCode = 500;
+ res.end("Page has no default export");
+ return;
+ }
+
+ // Collect page props via data fetching methods
+ let pageProps: Record = {};
+ let isrRevalidateSeconds: number | null = null;
+
+ // Handle getStaticPaths for dynamic routes: validate the path
+ // and respect fallback: false (return 404 for unlisted paths).
+ if (typeof pageModule.getStaticPaths === "function" && route.isDynamic) {
+ const pathsResult = await pageModule.getStaticPaths({
+ locales: i18nConfig?.locales ?? [],
+ defaultLocale: i18nConfig?.defaultLocale ?? "",
+ });
+ const fallback = pathsResult?.fallback ?? false;
+
+ if (fallback === false) {
+ // Only allow paths explicitly listed in getStaticPaths
+ const paths: Array<{ params: Record }> =
+ pathsResult?.paths ?? [];
+ const isValidPath = paths.some((p: { params: Record }) => {
+ return Object.entries(p.params).every(([key, val]) => {
+ const actual = params[key];
+ if (Array.isArray(val)) {
+ return Array.isArray(actual) && val.join("/") === actual.join("/");
+ }
+ return String(val) === String(actual);
+ });
+ });
+
+ if (!isValidPath) {
+ await renderErrorPage(
+ server,
+ req,
+ res,
+ url,
+ pagesDir,
+ 404,
+ routerShim.wrapWithRouterContext,
+ matcher,
+ );
+ return;
+ }
+ }
+ // fallback: true or "blocking" — always SSR on-demand.
+ // In dev mode, Next.js does the same (no fallback shell).
+ // In production, both modes SSR on-demand with caching.
+ // The difference is that fallback:true could serve a shell first,
+ // but since we always have data available via SSR, we render fully.
+ }
+
+ // Headers set by getServerSideProps for explicit forwarding to
+ // streamPageToResponse. Without this, they survive only through
+ // Node.js writeHead() implicitly merging setHeader() calls, which
+ // would silently break if streamPageToResponse is refactored.
+ const gsspExtraHeaders: Record = {};
+
+ if (typeof pageModule.getServerSideProps === "function") {
+ // Snapshot existing headers so we can detect what gSSP adds.
+ const headersBeforeGSSP = new Set(Object.keys(res.getHeaders()));
+
+ const context = {
+ params,
+ req,
+ res,
+ query: parseQuery(url),
+ resolvedUrl: localeStrippedUrl,
+ locale: locale ?? i18nConfig?.defaultLocale,
+ locales: i18nConfig?.locales,
+ defaultLocale: i18nConfig?.defaultLocale,
+ };
+ const result = await pageModule.getServerSideProps(context);
+ // If gSSP called res.end() directly (short-circuit pattern),
+ // the response is already sent. Do not continue rendering.
+ // Note: middleware headers are already on `res` (middleware runs
+ // before this handler in the connect chain), so they are included
+ // in the short-circuited response. The prod path achieves the same
+ // result via the worker entry merging middleware headers after
+ // renderPage() returns.
+ if (res.writableEnded) {
+ return;
+ }
+ if (result && "props" in result) {
+ pageProps = result.props;
+ }
+ if (result && "redirect" in result) {
+ const { redirect } = result;
+ const status = redirect.statusCode ?? (redirect.permanent ? 308 : 307);
+ // Sanitize destination to prevent open redirect via protocol-relative URLs.
+ // Also normalize backslashes — browsers treat \ as / in URL contexts.
+ let dest = redirect.destination;
+ if (!dest.startsWith("http://") && !dest.startsWith("https://")) {
+ dest = dest.replace(/^[\\/]+/, "/");
+ }
+ res.writeHead(status, {
+ Location: dest,
+ });
+ res.end();
+ return;
+ }
+ if (result && "notFound" in result && result.notFound) {
+ await renderErrorPage(
+ server,
+ req,
+ res,
+ url,
+ pagesDir,
+ 404,
+ routerShim.wrapWithRouterContext,
+ );
+ return;
+ }
+ // Preserve any status code set by gSSP (e.g. res.statusCode = 201).
+ // This takes precedence over the default 200 but not over middleware status.
+ if (!statusCode && res.statusCode !== 200) {
+ statusCode = res.statusCode;
+ }
+
+ // Capture headers newly set by gSSP and forward them explicitly.
+ // Remove from `res` to prevent duplication when writeHead() merges.
+ const headersAfterGSSP = res.getHeaders();
+ for (const [key, val] of Object.entries(headersAfterGSSP)) {
+ if (headersBeforeGSSP.has(key) || val == null) continue;
+ res.removeHeader(key);
+ if (Array.isArray(val)) {
+ gsspExtraHeaders[key] = val.map(String);
+ } else {
+ gsspExtraHeaders[key] = String(val);
+ }
+ }
+ }
+ // Collect font preloads early so ISR cached responses can include
+ // the Link header (font preloads are module-level state that persists
+ // across requests after the font modules are first loaded).
+ let earlyFontLinkHeader = "";
+ try {
+ const earlyPreloads: Array<{ href: string; type: string }> = [];
+ const fontGoogleEarly = await server.ssrLoadModule("next/font/google");
+ if (typeof fontGoogleEarly.getSSRFontPreloads === "function") {
+ earlyPreloads.push(...fontGoogleEarly.getSSRFontPreloads());
+ }
+ const fontLocalEarly = await server.ssrLoadModule("next/font/local");
+ if (typeof fontLocalEarly.getSSRFontPreloads === "function") {
+ earlyPreloads.push(...fontLocalEarly.getSSRFontPreloads());
+ }
+ if (earlyPreloads.length > 0) {
+ earlyFontLinkHeader = earlyPreloads
+ .map((p) => `<${p.href}>; rel=preload; as=font; type=${p.type}; crossorigin`)
+ .join(", ");
+ }
+ } catch {
+ // Font modules not loaded yet — skip
+ }
+
+ if (typeof pageModule.getStaticProps === "function") {
+ // Check ISR cache before calling getStaticProps
+ const cacheKey = isrCacheKey(
+ "pages",
+ url.split("?")[0],
+ // __VINEXT_BUILD_ID is a compile-time define — undefined in dev,
+ // which is fine: dev doesn't need cross-deploy cache isolation.
+ process.env.__VINEXT_BUILD_ID,
+ );
+ const cached = await isrGet(cacheKey);
+
+ if (cached && !cached.isStale && cached.value.value?.kind === "PAGES") {
+ // Fresh cache hit — serve directly
+ const cachedPage = cached.value.value as CachedPagesValue;
+ const cachedHtml = cachedPage.html;
+ const transformedHtml = await server.transformIndexHtml(url, cachedHtml);
+ const revalidateSecs = getRevalidateDuration(cacheKey) ?? 60;
+ const hitHeaders: Record = {
+ "Content-Type": "text/html",
+ "X-Vinext-Cache": "HIT",
+ "Cache-Control": `s-maxage=${revalidateSecs}, stale-while-revalidate`,
+ };
+ if (earlyFontLinkHeader) hitHeaders["Link"] = earlyFontLinkHeader;
+ res.writeHead(200, hitHeaders);
+ res.end(transformedHtml);
+ return;
+ }
+
+ if (cached && cached.isStale && cached.value.value?.kind === "PAGES") {
+ // Stale hit — serve stale immediately, trigger background regen
+ const cachedPage = cached.value.value as CachedPagesValue;
+ const cachedHtml = cachedPage.html;
+ const transformedHtml = await server.transformIndexHtml(url, cachedHtml);
+
+ // Trigger background regeneration: re-run getStaticProps and
+ // update the cache so the next request is a HIT with fresh data.
+ triggerBackgroundRegeneration(cacheKey, async () => {
+ const freshResult = await pageModule.getStaticProps({ params });
+ if (freshResult && "props" in freshResult) {
+ const revalidate =
+ typeof freshResult.revalidate === "number" ? freshResult.revalidate : 0;
+ if (revalidate > 0) {
+ await isrSet(
+ cacheKey,
+ buildPagesCacheValue(cachedHtml, freshResult.props),
+ revalidate,
+ );
+ }
+ }
+ });
+
+ const revalidateSecs = getRevalidateDuration(cacheKey) ?? 60;
+ const staleHeaders: Record = {
+ "Content-Type": "text/html",
+ "X-Vinext-Cache": "STALE",
+ "Cache-Control": `s-maxage=${revalidateSecs}, stale-while-revalidate`,
+ };
+ if (earlyFontLinkHeader) staleHeaders["Link"] = earlyFontLinkHeader;
+ res.writeHead(200, staleHeaders);
+ res.end(transformedHtml);
+ return;
+ }
+
+ // Cache miss — call getStaticProps normally
+ const context = {
+ params,
+ locale: locale ?? i18nConfig?.defaultLocale,
+ locales: i18nConfig?.locales,
+ defaultLocale: i18nConfig?.defaultLocale,
+ };
+ const result = await pageModule.getStaticProps(context);
+ if (result && "props" in result) {
+ pageProps = result.props;
+ }
+ if (result && "redirect" in result) {
+ const { redirect } = result;
+ const status = redirect.statusCode ?? (redirect.permanent ? 308 : 307);
+ // Sanitize destination to prevent open redirect via protocol-relative URLs.
+ // Also normalize backslashes — browsers treat \ as / in URL contexts.
+ let dest = redirect.destination;
+ if (!dest.startsWith("http://") && !dest.startsWith("https://")) {
+ dest = dest.replace(/^[\\/]+/, "/");
+ }
+ res.writeHead(status, {
+ Location: dest,
+ });
+ res.end();
+ return;
+ }
+ if (result && "notFound" in result && result.notFound) {
+ await renderErrorPage(
+ server,
+ req,
+ res,
+ url,
+ pagesDir,
+ 404,
+ routerShim.wrapWithRouterContext,
+ );
+ return;
+ }
+
+ // Extract revalidate period for ISR caching after render
+ if (typeof result?.revalidate === "number" && result.revalidate > 0) {
+ isrRevalidateSeconds = result.revalidate;
+ }
+ }
+
+ // Try to load _app.tsx if it exists
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let AppComponent: any = null;
+ const appPath = path.join(pagesDir, "_app");
+ if (findFileWithExtensions(appPath, matcher)) {
+ try {
+ const appModule = await server.ssrLoadModule(appPath);
+ AppComponent = appModule.default ?? null;
+ } catch {
+ // _app exists but failed to load
+ }
+ }
+
+ // React and ReactDOMServer are imported at the top level as native Node
+ // modules. They must NOT go through Vite's SSR module runner because
+ // React is CJS and the ESModulesEvaluator doesn't define `module`.
+ const createElement = React.createElement;
+ let element: React.ReactElement;
+
+ // wrapWithRouterContext wraps the element in RouterContext.Provider so that
+ // next/compat/router's useRouter() returns the real router.
+ const wrapWithRouterContext = routerShim.wrapWithRouterContext;
+
+ if (AppComponent) {
+ element = createElement(AppComponent, {
+ Component: PageComponent,
+ pageProps,
+ });
+ } else {
+ element = createElement(PageComponent, pageProps);
+ }
+
+ if (wrapWithRouterContext) {
+ element = wrapWithRouterContext(element);
+ }
+
+ // Reset SSR head collector before rendering so tags are captured
+ const headShim = await server.ssrLoadModule("next/head");
+ if (typeof headShim.resetSSRHead === "function") {
+ headShim.resetSSRHead();
+ }
+
+ // Flush any pending dynamic() preloads so components are ready
+ const dynamicShim = await server.ssrLoadModule("next/dynamic");
+ if (typeof dynamicShim.flushPreloads === "function") {
+ await dynamicShim.flushPreloads();
+ }
+
+ // Collect any tags that were rendered during data fetching
+ // (shell head tags — Suspense children's head tags arrive late,
+ // matching Next.js behavior)
+
+ // Collect SSR font links (Google Fonts tags) and font class styles
+ let fontHeadHTML = "";
+ const allFontStyles: string[] = [];
+ const allFontPreloads: Array<{ href: string; type: string }> = [];
+ try {
+ const fontGoogle = await server.ssrLoadModule("next/font/google");
+ if (typeof fontGoogle.getSSRFontLinks === "function") {
+ const fontUrls = fontGoogle.getSSRFontLinks();
+ for (const fontUrl of fontUrls) {
+ const safeFontUrl = fontUrl.replace(/&/g, "&").replace(/"/g, """);
+ fontHeadHTML += `\n `;
+ }
+ }
+ if (typeof fontGoogle.getSSRFontStyles === "function") {
+ allFontStyles.push(...fontGoogle.getSSRFontStyles());
+ }
+ // Collect preloads from self-hosted Google fonts
+ if (typeof fontGoogle.getSSRFontPreloads === "function") {
+ allFontPreloads.push(...fontGoogle.getSSRFontPreloads());
+ }
+ } catch {
+ // next/font/google not used — skip
+ }
+ try {
+ const fontLocal = await server.ssrLoadModule("next/font/local");
+ if (typeof fontLocal.getSSRFontStyles === "function") {
+ allFontStyles.push(...fontLocal.getSSRFontStyles());
+ }
+ // Collect preloads from local font files
+ if (typeof fontLocal.getSSRFontPreloads === "function") {
+ allFontPreloads.push(...fontLocal.getSSRFontPreloads());
+ }
+ } catch {
+ // next/font/local not used — skip
+ }
+ // Emit for all collected font files (Google + local)
+ for (const { href, type } of allFontPreloads) {
+ // Escape href/type to prevent HTML attribute injection (defense-in-depth;
+ // Vite-resolved asset paths should never contain special chars).
+ const safeHref = href.replace(/&/g, "&").replace(/"/g, """);
+ const safeType = type.replace(/&/g, "&").replace(/"/g, """);
+ fontHeadHTML += `\n `;
+ }
+ if (allFontStyles.length > 0) {
+ fontHeadHTML += `\n `;
+ }
+
+ // Convert absolute file paths to Vite-servable URLs (relative to root)
+ const viteRoot = server.config.root;
+ const pageModuleUrl = "/" + path.relative(viteRoot, route.filePath);
+ const appModuleUrl = AppComponent
+ ? "/" + path.relative(viteRoot, path.join(pagesDir, "_app"))
+ : null;
+
+ // Hydration entry: inline script that imports the page and hydrates.
+ // Stores the React root and page loader for client-side navigation.
+ const hydrationScript = `
`;
- const nextDataScript = ``;
-
- // Try to load custom _document.tsx
- const docPath = path.join(pagesDir, "_document");
- let DocumentComponent: any = null;
- if (findFileWithExtensions(docPath, matcher)) {
- try {
- const docModule = await server.ssrLoadModule(docPath);
- DocumentComponent = docModule.default ?? null;
- } catch {
- // _document exists but failed to load
- }
- }
-
- const allScripts = `${nextDataScript}\n ${hydrationScript}`;
-
- // Build response headers: start with gSSP headers, then layer on
- // ISR and font preload headers (which take precedence).
- const extraHeaders: Record = {
- ...gsspExtraHeaders,
- };
- if (isrRevalidateSeconds) {
- extraHeaders["Cache-Control"] =
- `s-maxage=${isrRevalidateSeconds}, stale-while-revalidate`;
- extraHeaders["X-Vinext-Cache"] = "MISS";
- }
-
- // Set HTTP Link header for font preloading.
- // This lets the browser (and CDN) start fetching font files before parsing HTML.
- if (allFontPreloads.length > 0) {
- extraHeaders["Link"] = allFontPreloads
- .map(
- (p) =>
- `<${p.href}>; rel=preload; as=font; type=${p.type}; crossorigin`,
- )
- .join(", ");
- }
-
- // Stream the page using progressive SSR.
- // The shell (layouts, non-suspended content) arrives immediately.
- // Suspense content streams in as it resolves.
- await streamPageToResponse(res, element, {
- url,
- server,
- fontHeadHTML,
- scripts: allScripts,
- DocumentComponent,
- statusCode,
- extraHeaders,
- // Collect head HTML AFTER the shell renders (inside streamPageToResponse,
- // after renderToReadableStream resolves). Head tags from Suspense
- // children arrive late — this matches Next.js behavior.
- getHeadHTML: () =>
- typeof headShim.getSSRHeadHTML === "function"
- ? headShim.getSSRHeadHTML()
- : "",
- });
- _renderEnd = now();
-
- // Clear SSR context after rendering
- if (typeof routerShim.setSSRContext === "function") {
- routerShim.setSSRContext(null);
- }
-
- // If ISR is enabled, we need the full HTML for caching.
- // For ISR, re-render synchronously to get the complete HTML string.
- // This runs after the stream is already sent, so it doesn't affect TTFB.
- if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) {
- let isrElement = AppComponent
- ? createElement(AppComponent, {
- Component: pageModule.default,
- pageProps,
- })
- : createElement(pageModule.default, pageProps);
- if (wrapWithRouterContext) {
- isrElement = wrapWithRouterContext(isrElement);
- }
- const isrBodyHtml = await renderToStringAsync(isrElement);
- const isrHtml = `${isrBodyHtml}
${allScripts}`;
- const cacheKey = isrCacheKey(
- "pages",
- url.split("?")[0],
- // __VINEXT_BUILD_ID is a compile-time define — undefined in dev,
- // which is fine: dev doesn't need cross-deploy cache isolation.
- process.env.__VINEXT_BUILD_ID,
- );
- await isrSet(
- cacheKey,
- buildPagesCacheValue(isrHtml, pageProps),
- isrRevalidateSeconds,
- );
- setRevalidateDuration(cacheKey, isrRevalidateSeconds);
- }
- } catch (e) {
- // Let Vite fix the stack trace for better dev experience
- server.ssrFixStacktrace(e as Error);
- console.error(e);
- // Report error via instrumentation hook if registered
- reportRequestError(
- e instanceof Error ? e : new Error(String(e)),
- {
- path: url,
- method: req.method ?? "GET",
- headers: Object.fromEntries(
- Object.entries(req.headers).map(([k, v]) => [
- k,
- Array.isArray(v) ? v.join(", ") : String(v ?? ""),
- ]),
- ),
- },
- {
- routerKind: "Pages Router",
- routePath: route.pattern,
- routeType: "render",
- },
- ).catch(() => {
- /* ignore reporting errors */
- });
- // Try to render custom 500 error page
- try {
- await renderErrorPage(
- server,
- req,
- res,
- url,
- pagesDir,
- 500,
- undefined,
- matcher,
- );
- } catch (fallbackErr) {
- // If error page itself fails, fall back to plain text.
- // This is a dev-only code path (prod uses prod-server.ts), so
- // include the error message for debugging.
- res.statusCode = 500;
- res.end(`Internal Server Error: ${(fallbackErr as Error).message}`);
- }
- } finally {
- // Cleanup is handled by ALS scope unwinding —
- // each runWith*() scope is automatically cleaned up when it exits.
- }
- }), // end runWithFetchCache
- ), // end runWithPrivateCache
- ), // end _runWithCacheState
- ), // end runWithHeadState
- ); // end runWithRouterState
+ const nextDataScript = ``;
+
+ // Try to load custom _document.tsx
+ const docPath = path.join(pagesDir, "_document");
+ let DocumentComponent: any = null;
+ if (findFileWithExtensions(docPath, matcher)) {
+ try {
+ const docModule = await server.ssrLoadModule(docPath);
+ DocumentComponent = docModule.default ?? null;
+ } catch {
+ // _document exists but failed to load
+ }
+ }
+
+ const allScripts = `${nextDataScript}\n ${hydrationScript}`;
+
+ // Build response headers: start with gSSP headers, then layer on
+ // ISR and font preload headers (which take precedence).
+ const extraHeaders: Record = {
+ ...gsspExtraHeaders,
+ };
+ if (isrRevalidateSeconds) {
+ extraHeaders["Cache-Control"] =
+ `s-maxage=${isrRevalidateSeconds}, stale-while-revalidate`;
+ extraHeaders["X-Vinext-Cache"] = "MISS";
+ }
+
+ // Set HTTP Link header for font preloading.
+ // This lets the browser (and CDN) start fetching font files before parsing HTML.
+ if (allFontPreloads.length > 0) {
+ extraHeaders["Link"] = allFontPreloads
+ .map((p) => `<${p.href}>; rel=preload; as=font; type=${p.type}; crossorigin`)
+ .join(", ");
+ }
+
+ // Stream the page using progressive SSR.
+ // The shell (layouts, non-suspended content) arrives immediately.
+ // Suspense content streams in as it resolves.
+ await streamPageToResponse(res, element, {
+ url,
+ server,
+ fontHeadHTML,
+ scripts: allScripts,
+ DocumentComponent,
+ statusCode,
+ extraHeaders,
+ // Collect head HTML AFTER the shell renders (inside streamPageToResponse,
+ // after renderToReadableStream resolves). Head tags from Suspense
+ // children arrive late — this matches Next.js behavior.
+ getHeadHTML: () =>
+ typeof headShim.getSSRHeadHTML === "function" ? headShim.getSSRHeadHTML() : "",
+ });
+ _renderEnd = now();
+
+ // Clear SSR context after rendering
+ if (typeof routerShim.setSSRContext === "function") {
+ routerShim.setSSRContext(null);
+ }
+
+ // If ISR is enabled, we need the full HTML for caching.
+ // For ISR, re-render synchronously to get the complete HTML string.
+ // This runs after the stream is already sent, so it doesn't affect TTFB.
+ if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) {
+ let isrElement = AppComponent
+ ? createElement(AppComponent, {
+ Component: pageModule.default,
+ pageProps,
+ })
+ : createElement(pageModule.default, pageProps);
+ if (wrapWithRouterContext) {
+ isrElement = wrapWithRouterContext(isrElement);
+ }
+ const isrBodyHtml = await renderToStringAsync(isrElement);
+ const isrHtml = `${isrBodyHtml}
${allScripts}`;
+ const cacheKey = isrCacheKey(
+ "pages",
+ url.split("?")[0],
+ // __VINEXT_BUILD_ID is a compile-time define — undefined in dev,
+ // which is fine: dev doesn't need cross-deploy cache isolation.
+ process.env.__VINEXT_BUILD_ID,
+ );
+ await isrSet(cacheKey, buildPagesCacheValue(isrHtml, pageProps), isrRevalidateSeconds);
+ setRevalidateDuration(cacheKey, isrRevalidateSeconds);
+ }
+ } catch (e) {
+ // Let Vite fix the stack trace for better dev experience
+ server.ssrFixStacktrace(e as Error);
+ console.error(e);
+ // Report error via instrumentation hook if registered
+ reportRequestError(
+ e instanceof Error ? e : new Error(String(e)),
+ {
+ path: url,
+ method: req.method ?? "GET",
+ headers: Object.fromEntries(
+ Object.entries(req.headers).map(([k, v]) => [
+ k,
+ Array.isArray(v) ? v.join(", ") : String(v ?? ""),
+ ]),
+ ),
+ },
+ {
+ routerKind: "Pages Router",
+ routePath: route.pattern,
+ routeType: "render",
+ },
+ ).catch(() => {
+ /* ignore reporting errors */
+ });
+ // Try to render custom 500 error page
+ try {
+ await renderErrorPage(server, req, res, url, pagesDir, 500, undefined, matcher);
+ } catch (fallbackErr) {
+ // If error page itself fails, fall back to plain text.
+ // This is a dev-only code path (prod uses prod-server.ts), so
+ // include the error message for debugging.
+ res.statusCode = 500;
+ res.end(`Internal Server Error: ${(fallbackErr as Error).message}`);
+ }
+ } finally {
+ // Cleanup is handled by unified ALS scope unwinding.
+ }
+ });
};
}
diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts
index 87765fa2..f1519368 100644
--- a/packages/vinext/src/shims/cache-runtime.ts
+++ b/packages/vinext/src/shims/cache-runtime.ts
@@ -35,7 +35,11 @@ import {
_registerCacheContextAccessor,
type CacheLifeConfig,
} from "./cache.js";
-import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
+import {
+ isInsideUnifiedScope,
+ getRequestContext,
+ runWithUnifiedStateMutation,
+} from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// Cache execution context — AsyncLocalStorage for cacheLife/cacheTag
@@ -257,7 +261,13 @@ function _getPrivateState(): PrivateCacheState {
*/
export function runWithPrivateCache(fn: () => T | Promise): T | Promise {
if (isInsideUnifiedScope()) {
- return fn();
+ return runWithUnifiedStateMutation((uCtx) => {
+ const previous = uCtx._privateCache;
+ uCtx._privateCache = new Map();
+ return () => {
+ uCtx._privateCache = previous;
+ };
+ }, fn);
}
const state: PrivateCacheState = {
cache: new Map(),
diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts
index 68945481..0e3452e8 100644
--- a/packages/vinext/src/shims/cache.ts
+++ b/packages/vinext/src/shims/cache.ts
@@ -20,7 +20,11 @@
import { markDynamicUsage as _markDynamic } from "./headers.js";
import { AsyncLocalStorage } from "node:async_hooks";
import { fnv1a64 } from "../utils/hash.js";
-import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
+import {
+ isInsideUnifiedScope,
+ getRequestContext,
+ runWithUnifiedStateMutation,
+} from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// Lazy accessor for cache context — avoids circular imports with cache-runtime.
@@ -413,7 +417,13 @@ function _getCacheState(): CacheState {
*/
export function _runWithCacheState(fn: () => T | Promise): T | Promise {
if (isInsideUnifiedScope()) {
- return fn();
+ return runWithUnifiedStateMutation((uCtx) => {
+ const previous = uCtx.requestScopedCacheLife;
+ uCtx.requestScopedCacheLife = null;
+ return () => {
+ uCtx.requestScopedCacheLife = previous;
+ };
+ }, fn);
}
const state: CacheState = {
requestScopedCacheLife: null,
diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts
index 1a17ff1c..9334a9e3 100644
--- a/packages/vinext/src/shims/fetch-cache.ts
+++ b/packages/vinext/src/shims/fetch-cache.ts
@@ -22,7 +22,11 @@
import { getCacheHandler, type CachedFetchValue } from "./cache.js";
import { getRequestExecutionContext } from "./request-context.js";
import { AsyncLocalStorage } from "node:async_hooks";
-import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
+import {
+ isInsideUnifiedScope,
+ getRequestContext,
+ runWithUnifiedStateMutation,
+} from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// Cache key generation
@@ -742,7 +746,13 @@ export function withFetchCache(): () => void {
export async function runWithFetchCache(fn: () => Promise): Promise {
_ensurePatchInstalled();
if (isInsideUnifiedScope()) {
- return fn();
+ return await runWithUnifiedStateMutation((uCtx) => {
+ const previousTags = uCtx.currentRequestTags;
+ uCtx.currentRequestTags = [];
+ return () => {
+ uCtx.currentRequestTags = previousTags;
+ };
+ }, fn);
}
return _als.run({ currentRequestTags: [] }, fn);
}
diff --git a/packages/vinext/src/shims/head-state.ts b/packages/vinext/src/shims/head-state.ts
index c59ab26d..e048f8f4 100644
--- a/packages/vinext/src/shims/head-state.ts
+++ b/packages/vinext/src/shims/head-state.ts
@@ -10,6 +10,11 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { _registerHeadStateAccessors } from "./head.js";
+import {
+ getRequestContext,
+ isInsideUnifiedScope,
+ runWithUnifiedStateMutation,
+} from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// ALS setup
@@ -29,6 +34,9 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= {
} satisfies HeadState) as HeadState;
function _getState(): HeadState {
+ if (isInsideUnifiedScope()) {
+ return getRequestContext() as unknown as HeadState;
+ }
return _als.getStore() ?? _fallbackState;
}
@@ -38,6 +46,16 @@ function _getState(): HeadState {
* on concurrent runtimes.
*/
export function runWithHeadState(fn: () => T | Promise): T | Promise {
+ if (isInsideUnifiedScope()) {
+ return runWithUnifiedStateMutation((uCtx) => {
+ const previous = uCtx.ssrHeadElements;
+ uCtx.ssrHeadElements = [];
+ return () => {
+ uCtx.ssrHeadElements = previous;
+ };
+ }, fn);
+ }
+
const state: HeadState = {
ssrHeadElements: [],
};
@@ -54,6 +72,10 @@ _registerHeadStateAccessors({
},
resetSSRHead(): void {
+ if (isInsideUnifiedScope()) {
+ getRequestContext().ssrHeadElements = [];
+ return;
+ }
const state = _als.getStore();
if (state) {
state.ssrHeadElements = [];
diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts
index 3a9daac7..30596397 100644
--- a/packages/vinext/src/shims/headers.ts
+++ b/packages/vinext/src/shims/headers.ts
@@ -11,7 +11,11 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { buildRequestHeadersFromMiddlewareResponse } from "../server/middleware-request-headers.js";
import { parseCookieHeader } from "./internal/parse-cookie-header.js";
-import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
+import {
+ isInsideUnifiedScope,
+ getRequestContext,
+ runWithUnifiedStateMutation,
+} from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// Request context
@@ -202,14 +206,29 @@ export function runWithHeadersContext(
fn: () => T | Promise,
): T | Promise {
if (isInsideUnifiedScope()) {
- // Inside unified scope — update the unified store directly, no extra ALS.
- const uCtx = getRequestContext();
- uCtx.headersContext = ctx as unknown;
- uCtx.dynamicUsageDetected = false;
- uCtx.pendingSetCookies = [];
- uCtx.draftModeCookieHeader = null;
- uCtx.phase = "render";
- return fn();
+ return runWithUnifiedStateMutation((uCtx) => {
+ const previous = {
+ headersContext: uCtx.headersContext,
+ dynamicUsageDetected: uCtx.dynamicUsageDetected,
+ pendingSetCookies: uCtx.pendingSetCookies,
+ draftModeCookieHeader: uCtx.draftModeCookieHeader,
+ phase: uCtx.phase,
+ };
+
+ uCtx.headersContext = ctx as unknown;
+ uCtx.dynamicUsageDetected = false;
+ uCtx.pendingSetCookies = [];
+ uCtx.draftModeCookieHeader = null;
+ uCtx.phase = "render";
+
+ return () => {
+ uCtx.headersContext = previous.headersContext;
+ uCtx.dynamicUsageDetected = previous.dynamicUsageDetected;
+ uCtx.pendingSetCookies = previous.pendingSetCookies;
+ uCtx.draftModeCookieHeader = previous.draftModeCookieHeader;
+ uCtx.phase = previous.phase;
+ };
+ }, fn);
}
const state: VinextHeadersShimState = {
diff --git a/packages/vinext/src/shims/navigation-state.ts b/packages/vinext/src/shims/navigation-state.ts
index 5b3e962f..80d50547 100644
--- a/packages/vinext/src/shims/navigation-state.ts
+++ b/packages/vinext/src/shims/navigation-state.ts
@@ -13,7 +13,11 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { _registerStateAccessors, type NavigationContext } from "./navigation.js";
-import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
+import {
+ isInsideUnifiedScope,
+ getRequestContext,
+ runWithUnifiedStateMutation,
+} from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// ALS setup — same pattern as headers.ts
@@ -49,7 +53,18 @@ function _getState(): NavigationState {
*/
export function runWithNavigationContext(fn: () => T | Promise): T | Promise {
if (isInsideUnifiedScope()) {
- return fn();
+ return runWithUnifiedStateMutation((uCtx) => {
+ const previousServerContext = uCtx.serverContext;
+ const previousCallbacks = uCtx.serverInsertedHTMLCallbacks;
+
+ uCtx.serverContext = null;
+ uCtx.serverInsertedHTMLCallbacks = [];
+
+ return () => {
+ uCtx.serverContext = previousServerContext;
+ uCtx.serverInsertedHTMLCallbacks = previousCallbacks;
+ };
+ }, fn);
}
const state: NavigationState = {
serverContext: null,
diff --git a/packages/vinext/src/shims/request-context.ts b/packages/vinext/src/shims/request-context.ts
index f1e7c9f8..762e7023 100644
--- a/packages/vinext/src/shims/request-context.ts
+++ b/packages/vinext/src/shims/request-context.ts
@@ -22,7 +22,11 @@
*/
import { AsyncLocalStorage } from "node:async_hooks";
-import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js";
+import {
+ isInsideUnifiedScope,
+ getRequestContext,
+ runWithUnifiedStateMutation,
+} from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// ExecutionContext interface
@@ -66,8 +70,13 @@ export function runWithExecutionContext(
fn: () => T | Promise,
): T | Promise {
if (isInsideUnifiedScope()) {
- getRequestContext().executionContext = ctx;
- return fn();
+ return runWithUnifiedStateMutation((uCtx) => {
+ const previous = uCtx.executionContext;
+ uCtx.executionContext = ctx;
+ return () => {
+ uCtx.executionContext = previous;
+ };
+ }, fn);
}
return _als.run(ctx, fn);
}
diff --git a/packages/vinext/src/shims/router-state.ts b/packages/vinext/src/shims/router-state.ts
index 67bd9aef..5ea5c471 100644
--- a/packages/vinext/src/shims/router-state.ts
+++ b/packages/vinext/src/shims/router-state.ts
@@ -10,6 +10,11 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { _registerRouterStateAccessors } from "./router.js";
+import {
+ getRequestContext,
+ isInsideUnifiedScope,
+ runWithUnifiedStateMutation,
+} from "./unified-request-context.js";
// ---------------------------------------------------------------------------
// ALS setup
@@ -39,6 +44,9 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= {
} satisfies RouterState) as RouterState;
function _getState(): RouterState {
+ if (isInsideUnifiedScope()) {
+ return getRequestContext() as unknown as RouterState;
+ }
return _als.getStore() ?? _fallbackState;
}
@@ -48,6 +56,16 @@ function _getState(): RouterState {
* on concurrent runtimes.
*/
export function runWithRouterState(fn: () => T | Promise): T | Promise {
+ if (isInsideUnifiedScope()) {
+ return runWithUnifiedStateMutation((uCtx) => {
+ const previous = uCtx.ssrContext;
+ uCtx.ssrContext = null;
+ return () => {
+ uCtx.ssrContext = previous;
+ };
+ }, fn);
+ }
+
const state: RouterState = {
ssrContext: null,
};
@@ -64,6 +82,10 @@ _registerRouterStateAccessors({
},
setSSRContext(ctx: SSRContext | null): void {
+ if (isInsideUnifiedScope()) {
+ getRequestContext().ssrContext = ctx as unknown;
+ return;
+ }
const state = _als.getStore();
if (state) {
state.ssrContext = ctx;
diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts
index fd330ec9..27d01fd0 100644
--- a/packages/vinext/src/shims/unified-request-context.ts
+++ b/packages/vinext/src/shims/unified-request-context.ts
@@ -57,6 +57,12 @@ export interface UnifiedRequestContext {
// ── request-context.ts ─────────────────────────────────────────────
/** Cloudflare Workers ExecutionContext, or null on Node.js dev. */
executionContext: unknown;
+
+ // ── router-state.ts / head-state.ts (Pages Router) ────────────────
+ /** Pages Router SSR context used by next/router during SSR. */
+ ssrContext: unknown;
+ /** Collected SSR HTML for the Pages Router. */
+ ssrHeadElements: string[];
}
// ---------------------------------------------------------------------------
@@ -66,10 +72,29 @@ export interface UnifiedRequestContext {
const _ALS_KEY = Symbol.for("vinext.unifiedRequestContext.als");
const _FALLBACK_KEY = Symbol.for("vinext.unifiedRequestContext.fallback");
+const _REQUEST_CONTEXT_ALS_KEY = Symbol.for("vinext.requestContext.als");
const _g = globalThis as unknown as Record;
const _als = (_g[_ALS_KEY] ??=
new AsyncLocalStorage()) as AsyncLocalStorage;
+function _getInheritedExecutionContext(): unknown {
+ const unifiedStore = _als.getStore();
+ if (unifiedStore) return unifiedStore.executionContext;
+
+ const executionContextAls = _g[_REQUEST_CONTEXT_ALS_KEY] as
+ | AsyncLocalStorage
+ | undefined;
+ return executionContextAls?.getStore() ?? null;
+}
+
+function _isPromiseLike(value: T | Promise): value is Promise {
+ return (
+ (typeof value === "object" || typeof value === "function") &&
+ value !== null &&
+ typeof (value as Promise).then === "function"
+ );
+}
+
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -90,7 +115,9 @@ export function createRequestContext(opts?: Partial): Uni
requestScopedCacheLife: null,
_privateCache: null,
currentRequestTags: [],
- executionContext: null,
+ executionContext: _getInheritedExecutionContext(),
+ ssrContext: null,
+ ssrHeadElements: [],
...opts,
};
}
@@ -110,6 +137,34 @@ export function runWithRequestContext(
return _als.run(ctx, fn);
}
+/**
+ * Apply a temporary mutation to the current unified store, then restore it
+ * after `fn` completes. Used by legacy runWith* wrappers to preserve their
+ * nested-scope semantics without creating another ALS layer.
+ *
+ * @internal
+ */
+export function runWithUnifiedStateMutation(
+ mutate: (ctx: UnifiedRequestContext) => () => void,
+ fn: () => T | Promise,
+): T | Promise {
+ const ctx = _als.getStore();
+ if (!ctx) return fn();
+
+ const restore = mutate(ctx);
+ try {
+ const result = fn();
+ if (_isPromiseLike(result)) {
+ return Promise.resolve(result).finally(restore) as Promise;
+ }
+ restore();
+ return result;
+ } catch (error) {
+ restore();
+ throw error;
+ }
+}
+
/**
* Get the current unified request context.
* Returns the ALS store when inside a `runWithRequestContext()` scope,
diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap
index a17ef826..2d6d1a04 100644
--- a/tests/__snapshots__/entry-templates.test.ts.snap
+++ b/tests/__snapshots__/entry-templates.test.ts.snap
@@ -1670,7 +1670,7 @@ export default async function handler(request, ctx) {
const headersCtx = headersContextFromRequest(request);
const __uCtx = _createUnifiedCtx({
headersContext: headersCtx,
- executionContext: ctx ?? null,
+ executionContext: ctx ?? _getRequestExecutionContext() ?? null,
});
return _runWithUnifiedCtx(__uCtx, async () => {
_ensureFetchPatch();
@@ -1679,7 +1679,7 @@ export default async function handler(request, ctx) {
// _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 response = await _handleRequest(request, __reqCtx, _mwCtx);
// 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.
@@ -4429,7 +4429,7 @@ export default async function handler(request, ctx) {
const headersCtx = headersContextFromRequest(request);
const __uCtx = _createUnifiedCtx({
headersContext: headersCtx,
- executionContext: ctx ?? null,
+ executionContext: ctx ?? _getRequestExecutionContext() ?? null,
});
return _runWithUnifiedCtx(__uCtx, async () => {
_ensureFetchPatch();
@@ -4438,7 +4438,7 @@ export default async function handler(request, ctx) {
// _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 response = await _handleRequest(request, __reqCtx, _mwCtx);
// 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.
@@ -7221,7 +7221,7 @@ export default async function handler(request, ctx) {
const headersCtx = headersContextFromRequest(request);
const __uCtx = _createUnifiedCtx({
headersContext: headersCtx,
- executionContext: ctx ?? null,
+ executionContext: ctx ?? _getRequestExecutionContext() ?? null,
});
return _runWithUnifiedCtx(__uCtx, async () => {
_ensureFetchPatch();
@@ -7230,7 +7230,7 @@ export default async function handler(request, ctx) {
// _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 response = await _handleRequest(request, __reqCtx, _mwCtx);
// 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.
@@ -10020,7 +10020,7 @@ export default async function handler(request, ctx) {
const headersCtx = headersContextFromRequest(request);
const __uCtx = _createUnifiedCtx({
headersContext: headersCtx,
- executionContext: ctx ?? null,
+ executionContext: ctx ?? _getRequestExecutionContext() ?? null,
});
return _runWithUnifiedCtx(__uCtx, async () => {
_ensureFetchPatch();
@@ -10029,7 +10029,7 @@ export default async function handler(request, ctx) {
// _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 response = await _handleRequest(request, __reqCtx, _mwCtx);
// 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.
@@ -12786,7 +12786,7 @@ export default async function handler(request, ctx) {
const headersCtx = headersContextFromRequest(request);
const __uCtx = _createUnifiedCtx({
headersContext: headersCtx,
- executionContext: ctx ?? null,
+ executionContext: ctx ?? _getRequestExecutionContext() ?? null,
});
return _runWithUnifiedCtx(__uCtx, async () => {
_ensureFetchPatch();
@@ -12795,7 +12795,7 @@ export default async function handler(request, ctx) {
// _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 response = await _handleRequest(request, __reqCtx, _mwCtx);
// 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.
@@ -15741,7 +15741,7 @@ export default async function handler(request, ctx) {
const headersCtx = headersContextFromRequest(request);
const __uCtx = _createUnifiedCtx({
headersContext: headersCtx,
- executionContext: ctx ?? null,
+ executionContext: ctx ?? _getRequestExecutionContext() ?? null,
});
return _runWithUnifiedCtx(__uCtx, async () => {
_ensureFetchPatch();
@@ -15750,7 +15750,7 @@ export default async function handler(request, ctx) {
// _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 response = await _handleRequest(request, __reqCtx, _mwCtx);
// 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.
@@ -17791,11 +17791,10 @@ import { resetSSRHead, getSSRHeadHTML } from "next/head";
import { flushPreloads } from "next/dynamic";
import { setSSRContext, wrapWithRouterContext } from "next/router";
import { getCacheHandler } from "next/cache";
-import { runWithFetchCache } from "vinext/fetch-cache";
-import { _runWithCacheState } from "next/cache";
-import { runWithPrivateCache } from "vinext/cache-runtime";
-import { runWithRouterState } from "vinext/router-state";
-import { runWithHeadState } from "vinext/head-state";
+import { ensureFetchPatch } from "vinext/fetch-cache";
+import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context";
+import "vinext/router-state";
+import "vinext/head-state";
import { safeJsonStringify } from "vinext/html";
import { decode as decodeQueryString } from "node:querystring";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
@@ -18334,11 +18333,9 @@ async function _renderPage(request, url, manifest) {
}
const { route, params } = match;
- return runWithRouterState(() =>
- runWithHeadState(() =>
- _runWithCacheState(() =>
- runWithPrivateCache(() =>
- runWithFetchCache(async () => {
+ const __uCtx = _createUnifiedCtx();
+ return _runWithUnifiedCtx(__uCtx, async () => {
+ ensureFetchPatch();
try {
if (typeof setSSRContext === "function") {
setSSRContext({
@@ -18625,11 +18622,7 @@ async function _renderPage(request, url, manifest) {
console.error("[vinext] SSR error:", e);
return new Response("Internal Server Error", { status: 500 });
}
- }) // end runWithFetchCache
- ) // end runWithPrivateCache
- ) // end _runWithCacheState
- ) // end runWithHeadState
- ); // end runWithRouterState
+ });
}
export async function handleApiRoute(request, url) {
diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts
index d7e8178c..4db692c9 100644
--- a/tests/fetch-cache.test.ts
+++ b/tests/fetch-cache.test.ts
@@ -38,6 +38,8 @@ const { withFetchCache, runWithFetchCache, getCollectedFetchTags, getOriginalFet
const { getCacheHandler, revalidateTag, MemoryCacheHandler, setCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { runWithExecutionContext } = await import("../packages/vinext/src/shims/request-context.js");
+const { createRequestContext, runWithRequestContext } =
+ await import("../packages/vinext/src/shims/unified-request-context.js");
describe("fetch cache shim", () => {
let cleanup: (() => void) | null = null;
@@ -311,6 +313,36 @@ describe("fetch cache shim", () => {
});
});
+ it("registers stale background refetch with waitUntil inside a unified request scope", async () => {
+ const waitUntilSpy = vi.fn<(p: Promise) => void>();
+ const mockCtx = { waitUntil: waitUntilSpy };
+
+ await runWithExecutionContext(mockCtx, async () => {
+ await runWithRequestContext(createRequestContext(), async () => {
+ const res1 = await fetch("https://api.example.com/unified-waituntil-test", {
+ next: { revalidate: 1 },
+ });
+ expect((await res1.json()).count).toBe(1);
+
+ const handler = getCacheHandler() as InstanceType;
+ const store = (handler as any).store as Map;
+ for (const [, entry] of store) {
+ entry.revalidateAt = Date.now() - 1000;
+ }
+
+ const res2 = await fetch("https://api.example.com/unified-waituntil-test", {
+ next: { revalidate: 1 },
+ });
+ expect((await res2.json()).count).toBe(1);
+
+ expect(waitUntilSpy).toHaveBeenCalledTimes(1);
+ expect(waitUntilSpy.mock.calls[0]![0]).toBeInstanceOf(Promise);
+ await waitUntilSpy.mock.calls[0]![0];
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+ });
+ });
+
// ── Independent cache entries per URL ───────────────────────────────
it("different URLs get independent cache entries", async () => {
diff --git a/tests/request-context.test.ts b/tests/request-context.test.ts
index a2e466d4..22715024 100644
--- a/tests/request-context.test.ts
+++ b/tests/request-context.test.ts
@@ -4,6 +4,10 @@ import {
getRequestExecutionContext,
type ExecutionContextLike,
} from "../packages/vinext/src/shims/request-context.js";
+import {
+ createRequestContext,
+ runWithRequestContext,
+} from "../packages/vinext/src/shims/unified-request-context.js";
function makeCtx(): ExecutionContextLike & { calls: Promise[] } {
const calls: Promise[] = [];
@@ -108,4 +112,21 @@ describe("runWithExecutionContext", () => {
expect(getRequestExecutionContext()).toBe(outerCtx);
});
});
+
+ it("restores the outer ctx when nested inside a unified request scope", () => {
+ const outerCtx = makeCtx();
+ const innerCtx = makeCtx();
+
+ runWithExecutionContext(outerCtx, () => {
+ runWithRequestContext(createRequestContext(), () => {
+ expect(getRequestExecutionContext()).toBe(outerCtx);
+
+ runWithExecutionContext(innerCtx, () => {
+ expect(getRequestExecutionContext()).toBe(innerCtx);
+ });
+
+ expect(getRequestExecutionContext()).toBe(outerCtx);
+ });
+ });
+ });
});
diff --git a/tests/shims.test.ts b/tests/shims.test.ts
index 4c5f44f8..4a2b66f5 100644
--- a/tests/shims.test.ts
+++ b/tests/shims.test.ts
@@ -2846,8 +2846,9 @@ describe("double-encoded path handling in middleware", () => {
expect(entryCode).not.toMatch(/normalizedRequest\s*=\s*new Request\(normalizedUrl/);
// It should still validate malformed encoding (return 400)
expect(entryCode).toContain("decodeURIComponent(rawPathname)");
- // The delegate call should pass `request` (not normalizedRequest)
- expect(entryCode).toMatch(/rscHandler\(request\)/);
+ // The delegate call should pass the original request object through,
+ // without reconstructing a normalized Request before delegation.
+ expect(entryCode).toMatch(/rscHandler\(request(?:,\s*ctx)?\)/);
});
});
diff --git a/tests/unified-request-context.test.ts b/tests/unified-request-context.test.ts
index 35cbf660..182e3cde 100644
--- a/tests/unified-request-context.test.ts
+++ b/tests/unified-request-context.test.ts
@@ -5,6 +5,10 @@ import {
getRequestContext,
isInsideUnifiedScope,
} from "../packages/vinext/src/shims/unified-request-context.js";
+import {
+ getRequestExecutionContext,
+ runWithExecutionContext,
+} from "../packages/vinext/src/shims/request-context.js";
describe("unified-request-context", () => {
describe("isInsideUnifiedScope", () => {
@@ -35,6 +39,8 @@ describe("unified-request-context", () => {
expect(ctx._privateCache).toBeNull();
expect(ctx.currentRequestTags).toEqual([]);
expect(ctx.executionContext).toBeNull();
+ expect(ctx.ssrContext).toBeNull();
+ expect(ctx.ssrHeadElements).toEqual([]);
});
});
@@ -217,6 +223,19 @@ describe("unified-request-context", () => {
});
expect(calls).toHaveLength(1);
});
+
+ it("inherits the outer ExecutionContext ALS when none is provided", () => {
+ const outerCtx = {
+ waitUntil() {},
+ };
+
+ runWithExecutionContext(outerCtx, () => {
+ runWithRequestContext(createRequestContext(), () => {
+ expect(getRequestContext().executionContext).toBe(outerCtx);
+ expect(getRequestExecutionContext()).toBe(outerCtx);
+ });
+ });
+ });
});
describe("sub-state field access", () => {
@@ -248,10 +267,144 @@ describe("unified-request-context", () => {
});
expect(ctx.currentRequestTags).toEqual(["tag1"]);
expect(ctx.executionContext).not.toBeNull();
+ expect(ctx.ssrContext).toBeNull();
+ expect(ctx.ssrHeadElements).toEqual([]);
});
});
});
+ describe("legacy wrapper semantics inside unified scope", () => {
+ it("runWithHeadersContext restores the outer headers sub-state", async () => {
+ const { runWithHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
+
+ const outerHeaders = {
+ headers: new Headers({ "x-id": "outer" }),
+ cookies: new Map([["outer", "1"]]),
+ };
+ const innerHeaders = {
+ headers: new Headers({ "x-id": "inner" }),
+ cookies: new Map([["inner", "1"]]),
+ };
+
+ runWithRequestContext(
+ createRequestContext({
+ headersContext: outerHeaders,
+ dynamicUsageDetected: true,
+ pendingSetCookies: ["outer=1"],
+ draftModeCookieHeader: "outer=draft",
+ phase: "action",
+ }),
+ () => {
+ runWithHeadersContext(innerHeaders as any, () => {
+ const ctx = getRequestContext();
+ expect((ctx.headersContext as any).headers.get("x-id")).toBe("inner");
+ expect(ctx.dynamicUsageDetected).toBe(false);
+ expect(ctx.pendingSetCookies).toEqual([]);
+ expect(ctx.draftModeCookieHeader).toBeNull();
+ expect(ctx.phase).toBe("render");
+
+ ctx.dynamicUsageDetected = true;
+ ctx.pendingSetCookies.push("inner=1");
+ ctx.draftModeCookieHeader = "inner=draft";
+ ctx.phase = "route-handler";
+ });
+
+ const ctx = getRequestContext();
+ expect(ctx.headersContext).toBe(outerHeaders);
+ expect(ctx.dynamicUsageDetected).toBe(true);
+ expect(ctx.pendingSetCookies).toEqual(["outer=1"]);
+ expect(ctx.draftModeCookieHeader).toBe("outer=draft");
+ expect(ctx.phase).toBe("action");
+ },
+ );
+ });
+
+ it("runWithNavigationContext restores the outer navigation sub-state", async () => {
+ await import("../packages/vinext/src/shims/navigation-state.js");
+ const { runWithNavigationContext } =
+ await import("../packages/vinext/src/shims/navigation-state.js");
+ const { setNavigationContext, getNavigationContext } =
+ await import("../packages/vinext/src/shims/navigation.js");
+
+ const outerCallback = () => "outer";
+
+ runWithRequestContext(
+ createRequestContext({
+ serverContext: { pathname: "/outer", searchParams: new URLSearchParams(), params: {} },
+ serverInsertedHTMLCallbacks: [outerCallback],
+ }),
+ () => {
+ runWithNavigationContext(() => {
+ expect(getNavigationContext()).toBeNull();
+ expect(getRequestContext().serverInsertedHTMLCallbacks).toEqual([]);
+
+ setNavigationContext({
+ pathname: "/inner",
+ searchParams: new URLSearchParams("q=1"),
+ params: { id: "1" },
+ });
+ getRequestContext().serverInsertedHTMLCallbacks.push(() => "inner");
+ });
+
+ expect((getNavigationContext() as any)?.pathname).toBe("/outer");
+ expect(getRequestContext().serverInsertedHTMLCallbacks).toEqual([outerCallback]);
+ },
+ );
+ });
+
+ it("cache/private/fetch/router/head sub-scopes reset and restore correctly", async () => {
+ const { _runWithCacheState } = await import("../packages/vinext/src/shims/cache.js");
+ const { runWithPrivateCache } = await import("../packages/vinext/src/shims/cache-runtime.js");
+ const { runWithFetchCache, getCollectedFetchTags } =
+ await import("../packages/vinext/src/shims/fetch-cache.js");
+ const { runWithRouterState } = await import("../packages/vinext/src/shims/router-state.js");
+ const { setSSRContext } = await import("../packages/vinext/src/shims/router.js");
+ const { runWithHeadState } = await import("../packages/vinext/src/shims/head-state.js");
+
+ runWithRequestContext(
+ createRequestContext({
+ requestScopedCacheLife: { revalidate: 60 },
+ _privateCache: new Map([["outer", 1]]),
+ currentRequestTags: ["outer-tag"],
+ ssrContext: { pathname: "/outer", query: {}, asPath: "/outer" },
+ ssrHeadElements: [""],
+ }),
+ async () => {
+ _runWithCacheState(() => {
+ expect(getRequestContext().requestScopedCacheLife).toBeNull();
+ getRequestContext().requestScopedCacheLife = { revalidate: 1 } as any;
+ });
+ expect(getRequestContext().requestScopedCacheLife).toEqual({ revalidate: 60 });
+
+ runWithPrivateCache(() => {
+ expect(getRequestContext()._privateCache).toBeInstanceOf(Map);
+ expect(getRequestContext()._privateCache?.size).toBe(0);
+ getRequestContext()._privateCache?.set("inner", 2);
+ });
+ expect([...getRequestContext()._privateCache!.entries()]).toEqual([["outer", 1]]);
+
+ await runWithFetchCache(async () => {
+ expect(getCollectedFetchTags()).toEqual([]);
+ getRequestContext().currentRequestTags.push("inner-tag");
+ });
+ expect(getCollectedFetchTags()).toEqual(["outer-tag"]);
+
+ runWithRouterState(() => {
+ expect(getRequestContext().ssrContext).toBeNull();
+ setSSRContext({ pathname: "/inner", query: {}, asPath: "/inner" } as any);
+ });
+ expect((getRequestContext().ssrContext as any).pathname).toBe("/outer");
+
+ runWithHeadState(() => {
+ expect(getRequestContext().ssrHeadElements).toEqual([]);
+ getRequestContext().ssrHeadElements.push("");
+ });
+ expect(getRequestContext().ssrHeadElements).toEqual([""]);
+ },
+ );
+ });
+ });
+
describe("createRequestContext", () => {
it("creates context with all defaults", () => {
const ctx = createRequestContext();
@@ -266,6 +419,8 @@ describe("unified-request-context", () => {
expect(ctx._privateCache).toBeNull();
expect(ctx.currentRequestTags).toEqual([]);
expect(ctx.executionContext).toBeNull();
+ expect(ctx.ssrContext).toBeNull();
+ expect(ctx.ssrHeadElements).toEqual([]);
});
it("merges partial overrides", () => {
@@ -278,6 +433,8 @@ describe("unified-request-context", () => {
// Other fields get defaults
expect(ctx.headersContext).toBeNull();
expect(ctx.currentRequestTags).toEqual([]);
+ expect(ctx.ssrContext).toBeNull();
+ expect(ctx.ssrHeadElements).toEqual([]);
});
});
});
From 68ff3edc82b4274935a07c00f46defd1b587dd7e Mon Sep 17 00:00:00 2001
From: Divanshu Chauhan
Date: Wed, 11 Mar 2026 11:20:53 -0700
Subject: [PATCH 5/6] fix: address unified request context review feedback
---
packages/vinext/src/entries/app-rsc-entry.ts | 2 +-
.../vinext/src/entries/pages-server-entry.ts | 36 +++---
packages/vinext/src/shims/cache-runtime.ts | 20 ++--
packages/vinext/src/shims/cache.ts | 8 +-
packages/vinext/src/shims/fetch-cache.ts | 8 +-
packages/vinext/src/shims/head-state.ts | 8 +-
packages/vinext/src/shims/headers.ts | 24 +---
packages/vinext/src/shims/navigation-state.ts | 12 +-
packages/vinext/src/shims/request-context.ts | 4 -
.../vinext/src/shims/request-state-types.ts | 9 ++
packages/vinext/src/shims/router-state.ts | 12 +-
.../src/shims/unified-request-context.ts | 108 ++++++------------
.../entry-templates.test.ts.snap | 48 ++++----
tests/app-router.test.ts | 5 +
tests/unified-request-context.test.ts | 63 +++++++++-
15 files changed, 174 insertions(+), 193 deletions(-)
create mode 100644 packages/vinext/src/shims/request-state-types.ts
diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index 9e82ffda..a8bc5672 100644
--- a/packages/vinext/src/entries/app-rsc-entry.ts
+++ b/packages/vinext/src/entries/app-rsc-entry.ts
@@ -1421,7 +1421,7 @@ export default async function handler(request, ctx) {
if (__configHeaders.length) {
const url = new URL(request.url);
let pathname;
- try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+ try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""}
const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
for (const h of extraHeaders) {
diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts
index 2663ad3a..e4c37f6f 100644
--- a/packages/vinext/src/entries/pages-server-entry.ts
+++ b/packages/vinext/src/entries/pages-server-entry.ts
@@ -713,26 +713,26 @@ async function _renderPage(request, url, manifest) {
const __uCtx = _createUnifiedCtx();
return _runWithUnifiedCtx(__uCtx, async () => {
ensureFetchPatch();
- try {
- if (typeof setSSRContext === "function") {
- setSSRContext({
- pathname: patternToNextFormat(route.pattern),
- query: { ...params, ...parseQuery(routeUrl) },
- asPath: routeUrl,
- locale: locale,
- locales: i18nConfig ? i18nConfig.locales : undefined,
- defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined,
- });
- }
+ try {
+ if (typeof setSSRContext === "function") {
+ setSSRContext({
+ pathname: patternToNextFormat(route.pattern),
+ query: { ...params, ...parseQuery(routeUrl) },
+ asPath: routeUrl,
+ locale: locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined,
+ });
+ }
- if (i18nConfig) {
- globalThis.__VINEXT_LOCALE__ = locale;
- globalThis.__VINEXT_LOCALES__ = i18nConfig.locales;
- globalThis.__VINEXT_DEFAULT_LOCALE__ = i18nConfig.defaultLocale;
- }
+ if (i18nConfig) {
+ globalThis.__VINEXT_LOCALE__ = locale;
+ globalThis.__VINEXT_LOCALES__ = i18nConfig.locales;
+ globalThis.__VINEXT_DEFAULT_LOCALE__ = i18nConfig.defaultLocale;
+ }
- const pageModule = route.module;
- const PageComponent = pageModule.default;
+ const pageModule = route.module;
+ const PageComponent = pageModule.default;
if (!PageComponent) {
return new Response("Page has no default export", { status: 500 });
}
diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts
index f1519368..397167d3 100644
--- a/packages/vinext/src/shims/cache-runtime.ts
+++ b/packages/vinext/src/shims/cache-runtime.ts
@@ -229,8 +229,8 @@ function resolveCacheLife(configs: CacheLifeConfig[]): CacheLifeConfig {
// Uses AsyncLocalStorage for request isolation so concurrent requests
// on Workers don't share private cache entries.
// ---------------------------------------------------------------------------
-interface PrivateCacheState {
- cache: Map;
+export interface PrivateCacheState {
+ _privateCache: Map | null;
}
const _PRIVATE_ALS_KEY = Symbol.for("vinext.cacheRuntime.privateAls");
@@ -240,7 +240,7 @@ const _privateAls = (_g[_PRIVATE_ALS_KEY] ??=
new AsyncLocalStorage()) as AsyncLocalStorage;
const _privateFallbackState = (_g[_PRIVATE_FALLBACK_KEY] ??= {
- cache: new Map(),
+ _privateCache: new Map(),
} satisfies PrivateCacheState) as PrivateCacheState;
function _getPrivateState(): PrivateCacheState {
@@ -249,7 +249,7 @@ function _getPrivateState(): PrivateCacheState {
if (ctx._privateCache === null) {
ctx._privateCache = new Map();
}
- return { cache: ctx._privateCache };
+ return ctx;
}
return _privateAls.getStore() ?? _privateFallbackState;
}
@@ -262,15 +262,11 @@ function _getPrivateState(): PrivateCacheState {
export function runWithPrivateCache(fn: () => T | Promise): T | Promise {
if (isInsideUnifiedScope()) {
return runWithUnifiedStateMutation((uCtx) => {
- const previous = uCtx._privateCache;
uCtx._privateCache = new Map();
- return () => {
- uCtx._privateCache = previous;
- };
}, fn);
}
const state: PrivateCacheState = {
- cache: new Map(),
+ _privateCache: new Map(),
};
return _privateAls.run(state, fn);
}
@@ -286,9 +282,9 @@ export function clearPrivateCache(): void {
}
const state = _privateAls.getStore();
if (state) {
- state.cache = new Map();
+ state._privateCache = new Map();
} else {
- _privateFallbackState.cache = new Map();
+ _privateFallbackState._privateCache = new Map();
}
}
@@ -357,7 +353,7 @@ export function registerCachedFunction Promise(fn: () => T | Promise): T | Promise {
if (isInsideUnifiedScope()) {
return runWithUnifiedStateMutation((uCtx) => {
- const previous = uCtx.requestScopedCacheLife;
uCtx.requestScopedCacheLife = null;
- return () => {
- uCtx.requestScopedCacheLife = previous;
- };
}, fn);
}
const state: CacheState = {
diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts
index 9334a9e3..d2f9b660 100644
--- a/packages/vinext/src/shims/fetch-cache.ts
+++ b/packages/vinext/src/shims/fetch-cache.ts
@@ -428,7 +428,7 @@ const originalFetch: typeof globalThis.fetch = (_gFetch[_ORIG_FETCH_KEY] ??=
// Uses Symbol.for() on globalThis so the storage is shared across Vite's
// multi-environment module instances.
// ---------------------------------------------------------------------------
-interface FetchCacheState {
+export interface FetchCacheState {
currentRequestTags: string[];
}
@@ -444,7 +444,7 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= {
function _getState(): FetchCacheState {
if (isInsideUnifiedScope()) {
- return getRequestContext() as unknown as FetchCacheState;
+ return getRequestContext();
}
return _als.getStore() ?? _fallbackState;
}
@@ -747,11 +747,7 @@ export async function runWithFetchCache(fn: () => Promise): Promise {
_ensurePatchInstalled();
if (isInsideUnifiedScope()) {
return await runWithUnifiedStateMutation((uCtx) => {
- const previousTags = uCtx.currentRequestTags;
uCtx.currentRequestTags = [];
- return () => {
- uCtx.currentRequestTags = previousTags;
- };
}, fn);
}
return _als.run({ currentRequestTags: [] }, fn);
diff --git a/packages/vinext/src/shims/head-state.ts b/packages/vinext/src/shims/head-state.ts
index e048f8f4..cb2564d4 100644
--- a/packages/vinext/src/shims/head-state.ts
+++ b/packages/vinext/src/shims/head-state.ts
@@ -20,7 +20,7 @@ import {
// ALS setup
// ---------------------------------------------------------------------------
-interface HeadState {
+export interface HeadState {
ssrHeadElements: string[];
}
@@ -35,7 +35,7 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= {
function _getState(): HeadState {
if (isInsideUnifiedScope()) {
- return getRequestContext() as unknown as HeadState;
+ return getRequestContext();
}
return _als.getStore() ?? _fallbackState;
}
@@ -48,11 +48,7 @@ function _getState(): HeadState {
export function runWithHeadState(fn: () => T | Promise): T | Promise {
if (isInsideUnifiedScope()) {
return runWithUnifiedStateMutation((uCtx) => {
- const previous = uCtx.ssrHeadElements;
uCtx.ssrHeadElements = [];
- return () => {
- uCtx.ssrHeadElements = previous;
- };
}, fn);
}
diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts
index 30596397..1bbbdd5f 100644
--- a/packages/vinext/src/shims/headers.ts
+++ b/packages/vinext/src/shims/headers.ts
@@ -21,7 +21,7 @@ import {
// Request context
// ---------------------------------------------------------------------------
-interface HeadersContext {
+export interface HeadersContext {
headers: Headers;
cookies: Map;
accessError?: Error;
@@ -32,7 +32,7 @@ interface HeadersContext {
export type HeadersAccessPhase = "render" | "action" | "route-handler";
-type VinextHeadersShimState = {
+export type VinextHeadersShimState = {
headersContext: HeadersContext | null;
dynamicUsageDetected: boolean;
pendingSetCookies: string[];
@@ -63,7 +63,7 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= {
function _getState(): VinextHeadersShimState {
if (isInsideUnifiedScope()) {
- return getRequestContext() as unknown as VinextHeadersShimState;
+ return getRequestContext();
}
return _als.getStore() ?? _fallbackState;
}
@@ -207,27 +207,11 @@ export function runWithHeadersContext(
): T | Promise {
if (isInsideUnifiedScope()) {
return runWithUnifiedStateMutation((uCtx) => {
- const previous = {
- headersContext: uCtx.headersContext,
- dynamicUsageDetected: uCtx.dynamicUsageDetected,
- pendingSetCookies: uCtx.pendingSetCookies,
- draftModeCookieHeader: uCtx.draftModeCookieHeader,
- phase: uCtx.phase,
- };
-
- uCtx.headersContext = ctx as unknown;
+ uCtx.headersContext = ctx;
uCtx.dynamicUsageDetected = false;
uCtx.pendingSetCookies = [];
uCtx.draftModeCookieHeader = null;
uCtx.phase = "render";
-
- return () => {
- uCtx.headersContext = previous.headersContext;
- uCtx.dynamicUsageDetected = previous.dynamicUsageDetected;
- uCtx.pendingSetCookies = previous.pendingSetCookies;
- uCtx.draftModeCookieHeader = previous.draftModeCookieHeader;
- uCtx.phase = previous.phase;
- };
}, fn);
}
diff --git a/packages/vinext/src/shims/navigation-state.ts b/packages/vinext/src/shims/navigation-state.ts
index 80d50547..c831a1d0 100644
--- a/packages/vinext/src/shims/navigation-state.ts
+++ b/packages/vinext/src/shims/navigation-state.ts
@@ -23,7 +23,7 @@ import {
// ALS setup — same pattern as headers.ts
// ---------------------------------------------------------------------------
-interface NavigationState {
+export interface NavigationState {
serverContext: NavigationContext | null;
serverInsertedHTMLCallbacks: Array<() => unknown>;
}
@@ -41,7 +41,7 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= {
function _getState(): NavigationState {
if (isInsideUnifiedScope()) {
- return getRequestContext() as unknown as NavigationState;
+ return getRequestContext();
}
return _als.getStore() ?? _fallbackState;
}
@@ -54,16 +54,8 @@ function _getState(): NavigationState {
export function runWithNavigationContext(fn: () => T | Promise): T | Promise {
if (isInsideUnifiedScope()) {
return runWithUnifiedStateMutation((uCtx) => {
- const previousServerContext = uCtx.serverContext;
- const previousCallbacks = uCtx.serverInsertedHTMLCallbacks;
-
uCtx.serverContext = null;
uCtx.serverInsertedHTMLCallbacks = [];
-
- return () => {
- uCtx.serverContext = previousServerContext;
- uCtx.serverInsertedHTMLCallbacks = previousCallbacks;
- };
}, fn);
}
const state: NavigationState = {
diff --git a/packages/vinext/src/shims/request-context.ts b/packages/vinext/src/shims/request-context.ts
index 762e7023..ea03863d 100644
--- a/packages/vinext/src/shims/request-context.ts
+++ b/packages/vinext/src/shims/request-context.ts
@@ -71,11 +71,7 @@ export function runWithExecutionContext(
): T | Promise {
if (isInsideUnifiedScope()) {
return runWithUnifiedStateMutation((uCtx) => {
- const previous = uCtx.executionContext;
uCtx.executionContext = ctx;
- return () => {
- uCtx.executionContext = previous;
- };
}, fn);
}
return _als.run(ctx, fn);
diff --git a/packages/vinext/src/shims/request-state-types.ts b/packages/vinext/src/shims/request-state-types.ts
new file mode 100644
index 00000000..089a7317
--- /dev/null
+++ b/packages/vinext/src/shims/request-state-types.ts
@@ -0,0 +1,9 @@
+export type { HeadersAccessPhase, HeadersContext, VinextHeadersShimState } from "./headers.js";
+export type { NavigationContext } from "./navigation.js";
+export type { NavigationState } from "./navigation-state.js";
+export type { CacheLifeConfig, CacheState } from "./cache.js";
+export type { PrivateCacheState } from "./cache-runtime.js";
+export type { FetchCacheState } from "./fetch-cache.js";
+export type { ExecutionContextLike } from "./request-context.js";
+export type { SSRContext, RouterState } from "./router-state.js";
+export type { HeadState } from "./head-state.js";
diff --git a/packages/vinext/src/shims/router-state.ts b/packages/vinext/src/shims/router-state.ts
index 5ea5c471..08deaf8f 100644
--- a/packages/vinext/src/shims/router-state.ts
+++ b/packages/vinext/src/shims/router-state.ts
@@ -20,7 +20,7 @@ import {
// ALS setup
// ---------------------------------------------------------------------------
-interface SSRContext {
+export interface SSRContext {
pathname: string;
query: Record;
asPath: string;
@@ -29,7 +29,7 @@ interface SSRContext {
defaultLocale?: string;
}
-interface RouterState {
+export interface RouterState {
ssrContext: SSRContext | null;
}
@@ -45,7 +45,7 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= {
function _getState(): RouterState {
if (isInsideUnifiedScope()) {
- return getRequestContext() as unknown as RouterState;
+ return getRequestContext();
}
return _als.getStore() ?? _fallbackState;
}
@@ -58,11 +58,7 @@ function _getState(): RouterState {
export function runWithRouterState(fn: () => T | Promise): T | Promise {
if (isInsideUnifiedScope()) {
return runWithUnifiedStateMutation((uCtx) => {
- const previous = uCtx.ssrContext;
uCtx.ssrContext = null;
- return () => {
- uCtx.ssrContext = previous;
- };
}, fn);
}
@@ -83,7 +79,7 @@ _registerRouterStateAccessors({
setSSRContext(ctx: SSRContext | null): void {
if (isInsideUnifiedScope()) {
- getRequestContext().ssrContext = ctx as unknown;
+ getRequestContext().ssrContext = ctx;
return;
}
const state = _als.getStore();
diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts
index 27d01fd0..950da124 100644
--- a/packages/vinext/src/shims/unified-request-context.ts
+++ b/packages/vinext/src/shims/unified-request-context.ts
@@ -11,6 +11,16 @@
*/
import { AsyncLocalStorage } from "node:async_hooks";
+import type {
+ CacheState,
+ ExecutionContextLike,
+ FetchCacheState,
+ HeadState,
+ NavigationState,
+ PrivateCacheState,
+ RouterState,
+ VinextHeadersShimState,
+} from "./request-state-types.js";
// ---------------------------------------------------------------------------
// Unified context shape
@@ -23,46 +33,18 @@ import { AsyncLocalStorage } from "node:async_hooks";
*
* Each field group is documented with its source shim module.
*/
-export interface UnifiedRequestContext {
- // ── headers.ts (VinextHeadersShimState) ────────────────────────────
- /** The request's headers/cookies context, or null before setup. */
- headersContext: unknown;
- /** Set to true when a component calls connection/cookies/headers/noStore. */
- dynamicUsageDetected: boolean;
- /** Accumulated Set-Cookie header strings from cookies().set()/delete(). */
- pendingSetCookies: string[];
- /** Set-Cookie header from draftMode().enable()/disable(). */
- draftModeCookieHeader: string | null;
- /** Current request phase — determines cookie mutability. */
- phase: "render" | "action" | "route-handler";
-
- // ── navigation-state.ts (NavigationState) ──────────────────────────
- /** Server-side navigation context (pathname, searchParams, params). */
- serverContext: unknown;
- /** useServerInsertedHTML callbacks for CSS-in-JS etc. */
- serverInsertedHTMLCallbacks: Array<() => unknown>;
-
- // ── cache.ts (CacheState) ──────────────────────────────────────────
- /** Request-scoped cacheLife config from page-level "use cache". */
- requestScopedCacheLife: unknown;
-
- // ── cache-runtime.ts (PrivateCacheState) — lazy ────────────────────
- /** Per-request cache for "use cache: private". Null until first access. */
- _privateCache: Map | null;
-
- // ── fetch-cache.ts (FetchCacheState) ───────────────────────────────
- /** Tags collected from fetch() calls during this render pass. */
- currentRequestTags: string[];
-
+export interface UnifiedRequestContext
+ extends
+ VinextHeadersShimState,
+ NavigationState,
+ CacheState,
+ PrivateCacheState,
+ FetchCacheState,
+ RouterState,
+ HeadState {
// ── request-context.ts ─────────────────────────────────────────────
/** Cloudflare Workers ExecutionContext, or null on Node.js dev. */
- executionContext: unknown;
-
- // ── router-state.ts / head-state.ts (Pages Router) ────────────────
- /** Pages Router SSR context used by next/router during SSR. */
- ssrContext: unknown;
- /** Collected SSR HTML for the Pages Router. */
- ssrHeadElements: string[];
+ executionContext: ExecutionContextLike | null;
}
// ---------------------------------------------------------------------------
@@ -71,30 +53,21 @@ export interface UnifiedRequestContext {
// ---------------------------------------------------------------------------
const _ALS_KEY = Symbol.for("vinext.unifiedRequestContext.als");
-const _FALLBACK_KEY = Symbol.for("vinext.unifiedRequestContext.fallback");
const _REQUEST_CONTEXT_ALS_KEY = Symbol.for("vinext.requestContext.als");
const _g = globalThis as unknown as Record;
const _als = (_g[_ALS_KEY] ??=
new AsyncLocalStorage()) as AsyncLocalStorage;
-function _getInheritedExecutionContext(): unknown {
+function _getInheritedExecutionContext(): ExecutionContextLike | null {
const unifiedStore = _als.getStore();
if (unifiedStore) return unifiedStore.executionContext;
const executionContextAls = _g[_REQUEST_CONTEXT_ALS_KEY] as
- | AsyncLocalStorage
+ | AsyncLocalStorage
| undefined;
return executionContextAls?.getStore() ?? null;
}
-function _isPromiseLike(value: T | Promise): value is Promise {
- return (
- (typeof value === "object" || typeof value === "function") &&
- value !== null &&
- typeof (value as Promise).then === "function"
- );
-}
-
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -122,9 +95,6 @@ export function createRequestContext(opts?: Partial): Uni
};
}
-/** Module-level fallback for environments without ALS wrapping (dev, tests). */
-const _fallbackState = (_g[_FALLBACK_KEY] ??= createRequestContext()) as UnifiedRequestContext;
-
/**
* Run `fn` within a unified request context scope.
* All shim modules will read/write their state from `ctx` for the
@@ -138,40 +108,34 @@ export function runWithRequestContext(
}
/**
- * Apply a temporary mutation to the current unified store, then restore it
- * after `fn` completes. Used by legacy runWith* wrappers to preserve their
- * nested-scope semantics without creating another ALS layer.
+ * Run `fn` in a nested unified scope derived from the current request context.
+ * Used by legacy runWith* wrappers to reset or override one sub-state while
+ * preserving proper async isolation for continuations created inside `fn`.
+ * The child scope is a shallow clone of the parent store, so untouched fields
+ * keep sharing their existing references while overridden slices can be reset.
*
* @internal
*/
export function runWithUnifiedStateMutation(
- mutate: (ctx: UnifiedRequestContext) => () => void,
+ mutate: (ctx: UnifiedRequestContext) => void,
fn: () => T | Promise,
): T | Promise {
- const ctx = _als.getStore();
- if (!ctx) return fn();
+ const parentCtx = _als.getStore();
+ if (!parentCtx) return fn();
- const restore = mutate(ctx);
- try {
- const result = fn();
- if (_isPromiseLike(result)) {
- return Promise.resolve(result).finally(restore) as Promise;
- }
- restore();
- return result;
- } catch (error) {
- restore();
- throw error;
- }
+ const childCtx = { ...parentCtx };
+ mutate(childCtx);
+ return _als.run(childCtx, fn);
}
/**
* Get the current unified request context.
* Returns the ALS store when inside a `runWithRequestContext()` scope,
- * or the module-level fallback otherwise.
+ * or a fresh detached context otherwise. Mutations to the detached value do
+ * not persist across calls.
*/
export function getRequestContext(): UnifiedRequestContext {
- return _als.getStore() ?? _fallbackState;
+ return _als.getStore() ?? createRequestContext();
}
/**
diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap
index 2d6d1a04..50c0d98d 100644
--- a/tests/__snapshots__/entry-templates.test.ts.snap
+++ b/tests/__snapshots__/entry-templates.test.ts.snap
@@ -1687,7 +1687,7 @@ export default async function handler(request, ctx) {
if (__configHeaders.length) {
const url = new URL(request.url);
let pathname;
- try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+ try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
for (const h of extraHeaders) {
@@ -4446,7 +4446,7 @@ export default async function handler(request, ctx) {
if (__configHeaders.length) {
const url = new URL(request.url);
let pathname;
- try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+ try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
if (pathname.startsWith("/base")) pathname = pathname.slice("/base".length) || "/";
const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
for (const h of extraHeaders) {
@@ -7238,7 +7238,7 @@ export default async function handler(request, ctx) {
if (__configHeaders.length) {
const url = new URL(request.url);
let pathname;
- try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+ try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
for (const h of extraHeaders) {
@@ -10037,7 +10037,7 @@ export default async function handler(request, ctx) {
if (__configHeaders.length) {
const url = new URL(request.url);
let pathname;
- try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+ try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
for (const h of extraHeaders) {
@@ -12803,7 +12803,7 @@ export default async function handler(request, ctx) {
if (__configHeaders.length) {
const url = new URL(request.url);
let pathname;
- try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+ try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
for (const h of extraHeaders) {
@@ -15758,7 +15758,7 @@ export default async function handler(request, ctx) {
if (__configHeaders.length) {
const url = new URL(request.url);
let pathname;
- try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
+ try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
for (const h of extraHeaders) {
@@ -18336,26 +18336,26 @@ async function _renderPage(request, url, manifest) {
const __uCtx = _createUnifiedCtx();
return _runWithUnifiedCtx(__uCtx, async () => {
ensureFetchPatch();
- try {
- if (typeof setSSRContext === "function") {
- setSSRContext({
- pathname: patternToNextFormat(route.pattern),
- query: { ...params, ...parseQuery(routeUrl) },
- asPath: routeUrl,
- locale: locale,
- locales: i18nConfig ? i18nConfig.locales : undefined,
- defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined,
- });
- }
+ try {
+ if (typeof setSSRContext === "function") {
+ setSSRContext({
+ pathname: patternToNextFormat(route.pattern),
+ query: { ...params, ...parseQuery(routeUrl) },
+ asPath: routeUrl,
+ locale: locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined,
+ });
+ }
- if (i18nConfig) {
- globalThis.__VINEXT_LOCALE__ = locale;
- globalThis.__VINEXT_LOCALES__ = i18nConfig.locales;
- globalThis.__VINEXT_DEFAULT_LOCALE__ = i18nConfig.defaultLocale;
- }
+ if (i18nConfig) {
+ globalThis.__VINEXT_LOCALE__ = locale;
+ globalThis.__VINEXT_LOCALES__ = i18nConfig.locales;
+ globalThis.__VINEXT_DEFAULT_LOCALE__ = i18nConfig.defaultLocale;
+ }
- const pageModule = route.module;
- const PageComponent = pageModule.default;
+ const pageModule = route.module;
+ const PageComponent = pageModule.default;
if (!PageComponent) {
return new Response("Page has no default export", { status: 500 });
}
diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts
index 523e3948..26c65317 100644
--- a/tests/app-router.test.ts
+++ b/tests/app-router.test.ts
@@ -2295,6 +2295,11 @@ describe("App Router next.config.js features (dev server integration)", () => {
expect(res.headers.get("x-page-header")).toBe("about-page");
});
+ it("encoded slashes stay within a single segment for config header matching", async () => {
+ const res = await fetch(`${baseUrl}/api%2Fhello`);
+ expect(res.headers.get("x-custom-header")).toBeNull();
+ });
+
it("percent-encoded rewrite path is decoded before config matching", async () => {
// /rewrite-%61bout decodes to /rewrite-about → /about (beforeFiles rewrite)
const res = await fetch(`${baseUrl}/rewrite-%61bout`);
diff --git a/tests/unified-request-context.test.ts b/tests/unified-request-context.test.ts
index 182e3cde..72b4cdf2 100644
--- a/tests/unified-request-context.test.ts
+++ b/tests/unified-request-context.test.ts
@@ -25,7 +25,7 @@ describe("unified-request-context", () => {
});
describe("getRequestContext", () => {
- it("returns fallback with default values outside any scope", () => {
+ it("returns default values outside any scope", () => {
const ctx = getRequestContext();
expect(ctx).toBeDefined();
expect(ctx.headersContext).toBeNull();
@@ -42,6 +42,17 @@ describe("unified-request-context", () => {
expect(ctx.ssrContext).toBeNull();
expect(ctx.ssrHeadElements).toEqual([]);
});
+
+ it("returns a fresh detached context on each call outside any scope", () => {
+ const first = getRequestContext();
+ first.dynamicUsageDetected = true;
+ first.pendingSetCookies.push("first=1");
+
+ const second = getRequestContext();
+ expect(second).not.toBe(first);
+ expect(second.dynamicUsageDetected).toBe(false);
+ expect(second.pendingSetCookies).toEqual([]);
+ });
});
describe("runWithRequestContext", () => {
@@ -106,11 +117,15 @@ describe("unified-request-context", () => {
cookies: new Map(),
},
currentRequestTags: [`tag-${i}`],
- serverContext: { pathname: `/path-${i}` },
+ serverContext: {
+ pathname: `/path-${i}`,
+ searchParams: new URLSearchParams(),
+ params: {},
+ },
});
return runWithRequestContext(reqCtx, async () => {
- // Simulate async work with varying delays
- await new Promise((resolve) => setTimeout(resolve, Math.random() * 10));
+ const delayMs = (i % 10) + 1;
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
const ctx = getRequestContext();
return {
headerId: (ctx.headersContext as any)?.headers?.get("x-id"),
@@ -246,7 +261,7 @@ describe("unified-request-context", () => {
pendingSetCookies: ["a=b"],
draftModeCookieHeader: "c=d",
phase: "action",
- serverContext: { pathname: "/test" },
+ serverContext: { pathname: "/test", searchParams: new URLSearchParams(), params: {} },
serverInsertedHTMLCallbacks: [() => "html"],
requestScopedCacheLife: { stale: 10, revalidate: 20 },
currentRequestTags: ["tag1"],
@@ -319,8 +334,44 @@ describe("unified-request-context", () => {
);
});
+ it("runWithHeadersContext keeps spawned async work on the inner sub-state", async () => {
+ const { runWithHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
+
+ let releaseInnerRead!: () => void;
+ const waitForInnerRead = new Promise((resolve) => {
+ releaseInnerRead = resolve;
+ });
+ let innerRead!: Promise;
+
+ await runWithRequestContext(
+ createRequestContext({
+ headersContext: {
+ headers: new Headers({ "x-id": "outer" }),
+ cookies: new Map(),
+ },
+ }),
+ async () => {
+ runWithHeadersContext(
+ {
+ headers: new Headers({ "x-id": "inner" }),
+ cookies: new Map(),
+ },
+ () => {
+ innerRead = (async () => {
+ await waitForInnerRead;
+ return (getRequestContext().headersContext as any)?.headers?.get("x-id") ?? null;
+ })();
+ },
+ );
+
+ expect((getRequestContext().headersContext as any)?.headers?.get("x-id")).toBe("outer");
+ releaseInnerRead();
+ await expect(innerRead).resolves.toBe("inner");
+ },
+ );
+ });
+
it("runWithNavigationContext restores the outer navigation sub-state", async () => {
- await import("../packages/vinext/src/shims/navigation-state.js");
const { runWithNavigationContext } =
await import("../packages/vinext/src/shims/navigation-state.js");
const { setNavigationContext, getNavigationContext } =
From 9408a13d2f55444c64fca506dd11627870a5a9d7 Mon Sep 17 00:00:00 2001
From: Divanshu Chauhan
Date: Wed, 11 Mar 2026 12:47:35 -0700
Subject: [PATCH 6/6] fix: address unified ALS review feedback
---
.../vinext/src/entries/pages-server-entry.ts | 567 +++++++++--------
packages/vinext/src/shims/head-state.ts | 11 +-
packages/vinext/src/shims/request-context.ts | 2 +-
packages/vinext/src/shims/router-state.ts | 12 +-
.../src/shims/unified-request-context.ts | 3 +
.../entry-templates.test.ts.snap | 573 ++++++++++--------
6 files changed, 665 insertions(+), 503 deletions(-)
diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts
index e4c37f6f..06084e99 100644
--- a/packages/vinext/src/entries/pages-server-entry.ts
+++ b/packages/vinext/src/entries/pages-server-entry.ts
@@ -733,272 +733,361 @@ async function _renderPage(request, url, manifest) {
const pageModule = route.module;
const PageComponent = pageModule.default;
- if (!PageComponent) {
- return new Response("Page has no default export", { status: 500 });
- }
+ if (!PageComponent) {
+ return new Response("Page has no default export", { status: 500 });
+ }
- // Handle getStaticPaths for dynamic routes
- if (typeof pageModule.getStaticPaths === "function" && route.isDynamic) {
- const pathsResult = await pageModule.getStaticPaths({
- locales: i18nConfig ? i18nConfig.locales : [],
- defaultLocale: i18nConfig ? i18nConfig.defaultLocale : "",
- });
- const fallback = pathsResult && pathsResult.fallback !== undefined ? pathsResult.fallback : false;
-
- if (fallback === false) {
- const paths = pathsResult && pathsResult.paths ? pathsResult.paths : [];
- const isValidPath = paths.some(function(p) {
- return Object.entries(p.params).every(function(entry) {
- var key = entry[0], val = entry[1];
- var actual = params[key];
- if (Array.isArray(val)) {
- return Array.isArray(actual) && val.join("/") === actual.join("/");
- }
- return String(val) === String(actual);
- });
+ // Handle getStaticPaths for dynamic routes
+ if (typeof pageModule.getStaticPaths === "function" && route.isDynamic) {
+ const pathsResult = await pageModule.getStaticPaths({
+ locales: i18nConfig ? i18nConfig.locales : [],
+ defaultLocale: i18nConfig ? i18nConfig.defaultLocale : "",
});
- if (!isValidPath) {
- return new Response("404 - Page not found
",
- { status: 404, headers: { "Content-Type": "text/html" } });
+ const fallback =
+ pathsResult && pathsResult.fallback !== undefined ? pathsResult.fallback : false;
+
+ if (fallback === false) {
+ const paths = pathsResult && pathsResult.paths ? pathsResult.paths : [];
+ const isValidPath = paths.some(function(p) {
+ return Object.entries(p.params).every(function(entry) {
+ var key = entry[0], val = entry[1];
+ var actual = params[key];
+ if (Array.isArray(val)) {
+ return Array.isArray(actual) && val.join("/") === actual.join("/");
+ }
+ return String(val) === String(actual);
+ });
+ });
+ if (!isValidPath) {
+ return new Response(
+ "404 - Page not found
",
+ { status: 404, headers: { "Content-Type": "text/html" } },
+ );
+ }
}
}
- }
- let pageProps = {};
- var gsspRes = null;
- if (typeof pageModule.getServerSideProps === "function") {
- const { req, res, responsePromise } = createReqRes(request, routeUrl, parseQuery(routeUrl), undefined);
- const ctx = {
- params, req, res,
- query: parseQuery(routeUrl),
- resolvedUrl: routeUrl,
- locale: locale,
- locales: i18nConfig ? i18nConfig.locales : undefined,
- defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined,
- };
- const result = await pageModule.getServerSideProps(ctx);
- // If gSSP called res.end() directly (short-circuit), return that response.
- if (res.headersSent) {
- return await responsePromise;
- }
- if (result && result.props) pageProps = result.props;
- if (result && result.redirect) {
- var gsspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307);
- return new Response(null, { status: gsspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } });
- }
- if (result && result.notFound) {
- return new Response("404", { status: 404 });
- }
- // Preserve the res object so headers/status/cookies set by gSSP
- // can be merged into the final HTML response.
- gsspRes = res;
- }
- // Build font Link header early so it's available for ISR cached responses too.
- // Font preloads are module-level state populated at import time and persist across requests.
- var _fontLinkHeader = "";
- var _allFp = [];
- try {
- var _fpGoogle = typeof _getSSRFontPreloadsGoogle === "function" ? _getSSRFontPreloadsGoogle() : [];
- var _fpLocal = typeof _getSSRFontPreloadsLocal === "function" ? _getSSRFontPreloadsLocal() : [];
- _allFp = _fpGoogle.concat(_fpLocal);
- if (_allFp.length > 0) {
- _fontLinkHeader = _allFp.map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; }).join(", ");
- }
- } catch (e) { /* font preloads not available */ }
-
- let isrRevalidateSeconds = null;
- if (typeof pageModule.getStaticProps === "function") {
- const pathname = routeUrl.split("?")[0];
- const cacheKey = isrCacheKey("pages", pathname);
- const cached = await isrGet(cacheKey);
-
- if (cached && !cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") {
- var _hitHeaders = {
- "Content-Type": "text/html", "X-Vinext-Cache": "HIT",
- "Cache-Control": "s-maxage=" + (cached.value.value.revalidate || 60) + ", stale-while-revalidate",
+ let pageProps = {};
+ var gsspRes = null;
+ if (typeof pageModule.getServerSideProps === "function") {
+ const { req, res, responsePromise } = createReqRes(
+ request,
+ routeUrl,
+ parseQuery(routeUrl),
+ undefined,
+ );
+ const ctx = {
+ params, req, res,
+ query: parseQuery(routeUrl),
+ resolvedUrl: routeUrl,
+ locale: locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined,
};
- if (_fontLinkHeader) _hitHeaders["Link"] = _fontLinkHeader;
- return new Response(cached.value.value.html, { status: 200, headers: _hitHeaders });
+ const result = await pageModule.getServerSideProps(ctx);
+ // If gSSP called res.end() directly (short-circuit), return that response.
+ if (res.headersSent) {
+ return await responsePromise;
+ }
+ if (result && result.props) pageProps = result.props;
+ if (result && result.redirect) {
+ var gsspStatus =
+ result.redirect.statusCode != null
+ ? result.redirect.statusCode
+ : (result.redirect.permanent ? 308 : 307);
+ return new Response(null, {
+ status: gsspStatus,
+ headers: { Location: sanitizeDestinationLocal(result.redirect.destination) },
+ });
+ }
+ if (result && result.notFound) {
+ return new Response("404", { status: 404 });
+ }
+ // Preserve the res object so headers/status/cookies set by gSSP
+ // can be merged into the final HTML response.
+ gsspRes = res;
}
- if (cached && cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") {
- triggerBackgroundRegeneration(cacheKey, async function() {
- const freshResult = await pageModule.getStaticProps({ params });
- if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) {
- await isrSet(cacheKey, { kind: "PAGES", html: cached.value.value.html, pageData: freshResult.props, headers: undefined, status: undefined }, freshResult.revalidate);
- }
- });
- var _staleHeaders = {
- "Content-Type": "text/html", "X-Vinext-Cache": "STALE",
- "Cache-Control": "s-maxage=0, stale-while-revalidate",
+ // Build font Link header early so it's available for ISR cached responses too.
+ // Font preloads are module-level state populated at import time and persist across requests.
+ var _fontLinkHeader = "";
+ var _allFp = [];
+ try {
+ var _fpGoogle =
+ typeof _getSSRFontPreloadsGoogle === "function" ? _getSSRFontPreloadsGoogle() : [];
+ var _fpLocal =
+ typeof _getSSRFontPreloadsLocal === "function" ? _getSSRFontPreloadsLocal() : [];
+ _allFp = _fpGoogle.concat(_fpLocal);
+ if (_allFp.length > 0) {
+ _fontLinkHeader = _allFp
+ .map(function(p) {
+ return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin";
+ })
+ .join(", ");
+ }
+ } catch (e) { /* font preloads not available */ }
+
+ let isrRevalidateSeconds = null;
+ if (typeof pageModule.getStaticProps === "function") {
+ const pathname = routeUrl.split("?")[0];
+ const cacheKey = isrCacheKey("pages", pathname);
+ const cached = await isrGet(cacheKey);
+
+ if (cached && !cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") {
+ var _hitHeaders = {
+ "Content-Type": "text/html", "X-Vinext-Cache": "HIT",
+ "Cache-Control":
+ "s-maxage=" + (cached.value.value.revalidate || 60) + ", stale-while-revalidate",
+ };
+ if (_fontLinkHeader) _hitHeaders["Link"] = _fontLinkHeader;
+ return new Response(cached.value.value.html, { status: 200, headers: _hitHeaders });
+ }
+
+ if (cached && cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") {
+ triggerBackgroundRegeneration(cacheKey, async function() {
+ const freshResult = await pageModule.getStaticProps({ params });
+ if (
+ freshResult &&
+ freshResult.props &&
+ typeof freshResult.revalidate === "number" &&
+ freshResult.revalidate > 0
+ ) {
+ await isrSet(
+ cacheKey,
+ {
+ kind: "PAGES",
+ html: cached.value.value.html,
+ pageData: freshResult.props,
+ headers: undefined,
+ status: undefined,
+ },
+ freshResult.revalidate,
+ );
+ }
+ });
+ var _staleHeaders = {
+ "Content-Type": "text/html", "X-Vinext-Cache": "STALE",
+ "Cache-Control": "s-maxage=0, stale-while-revalidate",
+ };
+ if (_fontLinkHeader) _staleHeaders["Link"] = _fontLinkHeader;
+ return new Response(cached.value.value.html, { status: 200, headers: _staleHeaders });
+ }
+
+ const ctx = {
+ params,
+ locale: locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined,
};
- if (_fontLinkHeader) _staleHeaders["Link"] = _fontLinkHeader;
- return new Response(cached.value.value.html, { status: 200, headers: _staleHeaders });
+ const result = await pageModule.getStaticProps(ctx);
+ if (result && result.props) pageProps = result.props;
+ if (result && result.redirect) {
+ var gspStatus =
+ result.redirect.statusCode != null
+ ? result.redirect.statusCode
+ : (result.redirect.permanent ? 308 : 307);
+ return new Response(null, {
+ status: gspStatus,
+ headers: { Location: sanitizeDestinationLocal(result.redirect.destination) },
+ });
+ }
+ if (result && result.notFound) {
+ return new Response("404", { status: 404 });
+ }
+ if (typeof result.revalidate === "number" && result.revalidate > 0) {
+ isrRevalidateSeconds = result.revalidate;
+ }
}
- const ctx = {
- params,
- locale: locale,
- locales: i18nConfig ? i18nConfig.locales : undefined,
- defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined,
- };
- const result = await pageModule.getStaticProps(ctx);
- if (result && result.props) pageProps = result.props;
- if (result && result.redirect) {
- var gspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307);
- return new Response(null, { status: gspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } });
- }
- if (result && result.notFound) {
- return new Response("404", { status: 404 });
- }
- if (typeof result.revalidate === "number" && result.revalidate > 0) {
- isrRevalidateSeconds = result.revalidate;
+ let element;
+ if (AppComponent) {
+ element = React.createElement(AppComponent, { Component: PageComponent, pageProps });
+ } else {
+ element = React.createElement(PageComponent, pageProps);
}
- }
+ element = wrapWithRouterContext(element);
- let element;
- if (AppComponent) {
- element = React.createElement(AppComponent, { Component: PageComponent, pageProps });
- } else {
- element = React.createElement(PageComponent, pageProps);
- }
- element = wrapWithRouterContext(element);
+ if (typeof resetSSRHead === "function") resetSSRHead();
+ if (typeof flushPreloads === "function") await flushPreloads();
- if (typeof resetSSRHead === "function") resetSSRHead();
- if (typeof flushPreloads === "function") await flushPreloads();
+ const ssrHeadHTML = typeof getSSRHeadHTML === "function" ? getSSRHeadHTML() : "";
- const ssrHeadHTML = typeof getSSRHeadHTML === "function" ? getSSRHeadHTML() : "";
+ // Collect SSR font data (Google Font links, font preloads, font-face styles)
+ var fontHeadHTML = "";
+ function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); }
+ try {
+ var fontLinks = typeof _getSSRFontLinks === "function" ? _getSSRFontLinks() : [];
+ for (var fl of fontLinks) {
+ fontHeadHTML += '\\n ';
+ }
+ } catch (e) { /* next/font/google not used */ }
+ // Emit for all font files (reuse _allFp collected earlier for Link header)
+ for (var fp of _allFp) {
+ fontHeadHTML +=
+ '\\n ';
+ }
+ try {
+ var allFontStyles = [];
+ if (typeof _getSSRFontStylesGoogle === "function") {
+ allFontStyles.push(..._getSSRFontStylesGoogle());
+ }
+ if (typeof _getSSRFontStylesLocal === "function") {
+ allFontStyles.push(..._getSSRFontStylesLocal());
+ }
+ if (allFontStyles.length > 0) {
+ fontHeadHTML +=
+ '\\n ";
+ }
+ } catch (e) { /* font styles not available */ }
- // Collect SSR font data (Google Font links, font preloads, font-face styles)
- var fontHeadHTML = "";
- function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); }
- try {
- var fontLinks = typeof _getSSRFontLinks === "function" ? _getSSRFontLinks() : [];
- for (var fl of fontLinks) { fontHeadHTML += '\\n '; }
- } catch (e) { /* next/font/google not used */ }
- // Emit for all font files (reuse _allFp collected earlier for Link header)
- for (var fp of _allFp) { fontHeadHTML += '\\n '; }
- try {
- var allFontStyles = [];
- if (typeof _getSSRFontStylesGoogle === "function") allFontStyles.push(..._getSSRFontStylesGoogle());
- if (typeof _getSSRFontStylesLocal === "function") allFontStyles.push(..._getSSRFontStylesLocal());
- if (allFontStyles.length > 0) { fontHeadHTML += '\\n '; }
- } catch (e) { /* font styles not available */ }
-
- const pageModuleIds = route.filePath ? [route.filePath] : [];
- const assetTags = collectAssetTags(manifest, pageModuleIds);
- const nextDataPayload = {
- props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false,
- };
- if (i18nConfig) {
- nextDataPayload.locale = locale;
- nextDataPayload.locales = i18nConfig.locales;
- nextDataPayload.defaultLocale = i18nConfig.defaultLocale;
- }
- const localeGlobals = i18nConfig
- ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) +
- ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) +
- ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(i18nConfig.defaultLocale)
- : "";
- const nextDataScript = "";
-
- // Build the document shell with a placeholder for the streamed body
- var BODY_MARKER = "";
- var shellHtml;
- if (DocumentComponent) {
- const docElement = React.createElement(DocumentComponent);
- shellHtml = await renderToStringAsync(docElement);
- shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER);
- if (ssrHeadHTML || assetTags || fontHeadHTML) {
- shellHtml = shellHtml.replace("", " " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n");
+ const pageModuleIds = route.filePath ? [route.filePath] : [];
+ const assetTags = collectAssetTags(manifest, pageModuleIds);
+ const nextDataPayload = {
+ props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false,
+ };
+ if (i18nConfig) {
+ nextDataPayload.locale = locale;
+ nextDataPayload.locales = i18nConfig.locales;
+ nextDataPayload.defaultLocale = i18nConfig.defaultLocale;
}
- shellHtml = shellHtml.replace("", nextDataScript);
- if (!shellHtml.includes("__NEXT_DATA__")) {
- shellHtml = shellHtml.replace("", " " + nextDataScript + "\\n");
+ const localeGlobals = i18nConfig
+ ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) +
+ ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) +
+ ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(i18nConfig.defaultLocale)
+ : "";
+ const nextDataScript =
+ "";
+
+ // Build the document shell with a placeholder for the streamed body
+ var BODY_MARKER = "";
+ var shellHtml;
+ if (DocumentComponent) {
+ const docElement = React.createElement(DocumentComponent);
+ shellHtml = await renderToStringAsync(docElement);
+ shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER);
+ if (ssrHeadHTML || assetTags || fontHeadHTML) {
+ shellHtml = shellHtml.replace(
+ "",
+ " " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n",
+ );
+ }
+ shellHtml = shellHtml.replace("", nextDataScript);
+ if (!shellHtml.includes("__NEXT_DATA__")) {
+ shellHtml = shellHtml.replace("
\\n " +
+ BODY_MARKER +
+ "
\\n " +
+ nextDataScript +
+ "\\n", " " + nextDataScript + "\\n");
+ }
+ } else {
+ shellHtml =
+ "\\n\\n