UNPKG

vite-plugin-react-server

Version:
363 lines (330 loc) 14.9 kB
import type { Plugin, UserConfig, ViteBuilder } from "vite"; import type { VitePluginFn } from "../types.js"; import { resolveAutoDiscover } from "../config/autoDiscover/resolveAutoDiscover.js"; import { resolveUserConfig } from "../config/resolveUserConfig.js"; import { resolveOptions } from "../config/resolveOptions.js"; import { handleError } from "../error/handleError.js"; import { createDefaultModuleID } from "../config/createModuleID.js"; import { createLogger } from "vite"; import { join } from "node:path"; import { DEFAULT_LOADER_CONFIG } from "../config/defaults.js"; import { runDeferredStaticGeneration } from "../bundle/deferredStaticGeneration.js"; /** * Creates a plugin that ensures consistent hash generation across environments * by using the original source file content as the basis for hash generation. */ // Note: Path normalization should be handled in the file naming functions, not in writeBundle /** * Environment Configuration Plugin * * This plugin configures Vite Environment API environments for React Server Components. * It's separate from the env plugin which handles process environment variables. * * Environment mapping: * - client (Vite client = browser) → dist/client (React client components - real implementations) * - ssr (Vite SSR = SSR-safe) → dist/static (SSR-compatible static files) * - server (custom) → dist/server (React server components with registerClientReference) */ export const createEnvironmentPlugin: VitePluginFn = (options): Plugin => { const environmentPlugin: Plugin = { name: "vite:plugin-react-server/environments", enforce: "pre", async config(config: UserConfig, configEnv) { // Resolve plugin options const resolvedOptionsResult = resolveOptions(options); if (resolvedOptionsResult.type === "error") { throw resolvedOptionsResult.error; } const userOptions = resolvedOptionsResult.userOptions; // Add transformer plugins at the Vite level with proper environment filtering if (!config.plugins) { config.plugins = []; } // Note: Transformer is now added via orchestrator, so skip adding it here // to avoid duplicates and ensure proper registration // Note: Hash coordination is handled by the sequential build approach // Each environment will use the manifest from the previous build // Set up logger and moduleID const logger = config.customLogger || createLogger(); if (typeof userOptions.moduleID !== "function") { userOptions.moduleID = createDefaultModuleID( userOptions, configEnv, userOptions.loader?.mode ); } // Always override the moduleID function to ensure it has the forTransformer logic if (!userOptions.loader) { userOptions.loader = DEFAULT_LOADER_CONFIG; } userOptions.loader.moduleID = createDefaultModuleID( userOptions, configEnv, userOptions.loader?.mode ); // Run auto-discovery once to get all files - we don't need separate calls since // the file discovery process is identical, only the organization differs const autoDiscoverResult = await resolveAutoDiscover({ config, configEnv, userOptions, logger, }); if (autoDiscoverResult.type === "error") { const panicError = handleError({ error: autoDiscoverResult.error, logger, context: "createEnvironmentPlugin(autoDiscover)", panicThreshold: userOptions.panicThreshold, critical: true, // Auto-discovery is critical for environment setup }); if (panicError != null) { throw panicError; } else { // If handleError returns null but this is critical, we can't continue throw new Error("Cannot continue without auto-discovery"); } } // Get the auto-discovered files (safe to access since we checked for errors above) const autoDiscoveredFiles = autoDiscoverResult.autoDiscoveredFiles!; // Define environment configurations const allEnvironmentConfigs = [ { name: "client", condition: "react-client" as const, ssr: false, outDir: join(userOptions.build.outDir, userOptions.build.static), }, { name: "ssr", condition: "react-client" as const, ssr: true, outDir: join(userOptions.build.outDir, userOptions.build.client), }, { name: "server", condition: "react-server" as const, ssr: true, outDir: join(userOptions.build.outDir, userOptions.build.server), }, ]; // Filter environments based on availableEnvironments from orchestrator const availableEnvironments = (userOptions as any) .availableEnvironments || ["client", "ssr", "server"]; const environmentConfigs = allEnvironmentConfigs.filter((config) => availableEnvironments.includes(config.name) ); // Resolve all environment configurations using resolveUserConfig const environments: Record<string, import("vite").EnvironmentOptions> = {}; // Sort environments to process static first (to establish hashes) // Use the environment configs as-is const sortedEnvConfigs = environmentConfigs; for (const envConfig of sortedEnvConfigs) { const configResult = resolveUserConfig({ condition: envConfig.condition, config, configEnv, userOptions, autoDiscoveredFiles, ssr: envConfig.ssr, }); if (configResult.type === "error") { const panicError = handleError({ error: configResult.error, logger, context: `createEnvironmentPlugin(${envConfig.name}Config)`, panicThreshold: userOptions.panicThreshold, critical: true, }); if (panicError != null) { throw panicError; } else { throw new Error( `Cannot continue without ${envConfig.name} environment configuration` ); } } // Map the resolved user config to Environment API compatible options const userConfig = configResult.userConfig; // Log the rollup inputs for this environment (only in verbose mode) if (userOptions.verbose) { logger?.info( `${envConfig.name} environment rollup inputs: ${JSON.stringify( userConfig.build.rollupOptions.input, null, 2 )}` ); logger?.info( `${ envConfig.name } environment output preserveModulesRoot: ${JSON.stringify( userConfig.build.rollupOptions.output, null, 2 )}` ); } // Debug: Log what resolveUserConfig provided if (userOptions.verbose) { logger?.info( `${envConfig.name} userConfig.resolve: ${JSON.stringify( userConfig.resolve, null, 2 )}` ); logger?.info( `${ envConfig.name } userConfig.build.rollupOptions.external: ${JSON.stringify( userConfig.build.rollupOptions.external, null, 2 )}` ); } // detect if legacy build or not const legacyBuild = userOptions.strategy?.legacyBuilder && !config?.builder; const implicitSsr = userOptions.strategy?.mainThreadCondition === "react-server" && userOptions.strategy?.legacyBuilder; // this follows vite's logic for legacy builds const implicitViteBuildName = userOptions.strategy?.legacyBuilder && !config.build?.ssr ? "client" : "ssr"; const consumer = legacyBuild ? implicitViteBuildName === "ssr" ? "server" : "client" : envConfig.name === "server" || envConfig.name === "ssr" ? "server" : "client"; // Note: Path normalization should be handled in the file naming functions environments[envConfig.name] = { keepProcessEnv: envConfig.name === "server" ? true : false, define: userConfig.define, consumer: consumer, resolve: { ...userConfig.resolve, // IMPORTANT: Map externals from resolveUserConfig (rollupOptions.external) to Environment API format // In Environment API, externals go in resolve.external, not build.rollupOptions.external // For static builds (browser/ESM): don't externalize anything - bundle everything to avoid _virtual files // For client/server builds (SSR): externalize as configured external: (() => { const isStaticBuild = envConfig.name === "static" || (!envConfig.ssr && envConfig.name === "client"); if (isStaticBuild) { // For static builds, don't externalize anything (bundle everything) return []; } // For SSR builds, use configured externals return Array.isArray(userConfig.build.rollupOptions.external) ? userConfig.build.rollupOptions.external.filter( (item): item is string => typeof item === "string" ) : []; })(), }, build: { ...userConfig.build, ssr: envConfig.name === "server" ? true : legacyBuild ? implicitSsr : envConfig.name === "ssr", target: userConfig.build.target, // Remove externals from rollupOptions since they should be in resolve.external for Environment API rollupOptions: { ...userConfig.build.rollupOptions, external: undefined, // Remove external from rollupOptions, it's now in resolve.external // Set preserveModules in the output configuration, not at the top level output: (() => { const output = userConfig.build.rollupOptions.output; // Handle array output configuration - extract the plugin output that contains preserveModulesRoot if (Array.isArray(output)) { const pluginOutput = output.find(o => o && typeof o === 'object' && 'preserveModulesRoot' in o); if (pluginOutput) { return pluginOutput; } // If no pluginOutput found, use the first output configuration if (output.length > 0) { return output[0]; } } // Ensure preserveModulesRoot is always present in the output configuration if (output && typeof output === 'object' && !Array.isArray(output)) { // Check if the property exists in the object (not just checking the value) const hasPreserveModulesRoot = 'preserveModulesRoot' in output; if (hasPreserveModulesRoot) { // Property exists, preserve the preserveModules value from the output (don't override it) // This is critical for static builds where preserveModules: false is set return output; // Return as-is, preserveModules is already set correctly } else { // Property missing, add it based on user options const preserveModulesRootString = userOptions.build.preserveModulesRoot === false ? userOptions.moduleBase : undefined; return { ...output, preserveModulesRoot: preserveModulesRootString }; } } return output; })(), }, }, }; } // Force the PRODUCTION JSX transform for every build environment. // // Under a dev-mode build (`NODE_ENV=development … vite build --mode // development`) esbuild's automatic-runtime JSX transform emits the // dev call shape `jsxDEV(type, props, key, isStaticChildren, source, // self)`. esbuild renders the trailing `self` argument as a bare // `module` reference when it can't prove the file is ESM at // per-file transform time. The vprs server bundle is pure ESM // (`dist/server/*.js`), so at SSG-prerender time that `module` // identifier is undefined and the very first server component to // render (e.g. the built-in `Html` component) throws // `ReferenceError: module is not defined`. // // The server bundle never consumes jsxDEV's client-warning // `source`/`self` info, so dropping to the production transform // (`jsx`/`jsxs`, no `self` arg) is a pure win for builds: // - It only changes the JSX *call shape*, NOT which React build is // bundled — `NODE_ENV=development` still resolves the development // (non-minified) React, so dev builds keep surfacing the errors // production minifies away. // - Production builds already use the production JSX transform // (esbuild only emits jsxDEV in dev), so this is a no-op there. // - Scoped to `command === "build"`, so the dev SERVER / client // Fast Refresh path (`command === "serve"`) is untouched. const esbuildJsxDevOverride = configEnv.command === "build" && config.esbuild !== false ? { esbuild: { ...config.esbuild, jsxDev: false } } : {}; // Return the configuration with all environments // Build order: client → ssr → server → static generation (step 4) // Server build runs LAST so dist/client exists when HTML rendering references client components // Static generation is deferred to run after ALL environments complete (needs server manifest) return { root: userOptions.projectRoot, ...config, ...esbuildJsxDevOverride, environments, builder: { async buildApp(builder: ViteBuilder) { // Build all environments in definition order for (const environment of Object.values(builder.environments)) { await builder.build(environment); } // Step 4: Run deferred static generation now that all manifests are available await runDeferredStaticGeneration(); }, }, }; }, }; return environmentPlugin; };