Skip to content

@caprid/relay-quic

The Relay SDK’s transport adapter for Node. It carries MOQT straight on a QUIC connection (native QUIC), offering the ALPNs moqt-22, then moqt-20. A napi-rs addon over quinn does the QUIC work, so a plain Node script can publish and subscribe without a browser. The adapter follows the transport adapter contract in spec/transport-adapter.md and passes its conformance suite. The design is ADR 0002.

Install it next to @caprid/relay. Most scripts need no code from this package: @caprid/relay/node uses it by default and takes the trust options below (see the SDK README).

To use it with @caprid/relay directly, pass the factory as createTransport. The URL is the same https:// endpoint the browser uses. The adapter dials only its host and port, and the core sends the path, the token, and the authority in SETUP.

import { connectRelayPublisher } from '@caprid/relay';
import { nativeQuicTransport } from '@caprid/relay-quic';
const publisher = await connectRelayPublisher({
url: 'https://relay.mux.global',
namespace: ['acme', 'rooms', '42'],
getToken: () => process.env.RELAY_TOKEN,
createTransport: nativeQuicTransport(),
});

nativeQuicTransport() throws at once if the addon is missing for the platform or an option is invalid. Build it once and reuse it for every connection.

By default a dial trusts the platform’s root certificates.

  • ca: extra trust anchors, PEM, as a string, bytes, or an array of either. They are added to the platform’s roots.
  • serverCertificateHashes: [{ algorithm: 'sha-256', value }], in the W3C WebTransport shape. The server’s end-entity certificate must match one hash, and the adapter does not validate its chain. It still checks the handshake signature. Unlike a browser, the adapter does not require the certificate to be valid for 14 days or less.

anticipatedConcurrentIncomingUnidirectionalStreams is how many unidirectional streams the relay may have open to the connection at once: the MAX_STREAMS credit this end grants (RFC 9000 §4.6). It is an integer from 1 to 65535, and the default is 1000. quinn’s own default is 100. The relay sends each group on a stream of its own, so a voice track at 50 frames a second takes 50 streams a second, and the credit for each closed stream returns over your uplink. In relay-dev’s voice benchmark (finding P-06), a subscriber whose own upload queued its uplink for about 2 s ran out of credit at 100: locally, the median arrival delay was 2865 ms, and 21.5 ms with more credit. 1000 covers a 20 s stall of the uplink on one such track.

quinn sets aside an entry per allowed stream up front, so a higher value costs memory on every connection. Unlike the W3C WebTransport option of the same name, which is a hint that the browser may round up, this is the exact limit. A value that is not an integer from 1 to 65535 throws a RangeError at once, before the addon loads. Bidirectional streams keep quinn’s default of 100, because the relay opens one per request, not per object. @caprid/relay/node uses the default and takes no such option. To change it, build the factory yourself:

import { connectRelaySubscriber } from '@caprid/relay';
import { nativeQuicTransport } from '@caprid/relay-quic';
const subscriber = await connectRelaySubscriber({
url: 'https://relay.mux.global',
getToken: () => process.env.RELAY_TOKEN,
createTransport: nativeQuicTransport({ anticipatedConcurrentIncomingUnidirectionalStreams: 5000 }),
});

nativeQuicTransport({ qlogDir }) writes a qlog trace of every connection into that directory. Without the option, the RELAY_QUIC_QLOG_DIR environment variable is read when the factory is made; an empty string turns qlog off. It is off by default. The directory is created if it is missing, and a failure to create it throws at once. Each connection attempt gets one file, relay-quic-<unix ms>-<pid>-<n>.sqlog: JSON text sequences, written by quinn’s qlog feature. A trace holds each packet’s number and type as it is sent and received, the size of each packet sent, the packets declared lost, and the RTT, congestion window and bytes in flight as they change. quinn 0.11 logs no frames, so a trace holds no stream data and no token. A file that cannot be created is skipped, and the dial goes on. @caprid/relay/node takes no such option, but reads the environment variable, so RELAY_QUIC_QLOG_DIR=/tmp/qlog node script.mjs traces a script without changing it.

The feature has a cost in size. The linux-x64-gnu release addon grew from 2,766,896 to 2,912,848 bytes (+145,952 B, +5.3%), and gzip -9 of it from 1,306,675 to 1,372,739 bytes (+66,064 B), built locally with Rust 1.97.1 from main and from this change. The sizes in the table under “Prebuilt binaries” are from before it.

An open session keeps the Node process alive, from the start of the dial until closed settles. After that the adapter holds nothing, so a script exits once it has closed its connections. Closing before the dial completes cancels the dial and releases its socket.

Each platform’s binary ships in its own package, which @caprid/relay-quic lists as an optional dependency, as napi-rs packages do. A package manager installs only the one whose os, cpu, and libc match, so you need no Rust.

Platform package Built on Packed Installed
@caprid/relay-quic-darwin-arm64 macos-15 1.14 MiB 2.62 MiB
@caprid/relay-quic-darwin-x64 macos-15, cross 1.21 MiB 2.85 MiB
@caprid/relay-quic-linux-arm64-gnu ubuntu-latest, zig 1.19 MiB 2.67 MiB
@caprid/relay-quic-linux-arm64-musl ubuntu-latest, zig 1.19 MiB 2.67 MiB
@caprid/relay-quic-linux-x64-gnu ubuntu-latest, zig 1.26 MiB 3.01 MiB
@caprid/relay-quic-linux-x64-musl ubuntu-latest, zig 1.26 MiB 3.00 MiB
@caprid/relay-quic-win32-x64-msvc windows-latest 1.26 MiB 3.33 MiB

Installed is what an install puts on disk on that platform: this package, @caprid/moq-core and the one binary. The sizes are from the 0.0.0 packages CI packed on 2026-10-01. The glibc builds need glibc 2.28 or later, which Node 24 needs too.

src/addon.ts loads a local build, native/relay-quic.<platform>.node, first, so a build from source always wins. Without one it loads @caprid/relay-quic-<platform>. If neither is there, the error names both. A platform package that is installed but cannot load, such as one built for the wrong libc, throws its own error. The loader does not skip it.

The relay-quic binaries workflow (.github/workflows/relay-quic-binaries.yml) runs on each PR that touches the crate, the loader or the packaging. It builds every target, and scripts/pack.mjs packs the main package, the platform packages and @caprid/moq-core, with the sizes in the job summary. scripts/smoke.mjs then installs those tarballs with npm on each platform (musl in Alpine, arm64 Linux under QEMU, darwin-x64 under Rosetta). It checks that npm picked only the matching binary, that the loader finds it, and that a dial sends a QUIC Initial and can be cancelled. The Release TypeScript SDK workflow (release-ts.yml) calls this one and publishes the tarballs it packs, with the rest of the TypeScript packages (releasing).

The platform packages live in npm/<platform>/. napi create-npm-dirs generates them from napi.targets in package.json. scripts/pack.mjs adds the optional dependencies on them only when it packs, so the lockfile never names them. pnpm check:boundaries, run from ts/, checks that npm/ and napi.targets agree.

Before 1.0, a minor version can break the API. This package and its platform packages release together with @caprid/relay at the same version; see the changelog.

To work on the crate, build the addon locally with Rust. The mise tasks bring the toolchain that mise.toml pins. Nothing else in the repo installs one.

mise run ts:test:conformance:node # builds the addon, then runs the conformance suite against it
mise run ts:check:native # cargo fmt --check, clippy and the unit tests
mise run ts:build:native -- --target aarch64-unknown-linux-musl -x # cross-compiles with zig, as CI does

With a Rust toolchain of your own, cd ts && pnpm --filter @caprid/relay-quic build:native writes native/relay-quic.<platform>.node. Cross-compiling with -x also needs cargo-zigbuild and the target’s rustup target add.

build:native also regenerates src/native.d.ts, the addon’s typings, from the Rust. Commit it with any change to the crate. CI checks that it matches.

pnpm check, run from ts/, builds and checks the TypeScript only, so the rest of the workspace needs no Rust.

Path What it is
src/index.ts nativeQuicTransport and its option and result types.
src/transport.ts The MoqtTransport: the dial, ready, protocol, closed, close() and the incoming streams.
src/streams.ts WHATWG streams over the addon’s stream halves.
src/errors.ts The addon’s failures as the contract’s error shapes (TA-22).
src/addon.ts Finds and loads the addon for this platform: a local build, else the platform package.
native/ The Rust crate: trust (tls.rs), stream halves (stream.rs), failures as data (failure.rs), the dial, qlog and the session (lib.rs).
npm/<platform>/ The platform packages, one per napi.targets entry; CI puts each binary in its own.
scripts/pack.mjs, scripts/smoke.mjs Pack the release tarballs with their sizes; install and smoke-load them.