Skip to content

feat: support assetPrefix in next.config#474

Open
elydelva wants to merge 12 commits intocloudflare:mainfrom
elydelva:feat/asset-prefix
Open

feat: support assetPrefix in next.config#474
elydelva wants to merge 12 commits intocloudflare:mainfrom
elydelva:feat/asset-prefix

Conversation

@elydelva
Copy link

@elydelva elydelva commented Mar 11, 2026

Closes #472

Summary

assetPrefix from next.config is currently silently ignored in vinext. This PR implements full support conformant with the Next.js assetPrefix spec.

Motivation

When deploying to Cloudflare Workers behind a gateway Worker (routing pattern for auth, multi-tenant apps, etc.), every request — including static fonts, JS chunks, and CSS — goes through the gateway and incurs a Worker invocation. With assetPrefix, static assets can be served from a dedicated subdomain (e.g. assets-app.example.com) backed by a Cloudflare Workers Assets-only deployment with run_worker_first: false, resulting in zero Worker invocations for static assets.

Exploration

Before writing code, the Next.js reference implementation and the vinext codebase were fully analysed:

Next.js (vercel/next.js)assetPrefix flows through:

  • webpack output.publicPath → all JS/CSS chunks
  • next-font-loader outputPath → font files baked at build time
  • Manual SSR HTML injection (<script src>, <link href>) in app-render/
  • Client-side runtime detection via bootstrap script URL (client/asset-prefix.ts)
  • Does not cover public/ files or /_next/image (by design)
  • Key rule: when assetPrefix is empty and basePath is set → assetPrefix inherits basePath

vinextassetPrefix requires 8 changes across 5 files. Critical gap found: shims/font-local.ts line 329 gates <link rel="preload"> emission on href.startsWith("/"), which silently breaks when assetPrefix is an absolute CDN URL.

Changes

1. src/config/next-config.ts — config types + resolution

  • Add assetPrefix?: string to NextConfig interface
  • Add assetPrefix: string to ResolvedNextConfig interface
  • Default to "" in null-config fallback
  • Resolve from user config with trailing slash strip
  • Apply basePath inheritance rule: if assetPrefix is empty and basePath is set → assetPrefix = basePath

2. src/index.ts — Vite experimental.renderBuiltUrl

  • When assetPrefix is set, add Vite's experimental.renderBuiltUrl hook
  • Prefix type === "asset" (fonts, images) and type === "chunk" (JS) URLs with assetPrefix
  • Guard ssr: true so server-side bundles keep relative paths
  • Does not touch Vite's base (driven by basePath, separate concern)

3. src/shims/font-local.ts — font preload hints

  • collectFontPreloads() line ~329: extend URL guard from href.startsWith("/") to also accept https://, http://, and // prefixes
  • Without this fix, font <link rel="preload"> tags are silently dropped when assetPrefix is an absolute URL

4. src/entries/pages-server-entry.ts — Pages Router HTML injection

  • Embed assetPrefix in the serialised vinextConfigJson passed to the generated entry
  • In collectAssetTags generated code, replace hardcoded / href prefix with vinextConfig.assetPrefix + "/"

5. src/entries/app-rsc-entry.ts — App Router RSC serialisation

  • Embed assetPrefix in App Router RSC config alongside basePath

6. src/server/prod-server.ts — lazy chunks dedup

  • Verify lazy chunk set comparison uses relative paths (no CDN prefix leak into the dedup set)

7. src/index.ts (Cloudflare build plugin) — worker entry

  • Apply assetPrefix to __VINEXT_CLIENT_ENTRY__ global for Cloudflare Worker injection

8. Tests

  • Unit tests in tests/asset-prefix.test.ts
  • E2E test verifying CDN-prefixed asset URLs appear in rendered HTML

Coverage matrix (after this PR)

Asset type Covered
JS chunks ✅ via renderBuiltUrl
CSS chunks ✅ via renderBuiltUrl
Font files (next/font/local) ✅ via renderBuiltUrl + font-local.ts fix
<script> / <link> in SSR HTML ✅ via vinextConfig.assetPrefix in collectAssetTags
Font <link rel="preload"> ✅ via font-local.ts guard fix
public/ folder files ❌ not covered (matches Next.js spec)
/_next/image ❌ not covered (matches Next.js spec)

What assetPrefix does NOT cover (per spec)

Files in the public/ folder — if you want to serve those assets over a CDN, you'll have to introduce the prefix yourself.

This is intentional and matches the Next.js documentation.

@james-elicx james-elicx marked this pull request as draft March 11, 2026 17:41
@elydelva
Copy link
Author

Exploration: Next.js reference implementation

Before coding, I read through vercel/next.js to establish the exact spec.

Key files:

  • packages/next/src/server/config.ts:471-504 — config validation + basePath inheritance rule
  • packages/next/src/build/webpack-config.ts:1305-1314output.publicPath = assetPrefix + "/_next/" is the master injection point for webpack
  • packages/next/src/build/define-env.ts:312process.env.__NEXT_ASSET_PREFIX baked into client bundle
  • packages/next/src/build/webpack/loaders/next-font-loader/index.ts:83-96 — font files get assetPrefix baked into their output path at build time
  • packages/next/src/client/asset-prefix.ts — client-side runtime detection reads prefix from bootstrap script's own URL (not from process.env.__NEXT_ASSET_PREFIX)

Coverage confirmed by the spec:

Asset Mechanism
JS/CSS chunks webpack output.publicPath
Fonts (next/font) next-font-loader output path (build-time)
SSR HTML <script>/<link> Manual injection in app-render/
Dynamic client chunks Runtime detection from bootstrap script URL
public/ files Not covered — by design
/_next/image Not covered — by design

basePath interaction rule (config.ts:503):

if (result.assetPrefix === '') {
  result.assetPrefix = result.basePath
}

When basePath is /app and no assetPrefix is set, assets resolve to /app/_next/… automatically.

@elydelva
Copy link
Author

Exploration: vinext codebase analysis

Explored dist/ to map every location that needs changing.

Critical finding — silent bug in font-local.ts:

// shims/font-local.ts ~line 329
if (href && href.startsWith("/") && !ssrFontPreloadHrefs.has(href)) {

When renderBuiltUrl transforms font URLs to https://cdn.example.com/assets/font-xxx.woff2, this guard silently fails → no <link rel="preload"> emitted for fonts. Fonts still load (the @font-face src: URL is correct), but without preload hints, they block first paint. Fix: accept https://, http://, and // prefixes.

renderBuiltUrl coverage:

experimental.renderBuiltUrl covers everything that passes through Vite's asset pipeline (JS chunks, CSS, font imports via vinext:local-fonts). It does not cover:

  • vinextConfigJson serialised into the server entry (needs manual embed)
  • collectAssetTags generated code that builds <script>/<link> from SSR manifests (hardcoded / prefix)
  • __VINEXT_CLIENT_ENTRY__ global in the Cloudflare build plugin

ssr guard in renderBuiltUrl:

Must guard if (ssr) return { relative: true } — otherwise server-side bundles (workerd/Node) get CDN URLs baked in, causing them to try fetching assets from the CDN at runtime instead of locally.

basePath + assetPrefix with Vite:

Vite's base is set to basePath + "/". When both basePath and assetPrefix are set, filename inside renderBuiltUrl already includes the basePath prefix (because Vite resolves it relative to base). So the correct CDN URL is simply assetPrefix + "/" + filename — no double-prefixing.

@elydelva elydelva marked this pull request as ready for review March 11, 2026 18:09
@elydelva
Copy link
Author

Implementation notes

All 8 planned changes are in. Here's a summary of the test results on my end, so reviewers know what to expect from CI.

Passing:

  • tests/asset-prefix.test.ts — 7/7 new tests (trailing slash stripping, basePath inheritance, explicit override, null config default)
  • tests/entry-templates.test.ts — 11/11, snapshots updated to reflect the new assetPrefix field in serialised config and the updated href generation in collectAssetTags
  • pnpm run typecheck — clean

Pre-existing failures (not introduced by this PR):

  • tests/next-config.test.ts > detectNextIntlConfig — 7 failures present on main before this branch; confirmed by running the test suite on the base commit

Local environment caveat:

  • pnpm run fmt:check and pnpm run lint fail with MODULE_NOT_FOUND on my machine (Node.js v25.5.0 — the native binaries for oxfmt and oxlint don't load). This is an environment issue, not a code issue. Happy to reformat if CI catches anything.

… tests

- 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
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 11, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@474

commit: e220443

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:
- <script type="module" src="..."> tags begin with the CDN origin
- <link rel="modulepreload" href="..."> tags begin with the CDN origin
- <link rel="stylesheet" href="..."> tags (if present) begin with the CDN origin
- <a href="..."> page navigation links do NOT carry the CDN prefix
@elydelva elydelva marked this pull request as draft March 11, 2026 18:37
…x inherits basePath

When assetPrefix is a same-origin path (e.g. "/docs"), Vite already
embeds basePath into every emitted asset filename, so prepending
assetPrefix again produced /docs/docs/assets/chunk.js.

Only use assetPrefix as a href prefix when it is an absolute CDN URL
(https://, http://, or protocol-relative //). Otherwise fall back to
an empty prefix so the existing "/" + tf produces the correct path.
@elydelva elydelva marked this pull request as ready for review March 11, 2026 18:47
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.
@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Test review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

test 2

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

test 3

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

test 4

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: assetPrefix support

The approach is well-researched and the PR description is thorough. The core implementation is correct: renderBuiltUrl for client-side builds, collectAssetTags for SSR HTML injection, and the font-local.ts preload fix.

I found one functional issue (unused variable in app-rsc-entry), one correctness concern (redundant renderBuiltUrl activation for basePath-only case), and several style/hygiene items.

Key findings

1. __assetPrefix is declared but never used in app-rsc-entry.ts — it gets serialized into the generated entry code but nothing reads it. This is dead code that should either be wired up (App Router asset tag injection) or removed until it's needed.

2. renderBuiltUrl fires unnecessarily when assetPrefix === basePath — When only basePath is set and no explicit assetPrefix, the inheritance rule sets assetPrefix = basePath. This activates renderBuiltUrl, which then does the exact same thing Vite's built-in base handling already does. It's functionally harmless today, but it's a subtle maintenance trap.

3. Import reordering in index.ts — The ~56-line import rearrangement is pure cosmetic churn that bloats the diff significantly and increases merge conflict risk with other in-flight branches. Consider reverting the import sort or splitting it into a separate commit.

4. Potential dedup mismatch with basePath + CDN assetPrefix — When both are set, clientEntryFile uses normalizeManifestFile (line 3163, strips leading / only → assets/entry.js) while lazyChunksData (line 3170) still uses manifestFilesWithBase (→ app/assets/lazy.js). In collectAssetTags, the seen set would contain assets/entry.js but SSR manifest values resolve to app/assets/entry.js, so seen.has(tf) dedup fails, potentially emitting the entry <script> twice. Worth adding a test case for basePath + CDN assetPrefix together.

5. experimental config clobbering — The { experimental: { renderBuiltUrl } } spread into the config object would silently overwrite any user-defined experimental options in their vite.config.ts. See inline comment.

Summary

The feature implementation is solid. The collectAssetTags CDN-only guard is the right design — it correctly handles the SSR manifest values that already contain the base prefix. The font preload fix is a genuine bug fix. Tests cover the important cases. The issues above are mostly about tightening up the edges.

(Please disregard the earlier "test" reviews — those were testing which diff lines the API accepts.)

trailingSlash?: boolean,
config?: AppRouterConfig,
instrumentationPath?: string | null,
assetPrefix?: string,
Copy link
Contributor

Choose a reason for hiding this comment

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

assetPrefix is passed in and serialized to __assetPrefix (line 1283), but __assetPrefix is never referenced anywhere in the generated entry code. This is dead code.

If App Router doesn't need it yet (because the RSC plugin handles asset URLs differently), consider removing this parameter until it's actually wired up. Alternatively, if App Router asset tags should use this (analogous to the Pages Router collectAssetTags change), that's a gap worth noting.

...(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
Copy link
Contributor

Choose a reason for hiding this comment

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

When only basePath is set (no explicit assetPrefix), resolveNextConfig sets assetPrefix = basePath. This makes the condition truthy, so renderBuiltUrl activates — but it then produces exactly the same URLs that Vite's built-in base handling would have produced (basePath + "/" + filename), since filename from Vite does NOT include the base prefix.

This is functionally correct today, but it's a subtle redundancy that could become a bug if either path changes independently. Consider guarding this more precisely:

Suggested change
...(nextConfig.assetPrefix
...(nextConfig.assetPrefix && nextConfig.assetPrefix !== nextConfig.basePath

This makes the intent explicit: only activate renderBuiltUrl when assetPrefix points somewhere different from basePath (i.e., an external CDN).

// SSR environments stay relative so server-side imports resolve correctly.
...(nextConfig.assetPrefix
? {
experimental: {
Copy link
Contributor

Choose a reason for hiding this comment

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

This spreads { experimental: { renderBuiltUrl } } into the returned config object. If the user's vite.config.ts also sets experimental options, the shallow spread will silently overwrite them. Consider merging with the existing config:

experimental: {
  ...config.experimental,
  renderBuiltUrl(...) { ... },
},

Or at minimum, document that assetPrefix takes precedence over user-defined renderBuiltUrl.

// 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.

@@ -0,0 +1,92 @@
/**
Copy link
Contributor

Choose a reason for hiding this comment

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

Good test coverage for config resolution. One missing edge case worth adding: assetPrefix set to just "/" (a single slash). After trailing-slash stripping this becomes "", which would trigger basePath inheritance. Is that the intended behavior? Next.js treats assetPrefix: "/" as "no prefix", which seems correct, but it's worth an explicit test to document this.

import { describe, expect, it } from "vitest";
import localFont, { getSSRFontPreloads } from "../packages/vinext/src/shims/font-local.js";

describe("collectFontPreloads — CDN URL acceptance", () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

These tests call localFont() without any cleanup between test cases, and getSSRFontPreloads() returns the cumulative global list. Each test asserts toContainEqual which works because unique font names are used, but the tests are order-dependent and would break if run in isolation with it.only. Consider adding a beforeEach that clears the preload set, or noting this limitation.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 12, 2026

The review has been submitted successfully. Here's a summary of what was posted:

Review posted on PR #474

The review covers 6 inline comments and a comprehensive summary identifying 5 key findings:

  1. Dead code in app-rsc-entry.ts__assetPrefix is declared but never referenced in the generated App Router entry code
  2. Redundant renderBuiltUrl for basePath-only case — suggested adding && nextConfig.assetPrefix !== nextConfig.basePath guard to avoid activating the hook when it duplicates Vite's built-in base behavior
  3. Import reordering noise — recommended separating the cosmetic import sort from the feature changes
  4. Potential dedup mismatchclientEntryFile and lazyChunksData use different normalization when basePath + CDN assetPrefix are combined, which could cause duplicate <script> tags
  5. experimental config clobbering — shallow spread would overwrite user-defined experimental Vite options
  6. Test coverage suggestions — edge case for assetPrefix: "/" and font preload test isolation

github run

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 12, 2026

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx
Copy link
Collaborator

The bot is going off the rails!!

Preserve user experimental renderBuiltUrl config without clobbering, remove unused App Router assetPrefix plumbing, and add regression coverage for assetPrefix '/' plus basePath+CDN dedup behavior.
@elydelva
Copy link
Author

Follow-up updates are now in:

  • Preserved user experimental options while composing renderBuiltUrl (no config clobbering).
  • Replaced the temporary any cast with a typed cast based on callback parameters.
  • Removed unused App Router assetPrefix plumbing (generateRscEntry param + dead __assetPrefix).
  • Added explicit coverage for assetPrefix: "/" fallback behavior.
  • Added a production regression test for basePath + absolute assetPrefix to assert no duplicate module tags and correct CDN-prefixed asset URLs.

Validation run locally:

  • pnpm run fmt:check
  • pnpm run typecheck
  • pnpm test tests/asset-prefix.test.ts tests/pages-router.test.ts
  • pnpm test tests/app-router.test.ts

@james-elicx
Copy link
Collaborator

/bigbonk re-review

Resolve conflict in packages/vinext/src/index.ts by keeping upstream changes and reapplying assetPrefix renderBuiltUrl composition, typed callback forwarding, and basePath guard behavior.
Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: assetPrefix support

The feature implementation is well-researched and the core approach is correct: renderBuiltUrl for client builds, collectAssetTags for SSR HTML injection, and the font-local.ts preload fix. The PR description is thorough and the exploration comments are excellent.

However, there are two blocking issues and several significant concerns.

Blocking

  1. Stale snapshots cause 6 test failures. The entry-templates.test.ts snapshots contain const __assetPrefix = "" in the RSC entry output, but app-rsc-entry.ts no longer generates this variable. Running pnpm test tests/entry-templates.test.ts produces 6 snapshot mismatches. The snapshots need to be regenerated with pnpm test tests/entry-templates.test.ts -- -u.

  2. Massive formatting churn in index.ts. The diff shows +3685/-3334 for index.ts, but the actual feature change is ~30 lines (the renderBuiltUrl block). The rest is a full-file indent change from 2-space to tabs plus import reordering. This creates unnecessary merge conflicts with other in-flight work and makes the PR nearly impossible to review in GitHub's diff view. The formatting changes should be reverted or split into a separate commit/PR.

Non-blocking concerns

  1. Unrelated code removalUserResolveConfigWithTsconfigPaths, viteMajorVersion, shouldEnableNativeTsconfigPaths, and the Vite 8 tsconfigPaths conditional are all removed. These are functional changes unrelated to assetPrefix and should be in a separate PR.

  2. Font preload tests lack isolationfont-local.ts uses module-level mutable state (ssrFontPreloads, ssrFontPreloadHrefs, classCounter) that accumulates across tests with no reset mechanism. The tests work around this with unique filenames and toContainEqual, but they're order-dependent and would break with it.only. Consider using vi.resetModules() in a beforeEach to get a fresh module instance.

  3. E2E test is HTTP-only, not browser-based — The Playwright tests use request.get() to check HTML output, which is fine for verifying URL generation, but doesn't verify that CDN-prefixed scripts actually hydrate correctly. Given that https://cdn.example.com is unreachable, this is understandable, but worth noting the coverage gap.



const __basePath = "";
const __assetPrefix = "";
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug (blocking): This const __assetPrefix = "" line was generated by an earlier version of the code. The PR author removed __assetPrefix from app-rsc-entry.ts (correctly — it was dead code), but the snapshots weren't regenerated afterward.

Running pnpm test tests/entry-templates.test.ts produces 6 snapshot failures — all 6 RSC entry snapshots contain this line but the generated code no longer emits it.

Fix: run pnpm test tests/entry-templates.test.ts -- -u to update the snapshots.

// 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 &&
Copy link
Contributor

Choose a reason for hiding this comment

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

The renderBuiltUrl implementation is correct. The guard nextConfig.assetPrefix !== nextConfig.basePath correctly avoids redundant activation when assetPrefix was inherited from basePath. The config.experimental spread preserves user config, and the fallback to userRenderBuiltUrl for non-asset/chunk types is a nice touch.

One edge case: for ctx.type === "asset" || ctx.type === "chunk", the function always returns the CDN-prefixed URL without consulting userRenderBuiltUrl. This means a user can't selectively override CDN prefixing for specific assets. This is probably fine for v1 — if someone sets assetPrefix, they want all assets CDN-prefixed — but worth documenting if the option grows more complex.

// 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.

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.

// 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.

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.

import { describe, expect, it } from "vitest";
import localFont, { getSSRFontPreloads } from "../packages/vinext/src/shims/font-local.js";

describe("collectFontPreloads — CDN URL acceptance", () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

These tests have a global state accumulation issue. localFont() pushes to ssrFontPreloads and ssrFontPreloadHrefs (module-level arrays/sets in font-local.ts) that are never cleared between tests. The unique filenames and toContainEqual assertions work around this, but:

  • Tests are order-dependent
  • it.only on any single test would still pass, but getSSRFontPreloads() returns the cumulative list
  • If another test file imports font-local.ts first, the state leaks across files

Consider wrapping each test with vi.resetModules() + fresh dynamic import, or exporting a clearSSRFontPreloads() for testing.


expect(new Set(moduleScriptSrcs).size).toBe(moduleScriptSrcs.length);
expect(new Set(modulePreloadHrefs).size).toBe(modulePreloadHrefs.length);
expect(moduleScriptSrcs.some((src) => src.includes("/docs/assets/"))).toBe(true);
Copy link
Contributor

Choose a reason for hiding this comment

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

Good regression test. This assertion moduleScriptSrcs.some((src) => src.includes("/docs/assets/")) verifies that the basePath path segment appears inside the CDN URL (e.g., https://cdn.example.com/docs/assets/entry-xxx.js), proving both basePath and assetPrefix are applied without double-prefixing.

The dedup assertions on lines 1561-1562 (Set.size === array.length) directly address the concern about duplicate <script> tags — good coverage.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 12, 2026

The review has been submitted successfully. Here's a summary of what was posted:

Review posted on PR #474

Verdict: Changes Requested — 2 blocking issues found, 3 non-blocking concerns.

Blocking issues:

  1. Stale snapshots (6 test failures) — The entry-templates.test.ts snapshots still contain const __assetPrefix = "" in RSC entry output, but app-rsc-entry.ts no longer generates this variable. I confirmed this by running the test suite — all 6 RSC entry snapshot tests fail. Needs pnpm test tests/entry-templates.test.ts -- -u.

  2. Massive formatting churn in index.ts — The diff shows +3685/-3334 but the actual feature change is ~30 lines (the renderBuiltUrl block). The rest is 2-space-to-tab indent reformatting and import reordering, which makes review nearly impossible and creates merge conflict risk.

Non-blocking concerns:

  1. Unrelated code removal — Vite 8 tsconfigPaths logic and UserResolveConfigWithTsconfigPaths type removed without relation to assetPrefix
  2. Font preload tests lack isolation — module-level mutable state accumulates across tests
  3. E2E tests are HTTP-only — verify HTML output but not browser hydration

7 inline comments were posted on specific lines covering the renderBuiltUrl implementation, collectAssetTags CDN guard, font preload fix, config resolution, snapshot staleness, and test isolation.

github run

- Remove __assetPrefix from RSC entry snapshots (variable no longer generated)
- Regenerate pnpm-lock.yaml to fix lightningcss 1.31.1 vs 1.32.0 mismatch
@elydelva
Copy link
Author

Thanks for the thorough review. I've addressed the two blocking issues:

Blocking (fixed):

  • Snapshots — Regenerated with pnpm test tests/entry-templates.test.ts -u. The 6 RSC entry snapshots no longer contain __assetPrefix.
  • index.ts churn — The merge resolution kept upstream formatting (2-space indent). The diff vs main is now limited to the ~30-line renderBuiltUrl block. Any local tab/import changes were discarded.

Non-blocking (acknowledged):

  • Unrelated code removalUserResolveConfigWithTsconfigPaths, shouldEnableNativeTsconfigPaths, and the tsconfigPaths logic are still present in the codebase (verified). No removal occurred.
  • Font preload test isolation — Noted. I'll consider vi.resetModules() or a clearSSRFontPreloads() helper in a follow-up.
  • E2E HTTP-only — Agreed, the Playwright tests only verify HTML output. Full hydration against a real CDN would require a different setup.

Lockfile — Regenerated to fix the lightningcss 1.31.1 vs 1.32.0 mismatch that was breaking CI.

Ready for re-review.

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: assetPrefix support

Well-researched PR with thorough exploration of the Next.js reference implementation. The core design is sound: renderBuiltUrl for Vite-produced client assets, _assetHrefPrefix in collectAssetTags for SSR-injected HTML, and the font-local.ts preload guard fix. The previous review's blocking issues (stale snapshots, formatting churn, dead __assetPrefix in app-rsc-entry) have been addressed.

I have one correctness concern, one coverage gap observation, and a few minor items.

1. _assetHrefPrefix guard doesn't cover protocol-relative assetPrefix set via config

The _assetHrefPrefix logic in collectAssetTags only activates CDN prefixing for URLs starting with https://, http://, or //. But assetPrefix can legitimately be set to //cdn.example.com (protocol-relative) in next.config. This case is correctly handled — good.

However, there's no test covering assetPrefix: "//cdn.example.com" end-to-end. The asset-prefix.test.ts config tests only check https:// variants. Consider adding a protocol-relative config test to asset-prefix.test.ts to ensure the _assetHrefPrefix guard and renderBuiltUrl both handle it.

2. App Router asset prefix coverage gap

The PR correctly removed the dead __assetPrefix from app-rsc-entry.ts, but this means App Router SSR HTML has no assetPrefix injection path for collectAssetTags-equivalent logic. The App Router relies on the RSC plugin's loadBootstrapScriptContent() for the client entry, and CSS <link> tags are injected by the RSC rendering pipeline, not by hand-written collectAssetTags.

This means:

  • JS chunks: Covered by renderBuiltUrl (Vite rewrites the URLs at build time)
  • CSS: Covered by renderBuiltUrl
  • Bootstrap script in HTML: Depends on how @vitejs/plugin-rsc generates the bootstrap <script> tag — if it uses the built URL from Vite's pipeline, renderBuiltUrl covers it. If it constructs it manually, there's a gap.

This isn't necessarily a bug in this PR (the PR description says App Router coverage comes from renderBuiltUrl), but it's worth verifying with an App Router fixture test in a follow-up. The coverage matrix in the PR description should note this distinction.

3. Font preload test isolation (non-blocking, acknowledged)

The author acknowledged this in the thread. The font-local.ts module uses global mutable state (ssrFontPreloads, ssrFontPreloadHrefs) that accumulates across tests. The current workaround (unique filenames + toContainEqual) is fragile.

Summary

The implementation is correct for the Pages Router path and the Vite build pipeline. The basePath inheritance rule, the renderBuiltUrl guard for assetPrefix !== basePath, the CDN-only check in collectAssetTags, and the font preload fix are all well-reasoned. The regression test for basePath + absolute assetPrefix dedup (lines 1506-1598 in pages-router.test.ts) directly addresses the most dangerous edge case.

Good to merge once CI passes, with the protocol-relative test gap as a minor follow-up item.

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.

// 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.

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.

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 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.

expect(resolved.assetPrefix).toBe("https://cdn.example.com/subdir");
});

it('treats assetPrefix "/" as empty and falls back to basePath inheritance', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Good edge case test — this was called out in the first review and added. The behavior ("/" strips to "", then inherits basePath) matches Next.js.

});
});

it("does not collect bare relative font paths (no leading slash or scheme)", () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

This negative test is important — it verifies that the expanded guard doesn't accidentally accept ALL strings. One additional case worth testing: "data:font/woff2;base64,..." — data URIs shouldn't be collected as preload hints since they're already inline.

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.


// 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.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 12, 2026

Review posted successfully. Here's a summary of what was submitted:

Review posted on PR #474

Verdict: COMMENT (no blocking issues remaining)

The review covers 7 inline comments and identifies 3 key findings:

  1. Correctness concern — same-origin non-basePath assetPrefix: When assetPrefix is a same-origin path different from basePath (e.g., /cdn-path), renderBuiltUrl will prefix it but collectAssetTags won't (because the CDN-only guard checks for https://, http://, //). This is an unlikely config but creates an inconsistency worth documenting.

  2. App Router coverage gap: The PR covers Pages Router comprehensively but App Router assetPrefix support depends entirely on renderBuiltUrl and the RSC plugin's bootstrap mechanism. If the RSC plugin constructs bootstrap script URLs manually (not through Vite's pipeline), there could be a gap. Worth verifying with an App Router fixture in a follow-up.

  3. Missing protocol-relative test: No end-to-end test for assetPrefix: "//cdn.example.com" — the code handles it correctly but it's not covered by tests.

The previous review's blocking issues (stale snapshots, formatting churn, dead __assetPrefix) have all been addressed. The implementation is solid and good to merge once CI passes.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: support assetPrefix in next.config

2 participants