Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -127,6 +128,8 @@ export interface NextConfig {
env?: Record<string, string>;
/** 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 */
Expand Down Expand Up @@ -204,6 +207,8 @@ export interface NextConfig {
export interface ResolvedNextConfig {
env: Record<string, string>;
basePath: string;
/** Resolved CDN URL prefix for static assets. Empty string if not set. */
assetPrefix: string;
trailingSlash: boolean;
output: "" | "export" | "standalone";
pageExtensions: string[];
Expand Down Expand Up @@ -393,6 +398,7 @@ export async function resolveNextConfig(
const resolved: ResolvedNextConfig = {
env: {},
basePath: "",
assetPrefix: "",
trailingSlash: false,
output: "",
pageExtensions: normalizePageExtensions(),
Expand Down Expand Up @@ -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(/\/$/, "") : "",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex replace(/\/$/, "") strips a single trailing slash, which matches Next.js behavior. Worth noting that assetPrefix: "https://cdn.example.com//" (double slash) would only strip one, leaving a trailing /. Next.js has the same behavior, so this is consistent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The replace(/\/$/, "") only strips a single trailing slash, which is correct and matches Next.js. Just noting: typeof config.assetPrefix === "string" correctly handles undefined, null, and other non-string values, falling back to "". Good defensive check.

trailingSlash: config.trailingSlash ?? false,
output: output === "export" || output === "standalone" ? output : "",
pageExtensions,
Expand All @@ -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);
Expand Down
18 changes: 11 additions & 7 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 17 additions & 5 deletions packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
Expand Down Expand Up @@ -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 &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: var in new code. The rest of collectAssetTags uses var because it's generated ES5-ish code, so this is consistent with the surrounding style — just noting it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CDN-only guard here is the key design decision and it's correct. When assetPrefix === basePath (same-origin path), Vite's base already embeds the prefix into emitted paths, so prepending it again in collectAssetTags would double-prefix.

The var is consistent with the surrounding generated code style (ES5-ish). No issue here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CDN-only guard is the right design decision. One observation: if someone sets assetPrefix: "/cdn-path" (a same-origin absolute path that is NOT the same as basePath), this guard will treat it as non-CDN and skip prefixing, even though renderBuiltUrl WILL prefix it (since assetPrefix !== basePath). This creates an inconsistency where Vite-emitted asset URLs in JS/CSS use the prefix but SSR-injected <script>/<link> tags don't.

In practice this is unlikely — assetPrefix as a same-origin path different from basePath is an unusual config — but it's a spec divergence from Next.js, which applies assetPrefix unconditionally. Worth documenting as a known limitation or handling it.

(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 <link rel="modulepreload"> or <script type="module">
// tags — they are fetched on demand when the dynamic import() executes (e.g.
Expand All @@ -432,8 +444,8 @@ function collectAssetTags(manifest, moduleIds) {
if (typeof globalThis !== "undefined" && globalThis.__VINEXT_CLIENT_ENTRY__) {
const entry = globalThis.__VINEXT_CLIENT_ENTRY__;
seen.add(entry);
tags.push('<link rel="modulepreload" href="/' + entry + '" />');
tags.push('<script type="module" src="/' + entry + '" crossorigin></script>');
tags.push('<link rel="modulepreload" href="' + _assetHrefPrefix + '/' + entry + '" />');
tags.push('<script type="module" src="' + _assetHrefPrefix + '/' + entry + '" crossorigin></script>');
}
if (m) {
// Always inject shared chunks (framework, vinext runtime, entry) and
Expand Down Expand Up @@ -508,13 +520,13 @@ function collectAssetTags(manifest, moduleIds) {
if (seen.has(tf)) continue;
seen.add(tf);
if (tf.endsWith(".css")) {
tags.push('<link rel="stylesheet" href="/' + tf + '" />');
tags.push('<link rel="stylesheet" href="' + _assetHrefPrefix + '/' + tf + '" />');
} 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('<link rel="modulepreload" href="/' + tf + '" />');
tags.push('<script type="module" src="/' + tf + '" crossorigin></script>');
tags.push('<link rel="modulepreload" href="' + _assetHrefPrefix + '/' + tf + '" />');
tags.push('<script type="module" src="' + _assetHrefPrefix + '/' + tf + '" crossorigin></script>');
}
}
}
Expand Down
24 changes: 24 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subtle detail worth calling out: filename here does NOT include the base prefix when renderBuiltUrl is called by Vite. So when basePath: "/docs" and assetPrefix: "https://cdn.example.com", this produces https://cdn.example.com/assets/chunk-xxx.js — no /docs/ segment.

But in collectAssetTags, the SSR manifest values DO include the base prefix (docs/assets/chunk-xxx.js), so the CDN URL becomes https://cdn.example.com/docs/assets/chunk-xxx.js.

This means JS chunks loaded via <script> tags (from collectAssetTags) will have /docs/ in the path, but JS chunks loaded via native import() (from renderBuiltUrl) will NOT. Both resolve correctly because they point to different file references, but it's a subtlety worth a comment for future maintainers.

}
const userRenderBuiltUrl = config.experimental?.renderBuiltUrl;
if (typeof userRenderBuiltUrl === "function") {
return userRenderBuiltUrl(
filename,
ctx as Parameters<typeof userRenderBuiltUrl>[1],
);
}
return { relative: true };
},
},
}
: {}),
// Inject resolved PostCSS plugins if string names were found
...(postcssOverride ? { css: { postcss: postcssOverride } } : {}),
};
Expand Down
4 changes: 4 additions & 0 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good clarifying comment. This was a source of confusion in the first review (potential dedup mismatch between assetBase and CDN assetPrefix). The comment makes it clear that assetBase is for manifest key lookups, not HTML href construction.

// (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 ?? [];
Expand Down
12 changes: 10 additions & 2 deletions packages/vinext/src/shims/font-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix. This was a genuine silent bug — CDN-prefixed font URLs were being dropped because the guard only accepted href.startsWith("/"). The extended check for https://, http://, and // correctly handles all assetPrefix forms.

Minor: getFontMimeType() (line 306) determines MIME type from the URL suffix. For CDN URLs like https://cdn.example.com/assets/font-abc123.woff2, the .woff2 extension is still at the end, so this works correctly. Just noting it for completeness.

href &&
(href.startsWith("/") ||
href.startsWith("https://") ||
href.startsWith("http://") ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using a small helper or URL.canParse() check instead of manual prefix matching. The current approach is correct for the known cases, but a data: URI or a blob: URL would pass through if the guard were further relaxed in the future.

Not blocking — the current implementation is correct for the assetPrefix use case.

href.startsWith("//")) &&
!ssrFontPreloadHrefs.has(href)
) {
ssrFontPreloadHrefs.add(href);
ssrFontPreloads.push({ href, type: getFontMimeType(href) });
}
Expand Down
11 changes: 11 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 17 additions & 6 deletions tests/__snapshots__/entry-templates.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <link rel="modulepreload"> or <script type="module">
// tags — they are fetched on demand when the dynamic import() executes (e.g.
Expand All @@ -18177,8 +18188,8 @@ function collectAssetTags(manifest, moduleIds) {
if (typeof globalThis !== "undefined" && globalThis.__VINEXT_CLIENT_ENTRY__) {
const entry = globalThis.__VINEXT_CLIENT_ENTRY__;
seen.add(entry);
tags.push('<link rel="modulepreload" href="/' + entry + '" />');
tags.push('<script type="module" src="/' + entry + '" crossorigin></script>');
tags.push('<link rel="modulepreload" href="' + _assetHrefPrefix + '/' + entry + '" />');
tags.push('<script type="module" src="' + _assetHrefPrefix + '/' + entry + '" crossorigin></script>');
}
if (m) {
// Always inject shared chunks (framework, vinext runtime, entry) and
Expand Down Expand Up @@ -18253,13 +18264,13 @@ function collectAssetTags(manifest, moduleIds) {
if (seen.has(tf)) continue;
seen.add(tf);
if (tf.endsWith(".css")) {
tags.push('<link rel="stylesheet" href="/' + tf + '" />');
tags.push('<link rel="stylesheet" href="' + _assetHrefPrefix + '/' + tf + '" />');
} 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('<link rel="modulepreload" href="/' + tf + '" />');
tags.push('<script type="module" src="/' + tf + '" crossorigin></script>');
tags.push('<link rel="modulepreload" href="' + _assetHrefPrefix + '/' + tf + '" />');
tags.push('<script type="module" src="' + _assetHrefPrefix + '/' + tf + '" crossorigin></script>');
}
}
}
Expand Down
Loading
Loading