diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..c98668b --- /dev/null +++ b/Caddyfile @@ -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 + } +} diff --git a/Dockerfile b/Dockerfile index 5b85c7f..22a6678 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,21 +5,28 @@ COPY package.json bun.lock tsconfig.json ./ RUN bun install --frozen-lockfile COPY src ./src -COPY README.md ./ RUN bun run build +FROM caddy:2-alpine AS caddy + FROM oven/bun:1.3.11-alpine AS runner -WORKDIR /app/dist +WORKDIR /app ENV NODE_ENV=production -ENV HOST=0.0.0.0 +ENV HOST=127.0.0.1 ENV PORT=3000 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"] diff --git a/README.md b/README.md index c4ab0fb..4ea6990 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # 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. -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 @@ -15,7 +15,7 @@ Images are published to: ```bash 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 @@ -38,7 +38,7 @@ bun run typecheck ```bash 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` diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..d9c4b1e --- /dev/null +++ b/docker-entrypoint.sh @@ -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" diff --git a/package.json b/package.json index be030dc..c33658c 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,11 @@ "type": "module", "private": true, "scripts": { - "dev": "bun --hot src/server.tsx", - "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", + "dev": "bun --hot src/dev-server.tsx", + "build": "rm -rf dist && bun run build:client && bun run build:server", + "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", "typecheck": "tsc --noEmit" }, diff --git a/scripts/compress-assets.ts b/scripts/compress-assets.ts deleted file mode 100644 index ddd43b4..0000000 --- a/scripts/compress-assets.ts +++ /dev/null @@ -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)}`); diff --git a/src/dev-server.tsx b/src/dev-server.tsx new file mode 100644 index 0000000..942a942 --- /dev/null +++ b/src/dev-server.tsx @@ -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[0]; +} + +if (import.meta.main) { + const server = Bun.serve(createDevServer()); + console.log(`Listening on http://${server.hostname}:${server.port}`); +} diff --git a/src/server.test.ts b/src/server.test.ts index c1b93a8..b00a27e 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,13 +1,18 @@ 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 { join } from "node:path"; -import { pathToFileURL } from "node:url"; -import { createProductionClientAssets } from "./server"; +import { createDrawingStore, type DrawingStore } from "./db"; +import { createServer } from "./server"; const cleanup: string[] = []; +const stores: DrawingStore[] = []; afterEach(() => { + while (stores.length > 0) { + stores.pop()?.close(); + } + while (cleanup.length > 0) { const dir = cleanup.pop(); if (dir) { @@ -16,83 +21,50 @@ afterEach(() => { } }); -function createClientAssetsFixture() { - const dir = mkdtempSync(join(tmpdir(), "excali-client-assets-")); +function createServerFixture() { + const dir = mkdtempSync(join(tmpdir(), "excali-server-")); 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"); + const store = createDrawingStore(join(dir, "test.sqlite")); + stores.push(store); - 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}/`), - ); + return { + store, + server: createServer({ drawingStore: store }), + }; } -describe("production client assets", () => { - test("serves the HTML shell with revalidation headers", async () => { - const assets = createClientAssetsFixture(); +describe("createServer", () => { + test("redirects / to the latest drawing", () => { + 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(response.headers.get("cache-control")).toBe("no-cache"); - expect(response.headers.get("etag")).toBeNull(); - expect(await response.text()).toContain(""); + expect(created).toHaveLength(1); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`); }); - 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", - }, - }), - ); + test("keeps Bun responsible for API routes", async () => { + const { server } = createServerFixture(); + const response = await server.routes["/api/health"]?.GET?.(); 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"); + expect(await response?.json()).toEqual({ ok: true }); }); - test("rejects asset requests without Brotli support", async () => { - const assets = createClientAssetsFixture(); + test("does not expose a Bun static route for drawings in production", () => { + const { server } = createServerFixture(); - const response = assets.serveAsset( - new Request("http://local/index-abc123.js", { - headers: { - "accept-encoding": "gzip", - }, - }), - ); + expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false); + }); - 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"); + test("returns JSON 404 for unmatched requests", async () => { + const { server } = createServerFixture(); + const response = await server.fetch(new Request("http://local/index-abc123.js")); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ ok: false, error: "Not found" }); }); }); diff --git a/src/server.tsx b/src/server.tsx index 8f80b22..e1d9479 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -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 { getDefaultDrawingStore } from "./db"; +import { type DrawingStore, getDefaultDrawingStore } from "./db"; -type ClientManifestFile = { - path: string; - loader: string; - headers?: Record; +type CreateServerOptions = { + drawingStore?: DrawingStore; }; -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(); +export function createServer(options: CreateServerOptions = {}) { + const drawingStore = options.drawingStore ?? getDefaultDrawingStore(); const api = createApi(drawingStore); - const productionClient = isClientManifest(index) ? createProductionClientAssets(index) : null; return { port: Number(process.env.PORT ?? "3000"), @@ -156,7 +17,6 @@ export function createServer() { const drawing = drawingStore.ensureInitialDrawing(); return Response.redirect(new URL(`/d/${drawing.id}`, request.url), 302); }, - "/d/:id": productionClient ? (request: Request) => productionClient.serveHtml(request) : index, "/api/health": { GET: () => api.health(), }, @@ -170,14 +30,7 @@ export function createServer() { DELETE: (request: Request & { params: Record }) => api.deleteDrawing(request), }, }, - fetch(request: Request) { - const assetResponse = productionClient?.serveAsset(request); - if (assetResponse) { - return assetResponse; - } - - return Response.json({ ok: false, error: "Not found" }, { status: 404 }); - }, + fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }), } satisfies Parameters[0]; }