@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.
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/data-channel-like.ts","../src/webrtc-transport.ts"],"mappings":";;;;;;AA6CA;;;;;;;;;;;;;;;;;;AAyDkE;;;;
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
37
|
-
"@kyneta/
|
|
38
|
-
"@kyneta/
|
|
39
|
-
"@kyneta/wire": "^1.
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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"
|
|
35
|
-
|
|
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
|
|
46
|
+
/** Encode a ChannelMsg through the wire pipeline to a binary frame. */
|
|
58
47
|
function encodeViaAlias(msg: ChannelMsg): Uint8Array<ArrayBuffer> {
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
frag.byteOffset
|
|
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
|
|
364
|
-
if (!
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
package/src/webrtc-transport.ts
CHANGED
|
@@ -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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// Uses the shared Pipeline from @kyneta/transport (same as WebSocket):
|
|
8
|
+
// pipeline.send(msg) — outbound: alias → encode → fragment
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
238
|
-
const
|
|
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
|
-
|
|
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
|
|
308
|
-
attached.
|
|
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
|
-
?
|
|
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
|
|
406
|
-
|
|
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(
|