TestWSRelayBetweenPeers read b with a 100ms context and retried on timeout. coder/websocket closes a connection whose read context expires, so after the first timeout every retry read a closed socket and the test failed whenever b joined later than that: 36 of 960 runs under 48 parallel processes. b now reads once, under the test's context, while a resends every 100ms; the same load fails 0 of 1600 runs. Co-authored-by: Hanzo Dev <[email protected]>
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.