From 00c87115ab2047bfcea80be44820c659740ea04a Mon Sep 17 00:00:00 2001 From: Ely Delva Date: Wed, 11 Mar 2026 18:33:52 +0100 Subject: [PATCH 01/11] feat: add assetPrefix support in next.config Closes #472 From 8b5071aec79aee20f813ec31b1e1ad4ad7166c4b Mon Sep 17 00:00:00 2001 From: Ely Delva Date: Wed, 11 Mar 2026 18:57:47 +0100 Subject: [PATCH 02/11] feat: support assetPrefix in next.config Closes #472 --- packages/vinext/src/config/next-config.ts | 13 ++++ packages/vinext/src/entries/app-rsc-entry.ts | 3 + .../vinext/src/entries/pages-server-entry.ts | 11 ++-- packages/vinext/src/index.ts | 28 +++++++- packages/vinext/src/server/prod-server.ts | 4 ++ packages/vinext/src/shims/font-local.ts | 12 +++- tests/asset-prefix.test.ts | 64 +++++++++++++++++++ tests/next-config.test.ts | 1 + 8 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 tests/asset-prefix.test.ts diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index e6e969f11..ab0cc1fc4 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -127,6 +127,8 @@ export interface NextConfig { env?: Record; /** Base URL path prefix */ basePath?: string; + /** CDN URL prefix for all static assets (JS chunks, CSS, fonts). Trailing slash stripped. */ + assetPrefix?: string; /** Whether to add trailing slashes */ trailingSlash?: boolean; /** Internationalization routing config */ @@ -204,6 +206,8 @@ export interface NextConfig { export interface ResolvedNextConfig { env: Record; basePath: string; + /** Resolved CDN URL prefix for static assets. Empty string if not set. */ + assetPrefix: string; trailingSlash: boolean; output: "" | "export" | "standalone"; pageExtensions: string[]; @@ -393,6 +397,7 @@ export async function resolveNextConfig( const resolved: ResolvedNextConfig = { env: {}, basePath: "", + assetPrefix: "", trailingSlash: false, output: "", pageExtensions: normalizePageExtensions(), @@ -526,6 +531,7 @@ export async function resolveNextConfig( const resolved: ResolvedNextConfig = { env: config.env ?? {}, basePath: config.basePath ?? "", + assetPrefix: typeof config.assetPrefix === "string" ? config.assetPrefix.replace(/\/$/, "") : "", trailingSlash: config.trailingSlash ?? false, output: output === "export" || output === "standalone" ? output : "", pageExtensions, @@ -543,6 +549,13 @@ export async function resolveNextConfig( buildId, }; + // If assetPrefix is empty and basePath is set, inherit basePath as assetPrefix. + // This matches Next.js behavior: static assets are served under basePath when + // no explicit CDN prefix is configured. + if (resolved.assetPrefix === "" && resolved.basePath !== "") { + resolved.assetPrefix = resolved.basePath; + } + // Auto-detect next-intl (lowest priority — explicit aliases from // webpack/turbopack already in `aliases` take precedence) detectNextIntlConfig(root, resolved); diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 099e9fcac..b02424c0d 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -82,9 +82,11 @@ export function generateRscEntry( trailingSlash?: boolean, config?: AppRouterConfig, instrumentationPath?: string | null, + assetPrefix?: string, ): string { const bp = basePath ?? ""; const ts = trailingSlash ?? false; + const ap = assetPrefix ?? ""; const redirects = config?.redirects ?? []; const rewrites = config?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] }; const headers = config?.headers ?? []; @@ -1278,6 +1280,7 @@ async function buildPageElement(route, params, opts, searchParams) { ${middlewarePath ? generateMiddlewareMatcherCode("modern") : ""} const __basePath = ${JSON.stringify(bp)}; +const __assetPrefix = ${JSON.stringify(ap)}; const __trailingSlash = ${JSON.stringify(ts)}; const __i18nConfig = ${JSON.stringify(i18nConfig)}; const __configRedirects = ${JSON.stringify(redirects)}; diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index fb452f291..4dfc47d80 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -97,6 +97,7 @@ export async function generateServerEntry( // so prod-server.ts can apply them without loading next.config.js at runtime. const vinextConfigJson = JSON.stringify({ basePath: nextConfig?.basePath ?? "", + assetPrefix: nextConfig?.assetPrefix ?? "", trailingSlash: nextConfig?.trailingSlash ?? false, redirects: nextConfig?.redirects ?? [], rewrites: nextConfig?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] }, @@ -423,8 +424,8 @@ function collectAssetTags(manifest, moduleIds) { if (typeof globalThis !== "undefined" && globalThis.__VINEXT_CLIENT_ENTRY__) { const entry = globalThis.__VINEXT_CLIENT_ENTRY__; seen.add(entry); - tags.push(''); - tags.push(''); + tags.push(''); + tags.push(''); } if (m) { // Always inject shared chunks (framework, vinext runtime, entry) and @@ -499,13 +500,13 @@ function collectAssetTags(manifest, moduleIds) { if (seen.has(tf)) continue; seen.add(tf); if (tf.endsWith(".css")) { - tags.push(''); + tags.push(''); } else if (tf.endsWith(".js")) { // Skip lazy chunks — they are behind dynamic import() boundaries // (React.lazy, next/dynamic) and should only be fetched on demand. if (lazySet && lazySet.has(tf)) continue; - tags.push(''); - tags.push(''); + tags.push(''); + tags.push(''); } } } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 414dcb97f..46671f25a 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1235,6 +1235,24 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { define: defines, // Set base path if configured ...(nextConfig.basePath ? { base: nextConfig.basePath + "/" } : {}), + // When assetPrefix is set, rewrite built asset/chunk URLs to the CDN origin. + // SSR environments stay relative so server-side imports resolve correctly. + ...(nextConfig.assetPrefix + ? { + experimental: { + renderBuiltUrl( + filename: string, + { type, ssr }: { type: string; ssr: boolean }, + ) { + if (ssr) return { relative: true } + if (type === "asset" || type === "chunk") { + return nextConfig.assetPrefix + "/" + filename + } + return { relative: true } + }, + }, + } + : {}), // Inject resolved PostCSS plugins if string names were found ...(postcssOverride ? { css: { postcss: postcssOverride } } : {}), }; @@ -1526,6 +1544,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { i18n: nextConfig?.i18n, }, instrumentationPath, + nextConfig?.assetPrefix, ); } if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) { @@ -3135,7 +3154,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { 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); + // When assetPrefix is set, store the raw filename (without the Vite + // base prefix) so that collectAssetTags can prepend assetPrefix at + // render time without double-prefixing. + if (nextConfig?.assetPrefix) { + clientEntryFile = normalizeManifestFile(value.file) + } else { + clientEntryFile = manifestFileWithBase(value.file, clientBase) + } break; } } diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 82b5ee507..3dfec6dec 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -732,6 +732,10 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // Extract config values (embedded at build time in the server entry) const basePath: string = vinextConfig?.basePath ?? ""; + // assetBase is used only for internal manifest path lookups and dedup + // (e.g. mapping SSR manifest keys to lazy-chunk filenames). It is NOT + // used to construct href attributes in HTML — those are handled by + // collectAssetTags in the generated server entry, which applies assetPrefix. const assetBase = basePath ? `${basePath}/` : "/"; const trailingSlash: boolean = vinextConfig?.trailingSlash ?? false; const configRedirects = vinextConfig?.redirects ?? []; diff --git a/packages/vinext/src/shims/font-local.ts b/packages/vinext/src/shims/font-local.ts index d689c9adb..f828233d1 100644 --- a/packages/vinext/src/shims/font-local.ts +++ b/packages/vinext/src/shims/font-local.ts @@ -323,10 +323,18 @@ function collectFontPreloads(options: LocalFontOptions): void { for (const src of sources) { const href = src.path; - // Only collect URLs that are absolute (start with /) — relative paths + // Only collect URLs that are absolute or CDN-prefixed — relative paths // would resolve incorrectly from different page URLs. The vinext:local-fonts // Vite transform should have already resolved them to absolute URLs. - if (href && href.startsWith("/") && !ssrFontPreloadHrefs.has(href)) { + // Accept https://, http://, and protocol-relative // URLs for assetPrefix CDN cases. + if ( + href && + (href.startsWith("/") || + href.startsWith("https://") || + href.startsWith("http://") || + href.startsWith("//")) && + !ssrFontPreloadHrefs.has(href) + ) { ssrFontPreloadHrefs.add(href); ssrFontPreloads.push({ href, type: getFontMimeType(href) }); } diff --git a/tests/asset-prefix.test.ts b/tests/asset-prefix.test.ts new file mode 100644 index 000000000..144d6850a --- /dev/null +++ b/tests/asset-prefix.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for assetPrefix support in next.config resolution. + * + * Ported from Next.js: test/unit/next-config-output-export.test.ts (conceptually) + * https://github.com/vercel/next.js/blob/canary/test/unit/next-config-output-export.test.ts + * + * Verifies: + * - resolveNextConfig propagates assetPrefix with trailing slash stripped + * - assetPrefix defaults to basePath when assetPrefix is empty and basePath is set + * - explicit assetPrefix is NOT overridden by basePath + * - null config defaults assetPrefix to "" + */ +import { describe, it, expect } from "vitest" +import { + resolveNextConfig, + type NextConfig, +} from "../packages/vinext/src/config/next-config.js" + +describe("assetPrefix — resolveNextConfig", () => { + it("resolves assetPrefix from next.config and strips trailing slash", async () => { + const config: NextConfig = { assetPrefix: "https://cdn.example.com/" } + const resolved = await resolveNextConfig(config) + expect(resolved.assetPrefix).toBe("https://cdn.example.com") + }) + + it("resolves assetPrefix without trailing slash unchanged", async () => { + const config: NextConfig = { assetPrefix: "https://cdn.example.com" } + const resolved = await resolveNextConfig(config) + expect(resolved.assetPrefix).toBe("https://cdn.example.com") + }) + + it("defaults assetPrefix to basePath when assetPrefix is empty and basePath is set", async () => { + const config: NextConfig = { basePath: "/app" } + const resolved = await resolveNextConfig(config) + expect(resolved.basePath).toBe("/app") + expect(resolved.assetPrefix).toBe("/app") + }) + + it("does NOT inherit basePath when assetPrefix is explicitly set", async () => { + const config: NextConfig = { + basePath: "/app", + assetPrefix: "https://cdn.example.com", + } + const resolved = await resolveNextConfig(config) + expect(resolved.assetPrefix).toBe("https://cdn.example.com") + }) + + it("defaults assetPrefix to empty string when neither assetPrefix nor basePath are set", async () => { + const config: NextConfig = {} + const resolved = await resolveNextConfig(config) + expect(resolved.assetPrefix).toBe("") + }) + + it("defaults assetPrefix to empty string when config is null", async () => { + const resolved = await resolveNextConfig(null) + expect(resolved.assetPrefix).toBe("") + }) + + it("strips trailing slash from assetPrefix regardless of position", async () => { + const config: NextConfig = { assetPrefix: "https://cdn.example.com/subdir/" } + const resolved = await resolveNextConfig(config) + expect(resolved.assetPrefix).toBe("https://cdn.example.com/subdir") + }) +}) diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index f6ca9728c..27ae77614 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -344,6 +344,7 @@ describe("detectNextIntlConfig", () => { return { env: {}, basePath: "", + assetPrefix: "", trailingSlash: false, output: "", pageExtensions: ["tsx", "ts", "jsx", "js"], From d99cc979da694a9a40a04a944d1c601ff253d3f5 Mon Sep 17 00:00:00 2001 From: Ely Delva Date: Wed, 11 Mar 2026 19:08:47 +0100 Subject: [PATCH 03/11] test: update entry-template snapshots for assetPrefix --- .../__snapshots__/entry-templates.test.ts.snap | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index c853dc155..0d33a0a61 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1389,6 +1389,7 @@ async function buildPageElement(route, params, opts, searchParams) { const __basePath = ""; +const __assetPrefix = ""; const __trailingSlash = false; const __i18nConfig = null; const __configRedirects = []; @@ -4161,6 +4162,7 @@ async function buildPageElement(route, params, opts, searchParams) { const __basePath = "/base"; +const __assetPrefix = ""; const __trailingSlash = true; const __i18nConfig = null; const __configRedirects = [{"source":"/old","destination":"/new","permanent":true}]; @@ -6966,6 +6968,7 @@ async function buildPageElement(route, params, opts, searchParams) { const __basePath = ""; +const __assetPrefix = ""; const __trailingSlash = false; const __i18nConfig = null; const __configRedirects = []; @@ -9775,6 +9778,7 @@ async function buildPageElement(route, params, opts, searchParams) { const __basePath = ""; +const __assetPrefix = ""; const __trailingSlash = false; const __i18nConfig = null; const __configRedirects = []; @@ -12557,6 +12561,7 @@ async function buildPageElement(route, params, opts, searchParams) { const __basePath = ""; +const __assetPrefix = ""; const __trailingSlash = false; const __i18nConfig = null; const __configRedirects = []; @@ -15525,6 +15530,7 @@ function matchesMiddleware(pathname, matcher, request, i18nConfig) { } const __basePath = ""; +const __assetPrefix = ""; const __trailingSlash = false; const __i18nConfig = null; const __configRedirects = []; @@ -17904,7 +17910,7 @@ const i18nConfig = null; const buildId = "test-build-id"; // Full resolved config for production server (embedded at build time) -export const vinextConfig = {"basePath":"","trailingSlash":false,"redirects":[{"source":"/old-about","destination":"/about","permanent":true},{"source":"/repeat-redirect/:id","destination":"/docs/:id/:id","permanent":false},{"source":"/redirect-before-middleware-rewrite","destination":"/about","permanent":false},{"source":"/redirect-before-middleware-response","destination":"/about","permanent":false}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"source":"/repeat-rewrite/:id","destination":"/docs/:id/:id"},{"source":"/mw-gated-before","has":[{"type":"cookie","key":"mw-before-user"}],"destination":"/about"}],"afterFiles":[{"source":"/after-rewrite","destination":"/about"},{"source":"/mw-gated-rewrite","has":[{"type":"cookie","key":"mw-user"}],"destination":"/about"}],"fallback":[{"source":"/fallback-rewrite","destination":"/about"}]},"headers":[{"source":"/api/(.*)","headers":[{"key":"X-Custom-Header","value":"vinext"}]},{"source":"/about","has":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Auth-Only-Header","value":"1"}]},{"source":"/about","missing":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Guest-Only-Header","value":"1"}]},{"source":"/ssr","headers":[{"key":"Vary","value":"Accept-Language"}]},{"source":"/headers-before-middleware-rewrite","headers":[{"key":"X-Rewrite-Source-Header","value":"1"}]}],"i18n":null,"images":{}}; +export const vinextConfig = {"basePath":"","assetPrefix":"","trailingSlash":false,"redirects":[{"source":"/old-about","destination":"/about","permanent":true},{"source":"/repeat-redirect/:id","destination":"/docs/:id/:id","permanent":false},{"source":"/redirect-before-middleware-rewrite","destination":"/about","permanent":false},{"source":"/redirect-before-middleware-response","destination":"/about","permanent":false}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"source":"/repeat-rewrite/:id","destination":"/docs/:id/:id"},{"source":"/mw-gated-before","has":[{"type":"cookie","key":"mw-before-user"}],"destination":"/about"}],"afterFiles":[{"source":"/after-rewrite","destination":"/about"},{"source":"/mw-gated-rewrite","has":[{"type":"cookie","key":"mw-user"}],"destination":"/about"}],"fallback":[{"source":"/fallback-rewrite","destination":"/about"}]},"headers":[{"source":"/api/(.*)","headers":[{"key":"X-Custom-Header","value":"vinext"}]},{"source":"/about","has":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Auth-Only-Header","value":"1"}]},{"source":"/about","missing":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Guest-Only-Header","value":"1"}]},{"source":"/ssr","headers":[{"key":"Vary","value":"Accept-Language"}]},{"source":"/headers-before-middleware-rewrite","headers":[{"key":"X-Rewrite-Source-Header","value":"1"}]}],"i18n":null,"images":{}}; class ApiBodyParseError extends Error { constructor(message, statusCode) { @@ -18124,8 +18130,8 @@ function collectAssetTags(manifest, moduleIds) { if (typeof globalThis !== "undefined" && globalThis.__VINEXT_CLIENT_ENTRY__) { const entry = globalThis.__VINEXT_CLIENT_ENTRY__; seen.add(entry); - tags.push(''); - tags.push(''); + tags.push(''); + tags.push(''); } if (m) { // Always inject shared chunks (framework, vinext runtime, entry) and @@ -18200,13 +18206,13 @@ function collectAssetTags(manifest, moduleIds) { if (seen.has(tf)) continue; seen.add(tf); if (tf.endsWith(".css")) { - tags.push(''); + tags.push(''); } else if (tf.endsWith(".js")) { // Skip lazy chunks — they are behind dynamic import() boundaries // (React.lazy, next/dynamic) and should only be fetched on demand. if (lazySet && lazySet.has(tf)) continue; - tags.push(''); - tags.push(''); + tags.push(''); + tags.push(''); } } } From 04e09e153a721456086f2758722f5299841ce70f Mon Sep 17 00:00:00 2001 From: Ely Delva Date: Wed, 11 Mar 2026 19:19:43 +0100 Subject: [PATCH 04/11] test: add font-local CDN preload and basePath+assetPrefix combination tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/font-asset-prefix.test.ts: covers collectFontPreloads accepting https://, http://, and // protocol-relative URLs — the bug where CDN URLs were silently dropped before the assetPrefix fix - tests/asset-prefix.test.ts: adds basePath+assetPrefix combination describe block asserting both fields simultaneously and verifying no double-prefix (e.g. "https://cdn.example.com/app") is produced --- tests/asset-prefix.test.ts | 29 ++++++++++++++++++ tests/font-asset-prefix.test.ts | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 tests/font-asset-prefix.test.ts diff --git a/tests/asset-prefix.test.ts b/tests/asset-prefix.test.ts index 144d6850a..4d1e63b1c 100644 --- a/tests/asset-prefix.test.ts +++ b/tests/asset-prefix.test.ts @@ -62,3 +62,32 @@ describe("assetPrefix — resolveNextConfig", () => { expect(resolved.assetPrefix).toBe("https://cdn.example.com/subdir") }) }) + +describe("assetPrefix — basePath + assetPrefix combination", () => { + it("explicit CDN assetPrefix is not overridden by basePath, with both fields asserted", async () => { + const config: NextConfig = { + basePath: "/app", + assetPrefix: "https://cdn.example.com", + } + const resolved = await resolveNextConfig(config) + expect(resolved.basePath).toBe("/app") + expect(resolved.assetPrefix).toBe("https://cdn.example.com") + }) + + it("does not produce a double-prefix when basePath and CDN assetPrefix are combined", async () => { + const config: NextConfig = { + basePath: "/app", + assetPrefix: "https://cdn.example.com", + } + const resolved = await resolveNextConfig(config) + expect(resolved.assetPrefix).not.toBe("https://cdn.example.com/app") + expect(resolved.assetPrefix).toBe("https://cdn.example.com") + }) + + it("assetPrefix inherits basePath when assetPrefix is explicitly empty, with both fields asserted", async () => { + const config: NextConfig = { basePath: "/app", assetPrefix: "" } + const resolved = await resolveNextConfig(config) + expect(resolved.basePath).toBe("/app") + expect(resolved.assetPrefix).toBe("/app") + }) +}) diff --git a/tests/font-asset-prefix.test.ts b/tests/font-asset-prefix.test.ts new file mode 100644 index 000000000..01044cefe --- /dev/null +++ b/tests/font-asset-prefix.test.ts @@ -0,0 +1,54 @@ +/** + * Tests for collectFontPreloads CDN URL acceptance in font-local.ts. + * + * The critical bug: collectFontPreloads previously only accepted href.startsWith("/"), + * which silently dropped for CDN URLs when assetPrefix is a + * full https:// or protocol-relative // URL. The fix accepts https://, http://, and //. + */ +import { describe, it, expect } from "vitest" +import localFont, { getSSRFontPreloads } from "../packages/vinext/src/shims/font-local.js" + +describe("collectFontPreloads — CDN URL acceptance", () => { + it("collects https:// CDN font URL for preload (not silently dropped)", () => { + localFont({ src: "https://cdn.example.com/assets/font-cdn-https-unique-1.woff2" }) + const preloads = getSSRFontPreloads() + expect(preloads).toContainEqual({ + href: "https://cdn.example.com/assets/font-cdn-https-unique-1.woff2", + type: "font/woff2", + }) + }) + + it("collects standard /assets/ font URL for preload", () => { + localFont({ src: "/assets/font-standard-unique-2.woff2" }) + const preloads = getSSRFontPreloads() + expect(preloads).toContainEqual({ + href: "/assets/font-standard-unique-2.woff2", + type: "font/woff2", + }) + }) + + it("collects protocol-relative // CDN font URL for preload (not silently dropped)", () => { + localFont({ src: "//cdn.example.com/assets/font-proto-rel-unique-3.woff2" }) + const preloads = getSSRFontPreloads() + expect(preloads).toContainEqual({ + href: "//cdn.example.com/assets/font-proto-rel-unique-3.woff2", + type: "font/woff2", + }) + }) + + it("collects http:// CDN font URL for preload", () => { + localFont({ src: "http://cdn.example.com/assets/font-cdn-http-unique-4.woff2" }) + const preloads = getSSRFontPreloads() + expect(preloads).toContainEqual({ + href: "http://cdn.example.com/assets/font-cdn-http-unique-4.woff2", + type: "font/woff2", + }) + }) + + it("does not collect bare relative font paths (no leading slash or scheme)", () => { + const before = getSSRFontPreloads().length + localFont({ src: "relative/path/font-unique-5.woff2" }) + const after = getSSRFontPreloads() + expect(after.length).toBe(before) + }) +}) From 69494278a749400090470949dc29a717f37b08b0 Mon Sep 17 00:00:00 2001 From: Ely Delva Date: Wed, 11 Mar 2026 19:34:24 +0100 Subject: [PATCH 05/11] test: add E2E test verifying CDN-prefixed asset URLs in rendered HTML Adds a new Pages Router fixture (tests/fixtures/asset-prefix/) configured with assetPrefix: "https://cdn.example.com" and a Playwright project (asset-prefix-prod) that runs a production build + vinext start on port 4180. The E2E spec (tests/e2e/asset-prefix/asset-prefix.spec.ts) asserts: - '); + tags.push(''); + tags.push(''); } if (m) { // Always inject shared chunks (framework, vinext runtime, entry) and @@ -500,13 +511,13 @@ function collectAssetTags(manifest, moduleIds) { if (seen.has(tf)) continue; seen.add(tf); if (tf.endsWith(".css")) { - tags.push(''); + tags.push(''); } else if (tf.endsWith(".js")) { // Skip lazy chunks — they are behind dynamic import() boundaries // (React.lazy, next/dynamic) and should only be fetched on demand. if (lazySet && lazySet.has(tf)) continue; - tags.push(''); - tags.push(''); + tags.push(''); + tags.push(''); } } } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 0d33a0a61..d02d41923 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -18119,6 +18119,17 @@ function collectAssetTags(manifest, moduleIds) { const tags = []; const seen = new Set(); + // Only prepend assetPrefix when it is an absolute CDN URL (https://, http://, + // or protocol-relative //). When assetPrefix is a same-origin path like + // "/docs", Vite already embeds basePath into every emitted asset path, so + // prepending it again would produce a double-prefix like /docs/docs/assets/. + var _assetHrefPrefix = (vinextConfig.assetPrefix && + (vinextConfig.assetPrefix.startsWith("https://") || + vinextConfig.assetPrefix.startsWith("http://") || + vinextConfig.assetPrefix.startsWith("//"))) + ? vinextConfig.assetPrefix + : ""; + // Load the set of lazy chunk filenames (only reachable via dynamic imports). // These should NOT get or '); + tags.push(''); + tags.push(''); } if (m) { // Always inject shared chunks (framework, vinext runtime, entry) and @@ -18206,13 +18217,13 @@ function collectAssetTags(manifest, moduleIds) { if (seen.has(tf)) continue; seen.add(tf); if (tf.endsWith(".css")) { - tags.push(''); + tags.push(''); } else if (tf.endsWith(".js")) { // Skip lazy chunks — they are behind dynamic import() boundaries // (React.lazy, next/dynamic) and should only be fetched on demand. if (lazySet && lazySet.has(tf)) continue; - tags.push(''); - tags.push(''); + tags.push(''); + tags.push(''); } } } From eca42954a4c2d4290894033bc91a318f9135568a Mon Sep 17 00:00:00 2001 From: Ely Delva Date: Wed, 11 Mar 2026 20:06:47 +0100 Subject: [PATCH 08/11] chore: update pnpm lockfile for asset-prefix fixture --- pnpm-lock.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cf5d5133..dfd356537 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,6 +495,22 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1) + tests/fixtures/asset-prefix: + dependencies: + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + vinext: + specifier: workspace:* + version: link:../../../packages/vinext + devDependencies: + vite: + specifier: ^7.0.0 + version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1) + tests/fixtures/ecosystem/better-auth: dependencies: '@vitejs/plugin-rsc': From dd8364e013af7d1bde0677b365cbe9310cdfc919 Mon Sep 17 00:00:00 2001 From: Ely Delva Date: Wed, 11 Mar 2026 20:23:38 +0100 Subject: [PATCH 09/11] style: run oxfmt on all modified files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our assetPrefix additions used tab indentation while the rest of the codebase uses 2-space indentation. Running `pnpm run fmt` normalises the five affected files (tabs → spaces) so that `pnpm run fmt:check` passes cleanly. --- packages/vinext/src/config/next-config.ts | 1251 ++-- packages/vinext/src/index.ts | 6988 +++++++++---------- tests/asset-prefix.test.ts | 133 +- tests/e2e/asset-prefix/asset-prefix.spec.ts | 20 +- tests/font-asset-prefix.test.ts | 92 +- 5 files changed, 4050 insertions(+), 4434 deletions(-) diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 7bb3e05a9..ef80c1cfd 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -20,102 +20,96 @@ import { isExternalUrl } from "./config-matchers.js"; * Returns the default 1MB if the value is not provided or invalid. * Throws if the parsed value is less than 1. */ -export function parseBodySizeLimit( - value: string | number | undefined | null, -): number { - if (value === undefined || value === null) return 1 * 1024 * 1024; - if (typeof value === "number") { - if (value < 1) - throw new Error( - `Body size limit must be a positive number, got ${value}`, - ); - return value; - } - const trimmed = value.trim(); - const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb|pb)?$/i); - if (!match) { - console.warn( - `[vinext] Invalid bodySizeLimit value: "${value}". Expected a number or a string like "1mb", "500kb". Falling back to 1MB.`, - ); - return 1 * 1024 * 1024; - } - const num = parseFloat(match[1]); - const unit = (match[2] ?? "b").toLowerCase(); - let bytes: number; - switch (unit) { - case "b": - bytes = Math.floor(num); - break; - case "kb": - bytes = Math.floor(num * 1024); - break; - case "mb": - bytes = Math.floor(num * 1024 * 1024); - break; - case "gb": - bytes = Math.floor(num * 1024 * 1024 * 1024); - break; - case "tb": - bytes = Math.floor(num * 1024 * 1024 * 1024 * 1024); - break; - case "pb": - bytes = Math.floor(num * 1024 * 1024 * 1024 * 1024 * 1024); - break; - default: - return 1 * 1024 * 1024; - } - if (bytes < 1) - throw new Error(`Body size limit must be a positive number, got ${bytes}`); - return bytes; +export function parseBodySizeLimit(value: string | number | undefined | null): number { + if (value === undefined || value === null) return 1 * 1024 * 1024; + if (typeof value === "number") { + if (value < 1) throw new Error(`Body size limit must be a positive number, got ${value}`); + return value; + } + const trimmed = value.trim(); + const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb|pb)?$/i); + if (!match) { + console.warn( + `[vinext] Invalid bodySizeLimit value: "${value}". Expected a number or a string like "1mb", "500kb". Falling back to 1MB.`, + ); + return 1 * 1024 * 1024; + } + const num = parseFloat(match[1]); + const unit = (match[2] ?? "b").toLowerCase(); + let bytes: number; + switch (unit) { + case "b": + bytes = Math.floor(num); + break; + case "kb": + bytes = Math.floor(num * 1024); + break; + case "mb": + bytes = Math.floor(num * 1024 * 1024); + break; + case "gb": + bytes = Math.floor(num * 1024 * 1024 * 1024); + break; + case "tb": + bytes = Math.floor(num * 1024 * 1024 * 1024 * 1024); + break; + case "pb": + bytes = Math.floor(num * 1024 * 1024 * 1024 * 1024 * 1024); + break; + default: + return 1 * 1024 * 1024; + } + if (bytes < 1) throw new Error(`Body size limit must be a positive number, got ${bytes}`); + return bytes; } export interface HasCondition { - type: "header" | "cookie" | "query" | "host"; - key: string; - value?: string; + type: "header" | "cookie" | "query" | "host"; + key: string; + value?: string; } export interface NextRedirect { - source: string; - destination: string; - permanent: boolean; - has?: HasCondition[]; - missing?: HasCondition[]; + source: string; + destination: string; + permanent: boolean; + has?: HasCondition[]; + missing?: HasCondition[]; } export interface NextRewrite { - source: string; - destination: string; - has?: HasCondition[]; - missing?: HasCondition[]; + source: string; + destination: string; + has?: HasCondition[]; + missing?: HasCondition[]; } export interface NextHeader { - source: string; - has?: HasCondition[]; - missing?: HasCondition[]; - headers: Array<{ key: string; value: string }>; + source: string; + has?: HasCondition[]; + missing?: HasCondition[]; + headers: Array<{ key: string; value: string }>; } export interface NextI18nConfig { - /** List of supported locales */ - locales: string[]; - /** The default locale (used when no locale prefix is in the URL) */ - defaultLocale: string; - /** - * Whether to auto-detect locale from Accept-Language header. - * Defaults to true in Next.js. - */ - localeDetection?: boolean; - /** - * Domain-based routing. Each domain maps to a specific locale. - */ - domains?: Array<{ - domain: string; - defaultLocale: string; - locales?: string[]; - http?: boolean; - }>; + /** List of supported locales */ + locales: string[]; + /** The default locale (used when no locale prefix is in the URL) */ + defaultLocale: string; + /** + * Whether to auto-detect locale from Accept-Language header. + * Defaults to true in Next.js. + */ + localeDetection?: boolean; + /** + * Domain-based routing. Each domain maps to a specific locale. + */ + domains?: Array<{ + domain: string; + defaultLocale: string; + locales?: string[]; + http?: boolean; + }>; } /** @@ -124,146 +118,141 @@ export interface NextI18nConfig { * remark/rehype/recma plugins configured in next.config work with Vite. */ export interface MdxOptions { - remarkPlugins?: unknown[]; - rehypePlugins?: unknown[]; - recmaPlugins?: unknown[]; + remarkPlugins?: unknown[]; + rehypePlugins?: unknown[]; + recmaPlugins?: unknown[]; } export interface NextConfig { - /** Additional env variables */ - env?: Record; - /** Base URL path prefix */ - basePath?: string; - /** CDN URL prefix for all static assets (JS chunks, CSS, fonts). Trailing slash stripped. */ - assetPrefix?: string; - /** Whether to add trailing slashes */ - trailingSlash?: boolean; - /** Internationalization routing config */ - i18n?: NextI18nConfig; - /** URL redirect rules */ - redirects?: () => Promise | NextRedirect[]; - /** URL rewrite rules */ - rewrites?: () => - | Promise< - | NextRewrite[] - | { - beforeFiles: NextRewrite[]; - afterFiles: NextRewrite[]; - fallback: NextRewrite[]; - } - > - | NextRewrite[] - | { - beforeFiles: NextRewrite[]; - afterFiles: NextRewrite[]; - fallback: NextRewrite[]; - }; - /** Custom response headers */ - headers?: () => Promise | NextHeader[]; - /** Image optimization config */ - images?: { - remotePatterns?: Array<{ - protocol?: string; - hostname: string; - port?: string; - pathname?: string; - search?: string; - }>; - domains?: string[]; - unoptimized?: boolean; - /** Allowed device widths for image optimization. Defaults to Next.js defaults: [640, 750, 828, 1080, 1200, 1920, 2048, 3840] */ - deviceSizes?: number[]; - /** Allowed image sizes for fixed-width images. Defaults to Next.js defaults: [16, 32, 48, 64, 96, 128, 256, 384] */ - imageSizes?: number[]; - /** Allow SVG images through the image optimization endpoint. SVG can contain scripts, so only enable if you trust all image sources. */ - dangerouslyAllowSVG?: boolean; - /** Content-Disposition header for image responses. Defaults to "inline". */ - contentDispositionType?: "inline" | "attachment"; - /** Content-Security-Policy header for image responses. Defaults to "script-src 'none'; frame-src 'none'; sandbox;" */ - contentSecurityPolicy?: string; - }; - /** Build output mode: 'export' for full static export, 'standalone' for single server */ - output?: "export" | "standalone"; - /** File extensions treated as routable pages/routes (Next.js pageExtensions) */ - pageExtensions?: string[]; - /** Extra origins allowed to access the dev server. */ - allowedDevOrigins?: string[]; - /** - * Enable Cache Components (Next.js 16). - * When true, enables the "use cache" directive for pages, components, and functions. - * Replaces the removed experimental.ppr and experimental.dynamicIO flags. - */ - cacheComponents?: boolean; - /** Transpile packages (Vite handles this natively) */ - transpilePackages?: string[]; - /** Webpack config (ignored — we use Vite) */ - webpack?: unknown; - /** - * Custom build ID generator. If provided, called once at build/dev start. - * Must return a non-empty string, or null to use the default random ID. - */ - generateBuildId?: () => string | null | Promise; - /** Any other options */ - [key: string]: unknown; + /** Additional env variables */ + env?: Record; + /** Base URL path prefix */ + basePath?: string; + /** CDN URL prefix for all static assets (JS chunks, CSS, fonts). Trailing slash stripped. */ + assetPrefix?: string; + /** Whether to add trailing slashes */ + trailingSlash?: boolean; + /** Internationalization routing config */ + i18n?: NextI18nConfig; + /** URL redirect rules */ + redirects?: () => Promise | NextRedirect[]; + /** URL rewrite rules */ + rewrites?: () => + | Promise< + | NextRewrite[] + | { + beforeFiles: NextRewrite[]; + afterFiles: NextRewrite[]; + fallback: NextRewrite[]; + } + > + | NextRewrite[] + | { + beforeFiles: NextRewrite[]; + afterFiles: NextRewrite[]; + fallback: NextRewrite[]; + }; + /** Custom response headers */ + headers?: () => Promise | NextHeader[]; + /** Image optimization config */ + images?: { + remotePatterns?: Array<{ + protocol?: string; + hostname: string; + port?: string; + pathname?: string; + search?: string; + }>; + domains?: string[]; + unoptimized?: boolean; + /** Allowed device widths for image optimization. Defaults to Next.js defaults: [640, 750, 828, 1080, 1200, 1920, 2048, 3840] */ + deviceSizes?: number[]; + /** Allowed image sizes for fixed-width images. Defaults to Next.js defaults: [16, 32, 48, 64, 96, 128, 256, 384] */ + imageSizes?: number[]; + /** Allow SVG images through the image optimization endpoint. SVG can contain scripts, so only enable if you trust all image sources. */ + dangerouslyAllowSVG?: boolean; + /** Content-Disposition header for image responses. Defaults to "inline". */ + contentDispositionType?: "inline" | "attachment"; + /** Content-Security-Policy header for image responses. Defaults to "script-src 'none'; frame-src 'none'; sandbox;" */ + contentSecurityPolicy?: string; + }; + /** Build output mode: 'export' for full static export, 'standalone' for single server */ + output?: "export" | "standalone"; + /** File extensions treated as routable pages/routes (Next.js pageExtensions) */ + pageExtensions?: string[]; + /** Extra origins allowed to access the dev server. */ + allowedDevOrigins?: string[]; + /** + * Enable Cache Components (Next.js 16). + * When true, enables the "use cache" directive for pages, components, and functions. + * Replaces the removed experimental.ppr and experimental.dynamicIO flags. + */ + cacheComponents?: boolean; + /** Transpile packages (Vite handles this natively) */ + transpilePackages?: string[]; + /** Webpack config (ignored — we use Vite) */ + webpack?: unknown; + /** + * Custom build ID generator. If provided, called once at build/dev start. + * Must return a non-empty string, or null to use the default random ID. + */ + generateBuildId?: () => string | null | Promise; + /** Any other options */ + [key: string]: unknown; } /** * Resolved configuration with all async values awaited. */ export interface ResolvedNextConfig { - env: Record; - basePath: string; - /** Resolved CDN URL prefix for static assets. Empty string if not set. */ - assetPrefix: string; - trailingSlash: boolean; - output: "" | "export" | "standalone"; - pageExtensions: string[]; - cacheComponents: boolean; - redirects: NextRedirect[]; - rewrites: { - beforeFiles: NextRewrite[]; - afterFiles: NextRewrite[]; - fallback: NextRewrite[]; - }; - headers: NextHeader[]; - images: NextConfig["images"]; - i18n: NextI18nConfig | null; - /** MDX remark/rehype/recma plugins extracted from @next/mdx config */ - mdx: MdxOptions | null; - /** Explicit module aliases preserved from wrapped next.config plugins. */ - aliases: Record; - /** Extra allowed origins for dev server access (from allowedDevOrigins). */ - allowedDevOrigins: string[]; - /** Extra allowed origins for server action CSRF validation (from experimental.serverActions.allowedOrigins). */ - serverActionsAllowedOrigins: string[]; - /** Parsed body size limit for server actions in bytes (from experimental.serverActions.bodySizeLimit). Defaults to 1MB. */ - serverActionsBodySizeLimit: number; - /** Resolved build ID (from generateBuildId, or a random UUID if not provided). */ - buildId: string; + env: Record; + basePath: string; + /** Resolved CDN URL prefix for static assets. Empty string if not set. */ + assetPrefix: string; + trailingSlash: boolean; + output: "" | "export" | "standalone"; + pageExtensions: string[]; + cacheComponents: boolean; + redirects: NextRedirect[]; + rewrites: { + beforeFiles: NextRewrite[]; + afterFiles: NextRewrite[]; + fallback: NextRewrite[]; + }; + headers: NextHeader[]; + images: NextConfig["images"]; + i18n: NextI18nConfig | null; + /** MDX remark/rehype/recma plugins extracted from @next/mdx config */ + mdx: MdxOptions | null; + /** Explicit module aliases preserved from wrapped next.config plugins. */ + aliases: Record; + /** Extra allowed origins for dev server access (from allowedDevOrigins). */ + allowedDevOrigins: string[]; + /** Extra allowed origins for server action CSRF validation (from experimental.serverActions.allowedOrigins). */ + serverActionsAllowedOrigins: string[]; + /** Parsed body size limit for server actions in bytes (from experimental.serverActions.bodySizeLimit). Defaults to 1MB. */ + serverActionsBodySizeLimit: number; + /** Resolved build ID (from generateBuildId, or a random UUID if not provided). */ + buildId: string; } -const CONFIG_FILES = [ - "next.config.ts", - "next.config.mjs", - "next.config.js", - "next.config.cjs", -]; +const CONFIG_FILES = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs"]; /** * Check whether an error indicates a CJS module was loaded in an ESM context * (i.e. the file uses `require()` which is not available in ESM). */ function isCjsError(e: unknown): boolean { - if (!(e instanceof Error)) return false; - const msg = e.message; - return ( - msg.includes("require is not a function") || - msg.includes("require is not defined") || - msg.includes("exports is not defined") || - msg.includes("module is not defined") || - msg.includes("__dirname is not defined") || - msg.includes("__filename is not defined") - ); + if (!(e instanceof Error)) return false; + const msg = e.message; + return ( + msg.includes("require is not a function") || + msg.includes("require is not defined") || + msg.includes("exports is not defined") || + msg.includes("module is not defined") || + msg.includes("__dirname is not defined") || + msg.includes("__filename is not defined") + ); } /** @@ -271,21 +260,21 @@ function isCjsError(e: unknown): boolean { * known plugin wrappers that are unnecessary in vinext. */ function warnConfigLoadFailure(filename: string, err: Error): void { - const msg = err.message ?? ""; - const stack = err.stack ?? ""; - const isNextIntlPlugin = - msg.includes("next-intl") || - stack.includes("next-intl/plugin") || - stack.includes("next-intl/dist"); - - console.warn(`[vinext] Failed to load ${filename}: ${msg}`); - if (isNextIntlPlugin) { - console.warn( - "[vinext] Hint: createNextIntlPlugin() is not needed with vinext. " + - "Remove the next-intl/plugin wrapper from your next.config — " + - "vinext auto-detects next-intl and registers the i18n config alias automatically.", - ); - } + const msg = err.message ?? ""; + const stack = err.stack ?? ""; + const isNextIntlPlugin = + msg.includes("next-intl") || + stack.includes("next-intl/plugin") || + stack.includes("next-intl/dist"); + + console.warn(`[vinext] Failed to load ${filename}: ${msg}`); + if (isNextIntlPlugin) { + console.warn( + "[vinext] Hint: createNextIntlPlugin() is not needed with vinext. " + + "Remove the next-intl/plugin wrapper from your next.config — " + + "vinext auto-detects next-intl and registers the i18n config alias automatically.", + ); + } } /** @@ -293,17 +282,17 @@ function warnConfigLoadFailure(filename: string, err: Error): void { * function-form config (Next.js supports `module.exports = (phase, opts) => config`). */ async function unwrapConfig( - mod: any, - phase: string = PHASE_DEVELOPMENT_SERVER, + mod: any, + phase: string = PHASE_DEVELOPMENT_SERVER, ): Promise { - const config = mod.default ?? mod; - if (typeof config === "function") { - const result = await config(phase, { - defaultConfig: {}, - }); - return result as NextConfig; - } - return config as NextConfig; + const config = mod.default ?? mod; + if (typeof config === "function") { + const result = await config(phase, { + defaultConfig: {}, + }); + return result as NextConfig; + } + return config as NextConfig; } /** @@ -316,45 +305,42 @@ async function unwrapConfig( * so common CJS plugin wrappers (nextra, @next/mdx, etc.) still work. */ export async function loadNextConfig( - root: string, - phase: string = PHASE_DEVELOPMENT_SERVER, + root: string, + phase: string = PHASE_DEVELOPMENT_SERVER, ): Promise { - for (const filename of CONFIG_FILES) { - const configPath = path.join(root, filename); - if (!fs.existsSync(configPath)) continue; - - try { - // Load config via Vite's module runner (TS + extensionless import support) - const { runnerImport } = await import("vite"); - const { module: mod } = await runnerImport(configPath, { - root, - logLevel: "error", - clearScreen: false, - }); - return await unwrapConfig(mod, phase); - } catch (e) { - // If the error indicates a CJS file loaded in ESM context, retry with - // createRequire which provides a proper CommonJS environment. - if ( - isCjsError(e) && - (filename.endsWith(".js") || filename.endsWith(".cjs")) - ) { - try { - const require = createRequire(path.join(root, "package.json")); - const mod = require(configPath); - return await unwrapConfig({ default: mod }, phase); - } catch (e2) { - warnConfigLoadFailure(filename, e2 as Error); - return null; - } - } - - warnConfigLoadFailure(filename, e as Error); - return null; - } - } - - return null; + for (const filename of CONFIG_FILES) { + const configPath = path.join(root, filename); + if (!fs.existsSync(configPath)) continue; + + try { + // Load config via Vite's module runner (TS + extensionless import support) + const { runnerImport } = await import("vite"); + const { module: mod } = await runnerImport(configPath, { + root, + logLevel: "error", + clearScreen: false, + }); + return await unwrapConfig(mod, phase); + } catch (e) { + // If the error indicates a CJS file loaded in ESM context, retry with + // createRequire which provides a proper CommonJS environment. + if (isCjsError(e) && (filename.endsWith(".js") || filename.endsWith(".cjs"))) { + try { + const require = createRequire(path.join(root, "package.json")); + const mod = require(configPath); + return await unwrapConfig({ default: mod }, phase); + } catch (e2) { + warnConfigLoadFailure(filename, e2 as Error); + return null; + } + } + + warnConfigLoadFailure(filename, e as Error); + return null; + } + } + + return null; } /** @@ -362,9 +348,9 @@ export async function loadNextConfig( * Mirrors Next.js's own nanoid retry loop. */ function safeUUID(): string { - let id = randomUUID(); - while (/ad/i.test(id)) id = randomUUID(); - return id; + let id = randomUUID(); + while (/ad/i.test(id)) id = randomUUID(); + return id; } /** @@ -375,28 +361,28 @@ function safeUUID(): string { * @see https://nextjs.org/docs/app/api-reference/config/next-config-js/generateBuildId */ async function resolveBuildId( - generate: (() => string | null | Promise) | undefined, + generate: (() => string | null | Promise) | undefined, ): Promise { - if (!generate) return safeUUID(); + if (!generate) return safeUUID(); - const result = await generate(); + const result = await generate(); - if (result === null) return safeUUID(); + if (result === null) return safeUUID(); - if (typeof result !== "string") { - throw new Error( - "generateBuildId did not return a string. https://nextjs.org/docs/messages/generatebuildid-not-a-string", - ); - } + if (typeof result !== "string") { + throw new Error( + "generateBuildId did not return a string. https://nextjs.org/docs/messages/generatebuildid-not-a-string", + ); + } - const trimmed = result.trim(); - if (trimmed.length === 0) { - throw new Error( - "generateBuildId returned an empty string. https://nextjs.org/docs/messages/generatebuildid-not-a-string", - ); - } + const trimmed = result.trim(); + if (trimmed.length === 0) { + throw new Error( + "generateBuildId returned an empty string. https://nextjs.org/docs/messages/generatebuildid-not-a-string", + ); + } - return trimmed; + return trimmed; } /** @@ -404,279 +390,245 @@ async function resolveBuildId( * Awaits async functions for redirects/rewrites/headers. */ export async function resolveNextConfig( - config: NextConfig | null, - root: string = process.cwd(), + config: NextConfig | null, + root: string = process.cwd(), ): Promise { - if (!config) { - const buildId = await resolveBuildId(undefined); - const resolved: ResolvedNextConfig = { - env: {}, - basePath: "", - assetPrefix: "", - trailingSlash: false, - output: "", - pageExtensions: normalizePageExtensions(), - cacheComponents: false, - redirects: [], - rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, - headers: [], - images: undefined, - i18n: null, - mdx: null, - aliases: {}, - allowedDevOrigins: [], - serverActionsAllowedOrigins: [], - serverActionsBodySizeLimit: 1 * 1024 * 1024, - buildId, - }; - detectNextIntlConfig(root, resolved); - return resolved; - } - - // Resolve redirects - let redirects: NextRedirect[] = []; - if (config.redirects) { - const result = await config.redirects(); - redirects = Array.isArray(result) ? result : []; - } - - // Resolve rewrites - let rewrites = { - beforeFiles: [] as NextRewrite[], - afterFiles: [] as NextRewrite[], - fallback: [] as NextRewrite[], - }; - if (config.rewrites) { - const result = await config.rewrites(); - if (Array.isArray(result)) { - rewrites.afterFiles = result; - } else { - rewrites = { - beforeFiles: result.beforeFiles ?? [], - afterFiles: result.afterFiles ?? [], - fallback: result.fallback ?? [], - }; - } - } - - { - const allRewrites = [ - ...rewrites.beforeFiles, - ...rewrites.afterFiles, - ...rewrites.fallback, - ]; - const externalRewrites = allRewrites.filter((rewrite) => - isExternalUrl(rewrite.destination), - ); - - if (externalRewrites.length > 0) { - const noun = - externalRewrites.length === 1 - ? "external rewrite" - : "external rewrites"; - const listing = externalRewrites - .map((rewrite) => ` ${rewrite.source} → ${rewrite.destination}`) - .join("\n"); - - console.warn( - `[vinext] Found ${externalRewrites.length} ${noun} that proxy requests to external origins:\n` + - `${listing}\n` + - `Request headers, including credential headers (cookie, authorization, proxy-authorization, x-api-key), ` + - `are forwarded to the external origin to match Next.js behavior. ` + - `If you do not want to forward credentials, use an API route or route handler where you control exactly which headers are sent.`, - ); - } - } - - // Resolve headers - let headers: NextHeader[] = []; - if (config.headers) { - headers = await config.headers(); - } - - // Probe wrapped webpack config once so alias extraction and MDX extraction - // observe the same mock environment. - const webpackProbe = await probeWebpackConfig(config, root); - const mdx = webpackProbe.mdx; - const aliases = { - ...extractTurboAliases(config, root), - ...webpackProbe.aliases, - }; - - const allowedDevOrigins = Array.isArray(config.allowedDevOrigins) - ? config.allowedDevOrigins - : []; - - // Resolve serverActions.allowedOrigins and bodySizeLimit from experimental config - const experimental = config.experimental as - | Record - | undefined; - const serverActionsConfig = experimental?.serverActions as - | Record - | undefined; - const serverActionsAllowedOrigins = Array.isArray( - serverActionsConfig?.allowedOrigins, - ) - ? (serverActionsConfig.allowedOrigins as string[]) - : []; - const serverActionsBodySizeLimit = parseBodySizeLimit( - serverActionsConfig?.bodySizeLimit as string | number | undefined, - ); - - // Warn about unsupported webpack usage. We preserve alias injection and - // extract MDX settings, but all other webpack customization is still ignored. - if (config.webpack !== undefined) { - if (mdx || Object.keys(webpackProbe.aliases).length > 0) { - console.warn( - '[vinext] next.config option "webpack" is only partially supported. ' + - "vinext preserves resolve.alias entries and MDX loader settings, but other webpack customization is ignored", - ); - } else { - console.warn( - '[vinext] next.config option "webpack" is not yet supported and will be ignored', - ); - } - } - - const output = config.output ?? ""; - if (output && output !== "export" && output !== "standalone") { - console.warn( - `[vinext] Unknown output mode "${output as string}", ignoring`, - ); - } - - const pageExtensions = normalizePageExtensions(config.pageExtensions); - - // Parse i18n config - let i18n: NextI18nConfig | null = null; - if (config.i18n) { - i18n = { - locales: config.i18n.locales, - defaultLocale: config.i18n.defaultLocale, - localeDetection: config.i18n.localeDetection ?? true, - domains: config.i18n.domains, - }; - } - - const buildId = await resolveBuildId( - config.generateBuildId as - | (() => string | null | Promise) - | undefined, - ); - - const resolved: ResolvedNextConfig = { - env: config.env ?? {}, - basePath: config.basePath ?? "", - assetPrefix: - typeof config.assetPrefix === "string" - ? config.assetPrefix.replace(/\/$/, "") - : "", - trailingSlash: config.trailingSlash ?? false, - output: output === "export" || output === "standalone" ? output : "", - pageExtensions, - cacheComponents: config.cacheComponents ?? false, - redirects, - rewrites, - headers, - images: config.images, - i18n, - mdx, - aliases, - allowedDevOrigins, - serverActionsAllowedOrigins, - serverActionsBodySizeLimit, - buildId, - }; - - // If assetPrefix is empty and basePath is set, inherit basePath as assetPrefix. - // This matches Next.js behavior: static assets are served under basePath when - // no explicit CDN prefix is configured. - if (resolved.assetPrefix === "" && resolved.basePath !== "") { - resolved.assetPrefix = resolved.basePath; - } - - // Auto-detect next-intl (lowest priority — explicit aliases from - // webpack/turbopack already in `aliases` take precedence) - detectNextIntlConfig(root, resolved); - - return resolved; + if (!config) { + const buildId = await resolveBuildId(undefined); + const resolved: ResolvedNextConfig = { + env: {}, + basePath: "", + assetPrefix: "", + trailingSlash: false, + output: "", + pageExtensions: normalizePageExtensions(), + cacheComponents: false, + redirects: [], + rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, + headers: [], + images: undefined, + i18n: null, + mdx: null, + aliases: {}, + allowedDevOrigins: [], + serverActionsAllowedOrigins: [], + serverActionsBodySizeLimit: 1 * 1024 * 1024, + buildId, + }; + detectNextIntlConfig(root, resolved); + return resolved; + } + + // Resolve redirects + let redirects: NextRedirect[] = []; + if (config.redirects) { + const result = await config.redirects(); + redirects = Array.isArray(result) ? result : []; + } + + // Resolve rewrites + let rewrites = { + beforeFiles: [] as NextRewrite[], + afterFiles: [] as NextRewrite[], + fallback: [] as NextRewrite[], + }; + if (config.rewrites) { + const result = await config.rewrites(); + if (Array.isArray(result)) { + rewrites.afterFiles = result; + } else { + rewrites = { + beforeFiles: result.beforeFiles ?? [], + afterFiles: result.afterFiles ?? [], + fallback: result.fallback ?? [], + }; + } + } + + { + const allRewrites = [...rewrites.beforeFiles, ...rewrites.afterFiles, ...rewrites.fallback]; + const externalRewrites = allRewrites.filter((rewrite) => isExternalUrl(rewrite.destination)); + + if (externalRewrites.length > 0) { + const noun = externalRewrites.length === 1 ? "external rewrite" : "external rewrites"; + const listing = externalRewrites + .map((rewrite) => ` ${rewrite.source} → ${rewrite.destination}`) + .join("\n"); + + console.warn( + `[vinext] Found ${externalRewrites.length} ${noun} that proxy requests to external origins:\n` + + `${listing}\n` + + `Request headers, including credential headers (cookie, authorization, proxy-authorization, x-api-key), ` + + `are forwarded to the external origin to match Next.js behavior. ` + + `If you do not want to forward credentials, use an API route or route handler where you control exactly which headers are sent.`, + ); + } + } + + // Resolve headers + let headers: NextHeader[] = []; + if (config.headers) { + headers = await config.headers(); + } + + // Probe wrapped webpack config once so alias extraction and MDX extraction + // observe the same mock environment. + const webpackProbe = await probeWebpackConfig(config, root); + const mdx = webpackProbe.mdx; + const aliases = { + ...extractTurboAliases(config, root), + ...webpackProbe.aliases, + }; + + const allowedDevOrigins = Array.isArray(config.allowedDevOrigins) ? config.allowedDevOrigins : []; + + // Resolve serverActions.allowedOrigins and bodySizeLimit from experimental config + const experimental = config.experimental as Record | undefined; + const serverActionsConfig = experimental?.serverActions as Record | undefined; + const serverActionsAllowedOrigins = Array.isArray(serverActionsConfig?.allowedOrigins) + ? (serverActionsConfig.allowedOrigins as string[]) + : []; + const serverActionsBodySizeLimit = parseBodySizeLimit( + serverActionsConfig?.bodySizeLimit as string | number | undefined, + ); + + // Warn about unsupported webpack usage. We preserve alias injection and + // extract MDX settings, but all other webpack customization is still ignored. + if (config.webpack !== undefined) { + if (mdx || Object.keys(webpackProbe.aliases).length > 0) { + console.warn( + '[vinext] next.config option "webpack" is only partially supported. ' + + "vinext preserves resolve.alias entries and MDX loader settings, but other webpack customization is ignored", + ); + } else { + console.warn( + '[vinext] next.config option "webpack" is not yet supported and will be ignored', + ); + } + } + + const output = config.output ?? ""; + if (output && output !== "export" && output !== "standalone") { + console.warn(`[vinext] Unknown output mode "${output as string}", ignoring`); + } + + const pageExtensions = normalizePageExtensions(config.pageExtensions); + + // Parse i18n config + let i18n: NextI18nConfig | null = null; + if (config.i18n) { + i18n = { + locales: config.i18n.locales, + defaultLocale: config.i18n.defaultLocale, + localeDetection: config.i18n.localeDetection ?? true, + domains: config.i18n.domains, + }; + } + + const buildId = await resolveBuildId( + config.generateBuildId as (() => string | null | Promise) | undefined, + ); + + const resolved: ResolvedNextConfig = { + env: config.env ?? {}, + basePath: config.basePath ?? "", + assetPrefix: + typeof config.assetPrefix === "string" ? config.assetPrefix.replace(/\/$/, "") : "", + trailingSlash: config.trailingSlash ?? false, + output: output === "export" || output === "standalone" ? output : "", + pageExtensions, + cacheComponents: config.cacheComponents ?? false, + redirects, + rewrites, + headers, + images: config.images, + i18n, + mdx, + aliases, + allowedDevOrigins, + serverActionsAllowedOrigins, + serverActionsBodySizeLimit, + buildId, + }; + + // If assetPrefix is empty and basePath is set, inherit basePath as assetPrefix. + // This matches Next.js behavior: static assets are served under basePath when + // no explicit CDN prefix is configured. + if (resolved.assetPrefix === "" && resolved.basePath !== "") { + resolved.assetPrefix = resolved.basePath; + } + + // Auto-detect next-intl (lowest priority — explicit aliases from + // webpack/turbopack already in `aliases` take precedence) + detectNextIntlConfig(root, resolved); + + return resolved; } function normalizeAliasEntries( - aliases: Record | undefined, - root: string, + aliases: Record | undefined, + root: string, ): Record { - if (!aliases) return {}; - - const normalized: Record = {}; - for (const [key, value] of Object.entries(aliases)) { - if (typeof value !== "string") continue; - normalized[key] = path.isAbsolute(value) - ? value - : path.resolve(root, value); - } - return normalized; + if (!aliases) return {}; + + const normalized: Record = {}; + for (const [key, value] of Object.entries(aliases)) { + if (typeof value !== "string") continue; + normalized[key] = path.isAbsolute(value) ? value : path.resolve(root, value); + } + return normalized; } -function extractTurboAliases( - config: NextConfig, - root: string, -): Record { - const experimental = config.experimental as - | Record - | undefined; - const experimentalTurbo = experimental?.turbo as - | Record - | undefined; - const topLevelTurbopack = config.turbopack as - | Record - | undefined; - - return { - ...normalizeAliasEntries( - experimentalTurbo?.resolveAlias as Record | undefined, - root, - ), - ...normalizeAliasEntries( - topLevelTurbopack?.resolveAlias as Record | undefined, - root, - ), - }; +function extractTurboAliases(config: NextConfig, root: string): Record { + const experimental = config.experimental as Record | undefined; + const experimentalTurbo = experimental?.turbo as Record | undefined; + const topLevelTurbopack = config.turbopack as Record | undefined; + + return { + ...normalizeAliasEntries( + experimentalTurbo?.resolveAlias as Record | undefined, + root, + ), + ...normalizeAliasEntries( + topLevelTurbopack?.resolveAlias as Record | undefined, + root, + ), + }; } async function probeWebpackConfig( - config: NextConfig, - root: string, + config: NextConfig, + root: string, ): Promise<{ aliases: Record; mdx: MdxOptions | null }> { - if (typeof config.webpack !== "function") { - return { aliases: {}, mdx: null }; - } - - const mockModuleRules: any[] = []; - const mockConfig = { - context: root, - resolve: { alias: {} as Record }, - module: { rules: mockModuleRules }, - plugins: [] as any[], - }; - const mockOptions = { - defaultLoaders: { babel: { loader: "next-babel-loader" } }, - isServer: false, - dev: false, - dir: root, - }; - - try { - const result = await (config.webpack as Function)(mockConfig, mockOptions); - const finalConfig = result ?? mockConfig; - const rules: any[] = finalConfig.module?.rules ?? mockModuleRules; - return { - aliases: normalizeAliasEntries(finalConfig.resolve?.alias, root), - mdx: extractMdxOptionsFromRules(rules), - }; - } catch { - return { aliases: {}, mdx: null }; - } + if (typeof config.webpack !== "function") { + return { aliases: {}, mdx: null }; + } + + const mockModuleRules: any[] = []; + const mockConfig = { + context: root, + resolve: { alias: {} as Record }, + module: { rules: mockModuleRules }, + plugins: [] as any[], + }; + const mockOptions = { + defaultLoaders: { babel: { loader: "next-babel-loader" } }, + isServer: false, + dev: false, + dir: root, + }; + + try { + const result = await (config.webpack as Function)(mockConfig, mockOptions); + const finalConfig = result ?? mockConfig; + const rules: any[] = finalConfig.module?.rules ?? mockModuleRules; + return { + aliases: normalizeAliasEntries(finalConfig.resolve?.alias, root), + mdx: extractMdxOptionsFromRules(rules), + }; + } catch { + return { aliases: {}, mdx: null }; + } } /** @@ -688,10 +640,10 @@ async function probeWebpackConfig( * We probe the webpack function with a mock config to extract them. */ export async function extractMdxOptions( - config: NextConfig, - root: string = process.cwd(), + config: NextConfig, + root: string = process.cwd(), ): Promise { - return (await probeWebpackConfig(config, root)).mdx; + return (await probeWebpackConfig(config, root)).mdx; } /** @@ -699,22 +651,22 @@ export async function extractMdxOptions( * or null if none match. */ function probeFiles(root: string, candidates: string[]): string | null { - for (const candidate of candidates) { - const abs = path.resolve(root, candidate); - if (fs.existsSync(abs)) return abs; - } - return null; + for (const candidate of candidates) { + const abs = path.resolve(root, candidate); + if (fs.existsSync(abs)) return abs; + } + return null; } const I18N_REQUEST_CANDIDATES = [ - "i18n/request.ts", - "i18n/request.tsx", - "i18n/request.js", - "i18n/request.jsx", - "src/i18n/request.ts", - "src/i18n/request.tsx", - "src/i18n/request.js", - "src/i18n/request.jsx", + "i18n/request.ts", + "i18n/request.tsx", + "i18n/request.js", + "i18n/request.jsx", + "src/i18n/request.ts", + "src/i18n/request.tsx", + "src/i18n/request.js", + "src/i18n/request.jsx", ]; /** @@ -733,40 +685,37 @@ const I18N_REQUEST_CANDIDATES = [ * * Mutates `resolved.aliases` and `resolved.env` in place. */ -export function detectNextIntlConfig( - root: string, - resolved: ResolvedNextConfig, -): void { - // Explicit alias wins — user or plugin already set it - if (resolved.aliases["next-intl/config"]) return; - - // Check if next-intl is installed (use main entry — some packages - // don't expose ./package.json in their exports map) - const require = createRequire(path.join(root, "package.json")); - try { - require.resolve("next-intl"); - } catch { - return; // next-intl not installed - } - - // Probe for the i18n request config file - const configPath = probeFiles(root, I18N_REQUEST_CANDIDATES); - if (!configPath) return; - - resolved.aliases["next-intl/config"] = configPath; - - if (resolved.trailingSlash) { - resolved.env._next_intl_trailing_slash = "true"; - } +export function detectNextIntlConfig(root: string, resolved: ResolvedNextConfig): void { + // Explicit alias wins — user or plugin already set it + if (resolved.aliases["next-intl/config"]) return; + + // Check if next-intl is installed (use main entry — some packages + // don't expose ./package.json in their exports map) + const require = createRequire(path.join(root, "package.json")); + try { + require.resolve("next-intl"); + } catch { + return; // next-intl not installed + } + + // Probe for the i18n request config file + const configPath = probeFiles(root, I18N_REQUEST_CANDIDATES); + if (!configPath) return; + + resolved.aliases["next-intl/config"] = configPath; + + if (resolved.trailingSlash) { + resolved.env._next_intl_trailing_slash = "true"; + } } function extractMdxOptionsFromRules(rules: any[]): MdxOptions | null { - // Search through webpack rules for the MDX loader injected by @next/mdx - for (const rule of rules) { - const loaders = extractMdxLoaders(rule); - if (loaders) return loaders; - } - return null; + // Search through webpack rules for the MDX loader injected by @next/mdx + for (const rule of rules) { + const loaders = extractMdxLoaders(rule); + if (loaders) return loaders; + } + return null; } /** @@ -774,69 +723,63 @@ function extractMdxOptionsFromRules(rules: any[]): MdxOptions | null { * for an MDX loader and extract its remark/rehype/recma plugin options. */ function extractMdxLoaders(rule: any): MdxOptions | null { - if (!rule) return null; - - // Check `oneOf` arrays (Next.js uses these extensively) - if (Array.isArray(rule.oneOf)) { - for (const child of rule.oneOf) { - const result = extractMdxLoaders(child); - if (result) return result; - } - } - - // Check `use` array (loader chain) - const use = Array.isArray(rule.use) ? rule.use : rule.use ? [rule.use] : []; - for (const loader of use) { - const loaderPath = typeof loader === "string" ? loader : loader?.loader; - if (typeof loaderPath === "string" && isMdxLoader(loaderPath)) { - const opts = typeof loader === "object" ? loader.options : {}; - return extractPluginsFromOptions(opts); - } - } - - // Check direct `loader` field - if (typeof rule.loader === "string" && isMdxLoader(rule.loader)) { - return extractPluginsFromOptions(rule.options); - } - - return null; + if (!rule) return null; + + // Check `oneOf` arrays (Next.js uses these extensively) + if (Array.isArray(rule.oneOf)) { + for (const child of rule.oneOf) { + const result = extractMdxLoaders(child); + if (result) return result; + } + } + + // Check `use` array (loader chain) + const use = Array.isArray(rule.use) ? rule.use : rule.use ? [rule.use] : []; + for (const loader of use) { + const loaderPath = typeof loader === "string" ? loader : loader?.loader; + if (typeof loaderPath === "string" && isMdxLoader(loaderPath)) { + const opts = typeof loader === "object" ? loader.options : {}; + return extractPluginsFromOptions(opts); + } + } + + // Check direct `loader` field + if (typeof rule.loader === "string" && isMdxLoader(rule.loader)) { + return extractPluginsFromOptions(rule.options); + } + + return null; } function isMdxLoader(loaderPath: string): boolean { - return ( - loaderPath.includes("mdx") && - (loaderPath.includes("@next") || - loaderPath.includes("@mdx-js") || - loaderPath.includes("mdx-js-loader") || - loaderPath.includes("next-mdx")) - ); + return ( + loaderPath.includes("mdx") && + (loaderPath.includes("@next") || + loaderPath.includes("@mdx-js") || + loaderPath.includes("mdx-js-loader") || + loaderPath.includes("next-mdx")) + ); } function extractPluginsFromOptions(opts: any): MdxOptions | null { - if (!opts || typeof opts !== "object") return null; - - const remarkPlugins = Array.isArray(opts.remarkPlugins) - ? opts.remarkPlugins - : undefined; - const rehypePlugins = Array.isArray(opts.rehypePlugins) - ? opts.rehypePlugins - : undefined; - const recmaPlugins = Array.isArray(opts.recmaPlugins) - ? opts.recmaPlugins - : undefined; - - // Only return if at least one plugin array is non-empty - if ( - (remarkPlugins && remarkPlugins.length > 0) || - (rehypePlugins && rehypePlugins.length > 0) || - (recmaPlugins && recmaPlugins.length > 0) - ) { - return { - ...(remarkPlugins && remarkPlugins.length > 0 ? { remarkPlugins } : {}), - ...(rehypePlugins && rehypePlugins.length > 0 ? { rehypePlugins } : {}), - ...(recmaPlugins && recmaPlugins.length > 0 ? { recmaPlugins } : {}), - }; - } - - return null; + if (!opts || typeof opts !== "object") return null; + + const remarkPlugins = Array.isArray(opts.remarkPlugins) ? opts.remarkPlugins : undefined; + const rehypePlugins = Array.isArray(opts.rehypePlugins) ? opts.rehypePlugins : undefined; + const recmaPlugins = Array.isArray(opts.recmaPlugins) ? opts.recmaPlugins : undefined; + + // Only return if at least one plugin array is non-empty + if ( + (remarkPlugins && remarkPlugins.length > 0) || + (rehypePlugins && rehypePlugins.length > 0) || + (recmaPlugins && recmaPlugins.length > 0) + ) { + return { + ...(remarkPlugins && remarkPlugins.length > 0 ? { remarkPlugins } : {}), + ...(rehypePlugins && rehypePlugins.length > 0 ? { rehypePlugins } : {}), + ...(recmaPlugins && recmaPlugins.length > 0 ? { recmaPlugins } : {}), + }; + } + + return null; } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index b774b1fc3..d5cdde50f 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -10,27 +10,24 @@ import commonjs from "vite-plugin-commonjs"; import tsconfigPaths from "vite-tsconfig-paths"; import { staticExportPages } from "./build/static-export.js"; import { - isExternalUrl, - matchHeaders, - matchRedirect, - matchRewrite, - proxyExternalRequest, - type RequestContext, - requestContextFromRequest, - sanitizeDestination, + isExternalUrl, + matchHeaders, + matchRedirect, + matchRewrite, + proxyExternalRequest, + type RequestContext, + requestContextFromRequest, + sanitizeDestination, } from "./config/config-matchers.js"; import { - loadNextConfig, - type NextHeader, - type NextRedirect, - type NextRewrite, - type ResolvedNextConfig, - resolveNextConfig, + loadNextConfig, + type NextHeader, + type NextRedirect, + type NextRewrite, + type ResolvedNextConfig, + resolveNextConfig, } from "./config/next-config.js"; -import { - formatMissingCloudflarePluginError, - hasWranglerConfig, -} from "./deploy.js"; +import { formatMissingCloudflarePluginError, hasWranglerConfig } from "./deploy.js"; import { generateBrowserEntry } from "./entries/app-browser-entry.js"; import { generateRscEntry } from "./entries/app-rsc-entry.js"; import { generateSsrEntry } from "./entries/app-ssr-entry.js"; @@ -41,34 +38,28 @@ import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js" import { appRouter, invalidateAppRouteCache } from "./routing/app-router.js"; import { createValidFileMatcher } from "./routing/file-matcher.js"; import { - apiRouter, - invalidateRouteCache, - matchRoute, - pagesRouter, + apiRouter, + invalidateRouteCache, + matchRoute, + pagesRouter, } from "./routing/pages-router.js"; import { normalizePathnameForRouteMatchStrict } from "./routing/utils.js"; import { handleApiRoute } from "./server/api-handler.js"; import { createDirectRunner } from "./server/dev-module-runner.js"; import { validateDevRequest } from "./server/dev-origin-check.js"; import { createSSRHandler } from "./server/dev-server.js"; -import { - findInstrumentationFile, - runInstrumentation, -} from "./server/instrumentation.js"; +import { findInstrumentationFile, runInstrumentation } from "./server/instrumentation.js"; import { scanMetadataFiles } from "./server/metadata-routes.js"; import { findMiddlewareFile, runMiddleware } from "./server/middleware.js"; import { buildRequestHeadersFromMiddlewareResponse } from "./server/middleware-request-headers.js"; import { normalizePath } from "./server/normalize-path.js"; import { logRequest, now } from "./server/request-log.js"; -import { - PHASE_DEVELOPMENT_SERVER, - PHASE_PRODUCTION_BUILD, -} from "./shims/constants.js"; +import { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD } from "./shims/constants.js"; import { hasBasePath } from "./utils/base-path.js"; import { - manifestFilesWithBase, - manifestFileWithBase, - normalizeManifestFile, + manifestFilesWithBase, + manifestFileWithBase, + normalizeManifestFile, } from "./utils/manifest-paths.js"; import { detectPackageManager } from "./utils/project.js"; @@ -83,77 +74,68 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); * - *.woff2 (downloaded font files) */ async function fetchAndCacheFont( - cssUrl: string, - family: string, - cacheDir: string, + cssUrl: string, + family: string, + cacheDir: string, ): Promise { - // 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}`, - ); - - // Check if already cached - const cachedCSSPath = path.join(fontDir, "style.css"); - if (fs.existsSync(cachedCSSPath)) { - return fs.readFileSync(cachedCSSPath, "utf-8"); - } - - // Fetch CSS from Google Fonts (woff2 user-agent gives woff2 URLs) - const cssResponse = await fetch(cssUrl, { - headers: { - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - }, - }); - if (!cssResponse.ok) { - throw new Error(`Failed to fetch Google Fonts CSS: ${cssResponse.status}`); - } - let css = await cssResponse.text(); - - // Extract all font file URLs - const urlRe = /url\((https:\/\/fonts\.gstatic\.com\/[^)]+)\)/g; - const urls = new Map(); // original URL -> local filename - let urlMatch; - while ((urlMatch = urlRe.exec(css)) !== null) { - const fontUrl = urlMatch[1]; - if (!urls.has(fontUrl)) { - const ext = fontUrl.includes(".woff2") - ? ".woff2" - : 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}`, - ); - } - } - - // Download font files - fs.mkdirSync(fontDir, { recursive: true }); - for (const [fontUrl, filename] of urls) { - const filePath = path.join(fontDir, filename); - if (!fs.existsSync(filePath)) { - const fontResponse = await fetch(fontUrl); - if (fontResponse.ok) { - const buffer = Buffer.from(await fontResponse.arrayBuffer()); - fs.writeFileSync(filePath, buffer); - } - } - // Rewrite CSS to use relative path (Vite will resolve /@fs/ for dev, or asset for build) - css = css.split(fontUrl).join(filePath); - } - - // Cache the rewritten CSS - fs.writeFileSync(cachedCSSPath, css); - return css; + // 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}`); + + // Check if already cached + const cachedCSSPath = path.join(fontDir, "style.css"); + if (fs.existsSync(cachedCSSPath)) { + return fs.readFileSync(cachedCSSPath, "utf-8"); + } + + // Fetch CSS from Google Fonts (woff2 user-agent gives woff2 URLs) + const cssResponse = await fetch(cssUrl, { + headers: { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + }, + }); + if (!cssResponse.ok) { + throw new Error(`Failed to fetch Google Fonts CSS: ${cssResponse.status}`); + } + let css = await cssResponse.text(); + + // Extract all font file URLs + const urlRe = /url\((https:\/\/fonts\.gstatic\.com\/[^)]+)\)/g; + const urls = new Map(); // original URL -> local filename + let urlMatch; + while ((urlMatch = urlRe.exec(css)) !== null) { + const fontUrl = urlMatch[1]; + if (!urls.has(fontUrl)) { + const ext = fontUrl.includes(".woff2") + ? ".woff2" + : 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}`); + } + } + + // Download font files + fs.mkdirSync(fontDir, { recursive: true }); + for (const [fontUrl, filename] of urls) { + const filePath = path.join(fontDir, filename); + if (!fs.existsSync(filePath)) { + const fontResponse = await fetch(fontUrl); + if (fontResponse.ok) { + const buffer = Buffer.from(await fontResponse.arrayBuffer()); + fs.writeFileSync(filePath, buffer); + } + } + // Rewrite CSS to use relative path (Vite will resolve /@fs/ for dev, or asset for build) + css = css.split(fontUrl).join(filePath); + } + + // Cache the rewritten CSS + fs.writeFileSync(cachedCSSPath, css); + return css; } /** @@ -165,26 +147,24 @@ async function fetchAndCacheFont( * Supports: string literals, numeric literals, boolean literals, * arrays of the above, and nested object literals. */ -function parseStaticObjectLiteral( - objectStr: string, -): Record | null { - let ast: ReturnType; - try { - // Wrap in parens so the parser treats `{…}` as an expression, not a block - ast = parseAst(`(${objectStr})`); - } catch { - return null; - } - - // The AST should be: Program > ExpressionStatement > ObjectExpression - const body = ast.body; - if (body.length !== 1 || body[0].type !== "ExpressionStatement") return null; - - const expr = body[0].expression; - if (expr.type !== "ObjectExpression") return null; - - const result = extractStaticValue(expr); - return result === undefined ? null : (result as Record); +function parseStaticObjectLiteral(objectStr: string): Record | null { + let ast: ReturnType; + try { + // Wrap in parens so the parser treats `{…}` as an expression, not a block + ast = parseAst(`(${objectStr})`); + } catch { + return null; + } + + // The AST should be: Program > ExpressionStatement > ObjectExpression + const body = ast.body; + if (body.length !== 1 || body[0].type !== "ExpressionStatement") return null; + + const expr = body[0].expression; + if (expr.type !== "ObjectExpression") return null; + + const result = extractStaticValue(expr); + return result === undefined ? null : (result as Record); } /** @@ -197,63 +177,60 @@ function parseStaticObjectLiteral( */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractStaticValue(node: any): unknown { - switch (node.type) { - case "Literal": - // String, number, boolean, null - return node.value; - - case "UnaryExpression": - // Handle negative numbers: -1, -3.14 - if ( - node.operator === "-" && - node.argument?.type === "Literal" && - typeof node.argument.value === "number" - ) { - return -node.argument.value; - } - return undefined; - - case "ArrayExpression": { - const arr: unknown[] = []; - for (const elem of node.elements) { - if (!elem) return undefined; // sparse array - const val = extractStaticValue(elem); - if (val === undefined) return undefined; - arr.push(val); - } - return arr; - } - - case "ObjectExpression": { - const obj: Record = {}; - for (const prop of node.properties) { - if (prop.type !== "Property") return undefined; // SpreadElement etc. - if (prop.computed) return undefined; // [expr]: val - - // Key can be Identifier (unquoted) or Literal (quoted) - let key: string; - if (prop.key.type === "Identifier") { - key = prop.key.name; - } else if ( - prop.key.type === "Literal" && - typeof prop.key.value === "string" - ) { - key = prop.key.value; - } else { - return undefined; - } - - const val = extractStaticValue(prop.value); - if (val === undefined) return undefined; - obj[key] = val; - } - return obj; - } - - default: - // TemplateLiteral, CallExpression, Identifier, etc. — reject - return undefined; - } + switch (node.type) { + case "Literal": + // String, number, boolean, null + return node.value; + + case "UnaryExpression": + // Handle negative numbers: -1, -3.14 + if ( + node.operator === "-" && + node.argument?.type === "Literal" && + typeof node.argument.value === "number" + ) { + return -node.argument.value; + } + return undefined; + + case "ArrayExpression": { + const arr: unknown[] = []; + for (const elem of node.elements) { + if (!elem) return undefined; // sparse array + const val = extractStaticValue(elem); + if (val === undefined) return undefined; + arr.push(val); + } + return arr; + } + + case "ObjectExpression": { + const obj: Record = {}; + for (const prop of node.properties) { + if (prop.type !== "Property") return undefined; // SpreadElement etc. + if (prop.computed) return undefined; // [expr]: val + + // Key can be Identifier (unquoted) or Literal (quoted) + let key: string; + if (prop.key.type === "Identifier") { + key = prop.key.name; + } else if (prop.key.type === "Literal" && typeof prop.key.value === "string") { + key = prop.key.value; + } else { + return undefined; + } + + const val = extractStaticValue(prop.value); + if (val === undefined) return undefined; + obj[key] = val; + } + return obj; + } + + default: + // TemplateLiteral, CallExpression, Identifier, etc. — reject + return undefined; + } } /** @@ -263,13 +240,13 @@ function extractStaticValue(node: any): unknown { * the plugin's own location. */ function getViteMajorVersion(): number { - try { - const require = createRequire(path.join(process.cwd(), "package.json")); - const vitePkg = require("vite/package.json"); - return parseInt(vitePkg.version, 10); - } catch { - return 7; // default to Vite 7 - } + try { + const require = createRequire(path.join(process.cwd(), "package.json")); + const vitePkg = require("vite/package.json"); + return parseInt(vitePkg.version, 10); + } catch { + return 7; // default to Vite 7 + } } /** @@ -277,22 +254,22 @@ function getViteMajorVersion(): number { * Matches the same search order as postcss-load-config / lilconfig. */ const POSTCSS_CONFIG_FILES = [ - "postcss.config.js", - "postcss.config.cjs", - "postcss.config.mjs", - "postcss.config.ts", - "postcss.config.cts", - "postcss.config.mts", - ".postcssrc", - ".postcssrc.js", - ".postcssrc.cjs", - ".postcssrc.mjs", - ".postcssrc.ts", - ".postcssrc.cts", - ".postcssrc.mts", - ".postcssrc.json", - ".postcssrc.yaml", - ".postcssrc.yml", + "postcss.config.js", + "postcss.config.cjs", + "postcss.config.mjs", + "postcss.config.ts", + "postcss.config.cts", + "postcss.config.mts", + ".postcssrc", + ".postcssrc.js", + ".postcssrc.cjs", + ".postcssrc.mjs", + ".postcssrc.ts", + ".postcssrc.cts", + ".postcssrc.mts", + ".postcssrc.json", + ".postcssrc.yaml", + ".postcssrc.yml", ]; /** @@ -300,10 +277,7 @@ 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< - string, - Promise<{ plugins: any[] } | undefined> ->(); +const _postcssCache = new Map>(); /** * Resolve PostCSS string plugin names in a project's PostCSS config. @@ -317,96 +291,93 @@ const _postcssCache = new Map< * 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> { - if (_postcssCache.has(projectRoot)) return _postcssCache.get(projectRoot)!; +function resolvePostcssStringPlugins(projectRoot: string): Promise<{ plugins: any[] } | undefined> { + if (_postcssCache.has(projectRoot)) return _postcssCache.get(projectRoot)!; - const promise = _resolvePostcssStringPluginsUncached(projectRoot); - _postcssCache.set(projectRoot, promise); - return promise; + const promise = _resolvePostcssStringPluginsUncached(projectRoot); + _postcssCache.set(projectRoot, promise); + return promise; } async function _resolvePostcssStringPluginsUncached( - projectRoot: string, + projectRoot: string, ): Promise<{ plugins: any[] } | undefined> { - // Find the PostCSS config file - let configPath: string | null = null; - for (const name of POSTCSS_CONFIG_FILES) { - const candidate = path.join(projectRoot, name); - if (fs.existsSync(candidate)) { - configPath = candidate; - break; - } - } - if (!configPath) { - return undefined; - } - - // Load the config file - let config: any; - try { - if ( - configPath.endsWith(".json") || - configPath.endsWith(".yaml") || - configPath.endsWith(".yml") - ) { - // JSON/YAML configs use object form — postcss-load-config handles these fine - return undefined; - } - // For .postcssrc without extension, check if it's JSON - if (configPath.endsWith(".postcssrc")) { - const content = fs.readFileSync(configPath, "utf-8").trim(); - if (content.startsWith("{")) { - // JSON format — postcss-load-config handles object form - return undefined; - } - } - const mod = await import(pathToFileURL(configPath).href); - config = mod.default ?? mod; - } catch { - // If we can't load the config, let Vite/postcss-load-config handle it - return undefined; - } - - // Only process array-form plugins that contain string entries - // (either bare strings or tuple form ["plugin-name", { options }]) - if (!config || !Array.isArray(config.plugins)) { - return undefined; - } - const hasStringPlugins = config.plugins.some( - (p: any) => - typeof p === "string" || (Array.isArray(p) && typeof p[0] === "string"), - ); - if (!hasStringPlugins) { - return undefined; - } - - // Resolve string plugin names to actual plugin functions - const req = createRequire(path.join(projectRoot, "package.json")); - const resolved = await Promise.all( - config.plugins.filter(Boolean).map(async (plugin: any) => { - if (typeof plugin === "string") { - const resolved = req.resolve(plugin); - const mod = await import(pathToFileURL(resolved).href); - const fn = mod.default ?? mod; - // If the export is a function, call it to get the plugin instance - return typeof fn === "function" ? fn() : fn; - } - // Array tuple form: ["plugin-name", { options }] - if (Array.isArray(plugin) && typeof plugin[0] === "string") { - const [name, options] = plugin; - const resolved = req.resolve(name); - const mod = await import(pathToFileURL(resolved).href); - const fn = mod.default ?? mod; - return typeof fn === "function" ? fn(options) : fn; - } - // Already a function or plugin object — pass through - return plugin; - }), - ); - - return { plugins: resolved }; + // Find the PostCSS config file + let configPath: string | null = null; + for (const name of POSTCSS_CONFIG_FILES) { + const candidate = path.join(projectRoot, name); + if (fs.existsSync(candidate)) { + configPath = candidate; + break; + } + } + if (!configPath) { + return undefined; + } + + // Load the config file + let config: any; + try { + if ( + configPath.endsWith(".json") || + configPath.endsWith(".yaml") || + configPath.endsWith(".yml") + ) { + // JSON/YAML configs use object form — postcss-load-config handles these fine + return undefined; + } + // For .postcssrc without extension, check if it's JSON + if (configPath.endsWith(".postcssrc")) { + const content = fs.readFileSync(configPath, "utf-8").trim(); + if (content.startsWith("{")) { + // JSON format — postcss-load-config handles object form + return undefined; + } + } + const mod = await import(pathToFileURL(configPath).href); + config = mod.default ?? mod; + } catch { + // If we can't load the config, let Vite/postcss-load-config handle it + return undefined; + } + + // Only process array-form plugins that contain string entries + // (either bare strings or tuple form ["plugin-name", { options }]) + if (!config || !Array.isArray(config.plugins)) { + return undefined; + } + const hasStringPlugins = config.plugins.some( + (p: any) => typeof p === "string" || (Array.isArray(p) && typeof p[0] === "string"), + ); + if (!hasStringPlugins) { + return undefined; + } + + // Resolve string plugin names to actual plugin functions + const req = createRequire(path.join(projectRoot, "package.json")); + const resolved = await Promise.all( + config.plugins.filter(Boolean).map(async (plugin: any) => { + if (typeof plugin === "string") { + const resolved = req.resolve(plugin); + const mod = await import(pathToFileURL(resolved).href); + const fn = mod.default ?? mod; + // If the export is a function, call it to get the plugin instance + return typeof fn === "function" ? fn() : fn; + } + // Array tuple form: ["plugin-name", { options }] + if (Array.isArray(plugin) && typeof plugin[0] === "string") { + const [name, options] = plugin; + const resolved = req.resolve(name); + const mod = await import(pathToFileURL(resolved).href); + const fn = mod.default ?? mod; + return typeof fn === "function" ? fn(options) : fn; + } + // Already a function or plugin object — pass through + return plugin; + }), + ); + + return { plugins: resolved }; } // Virtual module IDs for Pages Router production build @@ -435,15 +406,15 @@ const IMAGE_EXTS = "png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?"; * (node_modules/.pnpm/pkg@ver/node_modules/pkg). */ function getPackageName(id: string): string | null { - const nmIdx = id.lastIndexOf("node_modules/"); - if (nmIdx === -1) return null; - const rest = id.slice(nmIdx + "node_modules/".length); - if (rest.startsWith("@")) { - // Scoped package: @org/pkg - const parts = rest.split("/"); - return parts.length >= 2 ? parts[0] + "/" + parts[1] : null; - } - return rest.split("/")[0] || null; + const nmIdx = id.lastIndexOf("node_modules/"); + if (nmIdx === -1) return null; + const rest = id.slice(nmIdx + "node_modules/".length); + if (rest.startsWith("@")) { + // Scoped package: @org/pkg + const parts = rest.split("/"); + return parts.length >= 2 ? parts[0] + "/" + parts[1] : null; + } + return rest.split("/")[0] || null; } /** Absolute path to vinext's shims directory, used by clientManualChunks. */ @@ -477,31 +448,31 @@ const _shimsDir = path.resolve(__dirname, "shims") + "/"; * and route-specific code stays in route chunks. */ function clientManualChunks(id: string): string | undefined { - // React framework — always loaded, shared across all pages. - // Isolating React into its own chunk is the single highest-value - // split: it's ~130KB compressed, loaded on every page, and its - // content hash rarely changes between deploys. - if (id.includes("node_modules")) { - const pkg = getPackageName(id); - if (!pkg) return undefined; - if (pkg === "react" || pkg === "react-dom" || pkg === "scheduler") { - return "framework"; - } - // Let Rollup handle all other vendor code via its default - // graph-based splitting. This produces a reasonable number of - // shared chunks (typically 5-15) based on actual import patterns, - // with good compression efficiency. - return undefined; - } - - // vinext shims — small runtime, shared across all pages. - // Use the absolute shims directory path to avoid matching user files - // that happen to have "/shims/" in their path. - if (id.startsWith(_shimsDir)) { - return "vinext"; - } - - return undefined; + // React framework — always loaded, shared across all pages. + // Isolating React into its own chunk is the single highest-value + // split: it's ~130KB compressed, loaded on every page, and its + // content hash rarely changes between deploys. + if (id.includes("node_modules")) { + const pkg = getPackageName(id); + if (!pkg) return undefined; + if (pkg === "react" || pkg === "react-dom" || pkg === "scheduler") { + return "framework"; + } + // Let Rollup handle all other vendor code via its default + // graph-based splitting. This produces a reasonable number of + // shared chunks (typically 5-15) based on actual import patterns, + // with good compression efficiency. + return undefined; + } + + // vinext shims — small runtime, shared across all pages. + // Use the absolute shims directory path to avoid matching user files + // that happen to have "/shims/" in their path. + if (id.startsWith(_shimsDir)) { + return "vinext"; + } + + return undefined; } /** @@ -514,8 +485,8 @@ function clientManualChunks(id: string): string | undefined { * adding ~5-15% wire overhead vs fewer larger chunks. */ const clientOutputConfig = { - manualChunks: clientManualChunks, - experimentalMinChunkSize: 10_000, + manualChunks: clientManualChunks, + experimentalMinChunkSize: 10_000, }; /** @@ -544,18 +515,18 @@ const clientOutputConfig = { * - 'recommended' + 'no-external' gives most of the benefit with less risk */ const clientTreeshakeConfig = { - preset: "recommended" as const, - moduleSideEffects: "no-external" as const, + preset: "recommended" as const, + moduleSideEffects: "no-external" as const, }; type BuildManifestChunk = { - file: string; - isEntry?: boolean; - isDynamicEntry?: boolean; - imports?: string[]; - dynamicImports?: string[]; - css?: string[]; - assets?: string[]; + file: string; + isEntry?: boolean; + isDynamicEntry?: boolean; + imports?: string[]; + dynamicImports?: string[]; + css?: string[]; + assets?: string[]; }; /** @@ -575,3073 +546,2809 @@ 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[] { - // Collect all chunk files that are statically reachable from entries - const eagerFiles = new Set(); - const visited = new Set(); - const queue: string[] = []; - - // Start BFS from all entry chunks - for (const key of Object.keys(buildManifest)) { - const chunk = buildManifest[key]; - if (chunk.isEntry) { - queue.push(key); - } - } - - while (queue.length > 0) { - const key = queue.shift()!; - if (visited.has(key)) continue; - visited.add(key); - - const chunk = buildManifest[key]; - if (!chunk) continue; - - // Mark this chunk's file as eager - eagerFiles.add(chunk.file); - - // Also mark its CSS as eager (CSS should always be preloaded to avoid FOUC) - if (chunk.css) { - for (const cssFile of chunk.css) { - eagerFiles.add(cssFile); - } - } - - // Follow only static imports — NOT dynamicImports - if (chunk.imports) { - for (const imp of chunk.imports) { - if (!visited.has(imp)) { - queue.push(imp); - } - } - } - } - - // Any JS file in the manifest that's NOT in eagerFiles is a lazy chunk - const lazyChunks: string[] = []; - const allFiles = new Set(); - for (const key of Object.keys(buildManifest)) { - const chunk = buildManifest[key]; - if (chunk.file && !allFiles.has(chunk.file)) { - allFiles.add(chunk.file); - if (!eagerFiles.has(chunk.file) && chunk.file.endsWith(".js")) { - lazyChunks.push(chunk.file); - } - } - } - - return lazyChunks; +function computeLazyChunks(buildManifest: Record): string[] { + // Collect all chunk files that are statically reachable from entries + const eagerFiles = new Set(); + const visited = new Set(); + const queue: string[] = []; + + // Start BFS from all entry chunks + for (const key of Object.keys(buildManifest)) { + const chunk = buildManifest[key]; + if (chunk.isEntry) { + queue.push(key); + } + } + + while (queue.length > 0) { + const key = queue.shift()!; + if (visited.has(key)) continue; + visited.add(key); + + const chunk = buildManifest[key]; + if (!chunk) continue; + + // Mark this chunk's file as eager + eagerFiles.add(chunk.file); + + // Also mark its CSS as eager (CSS should always be preloaded to avoid FOUC) + if (chunk.css) { + for (const cssFile of chunk.css) { + eagerFiles.add(cssFile); + } + } + + // Follow only static imports — NOT dynamicImports + if (chunk.imports) { + for (const imp of chunk.imports) { + if (!visited.has(imp)) { + queue.push(imp); + } + } + } + } + + // Any JS file in the manifest that's NOT in eagerFiles is a lazy chunk + const lazyChunks: string[] = []; + const allFiles = new Set(); + for (const key of Object.keys(buildManifest)) { + const chunk = buildManifest[key]; + if (chunk.file && !allFiles.has(chunk.file)) { + allFiles.add(chunk.file); + if (!eagerFiles.has(chunk.file) && chunk.file.endsWith(".js")) { + lazyChunks.push(chunk.file); + } + } + } + + return lazyChunks; } type BundleBackfillChunk = { - type: "chunk"; - fileName: string; - imports?: string[]; - modules?: Record; - viteMetadata?: { - importedCss?: Set; - importedAssets?: Set; - }; + type: "chunk"; + fileName: string; + imports?: string[]; + modules?: Record; + viteMetadata?: { + importedCss?: Set; + importedAssets?: Set; + }; }; function normalizeManifestModuleId(moduleId: string, root: string): string { - const normalizedId = moduleId.replace(/\\/g, "/"); - const isWindowsAbsolute = - /^[a-zA-Z]:[\\/]/.test(moduleId) || moduleId.startsWith("\\\\"); - if (isWindowsAbsolute) { - const relativeId = path.win32.relative(root, moduleId).replace(/\\/g, "/"); - if (!relativeId || relativeId.startsWith("../")) return normalizedId; - return relativeId; - } - - if (!path.isAbsolute(moduleId)) return normalizedId; - - const relativeId = path.relative(root, moduleId).replace(/\\/g, "/"); - if (!relativeId || relativeId.startsWith("../")) return normalizedId; - return relativeId; + const normalizedId = moduleId.replace(/\\/g, "/"); + const isWindowsAbsolute = /^[a-zA-Z]:[\\/]/.test(moduleId) || moduleId.startsWith("\\\\"); + if (isWindowsAbsolute) { + const relativeId = path.win32.relative(root, moduleId).replace(/\\/g, "/"); + if (!relativeId || relativeId.startsWith("../")) return normalizedId; + return relativeId; + } + + if (!path.isAbsolute(moduleId)) return normalizedId; + + const relativeId = path.relative(root, moduleId).replace(/\\/g, "/"); + if (!relativeId || relativeId.startsWith("../")) return normalizedId; + return relativeId; } function augmentSsrManifestFromBundle( - ssrManifest: Record, - bundle: Record, - root: string, - base = "/", + ssrManifest: Record, + bundle: Record, + root: string, + base = "/", ): Record { - const nextManifest = Object.fromEntries( - Object.entries(ssrManifest).map(([key, files]) => [ - key, - new Set(files.map((file) => normalizeManifestFile(file))), - ]), - ) as Record>; - - for (const item of Object.values(bundle)) { - if (item.type !== "chunk") continue; - const chunk = item as BundleBackfillChunk; - - const files = new Set(); - files.add(manifestFileWithBase(chunk.fileName, base)); - for (const importedFile of chunk.imports ?? []) { - files.add(manifestFileWithBase(importedFile, base)); - } - for (const cssFile of chunk.viteMetadata?.importedCss ?? []) { - files.add(manifestFileWithBase(cssFile, base)); - } - for (const assetFile of chunk.viteMetadata?.importedAssets ?? []) { - files.add(manifestFileWithBase(assetFile, base)); - } - - 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("\0")) continue; - if (!nextManifest[key]) nextManifest[key] = new Set(); - for (const file of files) { - nextManifest[key].add(file); - } - } - } - - return Object.fromEntries( - Object.entries(nextManifest).map(([key, files]) => [key, [...files]]), - ) as Record; + const nextManifest = Object.fromEntries( + Object.entries(ssrManifest).map(([key, files]) => [ + key, + new Set(files.map((file) => normalizeManifestFile(file))), + ]), + ) as Record>; + + for (const item of Object.values(bundle)) { + if (item.type !== "chunk") continue; + const chunk = item as BundleBackfillChunk; + + const files = new Set(); + files.add(manifestFileWithBase(chunk.fileName, base)); + for (const importedFile of chunk.imports ?? []) { + files.add(manifestFileWithBase(importedFile, base)); + } + for (const cssFile of chunk.viteMetadata?.importedCss ?? []) { + files.add(manifestFileWithBase(cssFile, base)); + } + for (const assetFile of chunk.viteMetadata?.importedAssets ?? []) { + files.add(manifestFileWithBase(assetFile, base)); + } + + 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("\0")) continue; + if (!nextManifest[key]) nextManifest[key] = new Set(); + for (const file of files) { + nextManifest[key].add(file); + } + } + } + + return Object.fromEntries( + Object.entries(nextManifest).map(([key, files]) => [key, [...files]]), + ) as Record; } export interface VinextOptions { - /** - * Base directory containing the app/ and pages/ directories. - * Can be an absolute path or a path relative to the Vite root. - * - * By default, vinext auto-detects: checks for app/ and pages/ at the - * project root first, then falls back to src/app/ and src/pages/. - */ - appDir?: string; - /** - * Auto-register @vitejs/plugin-rsc when an app/ directory is detected. - * Set to `false` to disable auto-registration (e.g. if you configure - * @vitejs/plugin-rsc manually with custom options). - * @default true - */ - rsc?: boolean; - /** - * Options passed to @vitejs/plugin-react (React Fast Refresh + JSX transform). - * Enabled by default. Set to `false` to disable (e.g. if you already have - * @vitejs/plugin-react in your vite.config.ts), or pass an options object - * to customize the Babel transform. - * @default true - */ - react?: VitePluginReactOptions | boolean; + /** + * Base directory containing the app/ and pages/ directories. + * Can be an absolute path or a path relative to the Vite root. + * + * By default, vinext auto-detects: checks for app/ and pages/ at the + * project root first, then falls back to src/app/ and src/pages/. + */ + appDir?: string; + /** + * Auto-register @vitejs/plugin-rsc when an app/ directory is detected. + * Set to `false` to disable auto-registration (e.g. if you configure + * @vitejs/plugin-rsc manually with custom options). + * @default true + */ + rsc?: boolean; + /** + * Options passed to @vitejs/plugin-react (React Fast Refresh + JSX transform). + * Enabled by default. Set to `false` to disable (e.g. if you already have + * @vitejs/plugin-react in your vite.config.ts), or pass an options object + * to customize the Babel transform. + * @default true + */ + react?: VitePluginReactOptions | boolean; } export default function vinext(options: VinextOptions = {}): PluginOption[] { - let root: string; - let pagesDir: string; - let appDir: string; - let hasAppDir = false; - let hasPagesDir = false; - let nextConfig: ResolvedNextConfig; - let fileMatcher: ReturnType; - let middlewarePath: string | null = null; - let instrumentationPath: string | null = null; - let hasCloudflarePlugin = false; - let hasNitroPlugin = false; - - // Resolve shim paths - works both from source (.ts) and built (.js) - const shimsDir = path.resolve(__dirname, "shims"); - - // Shim alias map — populated in config(), used by resolveId() for .js variants - let nextShimMap: Record = {}; - - // Build-only cache for og-inline-fetch-assets to avoid repeated file reads - // during a single production build. Dev mode skips the cache so asset edits - // are picked up without restarting the Vite server. - const _ogInlineCache = new Map(); - let _ogInlineIsBuild = false; - - /** - * Generate the virtual SSR server entry module. - * This is the entry point for `vite build --ssr`. - */ - async function generateServerEntry(): Promise { - return _generateServerEntry( - pagesDir, - nextConfig, - fileMatcher, - middlewarePath, - instrumentationPath, - ); - } - - /** - * Generate the virtual client hydration entry module. - * This is the entry point for `vite build` (client bundle). - * - * It maps route patterns to dynamic imports of page modules so Vite - * code-splits each page into its own chunk. At runtime it reads - * __NEXT_DATA__ to determine which page to hydrate. - */ - async function generateClientEntry(): Promise { - return _generateClientEntry(pagesDir, nextConfig, fileMatcher); - } - - // Auto-register @vitejs/plugin-rsc when App Router is detected. - // Check eagerly at call time using the same heuristic as config(). - // Must mirror the full detection logic: check {base}/app then {base}/src/app. - const autoRsc = options.rsc !== false; - const earlyBaseDir = options.appDir ?? process.cwd(); - const earlyAppDirExists = - fs.existsSync(path.join(earlyBaseDir, "app")) || - fs.existsSync(path.join(earlyBaseDir, "src", "app")); - - // IMPORTANT: Resolve @vitejs/plugin-rsc subpath imports from the user's - // project root, not from vinext's own package location. When vinext is - // installed via symlink (npm file: deps, pnpm workspace:*), a bare - // import() resolves from vinext's realpath, which can find a different - // copy of the RSC plugin (and transitively a different copy of vite). - // This causes instanceof RunnableDevEnvironment checks to fail at - // runtime because the Vite server and the RSC plugin end up with - // different class identities. Resolving from the project root ensures a - // single shared vite instance. - // - // Pre-resolve both the main plugin and the /transforms subpath eagerly - // so all import() calls in this module use consistent resolution. - const earlyRequire = createRequire(path.join(earlyBaseDir, "package.json")); - let resolvedRscPath: string | null = null; - let resolvedRscTransformsPath: string | null = null; - try { - resolvedRscPath = earlyRequire.resolve("@vitejs/plugin-rsc"); - resolvedRscTransformsPath = earlyRequire.resolve( - "@vitejs/plugin-rsc/transforms", - ); - } catch { - // @vitejs/plugin-rsc not installed — that's fine for Pages Router - // projects. If App Router is detected, the error is thrown below. - } - - // If app/ exists and auto-RSC is enabled, create a lazy Promise that - // resolves to the configured RSC plugin array. Vite's asyncFlatten - // will resolve this before processing the plugin list. - let rscPluginPromise: Promise | null = null; - if (earlyAppDirExists && autoRsc) { - if (!resolvedRscPath) { - throw new Error( - "vinext: App Router detected but @vitejs/plugin-rsc is not installed.\n" + - "Run: " + - detectPackageManager(process.cwd()) + - " @vitejs/plugin-rsc", - ); - } - const rscImport = import(pathToFileURL(resolvedRscPath).href); - rscPluginPromise = rscImport.then((mod) => { - const rsc = mod.default; - return rsc({ - entries: { - rsc: VIRTUAL_RSC_ENTRY, - ssr: VIRTUAL_APP_SSR_ENTRY, - client: VIRTUAL_APP_BROWSER_ENTRY, - }, - }); - }); - } - - 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. - let mdxDelegate: Plugin | null = null; - - const reactPlugin = - options.react === false - ? false - : react(options.react === true ? undefined : options.react); - - const plugins: PluginOption[] = [ - // Resolve tsconfig paths/baseUrl aliases so real-world Next.js repos - // that use @/*, #/*, or baseUrl imports work out of the box. - tsconfigPaths(), - // React Fast Refresh + JSX transform for client components. - reactPlugin, - // Transform CJS require()/module.exports to ESM before other plugins - // analyze imports (RSC directive scanning, shim resolution, etc.) - commonjs(), - { - name: "vinext:config", - enforce: "pre", - - async config(config, env) { - root = config.root ?? process.cwd(); - - // Load .env files into process.env before anything else. - // Next.js loads .env files before evaluating next.config.js, so - // env vars are available in config, server-side code, and as - // NEXT_PUBLIC_* defines for the client bundle. - // Pass '' as prefix to load ALL vars, not just VITE_-prefixed ones. - const mode = env?.mode ?? "development"; - const envDir = config.envDir ?? root; - const dotenvVars = loadEnv(mode, envDir, ""); - for (const [key, value] of Object.entries(dotenvVars)) { - if (process.env[key] === undefined) { - process.env[key] = value; - } - } - // Align NODE_ENV with Next.js semantics: build -> production, serve -> development. - // Next.js unconditionally forces NODE_ENV during build/dev, so we do the same. - let resolvedNodeEnv: string; - if (mode === "test") { - resolvedNodeEnv = "test"; - } else if (env?.command === "build") { - resolvedNodeEnv = "production"; - } else { - resolvedNodeEnv = "development"; - } - if (process.env.NODE_ENV !== resolvedNodeEnv) { - process.env.NODE_ENV = resolvedNodeEnv; - } - - // Resolve the base directory for app/pages detection. - // If appDir is provided, resolve it (supports both relative and absolute paths). - // If not provided, auto-detect: check root first, then src/ subdirectory. - let baseDir: string; - if (options.appDir) { - baseDir = path.isAbsolute(options.appDir) - ? options.appDir - : path.resolve(root, options.appDir); - } else { - // Auto-detect: prefer root-level app/ and pages/, fall back to src/ - const hasRootApp = fs.existsSync(path.join(root, "app")); - const hasRootPages = fs.existsSync(path.join(root, "pages")); - const hasSrcApp = fs.existsSync(path.join(root, "src", "app")); - const hasSrcPages = fs.existsSync(path.join(root, "src", "pages")); - - if (hasRootApp || hasRootPages) { - baseDir = root; - } else if (hasSrcApp || hasSrcPages) { - baseDir = path.join(root, "src"); - } else { - baseDir = root; - } - } - - pagesDir = path.join(baseDir, "pages"); - appDir = path.join(baseDir, "app"); - hasPagesDir = fs.existsSync(pagesDir); - hasAppDir = fs.existsSync(appDir); - middlewarePath = findMiddlewareFile(root); - instrumentationPath = findInstrumentationFile(root); - - // 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); - - // Merge env from next.config.js with NEXT_PUBLIC_* env vars - const defines = getNextPublicEnvDefines(); - if ( - !config.define || - typeof config.define !== "object" || - !("process.env.NODE_ENV" in config.define) - ) { - defines["process.env.NODE_ENV"] = JSON.stringify(resolvedNodeEnv); - } - for (const [key, value] of Object.entries(nextConfig.env)) { - // Skip NODE_ENV from next.config.js env — Next.js ignores it too, - // and it would silently override the value we just set above. - if (key === "NODE_ENV") continue; - defines[`process.env.${key}`] = JSON.stringify(value); - } - // Expose basePath to client-side code - 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 ?? []), - ); - defines["process.env.__VINEXT_IMAGE_DOMAINS"] = JSON.stringify( - JSON.stringify(nextConfig.images?.domains ?? []), - ); - // Expose allowed image widths (union of deviceSizes + imageSizes) for - // server-side validation. Matches Next.js behavior: only configured - // sizes are accepted by the image optimization endpoint. - { - 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, - ]; - defines["process.env.__VINEXT_IMAGE_DEVICE_SIZES"] = JSON.stringify( - JSON.stringify(deviceSizes), - ); - 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), - ); - // 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(), - ); - // 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, - ); - - // Build the shim alias map — used by both resolve.alias and resolveId - // (resolveId handles .js extension variants for libraries like nuqs) - nextShimMap = { - ...nextConfig.aliases, - "next/link": path.join(shimsDir, "link"), - "next/head": path.join(shimsDir, "head"), - "next/router": path.join(shimsDir, "router"), - "next/compat/router": path.join(shimsDir, "compat-router"), - "next/image": path.join(shimsDir, "image"), - "next/legacy/image": path.join(shimsDir, "legacy-image"), - "next/dynamic": path.join(shimsDir, "dynamic"), - "next/app": path.join(shimsDir, "app"), - "next/document": path.join(shimsDir, "document"), - "next/config": path.join(shimsDir, "config"), - "next/script": path.join(shimsDir, "script"), - "next/server": path.join(shimsDir, "server"), - "next/navigation": path.join(shimsDir, "navigation"), - "next/headers": path.join(shimsDir, "headers"), - "next/font/google": path.join(shimsDir, "font-google"), - "next/font/local": path.join(shimsDir, "font-local"), - "next/cache": path.join(shimsDir, "cache"), - "next/form": path.join(shimsDir, "form"), - "next/og": path.join(shimsDir, "og"), - "next/web-vitals": path.join(shimsDir, "web-vitals"), - "next/amp": path.join(shimsDir, "amp"), - "next/error": path.join(shimsDir, "error"), - "next/constants": path.join(shimsDir, "constants"), - // Internal next/dist/* paths used by popular libraries - // (next-intl, @clerk/nextjs, @sentry/nextjs, next-nprogress-bar, etc.) - "next/dist/shared/lib/app-router-context.shared-runtime": path.join( - shimsDir, - "internal", - "app-router-context", - ), - "next/dist/shared/lib/app-router-context": path.join( - shimsDir, - "internal", - "app-router-context", - ), - "next/dist/shared/lib/router-context.shared-runtime": path.join( - shimsDir, - "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( - shimsDir, - "internal", - "cookies", - ), - "next/dist/compiled/@edge-runtime/cookies": path.join( - shimsDir, - "internal", - "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", - ), - // 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/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/router-state": path.join(shimsDir, "router-state"), - "vinext/head-state": path.join(shimsDir, "head-state"), - "vinext/instrumentation": path.resolve( - __dirname, - "server", - "instrumentation", - ), - "vinext/html": path.resolve(__dirname, "server", "html"), - }; - - // Detect if Cloudflare's vite plugin is present — if so, skip - // SSR externals (Workers bundle everything, can't have Node.js externals). - const pluginsFlat: any[] = []; - function flattenPlugins(arr: any[]) { - for (const p of arr) { - if (Array.isArray(p)) flattenPlugins(p); - else if (p) pluginsFlat.push(p); - } - } - flattenPlugins((config.plugins as any[]) ?? []); - hasCloudflarePlugin = pluginsFlat.some( - (p: any) => - p && - typeof p === "object" && - typeof p.name === "string" && - (p.name === "vite-plugin-cloudflare" || - p.name.startsWith("vite-plugin-cloudflare:")), - ); - hasNitroPlugin = pluginsFlat.some( - (p: any) => - p && - typeof p === "object" && - typeof p.name === "string" && - (p.name === "nitro" || p.name.startsWith("nitro:")), - ); - - // Resolve PostCSS string plugin names that Vite can't handle. - // Next.js projects commonly use array-form plugins like - // `plugins: ["@tailwindcss/postcss"]` which postcss-load-config - // doesn't resolve (only object-form keys are resolved). We detect - // this and resolve the strings to actual plugin functions, then - // inject via css.postcss so Vite uses the resolved plugins. - // Only do this if the user hasn't already set css.postcss inline. - let postcssOverride: { plugins: any[] } | undefined; - if (!config.css?.postcss || typeof config.css.postcss === "string") { - postcssOverride = await resolvePostcssStringPlugins(root); - } - - // Auto-inject @mdx-js/rollup when MDX files exist and no MDX plugin is - // already configured. Applies remark/rehype plugins from next.config. - const hasMdxPlugin = pluginsFlat.some( - (p: any) => - p && - typeof p === "object" && - typeof p.name === "string" && - (p.name === "@mdx-js/rollup" || p.name === "mdx"), - ); - if ( - !hasMdxPlugin && - hasMdxFiles( - root, - hasAppDir ? appDir : null, - hasPagesDir ? pagesDir : null, - ) - ) { - try { - const mdxRollup = await import("@mdx-js/rollup"); - const mdxFactory = mdxRollup.default ?? mdxRollup; - const mdxOpts: Record = {}; - if (nextConfig.mdx) { - if (nextConfig.mdx.remarkPlugins) - mdxOpts.remarkPlugins = nextConfig.mdx.remarkPlugins; - if (nextConfig.mdx.rehypePlugins) - mdxOpts.rehypePlugins = nextConfig.mdx.rehypePlugins; - if (nextConfig.mdx.recmaPlugins) - mdxOpts.recmaPlugins = nextConfig.mdx.recmaPlugins; - } - mdxDelegate = mdxFactory(mdxOpts); - if (nextConfig.mdx) { - console.log( - "[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", - ); - } - } catch { - // @mdx-js/rollup not installed — warn but don't fail - console.warn( - "[vinext] MDX files detected but @mdx-js/rollup is not installed. " + - "Install it with: " + - detectPackageManager(process.cwd()) + - " @mdx-js/rollup", - ); - } - } - - // Detect if this is a standalone SSR build (set by `vite build --ssr` - // or `build.ssr` in config). SSR builds must NOT use manualChunks - // because they use inlineDynamicImports which is incompatible. - const isSSR = !!config.build?.ssr; - // Detect if this is a multi-environment build (App Router or Cloudflare). - // In multi-env builds, manualChunks must only be set per-environment - // (on the client env), not globally — otherwise it leaks into RSC/SSR - // environments where it can cause asset resolution issues. - const isMultiEnv = hasAppDir || hasCloudflarePlugin || hasNitroPlugin; - - 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; - } - 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 } : {}), - }, - }, - // 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 - // OPTIONS with a 204 before the request reaches vinext's handler. - // Keep Vite's default restrictive origin policy by explicitly - // setting it. Without the `origin` field, `preflightContinue: true` - // would override Vite's default and allow any origin. - server: { - cors: { - preflightContinue: true, - origin: - /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/, - }, - }, - // Configure SSR transform behaviour for Node targets. - // - `external`: React packages are loaded natively by Node (CJS) - // rather than through Vite's ESM evaluator. - // - `noExternal: true`: force everything else through Vite's - // transform pipeline so non-JS imports (CSS, images) from - // node_modules don't hit Node's native ESM loader. - // Any user-provided `ssr.noExternal` is intentionally superseded - // by this setting; only `ssr.external` entries escape Vite's transform. - // Skip when targeting bundled runtimes (Cloudflare/Nitro bundle everything). - // This also resolves extensionless-import issues in packages like - // `validator` (see #189) by routing them through Vite's resolver. - ...(hasCloudflarePlugin || hasNitroPlugin - ? {} - : { - ssr: { - external: ["react", "react-dom", "react-dom/server"], - noExternal: true, - }, - }), - resolve: { - alias: 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", - ], - }, - // Exclude vinext from dependency optimization so esbuild doesn't - // scan dist files containing virtual module imports (virtual:vinext-*) - // that only resolve at Vite plugin time, not during pre-bundling. - // Exclude @vercel/og so Vite's esbuild pre-bundler doesn't cache it - // before our vinext:og-font-patch transform can inline the font and - // patch the yoga WASM instantiation for workerd compatibility. - optimizeDeps: { - exclude: ["vinext", "@vercel/og"], - }, - // Enable JSX in .tsx/.jsx files - // Vite 7 uses `esbuild` for transforms, Vite 8+ uses `oxc` - ...(getViteMajorVersion() >= 8 - ? { oxc: { jsx: { runtime: "automatic" } } } - : { esbuild: { jsx: "automatic" } }), - // Define env vars for client bundle - define: defines, - // Set base path if configured - ...(nextConfig.basePath ? { base: nextConfig.basePath + "/" } : {}), - // When assetPrefix is set, rewrite built asset/chunk URLs to the CDN origin. - // SSR environments stay relative so server-side imports resolve correctly. - ...(nextConfig.assetPrefix - ? { - experimental: { - renderBuiltUrl( - filename: string, - { type, ssr }: { type: string; ssr: boolean }, - ) { - if (ssr) return { relative: true }; - if (type === "asset" || type === "chunk") { - return nextConfig.assetPrefix + "/" + filename; - } - return { relative: true }; - }, - }, - } - : {}), - // Inject resolved PostCSS plugins if string names were found - ...(postcssOverride ? { css: { postcss: postcssOverride } } : {}), - }; - - // Collect user-provided ssr.external so we can propagate it into - // both the RSC and SSR environment configs. Vite's `ssr.*` config - // only applies to the default `ssr` environment, not custom ones - // like `rsc`. Native addon packages (e.g. better-sqlite3) listed - // in ssr.external must be externalized from ALL server environments. - // Vite's SSROptions.external is `string[] | true`; handle both forms. - const userSsrExternal: string[] | true = Array.isArray( - config.ssr?.external, - ) - ? config.ssr.external - : config.ssr?.external === true - ? true - : []; - - // If app/ directory exists, configure RSC environments - if (hasAppDir) { - // Compute optimizeDeps.entries so Vite discovers server-side - // dependencies at startup instead of on first request. Without - // this, deps imported in rsc/ssr environments are found lazily, - // causing re-optimisation cascades and runtime errors (e.g. - // "Invalid hook call" from duplicate React instances). - // The entries must be relative to the project root. - const relAppDir = path.relative(root, appDir); - const appEntries = [`${relAppDir}/**/*.{tsx,ts,jsx,js}`]; - - viteConfig.environments = { - rsc: { - ...(hasCloudflarePlugin || hasNitroPlugin - ? {} - : { - resolve: { - // Externalize native/heavy packages so the RSC environment - // loads them natively via Node rather than through Vite's - // ESM module evaluator (which can't handle native addons). - // Note: Do NOT externalize react/react-dom here — they must - // be bundled with the "react-server" condition for RSC. - // Skip when targeting bundled runtimes (Cloudflare/Nitro). - external: - userSsrExternal === true - ? true - : [ - "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 }), - }, - }), - optimizeDeps: { - exclude: ["vinext", "@vercel/og"], - entries: appEntries, - }, - build: { - outDir: "dist/server", - rollupOptions: { - input: { index: VIRTUAL_RSC_ENTRY }, - }, - }, - }, - ssr: { - ...(hasCloudflarePlugin || hasNitroPlugin - ? {} - : { - resolve: { - 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 }), - }, - }), - optimizeDeps: { - exclude: ["vinext", "@vercel/og"], - entries: appEntries, - }, - build: { - outDir: "dist/server/ssr", - rollupOptions: { - input: { index: VIRTUAL_APP_SSR_ENTRY }, - }, - }, - }, - client: { - // Explicitly mark as client consumer so other plugins (e.g. Nitro) - // can detect this during configEnvironment hooks — before Vite - // applies the default consumer based on environment name. - // Without this, Nitro's configEnvironment creates a server-side - // service for the client environment, causing virtual module - // imports to leak to Node's native ESM loader (ERR_UNSUPPORTED_ESM_URL_SCHEME). - consumer: "client", - optimizeDeps: { - exclude: ["vinext"], - // Crawl app/ source files up front so client-only deps imported - // by user components are discovered during startup instead of - // triggering a late re-optimisation + full page reload. - entries: appEntries, - // React packages aren't crawled from app/ source files, - // so must be pre-included to avoid late discovery (#25). - include: [ - "react", - "react-dom", - "react-dom/client", - "react/jsx-runtime", - "react/jsx-dev-runtime", - ], - }, - build: { - // When targeting Cloudflare Workers, enable manifest generation - // so the vinext:cloudflare-build closeBundle hook can read the - // client build manifest, compute lazy chunks (only reachable - // via dynamic imports), and inject __VINEXT_LAZY_CHUNKS__ into - // the worker entry. Without this, all chunks are modulepreloaded - // 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, - }, - }, - }, - }; - } else if (hasCloudflarePlugin) { - // Pages Router on Cloudflare Workers: add a client environment - // so the multi-environment build produces client JS bundles - // alongside the worker. Without this, only the worker is built - // and there's no client-side hydration. - viteConfig.environments = { - client: { - consumer: "client", - build: { - manifest: true, - ssrManifest: true, - rollupOptions: { - input: { index: VIRTUAL_CLIENT_ENTRY }, - output: clientOutputConfig, - treeshake: clientTreeshakeConfig, - }, - }, - }, - }; - } - - return viteConfig; - }, - - configResolved(config) { - // Detect double RSC plugin registration. When vinext auto-injects - // @vitejs/plugin-rsc AND the user also registers it manually, the - // RSC transform pipeline runs twice — doubling build time. - // Rather than trying to magically fix this at runtime, fail fast - // with a clear error telling the user how to fix their config. - 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", - ); - if (rscRootPlugins.length > 1) { - throw new Error( - "[vinext] Duplicate @vitejs/plugin-rsc detected.\n" + - " vinext auto-registers @vitejs/plugin-rsc when app/ is detected.\n" + - " Your config also registers it manually, which doubles build time.\n\n" + - " Fix: remove the explicit rsc() call from your plugins array.\n" + - " Or: pass rsc: false to vinext() if you want to configure rsc() yourself.", - ); - } - } - - // Fail the build when targeting Cloudflare Workers without the - // cloudflare() plugin. Without it, wrangler's esbuild can't resolve - // virtual:vinext-rsc-entry and produces a cryptic error. (#325) - if ( - config.command === "build" && - !hasCloudflarePlugin && - !hasNitroPlugin && - hasWranglerConfig(root) - ) { - throw new Error( - formatMissingCloudflarePluginError({ - isAppRouter: hasAppDir, - configFile: config.configFile, - }), - ); - } - }, - - resolveId: { - // Hook filter: only invoke JS for next/* imports and virtual:vinext-* modules. - // Matches "next/navigation", "next/router.js", "virtual:vinext-rsc-entry", - // and \0-prefixed re-imports from @vitejs/plugin-rsc. - filter: { - id: /(?:next\/|virtual:vinext-)/, - }, - handler(id) { - // Strip \0 prefix if present — @vitejs/plugin-rsc's generated - // browser entry imports our virtual module using the already-resolved - // ID (with \0 prefix). We need to re-resolve it so the client - // environment's import-analysis can find it. - const cleanId = id.startsWith("\0") ? id.slice(1) : id; - - // Handle next/* imports with .js extension (e.g. "next/navigation.js") - // Libraries like nuqs import "next/navigation.js" which doesn't match - // our resolve.alias for "next/navigation". Strip the .js and resolve - // through our shim map, appending .js to the resolved path. - if (cleanId.startsWith("next/") && cleanId.endsWith(".js")) { - const withoutExt = cleanId.slice(0, -3); - if (nextShimMap[withoutExt]) { - const shimPath = nextShimMap[withoutExt]; - // Alias values don't include .js — append it for resolveId - return shimPath.endsWith(".js") ? shimPath : shimPath + ".js"; - } - } - - // Pages Router virtual modules - if (cleanId === VIRTUAL_SERVER_ENTRY) return RESOLVED_SERVER_ENTRY; - if (cleanId === VIRTUAL_CLIENT_ENTRY) return RESOLVED_CLIENT_ENTRY; - if ( - cleanId.endsWith("/" + VIRTUAL_SERVER_ENTRY) || - cleanId.endsWith("\\" + VIRTUAL_SERVER_ENTRY) - ) { - return RESOLVED_SERVER_ENTRY; - } - if ( - cleanId.endsWith("/" + VIRTUAL_CLIENT_ENTRY) || - cleanId.endsWith("\\" + VIRTUAL_CLIENT_ENTRY) - ) { - return RESOLVED_CLIENT_ENTRY; - } - // 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.endsWith("/" + VIRTUAL_RSC_ENTRY) || - cleanId.endsWith("\\" + VIRTUAL_RSC_ENTRY) - ) { - return RESOLVED_RSC_ENTRY; - } - if ( - cleanId.endsWith("/" + VIRTUAL_APP_SSR_ENTRY) || - cleanId.endsWith("\\" + VIRTUAL_APP_SSR_ENTRY) - ) { - return RESOLVED_APP_SSR_ENTRY; - } - if ( - cleanId.endsWith("/" + VIRTUAL_APP_BROWSER_ENTRY) || - cleanId.endsWith("\\" + VIRTUAL_APP_BROWSER_ENTRY) - ) { - return RESOLVED_APP_BROWSER_ENTRY; - } - }, - }, - - async load(id) { - // Pages Router virtual modules - if (id === RESOLVED_SERVER_ENTRY) { - return await generateServerEntry(); - } - if (id === RESOLVED_CLIENT_ENTRY) { - return await generateClientEntry(); - } - // App Router virtual modules - if (id === RESOLVED_RSC_ENTRY && hasAppDir) { - 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, - ); - return generateRscEntry( - appDir, - routes, - middlewarePath, - metaRoutes, - globalErrorPath, - nextConfig?.basePath, - nextConfig?.trailingSlash, - { - redirects: nextConfig?.redirects, - rewrites: nextConfig?.rewrites, - headers: nextConfig?.headers, - allowedOrigins: nextConfig?.serverActionsAllowedOrigins, - allowedDevOrigins: nextConfig?.allowedDevOrigins, - bodySizeLimit: nextConfig?.serverActionsBodySizeLimit, - i18n: nextConfig?.i18n, - }, - instrumentationPath, - nextConfig?.assetPrefix, - ); - } - if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) { - return generateSsrEntry(); - } - if (id === RESOLVED_APP_BROWSER_ENTRY && hasAppDir) { - return generateBrowserEntry(); - } - }, - }, - // 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 - 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 — - // after vite:import-analysis. This top-level proxy with enforce:"pre" - // ensures MDX transforms run at the correct stage. Both vinext:config - // and this proxy are enforce:"pre", and vinext:config comes first in - // the array, so mdxDelegate is already set when this proxy's hooks fire. - { - name: "vinext:mdx", - enforce: "pre", - config(config, env) { - if (!mdxDelegate?.config) return; - const hook = mdxDelegate.config; - const fn = typeof hook === "function" ? hook : hook.handler; - return fn.call(this, config, env); - }, - transform(code, id, options) { - // Skip ?raw and other query imports — @mdx-js/rollup ignores the query - // and would compile the file as MDX instead of returning raw text. - if (id.includes("?")) return; - if (!mdxDelegate?.transform) return; - const hook = mdxDelegate.transform; - const fn = typeof hook === "function" ? hook : hook.handler; - return fn.call(this, code, id, options); - }, - }, - // Shim React canary/experimental APIs (ViewTransition, addTransitionType) - // that exist in Next.js's bundled React canary but not in stable React 19. - // Provides graceful no-op fallbacks so projects using these APIs degrade - // instead of crashing with "does not provide an export named 'ViewTransition'". - { - name: "vinext:react-canary", - enforce: "pre", - - resolveId(id) { - if (id === "virtual:vinext-react-canary") - return "\0virtual:vinext-react-canary"; - }, - - load(id) { - if (id === "\0virtual:vinext-react-canary") { - return [ - `export * from "react";`, - `export { default } from "react";`, - `import * as _React from "react";`, - `export const ViewTransition = _React.ViewTransition || function ViewTransition({ children }) { return children; };`, - `export const addTransitionType = _React.addTransitionType || function addTransitionType() {};`, - ].join("\n"); - } - }, - - transform(code, id) { - // Only transform user source files, not node_modules or virtual modules - if (id.includes("node_modules")) return null; - if (id.startsWith("\0")) return null; - if (!/\.(tsx?|jsx?|mjs)$/.test(id)) return null; - - // Quick check: does this file reference canary APIs and import from "react"? - if ( - !( - code.includes("ViewTransition") || - code.includes("addTransitionType") - ) || - !/from\s+['"]react['"]/.test(code) - ) { - return null; - } - - // Only rewrite if the import actually destructures a canary API - const canaryImportRegex = - /import\s*\{[^}]*(ViewTransition|addTransitionType)[^}]*\}\s*from\s*['"]react['"]/; - if (!canaryImportRegex.test(code)) return null; - - // 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"', - ); - if (result !== code) { - return { code: result, map: null }; - } - return null; - }, - }, - { - name: "vinext:pages-router", - - // HMR: trigger full-reload for Pages Router page changes. - // Even with @vitejs/plugin-react providing React Fast Refresh, - // the Pages Router injects hydration via inline