@kyneta/webrtc-transport 1.4.0 → 1.5.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 +1 -1
- package/dist/index.js +19 -5
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/webrtc-transport.test.ts +26 -18
- package/src/webrtc-transport.ts +33 -9
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;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/data-channel-like.ts","../src/webrtc-transport.ts"],"mappings":";;;;;;AA6CA;;;;;;;;;;;;;;;;;;;;;;ACCA;UDDiB,eAAA;;;;ACUjB;;;;;AAQC;;;WDNU,UAAA;ECgBT;;;;;AA2DF;;;;;;ED9DE,UAAA;ECyHiB;;;;;;;;ED/GjB,IAAA,CAAK,IAAA,EAAM,UAAA;;;;;;;;;;;;EAaX,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;;;;;;ECxCpB,iBAAA;AAAA;AAhBF;;;AAAA,KA0BK,kBAAA;EACH,YAAA;EACA,OAAA,EAAS,eAAA;AAAA;;;;AAXV;;;;;;;;;AAqED;;;;;;;;;;;;;;;;;;;;;;;;;cAAa,eAAA,SAAwB,SAAA,CAAU,kBAAA;EAAA;cAWjC,OAAA,GAAU,sBAAA;EAgFpB;;;;;;EAAA,UAhEQ,QAAA,CAAS,OAAA,EAAS,kBAAA,GAAqB,gBAAA;EAuKjD;;;AA+HF;;;EAtQQ,OAAA,CAAA,GAAW,OAAA;EAuQP;;;;;EAhQJ,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,gBAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Transport } from "@kyneta/transport";
|
|
2
|
-
import { FragmentReassembler,
|
|
2
|
+
import { FragmentReassembler, applyInboundAliasing, applyOutboundAliasing, createFrameIdCounter, decodeBinaryWires, emptyAliasState, encodeWireFrameAndSend } from "@kyneta/wire";
|
|
3
3
|
//#region src/webrtc-transport.ts
|
|
4
4
|
/**
|
|
5
5
|
* Default fragment threshold in bytes.
|
|
@@ -72,8 +72,11 @@ var WebrtcTransport = class extends Transport {
|
|
|
72
72
|
return {
|
|
73
73
|
transportType: this.transportType,
|
|
74
74
|
send: (msg) => {
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
const attached = this.#attachedChannels.get(context.remotePeerId);
|
|
76
|
+
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);
|
|
77
80
|
},
|
|
78
81
|
stop: () => {}
|
|
79
82
|
};
|
|
@@ -138,6 +141,8 @@ var WebrtcTransport = class extends Transport {
|
|
|
138
141
|
channel,
|
|
139
142
|
channelId: null,
|
|
140
143
|
reassembler,
|
|
144
|
+
nextFrameId: createFrameIdCounter(),
|
|
145
|
+
aliasState: emptyAliasState(),
|
|
141
146
|
cleanup
|
|
142
147
|
};
|
|
143
148
|
this.#attachedChannels.set(remotePeerId, attached);
|
|
@@ -217,8 +222,17 @@ var WebrtcTransport = class extends Transport {
|
|
|
217
222
|
const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw instanceof Uint8Array ? raw : null;
|
|
218
223
|
if (!bytes) return;
|
|
219
224
|
try {
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
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
|
+
}
|
|
222
236
|
} catch (error) {
|
|
223
237
|
console.error(`[webrtc-transport] Failed to decode message from peer ${remotePeerId}:`, error);
|
|
224
238
|
}
|
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 decodeBinaryMessages,\n encodeBinaryAndSend,\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 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 if (channel.readyState !== \"open\") {\n return\n }\n encodeBinaryAndSend(msg, this.#fragmentThreshold, data =>\n channel.send(data),\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 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 messages = decodeBinaryMessages(bytes, attached.reassembler)\n if (messages) {\n for (const msg of messages) {\n syncChannel.onReceive(msg)\n }\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":";;;;;;;;;;;;AAyCA,MAAa,6BAA6B,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmFhD,IAAa,kBAAb,cAAqC,UAA8B;;;;CAIjE,oCAA6B,IAAI,KAA8B;;;;CAK/D;CAEA,YAAY,SAAkC;AAC5C,QAAM,EAAE,eAAe,sBAAsB,CAAC;AAC9C,QAAA,oBACE,SAAS,qBAAA;;;;;;;;CAab,SAAmB,SAA+C;EAChE,MAAM,EAAE,YAAY;AAEpB,SAAO;GACL,eAAe,KAAK;GACpB,OAAO,QAAoB;AACzB,QAAI,QAAQ,eAAe,OACzB;AAEF,wBAAoB,KAAK,MAAA,oBAAyB,SAChD,QAAQ,KAAK,KAAK,CACnB;;GAEH,YAAY;GAIb;;;;;;;;CASH,MAAM,UAAyB;;;;;;CAO/B,MAAM,SAAwB;AAC5B,OAAK,MAAM,gBAAgB,CAAC,GAAG,MAAA,iBAAuB,MAAM,CAAC,CAC3D,MAAK,kBAAkB,aAAa;;;;;;;;;;;;;;;;CAsBxC,kBACE,cACA,SACY;AAEZ,MAAI,MAAA,iBAAuB,IAAI,aAAa,CAC1C,MAAK,kBAAkB,aAAa;AAMtC,UAAQ,aAAa;EAGrB,MAAM,cAAc,IAAI,oBAAoB,EAAE,WAAW,KAAQ,CAAC;EAGlE,MAAM,eAAe;AACnB,SAAA,kBAAwB,aAAa;;EAGvC,MAAM,gBAAgB;AACpB,SAAA,kBAAwB,aAAa;;EAGvC,MAAM,gBAAgB;AACpB,SAAA,kBAAwB,aAAa;;EAGvC,MAAM,aAAa,UAAe;AAChC,SAAA,cAAoB,cAAc,MAAM;;EAI1C,MAAM,gBAAgB;AACpB,WAAQ,oBAAoB,QAAQ,OAAO;AAC3C,WAAQ,oBAAoB,SAAS,QAAQ;AAC7C,WAAQ,oBAAoB,SAAS,QAAQ;AAC7C,WAAQ,oBAAoB,WAAW,UAAU;;AAInD,UAAQ,iBAAiB,QAAQ,OAAO;AACxC,UAAQ,iBAAiB,SAAS,QAAQ;AAC1C,UAAQ,iBAAiB,SAAS,QAAQ;AAC1C,UAAQ,iBAAiB,WAAW,UAAU;EAG9C,MAAM,WAA4B;GAChC;GACA;GACA,WAAW;GACX;GACA;GACD;AACD,QAAA,iBAAuB,IAAI,cAAc,SAAS;AAGlD,MAAI,QAAQ,eAAe,OACzB,OAAA,kBAAwB,aAAa;AAGvC,eAAa,KAAK,kBAAkB,aAAa;;;;;;;;;;;CAYnD,kBAAkB,cAA4B;EAC5C,MAAM,WAAW,MAAA,iBAAuB,IAAI,aAAa;AACzD,MAAI,CAAC,SAAU;AAGf,QAAA,kBAAwB,aAAa;AAGrC,WAAS,YAAY,SAAS;AAG9B,WAAS,SAAS;AAGlB,QAAA,iBAAuB,OAAO,aAAa;;;;;CAM7C,eAAe,cAA+B;AAC5C,SAAO,MAAA,iBAAuB,IAAI,aAAa;;;;;CAMjD,qBAA+B;AAC7B,SAAO,CAAC,GAAG,MAAA,iBAAuB,MAAM,CAAC;;;;;;;;;CAc3C,mBAAmB,cAA4B;EAC7C,MAAM,WAAW,MAAA,iBAAuB,IAAI,aAAa;AACzD,MAAI,CAAC,SAAU;AAGf,MAAI,SAAS,cAAc,KAAM;EAGjC,MAAM,cAAc,KAAK,WAAW;GAClC;GACA,SAAS,SAAS;GACnB,CAAC;AACF,WAAS,YAAY,YAAY;AAGjC,OAAK,iBAAiB,YAAY,UAAU;;;;;CAM9C,mBAAmB,cAA4B;EAC7C,MAAM,WAAW,MAAA,iBAAuB,IAAI,aAAa;AACzD,MAAI,CAAC,YAAY,SAAS,cAAc,KAAM;AAE9C,OAAK,cAAc,SAAS,UAAU;AACtC,WAAS,YAAY;;;;;;;;;;CAevB,eAAe,cAAsB,OAAkB;EACrD,MAAM,WAAW,MAAA,iBAAuB,IAAI,aAAa;AACzD,MAAI,CAAC,YAAY,SAAS,cAAc,KAAM;EAE9C,MAAM,cAAc,KAAK,SAAS,IAAI,SAAS,UAAU;AACzD,MAAI,CAAC,YAAa;EAGlB,MAAM,MAAM,MAAM;EAClB,MAAM,QACJ,eAAe,cACX,IAAI,WAAW,IAAI,GACnB,eAAe,aACb,MACA;AAER,MAAI,CAAC,MAEH;AAGF,MAAI;GACF,MAAM,WAAW,qBAAqB,OAAO,SAAS,YAAY;AAClE,OAAI,SACF,MAAK,MAAM,OAAO,SAChB,aAAY,UAAU,IAAI;WAGvB,OAAO;AACd,WAAQ,MACN,yDAAyD,aAAa,IACtE,MACD;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BP,SAAgB,sBACd,SACkB;AAClB,cAAa,IAAI,gBAAgB,QAAQ"}
|
|
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":";;;;;;;;;;;;AA8CA,MAAa,6BAA6B,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsFhD,IAAa,kBAAb,cAAqC,UAA8B;;;;CAIjE,oCAA6B,IAAI,KAA8B;;;;CAK/D;CAEA,YAAY,SAAkC;AAC5C,QAAM,EAAE,eAAe,sBAAsB,CAAC;AAC9C,QAAA,oBACE,SAAS,qBAAA;;;;;;;;CAab,SAAmB,SAA+C;EAChE,MAAM,EAAE,YAAY;AAEpB,SAAO;GACL,eAAe,KAAK;GACpB,OAAO,QAAoB;IACzB,MAAM,WAAW,MAAA,iBAAuB,IAAI,QAAQ,aAAa;AACjE,QAAI,CAAC,YAAY,QAAQ,eAAe,OACtC;IAEF,MAAM,EAAE,OAAO,SAAS,sBAAsB,SAAS,YAAY,IAAI;AACvE,aAAS,aAAa;AACtB,2BACE,OACA,SAAQ,QAAQ,KAAK,KAAK,EAC1B,MAAA,mBACA,SAAS,YACV;;GAEH,YAAY;GAIb;;;;;;;;CASH,MAAM,UAAyB;;;;;;CAO/B,MAAM,SAAwB;AAC5B,OAAK,MAAM,gBAAgB,CAAC,GAAG,MAAA,iBAAuB,MAAM,CAAC,CAC3D,MAAK,kBAAkB,aAAa;;;;;;;;;;;;;;;;CAsBxC,kBACE,cACA,SACY;AAEZ,MAAI,MAAA,iBAAuB,IAAI,aAAa,CAC1C,MAAK,kBAAkB,aAAa;AAMtC,UAAQ,aAAa;EAGrB,MAAM,cAAc,IAAI,oBAAoB,EAAE,WAAW,KAAQ,CAAC;EAGlE,MAAM,eAAe;AACnB,SAAA,kBAAwB,aAAa;;EAGvC,MAAM,gBAAgB;AACpB,SAAA,kBAAwB,aAAa;;EAGvC,MAAM,gBAAgB;AACpB,SAAA,kBAAwB,aAAa;;EAGvC,MAAM,aAAa,UAAe;AAChC,SAAA,cAAoB,cAAc,MAAM;;EAI1C,MAAM,gBAAgB;AACpB,WAAQ,oBAAoB,QAAQ,OAAO;AAC3C,WAAQ,oBAAoB,SAAS,QAAQ;AAC7C,WAAQ,oBAAoB,SAAS,QAAQ;AAC7C,WAAQ,oBAAoB,WAAW,UAAU;;AAInD,UAAQ,iBAAiB,QAAQ,OAAO;AACxC,UAAQ,iBAAiB,SAAS,QAAQ;AAC1C,UAAQ,iBAAiB,SAAS,QAAQ;AAC1C,UAAQ,iBAAiB,WAAW,UAAU;EAG9C,MAAM,WAA4B;GAChC;GACA;GACA,WAAW;GACX;GACA,aAAa,sBAAsB;GACnC,YAAY,iBAAiB;GAC7B;GACD;AACD,QAAA,iBAAuB,IAAI,cAAc,SAAS;AAGlD,MAAI,QAAQ,eAAe,OACzB,OAAA,kBAAwB,aAAa;AAGvC,eAAa,KAAK,kBAAkB,aAAa;;;;;;;;;;;CAYnD,kBAAkB,cAA4B;EAC5C,MAAM,WAAW,MAAA,iBAAuB,IAAI,aAAa;AACzD,MAAI,CAAC,SAAU;AAGf,QAAA,kBAAwB,aAAa;AAGrC,WAAS,YAAY,SAAS;AAG9B,WAAS,SAAS;AAGlB,QAAA,iBAAuB,OAAO,aAAa;;;;;CAM7C,eAAe,cAA+B;AAC5C,SAAO,MAAA,iBAAuB,IAAI,aAAa;;;;;CAMjD,qBAA+B;AAC7B,SAAO,CAAC,GAAG,MAAA,iBAAuB,MAAM,CAAC;;;;;;;;;CAc3C,mBAAmB,cAA4B;EAC7C,MAAM,WAAW,MAAA,iBAAuB,IAAI,aAAa;AACzD,MAAI,CAAC,SAAU;AAGf,MAAI,SAAS,cAAc,KAAM;EAGjC,MAAM,cAAc,KAAK,WAAW;GAClC;GACA,SAAS,SAAS;GACnB,CAAC;AACF,WAAS,YAAY,YAAY;AAGjC,OAAK,iBAAiB,YAAY,UAAU;;;;;CAM9C,mBAAmB,cAA4B;EAC7C,MAAM,WAAW,MAAA,iBAAuB,IAAI,aAAa;AACzD,MAAI,CAAC,YAAY,SAAS,cAAc,KAAM;AAE9C,OAAK,cAAc,SAAS,UAAU;AACtC,WAAS,YAAY;;;;;;;;;;CAevB,eAAe,cAAsB,OAAkB;EACrD,MAAM,WAAW,MAAA,iBAAuB,IAAI,aAAa;AACzD,MAAI,CAAC,YAAY,SAAS,cAAc,KAAM;EAE9C,MAAM,cAAc,KAAK,SAAS,IAAI,SAAS,UAAU;AACzD,MAAI,CAAC,YAAa;EAGlB,MAAM,MAAM,MAAM;EAClB,MAAM,QACJ,eAAe,cACX,IAAI,WAAW,IAAI,GACnB,eAAe,aACb,MACA;AAER,MAAI,CAAC,MAEH;AAGF,MAAI;GACF,MAAM,QAAQ,kBAAkB,OAAO,SAAS,YAAY;AAC5D,OAAI,CAAC,MAAO;AACZ,QAAK,MAAM,QAAQ,OAAO;IACxB,MAAM,SAAS,qBAAqB,SAAS,YAAY,KAAK;AAC9D,aAAS,aAAa,OAAO;AAC7B,QAAI,OAAO,SAAS,CAAC,OAAO,KAAK;AAC/B,aAAQ,KACN,uDAAuD,aAAa,IACpE,OAAO,MACR;AACD;;AAEF,gBAAY,UAAU,OAAO,IAAI;;WAE5B,OAAO;AACd,WAAQ,MACN,yDAAyD,aAAa,IACtE,MACD;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BP,SAAgB,sBACd,SACkB;AAClB,cAAa,IAAI,gBAAgB,QAAQ"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyneta/webrtc-transport",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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",
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"./src/*": "./src/*"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
|
-
"@kyneta/transport": "^1.
|
|
29
|
-
"@kyneta/wire": "^1.
|
|
28
|
+
"@kyneta/transport": "^1.5.0",
|
|
29
|
+
"@kyneta/wire": "^1.5.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^22",
|
|
33
33
|
"tsdown": "^0.21.9",
|
|
34
34
|
"typescript": "^5.9.2",
|
|
35
35
|
"vitest": "^4.0.17",
|
|
36
|
-
"@kyneta/exchange": "^1.
|
|
37
|
-
"@kyneta/wire": "^1.
|
|
38
|
-
"@kyneta/schema": "^1.
|
|
39
|
-
"@kyneta/transport": "^1.
|
|
36
|
+
"@kyneta/exchange": "^1.5.0",
|
|
37
|
+
"@kyneta/wire": "^1.5.0",
|
|
38
|
+
"@kyneta/schema": "^1.5.0",
|
|
39
|
+
"@kyneta/transport": "^1.5.0"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsdown",
|
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ChannelMsg } from "@kyneta/transport"
|
|
8
8
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
applyOutboundAliasing,
|
|
10
|
+
complete,
|
|
11
|
+
emptyAliasState,
|
|
12
|
+
encodeBinaryFrame,
|
|
13
|
+
encodeWireMessage,
|
|
11
14
|
fragmentPayload,
|
|
12
|
-
|
|
15
|
+
WIRE_VERSION,
|
|
13
16
|
} from "@kyneta/wire"
|
|
14
17
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
15
18
|
import { WebrtcTransport } from "../webrtc-transport.js"
|
|
@@ -51,6 +54,14 @@ const TEST_MSG: ChannelMsg = {
|
|
|
51
54
|
identity: { peerId: "remote", name: "R", type: "user" },
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
/** Encode a ChannelMsg through the alias-aware pipeline. */
|
|
58
|
+
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))
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
// ---------------------------------------------------------------------------
|
|
55
66
|
// 1. Lifecycle
|
|
56
67
|
// ---------------------------------------------------------------------------
|
|
@@ -193,14 +204,13 @@ describe("Receive", () => {
|
|
|
193
204
|
const dc = new MockDataChannel("open")
|
|
194
205
|
transport.attachDataChannel("peer-1", dc)
|
|
195
206
|
|
|
196
|
-
// Encode a test message through the
|
|
197
|
-
const encoded =
|
|
198
|
-
const wrapped = wrapCompleteMessage(encoded)
|
|
207
|
+
// Encode a test message through the alias-aware pipeline
|
|
208
|
+
const encoded = encodeViaAlias(TEST_MSG)
|
|
199
209
|
|
|
200
210
|
// Convert to ArrayBuffer (as native RTCDataChannel with binaryType "arraybuffer" would deliver)
|
|
201
|
-
const ab =
|
|
202
|
-
|
|
203
|
-
|
|
211
|
+
const ab = encoded.buffer.slice(
|
|
212
|
+
encoded.byteOffset,
|
|
213
|
+
encoded.byteOffset + encoded.byteLength,
|
|
204
214
|
)
|
|
205
215
|
|
|
206
216
|
dc.emit("message", { data: ab })
|
|
@@ -218,11 +228,10 @@ describe("Receive", () => {
|
|
|
218
228
|
const dc = new MockDataChannel("open")
|
|
219
229
|
transport.attachDataChannel("peer-1", dc)
|
|
220
230
|
|
|
221
|
-
const encoded =
|
|
222
|
-
const wrapped = wrapCompleteMessage(encoded)
|
|
231
|
+
const encoded = encodeViaAlias(TEST_MSG)
|
|
223
232
|
|
|
224
233
|
// Pass Uint8Array directly (as simple-peer and other wrappers may deliver)
|
|
225
|
-
dc.emit("message", { data:
|
|
234
|
+
dc.emit("message", { data: encoded })
|
|
226
235
|
|
|
227
236
|
expect(ctx.onChannelReceive).toHaveBeenCalled()
|
|
228
237
|
const callArgs = ctx.onChannelReceive.mock.calls.at(0)
|
|
@@ -334,8 +343,8 @@ describe("Fragmentation", () => {
|
|
|
334
343
|
},
|
|
335
344
|
}
|
|
336
345
|
|
|
337
|
-
const encoded =
|
|
338
|
-
const fragments = fragmentPayload(encoded, 50)
|
|
346
|
+
const encoded = encodeViaAlias(largeMsg)
|
|
347
|
+
const fragments = fragmentPayload(encoded, 50, 1)
|
|
339
348
|
expect(fragments.length).toBeGreaterThan(1)
|
|
340
349
|
|
|
341
350
|
// Emit all but the last fragment — should NOT trigger receive yet
|
|
@@ -466,9 +475,8 @@ describe("Message before open (race condition)", () => {
|
|
|
466
475
|
expect(transport.channels.size).toBe(0)
|
|
467
476
|
|
|
468
477
|
// Simulate a message arriving before the open event
|
|
469
|
-
const encoded =
|
|
470
|
-
|
|
471
|
-
dc.emit("message", { data: wrapped })
|
|
478
|
+
const encoded = encodeViaAlias(TEST_MSG)
|
|
479
|
+
dc.emit("message", { data: encoded })
|
|
472
480
|
|
|
473
481
|
// Message should be silently dropped — no delivery, no error
|
|
474
482
|
expect(ctx.onChannelReceive).not.toHaveBeenCalled()
|
|
@@ -477,7 +485,7 @@ describe("Message before open (race condition)", () => {
|
|
|
477
485
|
dc.open()
|
|
478
486
|
expect(transport.channels.size).toBe(1)
|
|
479
487
|
|
|
480
|
-
dc.emit("message", { data:
|
|
488
|
+
dc.emit("message", { data: encoded })
|
|
481
489
|
expect(ctx.onChannelReceive).toHaveBeenCalledOnce()
|
|
482
490
|
})
|
|
483
491
|
})
|
package/src/webrtc-transport.ts
CHANGED
|
@@ -20,8 +20,13 @@ import type {
|
|
|
20
20
|
} from "@kyneta/transport"
|
|
21
21
|
import { Transport } from "@kyneta/transport"
|
|
22
22
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
type AliasState,
|
|
24
|
+
applyInboundAliasing,
|
|
25
|
+
applyOutboundAliasing,
|
|
26
|
+
createFrameIdCounter,
|
|
27
|
+
decodeBinaryWires,
|
|
28
|
+
emptyAliasState,
|
|
29
|
+
encodeWireFrameAndSend,
|
|
25
30
|
FragmentReassembler,
|
|
26
31
|
} from "@kyneta/wire"
|
|
27
32
|
import type { DataChannelLike } from "./data-channel-like.js"
|
|
@@ -78,6 +83,9 @@ type AttachedChannel = {
|
|
|
78
83
|
channel: DataChannelLike
|
|
79
84
|
channelId: ChannelId | null
|
|
80
85
|
reassembler: FragmentReassembler
|
|
86
|
+
nextFrameId: () => number
|
|
87
|
+
/** Per-channel alias state (Phase 4). */
|
|
88
|
+
aliasState: AliasState
|
|
81
89
|
cleanup: () => void
|
|
82
90
|
}
|
|
83
91
|
|
|
@@ -155,11 +163,17 @@ export class WebrtcTransport extends Transport<DataChannelContext> {
|
|
|
155
163
|
return {
|
|
156
164
|
transportType: this.transportType,
|
|
157
165
|
send: (msg: ChannelMsg) => {
|
|
158
|
-
|
|
166
|
+
const attached = this.#attachedChannels.get(context.remotePeerId)
|
|
167
|
+
if (!attached || channel.readyState !== "open") {
|
|
159
168
|
return
|
|
160
169
|
}
|
|
161
|
-
|
|
162
|
-
|
|
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,
|
|
163
177
|
)
|
|
164
178
|
},
|
|
165
179
|
stop: () => {
|
|
@@ -260,6 +274,8 @@ export class WebrtcTransport extends Transport<DataChannelContext> {
|
|
|
260
274
|
channel,
|
|
261
275
|
channelId: null,
|
|
262
276
|
reassembler,
|
|
277
|
+
nextFrameId: createFrameIdCounter(),
|
|
278
|
+
aliasState: emptyAliasState(),
|
|
263
279
|
cleanup,
|
|
264
280
|
}
|
|
265
281
|
this.#attachedChannels.set(remotePeerId, attached)
|
|
@@ -386,11 +402,19 @@ export class WebrtcTransport extends Transport<DataChannelContext> {
|
|
|
386
402
|
}
|
|
387
403
|
|
|
388
404
|
try {
|
|
389
|
-
const
|
|
390
|
-
if (
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
393
416
|
}
|
|
417
|
+
syncChannel.onReceive(result.msg)
|
|
394
418
|
}
|
|
395
419
|
} catch (error) {
|
|
396
420
|
console.error(
|