Relay and rooms¶
Who this is for: hosters (sysadmins) and technical readers.
This page is the technical reference for the private caption relay: its rooms,
tokens, connection protocol and message flow. It describes the relay in
captionninja/relay, an independent implementation of the browser message
protocol. The relay ships as part of both supported deployments — see
Choose a deployment.
The relay forwards caption text only. It performs no speech recognition and has no GPU, model or cloud dependency, so it runs separately from inference.
Rooms¶
Rooms are named channels on the relay. Clients join exactly one room with exactly one role per WebSocket, and rooms are isolated from one another. The configuration generator creates two rooms:
| Room | Purpose | Typical producers | Typical readers |
|---|---|---|---|
source |
Raw, uncorrected captions from the capture page | Capture page (write) | Editor (read) |
output |
Corrected captions for the audience | Editor (write) | Overlay, OBS, viewers (read) |
The editor's output room must exist in the relay configuration and must differ from its source room. Rooms must be provisioned ahead of time (at most 1,000), so provisioning another event means adding rooms and restarting the relay.
Room tokens¶
Each room has separate read and write credentials. A read token subscribes,
a write token publishes. A viewer cannot publish with a read token, and
credentials for one room cannot join another room.
| Role | Permission | Who needs it |
|---|---|---|
write on source |
Publish raw captions | Capture page |
read on source |
Subscribe to raw captions | Editor |
write on output |
Publish corrected captions | Editor |
read on output |
Subscribe to corrected captions | Overlay, OBS, viewers |
These are relay credentials and are separate from the speech service's service
access token (CAPTION_API_KEY). Only authenticated readers in the same room
receive captions.
Connecting¶
Local testing uses the loopback address; internet hosting uses a TLS reverse proxy
and the wss scheme:
| Deployment | WebSocket URL |
|---|---|
| Loopback | ws://127.0.0.1:8787 |
| Internet | wss://relay.example.org/ |
A reliable client joins by sending a JSON join frame. The join value is the room
name and role is read or write:
{"protocol":2,"join":"ROOM","role":"read","token":"TOKEN"}
The join response includes the process epoch, the next sequence and any gap reason. Returning readers resume by adding a cursor from their last position:
{"protocol":2,"join":"ROOM","role":"read","token":"TOKEN","cursor":{"epoch":"PREVIOUS_EPOCH","sequence":123}}
A legacy private join without protocol is still supported, but it has no
reliable client acknowledgements and no resume cursor. One socket has one
immutable room and role; viewers cannot publish.
Message flow¶
Raw captions travel to source, where the editor reads them. After a human
reviews the text, the editor publishes the corrected captions to output, which
the overlay and audience read.
capture page --write--> source --read--> editor --write--> output --read--> overlay / OBS / viewers
Writers send captions in this shape:
{"msg":true,"final":"text","id":1,"delivery":{"client":"UNIQUE_PRODUCER_ID","sequence":1}}
| Field | Meaning |
|---|---|
msg |
Marks a caption message |
final |
A completed caption; interm can replace it for an interim result |
id |
Original caption identifier, kept distinct from the delivery identity |
delivery.client |
Unique producer identifier for the retry/acknowledgement scheme |
delivery.sequence |
Per-producer sequence number |
label, ln |
Optional; preserved unchanged by the relay |
The relay acknowledges the delivery identity and adds an epoch/sequence cursor to forwarded captions. Duplicate receipts are suppressed within the retained history.
Origin check¶
The relay rejects any WebSocket upgrade whose Origin is not listed in the
private configuration's origins. Origins are the exact scheme plus host, written
with no trailing slash and no path. Because the capture page and the
editor/overlay usually run on different hosts, every page origin must be listed
separately.
Origin checks supplement tokens; they are not authentication. Native clients
that send no Origin header still need valid room credentials. Forwarded-IP
headers are deliberately ignored, so clients behind one reverse proxy share the
per-IP limit.
Bounds and defaults¶
The relay bounds its own resources and rejects or disconnects rather than growing buffers. Implementation defaults:
| Limit | Default |
|---|---|
| Total WebSockets | 512 |
| WebSockets per direct peer IP | 128 |
| WebSockets per room | 256 |
| Messages per connection per second (including join) | 40 |
| Message size | 8 KiB |
| Caption length | 4,000 characters |
| Pending transport output per connection | 64 KiB |
| Join deadline | 10 seconds |
| Heartbeat | 30 seconds |
| Backpressure grace for slow reliable viewers | 10 seconds |
| Unacknowledged captions retained by a publisher | 100 |
| Rooms | 1,000 |
An optional limits object in the private configuration can raise individual
bounds, for example "limits": {"perIp": 512} for large audiences behind one
proxy. This raises admission capacity only; it does not enlarge caption buffers.
Unknown limit names and nonpositive or noninteger values are rejected.
Replay and recovery¶
The relay keeps a short replay history in RAM with delivery receipts: at most 120 seconds and 256 messages per room, bounded globally by 8,192 messages and a 16 MiB accounting budget. Text never goes to disk or logs, and no audio is stored. Each room's viewing (read) token authorizes both its live captions and its retained history.
This is bounded recovery, not durable or exactly-once delivery. If the buffer expires or is evicted, the viewer shows a gap warning. Restarting the relay clears history and receipts, and an unacknowledged retry can repeat. A private relay failure never falls back to the public relay.
Audience links¶
The editor can build a view-only OBS link that uses the output room's read token in the URL fragment:
#relayReadToken=YOUR_OUTPUT_READ_TOKEN
The page reads the fragment into memory and removes it from the address bar. Fragments are not sent in HTTP requests, but the original link is still a credential that OBS settings, the clipboard and browser history may retain. Never put a write token in an audience link.