Files
cloudflare__vinext/tests/asset-prefix.test.ts
T
James Anderson d504f022b0 fix(assets): default assetsDir to _next/static (Next.js parity) (#1411)
* fix(assets): default assetsDir to _next/static (Next.js parity)

Closes #1337. Supersedes #1383.

vinext's default `assetsDir` was Vite's historical `assets/`, so URL emission
(`/_next/static/...` via `resolveAssetUrlPrefix("")`) and on-disk layout
(`dist/client/assets/...`) disagreed in the empty-`assetPrefix` case. The
`build.assetsDir` and `experimental.renderBuiltUrl` Vite overrides only
applied when `assetPrefix` was configured, leaving the no-prefix branch
serving from a different path than the URL contract Next.js's client
runtime and test harness assert against.

This commit flips the default to Next.js's canonical layout:

- `resolveAssetsDir("")` returns `_next/static` (was `assets`)
- `build.assetsDir` is now set unconditionally from `resolveAssetsDir`
- Vite's default `base + assetsDir` composition produces correct URLs
  in the no-prefix case; `renderBuiltUrl` stays gated on `assetPrefix`
  because it's only needed for the configured cases
- Drops the legacy `/assets/` branches in prod-server (3 sites) and
  static-file-cache (hard cutover — see PR description for rationale)
- Updates `_headers` generation, precompress default, fonts plugin
  `DEFAULT_ASSETS_DIR` to match the new default

With the layout aligned, invalid `_next/static/*` requests naturally
return plain-text `404 + "Not Found"` from the static-file layer instead
of falling through to the page renderer (which would produce an HTML
404 with bootstrap scripts and CSS). This replaces #1383's parity
short-circuit with the natural code path:

- Node prod-server (App + Pages branches): missing asset under
  `resolveAppRouterAssetPath` returns `text/plain; charset=utf-8` 404
- Cloudflare worker entry: `isNextStaticPath` recognises asset-shape
  after ASSETS-binding misses; returns `notFoundStaticAssetResponse`

Mirrors Next.js: packages/next/src/server/lib/router-server.ts.

Adds `tests/invalid-static-asset-404.test.ts` (3 App Router + 3 Pages
Router cases — no prefix / basePath / assetPrefix) ported from Next.js
e2e suites. Updates 11 source files + 12 test files; 914+ tests pass
in the touched suites (asset-prefix, app-router, pages-router, deploy,
font-google, static-file-cache, serve-static, precompress, features,
routing, isr-cache, build-optimization, app-rsc/ssr, standalone,
middleware, shims).

Ported from Next.js:
- test/e2e/invalid-static-asset-404-app/*.test.ts
- test/e2e/invalid-static-asset-404-pages/*.test.ts

* fix(e2e): update cloudflare-pages-router hydration spec for _next/static

* PR #1411 approved. Clean fix, 2 nits.

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>

* refactor(assets): derive isHashed checks from ASSET_PREFIX_URL_DIR

Replace hardcoded '_next/static' string literals in runtime code paths
with the ASSET_PREFIX_URL_DIR constant so the asset URL convention has
a single source of truth.

- server/prod-server.ts isHashed: pathname.includes(`/${ASSET_PREFIX_URL_DIR}/`)
- server/prod-server.ts public-dir guard: startsWith(`/${ASSET_PREFIX_URL_DIR}/`)
- server/static-file-cache.ts isHashed: both startsWith + includes derived
- plugins/fonts.ts DEFAULT_ASSETS_DIR = ASSET_PREFIX_URL_DIR

Behavioural no-op — the constant is '_next/static' so the resolved
strings are identical. Centralises the URL contract so future changes
to ASSET_PREFIX_URL_DIR (or a refactor that derives it from build
config) flow through to every consumer.

Doc-comment references to '_next/static' left in place — they
describe the canonical value for human readers and are not code paths.

---------

Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
2026-05-21 20:36:04 +00:00

661 lines
28 KiB
TypeScript

/**
* `assetPrefix` integration tests.
*
* Mirrors Next.js's `assetPrefix` behaviour:
* - Path prefix (e.g. `/custom-asset-prefix`): every emitted script/CSS URL
* starts with that prefix and lives under `_next/static/` to match
* Next.js's URL convention. Fetching those URLs from the prod server
* must succeed.
* - Absolute URL (e.g. `https://cdn.example.com`): emitted URLs are fully
* qualified — runtime serving on the deployment origin is a no-op.
* - Combined with `basePath`: routes stay under `basePath`, assets under
* `assetPrefix`. They are independent.
* - Unset (default): URLs live under `/_next/static/` — the Next.js
* canonical convention. On-disk layout mirrors the URL so the
* static-file layer can serve hits and naturally return plain-text
* `404 + "Not Found"` on misses.
*
* Ported from Next.js: test/e2e/app-dir/asset-prefix/asset-prefix.test.ts
* https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/asset-prefix/asset-prefix.test.ts
*
* @see https://nextjs.org/docs/app/api-reference/config/next-config-js/assetPrefix
*/
import { describe, it, expect, afterAll } from "vite-plus/test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { build, createBuilder } from "vite";
import vinext from "../packages/vinext/src/index.js";
import {
isAbsoluteAssetPrefix,
isNextStaticPath,
resolveAssetUrlPrefix,
resolveAssetsDir,
assetPrefixPathname,
ASSET_PREFIX_URL_DIR,
} from "../packages/vinext/src/utils/asset-prefix.js";
import { resolveAppRouterAssetPath } from "../packages/vinext/src/server/prod-server.js";
import { normalizeAssetPrefix } from "../packages/vinext/src/config/next-config.js";
const APP_FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/app-basic");
const ROOT_NODE_MODULES = path.resolve(import.meta.dirname, "../node_modules");
// ── Unit tests on the asset-prefix helpers ────────────────────────────────────
describe("normalizeAssetPrefix", () => {
it("returns an empty string when unset, null, or empty", () => {
expect(normalizeAssetPrefix(undefined)).toBe("");
expect(normalizeAssetPrefix(null)).toBe("");
expect(normalizeAssetPrefix("")).toBe("");
expect(normalizeAssetPrefix(" ")).toBe("");
});
it("trims trailing slashes and preserves a path prefix", () => {
expect(normalizeAssetPrefix("/cdn")).toBe("/cdn");
expect(normalizeAssetPrefix("/cdn/")).toBe("/cdn");
expect(normalizeAssetPrefix("/cdn//")).toBe("/cdn");
expect(normalizeAssetPrefix("/custom-asset-prefix")).toBe("/custom-asset-prefix");
});
it("adds a leading slash to bare path prefixes", () => {
// Next.js accepts both `cdn` and `/cdn`; normalize to the leading-slash form.
expect(normalizeAssetPrefix("cdn")).toBe("/cdn");
});
it("preserves absolute URLs verbatim (sans trailing slash)", () => {
expect(normalizeAssetPrefix("https://cdn.example.com")).toBe("https://cdn.example.com");
expect(normalizeAssetPrefix("https://cdn.example.com/")).toBe("https://cdn.example.com");
expect(normalizeAssetPrefix("https://cdn.example.com/sub")).toBe("https://cdn.example.com/sub");
expect(normalizeAssetPrefix("HTTP://cdn.example.com")).toBe("HTTP://cdn.example.com");
});
it("throws on non-string values to surface config typos early", () => {
expect(() => normalizeAssetPrefix(42 as unknown as string)).toThrow(/must be a string/);
expect(() => normalizeAssetPrefix({} as unknown as string)).toThrow(/must be a string/);
});
it("throws on unparseable absolute URLs", () => {
// A URL that begins with the http(s) scheme but cannot be parsed —
// the colon-only host triggers `URL.canParse` to return false.
expect(() => normalizeAssetPrefix("https://:::")).toThrow(/parseable URL/);
});
});
describe("isAbsoluteAssetPrefix", () => {
it("is true for http/https URLs and false for path prefixes or empty", () => {
expect(isAbsoluteAssetPrefix("https://cdn.example.com")).toBe(true);
expect(isAbsoluteAssetPrefix("http://cdn.example.com")).toBe(true);
expect(isAbsoluteAssetPrefix("HTTPS://cdn.example.com")).toBe(true);
expect(isAbsoluteAssetPrefix("/custom-asset-prefix")).toBe(false);
expect(isAbsoluteAssetPrefix("")).toBe(false);
});
});
describe("resolveAssetsDir", () => {
it("returns `_next/static` when no prefix is configured (Next.js canonical default)", () => {
expect(resolveAssetsDir("")).toBe(ASSET_PREFIX_URL_DIR);
});
it("returns `<prefix>/_next/static` for path prefixes so disk and URL align", () => {
expect(resolveAssetsDir("/custom-asset-prefix")).toBe("custom-asset-prefix/_next/static");
expect(resolveAssetsDir("/cdn")).toBe(`cdn/${ASSET_PREFIX_URL_DIR}`);
});
it("returns `_next/static` for absolute-URL prefixes — the CDN owns the URL prefix", () => {
expect(resolveAssetsDir("https://cdn.example.com")).toBe(ASSET_PREFIX_URL_DIR);
expect(resolveAssetsDir("https://cdn.example.com/sub")).toBe(ASSET_PREFIX_URL_DIR);
});
});
describe("resolveAssetUrlPrefix", () => {
it("returns `/_next/static/` when no prefix is configured", () => {
expect(resolveAssetUrlPrefix("")).toBe("/_next/static/");
});
it("concatenates the prefix with the static dir for path prefixes", () => {
expect(resolveAssetUrlPrefix("/cdn")).toBe("/cdn/_next/static/");
expect(resolveAssetUrlPrefix("/custom-asset-prefix")).toBe(
"/custom-asset-prefix/_next/static/",
);
});
it("preserves the full URL for absolute-URL prefixes", () => {
expect(resolveAssetUrlPrefix("https://cdn.example.com")).toBe(
"https://cdn.example.com/_next/static/",
);
expect(resolveAssetUrlPrefix("https://cdn.example.com/sub")).toBe(
"https://cdn.example.com/sub/_next/static/",
);
});
});
describe("assetPrefixPathname", () => {
it("returns empty for unset prefix and absolute URLs without a path component", () => {
expect(assetPrefixPathname("")).toBe("");
expect(assetPrefixPathname("https://cdn.example.com")).toBe("");
expect(assetPrefixPathname("https://cdn.example.com/")).toBe("");
});
it("returns the path component for path-prefix and pathful URL prefixes", () => {
expect(assetPrefixPathname("/custom-asset-prefix")).toBe("/custom-asset-prefix");
expect(assetPrefixPathname("https://cdn.example.com/sub")).toBe("/sub");
});
});
describe("resolveAppRouterAssetPath", () => {
it("accepts `/_next/static/<file>` when no prefix is configured", () => {
expect(resolveAppRouterAssetPath("/_next/static/foo-abc.js", "", "")).toBe(
"/_next/static/foo-abc.js",
);
expect(resolveAppRouterAssetPath("/about", "", "")).toBeNull();
});
it("does not match the legacy /assets/ layout (hard cutover)", () => {
// The historical Vite default `/assets/` is no longer emitted or served.
// Requests under that prefix fall through to the RSC handler (which
// renders the 404 page) — not the static-file layer.
expect(resolveAppRouterAssetPath("/assets/foo-abc.js", "", "")).toBeNull();
});
it("recognises `<prefix>/_next/static/<file>` for path-prefix configs", () => {
expect(
resolveAppRouterAssetPath(
"/custom-asset-prefix/_next/static/foo-abc.js",
"/custom-asset-prefix",
"/custom-asset-prefix",
),
).toBe("/custom-asset-prefix/_next/static/foo-abc.js");
});
it("strips the path component for absolute-URL prefixes with a path", () => {
// Disk layout for absolute-URL prefixes is `_next/static/...` (no
// extra prefix dir on disk), so a same-origin proxy that forwards the
// full URL path lands on the right file once the URL prefix is stripped.
expect(
resolveAppRouterAssetPath(
"/sub/_next/static/foo-abc.js",
"/sub",
"https://cdn.example.com/sub",
),
).toBe("/_next/static/foo-abc.js");
});
it("accepts `/_next/static/<file>` for absolute-URL prefixes without a path", () => {
expect(
resolveAppRouterAssetPath("/_next/static/foo-abc.js", "", "https://cdn.example.com"),
).toBe("/_next/static/foo-abc.js");
});
it("returns null for unrelated paths when a prefix is configured", () => {
expect(
resolveAppRouterAssetPath(
"/some-other-path/foo.js",
"/custom-asset-prefix",
"/custom-asset-prefix",
),
).toBeNull();
});
});
describe("isNextStaticPath", () => {
it("matches `/_next/static/*` at the root", () => {
expect(isNextStaticPath("/_next/static/foo-abc.js", "", "")).toBe(true);
expect(isNextStaticPath("/_next/static/chunks/main.js", "", "")).toBe(true);
});
it("does not match unrelated paths", () => {
expect(isNextStaticPath("/", "", "")).toBe(false);
expect(isNextStaticPath("/about", "", "")).toBe(false);
expect(isNextStaticPath("/_next/data/foo.json", "", "")).toBe(false);
// `/_next/static` alone (no trailing slash) is not a static-asset path.
expect(isNextStaticPath("/_next/static", "", "")).toBe(false);
});
it("strips basePath before matching", () => {
expect(isNextStaticPath("/docs/_next/static/foo.js", "/docs", "")).toBe(true);
expect(isNextStaticPath("/docs/about", "/docs", "")).toBe(false);
// basePath set but request without basePath — still matches the root form.
expect(isNextStaticPath("/_next/static/foo.js", "/docs", "")).toBe(true);
});
it("strips assetPathPrefix before matching", () => {
expect(isNextStaticPath("/cdn/_next/static/foo.js", "", "/cdn")).toBe(true);
expect(isNextStaticPath("/cdn/other", "", "/cdn")).toBe(false);
});
it("strips basePath then assetPathPrefix (independent prefixes)", () => {
expect(isNextStaticPath("/docs/cdn/_next/static/foo.js", "/docs", "/cdn")).toBe(true);
});
it("does not partial-match prefixes", () => {
// `/baseball` should NOT be stripped by `basePath: "/base"`.
expect(isNextStaticPath("/baseball/_next/static/foo.js", "/base", "")).toBe(false);
});
});
// ── End-to-end build tests ────────────────────────────────────────────────────
/**
* Build the app-basic fixture into an isolated tmp dir, optionally patching
* `next.config.ts` with extra config keys.
*
* The fixture is copied so tests can mutate next.config.ts independently
* without polluting each other or the shared on-disk fixture. node_modules
* is symlinked to the workspace root to avoid a real install.
*
* `registerCleanup` is invoked synchronously right after `mkdtempSync` so
* the tmp dir is always tracked, even if `createBuilder` or `buildApp`
* throws before this function returns.
*/
async function buildFixtureWithConfig(
extraConfigJson: string,
registerCleanup: (cleanup: () => void) => void,
): Promise<{
fixtureRoot: string;
outDir: string;
}> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-asset-prefix-"));
// Register the cleanup BEFORE any work that can throw. If the copy/symlink/
// build step fails, the afterAll hook still removes the tmp dir.
registerCleanup(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
const fixtureRoot = path.join(tmpDir, "fixture");
fs.cpSync(APP_FIXTURE_DIR, fixtureRoot, { recursive: true });
// Symlink the workspace node_modules so the fixture can resolve React,
// vinext, and @vitejs/plugin-rsc.
const fixtureNodeModules = path.join(fixtureRoot, "node_modules");
if (!fs.existsSync(fixtureNodeModules)) {
fs.symlinkSync(ROOT_NODE_MODULES, fixtureNodeModules, "junction");
}
// Patch next.config.ts to add the extra keys. We splice them in right after
// the opening `{` to avoid clobbering the existing async functions.
const nextConfigPath = path.join(fixtureRoot, "next.config.ts");
const original = fs.readFileSync(nextConfigPath, "utf-8");
const patched = original.replace(
"const nextConfig: NextConfig = {",
`const nextConfig: NextConfig = {\n ${extraConfigJson}`,
);
fs.writeFileSync(nextConfigPath, patched);
const outDir = path.join(fixtureRoot, "dist");
const builder = await createBuilder({
root: fixtureRoot,
configFile: false,
plugins: [vinext({ appDir: fixtureRoot })],
logLevel: "silent",
});
await builder.buildApp();
return { fixtureRoot, outDir };
}
describe("assetPrefix end-to-end build", () => {
// Track tmp dirs so we can clean up even if a build throws. `cleanups` is
// populated by `buildFixtureWithConfig` synchronously right after the tmp
// dir is created, so a thrown `createBuilder`/`buildApp` still leaves the
// tmp dir registered for the afterAll teardown.
const cleanups: Array<() => void> = [];
afterAll(() => {
for (const c of cleanups) c();
});
const register = (cleanup: () => void) => cleanups.push(cleanup);
it("path-prefix: emits assets under <prefix>/_next/static/ on disk and in HTML", async () => {
const built = await buildFixtureWithConfig(`assetPrefix: "/custom-asset-prefix",`, register);
// Files land on disk where the URLs say they should — Cloudflare's
// ASSETS binding (and any static file server) can serve them directly
// without a runtime rewrite.
const onDiskStatic = path.join(
built.outDir,
"client",
"custom-asset-prefix",
"_next",
"static",
);
expect(fs.existsSync(onDiskStatic), `expected on-disk layout under ${onDiskStatic}`).toBe(true);
const entries = fs.readdirSync(onDiskStatic);
expect(entries.some((f) => f.endsWith(".js"))).toBe(true);
// Serve the build via startProdServer and verify SSR HTML references
// the assetPrefix-anchored URLs, and that those URLs return 200.
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const { server } = await startProdServer({
port: 0,
outDir: built.outDir,
noCompression: true,
});
try {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const baseUrl = `http://localhost:${port}`;
const homeRes = await fetch(`${baseUrl}/`);
expect(homeRes.status).toBe(200);
const html = await homeRes.text();
// Helpful when this test starts to drift — uncomment to inspect HTML.
// console.log("--- HTML ---\n" + html + "\n--- /HTML ---");
// Bootstrap is emitted as `<script type="module" src="…">` via React's
// `bootstrapModules` option (see app-ssr-entry.ts). Mirrors the Next.js
// fixture assertion:
// src?.includes('/custom-asset-prefix/_next/static') ||
// src?.includes('/custom-asset-prefix/_next/static/immutable')
const bootstrapSrcRe = /<script[^>]+type="module"[^>]+src="([^"]+\.js)"/;
const bootstrapMatch = html.match(bootstrapSrcRe);
expect(
bootstrapMatch,
'expected a `<script type="module" src="…">` bootstrap in the HTML',
).not.toBeNull();
expect(bootstrapMatch![1]).toMatch(/^\/custom-asset-prefix\/_next\/static\//);
// Every `<script src="...">` tag (bootstrap + any preload-related
// injected tags) must live under the configured prefix. Vite/RSC may
// inject modulepreload links too — we don't strictly require them
// here, but they must not leak the old /assets/ default.
const scriptSrcRe = /<script[^>]+src="([^"]+)"/g;
const scriptSrcs: string[] = [];
for (const m of html.matchAll(scriptSrcRe)) {
scriptSrcs.push(m[1]);
}
for (const src of scriptSrcs) {
expect(src.startsWith("/custom-asset-prefix/_next/static/")).toBe(true);
}
// Fetching the bootstrap URL must return 200 with the JS body.
const bundleRes = await fetch(`${baseUrl}${bootstrapMatch![1]}`);
expect(bundleRes.status).toBe(200);
expect(bundleRes.headers.get("content-type")).toContain("javascript");
} finally {
server.close();
}
}, 180_000);
it("absolute URL: emits fully-qualified asset URLs and never includes /assets/", async () => {
const built = await buildFixtureWithConfig(`assetPrefix: "https://cdn.example.com",`, register);
// Disk layout is just _next/static/ — the CDN owns the URL prefix.
const onDiskStatic = path.join(built.outDir, "client", "_next", "static");
expect(fs.existsSync(onDiskStatic), `expected on-disk layout under ${onDiskStatic}`).toBe(true);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const { server } = await startProdServer({
port: 0,
outDir: built.outDir,
noCompression: true,
});
try {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const baseUrl = `http://localhost:${port}`;
const homeRes = await fetch(`${baseUrl}/`);
expect(homeRes.status).toBe(200);
const html = await homeRes.text();
const bootstrapMatch = html.match(/<script[^>]+type="module"[^>]+src="([^"]+\.js)"/);
expect(bootstrapMatch).not.toBeNull();
// Fully qualified — no protocol-relative or path-only URLs.
expect(bootstrapMatch![1]).toMatch(/^https:\/\/cdn\.example\.com\/_next\/static\//);
// No emitted asset URL should leak the historical /assets/ default.
expect(html).not.toMatch(/<script[^>]+src="\/assets\//);
} finally {
server.close();
}
}, 180_000);
it("basePath + assetPrefix: routes under basePath, assets under assetPrefix", async () => {
const built = await buildFixtureWithConfig(
`basePath: "/app",\n assetPrefix: "/cdn-prefix",`,
register,
);
// On-disk path mirrors the assetPrefix path — independent of basePath.
const onDiskStatic = path.join(built.outDir, "client", "cdn-prefix", "_next", "static");
expect(fs.existsSync(onDiskStatic), `expected on-disk layout under ${onDiskStatic}`).toBe(true);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const { server } = await startProdServer({
port: 0,
outDir: built.outDir,
noCompression: true,
});
try {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const baseUrl = `http://localhost:${port}`;
// Routes live under basePath only.
const homeRes = await fetch(`${baseUrl}/app/`);
expect(homeRes.status).toBe(200);
const html = await homeRes.text();
const bootstrapMatch = html.match(/<script[^>]+type="module"[^>]+src="([^"]+\.js)"/);
expect(bootstrapMatch).not.toBeNull();
// Asset URLs must NOT be under basePath — they are under assetPrefix only.
expect(bootstrapMatch![1]).toMatch(/^\/cdn-prefix\/_next\/static\//);
expect(bootstrapMatch![1].startsWith("/app/")).toBe(false);
// Fetching the asset URL succeeds (no basePath redirect).
const bundleRes = await fetch(`${baseUrl}${bootstrapMatch![1]}`);
expect(bundleRes.status).toBe(200);
} finally {
server.close();
}
}, 180_000);
// Ported from Next.js: packages/next/src/server/config.ts:528-531
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/config.ts
it("basePath alone: falls back to using basePath as assetPrefix", async () => {
// Next.js parity — `basePath: "/app"` with no `assetPrefix` should serve
// assets from `/app/_next/static/...` (NOT `/assets/...` and NOT
// `/app/assets/...`). The fallback in resolveNextConfig() rewrites
// `assetPrefix = basePath`, which feeds the rest of the pipeline.
const built = await buildFixtureWithConfig(`basePath: "/app",`, register);
// On-disk layout mirrors the asset URL: <basePath>/_next/static/...
const onDiskStatic = path.join(built.outDir, "client", "app", "_next", "static");
expect(fs.existsSync(onDiskStatic), `expected on-disk layout under ${onDiskStatic}`).toBe(true);
// Legacy `<basePath>/assets/` directory should NOT exist — the fallback
// moves assets to the Next.js-canonical location.
const onDiskAssets = path.join(built.outDir, "client", "app", "assets");
expect(fs.existsSync(onDiskAssets)).toBe(false);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const { server } = await startProdServer({
port: 0,
outDir: built.outDir,
noCompression: true,
});
try {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const baseUrl = `http://localhost:${port}`;
const homeRes = await fetch(`${baseUrl}/app/`);
expect(homeRes.status).toBe(200);
const html = await homeRes.text();
const bootstrapMatch = html.match(/<script[^>]+type="module"[^>]+src="([^"]+\.js)"/);
expect(bootstrapMatch).not.toBeNull();
// Asset URL lives under basePath at Next.js's `_next/static/` path.
expect(bootstrapMatch![1]).toMatch(/^\/app\/_next\/static\//);
// Critically, the asset URL must NOT include `/assets/` (the old
// Vite default) anywhere — the fallback should have completely
// replaced that layout.
expect(bootstrapMatch![1]).not.toContain("/assets/");
// The prod-server resolves the URL against the on-disk path and
// returns the JS bundle.
const bundleRes = await fetch(`${baseUrl}${bootstrapMatch![1]}`);
expect(bundleRes.status).toBe(200);
expect(bundleRes.headers.get("content-type")).toContain("javascript");
} finally {
server.close();
}
}, 180_000);
it("unset: emits URLs under /_next/static/ (Next.js canonical default)", async () => {
// Default behaviour — no assetPrefix set. URLs and on-disk layout both
// use Next.js's canonical `_next/static/` path so requests for valid
// chunks succeed and invalid ones produce a plain-text 404 from the
// static-file layer (matching Next.js's router-server.ts).
const built = await buildFixtureWithConfig(`// no assetPrefix`, register);
// On-disk layout mirrors the URL.
const onDiskStatic = path.join(built.outDir, "client", "_next", "static");
expect(fs.existsSync(onDiskStatic), `expected on-disk layout under ${onDiskStatic}`).toBe(true);
// Legacy `/assets/` directory should NOT exist — the hard cutover
// removes Vite's historical default in favour of Next.js parity.
const legacyAssets = path.join(built.outDir, "client", "assets");
expect(fs.existsSync(legacyAssets)).toBe(false);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const { server } = await startProdServer({
port: 0,
outDir: built.outDir,
noCompression: true,
});
try {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const baseUrl = `http://localhost:${port}`;
const homeRes = await fetch(`${baseUrl}/`);
expect(homeRes.status).toBe(200);
const html = await homeRes.text();
const bootstrapMatch = html.match(/<script[^>]+type="module"[^>]+src="([^"]+\.js)"/);
expect(bootstrapMatch).not.toBeNull();
expect(bootstrapMatch![1]).toMatch(/^\/_next\/static\//);
// No emitted URL should leak the legacy `/assets/` default.
expect(html).not.toMatch(/<script[^>]+src="\/assets\//);
const bundleRes = await fetch(`${baseUrl}${bootstrapMatch![1]}`);
expect(bundleRes.status).toBe(200);
// Invalid `_next/static/*` paths return plain-text 404 (Next.js
// parity — see packages/next/src/server/lib/router-server.ts).
const invalidRes = await fetch(`${baseUrl}/_next/static/does-not-exist.js`);
expect(invalidRes.status).toBe(404);
expect(invalidRes.headers.get("content-type")).toBe("text/plain; charset=utf-8");
expect(await invalidRes.text()).toBe("Not Found");
} finally {
server.close();
}
}, 180_000);
// Regression test for round-5 review feedback on #1311. Mirrors the App
// Router "basePath alone" test above, but for the Pages Router. The bug:
// the Pages Router prod-server stripped basePath from the request path
// BEFORE matching against assetPrefix's path-prefix branch, so when the
// Next.js parity fallback set `assetPrefix = basePath`, every asset 404'd.
//
// Critically: this test FETCHES the asset URLs (not just inspecting HTML),
// which is what the existing pages-router basePath test missed.
it("Pages Router with basePath alone serves assets under <basePath>/_next/static/", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-asset-prefix-"));
register(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
// Spin up a minimal Pages Router fixture inline rather than copy
// tests/fixtures/pages-basic — that fixture pulls in a large config
// object (redirects/rewrites/headers) we don't need here.
fs.symlinkSync(ROOT_NODE_MODULES, path.join(tmpDir, "node_modules"), "junction");
fs.writeFileSync(path.join(tmpDir, "package.json"), JSON.stringify({ type: "module" }));
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default { basePath: "/docs" };\n`,
);
fs.mkdirSync(path.join(tmpDir, "pages"), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, "pages", "index.tsx"),
`import { useState } from "react";
export default function HomePage() {
const [count, setCount] = useState(0);
return (
<button data-testid="increment" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}
`,
);
const outDir = path.join(tmpDir, "dist");
// Pages Router build pipeline — server then client. Matches the pattern
// in tests/pages-router.test.ts for the SSR-manifest basePath test.
await build({
root: tmpDir,
configFile: false,
plugins: [vinext({ disableAppRouter: true })],
logLevel: "silent",
build: {
outDir: path.join(outDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: tmpDir,
configFile: false,
plugins: [vinext({ disableAppRouter: true })],
logLevel: "silent",
build: {
outDir: path.join(outDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const { server } = await startProdServer({
port: 0,
host: "127.0.0.1",
outDir,
noCompression: true,
});
try {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const baseUrl = `http://127.0.0.1:${port}`;
const homeRes = await fetch(`${baseUrl}/docs/`);
expect(homeRes.status).toBe(200);
const html = await homeRes.text();
// Collect every emitted asset URL (script srcs + stylesheet hrefs).
// The parity fallback (`assetPrefix = basePath`) puts them all under
// `<basePath>/_next/static/`, which is the Next.js-canonical layout.
const assetUrls = new Set<string>();
for (const m of html.matchAll(
/<(?:script|link)[^>]+(?:src|href)="(\/docs\/_next\/[^"]+)"/g,
)) {
assetUrls.add(m[1]);
}
expect(assetUrls.size, "expected at least one asset URL in the SSR HTML").toBeGreaterThan(0);
// Critically: fetch each asset URL and assert 200. Before the
// round-5 fix, the Pages Router lookup stripped basePath from the
// request path before matching assetPrefix's path-prefix branch,
// which made the helper return null → 404 here. Inspecting HTML
// alone (as the pre-existing pages-router basePath test did) was
// not enough to catch the regression.
for (const url of assetUrls) {
const assetRes = await fetch(`${baseUrl}${url}`);
expect(assetRes.status, `expected 200 for ${url}`).toBe(200);
if (url.endsWith(".js")) {
expect(assetRes.headers.get("content-type")).toContain("javascript");
}
}
} finally {
server.close();
}
}, 180_000);
});