vite-plugin-react-server
Version:
Vite plugin for React Server Components (RSC)
485 lines (431 loc) • 17.2 kB
text/typescript
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";