Skip to content

API Overview ​

SymbolPurposeExecutionCommon gotcha
createMeshHostHost-authoritative session node; pairs one RTCPeerConnection per guestSynccreateInvitation/acceptAnswer are async; calls after dispose() throw MeshDisposedError
createMeshGuestGuest node that pairs with one hostSyncSecond acceptInvitation throws MeshPairingError
meshCodecbase64url encode/decode for pairing payloadsSyncdecode throws MeshPairingError on malformed text or wrong v
meshQrCodecdeflate-raw + base45 codec for QR-sized payloadsAsyncFalls back to plain meshCodec output where CompressionStream is missing
MeshProtocolDeclares the toHost/toGuest message mapsN/AExtend it; the maps themselves stay plain records
MeshErrorBase class for all mesh errorsN/Ainstanceof MeshError catches every mesh-originated error

Package Entry Point ​

ImportPurpose
@vielzeug/meshComplete public Mesh API

Factory Functions ​

createMeshHost() ​

ts
function createMeshHost<P extends MeshProtocol>(options?: MeshHostOptions): MeshHost<P>;

Returns a host node in status 'idle'. Creating it never touches WebRTC: the first createInvitation() does.

Options (MeshHostOptions) ​

OptionTypeDefaultPurpose
iceServersreadonly RTCIceServer[][]Passed to the peer connection; mesh ships no STUN/TURN.
channelMeshChannelOptions{ ordered: true, label: 'mesh' }Data-channel init mapped to RTCDataChannelInit.
maxMessageBytesnumber65_536Serialized message cap, both directions.
invitationTtlMsnumber300_000Invitation validity window.
iceGatheringTimeoutMsnumber5_000ICE gathering cap; pairing proceeds with gathered candidates on timeout.
channelOpenTimeoutMsnumber60_000Channel-open wait in acceptAnswer and on the guest: generous to cover the human carry-back of the answer.
rtcMeshRtcFactoryglobalThis.RTCPeerConnectionInjection point for tests and non-browser runtimes.
clock() => numberDate.nowTTLs and message timestamps.
randomRandomSourcecrypto.getRandomValuesIds and secrets.
signalAbortSignalN/ADisposes the node on abort.
approvePeer(peer: MeshPeerInfo) => boolean | Promise<boolean>approveRuns after proof verification; false rejects with MeshPairingError.

Example ​

ts
import { createMeshHost, meshCodec } from '@vielzeug/mesh';

const host = createMeshHost<AppProtocol>({ approvePeer: (peer) => peer.name !== undefined });
const invitation = await host.createInvitation();

createMeshGuest() ​

ts
function createMeshGuest<P extends MeshProtocol>(options?: MeshGuestOptions): MeshGuest<P>;

Returns a guest node in status 'idle'. MeshGuestOptions is MeshOptions: the shared option table above without approvePeer.

Example ​

ts
import { createMeshGuest, meshCodec } from '@vielzeug/mesh';

const guest = createMeshGuest<AppProtocol>();
const answer = await guest.acceptInvitation(meshCodec.decode(invitationText), { name: 'Sam' });

Host Surface ​

MeshHost<P> extends MeshNode.

MemberSignaturePurpose
createInvitation(meta?: { name?: string }) => Promise<MeshInvitation>Single-use invitation for one guest; repeatable: one per guest. meta.name becomes this host's peer name on the guest side.
acceptAnswer(answer: MeshAnswer) => Promise<MeshPeer>Verifies proof + approval, then resolves once the channel opens. Rejects MeshPairingError (unknown/expired/duplicate session, duplicate peer id, proof mismatch, refusal) or MeshTimeoutError.
peersReadonlyMap<string, MeshPeer>Live peer inventory keyed by peer id.
send<K extends keyof P['toGuest'] & string>(peerId: string, type: K, payload: P['toGuest'][K]) => voidTyped unicast; throws MeshConnectionError for unknown/unconnected peers, MeshPayloadError over the cap.
broadcast<K extends keyof P['toGuest'] & string>(type: K, payload: P['toGuest'][K], options?: { except?: readonly string[] }) => voidSends to every connected peer not in except.
on<K extends keyof P['toHost'] & string>(type: K, handler: (message: MeshInbound<P['toHost'][K]>) => void) => UnsubscribeTyped inbound subscription from any guest.
kick(peerId: string, reason?: string) => voidDisconnects a peer; the guest observes 'disconnected'.

Guest Surface ​

MeshGuest<P> extends MeshNode.

MemberSignaturePurpose
acceptInvitation(invitation: MeshInvitation, meta?: { name?: string }) => Promise<MeshAnswer>Consumes an invitation; the returned answer goes back to the host out-of-band. Throws MeshPairingError on expired/malformed input or while a live host peer exists; after the host peer fails or disconnects, a fresh invitation re-pairs on the same node with listeners intact.
hostMeshPeer | nullThe host peer once pairing started; its id is the invitation's sessionId.
send<K extends keyof P['toHost'] & string>(type: K, payload: P['toHost'][K]) => voidTyped send to the host; throws MeshConnectionError when unpaired.
on<K extends keyof P['toGuest'] & string>(type: K, handler: (message: MeshInbound<P['toGuest'][K]>) => void) => UnsubscribeTyped inbound subscription from the host.

Shared Surface ​

MeshNode members common to host and guest.

MemberSignaturePurpose
statusMeshStatusCurrent lifecycle state.
disposedbooleanWhether the node is permanently disposed.
disposalSignalAbortSignalAborted by dispose().
dispose() => voidIdempotent teardown: closes every connection, aborts disposalSignal, emits 'dispose', detaches tappers.
[Symbol.dispose]() => voidEnables using declarations.
tap(handler: (event: MeshEvent) => void, options?: { signal?: AbortSignal }) => UnsubscribeObservability stream; handler errors are swallowed.

Codec ​

ts
const meshCodec: {
  encode(payload: MeshInvitation | MeshAnswer): string;
  decode(text: string): MeshInvitation | MeshAnswer;
};

Compact JSON → base64url for pairing payloads, kept pure so other encodings can slot in later. decode validates shape and v, throwing MeshPairingError on malformed text, wrong version, or unrecognized payload.

ts
const text = meshCodec.encode(await host.createInvitation());
const answer = await guest.acceptInvitation(meshCodec.decode(text));
await host.acceptAnswer(meshCodec.decode(meshCodec.encode(answer)));

meshQrCodec ​

ts
const meshQrCodec: {
  encode(payload: MeshInvitation | MeshAnswer): Promise<string>;
  decode(text: string): Promise<MeshInvitation | MeshAnswer>;
};

QR-oriented variant: deflate-raw compresses the JSON payload, then base45-encodes it with an mq2. prefix. encode falls back to plain meshCodec output where CompressionStream is unavailable, so it never fails on a missing capability. decode accepts both mq2.* and plain meshCodec output, so camera scans and paste fallbacks share one path.

ts
const invitationText = await meshQrCodec.encode(await host.createInvitation()); // "mq2.…"
const answer = await host.acceptAnswer(await meshQrCodec.decode(scannedText));

encode never throws for a missing capability: it falls back to plain meshCodec output where CompressionStream is unavailable. decode throws MeshPairingError on corrupt base45/deflate/JSON and MeshUnsupportedError only when the text is mq2.-compressed but DecompressionStream is missing.

Types ​

Protocol ​

ts
interface MeshProtocol {
  readonly toHost: MeshMessageMap;  // messages a guest may send
  readonly toGuest: MeshMessageMap; // messages the host may send
}

type MeshMessageMap = Record<string, unknown>;

Pairing payloads ​

ts
interface MeshInvitation {
  readonly v: 1;
  readonly sessionId: string;  // 128-bit, base64url
  readonly secret: string;     // 256-bit, base64url
  readonly expiresAt: number;  // epoch ms
  readonly hostName?: string;  // host display name for the guest's peer list
  readonly sdp: string;        // host offer, candidates inlined
}

interface MeshAnswer {
  readonly v: 1;
  readonly sessionId: string;
  readonly proof: string;      // HMAC-SHA-256 of the answer SDP keyed by secret
  readonly peer: { readonly id: string; readonly name?: string };
  readonly sdp: string;
}

Peers, status, messages ​

ts
type MeshStatus = 'idle' | 'pairing' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'disposed';

type MeshPeerStatus = 'connecting' | 'connected' | 'disconnected' | 'failed';

interface MeshPeerInfo {
  readonly id: string;
  readonly name?: string;
}

interface MeshPeer extends MeshPeerInfo {
  readonly status: MeshPeerStatus;
  readonly role: 'host' | 'guest';
}

interface MeshInbound<T> {
  readonly peerId: string;
  readonly messageId: string;
  readonly sentAt: number;
  readonly payload: T;
}

type Unsubscribe = () => void;

Events ​

ts
type MeshEvent =
  | { readonly type: 'status-change'; readonly status: MeshStatus }
  | { readonly type: 'peer-status-change'; readonly peerId: string; readonly status: MeshPeerStatus }
  | { readonly type: 'invitation-created' | 'invitation-expired'; readonly sessionId: string }
  | { readonly type: 'peer-approved' | 'peer-rejected'; readonly peerId: string }
  | { readonly type: 'peer-joined'; readonly peer: MeshPeer }
  | { readonly type: 'peer-left'; readonly peer: MeshPeer; readonly reason?: string }
  | { readonly type: 'message-sent' | 'message-received'; readonly peerId: string; readonly messageType: string; readonly bytes: number }
  | { readonly type: 'message-rejected'; readonly peerId: string; readonly reason: 'too-large' | 'duplicate' | 'malformed' | 'unknown-type' }
  | { readonly type: 'ice-state'; readonly peerId: string; readonly state: string }
  | { readonly type: 'error'; readonly error: MeshError }
  | { readonly type: 'dispose' };

status-change reports node-level transitions; peer-status-change reports one peer's transition and always names a peer id that exists in peers (or guest.host).

Options ​

ts
interface MeshChannelOptions {
  readonly ordered?: boolean;
  readonly maxRetransmits?: number;
  readonly label?: string;
}

interface MeshOptions {
  readonly iceServers?: readonly RTCIceServer[];
  readonly channel?: MeshChannelOptions;
  readonly maxMessageBytes?: number;
  readonly invitationTtlMs?: number;
  readonly iceGatheringTimeoutMs?: number;
  readonly rtc?: MeshRtcFactory;
  readonly clock?: () => number;
  readonly random?: RandomSource;
  readonly signal?: AbortSignal;
}

interface MeshHostOptions extends MeshOptions {
  readonly approvePeer?: (peer: MeshPeerInfo) => boolean | Promise<boolean>;
}

type MeshGuestOptions = MeshOptions;

WebRTC injection ​

Minimal structural subsets: the surface mesh actually calls, no more. Implement them over wrtc or a test double to run outside a browser.

ts
interface MeshRtcFactory {
  createPeerConnection(config: RTCConfiguration): RTCPeerConnectionLike;
}

interface RTCPeerConnectionLike {
  readonly connectionState: string;
  readonly iceConnectionState: string;
  readonly iceGatheringState: string;
  readonly localDescription: RTCSessionDescriptionLike | null;
  createDataChannel(label: string, init?: { ordered?: boolean; maxRetransmits?: number }): RTCDataChannelLike;
  createOffer(): Promise<RTCSessionDescriptionLike>;
  createAnswer(): Promise<RTCSessionDescriptionLike>;
  setLocalDescription(description: RTCSessionDescriptionLike): Promise<void>;
  setRemoteDescription(description: RTCSessionDescriptionLike): Promise<void>;
  close(): void;
  addEventListener(type: 'datachannel', listener: (event: { readonly channel: RTCDataChannelLike }) => void): void;
  addEventListener(type: 'icegatheringstatechange' | 'iceconnectionstatechange' | 'connectionstatechange', listener: () => void): void;
  addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;
  removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;
}

interface RTCDataChannelLike {
  readonly readyState: string;
  send(data: string): void;
  close(): void;
  addEventListener(type: 'open' | 'close' | 'error', listener: () => void): void;
  addEventListener(type: 'message', listener: (event: { readonly data: string }) => void): void;
  addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;
  removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;
}

interface RTCSessionDescriptionLike {
  readonly type?: 'offer' | 'answer' | 'pranswer' | 'rollback';
  readonly sdp?: string;
}

interface MeshRtcEvent {
  readonly data?: unknown;
  readonly channel?: RTCDataChannelLike;
}

Errors ​

ClassThrown whenNotable properties
MeshErrorBase class: never thrown directlyinstanceof MeshError catches all mesh errors
MeshPairingErrorMalformed/expired/unknown invitation or answer, proof mismatch, duplicate answer, approvePeer refusal, codec decode failureN/A
MeshConnectionErrorICE/DTLS failure, channel closed, send to unknown/unconnected peer, offer/answer creation failurepeerId: string | null
MeshPayloadErrorOutbound message over maxMessageBytes or unserializableN/A
MeshTimeoutErrorChannel-open wait exceededN/A
MeshDisposedErrorAny method called after dispose()N/A
MeshUnsupportedErrorNo RTCPeerConnection in the environment and no rtc injected (raised at first use, never at import), or meshQrCodec.decode given mq2. text without DecompressionStreamN/A