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.
main
- src
- .gitignore 37 B · ~11 tok
- AGENTS.md 2.5 KB · ~636 tok
- forge.json 111 B · ~37 tok
- package.json 143 B · ~48 tok
- README.md 6.6 KB · ~1.7k tok
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.
| file | one concept |
|---|---|
src/server.ts | the socket, the same-origin rule, the security headers on every response |
src/routes.ts | the table of paths and who is allowed to do what |
src/db.ts | the schema and every query: boards, shapes, revisions, edit grants |
src/shape.ts | what a shape is, how it is validated, and what it looks like |
src/svg.ts | a board as SVG on the server: the first paint and the export |
src/bus.ts | who is on a board and how a change reaches them |
src/watch.ts | the bridge that carries a terminal's change to the browsers |
src/board.ts | board ids, titles, passwords, cookies |
src/limit.ts | a leaky bucket for making boards and guessing passwords |
src/assets.ts | the 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.