vite-plugin-react-server
Version:
Vite plugin for React Server Components (RSC)
939 lines (852 loc) • 36.5 kB
text/typescript
/**
* plugin.client.ts
*
* PURPOSE: Client-side static plugin for React Server Components
*
* This module:
* 1. Handles static site generation in the client environment
* 2. Uses RSC worker for RSC rendering and main-thread for HTML rendering
* 3. Generates both RSC and HTML files for static pages
* 4. Integrates with Vite's build process
*
* Feature parity with main react-static plugin, but in reverse. Uses rsc-worker to render rsc, and main thread for html.
* This is not the default behavior, but is supported for testing and custom app development purposes.
* Additionally, this can make it easier to use the --app flag to build all the modules + static generation at once.
*/
import {
createLogger,
type ResolvedConfig,
type Manifest,
type ConfigEnv,
} from "vite";
import { resolveOptions } from "../config/resolveOptions.js";
import type {
BuildTiming,
VitePluginFn,
AutoDiscoveredFiles,
} from "../types.js";
import type { OutputBundle } from "rollup";
import { renderPagesBatched } from "./renderPagesBatched.js";
import { performance } from "node:perf_hooks";
import { renderPage } from "./renderPage.client.js";
import { createWorker } from "../worker/createWorker.js";
import {
serializedOptions,
serializeResolvedConfig,
} from "../helpers/serializeUserOptions.js";
import { getBundleManifest } from "../helpers/getBundleManifest.js";
import { handleError } from "../error/handleError.js";
import { shouldCausePanic } from "../error/panicThresholdHandler.js";
import { configurePreviewServer } from "./configurePreviewServer.js";
import { assertNonReactServer } from "../config/getCondition.js";
import { envPrefixFromConfig } from "../config/envPrefixFromConfig.js";
import { createWorkerStartupMetrics } from "../metrics/createWorkerStartupMetrics.js";
import { processCssFilesForPages } from "./processCssFilesForPages.js";
import { createBuildLoader } from "./createBuildLoader.client.js";
import { getNodeEnv } from "../config/getNodeEnv.js";
import { toError } from "../error/toError.js";
import {
addStaticManifest,
manifests,
getSharedManifestStore,
} from "../bundle/manifests.js";
import { deferStaticGeneration } from "../bundle/deferredStaticGeneration.js";
import type { Worker } from "node:worker_threads";
import { resolveAutoDiscover } from "../config/autoDiscover/resolveAutoDiscover.js";
import { join } from "node:path";
import { baseURL } from "../utils/envUrls.node.js";
import { tryManifest } from "../helpers/tryManifest.js";
// cssCollector removed - using filesystem-based CSS processing
assertNonReactServer();
/**
* plugin.client.ts
*
* PURPOSE: Client-side static plugin for React Server Components
*
* This module:
* 1. Handles static site generation in the client environment
* 2. Uses RSC worker for RSC rendering and main-thread for HTML rendering
* 3. Generates both RSC and HTML files for static pages
* 4. Integrates with Vite's build process
*
* @param options
* @returns
*/
export const reactStaticPlugin: VitePluginFn = function _reactStaticPlugin(
options
) {
let logger: ReturnType<typeof createLogger>;
let autoDiscoveredFiles: AutoDiscoveredFiles | null = null;
let rscWorker: Worker | undefined = undefined;
let resolvedConfig: ResolvedConfig | null = null;
let serverManifest: Manifest | undefined = undefined;
let staticBundle: OutputBundle | undefined = undefined;
let serverBundle: OutputBundle | 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/client-static",
enforce: "post",
apply: "build", // Apply to build mode
api: {
meta: { timing },
},
async config(_config, viteConfigEnv) {
configEnv = viteConfigEnv;
},
applyToEnvironment(partialEnvironment) {
// Client static plugin should apply to static environment (browser/ESM builds)
// This is where we want to bundle everything and filter out _virtual files
// Apply to both "static" and "client" environments - we'll handle which one runs static generation in closeBundle
const envName = partialEnvironment.name as "client" | "server" | "ssr" | "static";
if (
["static", "client"].includes(envName)
) {
return true;
}
return false;
},
async configResolved(config) {
timing.configResolved = performance.now();
logger = config.customLogger || createLogger();
resolvedConfig = config;
// Perform auto-discovery to populate autoDiscoveredFiles
const autoDiscoverResult = await resolveAutoDiscover({
config: config,
configEnv: configEnv || {
mode: config.mode,
command: config.command,
isSsrBuild: false,
isPreview: false,
},
userOptions,
logger,
});
if (autoDiscoverResult.type === "error") {
throw autoDiscoverResult.error;
}
autoDiscoveredFiles = autoDiscoverResult.autoDiscoveredFiles;
if(userOptions.verbose) {
logger?.info(`Auto-discovery ${autoDiscoverResult.type === "success" ? "completed" : "skipped"}`);
}
},
async buildStart() {
timing.buildStart = performance.now();
if(userOptions.verbose) {
logger?.info("[react-static-client] Build started");
}
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) {
rscWorker?.terminate();
throw panicError;
}
}
}
},
async renderStart() {
timing.renderStart = performance.now();
if(userOptions.verbose) {
logger?.info("[react-static-client] Render started");
}
},
// 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 writeBundle(_options, bundle) {
// Capture manifests from all environments
try {
if (!autoDiscoveredFiles?.urlMap) {
return;
}
const bundleManifest = getBundleManifest<false>({
bundle,
normalizer: userOptions.normalizer,
});
// Store manifest based on environment
if (this.environment.name === "static") {
// Store in global manifest store for environment plugin access
addStaticManifest(bundleManifest);
staticBundle = bundle;
} else if (this.environment.name === "client") {
// Client build manifest (SSR modules) - stored globally now
if (manifests.static) {
const staticManifest = manifests.static;
// Update bundle filenames to match static manifest
for (const [, chunk] of Object.entries(bundle)) {
if (chunk.type === "chunk" && chunk.fileName) {
const normalized = userOptions.normalizer(chunk.fileName);
let value = normalized[1];
if (value.startsWith(userOptions.moduleBasePath)) {
value = value.slice(userOptions.moduleBasePath.length);
}
const entry = staticManifest[value];
if (entry && entry.file !== chunk.fileName) {
// Update the filename to match static manifest
chunk.fileName = entry.file;
}
}
}
}
} else if (this.environment.name === "server") {
// Server build manifest (server components) - stored globally now
serverBundle = bundle;
}
// Skip the static generation here - it will happen in closeBundle
return;
} catch (error) {
const panicError = handleError({
error,
logger: logger,
panicThreshold: userOptions.panicThreshold,
context: "writeBundle",
});
if (panicError != null) {
throw panicError;
}
}
},
async closeBundle() {
const envName = this.environment.name;
const isSsr = this.environment.config.build?.ssr === true;
if (userOptions.verbose) {
logger?.info(`[react-static-client] closeBundle called for environment: ${envName}, ssr: ${isSsr}`);
}
// Only run static generation in the non-SSR client environment (static builds)
// Skip SSR client builds and server builds
if (envName === "ssr" || envName === "server" || isSsr) {
if (userOptions.verbose) {
logger?.info(`[react-static-client] Skipping static generation for environment: ${envName} (ssr: ${isSsr})`);
}
return;
}
// Clean up _virtual files after build completes
// These are Vite's internal virtual modules and aren't needed in the final output
if (envName === "static" || (envName === "client" && !isSsr)) {
try {
const { rmSync, 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);
// Clean up _virtual from client/static output directories only
// Don't clean up server/_virtual since we need dynamic-import-helper.js there
const outputDirs = [
join(resolvedOutDir, userOptions.build.static || "static"),
join(resolvedOutDir, userOptions.build.client || "client"),
];
for (const outDir of outputDirs) {
const virtualDir = join(outDir, "_virtual");
if (existsSync(virtualDir)) {
rmSync(virtualDir, { recursive: true, force: true });
if (userOptions.verbose) {
logger?.info(`[react-static-client] Cleaned up _virtual directory: ${virtualDir}`);
}
}
}
} catch (error) {
// Non-critical - log but don't fail the build
if (userOptions.verbose) {
logger?.warn(`[react-static-client] Failed to clean up _virtual directory: ${error}`);
}
}
}
// This runs after all writeBundle hooks are complete
// Run static generation in the non-SSR client environment (static builds)
// This could be "static" or "client" depending on how environments are configured
if (envName === "ssr" || envName === "server" || isSsr) {
if (userOptions.verbose) {
logger?.info(`[react-static-client] Skipping static generation - not in static environment (${envName}, ssr: ${isSsr})`);
}
return;
}
// Defer static generation to run after ALL environments complete their builds.
// This is necessary because we need the server manifest (from server env's writeBundle)
// to resolve function-based component paths like Root: (url) => 'src/CustomRoot.tsx'.
// The buildApp hook in createEnvironmentPlugin will call runDeferredStaticGeneration().
const closeBundleContext = this;
deferStaticGeneration(async () => {
try {
// Re-check autoDiscoveredFiles - it might not be set if configResolved didn't run
// or if it was cleared. Try to get it from stashed options if needed
if (!autoDiscoveredFiles) {
if (userOptions.verbose) {
logger?.warn("[react-static-client] autoDiscoveredFiles not set, attempting to re-discover");
}
const { getStashedUserOptions, getEnvironmentId } = await import("../config/stashedOptionsState.js");
const { getCondition } = await import("../config/getCondition.js");
const envId = getEnvironmentId(getCondition(), resolvedConfig?.mode || "production");
const stashedOptions = getStashedUserOptions(envId);
if (stashedOptions && resolvedConfig) {
// Try to re-run auto-discovery if we have the config
const autoDiscoverResult = await resolveAutoDiscover({
config: resolvedConfig,
configEnv: configEnv || {
mode: resolvedConfig.mode || "production",
command: resolvedConfig.command || "build",
isSsrBuild: false,
isPreview: false,
},
userOptions,
logger,
});
if (autoDiscoverResult.type === "success") {
autoDiscoveredFiles = autoDiscoverResult.autoDiscoveredFiles;
if (userOptions.verbose) {
logger?.info(`[react-static-client] Re-discovered ${autoDiscoveredFiles.urlMap.size} pages`);
}
} else {
if (userOptions.verbose) {
logger?.warn(`[react-static-client] Failed to re-discover pages: ${autoDiscoverResult.error}`);
}
}
}
}
if (
!autoDiscoveredFiles?.urlMap ||
autoDiscoveredFiles?.urlMap.size === 0
) {
if (userOptions.verbose) {
logger?.warn(`[react-static-client] No pages to generate - urlMap is empty (size: ${autoDiscoveredFiles?.urlMap?.size || 0})`);
logger?.warn(`[react-static-client] autoDiscoveredFiles exists: ${!!autoDiscoveredFiles}, urlMap exists: ${!!autoDiscoveredFiles?.urlMap}`);
}
return;
}
if (userOptions.verbose) {
logger?.info(`[react-static-client] Starting static generation with ${autoDiscoveredFiles.urlMap.size} pages`);
}
// Check if we can access the shared manifest store
try {
if (userOptions.verbose) {
logger?.info(`[react-static-client] Attempting to get server manifest from shared state`);
}
const sharedState = getSharedManifestStore(closeBundleContext);
if (sharedState.server) {
serverManifest = sharedState.server;
if (userOptions.verbose) {
logger?.info(`[react-static-client] Got server manifest from shared state`);
}
} else {
throw new Error("No server manifest in shared state");
}
} catch (error) {
if (userOptions.verbose) {
logger?.info(`[react-static-client] Failed to get server manifest from shared state, trying filesystem: ${error}`);
}
const serverManifestPath = join(
userOptions.build.outDir,
userOptions.build.server
);
const manifestPath =
(typeof resolvedConfig?.build.manifest === "string"
? resolvedConfig.build.manifest
: ".vite/manifest.json");
if (userOptions.verbose) {
logger?.info(`[react-static-client] Loading server manifest from: ${join(serverManifestPath, manifestPath)}`);
}
const serverManifestResult = await tryManifest({
root: userOptions.projectRoot,
outDir: serverManifestPath,
manifestPath: manifestPath,
ssrManifest: false,
});
if (serverManifestResult.type === "error") {
if (userOptions.verbose) {
logger?.warn(`[react-static-client] Failed to load server manifest: ${serverManifestResult.error}`);
}
// Use empty manifest as fallback - static generation can proceed without it
serverManifest = {};
if (userOptions.verbose) {
logger?.warn(`[react-static-client] Using empty server manifest as fallback`);
}
} else if (serverManifestResult.type === "skip") {
if (userOptions.verbose) {
logger?.warn(`[react-static-client] Server manifest not found, using empty manifest as fallback`);
}
// Use empty manifest as fallback - static generation can proceed without it
serverManifest = {};
} else {
serverManifest = serverManifestResult.manifest;
if (userOptions.verbose) {
logger?.info(`[react-static-client] Loaded server manifest from filesystem`);
}
}
}
// Load static manifest from filesystem for CSS path mapping
const staticManifestResult = await tryManifest({
root: userOptions.projectRoot,
outDir: join(userOptions.build.outDir, userOptions.build.static),
manifestPath: resolvedConfig?.build.manifest ?? ".vite/manifest.json",
ssrManifest: false,
});
if (staticManifestResult.type === "error") {
throw staticManifestResult.error;
}
const staticManifest = staticManifestResult.manifest;
// Construct bootstrapModules like the server plugin does
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 ??
[]),
],
};
// Create CSS props for each CSS file (same as server-static)
const { cssFilesByPage, globalCss } = processCssFilesForPages({
userOptions,
autoDiscoveredFiles,
serverManifest,
staticManifest,
bundle: staticBundle || {},
logger,
});
if (userOptions.verbose) {
for (const [route, cssMap] of cssFilesByPage.entries()) {
logger.info(
`[react-static-client] Route ${route}: ${cssMap.size} CSS files`
);
for (const [key, value] of cssMap.entries()) {
logger.info(
`[react-static-client] CSS file: ${key} -> ${value.as} (${
value.children ? "inline" : "link"
})`
);
}
}
}
const routes = Array.from(
autoDiscoveredFiles.urlMap.keys()
) as string[];
// If no pages to generate, skip static generation
if (routes.length === 0) {
if (userOptions.verbose) {
logger?.info(
"[react-static-client] No pages to generate, skipping static generation"
);
}
return;
}
// Use the static manifest to ensure consistent module IDs between RSC stream and client build
// The static manifest contains the correct hashes that should be used for both builds
// (staticManifest already loaded above)
// Create a build loader for client mode (reuse server's sophisticated loader)
if (userOptions.verbose) {
logger?.info(`[react-static-client] Creating build loader`);
}
const buildLoader = createBuildLoader();
if (userOptions.verbose) {
logger?.info(`[react-static-client] Build loader created`);
}
// Create an RSC worker for generating RSC content
if (userOptions.verbose) {
logger?.info(
`[react-static-client] Creating RSC worker with path: ${userOptions.rscWorkerPath}`
);
}
const workerStartTime = performance.now();
let rscWorkerResult;
try {
rscWorkerResult = await createWorker({
projectRoot: userOptions.projectRoot,
workerPath: userOptions.rscWorkerPath,
currentCondition: "react-client",
reverseCondition: "react-server",
maxListeners: Math.max(routes.length * 3, 10), // Account for multiple listeners per route
envPrefix: envPrefixFromConfig(resolvedConfig as any),
logger: logger,
verbose: userOptions.verbose,
mode: getNodeEnv(),
workerData: {
userOptions: serializedOptions(userOptions, autoDiscoveredFiles),
resolvedConfig: serializeResolvedConfig(resolvedConfig as any),
configEnv: (() => {
const fallback = resolvedConfig
? {
command: resolvedConfig.command,
mode: resolvedConfig.mode,
isSsrBuild: false,
isPreview: false,
}
: undefined;
const finalConfigEnv = configEnv || fallback;
return finalConfigEnv;
})(),
serverManifest: serverManifest || {}, // Use server manifest for page component resolution
bundle: staticBundle || {}, // Use static bundle (client build) for page component resolution
staticBundle: staticBundle || {}, // Pass static bundle separately for path resolution
id: "static-client-rsc-worker",
},
});
} catch (workerError) {
if (userOptions.verbose) {
logger?.error(`[react-static-client] Error creating RSC worker: ${workerError}`);
}
throw workerError;
}
if (rscWorkerResult.type !== "success") {
const err = rscWorkerResult.error ?? new Error(`Failed to create RSC worker`);
if (userOptions.verbose) {
logger?.error(
`[react-static-client] RSC worker creation failed, throwing error`, { error: err }
);
}
throw err;
}
rscWorker = rscWorkerResult.worker;
if (userOptions.verbose) {
logger?.info(`[react-static-client] RSC worker created successfully`);
}
// 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: "rsc", // This is the RSC worker for client-side static generation
startupTime: workerStartupTime,
fromMainThread: true,
fromRscWorker: false,
fromHtmlWorker: false,
description: `RSC worker startup for client-side static generation`,
});
userOptions.onMetrics(workerStartupMetric);
}
// Render pages using client-side renderer with RSC worker only
const { onEvent, onMetrics, ...handlerOptions } = userOptions;
if (userOptions.verbose) {
logger?.info(`[react-static-client] Extracted onEvent: ${typeof onEvent}, userOptions.onEvent: ${typeof userOptions.onEvent}`);
}
// Capture server bundle from onEvent if not already captured
if (!serverBundle && onEvent) {
// Create a temporary event handler to capture the server bundle
const originalOnEvent = onEvent;
const tempOnEvent = (event: any) => {
if (event.type === "build.writeBundle.server") {
serverBundle = event.data.bundle;
logger?.info(
"[react-static-client] Captured server bundle from build event"
);
}
// Call the original event handler
originalOnEvent(event);
};
// Replace the onEvent temporarily to capture the server bundle
userOptions.onEvent = tempOnEvent;
}
// Emit the static site generation start event
// Use the extracted onEvent if available, otherwise fall back to userOptions.onEvent
const eventHandler = onEvent || userOptions.onEvent;
if (typeof eventHandler === "function") {
try {
if (userOptions.verbose) {
logger?.info(`[react-static-client] Emitting build.ssg.start event`);
}
const r = eventHandler({
type: "build.ssg.start",
data: {
pages: Array.from(autoDiscoveredFiles?.urlMap.keys() ?? []),
options: null as any, // No specific rollup output options for static generation
bundle: staticBundle || {},
},
});
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 if (userOptions.verbose) {
logger?.warn(`[react-static-client] No onEvent handler available to emit build.ssg.start`);
}
const renderPagesGenerator = renderPagesBatched(
routes,
{
...handlerOptions, // Use the clean options instead of the original handlerOptions
worker: rscWorker, // Pass the RSC worker for RSC rendering only
rscWorker: rscWorker, // Pass the RSC worker for RSC rendering only
loader: buildLoader, // Use proper build loader instead of no-op
logger: logger,
autoDiscoveredFiles: autoDiscoveredFiles,
cssFilesByPage: cssFilesByPage, // Pass CSS files by page
serverPipeableStreamOptions: serverPipeableStreamOptions, // Pass server options to RSC worker
clientPipeableStreamOptions: clientPipeableStreamOptions, // Pass client options to RSC worker
globalCss: globalCss, // Pass global CSS
manifest: serverManifest || {}, // Server manifest for RSC worker
staticManifest: staticManifest, // Static manifest for consistent module IDs
onEvent: onEvent,
onMetrics: onMetrics, // Pass through the onMetrics callback (metric watcher)
},
renderPage
);
// Process the rendered pages
let finalResult: any = undefined;
try {
for await (const result of renderPagesGenerator) {
if (result.type === "error") {
if (userOptions.verbose) {
logger?.error(`[react-static-client] Render error: ${result.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 (same as server plugin)
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 other panic thresholds, log warnings but continue
for (const [route, error] of result.failedRoutes) {
const err = error instanceof Error ? error : toError(error);
closeBundleContext.warn(
new Error(
"Failed to render route: " +
route +
"\n" +
err.message +
"\n" +
err.stack,
{ cause: err }
)
);
}
}
finalResult = result;
}
} catch (renderError) {
if (userOptions.verbose) {
logger?.error(`[react-static-client] Error during renderPages: ${renderError}`);
}
throw renderError;
}
if (!finalResult) {
const errorMsg = "No render result produced";
if (userOptions.verbose) {
logger?.error(`[react-static-client] ${errorMsg}`);
}
throw new Error(errorMsg);
}
if (userOptions.verbose) {
logger?.info(`[react-static-client] Render completed: ${finalResult.completedRoutes.size} pages, ${finalResult.failedRoutes?.size || 0} failed`);
}
// File writes are handled by renderPages, no need to do them here
// Calculate duration from timing
const duration = Math.round(
performance.now() - (timing.renderStart || timing.start)
);
closeBundleContext.info(
`Rendered ${finalResult.completedRoutes.size} pages in ${duration}ms`
);
if (process.env["NODE_ENV"] !== "production") {
closeBundleContext.warn(
`THIS BUILD IS NOT INTENDED FOR PRODUCTION (${process.env["NODE_ENV"]})`
);
}
// Update timing
timing.render =
performance.now() - (timing.renderStart ?? timing.start);
if (userOptions.verbose) {
logger?.info("[react-static-client] Static generation completed");
}
// 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: staticBundle || {},
},
});
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.end)",
});
if (eventPanicError != null) {
throw eventPanicError; // Re-throw to abort the build
}
}
}
} catch (error) {
const panicError = handleError({
error,
context: "react-static-client",
logger,
panicThreshold: userOptions.panicThreshold,
});
// Ensure graceful shutdown on error
if (rscWorker) {
const workerToCleanup = rscWorker;
try {
// Use graceful shutdown protocol even on error
await Promise.race([
new Promise<void>((resolve) => {
const timeoutId = setTimeout(() => {
workerToCleanup.removeAllListeners();
workerToCleanup.terminate();
resolve();
}, 1000); // 1 second timeout for graceful shutdown
const messageHandler = (message: any) => {
if (message.type === "SHUTDOWN_COMPLETE") {
clearTimeout(timeoutId);
workerToCleanup.removeListener("message", messageHandler);
resolve();
}
};
workerToCleanup.on("message", messageHandler);
workerToCleanup.postMessage({ type: "SHUTDOWN" });
}),
]);
rscWorker = undefined;
} catch (cleanupError) {
logger.warn(`Failed to cleanup worker on error: ${cleanupError}`);
// Force terminate if graceful shutdown fails
try {
workerToCleanup.removeAllListeners();
workerToCleanup.terminate();
} catch (terminateError) {
// Ignore termination errors
}
rscWorker = 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;
}
} finally {
// Graceful worker shutdown — runs on both success and error paths
if (rscWorker) {
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));
const shutdownMessageHandler = (message: any) => {
if (message.type === "SHUTDOWN_COMPLETE") {
clearTimeout(timeout);
clearTimeout(backupTimeout);
rscWorker?.removeListener(
"message",
shutdownMessageHandler
);
rscWorker?.removeAllListeners();
resolve();
}
};
rscWorker?.on("message", shutdownMessageHandler);
rscWorker?.postMessage({
type: "SHUTDOWN",
id: "*",
});
}),
]);
} catch {
// Shutdown protocol failed — force terminate below
} finally {
if (rscWorker) {
try {
(rscWorker as Worker).removeAllListeners();
// Await full worker exit before letting the build's promise
// resolve. Without this, libuv-level handles in the worker
// (file reads/writes pending at exit) can fire AFTER doBuild
// has restored cwd, producing post-teardown ENOENT errors
// against relative paths the worker started while cwd was
// the test fixture root.
await (rscWorker as Worker).terminate();
} catch {
// Ignore termination errors
}
rscWorker = undefined;
}
}
}
// Reset any cached state to prevent issues in subsequent builds
autoDiscoveredFiles = null;
serverManifest = undefined;
}
}); // end deferStaticGeneration
},
};
};