diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index e6e969f11..ef80c1cfd 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -4,12 +4,13 @@ * Loads the Next.js config file (if present) and extracts supported options. * Unsupported options are logged as warnings. */ -import path from "node:path"; -import { createRequire } from "node:module"; -import fs from "node:fs"; + import { randomUUID } from "node:crypto"; -import { PHASE_DEVELOPMENT_SERVER } from "../shims/constants.js"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; import { normalizePageExtensions } from "../routing/file-matcher.js"; +import { PHASE_DEVELOPMENT_SERVER } from "../shims/constants.js"; import { isExternalUrl } from "./config-matchers.js"; /** @@ -127,6 +128,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 +207,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 +398,7 @@ export async function resolveNextConfig( const resolved: ResolvedNextConfig = { env: {}, basePath: "", + assetPrefix: "", trailingSlash: false, output: "", pageExtensions: normalizePageExtensions(), @@ -526,6 +532,8 @@ 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 +551,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 78b0b0fd6..2f65d979d 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -9,22 +9,22 @@ */ import fs from "node:fs"; import { fileURLToPath } from "node:url"; -import type { AppRoute } from "../routing/app-router.js"; -import type { MetadataFileRoute } from "../server/metadata-routes.js"; import type { - NextRedirect, - NextRewrite, NextHeader, NextI18nConfig, + NextRedirect, + NextRewrite, } from "../config/next-config.js"; +import type { AppRoute } from "../routing/app-router.js"; import { generateDevOriginCheckCode } from "../server/dev-origin-check.js"; +import type { MetadataFileRoute } from "../server/metadata-routes.js"; +import { isProxyFile } from "../server/middleware.js"; import { - generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode, generateRouteMatchNormalizationCode, + generateSafeRegExpCode, } from "../server/middleware-codegen.js"; -import { isProxyFile } from "../server/middleware.js"; // Pre-computed absolute paths for generated-code imports. The virtual RSC // entry can't use relative imports (it has no real file location), so we @@ -86,7 +86,11 @@ export function generateRscEntry( const bp = basePath ?? ""; const ts = trailingSlash ?? false; const redirects = config?.redirects ?? []; - const rewrites = config?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] }; + const rewrites = config?.rewrites ?? { + beforeFiles: [], + afterFiles: [], + fallback: [], + }; const headers = config?.headers ?? []; const allowedOrigins = config?.allowedOrigins ?? []; const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024; diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 49cd874d8..9dad390fe 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -102,6 +102,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: [] }, @@ -421,6 +422,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 @@ -508,13 +520,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 81e0b1453..49aa02e48 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1269,6 +1269,30 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { define: defines, // Set base path if configured ...(nextConfig.basePath ? { base: nextConfig.basePath + "/" } : {}), + // When assetPrefix is set (and differs from basePath inheritance), + // rewrite built asset/chunk URLs to the configured prefix. + // SSR environments stay relative so server-side imports resolve correctly. + ...(nextConfig.assetPrefix && nextConfig.assetPrefix !== nextConfig.basePath + ? { + experimental: { + ...config.experimental, + renderBuiltUrl(filename: string, ctx: { type: string; ssr: boolean }) { + if (ctx.ssr) return { relative: true }; + if (ctx.type === "asset" || ctx.type === "chunk") { + return nextConfig.assetPrefix + "/" + filename; + } + const userRenderBuiltUrl = config.experimental?.renderBuiltUrl; + if (typeof userRenderBuiltUrl === "function") { + return userRenderBuiltUrl( + filename, + ctx as Parameters[1], + ); + } + return { relative: true }; + }, + }, + } + : {}), // Inject resolved PostCSS plugins if string names were found ...(postcssOverride ? { css: { postcss: postcssOverride } } : {}), }; diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 951ec231b..01b6a103f 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/playwright.config.ts b/playwright.config.ts index 47b0b354b..1e26ed3c8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -107,6 +107,17 @@ const projectServers = { timeout: 30_000, }, }, + "asset-prefix-prod": { + testDir: "./tests/e2e/asset-prefix", + server: { + command: + "npx tsc -p ../../../packages/vinext/tsconfig.json && node ../../../packages/vinext/dist/cli.js build && node ../../../packages/vinext/dist/cli.js start --port 4180", + cwd: "./tests/fixtures/asset-prefix", + port: 4180, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + }, }; type ProjectName = keyof typeof projectServers; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e7243464..b462fd2ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -542,6 +542,22 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0) + 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.32.0) + tests/fixtures/ecosystem/better-auth: dependencies: '@vitejs/plugin-rsc': diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index e78ea28f3..191f5da04 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -17957,7 +17957,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) { @@ -18166,6 +18166,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 @@ -18253,13 +18264,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/asset-prefix.test.ts b/tests/asset-prefix.test.ts new file mode 100644 index 000000000..db2c8126e --- /dev/null +++ b/tests/asset-prefix.test.ts @@ -0,0 +1,102 @@ +/** + * 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, expect, it } from "vitest"; +import { type NextConfig, resolveNextConfig } 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"); + }); + + it('treats assetPrefix "/" as empty and falls back to basePath inheritance', async () => { + const config: NextConfig = { + basePath: "/app", + assetPrefix: "/", + }; + const resolved = await resolveNextConfig(config); + expect(resolved.basePath).toBe("/app"); + expect(resolved.assetPrefix).toBe("/app"); + }); +}); + +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/e2e/asset-prefix/asset-prefix.spec.ts b/tests/e2e/asset-prefix/asset-prefix.spec.ts new file mode 100644 index 000000000..61e16097d --- /dev/null +++ b/tests/e2e/asset-prefix/asset-prefix.spec.ts @@ -0,0 +1,128 @@ +/** + * E2E tests for assetPrefix — CDN-prefixed asset URLs in rendered HTML. + * + * Ported from Next.js: test/e2e/asset-prefix/asset-prefix.test.ts + * https://github.com/vercel/next.js/blob/canary/test/e2e/asset-prefix/asset-prefix.test.ts + * + * Verifies that when `assetPrefix: "https://cdn.example.com"` is set in + * next.config, the server-rendered HTML injects