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

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); + const fallback = + pathsResult && pathsResult.fallback !== undefined ? pathsResult.fallback : false; + + if (fallback === false) { + const paths = pathsResult && pathsResult.paths ? pathsResult.paths : []; + const isValidPath = paths.some(function(p) { + return Object.entries(p.params).every(function(entry) { + var key = entry[0], val = entry[1]; + var actual = params[key]; + if (Array.isArray(val)) { + return Array.isArray(actual) && val.join("/") === actual.join("/"); + } + return String(val) === String(actual); + }); + }); + if (!isValidPath) { + return new Response( + "

404 - Page not found

", + { status: 404, headers: { "Content-Type": "text/html" } }, + ); + } } } - } - let pageProps = {}; - var gsspRes = null; - if (typeof pageModule.getServerSideProps === "function") { - const { req, res, responsePromise } = createReqRes(request, routeUrl, parseQuery(routeUrl), undefined); - const ctx = { - params, req, res, - query: parseQuery(routeUrl), - resolvedUrl: routeUrl, - locale: locale, - locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, - }; - const result = await pageModule.getServerSideProps(ctx); - // If gSSP called res.end() directly (short-circuit), return that response. - if (res.headersSent) { - return await responsePromise; - } - if (result && result.props) pageProps = result.props; - if (result && result.redirect) { - var gsspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307); - return new Response(null, { status: gsspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); - } - if (result && result.notFound) { - return new Response("404", { status: 404 }); - } - // Preserve the res object so headers/status/cookies set by gSSP - // can be merged into the final HTML response. - gsspRes = res; - } - // Build font Link header early so it's available for ISR cached responses too. - // Font preloads are module-level state populated at import time and persist across requests. - var _fontLinkHeader = ""; - var _allFp = []; - try { - var _fpGoogle = typeof _getSSRFontPreloadsGoogle === "function" ? _getSSRFontPreloadsGoogle() : []; - var _fpLocal = typeof _getSSRFontPreloadsLocal === "function" ? _getSSRFontPreloadsLocal() : []; - _allFp = _fpGoogle.concat(_fpLocal); - if (_allFp.length > 0) { - _fontLinkHeader = _allFp.map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; }).join(", "); - } - } catch (e) { /* font preloads not available */ } - - let isrRevalidateSeconds = null; - if (typeof pageModule.getStaticProps === "function") { - const pathname = routeUrl.split("?")[0]; - const cacheKey = isrCacheKey("pages", pathname); - const cached = await isrGet(cacheKey); - - if (cached && !cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { - var _hitHeaders = { - "Content-Type": "text/html", "X-Vinext-Cache": "HIT", - "Cache-Control": "s-maxage=" + (cached.value.value.revalidate || 60) + ", stale-while-revalidate", + let pageProps = {}; + var gsspRes = null; + if (typeof pageModule.getServerSideProps === "function") { + const { req, res, responsePromise } = createReqRes( + request, + routeUrl, + parseQuery(routeUrl), + undefined, + ); + const ctx = { + params, req, res, + query: parseQuery(routeUrl), + resolvedUrl: routeUrl, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, }; - if (_fontLinkHeader) _hitHeaders["Link"] = _fontLinkHeader; - return new Response(cached.value.value.html, { status: 200, headers: _hitHeaders }); + const result = await pageModule.getServerSideProps(ctx); + // If gSSP called res.end() directly (short-circuit), return that response. + if (res.headersSent) { + return await responsePromise; + } + if (result && result.props) pageProps = result.props; + if (result && result.redirect) { + var gsspStatus = + result.redirect.statusCode != null + ? result.redirect.statusCode + : (result.redirect.permanent ? 308 : 307); + return new Response(null, { + status: gsspStatus, + headers: { Location: sanitizeDestinationLocal(result.redirect.destination) }, + }); + } + if (result && result.notFound) { + return new Response("404", { status: 404 }); + } + // Preserve the res object so headers/status/cookies set by gSSP + // can be merged into the final HTML response. + gsspRes = res; } - if (cached && cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { - triggerBackgroundRegeneration(cacheKey, async function() { - const freshResult = await pageModule.getStaticProps({ params }); - if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) { - await isrSet(cacheKey, { kind: "PAGES", html: cached.value.value.html, pageData: freshResult.props, headers: undefined, status: undefined }, freshResult.revalidate); - } - }); - var _staleHeaders = { - "Content-Type": "text/html", "X-Vinext-Cache": "STALE", - "Cache-Control": "s-maxage=0, stale-while-revalidate", + // Build font Link header early so it's available for ISR cached responses too. + // Font preloads are module-level state populated at import time and persist across requests. + var _fontLinkHeader = ""; + var _allFp = []; + try { + var _fpGoogle = + typeof _getSSRFontPreloadsGoogle === "function" ? _getSSRFontPreloadsGoogle() : []; + var _fpLocal = + typeof _getSSRFontPreloadsLocal === "function" ? _getSSRFontPreloadsLocal() : []; + _allFp = _fpGoogle.concat(_fpLocal); + if (_allFp.length > 0) { + _fontLinkHeader = _allFp + .map(function(p) { + return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; + }) + .join(", "); + } + } catch (e) { /* font preloads not available */ } + + let isrRevalidateSeconds = null; + if (typeof pageModule.getStaticProps === "function") { + const pathname = routeUrl.split("?")[0]; + const cacheKey = isrCacheKey("pages", pathname); + const cached = await isrGet(cacheKey); + + if (cached && !cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { + var _hitHeaders = { + "Content-Type": "text/html", "X-Vinext-Cache": "HIT", + "Cache-Control": + "s-maxage=" + (cached.value.value.revalidate || 60) + ", stale-while-revalidate", + }; + if (_fontLinkHeader) _hitHeaders["Link"] = _fontLinkHeader; + return new Response(cached.value.value.html, { status: 200, headers: _hitHeaders }); + } + + if (cached && cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { + triggerBackgroundRegeneration(cacheKey, async function() { + const freshResult = await pageModule.getStaticProps({ params }); + if ( + freshResult && + freshResult.props && + typeof freshResult.revalidate === "number" && + freshResult.revalidate > 0 + ) { + await isrSet( + cacheKey, + { + kind: "PAGES", + html: cached.value.value.html, + pageData: freshResult.props, + headers: undefined, + status: undefined, + }, + freshResult.revalidate, + ); + } + }); + var _staleHeaders = { + "Content-Type": "text/html", "X-Vinext-Cache": "STALE", + "Cache-Control": "s-maxage=0, stale-while-revalidate", + }; + if (_fontLinkHeader) _staleHeaders["Link"] = _fontLinkHeader; + return new Response(cached.value.value.html, { status: 200, headers: _staleHeaders }); + } + + const ctx = { + params, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, }; - if (_fontLinkHeader) _staleHeaders["Link"] = _fontLinkHeader; - return new Response(cached.value.value.html, { status: 200, headers: _staleHeaders }); + const result = await pageModule.getStaticProps(ctx); + if (result && result.props) pageProps = result.props; + if (result && result.redirect) { + var gspStatus = + result.redirect.statusCode != null + ? result.redirect.statusCode + : (result.redirect.permanent ? 308 : 307); + return new Response(null, { + status: gspStatus, + headers: { Location: sanitizeDestinationLocal(result.redirect.destination) }, + }); + } + if (result && result.notFound) { + return new Response("404", { status: 404 }); + } + if (typeof result.revalidate === "number" && result.revalidate > 0) { + isrRevalidateSeconds = result.revalidate; + } } - const ctx = { - params, - locale: locale, - locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, - }; - const result = await pageModule.getStaticProps(ctx); - if (result && result.props) pageProps = result.props; - if (result && result.redirect) { - var gspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307); - return new Response(null, { status: gspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); - } - if (result && result.notFound) { - return new Response("404", { status: 404 }); - } - if (typeof result.revalidate === "number" && result.revalidate > 0) { - isrRevalidateSeconds = result.revalidate; + let element; + if (AppComponent) { + element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + } else { + element = React.createElement(PageComponent, pageProps); } - } + element = wrapWithRouterContext(element); - let element; - if (AppComponent) { - element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); - } else { - element = React.createElement(PageComponent, pageProps); - } - element = wrapWithRouterContext(element); + if (typeof resetSSRHead === "function") resetSSRHead(); + if (typeof flushPreloads === "function") await flushPreloads(); - if (typeof resetSSRHead === "function") resetSSRHead(); - if (typeof flushPreloads === "function") await flushPreloads(); + const ssrHeadHTML = typeof getSSRHeadHTML === "function" ? getSSRHeadHTML() : ""; - const ssrHeadHTML = typeof getSSRHeadHTML === "function" ? getSSRHeadHTML() : ""; + // Collect SSR font data (Google Font links, font preloads, font-face styles) + var fontHeadHTML = ""; + function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); } + try { + var fontLinks = typeof _getSSRFontLinks === "function" ? _getSSRFontLinks() : []; + for (var fl of fontLinks) { + fontHeadHTML += '\\n '; + } + } catch (e) { /* next/font/google not used */ } + // Emit for all font files (reuse _allFp collected earlier for Link header) + for (var fp of _allFp) { + fontHeadHTML += + '\\n '; + } + try { + var allFontStyles = []; + if (typeof _getSSRFontStylesGoogle === "function") { + allFontStyles.push(..._getSSRFontStylesGoogle()); + } + if (typeof _getSSRFontStylesLocal === "function") { + allFontStyles.push(..._getSSRFontStylesLocal()); + } + if (allFontStyles.length > 0) { + fontHeadHTML += + '\\n "; + } + } catch (e) { /* font styles not available */ } - // Collect SSR font data (Google Font links, font preloads, font-face styles) - var fontHeadHTML = ""; - function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); } - try { - var fontLinks = typeof _getSSRFontLinks === "function" ? _getSSRFontLinks() : []; - for (var fl of fontLinks) { fontHeadHTML += '\\n '; } - } catch (e) { /* next/font/google not used */ } - // Emit for all font files (reuse _allFp collected earlier for Link header) - for (var fp of _allFp) { fontHeadHTML += '\\n '; } - try { - var allFontStyles = []; - if (typeof _getSSRFontStylesGoogle === "function") allFontStyles.push(..._getSSRFontStylesGoogle()); - if (typeof _getSSRFontStylesLocal === "function") allFontStyles.push(..._getSSRFontStylesLocal()); - if (allFontStyles.length > 0) { fontHeadHTML += '\\n '; } - } catch (e) { /* font styles not available */ } - - const pageModuleIds = route.filePath ? [route.filePath] : []; - const assetTags = collectAssetTags(manifest, pageModuleIds); - const nextDataPayload = { - props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, - }; - if (i18nConfig) { - nextDataPayload.locale = locale; - nextDataPayload.locales = i18nConfig.locales; - nextDataPayload.defaultLocale = i18nConfig.defaultLocale; - } - const localeGlobals = i18nConfig - ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + - ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + - ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(i18nConfig.defaultLocale) - : ""; - const nextDataScript = ""; - - // Build the document shell with a placeholder for the streamed body - var BODY_MARKER = ""; - var shellHtml; - if (DocumentComponent) { - const docElement = React.createElement(DocumentComponent); - shellHtml = await renderToStringAsync(docElement); - shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER); - if (ssrHeadHTML || assetTags || fontHeadHTML) { - shellHtml = shellHtml.replace("", " " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n"); + const pageModuleIds = route.filePath ? [route.filePath] : []; + const assetTags = collectAssetTags(manifest, pageModuleIds); + const nextDataPayload = { + props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, + }; + if (i18nConfig) { + nextDataPayload.locale = locale; + nextDataPayload.locales = i18nConfig.locales; + nextDataPayload.defaultLocale = i18nConfig.defaultLocale; } - shellHtml = shellHtml.replace("", nextDataScript); - if (!shellHtml.includes("__NEXT_DATA__")) { - shellHtml = shellHtml.replace("", " " + nextDataScript + "\\n"); + const localeGlobals = i18nConfig + ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + + ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + + ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(i18nConfig.defaultLocale) + : ""; + const nextDataScript = + ""; + + // Build the document shell with a placeholder for the streamed body + var BODY_MARKER = ""; + var shellHtml; + if (DocumentComponent) { + const docElement = React.createElement(DocumentComponent); + shellHtml = await renderToStringAsync(docElement); + shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER); + if (ssrHeadHTML || assetTags || fontHeadHTML) { + shellHtml = shellHtml.replace( + "", + " " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n", + ); + } + shellHtml = shellHtml.replace("", nextDataScript); + if (!shellHtml.includes("__NEXT_DATA__")) { + shellHtml = shellHtml.replace("", " " + nextDataScript + "\\n"); + } + } else { + shellHtml = + "\\n\\n\\n \\n \\n " + + fontHeadHTML + + ssrHeadHTML + + "\\n " + + assetTags + + "\\n\\n\\n
" + + BODY_MARKER + + "
\\n " + + nextDataScript + + "\\n\\n"; } - } else { - shellHtml = "\\n\\n\\n \\n \\n " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n\\n\\n
" + BODY_MARKER + "
\\n " + nextDataScript + "\\n\\n"; - } - if (typeof setSSRContext === "function") setSSRContext(null); - - // Split the shell at the body marker - var markerIdx = shellHtml.indexOf(BODY_MARKER); - var shellPrefix = shellHtml.slice(0, markerIdx); - var shellSuffix = shellHtml.slice(markerIdx + BODY_MARKER.length); - - // Start the React body stream — progressive SSR (no allReady wait) - var bodyStream = await renderToReadableStream(element); - var encoder = new TextEncoder(); - - // Create a composite stream: prefix + body + suffix - var compositeStream = new ReadableStream({ - async start(controller) { - controller.enqueue(encoder.encode(shellPrefix)); - var reader = bodyStream.getReader(); - try { - for (;;) { - var chunk = await reader.read(); - if (chunk.done) break; - controller.enqueue(chunk.value); + if (typeof setSSRContext === "function") setSSRContext(null); + + // Split the shell at the body marker + var markerIdx = shellHtml.indexOf(BODY_MARKER); + var shellPrefix = shellHtml.slice(0, markerIdx); + var shellSuffix = shellHtml.slice(markerIdx + BODY_MARKER.length); + + // Start the React body stream — progressive SSR (no allReady wait) + var bodyStream = await renderToReadableStream(element); + var encoder = new TextEncoder(); + + // Create a composite stream: prefix + body + suffix + var compositeStream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(shellPrefix)); + var reader = bodyStream.getReader(); + try { + for (;;) { + var chunk = await reader.read(); + if (chunk.done) break; + controller.enqueue(chunk.value); + } + } finally { + reader.releaseLock(); } - } finally { - reader.releaseLock(); + controller.enqueue(encoder.encode(shellSuffix)); + controller.close(); } - controller.enqueue(encoder.encode(shellSuffix)); - controller.close(); - } - }); + }); - // Cache the rendered HTML for ISR (needs the full string — re-render synchronously) - if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) { - // Tee the stream so we can cache and respond simultaneously would be ideal, - // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. - var isrElement; - if (AppComponent) { - isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); - } else { - isrElement = React.createElement(PageComponent, pageProps); + // Cache the rendered HTML for ISR (needs the full string — re-render synchronously) + if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) { + // Tee the stream so we can cache and respond simultaneously would be ideal, + // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. + var isrElement; + if (AppComponent) { + isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + } else { + isrElement = React.createElement(PageComponent, pageProps); + } + isrElement = wrapWithRouterContext(isrElement); + var isrHtml = await renderToStringAsync(isrElement); + var fullHtml = shellPrefix + isrHtml + shellSuffix; + var isrPathname = url.split("?")[0]; + var _cacheKey = isrCacheKey("pages", isrPathname); + await isrSet( + _cacheKey, + { + kind: "PAGES", + html: fullHtml, + pageData: pageProps, + headers: undefined, + status: undefined, + }, + isrRevalidateSeconds, + ); } - isrElement = wrapWithRouterContext(isrElement); - var isrHtml = await renderToStringAsync(isrElement); - var fullHtml = shellPrefix + isrHtml + shellSuffix; - var isrPathname = url.split("?")[0]; - var _cacheKey = isrCacheKey("pages", isrPathname); - await isrSet(_cacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds); - } - // Merge headers/status/cookies set by getServerSideProps on the res object. - // gSSP commonly uses res.setHeader("Set-Cookie", ...) or res.status(304). - var finalStatus = 200; - const responseHeaders = new Headers({ "Content-Type": "text/html" }); - if (gsspRes) { - finalStatus = gsspRes.statusCode; - var gsspHeaders = gsspRes.getHeaders(); - for (var hk of Object.keys(gsspHeaders)) { - var hv = gsspHeaders[hk]; - if (hk === "set-cookie" && Array.isArray(hv)) { - for (var sc of hv) responseHeaders.append("set-cookie", sc); - } else if (hv != null) { - responseHeaders.set(hk, String(hv)); + // Merge headers/status/cookies set by getServerSideProps on the res object. + // gSSP commonly uses res.setHeader("Set-Cookie", ...) or res.status(304). + var finalStatus = 200; + const responseHeaders = new Headers({ "Content-Type": "text/html" }); + if (gsspRes) { + finalStatus = gsspRes.statusCode; + var gsspHeaders = gsspRes.getHeaders(); + for (var hk of Object.keys(gsspHeaders)) { + var hv = gsspHeaders[hk]; + if (hk === "set-cookie" && Array.isArray(hv)) { + for (var sc of hv) responseHeaders.append("set-cookie", sc); + } else if (hv != null) { + responseHeaders.set(hk, String(hv)); + } } + // Ensure Content-Type stays text/html (gSSP shouldn't override it for page renders) + responseHeaders.set("Content-Type", "text/html"); } - // Ensure Content-Type stays text/html (gSSP shouldn't override it for page renders) - responseHeaders.set("Content-Type", "text/html"); - } - if (isrRevalidateSeconds) { - responseHeaders.set("Cache-Control", "s-maxage=" + isrRevalidateSeconds + ", stale-while-revalidate"); - responseHeaders.set("X-Vinext-Cache", "MISS"); - } - // Set HTTP Link header for font preloading - if (_fontLinkHeader) { - responseHeaders.set("Link", _fontLinkHeader); + if (isrRevalidateSeconds) { + responseHeaders.set( + "Cache-Control", + "s-maxage=" + isrRevalidateSeconds + ", stale-while-revalidate", + ); + responseHeaders.set("X-Vinext-Cache", "MISS"); + } + // Set HTTP Link header for font preloading + if (_fontLinkHeader) { + responseHeaders.set("Link", _fontLinkHeader); + } + return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); + } catch (e) { + console.error("[vinext] SSR error:", e); + return new Response("Internal Server Error", { status: 500 }); } - return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); - } catch (e) { - console.error("[vinext] SSR error:", e); - return new Response("Internal Server Error", { status: 500 }); - } }); } diff --git a/packages/vinext/src/shims/head-state.ts b/packages/vinext/src/shims/head-state.ts index cb2564d4..ba866e16 100644 --- a/packages/vinext/src/shims/head-state.ts +++ b/packages/vinext/src/shims/head-state.ts @@ -68,15 +68,6 @@ _registerHeadStateAccessors({ }, resetSSRHead(): void { - if (isInsideUnifiedScope()) { - getRequestContext().ssrHeadElements = []; - return; - } - const state = _als.getStore(); - if (state) { - state.ssrHeadElements = []; - } else { - _fallbackState.ssrHeadElements = []; - } + _getState().ssrHeadElements = []; }, }); diff --git a/packages/vinext/src/shims/request-context.ts b/packages/vinext/src/shims/request-context.ts index ea03863d..b48d1264 100644 --- a/packages/vinext/src/shims/request-context.ts +++ b/packages/vinext/src/shims/request-context.ts @@ -86,7 +86,7 @@ export function runWithExecutionContext( */ export function getRequestExecutionContext(): ExecutionContextLike | null { if (isInsideUnifiedScope()) { - return getRequestContext().executionContext as ExecutionContextLike | null; + return getRequestContext().executionContext; } // getStore() returns undefined when called outside an ALS scope; // normalise to null for a consistent return type. diff --git a/packages/vinext/src/shims/router-state.ts b/packages/vinext/src/shims/router-state.ts index 08deaf8f..fe4c1e17 100644 --- a/packages/vinext/src/shims/router-state.ts +++ b/packages/vinext/src/shims/router-state.ts @@ -78,16 +78,6 @@ _registerRouterStateAccessors({ }, setSSRContext(ctx: SSRContext | null): void { - if (isInsideUnifiedScope()) { - getRequestContext().ssrContext = ctx; - return; - } - const state = _als.getStore(); - if (state) { - state.ssrContext = ctx; - } else { - // No ALS scope — fallback for environments without als.run() wrapping. - _fallbackState.ssrContext = ctx; - } + _getState().ssrContext = ctx; }, }); diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 950da124..9688af57 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -124,6 +124,9 @@ export function runWithUnifiedStateMutation( if (!parentCtx) return fn(); const childCtx = { ...parentCtx }; + // NOTE: This is a shallow clone. Callers must replace array/object slices + // instead of mutating inherited references in-place, or the parent scope + // will observe those changes too. mutate(childCtx); return _als.run(childCtx, fn); } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 50c0d98d..77937eaf 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -18356,272 +18356,361 @@ async function _renderPage(request, url, manifest) { const pageModule = route.module; const PageComponent = pageModule.default; - if (!PageComponent) { - return new Response("Page has no default export", { status: 500 }); - } + if (!PageComponent) { + return new Response("Page has no default export", { status: 500 }); + } - // Handle getStaticPaths for dynamic routes - if (typeof pageModule.getStaticPaths === "function" && route.isDynamic) { - const pathsResult = await pageModule.getStaticPaths({ - locales: i18nConfig ? i18nConfig.locales : [], - defaultLocale: i18nConfig ? i18nConfig.defaultLocale : "", - }); - const fallback = pathsResult && pathsResult.fallback !== undefined ? pathsResult.fallback : false; - - if (fallback === false) { - const paths = pathsResult && pathsResult.paths ? pathsResult.paths : []; - const isValidPath = paths.some(function(p) { - return Object.entries(p.params).every(function(entry) { - var key = entry[0], val = entry[1]; - var actual = params[key]; - if (Array.isArray(val)) { - return Array.isArray(actual) && val.join("/") === actual.join("/"); - } - return String(val) === String(actual); - }); + // Handle getStaticPaths for dynamic routes + if (typeof pageModule.getStaticPaths === "function" && route.isDynamic) { + const pathsResult = await pageModule.getStaticPaths({ + locales: i18nConfig ? i18nConfig.locales : [], + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : "", }); - if (!isValidPath) { - return new Response("

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); + const fallback = + pathsResult && pathsResult.fallback !== undefined ? pathsResult.fallback : false; + + if (fallback === false) { + const paths = pathsResult && pathsResult.paths ? pathsResult.paths : []; + const isValidPath = paths.some(function(p) { + return Object.entries(p.params).every(function(entry) { + var key = entry[0], val = entry[1]; + var actual = params[key]; + if (Array.isArray(val)) { + return Array.isArray(actual) && val.join("/") === actual.join("/"); + } + return String(val) === String(actual); + }); + }); + if (!isValidPath) { + return new Response( + "

404 - Page not found

", + { status: 404, headers: { "Content-Type": "text/html" } }, + ); + } } } - } - let pageProps = {}; - var gsspRes = null; - if (typeof pageModule.getServerSideProps === "function") { - const { req, res, responsePromise } = createReqRes(request, routeUrl, parseQuery(routeUrl), undefined); - const ctx = { - params, req, res, - query: parseQuery(routeUrl), - resolvedUrl: routeUrl, - locale: locale, - locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, - }; - const result = await pageModule.getServerSideProps(ctx); - // If gSSP called res.end() directly (short-circuit), return that response. - if (res.headersSent) { - return await responsePromise; - } - if (result && result.props) pageProps = result.props; - if (result && result.redirect) { - var gsspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307); - return new Response(null, { status: gsspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); - } - if (result && result.notFound) { - return new Response("404", { status: 404 }); - } - // Preserve the res object so headers/status/cookies set by gSSP - // can be merged into the final HTML response. - gsspRes = res; - } - // Build font Link header early so it's available for ISR cached responses too. - // Font preloads are module-level state populated at import time and persist across requests. - var _fontLinkHeader = ""; - var _allFp = []; - try { - var _fpGoogle = typeof _getSSRFontPreloadsGoogle === "function" ? _getSSRFontPreloadsGoogle() : []; - var _fpLocal = typeof _getSSRFontPreloadsLocal === "function" ? _getSSRFontPreloadsLocal() : []; - _allFp = _fpGoogle.concat(_fpLocal); - if (_allFp.length > 0) { - _fontLinkHeader = _allFp.map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; }).join(", "); - } - } catch (e) { /* font preloads not available */ } - - let isrRevalidateSeconds = null; - if (typeof pageModule.getStaticProps === "function") { - const pathname = routeUrl.split("?")[0]; - const cacheKey = isrCacheKey("pages", pathname); - const cached = await isrGet(cacheKey); - - if (cached && !cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { - var _hitHeaders = { - "Content-Type": "text/html", "X-Vinext-Cache": "HIT", - "Cache-Control": "s-maxage=" + (cached.value.value.revalidate || 60) + ", stale-while-revalidate", + let pageProps = {}; + var gsspRes = null; + if (typeof pageModule.getServerSideProps === "function") { + const { req, res, responsePromise } = createReqRes( + request, + routeUrl, + parseQuery(routeUrl), + undefined, + ); + const ctx = { + params, req, res, + query: parseQuery(routeUrl), + resolvedUrl: routeUrl, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, }; - if (_fontLinkHeader) _hitHeaders["Link"] = _fontLinkHeader; - return new Response(cached.value.value.html, { status: 200, headers: _hitHeaders }); + const result = await pageModule.getServerSideProps(ctx); + // If gSSP called res.end() directly (short-circuit), return that response. + if (res.headersSent) { + return await responsePromise; + } + if (result && result.props) pageProps = result.props; + if (result && result.redirect) { + var gsspStatus = + result.redirect.statusCode != null + ? result.redirect.statusCode + : (result.redirect.permanent ? 308 : 307); + return new Response(null, { + status: gsspStatus, + headers: { Location: sanitizeDestinationLocal(result.redirect.destination) }, + }); + } + if (result && result.notFound) { + return new Response("404", { status: 404 }); + } + // Preserve the res object so headers/status/cookies set by gSSP + // can be merged into the final HTML response. + gsspRes = res; } - if (cached && cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { - triggerBackgroundRegeneration(cacheKey, async function() { - const freshResult = await pageModule.getStaticProps({ params }); - if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) { - await isrSet(cacheKey, { kind: "PAGES", html: cached.value.value.html, pageData: freshResult.props, headers: undefined, status: undefined }, freshResult.revalidate); - } - }); - var _staleHeaders = { - "Content-Type": "text/html", "X-Vinext-Cache": "STALE", - "Cache-Control": "s-maxage=0, stale-while-revalidate", + // Build font Link header early so it's available for ISR cached responses too. + // Font preloads are module-level state populated at import time and persist across requests. + var _fontLinkHeader = ""; + var _allFp = []; + try { + var _fpGoogle = + typeof _getSSRFontPreloadsGoogle === "function" ? _getSSRFontPreloadsGoogle() : []; + var _fpLocal = + typeof _getSSRFontPreloadsLocal === "function" ? _getSSRFontPreloadsLocal() : []; + _allFp = _fpGoogle.concat(_fpLocal); + if (_allFp.length > 0) { + _fontLinkHeader = _allFp + .map(function(p) { + return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; + }) + .join(", "); + } + } catch (e) { /* font preloads not available */ } + + let isrRevalidateSeconds = null; + if (typeof pageModule.getStaticProps === "function") { + const pathname = routeUrl.split("?")[0]; + const cacheKey = isrCacheKey("pages", pathname); + const cached = await isrGet(cacheKey); + + if (cached && !cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { + var _hitHeaders = { + "Content-Type": "text/html", "X-Vinext-Cache": "HIT", + "Cache-Control": + "s-maxage=" + (cached.value.value.revalidate || 60) + ", stale-while-revalidate", + }; + if (_fontLinkHeader) _hitHeaders["Link"] = _fontLinkHeader; + return new Response(cached.value.value.html, { status: 200, headers: _hitHeaders }); + } + + if (cached && cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { + triggerBackgroundRegeneration(cacheKey, async function() { + const freshResult = await pageModule.getStaticProps({ params }); + if ( + freshResult && + freshResult.props && + typeof freshResult.revalidate === "number" && + freshResult.revalidate > 0 + ) { + await isrSet( + cacheKey, + { + kind: "PAGES", + html: cached.value.value.html, + pageData: freshResult.props, + headers: undefined, + status: undefined, + }, + freshResult.revalidate, + ); + } + }); + var _staleHeaders = { + "Content-Type": "text/html", "X-Vinext-Cache": "STALE", + "Cache-Control": "s-maxage=0, stale-while-revalidate", + }; + if (_fontLinkHeader) _staleHeaders["Link"] = _fontLinkHeader; + return new Response(cached.value.value.html, { status: 200, headers: _staleHeaders }); + } + + const ctx = { + params, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, }; - if (_fontLinkHeader) _staleHeaders["Link"] = _fontLinkHeader; - return new Response(cached.value.value.html, { status: 200, headers: _staleHeaders }); + const result = await pageModule.getStaticProps(ctx); + if (result && result.props) pageProps = result.props; + if (result && result.redirect) { + var gspStatus = + result.redirect.statusCode != null + ? result.redirect.statusCode + : (result.redirect.permanent ? 308 : 307); + return new Response(null, { + status: gspStatus, + headers: { Location: sanitizeDestinationLocal(result.redirect.destination) }, + }); + } + if (result && result.notFound) { + return new Response("404", { status: 404 }); + } + if (typeof result.revalidate === "number" && result.revalidate > 0) { + isrRevalidateSeconds = result.revalidate; + } } - const ctx = { - params, - locale: locale, - locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, - }; - const result = await pageModule.getStaticProps(ctx); - if (result && result.props) pageProps = result.props; - if (result && result.redirect) { - var gspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307); - return new Response(null, { status: gspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); - } - if (result && result.notFound) { - return new Response("404", { status: 404 }); - } - if (typeof result.revalidate === "number" && result.revalidate > 0) { - isrRevalidateSeconds = result.revalidate; + let element; + if (AppComponent) { + element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + } else { + element = React.createElement(PageComponent, pageProps); } - } - - let element; - if (AppComponent) { - element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); - } else { - element = React.createElement(PageComponent, pageProps); - } - element = wrapWithRouterContext(element); + element = wrapWithRouterContext(element); - if (typeof resetSSRHead === "function") resetSSRHead(); - if (typeof flushPreloads === "function") await flushPreloads(); + if (typeof resetSSRHead === "function") resetSSRHead(); + if (typeof flushPreloads === "function") await flushPreloads(); - const ssrHeadHTML = typeof getSSRHeadHTML === "function" ? getSSRHeadHTML() : ""; + const ssrHeadHTML = typeof getSSRHeadHTML === "function" ? getSSRHeadHTML() : ""; - // Collect SSR font data (Google Font links, font preloads, font-face styles) - var fontHeadHTML = ""; - function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); } - try { - var fontLinks = typeof _getSSRFontLinks === "function" ? _getSSRFontLinks() : []; - for (var fl of fontLinks) { fontHeadHTML += '\\n '; } - } catch (e) { /* next/font/google not used */ } - // Emit for all font files (reuse _allFp collected earlier for Link header) - for (var fp of _allFp) { fontHeadHTML += '\\n '; } - try { - var allFontStyles = []; - if (typeof _getSSRFontStylesGoogle === "function") allFontStyles.push(..._getSSRFontStylesGoogle()); - if (typeof _getSSRFontStylesLocal === "function") allFontStyles.push(..._getSSRFontStylesLocal()); - if (allFontStyles.length > 0) { fontHeadHTML += '\\n '; } - } catch (e) { /* font styles not available */ } - - const pageModuleIds = route.filePath ? [route.filePath] : []; - const assetTags = collectAssetTags(manifest, pageModuleIds); - const nextDataPayload = { - props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, - }; - if (i18nConfig) { - nextDataPayload.locale = locale; - nextDataPayload.locales = i18nConfig.locales; - nextDataPayload.defaultLocale = i18nConfig.defaultLocale; - } - const localeGlobals = i18nConfig - ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + - ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + - ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(i18nConfig.defaultLocale) - : ""; - const nextDataScript = ""; - - // Build the document shell with a placeholder for the streamed body - var BODY_MARKER = ""; - var shellHtml; - if (DocumentComponent) { - const docElement = React.createElement(DocumentComponent); - shellHtml = await renderToStringAsync(docElement); - shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER); - if (ssrHeadHTML || assetTags || fontHeadHTML) { - shellHtml = shellHtml.replace("", " " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n"); - } - shellHtml = shellHtml.replace("", nextDataScript); - if (!shellHtml.includes("__NEXT_DATA__")) { - shellHtml = shellHtml.replace("", " " + nextDataScript + "\\n"); + // Collect SSR font data (Google Font links, font preloads, font-face styles) + var fontHeadHTML = ""; + function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); } + try { + var fontLinks = typeof _getSSRFontLinks === "function" ? _getSSRFontLinks() : []; + for (var fl of fontLinks) { + fontHeadHTML += '\\n '; + } + } catch (e) { /* next/font/google not used */ } + // Emit for all font files (reuse _allFp collected earlier for Link header) + for (var fp of _allFp) { + fontHeadHTML += + '\\n '; } - } else { - shellHtml = "\\n\\n\\n \\n \\n " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n\\n\\n
" + BODY_MARKER + "
\\n " + nextDataScript + "\\n\\n"; - } - - if (typeof setSSRContext === "function") setSSRContext(null); - - // Split the shell at the body marker - var markerIdx = shellHtml.indexOf(BODY_MARKER); - var shellPrefix = shellHtml.slice(0, markerIdx); - var shellSuffix = shellHtml.slice(markerIdx + BODY_MARKER.length); - - // Start the React body stream — progressive SSR (no allReady wait) - var bodyStream = await renderToReadableStream(element); - var encoder = new TextEncoder(); + try { + var allFontStyles = []; + if (typeof _getSSRFontStylesGoogle === "function") { + allFontStyles.push(..._getSSRFontStylesGoogle()); + } + if (typeof _getSSRFontStylesLocal === "function") { + allFontStyles.push(..._getSSRFontStylesLocal()); + } + if (allFontStyles.length > 0) { + fontHeadHTML += + '\\n "; + } + } catch (e) { /* font styles not available */ } - // Create a composite stream: prefix + body + suffix - var compositeStream = new ReadableStream({ - async start(controller) { - controller.enqueue(encoder.encode(shellPrefix)); - var reader = bodyStream.getReader(); - try { - for (;;) { - var chunk = await reader.read(); - if (chunk.done) break; - controller.enqueue(chunk.value); + const pageModuleIds = route.filePath ? [route.filePath] : []; + const assetTags = collectAssetTags(manifest, pageModuleIds); + const nextDataPayload = { + props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, + }; + if (i18nConfig) { + nextDataPayload.locale = locale; + nextDataPayload.locales = i18nConfig.locales; + nextDataPayload.defaultLocale = i18nConfig.defaultLocale; + } + const localeGlobals = i18nConfig + ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + + ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + + ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(i18nConfig.defaultLocale) + : ""; + const nextDataScript = + ""; + + // Build the document shell with a placeholder for the streamed body + var BODY_MARKER = ""; + var shellHtml; + if (DocumentComponent) { + const docElement = React.createElement(DocumentComponent); + shellHtml = await renderToStringAsync(docElement); + shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER); + if (ssrHeadHTML || assetTags || fontHeadHTML) { + shellHtml = shellHtml.replace( + "", + " " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n", + ); + } + shellHtml = shellHtml.replace("", nextDataScript); + if (!shellHtml.includes("__NEXT_DATA__")) { + shellHtml = shellHtml.replace("", " " + nextDataScript + "\\n"); + } + } else { + shellHtml = + "\\n\\n\\n \\n \\n " + + fontHeadHTML + + ssrHeadHTML + + "\\n " + + assetTags + + "\\n\\n\\n
" + + BODY_MARKER + + "
\\n " + + nextDataScript + + "\\n\\n"; + } + + if (typeof setSSRContext === "function") setSSRContext(null); + + // Split the shell at the body marker + var markerIdx = shellHtml.indexOf(BODY_MARKER); + var shellPrefix = shellHtml.slice(0, markerIdx); + var shellSuffix = shellHtml.slice(markerIdx + BODY_MARKER.length); + + // Start the React body stream — progressive SSR (no allReady wait) + var bodyStream = await renderToReadableStream(element); + var encoder = new TextEncoder(); + + // Create a composite stream: prefix + body + suffix + var compositeStream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(shellPrefix)); + var reader = bodyStream.getReader(); + try { + for (;;) { + var chunk = await reader.read(); + if (chunk.done) break; + controller.enqueue(chunk.value); + } + } finally { + reader.releaseLock(); } - } finally { - reader.releaseLock(); + controller.enqueue(encoder.encode(shellSuffix)); + controller.close(); } - controller.enqueue(encoder.encode(shellSuffix)); - controller.close(); + }); + + // Cache the rendered HTML for ISR (needs the full string — re-render synchronously) + if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) { + // Tee the stream so we can cache and respond simultaneously would be ideal, + // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. + var isrElement; + if (AppComponent) { + isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + } else { + isrElement = React.createElement(PageComponent, pageProps); + } + isrElement = wrapWithRouterContext(isrElement); + var isrHtml = await renderToStringAsync(isrElement); + var fullHtml = shellPrefix + isrHtml + shellSuffix; + var isrPathname = url.split("?")[0]; + var _cacheKey = isrCacheKey("pages", isrPathname); + await isrSet( + _cacheKey, + { + kind: "PAGES", + html: fullHtml, + pageData: pageProps, + headers: undefined, + status: undefined, + }, + isrRevalidateSeconds, + ); } - }); - // Cache the rendered HTML for ISR (needs the full string — re-render synchronously) - if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) { - // Tee the stream so we can cache and respond simultaneously would be ideal, - // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. - var isrElement; - if (AppComponent) { - isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); - } else { - isrElement = React.createElement(PageComponent, pageProps); - } - isrElement = wrapWithRouterContext(isrElement); - var isrHtml = await renderToStringAsync(isrElement); - var fullHtml = shellPrefix + isrHtml + shellSuffix; - var isrPathname = url.split("?")[0]; - var _cacheKey = isrCacheKey("pages", isrPathname); - await isrSet(_cacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds); - } - - // Merge headers/status/cookies set by getServerSideProps on the res object. - // gSSP commonly uses res.setHeader("Set-Cookie", ...) or res.status(304). - var finalStatus = 200; - const responseHeaders = new Headers({ "Content-Type": "text/html" }); - if (gsspRes) { - finalStatus = gsspRes.statusCode; - var gsspHeaders = gsspRes.getHeaders(); - for (var hk of Object.keys(gsspHeaders)) { - var hv = gsspHeaders[hk]; - if (hk === "set-cookie" && Array.isArray(hv)) { - for (var sc of hv) responseHeaders.append("set-cookie", sc); - } else if (hv != null) { - responseHeaders.set(hk, String(hv)); + // Merge headers/status/cookies set by getServerSideProps on the res object. + // gSSP commonly uses res.setHeader("Set-Cookie", ...) or res.status(304). + var finalStatus = 200; + const responseHeaders = new Headers({ "Content-Type": "text/html" }); + if (gsspRes) { + finalStatus = gsspRes.statusCode; + var gsspHeaders = gsspRes.getHeaders(); + for (var hk of Object.keys(gsspHeaders)) { + var hv = gsspHeaders[hk]; + if (hk === "set-cookie" && Array.isArray(hv)) { + for (var sc of hv) responseHeaders.append("set-cookie", sc); + } else if (hv != null) { + responseHeaders.set(hk, String(hv)); + } } + // Ensure Content-Type stays text/html (gSSP shouldn't override it for page renders) + responseHeaders.set("Content-Type", "text/html"); } - // Ensure Content-Type stays text/html (gSSP shouldn't override it for page renders) - responseHeaders.set("Content-Type", "text/html"); - } - if (isrRevalidateSeconds) { - responseHeaders.set("Cache-Control", "s-maxage=" + isrRevalidateSeconds + ", stale-while-revalidate"); - responseHeaders.set("X-Vinext-Cache", "MISS"); - } - // Set HTTP Link header for font preloading - if (_fontLinkHeader) { - responseHeaders.set("Link", _fontLinkHeader); + if (isrRevalidateSeconds) { + responseHeaders.set( + "Cache-Control", + "s-maxage=" + isrRevalidateSeconds + ", stale-while-revalidate", + ); + responseHeaders.set("X-Vinext-Cache", "MISS"); + } + // Set HTTP Link header for font preloading + if (_fontLinkHeader) { + responseHeaders.set("Link", _fontLinkHeader); + } + return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); + } catch (e) { + console.error("[vinext] SSR error:", e); + return new Response("Internal Server Error", { status: 500 }); } - return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); - } catch (e) { - console.error("[vinext] SSR error:", e); - return new Response("Internal Server Error", { status: 500 }); - } }); }