Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

107 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

collab

An offline-first collaborative planning board, built entirely in Go — real-time multiplayer kanban with a collaborative text editor inside every card. Both sync engines (Operational Transformation and CRDTs) are implemented from scratch, and the browser client is Go compiled to WebAssembly.

What it does

  • A shared kanban board. Cards, columns, drag-to-move, votes, editable titles — every change fans out live to everyone looking at the board.
  • A collaborative text document inside every card. Open a card's description in two browsers and type simultaneously; the edits merge Google-Docs style, with carets staying put as remote edits land.
  • Offline-first, for real. Kill the network and keep working: board edits and every keystroke are persisted locally (IndexedDB) as they happen. Close the tab while offline, reopen later — nothing is lost. On reconnect, everything merges cleanly with what everyone else did meanwhile.
  • Honest conflict surfacing. If two people move the same card to different columns while one was offline, both converge on one winner and the card shows a banner naming the kept move — conflict transparency instead of silent resolution.

The interesting bits

  • OT implemented from scratch (internal/ot): retain/insert/delete operations with Apply, Compose, and Transform. The TP1 property — the correctness law of server-based OT — is verified by a fuzzer, not by hope. Insert-position ties break deterministically by client ID, so concurrent inserts converge identically on every replica.
  • A sequence CRDT implemented from scratch (internal/crdt): a textbook RGA (Replicated Growable Array) with Lamport-clock IDs, tombstoned deletes, and causal buffering. Convergence (any order, any duplication → same text) is fuzz-tested.
  • A hybrid sync model for text: OT through the central server while online; the local RGA while offline; on reconnect, the offline delta is diffed into a single composite OT operation and submitted through the ordinary pipeline. The server never learns the CRDT existed.
  • An always-on CRDT layer for the board (internal/boardcrdt): add-wins OR-Set/OR-Map for cards and their placement, LWW registers for fields, PN-Counters for votes, fractional indexing for ordering. No revisions, no transforms — offline board edits are just late-delivered ops.
  • Go on both sides of the wire: the client sync engines are the same Go packages the tests exercise natively, compiled to WebAssembly. The JS shell is deliberately dumb — DOM events in, rendered HTML out.
  • Hub-pattern concurrency: one event-loop goroutine owns all document state; connections talk to it over channels. No mutexes anywhere near the hot path — the loop is the total order that OT requires.
  • Zero CGO, two dependencies: pure-Go SQLite (modernc.org/sqlite) and a WebSocket library (coder/websocket). Everything else is the standard library.

Quickstart

Requires Go 1.26+.

./build.sh      # builds the WASM client, then the server (which embeds it)
./bin/server    # serves http://localhost:8080 (SQLite db: ./collab.db)

build.sh looks for the Go toolchain at ~/.local/go/bin/go by default; point it elsewhere with GO=$(command -v go) ./build.sh.

Open http://localhost:8080 in two browser windows side by side, then try:

  1. Add cards, drag them between columns, vote — watch the other window follow.
  2. Click a card's ✎ and type in its description in both windows at once — the text converges without clobbering either side.
  3. DevTools → Network → Offline in one window. Keep typing and moving cards — everything keeps working and is saved locally. Go back online: offline edits merge with whatever the other window did meanwhile.
  4. While one window is offline, move the same card to different columns in each window, then reconnect: both settle on one column and show a conflict banner naming the kept move.

Running the tests

go test -race ./...

Property and fuzz tests are the correctness backbone. The seed corpora run on every go test; to actively fuzz:

go test -fuzz=FuzzTP1              -fuzztime=30s ./internal/ot        # OT transform correctness
go test -fuzz=FuzzCompose          -fuzztime=30s ./internal/ot        # compose ≡ sequential apply
go test -fuzz=FuzzConvergence      -fuzztime=30s ./internal/crdt      # RGA convergence
go test -fuzz=FuzzBoardConvergence -fuzztime=30s ./internal/boardcrdt # board CRDT convergence
go test -fuzz=FuzzDiff             -fuzztime=30s ./internal/client    # apply(a, diff(a,b)) == b

If Node.js is on PATH, the cmd/wasm tests also run a live end-to-end suite: two real WASM client builds under Node against a real server, typing concurrently, dropping the network, and reconciling. Without Node those tests skip.

Deployment

The whole server contract is two knobs:

  • -addr / $PORT — the server binds localhost:8080 by default. Set $PORT and it binds :$PORT (all interfaces — the usual platform convention); pass -addr explicitly and that wins over both.
  • -db — the SQLite file. Put it on a persistent volume; everything else is embedded in the binary.

The included multi-stage Dockerfile builds the WASM client and the server exactly like build.sh and ships a single static binary on a distroless base, with the database at /data/collab.db.

Run exactly one instance. The hub's event loop is both the OT total order and SQLite's single writer — two replicas means two divergent boards, and scaling to zero drops live WebSocket sessions. No horizontal scaling, no scale-to-zero.

Fly.io

fly launch --no-deploy                                   # uses fly.toml; pick a name + region
fly volumes create collab_data --region <region> --size 1
fly deploy

fly.toml already pins the single-instance shape (min_machines_running = 1, auto-stop off); keep fly scale count 1.

Elsewhere

  • Railway / Render — work as-is: both inject $PORT; attach a persistent volume/disk and point -db at it.
  • Any VPSdocker build -t collab . && docker run -p 8080:8080 -v collab-data:/data collab, behind whatever TLS proxy you like (WebSockets pass through nginx/Caddy fine).

Project layout

Path What lives there
cmd/server Server entrypoint: wiring, graceful shutdown
cmd/wasm Browser client entrypoint: DOM/WebSocket bridge, board rendering, UTF-16 boundary
internal/ot Operational Transformation core: ops, Apply/Compose/Transform, TP1 fuzzing
internal/crdt Sequence CRDT (RGA): IDs, tombstones, causal buffering, convergence fuzzing
internal/boardcrdt Board CRDTs: OR-Set/OR-Map, LWW registers, PN-Counters, fractional indexing
internal/client OT client state machine (Synchronized / AwaitingConfirm / AwaitingWithBuffer) + text diff
internal/sync The hybrid engine: Online→Offline→Reconciling state machine and reconnect reconciliation
internal/boardclient Client half of the board layer: replica, offline queue, reconnect flush
internal/hub The server: event-loop hub, per-document rooms, multi-doc routing, board fan-out
internal/protocol The JSON wire protocol — one codec shared by server and client
internal/store Persistence: snapshot + append-only op log on SQLite (plus an in-memory test double)
internal/idb Minimal IndexedDB wrapper for the WASM client
web HTTP layer: embedded templates and static assets (JS shell, CSS)

For the full design — the two sync layers, the reconnect reconciliation algorithm, the server's concurrency model, and the testing philosophy — see ARCHITECTURE.md.

Limitations

  • Single server instance. A document lives in one process; there is no clustering or horizontal scaling of a single board.
  • No authentication. Anyone who can reach the server can edit the board.
  • No presence or remote carets. You see other people's edits, not their cursors. (The caret-transform machinery exists and is tested; the presence protocol and rendering do not.)
  • One offline session per browser. Multiple tabs editing the same document offline share one local session and can interfere; keep offline editing to a single tab.
  • Plain text only in card descriptions — no rich text.
  • The WASM binary is ~4 MB uncompressed; fine on localhost, worth compressing before serving it anywhere else.

About

Google-Docs-style sync meets a kanban board — both algorithms built in Go (because i wanted to get some experience in Go)

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages