Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a04de9a4f | |||
| 66168e95cc | |||
| 17d01aff21 |
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-14
@@ -5,28 +5,21 @@ 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
|
WORKDIR /app/dist
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=127.0.0.1
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
||||||
|
|
||||||
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
COPY --from=build /app/dist ./
|
||||||
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 && chmod +x /usr/local/bin/docker-entrypoint.sh
|
RUN mkdir -p /data
|
||||||
|
|
||||||
VOLUME ["/data"]
|
EXPOSE 3000
|
||||||
|
|
||||||
EXPOSE 80
|
CMD ["bun", "./server.js"]
|
||||||
|
|
||||||
CMD ["/usr/local/bin/docker-entrypoint.sh"]
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# excali-box
|
# excali-box
|
||||||
|
|
||||||
Private self-hosted Excalidraw with Bun, Caddy, and SQLite.
|
Private self-hosted Excalidraw with Bun 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 Caddy auth or similar solutions.
|
No auth to keep things simple - you either run in a private network or put it behind a 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:80 -v "$PWD/excali-box-data:/data" 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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:80 -v "$PWD/excali-box-data:/data" excali
|
docker run --rm -p 127.0.0.1:3000:3000 -v "$PWD/excali-box-data:/data" excali
|
||||||
```
|
```
|
||||||
|
|
||||||
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`
|
This will make the data persistent in the `excali-box-data` folder in the current directory, the site will be available at `localhost:3000`.
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
+3
-5
@@ -3,11 +3,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --hot src/dev-server.tsx",
|
"dev": "bun --hot src/server.tsx",
|
||||||
"build": "rm -rf dist && bun run build:client && bun run build:server",
|
"build": "rm -rf dist && bun build --minify --target=browser ./src/index.html --outdir ./dist/public && bun build --target=bun ./src/server.tsx --outdir ./dist",
|
||||||
"build:client": "bun build --target=browser --production --outdir ./dist/public ./src/index.html",
|
"start": "cd dist && DATABASE_PATH=../data/excalidraw.sqlite bun ./server.js",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
import { StrictMode } from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
@@ -10,7 +10,7 @@ if (!root) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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}`);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test";
|
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
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) {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function createServerFixture() {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "excali-server-"));
|
|
||||||
cleanup.push(dir);
|
|
||||||
|
|
||||||
const store = createDrawingStore(join(dir, "test.sqlite"));
|
|
||||||
stores.push(store);
|
|
||||||
|
|
||||||
return {
|
|
||||||
store,
|
|
||||||
server: createServer({ drawingStore: store }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("createServer", () => {
|
|
||||||
test("redirects / to the latest drawing", () => {
|
|
||||||
const { server, store } = createServerFixture();
|
|
||||||
|
|
||||||
const response = server.routes["/"]?.(new Request("http://local/")) as Response;
|
|
||||||
const created = store.listDrawings();
|
|
||||||
|
|
||||||
expect(created).toHaveLength(1);
|
|
||||||
expect(response.status).toBe(302);
|
|
||||||
expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
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(await response?.json()).toEqual({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not expose a Bun static route for drawings in production", () => {
|
|
||||||
const { server } = createServerFixture();
|
|
||||||
|
|
||||||
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
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" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+53
-8
@@ -1,12 +1,9 @@
|
|||||||
|
import index from "./index.html";
|
||||||
import { createApi } from "./api";
|
import { createApi } from "./api";
|
||||||
import { type DrawingStore, getDefaultDrawingStore } from "./db";
|
import { getDefaultDrawingStore } from "./db";
|
||||||
|
|
||||||
type CreateServerOptions = {
|
export function createServer() {
|
||||||
drawingStore?: DrawingStore;
|
const drawingStore = getDefaultDrawingStore();
|
||||||
};
|
|
||||||
|
|
||||||
export function createServer(options: CreateServerOptions = {}) {
|
|
||||||
const drawingStore = options.drawingStore ?? getDefaultDrawingStore();
|
|
||||||
const api = createApi(drawingStore);
|
const api = createApi(drawingStore);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -17,6 +14,16 @@ export function createServer(options: CreateServerOptions = {}) {
|
|||||||
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);
|
||||||
},
|
},
|
||||||
|
...(process.cwd().endsWith("/dist") ? {
|
||||||
|
"/d/:id": async (req: Request) => {
|
||||||
|
let file = Bun.file("./public/index.html");
|
||||||
|
return new Response(file, {
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} : {
|
||||||
|
"/d/:id": index
|
||||||
|
}),
|
||||||
"/api/health": {
|
"/api/health": {
|
||||||
GET: () => api.health(),
|
GET: () => api.health(),
|
||||||
},
|
},
|
||||||
@@ -30,7 +37,45 @@ export function createServer(options: CreateServerOptions = {}) {
|
|||||||
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
|
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
|
async fetch(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const isProd = process.cwd().endsWith("/dist");
|
||||||
|
|
||||||
|
if (!isProd) {
|
||||||
|
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any "/public" prefix from the request URL if it accidentally got included
|
||||||
|
let reqPath = url.pathname;
|
||||||
|
if (reqPath.startsWith("/public/")) {
|
||||||
|
reqPath = reqPath.substring(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In dist, static files are inside public
|
||||||
|
const path = "./public" + reqPath;
|
||||||
|
const file = Bun.file(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
// Cache indefinitely except index.html
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (!reqPath.endsWith("index.html")) {
|
||||||
|
// Add etag logic or use bun's hashes by only caching files that actually have hashes in them.
|
||||||
|
// Bun generates hashes like chunk-02ahh9r5.js, we can check for that pattern
|
||||||
|
// If we wanted to do ETag we could use Bun.file(path).size + lastModified but hashing is safer
|
||||||
|
const isHashed = reqPath.match(/-[a-zA-Z0-9]{8}\.(js|css)$/);
|
||||||
|
if (isHashed) {
|
||||||
|
headers["Cache-Control"] = "public, max-age=31536000, immutable";
|
||||||
|
} else {
|
||||||
|
headers["Cache-Control"] = "no-cache";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
headers["Content-Type"] = "text/html; charset=utf-8";
|
||||||
|
headers["Cache-Control"] = "no-cache";
|
||||||
|
}
|
||||||
|
return new Response(file, { headers });
|
||||||
|
}
|
||||||
|
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