mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
d504f022b0
* 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>
232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
/**
|
|
* Tests for build-time precompression of hashed static assets.
|
|
*
|
|
* precompressAssets() runs after `vinext build` and generates .br (brotli) and
|
|
* .gz (gzip) files alongside compressible hashed assets in dist/client/assets/.
|
|
* This eliminates per-request compression overhead for immutable build output.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test";
|
|
import fsp from "node:fs/promises";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import zlib from "node:zlib";
|
|
import { precompressAssets } from "../packages/vinext/src/build/precompress.js";
|
|
|
|
/** Write a file with repeated content to ensure it exceeds compression threshold. */
|
|
async function writeAsset(clientDir: string, relativePath: string, content: string): Promise<void> {
|
|
const fullPath = path.join(clientDir, relativePath);
|
|
await fsp.mkdir(path.dirname(fullPath), { recursive: true });
|
|
await fsp.writeFile(fullPath, content);
|
|
}
|
|
|
|
describe("precompressAssets", () => {
|
|
let clientDir: string;
|
|
|
|
beforeEach(async () => {
|
|
clientDir = path.join(
|
|
os.tmpdir(),
|
|
`vinext-precompress-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
);
|
|
await fsp.mkdir(clientDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fsp.rm(clientDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("generates .br and .gz files for compressible hashed assets", async () => {
|
|
const jsContent = "const x = 1;\n".repeat(200); // well above 1KB threshold
|
|
await writeAsset(clientDir, "_next/static/index-abc123.js", jsContent);
|
|
|
|
const result = await precompressAssets(clientDir);
|
|
|
|
// Both compressed variants should exist on disk
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/index-abc123.js.br"))).toBe(true);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/index-abc123.js.gz"))).toBe(true);
|
|
|
|
// Result should report what was compressed
|
|
expect(result.filesCompressed).toBe(1);
|
|
});
|
|
|
|
it("brotli output decompresses to original content", async () => {
|
|
const jsContent = "export function hello() { return 'world'; }\n".repeat(100);
|
|
await writeAsset(clientDir, "_next/static/hello-def456.js", jsContent);
|
|
|
|
await precompressAssets(clientDir);
|
|
|
|
const brBuffer = await fsp.readFile(path.join(clientDir, "_next/static/hello-def456.js.br"));
|
|
const decompressed = zlib.brotliDecompressSync(brBuffer).toString();
|
|
expect(decompressed).toBe(jsContent);
|
|
});
|
|
|
|
it("gzip output decompresses to original content", async () => {
|
|
const cssContent = ".container { display: flex; }\n".repeat(100);
|
|
await writeAsset(clientDir, "_next/static/styles-789abc.css", cssContent);
|
|
|
|
await precompressAssets(clientDir);
|
|
|
|
const gzBuffer = await fsp.readFile(path.join(clientDir, "_next/static/styles-789abc.css.gz"));
|
|
const decompressed = zlib.gunzipSync(gzBuffer).toString();
|
|
expect(decompressed).toBe(cssContent);
|
|
});
|
|
|
|
it("skips files below the compression threshold", async () => {
|
|
// Tiny file — not worth compressing
|
|
await writeAsset(clientDir, "_next/static/tiny-aaa111.js", "const x = 1;");
|
|
|
|
const result = await precompressAssets(clientDir);
|
|
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/tiny-aaa111.js.br"))).toBe(false);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/tiny-aaa111.js.gz"))).toBe(false);
|
|
expect(result.filesCompressed).toBe(0);
|
|
});
|
|
|
|
it("skips non-compressible file types (images, fonts)", async () => {
|
|
// PNG file (binary, already compressed)
|
|
const pngHeader = Buffer.alloc(2048, 0x89); // fake PNG data, above threshold
|
|
await writeAsset(clientDir, "_next/static/logo-bbb222.png", pngHeader.toString("binary"));
|
|
|
|
// WOFF2 font (already compressed)
|
|
const fontData = Buffer.alloc(2048, 0x77);
|
|
await writeAsset(clientDir, "_next/static/font-ccc333.woff2", fontData.toString("binary"));
|
|
|
|
const result = await precompressAssets(clientDir);
|
|
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/logo-bbb222.png.br"))).toBe(false);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/font-ccc333.woff2.br"))).toBe(false);
|
|
expect(result.filesCompressed).toBe(0);
|
|
});
|
|
|
|
it("returns empty result for missing assets/ directory", async () => {
|
|
// clientDir exists but has no assets/ subdirectory
|
|
const result = await precompressAssets(clientDir);
|
|
|
|
expect(result.filesCompressed).toBe(0);
|
|
expect(result.totalOriginalBytes).toBe(0);
|
|
expect(result.totalBrotliBytes).toBe(0);
|
|
});
|
|
|
|
it("compresses CSS files alongside JS", async () => {
|
|
const jsContent = "function render() {}\n".repeat(200);
|
|
const cssContent = "body { margin: 0; }\n".repeat(200);
|
|
await writeAsset(clientDir, "_next/static/app-aaa111.js", jsContent);
|
|
await writeAsset(clientDir, "_next/static/app-bbb222.css", cssContent);
|
|
|
|
const result = await precompressAssets(clientDir);
|
|
|
|
expect(result.filesCompressed).toBe(2);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/app-aaa111.js.br"))).toBe(true);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/app-bbb222.css.br"))).toBe(true);
|
|
});
|
|
|
|
it("does not re-compress existing .br or .gz files", async () => {
|
|
const jsContent = "const x = 1;\n".repeat(200);
|
|
await writeAsset(clientDir, "_next/static/index-abc123.js", jsContent);
|
|
|
|
// Run twice — should not create .br.br or .gz.gz
|
|
await precompressAssets(clientDir);
|
|
await precompressAssets(clientDir);
|
|
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/index-abc123.js.br.br"))).toBe(false);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/index-abc123.js.gz.gz"))).toBe(false);
|
|
});
|
|
|
|
it("handles nested directories under assets/", async () => {
|
|
const jsContent = "export default {}\n".repeat(200);
|
|
await writeAsset(clientDir, "_next/static/chunks/vendor-ddd444.js", jsContent);
|
|
|
|
const result = await precompressAssets(clientDir);
|
|
|
|
expect(result.filesCompressed).toBe(1);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/chunks/vendor-ddd444.js.br"))).toBe(
|
|
true,
|
|
);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/chunks/vendor-ddd444.js.gz"))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("only compresses files inside assets/ not other client files", async () => {
|
|
const htmlContent = "<html><body>hello</body></html>\n".repeat(100);
|
|
const jsContent = "const x = 1;\n".repeat(200);
|
|
// This file is in client root, not in assets/ — should not be compressed
|
|
await writeAsset(clientDir, "index.html", htmlContent);
|
|
// This one is in assets/ — should be compressed
|
|
await writeAsset(clientDir, "_next/static/main-eee555.js", jsContent);
|
|
|
|
const result = await precompressAssets(clientDir);
|
|
|
|
expect(result.filesCompressed).toBe(1);
|
|
expect(fs.existsSync(path.join(clientDir, "index.html.br"))).toBe(false);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/main-eee555.js.br"))).toBe(true);
|
|
});
|
|
|
|
it("generates .zst files alongside .br and .gz", async () => {
|
|
const jsContent = "const x = 1;\n".repeat(200);
|
|
await writeAsset(clientDir, "_next/static/zstd-aaa111.js", jsContent);
|
|
|
|
const result = await precompressAssets(clientDir);
|
|
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/zstd-aaa111.js.zst"))).toBe(true);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/zstd-aaa111.js.br"))).toBe(true);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/zstd-aaa111.js.gz"))).toBe(true);
|
|
expect(result.filesCompressed).toBe(1);
|
|
});
|
|
|
|
it("zstd output decompresses to original content", async () => {
|
|
const jsContent = "export function hello() { return 'world'; }\n".repeat(100);
|
|
await writeAsset(clientDir, "_next/static/hello-zstd.js", jsContent);
|
|
|
|
await precompressAssets(clientDir);
|
|
|
|
const zstdBuffer = await fsp.readFile(path.join(clientDir, "_next/static/hello-zstd.js.zst"));
|
|
const decompressed = zlib.zstdDecompressSync(zstdBuffer).toString();
|
|
expect(decompressed).toBe(jsContent);
|
|
});
|
|
|
|
it("compresses assets under a custom assetsDir (assetPrefix layout)", async () => {
|
|
// With `assetPrefix: "/cdn"` the build emits hashed assets under
|
|
// `<clientDir>/cdn/_next/static/...` instead of the default `assets/`.
|
|
// Caller threads the resolved subdir in via options.assetsDir.
|
|
const jsContent = "const x = 1;\n".repeat(200);
|
|
await writeAsset(clientDir, "cdn/_next/static/main-abc123.js", jsContent);
|
|
// A file at the legacy `assets/` location must NOT be picked up when a
|
|
// custom assetsDir is in effect — otherwise both layouts would be walked.
|
|
await writeAsset(clientDir, "_next/static/legacy-def456.js", jsContent);
|
|
|
|
const result = await precompressAssets(clientDir, { assetsDir: "cdn/_next/static" });
|
|
|
|
expect(result.filesCompressed).toBe(1);
|
|
expect(fs.existsSync(path.join(clientDir, "cdn/_next/static/main-abc123.js.br"))).toBe(true);
|
|
expect(fs.existsSync(path.join(clientDir, "cdn/_next/static/main-abc123.js.gz"))).toBe(true);
|
|
// Legacy location is untouched
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/legacy-def456.js.br"))).toBe(false);
|
|
});
|
|
|
|
it("compresses assets under _next/static for absolute-URL assetPrefix", async () => {
|
|
// With an absolute-URL `assetPrefix` (e.g. `https://cdn.example.com`)
|
|
// assets are written to `_next/static/` on disk; the URL prefix is
|
|
// applied at render time, not on the filesystem.
|
|
const jsContent = "function f() { return 1; }\n".repeat(200);
|
|
await writeAsset(clientDir, "_next/static/chunk-xyz999.js", jsContent);
|
|
|
|
const result = await precompressAssets(clientDir, { assetsDir: "_next/static" });
|
|
|
|
expect(result.filesCompressed).toBe(1);
|
|
expect(fs.existsSync(path.join(clientDir, "_next/static/chunk-xyz999.js.br"))).toBe(true);
|
|
});
|
|
|
|
it("reports total original and compressed byte sizes", async () => {
|
|
const jsContent = "const x = 1;\n".repeat(500);
|
|
await writeAsset(clientDir, "_next/static/big-fff666.js", jsContent);
|
|
|
|
const result = await precompressAssets(clientDir);
|
|
|
|
expect(result.totalOriginalBytes).toBe(jsContent.length);
|
|
// Compressed should be smaller than original for repetitive content
|
|
expect(result.totalBrotliBytes).toBeGreaterThan(0);
|
|
expect(result.totalBrotliBytes).toBeLessThan(result.totalOriginalBytes);
|
|
});
|
|
});
|