UNPKG

vite-plugin-react-server

Version:
655 lines (586 loc) 23.5 kB
/** * renderPage.server.ts * * PURPOSE: Server-side static page rendering for React Server Components * * ARCHITECTURE OVERVIEW: * * SERVER-SIDE vs CLIENT-SIDE: * - Server-side: RSC generation in main thread, HTML generation in worker * - Client-side: RSC generation in worker, HTML generation in main thread * * FLOW: * 1. Create headless RSC stream (for .rsc file) * 2. Create full RSC stream (for HTML generation) * 3. Create HTML transform stream that converts RSC to HTML * 4. Both streams are piped to file writers * * SIMPLIFIED APPROACH: * This implementation follows the same simple pattern as the client side, * avoiding complex backpressure handling and race conditions. */ import { createRenderMetrics } from "../metrics/createRenderMetrics.js"; import { routeToURL } from "../utils/routeToURL.js"; import type { RenderPageFn } from "./types.js"; import { handleError } from "../error/handleError.js"; import { assertReactServer } from "../config/getCondition.js"; import { renderRscStream } from "../stream/renderRscStream.server.js"; import { createMainThreadHandlers } from "../stream/createMainThreadHandlers.js"; import { createRscToHtmlStream } from "./rscToHtmlStream.server.js"; import { resolveComponent } from "../helpers/resolveComponent.js"; import { resolvePageAndProps } from "../helpers/resolvePageAndProps.js"; import { Root as DefaultRoot } from "../components/root.js"; import { Html as DefaultHtml } from "../components/html.js"; import { createStreamMetrics } from "../metrics/createStreamMetrics.js"; import { join } from "node:path"; import { createHeadlessStreamState, trackHeadlessStreamError, hasHeadlessStreamError } from "../helpers/headlessStreamState.js"; export const renderPage: RenderPageFn = async function* renderPage( handlerOptions ) { // Ensure we're in the correct environment assertReactServer(); // Create metrics upfront with proper types const baseDir = join( handlerOptions.build.outDir, handlerOptions.build.static ); const routePath = handlerOptions.route.replace(/^\//, ""); const htmlMetrics = createRenderMetrics({ route: handlerOptions.route, type: "html", fromMainThread: false, // Server: HTML rendered in worker fromRscWorker: false, fromHtmlWorker: true, baseDir, routePath, fileName: handlerOptions.build.htmlOutputPath, outputPath: join(baseDir, routePath, handlerOptions.build.htmlOutputPath), }); const rscFullMetrics = createRenderMetrics({ route: handlerOptions.route, type: "rsc-full", fromMainThread: true, // Server: RSC rendered on main thread fromRscWorker: false, fromHtmlWorker: false, }); const rscHeadlessMetrics = createRenderMetrics({ route: handlerOptions.route, type: "rsc-headless", fromMainThread: true, // Server: RSC rendered on main thread fromRscWorker: false, fromHtmlWorker: false, baseDir, routePath, fileName: handlerOptions.build.rscOutputPath, outputPath: join(baseDir, routePath, handlerOptions.build.rscOutputPath), }); // Declare variables outside try block let headlessRscHandler: any = null; let fullRscHandler: any = null; let htmlTransformStream: any = null; // Error tracking variables for headless stream let headlessStreamErrored = false; let headlessError: Error | null = null; // Error tracking variables for HTML stream let htmlStreamErrored = false; let htmlStreamError: Error | null = null; // Server-side stream reuse storage (similar to client-side headlessStreamElements) const headlessStreamState = createHeadlessStreamState(); try { if (handlerOptions.verbose) { handlerOptions.logger?.info( `[renderPage.server] Server-side rendering for route: ${handlerOptions.route}` ); } // Set URL if not provided if (!handlerOptions.url) { handlerOptions.url = routeToURL( handlerOptions.route, handlerOptions.moduleBaseURL, handlerOptions.build.rscOutputPath ); } // Resolve components and props using the proper helper let PageComponent: any = null; let RootComponent: any = null; let HtmlComponent: any = null; let pageProps: any = {}; // Initialize as empty object - props function will populate it // Use resolvePageAndProps helper to properly load page and props if (handlerOptions.pagePath) { try { const pageAndPropsResult = await resolvePageAndProps({ pagePath: handlerOptions.pagePath, pageExportName: handlerOptions.pageExportName, propsPath: handlerOptions.propsPath, propsExportName: handlerOptions.propsExportName, loader: handlerOptions.loader, verbose: handlerOptions.verbose, logger: handlerOptions.logger, route: handlerOptions.route, url: handlerOptions.url, moduleBaseURL: handlerOptions.moduleBaseURL, build: { rscOutputPath: handlerOptions.build.rscOutputPath, }, }); if (pageAndPropsResult.type === "success") { PageComponent = pageAndPropsResult.PageComponent; // Always use the props returned from the props function // Root components can handle empty props with their defaults pageProps = pageAndPropsResult.pageProps || {}; if (handlerOptions.verbose) { handlerOptions.logger?.info( `[renderPage.server] Successfully loaded page and props for route ${handlerOptions.route}: pageProps=${JSON.stringify(pageProps)}` ); } } else { handlerOptions.logger?.warn( `Failed to load page and props from ${handlerOptions.pagePath}: ${ pageAndPropsResult.error?.message || "Unknown error" }` ); } } catch (error) { handlerOptions.logger?.warn( `Error loading page and props from ${handlerOptions.pagePath}: ${ error instanceof Error ? error.message : String(error) }` ); } } // Load Root component if (handlerOptions.rootPath) { try { const rootResult = await resolveComponent({ componentPath: handlerOptions.rootPath, exportName: handlerOptions.rootExportName, loader: handlerOptions.loader, }); if (rootResult.type === "success") { RootComponent = rootResult.component; } else { handlerOptions.logger?.warn( `Failed to load Root component from ${handlerOptions.rootPath}: ${ rootResult.error?.message || "Unknown error" }` ); } } catch (error) { handlerOptions.logger?.warn( `Error loading Root component from ${handlerOptions.rootPath}: ${ error instanceof Error ? error.message : String(error) }` ); } } // Load Html component if (handlerOptions.htmlPath) { try { const htmlResult = await resolveComponent({ componentPath: handlerOptions.htmlPath, exportName: handlerOptions.htmlExportName, loader: handlerOptions.loader, }); if (htmlResult.type === "success") { HtmlComponent = htmlResult.component; } else { handlerOptions.logger?.warn( `Failed to load Html component from ${handlerOptions.htmlPath}: ${ htmlResult.error?.message || "Unknown error" }` ); } } catch (error) { handlerOptions.logger?.warn( `Error loading Html component from ${handlerOptions.htmlPath}: ${ error instanceof Error ? error.message : String(error) }` ); } } // Use defaults if components are still not loaded if (!RootComponent) { RootComponent = DefaultRoot as any; } if (!HtmlComponent) { HtmlComponent = DefaultHtml as any; } // Ensure we have all required components if (!PageComponent || !RootComponent || !HtmlComponent) { yield { type: "error", error: new Error( `Component resolution failed: missing required components (Page: ${!!PageComponent}, Root: ${!!RootComponent}, Html: ${!!HtmlComponent})` ), metrics: { rscFull: rscFullMetrics, rscHeadless: rscHeadlessMetrics, html: htmlMetrics, }, }; return; } // Create handler options with resolved components and props const uniqueId = handlerOptions.id ?? `${handlerOptions.route}?id=${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; const newHandlerOptions = { ...handlerOptions, id: uniqueId, url: `${handlerOptions.url}`, route: `${handlerOptions.route}`, PageComponent, RootComponent, HtmlComponent, pageProps, }; if (handlerOptions.verbose) { handlerOptions.logger?.info( `[renderPage.server] Created newHandlerOptions for route ${handlerOptions.route} with pageProps: ${JSON.stringify(pageProps)}` ); } // Create headless RSC handler (for .rsc file) - with proper error handling const headlessHandlers = createMainThreadHandlers( handlerOptions, (error, isPanic) => { if (handlerOptions.verbose) { handlerOptions.logger?.info( `[renderPage.server] Headless stream error handler called for route ${handlerOptions.route}: ${error.message}, isPanic: ${isPanic}` ); } // Track if the headless stream had errors headlessStreamErrored = true; headlessError = error instanceof Error ? error : new Error("Headless RSC stream failed"); // Track headless stream errors for conditional reuse logic (like RSC worker) trackHeadlessStreamError(headlessStreamState, handlerOptions.route, headlessError); if (handlerOptions.verbose) { handlerOptions.logger?.info( `[renderPage.server] Stored headless stream error for route ${handlerOptions.route} in headlessStreamErrors map` ); } // Store panic errors for later handling if (isPanic) { // For panic threshold "all_errors", the panic error will be handled by renderPages if (handlerOptions.verbose) { handlerOptions.logger?.info( `[renderPage.server] Panic error detected for route ${handlerOptions.route}, will be handled by renderPages` ); } } } ); // Override onData to track metrics headlessHandlers.onData = (_id, chunk) => { rscHeadlessMetrics.chunks++; rscHeadlessMetrics.streamMetrics.bytes += chunk.length; }; headlessRscHandler = renderRscStream( { ...newHandlerOptions, htmlPath: '', // Headless RSC - no HTML wrapper // If we expect errors, provide a safe Page component that doesn't throw PageComponent: newHandlerOptions.PageComponent, // Use original for now, will be overridden if errors occur }, headlessHandlers ); // Note: Panic errors will be yielded from the error handler when they occur // No need to check shouldYieldPanicError here as it's set asynchronously // Store PageComponent for reuse when headless stream completes (like RSC worker) headlessRscHandler.rscStream.on('end', () => { // Only store if this is a headless stream and no errors occurred (like RSC worker) if (!hasHeadlessStreamError(headlessStreamState, handlerOptions.route)) { headlessStreamState.elements.set(uniqueId, { PageComponent: newHandlerOptions.PageComponent, errored: false }); if (handlerOptions.verbose) { handlerOptions.logger?.info(`[renderPage.server] Stored PageComponent for headless stream ${uniqueId}`); } } else { if (handlerOptions.verbose) { handlerOptions.logger?.info(`[renderPage.server] Headless stream errored for route ${handlerOptions.route}, not storing PageComponent for reuse`); } } }); // Create full RSC handler (for HTML generation) - reuse headless stream elements if no errors // For server-side, we create both streams in parallel like the client-side let fullPanicError: Error | null = null; const fullHandlers = createMainThreadHandlers( handlerOptions, (error, isPanic) => { // If this is a panic error, store it to be handled later if (isPanic) { fullPanicError = error instanceof Error ? error : new Error("Full RSC stream failed"); } } ); // Override onData to track metrics fullHandlers.onData = (_id, chunk) => { rscFullMetrics.chunks++; rscFullMetrics.streamMetrics.bytes += chunk.length; }; // Create full RSC handler options - use React.Fragment if headless stream had errors (like RSC worker) // Check if there are any existing headless stream errors for this route const hasExistingHeadlessError = hasHeadlessStreamError(headlessStreamState, handlerOptions.route); const shouldUseFallback = headlessStreamErrored || hasExistingHeadlessError; if (handlerOptions.verbose) { handlerOptions.logger?.info( `[renderPage.server] Creating full RSC handler options for route ${handlerOptions.route}: headlessStreamErrored=${headlessStreamErrored}, hasExistingHeadlessError=${hasExistingHeadlessError}, shouldUseFallback=${shouldUseFallback}` ); } // Create a wrapper PageComponent that returns null if there are headless stream errors const SafePageComponent = (props: any) => { // Check if there are any headless stream errors for this route const hasError = hasHeadlessStreamError(headlessStreamState, handlerOptions.route); if (hasError) { return null; } return newHandlerOptions.PageComponent(props); }; const fullRscHandlerOptions = { ...newHandlerOptions, htmlPath: undefined, // Full RSC - include HTML wrapper headlessStreamElements: headlessStreamState.elements, // Pass the storage map for reuse // Use SafePageComponent that returns null when there are headless stream errors PageComponent: SafePageComponent, }; // Create a PageComponent that uses React.use() to consume the headless stream and check for errors // Store the headless stream elements for reuse (like RSC worker does) // Listen for the headless stream to complete and store its elements headlessRscHandler.rscStream.on('end', () => { if (!hasHeadlessStreamError(headlessStreamState, handlerOptions.route)) { if (handlerOptions.verbose) { handlerOptions.logger?.info( `[renderPage.server] Headless stream completed successfully for route ${handlerOptions.route}` ); } } }); if (handlerOptions.verbose) { handlerOptions.logger?.info( `[renderPage.server] Created PageComponent that uses React.use() to consume headless stream for route ${handlerOptions.route}` ); } fullRscHandler = renderRscStream(fullRscHandlerOptions, fullHandlers); // Check for panic error after creating the handler if (fullPanicError) { yield { type: "error", error: fullPanicError, metrics: { rscFull: rscFullMetrics, rscHeadless: rscHeadlessMetrics, html: htmlMetrics, }, }; return; } // Create HTML transform stream - need createRscToHtmlStream for async server actions htmlTransformStream = createRscToHtmlStream({ id: handlerOptions.id, worker: handlerOptions.worker, route: handlerOptions.route, url: handlerOptions.url, moduleRootPath: handlerOptions.moduleRootPath, moduleBasePath: handlerOptions.moduleBasePath, moduleBaseURL: handlerOptions.moduleBaseURL, projectRoot: handlerOptions.projectRoot, build: handlerOptions.build, panicThreshold: handlerOptions.panicThreshold, verbose: handlerOptions.verbose, signal: handlerOptions.signal, logger: handlerOptions.logger, htmlWorker: handlerOptions.htmlWorker, clientPipeableStreamOptions: handlerOptions.clientPipeableStreamOptions, onMetrics: handlerOptions.onMetrics, htmlTimeout: handlerOptions.htmlTimeout || 15000, rscStream: fullRscHandler.rscStream, onError: (error, isPanic) => { // Track HTML stream errors htmlStreamErrored = true; htmlStreamError = error; if (isPanic) { // This is a panic error, it should be yielded as an error result if (handlerOptions.verbose) { handlerOptions.logger?.error( `[renderPage.server] HTML stream panic error for route ${handlerOptions.route}: ${error.message}` ); } } else { // For non-panic errors, just log them if (handlerOptions.verbose) { handlerOptions.logger?.warn( `[renderPage.server] HTML stream error for route ${handlerOptions.route}: ${error.message}` ); } } }, }); // Create stream wrappers for file writing - simplified like client side const rscStreamWrapper = { pipe: <Writable extends NodeJS.WritableStream>(destination: Writable) => { const streamMetrics = createStreamMetrics(); streamMetrics.startTime = performance.now(); // Use the headless RSC stream directly for the .rsc file const rscFileStream = headlessRscHandler.rscStream; rscFileStream.on("data", (chunk: Buffer) => { streamMetrics.chunks++; streamMetrics.bytes += chunk.length; }); rscFileStream.on("end", () => { streamMetrics.duration = performance.now() - streamMetrics.startTime; streamMetrics.endTime = performance.now(); rscHeadlessMetrics.streamMetrics = streamMetrics; rscHeadlessMetrics.chunkRate = streamMetrics.chunks / (streamMetrics.duration / 1000); rscHeadlessMetrics.processingTime = streamMetrics.duration; rscHeadlessMetrics.memoryUsage = process.memoryUsage(); rscHeadlessMetrics.chunks = streamMetrics.chunks; }); rscFileStream.pipe(destination); return destination; }, abort: () => headlessRscHandler.abort(), }; const htmlStreamWrapper = { pipe: <Writable extends NodeJS.WritableStream>(destination: Writable) => { // Use the HTML transform stream's pipe method directly (same as client side) return htmlTransformStream.pipe(destination); }, abort: () => { htmlTransformStream.abort(); }, on: (event: string, listener: (...args: any[]) => void) => { // Forward error events from the HTML transform stream to the wrapper if (event === 'error') { // Access the actual stream from the transform result const htmlStream = (htmlTransformStream as any).htmlStream; if (htmlStream && typeof htmlStream.on === 'function') { htmlStream.on('error', listener); } } return htmlStreamWrapper; }, }; // Wait for HTML stream to complete or error before yielding success // This ensures that any errors from the HTML stream are caught before we yield success await new Promise<void>((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`HTML stream timeout for route ${handlerOptions.route}`)); }, handlerOptions.htmlTimeout || 15000); // Check if HTML stream already errored if (htmlStreamErrored) { clearTimeout(timeout); resolve(); // Let the panic threshold logic handle this at the renderPages level return; } // Set up a flag to track if we've resolved let resolved = false; // Create a wrapper that resolves the promise when the stream completes const originalPipe = htmlTransformStream.pipe; htmlTransformStream.pipe = function(destination: any) { const result = originalPipe.call(this, destination); // Listen for the destination stream to end destination.on('finish', () => { if (!resolved) { resolved = true; clearTimeout(timeout); resolve(); } }); destination.on('error', (error: Error) => { if (!resolved) { resolved = true; clearTimeout(timeout); reject(error); } }); return result; }; // If we don't have a destination yet, resolve after a short delay // This handles the case where the stream is created but not yet piped setTimeout(() => { if (!resolved) { resolved = true; clearTimeout(timeout); resolve(); } }, 100); }); // Check for HTML stream errors after waiting for completion if (htmlStreamErrored) { yield { type: "error", error: htmlStreamError || new Error("HTML stream failed"), metrics: { rscFull: rscFullMetrics, rscHeadless: rscHeadlessMetrics, html: htmlMetrics, }, }; return; } // Yield success result - simplified like client side yield { type: "success", html: htmlStreamWrapper, rsc: rscStreamWrapper, metrics: { rscFull: rscFullMetrics, rscHeadless: rscHeadlessMetrics, html: htmlMetrics, }, } as const; } catch (err) { if (handlerOptions.verbose) { handlerOptions.logger?.error(`[renderPage.server] Error: ${JSON.stringify(err)}`); } // Clean up any resources try { if (headlessRscHandler) headlessRscHandler.abort(); if (fullRscHandler) fullRscHandler.abort(); if (htmlTransformStream) htmlTransformStream.abort(); } catch (cleanupError: unknown) { handlerOptions.logger?.warn(`Failed to cleanup streams on error: ${cleanupError}`); } const panicError = handleError({ error: err, critical: false, logger: handlerOptions.logger, panicThreshold: handlerOptions.panicThreshold, context: `RenderPage Error (${handlerOptions.route})`, }); if (panicError != null) { yield { type: "error", error: panicError, metrics: { rscFull: rscFullMetrics, rscHeadless: rscHeadlessMetrics, html: htmlMetrics, }, }; } else { yield { type: "skip", reason: err, html: { pipe: <Writable extends NodeJS.WritableStream>(destination: Writable) => { destination.end(); return destination; }, abort: () => {}, }, rsc: { pipe: <Writable extends NodeJS.WritableStream>(destination: Writable) => { destination.end(); return destination; }, abort: () => {}, }, metrics: { rscFull: rscFullMetrics, rscHeadless: rscHeadlessMetrics, html: htmlMetrics, }, }; } } };