diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 26733c676..a8a5cc080 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -435,12 +435,15 @@ async function dispatchAppPageInner( styles: options.getFontStyles(), preloads: options.getFontPreloads(), }, - revalidatedRscCapture.sideStream - ? { - sideStream: revalidatedRscCapture.sideStream, - capturedRscDataRef: revalidatedCapturedRscRef, - } - : undefined, + { + basePath: options.basePath, + ...(revalidatedRscCapture.sideStream + ? { + sideStream: revalidatedRscCapture.sideStream, + capturedRscDataRef: revalidatedCapturedRscRef, + } + : {}), + }, ); const html = await readStreamAsText(revalidatedHtmlStream); const rscData = await getCapturedRscDataPromise(revalidatedCapturedRscRef.value); @@ -603,6 +606,7 @@ async function dispatchAppPageInner( } return renderAppPageLifecycle({ + basePath: options.basePath, cleanPathname: options.cleanPathname, clearRequestContext: options.clearRequestContext, consumeDynamicUsage, diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index b6857b07e..2976b1afc 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -3,7 +3,7 @@ import type { ClassificationReason } from "../build/layout-classification-types. import { createRscRedirectLocation } from "./app-rsc-cache-busting.js"; import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js"; import { parseNextHttpErrorDigest, parseNextRedirectDigest } from "./next-error-digest.js"; -import { hasBasePath } from "../utils/base-path.js"; +import { addBasePathToPathname } from "../utils/base-path.js"; export type { LayoutFlags }; export type { ClassificationReason }; @@ -175,10 +175,7 @@ function applyAppPageRedirectBasePath( if (!basePath || resolved.origin !== requestOrigin) { return resolved.toString(); } - if (hasBasePath(resolved.pathname, basePath)) { - return resolved.toString(); - } - resolved.pathname = resolved.pathname === "/" ? basePath : `${basePath}${resolved.pathname}`; + resolved.pathname = addBasePathToPathname(resolved.pathname, basePath); return resolved.toString(); } diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index dce545800..da5e839e7 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -68,6 +68,7 @@ type AppPageRequestCacheLife = { }; type RenderAppPageLifecycleOptions = { + basePath?: string; cleanPathname: string; clearRequestContext: () => void; consumeDynamicUsage: () => boolean; @@ -492,6 +493,7 @@ export async function renderAppPageLifecycle( capturedRscDataRef, fontData, navigationContext: options.getNavigationContext(), + basePath: options.basePath, formState: options.formState ?? null, rscStream: rscForResponse, scriptNonce: options.scriptNonce, diff --git a/packages/vinext/src/server/app-page-stream.ts b/packages/vinext/src/server/app-page-stream.ts index 9c3e705b7..0d1155f30 100644 --- a/packages/vinext/src/server/app-page-stream.ts +++ b/packages/vinext/src/server/app-page-stream.ts @@ -23,6 +23,7 @@ export type AppPageSsrHandler = { options?: { formState?: ReactFormState | null; scriptNonce?: string; + basePath?: string; sideStream?: ReadableStream; capturedRscDataRef?: { value: Promise | null }; /** When true, wait for the full React tree before emitting bytes. */ @@ -37,6 +38,7 @@ type RenderAppPageHtmlStreamOptions = { navigationContext: unknown; rscStream: ReadableStream; scriptNonce?: string; + basePath?: string; ssrHandler: AppPageSsrHandler; /** Pre-split side stream for fused embed+capture (#981). When set, * handleSsr skips its internal tee and accumulates raw RSC bytes. */ @@ -98,6 +100,7 @@ export async function renderAppPageHtmlStream( const ssrOptions = { formState: options.formState ?? null, scriptNonce: options.scriptNonce, + basePath: options.basePath, sideStream: options.sideStream, capturedRscDataRef: options.capturedRscDataRef, waitForAllReady: options.waitForAllReady, diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index 29f388c13..ef63b77e0 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -27,6 +27,7 @@ import { } from "./html.js"; import { createRscEmbedTransform, createTickBufferedTransform } from "./app-ssr-stream.js"; import { deferUntilStreamConsumed } from "./app-page-stream.js"; +import { createSsrErrorMetaRenderer } from "./app-ssr-error-meta.js"; import { AppElementsWire, type AppWireElements } from "./app-elements.js"; import { ElementsContext, Slot } from "vinext/shims/slot"; import { AppRouterContext } from "vinext/shims/internal/app-router-context"; @@ -169,6 +170,7 @@ export async function handleSsr( /** Out-parameter: filled with accumulated raw RSC bytes when sideStream is consumed. */ capturedRscDataRef?: { value: Promise | null }; formState?: ReactFormState | null; + basePath?: string; /** When true, wait for the full React tree (including Suspense boundaries) * to resolve before returning the HTML stream. Used for static prerender * and ISR cache writes to avoid caching fallback content. */ @@ -242,12 +244,17 @@ export async function handleSsr( const ssrRoot = withScriptNonce(ssrTree, options?.scriptNonce); const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent("index"); + const errorMetaRenderer = createSsrErrorMetaRenderer({ + basePath: options?.basePath, + }); const htmlStream = await renderToReadableStream(ssrRoot, { bootstrapScriptContent, formState: options?.formState ?? null, nonce: options?.scriptNonce, onError(error) { + errorMetaRenderer.capture(error); + if (error && typeof error === "object" && "digest" in error) { return String(error.digest); } @@ -274,14 +281,15 @@ export async function handleSsr( let didInjectHeadHTML = false; const getInsertedHTML = (): string => { const insertedHTML = renderInsertedHtml(renderServerInsertedHTML()); - if (didInjectHeadHTML) return insertedHTML; + const errorMetaHTML = errorMetaRenderer.flush(); + if (didInjectHeadHTML) return insertedHTML + errorMetaHTML; didInjectHeadHTML = true; return buildHeadInjectionHtml( navContext, bootstrapScriptContent, options?.formState ?? null, - insertedHTML, + insertedHTML + errorMetaHTML, fontHTML, options?.scriptNonce, ); diff --git a/packages/vinext/src/server/app-ssr-error-meta.ts b/packages/vinext/src/server/app-ssr-error-meta.ts new file mode 100644 index 000000000..8b32a04b0 --- /dev/null +++ b/packages/vinext/src/server/app-ssr-error-meta.ts @@ -0,0 +1,99 @@ +import { addBasePathToPathname } from "../utils/base-path.js"; +import { escapeHtmlAttr } from "./html.js"; +import { + getNextErrorDigest, + parseNextHttpErrorDigest, + parseNextRedirectDigest, +} from "./next-error-digest.js"; + +type SsrErrorMetaRenderOptions = { + basePath?: string; + nodeEnv?: string; +}; + +type SsrErrorMetaRenderer = { + capture: (error: unknown) => void; + flush: () => string; +}; + +const PERMANENT_REDIRECT_STATUS = 308; + +function prefixRedirectLocation(location: string, basePath?: string): string { + if (!basePath || !location.startsWith("/")) { + return location; + } + + const hashIndex = location.indexOf("#"); + const queryIndex = location.indexOf("?"); + const pathnameEnd = + queryIndex === -1 + ? hashIndex === -1 + ? location.length + : hashIndex + : hashIndex === -1 + ? queryIndex + : Math.min(queryIndex, hashIndex); + const pathname = location.slice(0, pathnameEnd); + + return addBasePathToPathname(pathname, basePath) + location.slice(pathnameEnd); +} + +function renderSsrErrorMetaTag(error: unknown, options: SsrErrorMetaRenderOptions): string { + const digest = getNextErrorDigest(error); + if (!digest) return ""; + + const httpError = parseNextHttpErrorDigest(digest); + if (httpError) { + let html = ''; + if ((options.nodeEnv ?? process.env.NODE_ENV) === "development") { + html += ''; + } + return html; + } + + const redirect = parseNextRedirectDigest(digest); + if (!redirect) return ""; + + const delay = redirect.status === PERMANENT_REDIRECT_STATUS ? 0 : 1; + const location = prefixRedirectLocation(redirect.url, options.basePath); + return ( + '' + ); +} + +export function renderSsrErrorMetaTags( + errors: readonly unknown[], + options: SsrErrorMetaRenderOptions = {}, +): string { + let html = ""; + + for (const error of errors) { + html += renderSsrErrorMetaTag(error, options); + } + + return html; +} + +export function createSsrErrorMetaRenderer( + options: SsrErrorMetaRenderOptions = {}, +): SsrErrorMetaRenderer { + const capturedErrors: unknown[] = []; + let flushedUntil = 0; + + return { + capture(error) { + capturedErrors.push(error); + }, + flush() { + if (flushedUntil >= capturedErrors.length) return ""; + + const html = renderSsrErrorMetaTags(capturedErrors.slice(flushedUntil), options); + flushedUntil = capturedErrors.length; + return html; + }, + }; +} diff --git a/packages/vinext/src/server/html.ts b/packages/vinext/src/server/html.ts index 8a7622501..dee5f3c8e 100644 --- a/packages/vinext/src/server/html.ts +++ b/packages/vinext/src/server/html.ts @@ -28,7 +28,11 @@ export function safeJsonStringify(data: unknown): string { } export function escapeHtmlAttr(value: string): string { - return value.replace(/&/g, "&").replace(/"/g, """); + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); } export function createNonceAttribute(nonce?: string): string { diff --git a/packages/vinext/src/utils/base-path.ts b/packages/vinext/src/utils/base-path.ts index 4faf6f78c..9beb06635 100644 --- a/packages/vinext/src/utils/base-path.ts +++ b/packages/vinext/src/utils/base-path.ts @@ -23,6 +23,16 @@ export function stripBasePath(pathname: string, basePath: string): string { return pathname.slice(basePath.length) || "/"; } +/** + * Add the configured basePath to a pathname unless it is already inside that + * basePath. Query strings and hashes must be handled by callers before calling + * this pathname-only helper. + */ +export function addBasePathToPathname(pathname: string, basePath: string | undefined): string { + if (!basePath || hasBasePath(pathname, basePath)) return pathname; + return pathname === "/" ? basePath : `${basePath}${pathname}`; +} + /** * Remove trailing slashes from a pathname while preserving the root "/". * Collapses any number of trailing slashes ("/a//" → "/a"). Used by the diff --git a/tests/app-page-execution.test.ts b/tests/app-page-execution.test.ts index c846946b7..1da01aff9 100644 --- a/tests/app-page-execution.test.ts +++ b/tests/app-page-execution.test.ts @@ -164,6 +164,38 @@ describe("app page execution helpers", () => { expect(alreadyPrefixed.headers.get("location")).toBe("https://example.com/blog/about"); + const alreadyPrefixedRootWithQuery = await buildAppPageSpecialErrorResponse({ + basePath: "/blog", + clearRequestContext, + isRscRequest: false, + request: new Request("https://example.com/blog/protected"), + specialError: { + kind: "redirect", + location: "/blog?from=checkout", + statusCode: 307, + }, + }); + + expect(alreadyPrefixedRootWithQuery.headers.get("location")).toBe( + "https://example.com/blog?from=checkout", + ); + + const alreadyPrefixedRootWithHash = await buildAppPageSpecialErrorResponse({ + basePath: "/blog", + clearRequestContext, + isRscRequest: false, + request: new Request("https://example.com/blog/protected"), + specialError: { + kind: "redirect", + location: "/blog#top", + statusCode: 307, + }, + }); + + expect(alreadyPrefixedRootWithHash.headers.get("location")).toBe( + "https://example.com/blog#top", + ); + // No basePath configured → behavior unchanged (resolves against the // request URL as before). const unconfigured = await buildAppPageSpecialErrorResponse({ diff --git a/tests/app-page-stream.test.ts b/tests/app-page-stream.test.ts index cda86f1e8..a86c521a7 100644 --- a/tests/app-page-stream.test.ts +++ b/tests/app-page-stream.test.ts @@ -114,6 +114,30 @@ describe("app page stream helpers", () => { ); }); + it("forwards basePath to the SSR handler", async () => { + const ssrHandler = vi.fn(async () => createStream(["base-path"])); + + const htmlStream = await renderAppPageHtmlStream({ + basePath: "/docs", + fontData: createAppPageFontData({ + getLinks: () => [], + getPreloads: () => [], + getStyles: () => [], + }), + navigationContext: null, + rscStream: createStream(["flight"]), + ssrHandler: { handleSsr: ssrHandler }, + }); + + await expect(new Response(htmlStream).text()).resolves.toBe("base-path"); + expect(ssrHandler).toHaveBeenCalledWith( + expect.anything(), + null, + expect.anything(), + expect.objectContaining({ basePath: "/docs" }), + ); + }); + it("defers clearRequestContext until the HTML stream body is fully consumed", async () => { // Regression test for issue #660: clearRequestContext() must not race the // lazy RSC/SSR stream pipeline. It should be called only after the HTTP diff --git a/tests/app-ssr-error-meta.test.ts b/tests/app-ssr-error-meta.test.ts new file mode 100644 index 000000000..fd71aa4a2 --- /dev/null +++ b/tests/app-ssr-error-meta.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + createSsrErrorMetaRenderer, + renderSsrErrorMetaTags, +} from "../packages/vinext/src/server/app-ssr-error-meta.js"; + +function digestError(digest: string): Error & { digest: string } { + return Object.assign(new Error(digest), { digest }); +} + +describe("App SSR error meta tags", () => { + it("renders noindex meta tags for streamed notFound and HTTP access fallback errors", () => { + expect(renderSsrErrorMetaTags([digestError("NEXT_NOT_FOUND")])).toBe( + '', + ); + + expect(renderSsrErrorMetaTags([digestError("NEXT_HTTP_ERROR_FALLBACK;403")])).toBe( + '', + ); + }); + + it("renders development next-error metadata for streamed notFound errors", () => { + expect( + renderSsrErrorMetaTags([digestError("NEXT_NOT_FOUND")], { nodeEnv: "development" }), + ).toBe( + '' + '', + ); + }); + + it("renders refresh meta tags for streamed temporary and permanent redirects", () => { + expect(renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/target;307")])).toBe( + '', + ); + + expect(renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/target;308")])).toBe( + '', + ); + }); + + it("prefixes app-internal redirect meta URLs with the configured basePath", () => { + expect( + renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/target?ok=1#done;307")], { + basePath: "/docs", + }), + ).toBe( + '', + ); + + expect( + renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;https%3A%2F%2Fexample.com;307")], { + basePath: "/docs", + }), + ).toBe( + '', + ); + + expect( + renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/docs%3Ffrom%3Dcheckout;307")], { + basePath: "/docs", + }), + ).toBe( + '', + ); + + expect( + renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/docs%23top;307")], { + basePath: "/docs", + }), + ).toBe(''); + }); + + it("escapes redirect meta URLs before inserting them into HTML", () => { + expect( + renderSsrErrorMetaTags([ + digestError("NEXT_REDIRECT;replace;/target%3Fnext%3D%26%22%3Cscript%3E;307"), + ]), + ).toBe( + '', + ); + }); + + it("flushes each captured SSR error meta tag once", () => { + const renderer = createSsrErrorMetaRenderer({ nodeEnv: "production" }); + + renderer.capture(digestError("NEXT_NOT_FOUND")); + expect(renderer.flush()).toBe(''); + expect(renderer.flush()).toBe(""); + + renderer.capture(digestError("NEXT_REDIRECT;replace;/target;307")); + expect(renderer.flush()).toBe( + '', + ); + expect(renderer.flush()).toBe(""); + }); +}); diff --git a/tests/rsc-streaming.test.ts b/tests/rsc-streaming.test.ts index 80429cbb9..a46a09a50 100644 --- a/tests/rsc-streaming.test.ts +++ b/tests/rsc-streaming.test.ts @@ -16,6 +16,7 @@ import { createRscEmbedTransform, createTickBufferedTransform, } from "../packages/vinext/src/server/app-ssr-stream.js"; +import { createSsrErrorMetaRenderer } from "../packages/vinext/src/server/app-ssr-error-meta.js"; /** * Create a ReadableStream from an array of string chunks, with optional @@ -417,6 +418,45 @@ describe("Tick-buffered RSC streaming (behavioral)", () => { expect(trailingStylePos).toBeLessThan(donePos); }); + it("emits SSR-captured redirect meta tags after the shell has already flushed", async () => { + // Ported from Next.js: test/e2e/app-dir/navigation/navigation.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts + const rsc = createMockRscStream(); + rsc.close(); + + const rscEmbed = createRscEmbedTransform(rsc.stream); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const errorMetaRenderer = createSsrErrorMetaRenderer(); + const encoder = new TextEncoder(); + const htmlStream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode("
shell
")); + await new Promise((resolve) => setTimeout(resolve, 20)); + errorMetaRenderer.capture( + Object.assign(new Error("NEXT_REDIRECT"), { + digest: "NEXT_REDIRECT;replace;/redirect/result;307", + }), + ); + controller.enqueue(encoder.encode("")); + controller.close(); + }, + }); + + const transform = createTickBufferedTransform(rscEmbed, () => errorMetaRenderer.flush()); + const output = await collectStream(htmlStream.pipeThrough(transform)); + + const shellPos = output.indexOf("
shell
"); + const redirectMetaPos = output.indexOf( + '', + ); + const boundaryPos = output.indexOf(""); + + expect(shellPos).toBeGreaterThan(-1); + expect(redirectMetaPos).toBeGreaterThan(shellPos); + expect(redirectMetaPos).toBeLessThan(boundaryPos); + }); + it("still injects head content even without in stream", async () => { const rsc = createMockRscStream(); rsc.close();