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