@caprid/relay-websocket
The Relay SDK’s transport adapter for QMux over WebSocket. It carries MOQT on
QMux, which runs QUIC’s streams over a reliable byte stream
(wire format qmux-01), inside one WebSocket connection. moq-relay serves it on TCP 443
(draft-lcurley-qmux-websocket). The
adapter reaches the relay where WebTransport cannot: on networks that block UDP, and in browsers whose WebTransport
cannot negotiate protocols. It works in browsers and in Node 24.
It follows the transport adapter contract in spec/transport-adapter.md, with
the deviations recorded there, and passes its conformance suite in Node, Chromium, Firefox and WebKit. QMux comes from
@moq/qmux, by the author of moq-relay’s own QMux, at an exact version;
ADR 0005 records why.
Pass one of its factories as the SDK’s createTransport. Build it once and reuse it for every connection.
import { connectRelaySubscriber } from '@caprid/relay';import { createWebSocketTransport, withWebSocketFallback } from '@caprid/relay-websocket';
// WebTransport first; WebSocket races it once WebTransport has had 250 ms.const subscriber = await connectRelaySubscriber({ url, getToken, createTransport: withWebSocketFallback() });
// Always WebSocket.const other = await connectRelaySubscriber({ url, getToken, createTransport: createWebSocketTransport() });createWebSocketTransport()dials the endpoint’swss:URL and offersqmux-01.moqt-22, thenqmux-01.moqt-20.https:becomeswss:with the path and query unchanged, so the token travels in the upgrade request. The factory declaresframing: 'websocket'.withWebSocketFallback({ webSocketDelayMs, onFallback })dials the runtime’sWebTransportfirst. If that handshake has not completed withinwebSocketDelayMs(default 250, as Happy Eyeballs), it dials WebSocket alongside it; a WebTransport handshake that fails sooner starts the WebSocket at once. The first ready carries the session and the other dial is closed. Where the runtime has no usableWebTransport, as in Node or a Safari without it, it dials WebSocket at once. The adapter callsonFallback(reason)each time a connection ends up on WebSocket, and every connection decides afresh. Afterready, the factory’sframingand the transport’scarriersay which transport won.
What differs from WebTransport
Section titled “What differs from WebTransport”- Every stream shares one TCP connection, so a lost packet holds up every stream behind it (head-of-line blocking).
- No datagrams.
- A relay that refuses the token answers the WebSocket upgrade with HTTP 401, which no WebSocket API exposes, so the
SDK reports
TRANSPORT_ERRORin phaseconnecting, notUNAUTHORIZED. - A send stream’s close resolves once the FIN is handed to the socket, not once the relay has it.
The relay’s side, with measurements, is in QMux over WebSocket.
The adapter uses the runtime’s WebSocket and its certificate checks. A browser offers no way to pin a WebSocket’s
certificate. Node trusts the platform’s roots, plus any in NODE_EXTRA_CA_CERTS, which Node reads only at start-up.
Run these from ts/ in the repository.
pnpm testruns the unit tests insrc/tests/against an in-process accepting end over plainws://.pnpm test:conformance:websocketruns the conformance suite in Node againstscripts/websocket-peer.js, the accepting end for QMux over WebSocket.pnpm test:conformance:websocket:browsersruns the suite in Chromium, Firefox, and WebKit. SetBROWSERS=chromiumto run one.RELAY_RUNTIME=node-websocket,chromium-websocket pnpm test:relayruns the live scenario over WebSocket.
Stability
Section titled “Stability”Before 1.0, a minor version can break the API. This package releases together with @caprid/relay at the same version;
see the changelog.