UNPKG

vite-plugin-react-server

Version:
314 lines (274 loc) 12.6 kB
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; }; };