diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 5d43c20f..ed5867a9 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -247,7 +247,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"; @@ -257,12 +257,12 @@ ${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 { runWithFetchCache } from "vinext/fetch-cache"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)}; +import { ensureFetchPatch as _ensureFetchPatch } from "vinext/fetch-cache"; // 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"; @@ -706,7 +706,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" }, @@ -860,7 +860,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" }, @@ -1384,60 +1384,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, 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; - }) - ) - ) - ) - ); - 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, ctx) { @@ -1697,7 +1687,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // 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, @@ -1837,7 +1827,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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(); @@ -2171,56 +2161,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; - return { html: __freshHtml, rscData: __freshRscData }; - }) - ) - ) - ) - ); + 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; + return { html: __freshHtml, rscData: __freshRscData }; + }); // 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), @@ -2329,7 +2316,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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" }, @@ -2564,7 +2551,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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 215100e0..561c0484 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1005,6 +1005,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 e6881700..30352c3d 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -21,6 +21,7 @@ import { getCacheHandler, type CachedFetchValue } from "./cache.js"; import { AsyncLocalStorage } from "node:async_hooks"; +import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js"; // --------------------------------------------------------------------------- // Cache key generation @@ -437,6 +438,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; } @@ -733,9 +737,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/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index cb23dad7..550cbf84 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,12 +354,12 @@ 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 { runWithFetchCache } from "vinext/fetch-cache"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch } from "vinext/fetch-cache"; // 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"; @@ -855,7 +855,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" }, @@ -988,7 +988,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" }, @@ -1630,60 +1630,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, 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; - }) - ) - ) - ) - ); - 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, ctx) { @@ -1850,7 +1840,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // 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, @@ -1990,7 +1980,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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(); @@ -2324,56 +2314,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; - return { html: __freshHtml, rscData: __freshRscData }; - }) - ) - ) - ) - ); + 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; + return { html: __freshHtml, rscData: __freshRscData }; + }); // 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), @@ -2482,7 +2469,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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" }, @@ -2717,7 +2704,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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) { @@ -3056,7 +3043,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"; @@ -3066,12 +3053,12 @@ 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 { runWithFetchCache } from "vinext/fetch-cache"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch } from "vinext/fetch-cache"; // 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"; @@ -3567,7 +3554,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" }, @@ -3700,7 +3687,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" }, @@ -4342,60 +4329,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, 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; - }) - ) - ) - ) - ); - 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, ctx) { @@ -4565,7 +4542,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // 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, @@ -4705,7 +4682,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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(); @@ -5039,56 +5016,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; - return { html: __freshHtml, rscData: __freshRscData }; - }) - ) - ) - ) - ); + 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; + return { html: __freshHtml, rscData: __freshRscData }; + }); // 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), @@ -5197,7 +5171,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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" }, @@ -5432,7 +5406,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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) { @@ -5771,7 +5745,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"; @@ -5781,12 +5755,12 @@ 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 { runWithFetchCache } from "vinext/fetch-cache"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch } from "vinext/fetch-cache"; // 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"; @@ -6291,7 +6265,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" }, @@ -6437,7 +6411,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" }, @@ -7087,60 +7061,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, 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; - }) - ) - ) - ) - ); - 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, ctx) { @@ -7307,7 +7271,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // 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, @@ -7447,7 +7411,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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(); @@ -7781,56 +7745,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; - return { html: __freshHtml, rscData: __freshRscData }; - }) - ) - ) - ) - ); + 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; + return { html: __freshHtml, rscData: __freshRscData }; + }); // 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), @@ -7939,7 +7900,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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" }, @@ -8174,7 +8135,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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) { @@ -8521,7 +8482,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"; @@ -8531,12 +8492,12 @@ 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 { runWithFetchCache } from "vinext/fetch-cache"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch } from "vinext/fetch-cache"; // 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"; @@ -9061,7 +9022,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" }, @@ -9194,7 +9155,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" }, @@ -9839,60 +9800,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, 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; - }) - ) - ) - ) - ); - 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, ctx) { @@ -10059,7 +10010,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // 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, @@ -10199,7 +10150,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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(); @@ -10533,56 +10484,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; - return { html: __freshHtml, rscData: __freshRscData }; - }) - ) - ) - ) - ); + 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; + return { html: __freshHtml, rscData: __freshRscData }; + }); // 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), @@ -10691,7 +10639,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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" }, @@ -10926,7 +10874,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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) { @@ -11265,7 +11213,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"; @@ -11275,12 +11223,12 @@ 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 { runWithFetchCache } from "vinext/fetch-cache"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch } from "vinext/fetch-cache"; // 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"; @@ -11783,7 +11731,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" }, @@ -11916,7 +11864,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" }, @@ -12558,60 +12506,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, 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; - }) - ) - ) - ) - ); - 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, ctx) { @@ -12778,7 +12716,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // 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, @@ -12918,7 +12856,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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(); @@ -13252,56 +13190,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; - return { html: __freshHtml, rscData: __freshRscData }; - }) - ) - ) - ) - ); + 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; + return { html: __freshHtml, rscData: __freshRscData }; + }); // 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), @@ -13410,7 +13345,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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" }, @@ -13645,7 +13580,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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) { @@ -13984,7 +13919,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"; @@ -13994,12 +13929,12 @@ 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 { runWithFetchCache } from "vinext/fetch-cache"; -import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; +import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; +import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { ensureFetchPatch as _ensureFetchPatch } from "vinext/fetch-cache"; // 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"; @@ -14495,7 +14430,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" }, @@ -14628,7 +14563,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" }, @@ -15466,60 +15401,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, 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; - }) - ) - ) - ) - ); - 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, ctx) { @@ -15768,7 +15693,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } // 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, @@ -15908,7 +15833,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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(); @@ -16242,56 +16167,53 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; - return { html: __freshHtml, rscData: __freshRscData }; - }) - ) - ) - ) - ); + 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; + return { html: __freshHtml, rscData: __freshRscData }; + }); // 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), @@ -16400,7 +16322,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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" }, @@ -16635,7 +16557,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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/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([]); + }); + }); +});