fix: serve brotli-only prod assets
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --hot src/server.tsx",
|
"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",
|
"start": "cd dist && DATABASE_PATH=../data/excalidraw.sqlite bun ./server.js",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
@@ -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"), "<!doctype html><html><body>ok</body></html>");
|
||||||
|
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("<!doctype html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+149
-2
@@ -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 index from "./index.html";
|
||||||
import { createApi } from "./api";
|
import { createApi } from "./api";
|
||||||
import { getDefaultDrawingStore } from "./db";
|
import { getDefaultDrawingStore } from "./db";
|
||||||
|
|
||||||
|
type ClientManifestFile = {
|
||||||
|
path: string;
|
||||||
|
loader: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<ClientManifest>;
|
||||||
|
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<string, string> | undefined,
|
||||||
|
cacheControl: string,
|
||||||
|
extraHeaders?: Record<string, string>,
|
||||||
|
): 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() {
|
export function createServer() {
|
||||||
const drawingStore = getDefaultDrawingStore();
|
const drawingStore = getDefaultDrawingStore();
|
||||||
const api = createApi(drawingStore);
|
const api = createApi(drawingStore);
|
||||||
|
const productionClient = isClientManifest(index) ? createProductionClientAssets(index) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
port: Number(process.env.PORT ?? "3000"),
|
port: Number(process.env.PORT ?? "3000"),
|
||||||
@@ -14,7 +156,7 @@ export function createServer() {
|
|||||||
const drawing = drawingStore.ensureInitialDrawing();
|
const drawing = drawingStore.ensureInitialDrawing();
|
||||||
return Response.redirect(new URL(`/d/${drawing.id}`, request.url), 302);
|
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": {
|
"/api/health": {
|
||||||
GET: () => api.health(),
|
GET: () => api.health(),
|
||||||
},
|
},
|
||||||
@@ -28,7 +170,12 @@ export function createServer() {
|
|||||||
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
|
DELETE: (request: Request & { params: Record<string, string> }) => 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 });
|
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||||
},
|
},
|
||||||
} satisfies Parameters<typeof Bun.serve>[0];
|
} satisfies Parameters<typeof Bun.serve>[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user