diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index b0eb24b3..5cc7185a 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -148,6 +148,10 @@ export interface ProdOptions { port?: number; hostname?: string; maxBodyBytes?: number; + /** Keep-alive idle timeout in seconds. Defaults to 30. */ + idleTimeout?: number; + /** Allow multiple Bun workers to share the listening port. */ + reusePort?: boolean; /** Enable only for the CLI's supervised exact-production development mode. */ developmentRuntime?: boolean; } @@ -205,6 +209,19 @@ function buildProdRouter(manifest: ProdManifest): { const api = toRoutes(manifest.api); const realtime = toRoutes(manifest.realtime); + const optimizedMatcher = (routes: Route[]) => { + const exact = new Map(); + const dynamic: Route[] = []; + for (const route of routes) { + if (route.paramNames.length === 0) exact.set(route.raw, route); + else dynamic.push(route); + } + return (pathname: string) => { + const route = exact.get(pathname); + return route ? { route, params: {} } : matchRoute(dynamic, pathname); + }; + }; + // Components are keyed by name; the runtime resolves them via loadModule(name). for (const c of manifest.components) modules.set(c.name, c.mod); // Layouts share the module map under a `layout:` prefix (no name collisions). @@ -219,19 +236,30 @@ function buildProdRouter(manifest: ProdManifest): { layouts: manifest.layouts.map((l) => ({ name: l.name, file: `layout:${l.name}` })), stores: [], schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime - matchPage: (p) => matchRoute(pages, p), - matchApi: (p) => matchRoute(api, p), - matchRealtime: (p) => matchRoute(realtime, p), + matchPage: optimizedMatcher(pages), + matchApi: optimizedMatcher(api), + matchRealtime: optimizedMatcher(realtime), }; return { router, modules }; } -/** Serve a pre-built asset file from disk, or 404 if it is absent. */ +const productionFileCache = new Map>(); +const missingProductionFiles = new Set(); + +/** Serve a pre-built asset file from disk, caching stable build file handles. */ async function serveFile(path: string | undefined, headers: Record) { if (!path) return new Response("Not Found", { status: 404 }); - const file = Bun.file(path); - if (!(await file.exists())) return new Response("Not Found", { status: 404 }); + if (missingProductionFiles.has(path)) return new Response("Not Found", { status: 404 }); + let file = productionFileCache.get(path); + if (!file) { + file = Bun.file(path); + if (!(await file.exists())) { + missingProductionFiles.add(path); + return new Response("Not Found", { status: 404 }); + } + productionFileCache.set(path, file); + } return new Response(file, { headers }); } @@ -410,6 +438,8 @@ export async function createProductionServer(manifest: ProdManifest, opts: ProdO hostname: resolveProductionHostname(opts.hostname), development: false, maxRequestBodySize: opts.maxBodyBytes ?? 10 * 1024 * 1024, + idleTimeout: opts.idleTimeout ?? 30, + reusePort: opts.reusePort ?? false, fetch: handlers.fetch, websocket: handlers.websocket, }); diff --git a/packages/dev-server/src/public.ts b/packages/dev-server/src/public.ts index bf3afadb..bc8e2449 100644 --- a/packages/dev-server/src/public.ts +++ b/packages/dev-server/src/public.ts @@ -41,6 +41,11 @@ function cachePolicy(filePath: string, mode: Mode): string { /** Cache the public-dir existence check so it isn't a sync stat on every request. */ const publicDirExistsCache = new Map(); +const productionPublicFileCache = new Map< + string, + { body: Uint8Array; contentType: string; cacheControl: string } +>(); +const PRODUCTION_PUBLIC_FILE_CACHE_MAX = 256; function publicDirExists(dir: string): boolean { let exists = publicDirExistsCache.get(dir); if (exists === undefined) { @@ -73,6 +78,21 @@ export async function servePublicAsset( let filePath = resolve(base, rel); if (!isInside(base, filePath)) return fallback; + if (mode === "production") { + const cached = productionPublicFileCache.get(filePath); + if (cached) { + productionPublicFileCache.delete(filePath); + productionPublicFileCache.set(filePath, cached); + return new Response(cached.body.slice(), { + headers: { + "content-type": cached.contentType, + "cache-control": cached.cacheControl, + "x-content-type-options": "nosniff", + }, + }); + } + } + try { const info = await stat(filePath); if (info.isDirectory()) { @@ -86,10 +106,22 @@ export async function servePublicAsset( const body = await readFile(filePath); const contentType = CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream"; + const cacheControl = cachePolicy(filePath, mode); + if (mode === "production") { + productionPublicFileCache.set(filePath, { + body: new Uint8Array(body), + contentType, + cacheControl, + }); + if (productionPublicFileCache.size > PRODUCTION_PUBLIC_FILE_CACHE_MAX) { + const oldest = productionPublicFileCache.keys().next().value; + if (oldest) productionPublicFileCache.delete(oldest); + } + } return new Response(body, { headers: { "content-type": contentType, - "cache-control": cachePolicy(filePath, mode), + "cache-control": cacheControl, "x-content-type-options": "nosniff", }, }); diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index fb246bcc..da92456f 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -2073,6 +2073,23 @@ const COMPRESSIBLE_TYPE = /^(?:text\/|application\/(?:json|xml|javascript|manifest\+json)|image\/svg\+xml)/i; /** Below this size gzip's overhead isn't worth it. */ const COMPRESS_MIN_BYTES = 1024; +const COMPRESSED_RESPONSE_CACHE_MAX = 256; +const compressedResponseCache = new Map< + string, + { body: Uint8Array; headers: [string, string][]; status: number; statusText: string } +>(); + +function rememberCompressedResponse( + key: string, + value: { body: Uint8Array; headers: [string, string][]; status: number; statusText: string }, +) { + compressedResponseCache.delete(key); + compressedResponseCache.set(key, value); + if (compressedResponseCache.size > COMPRESSED_RESPONSE_CACHE_MAX) { + const oldest = compressedResponseCache.keys().next().value; + if (oldest) compressedResponseCache.delete(oldest); + } +} /** * Gzip a response when the client accepts it and the body is a compressible, @@ -2080,6 +2097,7 @@ const COMPRESS_MIN_BYTES = 1024; * `Cache-Control: no-transform`, so they are never buffered here. */ async function compressResponse(req: Request, res: Response): Promise { + if (req.method.toUpperCase() === "HEAD") return res; const accept = (req.headers.get("accept-encoding") ?? "").toLowerCase(); const acceptsBrotli = /(?:^|,)\s*br(?:\s*;|\s*,|$)/.test(accept); const acceptsGzip = /(?:^|,)\s*gzip(?:\s*;|\s*,|$)/.test(accept); @@ -2089,6 +2107,25 @@ async function compressResponse(req: Request, res: Response): Promise if (!COMPRESSIBLE_TYPE.test(res.headers.get("content-type") ?? "")) return res; if ((res.headers.get("cache-control") ?? "").includes("no-transform")) return res; + const immutable = (res.headers.get("cache-control") ?? "").includes("immutable"); + // Brotli is ideal for immutable assets because the result is cached. Prefer + // substantially faster gzip for per-request HTML/JSON when the client allows + // both, avoiding synchronous Brotli work on the request hot path. + const preferredEncoding = acceptsBrotli && (immutable || !acceptsGzip) ? "br" : "gzip"; + const cacheKey = immutable ? `${req.url}\n${preferredEncoding}` : ""; + if (cacheKey) { + const cached = compressedResponseCache.get(cacheKey); + if (cached) { + compressedResponseCache.delete(cacheKey); + compressedResponseCache.set(cacheKey, cached); + return new Response(cached.body.slice(), { + status: cached.status, + statusText: cached.statusText, + headers: cached.headers, + }); + } + } + const body = new Uint8Array(await res.arrayBuffer()); if (body.length < COMPRESS_MIN_BYTES) { return new Response(body, { @@ -2100,7 +2137,7 @@ async function compressResponse(req: Request, res: Response): Promise let encoded: Uint8Array; let encoding: "br" | "gzip"; - if (acceptsBrotli) { + if (preferredEncoding === "br") { encoded = new Uint8Array( brotliCompressSync(body, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 }, @@ -2120,6 +2157,14 @@ async function compressResponse(req: Request, res: Response): Promise else if (!/\baccept-encoding\b/i.test(vary)) headers.set("Vary", `${vary}, Accept-Encoding`); const responseBody = new ArrayBuffer(encoded.byteLength); new Uint8Array(responseBody).set(encoded); + if (cacheKey) { + rememberCompressedResponse(cacheKey, { + body: encoded.slice(), + headers: [...headers.entries()], + status: res.status, + statusText: res.statusText, + }); + } return new Response(responseBody, { status: res.status, statusText: res.statusText, diff --git a/packages/dev-server/test/production-hmr.test.ts b/packages/dev-server/test/production-hmr.test.ts index 2173ba6c..c7759284 100644 --- a/packages/dev-server/test/production-hmr.test.ts +++ b/packages/dev-server/test/production-hmr.test.ts @@ -1,4 +1,7 @@ import { expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createProductionHandlers, type ProdManifest } from "../src/prod.ts"; const manifest: ProdManifest = { @@ -72,3 +75,49 @@ test("production client-load endpoint returns only the requested named result", expect(response.headers.get("cache-control")).toBe("private, no-store"); expect(await response.json()).toEqual({ data: [{ id: 1, name: "Ada" }] }); }); + +test("production prefers fast gzip for dynamic HTML and cached Brotli for immutable assets", async () => { + const largeManifest: ProdManifest = { + ...manifest, + pages: [ + { + raw: "/large", + mod: { default: () => `
${"fast production ".repeat(500)}
` }, + }, + ], + }; + const directory = mkdtempSync(join(tmpdir(), "wrnexus-prod-assets-")); + const assetPath = join(directory, "app.js"); + writeFileSync(assetPath, "const production = true;\n".repeat(500)); + const handlers = createProductionHandlers(largeManifest, { + pluginAssets: { + "/assets/app.js": { + path: assetPath, + contentType: "text/javascript; charset=utf-8", + }, + }, + }); + const headers = { "accept-encoding": "br, gzip" }; + const page = (await handlers.fetch( + new Request("http://localhost/large", { headers }), + server, + )) as Response; + expect(page.headers.get("content-encoding")).toBe("gzip"); + + const firstAsset = (await handlers.fetch( + new Request("http://localhost/assets/app.js", { headers }), + server, + )) as Response; + const secondAsset = (await handlers.fetch( + new Request("http://localhost/assets/app.js", { headers }), + server, + )) as Response; + expect(firstAsset.headers.get("content-encoding")).toBe("br"); + expect(await secondAsset.arrayBuffer()).toEqual(await firstAsset.arrayBuffer()); + + const head = (await handlers.fetch( + new Request("http://localhost/assets/app.js", { method: "HEAD", headers }), + server, + )) as Response; + expect(head.headers.get("content-encoding")).toBeNull(); +});