diff --git a/apps/app/package.json b/apps/app/package.json index dbf260a85..19ff5115c 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "vite --config vite.dev.config.ts --configLoader runner", - "build": "vite build --logLevel warn", + "build": "vite build --logLevel warn && node ../../scripts/precompress-app-dist.mjs dist", "preview": "vite preview", "clean": "rimraf dist", "storybook": "ladle serve", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8a73c1f31..021d3d0ef 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -3,6 +3,7 @@ import { readFile, stat } from "node:fs/promises"; import { performance } from "node:perf_hooks"; import { extname, join, resolve } from "node:path"; import { Hono } from "hono"; +import { compress } from "hono/compress"; import { cors } from "hono/cors"; import { buildLocalAppOrigins, @@ -100,6 +101,8 @@ interface CreateAppOptions { } interface StaticResponseHeadersArgs { + contentEncoding?: string; + contentLength?: number; contentType: string; urlPath: string; } @@ -112,6 +115,10 @@ const WEB_SOCKET_SHUTDOWN_REASON = "server-shutdown"; const SLOW_API_REQUEST_LOG_THRESHOLD_MS = 1_000; const THREAD_EVENT_WAIT_PATH_PATTERN = /^\/api\/v1\/threads\/[^/]+\/events\/wait$/u; +const PRECOMPRESSED_STATIC_FILES = [ + { encoding: "br", extension: ".br" }, + { encoding: "gzip", extension: ".gz" }, +] as const; interface ShouldLogSlowApiRequestArgs { durationMs: number; @@ -135,9 +142,103 @@ function createStaticResponseHeaders(args: StaticResponseHeadersArgs): Headers { ? STATIC_ASSET_CACHE_CONTROL : STATIC_INDEX_CACHE_CONTROL, ); + if (args.contentEncoding !== undefined) { + headers.set("content-encoding", args.contentEncoding); + headers.set("vary", "Accept-Encoding"); + } + if (args.contentLength !== undefined) { + headers.set("content-length", String(args.contentLength)); + } return headers; } +function acceptedEncodingQuality( + acceptEncodingHeader: string | undefined, + encoding: string, +): number { + if (acceptEncodingHeader === undefined) { + return 0; + } + let wildcardQuality = 0; + for (const part of acceptEncodingHeader.split(",")) { + const [rawName, ...rawParams] = part.trim().split(";"); + const name = rawName?.trim().toLowerCase(); + const qParam = rawParams + .map((param) => param.trim().toLowerCase()) + .find((param) => param.startsWith("q=")); + const quality = + qParam === undefined + ? 1 + : Number.isNaN(Number(qParam.slice(2))) + ? 1 + : Number(qParam.slice(2)); + if (name === encoding) { + return quality; + } + if (name === "*") { + wildcardQuality = quality; + } + } + return wildcardQuality; +} + +function canServePrecompressedStaticFile(contentType: string): boolean { + return ( + contentType.startsWith("text/") || + contentType === "application/javascript" || + contentType === "application/json" || + contentType === "application/manifest+json" || + contentType === "application/wasm" || + contentType === "application/xml" || + contentType === "image/svg+xml" + ); +} + +async function findPrecompressedStaticFile(args: { + acceptEncodingHeader: string | undefined; + contentType: string; + filePath: string; +}): Promise<{ + contentLength: number; + encoding: string; + filePath: string; +} | null> { + if (!canServePrecompressedStaticFile(args.contentType)) { + return null; + } + + const candidates = PRECOMPRESSED_STATIC_FILES.map((candidate, index) => ({ + ...candidate, + index, + quality: acceptedEncodingQuality( + args.acceptEncodingHeader, + candidate.encoding, + ), + })) + .filter((candidate) => candidate.quality > 0) + .sort( + (left, right) => right.quality - left.quality || left.index - right.index, + ); + + for (const candidate of candidates) { + const encodedFilePath = `${args.filePath}${candidate.extension}`; + try { + const encodedStat = await stat(encodedFilePath); + if (encodedStat.isFile()) { + return { + contentLength: encodedStat.size, + encoding: candidate.encoding, + filePath: encodedFilePath, + }; + } + } catch { + // Sidecar missing — try the next acceptable encoding. + } + } + + return null; +} + function buildAllowedCorsOrigins(deps: AppDeps): Set { const originArgs: BuildLocalAppOriginsArgs = { serverPort: deps.config.serverPort, @@ -204,6 +305,7 @@ export function createApp( }, }), ); + app.use("*", compress()); app.onError((error) => errorToResponse(error, deps.logger)); app.get("/health", (context) => context.json({ ok: true })); app.use("/api/v1/*", async (context, next) => { @@ -375,6 +477,7 @@ export function createApp( ".woff": "font/woff", ".woff2": "font/woff2", ".webp": "image/webp", + ".webmanifest": "application/manifest+json", ".map": "application/json", }; @@ -403,6 +506,22 @@ export function createApp( headers: createStaticResponseHeaders({ contentType, urlPath }), }); } + const precompressedFile = await findPrecompressedStaticFile({ + acceptEncodingHeader: context.req.header("accept-encoding"), + contentType, + filePath, + }); + if (precompressedFile !== null) { + const content = await readFile(precompressedFile.filePath); + return new Response(content, { + headers: createStaticResponseHeaders({ + contentEncoding: precompressedFile.encoding, + contentLength: precompressedFile.contentLength, + contentType, + urlPath, + }), + }); + } const content = await readFile(filePath); return new Response(content, { headers: createStaticResponseHeaders({ contentType, urlPath }), diff --git a/apps/server/test/app/static-cache.test.ts b/apps/server/test/app/static-cache.test.ts index ed13996e5..c96401649 100644 --- a/apps/server/test/app/static-cache.test.ts +++ b/apps/server/test/app/static-cache.test.ts @@ -1,3 +1,4 @@ +import { brotliCompressSync, gzipSync } from "node:zlib"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -13,9 +14,16 @@ describe("production static cache headers", () => { join(staticDir, "index.html"), '', ); + const bundleBody = `console.log('${"fresh bundle ".repeat(600)}');`; + const bundlePath = join(staticDir, "assets", "index-test.js"); + const brotliBundle = brotliCompressSync(Buffer.from(bundleBody)); + const gzipBundle = gzipSync(Buffer.from(bundleBody)); + await writeFile(bundlePath, bundleBody); + await writeFile(`${bundlePath}.br`, brotliBundle); + await writeFile(`${bundlePath}.gz`, gzipBundle); await writeFile( - join(staticDir, "assets", "index-test.js"), - "console.log('fresh bundle');", + join(staticDir, "assets", "dynamic-only.js"), + `console.log('${"dynamic bundle ".repeat(600)}');`, ); const harness = await createTestAppHarness(); @@ -34,6 +42,52 @@ describe("production static cache headers", () => { "public, max-age=31536000, immutable", ); + const brotliAssetResponse = await serverApp.app.request( + "/assets/index-test.js", + { headers: { "accept-encoding": "br, gzip" } }, + ); + expect(brotliAssetResponse.headers.get("content-encoding")).toBe("br"); + expect( + brotliAssetResponse.headers + .get("vary") + ?.split(",") + .map((value) => value.trim()), + ).toContain("Accept-Encoding"); + expect(brotliAssetResponse.headers.get("content-length")).toBe( + String(brotliBundle.length), + ); + expect((await brotliAssetResponse.arrayBuffer()).byteLength).toBe( + brotliBundle.length, + ); + + const gzipAssetResponse = await serverApp.app.request( + "/assets/index-test.js", + { headers: { "accept-encoding": "gzip" } }, + ); + expect(gzipAssetResponse.headers.get("content-encoding")).toBe("gzip"); + expect(gzipAssetResponse.headers.get("content-length")).toBe( + String(gzipBundle.length), + ); + + const gzipPreferredAssetResponse = await serverApp.app.request( + "/assets/index-test.js", + { headers: { "accept-encoding": "br;q=0, gzip;q=1" } }, + ); + expect(gzipPreferredAssetResponse.headers.get("content-encoding")).toBe( + "gzip", + ); + + const dynamicCompressedAssetResponse = await serverApp.app.request( + "/assets/dynamic-only.js", + { headers: { "accept-encoding": "gzip" } }, + ); + expect( + dynamicCompressedAssetResponse.headers.get("content-encoding"), + ).toBe("gzip"); + expect(dynamicCompressedAssetResponse.headers.has("content-length")).toBe( + false, + ); + const apiMissResponse = await serverApp.app.request( "/api/v1/does-not-exist.js", ); diff --git a/scripts/precompress-app-dist.mjs b/scripts/precompress-app-dist.mjs new file mode 100644 index 000000000..68709d3f3 --- /dev/null +++ b/scripts/precompress-app-dist.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import { + constants as zlibConstants, + brotliCompressSync, + gzipSync, +} from "node:zlib"; +import { + existsSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { extname, join, resolve } from "node:path"; + +const DEFAULT_DIST_DIR = "apps/app/dist"; +const MIN_COMPRESS_BYTES = 1024; +const COMPRESSIBLE_EXTENSIONS = new Set([ + ".css", + ".js", + ".json", + ".mjs", + ".svg", + ".txt", + ".wasm", + ".webmanifest", + ".xml", +]); + +function usage() { + console.error("Usage: node scripts/precompress-app-dist.mjs [dist-dir]"); +} + +function walkFiles(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const filePath = join(dir, entry.name); + return entry.isDirectory() ? walkFiles(filePath) : [filePath]; + }); +} + +function shouldPrecompress(filePath) { + if (filePath.endsWith(".br") || filePath.endsWith(".gz")) { + return false; + } + return COMPRESSIBLE_EXTENSIONS.has(extname(filePath)); +} + +function writeIfSmaller(args) { + if (args.compressed.length >= args.rawLength) { + return false; + } + writeFileSync(args.outputPath, args.compressed); + return true; +} + +const args = process.argv.slice(2); +if (args.includes("-h") || args.includes("--help")) { + usage(); + process.exit(0); +} +if (args.length > 1) { + usage(); + process.exit(1); +} + +const distDir = resolve(args[0] ?? DEFAULT_DIST_DIR); +if (!existsSync(distDir)) { + console.error( + `Missing ${distDir}. Run: pnpm exec turbo run build --filter=@bb/app`, + ); + process.exit(1); +} + +let sourceFiles = 0; +let brotliFiles = 0; +let gzipFiles = 0; + +for (const filePath of walkFiles(distDir)) { + if (!shouldPrecompress(filePath)) { + continue; + } + const fileStat = statSync(filePath); + if (!fileStat.isFile() || fileStat.size < MIN_COMPRESS_BYTES) { + continue; + } + + sourceFiles += 1; + const body = readFileSync(filePath); + const brotli = brotliCompressSync(body, { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: 10, + }, + }); + const gzip = gzipSync(body, { level: 9 }); + + if ( + writeIfSmaller({ + compressed: brotli, + outputPath: `${filePath}.br`, + rawLength: body.length, + }) + ) { + brotliFiles += 1; + } + if ( + writeIfSmaller({ + compressed: gzip, + outputPath: `${filePath}.gz`, + rawLength: body.length, + }) + ) { + gzipFiles += 1; + } +} + +console.log( + `precompressed ${sourceFiles} files (${brotliFiles} br, ${gzipFiles} gzip) in ${distDir}`, +);