@kyneta/webrtc-transport 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/data-channel-like.ts","../src/webrtc-transport.ts"],"mappings":";;;;;;AA6CA;;;;;;;;;;;;;;;;;;AAyDkE;;;;ACxDlE;UDDiB,eAAA;;;ACCmC;AASpD;;;;AAOmB;AAClB;;;WDNU,UAAA;ECgBT;;;;AACwB;AA0D1B;;;;;;ED9DE,UAAA;ECyHiB;;;;;;;;ED/GjB,IAAA,CAAK,IAAA,EAAM,UAAU;;;;;;;;;;;;EAarB,gBAAA,CAAiB,IAAA,UAAc,QAAA,GAAW,KAAA;ECkIxC;;;;;;;EDzHF,mBAAA,CAAoB,IAAA,UAAc,QAAA,GAAW,KAAA;AAAA;;;AAzD/C;;;;;;;;;AAAA,cCCa,0BAAA;;;;UASI,sBAAA;ED+CK;;;;AAA4C;;ECxChE,iBAAiB;AAAA;AAhBnB;;;AAAA,KA0BK,kBAAA;EACH,YAAA;EACA,OAAA,EAAS,eAAe;AAAA;;;AAZP;AAClB;;;;;;;;AAWyB;AA0D1B;;;;;;;;;;;;;;;;;;;;;;;;;cAAa,eAAA,SAAwB,SAAA,CAAU,kBAAA;EAAA;cAWjC,OAAA,GAAU,sBAAA;EAgFpB;;;;;;EAAA,UAhEQ,QAAA,CAAS,OAAA,EAAS,kBAAA,GAAqB,gBAAA;EAuKjD;;AAAkB;AA+HpB;;;EAtQQ,OAAA,CAAA,GAAW,OAAA;EAuQP;;;;AACO;EAjQX,MAAA,CAAA,GAAU,OAAA;;;;;;;;;;;;;;;EAwBhB,iBAAA,CACE,YAAA,UACA,OAAA,EAAS,eAAA;;;;;;;;;;EA2EX,iBAAA,CAAkB,YAAA;;;;EAoBlB,cAAA,CAAe,YAAA;;;;EAOf,kBAAA,CAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;iBA+Hc,qBAAA,CACd,OAAA,GAAU,sBAAA,GACT,gBAAgB"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/data-channel-like.ts","../src/webrtc-transport.ts"],"mappings":";;;;;;AA6CA;;;;;;;;;;;;;;;;;;AAyDkE;;;;AClElE;UDSiB,eAAA;;;ACTmC;AASpD;;;;AAOmB;AAClB;;;WDIU,UAAA;ECMT;;;;AACwB;AAuD1B;;;;;;EDjDE,UAAA;ECqGiB;;;;;;;;ED3FjB,IAAA,CAAK,IAAA,EAAM,UAAU;;;;;;;;;;;;EAarB,gBAAA,CAAiB,IAAA,UAAc,QAAA,GAAW,KAAA;EC8GxC;;;;;;;EDrGF,mBAAA,CAAoB,IAAA,UAAc,QAAA,GAAW,KAAA;AAAA;;;AAzD/C;;;;;;;;;AAAA,cCTa,0BAAA;;;;UASI,sBAAA;EDyDK;;;;AAA4C;;EClDhE,iBAAiB;AAAA;AAhBnB;;;AAAA,KA0BK,kBAAA;EACH,YAAA;EACA,OAAA,EAAS,eAAe;AAAA;;;AAZP;AAClB;;;;;;;;AAWyB;AAuD1B;;;;;;;;;;;;;;;;;;;;;;;;;cAAa,eAAA,SAAwB,SAAA,CAAU,kBAAA;EAAA;cAWjC,OAAA,GAAU,sBAAA;EAyEpB;;;;;;EAAA,UAzDQ,QAAA,CAAS,OAAA,EAAS,kBAAA,GAAqB,gBAAA;EAyKjD;;AAAkB;AAyHpB;;;EAzQQ,OAAA,CAAA,GAAW,OAAA;EA0QP;;;;AACO;EApQX,MAAA,CAAA,GAAU,OAAA;;;;;;;;;;;;;;;EAwBhB,iBAAA,CACE,YAAA,UACA,OAAA,EAAS,eAAA;;;;;;;;;;EAoFX,iBAAA,CAAkB,YAAA;;;;EAoBlB,cAAA,CAAe,YAAA;;;;EAOf,kBAAA,CAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;iBAyHc,qBAAA,CACd,OAAA,GAAU,sBAAA,GACT,gBAAgB"}
package/dist/index.js CHANGED
@@ -1,5 +1,4 @@
1
- import { Transport } from "@kyneta/transport";
2
- import { FragmentReassembler, applyInboundAliasing, applyOutboundAliasing, createFrameIdCounter, decodeBinaryWires, emptyAliasState, encodeWireFrameAndSend } from "@kyneta/wire";
1
+ import { Pipeline, Transport } from "@kyneta/transport";
3
2
  //#region src/webrtc-transport.ts
4
3
  /**
5
4
  * Default fragment threshold in bytes.
@@ -74,9 +73,7 @@ var WebrtcTransport = class extends Transport {
74
73
  send: (msg) => {
75
74
  const attached = this.#attachedChannels.get(context.remotePeerId);
76
75
  if (!attached || channel.readyState !== "open") return;
77
- const { state, wire } = applyOutboundAliasing(attached.aliasState, msg);
78
- attached.aliasState = state;
79
- encodeWireFrameAndSend(wire, (data) => channel.send(data), this.#fragmentThreshold, attached.nextFrameId);
76
+ for (const r of attached.pipeline.send(msg)) if (r.ok) channel.send(r.value);
80
77
  },
81
78
  stop: () => {}
82
79
  };
@@ -113,7 +110,14 @@ var WebrtcTransport = class extends Transport {
113
110
  attachDataChannel(remotePeerId, channel) {
114
111
  if (this.#attachedChannels.has(remotePeerId)) this.detachDataChannel(remotePeerId);
115
112
  channel.binaryType = "arraybuffer";
116
- const reassembler = new FragmentReassembler({ timeoutMs: 1e4 });
113
+ const pipeline = new Pipeline({
114
+ send: "binary",
115
+ opts: {
116
+ threshold: this.#fragmentThreshold,
117
+ reassemblyTimeoutMs: 1e4,
118
+ onError: (e, dir) => console.warn(`[webrtc-transport] wire error (${dir}) for peer ${remotePeerId}:`, e)
119
+ }
120
+ });
117
121
  const onOpen = () => {
118
122
  this.#createSyncChannel(remotePeerId);
119
123
  };
@@ -140,9 +144,7 @@ var WebrtcTransport = class extends Transport {
140
144
  remotePeerId,
141
145
  channel,
142
146
  channelId: null,
143
- reassembler,
144
- nextFrameId: createFrameIdCounter(),
145
- aliasState: emptyAliasState(),
147
+ pipeline,
146
148
  cleanup
147
149
  };
148
150
  this.#attachedChannels.set(remotePeerId, attached);
@@ -162,7 +164,7 @@ var WebrtcTransport = class extends Transport {
162
164
  const attached = this.#attachedChannels.get(remotePeerId);
163
165
  if (!attached) return;
164
166
  this.#removeSyncChannel(remotePeerId);
165
- attached.reassembler.dispose();
167
+ attached.pipeline.dispose();
166
168
  attached.cleanup();
167
169
  this.#attachedChannels.delete(remotePeerId);
168
170
  }
@@ -219,20 +221,10 @@ var WebrtcTransport = class extends Transport {
219
221
  const syncChannel = this.channels.get(attached.channelId);
220
222
  if (!syncChannel) return;
221
223
  const raw = event.data;
222
- const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw instanceof Uint8Array ? raw : null;
224
+ const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw instanceof Uint8Array ? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength) : null;
223
225
  if (!bytes) return;
224
226
  try {
225
- const wires = decodeBinaryWires(bytes, attached.reassembler);
226
- if (!wires) return;
227
- for (const wire of wires) {
228
- const result = applyInboundAliasing(attached.aliasState, wire);
229
- attached.aliasState = result.state;
230
- if (result.error || !result.msg) {
231
- console.warn(`[webrtc-transport] alias resolution failed for peer ${remotePeerId}:`, result.error);
232
- continue;
233
- }
234
- syncChannel.onReceive(result.msg);
235
- }
227
+ for (const r of attached.pipeline.receive(bytes)) if (r.ok) syncChannel.onReceive(r.value);
236
228
  } catch (error) {
237
229
  console.error(`[webrtc-transport] Failed to decode message from peer ${remotePeerId}:`, error);
238
230
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["#attachedChannels","#fragmentThreshold","#createSyncChannel","#removeSyncChannel","#handleMessage"],"sources":["../src/webrtc-transport.ts"],"sourcesContent":["// webrtc-transport — BYODC WebRTC data channel transport for @kyneta/exchange.\n//\n// \"Bring Your Own Data Channel\" design: the application manages WebRTC\n// connections (signaling, ICE, media streams). This transport attaches\n// to already-established data channels for kyneta document sync.\n//\n// Uses the shared binary pipeline from @kyneta/wire (same as WebSocket):\n// encodeBinaryAndSend — outbound: encode → fragment → sendFn\n// decodeBinaryMessages — inbound: reassemble → decode → ChannelMsg[]\n//\n// The transport accepts any object satisfying `DataChannelLike` — a\n// 5-member interface that native RTCDataChannel satisfies structurally\n// and that libraries like simple-peer can conform to via a trivial bridge.\n\nimport type {\n ChannelId,\n ChannelMsg,\n GeneratedChannel,\n TransportFactory,\n} from \"@kyneta/transport\"\nimport { Transport } from \"@kyneta/transport\"\nimport {\n type AliasState,\n applyInboundAliasing,\n applyOutboundAliasing,\n createFrameIdCounter,\n decodeBinaryWires,\n emptyAliasState,\n encodeWireFrameAndSend,\n FragmentReassembler,\n} from \"@kyneta/wire\"\nimport type { DataChannelLike } from \"./data-channel-like.js\"\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/**\n * Default fragment threshold in bytes.\n *\n * SCTP (the underlying transport for WebRTC data channels) has a message\n * size limit of approximately 256KB. 200KB provides a safe margin.\n *\n * This differs from the WebSocket transport's 100KB default, which\n * targets AWS API Gateway's 128KB limit. WebRTC has no such gateway.\n */\nexport const DEFAULT_FRAGMENT_THRESHOLD = 200 * 1024\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Configuration options for the WebRTC transport.\n */\nexport interface WebrtcTransportOptions {\n /**\n * Fragment threshold in bytes. Messages larger than this are fragmented\n * for SCTP compatibility. Set to 0 to disable fragmentation (not recommended).\n *\n * @default 204800 (200KB)\n */\n fragmentThreshold?: number\n}\n\n// ---------------------------------------------------------------------------\n// Internal types\n// ---------------------------------------------------------------------------\n\n/**\n * Context for each attached data channel — stored per remotePeerId.\n */\ntype DataChannelContext = {\n remotePeerId: string\n channel: DataChannelLike\n}\n\n/**\n * Internal tracking for an attached data channel.\n */\ntype AttachedChannel = {\n remotePeerId: string\n channel: DataChannelLike\n channelId: ChannelId | null\n reassembler: FragmentReassembler\n nextFrameId: () => number\n /** Per-channel alias state (Phase 4). */\n aliasState: AliasState\n cleanup: () => void\n}\n\n// ---------------------------------------------------------------------------\n// WebrtcTransport\n// ---------------------------------------------------------------------------\n\n/**\n * WebRTC data channel transport for @kyneta/exchange.\n *\n * Follows a \"Bring Your Own Data Channel\" (BYODC) design — the application\n * manages WebRTC connections and attaches data channels to this transport\n * for kyneta document synchronization.\n *\n * Uses binary CBOR encoding with transport-level fragmentation via\n * `@kyneta/wire` — the same pipeline as the WebSocket transport.\n *\n * ## Usage\n *\n * ```typescript\n * import { Exchange } from \"@kyneta/exchange\"\n * import { createWebrtcTransport } from \"@kyneta/webrtc-transport\"\n *\n * const webrtcTransport = createWebrtcTransport()\n *\n * const exchange = new Exchange({\n * id: { peerId: \"alice\", name: \"Alice\" },\n * transports: [webrtcTransport],\n * })\n *\n * // When a WebRTC connection is established:\n * const cleanup = transport.attachDataChannel(remotePeerId, dataChannel)\n *\n * // When done:\n * cleanup() // or transport.detachDataChannel(remotePeerId)\n * ```\n *\n * ## Ownership\n *\n * The transport does NOT own the data channel. `detachDataChannel()`\n * removes the sync channel and event listeners but does not close the\n * data channel or the peer connection. The application manages the\n * WebRTC connection lifecycle independently.\n */\nexport class WebrtcTransport extends Transport<DataChannelContext> {\n /**\n * Map of remotePeerId → attached channel tracking.\n */\n readonly #attachedChannels = new Map<string, AttachedChannel>()\n\n /**\n * Fragment threshold in bytes.\n */\n readonly #fragmentThreshold: number\n\n constructor(options?: WebrtcTransportOptions) {\n super({ transportType: \"webrtc-datachannel\" })\n this.#fragmentThreshold =\n options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n }\n\n // ==========================================================================\n // Transport abstract method implementations\n // ==========================================================================\n\n /**\n * Generate a channel for a data channel context.\n *\n * Called internally by the `Transport` base class when `addChannel()` is\n * invoked. Users never call this directly — use `attachDataChannel()`.\n */\n protected generate(context: DataChannelContext): GeneratedChannel {\n const { channel } = context\n\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n const attached = this.#attachedChannels.get(context.remotePeerId)\n if (!attached || channel.readyState !== \"open\") {\n return\n }\n const { state, wire } = applyOutboundAliasing(attached.aliasState, msg)\n attached.aliasState = state\n encodeWireFrameAndSend(\n wire,\n data => channel.send(data),\n this.#fragmentThreshold,\n attached.nextFrameId,\n )\n },\n stop: () => {\n // Cleanup is handled by detachDataChannel().\n // This callback fires when the internal channel is removed.\n },\n }\n }\n\n /**\n * Called when the transport starts.\n *\n * No-op for WebRTC — channels are added dynamically via\n * `attachDataChannel()`, not at start time.\n */\n async onStart(): Promise<void> {}\n\n /**\n * Called when the transport stops.\n *\n * Detaches all attached data channels and cleans up resources.\n */\n async onStop(): Promise<void> {\n for (const remotePeerId of [...this.#attachedChannels.keys()]) {\n this.detachDataChannel(remotePeerId)\n }\n }\n\n // ==========================================================================\n // Public API — data channel management\n // ==========================================================================\n\n /**\n * Attach a data channel for a remote peer.\n *\n * Creates an internal sync channel when the data channel is open\n * (or waits for the `\"open\"` event if still connecting). The sync\n * channel triggers the establishment handshake with the remote peer.\n *\n * If a data channel is already attached for this peer, the old one\n * is detached first.\n *\n * @param remotePeerId - The stable peer ID of the remote peer\n * @param channel - Any object satisfying `DataChannelLike`\n * @returns A cleanup function that calls `detachDataChannel(remotePeerId)`\n */\n attachDataChannel(\n remotePeerId: string,\n channel: DataChannelLike,\n ): () => void {\n // Detach existing channel for this peer if any\n if (this.#attachedChannels.has(remotePeerId)) {\n this.detachDataChannel(remotePeerId)\n }\n\n // Best-effort: request arraybuffer mode for incoming data.\n // The message handler doesn't depend on this — it accepts both\n // ArrayBuffer and Uint8Array regardless.\n channel.binaryType = \"arraybuffer\"\n\n // Create reassembler for this data channel\n const reassembler = new FragmentReassembler({ timeoutMs: 10_000 })\n\n // Event handlers — stored as named functions for removeEventListener\n const onOpen = () => {\n this.#createSyncChannel(remotePeerId)\n }\n\n const onClose = () => {\n this.#removeSyncChannel(remotePeerId)\n }\n\n const onError = () => {\n this.#removeSyncChannel(remotePeerId)\n }\n\n const onMessage = (event: any) => {\n this.#handleMessage(remotePeerId, event)\n }\n\n // Cleanup function to remove all event listeners\n const cleanup = () => {\n channel.removeEventListener(\"open\", onOpen)\n channel.removeEventListener(\"close\", onClose)\n channel.removeEventListener(\"error\", onError)\n channel.removeEventListener(\"message\", onMessage)\n }\n\n // Register event listeners\n channel.addEventListener(\"open\", onOpen)\n channel.addEventListener(\"close\", onClose)\n channel.addEventListener(\"error\", onError)\n channel.addEventListener(\"message\", onMessage)\n\n // Track the attached channel\n const attached: AttachedChannel = {\n remotePeerId,\n channel,\n channelId: null,\n reassembler,\n nextFrameId: createFrameIdCounter(),\n aliasState: emptyAliasState(),\n cleanup,\n }\n this.#attachedChannels.set(remotePeerId, attached)\n\n // If the channel is already open, create the sync channel immediately\n if (channel.readyState === \"open\") {\n this.#createSyncChannel(remotePeerId)\n }\n\n return () => this.detachDataChannel(remotePeerId)\n }\n\n /**\n * Detach a data channel for a remote peer.\n *\n * Removes the sync channel, cleans up event listeners, and disposes\n * the reassembler. Does NOT close the data channel — the application\n * manages the WebRTC connection lifecycle.\n *\n * @param remotePeerId - The peer ID to detach\n */\n detachDataChannel(remotePeerId: string): void {\n const attached = this.#attachedChannels.get(remotePeerId)\n if (!attached) return\n\n // Remove the sync channel if it exists\n this.#removeSyncChannel(remotePeerId)\n\n // Dispose the reassembler to clean up timers\n attached.reassembler.dispose()\n\n // Remove event listeners from the data channel\n attached.cleanup()\n\n // Remove from tracking\n this.#attachedChannels.delete(remotePeerId)\n }\n\n /**\n * Check if a data channel is attached for a peer.\n */\n hasDataChannel(remotePeerId: string): boolean {\n return this.#attachedChannels.has(remotePeerId)\n }\n\n /**\n * Get all peer IDs with attached data channels.\n */\n getAttachedPeerIds(): string[] {\n return [...this.#attachedChannels.keys()]\n }\n\n // ==========================================================================\n // Internal — sync channel lifecycle\n // ==========================================================================\n\n /**\n * Create an internal sync channel for an attached data channel.\n *\n * Called when the data channel's `\"open\"` event fires (or immediately\n * if already open on attach). The sync channel is registered with the\n * Transport base class, which triggers the establishment handshake.\n */\n #createSyncChannel(remotePeerId: string): void {\n const attached = this.#attachedChannels.get(remotePeerId)\n if (!attached) return\n\n // Don't create if already exists\n if (attached.channelId !== null) return\n\n // addChannel() creates and registers the sync channel\n const syncChannel = this.addChannel({\n remotePeerId,\n channel: attached.channel,\n })\n attached.channelId = syncChannel.channelId\n\n // Start the establishment handshake\n this.establishChannel(syncChannel.channelId)\n }\n\n /**\n * Remove the internal sync channel for a peer.\n */\n #removeSyncChannel(remotePeerId: string): void {\n const attached = this.#attachedChannels.get(remotePeerId)\n if (!attached || attached.channelId === null) return\n\n this.removeChannel(attached.channelId)\n attached.channelId = null\n }\n\n // ==========================================================================\n // Internal — message handling\n // ==========================================================================\n\n /**\n * Handle an incoming message from a data channel.\n *\n * Extracts binary data from the event, feeding both `ArrayBuffer`\n * (native RTCDataChannel with binaryType \"arraybuffer\") and\n * `Uint8Array` (simple-peer and other wrappers) into the shared\n * decode pipeline.\n */\n #handleMessage(remotePeerId: string, event: any): void {\n const attached = this.#attachedChannels.get(remotePeerId)\n if (!attached || attached.channelId === null) return\n\n const syncChannel = this.channels.get(attached.channelId)\n if (!syncChannel) return\n\n // Extract bytes — robust to both ArrayBuffer and Uint8Array\n const raw = event.data\n const bytes =\n raw instanceof ArrayBuffer\n ? new Uint8Array(raw)\n : raw instanceof Uint8Array\n ? raw\n : null\n\n if (!bytes) {\n // Unexpected data type (e.g. string) — ignore silently\n return\n }\n\n try {\n const wires = decodeBinaryWires(bytes, attached.reassembler)\n if (!wires) return\n for (const wire of wires) {\n const result = applyInboundAliasing(attached.aliasState, wire)\n attached.aliasState = result.state\n if (result.error || !result.msg) {\n console.warn(\n `[webrtc-transport] alias resolution failed for peer ${remotePeerId}:`,\n result.error,\n )\n continue\n }\n syncChannel.onReceive(result.msg)\n }\n } catch (error) {\n console.error(\n `[webrtc-transport] Failed to decode message from peer ${remotePeerId}:`,\n error,\n )\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory function\n// ---------------------------------------------------------------------------\n\n/**\n * Create a WebRTC transport factory for use with `Exchange`.\n *\n * Returns a `TransportFactory` — pass directly to\n * `Exchange({ transports: [...] })`. The returned transport instance\n * exposes `attachDataChannel()` / `detachDataChannel()` for BYODC\n * data channel management.\n *\n * To access the transport instance after creation, use\n * `exchange.getTransport(\"webrtc-datachannel\")`.\n *\n * @example\n * ```typescript\n * import { Exchange } from \"@kyneta/exchange\"\n * import { createWebrtcTransport } from \"@kyneta/webrtc-transport\"\n *\n * const exchange = new Exchange({\n * id: { peerId: \"alice\", name: \"Alice\" },\n * transports: [createWebrtcTransport()],\n * })\n * ```\n */\nexport function createWebrtcTransport(\n options?: WebrtcTransportOptions,\n): TransportFactory {\n return () => new WebrtcTransport(options)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["#attachedChannels","#fragmentThreshold","#createSyncChannel","#removeSyncChannel","#handleMessage"],"sources":["../src/webrtc-transport.ts"],"sourcesContent":["// webrtc-transport — BYODC WebRTC data channel transport for @kyneta/exchange.\n//\n// \"Bring Your Own Data Channel\" design: the application manages WebRTC\n// connections (signaling, ICE, media streams). This transport attaches\n// to already-established data channels for kyneta document sync.\n//\n// Uses the shared Pipeline from @kyneta/transport (same as WebSocket):\n// pipeline.send(msg) — outbound: alias → encode → fragment\n// pipeline.receive(data) — inbound: reassemble → decode → alias → ChannelMsg\n//\n// The transport accepts any object satisfying `DataChannelLike` — a\n// 5-member interface that native RTCDataChannel satisfies structurally\n// and that libraries like simple-peer can conform to via a trivial bridge.\n\nimport type {\n ChannelId,\n ChannelMsg,\n GeneratedChannel,\n TransportFactory,\n} from \"@kyneta/transport\"\nimport { Pipeline, Transport } from \"@kyneta/transport\"\nimport type { DataChannelLike } from \"./data-channel-like.js\"\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/**\n * Default fragment threshold in bytes.\n *\n * SCTP (the underlying transport for WebRTC data channels) has a message\n * size limit of approximately 256KB. 200KB provides a safe margin.\n *\n * This differs from the WebSocket transport's 100KB default, which\n * targets AWS API Gateway's 128KB limit. WebRTC has no such gateway.\n */\nexport const DEFAULT_FRAGMENT_THRESHOLD = 200 * 1024\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Configuration options for the WebRTC transport.\n */\nexport interface WebrtcTransportOptions {\n /**\n * Fragment threshold in bytes. Messages larger than this are fragmented\n * for SCTP compatibility. Set to 0 to disable fragmentation (not recommended).\n *\n * @default 204800 (200KB)\n */\n fragmentThreshold?: number\n}\n\n// ---------------------------------------------------------------------------\n// Internal types\n// ---------------------------------------------------------------------------\n\n/**\n * Context for each attached data channel — stored per remotePeerId.\n */\ntype DataChannelContext = {\n remotePeerId: string\n channel: DataChannelLike\n}\n\n/**\n * Internal tracking for an attached data channel.\n */\ntype AttachedChannel = {\n remotePeerId: string\n channel: DataChannelLike\n channelId: ChannelId | null\n pipeline: Pipeline<\"binary\">\n cleanup: () => void\n}\n\n// ---------------------------------------------------------------------------\n// WebrtcTransport\n// ---------------------------------------------------------------------------\n\n/**\n * WebRTC data channel transport for @kyneta/exchange.\n *\n * Follows a \"Bring Your Own Data Channel\" (BYODC) design — the application\n * manages WebRTC connections and attaches data channels to this transport\n * for kyneta document synchronization.\n *\n * Uses binary CBOR encoding with transport-level fragmentation via\n * `@kyneta/wire` — the same pipeline as the WebSocket transport.\n *\n * ## Usage\n *\n * ```typescript\n * import { Exchange } from \"@kyneta/exchange\"\n * import { createWebrtcTransport } from \"@kyneta/webrtc-transport\"\n *\n * const webrtcTransport = createWebrtcTransport()\n *\n * const exchange = new Exchange({\n * id: { peerId: \"alice\", name: \"Alice\" },\n * transports: [webrtcTransport],\n * })\n *\n * // When a WebRTC connection is established:\n * const cleanup = transport.attachDataChannel(remotePeerId, dataChannel)\n *\n * // When done:\n * cleanup() // or transport.detachDataChannel(remotePeerId)\n * ```\n *\n * ## Ownership\n *\n * The transport does NOT own the data channel. `detachDataChannel()`\n * removes the sync channel and event listeners but does not close the\n * data channel or the peer connection. The application manages the\n * WebRTC connection lifecycle independently.\n */\nexport class WebrtcTransport extends Transport<DataChannelContext> {\n /**\n * Map of remotePeerId → attached channel tracking.\n */\n readonly #attachedChannels = new Map<string, AttachedChannel>()\n\n /**\n * Fragment threshold in bytes.\n */\n readonly #fragmentThreshold: number\n\n constructor(options?: WebrtcTransportOptions) {\n super({ transportType: \"webrtc-datachannel\" })\n this.#fragmentThreshold =\n options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n }\n\n // ==========================================================================\n // Transport abstract method implementations\n // ==========================================================================\n\n /**\n * Generate a channel for a data channel context.\n *\n * Called internally by the `Transport` base class when `addChannel()` is\n * invoked. Users never call this directly — use `attachDataChannel()`.\n */\n protected generate(context: DataChannelContext): GeneratedChannel {\n const { channel } = context\n\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n const attached = this.#attachedChannels.get(context.remotePeerId)\n if (!attached || channel.readyState !== \"open\") return\n for (const r of attached.pipeline.send(msg)) {\n if (r.ok) channel.send(r.value)\n }\n },\n stop: () => {\n // Cleanup is handled by detachDataChannel().\n // This callback fires when the internal channel is removed.\n },\n }\n }\n\n /**\n * Called when the transport starts.\n *\n * No-op for WebRTC — channels are added dynamically via\n * `attachDataChannel()`, not at start time.\n */\n async onStart(): Promise<void> {}\n\n /**\n * Called when the transport stops.\n *\n * Detaches all attached data channels and cleans up resources.\n */\n async onStop(): Promise<void> {\n for (const remotePeerId of [...this.#attachedChannels.keys()]) {\n this.detachDataChannel(remotePeerId)\n }\n }\n\n // ==========================================================================\n // Public API — data channel management\n // ==========================================================================\n\n /**\n * Attach a data channel for a remote peer.\n *\n * Creates an internal sync channel when the data channel is open\n * (or waits for the `\"open\"` event if still connecting). The sync\n * channel triggers the establishment handshake with the remote peer.\n *\n * If a data channel is already attached for this peer, the old one\n * is detached first.\n *\n * @param remotePeerId - The stable peer ID of the remote peer\n * @param channel - Any object satisfying `DataChannelLike`\n * @returns A cleanup function that calls `detachDataChannel(remotePeerId)`\n */\n attachDataChannel(\n remotePeerId: string,\n channel: DataChannelLike,\n ): () => void {\n // Detach existing channel for this peer if any\n if (this.#attachedChannels.has(remotePeerId)) {\n this.detachDataChannel(remotePeerId)\n }\n\n // Best-effort: request arraybuffer mode for incoming data.\n // The message handler doesn't depend on this — it accepts both\n // ArrayBuffer and Uint8Array regardless.\n channel.binaryType = \"arraybuffer\"\n\n // Create pipeline for this data channel\n const pipeline = new Pipeline<\"binary\">({\n send: \"binary\",\n opts: {\n threshold: this.#fragmentThreshold,\n reassemblyTimeoutMs: 10_000,\n onError: (e, dir) =>\n console.warn(\n `[webrtc-transport] wire error (${dir}) for peer ${remotePeerId}:`,\n e,\n ),\n },\n })\n\n // Event handlers — stored as named functions for removeEventListener\n const onOpen = () => {\n this.#createSyncChannel(remotePeerId)\n }\n\n const onClose = () => {\n this.#removeSyncChannel(remotePeerId)\n }\n\n const onError = () => {\n this.#removeSyncChannel(remotePeerId)\n }\n\n const onMessage = (event: any) => {\n this.#handleMessage(remotePeerId, event)\n }\n\n // Cleanup function to remove all event listeners\n const cleanup = () => {\n channel.removeEventListener(\"open\", onOpen)\n channel.removeEventListener(\"close\", onClose)\n channel.removeEventListener(\"error\", onError)\n channel.removeEventListener(\"message\", onMessage)\n }\n\n // Register event listeners\n channel.addEventListener(\"open\", onOpen)\n channel.addEventListener(\"close\", onClose)\n channel.addEventListener(\"error\", onError)\n channel.addEventListener(\"message\", onMessage)\n\n // Track the attached channel\n const attached: AttachedChannel = {\n remotePeerId,\n channel,\n channelId: null,\n pipeline,\n cleanup,\n }\n this.#attachedChannels.set(remotePeerId, attached)\n\n // If the channel is already open, create the sync channel immediately\n if (channel.readyState === \"open\") {\n this.#createSyncChannel(remotePeerId)\n }\n\n return () => this.detachDataChannel(remotePeerId)\n }\n\n /**\n * Detach a data channel for a remote peer.\n *\n * Removes the sync channel, cleans up event listeners, and disposes\n * the reassembler. Does NOT close the data channel — the application\n * manages the WebRTC connection lifecycle.\n *\n * @param remotePeerId - The peer ID to detach\n */\n detachDataChannel(remotePeerId: string): void {\n const attached = this.#attachedChannels.get(remotePeerId)\n if (!attached) return\n\n // Remove the sync channel if it exists\n this.#removeSyncChannel(remotePeerId)\n\n // Dispose the pipeline to clean up timers and state\n attached.pipeline.dispose()\n\n // Remove event listeners from the data channel\n attached.cleanup()\n\n // Remove from tracking\n this.#attachedChannels.delete(remotePeerId)\n }\n\n /**\n * Check if a data channel is attached for a peer.\n */\n hasDataChannel(remotePeerId: string): boolean {\n return this.#attachedChannels.has(remotePeerId)\n }\n\n /**\n * Get all peer IDs with attached data channels.\n */\n getAttachedPeerIds(): string[] {\n return [...this.#attachedChannels.keys()]\n }\n\n // ==========================================================================\n // Internal — sync channel lifecycle\n // ==========================================================================\n\n /**\n * Create an internal sync channel for an attached data channel.\n *\n * Called when the data channel's `\"open\"` event fires (or immediately\n * if already open on attach). The sync channel is registered with the\n * Transport base class, which triggers the establishment handshake.\n */\n #createSyncChannel(remotePeerId: string): void {\n const attached = this.#attachedChannels.get(remotePeerId)\n if (!attached) return\n\n // Don't create if already exists\n if (attached.channelId !== null) return\n\n // addChannel() creates and registers the sync channel\n const syncChannel = this.addChannel({\n remotePeerId,\n channel: attached.channel,\n })\n attached.channelId = syncChannel.channelId\n\n // Start the establishment handshake\n this.establishChannel(syncChannel.channelId)\n }\n\n /**\n * Remove the internal sync channel for a peer.\n */\n #removeSyncChannel(remotePeerId: string): void {\n const attached = this.#attachedChannels.get(remotePeerId)\n if (!attached || attached.channelId === null) return\n\n this.removeChannel(attached.channelId)\n attached.channelId = null\n }\n\n // ==========================================================================\n // Internal — message handling\n // ==========================================================================\n\n /**\n * Handle an incoming message from a data channel.\n *\n * Extracts binary data from the event, feeding both `ArrayBuffer`\n * (native RTCDataChannel with binaryType \"arraybuffer\") and\n * `Uint8Array` (simple-peer and other wrappers) into the shared\n * decode pipeline.\n */\n #handleMessage(remotePeerId: string, event: any): void {\n const attached = this.#attachedChannels.get(remotePeerId)\n if (!attached || attached.channelId === null) return\n\n const syncChannel = this.channels.get(attached.channelId)\n if (!syncChannel) return\n\n // Extract bytes — robust to both ArrayBuffer and Uint8Array.\n // Both paths produce Uint8Array<ArrayBuffer> for the pipeline.\n const raw = event.data\n const bytes: Uint8Array<ArrayBuffer> | null =\n raw instanceof ArrayBuffer\n ? new Uint8Array(raw)\n : raw instanceof Uint8Array\n ? new Uint8Array(\n raw.buffer as ArrayBuffer,\n raw.byteOffset,\n raw.byteLength,\n )\n : null\n\n if (!bytes) {\n // Unexpected data type (e.g. string) — ignore silently\n return\n }\n\n try {\n for (const r of attached.pipeline.receive(bytes)) {\n if (r.ok) syncChannel.onReceive(r.value)\n }\n } catch (error) {\n console.error(\n `[webrtc-transport] Failed to decode message from peer ${remotePeerId}:`,\n error,\n )\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory function\n// ---------------------------------------------------------------------------\n\n/**\n * Create a WebRTC transport factory for use with `Exchange`.\n *\n * Returns a `TransportFactory` — pass directly to\n * `Exchange({ transports: [...] })`. The returned transport instance\n * exposes `attachDataChannel()` / `detachDataChannel()` for BYODC\n * data channel management.\n *\n * To access the transport instance after creation, use\n * `exchange.getTransport(\"webrtc-datachannel\")`.\n *\n * @example\n * ```typescript\n * import { Exchange } from \"@kyneta/exchange\"\n * import { createWebrtcTransport } from \"@kyneta/webrtc-transport\"\n *\n * const exchange = new Exchange({\n * id: { peerId: \"alice\", name: \"Alice\" },\n * transports: [createWebrtcTransport()],\n * })\n * ```\n */\nexport function createWebrtcTransport(\n options?: WebrtcTransportOptions,\n): TransportFactory {\n return () => new WebrtcTransport(options)\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyneta/webrtc-transport",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "WebRTC data channel transport for @kyneta/exchange — BYODC (Bring Your Own Data Channel) with DataChannelLike interface",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
@@ -21,22 +21,20 @@
21
21
  ".": {
22
22
  "types": "./dist/index.d.ts",
23
23
  "import": "./dist/index.js"
24
- },
25
- "./src/*": "./src/*"
24
+ }
26
25
  },
27
26
  "peerDependencies": {
28
- "@kyneta/transport": "^1.6.0",
29
- "@kyneta/wire": "^1.6.0"
27
+ "@kyneta/transport": "^1.7.0"
30
28
  },
31
29
  "devDependencies": {
32
30
  "@types/node": "^22",
33
31
  "tsdown": "^0.22.0",
34
32
  "typescript": "^5.9.2",
35
33
  "vitest": "^4.0.17",
36
- "@kyneta/exchange": "^1.6.0",
37
- "@kyneta/schema": "^1.6.0",
38
- "@kyneta/transport": "^1.6.0",
39
- "@kyneta/wire": "^1.6.0"
34
+ "@kyneta/exchange": "^1.7.0",
35
+ "@kyneta/transport": "^1.7.0",
36
+ "@kyneta/schema": "^1.7.0",
37
+ "@kyneta/wire": "^1.7.0"
40
38
  },
41
39
  "scripts": {
42
40
  "build": "tsdown",
@@ -6,6 +6,7 @@
6
6
  // DataChannelLike contract (addEventListener/removeEventListener,
7
7
  // "open"/"message"/"close"/"error").
8
8
 
9
+ import { createTestTransportContext } from "@kyneta/transport/testing"
9
10
  import { describe, expect, it, vi } from "vitest"
10
11
  import type { DataChannelLike } from "../data-channel-like.js"
11
12
  import { WebrtcTransport } from "../webrtc-transport.js"
@@ -93,13 +94,11 @@ function fromSimplePeer(peer: MockSimplePeer): DataChannelLike {
93
94
  // ---------------------------------------------------------------------------
94
95
 
95
96
  async function initializeTransport(transport: WebrtcTransport) {
96
- transport._initialize({
97
- identity: { peerId: "local", name: "Local", type: "user" as const },
98
- onChannelReceive: vi.fn(),
99
- onChannelAdded: vi.fn(),
100
- onChannelRemoved: vi.fn(),
101
- onChannelEstablish: vi.fn(),
102
- })
97
+ await transport._initialize(
98
+ createTestTransportContext({
99
+ identity: { peerId: "local", name: "Local", type: "user" },
100
+ }),
101
+ )
103
102
  await transport._start()
104
103
  }
105
104
 
@@ -5,15 +5,8 @@
5
5
  // channel management.
6
6
 
7
7
  import type { ChannelMsg } from "@kyneta/transport"
8
- import {
9
- applyOutboundAliasing,
10
- complete,
11
- emptyAliasState,
12
- encodeBinaryFrame,
13
- encodeWireMessage,
14
- fragmentPayload,
15
- WIRE_VERSION,
16
- } from "@kyneta/wire"
8
+ import { Pipeline } from "@kyneta/transport"
9
+ import { createTestTransportContext } from "@kyneta/transport/testing"
17
10
  import { beforeEach, describe, expect, it, vi } from "vitest"
18
11
  import { WebrtcTransport } from "../webrtc-transport.js"
19
12
  import { MockDataChannel } from "./mock-data-channel.js"
@@ -30,20 +23,16 @@ import { MockDataChannel } from "./mock-data-channel.js"
30
23
  * channel lifecycle events and message delivery.
31
24
  */
32
25
  function createContext() {
33
- return {
34
- identity: { peerId: "local-peer", name: "Local", type: "user" as const },
35
- onChannelReceive: vi.fn(),
36
- onChannelAdded: vi.fn(),
37
- onChannelRemoved: vi.fn(),
38
- onChannelEstablish: vi.fn(),
39
- }
26
+ return createTestTransportContext({
27
+ identity: { peerId: "local-peer", name: "Local", type: "user" },
28
+ })
40
29
  }
41
30
 
42
31
  async function initializeTransport(
43
32
  transport: WebrtcTransport,
44
33
  ctx = createContext(),
45
34
  ) {
46
- transport._initialize(ctx)
35
+ await transport._initialize(ctx)
47
36
  await transport._start()
48
37
  return ctx
49
38
  }
@@ -54,12 +43,14 @@ const TEST_MSG: ChannelMsg = {
54
43
  identity: { peerId: "remote", name: "R", type: "user" },
55
44
  }
56
45
 
57
- /** Encode a ChannelMsg through the alias-aware pipeline. */
46
+ /** Encode a ChannelMsg through the wire pipeline to a binary frame. */
58
47
  function encodeViaAlias(msg: ChannelMsg): Uint8Array<ArrayBuffer> {
59
- const state = emptyAliasState()
60
- const { wire } = applyOutboundAliasing(state, msg)
61
- const payload = encodeWireMessage(wire)
62
- return encodeBinaryFrame(complete(WIRE_VERSION, payload))
48
+ const pipeline = new Pipeline({ send: "binary" })
49
+ const results = pipeline.send(msg)
50
+ pipeline.dispose()
51
+ const first = results[0]
52
+ if (!first || !first.ok) throw new Error("Pipeline send failed")
53
+ return first.value
63
54
  }
64
55
 
65
56
  // ---------------------------------------------------------------------------
@@ -343,28 +334,36 @@ describe("Fragmentation", () => {
343
334
  },
344
335
  }
345
336
 
346
- const encoded = encodeViaAlias(largeMsg)
347
- const fragments = fragmentPayload(encoded, 50, 1)
337
+ // Use a Pipeline to produce properly-framed fragments (same as
338
+ // the transport's send path). Tiny threshold forces fragmentation.
339
+ const sendPipeline = new Pipeline<"binary">({
340
+ send: "binary",
341
+ opts: { threshold: 50 },
342
+ })
343
+ const fragments = sendPipeline.send(largeMsg).filter(r => r.ok)
348
344
  expect(fragments.length).toBeGreaterThan(1)
345
+ sendPipeline.dispose()
349
346
 
350
347
  // Emit all but the last fragment — should NOT trigger receive yet
351
348
  for (let i = 0; i < fragments.length - 1; i++) {
352
349
  const frag = fragments.at(i)
353
- if (!frag) throw new Error(`expected fragment at index ${i}`)
354
- const ab = frag.buffer.slice(
355
- frag.byteOffset,
356
- frag.byteOffset + frag.byteLength,
350
+ if (!frag || !frag.ok)
351
+ throw new Error(`expected ok fragment at index ${i}`)
352
+ const ab = frag.value.buffer.slice(
353
+ frag.value.byteOffset,
354
+ frag.value.byteOffset + frag.value.byteLength,
357
355
  )
358
356
  dc.emit("message", { data: ab })
359
357
  }
360
358
  expect(ctx.onChannelReceive).not.toHaveBeenCalled()
361
359
 
362
360
  // Emit the last fragment — should complete reassembly
363
- const lastFrag = fragments.at(-1)
364
- if (!lastFrag) throw new Error("expected last fragment to exist")
365
- const ab = lastFrag.buffer.slice(
366
- lastFrag.byteOffset,
367
- lastFrag.byteOffset + lastFrag.byteLength,
361
+ const lastResult = fragments.at(-1)
362
+ if (!lastResult || !lastResult.ok)
363
+ throw new Error("expected last fragment to exist and be ok")
364
+ const ab = lastResult.value.buffer.slice(
365
+ lastResult.value.byteOffset,
366
+ lastResult.value.byteOffset + lastResult.value.byteLength,
368
367
  )
369
368
  dc.emit("message", { data: ab })
370
369
 
@@ -4,9 +4,9 @@
4
4
  // connections (signaling, ICE, media streams). This transport attaches
5
5
  // to already-established data channels for kyneta document sync.
6
6
  //
7
- // Uses the shared binary pipeline from @kyneta/wire (same as WebSocket):
8
- // encodeBinaryAndSend — outbound: encodefragmentsendFn
9
- // decodeBinaryMessages — inbound: reassemble → decode → ChannelMsg[]
7
+ // Uses the shared Pipeline from @kyneta/transport (same as WebSocket):
8
+ // pipeline.send(msg) — outbound: aliasencodefragment
9
+ // pipeline.receive(data) — inbound: reassemble → decode → alias → ChannelMsg
10
10
  //
11
11
  // The transport accepts any object satisfying `DataChannelLike` — a
12
12
  // 5-member interface that native RTCDataChannel satisfies structurally
@@ -18,17 +18,7 @@ import type {
18
18
  GeneratedChannel,
19
19
  TransportFactory,
20
20
  } from "@kyneta/transport"
21
- import { Transport } from "@kyneta/transport"
22
- import {
23
- type AliasState,
24
- applyInboundAliasing,
25
- applyOutboundAliasing,
26
- createFrameIdCounter,
27
- decodeBinaryWires,
28
- emptyAliasState,
29
- encodeWireFrameAndSend,
30
- FragmentReassembler,
31
- } from "@kyneta/wire"
21
+ import { Pipeline, Transport } from "@kyneta/transport"
32
22
  import type { DataChannelLike } from "./data-channel-like.js"
33
23
 
34
24
  // ---------------------------------------------------------------------------
@@ -82,10 +72,7 @@ type AttachedChannel = {
82
72
  remotePeerId: string
83
73
  channel: DataChannelLike
84
74
  channelId: ChannelId | null
85
- reassembler: FragmentReassembler
86
- nextFrameId: () => number
87
- /** Per-channel alias state (Phase 4). */
88
- aliasState: AliasState
75
+ pipeline: Pipeline<"binary">
89
76
  cleanup: () => void
90
77
  }
91
78
 
@@ -164,17 +151,10 @@ export class WebrtcTransport extends Transport<DataChannelContext> {
164
151
  transportType: this.transportType,
165
152
  send: (msg: ChannelMsg) => {
166
153
  const attached = this.#attachedChannels.get(context.remotePeerId)
167
- if (!attached || channel.readyState !== "open") {
168
- return
154
+ if (!attached || channel.readyState !== "open") return
155
+ for (const r of attached.pipeline.send(msg)) {
156
+ if (r.ok) channel.send(r.value)
169
157
  }
170
- const { state, wire } = applyOutboundAliasing(attached.aliasState, msg)
171
- attached.aliasState = state
172
- encodeWireFrameAndSend(
173
- wire,
174
- data => channel.send(data),
175
- this.#fragmentThreshold,
176
- attached.nextFrameId,
177
- )
178
158
  },
179
159
  stop: () => {
180
160
  // Cleanup is handled by detachDataChannel().
@@ -234,8 +214,19 @@ export class WebrtcTransport extends Transport<DataChannelContext> {
234
214
  // ArrayBuffer and Uint8Array regardless.
235
215
  channel.binaryType = "arraybuffer"
236
216
 
237
- // Create reassembler for this data channel
238
- const reassembler = new FragmentReassembler({ timeoutMs: 10_000 })
217
+ // Create pipeline for this data channel
218
+ const pipeline = new Pipeline<"binary">({
219
+ send: "binary",
220
+ opts: {
221
+ threshold: this.#fragmentThreshold,
222
+ reassemblyTimeoutMs: 10_000,
223
+ onError: (e, dir) =>
224
+ console.warn(
225
+ `[webrtc-transport] wire error (${dir}) for peer ${remotePeerId}:`,
226
+ e,
227
+ ),
228
+ },
229
+ })
239
230
 
240
231
  // Event handlers — stored as named functions for removeEventListener
241
232
  const onOpen = () => {
@@ -273,9 +264,7 @@ export class WebrtcTransport extends Transport<DataChannelContext> {
273
264
  remotePeerId,
274
265
  channel,
275
266
  channelId: null,
276
- reassembler,
277
- nextFrameId: createFrameIdCounter(),
278
- aliasState: emptyAliasState(),
267
+ pipeline,
279
268
  cleanup,
280
269
  }
281
270
  this.#attachedChannels.set(remotePeerId, attached)
@@ -304,8 +293,8 @@ export class WebrtcTransport extends Transport<DataChannelContext> {
304
293
  // Remove the sync channel if it exists
305
294
  this.#removeSyncChannel(remotePeerId)
306
295
 
307
- // Dispose the reassembler to clean up timers
308
- attached.reassembler.dispose()
296
+ // Dispose the pipeline to clean up timers and state
297
+ attached.pipeline.dispose()
309
298
 
310
299
  // Remove event listeners from the data channel
311
300
  attached.cleanup()
@@ -387,13 +376,18 @@ export class WebrtcTransport extends Transport<DataChannelContext> {
387
376
  const syncChannel = this.channels.get(attached.channelId)
388
377
  if (!syncChannel) return
389
378
 
390
- // Extract bytes — robust to both ArrayBuffer and Uint8Array
379
+ // Extract bytes — robust to both ArrayBuffer and Uint8Array.
380
+ // Both paths produce Uint8Array<ArrayBuffer> for the pipeline.
391
381
  const raw = event.data
392
- const bytes =
382
+ const bytes: Uint8Array<ArrayBuffer> | null =
393
383
  raw instanceof ArrayBuffer
394
384
  ? new Uint8Array(raw)
395
385
  : raw instanceof Uint8Array
396
- ? raw
386
+ ? new Uint8Array(
387
+ raw.buffer as ArrayBuffer,
388
+ raw.byteOffset,
389
+ raw.byteLength,
390
+ )
397
391
  : null
398
392
 
399
393
  if (!bytes) {
@@ -402,19 +396,8 @@ export class WebrtcTransport extends Transport<DataChannelContext> {
402
396
  }
403
397
 
404
398
  try {
405
- const wires = decodeBinaryWires(bytes, attached.reassembler)
406
- if (!wires) return
407
- for (const wire of wires) {
408
- const result = applyInboundAliasing(attached.aliasState, wire)
409
- attached.aliasState = result.state
410
- if (result.error || !result.msg) {
411
- console.warn(
412
- `[webrtc-transport] alias resolution failed for peer ${remotePeerId}:`,
413
- result.error,
414
- )
415
- continue
416
- }
417
- syncChannel.onReceive(result.msg)
399
+ for (const r of attached.pipeline.receive(bytes)) {
400
+ if (r.ok) syncChannel.onReceive(r.value)
418
401
  }
419
402
  } catch (error) {
420
403
  console.error(