feat: use caddy to serve static bundles to reduce manually reinventing it all in bun server

This commit is contained in:
2026-05-30 16:23:38 +00:00
parent e61d7c9cbd
commit 9ba2d91d05
9 changed files with 161 additions and 267 deletions
+31
View File
@@ -0,0 +1,31 @@
{
auto_https off
admin off
}
:80 {
encode zstd gzip
@api path /api/*
handle @api {
reverse_proxy 127.0.0.1:3000
}
@root path /
handle @root {
reverse_proxy 127.0.0.1:3000
}
handle {
root * /srv/public
@html path /index.html /d/*
header @html Cache-Control "no-cache"
@assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
header @assets Cache-Control "public, max-age=31536000, immutable"
try_files {path} /index.html
file_server
}
}
+14 -7
View File
@@ -5,21 +5,28 @@ COPY package.json bun.lock tsconfig.json ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY src ./src COPY src ./src
COPY README.md ./
RUN bun run build RUN bun run build
FROM caddy:2-alpine AS caddy
FROM oven/bun:1.3.11-alpine AS runner FROM oven/bun:1.3.11-alpine AS runner
WORKDIR /app/dist WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOST=0.0.0.0 ENV HOST=127.0.0.1
ENV PORT=3000 ENV PORT=3000
ENV DATABASE_PATH=/data/excalidraw.sqlite ENV DATABASE_PATH=/data/excalidraw.sqlite
COPY --from=build /app/dist ./ COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
COPY --from=build /app/dist/public /srv/public
COPY --from=build /app/dist/server /app/dist/server
COPY Caddyfile /etc/caddy/Caddyfile
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /data RUN mkdir -p /data && chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 3000 VOLUME ["/data"]
CMD ["bun", "./server.js"] EXPOSE 80
CMD ["/usr/local/bin/docker-entrypoint.sh"]
+5 -5
View File
@@ -1,9 +1,9 @@
# excali-box # excali-box
Private self-hosted Excalidraw with Bun and SQLite. Private self-hosted Excalidraw with Bun, Caddy, and SQLite.
This is bare wrapper around the excalidraw packages that adds autosave and disk-persistence. This is bare wrapper around the excalidraw packages that adds autosave and disk-persistence.
No auth to keep things simple - you either run in a private network or put it behind a Caddy auth or similar solutions. No auth to keep things simple - you either run in a private network or put it behind Caddy auth or similar solutions.
> GPT 5.5 generated > GPT 5.5 generated
@@ -15,7 +15,7 @@ Images are published to:
```bash ```bash
docker pull ghcr.io/ruinivist/excalidraw-box:latest docker pull ghcr.io/ruinivist/excalidraw-box:latest
docker run --rm -p 127.0.0.1:3000:3000 -v "$PWD/excali-box-data:/data" ghcr.io/ruinivist/excalidraw-box:latest docker run --rm -p 127.0.0.1:3000:80 -v "$PWD/excali-box-data:/data" ghcr.io/ruinivist/excalidraw-box:latest
``` ```
## Run locally ## Run locally
@@ -38,7 +38,7 @@ bun run typecheck
```bash ```bash
docker build -t excali . docker build -t excali .
docker run --rm -p 127.0.0.1:3000:3000 -v "$PWD/excali-box-data:/data" excali docker run --rm -p 127.0.0.1:3000:80 -v "$PWD/excali-box-data:/data" excali
``` ```
This will make the data persistent in the `excali-box-data` folder in the current directory, the site will be available at `localhost:3000`. This will make the data persistent in the `excali-box-data` folder in the current directory, with Caddy serving the browser build on `localhost:3000`
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
set -eu
bun_pid=""
caddy_pid=""
stop_children() {
for pid in "$bun_pid" "$caddy_pid"; do
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
fi
done
}
trap 'stop_children' INT TERM
bun /app/dist/server/server.js &
bun_pid=$!
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile &
caddy_pid=$!
exit_code=0
while :; do
if ! kill -0 "$bun_pid" 2>/dev/null; then
wait "$bun_pid" || exit_code=$?
break
fi
if ! kill -0 "$caddy_pid" 2>/dev/null; then
wait "$caddy_pid" || exit_code=$?
break
fi
sleep 1
done
stop_children
wait "$bun_pid" 2>/dev/null || true
wait "$caddy_pid" 2>/dev/null || true
exit "$exit_code"
+5 -3
View File
@@ -3,9 +3,11 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "bun --hot src/server.tsx", "dev": "bun --hot src/dev-server.tsx",
"build": "rm -rf dist && bun build --target=bun --production --splitting ./src/server.tsx --outdir ./dist && bun run ./scripts/compress-assets.ts", "build": "rm -rf dist && bun run build:client && bun run build:server",
"start": "cd dist && DATABASE_PATH=../data/excalidraw.sqlite bun ./server.js", "build:client": "bun build --target=browser --production --splitting --outdir ./dist/public ./src/index.html",
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server.tsx",
"start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
"test": "bun test", "test": "bun test",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
-33
View File
@@ -1,33 +0,0 @@
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)}`);
+19
View File
@@ -0,0 +1,19 @@
import index from "./index.html";
import { createServer } from "./server";
export function createDevServer() {
const server = createServer();
return {
...server,
routes: {
...server.routes,
"/d/:id": index,
},
} satisfies Parameters<typeof Bun.serve>[0];
}
if (import.meta.main) {
const server = Bun.serve(createDevServer());
console.log(`Listening on http://${server.hostname}:${server.port}`);
}
+38 -66
View File
@@ -1,13 +1,18 @@
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { pathToFileURL } from "node:url"; import { createDrawingStore, type DrawingStore } from "./db";
import { createProductionClientAssets } from "./server"; import { createServer } from "./server";
const cleanup: string[] = []; const cleanup: string[] = [];
const stores: DrawingStore[] = [];
afterEach(() => { afterEach(() => {
while (stores.length > 0) {
stores.pop()?.close();
}
while (cleanup.length > 0) { while (cleanup.length > 0) {
const dir = cleanup.pop(); const dir = cleanup.pop();
if (dir) { if (dir) {
@@ -16,83 +21,50 @@ afterEach(() => {
} }
}); });
function createClientAssetsFixture() { function createServerFixture() {
const dir = mkdtempSync(join(tmpdir(), "excali-client-assets-")); const dir = mkdtempSync(join(tmpdir(), "excali-server-"));
cleanup.push(dir); cleanup.push(dir);
writeFileSync(join(dir, "index.html"), "<!doctype html><html><body>ok</body></html>"); const store = createDrawingStore(join(dir, "test.sqlite"));
writeFileSync(join(dir, "index-abc123.js"), "console.log('ok');"); stores.push(store);
writeFileSync(join(dir, "index-abc123.js.br"), "compressed-brotli");
return createProductionClientAssets( return {
{ store,
index: "./index.html", server: createServer({ drawingStore: store }),
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", () => { describe("createServer", () => {
test("serves the HTML shell with revalidation headers", async () => { test("redirects / to the latest drawing", () => {
const assets = createClientAssetsFixture(); const { server, store } = createServerFixture();
const response = assets.serveHtml(new Request("http://local/d/abc123")); const response = server.routes["/"]?.(new Request("http://local/")) as Response;
const created = store.listDrawings();
expect(response.status).toBe(200); expect(created).toHaveLength(1);
expect(response.headers.get("cache-control")).toBe("no-cache"); expect(response.status).toBe(302);
expect(response.headers.get("etag")).toBeNull(); expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`);
expect(await response.text()).toContain("<!doctype html>");
}); });
test("serves immutable hashed assets as Brotli", async () => { test("keeps Bun responsible for API routes", async () => {
const assets = createClientAssetsFixture(); const { server } = createServerFixture();
const response = await server.routes["/api/health"]?.GET?.();
const response = assets.serveAsset(
new Request("http://local/index-abc123.js", {
headers: {
"accept-encoding": "gzip, br",
},
}),
);
expect(response?.status).toBe(200); expect(response?.status).toBe(200);
expect(response?.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); expect(await response?.json()).toEqual({ ok: true });
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 () => { test("does not expose a Bun static route for drawings in production", () => {
const assets = createClientAssetsFixture(); const { server } = createServerFixture();
const response = assets.serveAsset( expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
new Request("http://local/index-abc123.js", { });
headers: {
"accept-encoding": "gzip",
},
}),
);
expect(response?.status).toBe(406); test("returns JSON 404 for unmatched requests", async () => {
expect(response?.headers.get("cache-control")).toBe("no-store"); const { server } = createServerFixture();
expect(response?.headers.get("vary")).toBe("Accept-Encoding"); const response = await server.fetch(new Request("http://local/index-abc123.js"));
expect(await response?.text()).toBe("Brotli encoding is required");
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ ok: false, error: "Not found" });
}); });
}); });
+6 -153
View File
@@ -1,152 +1,13 @@
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 { createApi } from "./api";
import { getDefaultDrawingStore } from "./db"; import { type DrawingStore, getDefaultDrawingStore } from "./db";
type ClientManifestFile = { type CreateServerOptions = {
path: string; drawingStore?: DrawingStore;
loader: string;
headers?: Record<string, string>;
}; };
type ClientManifest = { export function createServer(options: CreateServerOptions = {}) {
index: string; const drawingStore = options.drawingStore ?? getDefaultDrawingStore();
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() {
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"),
@@ -156,7 +17,6 @@ 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": productionClient ? (request: Request) => productionClient.serveHtml(request) : index,
"/api/health": { "/api/health": {
GET: () => api.health(), GET: () => api.health(),
}, },
@@ -170,14 +30,7 @@ export function createServer() {
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request), DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
}, },
}, },
fetch(request: Request) { fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
const assetResponse = productionClient?.serveAsset(request);
if (assetResponse) {
return assetResponse;
}
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
},
} satisfies Parameters<typeof Bun.serve>[0]; } satisfies Parameters<typeof Bun.serve>[0];
} }