vite-plugin-react-server
Version:
Vite plugin for React Server Components (RSC)
595 lines (538 loc) • 22.9 kB
text/typescript
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
}
});
};