The Yjs server for OpenCloud. It runs a Hocuspocus server that relays Yjs updates between clients editing the same file.
The service persists nothing. Documents are file-backed via WebDAV and hydrated from the client. Rooms still live in process memory, so all peers of a document must reach the same instance: run a single instance, or shard by document with sticky routing. Peers split across instances silently stop seeing each other, and their saves collide instead of merging.
Every connection is authenticated and authorized against OpenCloud:
/graph/v1.0/meTwo jobs may only be done by one client at a time per room, or the room ends up holding the file
body twice: seeding an empty room (grant seed), and rewriting a stale room after the file changed
outside it (grant recover, requested as recover:<etag>). The yjs server decides who, because it
is the only party that sees every connection to a room at once. A client asks over a stateless
message, and gets a grant or a refusal. Read-only connections are always refused.
There is one recover grant per room, and the etag says which body the requester would rewrite the
room with. While it is held, every other writer is refused, whatever etag it asks for. Each grant
follows its own policy in src/lib/grants.ts:
| Grant | Released when | Holder leaves |
|---|---|---|
seed | never | passed on to another writer |
recover | the room holds the granted etag, the holder releases it, or 30 s pass | freed |
The server watches its own replica for the granted etag, and sends the room's pending updates before any grant, so the next writer always has the rewrite before its grant. A recovery grant is never passed to a client that did not ask, because only the requester holds the body to recover from.
Grants live in process memory. A grant for a job that is already done is harmless: it is permission, not an instruction, and the client checks its own document first.
| Variable | Default | Description |
|---|---|---|
OPENCLOUD_URL | - | Required. Base URL of the OpenCloud server, e.g. https://cloud.example.com |
PORT | 1234 | Port to listen on |
SHUTDOWN_GRACE_PERIOD_MS | 15000 | Grace period for graceful shutdown before the process exits with code 1 |
The service listens for plain HTTP on PORT and upgrades to WebSocket. Where it sits is up to the
deployment: behind a reverse proxy on the OpenCloud host, on its own hostname, or reachable
directly.
Clients connect to the URL configured as options.yjsServerUrl in the web config. Collaborative
editing is off while that option is unset.
Whatever sits in front must:
The second point is easy to get wrong. The bearer token does not travel in an Authorization
header: browsers cannot set headers on a WebSocket handshake, so it arrives in Hocuspocus' own
first message once the socket is already open. A proxy that demands an Authorization header
therefore rejects every connection before the service ever sees it - and the service validates the
token itself regardless.
The dev stack is one example: OpenCloud's own proxy forwards /yjs to the service, with the
route marked unprotected for exactly that reason (see dev/docker/opencloud/proxy.yaml).
The sources are TypeScript and are run directly by Node's type stripping, so there is no build step. Node 22.18 or newer is required.
The dev stack in docker-compose.yml builds and runs it as the yjs service, and mounts
src/ read-only - so editing the server needs a docker restart web-yjs-1, not a rebuild.
Running it on the host instead needs an OPENCLOUD_URL whose TLS certificate the host trusts; the
dev setup's self-signed Traefik certificate does not qualify, which is why the container gets
NODE_TLS_REJECT_UNAUTHORIZED=0.
As a container, built from the repository root:
docker build -f services/yjs/Dockerfile -t opencloud-yjs-server .
The image defines a Docker HEALTHCHECK that verifies:
GET /healthz/ready) returns HTTP 200ws://127.0.0.1:$PORT succeedsDuring normal operation /healthz/ready returns 200 ok. Once shutdown starts, it flips to 503
immediately.
The service installs a SIGTERM/SIGINT/SIGQUIT handler and performs graceful shutdown:
503)If shutdown does not complete within SHUTDOWN_GRACE_PERIOD_MS, the process exits with code 1.
In the repository docker-compose.yml:
yjs uses restart: unless-stoppedyjs uses stop_grace_period: 20s (5s buffer beyond the default 15s shutdown grace period)Keep stop_grace_period higher than SHUTDOWN_GRACE_PERIOD_MS to allow the service time to
flush and close cleanly before Docker sends SIGKILL.
The service writes operational logs to standard output/error (stdout/stderr) via Node's
console methods. No file logger is used.
Content type
Image
Digest
sha256:99c6ce468…
Size
60.2 MB
Last updated
7 days ago
docker pull opencloudeu/yjs