UNPKG

vite-plugin-react-server

Version:
485 lines (431 loc) 17.2 kB
import type { CreateHandlerOptions, AutoDiscoveredFiles, RootComponentType, HtmlComponentType } from "../types.js"; import type { Logger } from "vite"; import { getRouteFiles } from "../helpers/getRouteFiles.js"; import { routeToURL } from "../utils/routeToURL.js"; import { resolveAutoDiscover } from "./autoDiscover/resolveAutoDiscover.js"; import { getStashedUserOptions, getStashedHandlerOptions, stashHandlerOptions, getEnvironmentId, } from "./stashedOptionsState.js"; import { getNodeEnv } from "./getNodeEnv.js"; import { createLogger } from "vite"; import { DEFAULT_CONFIG } from "./defaults.js"; import type { CreateHandlerOptionsParams, ResolvedDefaults } from "./createHandlerOptions.types.js"; import { resolveComponent } from "../helpers/resolveComponent.js"; import { serializedOptions } from "../helpers/serializeUserOptions.js"; import { createWorker } from "../worker/createWorker.js"; /** * Server-specific handler options creation for React Server Components (RSC). * * WHAT THIS DOES: * - Creates handler options optimized for server-side rendering * - Resolves file paths for pages, props, root, and HTML components * - Sets up server-specific loaders and configuration * - Handles caching with unique IDs * - Provides all necessary options for RSC stream creation * * WHAT THIS DOESN'T DO: * - Does NOT load React components (that happens in the actual handlers) * - Does NOT create RSC streams (use createHandler for that) * - Does NOT handle client-side rendering (use .client.ts for that) * - Does NOT manage component lifecycle or state * * USAGE: * ```typescript * const handlerOptions = await createHandlerOptions("/my-route", { * logger: myLogger, * defaults: { loader: server.ssrLoadModule } * }); * ``` */ function createDefaultOptions(): ResolvedDefaults { return { pageExportName: DEFAULT_CONFIG.PAGE_EXPORT_NAME, propsExportName: DEFAULT_CONFIG.PROPS_EXPORT_NAME, rootExportName: DEFAULT_CONFIG.ROOT_EXPORT_NAME, htmlExportName: DEFAULT_CONFIG.HTML_EXPORT_NAME, cssFiles: new Map(), globalCss: new Map(), manifest: {}, css: DEFAULT_CONFIG.CSS, }; } async function resolveAutoDiscoveredFiles( options: CreateHandlerOptionsParams, stashedOptions: any, logger: Logger ): Promise<AutoDiscoveredFiles> { if (options.autoDiscoveredFiles) { return options.autoDiscoveredFiles; } const result = await resolveAutoDiscover({ config: options.config || {}, configEnv: options.configEnv || { mode: "production", command: "build" }, userOptions: stashedOptions, logger, }); if (result.type === "error") { throw result.error || new Error("Failed to resolve autoDiscover"); } return result.autoDiscoveredFiles; } export async function createHandlerOptions( route: string, options: CreateHandlerOptionsParams = {} ): Promise<CreateHandlerOptions> { const { mode = getNodeEnv(), logger = createLogger(), configEnv = { mode: mode || "production", command: "build" }, id = `${route}-${Date.now()}-${Math.random() .toString(36) .substring(2, 11)}`, envId = getEnvironmentId("react-server", mode), userOptions = getStashedUserOptions(envId), } = options; // Check cache first const cachedOptions = getStashedHandlerOptions(id); if (cachedOptions) { return cachedOptions; } if (!userOptions) { throw new Error( `No stashed userOptions found for environment: ${envId}. Make sure resolveOptions() has been called first.` ); } // Resolve defaults const defaults = { ...createDefaultOptions(), ...options.defaults }; // Resolve auto-discovered files const autoDiscoveredFiles = await resolveAutoDiscoveredFiles( options, userOptions, logger ); // Create URL const url = routeToURL( route, userOptions.moduleBaseURL, userOptions.build.rscOutputPath ); // Get route files const routeFilesResult = await getRouteFiles( route, autoDiscoveredFiles, userOptions, logger ); if (routeFilesResult.type === "error") { throw routeFilesResult.error || new Error("Failed to get route files"); } // Load components from resolved file paths let PageComponent = userOptions.components?.Page; let RootComponent = userOptions.components?.Root; let HtmlComponent = userOptions.components?.Html; // Load Page component if pagePath is available if (routeFilesResult.page && !PageComponent) { try { if (userOptions.verbose) { logger.info(`[createHandlerOptions] Attempting to load component from: ${routeFilesResult.page} export: ${userOptions.pageExportName}`); } // In development mode (serve), use dynamic import loader for TypeScript support const isServeMode = configEnv?.command === "serve" || configEnv?.mode === "development" || mode === "development"; const componentLoader = isServeMode ? async (path: string) => { if (userOptions.verbose) { logger.info(`[createHandlerOptions] Development mode: loading ${path} via dynamic import`); } return await import(path); } : defaults.loader || (() => Promise.resolve({})); const pageResult = await resolveComponent({ componentPath: routeFilesResult.page, exportName: userOptions.pageExportName, loader: componentLoader, }); if (pageResult.type === "success") { PageComponent = pageResult.component; logger.info(`[createHandlerOptions] Loaded Page component from ${routeFilesResult.page}`); } else { logger.warn( `[createHandlerOptions] Failed to load Page component from ${routeFilesResult.page}: ${ pageResult.error?.message || "Unknown error" }` ); } } catch (error) { logger.warn( `[createHandlerOptions] Error loading Page component from ${routeFilesResult.page}: ${ error instanceof Error ? error.message : String(error) }` ); } } // Load Root component if rootPath is available, or use default Root component if rootPath is undefined if (!RootComponent && routeFilesResult.root !== undefined) { // If rootPath is explicitly set to empty string, don't load any Root component (headless mode) if (routeFilesResult.root === '') { if (userOptions.verbose) { logger.info(`[createHandlerOptions] Root component explicitly disabled (headless mode)`); } RootComponent = undefined; } else { // Load custom Root component from specified path try { // Use same development mode loader logic const isServeMode = configEnv?.command === "serve" || configEnv?.mode === "development" || mode === "development"; const componentLoader = isServeMode ? async (path: string) => import(path) : defaults.loader || (() => Promise.resolve({})); const rootResult = await resolveComponent({ componentPath: routeFilesResult.root, exportName: userOptions.rootExportName, loader: componentLoader, }); if (rootResult.type === "success") { RootComponent = rootResult.component as RootComponentType; logger.info(`[createHandlerOptions] Loaded custom Root component from ${routeFilesResult.root}`); } } catch (error) { logger.warn( `[createHandlerOptions] Error loading custom Root component: ${ error instanceof Error ? error.message : String(error) }` ); } } } else if(!RootComponent) { // rootPath is undefined, use default Root component try { const { Root } = await import("../components/root.js"); RootComponent = Root; if (userOptions.verbose) { logger.info(`[createHandlerOptions] Using default Root component`); } } catch (error) { logger.warn( `[createHandlerOptions] Error loading default Root component: ${ error instanceof Error ? error.message : String(error) }` ); } } // Load Html component if htmlPath is available, or use default Html component if htmlPath is undefined if (!HtmlComponent && routeFilesResult.html !== undefined) { // If htmlPath is explicitly set to empty string, don't load any Html component (headless mode) if (routeFilesResult.html === '') { if (userOptions.verbose) { logger.info(`[createHandlerOptions] Html component explicitly disabled (headless mode)`); } HtmlComponent = undefined; } else { // Load custom Html component from specified path try { // Use same development mode loader logic const isServeMode = configEnv?.command === "serve" || configEnv?.mode === "development" || mode === "development"; const componentLoader = isServeMode ? async (path: string) => await import(path) : defaults.loader || (() => Promise.resolve({})); const htmlResult = await resolveComponent({ componentPath: routeFilesResult.html, exportName: userOptions.htmlExportName, loader: componentLoader, }); if (htmlResult.type === "success") { HtmlComponent = htmlResult.component as HtmlComponentType; logger.info(`[createHandlerOptions] Loaded custom Html component from ${routeFilesResult.html}`); } } catch (error) { logger.warn( `[createHandlerOptions] Error loading custom Html component: ${ error instanceof Error ? error.message : String(error) }` ); } } } else if(!HtmlComponent) { // htmlPath is undefined, use default Html component try { const { Html } = await import("../components/html.js"); HtmlComponent = Html; if (userOptions.verbose) { logger.info(`[createHandlerOptions] Using default Html component`); } } catch (error) { logger.warn( `[createHandlerOptions] Error loading default Html component: ${ error instanceof Error ? error.message : String(error) }` ); } } // Create workers for server environment based on configuration and configEnv let rscWorker: any = undefined; let htmlWorker: any = undefined; // Determine if we need workers based on configEnv and dev config const isServeMode = configEnv?.command === "serve" || configEnv?.mode === "development" || mode === "development"; const isBuildMode = configEnv?.command === "build"; // Create RSC worker if: // 1. useRscWorker is enabled in dev config AND we're in serve mode, OR // 2. useRscWorker is enabled in build config AND we're in build mode const shouldCreateRscWorker = (userOptions.dev?.useRscWorker && isServeMode) || (userOptions.build?.useRscWorker && isBuildMode); if (shouldCreateRscWorker) { if (userOptions.verbose) { logger.info(`[createHandlerOptions.server] Creating RSC worker for route: ${route}`); } try { const serializedUserOptions = serializedOptions(userOptions, autoDiscoveredFiles); // We don't need to create the RSC worker, but if the user wants to use their own worker // it can be done by setting dev.useRscWorker=true or build.useRscWorker=true const workerResult = await createWorker({ currentCondition: "react-server", // same CONDITION as the current one (this worker may be redundant) reverseCondition: "react-server", workerPath: userOptions.rscWorkerPath, verbose: userOptions.verbose, logger, workerData: { id: route, userOptions: serializedUserOptions, resolvedConfig: { configEnv, mode, }, }, }); if (workerResult.type === "error") { logger.warn(`[createHandlerOptions.server] Failed to create RSC worker: ${workerResult.error?.message}`); rscWorker = undefined; } else if (workerResult.type === "skip") { logger.warn(`[createHandlerOptions.server] RSC worker creation skipped: ${workerResult.reason}`); rscWorker = undefined; } else { rscWorker = workerResult.worker; if (userOptions.verbose) { logger.info(`[createHandlerOptions.server] RSC worker created successfully`); } } } catch (error) { logger.warn(`[createHandlerOptions.server] RSC worker creation failed: ${error instanceof Error ? error.message : String(error)}`); rscWorker = undefined; } } // Create HTML worker if: // Create HTML worker if: // 1. useHtmlWorker is enabled in dev config AND we're in serve mode, OR // 2. useHtmlWorker is enabled in build config AND we're in build mode const shouldCreateHtmlWorker = (userOptions.dev?.useHtmlWorker && isServeMode) || (userOptions.build?.useHtmlWorker && isBuildMode); if (shouldCreateHtmlWorker) { if (userOptions.verbose) { logger.info(`[createHandlerOptions.server] Creating HTML worker for route: ${route}`); } try { const serializedUserOptions = serializedOptions(userOptions, autoDiscoveredFiles); const workerResult = await createWorker({ currentCondition: "react-server", reverseCondition: "react-client", // HTML worker needs react-client condition workerPath: userOptions.htmlWorkerPath, verbose: userOptions.verbose, logger, workerData: { id: route, userOptions: serializedUserOptions, resolvedConfig: { configEnv, mode, }, }, }); if (workerResult.type === "error") { logger.warn(`[createHandlerOptions.server] Failed to create HTML worker: ${workerResult.error?.message}`); htmlWorker = undefined; } else if (workerResult.type === "skip") { logger.warn(`[createHandlerOptions.server] HTML worker creation skipped: ${workerResult.reason}`); htmlWorker = undefined; } else { htmlWorker = workerResult.worker; if (userOptions.verbose) { logger.info(`[createHandlerOptions.server] HTML worker created successfully`); } } } catch (error) { logger.warn(`[createHandlerOptions.server] HTML worker creation failed: ${error instanceof Error ? error.message : String(error)}`); htmlWorker = undefined; } } // Create server-specific handler options const handlerOptions: CreateHandlerOptions = { ...userOptions, // File paths pagePath: routeFilesResult.page, propsPath: routeFilesResult.props, rootPath: routeFilesResult.root, htmlPath: routeFilesResult.html, // Export names pageExportName: userOptions.pageExportName, propsExportName: userOptions.propsExportName, rootExportName: userOptions.rootExportName, htmlExportName: userOptions.htmlExportName, // Route and loader route, loader: defaults.loader || (() => Promise.resolve({})), // Configuration panicThreshold: userOptions.panicThreshold, verbose: userOptions.verbose, moduleBaseURL: userOptions.moduleBaseURL, build: userOptions.build, dev: { useHtmlWorker: userOptions.dev.useHtmlWorker, useRscWorker: userOptions.dev.useRscWorker, }, logger, // Required properties normalizer: userOptions.normalizer, onEvent: userOptions.onEvent, onMetrics: userOptions.onMetrics, autoDiscover: userOptions.autoDiscover, css: userOptions.css, projectRoot: userOptions.projectRoot, moduleBase: userOptions.moduleBase, moduleBasePath: userOptions.moduleBasePath, moduleRootPath: userOptions.moduleRootPath, moduleID: userOptions.moduleID, url, manifest: defaults.manifest, cssFiles: defaults.cssFiles, globalCss: defaults.globalCss, // Timeouts and paths rscTimeout: userOptions.rscTimeout, htmlTimeout: userOptions.htmlTimeout, fileWriteTimeout: userOptions.fileWriteTimeout, workerShutdownTimeout: userOptions.workerShutdownTimeout, rscWorkerPath: userOptions.rscWorkerPath, htmlWorkerPath: userOptions.htmlWorkerPath, publicOrigin: userOptions.publicOrigin, // Stream options serverPipeableStreamOptions: userOptions.serverPipeableStreamOptions, clientPipeableStreamOptions: userOptions.clientPipeableStreamOptions || {}, components: userOptions.components, // Server-specific id, // Always use the inverse worker for the main "worker" field worker: htmlWorker, rscWorker, htmlWorker, // Loaded components (server loads them at configuration time) PageComponent, RootComponent, HtmlComponent }; // Cache and return stashHandlerOptions(id, handlerOptions); return handlerOptions; } export type { CreateHandlerOptionsParams, CreateHandlerOptionsServerFn, CreateHandlerOptionsClientFn, } from "./createHandlerOptions.types.js";