UNPKG

vite-plugin-react-server

Version:
595 lines (538 loc) 22.9 kB
import type { ServerResponse } from "http"; import { React } from "../vendor/vendor.server.js"; import { collectViteModuleGraphCss } from "../helpers/collectViteModuleGraphCss.js"; import { createRscStream } from "../stream/createRscStream.server.js"; import { createRenderToPipeableStreamHandler } from "../stream/createRenderToPipeableStreamHandler.server.js"; import { createWorker } from "../worker/createWorker.js"; import type { Worker } from "node:worker_threads"; import { serializedOptions } from "../helpers/serializeUserOptions.js"; import { requestInfo } from "../helpers/requestInfo.js"; import { getRouteFiles } from "../helpers/getRouteFiles.js"; import { handleServerAction } from "./handleServerAction.server.js"; import type { ConfigureReactServerFn } from "./types.js"; import { handleError } from "../error/handleError.js"; import { cleanupWorker } from "../helpers/workerCleanup.js"; import { mergeConfig, type ResolvedConfig } from "vite"; import { resolvePageAndProps } from "../helpers/resolvePageAndProps.js"; import { Root as DefaultRoot } from "../components/root.js"; export const configureReactServer: ConfigureReactServerFn = function _configureReactServer({ server, autoDiscoveredFiles, userOptions: _userOptions, serverManifest, resolvedConfig, }) { const activeStreams = new Set<ServerResponse>(); const activeControllers = new Map< ServerResponse, { abort: (reason?: unknown) => void } >(); let isRestarting = false; const logger = server.config.customLogger || server.config.logger; const { Html: _UserHtmlComponent, // loader config isn't important here, since that's used by the transformer loader: _loaderConfig, verbose, // we can use these directly to create the handler ...userHandlerOptions } = _userOptions; server.config = mergeConfig( server.config, resolvedConfig ) as ResolvedConfig; // Handle Vite server restarts server.ws.on("restart", (path) => { logger.info( "[vite-plugin-react-server] 🔧 Plugin changed, preparing for restart:", path ); isRestarting = true; // Abort all active streams first, then close responses for (const res of activeStreams) { const controller = activeControllers.get(res); if (controller) { try { controller.abort("Server restarting"); } catch (e) { // Ignore abort errors } } res.writeHead(503, { "Content-Type": "text/x-component; charset=utf-8", "Retry-After": "1", }); res.end( `0:E{"digest":"","name":"Error","message":"Server restarting...","stack":"","env":"Server"}` ); } activeStreams.clear(); activeControllers.clear(); // Fallback: reset restart flag after a timeout setTimeout(() => { if (isRestarting) { isRestarting = false; logger.info( "[vite-plugin-react-server] ⏰ Restart timeout, resuming normal operation" ); } }, 5000); // 5 second timeout }); // Handle restart completion server.ws.on("full-reload", () => { isRestarting = false; logger.info("[vite-plugin-react-server] ✅ Server restart completed"); }); const loader = async (id: string) => { const [moduleID, exportName] = id.split("#"); // Determine the full module path let fullModulePath: string; // Check if already an absolute path (from server environment module graph) if (moduleID.startsWith(_userOptions.projectRoot)) { fullModulePath = moduleID; } else { // Resolve relative path against project root const resolvedModuleID = moduleID.startsWith("/") ? moduleID.slice(1) : moduleID; fullModulePath = `${_userOptions.projectRoot}/${resolvedModuleID}`; } // Use server environment runner for proper react-server condition handling // This ensures client components are transformed to registerClientReference const serverEnv = server.environments['server']; let result: Record<string, unknown>; if (serverEnv && 'runner' in serverEnv && serverEnv.runner) { // Vite 6 Environment API: use server environment runner for RSC result = await (serverEnv.runner as { import: (url: string) => Promise<Record<string, unknown>> }).import(fullModulePath); } else { // Fallback to ssrLoadModule (should not happen in Vite 6+) result = await server.ssrLoadModule(fullModulePath); } if (result == null) throw new Error(`Module \"${moduleID}\" does not have any exports`); if (!Object.keys(result).length && exportName.length) throw new Error( `Module \"${moduleID}\" is a module, but does not have any exports so it can't find ${exportName}` ); // Always return the full module - callers will extract the specific export if needed // This is consistent with how resolvePage and resolveProps work return result; }; server.middlewares.use(async (req, res, next) => { if (!req.url) { return next(); } const handlerOptions = { ...userHandlerOptions, moduleBaseURL: server.config.base, moduleBasePath: userHandlerOptions.moduleBasePath, projectRoot: _userOptions.projectRoot, css: userHandlerOptions.css, loader: loader, verbose, logger, rscStream: res }; const info = requestInfo(req, handlerOptions, ""); // Handle server actions if (info.isServerActionRequest) { return handleServerAction(req, res, server, handlerOptions); } if (!info.isRscRequest) { return next(); } // If server is restarting, return 503 immediately if (isRestarting) { res.writeHead(503, { "Content-Type": "text/x-component; charset=utf-8", "Retry-After": "1", }); res.end( `0:E{"digest":"","name":"Error","message":"Server restarting...","stack":"","env":"Server"}` ); return; } // Create RSC worker for consistent RSC stream formats let rscWorker: Worker | undefined; try { const routeFiles = await getRouteFiles( info.route, autoDiscoveredFiles, _userOptions, logger ); if (routeFiles.type === "error") { const panicError = handleError({ error: routeFiles.error, logger: logger, panicThreshold: userHandlerOptions.panicThreshold, critical: false, context: "configureReactServer", }); if (panicError != null) { return next(panicError); } return next(); } const pagePath = routeFiles.page; const propsPath = routeFiles.props; const rootPath = routeFiles.root; // Check if we have a page path - if not, skip this route if (!pagePath) { if (verbose) { logger.info(`No page found for route: ${info.route}, skipping`); } return next(); } if (verbose) { logger.info( `Components resolved successfully for route: ${info.route}` ); } if (verbose) { logger.info( `PageComponent is valid, creating handler options for route: ${info.route}` ); } const handlerOptions = { ...userHandlerOptions, route: info.route, pagePath, propsPath, logger: logger, manifest: serverManifest, server, moduleBaseURL: server.config.base, projectRoot: _userOptions.projectRoot, loader: loader, verbose: verbose, }; // Load actual components first - this registers them in the module graph // which is required for CSS collection to work let PageComponent: React.ComponentType<any> = React.Fragment; let RootComponent: React.ComponentType<any> = DefaultRoot; let pageProps: any = {}; // Load the Root component if (rootPath) { try { const rootExportName = userHandlerOptions.rootExportName || "Root"; const rootModule = await loader(rootPath); if (rootModule && rootModule[rootExportName] && typeof rootModule[rootExportName] === 'function') { RootComponent = rootModule[rootExportName] as React.ComponentType<any>; if (verbose) { logger.info(`Loaded Root component for route ${info.route} from ${rootPath}#${rootExportName}`); } } else if (rootModule && rootModule['default'] && typeof rootModule['default'] === 'function') { RootComponent = rootModule['default'] as React.ComponentType<any>; if (verbose) { logger.info(`Loaded default export as Root component for route ${info.route}`); } } else { if (verbose) { logger.warn(`Root component not found in ${rootPath}, using React.Fragment`); } } } catch (error) { // A Root-module load failure must not be silently swallowed — that // hides real bugs behind the DefaultRoot fallback. Log // unconditionally and honor panicThreshold like sibling error paths. const panicError = handleError({ error, logger, panicThreshold: userHandlerOptions.panicThreshold, critical: true, context: `configureReactServer: load Root from ${rootPath}`, log: true, }); if (panicError != null) { return next(panicError); } } } // Load the page component (registers it in module graph for CSS collection) if (pagePath) { try { const pageExportName = userHandlerOptions.pageExportName || "Page"; const pageModule = await loader(pagePath); if (pageModule && pageModule[pageExportName] && typeof pageModule[pageExportName] === 'function') { PageComponent = pageModule[pageExportName] as React.ComponentType<any>; if (verbose) { logger.info(`Loaded Page component for route ${info.route} from ${pagePath}#${pageExportName}`); } } else if (pageModule && pageModule['default'] && typeof pageModule['default'] === 'function') { PageComponent = pageModule['default'] as React.ComponentType<any>; if (verbose) { logger.info(`Loaded default export as Page component for route ${info.route}`); } } else if (pageModule && typeof pageModule === 'function') { PageComponent = pageModule as React.ComponentType<any>; if (verbose) { logger.info(`Loaded module as Page component for route ${info.route}`); } } else { if (verbose) { logger.warn(`Page component not found in ${pagePath}, using React.Fragment`); } } } catch (error) { // A page-module load failure was previously swallowed under // !verbose, leaving the route to render as a blank Fragment with no // error surfaced anywhere. Log unconditionally and honor // panicThreshold like sibling error paths. const panicError = handleError({ error, logger, panicThreshold: userHandlerOptions.panicThreshold, critical: true, context: `configureReactServer: load Page from ${pagePath}`, log: true, }); if (panicError != null) { return next(panicError); } } } // NOW collect CSS - page is registered in module graph if (verbose) { logger.info(`Collecting CSS files for route: ${info.route}`); } const serverEnv = server.environments['server']; const moduleGraphForCss = serverEnv?.moduleGraph ?? server.moduleGraph; const cssFilesResult = await collectViteModuleGraphCss({ moduleGraph: moduleGraphForCss, parentUrl: pagePath, handlerOptions: handlerOptions, }); if (verbose) { logger.info(`CSS collection completed for route: ${info.route}`); } if (cssFilesResult.type === "skip") { if (verbose) { logger.info(`CSS collection skipped for route: ${info.route}, continuing with RSC rendering`); } } if (cssFilesResult.type === "error") { return next(cssFilesResult.error); } const collectedCssFiles = cssFilesResult.type === "success" ? cssFilesResult.cssFiles : new Map(); if (verbose) { logger.info(`Creating RSC handler for route: ${info.route}`); } // Load props using the resolvePageAndProps helper try { if (verbose) { logger.info(`[configureReactServer] Loading props for route ${info.route}, pagePath: ${pagePath}, propsPath: ${propsPath}, url: ${info.url}`); } const propsResult = await resolvePageAndProps({ pagePath, propsPath, pageExportName: userHandlerOptions.pageExportName, propsExportName: userHandlerOptions.propsExportName, url: info.url, route: info.route, moduleBaseURL: server.config.base, loader, verbose, logger, build: { rscOutputPath: userHandlerOptions.build?.rscOutputPath || ".rsc", }, }); if (verbose) { logger.info(`[configureReactServer] Props resolution result type: ${propsResult.type}`); } if (propsResult.type === "success") { pageProps = propsResult.pageProps || {}; if (verbose) { logger.info(`[configureReactServer] Loaded props for route ${info.route}: ${JSON.stringify(pageProps, null, 2)}`); } } else if (propsResult.type === "skip") { if (verbose) { logger.info(`[configureReactServer] Props resolution skipped for route ${info.route}, using empty props`); } pageProps = {}; } else { if (verbose) { logger.warn(`[configureReactServer] Failed to load props for route ${info.route}: ${propsResult.error}`); } pageProps = {}; } } catch (error) { if (verbose) { logger.warn(`[configureReactServer] Error loading props for route ${info.route}: ${error}`); if (error instanceof Error) { logger.warn(`[configureReactServer] Error stack: ${error.stack}`); } } // Continue with empty props if loading fails pageProps = {}; } // DEV MODE OPTIMIZATION: Skip worker, use direct rendering on main thread // // Why: In dev:rsc mode, the main thread already has react-server condition and // loads modules via Vite's environment runner which handles HMR automatically. // The worker uses raw import() which bypasses Vite's module graph and HMR. // // Benefits of direct rendering in dev: // - Proper HMR: file changes are picked up immediately via Vite's module graph // - No module caching issues: environment runner handles cache invalidation // - Simpler debugging: all code runs in main thread // // The worker is still valuable for: // - Production builds (isolation, consistent behavior) // - Future: Running RSC in different runtimes (workerd, etc.) // // Users can opt-in to worker in dev via config if needed for testing production behavior. const useWorkerInDev = _userOptions.dev?.useRscWorker === true; if (useWorkerInDev) { try { if (verbose) { logger.info(`Creating RSC worker for route: ${info.route} (useRscWorker=true)`); } const workerResult = await createWorker({ projectRoot: _userOptions.projectRoot || server.config.root, workerData: { userOptions: serializedOptions(_userOptions, autoDiscoveredFiles), resolvedConfig: server.config, configEnv: { command: "serve", mode: "development" }, }, verbose, logger, }); if (workerResult.type === "success") { rscWorker = workerResult.worker; if (verbose) { logger.info(`RSC worker created successfully for route: ${info.route}`); } } else { if (verbose) { logger.warn(`RSC worker creation skipped for route: ${info.route}: ${workerResult.reason}`); } } } catch (error) { if (verbose) { logger.warn(`Failed to create RSC worker for route: ${info.route}: ${error}`); } } } else { if (verbose) { logger.info(`[dev:rsc] Using direct rendering (no worker) for proper HMR support`); } } // Use worker-based RSC stream if worker is available, otherwise fall back to direct rendering // CRITICAL: For RSC requests, use htmlPath: "" for headless mode (no Html wrapper) // This prevents hydration errors where <html> would be rendered inside #root div const rscResult = rscWorker ? createRscStream({ ...handlerOptions, url: info.url, pagePath, propsPath, rootPath, // Pass the root path for worker to load htmlPath: "", // Empty string = headless RSC (no Html wrapper) rscWorker, cssFiles: collectedCssFiles, globalCss: new Map(), }) : createRenderToPipeableStreamHandler({ ...handlerOptions, url: info.url, PageComponent: PageComponent as any, RootComponent: RootComponent as any, HtmlComponent: React.Fragment, // Headless stream - no Html wrapper pageProps: pageProps, // Pass the loaded props cssFiles: collectedCssFiles, }); if (verbose) { logger.info( `RSC handler created for route: ${ info.route }, result type: ${typeof rscResult}, has pipe: ${typeof rscResult?.pipe}, has abort: ${typeof rscResult?.abort}` ); } if (rscResult && typeof rscResult.pipe === "function") { if (verbose) { logger.info(`Setting up RSC stream for route: ${info.route}`); } // set headers res.setHeader("Content-Type", "text/x-component; charset=utf-8"); // CRITICAL: Disable caching in development mode // Without this, browsers cache RSC streams and don't show updates res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); // Add CORS headers for RSC files const origin = req.headers.origin; if (origin && (origin.includes('localhost') || origin.includes('127.0.0.1'))) { res.setHeader("Access-Control-Allow-Origin", origin); } else { res.setHeader("Access-Control-Allow-Origin", "*"); } res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Accept, Content-Type"); res.setHeader("Access-Control-Max-Age", "86400"); rscResult.pipe(res); // Store the controller for potential abort during restart activeControllers.set(res, rscResult); } else { if (verbose) { logger.error( `RSC handler failed for route: ${ info.route }, invalid result: ${typeof rscResult}` ); } // Handle the error case res.statusCode = 500; res.end("Internal Server Error"); } activeStreams.add(res); res.on("close", () => { activeStreams.delete(res); // Abort the RSC stream to clean up MessagePorts const controller = activeControllers.get(res); if (controller && typeof controller.abort === "function") { try { controller.abort(); } catch (error) { // Ignore cleanup errors } } activeControllers.delete(res); // Clean up worker when request completes cleanupWorker(rscWorker); }); } catch (error) { // Always log RSC stream errors (regardless of verbose) so a misconfigured // app surfaces the underlying error in the dev server log without // forcing the user to flip `verbose: true`. const panicError = handleError({ error, logger, panicThreshold: handlerOptions.panicThreshold, critical: false, context: "configureReactServer", log: true, }); if (panicError != null) { return next(panicError); } // Surface the failure to the HTTP client. Previously we set status 500 // but never ended the response, so the request would hang and any // already-flushed bytes (none yet, since we set headers right before // pipe()) would be all the caller saw. Emit a text/plain 500 with the // error message so dev clients (curl, the React fetcher) get a clear // failure instead of an empty 200 / hanging socket. if (!res.headersSent) { res.statusCode = 500; res.setHeader("Content-Type", "text/plain; charset=utf-8"); } if (!res.writableEnded) { const message = error instanceof Error ? error.message : String(error); res.end(`RSC render failed: ${message}\n`); } // Note: Worker cleanup is handled by the response close handler } }); };