# Project: `excalidraw-box` ## Goal Build a small self-hosted web app that provides: ```text - Excalidraw editor - Server-side autosave to SQLite - No collaboration - No accounts inside the app - Reverse-proxy auth handled by Caddy - Manual export to PNG/SVG/.excalidraw - Docker Compose deployment ``` The app should be intentionally small and auditable. --- # Non-goals Do **not** implement: ```text - real-time collaboration - public sharing links - user accounts - teams/workspaces - comments - Firebase - S3/MinIO in v1 - complex permissions ``` Authentication is handled outside the app by Caddy `basic_auth` or later Authelia/AuthentiK. --- # Stack Use: ```text Next.js App Router React TypeScript @excalidraw/excalidraw better-sqlite3 Docker Compose Caddy reverse proxy ``` Rationale: - `@excalidraw/excalidraw` is the official embeddable package. ([npm][1]) - Excalidraw exposes `onChange(elements, appState, files)` for persistence. ([Excalidraw Docs][2]) - Excalidraw provides export utilities such as `exportToCanvas` for rendering scenes to images. ([Excalidraw Docs][3]) - Next.js officially supports standalone Docker deployment. ([Next.js][4]) --- # Version 1 feature set ## V1 must have ```text 1. Single default drawing 2. Load drawing from SQLite on page load 3. Autosave drawing to SQLite after edits 4. Debounce saves by 1.5 seconds 5. Show save status: - Saved - Saving… - Save failed 6. Export PNG from the current scene 7. Export .excalidraw JSON from the current scene 8. Persist SQLite database under /data 9. Dockerfile 10. docker-compose.yml 11. Caddyfile example ``` ## V1 should avoid ```text - multiple drawings - login UI - sharing - collaboration - migrations framework - Prisma, unless really needed ``` Use plain `better-sqlite3` to keep the code small. --- # Directory structure ```text excalidraw-autosave/ app/ api/ drawing/ route.ts globals.css layout.tsx page.tsx components/ ExcalidrawClient.tsx lib/ db.ts scene.ts debounce.ts public/ Dockerfile docker-compose.yml Caddyfile.example next.config.ts package.json tsconfig.json .dockerignore README.md ``` --- # Data model Use one SQLite table. ```sql CREATE TABLE IF NOT EXISTS drawings ( id TEXT PRIMARY KEY, data TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); ``` For v1, always use: ```text id = "default" ``` Stored `data` should be the scene object: ```json { "type": "excalidraw-autosave-scene", "version": 1, "elements": [], "appState": {}, "files": {} } ``` Do not store only rendered PNG. Store the editable scene JSON as the source of truth. --- # API design ## `GET /api/drawing` Returns the saved drawing. If no drawing exists, return an empty scene: ```json { "type": "excalidraw-autosave-scene", "version": 1, "elements": [], "appState": {}, "files": {} } ``` ## `POST /api/drawing` Accepts: ```json { "elements": [], "appState": {}, "files": {} } ``` Behavior: ```text - Validate body is an object. - Validate elements is an array. - Validate appState is an object. - Validate files is an object. - Strip volatile or UI-only appState fields if needed. - Save JSON to SQLite. - Upsert row id = "default". - Return { "ok": true, "updatedAt": "..." }. ``` ## Optional `GET /api/health` Returns: ```json { "ok": true } ``` Useful for Docker health checks. --- # Scene handling Create `lib/scene.ts`. Responsibilities: ```text - define EmptyScene - normalize incoming scene - remove unwanted volatile appState fields - cap payload size ``` Recommended payload size cap for v1: ```text 25 MB ``` If the scene exceeds this, return HTTP `413 Payload Too Large`. Reason: Excalidraw embedded images are stored under `files`; they can make scene JSON large. --- # Frontend behavior ## Page load ```text 1. Render loading state. 2. Fetch /api/drawing. 3. Pass returned scene to Excalidraw as initialData. 4. Render editor full-screen. ``` The official integration docs show that the Excalidraw component should be used client-side in Next.js, with `"use client"` and the Excalidraw CSS import. ([Excalidraw Docs][5]) ## Autosave Use Excalidraw’s `onChange`. Pseudo-flow: ```tsx onChange={(elements, appState, files) => { setSaveStatus("dirty"); debouncedSave({ elements, appState, files }); }} ``` Debounced save: ```text - wait 1500 ms after last change - POST to /api/drawing - set status Saving… while request in flight - set status Saved after success - set status Save failed after failure ``` Important: Do not save on every pointer movement without debounce. ## Save status UI A small fixed indicator: ```text top-right: Saved Saving… Save failed ``` Optionally show last saved timestamp. --- # Export behavior ## `.excalidraw` export Implement client-side export by taking the current scene and downloading JSON. Filename: ```text drawing-YYYY-MM-DD-HHMM.excalidraw ``` Content: ```json { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [], "appState": {}, "files": {} } ``` This keeps compatibility with regular Excalidraw import/export. The Excalidraw repo describes the open `.excalidraw` JSON file format as a supported feature. ([GitHub][6]) ## PNG export Use Excalidraw export utilities client-side. The docs show `exportToCanvas` exported from `@excalidraw/excalidraw`; it takes `elements`, `appState`, and export dimensions. ([Excalidraw Docs][3]) Implementation idea: ```tsx import { exportToCanvas } from "@excalidraw/excalidraw"; const canvas = await exportToCanvas({ elements, appState: { ...appState, exportBackground: true, }, files, getDimensions: (width, height) => ({ width, height, scale: 2, }), }); const blob = await new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!), "image/png"), ); ``` Then trigger browser download. Do PNG export client-side first. It avoids needing headless browser rendering on the server. --- # Important implementation detail: access to current scene Use `excalidrawAPI`. The Excalidraw API exposes methods such as `getFiles`. ([Excalidraw Docs][7]) Store latest values from `onChange` in React refs: ```tsx const latestSceneRef = useRef({ elements: [], appState: {}, files: {}, }); ``` Update it inside `onChange`. Use that ref for export buttons. --- # Suggested files ## `package.json` ```json { "name": "excalidraw-autosave", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit" }, "dependencies": { "@excalidraw/excalidraw": "latest", "better-sqlite3": "latest", "next": "latest", "react": "latest", "react-dom": "latest" }, "devDependencies": { "@types/better-sqlite3": "latest", "@types/node": "latest", "@types/react": "latest", "@types/react-dom": "latest", "typescript": "latest" } } ``` ## `next.config.ts` ```ts import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", }; export default nextConfig; ``` --- # Database module ## `lib/db.ts` Requirements: ```text - Read DATABASE_PATH from env - Default to /data/excalidraw.sqlite - Ensure parent directory exists - Initialize schema - Export getDrawing() and saveDrawing() ``` Pseudo-code: ```ts import Database from "better-sqlite3"; import fs from "node:fs"; import path from "node:path"; const dbPath = process.env.DATABASE_PATH ?? "/data/excalidraw.sqlite"; fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const db = new Database(dbPath); db.pragma("journal_mode = WAL"); db.exec(` CREATE TABLE IF NOT EXISTS drawings ( id TEXT PRIMARY KEY, data TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) `); export function getDrawing(id = "default") { const row = db.prepare("SELECT data FROM drawings WHERE id = ?").get(id) as | { data: string } | undefined; return row ? JSON.parse(row.data) : null; } export function saveDrawing(data: unknown, id = "default") { const serialized = JSON.stringify(data); db.prepare( ` INSERT INTO drawings (id, data, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = CURRENT_TIMESTAMP `, ).run(id, serialized); return new Date().toISOString(); } ``` --- # API route ## `app/api/drawing/route.ts` Requirements: ```text - Force Node runtime, not Edge - GET returns saved or empty scene - POST saves normalized scene - Handle invalid JSON - Enforce payload size cap ``` Add: ```ts export const runtime = "nodejs"; ``` Pseudo-code: ```ts import { NextResponse } from "next/server"; import { getDrawing, saveDrawing } from "@/lib/db"; import { emptyScene, normalizeScene } from "@/lib/scene"; export const runtime = "nodejs"; export async function GET() { const saved = getDrawing("default"); return NextResponse.json(saved ?? emptyScene()); } export async function POST(req: Request) { const text = await req.text(); if (text.length > 25 * 1024 * 1024) { return NextResponse.json( { ok: false, error: "Scene too large" }, { status: 413 }, ); } let raw: unknown; try { raw = JSON.parse(text); } catch { return NextResponse.json( { ok: false, error: "Invalid JSON" }, { status: 400 }, ); } const scene = normalizeScene(raw); const updatedAt = saveDrawing(scene, "default"); return NextResponse.json({ ok: true, updatedAt }); } ``` --- # Client component ## `components/ExcalidrawClient.tsx` Requirements: ```text - "use client" - import Excalidraw and CSS - fetch initial data - render full viewport - onChange debounced autosave - export JSON button - export PNG button - save status ``` Important: ```tsx import "@excalidraw/excalidraw/index.css"; ``` Use a custom debounce instead of lodash to avoid another dependency. --- # CSS/UI Keep UI minimal. ```text - Editor fills viewport. - Floating toolbar at top-right. - Buttons: - Export PNG - Export .excalidraw - Save status badge: - Saved - Saving… - Failed ``` Do not attempt to reproduce Excalidraw’s own toolbar. --- # Docker ## `Dockerfile` Use Next.js standalone output. ```dockerfile FROM node:22-alpine AS deps WORKDIR /app COPY package.json package-lock.json* ./ RUN npm install FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME=0.0.0.0 ENV DATABASE_PATH=/data/excalidraw.sqlite RUN mkdir -p /data COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static EXPOSE 3000 CMD ["node", "server.js"] ``` ## `docker-compose.yml` ```yaml services: excalidraw-autosave: build: . container_name: excalidraw-autosave expose: - "3000" environment: DATABASE_PATH: /data/excalidraw.sqlite volumes: - ./data:/data restart: unless-stopped caddy: image: caddy:latest container_name: excalidraw-caddy depends_on: - excalidraw-autosave ports: - "80:80" - "443:443" volumes: - ./Caddyfile.example:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config restart: unless-stopped volumes: caddy_data: caddy_config: ``` ## `Caddyfile.example` ```caddyfile draw.example.com { basic_auth { zero REPLACE_WITH_CADDY_HASH } reverse_proxy excalidraw-autosave:3000 } ``` Generate password hash: ```bash docker run --rm -it caddy:latest caddy hash-password ``` --- # Backup plan SQLite file: ```text ./data/excalidraw.sqlite ``` Backup command: ```bash mkdir -p ./backups sqlite3 ./data/excalidraw.sqlite ".backup './backups/excalidraw-$(date +%F-%H%M).sqlite'" ``` Recommended cron: ```cron 0 3 * * * cd /path/to/excalidraw-autosave && sqlite3 ./data/excalidraw.sqlite ".backup './backups/excalidraw-$(date +\%F-\%H\%M).sqlite'" ``` Also back up `.excalidraw` exports manually for critical drawings. --- # Security model V1 app assumes: ```text - It is not public without reverse proxy auth. - Caddy handles TLS. - Caddy handles basic_auth. - The app does not implement users. - The app should not expose port 3000 to the public internet. ``` In Compose, use: ```yaml expose: - "3000" ``` Do **not** use: ```yaml ports: - "3000:3000" ``` unless testing locally. --- # Acceptance criteria Codex should finish with a repo that satisfies: ```text 1. `npm run dev` opens editor locally. 2. Drawing reloads after refresh. 3. Drawing persists after app restart. 4. Docker Compose starts Caddy + app. 5. SQLite DB is created under ./data. 6. No direct public app port is exposed. 7. Export PNG downloads a PNG. 8. Export .excalidraw downloads editable JSON. 9. Autosave status visibly changes. 10. No collaboration code exists. ``` Manual test: ```text 1. Start app. 2. Draw rectangle and text. 3. Wait until status says Saved. 4. Refresh browser. 5. Confirm rectangle and text remain. 6. Restart container. 7. Confirm drawing remains. 8. Export PNG. 9. Export .excalidraw. 10. Import .excalidraw into official Excalidraw and confirm it opens. ``` --- # Future V2, only after V1 works Add these later, not now: ```text - multiple named drawings - drawing list page - thumbnails - duplicate drawing - delete drawing - server-side export endpoint - periodic backup UI - WebDAV/S3 export - basic file manager ``` For V2 multiple drawings, change routes to: ```text GET /api/drawings POST /api/drawings GET /api/drawings/:id PUT /api/drawings/:id DELETE /api/drawings/:id ``` Schema: ```sql CREATE TABLE 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 ); ``` --- # Instruction Build the V1 exactly as specified. Keep code minimal. Do not add accounts, collaboration, sharing, Firebase, Prisma, Tailwind, or extra abstractions. Use the official `@excalidraw/excalidraw` package and SQLite persistence through `better-sqlite3`. Use Caddy for auth in the deployment example.