vite-plugin-react-server
Version:
Vite plugin for React Server Components (RSC)
314 lines (274 loc) • 12.6 kB
text/typescript
import type { ResolvedUserOptions } from "../types.js";
import { replaceExtension } from "./extMap.js";
import { getNodeEnv } from "./getNodeEnv.js";
import { DEFAULT_CONFIG } from "./defaults.js";
import { detectClientModule } from "react-server-loader/directives";
import type { ConfigEnv } from "vite";
import { sep, resolve, join } from "node:path";
import { readFileSync, existsSync } from "node:fs";
import { createRollupLikeHash } from "./createRollupLikeHash.js";
export type ModuleIDKey =
| "modulePattern"
| "cssPattern"
| "jsonPattern"
| "htmlPattern"
| "rscPattern"
| "nodeOnly"
| "cssModulePattern"
| "vendorPattern"
| "virtualPattern"
| "dotFiles";
export const createDefaultModuleID = (
options: Pick<
ResolvedUserOptions,
"moduleBase" | "moduleBasePath" | "autoDiscover" | "build" | "dev" | "moduleBaseURL" | "projectRoot"
>,
configEnv?: ConfigEnv,
mode = getNodeEnv()
) => {
const { moduleBase, moduleBasePath, build, moduleBaseURL, projectRoot, autoDiscover } = options;
const assetsDir = build.assetsDir || DEFAULT_CONFIG.BUILD.assetsDir;
const isBuild = configEnv?.command === "build";
// Hashing + moduleBase-stripping must track `isBuild`, NOT `mode`. The chunk
// EMISSION side (resolveOptions' entryFile/chunkFile hash() + Rollup's
// preserveModulesRoot) hashes and strips `src/` on EVERY build regardless of
// mode, so the client-REFERENCE side (this fn, used by the transformer) must
// do the same — otherwise a `vite build --mode development` (NODE_ENV=
// development) emits hashed/src-stripped chunks but bakes unhashed/src-kept
// refs into the RSC stream, and SSG fails with ERR_MODULE_NOT_FOUND.
// The dependency optimizer is NOT a concern here: it runs under
// command === "serve" (so isBuild === false) and only pre-bundles
// node_modules — it never names a first-party `src/*.client.*` chunk.
const shouldHash = isBuild;
const isProd = mode === "production" || isBuild;
const removeModuleBase =
(isProd || isBuild) && !options.build.preserveModulesRoot;
// Hash configuration
const hashOption = build?.hash ?? DEFAULT_CONFIG.BUILD.hash;
// Virtual pattern for excluding virtual modules from hashing
const virtualPattern = autoDiscover?.virtualPattern ?? DEFAULT_CONFIG.AUTO_DISCOVER.virtualPattern;
// A module is a client component (and therefore must get a hosted,
// `moduleBasePath`-prefixed moduleID) when the unified `detectClientModule`
// helper says so — filename `.client.[cm]?[jt]sx?$` OR a top-of-file
// `"use client"` directive. The `isClientByDirective` override fast-paths
// the build's transformer answer (computed with Rollup's JSX-aware
// `this.parse`), so we don't re-parse here.
//
// This is what lets directive-only client modules (no `.client.` suffix,
// e.g. node_modules libs that ship `"use client"`) be hosted in the static
// build instead of throwing "Attempted to load a Client Module outside the
// hosted root".
const isClientComponentId = (
id: string,
sourceContent?: string,
isClientByDirective?: boolean
) =>
isClientByDirective === true ||
detectClientModule({ source: sourceContent, moduleId: id });
// Hash function for client components - same logic as resolveOptions.ts
const hash = (
input: string | null,
_ssr: boolean,
sourceContent?: string,
isClientByDirective?: boolean
) => {
if (!input) return "";
if (new RegExp(/\.(node|d\.ts)$/).test(input)) {
return input;
}
// CRITICAL: Never hash node_modules files - Vite/Rollup handles those
if (input.includes("node_modules")) {
return input;
}
// CRITICAL: Never hash virtual modules (_virtual or matching virtualPattern) - Vite handles those
if (input.includes("_virtual") || (virtualPattern && virtualPattern.test(input))) {
return input;
}
// Check if hashing is disabled
if (hashOption === "false") {
return input;
}
// Only hash client components - server files should not be hashed.
// Recognize the `.client.` filename convention, a top-of-file
// `"use client"` directive (when source content is available), or an
// explicit directive override threaded from the transformer.
const isClientComponent = isClientComponentId(
input,
sourceContent,
isClientByDirective
);
if (!isClientComponent) {
return input;
}
// Always hash the source content for consistency across builds
// This ensures the same hash is generated in transformer and build process
let contentToHash: string;
if (sourceContent) {
// Use provided source content (preferred)
contentToHash = sourceContent;
} else {
// Try to read source file content
try {
const sourcePath = resolve(projectRoot, input);
if (existsSync(sourcePath)) {
contentToHash = readFileSync(sourcePath, 'utf-8');
} else {
// Fallback to filename
contentToHash = input;
}
} catch (error) {
// Fallback to filename
contentToHash = input;
}
}
// Generate hash using Rollup-like algorithm
const hashCharacters = typeof hashOption === 'object' && hashOption?.format === 'hex' ? 'hex' : 'base36';
const contentHash = createRollupLikeHash(contentToHash, hashCharacters);
// Apply naming logic
const extensionIndex = input.lastIndexOf(".");
if (extensionIndex !== -1) {
const extension = input.slice(extensionIndex);
const filename = input.slice(0, extensionIndex);
return filename + "-" + contentHash + extension;
} else {
return input + "-" + contentHash;
}
};
const staticClientDist = isBuild ? join(build?.outDir || "dist", build?.static || "static") : "";
const ssrClientDist = isBuild ? join(build?.outDir || "dist", build?.client || "client") : "";
const serverDist = isBuild ? join(build?.outDir || "dist", build?.server || "server") : "";
const buildDirs = isBuild ? [serverDist, ssrClientDist, staticClientDist] : [];
return (
id: string,
sourceContent?: string,
isClientByDirective?: boolean
) => {
// For transformer usage (when we're in build mode and processing server components),
// we want to strip build directory prefixes to get relative paths
// This ensures the RSC stream contains paths that can be resolved by the HTML transform
if (shouldHash) {
// Strip build directory prefixes to get relative paths
for (const buildDir of buildDirs) {
if (id.startsWith(buildDir)) {
const result = id.slice(buildDir.length);
return result;
}
}
// Check for double path issues (like dist/client//dist/server/)
if (id.includes('//')) {
// Try to fix double path issues by finding the last occurrence of dist/
const lastDistIndex = id.lastIndexOf('dist/');
if (lastDistIndex !== -1) {
const result = id.slice(lastDistIndex);
return result;
}
}
// For client components in build mode, transform source paths to built paths.
// Directive-detected client modules (no `.client.` suffix) are hosted too.
const isClientComponent = isClientComponentId(
id,
sourceContent,
isClientByDirective
);
if (isClientComponent) {
// Transform source path to built client path
let transformedId = id;
// Step 1: Remove moduleBase (typically "src/") from the beginning
if (removeModuleBase && transformedId.startsWith(moduleBase + sep)) {
transformedId = transformedId.slice(moduleBase.length + sep.length);
}
// Step 1b: Match the build's entryFile name normalization for
// DIRECTIVE-detected client modules. The emitted chunk name comes from
// `entryFile` → `normalizer(n.name)`, which strips one trailing
// ".segment" unless it's `.client`/`.server`. For a compound filename
// like `view/View.generated.tsx` that collapses to `view/View`, but
// this moduleID otherwise keeps `.generated` — so the registered client
// reference (`view/View.generated-<hash>.js`) wouldn't match the emitted
// chunk (`view/View-<hash>.js`) → ERR_MODULE_NOT_FOUND at SSG render.
// `.client.`-named modules are unaffected (the normalizer preserves
// that suffix, and so do we). Single-segment names have nothing to strip.
if (isClientByDirective) {
const noExt = transformedId.replace(/\.[cm]?[jt]sx?$/, "");
if (!noExt.endsWith(".client") && !noExt.endsWith(".server")) {
const lastDot = noExt.lastIndexOf(".");
if (lastDot > noExt.lastIndexOf("/")) {
transformedId =
noExt.slice(0, lastDot) + transformedId.slice(noExt.length);
}
}
}
// Step 2: Apply extension mapping for build
transformedId = replaceExtension(transformedId, {
build: { extensionMap: build.extensionMap },
});
// Step 3: Apply hashing for client components
transformedId = hash(transformedId, false, sourceContent, isClientByDirective);
// Step 4: Ensure paths start with moduleBasePath
if (moduleBasePath && !transformedId.startsWith(moduleBasePath)) {
transformedId = moduleBasePath + transformedId;
}
return transformedId;
}
return id;
}
// Normal build path transformation (existing logic)
// Step 1: Handle assets directory paths - remove src from within assets path
// Transform: assets/src/page/file.css -> assets/page/file.css
if (id.startsWith(assetsDir + sep + moduleBase + sep)) {
id = assetsDir + sep + id.slice((assetsDir + sep + moduleBase + sep).length);
}
// Step 2: Remove moduleBaseURL if present (for incoming IDs that already have base URL)
if (moduleBaseURL && moduleBaseURL !== "/" && id.startsWith(moduleBaseURL)) {
id = id.slice(moduleBaseURL.length);
}
// Step 3: Remove src after the moduleBasePath if present
if (moduleBasePath && moduleBasePath !== "/" && id.startsWith(moduleBasePath + moduleBase)) {
// slice inbetween the moduleBasePath and moduleBase
id = moduleBasePath + id.slice((moduleBasePath + moduleBase).length);
}
// Step 4: Remove moduleBase (typically "src/") from the beginning
if (removeModuleBase && id.startsWith(moduleBase + sep)) {
id = id.slice(moduleBase.length + sep.length);
}
// Step 5: Ensure paths start with moduleBasePath (avoid double-prefix)
if (moduleBasePath && !id.startsWith(moduleBasePath)) {
id = moduleBasePath + id;
}
// Step 6: Apply extension mapping — BUILD ONLY.
// In a build the browser can't import .tsx, so client components are mapped
// to .js. In DEV, Vite transpiles .tsx on the fly, so mapping there gives
// the client-reference id a phantom .js the dev module graph never has —
// the import resolves to "<name>.client.js.tsx", which 404s on the second
// HMR fetch and kills Fast Refresh after the first edit (bd-572). Keep the
// real .tsx id in dev.
const isClientComponent = isClientComponentId(
id,
sourceContent,
isClientByDirective
);
if (isBuild) {
id = replaceExtension(id, {
build: { extensionMap: build.extensionMap },
});
}
// Step 7: Ensure CSS files are placed in the assets directory
if (isBuild && id.endsWith('.css') && !id.startsWith(assetsDir + sep)) {
id = assetsDir + sep + id;
}
// Step 8: Apply hashing for client components (only in production builds, not dev)
if (shouldHash) {
id = hash(id, false, sourceContent, isClientByDirective);
}
// For client components, ensure no leading slash to allow proper relative resolution
// (isClientComponent already defined in Step 6)
if (isClientComponent && moduleBasePath === '') {
return id; // No leading slash for client components
}
// Don't add leading slash for relative paths - this causes module resolution issues
if (moduleBasePath === '') {
return id; // Return as-is without leading slash
}
// id already has moduleBasePath from Step 5 — return as-is
return id;
};
};