Live browser preview for .excalidraw files with auto-refresh on file changes.
excalirender watch starts a local HTTP server that renders .excalidraw files to PNG and serves them in a browser. When the source file is saved, the preview refreshes automatically via Server-Sent Events (SSE).
Mode detection is based on file count:
- 1 file argument: export mode — renders single file as PNG
- 2 file arguments: diff mode — renders visual diff between files
No new dependencies — uses Bun.serve() for HTTP, fs.watch() for file changes, and native ReadableStream for SSE.
┌──────────────┐ fs.watch() ┌─────────────────┐
│ .excalidraw │ ───────────────> │ Watch Server │
│ file(s) │ file change │ (Bun.serve) │
└──────────────┘ └────────┬────────┘
│
┌──────────────┼──────────────┐
│ │ │
GET / GET /image GET /events
HTML page PNG buffer SSE stream
│ │ │
└──────────────┼──────────────┘
│
┌──────▼──────┐
│ Browser │
│ <img> tag │
└─────────────┘
| Route | Content-Type | Description |
|---|---|---|
GET / |
text/html |
HTML page with <img> and SSE listener |
GET /image |
image/png |
Current rendered PNG buffer |
GET /events |
text/event-stream |
SSE stream, pushes data: reload\n\n on changes |
The server maintains a Set<ReadableStreamDefaultController> of connected SSE clients. When a file change triggers a re-render, notifyClients() enqueues "data: reload\n\n" to all controllers. The browser's EventSource listener updates the <img> src with a cache-busting timestamp.
const sseClients = new Set<ReadableStreamDefaultController>();
function notifyClients() {
const data = new TextEncoder().encode("data: reload\n\n");
for (const client of sseClients) {
try { client.enqueue(data); }
catch { sseClients.delete(client); }
}
}Uses node:fs watch() with a 200ms debounce to handle rapid editor saves (editors often write to temp file then rename):
const DEBOUNCE_MS = 200;
let lastRender = 0;
for (const filePath of inputPaths) {
watch(filePath, async () => {
if (Date.now() - lastRender < DEBOUNCE_MS) return;
lastRender = Date.now();
// Re-render and notify SSE clients
});
}Reuses the standard export pipeline:
prepareExport(inputPath, options)— reads file, sorts elements, computes boundsrenderElementsToCanvas(elements, renderOptions)— draws to canvascanvas.toBuffer("image/png")— produces PNG buffer
Reuses the diff algorithm with inline tag rendering:
computeDiff(oldPath, newPath)— computes added/removed/modified/unchanged- Style unchanged elements with
applyUnchangedStyle() renderElementsToCanvas(allElements, renderOptions)— draws to canvas withafterRendercallback for diff tagscanvas.toBuffer("image/png")— produces PNG buffer
Diff tags are rendered inline via renderDiffTag() which draws colored labels (added/removed/modified) below each changed element.
The served HTML page has:
- Dark background (
#1a1a1a) for comfortable viewing - Checkerboard pattern behind the image (visible with
--transparent) - File name header with last render timestamp
EventSourcelistening on/eventsfor live reload
Parse errors during re-render are caught and logged to the terminal. The last successfully rendered PNG is preserved — the browser continues showing the previous valid render. When the file is fixed and saved again, the preview updates normally.
[12:34:56] Rendered in 120ms
[12:35:02] Error: Failed to parse diagram.excalidraw — keeping last render
[12:35:10] Rendered in 95ms
- SSE over WebSocket: One-way server-to-browser push is all that's needed. SSE is simpler and sufficient.
- PNG only: The preview always renders PNG (not SVG/PDF) for consistent browser display and fast rendering.
- Dynamic import:
watch.tsis loaded viaawait import("./watch.js")so non-watch commands don't pay the import cost. - Browser open: Cross-platform via
Bun.spawn()— usesopenon macOS,cmd /c starton Windows,xdg-openon Linux. Child process isunref()'d so it doesn't block the server. Failures are silently ignored. - Mode from file count: Instead of a
--diffflag, the mode is auto-detected from the number of arguments (1 = export, 2 = diff).
| File | Role |
|---|---|
src/watch.ts |
Watch server: rendering, HTTP, SSE, file watcher |
src/cli.ts |
WatchCLIArgs interface, buildWatchArgs(), watch subcommand |
src/index.ts |
Watch routing with validation |
startWatchServer(config)— entry point, validates files, initial render, starts server + watchersrenderExportToBuffer(inputPath, options)— renders single file to PNG bufferrenderDiffToBuffer(oldPath, newPath, options)— renders visual diff to PNG bufferrenderDiffTag(ctx, element, status, offsetX, offsetY)— draws colored status tag below elementbuildHtmlPage(title)— returns HTML string for the preview page
interface WatchConfig {
inputPaths: string[];
mode: "export" | "diff";
port: number;
open: boolean;
exportOptions: ExportOptions;
diffOptions: DiffOptions;
}| Flag | Description | Default |
|---|---|---|
-p, --port <number> |
HTTP server port | 3333 |
-s, --scale <number> |
Export scale factor | 1 |
-d, --dark |
Enable dark mode | false |
--transparent |
Transparent background | false |
-b, --background <color> |
Background color | From file |
-f, --frame <name> |
Export specific frame (export mode only) | - |
--no-open |
Don't auto-open browser | false |
--hide-unchanged |
Don't render unchanged elements (diff mode) | false |
--no-tags |
Don't render status tags (diff mode) | - |