@kyneta/webrtc-transport 1.3.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.
@@ -0,0 +1,434 @@
1
+ // webrtc-transport — BYODC WebRTC data channel transport for @kyneta/exchange.
2
+ //
3
+ // "Bring Your Own Data Channel" design: the application manages WebRTC
4
+ // connections (signaling, ICE, media streams). This transport attaches
5
+ // to already-established data channels for kyneta document sync.
6
+ //
7
+ // Uses the shared binary pipeline from @kyneta/wire (same as WebSocket):
8
+ // encodeBinaryAndSend — outbound: encode → fragment → sendFn
9
+ // decodeBinaryMessages — inbound: reassemble → decode → ChannelMsg[]
10
+ //
11
+ // The transport accepts any object satisfying `DataChannelLike` — a
12
+ // 5-member interface that native RTCDataChannel satisfies structurally
13
+ // and that libraries like simple-peer can conform to via a trivial bridge.
14
+
15
+ import type {
16
+ ChannelId,
17
+ ChannelMsg,
18
+ GeneratedChannel,
19
+ TransportFactory,
20
+ } from "@kyneta/transport"
21
+ import { Transport } from "@kyneta/transport"
22
+ import {
23
+ decodeBinaryMessages,
24
+ encodeBinaryAndSend,
25
+ FragmentReassembler,
26
+ } from "@kyneta/wire"
27
+ import type { DataChannelLike } from "./data-channel-like.js"
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Constants
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Default fragment threshold in bytes.
35
+ *
36
+ * SCTP (the underlying transport for WebRTC data channels) has a message
37
+ * size limit of approximately 256KB. 200KB provides a safe margin.
38
+ *
39
+ * This differs from the WebSocket transport's 100KB default, which
40
+ * targets AWS API Gateway's 128KB limit. WebRTC has no such gateway.
41
+ */
42
+ export const DEFAULT_FRAGMENT_THRESHOLD = 200 * 1024
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Options
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Configuration options for the WebRTC transport.
50
+ */
51
+ export interface WebrtcTransportOptions {
52
+ /**
53
+ * Fragment threshold in bytes. Messages larger than this are fragmented
54
+ * for SCTP compatibility. Set to 0 to disable fragmentation (not recommended).
55
+ *
56
+ * @default 204800 (200KB)
57
+ */
58
+ fragmentThreshold?: number
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Internal types
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Context for each attached data channel — stored per remotePeerId.
67
+ */
68
+ type DataChannelContext = {
69
+ remotePeerId: string
70
+ channel: DataChannelLike
71
+ }
72
+
73
+ /**
74
+ * Internal tracking for an attached data channel.
75
+ */
76
+ type AttachedChannel = {
77
+ remotePeerId: string
78
+ channel: DataChannelLike
79
+ channelId: ChannelId | null
80
+ reassembler: FragmentReassembler
81
+ cleanup: () => void
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // WebrtcTransport
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * WebRTC data channel transport for @kyneta/exchange.
90
+ *
91
+ * Follows a "Bring Your Own Data Channel" (BYODC) design — the application
92
+ * manages WebRTC connections and attaches data channels to this transport
93
+ * for kyneta document synchronization.
94
+ *
95
+ * Uses binary CBOR encoding with transport-level fragmentation via
96
+ * `@kyneta/wire` — the same pipeline as the WebSocket transport.
97
+ *
98
+ * ## Usage
99
+ *
100
+ * ```typescript
101
+ * import { Exchange } from "@kyneta/exchange"
102
+ * import { createWebrtcTransport } from "@kyneta/webrtc-transport"
103
+ *
104
+ * const webrtcTransport = createWebrtcTransport()
105
+ *
106
+ * const exchange = new Exchange({
107
+ * identity: { peerId: "alice", name: "Alice" },
108
+ * transports: [webrtcTransport],
109
+ * })
110
+ *
111
+ * // When a WebRTC connection is established:
112
+ * const cleanup = transport.attachDataChannel(remotePeerId, dataChannel)
113
+ *
114
+ * // When done:
115
+ * cleanup() // or transport.detachDataChannel(remotePeerId)
116
+ * ```
117
+ *
118
+ * ## Ownership
119
+ *
120
+ * The transport does NOT own the data channel. `detachDataChannel()`
121
+ * removes the sync channel and event listeners but does not close the
122
+ * data channel or the peer connection. The application manages the
123
+ * WebRTC connection lifecycle independently.
124
+ */
125
+ export class WebrtcTransport extends Transport<DataChannelContext> {
126
+ /**
127
+ * Map of remotePeerId → attached channel tracking.
128
+ */
129
+ readonly #attachedChannels = new Map<string, AttachedChannel>()
130
+
131
+ /**
132
+ * Fragment threshold in bytes.
133
+ */
134
+ readonly #fragmentThreshold: number
135
+
136
+ constructor(options?: WebrtcTransportOptions) {
137
+ super({ transportType: "webrtc-datachannel" })
138
+ this.#fragmentThreshold =
139
+ options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD
140
+ }
141
+
142
+ // ==========================================================================
143
+ // Transport abstract method implementations
144
+ // ==========================================================================
145
+
146
+ /**
147
+ * Generate a channel for a data channel context.
148
+ *
149
+ * Called internally by the `Transport` base class when `addChannel()` is
150
+ * invoked. Users never call this directly — use `attachDataChannel()`.
151
+ */
152
+ protected generate(context: DataChannelContext): GeneratedChannel {
153
+ const { channel } = context
154
+
155
+ return {
156
+ transportType: this.transportType,
157
+ send: (msg: ChannelMsg) => {
158
+ if (channel.readyState !== "open") {
159
+ return
160
+ }
161
+ encodeBinaryAndSend(msg, this.#fragmentThreshold, data =>
162
+ channel.send(data),
163
+ )
164
+ },
165
+ stop: () => {
166
+ // Cleanup is handled by detachDataChannel().
167
+ // This callback fires when the internal channel is removed.
168
+ },
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Called when the transport starts.
174
+ *
175
+ * No-op for WebRTC — channels are added dynamically via
176
+ * `attachDataChannel()`, not at start time.
177
+ */
178
+ async onStart(): Promise<void> {}
179
+
180
+ /**
181
+ * Called when the transport stops.
182
+ *
183
+ * Detaches all attached data channels and cleans up resources.
184
+ */
185
+ async onStop(): Promise<void> {
186
+ for (const remotePeerId of [...this.#attachedChannels.keys()]) {
187
+ this.detachDataChannel(remotePeerId)
188
+ }
189
+ }
190
+
191
+ // ==========================================================================
192
+ // Public API — data channel management
193
+ // ==========================================================================
194
+
195
+ /**
196
+ * Attach a data channel for a remote peer.
197
+ *
198
+ * Creates an internal sync channel when the data channel is open
199
+ * (or waits for the `"open"` event if still connecting). The sync
200
+ * channel triggers the establishment handshake with the remote peer.
201
+ *
202
+ * If a data channel is already attached for this peer, the old one
203
+ * is detached first.
204
+ *
205
+ * @param remotePeerId - The stable peer ID of the remote peer
206
+ * @param channel - Any object satisfying `DataChannelLike`
207
+ * @returns A cleanup function that calls `detachDataChannel(remotePeerId)`
208
+ */
209
+ attachDataChannel(
210
+ remotePeerId: string,
211
+ channel: DataChannelLike,
212
+ ): () => void {
213
+ // Detach existing channel for this peer if any
214
+ if (this.#attachedChannels.has(remotePeerId)) {
215
+ this.detachDataChannel(remotePeerId)
216
+ }
217
+
218
+ // Best-effort: request arraybuffer mode for incoming data.
219
+ // The message handler doesn't depend on this — it accepts both
220
+ // ArrayBuffer and Uint8Array regardless.
221
+ channel.binaryType = "arraybuffer"
222
+
223
+ // Create reassembler for this data channel
224
+ const reassembler = new FragmentReassembler({ timeoutMs: 10_000 })
225
+
226
+ // Event handlers — stored as named functions for removeEventListener
227
+ const onOpen = () => {
228
+ this.#createSyncChannel(remotePeerId)
229
+ }
230
+
231
+ const onClose = () => {
232
+ this.#removeSyncChannel(remotePeerId)
233
+ }
234
+
235
+ const onError = () => {
236
+ this.#removeSyncChannel(remotePeerId)
237
+ }
238
+
239
+ const onMessage = (event: any) => {
240
+ this.#handleMessage(remotePeerId, event)
241
+ }
242
+
243
+ // Cleanup function to remove all event listeners
244
+ const cleanup = () => {
245
+ channel.removeEventListener("open", onOpen)
246
+ channel.removeEventListener("close", onClose)
247
+ channel.removeEventListener("error", onError)
248
+ channel.removeEventListener("message", onMessage)
249
+ }
250
+
251
+ // Register event listeners
252
+ channel.addEventListener("open", onOpen)
253
+ channel.addEventListener("close", onClose)
254
+ channel.addEventListener("error", onError)
255
+ channel.addEventListener("message", onMessage)
256
+
257
+ // Track the attached channel
258
+ const attached: AttachedChannel = {
259
+ remotePeerId,
260
+ channel,
261
+ channelId: null,
262
+ reassembler,
263
+ cleanup,
264
+ }
265
+ this.#attachedChannels.set(remotePeerId, attached)
266
+
267
+ // If the channel is already open, create the sync channel immediately
268
+ if (channel.readyState === "open") {
269
+ this.#createSyncChannel(remotePeerId)
270
+ }
271
+
272
+ return () => this.detachDataChannel(remotePeerId)
273
+ }
274
+
275
+ /**
276
+ * Detach a data channel for a remote peer.
277
+ *
278
+ * Removes the sync channel, cleans up event listeners, and disposes
279
+ * the reassembler. Does NOT close the data channel — the application
280
+ * manages the WebRTC connection lifecycle.
281
+ *
282
+ * @param remotePeerId - The peer ID to detach
283
+ */
284
+ detachDataChannel(remotePeerId: string): void {
285
+ const attached = this.#attachedChannels.get(remotePeerId)
286
+ if (!attached) return
287
+
288
+ // Remove the sync channel if it exists
289
+ this.#removeSyncChannel(remotePeerId)
290
+
291
+ // Dispose the reassembler to clean up timers
292
+ attached.reassembler.dispose()
293
+
294
+ // Remove event listeners from the data channel
295
+ attached.cleanup()
296
+
297
+ // Remove from tracking
298
+ this.#attachedChannels.delete(remotePeerId)
299
+ }
300
+
301
+ /**
302
+ * Check if a data channel is attached for a peer.
303
+ */
304
+ hasDataChannel(remotePeerId: string): boolean {
305
+ return this.#attachedChannels.has(remotePeerId)
306
+ }
307
+
308
+ /**
309
+ * Get all peer IDs with attached data channels.
310
+ */
311
+ getAttachedPeerIds(): string[] {
312
+ return [...this.#attachedChannels.keys()]
313
+ }
314
+
315
+ // ==========================================================================
316
+ // Internal — sync channel lifecycle
317
+ // ==========================================================================
318
+
319
+ /**
320
+ * Create an internal sync channel for an attached data channel.
321
+ *
322
+ * Called when the data channel's `"open"` event fires (or immediately
323
+ * if already open on attach). The sync channel is registered with the
324
+ * Transport base class, which triggers the establishment handshake.
325
+ */
326
+ #createSyncChannel(remotePeerId: string): void {
327
+ const attached = this.#attachedChannels.get(remotePeerId)
328
+ if (!attached) return
329
+
330
+ // Don't create if already exists
331
+ if (attached.channelId !== null) return
332
+
333
+ // addChannel() creates and registers the sync channel
334
+ const syncChannel = this.addChannel({
335
+ remotePeerId,
336
+ channel: attached.channel,
337
+ })
338
+ attached.channelId = syncChannel.channelId
339
+
340
+ // Start the establishment handshake
341
+ this.establishChannel(syncChannel.channelId)
342
+ }
343
+
344
+ /**
345
+ * Remove the internal sync channel for a peer.
346
+ */
347
+ #removeSyncChannel(remotePeerId: string): void {
348
+ const attached = this.#attachedChannels.get(remotePeerId)
349
+ if (!attached || attached.channelId === null) return
350
+
351
+ this.removeChannel(attached.channelId)
352
+ attached.channelId = null
353
+ }
354
+
355
+ // ==========================================================================
356
+ // Internal — message handling
357
+ // ==========================================================================
358
+
359
+ /**
360
+ * Handle an incoming message from a data channel.
361
+ *
362
+ * Extracts binary data from the event, feeding both `ArrayBuffer`
363
+ * (native RTCDataChannel with binaryType "arraybuffer") and
364
+ * `Uint8Array` (simple-peer and other wrappers) into the shared
365
+ * decode pipeline.
366
+ */
367
+ #handleMessage(remotePeerId: string, event: any): void {
368
+ const attached = this.#attachedChannels.get(remotePeerId)
369
+ if (!attached || attached.channelId === null) return
370
+
371
+ const syncChannel = this.channels.get(attached.channelId)
372
+ if (!syncChannel) return
373
+
374
+ // Extract bytes — robust to both ArrayBuffer and Uint8Array
375
+ const raw = event.data
376
+ const bytes =
377
+ raw instanceof ArrayBuffer
378
+ ? new Uint8Array(raw)
379
+ : raw instanceof Uint8Array
380
+ ? raw
381
+ : null
382
+
383
+ if (!bytes) {
384
+ // Unexpected data type (e.g. string) — ignore silently
385
+ return
386
+ }
387
+
388
+ try {
389
+ const messages = decodeBinaryMessages(bytes, attached.reassembler)
390
+ if (messages) {
391
+ for (const msg of messages) {
392
+ syncChannel.onReceive(msg)
393
+ }
394
+ }
395
+ } catch (error) {
396
+ console.error(
397
+ `[webrtc-transport] Failed to decode message from peer ${remotePeerId}:`,
398
+ error,
399
+ )
400
+ }
401
+ }
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Factory function
406
+ // ---------------------------------------------------------------------------
407
+
408
+ /**
409
+ * Create a WebRTC transport factory for use with `Exchange`.
410
+ *
411
+ * Returns a `TransportFactory` — pass directly to
412
+ * `Exchange({ transports: [...] })`. The returned transport instance
413
+ * exposes `attachDataChannel()` / `detachDataChannel()` for BYODC
414
+ * data channel management.
415
+ *
416
+ * To access the transport instance after creation, use
417
+ * `exchange.getTransport("webrtc-datachannel")`.
418
+ *
419
+ * @example
420
+ * ```typescript
421
+ * import { Exchange } from "@kyneta/exchange"
422
+ * import { createWebrtcTransport } from "@kyneta/webrtc-transport"
423
+ *
424
+ * const exchange = new Exchange({
425
+ * identity: { peerId: "alice", name: "Alice" },
426
+ * transports: [createWebrtcTransport()],
427
+ * })
428
+ * ```
429
+ */
430
+ export function createWebrtcTransport(
431
+ options?: WebrtcTransportOptions,
432
+ ): TransportFactory {
433
+ return () => new WebrtcTransport(options)
434
+ }