0.1.x kept each document as one row of docs(doc_id, state), every message it received concatenated. The frame store never read that table, so the first start of a later image on a 0.1.x volume served every document empty. NewSQLite now moves docs into doc and frame, in one transaction, and drops it. A state that splits into whole y-websocket messages keeps its content messages (sync step 2 and update) as frames, in order: what Append stores for the same messages. A state that does not split is kept whole as one frame, which is what 0.1.x replayed. Migrated frames follow any the document already has. Each row is deleted before its frames are written, so they reuse its pages; if they still outgrow COLLAB_STORE_BYTES, the transaction rolls back, docs is left as it was, and startup fails with "migrate 0.1 docs". pkg/frame holds the y-websocket framing: Content, which the room already used to decide what to store, and Split, which the migration uses. COLLAB_SQLITE_PATH is a file path; the store sets its own pragmas. 0.1.1 took a driver DSN there, and a DSN given to this store became "file:file:..." and failed as "unable to open database file: out of memory (14)". A "file:" URI or a path with a query is now refused before anything opens, naming it. Tests seed a database with 0.1.x's own DDL and upsert: its Yjs update and step 2 come back as frames, a non-Yjs state comes back whole, a document with frames keeps them first, a migration larger than half the cap fits, one that cannot fit leaves docs byte for byte, and reopening migrates nothing twice. Co-authored-by: Hanzo Dev <[email protected]>
4.9 KiB
collab
Hanzo realtime collaboration relay. Replaces Huly's collaborator
Node service with a single static Go binary.
clients (Y.js) ──WS──> collab ──Append──> SQLite
└─ fan-out to peers in the same room
API
| Method | Path | Notes |
|---|---|---|
| GET | /v1/health |
{"status":"ok","version":"..."} |
| WS | /v1/collab/<doc_id> |
Subprotocol yjs. Binary frames relayed + persisted. |
| GET | /v1/metrics |
Prometheus exposition. |
doc_id MUST be <orgID>:<workspaceID>:<docID>. The IAM JWT's
owner claim must equal <orgID> or the upgrade is rejected with 403.
Auth
Bearer JWT in Authorization: Bearer <token>, or, from a browser (whose
WebSocket API cannot set headers), as a bearer.<token> entry in the
subprotocol list beside yjs:
new WebSocket(url, ["yjs", "bearer." + token])
A ?token= query parameter is not read: the edge writes query strings to
its access log. Tokens are verified against the IAM JWKS at IAM_JWKS_URL
(default https://hanzo.id/v1/iam/.well-known/jwks); when a refresh fails,
the keys of the last good fetch keep verifying.
Sync protocol
The server is a dumb relay — it does NOT merge Y.js updates. Every
binary frame is relayed to the other peers in the room. A frame is
stored when its y-websocket header says it carries document content
(messageSync followed by syncStep2 or syncUpdate); sync step 1 and
awareness are relayed only. Applying the stored updates in order
rebuilds the document, so persistence is append-only frames. We revisit
this only when cross-region merge or server-side validation is required.
On peer join the server writes each stored frame as its own binary message, in order, before relaying live updates.
Limits
| Limit | Value | On breach |
|---|---|---|
| Frame size | 1 MiB | connection closed, 1009 |
| Frame rate per connection | 50/s, burst 200 | connection closed, 1008 |
| Stored bytes per document | 16 MiB | frame relayed, not stored (store_full) |
| Database file | COLLAB_STORE_BYTES |
frame relayed, not stored (store_append) |
Each connection has at most one append in flight: it reads its next
frame only after the last one is stored. The database runs a rollback
journal, which holds only the pages one transaction changes, so the
files on disk never exceed twice COLLAB_STORE_BYTES. Size the volume
from that, not the other way round.
Upgrading from 0.1.x
0.1.x kept each document as one row of docs, every message it received
concatenated. On open, collab moves those rows into the frame tables in
one transaction and drops docs: a state that splits into y-websocket
messages keeps its content messages as frames, in order; one that does
not is kept whole as one frame. If the frames do not fit under
COLLAB_STORE_BYTES, the transaction rolls back, docs is left as it
was, and collab exits with migrate 0.1 docs; raise the cap and start
again.
0.1.x took COLLAB_SQLITE_PATH as a driver DSN (file:...?_pragma=...).
Now it is a file path and the pragmas are collab's own, so a deployment
moving off 0.1.x sets the path in the same change as the image. A
file: URI is refused at startup, by name.
Run
go run ./cmd/collab
| Env | Default |
|---|---|
COLLAB_ADDR |
:3078 (Huly drop-in port) |
COLLAB_SQLITE_PATH |
collab.db (a file path; a file: URI is refused) |
COLLAB_STORE_BYTES |
67108864 (64 MiB database file cap) |
IAM_JWKS_URL |
https://hanzo.id/v1/iam/.well-known/jwks |
Build
go build ./...
go test ./...
Docker (CI/CD only — never build on a laptop, see hanzoai/.github):
docker build --build-arg VERSION=v0.1.0 -t ghcr.io/hanzoai/collab:v0.1.0 .
Deploy
Image: ghcr.io/hanzoai/collab:<semver>. Pin a vX.Y.Z or
sha-<sha7> tag — never :latest, :main, :dev.
Port 3078 is exposed (matches Huly's legacy collaborator port for
drop-in WS path replacement).
Declared in universe as charts/app/values/collab/collab.yaml, with
SQLite on one ReadWriteOnce volume, one replica, Recreate.
Layout
.
├── cmd/collab/main.go # boot + signal + env
├── pkg/auth/ # IAM JWKS verifier
├── pkg/server/ # HTTP + WS handlers
├── pkg/room/ # in-memory broadcast registry
├── pkg/frame/ # y-websocket message framing
├── pkg/store/ # Store interface + sqlite, 0.1.x migration
└── pkg/metrics/ # Prometheus registry
Licensed under MIT OR Apache-2.0, per HIP-0137.