import React from "react"; import { renderSegments } from "../segment-system.js"; import { filterSegmentOrder } from "../browser/react/filter-segment-order.js"; import { ThemeProvider } from "../theme/ThemeProvider.js"; import { NonceContext } from "../browser/react/nonce-context.js"; import { NavigationStoreContext } from "../browser/react/context.js"; import type { NavigationStoreContextValue } from "../browser/react/context.js"; import type { HandleData } from "../browser/types.js"; import type { ErrorPhase } from "../types.js"; import type { ResolvedSegment } from "../types.js"; import type { ResolvedThemeConfig, Theme } from "../theme/types.js"; import type { EventController, DerivedNavigationState, } from "../browser/event-controller.js"; /** * Options for injectRSCPayload */ export interface InjectRSCPayloadOptions { /** * Nonce for Content Security Policy (CSP) */ nonce?: string; } /** * Options for renderToReadableStream from react-dom/server */ interface RenderToReadableStreamOptions { bootstrapScriptContent?: string; nonce?: string; formState?: unknown; } /** * ReadableStream with the allReady promise added by react-dom/server.edge. */ interface ReactDOMReadableStream extends ReadableStream { allReady: Promise; } /** * Options for the renderHTML function */ export interface SSRRenderOptions { /** * Form state for useActionState progressive enhancement. * This is the result of decodeFormState() and should be passed to * react-dom's renderToReadableStream to enable useActionState to * receive the action result during SSR. */ formState?: unknown; /** * Nonce for Content Security Policy (CSP) */ nonce?: string; /** * SSR stream mode. * * - `"stream"` (default) — start flushing HTML immediately. * - `"allReady"` — await `stream.allReady` before returning. */ streamMode?: import("../router/router-options.js").SSRStreamMode; } /** * SSR dependencies from external packages */ export interface SSRDependencies { /** * createFromReadableStream from @vitejs/plugin-rsc/ssr */ createFromReadableStream: ( stream: ReadableStream, ) => Promise; /** * renderToReadableStream from react-dom/server.edge */ renderToReadableStream: ( element: React.ReactNode, options?: RenderToReadableStreamOptions, ) => Promise; /** * injectRSCPayload from rsc-html-stream/server */ injectRSCPayload: ( rscStream: ReadableStream, options?: InjectRSCPayloadOptions, ) => TransformStream; /** * Function to load bootstrap script content * Typically: () => import.meta.viteRsc.loadBootstrapScriptContent("index") */ loadBootstrapScriptContent: () => Promise; /** * Optional callback invoked when an error occurs during SSR rendering. * * This callback is for notification/logging purposes. * * @example * ```typescript * export const renderHTML = createSSRHandler({ * // ... other deps * onError: (error, context) => { * console.error('[SSR] Rendering error:', error); * Sentry.captureException(error); * }, * }); * ``` */ onError?: (error: Error, context: { phase: ErrorPhase }) => void; } /** * RSC payload type (minimal interface for SSR) */ interface RscPayload { metadata?: { segments?: ResolvedSegment[]; rootLayout?: React.ComponentType<{ children: React.ReactNode }>; handles?: AsyncGenerator; matched?: string[]; pathname?: string; params?: Record; basename?: string; themeConfig?: ResolvedThemeConfig | null; initialTheme?: Theme; version?: string; }; } /** * Consume an async generator and return a Promise that resolves with the final value. * Used for SSR where we need to await all handle data before rendering. */ async function consumeAsyncGenerator( generator: AsyncGenerator, ): Promise { let lastData: HandleData = {}; for await (const data of generator) { lastData = data; } return lastData; } /** * Create a minimal event controller for SSR. * This provides the correct pathname so useNavigation returns the right value during SSR. */ function createSsrEventController(opts: { pathname: string; params?: Record; handleData?: HandleData; matched?: string[]; }): EventController { const location = new URL(opts.pathname, "http://localhost"); let params = opts.params ?? {}; const rawMatched = opts.matched ?? []; const handleState = { data: opts.handleData ?? {}, segmentOrder: filterSegmentOrder(rawMatched), routeSegmentIds: rawMatched.filter( (id) => !id.includes(".@") && !/D\d+\./.test(id), ), }; const state: DerivedNavigationState = { state: "idle", isStreaming: false, isNavigating: false, location, pendingUrl: null, inflightActions: [], }; return { getState: () => state, getLocation: () => location, subscribe: () => () => {}, getActionState: () => ({ state: "idle", actionId: null, payload: null, error: null, result: null, }), subscribeToAction: () => () => {}, subscribeToHandles: () => () => {}, setHandleData: () => {}, getHandleState: () => handleState, setParams: (nextParams) => { params = nextParams; }, getParams: () => params, setLocation: () => {}, startNavigation: () => { throw new Error("Navigation not supported during SSR"); }, abortNavigation: () => {}, startAction: () => { throw new Error("Actions not supported during SSR"); }, abortAllActions: () => {}, getCurrentNavigation: () => null, getInflightActions: () => new Map(), hadAnyConcurrentActions: () => false, }; } /** * Create an SSR handler that converts RSC streams to HTML. * * @example * ```tsx * import { createSSRHandler } from "rsc-router/ssr"; * import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; * import { renderToReadableStream } from "react-dom/server.edge"; * import { injectRSCPayload } from "rsc-html-stream/server"; * * export const renderHTML = createSSRHandler({ * createFromReadableStream, * renderToReadableStream, * injectRSCPayload, * loadBootstrapScriptContent: () => * import.meta.viteRsc.loadBootstrapScriptContent("index"), * }); * ``` */ export function createSSRHandler(deps: SSRDependencies) { const { createFromReadableStream, renderToReadableStream, injectRSCPayload, loadBootstrapScriptContent, onError, } = deps; /** * Render RSC stream to HTML stream * * @param rscStream - The RSC stream to render * @param options - Optional render options including formState for useActionState and nonce for CSP */ return async function renderHTML( rscStream: ReadableStream, options?: SSRRenderOptions, ): Promise> { const { nonce, formState, streamMode } = options ?? {}; try { // Tee the stream: // - rscStream1: For SSR rendering (deserialize to React VDOM) // - rscStream2: For browser hydration (inject as __FLIGHT_DATA__) const [rscStream1, rscStream2] = rscStream.tee(); // Deserialize RSC stream to React tree let payload: Promise | undefined; let handlesPromise: Promise | undefined; let ssrContextValue: NavigationStoreContextValue | undefined; function SsrRoot() { payload ??= createFromReadableStream(rscStream1); const resolved = React.use(payload); const themeConfig = resolved.metadata?.themeConfig ?? null; const pathname = resolved.metadata?.pathname ?? "/"; // Await handles before creating SSR event controller so hooks can // read request-local handle data via NavigationStoreContext. // The handles property is an async generator that yields on each push // Memoize the promise since async generators can only be iterated once let handleData: HandleData = {}; if (resolved.metadata?.handles) { handlesPromise ??= consumeAsyncGenerator(resolved.metadata.handles); handleData = React.use(handlesPromise); } // Create SSR context with request-local pathname/params/handles. ssrContextValue ??= { store: null as any, eventController: createSsrEventController({ pathname, params: resolved.metadata?.params, handleData, matched: resolved.metadata?.matched, }), navigate: async () => {}, refresh: async () => {}, version: resolved.metadata?.version, basename: resolved.metadata?.basename, }; // Build content tree from segments. // Order must match NavigationProvider: NavigationStoreContext > ThemeProvider > content const reconstructedRoot = renderSegments( resolved.metadata?.segments ?? [], { rootLayout: resolved.metadata?.rootLayout, }, ); let content: React.ReactNode = reconstructedRoot instanceof Promise ? React.use(reconstructedRoot) : reconstructedRoot; // Wrap content with ThemeProvider if theme is enabled if (themeConfig) { content = ( {content} ); } // Wrap with NonceContext so client components (e.g. MetaTags) can // apply CSP nonces to inline scripts during SSR. Always present to // match the browser-side NavigationProvider tree shape for hydration. content = ( {content} ); // Wrap with NavigationStoreContext for useNavigation hook return ( {content} ); } // Get bootstrap script content const bootstrapScriptContent = await loadBootstrapScriptContent(); // Render React tree to HTML stream // Pass formState for useActionState progressive enhancement if provided // Pass nonce for CSP if provided const htmlStream = await renderToReadableStream(, { bootstrapScriptContent, formState, nonce, }); // Wait for all Suspense boundaries to resolve when streamMode is "allReady". // This buffers the entire HTML before flushing — used for bots that // cannot process streamed HTML. if (streamMode === "allReady") { await htmlStream.allReady; } // Inject RSC payload into HTML as return htmlStream.pipeThrough(injectRSCPayload(rscStream2, { nonce })); } catch (error) { // Invoke onError callback if provided if (onError) { const errorObj = error instanceof Error ? error : new Error(String(error)); try { onError(errorObj, { phase: "rendering" }); } catch (callbackError) { console.error("[SSRHandler.onError] Callback error:", callbackError); } } throw error; } }; }