chore: strip repository to provide a minimal reproduction of bun code splitting bug

Removes all server-side code, tests, CI pipelines, Docker config, extra CSS, and sidebar logic. Modifies `App.tsx` to just eagerly load and render `<Excalidraw />`. Modifies `package.json` to only contain a basic build command to reproduce the bug where Bun injects a dynamic chunk rather than the entrypoint chunk.
This commit is contained in:
ruinivist
2026-05-30 17:59:06 +00:00
parent be361e3896
commit 7bfa1051c3
21 changed files with 4 additions and 1797 deletions
-5
View File
@@ -1,5 +0,0 @@
node_modules
dist
data
.git
coverage
-80
View File
@@ -1,80 +0,0 @@
name: Publish GHCR Image
on:
push:
tags:
- 'release-*'
permissions:
contents: read
packages: write
concurrency:
group: publish-ghcr-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run typecheck
run: bun run typecheck
- name: Run tests
run: bun run test
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Derive image metadata
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#release-}"
version="${version#v}"
if [[ -z "$version" ]]; then
echo "Release version is empty after stripping prefix from tag: $GITHUB_REF_NAME" >&2
exit 1
fi
image_name="ghcr.io/${GITHUB_REPOSITORY,,}"
{
echo "VERSION=$version"
echo "IMAGE_NAME=$image_name"
} >> "$GITHUB_ENV"
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
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 }}
org.opencontainers.image.version=${{ env.VERSION }}
-1
View File
@@ -35,4 +35,3 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.DS_Store
PLAN.md
bun-repro-dir/
-31
View File
@@ -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
}
}
-32
View File
@@ -1,32 +0,0 @@
FROM oven/bun:1.3.11-alpine AS build
WORKDIR /app
COPY package.json bun.lock tsconfig.json ./
RUN bun install --frozen-lockfile
COPY src ./src
RUN bun run build
FROM caddy:2-alpine AS caddy
FROM oven/bun:1.3.11-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=127.0.0.1
ENV PORT=3000
ENV DATABASE_PATH=/data/excalidraw.sqlite
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 && chmod +x /usr/local/bin/docker-entrypoint.sh
VOLUME ["/data"]
EXPOSE 80
CMD ["/usr/local/bin/docker-entrypoint.sh"]
-43
View File
@@ -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"
+2 -8
View File
@@ -1,15 +1,9 @@
{
"name": "excali",
"name": "excali-repro",
"type": "module",
"private": true,
"scripts": {
"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"
"build": "rm -rf dist && bun build --target=browser --splitting --outdir ./dist/public ./src/index.html"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.18.1",
-59
View File
@@ -1,59 +0,0 @@
import { rmdir, mkdir, writeFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { spawn } from "node:child_process";
async function main() {
const testDir = join(process.cwd(), "bun-repro-dir");
await rmdir(testDir, { recursive: true }).catch(() => {});
await mkdir(testDir, { recursive: true });
const numChunks = 2500;
for (let i = 0; i < numChunks; i++) {
await writeFile(join(testDir, `chunk${i}.js`), `export const val = ${i};\nconsole.log(val);`);
}
let entryFile = "import { x } from './entry1.js'; console.log(x);\n";
let entry1File = "export const x = 1;\n";
for (let i = 0; i < numChunks; i++) {
entry1File += `import("./chunk${i}.js").then(m => console.log(m.val));\n`;
}
await writeFile(join(testDir, "entry1.js"), entry1File);
await writeFile(join(testDir, "index.js"), entryFile);
const htmlContent = `<!DOCTYPE html><html><head><script type="module" src="./index.js"></script></head><body></body></html>`;
await writeFile(join(testDir, "index.html"), htmlContent);
console.log("Building...");
const child = spawn(process.argv[0], ["build", "--target=browser", "--splitting", "--outdir", "./dist", "./index.html"], {
cwd: testDir,
stdio: "inherit",
});
await new Promise((resolve) => child.on("close", resolve));
const outputHtml = await readFile(join(testDir, "dist", "index.html"), "utf8");
console.log("\nGenerated HTML (partial):");
console.log(outputHtml.slice(0, 500));
// Parse the injected script src
const match = outputHtml.match(/src="([^"]+)"/);
if (!match) {
console.error("Failed to parse output HTML");
process.exit(1);
}
const injectedScript = match[1];
console.log(`\nInjected script: ${injectedScript}`);
const injectedContent = await readFile(join(testDir, "dist", injectedScript), "utf8");
if (injectedContent.includes('console.log(x)') || injectedContent.includes('console.log(1)')) {
console.log("✅ Bun successfully injected the main entrypoint!");
} else {
console.log("❌ Bug confirmed: Bun injected a random chunk instead of the main entrypoint.");
}
}
main().catch(console.error);
+2 -505
View File
@@ -1,512 +1,9 @@
import "../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { Excalidraw } from "@excalidraw/excalidraw";
import type {
AppState,
BinaryFiles,
ExcalidrawInitialDataState,
} from "@excalidraw/excalidraw/types";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "./shared";
type DrawingResponse = DrawingMeta & ScenePayload;
const EXCALIDRAW_THEME_TOKEN_MAP = [
["--island-bg-color", "--app-island-bg"],
["--sidebar-bg-color", "--app-sidebar-bg"],
["--sidebar-border-color", "--app-sidebar-border"],
["--sidebar-shadow", "--app-sidebar-shadow"],
["--color-surface-lowest", "--app-surface-lowest"],
["--color-surface-low", "--app-surface-low"],
["--color-surface-primary-container", "--app-selected-bg"],
["--color-on-surface", "--app-text-color"],
["--color-on-primary-container", "--app-selected-text-color"],
["--color-disabled", "--app-disabled-color"],
["--input-bg-color", "--app-input-bg"],
["--input-border-color", "--app-input-border"],
["--input-label-color", "--app-input-color"],
["--default-border-color", "--app-button-border"],
["--button-hover-bg", "--app-button-hover-bg"],
["--button-active-bg", "--app-button-active-bg"],
["--button-active-border", "--app-button-active-border"],
["--overlay-bg-color", "--app-overlay-bg"],
] as const;
function pathToId(pathname = window.location.pathname): string | null {
const match = pathname.match(/^\/d\/([^/]+)$/);
return match?.[1] ?? null;
}
function sortDrawings(drawings: DrawingMeta[]): DrawingMeta[] {
return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
function replaceMeta(drawings: DrawingMeta[], drawing: DrawingMeta): DrawingMeta[] {
const filtered = drawings.filter((item) => item.id !== drawing.id);
return sortDrawings([drawing, ...filtered]);
}
function sceneToInitialData(scene: ScenePayload): ExcalidrawInitialDataState {
return scene as ExcalidrawInitialDataState;
}
function sceneFromEditor(
elements: readonly unknown[],
appState: AppState,
files: BinaryFiles,
): ScenePayload {
return {
elements: [...elements],
appState: appState as unknown as Record<string, unknown>,
files: files as unknown as Record<string, unknown>,
};
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: {
...(init?.body ? { "content-type": "application/json" } : {}),
...init?.headers,
},
});
const body = (await response.json()) as T & { error?: string };
if (!response.ok) {
throw new Error(body.error ?? `Request failed: ${response.status}`);
}
return body;
}
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>
);
}
export function App() {
const [drawings, setDrawings] = useState<DrawingMeta[]>([]);
const [activeId, setActiveId] = useState<string | null>(pathToId());
const [activeTitle, setActiveTitle] = useState(DEFAULT_TITLE);
const [scene, setScene] = useState<ScenePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const currentIdRef = useRef<string | null>(activeId);
const currentTitleRef = useRef(activeTitle);
const latestSceneRef = useRef<ScenePayload | null>(null);
const ignoreChangeRef = useRef(false);
const appShellRef = useRef<HTMLDivElement | null>(null);
const timeoutRef = useRef<number | null>(null);
const inFlightSaveRef = useRef<Promise<void> | null>(null);
const loadVersionRef = useRef(0);
const themeSyncFrameRef = useRef<number | null>(null);
const activeDrawing = useMemo(
() => drawings.find((drawing) => drawing.id === activeId) ?? null,
[activeId, drawings],
);
const refreshList = useCallback(async () => {
const list = await requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" });
setDrawings(sortDrawings(list));
return list;
}, []);
const saveScene = useCallback(async () => {
const drawingId = currentIdRef.current;
const nextScene = latestSceneRef.current;
if (!drawingId || !nextScene) {
return;
}
setError(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));
})
.catch((saveError: unknown) => {
setError(saveError instanceof Error ? saveError.message : "Save failed");
throw saveError;
})
.finally(() => {
inFlightSaveRef.current = null;
});
inFlightSaveRef.current = promise;
await promise;
}, []);
const flushPendingSave = useCallback(async () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
await saveScene();
return;
}
if (inFlightSaveRef.current) {
await inFlightSaveRef.current;
}
}, [saveScene]);
const scheduleSave = useCallback(() => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null;
void saveScene();
}, 1500);
}, [saveScene]);
const loadDrawing = useCallback(
async (drawingId: string) => {
const version = ++loadVersionRef.current;
setLoading(true);
setError(null);
try {
const [list, drawing] = await Promise.all([
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }),
]);
if (version !== loadVersionRef.current) {
return;
}
const nextScene = {
elements: drawing.elements,
appState: drawing.appState,
files: drawing.files,
};
ignoreChangeRef.current = true;
currentIdRef.current = drawing.id;
currentTitleRef.current = drawing.title;
latestSceneRef.current = nextScene;
setDrawings(sortDrawings(list));
setActiveId(drawing.id);
setActiveTitle(drawing.title);
setScene(nextScene);
} catch (loadError) {
const list = await refreshList();
if (list.length === 0) {
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" });
window.history.replaceState(null, "", `/d/${created.id}`);
await loadDrawing(created.id);
return;
}
const fallback = list[0];
if (fallback && fallback.id !== drawingId) {
window.history.replaceState(null, "", `/d/${fallback.id}`);
await loadDrawing(fallback.id);
return;
}
setError(loadError instanceof Error ? loadError.message : "Failed to load drawing");
} finally {
if (version === loadVersionRef.current) {
setLoading(false);
}
}
},
[refreshList],
);
const navigateToDrawing = useCallback(
async (drawingId: string, options?: { replace?: boolean; flush?: boolean }) => {
const replace = options?.replace ?? false;
const flush = options?.flush ?? true;
if (drawingId === currentIdRef.current && latestSceneRef.current) {
if (replace) {
window.history.replaceState(null, "", `/d/${drawingId}`);
}
return;
}
if (flush) {
await flushPendingSave();
}
if (replace) {
window.history.replaceState(null, "", `/d/${drawingId}`);
} else {
window.history.pushState(null, "", `/d/${drawingId}`);
}
await loadDrawing(drawingId);
},
[flushPendingSave, loadDrawing],
);
const createDrawing = useCallback(async () => {
await flushPendingSave();
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" });
await navigateToDrawing(created.id, { flush: false });
}, [flushPendingSave, navigateToDrawing]);
const deleteDrawing = useCallback(
async (drawingId: string) => {
if (!window.confirm("Delete this drawing?")) {
return;
}
if (drawingId === currentIdRef.current) {
await flushPendingSave();
}
const response = await requestJson<{ ok: true; nextId: string | null }>(`/api/drawings/${drawingId}`, {
method: "DELETE",
});
if (drawingId === currentIdRef.current && response.nextId) {
await navigateToDrawing(response.nextId, { replace: true, flush: false });
} else {
await refreshList();
}
},
[flushPendingSave, navigateToDrawing, refreshList],
);
const handleTitleSubmit = useCallback(async () => {
const drawingId = currentIdRef.current;
if (!drawingId) {
return;
}
try {
const body = await requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
method: "PUT",
body: JSON.stringify({ title: currentTitleRef.current }),
});
currentTitleRef.current = body.drawing.title;
setActiveTitle(body.drawing.title);
setDrawings((current) => replaceMeta(current, body.drawing));
} catch (renameError) {
setError(renameError instanceof Error ? renameError.message : "Rename failed");
}
}, []);
const syncThemeTokens = useCallback(() => {
const appShell = appShellRef.current;
const excalidrawRoot = appShell?.querySelector<HTMLElement>(".excalidraw");
if (!appShell || !excalidrawRoot) {
return;
}
const computedStyles = window.getComputedStyle(excalidrawRoot);
for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) {
const value = computedStyles.getPropertyValue(sourceToken).trim();
if (value) {
appShell.style.setProperty(targetToken, value);
}
}
}, []);
const scheduleThemeTokenSync = useCallback(() => {
if (themeSyncFrameRef.current !== null) {
window.cancelAnimationFrame(themeSyncFrameRef.current);
}
themeSyncFrameRef.current = window.requestAnimationFrame(() => {
themeSyncFrameRef.current = null;
syncThemeTokens();
});
}, [syncThemeTokens]);
useEffect(() => {
const currentPathId = pathToId();
if (currentPathId) {
void loadDrawing(currentPathId);
} else {
window.location.replace("/");
}
const onPopState = () => {
const nextId = pathToId();
if (nextId) {
void navigateToDrawing(nextId, { replace: true, flush: true });
}
};
window.addEventListener("popstate", onPopState);
return () => {
window.removeEventListener("popstate", onPopState);
};
}, [loadDrawing, navigateToDrawing]);
useEffect(() => {
return () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
if (themeSyncFrameRef.current !== null) {
window.cancelAnimationFrame(themeSyncFrameRef.current);
}
};
}, []);
useEffect(() => {
if (!scene || loading) {
return;
}
scheduleThemeTokenSync();
}, [activeId, loading, scene, scheduleThemeTokenSync]);
useEffect(() => {
if (!isSidebarOpen) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsSidebarOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [isSidebarOpen]);
const openSidebar = useCallback(() => {
setIsSidebarOpen(true);
}, []);
const closeSidebar = useCallback(() => {
setIsSidebarOpen(false);
}, []);
const handleSelectDrawing = useCallback(
async (drawingId: string) => {
setIsSidebarOpen(false);
await navigateToDrawing(drawingId);
},
[navigateToDrawing],
);
const handleCreateDrawing = useCallback(async () => {
setIsSidebarOpen(false);
await createDrawing();
}, [createDrawing]);
return (
<div className="app-shell" ref={appShellRef}>
<main className="editor-shell">
<div className="app-actions">
<button
type="button"
className="secondary-button app-actions-toggle"
onClick={openSidebar}
aria-label="Open drawings"
>
<DrawerIcon />
<span className="sr-only">Open drawings</span>
</button>
</div>
{loading || !scene ? (
<div className="editor-loading">{error ?? "Loading..."}</div>
) : (
<div className="editor-frame">
<Excalidraw
key={activeDrawing?.id ?? activeId}
initialData={sceneToInitialData(scene)}
onChange={(elements, appState, files) => {
const nextScene = sceneFromEditor(elements, appState, files);
latestSceneRef.current = nextScene;
scheduleThemeTokenSync();
if (ignoreChangeRef.current) {
ignoreChangeRef.current = false;
return;
}
scheduleSave();
}}
/>
</div>
)}
</main>
<div
className={isSidebarOpen ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"}
onClick={closeSidebar}
aria-hidden={!isSidebarOpen}
/>
<aside className={isSidebarOpen ? "sidebar sidebar-open" : "sidebar"} aria-hidden={!isSidebarOpen}>
<div className="sidebar-header">
<h1>excali-box</h1>
<div className="sidebar-actions">
<button type="button" className="primary-button" onClick={() => void handleCreateDrawing()}>
New
</button>
<button type="button" className="icon-button" onClick={closeSidebar} aria-label="Close drawings">
×
</button>
</div>
</div>
<div className="drawing-list">
{drawings.map((drawing) => (
<div
key={drawing.id}
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
>
{drawing.id === activeId ? (
<div className="drawing-link">
<input
className="title-input"
value={activeTitle}
onChange={(event) => {
currentTitleRef.current = event.target.value;
setActiveTitle(event.target.value);
}}
onBlur={() => void handleTitleSubmit()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
</div>
) : (
<button
type="button"
className="drawing-link"
onClick={() => void handleSelectDrawing(drawing.id)}
>
<span className="drawing-title">{drawing.title}</span>
</button>
)}
<button
type="button"
className="icon-button"
onClick={() => void deleteDrawing(drawing.id)}
aria-label={`Delete ${drawing.title}`}
>
×
</button>
</div>
))}
</div>
</aside>
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
-82
View File
@@ -1,82 +0,0 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApi } from "./api";
import { createDrawingStore } from "./db";
function withApi() {
const dir = mkdtempSync(join(tmpdir(), "excali-api-"));
const store = createDrawingStore(join(dir, "test.sqlite"));
const api = createApi(store);
return {
api,
cleanup() {
store.close();
rmSync(dir, { recursive: true, force: true });
},
};
}
describe("api", () => {
test("creates a drawing with dark theme by default", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
expect(created.appState.theme).toBe("dark");
cleanup();
});
test("returns 400 for invalid JSON", async () => {
const { api, cleanup } = withApi();
const drawing = await api.createDrawing().json();
const response = await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${drawing.id}`, {
method: "PUT",
body: "{",
}),
{ params: { id: drawing.id } },
),
);
expect(response.status).toBe(400);
cleanup();
});
test("returns 404 for missing drawing", () => {
const { api, cleanup } = withApi();
const response = api.getDrawing(
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
);
expect(response.status).toBe(404);
cleanup();
});
test("updates a drawing scene", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
const response = await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
elements: [{ id: "shape" }],
appState: {},
files: {},
}),
}),
{ params: { id: created.id } },
),
);
expect(response.status).toBe(200);
cleanup();
});
});
-119
View File
@@ -1,119 +0,0 @@
import { type DrawingStore } from "./db";
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "./scene";
type RouteRequest = Request & {
params?: Record<string, string>;
};
function json(data: unknown, init?: ResponseInit): Response {
return Response.json(data, init);
}
function errorResponse(error: unknown): Response {
if (error instanceof HttpError) {
return json({ ok: false, error: error.message }, { status: error.status });
}
console.error(error);
return json({ ok: false, error: "Internal server error" }, { status: 500 });
}
export function createApi(store: DrawingStore) {
return {
health() {
return json({ ok: true });
},
listDrawings() {
return json(store.listDrawings());
},
createDrawing() {
const drawing = store.createDrawing();
return json(
{
id: drawing.id,
title: drawing.title,
createdAt: drawing.createdAt,
updatedAt: drawing.updatedAt,
...drawing.scene,
},
{ status: 201 },
);
},
getDrawing(request: RouteRequest) {
const id = request.params?.id;
const drawing = id ? store.getDrawing(id) : null;
if (!drawing) {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
}
return json({
id: drawing.id,
title: drawing.title,
createdAt: drawing.createdAt,
updatedAt: drawing.updatedAt,
...drawing.scene,
});
},
async updateDrawing(request: RouteRequest) {
try {
const id = request.params?.id;
if (!id) {
throw new HttpError(400, "Missing drawing id");
}
const text = await request.text();
const raw = parseJsonBody<Record<string, unknown>>(text);
const hasTitle = Object.hasOwn(raw, "title");
const hasScene =
Object.hasOwn(raw, "elements") ||
Object.hasOwn(raw, "appState") ||
Object.hasOwn(raw, "files");
if (!hasTitle && !hasScene) {
throw new HttpError(400, "Nothing to update");
}
const scene = hasScene ? parseSceneText(text) : undefined;
const updated = store.updateDrawing(id, {
scene,
title: hasTitle ? normalizeTitle(raw.title) : undefined,
});
if (!updated) {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
}
return json({
ok: true,
drawing: {
id: updated.id,
title: updated.title,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
},
});
} catch (error) {
return errorResponse(error);
}
},
deleteDrawing(request: RouteRequest) {
const id = request.params?.id;
if (!id) {
return json({ ok: false, error: "Missing drawing id" }, { status: 400 });
}
const { deleted, next } = store.deleteDrawing(id);
if (!deleted) {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
}
return json({ ok: true, nextId: next?.id ?? null });
},
};
}
-1
View File
@@ -1,7 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
const root = document.getElementById("root");
-50
View File
@@ -1,50 +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 } from "./db";
const cleanup: string[] = [];
afterEach(() => {
while (cleanup.length > 0) {
const dir = cleanup.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
function createTempStore() {
const dir = mkdtempSync(join(tmpdir(), "excali-"));
cleanup.push(dir);
return createDrawingStore(join(dir, "test.sqlite"));
}
describe("drawing store", () => {
test("creates, updates, lists, and deletes drawings", () => {
const store = createTempStore();
const created = store.createDrawing();
expect(created.title).toBe("Untitled");
expect(created.scene.appState.theme).toBe("dark");
expect(store.listDrawings()).toHaveLength(1);
const updated = store.updateDrawing(created.id, {
title: "Flow",
scene: {
elements: [{ id: "one" }],
appState: { gridSize: 20 },
files: {},
},
});
expect(updated?.title).toBe("Flow");
expect(updated?.scene.elements).toHaveLength(1);
const removed = store.deleteDrawing(created.id);
expect(removed.deleted).toBe(true);
expect(store.listDrawings()).toHaveLength(1);
expect(removed.next?.id).not.toBe(created.id);
});
});
-209
View File
@@ -1,209 +0,0 @@
import { Database } from "bun:sqlite";
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "./shared";
import { coerceStoredScene, emptyScene, normalizeTitle } from "./scene";
type DrawingRow = {
id: string;
title: string;
data: string;
createdAt: string;
updatedAt: string;
};
type DrawingMetaRow = Omit<DrawingRow, "data">;
export type DrawingStore = ReturnType<typeof createDrawingStore>;
function createId(): string {
return randomUUID().replaceAll("-", "").slice(0, 12);
}
function resolveDatabasePath(): string {
if (process.env.DATABASE_PATH) {
return process.env.DATABASE_PATH;
}
try {
mkdirSync("/data", { recursive: true });
return "/data/excalidraw.sqlite";
} catch {
return `${process.cwd()}/data/excalidraw.sqlite`;
}
}
function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
if (!row) {
return null;
}
return {
id: row.id,
title: row.title,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
if (!row) {
return null;
}
return {
...readMeta(row)!,
scene: coerceStoredScene(JSON.parse(row.data)),
};
}
export function createDrawingStore(databasePath = resolveDatabasePath()) {
mkdirSync(dirname(databasePath), { recursive: true });
const db = new Database(databasePath, { create: true, strict: true });
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS drawings (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
ON drawings(updated_at DESC, created_at DESC);
`);
const listQuery = db.query(`
SELECT
id,
title,
created_at AS createdAt,
updated_at AS updatedAt
FROM drawings
ORDER BY updated_at DESC, created_at DESC
`);
const getQuery = db.query(`
SELECT
id,
title,
data,
created_at AS createdAt,
updated_at AS updatedAt
FROM drawings
WHERE id = ?1
`);
const createQuery = db.query(`
INSERT INTO drawings (id, title, data)
VALUES (?1, ?2, ?3)
`);
const updateSceneQuery = db.query(`
UPDATE drawings
SET data = ?2, updated_at = CURRENT_TIMESTAMP
WHERE id = ?1
`);
const updateTitleQuery = db.query(`
UPDATE drawings
SET title = ?2, updated_at = CURRENT_TIMESTAMP
WHERE id = ?1
`);
const updateBothQuery = db.query(`
UPDATE drawings
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP
WHERE id = ?1
`);
const deleteQuery = db.query(`
DELETE FROM drawings
WHERE id = ?1
`);
function listDrawings(): DrawingMeta[] {
return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!);
}
function getDrawing(id: string): DrawingRecord | null {
return readRow(getQuery.get(id) as DrawingRow | null | undefined);
}
function createDrawing(title = DEFAULT_TITLE): DrawingRecord {
const id = createId();
createQuery.run(id, normalizeTitle(title), JSON.stringify(emptyScene()));
return getDrawing(id)!;
}
function getLatestDrawing(): DrawingMeta | null {
return listDrawings()[0] ?? null;
}
function ensureInitialDrawing(): DrawingMeta {
return getLatestDrawing() ?? createDrawing();
}
function updateDrawing(id: string, update: { scene?: ScenePayload; title?: string }): DrawingRecord | null {
const existing = getDrawing(id);
if (!existing) {
return null;
}
const hasScene = update.scene !== undefined;
const hasTitle = update.title !== undefined;
if (!hasScene && !hasTitle) {
return existing;
}
if (hasScene && hasTitle) {
updateBothQuery.run(id, normalizeTitle(update.title), JSON.stringify(update.scene));
} else if (hasScene) {
updateSceneQuery.run(id, JSON.stringify(update.scene));
} else {
updateTitleQuery.run(id, normalizeTitle(update.title));
}
return getDrawing(id);
}
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
const changes = Number(deleteQuery.run(id).changes);
if (changes === 0) {
return { deleted: false, next: null };
}
return {
deleted: true,
next: ensureInitialDrawing(),
};
}
function close() {
db.close(false);
}
return {
databasePath,
close,
listDrawings,
getDrawing,
createDrawing,
getLatestDrawing,
ensureInitialDrawing,
updateDrawing,
deleteDrawing,
};
}
let defaultStore: DrawingStore | null = null;
export function getDefaultDrawingStore(): DrawingStore {
defaultStore ??= createDrawingStore();
return defaultStore;
}
-19
View File
@@ -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}`);
}
-31
View File
@@ -1,31 +0,0 @@
import { describe, expect, test } from "bun:test";
import { HttpError, MAX_SCENE_BYTES, emptyScene, normalizeScene, parseJsonBody } from "./scene";
describe("scene helpers", () => {
test("creates empty scenes with dark theme by default", () => {
const scene = emptyScene();
expect(scene.appState.theme).toBe("dark");
});
test("normalizes volatile app state fields", () => {
const scene = normalizeScene({
elements: [],
appState: {
selectedElementIds: { one: true },
viewModeEnabled: false,
},
files: {},
});
expect(scene.appState.selectedElementIds).toBeUndefined();
expect(scene.appState.viewModeEnabled).toBe(false);
});
test("rejects oversized payloads", () => {
const text = "x".repeat(MAX_SCENE_BYTES + 1);
expect(() => parseJsonBody(text)).toThrow(HttpError);
expect(() => parseJsonBody(text)).toThrow("Payload too large");
});
});
-97
View File
@@ -1,97 +0,0 @@
import { DEFAULT_TITLE, type ScenePayload } from "./shared";
export const MAX_SCENE_BYTES = 25 * 1024 * 1024;
export class HttpError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
const VOLATILE_APP_STATE_KEYS = new Set([
"collaborators",
"selectedElementIds",
"selectedGroupIds",
"editingElement",
"editingLinearElement",
"openMenu",
"openSidebar",
"contextMenu",
"toast",
]);
export function emptyScene(): ScenePayload {
return {
elements: [],
appState: { theme: "dark" },
files: {},
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function normalizeTitle(input: unknown): string {
if (typeof input !== "string") {
return DEFAULT_TITLE;
}
const trimmed = input.trim();
return trimmed.length > 0 ? trimmed : DEFAULT_TITLE;
}
export function normalizeScene(input: unknown): ScenePayload {
if (!isRecord(input)) {
throw new HttpError(400, "Body must be an object");
}
if (!Array.isArray(input.elements)) {
throw new HttpError(400, "elements must be an array");
}
if (!isRecord(input.appState)) {
throw new HttpError(400, "appState must be an object");
}
if (!isRecord(input.files)) {
throw new HttpError(400, "files must be an object");
}
const appState = Object.fromEntries(
Object.entries(input.appState).filter(([key]) => !VOLATILE_APP_STATE_KEYS.has(key)),
);
return {
elements: input.elements,
appState,
files: input.files,
};
}
export function parseJsonBody<T = unknown>(text: string): T {
if (new TextEncoder().encode(text).byteLength > MAX_SCENE_BYTES) {
throw new HttpError(413, "Payload too large");
}
try {
return JSON.parse(text) as T;
} catch {
throw new HttpError(400, "Invalid JSON");
}
}
export function parseSceneText(text: string): ScenePayload {
return normalizeScene(parseJsonBody(text));
}
export function coerceStoredScene(input: unknown): ScenePayload {
try {
return normalizeScene(input);
} catch {
return emptyScene();
}
}
-70
View File
@@ -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" });
});
});
-40
View File
@@ -1,40 +0,0 @@
import { createApi } from "./api";
import { type DrawingStore, getDefaultDrawingStore } from "./db";
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 ?? "localhost",
routes: {
"/": (request: Request) => {
const drawing = drawingStore.ensureInitialDrawing();
return Response.redirect(new URL(`/d/${drawing.id}`, request.url), 302);
},
"/api/health": {
GET: () => api.health(),
},
"/api/drawings": {
GET: () => api.listDrawings(),
POST: () => api.createDrawing(),
},
"/api/drawings/:id": {
GET: (request: Request & { params: Record<string, string> }) => api.getDrawing(request),
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request),
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
},
},
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
} satisfies Parameters<typeof Bun.serve>[0];
}
if (import.meta.main) {
const server = Bun.serve(createServer());
console.log(`Listening on http://${server.hostname}:${server.port}`);
}
-18
View File
@@ -1,18 +0,0 @@
export type ScenePayload = {
elements: unknown[];
appState: Record<string, unknown>;
files: Record<string, unknown>;
};
export type DrawingMeta = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
};
export type DrawingRecord = DrawingMeta & {
scene: ScenePayload;
};
export const DEFAULT_TITLE = "Untitled";
-297
View File
@@ -1,297 +0,0 @@
:root {
color-scheme: light;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
}
body {
background: #f5f5f4;
color: #18181b;
}
button,
input {
font: inherit;
}
.app-shell {
height: 100%;
color: var(--app-text-color, #18181b);
--app-sidebar-font-size: 0.875rem;
--app-island-bg: #ffffff;
--app-sidebar-bg: #ffffff;
--app-sidebar-border: #e4e4e7;
--app-sidebar-shadow: 0 24px 80px rgba(24, 24, 27, 0.22);
--app-surface-lowest: #ffffff;
--app-surface-low: #f4f4f5;
--app-selected-bg: #f4f4f5;
--app-text-color: #18181b;
--app-selected-text-color: #18181b;
--app-disabled-color: #a1a1aa;
--app-input-bg: #ffffff;
--app-input-border: #d4d4d8;
--app-input-color: #18181b;
--app-button-border: #d4d4d8;
--app-button-hover-bg: #f4f4f5;
--app-button-active-bg: #e4e4e7;
--app-button-active-border: #5b8def;
--app-overlay-bg: rgba(255, 255, 255, 0.88);
}
.sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 40;
display: flex;
height: 100%;
width: min(340px, calc(100vw - 32px));
flex-direction: column;
border-right: 1px solid var(--app-sidebar-border);
background: var(--app-sidebar-bg);
box-shadow: var(--app-sidebar-shadow);
transform: translateX(calc(-100% - 24px));
transition:
transform 180ms ease,
box-shadow 180ms ease;
}
.sidebar-open {
transform: translateX(0);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--app-sidebar-border);
padding: 16px;
}
.sidebar-header h1 {
margin: 0;
font-size: var(--app-sidebar-font-size);
font-weight: 600;
color: var(--app-text-color);
}
.sidebar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-actions .primary-button,
.sidebar-actions .icon-button {
height: 32px;
}
.drawing-list {
flex: 1;
overflow: auto;
padding: 8px;
}
.drawing-item {
display: flex;
align-items: center;
gap: 4px;
border-radius: 8px;
padding: 4px 12px;
}
.drawing-item-active {
background: var(--app-selected-bg);
color: var(--app-selected-text-color);
}
.drawing-link {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
color: inherit;
padding: 8px 0;
text-align: left;
font-size: var(--app-sidebar-font-size);
}
.drawing-title,
.title-input {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title-input {
border: 1px solid var(--app-input-border);
border-radius: 6px;
background: var(--app-input-bg);
color: var(--app-input-color);
font-size: var(--app-sidebar-font-size);
padding: 6px 8px;
}
.editor-shell {
position: relative;
height: 100%;
}
.app-actions {
position: fixed;
right: max(16px, calc(env(safe-area-inset-right) + 16px));
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 48px);
z-index: 20;
display: flex;
}
.app-actions-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--lg-button-size, 2.25rem);
height: var(--lg-button-size, 2.25rem);
padding: 0;
border: none;
border-radius: var(--border-radius-lg, 12px);
box-shadow: 0 0 0 1px var(--app-surface-lowest);
background: var(--app-surface-low);
color: var(--app-text-color);
}
.app-actions-toggle svg {
width: 1rem;
height: 1rem;
overflow: visible;
transform: translateY(0.5px) scale(1.08);
transform-origin: center;
}
.primary-button,
.secondary-button,
.icon-button {
border: 1px solid var(--app-button-border);
border-radius: 8px;
background: var(--app-island-bg);
color: var(--app-text-color);
transition:
background-color 120ms ease,
border-color 120ms ease,
color 120ms ease,
box-shadow 120ms ease;
}
.primary-button,
.secondary-button {
cursor: pointer;
font-size: var(--app-sidebar-font-size);
font-weight: 500;
padding: 8px 12px;
}
.primary-button:hover,
.secondary-button:hover,
.icon-button:hover {
background: var(--app-button-hover-bg);
}
.primary-button:active,
.secondary-button:active,
.icon-button:active {
background: var(--app-button-active-bg);
border-color: var(--app-button-active-border);
}
.secondary-button:disabled {
cursor: not-allowed;
opacity: 0.6;
color: var(--app-disabled-color);
}
.icon-button {
display: inline-flex;
flex: 0 0 32px;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
cursor: pointer;
}
.sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 30;
background: color-mix(in srgb, var(--app-overlay-bg) 72%, #000000);
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
}
.sidebar-backdrop-open {
opacity: 1;
pointer-events: auto;
}
.editor-frame,
.editor-loading {
height: 100%;
}
.editor-loading {
display: grid;
place-items: center;
color: var(--app-text-color);
}
.app-actions-toggle:hover {
background: 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);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 900px) {
.sidebar {
width: min(300px, calc(100vw - 20px));
}
.app-actions {
right: max(16px, calc(env(safe-area-inset-right) + 16px));
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 56px);
}
}