perf: accelerate production request hot paths
This commit is contained in:
@@ -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<string, Route>();
|
||||
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<string, ReturnType<typeof Bun.file>>();
|
||||
const missingProductionFiles = new Set<string>();
|
||||
|
||||
/** Serve a pre-built asset file from disk, caching stable build file handles. */
|
||||
async function serveFile(path: string | undefined, headers: Record<string, string>) {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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<string, boolean>();
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<Response> {
|
||||
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<Response>
|
||||
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<Response>
|
||||
|
||||
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<Response>
|
||||
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,
|
||||
|
||||
@@ -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: () => `<main>${"fast production ".repeat(500)}</main>` },
|
||||
},
|
||||
],
|
||||
};
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user