WebSocket
The /api/ws upgrade, frame envelope, every push type, and reconnect semantics.
Mosaic exposes a single WebSocket fan-out at /api/ws. Connect once, stay connected, and receive a stream of typed frames as the engine ticks. There is no client-to-server message protocol — the server drains client reads for liveness but does nothing with them.
Upgrade
GET /api/ws
| Auth | Session cookie OR Authorization: Bearer <api-key> header. |
|---|---|
| Origin | Pinned to the request Host (OriginPatterns: []string{r.Host}). |
| TLS | Use wss:// when the server is bound to all interfaces; ws:// over loopback in the default HTTP-only mode. |
The legacy ?key=<api-key> query-param form has been removed. URL params leak into Referer headers, reverse-proxy access logs, and browser history, so passing a credential there is unsafe. The SPA’s WebSocket uses the session cookie (browser auto-attaches it same-origin); scripted clients use the Authorization header.
Browsers loading the SPA same-origin don’t need to do anything extra — the session cookie is auto-attached to the upgrade. Scripted Node / Python clients that need bearer-key auth must use a WebSocket library that supports a custom Authorization header on the upgrade (e.g. nodejs/ws’s headers: option, or Python websocket-client’s header=).
// Browser SPA — session cookie attaches automatically
const ws = new WebSocket(`wss://${location.host}/api/ws`);
// Node script — bearer header on the upgrade
import WebSocket from 'ws';
const ws = new WebSocket('wss://localhost:8080/api/ws', {
headers: { Authorization: `Bearer ${process.env.MOSAIC_API_KEY}` },
});
Envelope
Every frame is JSON of the form:
{ "type": "<discriminator>", "payload": <type-specific> }
type is a stable string. Unknown types should be ignored — Mosaic may add new ones in minor releases.
Frame types
torrents:tick
Emitted ~1 Hz. Full snapshot of every torrent. Use this to rerender the torrent list without polling REST.
Payload: TorrentDTO[]
{
type: "torrents:tick",
payload: [{
id: string, name: string, magnet: string, save_path: string,
total_bytes: number, bytes_done: number, progress: number,
download_rate: number, upload_rate: number,
peers: number, seeds: number,
paused: boolean, completed: boolean,
added_at: number,
category_id: number | null,
tags: { id: number, name: string, color: string }[],
queue_position: number, force_start: boolean, sequential: boolean,
queued: boolean, verifying: boolean, files_missing: boolean,
access: "owner" | "editor" | "viewer"
}, ...]
}
The list is sorted newest-first by added_at with id as tie-breaker — order is stable across ticks for the same torrents.
stats:tick
Emitted ~1 Hz. Aggregate counters for the status bar.
Payload: GlobalStats
{
type: "stats:tick",
payload: {
total_torrents: number,
active_torrents: number,
seeding_torrents: number,
total_download_rate: number, // bytes/sec
total_upload_rate: number,
total_peers: number
}
}
inspector:tick
Emitted ~1 Hz only when an inspector focus has been set via POST /api/inspector/focus. The payload’s optional fields (files, peers_list, trackers) are populated according to which tabs the focus call included.
Payload: DetailDTO
{
type: "inspector:tick",
payload: {
id: string, name: string, magnet: string, save_path: string,
total_bytes: number, bytes_done: number, progress: number,
ratio: number, total_down: number, total_up: number,
peers: number, seeds: number,
added_at: number, completed_at?: number,
paused: boolean, completed: boolean, files_missing: boolean,
files?: FileDTO[], // present iff "files" was in the focus tabs
peers_list?: PeerDTO[], // present iff "peers" was in the focus tabs
trackers?: TrackerDTO[] // present iff "trackers" was in the focus tabs
}
}
A focus is single-valued at the Service level — only the most recently set focus is active. Clearing it (POST /api/inspector/clear, or focus with empty id) stops the frames.
update:available
Emitted asynchronously when the background updater finds a newer release. Not periodic — fires once per check that returns available: true. Drives the in-app “update ready” toast.
Payload: UpdateInfoDTO
{
type: "update:available",
payload: {
available: boolean, // always true for this frame
latest_version: string,
asset_url: string,
asset_filename: string,
checked_at: number, // unix seconds
current_version: string
}
}
Throughput and back-pressure
The per-client send channel is buffered to 64 frames. The hub’s broadcast loop drops frames for any client whose buffer is full rather than blocking the producer. There is no replay or sequence number — clients catch up on the next tick.
Practically: a client that hangs on a long render briefly will see a few skipped ticks but reconverge automatically. A client that hangs for many seconds will receive a fresh torrents:tick (a full snapshot) the moment it consumes again, so the visible state is never stale.
Reconnect
Auth is checked at upgrade and re-validated on a recurring interval. Cookie-authed connections are re-checked against the session store periodically; if the session has been revoked (password change, username change, explicit logout, server restart), the server closes the socket with 1008 policy violation and a session revoked reason. Bearer-keyed connections (no cookie) don’t run the periodic re-check — RevokeUser hangs up their socket directly when the underlying user is mutated.
The server will close the connection if:
- The hub is shut down (process exit, or a web-config change that toggles the listener off / restarts it).
- The peer closes (browser tab closed, network drop, server-side
SetReadDeadlinetimeout from the per-client read pump). - The session backing a cookie-authed socket was revoked (password change, etc.).
- The user account backing a bearer-keyed socket was disabled, deleted, or had its role mutated.
Clients should reconnect with backoff. After reconnecting, you’ll receive a fresh torrents:tick immediately on the next producer cycle — no need to call GET /api/torrents separately.
Practical guidance:
- Reconnect on transport errors with exponential backoff capped at ~30 seconds.
- Reconnect explicitly after
POST /api/me/api_key/rotate(or the desktop’sPOST /api/settings/web/api_key/rotate) or after a logout/login on the cookie path — the existing socket will be torn down, but you want your client to come back up cleanly.
Minimal browser client
// The session cookie is auto-attached same-origin; no extra header needed.
function connect() {
const ws = new WebSocket(`wss://${location.host}/api/ws`);
ws.onmessage = (ev) => {
const env = JSON.parse(ev.data);
switch (env.type) {
case 'torrents:tick': onTorrents(env.payload); break;
case 'stats:tick': onStats(env.payload); break;
case 'inspector:tick': onInspector(env.payload); break;
case 'update:available': onUpdate(env.payload); break;
}
};
ws.onclose = () => setTimeout(connect, 2000);
}
See Worked Examples for a longer Python and curl flow.