diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index fcceaaa60..433df40eb 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -2036,28 +2036,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), return a 303 redirect response. + // This matches Next.js behavior for server action redirects. + // The client will handle the redirect and navigate to the target URL. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ - "Content-Type": "text/x-component; charset=utf-8", - "Vary": "RSC, Accept", - "x-action-redirect": actionRedirect.url, - "x-action-redirect-type": actionRedirect.type, - "x-action-redirect-status": String(actionRedirect.status), + Location: actionRedirect.url, }); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) - return new Response("", { status: 200, headers: redirectHeaders }); + + // Return 303 See Other for server action redirects (Next.js behavior) + return new Response(null, { status: 303, headers: redirectHeaders }); } // After the action, re-render the current page so the client diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 27589b830..eb2ca059f 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -133,6 +133,7 @@ function registerServerActionCallback(): void { const actionRedirect = fetchResponse.headers.get("x-action-redirect"); if (actionRedirect) { + // Check for external URLs that need a hard redirect. try { const redirectUrl = new URL(actionRedirect, window.location.origin); if (redirectUrl.origin !== window.location.origin) { @@ -140,20 +141,17 @@ function registerServerActionCallback(): void { return undefined; } } catch { - // Fall through to client-side navigation if URL parsing fails. + // Fall through to hard redirect below if URL parsing fails. } + // For same-origin redirects, use hard redirect to ensure navigation completes. + // This matches Next.js behavior for server action redirects with useActionState. const redirectType = fetchResponse.headers.get("x-action-redirect-type") ?? "replace"; if (redirectType === "push") { - window.history.pushState(null, "", actionRedirect); + window.location.assign(actionRedirect); } else { - window.history.replaceState(null, "", actionRedirect); + window.location.replace(actionRedirect); } - - if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") { - await window.__VINEXT_RSC_NAVIGATE__(actionRedirect); - } - return undefined; } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 771476fb9..f7594387f 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1748,13 +1748,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -1768,7 +1810,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -4718,13 +4759,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -4738,7 +4821,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -7715,13 +7797,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -7735,7 +7859,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -10723,13 +10846,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -10743,7 +10908,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -13697,13 +13861,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -13717,7 +13923,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -17024,13 +17229,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -17044,7 +17291,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -18251,1588 +18497,3 @@ export * from "/packages/vinext/src/server/app-ssr-entry.ts"; export { default } from "/packages/vinext/src/server/app-ssr-entry.ts"; " `; - -exports[`Pages Router entry templates > client entry snapshot 1`] = ` -" -import React from "react"; -import { hydrateRoot } from "react-dom/client"; -// Eagerly import the router shim so its module-level popstate listener is -// registered. Without this, browser back/forward buttons do nothing because -// navigateClient() is never invoked on history changes. -import "next/router"; - -const pageLoaders = { - "/": () => import("/tests/fixtures/pages-basic/pages/index.tsx"), - "/404": () => import("/tests/fixtures/pages-basic/pages/404.tsx"), - "/about": () => import("/tests/fixtures/pages-basic/pages/about.tsx"), - "/alias-test": () => import("/tests/fixtures/pages-basic/pages/alias-test.tsx"), - "/before-pop-state-destination": () => import("/tests/fixtures/pages-basic/pages/before-pop-state-destination.tsx"), - "/before-pop-state-test": () => import("/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx"), - "/cjs/basic": () => import("/tests/fixtures/pages-basic/pages/cjs/basic.tsx"), - "/cjs/random": () => import("/tests/fixtures/pages-basic/pages/cjs/random.ts"), - "/compat-router-test": () => import("/tests/fixtures/pages-basic/pages/compat-router-test.tsx"), - "/concurrent-head": () => import("/tests/fixtures/pages-basic/pages/concurrent-head.tsx"), - "/concurrent-router": () => import("/tests/fixtures/pages-basic/pages/concurrent-router.tsx"), - "/config-test": () => import("/tests/fixtures/pages-basic/pages/config-test.tsx"), - "/counter": () => import("/tests/fixtures/pages-basic/pages/counter.tsx"), - "/dynamic-page": () => import("/tests/fixtures/pages-basic/pages/dynamic-page.tsx"), - "/dynamic-ssr-false": () => import("/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"), - "/header-override-delete": () => import("/tests/fixtures/pages-basic/pages/header-override-delete.tsx"), - "/isr-second-render-state": () => import("/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx"), - "/isr-test": () => import("/tests/fixtures/pages-basic/pages/isr-test.tsx"), - "/link-test": () => import("/tests/fixtures/pages-basic/pages/link-test.tsx"), - "/mw-object-gated": () => import("/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"), - "/nav-test": () => import("/tests/fixtures/pages-basic/pages/nav-test.tsx"), - "/posts/missing": () => import("/tests/fixtures/pages-basic/pages/posts/missing.tsx"), - "/redirect-xss": () => import("/tests/fixtures/pages-basic/pages/redirect-xss.tsx"), - "/router-events-test": () => import("/tests/fixtures/pages-basic/pages/router-events-test.tsx"), - "/script-test": () => import("/tests/fixtures/pages-basic/pages/script-test.tsx"), - "/shallow-test": () => import("/tests/fixtures/pages-basic/pages/shallow-test.tsx"), - "/ssr": () => import("/tests/fixtures/pages-basic/pages/ssr.tsx"), - "/ssr-headers": () => import("/tests/fixtures/pages-basic/pages/ssr-headers.tsx"), - "/ssr-res-end": () => import("/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"), - "/suspense-test": () => import("/tests/fixtures/pages-basic/pages/suspense-test.tsx"), - "/articles/[id]": () => import("/tests/fixtures/pages-basic/pages/articles/[id].tsx"), - "/blog/[slug]": () => import("/tests/fixtures/pages-basic/pages/blog/[slug].tsx"), - "/posts/[id]": () => import("/tests/fixtures/pages-basic/pages/posts/[id].tsx"), - "/products/[pid]": () => import("/tests/fixtures/pages-basic/pages/products/[pid].tsx"), - "/docs/[...slug]": () => import("/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"), - "/sign-up/[[...sign-up]]": () => import("/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx") -}; - -async function hydrate() { - const nextData = window.__NEXT_DATA__; - if (!nextData) { - console.error("[vinext] No __NEXT_DATA__ found"); - return; - } - - const { pageProps } = nextData.props; - const loader = pageLoaders[nextData.page]; - if (!loader) { - console.error("[vinext] No page loader for route:", nextData.page); - return; - } - - const pageModule = await loader(); - const PageComponent = pageModule.default; - if (!PageComponent) { - console.error("[vinext] Page module has no default export"); - return; - } - - let element; - - try { - const appModule = await import("/tests/fixtures/pages-basic/pages/_app.tsx"); - const AppComponent = appModule.default; - window.__VINEXT_APP__ = AppComponent; - element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); - } catch { - element = React.createElement(PageComponent, pageProps); - } - - - // Wrap with RouterContext.Provider so next/compat/router works during hydration - const { wrapWithRouterContext } = await import("next/router"); - element = wrapWithRouterContext(element); - - const container = document.getElementById("__next"); - if (!container) { - console.error("[vinext] No #__next element found"); - return; - } - - const root = hydrateRoot(container, element); - window.__VINEXT_ROOT__ = root; -} - -hydrate(); -" -`; - -exports[`Pages Router entry templates > server entry snapshot 1`] = ` -" -import React from "react"; -import { renderToReadableStream } from "react-dom/server.edge"; -import { resetSSRHead, getSSRHeadHTML } from "next/head"; -import { flushPreloads } from "next/dynamic"; -import { setSSRContext, wrapWithRouterContext } from "next/router"; -import { getCacheHandler, _runWithCacheState } from "next/cache"; -import { runWithPrivateCache } from "vinext/cache-runtime"; -import { ensureFetchPatch, runWithFetchCache } from "vinext/fetch-cache"; -import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; -import "vinext/router-state"; -import { runWithServerInsertedHTMLState } from "vinext/navigation-state"; -import { runWithHeadState } from "vinext/head-state"; -import "vinext/i18n-state"; -import { setI18nContext } from "vinext/i18n-context"; -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"; -import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; -import { parseCookies, sanitizeDestination as sanitizeDestinationLocal } from "/packages/vinext/src/config/config-matchers.js"; -import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; -import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; -import { resolvePagesI18nRequest } from "/packages/vinext/src/server/pages-i18n.js"; -import * as _instrumentation from "/tests/fixtures/pages-basic/instrumentation.ts"; -import * as middlewareModule from "/tests/fixtures/pages-basic/middleware.ts"; -import { NextRequest, NextFetchEvent } from "next/server"; - -// Run instrumentation register() once at module evaluation time — before any -// requests are handled. Matches Next.js semantics: register() is called once -// on startup in the process that handles requests. -if (typeof _instrumentation.register === "function") { - await _instrumentation.register(); -} -// Store the onRequestError handler on globalThis so it is visible to all -// code within the Worker (same global scope). -if (typeof _instrumentation.onRequestError === "function") { - globalThis.__VINEXT_onRequestErrorHandler__ = _instrumentation.onRequestError; -} - -// i18n config (embedded at build time) -const i18nConfig = null; - -// Build ID (embedded at build time) -const buildId = "test-build-id"; - -// Full resolved config for production server (embedded at build time) -export const vinextConfig = {"basePath":"","trailingSlash":false,"redirects":[{"source":"/old-about","destination":"/about","permanent":true},{"source":"/repeat-redirect/:id","destination":"/docs/:id/:id","permanent":false},{"source":"/redirect-before-middleware-rewrite","destination":"/about","permanent":false},{"source":"/redirect-before-middleware-response","destination":"/about","permanent":false}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"source":"/repeat-rewrite/:id","destination":"/docs/:id/:id"},{"source":"/mw-gated-before","has":[{"type":"cookie","key":"mw-before-user"}],"destination":"/about"}],"afterFiles":[{"source":"/after-rewrite","destination":"/about"},{"source":"/mw-gated-rewrite","has":[{"type":"cookie","key":"mw-user"}],"destination":"/about"}],"fallback":[{"source":"/fallback-rewrite","destination":"/about"}]},"headers":[{"source":"/api/(.*)","headers":[{"key":"X-Custom-Header","value":"vinext"}]},{"source":"/about","has":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Auth-Only-Header","value":"1"}]},{"source":"/about","missing":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Guest-Only-Header","value":"1"}]},{"source":"/ssr","headers":[{"key":"Vary","value":"Accept-Language"}]},{"source":"/headers-before-middleware-rewrite","headers":[{"key":"X-Rewrite-Source-Header","value":"1"}]}],"i18n":null,"images":{}}; - -class ApiBodyParseError extends Error { - constructor(message, statusCode) { - super(message); - this.statusCode = statusCode; - this.name = "ApiBodyParseError"; - } -} - -// ISR cache helpers (inlined for the server entry) -async function isrGet(key) { - const handler = getCacheHandler(); - const result = await handler.get(key); - if (!result || !result.value) return null; - return { value: result, isStale: result.cacheState === "stale" }; -} -async function isrSet(key, data, revalidateSeconds, tags) { - const handler = getCacheHandler(); - await handler.set(key, data, { revalidate: revalidateSeconds, tags: tags || [] }); -} -const pendingRegenerations = new Map(); -function triggerBackgroundRegeneration(key, renderFn) { - if (pendingRegenerations.has(key)) return; - const promise = renderFn() - .catch((err) => console.error("[vinext] ISR regen failed for " + key + ":", err)) - .finally(() => pendingRegenerations.delete(key)); - pendingRegenerations.set(key, promise); - // Register with the Workers ExecutionContext so the isolate is kept alive - // until the regeneration finishes, even after the Response has been sent. - const ctx = _getRequestExecutionContext(); - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(promise); -} - -function fnv1a64(input) { - let h1 = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - h1 ^= input.charCodeAt(i); - h1 = (h1 * 0x01000193) >>> 0; - } - let h2 = 0x050c5d1f; - for (let i = 0; i < input.length; i++) { - h2 ^= input.charCodeAt(i); - h2 = (h2 * 0x01000193) >>> 0; - } - return h1.toString(36) + h2.toString(36); -} -// Keep prefix construction and hashing logic in sync with isrCacheKey() in server/isr-cache.ts. -// buildId is a top-level const in the generated entry (see "const buildId = ..." above). -function isrCacheKey(router, pathname) { - const normalized = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); - const prefix = buildId ? router + ":" + buildId : router; - const key = prefix + ":" + normalized; - if (key.length <= 200) return key; - return prefix + ":__hash:" + fnv1a64(normalized); -} - -function getMediaType(contentType) { - var type = (contentType || "text/plain").split(";")[0]; - type = type && type.trim().toLowerCase(); - return type || "text/plain"; -} - -function isJsonMediaType(mediaType) { - return mediaType === "application/json" || mediaType === "application/ld+json"; -} - -async function renderToStringAsync(element) { - const stream = await renderToReadableStream(element); - await stream.allReady; - return new Response(stream).text(); -} - -async function renderIsrPassToStringAsync(element) { - // The cache-fill render is a second render pass for the same request. - // Reset render-scoped state so it cannot leak from the streamed response - // render or affect async work that is still draining from that stream. - // Keep request identity state (pathname/query/locale/executionContext) - // intact: this second pass still belongs to the same request. - return await runWithServerInsertedHTMLState(() => - runWithHeadState(() => - _runWithCacheState(() => - runWithPrivateCache(() => runWithFetchCache(async () => renderToStringAsync(element))), - ), - ), - ); -} - -import * as page_0 from "/tests/fixtures/pages-basic/pages/index.tsx"; -import * as page_1 from "/tests/fixtures/pages-basic/pages/404.tsx"; -import * as page_2 from "/tests/fixtures/pages-basic/pages/about.tsx"; -import * as page_3 from "/tests/fixtures/pages-basic/pages/alias-test.tsx"; -import * as page_4 from "/tests/fixtures/pages-basic/pages/before-pop-state-destination.tsx"; -import * as page_5 from "/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx"; -import * as page_6 from "/tests/fixtures/pages-basic/pages/cjs/basic.tsx"; -import * as page_7 from "/tests/fixtures/pages-basic/pages/cjs/random.ts"; -import * as page_8 from "/tests/fixtures/pages-basic/pages/compat-router-test.tsx"; -import * as page_9 from "/tests/fixtures/pages-basic/pages/concurrent-head.tsx"; -import * as page_10 from "/tests/fixtures/pages-basic/pages/concurrent-router.tsx"; -import * as page_11 from "/tests/fixtures/pages-basic/pages/config-test.tsx"; -import * as page_12 from "/tests/fixtures/pages-basic/pages/counter.tsx"; -import * as page_13 from "/tests/fixtures/pages-basic/pages/dynamic-page.tsx"; -import * as page_14 from "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"; -import * as page_15 from "/tests/fixtures/pages-basic/pages/header-override-delete.tsx"; -import * as page_16 from "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx"; -import * as page_17 from "/tests/fixtures/pages-basic/pages/isr-test.tsx"; -import * as page_18 from "/tests/fixtures/pages-basic/pages/link-test.tsx"; -import * as page_19 from "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"; -import * as page_20 from "/tests/fixtures/pages-basic/pages/nav-test.tsx"; -import * as page_21 from "/tests/fixtures/pages-basic/pages/posts/missing.tsx"; -import * as page_22 from "/tests/fixtures/pages-basic/pages/redirect-xss.tsx"; -import * as page_23 from "/tests/fixtures/pages-basic/pages/router-events-test.tsx"; -import * as page_24 from "/tests/fixtures/pages-basic/pages/script-test.tsx"; -import * as page_25 from "/tests/fixtures/pages-basic/pages/shallow-test.tsx"; -import * as page_26 from "/tests/fixtures/pages-basic/pages/ssr.tsx"; -import * as page_27 from "/tests/fixtures/pages-basic/pages/ssr-headers.tsx"; -import * as page_28 from "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"; -import * as page_29 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; -import * as page_30 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; -import * as page_31 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; -import * as page_32 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; -import * as page_33 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; -import * as page_34 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; -import * as page_35 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; -import * as api_0 from "/tests/fixtures/pages-basic/pages/api/binary.ts"; -import * as api_1 from "/tests/fixtures/pages-basic/pages/api/echo-body.ts"; -import * as api_2 from "/tests/fixtures/pages-basic/pages/api/error-route.ts"; -import * as api_3 from "/tests/fixtures/pages-basic/pages/api/hello.ts"; -import * as api_4 from "/tests/fixtures/pages-basic/pages/api/instrumentation-test.ts"; -import * as api_5 from "/tests/fixtures/pages-basic/pages/api/middleware-test.ts"; -import * as api_6 from "/tests/fixtures/pages-basic/pages/api/no-content-type.ts"; -import * as api_7 from "/tests/fixtures/pages-basic/pages/api/parse.ts"; -import * as api_8 from "/tests/fixtures/pages-basic/pages/api/send-buffer.ts"; -import * as api_9 from "/tests/fixtures/pages-basic/pages/api/users/[id].ts"; - -import { default as AppComponent } from "/tests/fixtures/pages-basic/pages/_app.tsx"; -import { default as DocumentComponent } from "/tests/fixtures/pages-basic/pages/_document.tsx"; - -export const pageRoutes = [ - { pattern: "/", patternParts: [], isDynamic: false, params: [], module: page_0, filePath: "/tests/fixtures/pages-basic/pages/index.tsx" }, - { pattern: "/404", patternParts: ["404"], isDynamic: false, params: [], module: page_1, filePath: "/tests/fixtures/pages-basic/pages/404.tsx" }, - { pattern: "/about", patternParts: ["about"], isDynamic: false, params: [], module: page_2, filePath: "/tests/fixtures/pages-basic/pages/about.tsx" }, - { pattern: "/alias-test", patternParts: ["alias-test"], isDynamic: false, params: [], module: page_3, filePath: "/tests/fixtures/pages-basic/pages/alias-test.tsx" }, - { pattern: "/before-pop-state-destination", patternParts: ["before-pop-state-destination"], isDynamic: false, params: [], module: page_4, filePath: "/tests/fixtures/pages-basic/pages/before-pop-state-destination.tsx" }, - { pattern: "/before-pop-state-test", patternParts: ["before-pop-state-test"], isDynamic: false, params: [], module: page_5, filePath: "/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx" }, - { pattern: "/cjs/basic", patternParts: ["cjs","basic"], isDynamic: false, params: [], module: page_6, filePath: "/tests/fixtures/pages-basic/pages/cjs/basic.tsx" }, - { pattern: "/cjs/random", patternParts: ["cjs","random"], isDynamic: false, params: [], module: page_7, filePath: "/tests/fixtures/pages-basic/pages/cjs/random.ts" }, - { pattern: "/compat-router-test", patternParts: ["compat-router-test"], isDynamic: false, params: [], module: page_8, filePath: "/tests/fixtures/pages-basic/pages/compat-router-test.tsx" }, - { pattern: "/concurrent-head", patternParts: ["concurrent-head"], isDynamic: false, params: [], module: page_9, filePath: "/tests/fixtures/pages-basic/pages/concurrent-head.tsx" }, - { pattern: "/concurrent-router", patternParts: ["concurrent-router"], isDynamic: false, params: [], module: page_10, filePath: "/tests/fixtures/pages-basic/pages/concurrent-router.tsx" }, - { pattern: "/config-test", patternParts: ["config-test"], isDynamic: false, params: [], module: page_11, filePath: "/tests/fixtures/pages-basic/pages/config-test.tsx" }, - { pattern: "/counter", patternParts: ["counter"], isDynamic: false, params: [], module: page_12, filePath: "/tests/fixtures/pages-basic/pages/counter.tsx" }, - { pattern: "/dynamic-page", patternParts: ["dynamic-page"], isDynamic: false, params: [], module: page_13, filePath: "/tests/fixtures/pages-basic/pages/dynamic-page.tsx" }, - { pattern: "/dynamic-ssr-false", patternParts: ["dynamic-ssr-false"], isDynamic: false, params: [], module: page_14, filePath: "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx" }, - { pattern: "/header-override-delete", patternParts: ["header-override-delete"], isDynamic: false, params: [], module: page_15, filePath: "/tests/fixtures/pages-basic/pages/header-override-delete.tsx" }, - { pattern: "/isr-second-render-state", patternParts: ["isr-second-render-state"], isDynamic: false, params: [], module: page_16, filePath: "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx" }, - { pattern: "/isr-test", patternParts: ["isr-test"], isDynamic: false, params: [], module: page_17, filePath: "/tests/fixtures/pages-basic/pages/isr-test.tsx" }, - { pattern: "/link-test", patternParts: ["link-test"], isDynamic: false, params: [], module: page_18, filePath: "/tests/fixtures/pages-basic/pages/link-test.tsx" }, - { pattern: "/mw-object-gated", patternParts: ["mw-object-gated"], isDynamic: false, params: [], module: page_19, filePath: "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx" }, - { pattern: "/nav-test", patternParts: ["nav-test"], isDynamic: false, params: [], module: page_20, filePath: "/tests/fixtures/pages-basic/pages/nav-test.tsx" }, - { pattern: "/posts/missing", patternParts: ["posts","missing"], isDynamic: false, params: [], module: page_21, filePath: "/tests/fixtures/pages-basic/pages/posts/missing.tsx" }, - { pattern: "/redirect-xss", patternParts: ["redirect-xss"], isDynamic: false, params: [], module: page_22, filePath: "/tests/fixtures/pages-basic/pages/redirect-xss.tsx" }, - { pattern: "/router-events-test", patternParts: ["router-events-test"], isDynamic: false, params: [], module: page_23, filePath: "/tests/fixtures/pages-basic/pages/router-events-test.tsx" }, - { pattern: "/script-test", patternParts: ["script-test"], isDynamic: false, params: [], module: page_24, filePath: "/tests/fixtures/pages-basic/pages/script-test.tsx" }, - { pattern: "/shallow-test", patternParts: ["shallow-test"], isDynamic: false, params: [], module: page_25, filePath: "/tests/fixtures/pages-basic/pages/shallow-test.tsx" }, - { pattern: "/ssr", patternParts: ["ssr"], isDynamic: false, params: [], module: page_26, filePath: "/tests/fixtures/pages-basic/pages/ssr.tsx" }, - { pattern: "/ssr-headers", patternParts: ["ssr-headers"], isDynamic: false, params: [], module: page_27, filePath: "/tests/fixtures/pages-basic/pages/ssr-headers.tsx" }, - { pattern: "/ssr-res-end", patternParts: ["ssr-res-end"], isDynamic: false, params: [], module: page_28, filePath: "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx" }, - { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, - { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, - { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, - { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_32, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, - { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_33, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, - { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_34, filePath: "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx" }, - { pattern: "/sign-up/:sign-up*", patternParts: ["sign-up",":sign-up*"], isDynamic: true, params: ["sign-up"], module: page_35, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } -]; -const _pageRouteTrie = _buildRouteTrie(pageRoutes); - -const apiRoutes = [ - { pattern: "/api/binary", patternParts: ["api","binary"], isDynamic: false, params: [], module: api_0 }, - { pattern: "/api/echo-body", patternParts: ["api","echo-body"], isDynamic: false, params: [], module: api_1 }, - { pattern: "/api/error-route", patternParts: ["api","error-route"], isDynamic: false, params: [], module: api_2 }, - { pattern: "/api/hello", patternParts: ["api","hello"], isDynamic: false, params: [], module: api_3 }, - { pattern: "/api/instrumentation-test", patternParts: ["api","instrumentation-test"], isDynamic: false, params: [], module: api_4 }, - { pattern: "/api/middleware-test", patternParts: ["api","middleware-test"], isDynamic: false, params: [], module: api_5 }, - { pattern: "/api/no-content-type", patternParts: ["api","no-content-type"], isDynamic: false, params: [], module: api_6 }, - { pattern: "/api/parse", patternParts: ["api","parse"], isDynamic: false, params: [], module: api_7 }, - { pattern: "/api/send-buffer", patternParts: ["api","send-buffer"], isDynamic: false, params: [], module: api_8 }, - { pattern: "/api/users/:id", patternParts: ["api","users",":id"], isDynamic: true, params: ["id"], module: api_9 } -]; -const _apiRouteTrie = _buildRouteTrie(apiRoutes); - -function matchRoute(url, routes) { - const pathname = url.split("?")[0]; - let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); - // NOTE: Do NOT decodeURIComponent here. The pathname is already decoded at - // the entry point. Decoding again would create a double-decode vector. - const urlParts = normalizedUrl.split("/").filter(Boolean); - const trie = routes === pageRoutes ? _pageRouteTrie : _apiRouteTrie; - return _trieMatch(trie, urlParts); -} - -function parseQuery(url) { - const qs = url.split("?")[1]; - if (!qs) return {}; - const p = new URLSearchParams(qs); - const q = {}; - for (const [k, v] of p) { - if (k in q) { - q[k] = Array.isArray(q[k]) ? q[k].concat(v) : [q[k], v]; - } else { - q[k] = v; - } - } - return q; -} - -function patternToNextFormat(pattern) { - return pattern - .replace(/:([\\w]+)\\*/g, "[[...$1]]") - .replace(/:([\\w]+)\\+/g, "[...$1]") - .replace(/:([\\w]+)/g, "[$1]"); -} - -function collectAssetTags(manifest, moduleIds) { - // Fall back to embedded manifest (set by vinext:cloudflare-build for Workers) - const m = (manifest && Object.keys(manifest).length > 0) - ? manifest - : (typeof globalThis !== "undefined" && globalThis.__VINEXT_SSR_MANIFEST__) || null; - const tags = []; - const seen = new Set(); - - // Load the set of lazy chunk filenames (only reachable via dynamic imports). - // These should NOT get or '); - } - if (m) { - // Always inject shared chunks (framework, vinext runtime, entry) and - // page-specific chunks. The manifest maps module file paths to their - // associated JS/CSS assets. - // - // For page-specific injection, the module IDs may be absolute paths - // while the manifest uses relative paths. Try both the original ID - // and a suffix match to find the correct manifest entry. - var allFiles = []; - - if (moduleIds && moduleIds.length > 0) { - // Collect assets for the requested page modules - for (var mi = 0; mi < moduleIds.length; mi++) { - var id = moduleIds[mi]; - var files = m[id]; - if (!files) { - // Absolute path didn't match — try matching by suffix. - // Manifest keys are relative (e.g. "pages/about.tsx") while - // moduleIds may be absolute (e.g. "/home/.../pages/about.tsx"). - for (var mk in m) { - if (id.endsWith("/" + mk) || id === mk) { - files = m[mk]; - break; - } - } - } - if (files) { - for (var fi = 0; fi < files.length; fi++) allFiles.push(files[fi]); - } - } - - // Also inject shared chunks that every page needs: framework, - // vinext runtime, and the entry bootstrap. These are identified - // by scanning all manifest values for chunk filenames containing - // known prefixes. - for (var key in m) { - var vals = m[key]; - if (!vals) continue; - for (var vi = 0; vi < vals.length; vi++) { - var file = vals[vi]; - var basename = file.split("/").pop() || ""; - if ( - basename.startsWith("framework-") || - basename.startsWith("vinext-") || - basename.includes("vinext-client-entry") || - basename.includes("vinext-app-browser-entry") - ) { - allFiles.push(file); - } - } - } - } else { - // No specific modules — include all assets from manifest - for (var akey in m) { - var avals = m[akey]; - if (avals) { - for (var ai = 0; ai < avals.length; ai++) allFiles.push(avals[ai]); - } - } - } - - for (var ti = 0; ti < allFiles.length; ti++) { - var tf = allFiles[ti]; - // Normalize: Vite's SSR manifest values include a leading '/' - // (from base path), but we prepend '/' ourselves when building - // href/src attributes. Strip any existing leading slash to avoid - // producing protocol-relative URLs like "//assets/chunk.js". - // This also ensures consistent keys for the seen-set dedup and - // lazySet.has() checks (which use values without leading slash). - if (tf.charAt(0) === '/') tf = tf.slice(1); - if (seen.has(tf)) continue; - seen.add(tf); - if (tf.endsWith(".css")) { - tags.push(''); - } else if (tf.endsWith(".js")) { - // Skip lazy chunks — they are behind dynamic import() boundaries - // (React.lazy, next/dynamic) and should only be fetched on demand. - if (lazySet && lazySet.has(tf)) continue; - tags.push(''); - tags.push(''); - } - } - } - return tags.join("\\n "); -} - -// i18n helpers -function extractLocale(url) { - if (!i18nConfig) return { locale: undefined, url, hadPrefix: false }; - const pathname = url.split("?")[0]; - const parts = pathname.split("/").filter(Boolean); - const query = url.includes("?") ? url.slice(url.indexOf("?")) : ""; - if (parts.length > 0 && i18nConfig.locales.includes(parts[0])) { - const locale = parts[0]; - const rest = "/" + parts.slice(1).join("/"); - return { locale, url: (rest || "/") + query, hadPrefix: true }; - } - return { locale: i18nConfig.defaultLocale, url, hadPrefix: false }; -} - -function detectLocaleFromHeaders(headers) { - if (!i18nConfig) return null; - const acceptLang = headers.get("accept-language"); - if (!acceptLang) return null; - const langs = acceptLang.split(",").map(function(part) { - const pieces = part.trim().split(";"); - const q = pieces[1] ? parseFloat(pieces[1].replace("q=", "")) : 1; - return { lang: pieces[0].trim().toLowerCase(), q: q }; - }).sort(function(a, b) { return b.q - a.q; }); - for (let k = 0; k < langs.length; k++) { - const lang = langs[k].lang; - for (let j = 0; j < i18nConfig.locales.length; j++) { - if (i18nConfig.locales[j].toLowerCase() === lang) return i18nConfig.locales[j]; - } - const prefix = lang.split("-")[0]; - for (let j = 0; j < i18nConfig.locales.length; j++) { - const loc = i18nConfig.locales[j].toLowerCase(); - if (loc === prefix || loc.startsWith(prefix + "-")) return i18nConfig.locales[j]; - } - } - return null; -} - -function parseCookieLocaleFromHeader(cookieHeader) { - if (!i18nConfig || !cookieHeader) return null; - const match = cookieHeader.match(/(?:^|;\\s*)NEXT_LOCALE=([^;]*)/); - if (!match) return null; - var value; - try { value = decodeURIComponent(match[1].trim()); } catch (e) { return null; } - if (i18nConfig.locales.indexOf(value) !== -1) return value; - return null; -} - -// Lightweight req/res facade for getServerSideProps and API routes. -// Next.js pages expect ctx.req/ctx.res with Node-like shapes. -function createReqRes(request, url, query, body) { - const headersObj = {}; - for (const [k, v] of request.headers) headersObj[k.toLowerCase()] = v; - - const req = { - method: request.method, - url: url, - headers: headersObj, - query: query, - body: body, - cookies: parseCookies(request.headers.get("cookie")), - }; - - let resStatusCode = 200; - const resHeaders = {}; - // set-cookie needs array support (multiple Set-Cookie headers are common) - const setCookieHeaders = []; - let resBody = null; - let ended = false; - let resolveResponse; - const responsePromise = new Promise(function(r) { resolveResponse = r; }); - - const res = { - get statusCode() { return resStatusCode; }, - set statusCode(code) { resStatusCode = code; }, - writeHead: function(code, headers) { - resStatusCode = code; - if (headers) { - for (const [k, v] of Object.entries(headers)) { - if (k.toLowerCase() === "set-cookie") { - if (Array.isArray(v)) { for (const c of v) setCookieHeaders.push(c); } - else { setCookieHeaders.push(v); } - } else { - resHeaders[k] = v; - } - } - } - return res; - }, - setHeader: function(name, value) { - if (name.toLowerCase() === "set-cookie") { - if (Array.isArray(value)) { for (const c of value) setCookieHeaders.push(c); } - else { setCookieHeaders.push(value); } - } else { - resHeaders[name.toLowerCase()] = value; - } - return res; - }, - getHeader: function(name) { - if (name.toLowerCase() === "set-cookie") return setCookieHeaders.length > 0 ? setCookieHeaders : undefined; - return resHeaders[name.toLowerCase()]; - }, - end: function(data) { - if (ended) return; - ended = true; - if (data !== undefined && data !== null) resBody = data; - const h = new Headers(resHeaders); - for (const c of setCookieHeaders) h.append("set-cookie", c); - resolveResponse(new Response(resBody, { status: resStatusCode, headers: h })); - }, - status: function(code) { resStatusCode = code; return res; }, - json: function(data) { - resHeaders["content-type"] = "application/json"; - res.end(JSON.stringify(data)); - }, - send: function(data) { - if (Buffer.isBuffer(data)) { - if (!resHeaders["content-type"]) resHeaders["content-type"] = "application/octet-stream"; - resHeaders["content-length"] = String(data.length); - res.end(data); - } else if (typeof data === "object" && data !== null) { - res.json(data); - } else { - if (!resHeaders["content-type"]) resHeaders["content-type"] = "text/plain"; - res.end(String(data)); - } - }, - redirect: function(statusOrUrl, url2) { - if (typeof statusOrUrl === "string") { res.writeHead(307, { Location: statusOrUrl }); } - else { res.writeHead(statusOrUrl, { Location: url2 }); } - res.end(); - }, - getHeaders: function() { - var h = Object.assign({}, resHeaders); - if (setCookieHeaders.length > 0) h["set-cookie"] = setCookieHeaders; - return h; - }, - get headersSent() { return ended; }, - }; - - return { req, res, responsePromise }; -} - -/** - * Read request body as text with a size limit. - * Throws if the body exceeds maxBytes. This prevents DoS via chunked - * transfer encoding where Content-Length is absent or spoofed. - */ -async function readBodyWithLimit(request, maxBytes) { - if (!request.body) return ""; - var reader = request.body.getReader(); - var decoder = new TextDecoder(); - var chunks = []; - var totalSize = 0; - for (;;) { - var result = await reader.read(); - if (result.done) break; - totalSize += result.value.byteLength; - if (totalSize > maxBytes) { - reader.cancel(); - throw new Error("Request body too large"); - } - chunks.push(decoder.decode(result.value, { stream: true })); - } - chunks.push(decoder.decode()); - return chunks.join(""); -} - -export async function renderPage(request, url, manifest, ctx) { - if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest)); - return _renderPage(request, url, manifest); -} - -async function _renderPage(request, url, manifest) { - const localeInfo = i18nConfig - ? resolvePagesI18nRequest( - url, - i18nConfig, - request.headers, - new URL(request.url).hostname, - vinextConfig.basePath, - vinextConfig.trailingSlash, - ) - : { locale: undefined, url, hadPrefix: false, domainLocale: undefined, redirectUrl: undefined }; - const locale = localeInfo.locale; - const routeUrl = localeInfo.url; - const currentDefaultLocale = i18nConfig - ? (localeInfo.domainLocale ? localeInfo.domainLocale.defaultLocale : i18nConfig.defaultLocale) - : undefined; - const domainLocales = i18nConfig ? i18nConfig.domains : undefined; - - if (localeInfo.redirectUrl) { - return new Response(null, { status: 307, headers: { Location: localeInfo.redirectUrl } }); - } - - const match = matchRoute(routeUrl, pageRoutes); - if (!match) { - return new Response("

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); - } - - const { route, params } = match; - const __uCtx = _createUnifiedCtx({ - executionContext: _getRequestExecutionContext(), - }); - 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: currentDefaultLocale, - domainLocales: domainLocales, - }); - } - - if (i18nConfig) { - setI18nContext({ - locale: locale, - locales: i18nConfig.locales, - defaultLocale: currentDefaultLocale, - domainLocales: domainLocales, - hostname: new URL(request.url).hostname, - }); - } - - const pageModule = route.module; - const PageComponent = pageModule.default; - 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: currentDefaultLocale || "", - }); - 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: currentDefaultLocale, - }; - 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", - }; - 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() { - var revalCtx = _createUnifiedCtx({ - executionContext: _getRequestExecutionContext(), - }); - return _runWithUnifiedCtx(revalCtx, async () => { - ensureFetchPatch(); - var freshResult = await pageModule.getStaticProps({ - params: params, - locale: locale, - locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: currentDefaultLocale, - }); - if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) { - var _fp = freshResult.props; - if (typeof setSSRContext === "function") { - setSSRContext({ - pathname: patternToNextFormat(route.pattern), - query: { ...params, ...parseQuery(routeUrl) }, - asPath: routeUrl, - locale: locale, - locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: currentDefaultLocale, - domainLocales: domainLocales, - }); - } - if (i18nConfig) { - setI18nContext({ - locale: locale, - locales: i18nConfig.locales, - defaultLocale: currentDefaultLocale, - domainLocales: domainLocales, - hostname: new URL(request.url).hostname, - }); - } - // Re-render the page with fresh props inside fresh render sub-scopes - // so head/cache state cannot leak across passes. - var _el = AppComponent - ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp }) - : React.createElement(PageComponent, _fp); - _el = wrapWithRouterContext(_el); - var _freshBody = await renderIsrPassToStringAsync(_el); - // Rebuild __NEXT_DATA__ with fresh props - var _regenPayload = { - props: { pageProps: _fp }, page: patternToNextFormat(route.pattern), - query: params, buildId: buildId, isFallback: false, - }; - if (i18nConfig) { - _regenPayload.locale = locale; - _regenPayload.locales = i18nConfig.locales; - _regenPayload.defaultLocale = currentDefaultLocale; - _regenPayload.domainLocales = domainLocales; - } - var _lGlobals = i18nConfig - ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + - ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + - ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(currentDefaultLocale) - : ""; - var _freshNDS = ""; - // Reconstruct ISR HTML preserving the document shell from the - // cached entry (head, fonts, assets, custom _document markup). - var _cachedStr = cached.value.value.html; - var _btag = '
'; - var _bstart = _cachedStr.indexOf(_btag); - var _bodyStart = _bstart >= 0 ? _bstart + _btag.length : -1; - // Locate __NEXT_DATA__ script to split body from suffix - var _ndMarker = ' - var _ndEnd = _cachedStr.indexOf('', _ndStart) + 9; - var _tail = _cachedStr.slice(_ndEnd); - _freshHtml = _cachedStr.slice(0, _bodyStart) + _freshBody + '
' + _gap + _freshNDS + _tail; - } else { - _freshHtml = '\\n\\n\\n\\n\\n
' + _freshBody + '
\\n ' + _freshNDS + '\\n\\n'; - } - await isrSet(cacheKey, { kind: "PAGES", html: _freshHtml, pageData: _fp, 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: currentDefaultLocale, - }; - 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); - - if (typeof resetSSRHead === "function") resetSSRHead(); - if (typeof flushPreloads === "function") await flushPreloads(); - - 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 = currentDefaultLocale; - nextDataPayload.domainLocales = domainLocales; - } - const localeGlobals = i18nConfig - ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + - ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + - ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(currentDefaultLocale) - : ""; - 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(); - } - 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 renderIsrPassToStringAsync(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)); - } - } - // 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); - } - return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); - } catch (e) { - console.error("[vinext] SSR error:", e); - _reportRequestError( - e instanceof Error ? e : new Error(String(e)), - { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, - { routerKind: "Pages Router", routePath: route.pattern, routeType: "render" }, - ).catch(() => { /* ignore reporting errors */ }); - return new Response("Internal Server Error", { status: 500 }); - } - }); -} - -export async function handleApiRoute(request, url) { - const match = matchRoute(url, apiRoutes); - if (!match) { - return new Response("404 - API route not found", { status: 404 }); - } - - const { route, params } = match; - const handler = route.module.default; - if (typeof handler !== "function") { - return new Response("API route does not export a default function", { status: 500 }); - } - - const query = { ...params }; - const qs = url.split("?")[1]; - if (qs) { - for (const [k, v] of new URLSearchParams(qs)) { - if (k in query) { - // Multi-value: promote to array (Next.js returns string[] for duplicate keys) - query[k] = Array.isArray(query[k]) ? query[k].concat(v) : [query[k], v]; - } else { - query[k] = v; - } - } - } - - // Parse request body (enforce 1MB limit to prevent memory exhaustion, - // matching Next.js default bodyParser sizeLimit). - // Check Content-Length first as a fast path, then enforce on the actual - // stream to prevent bypasses via chunked transfer encoding. - const contentLength = parseInt(request.headers.get("content-length") || "0", 10); - if (contentLength > 1 * 1024 * 1024) { - return new Response("Request body too large", { status: 413 }); - } - try { - let body; - const mediaType = getMediaType(request.headers.get("content-type")); - let rawBody; - try { rawBody = await readBodyWithLimit(request, 1 * 1024 * 1024); } - catch { return new Response("Request body too large", { status: 413 }); } - if (!rawBody) { - body = isJsonMediaType(mediaType) - ? {} - : mediaType === "application/x-www-form-urlencoded" - ? decodeQueryString(rawBody) - : undefined; - } else if (isJsonMediaType(mediaType)) { - try { body = JSON.parse(rawBody); } - catch { throw new ApiBodyParseError("Invalid JSON", 400); } - } else if (mediaType === "application/x-www-form-urlencoded") { - body = decodeQueryString(rawBody); - } else { - body = rawBody; - } - - const { req, res, responsePromise } = createReqRes(request, url, query, body); - await handler(req, res); - // If handler didn't call res.end(), end it now. - // The end() method is idempotent — safe to call twice. - res.end(); - return await responsePromise; - } catch (e) { - if (e instanceof ApiBodyParseError) { - return new Response(e.message, { status: e.statusCode, statusText: e.message }); - } - console.error("[vinext] API error:", e); - _reportRequestError( - e instanceof Error ? e : new Error(String(e)), - { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, - { routerKind: "Pages Router", routePath: route.pattern, routeType: "route" }, - ); - return new Response("Internal Server Error", { status: 500 }); - } -} - - -// --- Middleware support (generated from middleware-codegen.ts) --- - -function __normalizePath(pathname) { - if ( - pathname === "/" || - (pathname.length > 1 && - pathname[0] === "/" && - !pathname.includes("//") && - !pathname.includes("/./") && - !pathname.includes("/../") && - !pathname.endsWith("/.") && - !pathname.endsWith("/..")) - ) { - return pathname; - } - var segments = pathname.split("/"); - var resolved = []; - for (var i = 0; i < segments.length; i++) { - var seg = segments[i]; - if (seg === "" || seg === ".") continue; - if (seg === "..") { resolved.pop(); } - else { resolved.push(seg); } - } - return "/" + resolved.join("/"); -} - -var __pathDelimiterRegex = /([/#?\\\\]|%(2f|23|3f|5c))/gi; -function __decodeRouteSegment(segment) { - return decodeURIComponent(segment).replace(__pathDelimiterRegex, function (char) { - return encodeURIComponent(char); - }); -} -function __decodeRouteSegmentSafe(segment) { - try { return __decodeRouteSegment(segment); } catch (e) { return segment; } -} -function __normalizePathnameForRouteMatch(pathname) { - var segments = pathname.split("/"); - var normalized = []; - for (var i = 0; i < segments.length; i++) { - normalized.push(__decodeRouteSegmentSafe(segments[i])); - } - return normalized.join("/"); -} -function __normalizePathnameForRouteMatchStrict(pathname) { - var segments = pathname.split("/"); - var normalized = []; - for (var i = 0; i < segments.length; i++) { - normalized.push(__decodeRouteSegment(segments[i])); - } - return normalized.join("/"); -} - -function __isSafeRegex(pattern) { - var quantifierAtDepth = []; - var depth = 0; - var i = 0; - while (i < pattern.length) { - var ch = pattern[i]; - if (ch === "\\\\") { i += 2; continue; } - if (ch === "[") { - i++; - while (i < pattern.length && pattern[i] !== "]") { - if (pattern[i] === "\\\\") i++; - i++; - } - i++; - continue; - } - if (ch === "(") { - depth++; - if (quantifierAtDepth.length <= depth) quantifierAtDepth.push(false); - else quantifierAtDepth[depth] = false; - i++; - continue; - } - if (ch === ")") { - var hadQ = depth > 0 && quantifierAtDepth[depth]; - if (depth > 0) depth--; - var next = pattern[i + 1]; - if (next === "+" || next === "*" || next === "{") { - if (hadQ) return false; - if (depth >= 0 && depth < quantifierAtDepth.length) quantifierAtDepth[depth] = true; - } - i++; - continue; - } - if (ch === "+" || ch === "*") { - if (depth > 0) quantifierAtDepth[depth] = true; - i++; - continue; - } - if (ch === "?") { - var prev = i > 0 ? pattern[i - 1] : ""; - if (prev !== "+" && prev !== "*" && prev !== "?" && prev !== "}") { - if (depth > 0) quantifierAtDepth[depth] = true; - } - i++; - continue; - } - if (ch === "{") { - var j = i + 1; - while (j < pattern.length && /[\\d,]/.test(pattern[j])) j++; - if (j < pattern.length && pattern[j] === "}" && j > i + 1) { - if (depth > 0) quantifierAtDepth[depth] = true; - i = j + 1; - continue; - } - } - i++; - } - return true; -} -function __safeRegExp(pattern, flags) { - if (!__isSafeRegex(pattern)) { - console.warn("[vinext] Ignoring potentially unsafe regex pattern (ReDoS risk): " + pattern); - return null; - } - try { return new RegExp(pattern, flags); } catch { return null; } -} - -var __mwPatternCache = new Map(); -function __extractConstraint(str, re) { - if (str[re.lastIndex] !== "(") return null; - var start = re.lastIndex + 1; - var depth = 1; - var i = start; - while (i < str.length && depth > 0) { - if (str[i] === "(") depth++; - else if (str[i] === ")") depth--; - i++; - } - if (depth !== 0) return null; - re.lastIndex = i; - return str.slice(start, i - 1); -} -function __compileMwPattern(pattern) { - var hasConstraints = /:[\\w-]+[*+]?\\(/.test(pattern); - if (!hasConstraints && (pattern.includes("(") || pattern.includes("\\\\"))) { - return __safeRegExp("^" + pattern + "$"); - } - var regexStr = ""; - var tokenRe = /\\/:([\\w-]+)\\*|\\/:([\\w-]+)\\+|:([\\w-]+)|[.]|[^/:.]+|./g; - var tok; - while ((tok = tokenRe.exec(pattern)) !== null) { - if (tok[1] !== undefined) { - var c1 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - regexStr += c1 !== null ? "(?:/(" + c1 + "))?" : "(?:/.*)?"; - } - else if (tok[2] !== undefined) { - var c2 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - regexStr += c2 !== null ? "(?:/(" + c2 + "))" : "(?:/.+)"; - } - else if (tok[3] !== undefined) { - var constraint = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - var isOptional = pattern[tokenRe.lastIndex] === "?"; - if (isOptional) tokenRe.lastIndex += 1; - var group = constraint !== null ? "(" + constraint + ")" : "([^/]+)"; - if (isOptional && regexStr.endsWith("/")) { - regexStr = regexStr.slice(0, -1) + "(?:/" + group + ")?"; - } else if (isOptional) { - regexStr += group + "?"; - } else { - regexStr += group; - } - } - else if (tok[0] === ".") { regexStr += "\\\\."; } - else { regexStr += tok[0]; } - } - return __safeRegExp("^" + regexStr + "$"); -} -function matchMiddlewarePattern(pathname, pattern) { - var cached = __mwPatternCache.get(pattern); - if (cached === undefined) { - cached = __compileMwPattern(pattern); - __mwPatternCache.set(pattern, cached); - } - return cached ? cached.test(pathname) : pathname === pattern; -} - -var __middlewareConditionRegexCache = new Map(); -// Requestless matcher checks reuse this singleton. Treat it as immutable. -var __emptyMiddlewareRequestContext = { - headers: new Headers(), - cookies: {}, - query: new URLSearchParams(), - host: "", -}; - -function __normalizeMiddlewareHost(hostHeader, fallbackHostname) { - var host = hostHeader ?? fallbackHostname; - return host.split(":", 1)[0].toLowerCase(); -} - -function __parseMiddlewareCookies(cookieHeader) { - if (!cookieHeader) return {}; - var cookies = {}; - for (var part of cookieHeader.split(";")) { - var eq = part.indexOf("="); - if (eq === -1) continue; - var key = part.slice(0, eq).trim(); - var value = part.slice(eq + 1).trim(); - if (key) cookies[key] = value; - } - return cookies; -} - -function __middlewareRequestContextFromRequest(request) { - if (!request) return __emptyMiddlewareRequestContext; - var url = new URL(request.url); - return { - headers: request.headers, - cookies: __parseMiddlewareCookies(request.headers.get("cookie")), - query: url.searchParams, - host: __normalizeMiddlewareHost(request.headers.get("host"), url.hostname), - }; -} - -function __stripMiddlewareLocalePrefix(pathname, i18nConfig) { - if (pathname === "/") return null; - var segments = pathname.split("/"); - var firstSegment = segments[1]; - if (!firstSegment || !i18nConfig || !i18nConfig.locales.includes(firstSegment)) { - return null; - } - var stripped = "/" + segments.slice(2).join("/"); - return stripped === "/" ? "/" : stripped.replace(/\\/+$/, "") || "/"; -} - -function __matchMiddlewareMatcherPattern(pathname, pattern, i18nConfig) { - if (!i18nConfig) return matchMiddlewarePattern(pathname, pattern); - var localeStrippedPathname = __stripMiddlewareLocalePrefix(pathname, i18nConfig); - return matchMiddlewarePattern(localeStrippedPathname ?? pathname, pattern); -} - -function __middlewareConditionRegex(value) { - if (__middlewareConditionRegexCache.has(value)) { - return __middlewareConditionRegexCache.get(value); - } - var re = __safeRegExp(value); - __middlewareConditionRegexCache.set(value, re); - return re; -} - -function __checkMiddlewareCondition(condition, ctx) { - switch (condition.type) { - case "header": { - var headerValue = ctx.headers.get(condition.key); - if (headerValue === null) return false; - if (condition.value !== undefined) { - var re = __middlewareConditionRegex(condition.value); - if (re) return re.test(headerValue); - return headerValue === condition.value; - } - return true; - } - case "cookie": { - var cookieValue = ctx.cookies[condition.key]; - if (cookieValue === undefined) return false; - if (condition.value !== undefined) { - var re = __middlewareConditionRegex(condition.value); - if (re) return re.test(cookieValue); - return cookieValue === condition.value; - } - return true; - } - case "query": { - var queryValue = ctx.query.get(condition.key); - if (queryValue === null) return false; - if (condition.value !== undefined) { - var re = __middlewareConditionRegex(condition.value); - if (re) return re.test(queryValue); - return queryValue === condition.value; - } - return true; - } - case "host": { - if (condition.value !== undefined) { - var re = __middlewareConditionRegex(condition.value); - if (re) return re.test(ctx.host); - return ctx.host === condition.value; - } - return ctx.host === condition.key; - } - default: - return false; - } -} - -function __checkMiddlewareHasConditions(has, missing, ctx) { - if (has) { - for (var condition of has) { - if (!__checkMiddlewareCondition(condition, ctx)) return false; - } - } - if (missing) { - for (var condition of missing) { - if (__checkMiddlewareCondition(condition, ctx)) return false; - } - } - return true; -} - -// Keep this in sync with isValidMiddlewareMatcherObject in middleware.ts. -function __isValidMiddlewareMatcherObject(matcher) { - if (!matcher || typeof matcher !== "object" || Array.isArray(matcher)) return false; - if (typeof matcher.source !== "string") return false; - for (var key of Object.keys(matcher)) { - if (key !== "source" && key !== "locale" && key !== "has" && key !== "missing") { - return false; - } - } - if ("locale" in matcher && matcher.locale !== undefined && matcher.locale !== false) return false; - if ("has" in matcher && matcher.has !== undefined && !Array.isArray(matcher.has)) return false; - if ("missing" in matcher && matcher.missing !== undefined && !Array.isArray(matcher.missing)) { - return false; - } - return true; -} - -function __matchMiddlewareObject(pathname, matcher, i18nConfig) { - return matcher.locale === false - ? matchMiddlewarePattern(pathname, matcher.source) - : __matchMiddlewareMatcherPattern(pathname, matcher.source, i18nConfig); -} - -function matchesMiddleware(pathname, matcher, request, i18nConfig) { - if (!matcher) { - return true; - } - if (typeof matcher === "string") { - return __matchMiddlewareMatcherPattern(pathname, matcher, i18nConfig); - } - if (!Array.isArray(matcher)) { - return false; - } - var requestContext = __middlewareRequestContextFromRequest(request); - for (var m of matcher) { - if (typeof m === "string") { - if (__matchMiddlewareMatcherPattern(pathname, m, i18nConfig)) return true; - continue; - } - if (__isValidMiddlewareMatcherObject(m)) { - if (!__matchMiddlewareObject(pathname, m, i18nConfig)) continue; - if (!__checkMiddlewareHasConditions(m.has, m.missing, requestContext)) continue; - return true; - } - } - return false; -} - -export async function runMiddleware(request, ctx) { - if (ctx) return _runWithExecutionContext(ctx, () => _runMiddleware(request)); - return _runMiddleware(request); -} - -async function _runMiddleware(request) { - var isProxy = false; - var middlewareFn = isProxy - ? (middlewareModule.proxy ?? middlewareModule.default) - : (middlewareModule.middleware ?? middlewareModule.default); - if (typeof middlewareFn !== "function") { - var fileType = isProxy ? "Proxy" : "Middleware"; - var expectedExport = isProxy ? "proxy" : "middleware"; - throw new Error("The " + fileType + " file must export a function named \`" + expectedExport + "\` or a \`default\` function."); - } - - var config = middlewareModule.config; - var matcher = config && config.matcher; - var url = new URL(request.url); - - // Normalize pathname before matching to prevent path-confusion bypasses - // (percent-encoding like /%61dmin, double slashes like /dashboard//settings). - var decodedPathname; - try { decodedPathname = __normalizePathnameForRouteMatchStrict(url.pathname); } catch (e) { - return { continue: false, response: new Response("Bad Request", { status: 400 }) }; - } - var normalizedPathname = __normalizePath(decodedPathname); - - if (!matchesMiddleware(normalizedPathname, matcher, request, i18nConfig)) return { continue: true }; - - // Construct a new Request with the decoded + normalized pathname so middleware - // always sees the same canonical path that the router uses. - var mwRequest = request; - if (normalizedPathname !== url.pathname) { - var mwUrl = new URL(url); - mwUrl.pathname = normalizedPathname; - mwRequest = new Request(mwUrl, request); - } - var __mwNextConfig = (vinextConfig.basePath || i18nConfig) ? { basePath: vinextConfig.basePath, i18n: i18nConfig || undefined } : undefined; - var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest, __mwNextConfig ? { nextConfig: __mwNextConfig } : undefined); - var fetchEvent = new NextFetchEvent({ page: normalizedPathname }); - var response; - try { response = await middlewareFn(nextRequest, fetchEvent); } - catch (e) { - console.error("[vinext] Middleware error:", e); - return { continue: false, response: new Response("Internal Server Error", { status: 500 }) }; - } - var _mwCtx = _getRequestExecutionContext(); - if (_mwCtx && typeof _mwCtx.waitUntil === "function") { _mwCtx.waitUntil(fetchEvent.drainWaitUntil()); } else { fetchEvent.drainWaitUntil(); } - - if (!response) return { continue: true }; - - if (response.headers.get("x-middleware-next") === "1") { - var rHeaders = new Headers(); - for (var [key, value] of response.headers) { - // Keep x-middleware-request-* headers so the production server can - // apply middleware-request header overrides before stripping internals - // from the final client response. - if ( - !key.startsWith("x-middleware-") || - key === "x-middleware-override-headers" || - key.startsWith("x-middleware-request-") - ) rHeaders.append(key, value); - } - return { continue: true, responseHeaders: rHeaders }; - } - - if (response.status >= 300 && response.status < 400) { - var location = response.headers.get("Location") || response.headers.get("location"); - if (location) { - var rdHeaders = new Headers(); - for (var [rk, rv] of response.headers) { - if (!rk.startsWith("x-middleware-") && rk.toLowerCase() !== "location") rdHeaders.append(rk, rv); - } - return { continue: false, redirectUrl: location, redirectStatus: response.status, responseHeaders: rdHeaders }; - } - } - - var rewriteUrl = response.headers.get("x-middleware-rewrite"); - if (rewriteUrl) { - var rwHeaders = new Headers(); - for (var [k, v] of response.headers) { - if (!k.startsWith("x-middleware-") || k === "x-middleware-override-headers" || k.startsWith("x-middleware-request-")) rwHeaders.append(k, v); - } - var rewritePath; - try { var parsed = new URL(rewriteUrl, request.url); rewritePath = parsed.pathname + parsed.search; } - catch { rewritePath = rewriteUrl; } - return { continue: true, rewriteUrl: rewritePath, rewriteStatus: response.status !== 200 ? response.status : undefined, responseHeaders: rwHeaders }; - } - - return { continue: false, response: response }; -} - -" -`; diff --git a/tests/e2e/app-router/server-actions.spec.ts b/tests/e2e/app-router/server-actions.spec.ts index 90616ca1e..335aa61dd 100644 --- a/tests/e2e/app-router/server-actions.spec.ts +++ b/tests/e2e/app-router/server-actions.spec.ts @@ -170,4 +170,23 @@ test.describe("useActionState", () => { await expect(page.locator("#count")).toHaveText("Count: -1", { timeout: 3_000 }); }).toPass({ timeout: 15_000 }); }); + + test("useActionState: redirect does not cause undefined state (issue #589)", async ({ page }) => { + await page.goto(`${BASE}/action-state-redirect`); + await expect(page.locator("h1")).toHaveText("useActionState Redirect Test"); + await waitForHydration(page); + + // Initial state should be { success: false } + await expect(async () => { + const stateText = await page.locator("#state").textContent(); + expect(stateText).toContain('"success":false'); + }).toPass({ timeout: 5_000 }); + + // Click the redirect button — should navigate without state becoming undefined + await page.click("#redirect-btn"); + + // Should navigate to /action-state-test without crashing + await expect(page).toHaveURL(/\/action-state-test$/); + await expect(page.locator("h1")).toHaveText("useActionState Test"); + }); }); diff --git a/tests/fixtures/app-basic/app/action-state-redirect/page.tsx b/tests/fixtures/app-basic/app/action-state-redirect/page.tsx new file mode 100644 index 000000000..2d00492a0 --- /dev/null +++ b/tests/fixtures/app-basic/app/action-state-redirect/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useActionState } from "react"; +import { redirectWithActionState } from "../actions/actions"; + +const initialState = { success: false, error: undefined as string | undefined }; + +export default function ActionStateRedirectTest() { + const [state, formAction] = useActionState(redirectWithActionState, initialState); + + return ( +
+

useActionState Redirect Test

+
{JSON.stringify(state)}
+
+ +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/actions/actions.ts b/tests/fixtures/app-basic/app/actions/actions.ts index 8243a08fd..6ec68df24 100644 --- a/tests/fixtures/app-basic/app/actions/actions.ts +++ b/tests/fixtures/app-basic/app/actions/actions.ts @@ -65,3 +65,18 @@ export async function counterAction( } return prevState; } + +/** + * Server action for useActionState that calls redirect() on success. + * This tests issue #589 — redirect() should not cause state to become undefined. + */ +export async function redirectWithActionState( + _prevState: { success: boolean; error?: string }, + formData: FormData, +): Promise<{ success: boolean; error?: string }> { + const shouldRedirect = formData.get("redirect") === "true"; + if (shouldRedirect) { + redirect("/action-state-test"); + } + return { success: true }; +}