vite-plugin-react-server
Version:
Vite plugin for React Server Components (RSC)
381 lines (354 loc) • 15 kB
text/typescript
import type { RequestHandler } from "../types.js";
import { type Worker } from "node:worker_threads";
import { serializedOptions } from "../helpers/serializeUserOptions.js";
import { requestInfo } from "../helpers/requestInfo.js";
import { restartWorker } from "./restartWorker.client.js";
import { handleRscStream } from "../stream/handleRscStream.client.js";
import { getRouteFiles } from "../helpers/getRouteFiles.js";
import type { RscWorkerInputMessage } from "../worker/rsc/types.js";
import { handleServerAction } from "./handleServerAction.client.js";
import type { ConfigureWorkerRequestHandlerFn } from "../react-client/types.js";
import { handleError } from "../error/handleError.js";
import { getNodeEnv } from "../config/getNodeEnv.js";
import { isReactServerCondition } from "../config/getCondition.js";
import { setupGlobalErrorHandler, cleanupGlobalErrorHandler } from "../error/setupGlobalErrorHandler.js";
import { pipeToResponse } from "../helpers/pipeToResponse.js";
/**
* Configures the worker request handler.
* @param server - The Vite dev server
* @param autoDiscoveredFiles - The auto discovered files
* @param userOptions - The user options
*/
export const configureRequestHandler: ConfigureWorkerRequestHandlerFn =
async function _configureWorkerRequestHandler({
server,
autoDiscoveredFiles,
userOptions: _userOptions,
configEnv,
hmrChannel,
onWorkerCreated,
}) {
const logger = server.config.customLogger || server.config.logger;
const {
// remove these
moduleBaseURL: _moduleBaseURL,
...handlerUserOptions
} = _userOptions;
const handlerOptions = Object.assign({}, handlerUserOptions, {
moduleBaseURL: server.config.base,
projectRoot: _userOptions.projectRoot || server.config.root,
logger: logger,
});
// Set up global error handler for all_errors panic threshold
setupGlobalErrorHandler({
panicThreshold: handlerOptions.panicThreshold,
logger: logger,
verbose: handlerOptions.verbose,
});
// Start the worker
let currentWorker: Worker | null = null;
let restartWorkerForHMR: (() => Promise<void>) | null = null;
// Handle server restarts
server.ws.on("restart", async () => {
logger.info("[react-client] Server restarting, shutting down worker...");
if (currentWorker) {
currentWorker.postMessage({
type: "SHUTDOWN",
id: "*",
} satisfies RscWorkerInputMessage);
await new Promise((resolve, reject) => {
currentWorker?.on("message", (message) => {
if (message.type === "SHUTDOWN_COMPLETE") {
resolve(true);
} else {
reject("Did not receive SHUTDOWN_COMPLETE");
}
});
});
currentWorker.removeAllListeners();
currentWorker = null;
}
// Clean up global error handler
cleanupGlobalErrorHandler();
});
// Create the request handler
const handler: RequestHandler = async (req, res, next) => {
if (!req.url) return next();
const info = requestInfo(req, handlerOptions, "");
const handlerOptionsWithUrl = {
...handlerOptions,
url: info.url,
};
if (handlerOptions.verbose) {
server.config.logger.info(`[configureRequestHandler] handlerOptionsWithUrl.projectRoot: ${handlerOptionsWithUrl.projectRoot}`);
server.config.logger.info(`[configureRequestHandler] handlerOptions.projectRoot: ${handlerOptions.projectRoot}`);
}
// Serialize user options for worker
const serializedUserOptions = serializedOptions(
handlerOptionsWithUrl,
autoDiscoveredFiles
);
// Define restart function for HMR (needs serializedUserOptions)
if (!restartWorkerForHMR) {
restartWorkerForHMR = async () => {
if (currentWorker) {
currentWorker = await restartWorker({
server,
autoDiscoveredFiles,
userOptions: serializedUserOptions,
configEnv: configEnv,
hmrChannel,
});
if (currentWorker && restartWorkerForHMR) {
onWorkerCreated?.(currentWorker, restartWorkerForHMR);
}
} else {
// Worker doesn't exist yet, create it
currentWorker = await restartWorker({
server,
autoDiscoveredFiles,
userOptions: serializedUserOptions,
configEnv: configEnv,
hmrChannel,
});
if (currentWorker && restartWorkerForHMR) {
onWorkerCreated?.(currentWorker, restartWorkerForHMR);
}
}
};
}
if (handlerOptions.verbose) {
server.config.logger.info(`[configureRequestHandler] serializedUserOptions.projectRoot: ${serializedUserOptions.projectRoot}`);
}
// Handle server action requests
if (info.isServerActionRequest) {
if (!currentWorker) {
currentWorker = await restartWorker({
server,
autoDiscoveredFiles,
userOptions: serializedUserOptions,
configEnv: configEnv,
hmrChannel,
});
}
if (!currentWorker) {
throw new Error("Failed to start worker");
}
return handleServerAction(req, res, currentWorker, logger);
}
// Handle RSC requests
if (!info.isRscRequest) {
return next();
}
const routeFiles = await getRouteFiles(
info.route,
autoDiscoveredFiles,
handlerOptions,
logger
);
if (routeFiles.type === "error") {
const panicError = handleError({
error: routeFiles.error,
logger,
mode: getNodeEnv(server.config.mode),
panicThreshold: handlerOptions.panicThreshold,
critical: false,
context: "configureWorkerRequestHandler",
});
if (panicError != null) {
throw panicError;
}
return next(routeFiles.error);
}
const pagePath = routeFiles.page;
const propsPath = routeFiles.props;
const rootPath = routeFiles.root;
// Note: htmlPath not used for RSC requests (always "" for headless mode)
// Pre-load props on main thread to apply Vite transforms (server actions need this).
// Only when the main process actually runs the react-server condition (dev:rsc):
// a props module pulls the server graph (and any client component it reaches loads
// its registerClientReference form, which imports react-server-dom-esm-server and
// hard-requires react-server). In dev:ssr the main thread is NOT react-server, so we
// must NOT attempt this — the worker loads props instead. Attempting it there made
// Vite's ModuleRunner log a "react-server condition must be enabled" error during
// full reloads even though we caught the rejection (bd-u7v). Gate on the condition
// so the doomed import never runs in dev:ssr.
let resolvedPageProps: Record<string, unknown> | undefined;
if (propsPath) {
try {
const fullPropsPath = `${handlerOptions.projectRoot}/${propsPath}`;
const serverEnv = server.environments?.['server'];
let propsModule: any;
if (handlerOptions.verbose) {
logger.info(`[configureRequestHandler] Pre-loading props from: ${fullPropsPath}`);
}
if (
isReactServerCondition() &&
serverEnv &&
'runner' in serverEnv &&
serverEnv.runner
) {
// dev:rsc — main thread has the react-server condition, so the server
// environment runner can evaluate the props (and the server graph) directly.
try {
propsModule = await (serverEnv.runner as any).import(fullPropsPath);
} catch (runnerError: any) {
if (handlerOptions.verbose) {
logger.info(`[configureRequestHandler] Main-thread props import failed; worker will load props`);
}
propsModule = null;
}
} else {
// dev:ssr (no react-server on the main thread) or no server env:
// the worker loads props in its react-server context.
propsModule = null;
}
const propsExportName = handlerOptions.propsExportName || "props";
// Only process if we got a module (server runner succeeded)
if (propsModule) {
const propsExport = propsModule[propsExportName];
if (typeof propsExport === "function") {
// Call the props function with the URL
let result = propsExport(info.url);
if (result instanceof Promise) {
result = await result;
}
resolvedPageProps = result;
if (handlerOptions.verbose) {
logger.info(`[configureRequestHandler] Pre-loaded props for ${info.route}: ${Object.keys(resolvedPageProps || {}).length} keys`);
}
} else if (propsExport && typeof propsExport === "object") {
resolvedPageProps = propsExport;
}
} // end if (propsModule)
} catch (error) {
if (handlerOptions.verbose) {
logger.warn(`[configureRequestHandler] Failed to pre-load props: ${error}`);
}
// Continue without pre-loaded props, worker will try to load them
}
}
try {
// Set up response headers for streaming
res.setHeader("Content-Type", info.contentType);
res.setHeader("Transfer-Encoding", "chunked");
res.setHeader("Connection", "keep-alive");
// 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");
const userOnMetrics = handlerOptions.onMetrics;
// Spawn the worker lazily on the first request. The ModuleRunner
// handles per-module invalidation via the HMR_UPDATE message
// handler, so a long-lived worker can keep serving across edits.
if (!currentWorker) {
currentWorker = await restartWorker({
server,
autoDiscoveredFiles,
userOptions: serializedUserOptions,
configEnv: configEnv,
hmrChannel,
});
}
if (!currentWorker) {
throw new Error("Failed to start worker");
}
// Notify about worker creation
onWorkerCreated?.(currentWorker, restartWorkerForHMR);
const stream = handleRscStream({
options: {
...serializedUserOptions,
worker: currentWorker,
id: info.route,
type: "INIT",
logger,
// we make the worker stream aware of the route, pagePath, propsPath, rootPath, htmlPath
route: info.route,
url: info.url,
pagePath: pagePath,
propsPath: propsPath,
// Pass pre-resolved props (loaded via Vite's ssrLoadModule for proper transforms)
resolvedPageProps: resolvedPageProps,
rootPath: rootPath,
// CRITICAL: For RSC requests, use htmlPath: "" for headless mode (no Html wrapper)
// This prevents hydration errors where <html> would be rendered inside #root div
htmlPath: "", // Empty string = headless RSC (no Html wrapper)
// Component overrides (undefined for file-based components in client dev)
HtmlComponent: undefined,
RootComponent: undefined,
// Use userOptions.projectRoot if available, otherwise fall back to server.config.root
projectRoot: serializedUserOptions.projectRoot || server.config.root,
build: {
...(serializedUserOptions.build || {}),
pages: Array.isArray(serializedUserOptions.build?.pages)
? serializedUserOptions.build.pages
: [],
},
manifest: {},
cssFiles: new Map(),
globalCss: new Map(),
serverPipeableStreamOptions: serializedUserOptions.serverPipeableStreamOptions,
clientPipeableStreamOptions: serializedUserOptions.clientPipeableStreamOptions,
} as any,
handlers: {
onMetrics: (id, metrics) => {
metrics.route = id;
userOnMetrics?.(metrics);
},
onHmrAccept: () => {
},
onHmrUpdate: () => {
},
// Always log shell errors. Without this an RSC render failure in
// the worker would surface only via `verbose: true`, leaving the
// user with a half-streamed RSC response and no explanation.
onShellError: (id, error) => {
logger.error(
`[configureRequestHandler:${id}] RSC shell error: ${error instanceof Error ? error.message : String(error)}`,
{ error: error instanceof Error ? error : new Error(String(error)) },
);
},
},
...handlerOptions,
});
// Pipe the stream to the response using the helper
pipeToResponse({
stream,
response: res,
contentType: info.contentType,
logger,
verbose: handlerOptions.verbose,
panicThreshold: handlerOptions.panicThreshold,
context: "configureWorkerRequestHandler",
});
// Response is now being streamed - no need to wait for timeout
} catch (error) {
// Always log: misconfigured apps would otherwise return an empty 500
// and leave the user staring at a hung tab without any console output.
const panicError = handleError({
error,
logger,
mode: getNodeEnv(server.config.mode),
panicThreshold: handlerOptions.panicThreshold,
critical: false,
context: "configureWorkerRequestHandler",
log: true,
});
if (panicError != null) {
throw panicError;
}
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
const message = error instanceof Error ? error.message : String(error);
res.end(`RSC render failed: ${message}\n`);
} else {
res.end();
}
}
};
// attach handler to the server
server.middlewares.use(handler);
// port check, should be handled by strictPort
};