Package:
@kyneta/webrtc-transportRole: WebRTC data-channel transport for@kyneta/exchange— attaches to application-owned data channels and runs the same alias-aware binary pipeline as the WebSocket transport over them. "Bring Your Own Data Channel" (BYODC). Depends on:@kyneta/transport(peer) Depended on by:@kyneta/exchange(through application configuration) Canonical symbols:WebrtcTransport,createWebrtcTransport,WebrtcTransportOptions,DataChannelLike,DEFAULT_FRAGMENT_THRESHOLDKey invariant(s): The transport never creates, negotiates, or closes a data channel. It only attaches to channels the application provides viaattachDataChannel(remotePeerId, channel)and detaches viadetachDataChannel(remotePeerId). Closing the data channel, tearing downRTCPeerConnection, handling ICE, and running signalling are all the application's job.
A tiny WebRTC transport kit. It accepts any object satisfying a five-member DataChannelLike interface, runs ChannelMsg through the same binary Pipeline (from @kyneta/transport) used by the WebSocket transport, and stays completely out of the signalling layer. Native RTCDataChannel satisfies DataChannelLike structurally with zero wrapping; simple-peer and other libraries conform through ~20 lines of bridge code.
Imported by applications that have already established a peer-to-peer connection via their own signalling infrastructure and now want document sync to flow over the existing data channel.
- What does "BYODC" mean in practice? → The ownership boundary
- Why is
DataChannelLikeso minimal? →DataChannelLike— the five-member contract - How do I attach a
simple-peerconnection? → Bridging non-spec libraries - Why 200 KB fragmentation threshold and not 100 KB like WebSocket? → Fragmentation threshold
- What happens to pending sends while a channel is in
"connecting"? → Readiness gating - How does this transport connect to the six-message protocol? → Wire pipeline
| Term | Means | Not to be confused with |
|---|---|---|
| BYODC | "Bring Your Own Data Channel" — the application owns the RTCPeerConnection and data-channel lifecycle; this transport only attaches to established channels. |
A framework that manages WebRTC end-to-end |
DataChannelLike |
Structural interface with readyState, binaryType, send, addEventListener, removeEventListener. Native RTCDataChannel satisfies it with no wrapping. |
RTCDataChannel specifically — any object with the right shape qualifies |
WebrtcTransport |
The concrete Transport<...> subclass. One instance manages many attached data channels, one per remote peer. |
A single data channel |
attachDataChannel(remotePeerId, channel) |
Tell the transport: "this data channel carries sync traffic for this peer." Wires four event listeners and registers a ConnectedChannel. |
Creating the data channel — the transport never creates one |
detachDataChannel(remotePeerId) |
Remove the sync channel. Unsubscribes listeners. Does not close the data channel or peer connection. | Closing the peer — the application still owns it |
DEFAULT_FRAGMENT_THRESHOLD |
200 * 1024 bytes — fragmentation kicks in above this. |
The WebSocket 100 KB threshold (different constraint) |
Thesis: let the application own every WebRTC concern it already has to own, and attach a sync pipeline to the data channel once it exists.
A WebRTC application already has:
- Signalling (offer / answer / ICE candidates over some side channel)
RTCPeerConnectionlifecycle- Possibly media tracks, stats, reconnection policy, TURN configuration
None of that has anything to do with document sync, and none of it is generalizable — every application signals differently. So this transport does zero of it. It expects the application to hand it a data channel that is already (or about to be) open, and runs sync traffic through it.
| Responsibility | Owner |
|---|---|
Create RTCPeerConnection |
Application |
| Run signalling (offer / answer / ICE) | Application |
Create data channel (pc.createDataChannel(...) or remote ondatachannel) |
Application |
Configure ordered, maxRetransmits, negotiated, etc. |
Application |
| Close data channel | Application |
| Close peer connection | Application |
| Attach sync traffic to the data channel | This transport |
| CBOR encode / decode | This transport (via Pipeline from @kyneta/transport) |
| Fragment / reassemble | This transport (via Pipeline from @kyneta/transport) |
| Apply alias transformer | This transport (via Pipeline from @kyneta/transport) |
| Run the six-message protocol | @kyneta/exchange (through this transport) |
detachDataChannel(remotePeerId) is explicit about its boundary: it removes the four event listeners, drops the reassembler, and removes the ConnectedChannel from the exchange — but it does not call channel.close(). The application may keep using the data channel for other purposes, or may close it later as part of its own teardown.
- Not a WebRTC library. It neither imports nor produces
RTCPeerConnection, ICE candidates, or SDP. It is the thinnest possible layer above a data channel. - Not a signalling transport. It does not exchange offer/answer messages. Those are application traffic, not sync traffic.
- Not a lifecycle manager. Reconnection, ICE restart, relay selection, and bandwidth estimation are all the application's problem. If a data channel closes, the transport simply detaches; re-establishing is an application concern.
- Not tied to
simple-peer,peerjs, or any specific library.DataChannelLikeis the contract. Any library that can produce something matching it works.
Source: packages/exchange/transports/webrtc/src/data-channel-like.ts.
interface DataChannelLike {
readonly readyState: string
binaryType: string
send(data: Uint8Array): void
addEventListener(type: string, listener: (event: any) => void): void
removeEventListener(type: string, listener: (event: any) => void): void
}
Five members. No DOM types imported. The event: any in the listener signature is deliberate — typing it as MessageEvent or Event would force DOM lib on every consumer and would not help a simple-peer bridge anyway (its events are emitted by an EventEmitter and have a different shape).
The transport uses exactly these members, nothing more. Narrower is better: the fewer members the contract demands, the easier it is to bridge non-spec libraries.
The transport addEventListeners for exactly four event types:
| Event | What the transport does |
|---|---|
"open" |
Mark the channel as sendable; flush any queued sends |
"close" |
Detach; emit disconnect |
"error" |
Log and treat like close |
"message" |
Read event.data (accepts both ArrayBuffer and Uint8Array), feed to the reassembler, decode, dispatch |
No other events are consumed. Libraries bridging to DataChannelLike need only route these four.
- Not
RTCDataChannel. It is a structural subset. A native channel satisfies it — the reverse is not true. - Not a class or abstract base. No
extends, noinstanceof. If the shape matches, the transport accepts it. - Not typed against
MessageEvent.event: anyis intentional; the transport only readsevent.dataat runtime.
A simple-peer bridge is ~20 lines: map "open" / "close" / "error" / "data" EventEmitter events into addEventListener-style callbacks and expose readyState. The transport's own simple-peer-bridge.test.ts exercises this pattern directly.
Source: packages/exchange/transports/webrtc/src/webrtc-transport.ts. Uses new Pipeline({ send: "binary" }) — identical to the WebSocket transport, differing only in the threshold constant.
Outbound:
ChannelMsg
└─ pipeline.send(msg) → Result<Uint8Array, WireError>[]
└─ channel.send(piece) per ok piece
Inbound:
event.data (ArrayBuffer | Uint8Array)
└─ pipeline.receive(piece) → Result<ChannelMsg, WireError>[]
└─ onChannelReceive(channelId, msg) per ok result
One Pipeline per attached channel — fragments from different peers cannot interleave because each peer has its own channel and its own pipeline (with its own reassembler).
When an attach happens before the data channel is "open" (common — applications often attach immediately after pc.createDataChannel), the transport:
- Registers the
ConnectedChannelwith the exchange. - Starts listening for
"open","close","error","message". - Sends from the exchange are routed through the channel's
sendmethod. - The spec:
channel.send()called in"connecting"state throws; the transport does not queue — it relies on the exchange's send-after-establish ordering to only send once the handshake completes, which itself only fires after"open".
Once "open" fires, the transport sends establish (kicking off the exchange handshake). The exchange then transitions the channel to established.
DEFAULT_FRAGMENT_THRESHOLD = 200 * 1024 (200 KB).
The WebRTC data-channel underlying transport is SCTP, which negotiates a maximum message size (typically ~256 KB across major implementations). 200 KB provides a safety margin. Some implementations support larger messages; applications that know their peer supports more can raise the threshold via WebrtcTransportOptions.fragmentThreshold, or disable fragmentation with 0 (not recommended unless message sizes are known-small).
This differs from the WebSocket transport's 100 KB default, which targets AWS API Gateway's 128 KB cap — an application-layer constraint unrelated to the underlying network. WebRTC has no gateway.
- Not an SCTP feature. SCTP does its own fragmentation below our layer. Our fragmentation exists because SCTP rejects (not splits) messages above its negotiated max-size at the application API level.
- Not ordered across fragment IDs. As in the wire format generally, fragments of different frame IDs may interleave; fragments of the same ID must all arrive.
application code WebrtcTransport
───────────────── ────────────────
pc.createDataChannel("kyneta") ──►
transport.attachDataChannel( ──► register listeners, create
remotePeerId, channel) ConnectedChannel, reassembler
│
◄──────── channel.addEventListener("open", ...)
channel becomes "open" ──────► onOpen → send establish
exchange handshake completes ◄──► channel upgraded to Established
...sync traffic flows...
channel "close" event ──────► onClose → detach
-OR-
transport.detachDataChannel( ──► removeEventListener × 4,
remotePeerId) drop reassembler, remove
ConnectedChannel
application may close the
data channel or keep it
for its own purposes
Detaching from a remote peer that was never attached is a no-op. Attaching twice to the same remotePeerId detaches the previous channel first.
import { createWebrtcTransport } from "@kyneta/webrtc-transport"
// Somewhere during app startup:
const webrtc = createWebrtcTransport()
const exchange = new Exchange({
peerId: myPeerId,
transports: [webrtc],
})
// After signalling has completed and the data channel exists:
pc.addEventListener("datachannel", (e) => {
webrtc.attachDataChannel(remotePeerId, e.channel)
})
// When the application decides this peer is done:
webrtc.detachDataChannel(remotePeerId)
Options:
createWebrtcTransport({
fragmentThreshold: 128 * 1024, // tune for peer's SCTP limit
})
| Type | File | Role |
|---|---|---|
WebrtcTransport |
src/webrtc-transport.ts |
The Transport<...> subclass. |
WebrtcTransportOptions |
src/webrtc-transport.ts |
{ fragmentThreshold? }. |
createWebrtcTransport |
src/webrtc-transport.ts |
TransportFactory returning a fresh WebrtcTransport. |
DataChannelLike |
src/data-channel-like.ts |
The five-member structural contract. |
DEFAULT_FRAGMENT_THRESHOLD |
src/webrtc-transport.ts |
200 * 1024. |
| File | Lines | Role |
|---|---|---|
src/index.ts |
16 | Public exports. |
src/data-channel-like.ts |
104 | The DataChannelLike interface + its doc contract. |
src/webrtc-transport.ts |
434 | WebrtcTransport, createWebrtcTransport, attach/detach, binary pipeline wiring. |
src/__tests__/mock-data-channel.ts |
94 | A test-only DataChannelLike implementation with scripted readyState transitions. |
src/__tests__/webrtc-transport.test.ts |
517 | Attach/detach lifecycle, readiness gating, binary pipeline, fragmentation. |
src/__tests__/simple-peer-bridge.test.ts |
197 | Demonstrates bridging an EventEmitter-shaped channel to DataChannelLike; verifies the bridge pattern works end-to-end. |
No real RTCPeerConnection, no real network. Tests drive a mock DataChannelLike whose readyState and event firings are scripted deterministically. The simple-peer-bridge.test.ts file is also a reference implementation for the pattern described in Bridging non-spec libraries.
Tests: 27 passed, 0 skipped across 2 files (webrtc-transport.test.ts: 21, simple-peer-bridge.test.ts: 6). Run with cd packages/exchange/transports/webrtc && pnpm exec vitest run.