Files
cloudflare__vinext/tests/serve-static.test.ts
T
Nathan Nguyen 6da45a680a perf: build-time precompression + startup metadata cache for static serving (#641)
* perf: add build-time precompression for hashed static assets

Generate .br (brotli q11), .gz (gzip l9), and .zst (zstandard l19)
files alongside compressible assets in dist/client/assets/ during
vinext build. These are served directly by the production server,
eliminating per-request compression overhead for immutable build output.

Only targets assets/ (hashed, immutable). Public directory files still
use on-the-fly compression since they may change between deploys.

* perf: add startup metadata cache for zero-syscall static serving

StaticFileCache walks dist/client/ once at server boot and caches:
- File metadata (path, size, content-type, cache-control, etag)
- Pre-computed response headers per variant (original, br, gz, zst)
- In-memory buffers for small files (< 64KB) for res.end(buffer)
- Precompressed variant paths and sizes

Per-request serving is Map.get() + res.end(buffer) with zero filesystem
calls, zero object allocation, and zero header construction. Modeled
after sirv's production mode but with in-memory buffering for small
files which eliminates createReadStream fd overhead.

* perf: refactor tryServeStatic to async + cache + precompressed serving

Refactor tryServeStatic to use StaticFileCache for the hot path:
- Pre-computed response headers (zero object allocation per request)
- In-memory buffer serving for small precompressed files
- 304 Not Modified via ETag + If-None-Match
- HEAD request optimization (headers only, no body)
- Zstandard serving (zstd > br > gzip > original fallback chain)
- Async filesystem fallback for non-cached files (replaces blocking
  existsSync + statSync)
- Skip decodeURIComponent for clean URLs (no % in path)

Wire StaticFileCache.create() into both startAppRouterServer and
startPagesRouterServer at startup. Integrate precompressAssets() into
the vinext build pipeline.

CONTENT_TYPES is now a single source of truth exported from
static-file-cache.ts (was duplicated in prod-server.ts).

* docs: fix stale comments in precompress (mention .zst)

* fix: deduplicate buffer reads for HTML alias entries, fix stale JSDoc

* docs: fix stale comment in cli.ts (mention zstd)

* fix: address code review findings

- Add Vary: Accept-Encoding to original variant when compressed siblings exist
- Use pipeline() instead of .pipe() for stream error safety
- Remove legacy accessor fields from StaticFileEntry (all new code)
- Exclude Content-Type from 304 responses per RFC 9110
- Fix misleading Accept-Encoding comment in prod-server
- Batch stat() calls in walkFilesWithStats (chunks of 64)
- Bound precompression concurrency to availableParallelism (max 16)
- Gracefully skip zstd when zlib.zstdCompress unavailable (Node <22.15)
- Add --no-precompress CLI flag to skip build-time compression
- Drop brotli to q5 (fast, nearly same ratio as q11 at build time)
- Max out zstd at level 22 (build time, no reason to hold back)

* fix: address remaining review findings in prod-server

- Gate negotiateEncoding zstd on runtime availability (HAS_ZSTD)
- Add ZSTD_e_flush for streaming mode (progressive SSR decodability)
- Serve HEAD without body on slow path (compressed + uncompressed)
- Handle pipeline errors instead of silently swallowing them
- Lowercase Accept-Encoding in fast path per RFC 9110
- Fix stale comments: add zstd to module header and variant detection
- Add tests for slow path, HEAD, and malformed percent-encoded pathnames

* perf: move precompression to Vite plugin with edge auto-detection

Move build-time precompression from CLI post-step into a writeBundle
plugin hook (vinext:precompress). The plugin already knows the
deployment target — auto-skips for Cloudflare Workers and Nitro where
the CDN handles compression at the edge.

- Add precompress option to VinextOptions: 'auto' | boolean
- CLI --no-precompress forwards via VINEXT_NO_PRECOMPRESS env var
- Works for any build invocation (CLI, API, CI), not just vinext build

* docs: fix inaccurate comments in precompress and static-file-cache

- precompress: remove false idempotency claim (overwrites, not skips)
- static-file-cache: "frozen objects" → "reusable objects" (no freeze)
- static-file-cache: fix unrealistic compression ratio example

* fix: address code review findings

- Remove dead code: redundant .br/.gz/.zst extension checks already
  caught by COMPRESSIBLE_EXTENSIONS filter
- Fix unreachable pipeline error handlers: res.writeHead() was called
  before pipeline(), so res.headersSent was always true in the callback.
  Use res.destroy(err) to cleanly tear down the socket on stream errors.
- Read file content lazily inside compression chunks instead of loading
  all files upfront, keeping peak memory at O(CONCURRENCY * max_file_size)

* perf: tune compression levels and restore auto mode

- Drop zstd from level 22 to 8 (250x faster build, 7% larger output)
- Drop gzip from level 9 to 8 (marginal size difference, faster)
- Restore precompress: 'auto' | boolean config with auto as default
  (auto-enables for Node.js targets, skips for Cloudflare/Nitro)
- Add progress bar for precompression step (matches prerender UX)
- Clarify compression ratio output as brotli-specific

* fix: use filename hash for ETag on hashed assets

Hashed assets (assets/app-DqZc3R4n.js) now derive their ETag from the
content hash in the filename instead of mtime. More robust across
Docker builds, CI artifacts, and cp -r where mtime changes between
deploys even when content is identical.

- Use weak validator (W/) since the tag is shared across content-encoded
  variants (.br, .gz, .zst) which are byte-different (RFC 9110 §8.8.1)
- Fall back to mtime-based ETag when filename has no recognizable hash
  (custom assetFileNames, plugin-emitted assets, public/ files)

* fix: make precompress opt-in per maintainer feedback

Real-world benchmark shows 13.5% build-time regression on fumadocs
with auto mode (2.4s → 2.7s). Switch to explicit opt-in via
`precompress: true` or `--precompress` CLI flag until the feature
stabilises.

* fix: address code review findings

- ETag regex: use lastIndexOf('-') to extract only the final hash
  segment, avoiding capture of filename fragments in multi-hyphenated
  names (e.g. `before-pop-state-D3R3Ien6` now extracts `D3R3Ien6`)
- Progress bar: track processed count (including skipped files) so
  the bar always reaches 100%
- Tests: update ETag test for filename-hash behavior, add coverage
  for mtime fallback on non-hashed and unhashed-in-assets files

* chore: use type instead of interface per lint rule

* fix final precompression review comments

* perf: optimize precompress build path

- Overlap precompression with SSR build phase (async writeBundle + closeBundle await)
- Lower concurrency cap from 16 to 8 (reduces zlib queue contention)
- Fix progress counter race in chunked processing

* fix: address final review feedback

- Fix progress counter race in precompress (single onProgress call site
  after readFile, monotonic by construction)
- Add console.error before res.destroy in all pipeline error handlers
  for operational visibility
- Chunk buffer reads at 64 concurrent to bound FD usage on large projects
- Defer createCompressor until after HEAD check on slow path
- Use Math.floor for mtime ETag (matches send/Express convention)
- Document Rolldown hash length assumption

* fix: address final bonk review nits

- Move progress reporting to after chunk completion to avoid
  non-deterministic ordering within Promise.all
- Use Math.trunc instead of Math.floor for mtime-based ETags
  (semantically correct for truncating to whole seconds)

* address bonk review comments: counters post-write, Math.floor, Content-Length comment, warn log level

- precompress.ts: move filesCompressed++ and totalOriginalBytes+= to after
  writes complete so partial failures (e.g. ENOSPC) don't inflate totals
- precompress.ts: add comment clarifying that progress tracks all files
  including skipped ones below MIN_SIZE, unlike filesCompressed
- static-file-cache.ts: Math.trunc -> Math.floor for mtime-based ETags
  (matches send/Express convention; correct for pre-epoch mtimes)
- prod-server.ts: add comment explaining Content-Length is intentionally
  omitted on slow-path compressed responses (chunked transfer encoding)
- prod-server.ts: downgrade all three pipeline error logs from console.error
  to console.warn and add comments explaining the destroy-only pattern

* address bonk: slow-path ETag+304 parity, visible precompress error log

- prod-server.ts slow path: add mtime-based ETag (W/"size-mtime") and
  304 Not Modified support, matching fast-path cache behaviour; also
  propagate mtimeMs through resolveStaticFile/statIfFile
- index.ts: add console.error in precompress .catch() so build errors are
  visible even if the SSR closeBundle hook never fires

* address bonk: slow-path 304 Vary header, HAS_ZSTD comment

- prod-server.ts slow path: include Vary: Accept-Encoding in 304
  responses when the content type is compressible, matching fast-path
  notModifiedHeaders behaviour and preventing shared caches from
  collapsing compressed/uncompressed representations
- prod-server.ts fast path: add comment explaining HAS_ZSTD is not
  checked during precompressed variant selection — we're serving a
  pre-existing .zst file from disk, not invoking zstdCompress() at
  runtime; HAS_ZSTD only guards the slow-path on-the-fly compressor

* address bonk: clarify Vary/compress=false intent in slow-path 304 comment

Expand the 304 comment to explain that Vary: Accept-Encoding is
intentionally omitted when compress=false (matching fast-path behaviour
where compress=false skips all compressed variants), and note that
spreading undefined is a deliberate no-op per ES2018+.

* address bonk: readFile-before-stat comment, document VINEXT_PRECOMPRESS env var

- precompress.ts: add comment on MIN_SIZE early-return explaining the
  read-first vs stat-first tradeoff (one wasted read vs extra syscall
  per tiny file; read-first cheaper for typical hashed asset sets)
- index.ts: document VINEXT_PRECOMPRESS=1 env var in VinextOptions.precompress
  JSDoc alongside the existing --precompress CLI flag mention

* address bonk: filename-hash ETag on slow path, entry-level dedup in buffer pass

- static-file-cache.ts: export etagFromFilenameHash so prod-server can
  reuse the same filename-hash extraction logic
- prod-server.ts: slow path now uses etagFromFilenameHash for /assets/*
  files, falling back to mtime-based ETag for non-hashed files; this
  matches fast-path cache behaviour and makes ETags stable across deploys
- static-file-cache.ts buffer pass: deduplicate at the StaticFileEntry
  level (using a seenEntries Set) rather than at the variant path level;
  HTML aliases share the same entry by reference so the old approach
  iterated variants multiple times for sites with many HTML pages

* address bonk: fix etagFromFilenameHash arg, explicit re-run safety comment

- prod-server.ts: use resolved.path (not pathname) for etagFromFilenameHash
  so that ext and the hash extraction always come from the same file;
  pathname and resolved.path diverge after HTML fallback (e.g. a request
  for /assets/widget-abc123 resolving to widget-abc123.html would have
  extracted a hash from the wrong name)
- precompress.ts: add inline comment on the COMPRESSIBLE_EXTENSIONS filter
  making explicit that .br/.gz/.zst are intentionally absent, so
  precompressed variants from a previous run are never re-compressed

* address bonk: add slow-path 304 test coverage

---------

Co-authored-by: James <james@eli.cx>
2026-04-01 23:33:12 +01:00

669 lines
23 KiB
TypeScript

/**
* Tests for the refactored tryServeStatic that uses StaticFileCache
* and serves precompressed assets without runtime compression overhead.
*/
import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test";
import fsp from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import zlib from "node:zlib";
import { StaticFileCache } from "../packages/vinext/src/server/static-file-cache.js";
import { tryServeStatic } from "../packages/vinext/src/server/prod-server.js";
import type { IncomingMessage, ServerResponse } from "node:http";
async function writeFile(
clientDir: string,
relativePath: string,
content: string | Buffer,
): Promise<void> {
const fullPath = path.join(clientDir, relativePath);
await fsp.mkdir(path.dirname(fullPath), { recursive: true });
await fsp.writeFile(fullPath, content);
}
/**
* Create a mock request with optional headers and method.
*/
function mockReq(
acceptEncoding?: string,
extraHeaders?: Record<string, string>,
method: string = "GET",
): IncomingMessage {
const headers: Record<string, string> = { ...extraHeaders };
if (acceptEncoding) headers["accept-encoding"] = acceptEncoding;
return { headers, method } as unknown as IncomingMessage;
}
type CapturedResponse = {
status: number;
headers: Record<string, string | string[]>;
body: Buffer;
ended: Promise<void>;
};
/**
* Create a mock response that captures writeHead + streamed/ended body data.
*/
function mockRes(): { res: ServerResponse; captured: CapturedResponse } {
const chunks: Buffer[] = [];
let resolveEnded: () => void;
const ended = new Promise<void>((r) => {
resolveEnded = r;
});
const captured: CapturedResponse = {
status: 0,
headers: {},
body: Buffer.alloc(0),
ended,
};
const res = {
writeHead(status: number, headers: Record<string, string | string[]>) {
captured.status = status;
captured.headers = headers;
},
write(chunk: Buffer | string) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return true;
},
end(chunk?: Buffer | string) {
if (chunk) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
captured.body = Buffer.concat(chunks);
resolveEnded!();
},
on(_event: string, _handler: (...args: unknown[]) => void) {
return res;
},
once(_event: string, _handler: (...args: unknown[]) => void) {
return res;
},
emit() {
return false;
},
removeListener() {
return res;
},
} as unknown as ServerResponse;
return { res, captured };
}
describe("tryServeStatic (with StaticFileCache)", () => {
let clientDir: string;
beforeEach(async () => {
clientDir = path.join(
os.tmpdir(),
`vinext-serve-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await fsp.mkdir(clientDir, { recursive: true });
});
afterEach(async () => {
await fsp.rm(clientDir, { recursive: true, force: true });
});
// ── Precompressed serving ──────────────────────────────────────
it("serves precompressed brotli for hashed assets when client accepts br", async () => {
const jsContent = "const app = () => {};\n".repeat(200);
await writeFile(clientDir, "assets/app-abc123.js", jsContent);
const brContent = zlib.brotliCompressSync(Buffer.from(jsContent));
await writeFile(clientDir, "assets/app-abc123.js.br", brContent);
const cache = await StaticFileCache.create(clientDir);
const req = mockReq("gzip, deflate, br");
const { res, captured } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/assets/app-abc123.js", true, cache);
await captured.ended;
expect(served).toBe(true);
expect(captured.headers["Content-Encoding"]).toBe("br");
expect(captured.headers["Content-Length"]).toBe(String(brContent.length));
expect(captured.headers["Content-Type"]).toBe("application/javascript");
// Body should be the precompressed brotli content
const decompressed = zlib.brotliDecompressSync(captured.body).toString();
expect(decompressed).toBe(jsContent);
});
it("serves precompressed gzip when client accepts gzip but not br", async () => {
const cssContent = ".app { display: flex; }\n".repeat(200);
await writeFile(clientDir, "assets/styles-def456.css", cssContent);
const gzContent = zlib.gzipSync(Buffer.from(cssContent));
await writeFile(clientDir, "assets/styles-def456.css.gz", gzContent);
const cache = await StaticFileCache.create(clientDir);
const req = mockReq("gzip, deflate");
const { res, captured } = mockRes();
const served = await tryServeStatic(
req,
res,
clientDir,
"/assets/styles-def456.css",
true,
cache,
);
await captured.ended;
expect(served).toBe(true);
expect(captured.headers["Content-Encoding"]).toBe("gzip");
expect(captured.headers["Content-Length"]).toBe(String(gzContent.length));
});
it("serves original file with Content-Length when no encoding accepted", async () => {
const jsContent = "const x = 1;\n".repeat(200);
await writeFile(clientDir, "assets/plain-ghi789.js", jsContent);
const cache = await StaticFileCache.create(clientDir);
const req = mockReq(); // no Accept-Encoding
const { res, captured } = mockRes();
const served = await tryServeStatic(
req,
res,
clientDir,
"/assets/plain-ghi789.js",
true,
cache,
);
await captured.ended;
expect(served).toBe(true);
expect(captured.headers["Content-Encoding"]).toBeUndefined();
expect(captured.headers["Content-Length"]).toBe(String(jsContent.length));
});
// ── Cache miss / non-existent ──────────────────────────────────
it("returns false for non-existent files", async () => {
const cache = await StaticFileCache.create(clientDir);
const req = mockReq("br");
const { res } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/assets/nope-xxx999.js", true, cache);
expect(served).toBe(false);
});
// ── immutable cache-control on hashed assets ───────────────────
it("sets immutable cache-control for /assets/ files", async () => {
await writeFile(clientDir, "assets/chunk-aaa111.js", "code".repeat(100));
const cache = await StaticFileCache.create(clientDir);
const req = mockReq();
const { res, captured } = mockRes();
await tryServeStatic(req, res, clientDir, "/assets/chunk-aaa111.js", true, cache);
await captured.ended;
expect(captured.headers["Cache-Control"]).toBe("public, max-age=31536000, immutable");
});
// ── Extra headers merging ──────────────────────────────────────
it("merges extra headers into the response", async () => {
await writeFile(clientDir, "photo.jpg", Buffer.alloc(100));
const cache = await StaticFileCache.create(clientDir);
const req = mockReq();
const { res, captured } = mockRes();
const extraHeaders = { "X-Custom": "value", "Content-Security-Policy": "default-src 'self'" };
await tryServeStatic(req, res, clientDir, "/photo.jpg", false, cache, extraHeaders);
await captured.ended;
expect(captured.headers["X-Custom"]).toBe("value");
expect(captured.headers["Content-Security-Policy"]).toBe("default-src 'self'");
});
// ── Vary header ────────────────────────────────────────────────
it("sets Vary: Accept-Encoding when serving precompressed content", async () => {
const jsContent = "code\n".repeat(500);
await writeFile(clientDir, "assets/vary-bbb222.js", jsContent);
await writeFile(
clientDir,
"assets/vary-bbb222.js.br",
zlib.brotliCompressSync(Buffer.from(jsContent)),
);
const cache = await StaticFileCache.create(clientDir);
const req = mockReq("br");
const { res, captured } = mockRes();
await tryServeStatic(req, res, clientDir, "/assets/vary-bbb222.js", true, cache);
await captured.ended;
expect(captured.headers["Vary"]).toBe("Accept-Encoding");
});
// ── Directory traversal protection ─────────────────────────────
it("blocks directory traversal attempts", async () => {
await writeFile(clientDir, "assets/safe-ccc333.js", "safe");
const cache = await StaticFileCache.create(clientDir);
const req = mockReq();
const { res } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/../../../etc/passwd", true, cache);
expect(served).toBe(false);
});
it("blocks .vite/ internal directory access", async () => {
await writeFile(clientDir, ".vite/manifest.json", "{}");
const cache = await StaticFileCache.create(clientDir);
const req = mockReq();
const { res } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/.vite/manifest.json", true, cache);
expect(served).toBe(false);
});
// ── Async operation (no event loop blocking) ───────────────────
it("returns a Promise (async function)", async () => {
const cache = await StaticFileCache.create(clientDir);
const req = mockReq();
const { res } = mockRes();
const result = tryServeStatic(req, res, clientDir, "/nope", true, cache);
// Must return a Promise, not a boolean
expect(result).toBeInstanceOf(Promise);
});
// ── 304 Not Modified (conditional requests) ────────────────────
it("returns 304 when If-None-Match matches the ETag", async () => {
await writeFile(clientDir, "assets/cached-aaa111.js", "cached content");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/assets/cached-aaa111.js");
const etag = entry!.etag;
const req = mockReq(undefined, { "if-none-match": etag });
const { res, captured } = mockRes();
const served = await tryServeStatic(
req,
res,
clientDir,
"/assets/cached-aaa111.js",
true,
cache,
);
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(304);
expect(captured.body.length).toBe(0); // no body on 304
});
it("returns 304 when If-None-Match contains the ETag in a comma-separated list", async () => {
await writeFile(clientDir, "assets/cached-list-aaa111.js", "cached content");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/assets/cached-list-aaa111.js");
const etag = entry!.etag;
const req = mockReq(undefined, {
"if-none-match": `W/"other", ${etag}, W/"another"`,
});
const { res, captured } = mockRes();
const served = await tryServeStatic(
req,
res,
clientDir,
"/assets/cached-list-aaa111.js",
true,
cache,
);
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(304);
expect(captured.body.length).toBe(0);
});
it("returns 200 when If-None-Match does not match", async () => {
await writeFile(clientDir, "assets/stale-bbb222.js", "new content");
const cache = await StaticFileCache.create(clientDir);
const req = mockReq(undefined, { "if-none-match": 'W/"999-0"' });
const { res, captured } = mockRes();
const served = await tryServeStatic(
req,
res,
clientDir,
"/assets/stale-bbb222.js",
true,
cache,
);
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(200);
expect(captured.body.length).toBeGreaterThan(0);
});
it("returns 200 when no If-None-Match header is present", async () => {
await writeFile(clientDir, "assets/fresh-ccc333.js", "fresh content");
const cache = await StaticFileCache.create(clientDir);
const req = mockReq();
const { res, captured } = mockRes();
await tryServeStatic(req, res, clientDir, "/assets/fresh-ccc333.js", true, cache);
await captured.ended;
expect(captured.status).toBe(200);
});
it("304 response excludes Content-Type per RFC 9110", async () => {
await writeFile(clientDir, "assets/rfc-aaa111.js", "rfc content");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/assets/rfc-aaa111.js");
const req = mockReq(undefined, { "if-none-match": entry!.etag });
const { res, captured } = mockRes();
await tryServeStatic(req, res, clientDir, "/assets/rfc-aaa111.js", true, cache);
await captured.ended;
expect(captured.status).toBe(304);
expect(captured.headers["Content-Type"]).toBeUndefined();
});
it("304 response includes ETag and Cache-Control but no Content-Length", async () => {
await writeFile(clientDir, "assets/headers-ddd444.js", "header test content");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/assets/headers-ddd444.js");
const req = mockReq(undefined, { "if-none-match": entry!.etag });
const { res, captured } = mockRes();
await tryServeStatic(req, res, clientDir, "/assets/headers-ddd444.js", true, cache);
await captured.ended;
expect(captured.status).toBe(304);
expect(captured.headers["ETag"]).toBe(entry!.etag);
expect(captured.headers["Cache-Control"]).toBe("public, max-age=31536000, immutable");
expect(captured.headers["Content-Length"]).toBeUndefined();
});
// ── HEAD request optimization ──────────────────────────────────
it("HEAD request returns headers without streaming body", async () => {
const jsContent = "const x = 1;\n".repeat(200);
await writeFile(clientDir, "assets/head-eee555.js", jsContent);
const cache = await StaticFileCache.create(clientDir);
const req = mockReq(undefined, undefined, "HEAD");
const { res, captured } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/assets/head-eee555.js", true, cache);
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(200);
expect(captured.headers["Content-Type"]).toBe("application/javascript");
expect(captured.headers["Content-Length"]).toBe(String(jsContent.length));
expect(captured.body.length).toBe(0); // no body for HEAD
});
it("HEAD request includes compressed Content-Length when precompressed variant exists", async () => {
const jsContent = "const app = () => {};\n".repeat(200);
await writeFile(clientDir, "assets/head-br-fff666.js", jsContent);
const brContent = zlib.brotliCompressSync(Buffer.from(jsContent));
await writeFile(clientDir, "assets/head-br-fff666.js.br", brContent);
const cache = await StaticFileCache.create(clientDir);
const req = mockReq("br", undefined, "HEAD");
const { res, captured } = mockRes();
await tryServeStatic(req, res, clientDir, "/assets/head-br-fff666.js", true, cache);
await captured.ended;
expect(captured.status).toBe(200);
expect(captured.headers["Content-Encoding"]).toBe("br");
expect(captured.headers["Content-Length"]).toBe(String(brContent.length));
expect(captured.body.length).toBe(0);
});
// ── Zstandard precompressed serving ────────────────────────────
it("serves precompressed zstd when client accepts zstd", async () => {
const jsContent = "const zstd = true;\n".repeat(200);
await writeFile(clientDir, "assets/zstd-ggg777.js", jsContent);
const zstdContent = zlib.zstdCompressSync(Buffer.from(jsContent));
await writeFile(clientDir, "assets/zstd-ggg777.js.zst", zstdContent);
const cache = await StaticFileCache.create(clientDir);
const req = mockReq("zstd, br, gzip");
const { res, captured } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/assets/zstd-ggg777.js", true, cache);
await captured.ended;
expect(served).toBe(true);
expect(captured.headers["Content-Encoding"]).toBe("zstd");
expect(captured.headers["Content-Length"]).toBe(String(zstdContent.length));
// Verify content decompresses correctly
const decompressed = zlib.zstdDecompressSync(captured.body).toString();
expect(decompressed).toBe(jsContent);
});
it("prefers zstd over br when both accepted and available", async () => {
const jsContent = "const priority = true;\n".repeat(200);
await writeFile(clientDir, "assets/priority-hhh888.js", jsContent);
await writeFile(
clientDir,
"assets/priority-hhh888.js.zst",
zlib.zstdCompressSync(Buffer.from(jsContent)),
);
await writeFile(
clientDir,
"assets/priority-hhh888.js.br",
zlib.brotliCompressSync(Buffer.from(jsContent)),
);
const cache = await StaticFileCache.create(clientDir);
const req = mockReq("zstd, br, gzip");
const { res, captured } = mockRes();
await tryServeStatic(req, res, clientDir, "/assets/priority-hhh888.js", true, cache);
await captured.ended;
expect(captured.headers["Content-Encoding"]).toBe("zstd");
});
it("falls back to br when zstd accepted but no .zst file exists", async () => {
const jsContent = "const fallback = true;\n".repeat(200);
await writeFile(clientDir, "assets/fallback-iii999.js", jsContent);
await writeFile(
clientDir,
"assets/fallback-iii999.js.br",
zlib.brotliCompressSync(Buffer.from(jsContent)),
);
// No .zst file
const cache = await StaticFileCache.create(clientDir);
const req = mockReq("zstd, br, gzip");
const { res, captured } = mockRes();
await tryServeStatic(req, res, clientDir, "/assets/fallback-iii999.js", true, cache);
await captured.ended;
expect(captured.headers["Content-Encoding"]).toBe("br");
});
// ── Slow path (no cache) ───────────────────────────────────────
it("slow path serves static file without cache", async () => {
await writeFile(clientDir, "assets/nocache-aaa111.js", "slow path content");
const req = mockReq();
const { res, captured } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/assets/nocache-aaa111.js", false);
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(200);
expect(captured.headers["Content-Type"]).toBe("application/javascript");
expect(captured.body.toString()).toBe("slow path content");
});
it("slow path returns false for non-existent files", async () => {
const req = mockReq();
const { res } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/nope.js", false);
expect(served).toBe(false);
});
it("slow path serves HEAD without body", async () => {
await writeFile(clientDir, "assets/head-slow-bbb222.js", "head content");
const req = mockReq(undefined, undefined, "HEAD");
const { res, captured } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/assets/head-slow-bbb222.js", false);
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(200);
expect(captured.headers["Content-Length"]).toBe(String("head content".length));
expect(captured.body.length).toBe(0);
});
it("slow path serves HEAD without body for compressed response", async () => {
await writeFile(clientDir, "assets/head-slow-comp-ccc333.js", "compress me");
const req = mockReq("br", undefined, "HEAD");
const { res, captured } = mockRes();
const served = await tryServeStatic(
req,
res,
clientDir,
"/assets/head-slow-comp-ccc333.js",
true,
);
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(200);
expect(captured.headers["Content-Encoding"]).toBe("br");
expect(captured.body.length).toBe(0);
});
// ── Malformed pathname handling ─────────────────────────────────
it("returns false for malformed percent-encoded pathname", async () => {
await writeFile(clientDir, "assets/safe-ddd444.js", "safe");
const cache = await StaticFileCache.create(clientDir);
const req = mockReq();
const { res } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/%E0%A4%A", true, cache);
expect(served).toBe(false);
});
it("slow path returns false for malformed percent-encoded pathname", async () => {
const req = mockReq();
const { res } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/%E0%A4%A", false);
expect(served).toBe(false);
});
// ── Slow path 304 (conditional requests) ───────────────────────
it("slow path returns 304 for hashed asset with matching filename-hash ETag", async () => {
await writeFile(clientDir, "assets/etag-slow-abc123.js", "etag content");
// The slow path computes a filename-hash ETag for /assets/* files.
// Derive the expected ETag the same way etagFromFilenameHash does.
const req = mockReq(undefined, { "if-none-match": 'W/"abc123"' });
const { res, captured } = mockRes();
const served = await tryServeStatic(req, res, clientDir, "/assets/etag-slow-abc123.js", true);
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(304);
expect(captured.body.length).toBe(0);
expect(captured.headers["ETag"]).toBe('W/"abc123"');
expect(captured.headers["Cache-Control"]).toBe("public, max-age=31536000, immutable");
});
it("slow path returns 200 when ETag does not match", async () => {
await writeFile(clientDir, "assets/etag-slow-miss-xyz999.js", "fresh content");
const req = mockReq(undefined, { "if-none-match": 'W/"stale-etag"' });
const { res, captured } = mockRes();
const served = await tryServeStatic(
req,
res,
clientDir,
"/assets/etag-slow-miss-xyz999.js",
true,
);
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(200);
expect(captured.body.length).toBeGreaterThan(0);
});
it("slow path 304 includes Vary: Accept-Encoding for compressible content", async () => {
await writeFile(clientDir, "assets/vary-slow-abc123.js", "vary content");
const req = mockReq(undefined, { "if-none-match": 'W/"abc123"' });
const { res, captured } = mockRes();
await tryServeStatic(req, res, clientDir, "/assets/vary-slow-abc123.js", true);
await captured.ended;
expect(captured.status).toBe(304);
expect(captured.headers["Vary"]).toBe("Accept-Encoding");
});
it("slow path 304 omits Vary for non-compressible content (compress=false)", async () => {
await writeFile(clientDir, "photo.jpg", Buffer.alloc(100, 0xff));
// jpg mtime-based etag — we need to stat the file to get the right etag
const stat = await fsp.stat(path.join(clientDir, "photo.jpg"));
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
const req = mockReq(undefined, { "if-none-match": etag });
const { res, captured } = mockRes();
await tryServeStatic(req, res, clientDir, "/photo.jpg", false);
await captured.ended;
expect(captured.status).toBe(304);
expect(captured.headers["Vary"]).toBeUndefined();
});
});