From 8077a8206eb3f79fff456e3b7b54deac5fd723fb Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:54:10 +1100 Subject: [PATCH 01/10] 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. --- packages/vinext/src/build/precompress.ts | 133 ++++++++++++++++ tests/precompress.test.ts | 195 +++++++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 packages/vinext/src/build/precompress.ts create mode 100644 tests/precompress.test.ts diff --git a/packages/vinext/src/build/precompress.ts b/packages/vinext/src/build/precompress.ts new file mode 100644 index 00000000..40f005b8 --- /dev/null +++ b/packages/vinext/src/build/precompress.ts @@ -0,0 +1,133 @@ +/** + * Build-time precompression for hashed static assets. + * + * Generates .br (brotli quality 11) and .gz (gzip level 9) files alongside + * compressible assets in dist/client/assets/. These are served directly by + * the production server — no per-request compression needed for immutable + * build output. + * + * Only targets assets/ (hashed, immutable) — public directory files use + * on-the-fly compression since they may change between deploys. + */ +import fsp from "node:fs/promises"; +import path from "node:path"; +import zlib from "node:zlib"; +import { promisify } from "node:util"; + +const brotliCompress = promisify(zlib.brotliCompress); +const gzip = promisify(zlib.gzip); +const zstdCompress = promisify(zlib.zstdCompress); + +/** File extensions worth compressing (text-based, not already compressed). */ +const COMPRESSIBLE_EXTENSIONS = new Set([ + ".js", + ".mjs", + ".css", + ".html", + ".json", + ".xml", + ".svg", + ".txt", + ".map", + ".wasm", +]); + +/** Below this size, compression overhead exceeds savings. */ +const MIN_SIZE = 1024; + +export interface PrecompressResult { + filesCompressed: number; + totalOriginalBytes: number; + /** Smallest compressed variant per file (brotli, since it always wins). */ + totalCompressedBytes: number; +} + +/** + * Walk a directory recursively, yielding relative paths for regular files. + */ +async function* walkFiles(dir: string, base: string = dir): AsyncGenerator { + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch { + return; // directory doesn't exist + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + yield* walkFiles(fullPath, base); + } else if (entry.isFile()) { + yield path.relative(base, fullPath); + } + } +} + +/** + * Precompress all compressible hashed assets under `clientDir/assets/`. + * + * Writes `.br` and `.gz` files alongside each original. Idempotent — skips + * files that already have compressed variants, and never compresses `.br` + * or `.gz` files themselves. + */ +export async function precompressAssets(clientDir: string): Promise { + const assetsDir = path.join(clientDir, "assets"); + const result: PrecompressResult = { + filesCompressed: 0, + totalOriginalBytes: 0, + totalCompressedBytes: 0, + }; + + const compressionWork: Promise[] = []; + + for await (const relativePath of walkFiles(assetsDir)) { + const ext = path.extname(relativePath).toLowerCase(); + + // Skip non-compressible types and already-compressed variants + if (!COMPRESSIBLE_EXTENSIONS.has(ext)) continue; + if ( + relativePath.endsWith(".br") || + relativePath.endsWith(".gz") || + relativePath.endsWith(".zst") + ) + continue; + + const fullPath = path.join(assetsDir, relativePath); + const content = await fsp.readFile(fullPath); + + if (content.length < MIN_SIZE) continue; + + result.filesCompressed++; + result.totalOriginalBytes += content.length; + + // Compress both variants concurrently + compressionWork.push( + (async () => { + const [brContent, gzContent, zstdContent] = await Promise.all([ + brotliCompress(content, { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, + }, + }), + gzip(content, { level: zlib.constants.Z_BEST_COMPRESSION }), + zstdCompress(content, { + params: { + [zlib.constants.ZSTD_c_compressionLevel]: 19, // High compression (1-22, 19 is a good max) + }, + }), + ]); + + await Promise.all([ + fsp.writeFile(fullPath + ".br", brContent), + fsp.writeFile(fullPath + ".gz", gzContent), + fsp.writeFile(fullPath + ".zst", zstdContent), + ]); + + // Track brotli size (typically the smallest variant) + result.totalCompressedBytes += brContent.length; + })(), + ); + } + + await Promise.all(compressionWork); + return result; +} diff --git a/tests/precompress.test.ts b/tests/precompress.test.ts new file mode 100644 index 00000000..85ead6e8 --- /dev/null +++ b/tests/precompress.test.ts @@ -0,0 +1,195 @@ +/** + * 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 { + 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, "assets/index-abc123.js", jsContent); + + const result = await precompressAssets(clientDir); + + // Both compressed variants should exist on disk + expect(fs.existsSync(path.join(clientDir, "assets/index-abc123.js.br"))).toBe(true); + expect(fs.existsSync(path.join(clientDir, "assets/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, "assets/hello-def456.js", jsContent); + + await precompressAssets(clientDir); + + const brBuffer = await fsp.readFile(path.join(clientDir, "assets/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, "assets/styles-789abc.css", cssContent); + + await precompressAssets(clientDir); + + const gzBuffer = await fsp.readFile(path.join(clientDir, "assets/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, "assets/tiny-aaa111.js", "const x = 1;"); + + const result = await precompressAssets(clientDir); + + expect(fs.existsSync(path.join(clientDir, "assets/tiny-aaa111.js.br"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "assets/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, "assets/logo-bbb222.png", pngHeader.toString("binary")); + + // WOFF2 font (already compressed) + const fontData = Buffer.alloc(2048, 0x77); + await writeAsset(clientDir, "assets/font-ccc333.woff2", fontData.toString("binary")); + + const result = await precompressAssets(clientDir); + + expect(fs.existsSync(path.join(clientDir, "assets/logo-bbb222.png.br"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "assets/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.totalCompressedBytes).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, "assets/app-aaa111.js", jsContent); + await writeAsset(clientDir, "assets/app-bbb222.css", cssContent); + + const result = await precompressAssets(clientDir); + + expect(result.filesCompressed).toBe(2); + expect(fs.existsSync(path.join(clientDir, "assets/app-aaa111.js.br"))).toBe(true); + expect(fs.existsSync(path.join(clientDir, "assets/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, "assets/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, "assets/index-abc123.js.br.br"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "assets/index-abc123.js.gz.gz"))).toBe(false); + }); + + it("handles nested directories under assets/", async () => { + const jsContent = "export default {}\n".repeat(200); + await writeAsset(clientDir, "assets/chunks/vendor-ddd444.js", jsContent); + + const result = await precompressAssets(clientDir); + + expect(result.filesCompressed).toBe(1); + expect(fs.existsSync(path.join(clientDir, "assets/chunks/vendor-ddd444.js.br"))).toBe(true); + expect(fs.existsSync(path.join(clientDir, "assets/chunks/vendor-ddd444.js.gz"))).toBe(true); + }); + + it("only compresses files inside assets/ not other client files", async () => { + const htmlContent = "hello\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, "assets/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, "assets/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, "assets/zstd-aaa111.js", jsContent); + + const result = await precompressAssets(clientDir); + + expect(fs.existsSync(path.join(clientDir, "assets/zstd-aaa111.js.zst"))).toBe(true); + expect(fs.existsSync(path.join(clientDir, "assets/zstd-aaa111.js.br"))).toBe(true); + expect(fs.existsSync(path.join(clientDir, "assets/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, "assets/hello-zstd.js", jsContent); + + await precompressAssets(clientDir); + + const zstdBuffer = await fsp.readFile(path.join(clientDir, "assets/hello-zstd.js.zst")); + const decompressed = zlib.zstdDecompressSync(zstdBuffer).toString(); + expect(decompressed).toBe(jsContent); + }); + + it("reports total original and compressed byte sizes", async () => { + const jsContent = "const x = 1;\n".repeat(500); + await writeAsset(clientDir, "assets/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.totalCompressedBytes).toBeGreaterThan(0); + expect(result.totalCompressedBytes).toBeLessThan(result.totalOriginalBytes); + }); +}); From cfdd357fc681dfe7684ac03c135463b7612cefe0 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:54:21 +1100 Subject: [PATCH 02/10] 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. --- .../vinext/src/server/static-file-cache.ts | 285 ++++++++++++++++++ tests/static-file-cache.test.ts | 259 ++++++++++++++++ 2 files changed, 544 insertions(+) create mode 100644 packages/vinext/src/server/static-file-cache.ts create mode 100644 tests/static-file-cache.test.ts diff --git a/packages/vinext/src/server/static-file-cache.ts b/packages/vinext/src/server/static-file-cache.ts new file mode 100644 index 00000000..a0c395bf --- /dev/null +++ b/packages/vinext/src/server/static-file-cache.ts @@ -0,0 +1,285 @@ +/** + * Startup metadata cache for static file serving. + * + * Walks dist/client/ once at server boot, pre-computes response headers for + * every file variant (original, brotli, gzip, zstd), and caches everything + * in memory. The per-request hot path is just: Map.get() → string compare + * (ETag) → writeHead(precomputed) → pipe. + * + * Modeled after sirv's production mode. Key insight from sirv: pre-compute + * ALL response headers at startup — Content-Type, Content-Length, ETag, + * Cache-Control, Content-Encoding, Vary — as frozen objects. The per-request + * handler should do zero object allocation for headers. + */ +import fsp from "node:fs/promises"; +import path from "node:path"; + +/** Content-type lookup for static assets. Shared with prod-server.ts. */ +export const CONTENT_TYPES: Record = { + ".js": "application/javascript", + ".mjs": "application/javascript", + ".css": "text/css", + ".html": "text/html", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject", + ".webp": "image/webp", + ".avif": "image/avif", + ".map": "application/json", + ".rsc": "text/x-component", +}; + +/** + * Files below this size are buffered in memory at startup for zero-syscall + * serving via res.end(buffer). Above this, files stream via createReadStream. + * 64KB covers virtually all precompressed assets (a 200KB JS bundle compresses + * to ~40KB with brotli q11). + */ +const BUFFER_THRESHOLD = 64 * 1024; + +/** A servable file variant with pre-computed response headers. */ +export interface FileVariant { + /** Absolute file path (used for streaming large files). */ + path: string; + /** Pre-computed response headers. */ + headers: Record; + /** In-memory buffer for small files (below BUFFER_THRESHOLD). */ + buffer?: Buffer; +} + +export interface StaticFileEntry { + /** Weak ETag for conditional request matching. */ + etag: string; + /** Pre-computed headers for 304 Not Modified response. */ + notModifiedHeaders: Record; + /** Original file variant (uncompressed). */ + original: FileVariant; + /** Brotli precompressed variant, if .br file exists. */ + br?: FileVariant; + /** Gzip precompressed variant, if .gz file exists. */ + gz?: FileVariant; + /** Zstandard precompressed variant, if .zst file exists. */ + zst?: FileVariant; + + // Legacy accessors for backwards compatibility with tests + resolvedPath: string; + size: number; + contentType: string; + cacheControl: string; + brPath?: string; + brSize?: number; + gzPath?: string; + gzSize?: number; + zstPath?: string; + zstSize?: number; +} + +/** + * In-memory cache of static file metadata, populated once at server startup. + * + * Usage: + * const cache = await StaticFileCache.create(clientDir); + * const entry = cache.lookup("/assets/app-abc123.js"); + * // entry.br?.headers, entry.original.headers, etc. + */ +export class StaticFileCache { + private readonly entries: Map; + + private constructor(entries: Map) { + this.entries = entries; + } + + /** + * Scan the client directory and build the cache. + * + * Gracefully handles non-existent directories (returns an empty cache). + */ + static async create(clientDir: string): Promise { + const entries = new Map(); + + // First pass: collect all regular files with their metadata + const allFiles = new Map(); + + for await (const { relativePath, fullPath, stat } of walkFilesWithStats(clientDir)) { + allFiles.set(relativePath, { fullPath, size: stat.size, mtimeMs: stat.mtimeMs }); + } + + // Second pass: build cache entries with pre-computed headers per variant + for (const [relativePath, fileInfo] of allFiles) { + // Skip precompressed variants — they're linked to their originals + if ( + relativePath.endsWith(".br") || + relativePath.endsWith(".gz") || + relativePath.endsWith(".zst") + ) + continue; + + // Skip .vite/ internal directory + if (relativePath.startsWith(".vite/") || relativePath === ".vite") continue; + + const ext = path.extname(relativePath); + const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const isHashed = relativePath.startsWith("assets/"); + const cacheControl = isHashed + ? "public, max-age=31536000, immutable" + : "public, max-age=3600"; + const etag = `W/"${fileInfo.size}-${fileInfo.mtimeMs}"`; + + // Base headers shared by all variants (Content-Type, Cache-Control, ETag) + const baseHeaders = { + "Content-Type": contentType, + "Cache-Control": cacheControl, + ETag: etag, + }; + + // Pre-compute original variant headers + const original: FileVariant = { + path: fileInfo.fullPath, + headers: { ...baseHeaders, "Content-Length": String(fileInfo.size) }, + }; + + const entry: StaticFileEntry = { + etag, + notModifiedHeaders: { ...baseHeaders }, + original, + // Legacy accessors + resolvedPath: fileInfo.fullPath, + size: fileInfo.size, + contentType, + cacheControl, + }; + + // Pre-compute compressed variant headers (with Content-Encoding, Vary, correct Content-Length) + const brInfo = allFiles.get(relativePath + ".br"); + if (brInfo) { + entry.br = buildVariant(brInfo, baseHeaders, "br"); + entry.brPath = brInfo.fullPath; + entry.brSize = brInfo.size; + } + + const gzInfo = allFiles.get(relativePath + ".gz"); + if (gzInfo) { + entry.gz = buildVariant(gzInfo, baseHeaders, "gzip"); + entry.gzPath = gzInfo.fullPath; + entry.gzSize = gzInfo.size; + } + + const zstInfo = allFiles.get(relativePath + ".zst"); + if (zstInfo) { + entry.zst = buildVariant(zstInfo, baseHeaders, "zstd"); + entry.zstPath = zstInfo.fullPath; + entry.zstSize = zstInfo.size; + } + + // Register under the URL pathname (leading /) + const pathname = "/" + relativePath; + entries.set(pathname, entry); + + // Register HTML fallback aliases + if (ext === ".html") { + if (relativePath.endsWith("/index.html")) { + const dirPath = "/" + relativePath.slice(0, -"/index.html".length); + if (dirPath !== "/") { + entries.set(dirPath, entry); + } + } else { + const withoutExt = "/" + relativePath.slice(0, -ext.length); + entries.set(withoutExt, entry); + } + } + } + + // Third pass: buffer small files in memory for zero-syscall serving. + // For a 50KB JS bundle compressed to ~100 bytes, res.end(buffer) is ~2x + // faster than createReadStream().pipe() because it skips fd open/close + // and stream plumbing overhead. + const bufferReads: Promise[] = []; + for (const entry of entries.values()) { + for (const variant of [entry.original, entry.br, entry.gz, entry.zst]) { + if (!variant) continue; + const size = parseInt(variant.headers["Content-Length"], 10); + if (size <= BUFFER_THRESHOLD) { + bufferReads.push( + fsp.readFile(variant.path).then((buf) => { + variant.buffer = buf; + }), + ); + } + } + } + await Promise.all(bufferReads); + + return new StaticFileCache(entries); + } + + /** + * Look up cached metadata for a URL pathname. + * + * Returns undefined if the file is not in the cache. The root path "/" + * always returns undefined — index.html is served by SSR/RSC. + */ + lookup(pathname: string): StaticFileEntry | undefined { + if (pathname === "/") return undefined; + + // Block .vite/ access (including encoded variants that were decoded before lookup) + if (pathname.startsWith("/.vite/") || pathname === "/.vite") return undefined; + + return this.entries.get(pathname); + } +} + +function buildVariant( + info: { fullPath: string; size: number }, + baseHeaders: Record, + encoding: string, +): FileVariant { + return { + path: info.fullPath, + headers: { + ...baseHeaders, + "Content-Encoding": encoding, + "Content-Length": String(info.size), + Vary: "Accept-Encoding", + }, + }; +} + +/** + * Walk a directory recursively, yielding file paths and stats. + */ +async function* walkFilesWithStats( + dir: string, + base: string = dir, +): AsyncGenerator<{ + relativePath: string; + fullPath: string; + stat: { size: number; mtimeMs: number }; +}> { + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch { + return; // directory doesn't exist or unreadable + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + yield* walkFilesWithStats(fullPath, base); + } else if (entry.isFile()) { + const stat = await fsp.stat(fullPath); + yield { + relativePath: path.relative(base, fullPath), + fullPath, + stat: { size: stat.size, mtimeMs: stat.mtimeMs }, + }; + } + } +} diff --git a/tests/static-file-cache.test.ts b/tests/static-file-cache.test.ts new file mode 100644 index 00000000..14c1b9a1 --- /dev/null +++ b/tests/static-file-cache.test.ts @@ -0,0 +1,259 @@ +/** + * Tests for the startup metadata cache used by the production server. + * + * StaticFileCache walks dist/client/ once at server startup, caches file + * metadata (path, size, content-type, cache-control, etag, precompressed + * variant paths), and serves lookups from memory with zero filesystem calls. + */ +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"; + +/** Create a temp directory that mimics dist/client/ structure. */ +async function setupClientDir(): Promise { + const dir = path.join( + os.tmpdir(), + `vinext-cache-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await fsp.mkdir(dir, { recursive: true }); + return dir; +} + +async function writeFile( + clientDir: string, + relativePath: string, + content: string | Buffer, +): Promise { + const fullPath = path.join(clientDir, relativePath); + await fsp.mkdir(path.dirname(fullPath), { recursive: true }); + await fsp.writeFile(fullPath, content); +} + +describe("StaticFileCache", () => { + let clientDir: string; + + beforeEach(async () => { + clientDir = await setupClientDir(); + }); + + afterEach(async () => { + await fsp.rm(clientDir, { recursive: true, force: true }); + }); + + // ── Creation and scanning ────────────────────────────────────── + + it("creates a cache by scanning the client directory", async () => { + await writeFile(clientDir, "assets/app-abc123.js", "const x = 1;"); + const cache = await StaticFileCache.create(clientDir); + + expect(cache).toBeDefined(); + }); + + it("handles empty client directory", async () => { + const cache = await StaticFileCache.create(clientDir); + + expect(cache.lookup("/assets/nope.js")).toBeUndefined(); + }); + + it("handles non-existent client directory gracefully", async () => { + const cache = await StaticFileCache.create(path.join(clientDir, "does-not-exist")); + + expect(cache.lookup("/anything")).toBeUndefined(); + }); + + // ── Lookup ───────────────────────────────────────────────────── + + it("returns cached metadata for an existing file", async () => { + await writeFile(clientDir, "assets/index-abc123.js", "const x = 1;"); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/assets/index-abc123.js"); + + expect(entry).toBeDefined(); + expect(entry!.contentType).toBe("application/javascript"); + expect(entry!.size).toBe(12); // "const x = 1;" + expect(entry!.resolvedPath).toBe(path.join(clientDir, "assets/index-abc123.js")); + }); + + it("returns undefined for non-existent files", async () => { + await writeFile(clientDir, "assets/real-abc123.js", "x"); + + const cache = await StaticFileCache.create(clientDir); + + expect(cache.lookup("/assets/missing-xyz789.js")).toBeUndefined(); + }); + + it("sets immutable cache-control for hashed assets under /assets/", async () => { + await writeFile(clientDir, "assets/bundle-abc123.js", "x".repeat(100)); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/assets/bundle-abc123.js"); + + expect(entry!.cacheControl).toBe("public, max-age=31536000, immutable"); + }); + + it("sets short cache-control for non-hashed files", async () => { + await writeFile(clientDir, "favicon.ico", "icon-data"); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/favicon.ico"); + + expect(entry!.cacheControl).toBe("public, max-age=3600"); + }); + + it("generates weak etag from size and mtime", async () => { + await writeFile(clientDir, "assets/app-abc123.css", ".body { margin: 0; }"); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/assets/app-abc123.css"); + + // Etag should be W/"-" format (like sirv) + expect(entry!.etag).toMatch(/^W\/"\d+-\d+(\.\d+)?"$/); + }); + + // ── Precompressed variants ───────────────────────────────────── + + it("detects brotli precompressed variant", async () => { + const content = "const x = 1;\n".repeat(200); + await writeFile(clientDir, "assets/app-abc123.js", content); + // Simulate build-time precompression + const brContent = zlib.brotliCompressSync(Buffer.from(content)); + await writeFile(clientDir, "assets/app-abc123.js.br", brContent); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/assets/app-abc123.js"); + + expect(entry!.brPath).toBe(path.join(clientDir, "assets/app-abc123.js.br")); + expect(entry!.brSize).toBe(brContent.length); + }); + + it("detects gzip precompressed variant", async () => { + const content = "body { margin: 0; }\n".repeat(200); + await writeFile(clientDir, "assets/styles-def456.css", content); + const gzContent = zlib.gzipSync(Buffer.from(content)); + await writeFile(clientDir, "assets/styles-def456.css.gz", gzContent); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/assets/styles-def456.css"); + + expect(entry!.gzPath).toBe(path.join(clientDir, "assets/styles-def456.css.gz")); + expect(entry!.gzSize).toBe(gzContent.length); + }); + + it("detects zstandard precompressed variant", async () => { + const content = "const zstd = true;\n".repeat(200); + await writeFile(clientDir, "assets/app-zstd.js", content); + const zstdContent = zlib.zstdCompressSync(Buffer.from(content)); + await writeFile(clientDir, "assets/app-zstd.js.zst", zstdContent); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/assets/app-zstd.js"); + + expect(entry!.zstPath).toBe(path.join(clientDir, "assets/app-zstd.js.zst")); + expect(entry!.zstSize).toBe(zstdContent.length); + }); + + it("does not expose .br/.gz/.zst files as standalone entries", async () => { + const content = "const x = 1;\n".repeat(200); + await writeFile(clientDir, "assets/app-abc123.js", content); + await writeFile( + clientDir, + "assets/app-abc123.js.br", + zlib.brotliCompressSync(Buffer.from(content)), + ); + await writeFile(clientDir, "assets/app-abc123.js.gz", zlib.gzipSync(Buffer.from(content))); + await writeFile( + clientDir, + "assets/app-abc123.js.zst", + zlib.zstdCompressSync(Buffer.from(content)), + ); + + const cache = await StaticFileCache.create(clientDir); + + // .br, .gz, .zst should not be independently servable + expect(cache.lookup("/assets/app-abc123.js.br")).toBeUndefined(); + expect(cache.lookup("/assets/app-abc123.js.gz")).toBeUndefined(); + expect(cache.lookup("/assets/app-abc123.js.zst")).toBeUndefined(); + }); + + // ── HTML fallbacks ───────────────────────────────────────────── + + it("resolves .html extension fallback for prerendered pages", async () => { + await writeFile(clientDir, "about.html", "About"); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/about"); + + expect(entry).toBeDefined(); + expect(entry!.resolvedPath).toBe(path.join(clientDir, "about.html")); + expect(entry!.contentType).toBe("text/html"); + }); + + it("resolves /index.html fallback for directory paths", async () => { + await writeFile(clientDir, "blog/index.html", "Blog"); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/blog"); + + expect(entry).toBeDefined(); + expect(entry!.resolvedPath).toBe(path.join(clientDir, "blog/index.html")); + }); + + // ── Directory traversal protection ───────────────────────────── + + it("blocks .vite/ internal directory access", async () => { + await writeFile(clientDir, ".vite/manifest.json", "{}"); + + const cache = await StaticFileCache.create(clientDir); + + expect(cache.lookup("/.vite/manifest.json")).toBeUndefined(); + }); + + it("skips root / path", async () => { + await writeFile(clientDir, "index.html", "Root"); + + const cache = await StaticFileCache.create(clientDir); + + // Root index.html is served by SSR/RSC, not static serving + expect(cache.lookup("/")).toBeUndefined(); + }); + + // ── Content type detection ───────────────────────────────────── + + it("detects content types from file extensions", async () => { + await writeFile(clientDir, "assets/style-aaa.css", "body{}"); + await writeFile(clientDir, "assets/data-bbb.json", "{}"); + await writeFile(clientDir, "logo.svg", ""); + await writeFile(clientDir, "photo.webp", "webp-data"); + + const cache = await StaticFileCache.create(clientDir); + + expect(cache.lookup("/assets/style-aaa.css")!.contentType).toBe("text/css"); + expect(cache.lookup("/assets/data-bbb.json")!.contentType).toBe("application/json"); + expect(cache.lookup("/logo.svg")!.contentType).toBe("image/svg+xml"); + expect(cache.lookup("/photo.webp")!.contentType).toBe("image/webp"); + }); + + it("falls back to application/octet-stream for unknown extensions", async () => { + await writeFile(clientDir, "assets/data-ccc.xyz", "unknown-data"); + + const cache = await StaticFileCache.create(clientDir); + + expect(cache.lookup("/assets/data-ccc.xyz")!.contentType).toBe("application/octet-stream"); + }); + + // ── Nested directory scanning ────────────────────────────────── + + it("scans nested directories recursively", async () => { + await writeFile(clientDir, "assets/chunks/vendor-aaa.js", "vendor code"); + await writeFile(clientDir, "assets/chunks/lazy/page-bbb.js", "page code"); + + const cache = await StaticFileCache.create(clientDir); + + expect(cache.lookup("/assets/chunks/vendor-aaa.js")).toBeDefined(); + expect(cache.lookup("/assets/chunks/lazy/page-bbb.js")).toBeDefined(); + }); +}); From baf3573871f684227804c12145453df83331fb84 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:54:32 +1100 Subject: [PATCH 03/10] 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). --- packages/vinext/src/cli.ts | 14 + packages/vinext/src/server/prod-server.ts | 259 ++++++++---- tests/serve-static.test.ts | 470 ++++++++++++++++++++++ 3 files changed, 666 insertions(+), 77 deletions(-) create mode 100644 tests/serve-static.test.ts diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 75389d1d..bae92082 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -16,6 +16,7 @@ import vinext, { clientOutputConfig, clientTreeshakeConfig } from "./index.js"; import { printBuildReport } from "./build/report.js"; import { runPrerender } from "./build/run-prerender.js"; +import { precompressAssets } from "./build/precompress.js"; import path from "node:path"; import fs from "node:fs"; import { pathToFileURL } from "node:url"; @@ -509,6 +510,19 @@ async function buildApp() { prerenderResult = await runPrerender({ root: process.cwd() }); } + // Precompress hashed assets (brotli q11 + gzip l9). These .br/.gz files + // are served directly by the production server — zero per-request + // compression overhead for immutable build output. + const clientDir = path.resolve("dist", "client"); + const precompressResult = await precompressAssets(clientDir); + if (precompressResult.filesCompressed > 0) { + const ratio = ( + (1 - precompressResult.totalCompressedBytes / precompressResult.totalOriginalBytes) * + 100 + ).toFixed(1); + console.log(` Precompressed ${precompressResult.filesCompressed} assets (${ratio}% smaller)`); + } + process.stdout.write("\x1b[0m"); await printBuildReport({ root: process.cwd(), diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 14c62e46..bffcc866 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -21,8 +21,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import { Readable, pipeline } from "node:stream"; import { pathToFileURL } from "node:url"; import fs from "node:fs"; +import fsp from "node:fs/promises"; import path from "node:path"; import zlib from "node:zlib"; +import { StaticFileCache, CONTENT_TYPES } from "./static-file-cache.js"; import { matchRedirect, matchRewrite, @@ -97,12 +99,17 @@ const COMPRESS_THRESHOLD = 1024; /** * Parse the Accept-Encoding header and return the best supported encoding. - * Preference order: br > gzip > deflate > identity. + * Preference order: zstd > br > gzip > deflate > identity. + * + * zstd decompresses ~3-5x faster than brotli at similar compression ratios. + * Supported in Chrome 123+, Firefox 126+. Safari can decompress but doesn't + * send zstd in Accept-Encoding, so it transparently falls back to br/gzip. */ -function negotiateEncoding(req: IncomingMessage): "br" | "gzip" | "deflate" | null { +function negotiateEncoding(req: IncomingMessage): "zstd" | "br" | "gzip" | "deflate" | null { const accept = req.headers["accept-encoding"]; if (!accept || typeof accept !== "string") return null; const lower = accept.toLowerCase(); + if (lower.includes("zstd")) return "zstd"; if (lower.includes("br")) return "br"; if (lower.includes("gzip")) return "gzip"; if (lower.includes("deflate")) return "deflate"; @@ -113,10 +120,14 @@ function negotiateEncoding(req: IncomingMessage): "br" | "gzip" | "deflate" | nu * Create a compression stream for the given encoding. */ function createCompressor( - encoding: "br" | "gzip" | "deflate", + encoding: "zstd" | "br" | "gzip" | "deflate", mode: "default" | "streaming" = "default", -): zlib.BrotliCompress | zlib.Gzip | zlib.Deflate { +): zlib.ZstdCompress | zlib.BrotliCompress | zlib.Gzip | zlib.Deflate { switch (encoding) { + case "zstd": + return zlib.createZstdCompress({ + params: { [zlib.constants.ZSTD_c_compressionLevel]: 3 }, // Fast for on-the-fly + }); case "br": return zlib.createBrotliCompress({ ...(mode === "streaming" ? { flush: zlib.constants.BROTLI_OPERATION_FLUSH } : {}), @@ -349,42 +360,96 @@ function sendCompressed( } } -/** Content-type lookup for static assets. */ -const CONTENT_TYPES: Record = { - ".js": "application/javascript", - ".mjs": "application/javascript", - ".css": "text/css", - ".html": "text/html", - ".json": "application/json", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".ttf": "font/ttf", - ".eot": "application/vnd.ms-fontobject", - ".webp": "image/webp", - ".avif": "image/avif", - ".map": "application/json", - ".rsc": "text/x-component", -}; - /** * Try to serve a static file from the client build directory. - * Returns true if the file was served, false otherwise. + * + * When a `StaticFileCache` is provided, lookups are pure in-memory Map.get() + * with zero filesystem calls. Precompressed .br/.gz variants (generated at + * build time) are served directly — no per-request compression needed for + * hashed assets. + * + * Without a cache, falls back to async filesystem probing (still non-blocking, + * unlike the old sync existsSync/statSync approach). */ -function tryServeStatic( +async function tryServeStatic( req: IncomingMessage, res: ServerResponse, clientDir: string, pathname: string, compress: boolean, + cache?: StaticFileCache, extraHeaders?: Record, -): boolean { - // Resolve the path and guard against directory traversal (e.g. /../../../etc/passwd) +): Promise { + if (pathname === "/") return false; + + // ── Fast path: pre-computed headers, minimal per-request work ── + // When a cache is provided, all path validation happened at startup. + // The only per-request work: Map.get(), string compare, pipe. + if (cache) { + // Decode only when needed (hashed /assets/ URLs never have %) + let lookupPath: string; + if (pathname.includes("%")) { + try { + lookupPath = decodeURIComponent(pathname); + } catch { + return false; + } + // Block encoded .vite/ access (e.g. /%2Evite/manifest.json) + if (lookupPath.startsWith("/.vite/") || lookupPath === "/.vite") return false; + } else { + // Fast: skip decode entirely for clean URLs + if (pathname.startsWith("/.vite/") || pathname === "/.vite") return false; + lookupPath = pathname; + } + + const entry = cache.lookup(lookupPath); + if (!entry) return false; + + // 304 Not Modified: string compare against pre-computed ETag + if (req.headers["if-none-match"] === entry.etag) { + if (extraHeaders) { + res.writeHead(304, { ...entry.notModifiedHeaders, ...extraHeaders }); + } else { + res.writeHead(304, entry.notModifiedHeaders); + } + res.end(); + return true; + } + + // Pick the best precompressed variant: zstd → br → gzip → original. + // Each variant has pre-computed headers — zero string building. + // Accept-Encoding is already lowercase in Node.js IncomingMessage. + const ae = compress ? req.headers["accept-encoding"] : undefined; + const variant = + typeof ae === "string" + ? (ae.includes("zstd") && entry.zst) || + (ae.includes("br") && entry.br) || + (ae.includes("gzip") && entry.gz) || + entry.original + : entry.original; + + if (extraHeaders) { + res.writeHead(200, { ...variant.headers, ...extraHeaders }); + } else { + res.writeHead(200, variant.headers); + } + + if (req.method === "HEAD") { + res.end(); + return true; + } + + // Small files: serve from in-memory buffer (no fd open/close overhead). + // Large files: stream from disk to avoid holding them in the heap. + if (variant.buffer) { + res.end(variant.buffer); + } else { + fs.createReadStream(variant.path).pipe(res); + } + return true; + } + + // ── Slow path: async filesystem probe (no cache) ─────────────── const resolvedClient = path.resolve(clientDir); let decodedPathname: string; try { @@ -392,51 +457,21 @@ function tryServeStatic( } catch { return false; } - - // Block access to internal build metadata directories. The .vite/ - // directory contains manifests and other build artifacts that should - // not be publicly served. Check after decoding to catch encoded - // variants like /%2Evite/manifest.json. - if (decodedPathname.startsWith("/.vite/") || decodedPathname === "/.vite") { - return false; - } + if (decodedPathname.startsWith("/.vite/") || decodedPathname === "/.vite") return false; const staticFile = path.resolve(clientDir, "." + decodedPathname); if (!staticFile.startsWith(resolvedClient + path.sep) && staticFile !== resolvedClient) { return false; } - // Resolve the actual file to serve. For extension-less paths (prerendered - // pages like /about → about.html, /blog/post → blog/post.html), try: - // 1. The exact path (e.g. /about.css, /assets/foo.js) - // 2. .html (e.g. /about → about.html) - // 3. /index.html (e.g. /about/ → about/index.html) - // Pathname "/" is always skipped — the index.html is served by SSR/RSC. - let resolvedStaticFile = staticFile; - if (pathname === "/") { - return false; - } - if (!fs.existsSync(resolvedStaticFile) || !fs.statSync(resolvedStaticFile).isFile()) { - // Try .html extension fallback for prerendered pages - const htmlFallback = staticFile + ".html"; - if (fs.existsSync(htmlFallback) && fs.statSync(htmlFallback).isFile()) { - resolvedStaticFile = htmlFallback; - } else { - // Try index.html inside directory (trailing-slash variant) - const indexFallback = path.join(staticFile, "index.html"); - if (fs.existsSync(indexFallback) && fs.statSync(indexFallback).isFile()) { - resolvedStaticFile = indexFallback; - } else { - return false; - } - } - } + const resolved = await resolveStaticFile(staticFile); + if (!resolved) return false; - const ext = path.extname(resolvedStaticFile); + const ext = path.extname(resolved.path); const ct = CONTENT_TYPES[ext] ?? "application/octet-stream"; const isHashed = pathname.startsWith("/assets/"); const cacheControl = isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600"; - const baseHeaders = { + const baseHeaders: Record = { "Content-Type": ct, "Cache-Control": cacheControl, ...extraHeaders, @@ -446,25 +481,58 @@ function tryServeStatic( if (compress && COMPRESSIBLE_TYPES.has(baseType)) { const encoding = negotiateEncoding(req); if (encoding) { - const fileStream = fs.createReadStream(resolvedStaticFile); const compressor = createCompressor(encoding); res.writeHead(200, { ...baseHeaders, "Content-Encoding": encoding, Vary: "Accept-Encoding", }); - pipeline(fileStream, compressor, res, () => { - /* ignore */ - }); + pipeline(fs.createReadStream(resolved.path), compressor, res, () => {}); return true; } } - res.writeHead(200, baseHeaders); - fs.createReadStream(resolvedStaticFile).pipe(res); + res.writeHead(200, { + ...baseHeaders, + "Content-Length": String(resolved.size), + }); + pipeline(fs.createReadStream(resolved.path), res, () => {}); return true; } +interface ResolvedFile { + path: string; + size: number; +} + +/** + * Resolve the actual file to serve, trying extension-less HTML fallbacks. + * Returns the resolved path + size, or null if not found. + */ +async function resolveStaticFile(staticFile: string): Promise { + const stat = await statIfFile(staticFile); + if (stat) return { path: staticFile, size: stat.size }; + + const htmlFallback = staticFile + ".html"; + const htmlStat = await statIfFile(htmlFallback); + if (htmlStat) return { path: htmlFallback, size: htmlStat.size }; + + const indexFallback = path.join(staticFile, "index.html"); + const indexStat = await statIfFile(indexFallback); + if (indexStat) return { path: indexFallback, size: indexStat.size }; + + return null; +} + +async function statIfFile(filePath: string): Promise<{ size: number } | null> { + try { + const stat = await fsp.stat(filePath); + return stat.isFile() ? { size: stat.size } : null; + } catch { + return null; + } +} + /** * Resolve the host for a request, ignoring X-Forwarded-Host to prevent * host header poisoning attacks (open redirects, cache poisoning). @@ -779,6 +847,11 @@ async function startAppRouterServer(options: AppRouterServerOptions) { ); } + // Build the static file metadata cache at startup. Eliminates per-request + // stat() calls — all lookups are pure in-memory Map.get(). Precompressed + // .br/.gz variants (generated at build time) are detected automatically. + const staticCache = await StaticFileCache.create(clientDir); + const server = createServer(async (req, res) => { const rawUrl = req.url ?? "/"; // Normalize backslashes (browsers treat /\ as //), then decode and normalize path. @@ -828,7 +901,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { // middleware before serving them. if ( pathname.startsWith("/assets/") && - tryServeStatic(req, res, clientDir, pathname, compress) + (await tryServeStatic(req, res, clientDir, pathname, compress, staticCache)) ) { return; } @@ -861,7 +934,17 @@ async function startAppRouterServer(options: AppRouterServerOptions) { "Content-Disposition": imageConfig?.contentDispositionType === "attachment" ? "attachment" : "inline", }; - if (tryServeStatic(req, res, clientDir, params.imageUrl, false, imageSecurityHeaders)) { + if ( + await tryServeStatic( + req, + res, + clientDir, + params.imageUrl, + false, + staticCache, + imageSecurityHeaders, + ) + ) { return; } res.writeHead(404); @@ -988,6 +1071,9 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { } } + // Build the static file metadata cache at startup (same as App Router). + const staticCache = await StaticFileCache.create(clientDir); + const server = createServer(async (req, res) => { const rawUrl = req.url ?? "/"; // Normalize backslashes (browsers treat /\ as //), then decode and normalize path. @@ -1066,7 +1152,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { const staticLookupPath = stripBasePath(pathname, basePath); if ( staticLookupPath.startsWith("/assets/") && - tryServeStatic(req, res, clientDir, staticLookupPath, compress) + (await tryServeStatic(req, res, clientDir, staticLookupPath, compress, staticCache)) ) { return; } @@ -1096,7 +1182,17 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { "Content-Disposition": pagesImageConfig?.contentDispositionType === "attachment" ? "attachment" : "inline", }; - if (tryServeStatic(req, res, clientDir, params.imageUrl, false, imageSecurityHeaders)) { + if ( + await tryServeStatic( + req, + res, + clientDir, + params.imageUrl, + false, + staticCache, + imageSecurityHeaders, + ) + ) { return; } res.writeHead(404); @@ -1314,7 +1410,15 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { staticLookupPath !== "/" && !staticLookupPath.startsWith("/api/") && !staticLookupPath.startsWith("/assets/") && - tryServeStatic(req, res, clientDir, staticLookupPath, compress, middlewareHeaders) + (await tryServeStatic( + req, + res, + clientDir, + staticLookupPath, + compress, + staticCache, + middlewareHeaders, + )) ) { return; } @@ -1477,4 +1581,5 @@ export { nodeToWebRequest, mergeResponseHeaders, mergeWebResponse, + tryServeStatic, }; diff --git a/tests/serve-static.test.ts b/tests/serve-static.test.ts new file mode 100644 index 00000000..7481746d --- /dev/null +++ b/tests/serve-static.test.ts @@ -0,0 +1,470 @@ +/** + * 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 { + 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, + method: string = "GET", +): IncomingMessage { + const headers: Record = { ...extraHeaders }; + if (acceptEncoding) headers["accept-encoding"] = acceptEncoding; + return { headers, method } as unknown as IncomingMessage; +} + +interface CapturedResponse { + status: number; + headers: Record; + body: Buffer; + ended: Promise; +} + +/** + * 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((r) => { + resolveEnded = r; + }); + + const captured: CapturedResponse = { + status: 0, + headers: {}, + body: Buffer.alloc(0), + ended, + }; + + const res = { + writeHead(status: number, headers: Record) { + 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 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 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"); + }); +}); From 935072164190b9a8bb60fae8affb75ab9a731dd4 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:30:51 +1100 Subject: [PATCH 04/10] docs: fix stale comments in precompress (mention .zst) --- packages/vinext/src/build/precompress.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/build/precompress.ts b/packages/vinext/src/build/precompress.ts index 40f005b8..dc4e52c5 100644 --- a/packages/vinext/src/build/precompress.ts +++ b/packages/vinext/src/build/precompress.ts @@ -65,9 +65,9 @@ async function* walkFiles(dir: string, base: string = dir): AsyncGenerator { const assetsDir = path.join(clientDir, "assets"); @@ -99,7 +99,7 @@ export async function precompressAssets(clientDir: string): Promise { const [brContent, gzContent, zstdContent] = await Promise.all([ From f8969191e07bc28c7e13e33a3d1492beaafc610c Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:33:23 +1100 Subject: [PATCH 05/10] fix: deduplicate buffer reads for HTML alias entries, fix stale JSDoc --- packages/vinext/src/build/precompress.ts | 4 ++-- packages/vinext/src/server/prod-server.ts | 2 +- packages/vinext/src/server/static-file-cache.ts | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/build/precompress.ts b/packages/vinext/src/build/precompress.ts index dc4e52c5..ae97289b 100644 --- a/packages/vinext/src/build/precompress.ts +++ b/packages/vinext/src/build/precompress.ts @@ -1,8 +1,8 @@ /** * Build-time precompression for hashed static assets. * - * Generates .br (brotli quality 11) and .gz (gzip level 9) files alongside - * compressible assets in dist/client/assets/. These are served directly by + * Generates .br (brotli q11), .gz (gzip l9), and .zst (zstd l19) files + * alongside compressible assets in dist/client/assets/. Served directly by * the production server — no per-request compression needed for immutable * build output. * diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index bffcc866..406a696a 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -364,7 +364,7 @@ function sendCompressed( * Try to serve a static file from the client build directory. * * When a `StaticFileCache` is provided, lookups are pure in-memory Map.get() - * with zero filesystem calls. Precompressed .br/.gz variants (generated at + * with zero filesystem calls. Precompressed .br/.gz/.zst variants (generated at * build time) are served directly — no per-request compression needed for * hashed assets. * diff --git a/packages/vinext/src/server/static-file-cache.ts b/packages/vinext/src/server/static-file-cache.ts index a0c395bf..d3fbe5a2 100644 --- a/packages/vinext/src/server/static-file-cache.ts +++ b/packages/vinext/src/server/static-file-cache.ts @@ -202,9 +202,11 @@ export class StaticFileCache { // faster than createReadStream().pipe() because it skips fd open/close // and stream plumbing overhead. const bufferReads: Promise[] = []; + const bufferedPaths = new Set(); for (const entry of entries.values()) { for (const variant of [entry.original, entry.br, entry.gz, entry.zst]) { - if (!variant) continue; + if (!variant || bufferedPaths.has(variant.path)) continue; + bufferedPaths.add(variant.path); const size = parseInt(variant.headers["Content-Length"], 10); if (size <= BUFFER_THRESHOLD) { bufferReads.push( From df7782142b9d39db31c18ad65e39da9bec578d1e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:37:21 +1100 Subject: [PATCH 06/10] docs: fix stale comment in cli.ts (mention zstd) --- packages/vinext/src/cli.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index bae92082..401f4512 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -510,9 +510,9 @@ async function buildApp() { prerenderResult = await runPrerender({ root: process.cwd() }); } - // Precompress hashed assets (brotli q11 + gzip l9). These .br/.gz files - // are served directly by the production server — zero per-request - // compression overhead for immutable build output. + // Precompress hashed assets (brotli q11 + gzip l9 + zstd l19). These + // .br/.gz/.zst files are served directly by the production server — + // zero per-request compression overhead for immutable build output. const clientDir = path.resolve("dist", "client"); const precompressResult = await precompressAssets(clientDir); if (precompressResult.filesCompressed > 0) { From ac936e76b2d43dc7c3cba5ec5de7465c7670b25c Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:20:00 +1100 Subject: [PATCH 07/10] 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) --- packages/vinext/src/build/precompress.ts | 62 ++++++++++------- packages/vinext/src/cli.ts | 28 +++++--- packages/vinext/src/server/prod-server.ts | 4 +- .../vinext/src/server/static-file-cache.ts | 59 ++++++++-------- tests/serve-static.test.ts | 16 +++++ tests/static-file-cache.test.ts | 68 +++++++++++++------ 6 files changed, 154 insertions(+), 83 deletions(-) diff --git a/packages/vinext/src/build/precompress.ts b/packages/vinext/src/build/precompress.ts index ae97289b..b63a9db3 100644 --- a/packages/vinext/src/build/precompress.ts +++ b/packages/vinext/src/build/precompress.ts @@ -1,7 +1,7 @@ /** * Build-time precompression for hashed static assets. * - * Generates .br (brotli q11), .gz (gzip l9), and .zst (zstd l19) files + * Generates .br (brotli q5), .gz (gzip l9), and .zst (zstd l22) files * alongside compressible assets in dist/client/assets/. Served directly by * the production server — no per-request compression needed for immutable * build output. @@ -10,13 +10,14 @@ * on-the-fly compression since they may change between deploys. */ import fsp from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import zlib from "node:zlib"; import { promisify } from "node:util"; const brotliCompress = promisify(zlib.brotliCompress); const gzip = promisify(zlib.gzip); -const zstdCompress = promisify(zlib.zstdCompress); +const zstdCompress = typeof zlib.zstdCompress === "function" ? promisify(zlib.zstdCompress) : null; /** File extensions worth compressing (text-based, not already compressed). */ const COMPRESSIBLE_EXTENSIONS = new Set([ @@ -35,10 +36,13 @@ const COMPRESSIBLE_EXTENSIONS = new Set([ /** Below this size, compression overhead exceeds savings. */ const MIN_SIZE = 1024; +/** Max files to compress concurrently (avoids memory spikes). */ +const CONCURRENCY = Math.min(os.availableParallelism(), 16); + export interface PrecompressResult { filesCompressed: number; totalOriginalBytes: number; - /** Smallest compressed variant per file (brotli, since it always wins). */ + /** Sum of brotli-compressed sizes (used for compression ratio reporting). */ totalCompressedBytes: number; } @@ -77,7 +81,8 @@ export async function precompressAssets(clientDir: string): Promise[] = []; + // Collect compressible files first, then process in bounded chunks + const files: { fullPath: string; content: Buffer }[] = []; for await (const relativePath of walkFiles(assetsDir)) { const ext = path.extname(relativePath).toLowerCase(); @@ -96,38 +101,47 @@ export async function precompressAssets(clientDir: string): Promise { - const [brContent, gzContent, zstdContent] = await Promise.all([ + // Process in chunks to bound concurrent CPU-heavy compressions + for (let i = 0; i < files.length; i += CONCURRENCY) { + const chunk = files.slice(i, i + CONCURRENCY); + await Promise.all( + chunk.map(async ({ fullPath, content }) => { + // Compress all variants concurrently within each file + const compressions: Promise[] = [ brotliCompress(content, { - params: { - [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, - }, + params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 }, }), gzip(content, { level: zlib.constants.Z_BEST_COMPRESSION }), - zstdCompress(content, { - params: { - [zlib.constants.ZSTD_c_compressionLevel]: 19, // High compression (1-22, 19 is a good max) - }, - }), - ]); - - await Promise.all([ + ]; + if (zstdCompress) { + compressions.push( + zstdCompress(content, { + params: { [zlib.constants.ZSTD_c_compressionLevel]: 22 }, + }), + ); + } + + const results = await Promise.all(compressions); + const [brContent, gzContent, zstdContent] = results; + + const writes = [ fsp.writeFile(fullPath + ".br", brContent), fsp.writeFile(fullPath + ".gz", gzContent), - fsp.writeFile(fullPath + ".zst", zstdContent), - ]); + ]; + if (zstdContent) { + writes.push(fsp.writeFile(fullPath + ".zst", zstdContent)); + } + await Promise.all(writes); - // Track brotli size (typically the smallest variant) result.totalCompressedBytes += brContent.length; - })(), + }), ); } - await Promise.all(compressionWork); return result; } diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 401f4512..4cae6947 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -99,6 +99,7 @@ interface ParsedArgs { turbopack?: boolean; // accepted for compat, always ignored experimental?: boolean; // accepted for compat, always ignored prerenderAll?: boolean; + noPrecompress?: boolean; } function parseArgs(args: string[]): ParsedArgs { @@ -115,6 +116,8 @@ function parseArgs(args: string[]): ParsedArgs { result.experimental = true; // no-op } else if (arg === "--prerender-all") { result.prerenderAll = true; + } else if (arg === "--no-precompress") { + result.noPrecompress = true; } else if (arg === "--port" || arg === "-p") { result.port = parseInt(args[++i], 10); } else if (arg.startsWith("--port=")) { @@ -510,17 +513,22 @@ async function buildApp() { prerenderResult = await runPrerender({ root: process.cwd() }); } - // Precompress hashed assets (brotli q11 + gzip l9 + zstd l19). These + // Precompress hashed assets (brotli q5 + gzip l9 + zstd l22). These // .br/.gz/.zst files are served directly by the production server — // zero per-request compression overhead for immutable build output. - const clientDir = path.resolve("dist", "client"); - const precompressResult = await precompressAssets(clientDir); - if (precompressResult.filesCompressed > 0) { - const ratio = ( - (1 - precompressResult.totalCompressedBytes / precompressResult.totalOriginalBytes) * - 100 - ).toFixed(1); - console.log(` Precompressed ${precompressResult.filesCompressed} assets (${ratio}% smaller)`); + // Skip when deploying behind a CDN that handles compression (e.g. Cloudflare). + if (!parsed.noPrecompress) { + const clientDir = path.resolve("dist", "client"); + const precompressResult = await precompressAssets(clientDir); + if (precompressResult.filesCompressed > 0) { + const ratio = ( + (1 - precompressResult.totalCompressedBytes / precompressResult.totalOriginalBytes) * + 100 + ).toFixed(1); + console.log( + ` Precompressed ${precompressResult.filesCompressed} assets (${ratio}% smaller)`, + ); + } } process.stdout.write("\x1b[0m"); @@ -691,6 +699,8 @@ function printHelp(cmd?: string) { --verbose Show full Vite/Rollup build output (suppressed by default) --prerender-all Pre-render discovered routes after building (future releases will serve these files in vinext start) + --no-precompress Skip build-time precompression of static assets (useful when + deploying behind a CDN that handles compression) -h, --help Show this help `); return; diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 406a696a..2e026d13 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -418,7 +418,7 @@ async function tryServeStatic( // Pick the best precompressed variant: zstd → br → gzip → original. // Each variant has pre-computed headers — zero string building. - // Accept-Encoding is already lowercase in Node.js IncomingMessage. + // Accept-Encoding header values from browsers use lowercase tokens. const ae = compress ? req.headers["accept-encoding"] : undefined; const variant = typeof ae === "string" @@ -444,7 +444,7 @@ async function tryServeStatic( if (variant.buffer) { res.end(variant.buffer); } else { - fs.createReadStream(variant.path).pipe(res); + pipeline(fs.createReadStream(variant.path), res, () => {}); } return true; } diff --git a/packages/vinext/src/server/static-file-cache.ts b/packages/vinext/src/server/static-file-cache.ts index d3fbe5a2..ea0daebb 100644 --- a/packages/vinext/src/server/static-file-cache.ts +++ b/packages/vinext/src/server/static-file-cache.ts @@ -41,7 +41,7 @@ export const CONTENT_TYPES: Record = { * Files below this size are buffered in memory at startup for zero-syscall * serving via res.end(buffer). Above this, files stream via createReadStream. * 64KB covers virtually all precompressed assets (a 200KB JS bundle compresses - * to ~40KB with brotli q11). + * to ~50KB with brotli q5). */ const BUFFER_THRESHOLD = 64 * 1024; @@ -68,18 +68,6 @@ export interface StaticFileEntry { gz?: FileVariant; /** Zstandard precompressed variant, if .zst file exists. */ zst?: FileVariant; - - // Legacy accessors for backwards compatibility with tests - resolvedPath: string; - size: number; - contentType: string; - cacheControl: string; - brPath?: string; - brSize?: number; - gzPath?: string; - gzSize?: number; - zstPath?: string; - zstSize?: number; } /** @@ -148,35 +136,31 @@ export class StaticFileCache { const entry: StaticFileEntry = { etag, - notModifiedHeaders: { ...baseHeaders }, + notModifiedHeaders: { ETag: etag, "Cache-Control": cacheControl }, original, - // Legacy accessors - resolvedPath: fileInfo.fullPath, - size: fileInfo.size, - contentType, - cacheControl, }; // Pre-compute compressed variant headers (with Content-Encoding, Vary, correct Content-Length) const brInfo = allFiles.get(relativePath + ".br"); if (brInfo) { entry.br = buildVariant(brInfo, baseHeaders, "br"); - entry.brPath = brInfo.fullPath; - entry.brSize = brInfo.size; } const gzInfo = allFiles.get(relativePath + ".gz"); if (gzInfo) { entry.gz = buildVariant(gzInfo, baseHeaders, "gzip"); - entry.gzPath = gzInfo.fullPath; - entry.gzSize = gzInfo.size; } const zstInfo = allFiles.get(relativePath + ".zst"); if (zstInfo) { entry.zst = buildVariant(zstInfo, baseHeaders, "zstd"); - entry.zstPath = zstInfo.fullPath; - entry.zstSize = zstInfo.size; + } + + // When compressed variants exist, the original needs Vary too so + // shared caches don't serve uncompressed to compression-capable clients. + if (entry.br || entry.gz || entry.zst) { + original.headers["Vary"] = "Accept-Encoding"; + entry.notModifiedHeaders["Vary"] = "Accept-Encoding"; } // Register under the URL pathname (leading /) @@ -254,8 +238,14 @@ function buildVariant( }; } +/** Batch size for concurrent stat() calls during directory walk. */ +const STAT_BATCH_SIZE = 64; + /** * Walk a directory recursively, yielding file paths and stats. + * + * Batches stat() calls per directory to avoid sequential syscall overhead + * for large dist/client/ directories. */ async function* walkFilesWithStats( dir: string, @@ -271,16 +261,27 @@ async function* walkFilesWithStats( } catch { return; // directory doesn't exist or unreadable } + + // Recurse into subdirectories first (they yield their own batched stats) + const files: string[] = []; for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { yield* walkFilesWithStats(fullPath, base); } else if (entry.isFile()) { - const stat = await fsp.stat(fullPath); + files.push(fullPath); + } + } + + // Batch stat() calls for files in this directory + for (let i = 0; i < files.length; i += STAT_BATCH_SIZE) { + const batch = files.slice(i, i + STAT_BATCH_SIZE); + const stats = await Promise.all(batch.map((f) => fsp.stat(f))); + for (let j = 0; j < batch.length; j++) { yield { - relativePath: path.relative(base, fullPath), - fullPath, - stat: { size: stat.size, mtimeMs: stat.mtimeMs }, + relativePath: path.relative(base, batch[j]), + fullPath: batch[j], + stat: { size: stats[j].size, mtimeMs: stats[j].mtimeMs }, }; } } diff --git a/tests/serve-static.test.ts b/tests/serve-static.test.ts index 7481746d..067295e5 100644 --- a/tests/serve-static.test.ts +++ b/tests/serve-static.test.ts @@ -344,6 +344,22 @@ describe("tryServeStatic (with StaticFileCache)", () => { 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"); diff --git a/tests/static-file-cache.test.ts b/tests/static-file-cache.test.ts index 14c1b9a1..9f6d29f7 100644 --- a/tests/static-file-cache.test.ts +++ b/tests/static-file-cache.test.ts @@ -73,9 +73,9 @@ describe("StaticFileCache", () => { const entry = cache.lookup("/assets/index-abc123.js"); expect(entry).toBeDefined(); - expect(entry!.contentType).toBe("application/javascript"); - expect(entry!.size).toBe(12); // "const x = 1;" - expect(entry!.resolvedPath).toBe(path.join(clientDir, "assets/index-abc123.js")); + expect(entry!.original.headers["Content-Type"]).toBe("application/javascript"); + expect(entry!.original.headers["Content-Length"]).toBe("12"); // "const x = 1;" + expect(entry!.original.path).toBe(path.join(clientDir, "assets/index-abc123.js")); }); it("returns undefined for non-existent files", async () => { @@ -92,7 +92,7 @@ describe("StaticFileCache", () => { const cache = await StaticFileCache.create(clientDir); const entry = cache.lookup("/assets/bundle-abc123.js"); - expect(entry!.cacheControl).toBe("public, max-age=31536000, immutable"); + expect(entry!.original.headers["Cache-Control"]).toBe("public, max-age=31536000, immutable"); }); it("sets short cache-control for non-hashed files", async () => { @@ -101,7 +101,7 @@ describe("StaticFileCache", () => { const cache = await StaticFileCache.create(clientDir); const entry = cache.lookup("/favicon.ico"); - expect(entry!.cacheControl).toBe("public, max-age=3600"); + expect(entry!.original.headers["Cache-Control"]).toBe("public, max-age=3600"); }); it("generates weak etag from size and mtime", async () => { @@ -126,8 +126,8 @@ describe("StaticFileCache", () => { const cache = await StaticFileCache.create(clientDir); const entry = cache.lookup("/assets/app-abc123.js"); - expect(entry!.brPath).toBe(path.join(clientDir, "assets/app-abc123.js.br")); - expect(entry!.brSize).toBe(brContent.length); + expect(entry!.br?.path).toBe(path.join(clientDir, "assets/app-abc123.js.br")); + expect(entry!.br?.headers["Content-Length"]).toBe(String(brContent.length)); }); it("detects gzip precompressed variant", async () => { @@ -139,8 +139,8 @@ describe("StaticFileCache", () => { const cache = await StaticFileCache.create(clientDir); const entry = cache.lookup("/assets/styles-def456.css"); - expect(entry!.gzPath).toBe(path.join(clientDir, "assets/styles-def456.css.gz")); - expect(entry!.gzSize).toBe(gzContent.length); + expect(entry!.gz?.path).toBe(path.join(clientDir, "assets/styles-def456.css.gz")); + expect(entry!.gz?.headers["Content-Length"]).toBe(String(gzContent.length)); }); it("detects zstandard precompressed variant", async () => { @@ -152,8 +152,32 @@ describe("StaticFileCache", () => { const cache = await StaticFileCache.create(clientDir); const entry = cache.lookup("/assets/app-zstd.js"); - expect(entry!.zstPath).toBe(path.join(clientDir, "assets/app-zstd.js.zst")); - expect(entry!.zstSize).toBe(zstdContent.length); + expect(entry!.zst?.path).toBe(path.join(clientDir, "assets/app-zstd.js.zst")); + expect(entry!.zst?.headers["Content-Length"]).toBe(String(zstdContent.length)); + }); + + it("sets Vary: Accept-Encoding on original variant when compressed siblings exist", async () => { + const content = "const x = 1;\n".repeat(200); + await writeFile(clientDir, "assets/app-abc123.js", content); + await writeFile( + clientDir, + "assets/app-abc123.js.br", + zlib.brotliCompressSync(Buffer.from(content)), + ); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/assets/app-abc123.js"); + + expect(entry!.original.headers["Vary"]).toBe("Accept-Encoding"); + }); + + it("omits Vary on original variant when no compressed siblings exist", async () => { + await writeFile(clientDir, "favicon.ico", "icon-data"); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/favicon.ico"); + + expect(entry!.original.headers["Vary"]).toBeUndefined(); }); it("does not expose .br/.gz/.zst files as standalone entries", async () => { @@ -188,8 +212,8 @@ describe("StaticFileCache", () => { const entry = cache.lookup("/about"); expect(entry).toBeDefined(); - expect(entry!.resolvedPath).toBe(path.join(clientDir, "about.html")); - expect(entry!.contentType).toBe("text/html"); + expect(entry!.original.path).toBe(path.join(clientDir, "about.html")); + expect(entry!.original.headers["Content-Type"]).toBe("text/html"); }); it("resolves /index.html fallback for directory paths", async () => { @@ -199,7 +223,7 @@ describe("StaticFileCache", () => { const entry = cache.lookup("/blog"); expect(entry).toBeDefined(); - expect(entry!.resolvedPath).toBe(path.join(clientDir, "blog/index.html")); + expect(entry!.original.path).toBe(path.join(clientDir, "blog/index.html")); }); // ── Directory traversal protection ───────────────────────────── @@ -231,10 +255,14 @@ describe("StaticFileCache", () => { const cache = await StaticFileCache.create(clientDir); - expect(cache.lookup("/assets/style-aaa.css")!.contentType).toBe("text/css"); - expect(cache.lookup("/assets/data-bbb.json")!.contentType).toBe("application/json"); - expect(cache.lookup("/logo.svg")!.contentType).toBe("image/svg+xml"); - expect(cache.lookup("/photo.webp")!.contentType).toBe("image/webp"); + expect(cache.lookup("/assets/style-aaa.css")!.original.headers["Content-Type"]).toBe( + "text/css", + ); + expect(cache.lookup("/assets/data-bbb.json")!.original.headers["Content-Type"]).toBe( + "application/json", + ); + expect(cache.lookup("/logo.svg")!.original.headers["Content-Type"]).toBe("image/svg+xml"); + expect(cache.lookup("/photo.webp")!.original.headers["Content-Type"]).toBe("image/webp"); }); it("falls back to application/octet-stream for unknown extensions", async () => { @@ -242,7 +270,9 @@ describe("StaticFileCache", () => { const cache = await StaticFileCache.create(clientDir); - expect(cache.lookup("/assets/data-ccc.xyz")!.contentType).toBe("application/octet-stream"); + expect(cache.lookup("/assets/data-ccc.xyz")!.original.headers["Content-Type"]).toBe( + "application/octet-stream", + ); }); // ── Nested directory scanning ────────────────────────────────── From f6b8c8db871f85e975de6ad4ed5cd202b0d4dc1c Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 25 Mar 2026 01:34:50 +1100 Subject: [PATCH 08/10] 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 --- packages/vinext/src/server/prod-server.ts | 56 +++++++++++---- tests/serve-static.test.ts | 85 +++++++++++++++++++++++ 2 files changed, 126 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 2e026d13..39d0ec95 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -5,7 +5,7 @@ * - Static asset serving from client build output * - Pages Router: SSR rendering + API route handling * - App Router: RSC/SSR rendering, route handlers, server actions - * - Gzip/Brotli compression for text-based responses + * - Zstd/Brotli/Gzip compression for text-based responses * - Streaming SSR for App Router * * Build output for Pages Router: @@ -105,11 +105,13 @@ const COMPRESS_THRESHOLD = 1024; * Supported in Chrome 123+, Firefox 126+. Safari can decompress but doesn't * send zstd in Accept-Encoding, so it transparently falls back to br/gzip. */ +const HAS_ZSTD = typeof zlib.createZstdCompress === "function"; + function negotiateEncoding(req: IncomingMessage): "zstd" | "br" | "gzip" | "deflate" | null { const accept = req.headers["accept-encoding"]; if (!accept || typeof accept !== "string") return null; const lower = accept.toLowerCase(); - if (lower.includes("zstd")) return "zstd"; + if (HAS_ZSTD && lower.includes("zstd")) return "zstd"; if (lower.includes("br")) return "br"; if (lower.includes("gzip")) return "gzip"; if (lower.includes("deflate")) return "deflate"; @@ -126,6 +128,7 @@ function createCompressor( switch (encoding) { case "zstd": return zlib.createZstdCompress({ + ...(mode === "streaming" ? { flush: zlib.constants.ZSTD_e_flush } : {}), params: { [zlib.constants.ZSTD_c_compressionLevel]: 3 }, // Fast for on-the-fly }); case "br": @@ -418,15 +421,15 @@ async function tryServeStatic( // Pick the best precompressed variant: zstd → br → gzip → original. // Each variant has pre-computed headers — zero string building. - // Accept-Encoding header values from browsers use lowercase tokens. - const ae = compress ? req.headers["accept-encoding"] : undefined; - const variant = - typeof ae === "string" - ? (ae.includes("zstd") && entry.zst) || - (ae.includes("br") && entry.br) || - (ae.includes("gzip") && entry.gz) || - entry.original - : entry.original; + // Encoding tokens are case-insensitive per RFC 9110; lowercase once. + const rawAe = compress ? req.headers["accept-encoding"] : undefined; + const ae = typeof rawAe === "string" ? rawAe.toLowerCase() : undefined; + const variant = ae + ? (ae.includes("zstd") && entry.zst) || + (ae.includes("br") && entry.br) || + (ae.includes("gzip") && entry.gz) || + entry.original + : entry.original; if (extraHeaders) { res.writeHead(200, { ...variant.headers, ...extraHeaders }); @@ -444,7 +447,12 @@ async function tryServeStatic( if (variant.buffer) { res.end(variant.buffer); } else { - pipeline(fs.createReadStream(variant.path), res, () => {}); + pipeline(fs.createReadStream(variant.path), res, (err) => { + if (err && !res.headersSent) { + res.writeHead(500); + res.end(); + } + }); } return true; } @@ -487,7 +495,16 @@ async function tryServeStatic( "Content-Encoding": encoding, Vary: "Accept-Encoding", }); - pipeline(fs.createReadStream(resolved.path), compressor, res, () => {}); + if (req.method === "HEAD") { + res.end(); + return true; + } + pipeline(fs.createReadStream(resolved.path), compressor, res, (err) => { + if (err && !res.headersSent) { + res.writeHead(500); + res.end(); + } + }); return true; } } @@ -496,7 +513,16 @@ async function tryServeStatic( ...baseHeaders, "Content-Length": String(resolved.size), }); - pipeline(fs.createReadStream(resolved.path), res, () => {}); + if (req.method === "HEAD") { + res.end(); + return true; + } + pipeline(fs.createReadStream(resolved.path), res, (err) => { + if (err && !res.headersSent) { + res.writeHead(500); + res.end(); + } + }); return true; } @@ -849,7 +875,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { // Build the static file metadata cache at startup. Eliminates per-request // stat() calls — all lookups are pure in-memory Map.get(). Precompressed - // .br/.gz variants (generated at build time) are detected automatically. + // .br/.gz/.zst variants (generated at build time) are detected automatically. const staticCache = await StaticFileCache.create(clientDir); const server = createServer(async (req, res) => { diff --git a/tests/serve-static.test.ts b/tests/serve-static.test.ts index 067295e5..f396acf7 100644 --- a/tests/serve-static.test.ts +++ b/tests/serve-static.test.ts @@ -483,4 +483,89 @@ describe("tryServeStatic (with StaticFileCache)", () => { 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); + }); }); From 1386bbb8a27fafc8339a7d683868adfc05978288 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 25 Mar 2026 01:35:01 +1100 Subject: [PATCH 09/10] perf: move precompression to Vite plugin with edge auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/vinext/src/cli.ts | 25 +++++----------- packages/vinext/src/index.ts | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 4cae6947..8ae121de 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -16,7 +16,6 @@ import vinext, { clientOutputConfig, clientTreeshakeConfig } from "./index.js"; import { printBuildReport } from "./build/report.js"; import { runPrerender } from "./build/run-prerender.js"; -import { precompressAssets } from "./build/precompress.js"; import path from "node:path"; import fs from "node:fs"; import { pathToFileURL } from "node:url"; @@ -118,6 +117,7 @@ function parseArgs(args: string[]): ParsedArgs { result.prerenderAll = true; } else if (arg === "--no-precompress") { result.noPrecompress = true; + process.env.VINEXT_NO_PRECOMPRESS = "1"; } else if (arg === "--port" || arg === "-p") { result.port = parseInt(args[++i], 10); } else if (arg.startsWith("--port=")) { @@ -513,23 +513,12 @@ async function buildApp() { prerenderResult = await runPrerender({ root: process.cwd() }); } - // Precompress hashed assets (brotli q5 + gzip l9 + zstd l22). These - // .br/.gz/.zst files are served directly by the production server — - // zero per-request compression overhead for immutable build output. - // Skip when deploying behind a CDN that handles compression (e.g. Cloudflare). - if (!parsed.noPrecompress) { - const clientDir = path.resolve("dist", "client"); - const precompressResult = await precompressAssets(clientDir); - if (precompressResult.filesCompressed > 0) { - const ratio = ( - (1 - precompressResult.totalCompressedBytes / precompressResult.totalOriginalBytes) * - 100 - ).toFixed(1); - console.log( - ` Precompressed ${precompressResult.filesCompressed} assets (${ratio}% smaller)`, - ); - } - } + // Precompression now runs as a Vite plugin writeBundle hook (vinext:precompress). + // The plugin auto-detects the deployment target: skips for Cloudflare Workers + // and Nitro (edge platforms handle compression at the CDN layer), runs for + // Node.js production server targets. + // + // --no-precompress is forwarded via VINEXT_NO_PRECOMPRESS env var. process.stdout.write("\x1b[0m"); await printBuildReport({ diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 7ccec5b7..40226aa0 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -35,6 +35,7 @@ import { logRequest, now } from "./server/request-log.js"; import { normalizePath } from "./server/normalize-path.js"; import { findInstrumentationFile, runInstrumentation } from "./server/instrumentation.js"; import { PHASE_PRODUCTION_BUILD, PHASE_DEVELOPMENT_SERVER } from "./shims/constants.js"; +import { precompressAssets } from "./build/precompress.js"; import { validateDevRequest } from "./server/dev-origin-check.js"; import { isExternalUrl, @@ -1166,6 +1167,19 @@ export interface VinextOptions { * @default true */ react?: VitePluginReactOptions | boolean; + /** + * Control build-time precompression of static assets (.br, .gz, .zst). + * + * - `'auto'` (default): enabled for Node.js production server targets, + * disabled when deploying to edge platforms (Cloudflare Workers, Nitro) + * that handle compression at the CDN layer. + * - `true`: always precompress, regardless of deployment target. + * - `false`: never precompress. + * + * Can also be disabled via `--no-precompress` CLI flag or + * `VINEXT_NO_PRECOMPRESS=1` environment variable. + */ + precompress?: "auto" | boolean; /** * Experimental vinext-only feature flags. */ @@ -4434,6 +4448,50 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }, }, + // Build-time precompression: generate .br, .gz, .zst for hashed assets. + // Runs after the client bundle is written so compressed variants are + // available for the production server's static file cache. + // + // Auto-skipped when targeting edge platforms (Cloudflare Workers, Nitro) + // that handle compression at the CDN layer — precompressing would waste + // build time and upload bandwidth for files the CDN compresses itself. + { + name: "vinext:precompress", + apply: "build", + enforce: "post", + writeBundle: { + sequential: true, + order: "post", + async handler(outputOptions) { + if (this.environment?.name !== "client") return; + + // Resolve the precompress option: 'auto' (default) defers to + // deployment target detection; explicit true/false overrides. + const opt = options.precompress ?? "auto"; + if (opt === false) return; + if (process.env.VINEXT_NO_PRECOMPRESS === "1") return; + + if (opt === "auto" && (hasCloudflarePlugin || hasNitroPlugin)) return; + + const outDir = outputOptions.dir; + if (!outDir) return; + + // Only precompress hashed assets — public directory files use + // on-the-fly compression since they may change between deploys. + const assetsDir = path.join(outDir, "assets"); + if (!fs.existsSync(assetsDir)) return; + + const result = await precompressAssets(outDir); + if (result.filesCompressed > 0) { + const ratio = ( + (1 - result.totalCompressedBytes / result.totalOriginalBytes) * + 100 + ).toFixed(1); + console.log(` Precompressed ${result.filesCompressed} assets (${ratio}% smaller)`); + } + }, + }, + }, // Cloudflare Workers production build integration: // After all environments are built, compute lazy chunks from the client // build manifest and inject globals into the worker entry. From c1c6826ba71ed714238d1fe7ffd3c198df42cfdb Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 25 Mar 2026 01:35:09 +1100 Subject: [PATCH 10/10] docs: fix inaccurate comments in precompress and static-file-cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/vinext/src/build/precompress.ts | 6 +++--- packages/vinext/src/server/static-file-cache.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/build/precompress.ts b/packages/vinext/src/build/precompress.ts index b63a9db3..d696e0e2 100644 --- a/packages/vinext/src/build/precompress.ts +++ b/packages/vinext/src/build/precompress.ts @@ -69,9 +69,9 @@ async function* walkFiles(dir: string, base: string = dir): AsyncGenerator { const assetsDir = path.join(clientDir, "assets"); diff --git a/packages/vinext/src/server/static-file-cache.ts b/packages/vinext/src/server/static-file-cache.ts index ea0daebb..1a52fa13 100644 --- a/packages/vinext/src/server/static-file-cache.ts +++ b/packages/vinext/src/server/static-file-cache.ts @@ -8,8 +8,8 @@ * * Modeled after sirv's production mode. Key insight from sirv: pre-compute * ALL response headers at startup — Content-Type, Content-Length, ETag, - * Cache-Control, Content-Encoding, Vary — as frozen objects. The per-request - * handler should do zero object allocation for headers. + * Cache-Control, Content-Encoding, Vary — as reusable objects. The common + * per-request path (no extraHeaders) does zero object allocation for headers. */ import fsp from "node:fs/promises"; import path from "node:path"; @@ -182,9 +182,9 @@ export class StaticFileCache { } // Third pass: buffer small files in memory for zero-syscall serving. - // For a 50KB JS bundle compressed to ~100 bytes, res.end(buffer) is ~2x - // faster than createReadStream().pipe() because it skips fd open/close - // and stream plumbing overhead. + // For small compressed variants (e.g. a 50KB JS bundle → ~15KB brotli), + // res.end(buffer) is ~2x faster than createReadStream().pipe() because + // it skips fd open/close and stream plumbing overhead. const bufferReads: Promise[] = []; const bufferedPaths = new Set(); for (const entry of entries.values()) {