A self-hosted, password-gated, collaborative multi-page whiteboard built on
top of the @excalidraw/excalidraw
React component.
Excalidraw upstream has no pages, and the maintainers have deferred the feature in favour of Excalidraw+ scenes. SketchSpace is not a fork — it consumes the editor as a package and supplies the two things that package does not ship: a page model and a collaboration server you own. The editor itself is vendored from a local excalidraw checkout so your own editor patches come along; see Where the editor comes from.
See NOTES.md for lessons learned and the roadmap.
- Boards hold an ordered list of pages. One tab strip, one link.
- Real-time collaboration on every page, with per-page cursors and board-wide presence.
- Password gate for the whole instance; past it, all boards are shared.
- SQLite + a files directory — one
.dbfile and one folder to back up.
The editor is vendored from a local excalidraw checkout, not installed from npm (see Where the editor comes from), so build it once first:
cd ../excalidraw && git switch SketchSpace && yarn build:packages
cd ../SketchSpace && npm install && npm run sync:editor
cp .env.example .env # then set SKETCHSPACE_PASSWORD
npm run build
npm startOpen http://localhost:3000.
npm start reads .env natively (Node's --env-file-if-exists), so there is
nothing to export. For a deployment, docker compose up --build instead — the
image takes real environment variables and ignores .env.
Keep the data directory out of Dropbox / OneDrive / iCloud. SQLite in WAL mode keeps
.db,.db-shmand.db-walopen together, and a file-sync client will both lock them and sync them out of step with each other — which risks a corrupt database, not just noise. This repo lives in Dropbox, so.envpointsSKETCHSPACE_DATA_DIRoutside it. The Docker setup is unaffected: it uses a named volume.
| Command | What it does |
|---|---|
npm start |
Run the built server, reading .env. |
npm run dev |
Watch mode. Vite on :5173 proxying to the server on :3000 — open 5173, not 3000. |
npm run build |
Build client then server. Refuses to run if vendor/ is missing. |
npm run typecheck |
Both tsconfigs. Run after every sync:editor — see below. |
npm run sync:editor |
Re-vendor the editor from your excalidraw checkout. |
npm run smoke |
End-to-end collaboration suite against a running server. |
After changing server code you must npm run build before npm start.
After changing client code, rebuild and hard-reload the browser
(Ctrl+Shift+R); no server restart is needed,
since the server only serves static files from dist/client.
Ctrl+C if it is in your terminal. Otherwise find it by port — the process
command line is a relative path, so filtering on the word "sketchspace" matches
nothing:
Get-NetTCPConnection -LocalPort 3000 -State Listen |
ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }Starting a second instance while one is already up prints that same command
along with an EADDRINUSE message, rather than a stack trace.
scripts/smoke.mjs drives three concurrent clients against a running server and
checks the whole path — auth, scene deltas, conflict resolution, page isolation,
presence, and persistence. Point it at a throwaway data directory and port
so it does not write into your real boards:
npm run build
SKETCHSPACE_PASSWORD=dev PORT=3111 SKETCHSPACE_DATA_DIR=C:/sketchspace-smoke \
node dist/server/index.js &
npm run smoke -- http://localhost:3111 devAll 18 checks should report ok.
The server adopts every board and registers its watchers before it listens, so on a machine with real projects that takes seconds rather than milliseconds. The smoke test waits for it, so the two commands above are safe to paste together - but if you script anything else against a freshly started server, wait for the port rather than assuming it is up.
The address bar always points at what is on screen:
#/board/<boardId>/<pageId>?v=<x1>,<y1>,<x2>,<y2>
Pan or zoom and it rewrites itself (via replaceState, throttled, so the back
button stays useful). Copy the URL and you have shared the sheet and the region
- "look at this detail on A501" becomes a link rather than a description, which matters on a construction sheet where most of the page is not the thing you mean.
Opening such a link frames that region with fit: "contain". Only the box is
encoded, not a zoom level, so a link resolves to the same content on a laptop
and a 4K monitor rather than reproducing someone else's pixel zoom.
The idea is lifted from first-draft's URL view sharing,
which encodes the viewport as l/t/r/b/z query parameters. Excalidraw's
getVisibleSceneBounds and setViewport({ target }) speak scene-coordinate
boxes directly, so no coordinate maths of our own is involved.
| Variable | Required | Default | Notes |
|---|---|---|---|
SKETCHSPACE_PASSWORD |
yes | — | The single shared password. The server refuses to boot without it. |
SKETCHSPACE_SESSION_SECRET |
strongly | random | Signs session cookies. If unset, everyone is logged out on restart. |
PORT |
no | 3000 |
|
SKETCHSPACE_DATA_DIR |
no | ./data |
/data in the container. |
SKETCHSPACE_SECURE_COOKIE |
no | false |
Set true when behind TLS. |
SketchSpace does not depend on @excalidraw/excalidraw from npm. It vendors a
build from your own excalidraw checkout into ./vendor, so any patch you carry
there — a custom preference, a behaviour fix — is actually in this app. The npm
package only ever contains what upstream has released.
cd ../excalidraw
git switch SketchSpace # our long-lived editor branch
yarn build:packages # after any editor change
cd ../SketchSpace && npm run sync:editorEditor work lives on the SketchSpace branch of
OpeningDesign/excalidraw, a fork of
upstream and that fork's default branch. master there stays a pristine mirror of
excalidraw/excalidraw, so git log master..SketchSpace answers "what is ours?"
exactly. To take upstream changes: git fetch upstream && git merge upstream/master
on SketchSpace, then re-run sync:editor and npm run typecheck.
scripts/sync-editor.mjs copies the built prod and types output of five
packages — excalidraw, common, element, math, fractional-indexing — and
prints the commit it took them from. Set EXCALIDRAW_REPO to use a checkout
somewhere other than ../excalidraw. npm run build refuses to run if the
vendor directory is missing.
Three things are worth knowing about this arrangement:
- Five packages, not one.
scripts/buildPackage.jsupstream marks the four sibling packages as esbuild externals, so the built editor imports them rather than inlining them. (The 0.18.1 npm release did inline them, which is why depending on the published package needed none of this.)client/vite.config.tsaliases each sibling — bare and/*— onto its singleindex.js, matching what each package's own exports map does. - The editor's own dependencies are in
devDependencies. The upstream build runs esbuild withpackages: "external", so every bare import —roughjs,jotai,radix-ui,pako, and ~25 more — has to resolve here. They are copied frompackages/excalidraw/package.jsonand are needed at build time only; the runtime image installs with--omit=dev. - You are tracking master, not a release. That is the point, but it cuts both
ways: upstream can rename things.
excalidrawAPIbecameonExcalidrawAPIin #10870 after 0.18.1, and because the vendoredtypesare what tsc reads,npm run typecheckcatches that class of break at build time rather than in the browser.
vendor/ is gitignored — it is build output. A fresh clone needs an excalidraw
checkout and one sync:editor before it can build.
client/src/Board.tsx passes an explicit <MainMenu> to <Excalidraw>. This
is not cosmetic, and removing it silently costs you features.
When a host app supplies no <MainMenu> child, the editor falls back to
DefaultMainMenu in packages/excalidraw/components/LayerUI.tsx — and that
fallback contains no <MainMenu.DefaultItems.Preferences /> at all. Everything
inside the Preferences submenu therefore renders nowhere: box-selection mode,
snap mode, grid mode, object snapping, and any preference you add yourself in
your excalidraw checkout. The item is present in the shipped bundle and simply
has no route to the screen.
This is easy to misdiagnose as the vendoring having failed. It has not; grep the
built bundle to confirm the feature is there before suspecting sync:editor:
grep -o wheelBehavior dist/client/assets/index-*.js | head -1excalidraw.com never hits this because excalidraw-app/components/AppMainMenu.tsx
supplies its own menu including Preferences. Any embedder relying on the
fallback does hit it.
Scene load/save entries are deliberately omitted from our menu: a page lives on
the server, not in a local file, so "Save to active file" would be misleading.
ToggleTheme uses allowSystemTheme={false} because system theme requires the
host to own theme state, and SketchSpace does not.
Two socket.io rooms per viewer:
board:{boardId} always joined — page list + board-wide presence
page:{pageId} joined/left on switch — scene updates + cursors
Splitting them is what keeps traffic proportional to what you are looking at: someone editing page 1 never receives a packet of page 7's scene updates, but still appears in the presence list and still sees pages appear and get renamed.
Unlike excalidraw.com — where collaboration is end-to-end encrypted and the server is a blind relay — you own this server, so it holds the authoritative element map for every open page. Each incoming batch is reconciled against it and only the winning elements are rebroadcast as a delta.
The reconciliation rule in server/src/reconcile.ts
is a deliberate mirror of Excalidraw's own
(packages/excalidraw/data/reconcile.ts): higher version wins, ties broken by
the lower versionNonce. Keeping the two in agreement is what lets a late
joiner receive a correct snapshot and two simultaneous editors converge — the
piece a naive rebroadcast server gets wrong.
| Table | Holds |
|---|---|
boards |
id, name, timestamps |
pages |
board, name, fractional index, soft-delete flag |
page_scenes |
one JSON element blob per page |
files |
image metadata; the data URLs live in data/files/ |
Open pages are cached in memory, written back on a 1s debounce, and evicted
five minutes after the last viewer leaves. Everything dirty is flushed on
SIGTERM.
Pages carry a fractional index string rather than an integer position, the
same technique Excalidraw uses for element z-order. Inserting between two pages
mints a key strictly between theirs, so two people reordering at once converge
without a coordinator. See server/src/fracIndex.ts.
Images are stored per board, not per page, so one can be moved between pages without re-uploading. They are kept as data URLs exactly as the editor produced them — about 33% larger on disk than raw binary, in exchange for exact round-tripping.
One board per IFC file, one tab per sheet. A drawing set is a board; each sheet is a page. Once a project is imported the two stay in step automatically, in both directions.
Set Bonsai's Layout SVG Command preference to (one line, JSON):
[["node", "--env-file-if-exists=D:/path/to/SketchSpace/.env", "D:/path/to/SketchSpace/scripts/open-layout.mjs", "path"]]bpy.ops.bim.open_layout() then opens the sheet in SketchSpace instead of
Inkscape. Bonsai runs this through subprocess.Popen with no shell, so the
executable must be resolvable by shutil.which, every argument is a separate
list entry, and the literal token "path" is replaced with the layout's path.
The script handles three cases: the sheet is already a tab (opens it), the board exists but this sheet is new (adds a tab), or neither (imports the whole set and creates the board). First use on a project imports every sheet, which can take a minute; it happens once.
It also starts SketchSpace if it is not running, detached, so the button
works from a cold machine - Blender is often the first thing opened in the
morning. That first press takes around fifteen seconds while the server boots;
later ones are instant. The server's output goes to <dataDir>/server.log.
Pass --no-start, or set SKETCHSPACE_NO_AUTOSTART=1, to have it refuse rather
than start anything.
Database work happens first and the server is started after: creating a board while nothing is running is entirely safe, and the server's startup reconcile then sees a finished board rather than one being built underneath it.
npm run import:sheets -- "<project>/Models/Bonsai" # the whole set
npm run import:sheets -- "<...>/layouts/A001 - SITE PLAN.svg" # a single sheet
npm run import:sheets -- <path> --board <boardId> # add tabs to a boardThe board takes its name from the .ifc file beside layouts/, since Bonsai
resolves every drawing path relative to that file's directory.
- Move a drawing here and the layout SVG is rewritten about two seconds after
it settles. A pill bottom-right reads
layout saved. Runbpy.ops.bim.create_sheets()in Blender to rebuild the sheet. - Add a sheet in Bonsai and a tab appears.
- Rename a sheet and the tab follows it, rather than duplicating.
- Delete a sheet and the tab goes — unless you have drawn on it.
- Add, remove or regenerate a drawing and the sheet updates in place, including Bonsai's reflow of its neighbours.
- Edit a linked file — a titleblock, a view-title asset, a drawing redrawn without changing size — and every sheet placing it refreshes.
The directories watched for that last one are derived from the layouts
themselves, not hardcoded and not read from the IFC: each <image href> is
resolved against its layout's directory and the containing directory is watched.
So schedules/ is watched only on projects that place one, and a reference
living in another repo via a git submodule is followed just the same.
Redlines are never written to the layout; they carry no customData.bonsai.
A layout SVG records no identity of its own — its root <svg> carries only
id="root" — and Bonsai renames the file when a sheet is renamed. Matching on
path alone therefore reads a rename as a brand new sheet: the old tab is orphaned
and a duplicate appears.
A sheet is instead identified by the set of IFC GlobalIds of the drawings it
places (data-drawing), which survives renaming. That is what lets a rename
rebind the existing tab.
Tab names still come from filenames, so a sheet called A01 - PLANS, SECTIONS
appears as A01 - PLANS SECTIONS — Bonsai strips commas when writing the file.
Only when its layout file is gone and nothing you drew is on it. A tab carrying redlines is kept and reported instead, because redlines exist nowhere else. Deletion is a soft delete, so a wrong call is recoverable from the database.
If a layouts directory suddenly contains no layouts at all, nothing is touched — a disconnected drive or a sync hiccup looks identical to a mass deletion and is far more likely.
- Write-back targets the group
transform, not imagex/y. Bonsai'sbuild_drawingscopies each<g>into the built sheet with attributes intact, swapping only the<image>children, so the transform survives the build. It is what Inkscape writes when you drag a group, and it leaves Bonsai's own coordinates and reflow logic untouched. - The edit is string surgery on one attribute. Re-serialising the XML would reformat the file and destroy the small diff that is the point of layouts.
- Writes refresh the stored baseline. Our own writes are echo-suppressed by
content hash so the watcher ignores them, which means nothing else would
refresh
groupTx; without it every change rewrites the whole sheet. - Nested references are inlined on import. A drawing may reference a raster
underlay relatively, and once the SVG is base64'd into a
data:URL there is no base to resolve it against. - SVG attributes are read by local name. Inkscape rewrites namespace prefixes
inconsistently; matching
xlink:hrefliterally made a sheet that binds the namespace asns3parse to zero placements, silently.
Sibling images of one layout group (foreground + view-title) are bound into an
Excalidraw group so they move together, and the titleblock imports locked,
honouring Bonsai's sodipodi:insensitive.
Moving a placement edits your project repository within ~2 seconds. git diff
reviews a session; git checkout -- undoes it.
Assets are stored as raw bytes and served from
GET /api/boards/:id/assets/:fileId, not embedded as data: URLs in JSON.
Excalidraw loads images with image.src = dataURL on a plain new Image(), so a
same-origin URL works - and being same-origin keeps the canvas untainted, so PNG
and SVG export still work. Ids are content hashes, so responses are served
immutable: a title block shared by fourteen sheets is fetched once.
On a real set this is the difference between a 26 MB JSON response per sheet and a 1.7 KB one.
What it does not fix: a raster underlay referenced inside a drawing SVG must
still be inlined as a data URL. An SVG loaded through <img> runs in secure
static mode and cannot fetch external resources at all, so an HTTP reference
there renders nothing - the same wall a relative reference hits. One site plan is
19.7 MB for this reason. It is now fetched once and cached rather than re-sent,
but the bytes are still there. Gzipping the asset endpoint and downsampling
underlays at import are the remaining levers.
npm run export:layout -- <boardId> [--dry-run] writes positions from the command
line, and npm run backfill:layout repairs boards imported before write-back
metadata existed. Both refuse to run while the server is up (except
--dry-run, which is read-only): the server caches open pages in memory and would
overwrite a direct database edit on its next flush.
Two halves, with very different value:
| size | replaceable? | |
|---|---|---|
sketchspace.db |
~0.5 MB | No. Redlines, sheet arrangement, which layout maps to which tab |
files/ |
hundreds of MB | Yes — re-importable from Bonsai |
Redlines exist nowhere else. The assets are derived from drawings that live in your own repositories.
npm run backup # to the default location
npm run backup -- --out D:/Dropbox/SS-Backups # anywhere you like
npm run backup -- --timestamped --keep 14 # if you have no versioning
npm run backup -- --assets # mirror the assets too
npm run backup -- --assets E:/SS-Assets # ...or somewhere elseSKETCHSPACE_BACKUP_DIR works too.
The script knows nothing about Dropbox, restic, borg or anything else. Its
only job is to produce a consistent .db at a path you choose; whatever backs
that path up is your business. Point --out at a synced folder and you are done.
It runs against the live server — no downtime. VACUUM INTO produces one
self-contained, compacted file, written under a .partial name and renamed into
place so a backup tool never catches a half-written snapshot. Every run verifies
the result with integrity_check and a row count before reporting success.
Defaults chosen to suit whatever is downstream: one stable filename (Dropbox
versions a path, restic and borg deduplicate against the previous blob, rsync
sends deltas — timestamps defeat all three), no compression (compresses
badly against dedupe; your backup tool does it better), and no retention
unless asked (restic forget, borg prune and Dropbox versions all do it
properly).
files/ is skipped unless --assets is given. It is content-addressed and
written once, so it is safe to mirror live with any tool — but doing it here gets
the ordering right, which matters more than it looks.
The snapshot is taken first and the assets mirrored after. Everything the snapshot references was written before it was taken, so a mirror run afterwards necessarily contains all of it; a few extra assets copied in between are harmless. The reverse order is not safe — an asset written between the mirror and the snapshot would be referenced but missing, and the restore shows a broken image.
The copy never deletes. A filename is a hash of its contents, so a name that already exists is already correct and is skipped, and an asset that an older snapshot still references is never removed just because the current database has stopped using it. First run copies everything; later runs copy only what is new.
# 1. stop the server
# 2. put the snapshot and the assets in one directory
cp sketchspace-2026-09-09.db <restore-dir>/sketchspace.db
cp -r <old data dir>/files <restore-dir>/files
# 3. point the server at it
SKETCHSPACE_DATA_DIR=<restore-dir> npm startThis procedure is exercised, not assumed: a server booted from a snapshot
recovers every board and tab, and assets serve normally. Restore the .db
without files/ and the boards return with broken images — re-importing
regenerates the assets but creates new boards, so redlines would not reattach.
Windows Task Scheduler, running npm run backup daily with the working
directory set to this repo. Missed runs (machine asleep) are caught up on the
next login.
- The password is the only boundary. Anyone who knows it can read, edit, and delete every board. That is the intended model for a small trusted group; it is not multi-tenant.
- No end-to-end encryption. The server reads your drawings — that is the trade that makes authoritative reconciliation possible. Do not put this on the public internet without TLS in front.
- No undo across page switches. Switching pages calls
updateScenewithCaptureUpdateAction.NEVER, so the switch itself is not an undo step, but history is not partitioned per page either. - Cross-page element links are not implemented. Pages are independent scenes.
- No offline mode. A disconnected client keeps drawing locally but its changes are not queued for reconnect.
- The vendored editor tracks master, not a release. That is the point, but
upstream can rename APIs between syncs.
npm run typecheckis the guard; run it after everysync:editor.
SketchSpace was first built in 2012 as a real-time whiteboarding plugin for Etherpad, the core collaboration tool on the OpeningDesign platform. That codebase is preserved at OpeningDesign/SketchSpace_Old_Version.
This is the same idea rebuilt: multi-page, model-aware, version-controlled.
MIT — see LICENSE. The Excalidraw editor it builds on is also MIT.