vite-plugin-react-server
Version:
Vite plugin for React Server Components (RSC)
730 lines (676 loc) • 27.6 kB
text/typescript
/**
* plugin.ts
*
* PURPOSE: Main Vite plugin for React Server Components (RSC) static site generation
*
* This module:
* 1. Orchestrates the entire static site generation process
* 2. Manages the lifecycle of the RSC rendering process
* 3. Handles file writing for both initial page loads and client-side navigation
* - Writes .html files for initial page loads (complete HTML document)
* - Writes .rsc files for client-side navigation (RSC content only)
* 4. Provides hooks for Vite to integrate with the build process
* 5. Manages worker threads for parallel rendering
* 6. Handles error reporting and metrics collection
*/
import type { Worker } from "node:worker_threads";
import {
type ConfigEnv,
type Logger,
type Manifest,
type ManifestChunk,
type ResolvedConfig,
createLogger,
} from "vite";
import { resolveOptions } from "../config/resolveOptions.js";
import { createBuildLoader } from "./createBuildLoader.server.js";
import type {
BuildTiming,
RenderPagesResult,
AutoDiscoveredFiles,
VitePluginFn,
} from "../types.js";
import { renderPagesBatched } from "./renderPagesBatched.js";
import { renderPages as renderPagesSequential } from "./renderPages.js";
import { getBundleManifest } from "../helpers/getBundleManifest.js";
import { createWorker } from "../worker/createWorker.js";
import {
serializedOptions,
serializeResolvedConfig,
} from "../helpers/serializeUserOptions.js";
import { performance } from "node:perf_hooks";
import { baseURL } from "../utils/envUrls.node.js";
import { handleError } from "../error/handleError.js";
import { shouldCausePanic } from "../error/panicThresholdHandler.js";
import { renderPage } from "./renderPage.server.js";
import { temporaryReferences } from "./temporaryReferences.server.js";
import { configurePreviewServer } from "./configurePreviewServer.js";
import { envPrefixFromConfig } from "../config/envPrefixFromConfig.js";
import { processCssFilesForPages } from "./processCssFilesForPages.js";
import { createWorkerStartupMetrics } from "../metrics/createWorkerStartupMetrics.js";
import { tryManifest } from "../helpers/tryManifest.js";
import { join } from "node:path";
import { resolveAutoDiscover } from "../config/autoDiscover/resolveAutoDiscover.js";
import { assertReactServer } from "../config/getCondition.js";
import { toError } from "../error/toError.js";
assertReactServer();
/**
* Main entrypoint for the static plugin.
*
* This plugin is responsible for:
* 1. Orchestrating the static site generation process
* 2. Handling the lifecycle of the RSC rendering process (main thread)
* 3. Writing .html files for initial page loads (complete HTML document)
* 4. Writing .rsc files for client-side navigation (RSC content only)
* 5. Managing worker threads for parallel rendering (html worker)
* 6. Handling error reporting and metrics collection
*/
// Global worker instance to prevent duplicate creation across plugin instances
let globalWorker: Worker | undefined;
export const reactStaticPlugin: VitePluginFn = function _reactStaticPlugin(
options
) {
let worker: Worker | undefined;
let logger: Logger;
let resolvedConfig: ResolvedConfig;
let autoDiscoveredFiles: AutoDiscoveredFiles | null = null;
let serverManifest: Manifest | undefined = undefined;
let configEnv: ConfigEnv | undefined;
const timing: BuildTiming = {
start: performance.now(),
configResolved: 0,
buildStart: 0,
renderStart: 0,
};
const resolvedOptions = resolveOptions(options);
if (resolvedOptions.type === "error") {
throw resolvedOptions.error;
}
const userOptions = resolvedOptions.userOptions;
return {
name: "vite:plugin-react-server/server-static",
enforce: "post",
api: {
meta: { timing },
},
async config(_config, viteConfigEnv) {
configEnv = viteConfigEnv;
},
applyToEnvironment(partialEnvironment) {
if (
["server"].includes(
partialEnvironment.name as "client" | "server" | "ssr"
)
) {
return true;
}
return false;
},
async configResolved(config) {
resolvedConfig = config;
if (!logger) {
logger = config.customLogger ?? createLogger();
}
const autoDiscoverResult = await resolveAutoDiscover({
config: config,
configEnv: configEnv!,
userOptions,
logger,
});
if (autoDiscoverResult.type === "error") {
throw autoDiscoverResult.error;
}
autoDiscoveredFiles = autoDiscoverResult.autoDiscoveredFiles;
},
async buildStart() {
if (!logger) {
logger = this.environment.logger;
}
timing.buildStart = performance.now();
if (userOptions.onEvent && autoDiscoveredFiles) {
try {
userOptions.onEvent({
type: "build.start",
data: {
pages: Array.from(autoDiscoveredFiles.urlMap.keys()),
files: autoDiscoveredFiles,
},
});
} catch (error) {
const panicError = handleError({
error,
logger: logger,
panicThreshold: userOptions.panicThreshold,
context: "buildStart",
});
if (panicError != null) {
worker?.terminate();
throw panicError;
} else {
this.error(new Error("Failed to emit build.start event"));
}
}
}
},
// the preview server helps to view the generated static folder, but only when the static plugin is enabled
// if no build.pages, then the preview server will instead use default vite preview server
// it works the same under both conditions
async configurePreviewServer(server) {
logger = server.config.customLogger || server.config.logger;
configurePreviewServer({
server,
userOptions,
});
},
async renderStart() {
timing.renderStart = performance.now();
},
generateBundle(_options, bundle) {
// Filter out unnecessary _virtual files from the bundle
// Keep dynamic-import-helper.js since it's needed for dynamic imports
// Note: Static builds are handled by plugin.client.ts, this only handles server builds
if (this.environment.name === "server") {
const keysToDelete: string[] = [];
for (const [key, chunk] of Object.entries(bundle)) {
if (chunk.type === "chunk") {
// Check fileName, key, moduleIds, and facadeModuleId for _virtual
const isVirtual =
chunk.fileName?.includes("_virtual") ||
key.includes("_virtual") ||
chunk.facadeModuleId?.includes("_virtual") ||
chunk.moduleIds?.some(id => id.includes("_virtual"));
// Keep dynamic-import-helper.js - it's needed for dynamic imports
const isDynamicImportHelper =
chunk.fileName?.includes("dynamic-import-helper") ||
key.includes("dynamic-import-helper");
if (isVirtual && !isDynamicImportHelper) {
keysToDelete.push(key);
if (userOptions.verbose) {
logger?.info(`[plugin.server] Filtered out virtual file: ${chunk.fileName || key} (moduleId: ${chunk.facadeModuleId || chunk.moduleIds?.[0]})`);
}
}
}
}
// Delete after iteration to avoid modifying while iterating
for (const key of keysToDelete) {
delete bundle[key];
}
}
},
async writeBundle(_options, bundle) {
// Only execute static generation for the server environment
if (this.environment.name !== "server") {
if (userOptions.verbose) {
logger?.info(`[plugin.server] Skipping static generation for environment: ${this.environment.name}`);
}
return;
}
let panicError: Error | null = null;
let bundleManifest:
| {
[key: string]: ManifestChunk & {
source: string;
};
}
| undefined = undefined;
if (!logger) {
logger = this.environment.logger;
}
// handle the bundle manifest
try {
bundleManifest = getBundleManifest<false>({
bundle,
normalizer: userOptions.normalizer,
});
// make sure that we have a manifest
const manifestPath =
typeof resolvedConfig.build.manifest === "string"
? resolvedConfig.build.manifest
: ".vite/manifest.json";
if (
!bundleManifest[manifestPath] ||
!("source" in bundleManifest[manifestPath])
) {
throw new Error("Server manifest not found");
}
// parse the manifest
serverManifest = JSON.parse(
bundleManifest[manifestPath].source as string
);
// make sure that we have a manifest
if (!serverManifest) {
throw new Error("Failed to parse server manifest");
}
} catch (error) {
const panicError = handleError({
error,
logger: logger,
panicThreshold: userOptions.panicThreshold,
context: "writeBundle(bundleManifest)",
});
if (panicError != null) {
throw panicError;
} else {
throw new Error("Failed to get bundle manifest");
}
}
try {
const staticManifestResult = await tryManifest({
root: userOptions.projectRoot,
outDir: join(userOptions.build.outDir, userOptions.build.static),
manifestPath: resolvedConfig.build.manifest,
ssrManifest: false,
});
if (staticManifestResult.type === "error") {
throw staticManifestResult.error;
}
const staticManifest = staticManifestResult.manifest;
// Don't create helper file - let resolveVirtualAndNodeModules shim handle it
// Same approach as client environment - no special file needed
const buildLoader = createBuildLoader(
{
userOptions: userOptions,
serverManifest: serverManifest ?? {},
staticManifest: staticManifest,
},
bundle,
temporaryReferences,
logger
);
// Create CSS props for each CSS file
const { cssFilesByPage, globalCss } = processCssFilesForPages({
userOptions,
autoDiscoveredFiles,
serverManifest,
staticManifest,
bundle,
logger,
});
if (userOptions.verbose) {
logger.info(
`[plugin.server] cssFilesByPage size: ${cssFilesByPage.size}`
);
for (const [route, cssMap] of cssFilesByPage.entries()) {
logger.info(
`[plugin.server] Route ${route}: ${cssMap.size} CSS files`
);
}
}
const indexHtml = staticManifest?.["index.html"]?.file;
const serverPipeableStreamOptions = {
...userOptions.serverPipeableStreamOptions,
bootstrapModules: [
...(indexHtml ? [baseURL(indexHtml)] : []),
...(userOptions.serverPipeableStreamOptions?.bootstrapModules ??
[]),
],
};
userOptions.serverPipeableStreamOptions = serverPipeableStreamOptions;
const clientPipeableStreamOptions = {
...userOptions.clientPipeableStreamOptions,
bootstrapModules: [
...(indexHtml ? [baseURL(indexHtml)] : []),
...(userOptions.clientPipeableStreamOptions?.bootstrapModules ??
[]),
],
};
// Get routes for worker configuration
const routes = !autoDiscoveredFiles
? []
: Array.from(autoDiscoveredFiles!.urlMap.keys());
// If no pages to generate, skip static generation entirely (including worker creation)
if (routes.length === 0) {
logger?.info(
"[plugin.server] No pages to generate, skipping static generation"
);
return;
}
const serializedUserOptions = serializedOptions(
userOptions,
autoDiscoveredFiles!
);
// Create HTML worker for HTML generation
// IMPORTANT: We create a new worker for each page render to ensure completely clean state
// This prevents race conditions where worker state persists between renders
// Guard against duplicate worker creation if plugin is instantiated multiple times
if (globalWorker) {
logger?.warn("[plugin.server] Global worker already exists, reusing existing worker");
worker = globalWorker;
} else {
const workerStartTime = performance.now();
const viteEnvPrefix = envPrefixFromConfig(resolvedConfig);
const routeCount = autoDiscoveredFiles?.urlMap.size ?? 0;
const maxListeners = routeCount + 1;
const workerResult = await createWorker({
projectRoot: userOptions.projectRoot,
workerPath: userOptions.htmlWorkerPath,
currentCondition: "react-server",
reverseCondition: "react-client", // HTML worker needs react-client for react-dom/server
maxListeners: maxListeners,
envPrefix: viteEnvPrefix,
logger: logger,
workerData: {
resolvedConfig: serializeResolvedConfig(resolvedConfig),
userOptions: serializedUserOptions,
configEnv,
},
});
if (workerResult.type === "error") {
if (workerResult.error != null) {
throw workerResult.error;
}
throw new Error("React static plugin failed to create worker");
} else if (workerResult.type === "skip") {
logger.info("Worker not created, skipping static build");
return;
} else {
worker = workerResult.worker;
// Emit worker startup metric after worker is created
const workerStartupTime = performance.now() - workerStartTime;
if (userOptions.onMetrics) {
const workerStartupMetric = createWorkerStartupMetrics({
route: "/", // Worker startup is global, not route-specific
workerType: "html", // This is the HTML worker for server-side static generation
startupTime: workerStartupTime,
fromMainThread: true,
fromRscWorker: false,
fromHtmlWorker: false,
description: `HTML worker startup for server-side static generation`,
});
// Only emit metrics from the server environment to prevent duplicates
if (this.environment.name === "server") {
userOptions.onMetrics(workerStartupMetric);
}
}
// Store the worker globally to prevent duplicate creation
globalWorker = worker;
}
}
// No RSC worker needed for static generation - main thread runs with react-server conditions
// Render pages - component resolution now happens per-route in renderPage
const { onEvent, ...handlerOptions } = userOptions;
// Emit the static site generation start event
if (typeof userOptions.onEvent === "function") {
try {
const r = userOptions.onEvent({
type: "build.ssg.start",
data: {
pages: Array.from(autoDiscoveredFiles?.urlMap.keys() ?? []),
options: null as any, // No specific rollup output options for static generation
bundle: bundle,
},
});
if (r != null && typeof r === "object" && "then" in r) {
await (r as Promise<any>);
}
} catch (error) {
const eventPanicError = handleError({
error,
logger: logger,
panicThreshold: userOptions.panicThreshold,
context: "onEvent(build.ssg.start)",
});
if (eventPanicError != null) {
throw eventPanicError; // Re-throw to abort the build
} else {
throw new Error("Failed to emit build.ssg.start event");
}
}
}
// Select render mode based on build config
const renderMode = userOptions.build?.renderMode ?? "parallel";
const renderPages = renderMode === "sequential" ? renderPagesSequential : renderPagesBatched;
if (userOptions.verbose) {
logger.info(`[static] Using ${renderMode} rendering${renderMode === "parallel" ? ` (batch size: ${userOptions.build?.batchSize ?? 8})` : ""}`);
}
// this will render the routes
const renderPagesGenerator = renderPages(
routes,
{
...handlerOptions,
loader: buildLoader,
worker: worker,
htmlWorker: worker, // Pass the HTML worker for HTML generation
logger: logger,
// Pass global CSS to downstream renderer
globalCss,
// Pass abort signal to cancel operations when errors occur
signal: AbortSignal.timeout(handlerOptions.htmlTimeout),
onEvent: onEvent,
serverPipeableStreamOptions: serverPipeableStreamOptions,
clientPipeableStreamOptions: clientPipeableStreamOptions,
manifest: serverManifest ?? {},
staticManifest: staticManifest, // Pass static manifest for path resolution
autoDiscoveredFiles: autoDiscoveredFiles!,
cssFilesByPage: cssFilesByPage,
batchSize: userOptions.build?.batchSize,
},
renderPage
);
// Process render results
let finalResult: RenderPagesResult | undefined;
try {
for await (const result of renderPagesGenerator) {
// Handle error results immediately
if (result.type === "error") {
throw result.error;
}
// Handle failed routes based on panic threshold
if (
result.type === "success" &&
result.failedRoutes &&
result.failedRoutes.size > 0
) {
// Use centralized panic threshold logic
const firstError = result.failedRoutes.values().next().value;
if (firstError != null && shouldCausePanic(firstError, { panicThreshold: userOptions.panicThreshold })) {
// This should cause a panic, throw the error
throw firstError;
}
// For non-panic errors, log warnings but continue
for (const [route, error] of result.failedRoutes) {
const err = error instanceof Error ? error : toError(error);
this.warn(
new Error("Failed to render route: " + route + "\n" + err.message + "\n" + err.stack, { cause: err })
);
}
}
finalResult = result;
}
} catch (renderError) {
// Handle render errors with panic threshold logic
const renderPanicError = handleError({
error: renderError,
logger: logger,
panicThreshold: userOptions.panicThreshold,
context: "renderPages",
});
if (renderPanicError != null) {
throw renderPanicError;
}
throw renderError;
}
if (!finalResult) {
throw new Error("No render result produced");
}
// Calculate duration from timing
const duration = Math.round(
performance.now() - (timing.renderStart || timing.start)
);
this.info(
`Rendered ${finalResult.completedRoutes.size} pages in ${duration}ms`
);
// Emit the static site generation completion event once
if (typeof userOptions.onEvent === "function") {
try {
const r = userOptions.onEvent({
type: "build.ssg.end",
data: {
pages: Array.from(autoDiscoveredFiles?.urlMap.keys() ?? []),
options: null as any, // No specific rollup output options for static generation
bundle: bundle,
},
});
if (r != null && typeof r === "object" && "then" in r) {
await (r as Promise<any>);
}
} catch (error) {
if (error != null) {
throw error; // Re-throw to abort the build
} else {
throw new Error("Failed to emit build.ssg.end event");
}
}
}
if (process.env["NODE_ENV"] !== "production") {
this.warn(
`THIS BUILD IS NOT INTENDED FOR PRODUCTION (${process.env["NODE_ENV"]})`
);
}
// Update timing
timing.render =
performance.now() - (timing.renderStart ?? timing.start);
} catch (error) {
panicError = handleError({
error,
logger: logger,
panicThreshold: userOptions.panicThreshold,
context: "writeBundle",
});
// Let the finally block handle additional cleanup
} finally {
// Reset any cached state to prevent issues in subsequent builds
autoDiscoveredFiles = null;
serverManifest = undefined;
// Clean up worker if it exists
if (worker) {
try {
worker.removeAllListeners();
// Await full worker exit before this writeBundle hook returns.
// Without await, libuv-level handles in the worker (file
// reads/writes pending at exit) can fire AFTER the build's
// promise has resolved and the caller has restored cwd —
// producing post-teardown ENOENT errors against relative paths
// the worker started while cwd was the test fixture root.
await worker.terminate();
} catch (terminateError) {
// Ignore termination errors
}
worker = undefined;
// Reset global worker since it's been terminated
globalWorker = undefined;
}
}
if (panicError != null) {
// Ensure we have a proper Error object that can have properties set on it
const errorToThrow =
panicError instanceof Error
? panicError
: new Error(String(panicError));
// Create a new Error object to avoid the "code" property issue
const finalError = new Error(errorToThrow.message);
finalError.stack = errorToThrow.stack;
finalError.cause = errorToThrow.cause;
// Copy any additional properties that might be needed
if (errorToThrow.name) finalError.name = errorToThrow.name;
throw finalError;
}
},
async closeBundle() {
// Clean up _virtual files after build completes
// These are Vite's internal virtual modules and aren't needed in the final output
if (this.environment.name === "server") {
try {
const { existsSync } = await import("node:fs");
const { join, resolve } = await import("node:path");
// Use the resolved output directory from the environment config
const resolvedOutDir = this.environment.config.build?.outDir
? resolve(this.environment.config.root || userOptions.projectRoot, this.environment.config.build.outDir)
: resolve(userOptions.projectRoot, userOptions.build.outDir);
// Don't clean up server/_virtual - we need dynamic-import-helper.js for runtime
// Only clean up static/_virtual if it exists (shouldn't, but just in case)
const staticOutDir = join(resolvedOutDir, userOptions.build.static || "static");
const staticVirtualDir = join(staticOutDir, "_virtual");
if (existsSync(staticVirtualDir)) {
const { rmSync } = await import("node:fs");
rmSync(staticVirtualDir, { recursive: true, force: true });
if (userOptions.verbose) {
logger?.info(`[plugin.server] Cleaned up _virtual directory: ${staticVirtualDir}`);
}
}
} catch (error) {
// Non-critical - log but don't fail the build
if (userOptions.verbose) {
logger?.warn(`[plugin.server] Failed to clean up _virtual directory: ${error}`);
}
}
}
// Graceful worker shutdown - only at the end of the entire build process
if (worker) {
try {
await Promise.race([
new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Worker shutdown timeout"));
}, userOptions.workerShutdownTimeout);
const backupTimeout = setTimeout(() => {
reject(new Error("Worker shutdown backup timeout"));
}, Math.floor(userOptions.workerShutdownTimeout * 0.6)); // 60% of main timeout
const messageHandler = (message: any) => {
if (message.type === "SHUTDOWN_COMPLETE") {
if (userOptions.verbose) {
logger.info("Worker shutdown complete");
}
clearTimeout(timeout);
clearTimeout(backupTimeout);
worker?.removeListener("message", messageHandler);
// Remove all other event listeners as well
worker?.removeAllListeners();
resolve();
} else if (message.type === "CLEANUP_COMPLETE") {
// Handle cleanup complete messages during shutdown - this is normal
if (userOptions.verbose) {
logger.info("Worker cleanup completed during shutdown");
}
// Don't resolve here - wait for SHUTDOWN_COMPLETE
} else {
if (userOptions.verbose) {
logger.info(
"Worker is still busy, received message " + message?.type
);
}
}
};
worker?.on("message", messageHandler);
// Send shutdown message
worker?.postMessage({
type: "SHUTDOWN",
id: "*",
});
}),
]);
} catch (error) {
// If shutdown protocol fails, force terminate
this.warn(
"Worker shutdown protocol failed, forcing termination: " +
(error instanceof Error ? error.message : String(error))
);
// Don't try to clean up listeners in error case - just force terminate
} finally {
// Always force cleanup and termination
if (worker) {
try {
worker.removeAllListeners();
worker.terminate();
} catch (terminateError) {
// Ignore termination errors
}
worker = undefined;
// Reset global worker since it's been terminated
globalWorker = undefined;
}
}
}
},
} as const;
};