UNPKG

vite-plugin-react-server

Version:
191 lines (190 loc) 8.53 kB
import { workerData } from "node:worker_threads"; import { join } from "node:path"; import { handleError } from "../../error/handleError.js"; import { assertNonReactServer } from "../../config/getCondition.js"; // Import React DOM Client for RSC stream processing import { createFromNodeStream } from "../../stream/createFromNodeStream.client.js"; import { createModuleResolutionMetrics } from "../../metrics/createModuleResolutionMetrics.js"; import { ReactDOMServer } from "../../vendor/vendor.client.js"; assertNonReactServer(); /** * Handle the render of an HTML stream from RSC chunks, creates the stream once and pipes directly. * * This html render expects all components as a serialized rsc stream. * * It does not have to resolve components, it just renders the html. * * @param handlerOptions * @param handlers * @param logger */ export const handleHtmlRender = function _handleHtmlRender(handlerOptions, handlers) { const { route, id = route, rscStream, // Use the RSC stream id passed from the main thread moduleRootPath, moduleBaseURL, moduleBasePath, verbose, logger, projectRoot, } = handlerOptions; try { if (verbose) { logger.info(`[html-worker:${route}] Creating HTML stream (${id})`); } if (!rscStream) { throw new Error("RSC stream is required for HTML rendering"); } // Convert RSC stream to React elements using ReactDOMClient.createFromNodeStream // // IMPORTANT: ReactDOMClient comes from react-server-dom-esm/client.node // We have reverse-engineered our own types for this (plugin/types/react-server-dom-esm.d.ts) // because there's no official @types package for react-server-dom-esm // // ACTUAL SIGNATURE FROM SOURCE CODE (patches/react-server-dom-esm+0.0.1.patch:9437): // exports.createFromNodeStream = function (stream, moduleRootPath, moduleBaseURL, options) // // This takes 4 parameters: // 1. stream: NodeJS.ReadableStream - the RSC stream // 2. moduleRootPath: string - the module root path for resolving client modules // 3. moduleBaseURL: string - the module base URL for resolving client modules // 4. options: object - optional configuration (encodeFormAction, nonce, etc.) // Construct the correct moduleRootPath using the projectRoot + moduleBasePath let resolvedModuleRootPath = moduleRootPath || ""; if (typeof resolvedModuleRootPath !== "string") { throw new Error("moduleRootPath is required"); } else if (!resolvedModuleRootPath.startsWith(projectRoot)) { resolvedModuleRootPath = join(projectRoot, resolvedModuleRootPath); } if (!resolvedModuleRootPath.endsWith(moduleBasePath)) { resolvedModuleRootPath = resolvedModuleRootPath + moduleBasePath; } if (moduleBasePath === "" && !resolvedModuleRootPath.endsWith("/")) { resolvedModuleRootPath = `${resolvedModuleRootPath}/`; } if (verbose) { logger.info(`[html-worker:${route}] Final resolvedModuleRootPath: ${resolvedModuleRootPath}`); } // Start measuring module resolution time const moduleResolutionStartTime = performance.now(); // Note: Module resolution metric will be emitted in onAllReady callback if (verbose) { logger.info(`[html-worker:${route}] Starting HTML render for route: ${route}`); } if (!rscStream.readable) { throw new Error("RSC stream is not readable"); } // Convert RSC stream to React elements using createFromNodeStream (like client-side) const result = createFromNodeStream({ rscStream: rscStream, moduleRootPath: resolvedModuleRootPath, moduleBasePath: moduleBasePath, moduleBaseURL: moduleBaseURL, logger, }); const mergedPipeableStreamOptions = { ...workerData.userOptions?.clientPipeableStreamOptions, ...handlerOptions.clientPipeableStreamOptions, }; // Render React elements to HTML stream using ReactDOMServer.renderToPipeableStream if (verbose) { logger.info(`[html-worker:${route}] clientPipeableStreamOptions: ${JSON.stringify(mergedPipeableStreamOptions)}`); } // Create the stream once and pipe directly with onData const { pipe } = ReactDOMServer.renderToPipeableStream(result.children, { ...mergedPipeableStreamOptions, onShellReady() { if (verbose) { logger.info(`[html-worker:${route}] Shell ready, starting to pipe HTML`); } if (handlers.onShellReady) { handlers.onShellReady(route); } if (mergedPipeableStreamOptions?.onShellReady) { mergedPipeableStreamOptions.onShellReady(); } }, onAllReady() { if (verbose) { logger.info(`[html-worker:${route}] All ready, HTML rendering complete`); } // Calculate module resolution time const moduleResolutionTime = performance.now() - moduleResolutionStartTime; // Send metrics if (handlers.onMetrics) { const moduleResolutionMetric = createModuleResolutionMetrics({ route, workerType: "html", resolutionTime: moduleResolutionTime, fromMainThread: false, fromRscWorker: false, fromHtmlWorker: true, description: `Module resolution for route ${route}`, }); handlers.onMetrics(route, moduleResolutionMetric); } if (handlers.onAllReady) { handlers.onAllReady(route); } if (mergedPipeableStreamOptions?.onAllReady) { mergedPipeableStreamOptions.onAllReady(); } }, onError(error, errorInfo) { if (verbose) { logger.error(`[html-worker:${route}] React rendering error: ${error}`); } handlers.onError(route, error, errorInfo); if (handlers.onError) { handlers.onError(route, error, errorInfo); } if (mergedPipeableStreamOptions?.onError) { mergedPipeableStreamOptions.onError(error, errorInfo); } }, }); // Create a custom writable stream that pipes directly to onData const customWritable = { write(chunk, _encoding, callback) { handlers.onData(id, chunk); if (callback) callback(); }, end(chunk, _encoding, callback) { if (chunk) { handlers.onData(id, chunk); } handlers.onEnd(id); if (callback) callback(); }, on() { }, // No-op for event listeners once() { }, // No-op for event listeners emit() { return false; }, // No-op for event emitters }; // Pipe the React stream directly to our custom writable pipe(customWritable); // Set up RSC stream error handling rscStream.on("error", (error) => { if (verbose) { logger.error(`[html-worker:${route}] RSC stream error: ${error}`); } handlers.onError(id, error, { componentStack: undefined, digest: undefined, }); }); } catch (error) { if (verbose) { logger.error(`[html-worker:${route}] Error in handleHtmlRender: ${error}`); } const panicError = handleError({ error: error, logger: logger, panicThreshold: workerData.userOptions?.panicThreshold, context: `HTML worker error for route ${route}`, }); if (panicError != null) { handlers.onError(id, panicError, { componentStack: undefined, digest: undefined, }); } throw error; } };