trevato/canvas

Public: anyone can read this; the crew can change it.

A collaborative whiteboard: freehand ink, shapes, text and sticky notes on an infinite canvas with pan and zoom, live presence and cursors for everyone on the same board through server-sent events, undo per user, export to SVG and PNG, and boards shared by URL with an optional edit password. It must feel instant on a laptop and work with a finger on a phone.

Running 89346db https://canvas--forge-trevato-apps.open-platform.sh in production 21d 14h

README.md

canvas

A whiteboard several people can draw on at once. Freehand ink, rectangles, ellipses, lines, arrows, text and sticky notes on an infinite sheet with pan and zoom; live cursors for everyone on the same board; undo that only undoes your own work; export to SVG or PNG; and a board is a URL you can send to someone, with an optional password if you want people to watch but not draw.

PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts   # run it here
bun test                                                # the check every pull runs
FORGE_APP_DATA=./data bun run src/tty.ts                # the terminal interface

Open /, name a board, and draw. Everything is real: the shapes are rows in SQLite under FORGE_APP_DATA, the presence is live, the export is the same renderer that drew the page. Nothing here is simulated or seeded.

Using it

Drawing. Pick a tool from the palette (or press its letter: p pen, e eraser, n note, t text, r rectangle, o ellipse, l line, a arrow, v select, h pan). Hold Shift while dragging a shape to keep it square, or a line to keep it straight. Double-click a note or a piece of text to edit it. Ctrl+Z undoes your own last change and Ctrl+Shift+Z puts it back — your undo never reaches into someone else's work.

On a phone. Under 640px the toolbar would be wider than the screen, so the six shape tools become one Shapes menu, the ten colours and three thicknesses become one menu whose button is the ink it would draw with, and the two exports, Share and Your name move into the header's More menu. Pen, eraser, select and pan stay on the bar. Nothing is hidden that a wide screen has, and every control you can tap is at least 44px. Escape, a tap outside, or using a control closes a menu.

Moving about. Scroll or drag empty space to pan, Ctrl/⌘ and scroll (or pinch on a trackpad) to zoom, two fingers to pan and zoom on a phone, Fit to frame everything. Where you were looking is remembered per board in your browser.

Sharing. The URL is the invitation. If you set an edit password when you make the board, the link still shows the board live to anyone; drawing on it needs the password, which is traded once for a cookie that only opens that one board. The password is stored as an argon2id hash and cannot be recovered.

Exporting. SVG downloads the board as a standalone file, drawn by the server from the same code the browser uses. PNG renders that SVG in your browser and saves the bitmap — no headless browser on the server, no dependency.

How it is put together

One Bun process, no dependencies, server-rendered first paint.

fileone concept
src/server.tsthe socket, the same-origin rule, the security headers on every response
src/routes.tsthe table of paths and who is allowed to do what
src/db.tsthe schema and every query: boards, shapes, revisions, edit grants
src/shape.tswhat a shape is, how it is validated, and what it looks like
src/svg.tsa board as SVG on the server: the first paint and the export
src/bus.tswho is on a board and how a change reaches them
src/watch.tsthe bridge that carries a terminal's change to the browsers
src/board.tsboard ids, titles, passwords, cookies
src/limit.tsa leaky bucket for making boards and guessing passwords
src/assets.tsthe client, bundled at boot and named by its content hash
src/pages/the two pages, and menu.ts: a group of controls that is a row on a wide screen and a menu on a phone
src/client/the browser half — the only JavaScript shipped

The interesting part is that src/shape.ts is imported by both halves. The server renders shapes to SVG strings; the browser renders the same descriptors into DOM nodes; the boundary validates against the same limits. There is one definition of what an arrow is.

Changes flow one way. A browser applies a change locally the moment your finger moves, and posts a batch to POST /b/<id>/sync on the next tick — cursor and shapes in one request, so drawing costs at most about sixteen requests a second. The server assigns the board a new revision, stores the batch in one transaction, and pushes it to every open GET /b/<id>/events stream with the revision as the SSE event id. If a stream drops, the browser reconnects with Last-Event-ID and is handed exactly what it missed; deletes are tombstones so an absence is never mistaken for a gap. Cursors are coalesced and flushed at 15 Hz per board, so a hundred hands are still a bounded fan-out.

Undo is deliberately per person and per tab: the browser remembers the inverse of what it did and sends that inverse as an ordinary change. Reloading the page empties the undo stack — there is no server-side history to walk, and I would rather have no undo than one that quietly reverses a colleague's work.

Security

Every input is validated at the boundary and every output is escaped. Shape colours are indexes into a palette in src/shape.ts, so no colour string from the network can ever reach a stylesheet or an attribute. Coordinates, sizes, stroke widths, text lengths, point counts, ops per request and body size are all bounded. Posts must be same-origin: a POST is refused when Sec-Fetch-Site says it came from anywhere but here, or when Origin names another site. A request carrying neither header is allowed on purpose — no browser can produce one, so it is a program talking to the API, and a program has none of the ambient cookies the rule exists to protect. src/server.ts says so in a comment and src/server.test.ts pins every case. The Content-Security-Policy allows no inline script at all — there is not one onclick in the app — and the edit grant lives in an HttpOnly, SameSite=Lax cookie scoped to one board.

Anyone who can reach this deployment can make a board and draw on any board that has no password; that is what a whiteboard is. There is no owner and no admin account, so there is nothing for a first visitor to claim.

The terminal

It also has a terminal: the exact command is in FORGE_APP_SSH when the app runs, and the front page prints it. It opens src/tty.ts on a real pty over the same database — list boards, make one, open one, stick a note on it, rename it, clear it, delete it. A note written over ssh appears on everyone's screen within a second: the web server watches the revision of boards that people are actually looking at (src/watch.ts) and streams on anything a stranger changed.

The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV (production or preview), FORGE_APP_URL, FORGE_APP_SSH and FORGE_APP_COMMIT when it starts the app. forge.json says how.

↑↓ navigate ↵ open esc close

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs