From e61d7c9cbd379ee7046ca7b277888c338b9caaa9 Mon Sep 17 00:00:00 2001 From: ruinivist Date: Sat, 30 May 2026 12:42:53 +0000 Subject: [PATCH] fix: serve brotli-only prod assets --- package.json | 2 +- scripts/compress-assets.ts | 33 ++++++++ src/server.test.ts | 98 ++++++++++++++++++++++++ src/server.tsx | 151 ++++++++++++++++++++++++++++++++++++- 4 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 scripts/compress-assets.ts create mode 100644 src/server.test.ts diff --git a/package.json b/package.json index 7e0453e..be030dc 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "bun --hot src/server.tsx", - "build": "rm -rf dist && bun build --target=bun ./src/server.tsx --outdir ./dist", + "build": "rm -rf dist && bun build --target=bun --production --splitting ./src/server.tsx --outdir ./dist && bun run ./scripts/compress-assets.ts", "start": "cd dist && DATABASE_PATH=../data/excalidraw.sqlite bun ./server.js", "test": "bun test", "typecheck": "tsc --noEmit" diff --git a/scripts/compress-assets.ts b/scripts/compress-assets.ts new file mode 100644 index 0000000..ddd43b4 --- /dev/null +++ b/scripts/compress-assets.ts @@ -0,0 +1,33 @@ +import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { brotliCompressSync, constants } from "node:zlib"; + +const distDir = resolve(import.meta.dir, "..", "dist"); +const compressibleExtensions = [".css", ".js", ".json", ".svg", ".txt"]; + +function shouldCompress(name: string) { + return compressibleExtensions.some((extension) => name.endsWith(extension)); +} + +mkdirSync(distDir, { recursive: true }); + +for (const name of readdirSync(distDir)) { + const path = join(distDir, name); + const stats = statSync(path); + if (!stats.isFile() || !shouldCompress(name)) { + continue; + } + + const source = readFileSync(path); + writeFileSync( + `${path}.br`, + brotliCompressSync(source, { + params: { + [constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT, + [constants.BROTLI_PARAM_QUALITY]: 11, + }, + }), + ); +} + +console.log(`Compressed static assets in ${basename(distDir)}`); diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 0000000..c1b93a8 --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createProductionClientAssets } from "./server"; + +const cleanup: string[] = []; + +afterEach(() => { + while (cleanup.length > 0) { + const dir = cleanup.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +function createClientAssetsFixture() { + const dir = mkdtempSync(join(tmpdir(), "excali-client-assets-")); + cleanup.push(dir); + + writeFileSync(join(dir, "index.html"), "ok"); + writeFileSync(join(dir, "index-abc123.js"), "console.log('ok');"); + writeFileSync(join(dir, "index-abc123.js.br"), "compressed-brotli"); + + return createProductionClientAssets( + { + index: "./index.html", + files: [ + { + path: "./index.html", + loader: "html", + headers: { + "content-type": "text/html;charset=utf-8", + }, + }, + { + path: "./index-abc123.js", + loader: "js", + headers: { + "content-type": "text/javascript;charset=utf-8", + }, + }, + ], + }, + pathToFileURL(`${dir}/`), + ); +} + +describe("production client assets", () => { + test("serves the HTML shell with revalidation headers", async () => { + const assets = createClientAssetsFixture(); + + const response = assets.serveHtml(new Request("http://local/d/abc123")); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-cache"); + expect(response.headers.get("etag")).toBeNull(); + expect(await response.text()).toContain(""); + }); + + test("serves immutable hashed assets as Brotli", async () => { + const assets = createClientAssetsFixture(); + + const response = assets.serveAsset( + new Request("http://local/index-abc123.js", { + headers: { + "accept-encoding": "gzip, br", + }, + }), + ); + + expect(response?.status).toBe(200); + expect(response?.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + expect(response?.headers.get("content-encoding")).toBe("br"); + expect(response?.headers.get("vary")).toBe("Accept-Encoding"); + expect(response?.headers.get("etag")).toBeNull(); + expect(await response?.text()).toBe("compressed-brotli"); + }); + + test("rejects asset requests without Brotli support", async () => { + const assets = createClientAssetsFixture(); + + const response = assets.serveAsset( + new Request("http://local/index-abc123.js", { + headers: { + "accept-encoding": "gzip", + }, + }), + ); + + expect(response?.status).toBe(406); + expect(response?.headers.get("cache-control")).toBe("no-store"); + expect(response?.headers.get("vary")).toBe("Accept-Encoding"); + expect(await response?.text()).toBe("Brotli encoding is required"); + }); +}); diff --git a/src/server.tsx b/src/server.tsx index 8ed35f5..8f80b22 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -1,10 +1,152 @@ +import { existsSync } from "node:fs"; +import { basename } from "node:path"; +import { fileURLToPath } from "node:url"; import index from "./index.html"; import { createApi } from "./api"; import { getDefaultDrawingStore } from "./db"; +type ClientManifestFile = { + path: string; + loader: string; + headers?: Record; +}; + +type ClientManifest = { + index: string; + files: ClientManifestFile[]; +}; + +type ProductionClientAssets = { + serveHtml: (request: Request) => Response; + serveAsset: (request: Request) => Response | null; +}; + +type AssetFile = ClientManifestFile & { + brotliPath: string; + fileUrl: URL; +}; + +function isClientManifest(value: unknown): value is ClientManifest { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Partial; + return typeof candidate.index === "string" && Array.isArray(candidate.files); +} + +function toRequestPath(path: string): string { + if (path.startsWith("/")) { + return path; + } + + return `/${path.replace(/^\.\//, "")}`; +} + +function isClientAssetFile(path: string): boolean { + const name = basename(path); + return name !== "server.js" && !name.startsWith("server-"); +} + +function acceptsBrotli(acceptEncoding: string | null): boolean { + if (!acceptEncoding) { + return false; + } + + return acceptEncoding + .split(",") + .map((value) => value.trim().toLowerCase()) + .some((value) => value === "br" || value.startsWith("br;")); +} + +function createStaticResponse( + fileUrl: URL, + headersInit: Record | undefined, + cacheControl: string, + extraHeaders?: Record, +): Response { + const headers = new Headers(headersInit); + headers.delete("ETag"); + headers.set("Cache-Control", cacheControl); + if (extraHeaders) { + for (const [name, value] of Object.entries(extraHeaders)) { + headers.set(name, value); + } + } + + return new Response(Bun.file(fileUrl), { + headers, + }); +} + +export function createProductionClientAssets( + manifest: ClientManifest, + baseUrl = new URL("./", import.meta.url), +): ProductionClientAssets { + const htmlEntry = manifest.files.find((file) => file.loader === "html" || file.path === manifest.index); + if (!htmlEntry) { + throw new Error("Production client manifest is missing the HTML shell"); + } + + const htmlUrl = new URL(htmlEntry.path, baseUrl); + const assetEntries = new Map( + manifest.files + .filter((file) => file.path !== htmlEntry.path && isClientAssetFile(file.path)) + .map((file) => { + const brotliPath = `${file.path}.br`; + const brotliUrl = new URL(brotliPath, baseUrl); + if (!existsSync(fileURLToPath(brotliUrl))) { + throw new Error(`Missing Brotli asset for ${file.path}`); + } + + return [ + toRequestPath(file.path), + { + ...file, + brotliPath, + fileUrl: new URL(file.path, baseUrl), + } satisfies AssetFile, + ] as const; + }), + ); + + return { + serveHtml() { + return createStaticResponse(htmlUrl, htmlEntry.headers, "no-cache"); + }, + serveAsset(request) { + const pathname = new URL(request.url).pathname; + const asset = assetEntries.get(pathname); + if (!asset) { + return null; + } + if (!acceptsBrotli(request.headers.get("accept-encoding"))) { + return new Response("Brotli encoding is required", { + status: 406, + headers: { + "Cache-Control": "no-store", + Vary: "Accept-Encoding", + }, + }); + } + + return createStaticResponse( + new URL(asset.brotliPath, asset.fileUrl), + asset.headers, + "public, max-age=31536000, immutable", + { + "Content-Encoding": "br", + Vary: "Accept-Encoding", + }, + ); + }, + }; +} + export function createServer() { const drawingStore = getDefaultDrawingStore(); const api = createApi(drawingStore); + const productionClient = isClientManifest(index) ? createProductionClientAssets(index) : null; return { port: Number(process.env.PORT ?? "3000"), @@ -14,7 +156,7 @@ export function createServer() { const drawing = drawingStore.ensureInitialDrawing(); return Response.redirect(new URL(`/d/${drawing.id}`, request.url), 302); }, - "/d/:id": index, + "/d/:id": productionClient ? (request: Request) => productionClient.serveHtml(request) : index, "/api/health": { GET: () => api.health(), }, @@ -28,7 +170,12 @@ export function createServer() { DELETE: (request: Request & { params: Record }) => api.deleteDrawing(request), }, }, - fetch() { + fetch(request: Request) { + const assetResponse = productionClient?.serveAsset(request); + if (assetResponse) { + return assetResponse; + } + return Response.json({ ok: false, error: "Not found" }, { status: 404 }); }, } satisfies Parameters[0];