Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

scalidraw

Excalidraw with a self-hosted cloud. Drawings live in the browser (IndexedDB), every change syncs through a shoal server as end-to-end encrypted ops, and a new device restores everything from the 12-word recovery phrase. The server never sees a drawing.

Built on @excalidraw/excalidraw (MIT) and shoal-client. Live at scalidraw.edfl.dev.

Architecture

architecture diagram, drawn in scalidraw itself

The diagram is a scalidraw drawing. It was made in the app and synced through the pipeline it describes.

Components

Component Where it runs Role
scalidraw app Vercel, static files at scalidraw.edfl.dev Serves the UI to every device. Holds no data and no secrets.
Browser client Each device Owns the data: excalidraw editor, Dexie/IndexedDB storage, encryption, merge logic.
shoal server Docker container, published with Tailscale Funnel Stores and orders encrypted ops. Cannot read them.

The app is a viewer onto local data. A device with the app and no server configured is fully functional, sync is an add-on.

Data model

One shoal collection, scalidraw, three record kinds:

Record Content Merge
drawing/<id> title, updatedAt, or {deleted:true} last writer wins
el/<drawingId>/<elementId> one excalidraw element, full JSON last writer wins per element
file/<fileId> one embedded image (BinaryFileData) content-addressed, written once

Per-element records are the load-bearing choice. A whole scene as one record would exceed the op payload cap as soon as an image lands and would turn every concurrent edit into a whole-drawing conflict. Excalidraw is built for element granularity: every element carries a stable id, a version counter, a versionNonce, and isDeleted tombstones, and element order is a fractional index stored on the element itself. Ordering therefore syncs with the elements and needs no record of its own. Per-device view state (zoom, scroll, selection) is deliberately not synced.

The bridge

src/Editor.tsx connects excalidraw to the sync engine in both directions.

Local writes: excalidraw fires onChange constantly, including for pure selection changes. A trailing 400 ms debounce takes a snapshot, diffs each element's (version, versionNonce) against a map of the last observed state, and writes only changed elements, to Dexie and to the outbox in one pass. A selection-only change diffs to nothing and costs nothing.

Remote changes: the sync engine applies incoming records to Dexie and pings the editor, which rebuilds the scene from Dexie and calls updateScene. The observed-state map is updated before the push, so the onChange that updateScene triggers diffs to nothing. That ordering is the echo guard.

Renames commit once per rename, one op, rather than one per keystroke.

Identity and crypto

Everything derives from a 12-word BIP39 phrase, client-side:

seed     = BIP39-seed(phrase)
sign_key = HKDF-SHA256(seed, info="shoal/v1/sign")   ed25519, request signatures
enc_key  = HKDF-SHA256(seed, info="shoal/v1/enc")    XChaCha20-Poly1305, payloads
user_id  = base64url(ed25519 public key)

Payloads are encrypted before leaving the device, with the record's collection and id bound in as AAD so the server cannot move an op between records. Every request carries an ed25519 signature over method, path, timestamp, and body hash. There are no accounts, no cookies, and no sessions. The same phrase on a new device reproduces the keys and restores the whole library. The phrase cannot be recovered from anywhere.

Sync flows

Push: the outbox drains to POST /v1/ops in signed batches. The server assigns each op a per-user sequence number.

Pull: the client asks for everything past its cursor (GET /v1/ops?since=N), applies through the LWW gate, advances the cursor. The cursor is a position in one specific server's log, so the client resets it whenever the server URL or identity changes.

Poke: GET /v1/poke is a long-lived SSE stream, a doorbell only. When any of the user's devices pushes, the server emits a tiny event and listening clients pull. Data never travels on the stream, so a dropped stream loses nothing. The stream is read via fetch because EventSource cannot send the signature headers.

Offline: writes queue in the outbox inside the app's own IndexedDB. A dead server changes nothing about the app. The live loop reconnects with exponential backoff and also syncs on tab refocus and on the browser's online event.

What the server can see

Payloads are ciphertext. Metadata is not: record ids (so the el/ and drawing/ structure), write timestamps, op counts, payload sizes, and the user's public key are visible to the server operator. Contents, titles included, are inside the encrypted payload. The full threat model is in the shoal repo's PROTOCOL.md.

Limitations

  • Not realtime co-editing. Two devices on one drawing converge per element by last writer wins, which suits one person on many devices. Live cursors would need excalidraw-room, orthogonal to shoal.
  • No key rotation. A leaked phrase means a new identity and a re-push from a healthy device.
  • The client's maxPayloadBytes and the server's SHOAL_MAX_PAYLOAD_BYTES must match, both sides currently 4 MiB.

Run

pnpm install
pnpm dev        # local dev
pnpm test       # unit tests for the sync bridge and list logic
pnpm build      # static site in dist/

Point it at a shoal server from the settings page (gear icon): server URL plus recovery phrase, empty phrase generates one.

Server setup

docker compose up -d --build in the shoal repo. The docker-compose.override.yml sets:

SHOAL_MAX_PAYLOAD_BYTES: "4194304"   # images ship as single ops; matches MAX_PAYLOAD_BYTES in src/sync.ts
SHOAL_ALLOWED_PUBKEYS: "<your public key>"
SHOAL_MAX_TOTAL_OPS: "2000000"

Publishing: tailscale funnel --bg 7420 gives the container a public HTTPS URL on the machine's ts.net name. Only the server machine runs Tailscale, clients just use the URL. The allowlist means a valid signature from any key not pinned gets 403 before touching storage.

Server URL is per-device configuration. Devices elsewhere use the funnel URL. Browsers on the server machine itself use http://127.0.0.1:7420, because Chrome's secure DNS resolves the machine's own funnel name to the public ingress and that hairpin path hangs from behind the same NAT.

Deploy the app

Manual static deploy of the local build. The link step is required every time, rebuilding wipes dist/.vercel:

pnpm build
cd dist
pnpm dlx vercel@latest link --yes --scope studio-labs --project scalidraw
pnpm dlx vercel@latest deploy --prod --yes

Tests

pnpm test covers the pure logic: record id round-trips, the element differ and its echo idempotence, search, pagination, and stats helpers. End-to-end behavior (two-device sync, offline drain, image payloads, restore from phrase) was verified against a live server and browser.

About

excalidraw with a self-hosted cloud: e2e-encrypted sync via shoal, restore from 12 words

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages