UNPKG

vite-plugin-react-server

Version:
939 lines (852 loc) 36.5 kB
/** * 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 }, }; };