perf: accelerate production request hot paths
This commit is contained in:
@@ -148,6 +148,10 @@ export interface ProdOptions {
|
|||||||
port?: number;
|
port?: number;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
maxBodyBytes?: number;
|
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. */
|
/** Enable only for the CLI's supervised exact-production development mode. */
|
||||||
developmentRuntime?: boolean;
|
developmentRuntime?: boolean;
|
||||||
}
|
}
|
||||||
@@ -205,6 +209,19 @@ function buildProdRouter(manifest: ProdManifest): {
|
|||||||
const api = toRoutes(manifest.api);
|
const api = toRoutes(manifest.api);
|
||||||
const realtime = toRoutes(manifest.realtime);
|
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).
|
// Components are keyed by name; the runtime resolves them via loadModule(name).
|
||||||
for (const c of manifest.components) modules.set(c.name, c.mod);
|
for (const c of manifest.components) modules.set(c.name, c.mod);
|
||||||
// Layouts share the module map under a `layout:` prefix (no name collisions).
|
// 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}` })),
|
layouts: manifest.layouts.map((l) => ({ name: l.name, file: `layout:${l.name}` })),
|
||||||
stores: [],
|
stores: [],
|
||||||
schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime
|
schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime
|
||||||
matchPage: (p) => matchRoute(pages, p),
|
matchPage: optimizedMatcher(pages),
|
||||||
matchApi: (p) => matchRoute(api, p),
|
matchApi: optimizedMatcher(api),
|
||||||
matchRealtime: (p) => matchRoute(realtime, p),
|
matchRealtime: optimizedMatcher(realtime),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { router, modules };
|
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>) {
|
async function serveFile(path: string | undefined, headers: Record<string, string>) {
|
||||||
if (!path) return new Response("Not Found", { status: 404 });
|
if (!path) return new Response("Not Found", { status: 404 });
|
||||||
const file = Bun.file(path);
|
if (missingProductionFiles.has(path)) return new Response("Not Found", { status: 404 });
|
||||||
if (!(await file.exists())) 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 });
|
return new Response(file, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +438,8 @@ export async function createProductionServer(manifest: ProdManifest, opts: ProdO
|
|||||||
hostname: resolveProductionHostname(opts.hostname),
|
hostname: resolveProductionHostname(opts.hostname),
|
||||||
development: false,
|
development: false,
|
||||||
maxRequestBodySize: opts.maxBodyBytes ?? 10 * 1024 * 1024,
|
maxRequestBodySize: opts.maxBodyBytes ?? 10 * 1024 * 1024,
|
||||||
|
idleTimeout: opts.idleTimeout ?? 30,
|
||||||
|
reusePort: opts.reusePort ?? false,
|
||||||
fetch: handlers.fetch,
|
fetch: handlers.fetch,
|
||||||
websocket: handlers.websocket,
|
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. */
|
/** Cache the public-dir existence check so it isn't a sync stat on every request. */
|
||||||
const publicDirExistsCache = new Map<string, boolean>();
|
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 {
|
function publicDirExists(dir: string): boolean {
|
||||||
let exists = publicDirExistsCache.get(dir);
|
let exists = publicDirExistsCache.get(dir);
|
||||||
if (exists === undefined) {
|
if (exists === undefined) {
|
||||||
@@ -73,6 +78,21 @@ export async function servePublicAsset(
|
|||||||
let filePath = resolve(base, rel);
|
let filePath = resolve(base, rel);
|
||||||
if (!isInside(base, filePath)) return fallback;
|
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 {
|
try {
|
||||||
const info = await stat(filePath);
|
const info = await stat(filePath);
|
||||||
if (info.isDirectory()) {
|
if (info.isDirectory()) {
|
||||||
@@ -86,10 +106,22 @@ export async function servePublicAsset(
|
|||||||
const body = await readFile(filePath);
|
const body = await readFile(filePath);
|
||||||
const contentType =
|
const contentType =
|
||||||
CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
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, {
|
return new Response(body, {
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": contentType,
|
"content-type": contentType,
|
||||||
"cache-control": cachePolicy(filePath, mode),
|
"cache-control": cacheControl,
|
||||||
"x-content-type-options": "nosniff",
|
"x-content-type-options": "nosniff",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2073,6 +2073,23 @@ const COMPRESSIBLE_TYPE =
|
|||||||
/^(?:text\/|application\/(?:json|xml|javascript|manifest\+json)|image\/svg\+xml)/i;
|
/^(?:text\/|application\/(?:json|xml|javascript|manifest\+json)|image\/svg\+xml)/i;
|
||||||
/** Below this size gzip's overhead isn't worth it. */
|
/** Below this size gzip's overhead isn't worth it. */
|
||||||
const COMPRESS_MIN_BYTES = 1024;
|
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,
|
* 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.
|
* `Cache-Control: no-transform`, so they are never buffered here.
|
||||||
*/
|
*/
|
||||||
async function compressResponse(req: Request, res: Response): Promise<Response> {
|
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 accept = (req.headers.get("accept-encoding") ?? "").toLowerCase();
|
||||||
const acceptsBrotli = /(?:^|,)\s*br(?:\s*;|\s*,|$)/.test(accept);
|
const acceptsBrotli = /(?:^|,)\s*br(?:\s*;|\s*,|$)/.test(accept);
|
||||||
const acceptsGzip = /(?:^|,)\s*gzip(?:\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 (!COMPRESSIBLE_TYPE.test(res.headers.get("content-type") ?? "")) return res;
|
||||||
if ((res.headers.get("cache-control") ?? "").includes("no-transform")) 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());
|
const body = new Uint8Array(await res.arrayBuffer());
|
||||||
if (body.length < COMPRESS_MIN_BYTES) {
|
if (body.length < COMPRESS_MIN_BYTES) {
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
@@ -2100,7 +2137,7 @@ async function compressResponse(req: Request, res: Response): Promise<Response>
|
|||||||
|
|
||||||
let encoded: Uint8Array;
|
let encoded: Uint8Array;
|
||||||
let encoding: "br" | "gzip";
|
let encoding: "br" | "gzip";
|
||||||
if (acceptsBrotli) {
|
if (preferredEncoding === "br") {
|
||||||
encoded = new Uint8Array(
|
encoded = new Uint8Array(
|
||||||
brotliCompressSync(body, {
|
brotliCompressSync(body, {
|
||||||
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
|
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`);
|
else if (!/\baccept-encoding\b/i.test(vary)) headers.set("Vary", `${vary}, Accept-Encoding`);
|
||||||
const responseBody = new ArrayBuffer(encoded.byteLength);
|
const responseBody = new ArrayBuffer(encoded.byteLength);
|
||||||
new Uint8Array(responseBody).set(encoded);
|
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, {
|
return new Response(responseBody, {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { expect, test } from "bun:test";
|
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";
|
import { createProductionHandlers, type ProdManifest } from "../src/prod.ts";
|
||||||
|
|
||||||
const manifest: ProdManifest = {
|
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(response.headers.get("cache-control")).toBe("private, no-store");
|
||||||
expect(await response.json()).toEqual({ data: [{ id: 1, name: "Ada" }] });
|
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