Basic Usage
Declare the protocol once, create a host and a guest, and exchange the pairing payloads out-of-band. RTCPeerConnection requires a secure context (https: or localhost).
import { createMeshGuest, createMeshHost, meshCodec, type MeshProtocol } from '@vielzeug/mesh';
interface AppProtocol extends MeshProtocol {
toHost: { note: string };
toGuest: { ack: { ok: boolean } };
}
const host = createMeshHost<AppProtocol>();
const guest = createMeshGuest<AppProtocol>();
// Host produces an invitation; the guest turns it into an answer.
const invitationText = meshCodec.encode(await host.createInvitation());
const answerText = meshCodec.encode(await guest.acceptInvitation(meshCodec.decode(invitationText)));
// The host resolves once the data channel is open.
await host.acceptAnswer(meshCodec.decode(answerText));
guest.send('note', 'hello');
host.on('note', (m) => host.send(m.peerId, 'ack', { ok: true }));Pairing Flow
Pairing is a two-leg manual exchange. Each leg is a base64url string produced by meshCodec.encode and consumed by meshCodec.decode.
// Host device: one invitation per prospective guest
const invitation = await host.createInvitation();
const text = meshCodec.encode(invitation);Deliver text any way you can: a textarea the user copies, or navigator.share on mobile:
if (navigator.share) {
await navigator.share({ text, title: 'Join my session' });
}The guest decodes it and returns an answer the same way:
const answer = await guest.acceptInvitation(meshCodec.decode(text), { name: 'Sam' });
const answerText = meshCodec.encode(answer); // back to the host
const peer = await host.acceptAnswer(meshCodec.decode(answerText));Rules that shape the flow:
- An invitation is single-use: call
createInvitation()once per guest. - It expires at
expiresAt(defaultinvitationTtlMs: 300_000). Expired invitations are refused on both sides and cleaned up on the host. - The answer carries a proof derived from the invitation's secret, so a swapped or forged answer is rejected before any connection attempt.
approvePeerruns after proof verification: returnfalseto refuse a guest withMeshPairingErrorand a'peer-rejected'tap.
const host = createMeshHost<AppProtocol>({
approvePeer: (peer) => allowlist.has(peer.id),
});On the guest, the host's peerId is the invitation's sessionId: the invitation carries no other host identity. On the host, a guest's peerId is the id the guest generated for its answer.
Pairing over QR
meshQrCodec is an async codec that compresses pairing payloads (deflate-raw + base45, mq2. prefix) so they fit a QR code comfortably. decode accepts both the compressed mq2.* form and plain meshCodec output: paste fallback and camera scanning work through one code path.
import { meshQrCodec } from '@vielzeug/mesh';
import { encodeQr, toSvg } from '@vielzeug/sigil';
// Async: deflate + base45 through CompressionStream, with a plain-codec
// fallback where the stream API is missing.
const invitationText = await meshQrCodec.encode(await host.createInvitation());
document.querySelector('#qr').innerHTML = toSvg(encodeQr(invitationText));
const answer = await host.acceptAnswer(await meshQrCodec.decode(scannedAnswerText));encode falls back to plain meshCodec output where CompressionStream is unavailable, so it never fails on a missing capability: check for the mq2. prefix if you need to know which form you got. decode accepts both forms interchangeably and throws MeshUnsupportedError only when the text is mq2.-compressed but DecompressionStream is missing. The sigil pairing recipe shows the full two-device flow, and ore-qr-code/ore-qr-scanner (Refine) wrap the rendering and camera sides.
Sending and Receiving
send/on/broadcast are typed from the protocol. Outbound messages serialize through JSON.stringify into a versioned envelope with id, type, timestamp, and payload.
host.send(peerId, 'ack', { ok: true }); // one guest
host.broadcast('ack', { ok: true }, { except: [peerId] }); // all but one
guest.send('note', 'from the guest');Inbound messages arrive as MeshInbound<T>: { peerId, messageId, sentAt, payload }. Rules:
- Payloads over
maxMessageBytes(default 65 536) throwMeshPayloadErroroutbound; oversized inbound frames are dropped and tapped as'message-rejected'with reason'too-large'. - Duplicate message ids (e.g. after a retry) are dropped and tapped as
'duplicate'. - A type with no registered
onhandler is tapped as'unknown-type': never thrown. sendto an unknown or unconnected peer throwsMeshConnectionError.
Peer Lifecycle
Node status moves through idle → pairing → connecting → connected, then disconnected, failed, or disposed. Watch transitions, and everything else: through tap:
host.tap((event) => {
if (event.type === 'status-change') console.log('node:', event.status);
if (event.type === 'peer-status-change') console.log('peer:', event.peerId, event.status);
if (event.type === 'peer-left') console.log('left:', event.peer.id, event.reason);
});peer-joined and peer-left carry the live MeshPeer; the inventory itself lives on peers:
host.tap((event) => {
if (event.type === 'peer-joined') console.log('peers now:', [...host.peers.keys()]);
});
host.kick(peerId, 'bye'); // guest observes 'disconnected'status-change reports node-level transitions; peer-status-change reports a single peer moving between 'connecting' | 'connected' | 'disconnected' | 'failed'. A guest whose host peer failed or disconnected can re-pair on the same node: call acceptInvitation again with a fresh invitation and existing on/tap listeners keep working.
Dispose nodes deterministically: dispose() is idempotent, aborts disposalSignal, emits a final 'dispose' event, and detaches all tappers. Passing an AbortSignal option disposes on abort; using works too.
Timeouts and Limits
| Option | Default | Meaning |
|---|---|---|
invitationTtlMs | 300_000 | How long an unanswered invitation stays valid. |
iceGatheringTimeoutMs | 5_000 | Caps ICE gathering: on timeout pairing proceeds with the candidates gathered so far. |
channelOpenTimeoutMs | 60_000 | How long acceptAnswer and the guest wait for the data channel to open: deliberately generous because it covers the human carry-back of the answer. |
maxMessageBytes | 65_536 | Serialized message cap, enforced in both directions. |
acceptAnswer rejects with MeshTimeoutError when the channel never opens; the guest independently marks itself 'failed' and taps an 'error' event. Inject clock to drive TTLs deterministically in tests.
Security Model
Mesh reduces the manual-pairing attack surface but does not remove it:
- Every invitation carries a random 256-bit secret; the answer must present a proof derived from it (HMAC-SHA-256 via
SubtleCrypto). Pairing requires a secure context: withoutSubtleCryptoboth ends throwMeshUnsupportedErrorrather than fall back to a forgeable hash. - Invitations are single-use and time-boxed, so a leaked invite is only useful briefly.
approvePeeris the host's admission hook: use it for a name check, a shared passphrase confirmed out-of-band, or an allowlist.maxMessageBytescaps every inbound frame; malformed frames are dropped, never dispatched.- Roles are app-level. A guest can send any declared
toHosttype with any payload: validate guest state on the host as if it came from a form. Never trustpeer.nameas an identity; it is self-declared.
Testing
Every WebRTC object comes from the injectable rtc factory: tests pair nodes fully in memory, with no browser. @vielzeug/mesh/testing ships the in-memory fake the package's own tests use: two peer connections wired through token SDP, with knobs for latency, drops, ICE failure, stalled gathering, and a never-opening channel.
import { createFakeRtc } from '@vielzeug/mesh/testing';
const fx = createFakeRtc();
const host = createMeshHost<AppProtocol>({ rtc: fx.rtc, clock: () => now });
const guest = createMeshGuest<AppProtocol>({ rtc: fx.rtc });The REPL examples include a runnable in-memory pairing you can copy as a starting point.
Framework Integration
A node is a plain object: own it in a composable/store and dispose on teardown.
import { useEffect, useRef } from 'react';
import { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';
export function useMeshHost<P extends MeshProtocol>() {
const ref = useRef<ReturnType<typeof createMeshHost<P>>>(null);
if (!ref.current) ref.current = createMeshHost<P>();
useEffect(() => () => ref.current?.dispose(), []);
return ref.current;
}import { onUnmounted } from 'vue';
import { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';
export function useMeshHost<P extends MeshProtocol>() {
const host = createMeshHost<P>();
onUnmounted(() => host.dispose());
return host;
}import { onDestroy } from 'svelte';
import { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';
export function createHost<P extends MeshProtocol>() {
const host = createMeshHost<P>();
onDestroy(() => host.dispose());
return host;
}Working with Other Vielzeug Libraries
Mesh builds on @vielzeug/arsenal for random ids, secrets, and base64url encoding: no extra wiring needed. Pair it with herald when you want mesh messages to feed an in-process event bus:
import { createBus } from '@vielzeug/herald';
const bus = createBus<{ 'guest-note': string }>();
host.on('note', (m) => bus.emit('guest-note', m.payload));Best Practices
- Pair over
meshCodecstrings, never raw SDP: the transport restores the CRLF line endings that copy/paste mangles. - Create one invitation per guest; never reuse or forward an invitation.
- Keep
invitationTtlMsshort for physical-proximity pairing and always gate unknown guests throughapprovePeer. - Subscribe
onhandlers beforeacceptAnswerresolves so early messages are not tapped as'unknown-type'. - Validate every guest payload on the host: treat
toHostmessages as untrusted input. dispose()both nodes on teardown; passsignalto bind disposal to a controller you already own.- Inject
rtcandclockin tests instead of mocking globals. - Plan re-pairing, not reconnection: after a reload, create a fresh invitation and let the app re-associate the stable
peerId.