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/meThe first client in a room writes the file body into the Y.Doc. Exactly one client may do that, or the room ends up holding the body twice. The yjs server decides which, 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.
Grants live in process memory and last as long as the holder's connection. If the holder leaves before it seeds, the grant passes to another writer in the room. A grant for a room that already has content 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:2beddf0cc…
Size
57.2 MB
Last updated
5 days ago
docker pull opencloudeu/yjs