diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 75389d1d..95c8b2bf 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -13,7 +13,7 @@ * needed for most Next.js apps. */ -import vinext, { clientOutputConfig, clientTreeshakeConfig } from "./index.js"; +import vinext, { getClientBuildOptions, getViteMajorVersion } from "./index.js"; import { printBuildReport } from "./build/report.js"; import { runPrerender } from "./build/run-prerender.js"; import path from "node:path"; @@ -362,6 +362,7 @@ async function buildApp() { console.log(`\n vinext build (Vite ${getViteVersion()})\n`); const isApp = hasAppDir(); + const viteMajorVersion = getViteMajorVersion(); // In verbose mode, skip the custom logger so raw Vite/Rollup output is shown. const logger = parsed.verbose ? vite.createLogger("info", { allowClearScreen: false }) @@ -465,11 +466,9 @@ async function buildApp() { outDir: "dist/client", manifest: true, ssrManifest: true, - rollupOptions: { - input: "virtual:vinext-client-entry", - output: clientOutputConfig, - treeshake: clientTreeshakeConfig, - }, + ...getClientBuildOptions(viteMajorVersion, { + index: "virtual:vinext-client-entry", + }), }, }, logger, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 7ccec5b7..350925a0 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -18,12 +18,8 @@ import { generateSsrEntry } from "./entries/app-ssr-entry.js"; import { generateBrowserEntry } from "./entries/app-browser-entry.js"; import { normalizePathnameForRouteMatchStrict } from "./routing/utils.js"; import { - findNextConfigPath, loadNextConfig, - resolveNextConfigInput, resolveNextConfig, - type NextConfig, - type NextConfigInput, type ResolvedNextConfig, type NextRedirect, type NextRewrite, @@ -33,8 +29,14 @@ import { import { findMiddlewareFile, runMiddleware } from "./server/middleware.js"; import { logRequest, now } from "./server/request-log.js"; import { normalizePath } from "./server/normalize-path.js"; -import { findInstrumentationFile, runInstrumentation } from "./server/instrumentation.js"; -import { PHASE_PRODUCTION_BUILD, PHASE_DEVELOPMENT_SERVER } from "./shims/constants.js"; +import { + findInstrumentationFile, + runInstrumentation, +} from "./server/instrumentation.js"; +import { + PHASE_PRODUCTION_BUILD, + PHASE_DEVELOPMENT_SERVER, +} from "./shims/constants.js"; import { validateDevRequest } from "./server/dev-origin-check.js"; import { isExternalUrl, @@ -59,7 +61,6 @@ import { asyncHooksStubPlugin } from "./plugins/async-hooks-stub.js"; import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js"; import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js"; import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js"; -import tsconfigPaths from "vite-tsconfig-paths"; import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react"; import MagicString from "magic-string"; import path from "node:path"; @@ -68,13 +69,19 @@ import { createRequire } from "node:module"; import fs from "node:fs"; import { randomBytes } from "node:crypto"; import commonjs from "vite-plugin-commonjs"; +import tsconfigPaths from "vite-tsconfig-paths"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); type VitePluginReactModule = typeof import("@vitejs/plugin-react"); -function resolveOptionalDependency(projectRoot: string, specifier: string): string | null { +function resolveOptionalDependency( + projectRoot: string, + specifier: string, +): string | null { try { - const projectRequire = createRequire(path.join(projectRoot, "package.json")); + const projectRequire = createRequire( + path.join(projectRoot, "package.json"), + ); return projectRequire.resolve(specifier); } catch {} @@ -115,7 +122,10 @@ async function fetchAndCacheFont( // Use a hash of the URL for the cache key const { createHash } = await import("node:crypto"); const urlHash = createHash("md5").update(cssUrl).digest("hex").slice(0, 12); - const fontDir = path.join(cacheDir, `${family.toLowerCase().replace(/\s+/g, "-")}-${urlHash}`); + const fontDir = path.join( + cacheDir, + `${family.toLowerCase().replace(/\s+/g, "-")}-${urlHash}`, + ); // Check if already cached const cachedCSSPath = path.join(fontDir, "style.css"); @@ -147,8 +157,14 @@ async function fetchAndCacheFont( : fontUrl.includes(".woff") ? ".woff" : ".ttf"; - const fileHash = createHash("md5").update(fontUrl).digest("hex").slice(0, 8); - urls.set(fontUrl, `${family.toLowerCase().replace(/\s+/g, "-")}-${fileHash}${ext}`); + const fileHash = createHash("md5") + .update(fontUrl) + .digest("hex") + .slice(0, 8); + urls.set( + fontUrl, + `${family.toLowerCase().replace(/\s+/g, "-")}-${fileHash}${ext}`, + ); } } @@ -181,7 +197,9 @@ async function fetchAndCacheFont( * Supports: string literals, numeric literals, boolean literals, * arrays of the above, and nested object literals. */ -function parseStaticObjectLiteral(objectStr: string): Record | null { +function parseStaticObjectLiteral( + objectStr: string, +): Record | null { let ast: ReturnType; try { // Wrap in parens so the parser treats `{…}` as an expression, not a block @@ -248,7 +266,10 @@ function extractStaticValue(node: any): unknown { let key: string; if (prop.key.type === "Identifier") { key = prop.key.name; - } else if (prop.key.type === "Literal" && typeof prop.key.value === "string") { + } else if ( + prop.key.type === "Literal" && + typeof prop.key.value === "string" + ) { key = prop.key.value; } else { return undefined; @@ -267,147 +288,13 @@ function extractStaticValue(node: any): unknown { } } -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -const TSCONFIG_FILES = ["tsconfig.json", "jsconfig.json"]; - -function resolveTsconfigPathCandidate(candidate: string): string | null { - const candidates = candidate.endsWith(".json") - ? [candidate] - : [candidate, `${candidate}.json`, path.join(candidate, "tsconfig.json")]; - - for (const item of candidates) { - if (fs.existsSync(item) && fs.statSync(item).isFile()) { - return item; - } - } - - return null; -} - -function resolveTsconfigExtends(configPath: string, specifier: string): string | null { - const fromDir = path.dirname(configPath); - if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("\\")) { - return resolveTsconfigPathCandidate(path.resolve(fromDir, specifier)); - } - - const requireFromConfig = createRequire(configPath); - const candidates = [specifier, `${specifier}.json`, path.join(specifier, "tsconfig.json")]; - - for (const item of candidates) { - try { - return requireFromConfig.resolve(item); - } catch {} - } - - return null; -} - -function materializeTsconfigPathAliases( - pathsConfig: Record, - baseUrl: string, - projectRoot: string, -): Record { - const aliases: Record = {}; - - for (const [find, rawTargets] of Object.entries(pathsConfig)) { - const target = Array.isArray(rawTargets) - ? rawTargets.find((value): value is string => typeof value === "string") - : typeof rawTargets === "string" - ? rawTargets - : null; - if (!target) continue; - - if (find.includes("*") || target.includes("*")) { - if (!find.endsWith("/*") || !target.endsWith("/*")) continue; - if (find.indexOf("*") !== find.length - 1 || target.indexOf("*") !== target.length - 1) { - continue; - } - - const aliasKey = find.slice(0, -2); - const targetDir = target.slice(0, -2); - if (!aliasKey || !targetDir) continue; - - aliases[aliasKey] = toViteAliasReplacement(path.resolve(baseUrl, targetDir), projectRoot); - continue; - } - - aliases[find] = toViteAliasReplacement(path.resolve(baseUrl, target), projectRoot); - } - - return aliases; -} - -function toViteAliasReplacement(absolutePath: string, projectRoot: string): string { - const normalizedPath = absolutePath.replace(/\\/g, "/"); - const rootCandidates = new Set([projectRoot]); - const realRoot = tryRealpathSync(projectRoot); - if (realRoot) rootCandidates.add(realRoot); - - const pathCandidates = new Set([absolutePath]); - const realPath = tryRealpathSync(absolutePath); - if (realPath) pathCandidates.add(realPath); - - for (const rootCandidate of rootCandidates) { - for (const pathCandidate of pathCandidates) { - if (pathCandidate === rootCandidate) return "/"; - const relativeId = relativeWithinRoot(rootCandidate, pathCandidate); - if (relativeId) return "/" + relativeId; - } - } - - return normalizedPath; -} - -function loadTsconfigPathAliases( - configPath: string, - projectRoot: string, - seen = new Set(), -): Record { - const normalizedPath = tryRealpathSync(configPath) ?? configPath; - if (seen.has(normalizedPath)) return {}; - seen.add(normalizedPath); - - let parsed: Record | null = null; - try { - parsed = parseStaticObjectLiteral(fs.readFileSync(normalizedPath, "utf-8")); - } catch { - return {}; - } - if (!parsed) return {}; - - let aliases: Record = {}; - if (typeof parsed.extends === "string") { - const extendedPath = resolveTsconfigExtends(normalizedPath, parsed.extends); - if (extendedPath) { - aliases = loadTsconfigPathAliases(extendedPath, projectRoot, seen); - } - } - - const compilerOptions = isRecord(parsed.compilerOptions) ? parsed.compilerOptions : null; - const pathsConfig = - compilerOptions && isRecord(compilerOptions.paths) ? compilerOptions.paths : null; - if (!pathsConfig) return aliases; - - const baseUrl = - compilerOptions && typeof compilerOptions.baseUrl === "string" ? compilerOptions.baseUrl : "."; - const resolvedBaseUrl = path.resolve(path.dirname(normalizedPath), baseUrl); - - return { - ...aliases, - ...materializeTsconfigPathAliases(pathsConfig, resolvedBaseUrl, projectRoot), - }; -} - /** * Detect Vite major version at runtime by resolving from cwd. * The plugin may be installed in a workspace root with Vite 7 but used * by a project that has Vite 8 — so we resolve from cwd, not from * the plugin's own location. */ -function getViteMajorVersion(): number { +export function getViteMajorVersion(): number { try { const require = createRequire(path.join(process.cwd(), "package.json")); const vitePkg = require("vite/package.json"); @@ -430,7 +317,9 @@ function getViteMajorVersion(): number { return 7; } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.warn(`[vinext] Failed to resolve vite/package.json (${message}); assuming Vite 7`); + console.warn( + `[vinext] Failed to resolve vite/package.json (${message}); assuming Vite 7`, + ); return 7; } } @@ -467,27 +356,10 @@ const POSTCSS_CONFIG_FILES = [ * Stores the Promise itself so concurrent calls (RSC/SSR/Client config() hooks firing in * parallel) all await the same in-flight scan rather than each starting their own. */ -const _postcssCache = new Map>(); -// Cache materialized tsconfig/jsconfig aliases so Vite's glob and dynamic-import -// transforms can see them via resolve.alias without re-reading config files per env. -const _tsconfigAliasCache = new Map>(); - -function resolveTsconfigAliases(projectRoot: string): Record { - if (_tsconfigAliasCache.has(projectRoot)) { - return _tsconfigAliasCache.get(projectRoot)!; - } - - let aliases: Record = {}; - for (const name of TSCONFIG_FILES) { - const candidate = path.join(projectRoot, name); - if (!fs.existsSync(candidate)) continue; - aliases = loadTsconfigPathAliases(candidate, projectRoot); - break; - } - - _tsconfigAliasCache.set(projectRoot, aliases); - return aliases; -} +const _postcssCache = new Map< + string, + Promise<{ plugins: any[] } | undefined> +>(); /** * Resolve PostCSS string plugin names in a project's PostCSS config. @@ -501,7 +373,9 @@ function resolveTsconfigAliases(projectRoot: string): Record { * Returns the resolved PostCSS config object to inject into Vite's * `css.postcss`, or `undefined` if no resolution is needed. */ -function resolvePostcssStringPlugins(projectRoot: string): Promise<{ plugins: any[] } | undefined> { +function resolvePostcssStringPlugins( + projectRoot: string, +): Promise<{ plugins: any[] } | undefined> { if (_postcssCache.has(projectRoot)) return _postcssCache.get(projectRoot)!; const promise = _resolvePostcssStringPluginsUncached(projectRoot); @@ -557,7 +431,8 @@ async function _resolvePostcssStringPluginsUncached( return undefined; } const hasStringPlugins = config.plugins.some( - (p: any) => typeof p === "string" || (Array.isArray(p) && typeof p[0] === "string"), + (p: any) => + typeof p === "string" || (Array.isArray(p) && typeof p[0] === "string"), ); if (!hasStringPlugins) { return undefined; @@ -603,23 +478,11 @@ const VIRTUAL_APP_SSR_ENTRY = "virtual:vinext-app-ssr-entry"; const RESOLVED_APP_SSR_ENTRY = "\0" + VIRTUAL_APP_SSR_ENTRY; const VIRTUAL_APP_BROWSER_ENTRY = "virtual:vinext-app-browser-entry"; const RESOLVED_APP_BROWSER_ENTRY = "\0" + VIRTUAL_APP_BROWSER_ENTRY; -const VIRTUAL_GOOGLE_FONTS = "virtual:vinext-google-fonts"; -const RESOLVED_VIRTUAL_GOOGLE_FONTS = "\0" + VIRTUAL_GOOGLE_FONTS; /** Image file extensions handled by the vinext:image-imports plugin. * Shared between the Rolldown hook filter and the transform handler regex. */ const IMAGE_EXTS = "png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?"; -// IMPORTANT: keep this set in sync with the non-default exports from -// packages/vinext/src/shims/font-google.ts (and its re-export barrel). -const GOOGLE_FONT_UTILITY_EXPORTS = new Set([ - "buildGoogleFontsUrl", - "getSSRFontLinks", - "getSSRFontStyles", - "getSSRFontPreloads", - "createFontLoader", -]); - /** * Extract the npm package name from a module ID (file path). * Returns null if not in node_modules. @@ -641,162 +504,6 @@ function getPackageName(id: string): string | null { /** Absolute path to vinext's shims directory, used by clientManualChunks. */ const _shimsDir = path.resolve(__dirname, "shims") + "/"; -const _fontGoogleShimPath = resolveShimModulePath(_shimsDir, "font-google"); - -type GoogleFontNamedSpecifier = { - imported: string; - local: string; - isType: boolean; - raw: string; -}; - -function parseGoogleFontNamedSpecifiers( - specifiersStr: string, - forceType = false, -): GoogleFontNamedSpecifier[] { - return specifiersStr - .split(",") - .map((spec) => spec.trim()) - .filter(Boolean) - .map((raw) => { - const isType = forceType || raw.startsWith("type "); - const valueSpec = isType ? raw.replace(/^type\s+/, "") : raw; - const asParts = valueSpec.split(/\s+as\s+/); - const imported = asParts[0]?.trim() ?? ""; - const local = (asParts[1] || asParts[0] || "").trim(); - return { imported, local, isType, raw }; - }) - .filter((spec) => spec.imported.length > 0 && spec.local.length > 0); -} - -function parseGoogleFontImportClause(clause: string): { - defaultLocal: string | null; - namespaceLocal: string | null; - named: GoogleFontNamedSpecifier[]; -} { - const trimmed = clause.trim(); - - if (trimmed.startsWith("type ")) { - const braceStart = trimmed.indexOf("{"); - const braceEnd = trimmed.lastIndexOf("}"); - if (braceStart === -1 || braceEnd === -1) { - return { defaultLocal: null, namespaceLocal: null, named: [] }; - } - return { - defaultLocal: null, - namespaceLocal: null, - named: parseGoogleFontNamedSpecifiers(trimmed.slice(braceStart + 1, braceEnd), true), - }; - } - - const braceStart = trimmed.indexOf("{"); - const braceEnd = trimmed.lastIndexOf("}"); - if (braceStart !== -1 && braceEnd !== -1) { - const beforeNamed = trimmed.slice(0, braceStart).trim().replace(/,\s*$/, "").trim(); - return { - defaultLocal: beforeNamed || null, - namespaceLocal: null, - named: parseGoogleFontNamedSpecifiers(trimmed.slice(braceStart + 1, braceEnd)), - }; - } - - const commaIndex = trimmed.indexOf(","); - if (commaIndex !== -1) { - const defaultLocal = trimmed.slice(0, commaIndex).trim() || null; - const rest = trimmed.slice(commaIndex + 1).trim(); - if (rest.startsWith("* as ")) { - return { - defaultLocal, - namespaceLocal: rest.slice("* as ".length).trim() || null, - named: [], - }; - } - } - - if (trimmed.startsWith("* as ")) { - return { - defaultLocal: null, - namespaceLocal: trimmed.slice("* as ".length).trim() || null, - named: [], - }; - } - - return { - defaultLocal: trimmed || null, - namespaceLocal: null, - named: [], - }; -} - -function encodeGoogleFontsVirtualId(payload: { - hasDefault: boolean; - fonts: string[]; - utilities: string[]; -}): string { - const params = new URLSearchParams(); - if (payload.hasDefault) params.set("default", "1"); - if (payload.fonts.length > 0) params.set("fonts", payload.fonts.join(",")); - if (payload.utilities.length > 0) params.set("utilities", payload.utilities.join(",")); - return `${VIRTUAL_GOOGLE_FONTS}?${params.toString()}`; -} - -function parseGoogleFontsVirtualId(id: string): { - hasDefault: boolean; - fonts: string[]; - utilities: string[]; -} | null { - const cleanId = id.startsWith("\0") ? id.slice(1) : id; - if (!cleanId.startsWith(VIRTUAL_GOOGLE_FONTS)) return null; - const queryIndex = cleanId.indexOf("?"); - const params = new URLSearchParams(queryIndex === -1 ? "" : cleanId.slice(queryIndex + 1)); - return { - hasDefault: params.get("default") === "1", - fonts: - params - .get("fonts") - ?.split(",") - .map((value) => value.trim()) - .filter(Boolean) ?? [], - utilities: - params - .get("utilities") - ?.split(",") - .map((value) => value.trim()) - .filter(Boolean) ?? [], - }; -} - -function generateGoogleFontsVirtualModule(id: string): string | null { - const payload = parseGoogleFontsVirtualId(id); - if (!payload) return null; - - const utilities = Array.from(new Set(payload.utilities)); - const fonts = Array.from(new Set(payload.fonts)); - const lines: string[] = []; - - lines.push(`import { createFontLoader } from ${JSON.stringify(_fontGoogleShimPath)};`); - - const reExports: string[] = []; - if (payload.hasDefault) reExports.push("default"); - reExports.push(...utilities); - if (reExports.length > 0) { - lines.push(`export { ${reExports.join(", ")} } from ${JSON.stringify(_fontGoogleShimPath)};`); - } - - for (const fontName of fonts) { - const family = fontName.replace(/_/g, " "); - lines.push( - `export const ${fontName} = /*#__PURE__*/ createFontLoader(${JSON.stringify(family)});`, - ); - } - - lines.push(""); - return lines.join("\n"); -} - -function propertyNameToGoogleFontFamily(prop: string): string { - return prop.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2"); -} /** * manualChunks function for client builds. @@ -861,6 +568,8 @@ function clientManualChunks(id: string): string | undefined { * their importers. This reduces HTTP request count and improves gzip * compression efficiency — small files restart the compression dictionary, * adding ~5-15% wire overhead vs fewer larger chunks. + * + * @deprecated Use `getClientOutputConfig()` instead — applies version-gated config. */ const clientOutputConfig = { manualChunks: clientManualChunks, @@ -891,12 +600,85 @@ const clientOutputConfig = { * tryCatchDeoptimization: false, which can break specific libraries * that rely on property access side effects or try/catch for feature detection * - 'recommended' + 'no-external' gives most of the benefit with less risk + * + * @deprecated Use `getClientTreeshakeConfig()` instead — applies version-gated config. */ const clientTreeshakeConfig = { preset: "recommended" as const, moduleSideEffects: "no-external" as const, }; +/** + * Get Rollup-compatible output config for client builds. + * Returns config without Vite 8/Rolldown-incompatible options. + */ +function getClientOutputConfig(viteVersion: number): { + manualChunks: typeof clientManualChunks; + experimentalMinChunkSize?: number; +} { + if (viteVersion >= 8) { + // Vite 8+ uses Rolldown which doesn't support experimentalMinChunkSize + return { + manualChunks: clientManualChunks, + }; + } + // Vite 7 uses Rollup with experimentalMinChunkSize support + return clientOutputConfig; +} + +/** + * Get Rollup-compatible treeshake config for client builds. + * Returns config without Vite 8/Rolldown-incompatible options. + */ +function getClientTreeshakeConfig(viteVersion: number): { + preset?: "recommended"; + moduleSideEffects: "no-external"; +} { + if (viteVersion >= 8) { + // Vite 8+ uses Rolldown which doesn't support `preset` option + // moduleSideEffects is still supported in Rolldown + return { + moduleSideEffects: "no-external" as const, + }; + } + // Vite 7 uses Rollup with preset support + return clientTreeshakeConfig; +} + +/** + * Get build options config for client builds, version-gated for Vite 8/Rolldown. + * Vite 7 uses build.rollupOptions, Vite 8+ uses build.rolldownOptions. + * @param viteVersion - Vite major version + * @param input - Optional input config for custom entry points + */ +function getClientBuildOptions( + viteVersion: number, + input?: Record, +): { + rollupOptions?: { + input?: Record; + output: { + manualChunks: typeof clientManualChunks; + experimentalMinChunkSize?: number; + }; + treeshake: { preset?: "recommended"; moduleSideEffects: "no-external" }; + }; + rolldownOptions?: { + input?: Record; + output: { manualChunks: typeof clientManualChunks }; + treeshake: { moduleSideEffects: "no-external" }; + }; +} { + const optionsKey = viteVersion >= 8 ? "rolldownOptions" : "rollupOptions"; + return { + [optionsKey]: { + ...(input ? { input } : {}), + output: getClientOutputConfig(viteVersion), + treeshake: getClientTreeshakeConfig(viteVersion), + }, + }; +} + type BuildManifestChunk = { file: string; isEntry?: boolean; @@ -924,7 +706,9 @@ type BuildManifestChunk = { * @returns Array of chunk filenames (e.g. "assets/mermaid-NOHMQCX5.js") that * should be excluded from modulepreload hints. */ -function computeLazyChunks(buildManifest: Record): string[] { +function computeLazyChunks( + buildManifest: Record, +): string[] { // Collect all chunk files that are statically reachable from entries const eagerFiles = new Set(); const visited = new Set(); @@ -1006,20 +790,27 @@ function isWindowsAbsolutePath(candidate: string): boolean { } function relativeWithinRoot(root: string, moduleId: string): string | null { - const useWindowsPath = isWindowsAbsolutePath(root) || isWindowsAbsolutePath(moduleId); + const useWindowsPath = + isWindowsAbsolutePath(root) || isWindowsAbsolutePath(moduleId); const relativeId = ( - useWindowsPath ? path.win32.relative(root, moduleId) : path.relative(root, moduleId) + useWindowsPath + ? path.win32.relative(root, moduleId) + : path.relative(root, moduleId) ).replace(/\\/g, "/"); // path.relative(root, root) returns "", which is not a usable manifest key and should be // treated the same as "outside root" for this helper. - if (!relativeId || relativeId === ".." || relativeId.startsWith("../")) return null; + if (!relativeId || relativeId === ".." || relativeId.startsWith("../")) + return null; return relativeId; } function normalizeManifestModuleId(moduleId: string, root: string): string { const normalizedId = moduleId.replace(/\\/g, "/"); if (normalizedId.startsWith("\0")) return normalizedId; - if (normalizedId.startsWith("node_modules/") || normalizedId.includes("/node_modules/")) { + if ( + normalizedId.startsWith("node_modules/") || + normalizedId.includes("/node_modules/") + ) { return normalizedId; } @@ -1070,7 +861,8 @@ function augmentSsrManifestFromBundle( for (const [key, files] of Object.entries(ssrManifest)) { const normalizedKey = normalizeManifestModuleId(key, root); - if (!nextManifest[normalizedKey]) nextManifest[normalizedKey] = new Set(); + if (!nextManifest[normalizedKey]) + nextManifest[normalizedKey] = new Set(); for (const file of files) { nextManifest[normalizedKey].add(normalizeManifestFile(file)); } @@ -1094,7 +886,8 @@ function augmentSsrManifestFromBundle( for (const moduleId of Object.keys(chunk.modules ?? {})) { const key = normalizeManifestModuleId(moduleId, root); - if (key.startsWith("node_modules/") || key.includes("/node_modules/")) continue; + if (key.startsWith("node_modules/") || key.includes("/node_modules/")) + continue; if (key.startsWith("\0")) continue; if (!nextManifest[key]) nextManifest[key] = new Set(); for (const file of files) { @@ -1143,14 +936,6 @@ export interface VinextOptions { * Defaults to Vite's default (dist/client or dist). */ clientOutDir?: string; - /** - * Inline Next.js config for projects that want to configure vinext from - * vite.config without a separate next.config file. - * - * When provided, vinext skips loading next.config.* from disk and uses this - * value instead. Supports both object-form and function-form config. - */ - nextConfig?: NextConfigInput; /** * Auto-register @vitejs/plugin-rsc when an app/ directory is detected. * Set to `false` to disable auto-registration (e.g. if you configure @@ -1180,6 +965,27 @@ export interface VinextOptions { }; } +/** + * Helper to suppress "Module level directives cause errors when bundled" + * warnings for "use client" / "use server" directives. + */ +function createDirectiveOnwarn(userOnwarn?: (warning: any, handler: (w: any) => void) => void) { + return (warning: any, defaultHandler: (warning: any) => void) => { + if ( + warning.code === "MODULE_LEVEL_DIRECTIVE" && + (warning.message?.includes('"use client"') || + warning.message?.includes('"use server"')) + ) { + return; + } + if (userOnwarn) { + userOnwarn(warning, defaultHandler); + } else { + defaultHandler(warning); + } + }; +} + export default function vinext(options: VinextOptions = {}): PluginOption[] { const viteMajorVersion = getViteMajorVersion(); let root: string; @@ -1192,7 +998,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let middlewarePath: string | null = null; let instrumentationPath: string | null = null; let hasCloudflarePlugin = false; - let warnedInlineNextConfigOverride = false; let hasNitroPlugin = false; // Resolve shim paths - works both from source (.ts) and built (.js) @@ -1261,8 +1066,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Prefer the user's project graph so vinext shares the app's Vite/plugin // instances. In source/workspace development, test fixtures may not declare // peer deps explicitly, so fall back to vinext's own install location. - resolvedReactPath = resolveOptionalDependency(earlyBaseDir, "@vitejs/plugin-react"); - resolvedRscPath = resolveOptionalDependency(earlyBaseDir, "@vitejs/plugin-rsc"); + resolvedReactPath = resolveOptionalDependency( + earlyBaseDir, + "@vitejs/plugin-react", + ); + resolvedRscPath = resolveOptionalDependency( + earlyBaseDir, + "@vitejs/plugin-rsc", + ); resolvedRscTransformsPath = resolveOptionalDependency( earlyBaseDir, "@vitejs/plugin-rsc/transforms", @@ -1294,11 +1105,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }); }) .catch((cause) => { - throw new Error("vinext: Failed to load @vitejs/plugin-rsc.", { cause }); + throw new Error("vinext: Failed to load @vitejs/plugin-rsc.", { + cause, + }); }); } - const reactOptions = options.react && options.react !== true ? options.react : undefined; + const reactOptions = + options.react && options.react !== true ? options.react : undefined; let reactPluginPromise: Promise | null = null; if (options.react !== false) { @@ -1314,11 +1128,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { reactPluginPromise = reactImport .then((mod) => (mod as VitePluginReactModule).default(reactOptions)) .catch((cause) => { - throw new Error("vinext: Failed to load @vitejs/plugin-react.", { cause }); + throw new Error("vinext: Failed to load @vitejs/plugin-react.", { + cause, + }); }); } - const imageImportDimCache = new Map(); + const imageImportDimCache = new Map< + string, + { width: number; height: number } + >(); // Shared state for the MDX proxy plugin. Populated during config() if MDX // files are detected and @mdx-js/rollup is installed. @@ -1382,7 +1201,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { for (const elem of pattern.elements) { collectPatternNames(elem, names); } - } else if (pattern.type === "RestElement" || pattern.type === "AssignmentPattern") { + } else if ( + pattern.type === "RestElement" || + pattern.type === "AssignmentPattern" + ) { collectPatternNames(pattern.left ?? pattern.argument, names); } } @@ -1451,7 +1273,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // of this node (e.g. top-level Program statements, or the direct body of // a BlockStatement) — those ARE in scope for everything in the same block. const immediateStmts: any[] = - node.type === "Program" ? node.body : node.type === "BlockStatement" ? node.body : []; + node.type === "Program" + ? node.body + : node.type === "BlockStatement" + ? node.body + : []; for (const stmt of immediateStmts) { if (stmt?.type === "VariableDeclaration") { for (const decl of stmt.declarations) @@ -1482,10 +1308,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Build the ancestor name set visible inside this function: // everything the parent saw, plus this function's own params. const namesForBody = new Set(ancestorNames); - for (const p of node.params ?? []) collectPatternNames(p, namesForBody); + for (const p of node.params ?? []) + collectPatternNames(p, namesForBody); // Check whether the body has the 'use server' directive. - const bodyStmts: any[] = node.body?.type === "BlockStatement" ? node.body.body : []; + const bodyStmts: any[] = + node.body?.type === "BlockStatement" ? node.body.body : []; const isServerFn = hasUseServerDirective(bodyStmts); if (isServerFn) { @@ -1564,7 +1392,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { collectFunctionScopedNames(node.body, namesForChildren); for (const stmt of bodyStmts) { if (stmt?.type === "VariableDeclaration" && stmt.kind !== "var") { - for (const decl of stmt.declarations) collectPatternNames(decl.id, namesForChildren); + for (const decl of stmt.declarations) + collectPatternNames(decl.id, namesForChildren); } } @@ -1592,7 +1421,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // - Never call s.update() on the same source range twice // // `parent` is the direct parent AST node, used to detect property contexts. - function renamingWalk(node: any, from: string, to: string, parent?: any) { + function renamingWalk( + node: any, + from: string, + to: string, + parent?: any, + ) { if (!node || typeof node !== "object") return; if (node.type === "Identifier" && node.name === from) { @@ -1606,7 +1440,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } // Non-computed property key in an object literal - if (parent?.type === "Property" && parent.key === node && !parent.computed) { + if ( + parent?.type === "Property" && + parent.key === node && + !parent.computed + ) { if (parent.shorthand) { // { cookies } — key and value are the same AST node. // Expand to { cookies: __local_cookies } by rewriting at the key @@ -1622,7 +1460,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } // Value side of a shorthand property — same node as key, already handled - if (parent?.type === "Property" && parent.shorthand && parent.value === node) { + if ( + parent?.type === "Property" && + parent.shorthand && + parent.value === node + ) { return; } @@ -1633,7 +1475,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // break/continue label: `break cookies` / `continue cookies` — not a variable reference if ( - (parent?.type === "BreakStatement" || parent?.type === "ContinueStatement") && + (parent?.type === "BreakStatement" || + parent?.type === "ContinueStatement") && parent.label === node ) { return; @@ -1658,7 +1501,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ) { const nestedDecls = new Set(); // Params - for (const p of node.params ?? []) collectPatternNames(p, nestedDecls); + for (const p of node.params ?? []) + collectPatternNames(p, nestedDecls); // Recursively find all var/const/let declarations in the body, // including those nested inside if/for/while/etc. collectAllDeclaredNames(node.body, nestedDecls); @@ -1666,7 +1510,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Also stop at nested 'use server' functions — visitNode will handle them // independently with the correct collision set, preventing double-rewrites. - if (node.body?.type === "BlockStatement" && hasUseServerDirective(node.body.body)) { + if ( + node.body?.type === "BlockStatement" && + hasUseServerDirective(node.body.body) + ) { return; } } @@ -1717,14 +1564,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } // FunctionExpression / ArrowFunctionExpression names are only in scope // inside their own body, not the enclosing scope — skip entirely. - if (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") { + if ( + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ) { return; } // var declarations are function-scoped — collect them wherever they appear. // let/const at a nested block level are block-scoped and NOT visible to // sibling or outer function declarations, so skip them here. if (node.type === "VariableDeclaration" && node.kind === "var") { - for (const decl of node.declarations) collectPatternNames(decl.id, names); + for (const decl of node.declarations) + collectPatternNames(decl.id, names); } for (const key of Object.keys(node)) { if (key === "type") continue; @@ -1746,7 +1597,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { function collectAllDeclaredNames(node: any, names: Set) { if (!node || typeof node !== "object") return; if (node.type === "VariableDeclaration") { - for (const decl of node.declarations) collectPatternNames(decl.id, names); + for (const decl of node.declarations) + collectPatternNames(decl.id, names); } // FunctionDeclaration name is a binding in the enclosing scope — record it. if (node.type === "FunctionDeclaration") { @@ -1758,7 +1610,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (node.id?.name) names.add(node.id.name); return; // don't recurse into the class body (separate scope) } - if (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") { + if ( + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ) { return; // different scope — stop } for (const key of Object.keys(node)) { @@ -1775,7 +1630,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { visitNode(ast, new Set()); if (!changed) return null; - return { code: s.toString(), map: s.generateMap({ hires: "boundary" }) }; + return { + code: s.toString(), + map: s.generateMap({ hires: "boundary" }), + }; }, }, { @@ -1784,10 +1642,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { async config(config, env) { root = config.root ?? process.cwd(); - const userResolve = config.resolve as UserResolveConfigWithTsconfigPaths | undefined; + const userResolve = config.resolve as + | UserResolveConfigWithTsconfigPaths + | undefined; const shouldEnableNativeTsconfigPaths = viteMajorVersion >= 8 && userResolve?.tsconfigPaths === undefined; - const tsconfigPathAliases = resolveTsconfigAliases(root); // Load .env files into process.env before anything else. // Next.js loads .env files before evaluating next.config.js, so @@ -1845,22 +1704,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { hasPagesDir = fs.existsSync(pagesDir); hasAppDir = !options.disableAppRouter && fs.existsSync(appDir); - // Load next.config.js if present (always from project root, not src/), - // unless vinext({ nextConfig }) explicitly overrides it. - const phase = env?.command === "build" ? PHASE_PRODUCTION_BUILD : PHASE_DEVELOPMENT_SERVER; - let rawConfig: NextConfig | null; - if (options.nextConfig) { - const diskConfigPath = findNextConfigPath(root); - if (diskConfigPath && !warnedInlineNextConfigOverride) { - warnedInlineNextConfigOverride = true; - console.warn( - `[vinext] vinext({ nextConfig }) overrides ${path.basename(diskConfigPath)}. Remove one of the config sources to avoid drift.`, - ); - } - rawConfig = await resolveNextConfigInput(options.nextConfig, phase); - } else { - rawConfig = await loadNextConfig(root, phase); - } + // Load next.config.js if present (always from project root, not src/) + const phase = + env?.command === "build" + ? PHASE_PRODUCTION_BUILD + : PHASE_DEVELOPMENT_SERVER; + const rawConfig = await loadNextConfig(root, phase); nextConfig = await resolveNextConfig(rawConfig, root); fileMatcher = createValidFileMatcher(nextConfig.pageExtensions); instrumentationPath = findInstrumentationFile(root, fileMatcher); @@ -1882,7 +1731,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { defines[`process.env.${key}`] = JSON.stringify(value); } // Expose basePath to client-side code - defines["process.env.__NEXT_ROUTER_BASEPATH"] = JSON.stringify(nextConfig.basePath); + defines["process.env.__NEXT_ROUTER_BASEPATH"] = JSON.stringify( + nextConfig.basePath, + ); // Expose image remote patterns for validation in next/image shim defines["process.env.__VINEXT_IMAGE_REMOTE_PATTERNS"] = JSON.stringify( JSON.stringify(nextConfig.images?.remotePatterns ?? []), @@ -1897,26 +1748,35 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const deviceSizes = nextConfig.images?.deviceSizes ?? [ 640, 750, 828, 1080, 1200, 1920, 2048, 3840, ]; - const imageSizes = nextConfig.images?.imageSizes ?? [16, 32, 48, 64, 96, 128, 256, 384]; + const imageSizes = nextConfig.images?.imageSizes ?? [ + 16, 32, 48, 64, 96, 128, 256, 384, + ]; defines["process.env.__VINEXT_IMAGE_DEVICE_SIZES"] = JSON.stringify( JSON.stringify(deviceSizes), ); - defines["process.env.__VINEXT_IMAGE_SIZES"] = JSON.stringify(JSON.stringify(imageSizes)); + defines["process.env.__VINEXT_IMAGE_SIZES"] = JSON.stringify( + JSON.stringify(imageSizes), + ); } // Expose dangerouslyAllowSVG flag for the image shim's auto-skip logic. // When false (default), .svg sources bypass the optimization endpoint. - defines["process.env.__VINEXT_IMAGE_DANGEROUSLY_ALLOW_SVG"] = JSON.stringify( - String(nextConfig.images?.dangerouslyAllowSVG ?? false), - ); + defines["process.env.__VINEXT_IMAGE_DANGEROUSLY_ALLOW_SVG"] = + JSON.stringify( + String(nextConfig.images?.dangerouslyAllowSVG ?? false), + ); // Draft mode secret — generated once at build time so the // __prerender_bypass cookie is consistent across all server // instances (e.g. multiple Cloudflare Workers isolates). - defines["process.env.__VINEXT_DRAFT_SECRET"] = JSON.stringify(crypto.randomUUID()); + defines["process.env.__VINEXT_DRAFT_SECRET"] = JSON.stringify( + crypto.randomUUID(), + ); // Build ID — resolved from next.config generateBuildId() or random UUID. // Exposed so server entries and the next/server shim can inject it. // Also used to namespace ISR cache keys so old cached entries from a // previous deploy are never served by the new one. - defines["process.env.__VINEXT_BUILD_ID"] = JSON.stringify(nextConfig.buildId); + defines["process.env.__VINEXT_BUILD_ID"] = JSON.stringify( + nextConfig.buildId, + ); // Build the shim alias map. Exact `.js` variants are included for the // public Next entrypoints that are file-backed in `next/package.json`. @@ -1965,52 +1825,72 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { "internal", "router-context", ), - "next/dist/shared/lib/utils": path.join(shimsDir, "internal", "utils"), - "next/dist/server/api-utils": path.join(shimsDir, "internal", "api-utils"), - "next/dist/server/web/spec-extension/cookies": path.join( + "next/dist/shared/lib/utils": path.join( shimsDir, "internal", - "cookies", + "utils", ), - "next/dist/compiled/@edge-runtime/cookies": path.join(shimsDir, "internal", "cookies"), - "next/dist/server/app-render/work-unit-async-storage.external": path.join( + "next/dist/server/api-utils": path.join( shimsDir, "internal", - "work-unit-async-storage", + "api-utils", ), - "next/dist/client/components/work-unit-async-storage.external": path.join( + "next/dist/server/web/spec-extension/cookies": path.join( shimsDir, "internal", - "work-unit-async-storage", + "cookies", ), - "next/dist/client/components/request-async-storage.external": path.join( + "next/dist/compiled/@edge-runtime/cookies": path.join( shimsDir, "internal", - "work-unit-async-storage", + "cookies", ), + "next/dist/server/app-render/work-unit-async-storage.external": + path.join(shimsDir, "internal", "work-unit-async-storage"), + "next/dist/client/components/work-unit-async-storage.external": + path.join(shimsDir, "internal", "work-unit-async-storage"), + "next/dist/client/components/request-async-storage.external": + path.join(shimsDir, "internal", "work-unit-async-storage"), "next/dist/client/components/request-async-storage": path.join( shimsDir, "internal", "work-unit-async-storage", ), // Re-export public modules for internal path imports - "next/dist/client/components/navigation": path.join(shimsDir, "navigation"), - "next/dist/server/config-shared": path.join(shimsDir, "internal", "utils"), + "next/dist/client/components/navigation": path.join( + shimsDir, + "navigation", + ), + "next/dist/server/config-shared": path.join( + shimsDir, + "internal", + "utils", + ), // server-only / client-only marker packages "server-only": path.join(shimsDir, "server-only"), "client-only": path.join(shimsDir, "client-only"), "vinext/error-boundary": path.join(shimsDir, "error-boundary"), - "vinext/layout-segment-context": path.join(shimsDir, "layout-segment-context"), + "vinext/layout-segment-context": path.join( + shimsDir, + "layout-segment-context", + ), "vinext/metadata": path.join(shimsDir, "metadata"), "vinext/fetch-cache": path.join(shimsDir, "fetch-cache"), "vinext/cache-runtime": path.join(shimsDir, "cache-runtime"), "vinext/navigation-state": path.join(shimsDir, "navigation-state"), - "vinext/unified-request-context": path.join(shimsDir, "unified-request-context"), + "vinext/unified-request-context": path.join( + shimsDir, + "unified-request-context", + ), "vinext/router-state": path.join(shimsDir, "router-state"), "vinext/head-state": path.join(shimsDir, "head-state"), "vinext/i18n-state": path.join(shimsDir, "i18n-state"), "vinext/i18n-context": path.join(shimsDir, "i18n-context"), - "vinext/instrumentation": path.resolve(__dirname, "server", "instrumentation"), + "vinext/instrumentation": path.resolve( + __dirname, + "server", + "instrumentation", + ), "vinext/html": path.resolve(__dirname, "server", "html"), }).flatMap(([k, v]) => k.startsWith("next/") @@ -2037,7 +1917,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { p && typeof p === "object" && typeof p.name === "string" && - (p.name === "vite-plugin-cloudflare" || p.name.startsWith("vite-plugin-cloudflare:")), + (p.name === "vite-plugin-cloudflare" || + p.name.startsWith("vite-plugin-cloudflare:")), ); hasNitroPlugin = pluginsFlat.some( (p: any) => @@ -2070,7 +1951,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ); if ( !hasMdxPlugin && - hasMdxFiles(root, hasAppDir ? appDir : null, hasPagesDir ? pagesDir : null) + hasMdxFiles( + root, + hasAppDir ? appDir : null, + hasPagesDir ? pagesDir : null, + ) ) { try { const mdxRollup = await import("@mdx-js/rollup"); @@ -2081,7 +1966,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { mdxOpts.remarkPlugins = nextConfig.mdx.remarkPlugins; if (nextConfig.mdx.rehypePlugins) mdxOpts.rehypePlugins = nextConfig.mdx.rehypePlugins; - if (nextConfig.mdx.recmaPlugins) mdxOpts.recmaPlugins = nextConfig.mdx.recmaPlugins; + if (nextConfig.mdx.recmaPlugins) + mdxOpts.recmaPlugins = nextConfig.mdx.recmaPlugins; } mdxDelegate = mdxFactory(mdxOpts); if (nextConfig.mdx) { @@ -2089,7 +1975,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { "[vinext] Auto-injected @mdx-js/rollup with remark/rehype plugins from next.config", ); } else { - console.log("[vinext] Auto-injected @mdx-js/rollup for MDX support"); + console.log( + "[vinext] Auto-injected @mdx-js/rollup for MDX support", + ); } } catch { // @mdx-js/rollup not installed — warn but don't fail @@ -2115,61 +2003,34 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const viteConfig: UserConfig = { // Disable Vite's default HTML serving - we handle all routing appType: "custom", - build: { - rollupOptions: { - // Suppress "Module level directives cause errors when bundled" - // warnings for "use client" / "use server" directives. Our shims - // and third-party libraries legitimately use these directives; - // they are handled by the RSC plugin and are harmless in the - // final bundle. We preserve any user-supplied onwarn so custom - // warning handling is not lost. - onwarn: (() => { - const userOnwarn = config.build?.rollupOptions?.onwarn; - return (warning, defaultHandler) => { - if ( - warning.code === "MODULE_LEVEL_DIRECTIVE" && - (warning.message?.includes('"use client"') || - warning.message?.includes('"use server"')) - ) { - return; - } - // Dynamic route pages that don't export generateStaticParams - // produce IMPORT_IS_UNDEFINED warnings because the virtual RSC - // entry unconditionally references mod?.generateStaticParams for - // every dynamic route. The ?. guards the access safely at runtime; - // suppress the build-time noise. - if ( - warning.code === "IMPORT_IS_UNDEFINED" && - warning.message?.includes("generateStaticParams") - ) { - return; - } - if (userOnwarn) { - userOnwarn(warning, defaultHandler); - } else { - defaultHandler(warning); - } - }; - })(), - // Enable aggressive tree-shaking for client builds. - // See clientTreeshakeConfig for rationale. - // Only apply globally for standalone client builds (Pages Router - // CLI). For multi-environment builds (App Router, Cloudflare), - // treeshake is set per-environment on the client env below to - // avoid leaking into RSC/SSR environments where - // moduleSideEffects: 'no-external' could drop server packages - // that rely on module-level side effects. - ...(!isSSR && !isMultiEnv ? { treeshake: clientTreeshakeConfig } : {}), - // Code-split client bundles: separate framework (React/ReactDOM), - // vinext runtime (shims), and vendor packages into their own - // chunks so pages only load the JS they need. - // Only apply globally for standalone client builds (CLI Pages - // Router). For multi-environment builds (App Router, Cloudflare), - // manualChunks is set per-environment on the client env below - // to avoid leaking into RSC/SSR environments. - ...(!isSSR && !isMultiEnv ? { output: clientOutputConfig } : {}), - }, - }, + ...(isSSR || isMultiEnv + ? { + build: {}, + } + : viteMajorVersion >= 8 + ? { + build: { + ["rolldownOptions" as keyof import("vite").BuildOptions]: { + ...getClientBuildOptions(viteMajorVersion) + .rolldownOptions, + onwarn: createDirectiveOnwarn( + config.build?.rolldownOptions?.onwarn ?? + config.build?.rollupOptions?.onwarn, + ), + }, + }, + } + : { + build: { + rollupOptions: { + ...getClientBuildOptions(viteMajorVersion).rollupOptions, + onwarn: createDirectiveOnwarn( + config.build?.rolldownOptions?.onwarn ?? + config.build?.rollupOptions?.onwarn, + ), + }, + }, + }), // Let OPTIONS requests pass through Vite's CORS middleware to our // route handlers so they can set the Allow header and run user-defined // OPTIONS handlers. Without this, Vite's CORS middleware responds to @@ -2180,7 +2041,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { server: { cors: { preflightContinue: true, - origin: /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/, + origin: + /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/, }, }, // Configure SSR transform behaviour for Node targets. @@ -2203,15 +2065,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }), resolve: { - // Materialize simple tsconfig/jsconfig path aliases into resolve.alias - // so Vite can transform import.meta.glob("@/...") and import(`@/...`). - alias: { ...tsconfigPathAliases, ...nextConfig.aliases, ...nextShimMap }, + alias: { ...nextConfig.aliases, ...nextShimMap }, // Dedupe React packages to prevent dual-instance errors. // When vinext is linked (npm link / bun link) or any dependency // brings its own React copy, multiple React instances can load, // causing cryptic "Invalid hook call" errors. This is a no-op // when only one copy exists. - dedupe: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"], + dedupe: [ + "react", + "react-dom", + "react/jsx-runtime", + "react/jsx-dev-runtime", + ], ...(shouldEnableNativeTsconfigPaths ? { tsconfigPaths: true } : {}), }, // NOTE: top-level optimizeDeps is now set below (after capturing @@ -2245,8 +2110,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // only from its `node` condition, not from the universal `default` one). // Without externalizing them, Vite's optimizer picks the wrong export // condition and the build fails with MISSING_EXPORT errors. - const nextServerExternal: string[] = nextConfig?.serverExternalPackages ?? []; - const userSsrExternal: string[] | true = Array.isArray(config.ssr?.external) + const nextServerExternal: string[] = + nextConfig?.serverExternalPackages ?? []; + const userSsrExternal: string[] | true = Array.isArray( + config.ssr?.external, + ) ? [...config.ssr.external, ...nextServerExternal] : config.ssr?.external === true ? true @@ -2295,18 +2163,27 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { external: userSsrExternal === true ? true - : ["satori", "@resvg/resvg-js", "yoga-wasm-web", ...userSsrExternal], + : [ + "satori", + "@resvg/resvg-js", + "yoga-wasm-web", + ...userSsrExternal, + ], // Force all node_modules through Vite's transform pipeline // so non-JS imports (CSS, images) don't hit Node's native // ESM loader. Matches Next.js behavior of bundling everything. // Packages in `external` above take precedence per Vite rules. // When user sets `ssr.external: true`, skip noExternal since // everything is already externalized. - ...(userSsrExternal === true ? {} : { noExternal: true as const }), + ...(userSsrExternal === true + ? {} + : { noExternal: true as const }), }, }), optimizeDeps: { - exclude: [...new Set([...incomingExclude, "vinext", "@vercel/og"])], + exclude: [ + ...new Set([...incomingExclude, "vinext", "@vercel/og"]), + ], entries: appEntries, }, build: { @@ -2321,17 +2198,22 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ? {} : { resolve: { - external: userSsrExternal === true ? true : [...userSsrExternal], + external: + userSsrExternal === true ? true : [...userSsrExternal], // Force all node_modules through Vite's transform pipeline // so non-JS imports (CSS, images) don't hit Node's native // ESM loader. Matches Next.js behavior of bundling everything. // When user sets `ssr.external: true`, skip noExternal since // everything is already externalized. - ...(userSsrExternal === true ? {} : { noExternal: true as const }), + ...(userSsrExternal === true + ? {} + : { noExternal: true as const }), }, }), optimizeDeps: { - exclude: [...new Set([...incomingExclude, "vinext", "@vercel/og"])], + exclude: [ + ...new Set([...incomingExclude, "vinext", "@vercel/og"]), + ], entries: appEntries, }, build: { @@ -2359,7 +2241,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // but the browser optimizer resolves to `core.js` which lacks it, // causing MISSING_EXPORT build failures). exclude: [ - ...new Set([...incomingExclude, "vinext", "@vercel/og", ...nextServerExternal]), + ...new Set([ + ...incomingExclude, + "vinext", + "@vercel/og", + ...nextServerExternal, + ]), ], // Crawl app/ source files up front so client-only deps imported // by user components are discovered during startup instead of @@ -2387,11 +2274,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // on every page — defeating code-splitting for React.lazy() and // next/dynamic boundaries. ...(hasCloudflarePlugin ? { manifest: true } : {}), - rollupOptions: { - input: { index: VIRTUAL_APP_BROWSER_ENTRY }, - output: clientOutputConfig, - treeshake: clientTreeshakeConfig, - }, + ...getClientBuildOptions(viteMajorVersion, { + index: VIRTUAL_APP_BROWSER_ENTRY, + }), }, }, }; @@ -2406,11 +2291,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { build: { manifest: true, ssrManifest: true, - rollupOptions: { - input: { index: VIRTUAL_CLIENT_ENTRY }, - output: clientOutputConfig, - treeshake: clientTreeshakeConfig, - }, + ...getClientBuildOptions(viteMajorVersion, { + index: VIRTUAL_CLIENT_ENTRY, + }), }, }, }; @@ -2427,13 +2310,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Assumes @vitejs/plugin-react top-level plugin names continue to use // the vite:react* prefix across supported versions. const reactRootPlugins = config.plugins.filter( - (p: any) => p && typeof p.name === "string" && p.name.startsWith("vite:react"), + (p: any) => + p && + typeof p.name === "string" && + p.name.startsWith("vite:react"), ); const counts = new Map(); for (const plugin of reactRootPlugins) { counts.set(plugin.name, (counts.get(plugin.name) ?? 0) + 1); } - const hasDuplicateReactPlugin = [...counts.values()].some((count) => count > 1); + const hasDuplicateReactPlugin = [...counts.values()].some( + (count) => count > 1, + ); if (hasDuplicateReactPlugin) { throw new Error( "[vinext] Duplicate @vitejs/plugin-react detected.\n" + @@ -2453,7 +2341,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (rscPluginPromise) { // Count top-level RSC plugins (name === "rsc") — each call to // the rsc() factory produces exactly one plugin with this name. - const rscRootPlugins = config.plugins.filter((p: any) => p && p.name === "rsc"); + const rscRootPlugins = config.plugins.filter( + (p: any) => p && p.name === "rsc", + ); if (rscRootPlugins.length > 1) { throw new Error( "[vinext] Duplicate @vitejs/plugin-rsc detected.\n" + @@ -2516,10 +2406,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // App Router virtual modules if (cleanId === VIRTUAL_RSC_ENTRY) return RESOLVED_RSC_ENTRY; if (cleanId === VIRTUAL_APP_SSR_ENTRY) return RESOLVED_APP_SSR_ENTRY; - if (cleanId === VIRTUAL_APP_BROWSER_ENTRY) return RESOLVED_APP_BROWSER_ENTRY; - if (cleanId.startsWith(VIRTUAL_GOOGLE_FONTS + "?")) { - return RESOLVED_VIRTUAL_GOOGLE_FONTS + cleanId.slice(VIRTUAL_GOOGLE_FONTS.length); - } + if (cleanId === VIRTUAL_APP_BROWSER_ENTRY) + return RESOLVED_APP_BROWSER_ENTRY; if ( cleanId.endsWith("/" + VIRTUAL_RSC_ENTRY) || cleanId.endsWith("\\" + VIRTUAL_RSC_ENTRY) @@ -2538,16 +2426,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ) { return RESOLVED_APP_BROWSER_ENTRY; } - if ( - cleanId.includes("/" + VIRTUAL_GOOGLE_FONTS + "?") || - cleanId.includes("\\" + VIRTUAL_GOOGLE_FONTS + "?") - ) { - const queryIndex = cleanId.indexOf(VIRTUAL_GOOGLE_FONTS + "?"); - return ( - RESOLVED_VIRTUAL_GOOGLE_FONTS + - cleanId.slice(queryIndex + VIRTUAL_GOOGLE_FONTS.length) - ); - } }, }, @@ -2561,10 +2439,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } // App Router virtual modules if (id === RESOLVED_RSC_ENTRY && hasAppDir) { - const routes = await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher); + const routes = await appRouter( + appDir, + nextConfig?.pageExtensions, + fileMatcher, + ); const metaRoutes = scanMetadataFiles(appDir); // Check for global-error.tsx at app root - const globalErrorPath = findFileWithExts(appDir, "global-error", fileMatcher); + const globalErrorPath = findFileWithExts( + appDir, + "global-error", + fileMatcher, + ); return generateRscEntry( appDir, routes, @@ -2592,15 +2478,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (id === RESOLVED_APP_BROWSER_ENTRY && hasAppDir) { return generateBrowserEntry(); } - if (id.startsWith(RESOLVED_VIRTUAL_GOOGLE_FONTS + "?")) { - return generateGoogleFontsVirtualModule(id); - } }, }, // Stub node:async_hooks in client builds — see src/plugins/async-hooks-stub.ts asyncHooksStubPlugin, // Dedup client references from RSC proxy modules — see src/plugins/client-reference-dedup.ts - ...(options.experimental?.clientReferenceDedup ? [clientReferenceDedupPlugin()] : []), + ...(options.experimental?.clientReferenceDedup + ? [clientReferenceDedupPlugin()] + : []), // Proxy plugin for @mdx-js/rollup. The real MDX plugin is created lazily // during vinext:config's config() (when MDX files are detected), but // plugins returned from config() hooks run too late in the pipeline — @@ -2636,7 +2521,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { enforce: "pre", resolveId(id) { - if (id === "virtual:vinext-react-canary") return "\0virtual:vinext-react-canary"; + if (id === "virtual:vinext-react-canary") + return "\0virtual:vinext-react-canary"; }, load(id) { @@ -2659,7 +2545,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Quick check: does this file reference canary APIs and import from "react"? if ( - !(code.includes("ViewTransition") || code.includes("addTransitionType")) || + !( + code.includes("ViewTransition") || + code.includes("addTransitionType") + ) || !/from\s+['"]react['"]/.test(code) ) { return null; @@ -2673,7 +2562,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Rewrite all `from "react"` / `from 'react'` to use the canary shim. // This is safe because the virtual module re-exports everything from // react, so non-canary imports continue to work. - const result = code.replace(/from\s*['"]react['"]/g, 'from "virtual:vinext-react-canary"'); + const result = code.replace( + /from\s*['"]react['"]/g, + 'from "virtual:vinext-react-canary"', + ); if (result !== code) { return { code: result, map: null }; } @@ -2689,9 +2581,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // which may not be tracked in Vite's module graph. Explicitly // sending full-reload ensures changes are always reflected in // the browser. - hotUpdate(options: { file: string; server: ViteDevServer; modules: any[] }) { + hotUpdate(options: { + file: string; + server: ViteDevServer; + modules: any[]; + }) { if (!hasPagesDir || hasAppDir) return; - if (options.file.startsWith(pagesDir) && fileMatcher.extensionRegex.test(options.file)) { + if ( + options.file.startsWith(pagesDir) && + fileMatcher.extensionRegex.test(options.file) + ) { options.server.environments.client.hot.send({ type: "full-reload" }); return []; } @@ -2717,12 +2616,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // The runner is created lazily on first use so that all environments are // fully registered before we inspect them. We prefer "ssr", then any // non-"rsc" environment, then whatever is available. - let pagesRunner: import("vite/module-runner").ModuleRunner | null = null; + let pagesRunner: import("vite/module-runner").ModuleRunner | null = + null; function getPagesRunner() { if (!pagesRunner) { const env = server.environments["ssr"] ?? - Object.values(server.environments).find((e) => e !== server.environments["rsc"]) ?? + Object.values(server.environments).find( + (e) => e !== server.environments["rsc"], + ) ?? Object.values(server.environments)[0]; pagesRunner = createDirectRunner(env); } @@ -2749,19 +2651,35 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } server.watcher.on("add", (filePath: string) => { - if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) { + if ( + hasPagesDir && + filePath.startsWith(pagesDir) && + pageExtensions.test(filePath) + ) { invalidateRouteCache(pagesDir); } - if (hasAppDir && filePath.startsWith(appDir) && pageExtensions.test(filePath)) { + if ( + hasAppDir && + filePath.startsWith(appDir) && + pageExtensions.test(filePath) + ) { invalidateAppRouteCache(); invalidateRscEntryModule(); } }); server.watcher.on("unlink", (filePath: string) => { - if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) { + if ( + hasPagesDir && + filePath.startsWith(pagesDir) && + pageExtensions.test(filePath) + ) { invalidateRouteCache(pagesDir); } - if (hasAppDir && filePath.startsWith(appDir) && pageExtensions.test(filePath)) { + if ( + hasAppDir && + filePath.startsWith(appDir) && + pageExtensions.test(filePath) + ) { invalidateAppRouteCache(); invalidateRscEntryModule(); } @@ -2777,14 +2695,22 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { { origin: req.headers.origin as string | undefined, host: req.headers.host, - "x-forwarded-host": req.headers["x-forwarded-host"] as string | undefined, - "sec-fetch-site": req.headers["sec-fetch-site"] as string | undefined, - "sec-fetch-mode": req.headers["sec-fetch-mode"] as string | undefined, + "x-forwarded-host": req.headers["x-forwarded-host"] as + | string + | undefined, + "sec-fetch-site": req.headers["sec-fetch-site"] as + | string + | undefined, + "sec-fetch-mode": req.headers["sec-fetch-mode"] as + | string + | undefined, }, nextConfig?.allowedDevOrigins, ); if (blockReason) { - console.warn(`[vinext] Blocked dev request: ${blockReason} (${req.url})`); + console.warn( + `[vinext] Blocked dev request: ${blockReason} (${req.url})`, + ); res.writeHead(403, { "Content-Type": "text/plain" }); res.end("Forbidden"); return; @@ -2816,9 +2742,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // calls environment.fetchModule() directly and never touches the hot // channel, making it safe with all Vite plugin combinations. if (instrumentationPath && !hasAppDir) { - runInstrumentation(getPagesRunner(), instrumentationPath).catch((err) => { - console.error("[vinext] Instrumentation error:", err); - }); + runInstrumentation(getPagesRunner(), instrumentationPath).catch( + (err) => { + console.error("[vinext] Instrumentation error:", err); + }, + ); } // App Router request logging in dev server // @@ -2839,7 +2767,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { url.startsWith("/@") || url.startsWith("/__vite") || url.startsWith("/node_modules") || - (url.includes(".") && !pathname.endsWith(".html") && !pathname.endsWith(".rsc")) + (url.includes(".") && + !pathname.endsWith(".html") && + !pathname.endsWith(".rsc")) ) { return next(); } @@ -2877,7 +2807,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { inHandlerCompileMs !== -1 ) { _compileMs = - Math.max(0, Math.round(handlerStart - _reqStart)) + inHandlerCompileMs; + Math.max(0, Math.round(handlerStart - _reqStart)) + + inHandlerCompileMs; } if (!Number.isNaN(renderMs) && renderMs !== -1) { _renderMs = renderMs; @@ -2905,7 +2836,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } // Pull timing out of the headers object when present. - if (headers && typeof headers === "object" && !Array.isArray(headers)) { + if ( + headers && + typeof headers === "object" && + !Array.isArray(headers) + ) { const timingKey = Object.keys(headers).find( (k) => k.toLowerCase() === "x-vinext-timing", ); @@ -2981,14 +2916,22 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { { origin: req.headers.origin as string | undefined, host: req.headers.host, - "x-forwarded-host": req.headers["x-forwarded-host"] as string | undefined, - "sec-fetch-site": req.headers["sec-fetch-site"] as string | undefined, - "sec-fetch-mode": req.headers["sec-fetch-mode"] as string | undefined, + "x-forwarded-host": req.headers["x-forwarded-host"] as + | string + | undefined, + "sec-fetch-site": req.headers["sec-fetch-site"] as + | string + | undefined, + "sec-fetch-mode": req.headers["sec-fetch-mode"] as + | string + | undefined, }, nextConfig?.allowedDevOrigins, ); if (blockReason) { - console.warn(`[vinext] Blocked dev request: ${blockReason} (${url})`); + console.warn( + `[vinext] Blocked dev request: ${blockReason} (${url})`, + ); res.writeHead(403, { "Content-Type": "text/plain" }); res.end("Forbidden"); return; @@ -3015,17 +2958,28 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { imgUrl.startsWith("/node_modules") ) { res.writeHead(400); - res.end(!rawImgUrl ? "Missing url parameter" : "Only relative URLs allowed"); + res.end( + !rawImgUrl + ? "Missing url parameter" + : "Only relative URLs allowed", + ); return; } // Validate the constructed URL's origin hasn't changed (defense in depth). - const resolvedImg = new URL(imgUrl, `http://${req.headers.host || "localhost"}`); - if (resolvedImg.origin !== `http://${req.headers.host || "localhost"}`) { + const resolvedImg = new URL( + imgUrl, + `http://${req.headers.host || "localhost"}`, + ); + if ( + resolvedImg.origin !== + `http://${req.headers.host || "localhost"}` + ) { res.writeHead(400); res.end("Only relative URLs allowed"); return; } - const encodedLocation = resolvedImg.pathname + resolvedImg.search; + const encodedLocation = + resolvedImg.pathname + resolvedImg.search; res.writeHead(302, { Location: encodedLocation }); res.end(); return; @@ -3062,7 +3016,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // decodeURIComponent prevents /%61dmin bypassing /admin matchers. // normalizePath collapses // and resolves . / .. segments. try { - pathname = normalizePath(normalizePathnameForRouteMatchStrict(pathname)); + pathname = normalizePath( + normalizePathnameForRouteMatchStrict(pathname), + ); } catch { // Malformed percent-encoding (e.g. /%E0%A4%A) — return 400 instead of crashing. res.writeHead(400); @@ -3098,14 +3054,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const hasTrailing = pathname.endsWith("/"); if (nextConfig.trailingSlash && !hasTrailing) { // trailingSlash: true — redirect /about → /about/ - const qs = url.includes("?") ? url.slice(url.indexOf("?")) : ""; + const qs = url.includes("?") + ? url.slice(url.indexOf("?")) + : ""; const dest = bp + pathname + "/" + qs; res.writeHead(308, { Location: dest }); res.end(); return; } else if (!nextConfig.trailingSlash && hasTrailing) { // trailingSlash: false (default) — redirect /about/ → /about - const qs = url.includes("?") ? url.slice(url.indexOf("?")) : ""; + const qs = url.includes("?") + ? url.slice(url.indexOf("?")) + : ""; const dest = bp + pathname.replace(/\/+$/, "") + qs; res.writeHead(308, { Location: dest }); res.end(); @@ -3128,15 +3088,21 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { Object.fromEntries( Object.entries(req.headers) .filter(([, v]) => v !== undefined) - .map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v)]), + .map(([k, v]) => [ + k, + Array.isArray(v) ? v.join(", ") : String(v), + ]), ), ); const requestOrigin = `http://${req.headers.host || "localhost"}`; const preMiddlewareReqUrl = new URL(url, requestOrigin); - const preMiddlewareReqCtx: RequestContext = requestContextFromRequest( - new Request(preMiddlewareReqUrl, { headers: nodeRequestHeaders }), - ); + const preMiddlewareReqCtx: RequestContext = + requestContextFromRequest( + new Request(preMiddlewareReqUrl, { + headers: nodeRequestHeaders, + }), + ); // Config redirects run before middleware, but still match against // the original normalized pathname and request headers/cookies. @@ -3151,7 +3117,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (redirected) return; } - const applyRequestHeadersToNodeRequest = (nextRequestHeaders: Headers) => { + const applyRequestHeadersToNodeRequest = ( + nextRequestHeaders: Headers, + ) => { for (const key of Object.keys(req.headers)) { delete req.headers[key]; } @@ -3176,13 +3144,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Only trust X-Forwarded-Proto when behind a trusted proxy const devTrustProxy = process.env.VINEXT_TRUST_PROXY === "1" || - (process.env.VINEXT_TRUSTED_HOSTS ?? "").split(",").some((h) => h.trim()); + (process.env.VINEXT_TRUSTED_HOSTS ?? "") + .split(",") + .some((h) => h.trim()); const rawProto = devTrustProxy ? String(req.headers["x-forwarded-proto"] || "") .split(",")[0] .trim() : ""; - const mwProto = rawProto === "https" || rawProto === "http" ? rawProto : "http"; + const mwProto = + rawProto === "https" || rawProto === "http" + ? rawProto + : "http"; const origin = `${mwProto}://${req.headers.host || "localhost"}`; const middlewareRequest = new Request(new URL(url, origin), { method: req.method, @@ -3213,7 +3186,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } } - res.writeHead(result.redirectStatus ?? 307, redirectHeaders); + res.writeHead( + result.redirectStatus ?? 307, + redirectHeaders, + ); res.end(); return; } @@ -3222,7 +3198,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { for (const [key, value] of result.response.headers) { res.appendHeader(key, value); } - const body = Buffer.from(await result.response.arrayBuffer()); + const body = Buffer.from( + await result.response.arrayBuffer(), + ); res.end(body); return; } @@ -3242,10 +3220,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - middlewareRequestHeaders = buildRequestHeadersFromMiddlewareResponse( - currentRequestHeaders, - result.responseHeaders, - ); + middlewareRequestHeaders = + buildRequestHeadersFromMiddlewareResponse( + currentRequestHeaders, + result.responseHeaders, + ); if (middlewareRequestHeaders && !hasAppDir) { applyRequestHeadersToNodeRequest(middlewareRequestHeaders); @@ -3296,7 +3275,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { for (const [key, value] of result.responseHeaders) { // Exclude control headers that runMiddleware already // consumed — matches the RSC entry's inline filtering. - if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") { + if ( + key !== "x-middleware-next" && + key !== "x-middleware-rewrite" + ) { mwCtxEntries.push([key, value]); } } @@ -3314,7 +3296,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Convert Node.js IncomingMessage headers to a Web Request for // requestContextFromRequest(), which uses the standard Web API. const reqUrl = new URL(url, requestOrigin); - const reqCtxHeaders = middlewareRequestHeaders ?? nodeRequestHeaders; + const reqCtxHeaders = + middlewareRequestHeaders ?? nodeRequestHeaders; const reqCtx: RequestContext = requestContextFromRequest( new Request(reqUrl, { headers: reqCtxHeaders }), ); @@ -3324,14 +3307,23 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // pre-middleware request state; middleware response headers win // later because they are already on the outgoing response. if (nextConfig?.headers.length) { - applyHeaders(pathname, res, nextConfig.headers, preMiddlewareReqCtx); + applyHeaders( + pathname, + res, + nextConfig.headers, + preMiddlewareReqCtx, + ); } // Apply rewrites from next.config.js (beforeFiles) let resolvedUrl = url; if (nextConfig?.rewrites.beforeFiles.length) { resolvedUrl = - applyRewrites(pathname, nextConfig.rewrites.beforeFiles, reqCtx) ?? url; + applyRewrites( + pathname, + nextConfig.rewrites.beforeFiles, + reqCtx, + ) ?? url; } // External rewrite from beforeFiles — proxy to external URL @@ -3343,7 +3335,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Handle API routes first (pages/api/*) const resolvedPathname = resolvedUrl.split("?")[0]; - if (resolvedPathname.startsWith("/api/") || resolvedPathname === "/api") { + if ( + resolvedPathname.startsWith("/api/") || + resolvedPathname === "/api" + ) { const apiRoutes = await apiRouter( pagesDir, nextConfig?.pageExtensions, @@ -3374,7 +3369,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return; } - const routes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); + const routes = await pagesRouter( + pagesDir, + nextConfig?.pageExtensions, + fileMatcher, + ); // Apply afterFiles rewrites — these run after initial route matching // If beforeFiles already rewrote the URL, afterFiles still run on the @@ -3433,7 +3432,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { await proxyExternalRewriteNode(req, res, fallbackRewrite); return; } - const fallbackMatch = matchRoute(fallbackRewrite.split("?")[0], routes); + const fallbackMatch = matchRoute( + fallbackRewrite.split("?")[0], + routes, + ); if (!fallbackMatch && hasAppDir) { return next(); } @@ -3479,7 +3481,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (!id.startsWith(pagesDir)) return null; // Skip API routes, _app, _document, _error const relativePath = id.slice(pagesDir.length); - if (relativePath.startsWith("/api/") || relativePath === "/api") return null; + if (relativePath.startsWith("/api/") || relativePath === "/api") + return null; if (/\/_(?:app|document|error)\b/.test(relativePath)) return null; const result = stripServerExports(code); @@ -3544,7 +3547,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { include: /\.(tsx?|jsx?|mjs)$/, exclude: /node_modules/, }, - code: new RegExp(`import\\s+\\w+\\s+from\\s+['"][^'"]+\\.(${IMAGE_EXTS})['"]`), + code: new RegExp( + `import\\s+\\w+\\s+from\\s+['"][^'"]+\\.(${IMAGE_EXTS})['"]`, + ), }, async handler(code, id) { // Defensive guard — duplicates filter logic @@ -3599,14 +3604,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }, } as Plugin & { _dimCache: Map }, - // Google Fonts import rewrite + self-hosting: - // - // 1. Rewrites named next/font/google imports/exports to a tiny virtual module - // that exports only the requested fonts plus any utility exports. - // This lets us delete the generated ~1,900-line runtime catalog while - // keeping ESM import semantics intact. - // 2. During production builds, fetches Google Fonts CSS + font files and - // injects _selfHostedCSS into statically analyzable font loader calls. + // Google Fonts self-hosting: + // During production builds, fetches Google Fonts CSS + .woff2 files, + // caches them locally in .vinext/fonts/, and rewrites font constructor + // calls to pass _selfHostedCSS with @font-face rules pointing at local assets. + // In dev mode, this plugin is a no-op (CDN loading is used instead). { name: "vinext:google-fonts", enforce: "pre", @@ -3622,129 +3624,68 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { transform: { // Hook filter: only invoke JS when code contains 'next/font/google'. - // This still eliminates nearly all Rust-to-JS calls since very few files + // The _isBuild runtime check can't be expressed as a filter, but this + // still eliminates nearly all Rust-to-JS calls since very few files // import from next/font/google. filter: { id: { include: /\.(tsx?|jsx?|mjs)$/, + exclude: /node_modules/, }, code: "next/font/google", }, async handler(code, id) { + if (!(this as any)._isBuild) return null; // Defensive guard — duplicates filter logic + if (id.includes("node_modules")) return null; if (id.startsWith("\0")) return null; if (!id.match(/\.(tsx?|jsx?|mjs)$/)) return null; if (!code.includes("next/font/google")) return null; - if (id.startsWith(_shimsDir)) return null; + + // Match font constructor calls: Inter({ weight: ..., subsets: ... }) + // We look for PascalCase or Name_Name identifiers followed by ({...}) + // This regex captures the font name and the options object literal + const fontCallRe = + /\b([A-Z][A-Za-z]*(?:_[A-Z][A-Za-z]*)*)\s*\(\s*(\{[^}]*\})\s*\)/g; + + // Also need to verify these names came from next/font/google import + const importRe = + /import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]/; + const importMatch = code.match(importRe); + if (!importMatch) return null; + + const importedNames = new Set( + importMatch[1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); const s = new MagicString(code); let hasChanges = false; - let proxyImportCounter = 0; - const overwrittenRanges: Array<[number, number]> = []; - const fontLocals = new Map(); - const proxyObjectLocals = new Set(); - - const importRe = /^[ \t]*import\s+([^;]+?)\s+from\s*(["'])next\/font\/google\2\s*;?/gm; - let importMatch; - while ((importMatch = importRe.exec(code)) !== null) { - const [fullMatch, clause] = importMatch; - const matchStart = importMatch.index; - const matchEnd = matchStart + fullMatch.length; - const parsed = parseGoogleFontImportClause(clause); - const utilityImports = parsed.named.filter( - (spec) => !spec.isType && GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), - ); - const fontImports = parsed.named.filter( - (spec) => !spec.isType && !GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), - ); - - if (parsed.defaultLocal) { - proxyObjectLocals.add(parsed.defaultLocal); - } - for (const fontImport of fontImports) { - fontLocals.set(fontImport.local, fontImport.imported); - } - - if (fontImports.length > 0) { - const virtualId = encodeGoogleFontsVirtualId({ - hasDefault: Boolean(parsed.defaultLocal), - fonts: Array.from(new Set(fontImports.map((spec) => spec.imported))), - utilities: Array.from(new Set(utilityImports.map((spec) => spec.imported))), - }); - s.overwrite( - matchStart, - matchEnd, - `import ${clause} from ${JSON.stringify(virtualId)};`, - ); - overwrittenRanges.push([matchStart, matchEnd]); - hasChanges = true; - continue; - } - - if (parsed.namespaceLocal) { - const proxyImportName = `__vinext_google_fonts_proxy_${proxyImportCounter++}`; - const replacementLines = [ - `import ${proxyImportName} from ${JSON.stringify(_fontGoogleShimPath)};`, - ]; - if (parsed.defaultLocal) { - replacementLines.push(`var ${parsed.defaultLocal} = ${proxyImportName};`); - } - replacementLines.push(`var ${parsed.namespaceLocal} = ${proxyImportName};`); - s.overwrite(matchStart, matchEnd, replacementLines.join("\n")); - overwrittenRanges.push([matchStart, matchEnd]); - proxyObjectLocals.add(parsed.namespaceLocal); - hasChanges = true; - } - } - - const exportRe = /^[ \t]*export\s*\{([^}]+)\}\s*from\s*(["'])next\/font\/google\2\s*;?/gm; - let exportMatch; - while ((exportMatch = exportRe.exec(code)) !== null) { - const [fullMatch, specifiers] = exportMatch; - const matchStart = exportMatch.index; - const matchEnd = matchStart + fullMatch.length; - const namedExports = parseGoogleFontNamedSpecifiers(specifiers); - const utilityExports = namedExports.filter( - (spec) => !spec.isType && GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), - ); - const fontExports = namedExports.filter( - (spec) => !spec.isType && !GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), - ); - if (fontExports.length === 0) continue; - - const virtualId = encodeGoogleFontsVirtualId({ - hasDefault: false, - fonts: Array.from(new Set(fontExports.map((spec) => spec.imported))), - utilities: Array.from(new Set(utilityExports.map((spec) => spec.imported))), - }); - s.overwrite( - matchStart, - matchEnd, - `export { ${specifiers.trim()} } from ${JSON.stringify(virtualId)};`, - ); - overwrittenRanges.push([matchStart, matchEnd]); - hasChanges = true; - } const cacheDir = (this as any)._cacheDir as string; const fontCache = (this as any)._fontCache as Map; - async function injectSelfHostedCss( - callStart: number, - callEnd: number, - optionsStr: string, - family: string, - calleeSource: string, - ) { + let match; + while ((match = fontCallRe.exec(code)) !== null) { + const [fullMatch, fontName, optionsStr] = match; + if (!importedNames.has(fontName)) continue; + + // Convert PascalCase/Underscore to font family + const family = fontName + .replace(/_/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2"); + // Parse options safely via AST — no eval/new Function // eslint-disable-next-line @typescript-eslint/no-explicit-any let options: Record = {}; try { const parsed = parseStaticObjectLiteral(optionsStr); - if (!parsed) return; // Contains dynamic expressions, skip + if (!parsed) continue; // Contains dynamic expressions, skip options = parsed as Record; } catch { - return; // Can't parse options statically, skip + continue; // Can't parse options statically, skip } // Build the Google Fonts CSS URL @@ -3791,70 +3732,28 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { fontCache.set(cssUrl, localCSS); } catch { // Fetch failed (offline?) — fall back to CDN mode - return; + continue; } } // Inject _selfHostedCSS into the options object + const matchStart = match.index; + const matchEnd = matchStart + fullMatch.length; const escapedCSS = JSON.stringify(localCSS); const closingBrace = optionsStr.lastIndexOf("}"); const optionsWithCSS = optionsStr.slice(0, closingBrace) + - (optionsStr.slice(0, closingBrace).trim().endsWith("{") ? "" : ", ") + + (optionsStr.slice(0, closingBrace).trim().endsWith("{") + ? "" + : ", ") + `_selfHostedCSS: ${escapedCSS}` + optionsStr.slice(closingBrace); - const replacement = `${calleeSource}(${optionsWithCSS})`; - s.overwrite(callStart, callEnd, replacement); + const replacement = `${fontName}(${optionsWithCSS})`; + s.overwrite(matchStart, matchEnd, replacement); hasChanges = true; } - if ((this as any)._isBuild) { - const namedCallRe = /\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(\{[^}]*\})\s*\)/g; - let namedCallMatch; - while ((namedCallMatch = namedCallRe.exec(code)) !== null) { - const [fullMatch, localName, optionsStr] = namedCallMatch; - const importedName = fontLocals.get(localName); - if (!importedName) continue; - - const callStart = namedCallMatch.index; - const callEnd = callStart + fullMatch.length; - if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) { - continue; - } - - await injectSelfHostedCss( - callStart, - callEnd, - optionsStr, - importedName.replace(/_/g, " "), - localName, - ); - } - - const memberCallRe = - /\b([A-Za-z_$][A-Za-z0-9_$]*)\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(\{[^}]*\})\s*\)/g; - let memberCallMatch; - while ((memberCallMatch = memberCallRe.exec(code)) !== null) { - const [fullMatch, objectName, propName, optionsStr] = memberCallMatch; - if (!proxyObjectLocals.has(objectName)) continue; - - const callStart = memberCallMatch.index; - const callEnd = callStart + fullMatch.length; - if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) { - continue; - } - - await injectSelfHostedCss( - callStart, - callEnd, - optionsStr, - propertyNameToGoogleFontFamily(propName), - `${objectName}.${propName}`, - ); - } - } - if (!hasChanges) return null; return { code: s.toString(), @@ -3862,7 +3761,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }; }, }, - } as Plugin & { _isBuild: boolean; _fontCache: Map; _cacheDir: string }, + } as Plugin & { + _isBuild: boolean; + _fontCache: Map; + _cacheDir: string; + }, // Local font path resolution: // When a source file calls localFont({ src: "./font.woff2" }) or // localFont({ src: [{ path: "./font.woff2" }] }), the relative paths @@ -3903,7 +3806,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Match font file paths in `path: "..."` or `src: "..."` properties. // Captures: (1) property+colon prefix, (2) quote char, (3) the path. - const fontPathRe = /((?:path|src)\s*:\s*)(['"])([^'"]+\.(?:woff2?|ttf|otf|eot))\2/g; + const fontPathRe = + /((?:path|src)\s*:\s*)(['"])([^'"]+\.(?:woff2?|ttf|otf|eot))\2/g; let match; while ((match = fontPathRe.exec(code)) !== null) { @@ -3933,15 +3837,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }, } as Plugin, - // Barrel import optimization: - // Rewrites `import { Slot } from "radix-ui"` → `import * as Slot from "@radix-ui/react-slot"` - // for packages listed in optimizePackageImports or DEFAULT_OPTIMIZE_PACKAGES. - // This prevents Vite from eagerly evaluating barrel re-exports that call - // React.createContext() in RSC environments where createContext doesn't exist. - createOptimizeImportsPlugin( - () => nextConfig, - () => root, - ), // "use cache" directive transform: // Detects "use cache" at file-level or function-level and wraps the // exports/functions with registerCachedFunction() from vinext/cache-runtime. @@ -3992,7 +3887,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // fn.body is a BlockStatement node ({type:"BlockStatement", body:Statement[]}), not // a raw array. Unwrap it. Arrow functions with expression bodies have a non-array // .body — the BlockStatement check handles that case (body.body would be undefined). - const stmts = fn?.body?.type === "BlockStatement" ? fn.body.body : null; + const stmts = + fn?.body?.type === "BlockStatement" ? fn.body.body : null; if (Array.isArray(stmts)) { for (const stmt of stmts) { if ( @@ -4021,9 +3917,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } // Walk into variable declarations, export declarations, etc. for (const key of Object.keys(node)) { - if (key === "type" || key === "start" || key === "end" || key === "loc") continue; + if ( + key === "type" || + key === "start" || + key === "end" || + key === "loc" + ) + continue; const child = node[key]; - if (Array.isArray(child) && child.some((c) => c && typeof c === "object")) { + if ( + Array.isArray(child) && + child.some((c) => c && typeof c === "object") + ) { if (astHasInlineCache(child)) return true; } else if (child && typeof child === "object" && child.type) { if (astHasInlineCache([child])) return true; @@ -4032,7 +3937,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } return false; } - const hasInlineCache = !cacheDirective && astHasInlineCache(ast.body as any[]); + const hasInlineCache = + !cacheDirective && astHasInlineCache(ast.body as any[]); if (!cacheDirective && !hasInlineCache) return null; @@ -4044,9 +3950,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { " @vitejs/plugin-rsc", ); } - const { transformWrapExport, transformHoistInlineDirective } = await import( - pathToFileURL(resolvedRscTransformsPath).href - ); + const { transformWrapExport, transformHoistInlineDirective } = + await import(pathToFileURL(resolvedRscTransformsPath).href); if (cacheDirective) { // File-level "use cache" — wrap function exports with @@ -4057,14 +3962,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const variant = directiveValue === "use cache" ? "" - : directiveValue.replace("use cache:", "").replace("use cache: ", "").trim(); + : directiveValue + .replace("use cache:", "") + .replace("use cache: ", "") + .trim(); // Only skip default export wrapping for layouts and templates — // they receive {children} from the framework which requires // temporary reference handling that registerCachedFunction doesn't // support yet. Pages, not-found, loading, error, and default are // leaf components with no {children} prop and can be cached directly. - const isLayoutOrTemplate = /\/(layout|template)\.(tsx?|jsx?|mjs)$/.test(id); + const isLayoutOrTemplate = + /\/(layout|template)\.(tsx?|jsx?|mjs)$/.test(id); const runtimeModuleUrl = pathToFileURL( resolveShimModulePath(shimsDir, "cache-runtime"), @@ -4128,7 +4037,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const variant = directiveMatch === "use cache" ? "" - : directiveMatch.replace("use cache:", "").replace("use cache: ", "").trim(); + : directiveMatch + .replace("use cache:", "") + .replace("use cache: ", "") + .trim(); return `(await import(${JSON.stringify(runtimeModuleUrl2)})).registerCachedFunction(${value}, ${JSON.stringify(id + ":" + name)}, ${JSON.stringify(variant)})`; }, rejectNonAsyncFunction: false, @@ -4276,15 +4188,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // // The edge build (dist/index.edge.js) uses: // - fetch(new URL("./noto-sans...", import.meta.url)) → inlined by og-inline-fetch-assets - // - resvg.wasm via dynamic import (og-font-patch rewrites the static import) + // - import resvg_wasm from "./resvg.wasm?module" → static Vite import, emitted by Rollup // // The node build (dist/index.node.js) uses: // - fs.readFileSync(fileURLToPath(new URL("./noto-sans...", import.meta.url))) → inlined // - fs.readFileSync(fileURLToPath(new URL("./resvg.wasm", import.meta.url))) → inlined // - // The og-font-patch plugin's resvg fallback uses new URL("./resvg.wasm", import.meta.url) - // which the bundler should emit as an asset. This plugin is kept as a safety net to ensure - // the resvg.wasm file exists in the output directory for the Node.js disk-read fallback. + // Both builds' font + WASM assets are inlined as base64 by vinext:og-inline-fetch-assets, + // so no file copy is strictly needed. This plugin is kept as a safety net for any edge-build + // ?module WASM imports that Rollup/Vite might not emit correctly in the RSC environment. { name: "vinext:og-assets", apply: "build", @@ -4309,7 +4221,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const ogAssets = ["resvg.wasm"]; // Only copy if the bundle actually references these files - const referencedAssets = ogAssets.filter((asset) => content.includes(asset)); + const referencedAssets = ogAssets.filter((asset) => + content.includes(asset), + ); if (referencedAssets.length === 0) return; // Find @vercel/og in node_modules @@ -4355,7 +4269,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { contentSecurityPolicy: nextConfig?.images?.contentSecurityPolicy, }; - fs.writeFileSync(path.join(outDir, "image-config.json"), JSON.stringify(imageConfig)); + fs.writeFileSync( + path.join(outDir, "image-config.json"), + JSON.stringify(imageConfig), + ); }, }, }, @@ -4390,7 +4307,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (!outDir) return; const manifest = { prerenderSecret }; - fs.writeFileSync(path.join(outDir, "vinext-server.json"), JSON.stringify(manifest)); + fs.writeFileSync( + path.join(outDir, "vinext-server.json"), + JSON.stringify(manifest), + ); }, }, }; @@ -4414,10 +4334,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (!fs.existsSync(ssrManifestPath)) return; try { - const ssrManifest = JSON.parse(fs.readFileSync(ssrManifestPath, "utf-8")) as Record< - string, - string[] - >; + const ssrManifest = JSON.parse( + fs.readFileSync(ssrManifestPath, "utf-8"), + ) as Record; const buildRoot = this.environment?.config.root ?? process.cwd(); const buildBase = this.environment?.config.base ?? "/"; const augmentedManifest = augmentSsrManifestFromBundle( @@ -4426,7 +4345,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { buildRoot, buildBase, ); - fs.writeFileSync(ssrManifestPath, JSON.stringify(augmentedManifest, null, 2)); + fs.writeFileSync( + ssrManifestPath, + JSON.stringify(augmentedManifest, null, 2), + ); } catch (err) { // Leave Vite's manifest untouched if parsing fails. console.warn("[vinext] Failed to augment SSR manifest:", err); @@ -4471,17 +4393,32 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // App Router gets its client entry via the RSC plugin instead. let lazyChunksData: string[] | null = null; let clientEntryFile: string | null = null; - const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); + const buildManifestPath = path.join( + clientDir, + ".vite", + "manifest.json", + ); if (fs.existsSync(buildManifestPath)) { try { - const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")); - for (const [, value] of Object.entries(buildManifest) as [string, any][]) { + const buildManifest = JSON.parse( + fs.readFileSync(buildManifestPath, "utf-8"), + ); + for (const [, value] of Object.entries(buildManifest) as [ + string, + any, + ][]) { if (value && value.isEntry && value.file) { - clientEntryFile = manifestFileWithBase(value.file, clientBase); + clientEntryFile = manifestFileWithBase( + value.file, + clientBase, + ); break; } } - const lazy = manifestFilesWithBase(computeLazyChunks(buildManifest), clientBase); + const lazy = manifestFilesWithBase( + computeLazyChunks(buildManifest), + clientBase, + ); if (lazy.length > 0) lazyChunksData = lazy; } catch { /* ignore parse errors */ @@ -4490,10 +4427,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Read SSR manifest for per-page CSS/JS injection let ssrManifestData: Record | null = null; - const ssrManifestPath = path.join(clientDir, ".vite", "ssr-manifest.json"); + const ssrManifestPath = path.join( + clientDir, + ".vite", + "ssr-manifest.json", + ); if (fs.existsSync(ssrManifestPath)) { try { - ssrManifestData = JSON.parse(fs.readFileSync(ssrManifestPath, "utf-8")); + ssrManifestData = JSON.parse( + fs.readFileSync(ssrManifestPath, "utf-8"), + ); } catch { /* ignore parse errors */ } @@ -4505,7 +4448,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // __VINEXT_LAZY_CHUNKS__ and __VINEXT_SSR_MANIFEST__ into the // worker entry at dist/server/index.js. const workerEntry = path.resolve(distDir, "server", "index.js"); - if (fs.existsSync(workerEntry) && (lazyChunksData || ssrManifestData)) { + if ( + fs.existsSync(workerEntry) && + (lazyChunksData || ssrManifestData) + ) { let code = fs.readFileSync(workerEntry, "utf-8"); const globals: string[] = []; if (ssrManifestData) { @@ -4550,10 +4496,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const files = fs.readdirSync(assetsDir); const entry = files.find( (f: string) => - (f.includes("vinext-client-entry") || f.includes("vinext-app-browser-entry")) && + (f.includes("vinext-client-entry") || + f.includes("vinext-app-browser-entry")) && f.endsWith(".js"), ); - if (entry) clientEntryFile = manifestFileWithBase("assets/" + entry, clientBase); + if (entry) + clientEntryFile = manifestFileWithBase( + "assets/" + entry, + clientBase, + ); } } @@ -4603,98 +4554,55 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }, { - // @vercel/og WASM patch — universal (workerd + Node.js) - // - // @vercel/og/dist/index.edge.js uses two WASM modules that need special handling: + // @vercel/og patch for workerd (cloudflare-dev + cloudflare-workers) // - // 1. YOGA WASM: yoga-layout embeds its WASM as a base64 data URL and instantiates - // it via WebAssembly.instantiate(bytes). workerd forbids this — WASM must be - // loaded as a pre-compiled WebAssembly.Module via the module system. + // @vercel/og/dist/index.edge.js has one remaining workerd issue after the + // generic vinext:og-inline-fetch-assets plugin runs (which already handles + // the font fetch pattern): // - // 2. RESVG WASM: imported as `import resvg_wasm from "./resvg.wasm?module"` which - // only works on workerd. Node.js can't import WASM files as ESM modules. - // - // Fix: replace all static WASM imports with dynamic imports that try the ?module - // path (for workerd) and fall back to compiling from bytes (for Node.js). This - // produces a single build output that runs on both runtimes. + // YOGA WASM: yoga-layout embeds its WASM as a base64 data URL and instantiates + // it via WebAssembly.instantiate(bytes) at runtime. + // workerd forbids dynamic WASM compilation from bytes — WASM must be loaded + // through the module system as a pre-compiled WebAssembly.Module. + // Fix: extract the yoga WASM bytes at Vite transform time (Node.js), write + // yoga.wasm to @vercel/og/dist/, import it via `?module` so @cloudflare/vite-plugin + // can serve it through the module system, and inject h2.instantiateWasm to + // use the pre-compiled module instead of bytes. name: "vinext:og-font-patch", enforce: "pre" as const, transform(code: string, id: string) { - if (!id.includes("@vercel/og") || !id.includes("index.edge.js")) return null; + if (!id.includes("@vercel/og") || !id.includes("index.edge.js")) + return null; let result = code; - // ── Yoga WASM: dynamic import + inline base64 fallback ────────────────────── + // ── Extract yoga WASM and import via ?module ────────────────────────────────── // yoga-layout's emscripten bundle sets H to a data URL containing the yoga WASM, // then later calls WebAssembly.instantiate(bytes, imports), which workerd rejects. - // Emscripten supports a custom h2.instantiateWasm(imports, callback) escape hatch. - // - // Strategy: try dynamic import("./yoga.wasm?module") for workerd (pre-compiled - // module), fall back to compiling from inline base64 bytes for Node.js. - // Yoga WASM is ~70KB so inlining the base64 (~95KB) is acceptable. - const YOGA_DATA_URL_RE = /H = "data:application\/octet-stream;base64,([A-Za-z0-9+/]+=*)";/; + // Emscripten supports a custom h2.instantiateWasm(imports, callback) escape hatch + // that we inject to use a pre-compiled WebAssembly.Module loaded via ?module. + const YOGA_DATA_URL_RE = + /H = "data:application\/octet-stream;base64,([A-Za-z0-9+/]+=*)";/; const yogaMatch = YOGA_DATA_URL_RE.exec(result); if (yogaMatch) { const yogaBase64 = yogaMatch[1]; const distDir = path.dirname(id); const yogaWasmPath = path.join(distDir, "yoga.wasm"); // Write yoga.wasm to disk idempotently at transform time (Node.js side) - // so the ?module dynamic import can resolve it on workerd builds. if (!fs.existsSync(yogaWasmPath)) { fs.writeFileSync(yogaWasmPath, Buffer.from(yogaBase64, "base64")); } // Disable the data-URL branch so emscripten doesn't try to instantiate from bytes result = result.replace(yogaMatch[0], `H = "";`); - // Patch the loadYoga call site to inject instantiateWasm with universal handler. - // WebAssembly.instantiate(Module, imports) → Instance (workerd path) - // WebAssembly.instantiate(bytes, imports) → { module, instance } (Node.js path) + // Patch the loadYoga call site to inject instantiateWasm using the ?module import const YOGA_CALL = `yoga_wasm_base64_esm_default()`; - const YOGA_CALL_PATCHED = [ - `yoga_wasm_base64_esm_default({ instantiateWasm: function(imports, callback) {`, - ` __vi_yoga_mod.then(function(mod) {`, - ` if (mod) {`, - ` WebAssembly.instantiate(mod, imports).then(function(inst) { callback(inst); });`, - ` } else {`, - ` var b = Buffer.from(__vi_yoga_b64, "base64");`, - ` WebAssembly.instantiate(b, imports).then(function(r) { callback(r.instance); });`, - ` }`, - ` });`, - ` return {};`, - `} })`, - ].join("\n"); + const YOGA_CALL_PATCHED = + `yoga_wasm_base64_esm_default({ instantiateWasm: function(imports, callback) {` + + ` WebAssembly.instantiate(yoga_wasm_module, imports).then(function(inst) { callback(inst); });` + + ` return {}; } })`; result = result.replace(YOGA_CALL, YOGA_CALL_PATCHED); - // Prepend dynamic import with base64 fallback (no static import — Node.js safe) - const yogaPreamble = [ - `var __vi_yoga_b64 = ${JSON.stringify(yogaBase64)};`, - `var __vi_yoga_mod = import("./yoga.wasm?module").then(function(m) { return m.default; }).catch(function() { return null; });`, - ].join("\n"); - result = yogaPreamble + "\n" + result; - } - - // ── Resvg WASM: dynamic import + disk fallback ────────────────────────────── - // The edge entry has `import resvg_wasm from "./resvg.wasm?module"` which is a - // static ESM import that only works on workerd. Node.js fails because the WASM - // binary's emscripten imports (module "a") can't be resolved as npm packages. - // - // Strategy: replace the static import with a dynamic import for workerd, falling - // back to reading the .wasm file from disk + WebAssembly.compile for Node.js. - // Resvg WASM is ~1.3MB so we read from disk instead of inlining base64. - const RESVG_STATIC_IMPORT_RE = - /import\s+resvg_wasm\s+from\s+["']\.\/resvg\.wasm\?module["']\s*;?/; - const resvgMatch = RESVG_STATIC_IMPORT_RE.exec(result); - if (resvgMatch) { - // Note: new URL("./resvg.wasm", import.meta.url) MUST be inside the catch handler, - // not at the top level. In workerd, import.meta.url is "worker" (not a valid URL - // base), so new URL(..., "worker") throws TypeError at module load time. - // The catch block only runs on Node.js where import.meta.url is a file:// URL. - const resvgLoader = [ - `var resvg_wasm = import("./resvg.wasm?module").then(function(m) { return m.default; }).catch(function() {`, - ` return Promise.all([import("node:fs"), import("node:url")]).then(function(mods) {`, - ` var p = mods[1].fileURLToPath(new URL("./resvg.wasm", import.meta.url));`, - ` return mods[0].promises.readFile(p).then(function(buf) { return WebAssembly.compile(buf); });`, - ` });`, - `});`, - ].join("\n"); - result = result.replace(resvgMatch[0], resvgLoader); + // Prepend the yoga wasm ?module import so @cloudflare/vite-plugin handles it + result = + `import yoga_wasm_module from "./yoga.wasm?module";\n` + result; } if (result === code) return null; @@ -4743,7 +4651,11 @@ export { matchConfigPattern } from "./config/config-matchers.js"; * Modeled after Next.js's SWC `next-ssg-transform`. */ function stripServerExports(code: string): string | null { - const SERVER_EXPORTS = new Set(["getServerSideProps", "getStaticProps", "getStaticPaths"]); + const SERVER_EXPORTS = new Set([ + "getServerSideProps", + "getStaticProps", + "getStaticPaths", + ]); if (![...SERVER_EXPORTS].some((name) => code.includes(name))) return null; let ast: ReturnType; @@ -4764,7 +4676,10 @@ function stripServerExports(code: string): string | null { // Case 2: export const/let/var name = ... if (node.declaration) { const decl = node.declaration; - if (decl.type === "FunctionDeclaration" && SERVER_EXPORTS.has(decl.id?.name)) { + if ( + decl.type === "FunctionDeclaration" && + SERVER_EXPORTS.has(decl.id?.name) + ) { s.overwrite( node.start, node.end, @@ -4773,8 +4688,15 @@ function stripServerExports(code: string): string | null { changed = true; } else if (decl.type === "VariableDeclaration") { for (const declarator of decl.declarations) { - if (declarator.id?.type === "Identifier" && SERVER_EXPORTS.has(declarator.id.name)) { - s.overwrite(node.start, node.end, `export const ${declarator.id.name} = undefined;`); + if ( + declarator.id?.type === "Identifier" && + SERVER_EXPORTS.has(declarator.id.name) + ) { + s.overwrite( + node.start, + node.end, + `export const ${declarator.id.name} = undefined;`, + ); changed = true; } } @@ -4836,7 +4758,9 @@ function applyRedirects( if (result) { // Sanitize to prevent open redirect via protocol-relative URLs const dest = sanitizeDestination( - basePath && !isExternalUrl(result.destination) && !hasBasePath(result.destination, basePath) + basePath && + !isExternalUrl(result.destination) && + !hasBasePath(result.destination, basePath) ? basePath + result.destination : result.destination, ); @@ -4885,7 +4809,9 @@ async function proxyExternalRewriteNode( proxyResponse.headers.forEach((value, key) => { const existing = nodeHeaders[key]; if (existing !== undefined) { - nodeHeaders[key] = Array.isArray(existing) ? [...existing, value] : [existing, value]; + nodeHeaders[key] = Array.isArray(existing) + ? [...existing, value] + : [existing, value]; } else { nodeHeaders[key] = value; } @@ -4994,7 +4920,11 @@ const _mdxScanCache = new Map(); /** * Check if the project has .mdx files in app/ or pages/ directories. */ -function hasMdxFiles(root: string, appDir: string | null, pagesDir: string | null): boolean { +function hasMdxFiles( + root: string, + appDir: string | null, + pagesDir: string | null, +): boolean { const cacheKey = `${root}\0${appDir ?? ""}\0${pagesDir ?? ""}`; if (_mdxScanCache.has(cacheKey)) return _mdxScanCache.get(cacheKey)!; const dirs = [appDir, pagesDir].filter(Boolean) as string[]; @@ -5039,7 +4969,15 @@ export type { export type { NextConfig } from "./config/next-config.js"; // Exported for CLI and testing -export { clientManualChunks, clientOutputConfig, clientTreeshakeConfig, computeLazyChunks }; +export { + clientManualChunks, + clientOutputConfig, + clientTreeshakeConfig, + computeLazyChunks, + getClientBuildOptions, + getClientOutputConfig, + getClientTreeshakeConfig, +}; export { augmentSsrManifestFromBundle as _augmentSsrManifestFromBundle }; export { resolvePostcssStringPlugins as _resolvePostcssStringPlugins }; export { _postcssCache }; diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 261309b7..fb1d2928 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -11,11 +11,15 @@ import path from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test"; import { clientManualChunks, + clientOutputConfig, clientTreeshakeConfig, computeLazyChunks, _augmentSsrManifestFromBundle, _stripServerExports, _asyncHooksStubPlugin, + getClientOutputConfig, + getClientTreeshakeConfig, + getViteMajorVersion, } from "../packages/vinext/src/index.js"; // The vinext config hook mutates process.env.NODE_ENV as a side effect (matching @@ -50,6 +54,50 @@ describe("clientTreeshakeConfig", () => { }); }); +// ─── getClientOutputConfig / getClientTreeshakeConfig ──────────────────────── + +describe("getClientOutputConfig", () => { + it("returns full config with experimentalMinChunkSize for Vite 7", () => { + const result = getClientOutputConfig(7); + expect(result).toEqual(clientOutputConfig); + expect((result as any).experimentalMinChunkSize).toBe(10_000); + expect(result.manualChunks).toBe(clientManualChunks); + }); + + it("returns config without experimentalMinChunkSize for Vite 8", () => { + const result = getClientOutputConfig(8); + expect(result).toEqual({ manualChunks: clientManualChunks }); + expect(result).not.toHaveProperty("experimentalMinChunkSize"); + }); + + it("returns config without experimentalMinChunkSize for Vite 9", () => { + const result = getClientOutputConfig(9); + expect(result).toEqual({ manualChunks: clientManualChunks }); + expect(result).not.toHaveProperty("experimentalMinChunkSize"); + }); +}); + +describe("getClientTreeshakeConfig", () => { + it("returns full config with preset for Vite 7", () => { + const result = getClientTreeshakeConfig(7); + expect(result).toEqual(clientTreeshakeConfig); + expect((result as any).preset).toBe("recommended"); + expect(result.moduleSideEffects).toBe("no-external"); + }); + + it("returns config without preset for Vite 8", () => { + const result = getClientTreeshakeConfig(8); + expect(result).toEqual({ moduleSideEffects: "no-external" }); + expect(result).not.toHaveProperty("preset"); + }); + + it("returns config without preset for Vite 9", () => { + const result = getClientTreeshakeConfig(9); + expect(result).toEqual({ moduleSideEffects: "no-external" }); + expect(result).not.toHaveProperty("preset"); + }); +}); + // ─── clientManualChunks ─────────────────────────────────────────────────────── describe("clientManualChunks", () => { @@ -377,11 +425,20 @@ describe("treeshake config integration", () => { }; const result = await (mainPlugin as any).config(mockConfig, { command: "build" }); - // treeshake should be set on rollupOptions for non-SSR builds - expect(result.build.rollupOptions.treeshake).toEqual({ - preset: "recommended", - moduleSideEffects: "no-external", - }); + // treeshake should be set for non-SSR builds (version-gated for Vite 8+) + const viteVersion = getViteMajorVersion(); + if (viteVersion >= 8) { + // Vite 8+ uses rolldownOptions + expect(result.build.rolldownOptions?.treeshake).toEqual({ + moduleSideEffects: "no-external", + }); + } else { + // Vite 7 uses rollupOptions + expect(result.build.rollupOptions.treeshake).toEqual({ + preset: "recommended", + moduleSideEffects: "no-external", + }); + } } finally { await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } @@ -420,7 +477,7 @@ describe("treeshake config integration", () => { const result = await (mainPlugin as any).config(mockConfig, { command: "build" }); // treeshake should NOT be set for SSR builds - expect(result.build.rollupOptions.treeshake).toBeUndefined(); + expect(result.build.rollupOptions?.treeshake).toBeUndefined(); } finally { await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } @@ -466,18 +523,31 @@ describe("treeshake config integration", () => { }; const result = await (mainPlugin as any).config(mockConfig, { command: "build" }); - // Global rollupOptions should NOT have treeshake (would leak into RSC/SSR) - expect(result.build.rollupOptions.treeshake).toBeUndefined(); - - // Client environment should have treeshake - expect(result.environments.client.build.rollupOptions.treeshake).toEqual({ - preset: "recommended", - moduleSideEffects: "no-external", - }); + // Global build should NOT have treeshake (would leak into RSC/SSR) + const viteVersion = getViteMajorVersion(); + if (viteVersion >= 8) { + expect(result.build.rolldownOptions?.treeshake).toBeUndefined(); + // Client environment should have treeshake (Vite 8+) + expect(result.environments.client.build.rolldownOptions?.treeshake).toEqual({ + moduleSideEffects: "no-external", + }); + } else { + expect(result.build.rollupOptions?.treeshake).toBeUndefined(); + // Client environment should have treeshake (Vite 7) + expect(result.environments.client.build.rollupOptions.treeshake).toEqual({ + preset: "recommended", + moduleSideEffects: "no-external", + }); + } // RSC and SSR environments should NOT have treeshake - expect(result.environments.rsc.build?.rollupOptions?.treeshake).toBeUndefined(); - expect(result.environments.ssr.build?.rollupOptions?.treeshake).toBeUndefined(); + if (viteVersion >= 8) { + expect(result.environments.rsc.build?.rolldownOptions?.treeshake).toBeUndefined(); + expect(result.environments.ssr.build?.rolldownOptions?.treeshake).toBeUndefined(); + } else { + expect(result.environments.rsc.build?.rollupOptions?.treeshake).toBeUndefined(); + expect(result.environments.ssr.build?.rollupOptions?.treeshake).toBeUndefined(); + } } finally { await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } @@ -516,10 +586,19 @@ describe("treeshake config integration", () => { const result = await (mainPlugin as any).config(mockConfig, { command: "build" }); // For standalone client builds (non-SSR, non-multi-env), - // output config should include experimentalMinChunkSize - const output = result.build.rollupOptions.output; - expect(output).toBeDefined(); - expect(output.experimentalMinChunkSize).toBe(10_000); + // output config should include experimentalMinChunkSize (Vite 7 only) + const viteVersion = getViteMajorVersion(); + if (viteVersion >= 8) { + // Vite 8+ doesn't support experimentalMinChunkSize + const output = result.build.rolldownOptions?.output; + expect(output).toBeDefined(); + expect((output as any).experimentalMinChunkSize).toBeUndefined(); + } else { + // Vite 7 includes experimentalMinChunkSize + const output = result.build.rollupOptions.output; + expect(output).toBeDefined(); + expect(output.experimentalMinChunkSize).toBe(10_000); + } } finally { await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } @@ -1621,3 +1700,69 @@ export const getStaticPaths = () => [ expect(result).not.toContain("a;b"); }); }); + +// Vite 8 multi-env tests +describe("multi-env client build config (Vite 8)", () => { + it("places output and treeshake under rolldownOptions, not rollupOptions", async () => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const plugins = vinext(); + + const mainPlugin = plugins.find( + (p: any) => p.name === "vinext:config" && typeof p.config === "function", + ); + expect(mainPlugin).toBeDefined(); + + const os = await import("node:os"); + const fsp = await import("node:fs/promises"); + const path = await import("node:path"); + + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-ts-test-vite8-")); + const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules"); + await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction"); + + // Create an app/ directory to trigger App Router multi-env mode + await fsp.mkdir(path.join(tmpDir, "app"), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, "app", "layout.tsx"), + `export default function RootLayout({ children }: { children: React.ReactNode }) { return {children}; }`, + ); + await fsp.writeFile( + path.join(tmpDir, "app", "page.tsx"), + `export default function Home() { return

Home

; }`, + ); + await fsp.writeFile(path.join(tmpDir, "next.config.mjs"), `export default {};`); + + try { + const mockConfig = { + root: tmpDir, + build: {}, + plugins: [], + }; + const result = await (mainPlugin as any).config(mockConfig, { command: "build" }); + + const viteVersion = getViteMajorVersion(); + if (viteVersion >= 8) { + // Vite 8 shape - config under rolldownOptions + const clientBuild = result.environments.client.build; + expect(clientBuild.rolldownOptions?.output?.manualChunks).toBeDefined(); + expect(clientBuild.rolldownOptions?.treeshake).toEqual({ + moduleSideEffects: "no-external", + }); + + // These must NOT be present on Vite 8 + expect(clientBuild.rollupOptions?.treeshake).toBeUndefined(); + expect(clientBuild.rolldownOptions?.output?.experimentalMinChunkSize).toBeUndefined(); + } else { + // Vite 7 shape - config under rollupOptions + const clientBuild = result.environments.client.build; + expect(clientBuild.rollupOptions?.output?.manualChunks).toBeDefined(); + expect(clientBuild.rollupOptions?.treeshake).toEqual({ + preset: "recommended", + moduleSideEffects: "no-external", + }); + } + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }); +});