Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58ac662d8e | |||
| 467b54c402 | |||
| 7e0ec4b1c4 | |||
| 9ba2d91d05 | |||
| e61d7c9cbd |
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -3,9 +3,11 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun --hot src/server.tsx",
|
||||
"build": "rm -rf dist && bun build --target=bun ./src/server.tsx --outdir ./dist",
|
||||
"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 --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"
|
||||
},
|
||||
|
||||
+56
-2
@@ -96,6 +96,7 @@ export function App() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
const currentIdRef = useRef<string | null>(activeId);
|
||||
const currentTitleRef = useRef(activeTitle);
|
||||
@@ -106,6 +107,7 @@ export function App() {
|
||||
const inFlightSaveRef = useRef<Promise<void> | null>(null);
|
||||
const loadVersionRef = useRef(0);
|
||||
const themeSyncFrameRef = useRef<number | null>(null);
|
||||
const toastTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const activeDrawing = useMemo(
|
||||
() => drawings.find((drawing) => drawing.id === activeId) ?? null,
|
||||
@@ -118,7 +120,7 @@ export function App() {
|
||||
return list;
|
||||
}, []);
|
||||
|
||||
const saveScene = useCallback(async () => {
|
||||
const saveScene = useCallback(async (isManual = false) => {
|
||||
const drawingId = currentIdRef.current;
|
||||
const nextScene = latestSceneRef.current;
|
||||
if (!drawingId || !nextScene) {
|
||||
@@ -126,15 +128,37 @@ export function App() {
|
||||
}
|
||||
setError(null);
|
||||
|
||||
if (isManual) {
|
||||
setToastMessage("Saving...");
|
||||
if (toastTimeoutRef.current !== null) {
|
||||
clearTimeout(toastTimeoutRef.current);
|
||||
toastTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
const promise = requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(nextScene),
|
||||
})
|
||||
.then((body) => {
|
||||
setDrawings((current) => replaceMeta(current, body.drawing));
|
||||
if (isManual) {
|
||||
setToastMessage("Saved");
|
||||
if (toastTimeoutRef.current !== null) {
|
||||
clearTimeout(toastTimeoutRef.current);
|
||||
}
|
||||
toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), 2000);
|
||||
}
|
||||
})
|
||||
.catch((saveError: unknown) => {
|
||||
setError(saveError instanceof Error ? saveError.message : "Save failed");
|
||||
if (isManual) {
|
||||
setToastMessage("Save failed");
|
||||
if (toastTimeoutRef.current !== null) {
|
||||
clearTimeout(toastTimeoutRef.current);
|
||||
}
|
||||
toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), 2000);
|
||||
}
|
||||
throw saveError;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -158,6 +182,17 @@ export function App() {
|
||||
}
|
||||
}, [saveScene]);
|
||||
|
||||
const triggerManualSave = useCallback(async () => {
|
||||
if (timeoutRef.current !== null) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (inFlightSaveRef.current) {
|
||||
await inFlightSaveRef.current;
|
||||
}
|
||||
await saveScene(true);
|
||||
}, [saveScene]);
|
||||
|
||||
const scheduleSave = useCallback(() => {
|
||||
if (timeoutRef.current !== null) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
@@ -166,7 +201,7 @@ export function App() {
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
void saveScene();
|
||||
}, 1500);
|
||||
}, 30000);
|
||||
}, [saveScene]);
|
||||
|
||||
const loadDrawing = useCallback(
|
||||
@@ -329,6 +364,20 @@ export function App() {
|
||||
});
|
||||
}, [syncThemeTokens]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void triggerManualSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [triggerManualSave]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentPathId = pathToId();
|
||||
if (currentPathId) {
|
||||
@@ -410,6 +459,11 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div className="app-shell" ref={appShellRef}>
|
||||
{toastMessage && (
|
||||
<div className="toast-overlay" aria-live="polite">
|
||||
{toastMessage}
|
||||
</div>
|
||||
)}
|
||||
<main className="editor-shell">
|
||||
<div className="app-actions">
|
||||
<button
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
@@ -10,7 +10,7 @@ if (!root) {
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
+8
-8
@@ -1,9 +1,12 @@
|
||||
import index from "./index.html";
|
||||
import { createApi } from "./api";
|
||||
import { getDefaultDrawingStore } from "./db";
|
||||
import { type DrawingStore, getDefaultDrawingStore } from "./db";
|
||||
|
||||
export function createServer() {
|
||||
const drawingStore = getDefaultDrawingStore();
|
||||
type CreateServerOptions = {
|
||||
drawingStore?: DrawingStore;
|
||||
};
|
||||
|
||||
export function createServer(options: CreateServerOptions = {}) {
|
||||
const drawingStore = options.drawingStore ?? getDefaultDrawingStore();
|
||||
const api = createApi(drawingStore);
|
||||
|
||||
return {
|
||||
@@ -14,7 +17,6 @@ export function createServer() {
|
||||
const drawing = drawingStore.ensureInitialDrawing();
|
||||
return Response.redirect(new URL(`/d/${drawing.id}`, request.url), 302);
|
||||
},
|
||||
"/d/:id": index,
|
||||
"/api/health": {
|
||||
GET: () => api.health(),
|
||||
},
|
||||
@@ -28,9 +30,7 @@ export function createServer() {
|
||||
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
|
||||
},
|
||||
},
|
||||
fetch() {
|
||||
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||
},
|
||||
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
|
||||
} satisfies Parameters<typeof Bun.serve>[0];
|
||||
}
|
||||
|
||||
|
||||
@@ -253,6 +253,22 @@ input {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast-overlay {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 100;
|
||||
background: var(--app-overlay-bg);
|
||||
backdrop-filter: blur(4px);
|
||||
color: var(--app-text-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--app-sidebar-font-size);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--app-sidebar-border);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor-frame,
|
||||
.editor-loading {
|
||||
height: 100%;
|
||||
|
||||
Reference in New Issue
Block a user