zeekayandHanzo Dev c72d211725 The relay test reads once and resends until the late peer has the frame
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]>
2026-09-23 16:25:16 -07:00

collab

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.

S
Description
Realtime collaboration relay for Y.js documents over WebSocket, backed by SQLite or S3.
Readme
157 KiB
Languages
Go 98.8%
Dockerfile 1.2%