Compare commits

...

13 Commits

Author SHA1 Message Date
ruinivist 589ad162f9 fix: adjust sidebar toggle button sizing and styling
Adjusts the CSS and SVG dimensions of the custom sidebar toggle
button so that it identically matches the 36x36px Excalidraw UI
native buttons (like the Help button) in both Light and Dark themes.

The inner SVG icon was precisely aligned to be 16x16px and use
a 1.8px stroke to match the weight and left-alignment of the native
icons. The .secondary-button class padding that was interfering
was removed.
2026-05-30 22:25:28 +00:00
ruinivist 93608bda1e fix: adjust sizing of sidebar SVG to match Excalidraw UI 2026-05-30 21:47:26 +00:00
ruinivist e576ca7d2d fix: adjust sizing of sidebar SVG to match Excalidraw UI 2026-05-30 20:31:46 +00:00
ruinivist 8630492b44 fix: apply precise styling to sidebar toggle for light and dark modes 2026-05-30 20:12:52 +00:00
ruinivist d52c08cd10 fix: style sidebar button to match native Excalidraw UI 2026-05-30 18:07:38 +00:00
ruinivist 7e0ec4b1c4 fix: remove broken code splitting 2026-05-30 17:15:39 +00:00
ruinivist 9ba2d91d05 feat: use caddy to serve static bundles to reduce manually reinventing it all in bun server 2026-05-30 16:23:38 +00:00
ruinivist e61d7c9cbd fix: serve brotli-only prod assets 2026-05-30 13:57:30 +00:00
ruinivist 9b44356782 fix: rem localhost bind inside of container 2026-05-26 22:05:50 +00:00
ruinivist a035162a6b fix: docs to localhost bind 2026-05-26 21:56:53 +00:00
ruinivist a19073d1f0 feat: publish to tag "latest" 2026-05-26 21:36:22 +00:00
ruinivist 10abe4a31f docs: update README 2026-05-26 20:16:31 +00:00
ruinivist af6d70fe6e fix: change default bind to be localhost 2026-05-26 20:15:26 +00:00
12 changed files with 233 additions and 51 deletions
+3 -1
View File
@@ -71,7 +71,9 @@ jobs:
context: .
file: ./Dockerfile
push: true
tags: ${{ env.IMAGE_NAME }}:${{ env.VERSION }}
tags: |
${{ env.IMAGE_NAME }}:${{ env.VERSION }}
${{ env.IMAGE_NAME }}:latest
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
+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
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"]
+20 -11
View File
@@ -1,6 +1,22 @@
# excali
# 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 Caddy auth or similar solutions.
> GPT 5.5 generated
## Pull from GHCR and run
Images are published to:
`ghcr.io/ruinivist/excalidraw-box:<version>`
```bash
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
```
## Run locally
@@ -22,14 +38,7 @@ bun run typecheck
```bash
docker build -t excali .
docker run --rm -p 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`.
> Note that this command will bind to ALL interfaces. If you use a rev
> proxy, you may want to bind to locahost instead, you can do that by setting the `HOST` environment variable to `localhost`:
```bash
docker run --rm -p 3000:3000 -v "$PWD/excali-box-data:/data" -e HOST=localhost 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`
+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",
"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"
},
+5 -5
View File
@@ -80,10 +80,10 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
function DrawerIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<svg viewBox="0 0 24 24" aria-hidden="true" width="16" height="16">
<rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
@@ -414,7 +414,7 @@ export function App() {
<div className="app-actions">
<button
type="button"
className="secondary-button app-actions-toggle"
className="app-actions-toggle"
onClick={openSidebar}
aria-label="Open drawings"
>
+3 -3
View File
@@ -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>,
);
+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}`);
}
+70
View File
@@ -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" });
});
});
+9 -9
View File
@@ -1,20 +1,22 @@
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 {
port: Number(process.env.PORT ?? "3000"),
hostname: process.env.HOST ?? "0.0.0.0",
hostname: process.env.HOST ?? "localhost",
routes: {
"/": (request: Request) => {
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];
}
+11 -12
View File
@@ -169,22 +169,21 @@ input {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--lg-button-size, 2.25rem);
height: var(--lg-button-size, 2.25rem);
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: var(--border-radius-lg, 12px);
box-shadow: 0 0 0 1px var(--app-surface-lowest);
border-radius: 8px;
box-shadow: none;
background: var(--app-surface-low);
color: var(--app-text-color);
cursor: pointer;
transition: background-color 120ms ease, box-shadow 120ms ease;
}
.app-actions-toggle svg {
width: 1rem;
height: 1rem;
overflow: visible;
transform: translateY(0.5px) scale(1.08);
transform-origin: center;
width: 16px;
height: 16px;
}
.primary-button,
@@ -265,12 +264,12 @@ input {
}
.app-actions-toggle:hover {
background: var(--app-button-hover-bg);
background-color: var(--app-button-hover-bg);
}
.app-actions-toggle:active {
box-shadow: 0 0 0 1px var(--app-button-active-border);
background: var(--app-button-active-bg);
background-color: var(--app-button-active-bg);
box-shadow: none;
}
.sr-only {