@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.
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.js +228 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/src/__tests__/mock-data-channel.ts +94 -0
- package/src/__tests__/simple-peer-bridge.test.ts +197 -0
- package/src/__tests__/webrtc-transport.test.ts +517 -0
- package/src/data-channel-like.ts +104 -0
- package/src/index.ts +16 -0
- package/src/webrtc-transport.ts +434 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// webrtc-transport.test — unit tests for WebrtcTransport.
|
|
2
|
+
//
|
|
3
|
+
// Tests the BYODC (Bring Your Own Data Channel) WebRTC transport
|
|
4
|
+
// lifecycle, send/receive pipelines, fragmentation, and multi-peer
|
|
5
|
+
// channel management.
|
|
6
|
+
|
|
7
|
+
import type { ChannelMsg } from "@kyneta/transport"
|
|
8
|
+
import {
|
|
9
|
+
cborCodec,
|
|
10
|
+
encodeComplete,
|
|
11
|
+
fragmentPayload,
|
|
12
|
+
wrapCompleteMessage,
|
|
13
|
+
} from "@kyneta/wire"
|
|
14
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
15
|
+
import { WebrtcTransport } from "../webrtc-transport.js"
|
|
16
|
+
import { MockDataChannel } from "./mock-data-channel.js"
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Initialize and start a WebrtcTransport for testing.
|
|
24
|
+
*
|
|
25
|
+
* Wires up the full Transport lifecycle (construct → initialize → start)
|
|
26
|
+
* with stubbed callbacks. Returns the spies so tests can assert on
|
|
27
|
+
* channel lifecycle events and message delivery.
|
|
28
|
+
*/
|
|
29
|
+
function createContext() {
|
|
30
|
+
return {
|
|
31
|
+
identity: { peerId: "local-peer", name: "Local", type: "user" as const },
|
|
32
|
+
onChannelReceive: vi.fn(),
|
|
33
|
+
onChannelAdded: vi.fn(),
|
|
34
|
+
onChannelRemoved: vi.fn(),
|
|
35
|
+
onChannelEstablish: vi.fn(),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function initializeTransport(
|
|
40
|
+
transport: WebrtcTransport,
|
|
41
|
+
ctx = createContext(),
|
|
42
|
+
) {
|
|
43
|
+
transport._initialize(ctx)
|
|
44
|
+
await transport._start()
|
|
45
|
+
return ctx
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A minimal establish-request message for send/receive tests. */
|
|
49
|
+
const TEST_MSG: ChannelMsg = {
|
|
50
|
+
type: "establish-request",
|
|
51
|
+
identity: { peerId: "remote", name: "R", type: "user" },
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// 1. Lifecycle
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe("Lifecycle", () => {
|
|
59
|
+
let transport: WebrtcTransport
|
|
60
|
+
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
transport = new WebrtcTransport()
|
|
63
|
+
await initializeTransport(transport)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("attach creates internal channel when data channel is already open", () => {
|
|
67
|
+
const dc = new MockDataChannel("open")
|
|
68
|
+
transport.attachDataChannel("peer-1", dc)
|
|
69
|
+
|
|
70
|
+
expect(transport.hasDataChannel("peer-1")).toBe(true)
|
|
71
|
+
expect(transport.getAttachedPeerIds()).toContain("peer-1")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("attach waits for open event when connecting", () => {
|
|
75
|
+
const dc = new MockDataChannel("connecting")
|
|
76
|
+
transport.attachDataChannel("peer-1", dc)
|
|
77
|
+
|
|
78
|
+
expect(transport.hasDataChannel("peer-1")).toBe(true)
|
|
79
|
+
// Sync channel not yet created — still connecting
|
|
80
|
+
expect(transport.channels.size).toBe(0)
|
|
81
|
+
|
|
82
|
+
dc.open()
|
|
83
|
+
// Now the sync channel should exist
|
|
84
|
+
expect(transport.channels.size).toBe(1)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("detach removes the attached channel", () => {
|
|
88
|
+
const dc = new MockDataChannel("open")
|
|
89
|
+
transport.attachDataChannel("peer-1", dc)
|
|
90
|
+
expect(transport.hasDataChannel("peer-1")).toBe(true)
|
|
91
|
+
|
|
92
|
+
transport.detachDataChannel("peer-1")
|
|
93
|
+
expect(transport.hasDataChannel("peer-1")).toBe(false)
|
|
94
|
+
expect(transport.getAttachedPeerIds()).toEqual([])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("detach cleans up event listeners", () => {
|
|
98
|
+
const dc = new MockDataChannel("open")
|
|
99
|
+
transport.attachDataChannel("peer-1", dc)
|
|
100
|
+
expect(dc.hasListeners()).toBe(true)
|
|
101
|
+
|
|
102
|
+
transport.detachDataChannel("peer-1")
|
|
103
|
+
expect(dc.hasListeners()).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("double-attach detaches old channel first", () => {
|
|
107
|
+
const dc1 = new MockDataChannel("open")
|
|
108
|
+
const dc2 = new MockDataChannel("open")
|
|
109
|
+
|
|
110
|
+
transport.attachDataChannel("peer-1", dc1)
|
|
111
|
+
transport.attachDataChannel("peer-1", dc2)
|
|
112
|
+
|
|
113
|
+
// Old channel's listeners should be cleaned up
|
|
114
|
+
expect(dc1.hasListeners()).toBe(false)
|
|
115
|
+
// New channel should be attached
|
|
116
|
+
expect(transport.hasDataChannel("peer-1")).toBe(true)
|
|
117
|
+
expect(dc2.hasListeners()).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("onStop detaches all channels", async () => {
|
|
121
|
+
const dc1 = new MockDataChannel("open")
|
|
122
|
+
const dc2 = new MockDataChannel("open")
|
|
123
|
+
|
|
124
|
+
transport.attachDataChannel("peer-a", dc1)
|
|
125
|
+
transport.attachDataChannel("peer-b", dc2)
|
|
126
|
+
expect(transport.getAttachedPeerIds()).toHaveLength(2)
|
|
127
|
+
|
|
128
|
+
await transport._stop()
|
|
129
|
+
|
|
130
|
+
expect(transport.hasDataChannel("peer-a")).toBe(false)
|
|
131
|
+
expect(transport.hasDataChannel("peer-b")).toBe(false)
|
|
132
|
+
expect(dc1.hasListeners()).toBe(false)
|
|
133
|
+
expect(dc2.hasListeners()).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("cleanup function returned by attach calls detach", () => {
|
|
137
|
+
const dc = new MockDataChannel("open")
|
|
138
|
+
const cleanup = transport.attachDataChannel("peer-1", dc)
|
|
139
|
+
|
|
140
|
+
expect(transport.hasDataChannel("peer-1")).toBe(true)
|
|
141
|
+
cleanup()
|
|
142
|
+
expect(transport.hasDataChannel("peer-1")).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("detach is idempotent for unknown peer", () => {
|
|
146
|
+
// Should not throw
|
|
147
|
+
transport.detachDataChannel("nonexistent")
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// 2. Send
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
describe("Send", () => {
|
|
156
|
+
let transport: WebrtcTransport
|
|
157
|
+
|
|
158
|
+
beforeEach(async () => {
|
|
159
|
+
transport = new WebrtcTransport()
|
|
160
|
+
await initializeTransport(transport)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("send encodes and delivers binary data via dc.send", () => {
|
|
164
|
+
const dc = new MockDataChannel("open")
|
|
165
|
+
transport.attachDataChannel("peer-1", dc)
|
|
166
|
+
|
|
167
|
+
const syncChannel = [...transport.channels].find(
|
|
168
|
+
ch => ch.type === "connected",
|
|
169
|
+
)
|
|
170
|
+
if (!syncChannel) throw new Error("expected syncChannel to be defined")
|
|
171
|
+
|
|
172
|
+
syncChannel.send(TEST_MSG)
|
|
173
|
+
|
|
174
|
+
expect(dc.send).toHaveBeenCalledOnce()
|
|
175
|
+
expect(dc.send.mock.calls.at(0)?.at(0)).toBeInstanceOf(Uint8Array)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// 3. Receive
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
describe("Receive", () => {
|
|
184
|
+
let transport: WebrtcTransport
|
|
185
|
+
let ctx: ReturnType<typeof createContext>
|
|
186
|
+
|
|
187
|
+
beforeEach(async () => {
|
|
188
|
+
transport = new WebrtcTransport()
|
|
189
|
+
ctx = await initializeTransport(transport)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("receive with ArrayBuffer data", () => {
|
|
193
|
+
const dc = new MockDataChannel("open")
|
|
194
|
+
transport.attachDataChannel("peer-1", dc)
|
|
195
|
+
|
|
196
|
+
// Encode a test message through the wire pipeline
|
|
197
|
+
const encoded = encodeComplete(cborCodec, TEST_MSG)
|
|
198
|
+
const wrapped = wrapCompleteMessage(encoded)
|
|
199
|
+
|
|
200
|
+
// Convert to ArrayBuffer (as native RTCDataChannel with binaryType "arraybuffer" would deliver)
|
|
201
|
+
const ab = wrapped.buffer.slice(
|
|
202
|
+
wrapped.byteOffset,
|
|
203
|
+
wrapped.byteOffset + wrapped.byteLength,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
dc.emit("message", { data: ab })
|
|
207
|
+
|
|
208
|
+
expect(ctx.onChannelReceive).toHaveBeenCalled()
|
|
209
|
+
const callArgs = ctx.onChannelReceive.mock.calls.at(0)
|
|
210
|
+
if (!callArgs)
|
|
211
|
+
throw new Error("expected onChannelReceive to have been called")
|
|
212
|
+
const [, receivedMsg] = callArgs
|
|
213
|
+
expect(receivedMsg.type).toBe("establish-request")
|
|
214
|
+
expect((receivedMsg as any).identity.peerId).toBe("remote")
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("receive with Uint8Array data", () => {
|
|
218
|
+
const dc = new MockDataChannel("open")
|
|
219
|
+
transport.attachDataChannel("peer-1", dc)
|
|
220
|
+
|
|
221
|
+
const encoded = encodeComplete(cborCodec, TEST_MSG)
|
|
222
|
+
const wrapped = wrapCompleteMessage(encoded)
|
|
223
|
+
|
|
224
|
+
// Pass Uint8Array directly (as simple-peer and other wrappers may deliver)
|
|
225
|
+
dc.emit("message", { data: wrapped })
|
|
226
|
+
|
|
227
|
+
expect(ctx.onChannelReceive).toHaveBeenCalled()
|
|
228
|
+
const callArgs = ctx.onChannelReceive.mock.calls.at(0)
|
|
229
|
+
if (!callArgs)
|
|
230
|
+
throw new Error("expected onChannelReceive to have been called")
|
|
231
|
+
const [, receivedMsg] = callArgs
|
|
232
|
+
expect(receivedMsg.type).toBe("establish-request")
|
|
233
|
+
expect((receivedMsg as any).identity.peerId).toBe("remote")
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("receive ignores string data", () => {
|
|
237
|
+
const dc = new MockDataChannel("open")
|
|
238
|
+
transport.attachDataChannel("peer-1", dc)
|
|
239
|
+
|
|
240
|
+
dc.emit("message", { data: "unexpected string" })
|
|
241
|
+
|
|
242
|
+
expect(ctx.onChannelReceive).not.toHaveBeenCalled()
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// 4. readyState
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
describe("readyState", () => {
|
|
251
|
+
let transport: WebrtcTransport
|
|
252
|
+
|
|
253
|
+
beforeEach(async () => {
|
|
254
|
+
transport = new WebrtcTransport()
|
|
255
|
+
await initializeTransport(transport)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it("sets binaryType on attach", () => {
|
|
259
|
+
const dc = new MockDataChannel("open")
|
|
260
|
+
expect(dc.binaryType).toBe("blob")
|
|
261
|
+
|
|
262
|
+
transport.attachDataChannel("peer-1", dc)
|
|
263
|
+
expect(dc.binaryType).toBe("arraybuffer")
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it("send is no-op when readyState is not open", () => {
|
|
267
|
+
const dc = new MockDataChannel("connecting")
|
|
268
|
+
transport.attachDataChannel("peer-1", dc)
|
|
269
|
+
dc.open()
|
|
270
|
+
|
|
271
|
+
const syncChannel = [...transport.channels].find(
|
|
272
|
+
ch => ch.type === "connected",
|
|
273
|
+
)
|
|
274
|
+
if (!syncChannel) throw new Error("expected syncChannel to be defined")
|
|
275
|
+
|
|
276
|
+
// Simulate the channel transitioning to closing
|
|
277
|
+
dc.readyState = "closing"
|
|
278
|
+
|
|
279
|
+
syncChannel.send(TEST_MSG)
|
|
280
|
+
expect(dc.send).not.toHaveBeenCalled()
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// 5. Fragmentation
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
describe("Fragmentation", () => {
|
|
289
|
+
it("fragments large messages", async () => {
|
|
290
|
+
// Tiny threshold to force fragmentation on any message
|
|
291
|
+
const transport = new WebrtcTransport({ fragmentThreshold: 200 })
|
|
292
|
+
await initializeTransport(transport)
|
|
293
|
+
|
|
294
|
+
const dc = new MockDataChannel("open")
|
|
295
|
+
transport.attachDataChannel("peer-1", dc)
|
|
296
|
+
|
|
297
|
+
const syncChannel = [...transport.channels].find(
|
|
298
|
+
ch => ch.type === "connected",
|
|
299
|
+
)
|
|
300
|
+
if (!syncChannel) throw new Error("expected syncChannel to be defined")
|
|
301
|
+
|
|
302
|
+
// Send a message — with a 200-byte threshold, even a small message's
|
|
303
|
+
// binary frame may exceed it if the CBOR encoding + frame header is large enough.
|
|
304
|
+
// Use a message with enough payload to guarantee fragmentation.
|
|
305
|
+
const largeMsg: ChannelMsg = {
|
|
306
|
+
type: "establish-request",
|
|
307
|
+
identity: {
|
|
308
|
+
peerId: `a]very-long-peer-id-${"x".repeat(200)}`,
|
|
309
|
+
name: `A Long Name ${"y".repeat(200)}`,
|
|
310
|
+
type: "user",
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
syncChannel.send(largeMsg)
|
|
315
|
+
|
|
316
|
+
// Should have been called more than once (multiple fragments)
|
|
317
|
+
expect(dc.send.mock.calls.length).toBeGreaterThan(1)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it("reassembles fragmented incoming messages across multiple events", async () => {
|
|
321
|
+
const transport = new WebrtcTransport()
|
|
322
|
+
const ctx = await initializeTransport(transport)
|
|
323
|
+
|
|
324
|
+
const dc = new MockDataChannel("open")
|
|
325
|
+
transport.attachDataChannel("peer-1", dc)
|
|
326
|
+
|
|
327
|
+
// Build a message large enough to guarantee multiple fragments at chunk size 50
|
|
328
|
+
const largeMsg: ChannelMsg = {
|
|
329
|
+
type: "establish-request",
|
|
330
|
+
identity: {
|
|
331
|
+
peerId: `peer-${"z".repeat(200)}`,
|
|
332
|
+
name: `Name-${"w".repeat(200)}`,
|
|
333
|
+
type: "user",
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const encoded = encodeComplete(cborCodec, largeMsg)
|
|
338
|
+
const fragments = fragmentPayload(encoded, 50)
|
|
339
|
+
expect(fragments.length).toBeGreaterThan(1)
|
|
340
|
+
|
|
341
|
+
// Emit all but the last fragment — should NOT trigger receive yet
|
|
342
|
+
for (let i = 0; i < fragments.length - 1; i++) {
|
|
343
|
+
const frag = fragments.at(i)
|
|
344
|
+
if (!frag) throw new Error(`expected fragment at index ${i}`)
|
|
345
|
+
const ab = frag.buffer.slice(
|
|
346
|
+
frag.byteOffset,
|
|
347
|
+
frag.byteOffset + frag.byteLength,
|
|
348
|
+
)
|
|
349
|
+
dc.emit("message", { data: ab })
|
|
350
|
+
}
|
|
351
|
+
expect(ctx.onChannelReceive).not.toHaveBeenCalled()
|
|
352
|
+
|
|
353
|
+
// Emit the last fragment — should complete reassembly
|
|
354
|
+
const lastFrag = fragments.at(-1)
|
|
355
|
+
if (!lastFrag) throw new Error("expected last fragment to exist")
|
|
356
|
+
const ab = lastFrag.buffer.slice(
|
|
357
|
+
lastFrag.byteOffset,
|
|
358
|
+
lastFrag.byteOffset + lastFrag.byteLength,
|
|
359
|
+
)
|
|
360
|
+
dc.emit("message", { data: ab })
|
|
361
|
+
|
|
362
|
+
expect(ctx.onChannelReceive).toHaveBeenCalledTimes(1)
|
|
363
|
+
const callArgs = ctx.onChannelReceive.mock.calls.at(0)
|
|
364
|
+
if (!callArgs)
|
|
365
|
+
throw new Error("expected onChannelReceive to have been called")
|
|
366
|
+
const [, receivedMsg] = callArgs
|
|
367
|
+
expect(receivedMsg.type).toBe("establish-request")
|
|
368
|
+
expect((receivedMsg as any).identity.peerId).toBe(`peer-${"z".repeat(200)}`)
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// 6. Multi-peer
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
describe("Multi-peer", () => {
|
|
377
|
+
let transport: WebrtcTransport
|
|
378
|
+
|
|
379
|
+
beforeEach(async () => {
|
|
380
|
+
transport = new WebrtcTransport()
|
|
381
|
+
await initializeTransport(transport)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it("independent channels for different peers", () => {
|
|
385
|
+
const dc1 = new MockDataChannel("open")
|
|
386
|
+
const dc2 = new MockDataChannel("open")
|
|
387
|
+
|
|
388
|
+
transport.attachDataChannel("peer-a", dc1)
|
|
389
|
+
transport.attachDataChannel("peer-b", dc2)
|
|
390
|
+
|
|
391
|
+
expect(transport.getAttachedPeerIds()).toContain("peer-a")
|
|
392
|
+
expect(transport.getAttachedPeerIds()).toContain("peer-b")
|
|
393
|
+
expect(transport.channels.size).toBe(2)
|
|
394
|
+
|
|
395
|
+
const channels = [...transport.channels]
|
|
396
|
+
const ch0 = channels.at(0)
|
|
397
|
+
const ch1 = channels.at(1)
|
|
398
|
+
if (!ch0) throw new Error("expected channel at index 0")
|
|
399
|
+
if (!ch1) throw new Error("expected channel at index 1")
|
|
400
|
+
ch0.send(TEST_MSG)
|
|
401
|
+
ch1.send(TEST_MSG)
|
|
402
|
+
|
|
403
|
+
// Each data channel received its own send call
|
|
404
|
+
expect(dc1.send).toHaveBeenCalledOnce()
|
|
405
|
+
expect(dc2.send).toHaveBeenCalledOnce()
|
|
406
|
+
|
|
407
|
+
// Payloads are distinct Uint8Array instances (not shared references)
|
|
408
|
+
const bytes1 = dc1.send.mock.calls.at(0)?.at(0) as Uint8Array
|
|
409
|
+
const bytes2 = dc2.send.mock.calls.at(0)?.at(0) as Uint8Array
|
|
410
|
+
expect(bytes1).not.toBe(bytes2)
|
|
411
|
+
})
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// 7. DataChannelLike conformance
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
describe("DataChannelLike conformance", () => {
|
|
419
|
+
let transport: WebrtcTransport
|
|
420
|
+
|
|
421
|
+
beforeEach(async () => {
|
|
422
|
+
transport = new WebrtcTransport()
|
|
423
|
+
await initializeTransport(transport)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it("works with a plain object literal", () => {
|
|
427
|
+
const dc = {
|
|
428
|
+
readyState: "open" as string,
|
|
429
|
+
binaryType: "blob",
|
|
430
|
+
send: vi.fn(),
|
|
431
|
+
addEventListener: vi.fn(),
|
|
432
|
+
removeEventListener: vi.fn(),
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Should not throw
|
|
436
|
+
transport.attachDataChannel("peer-1", dc)
|
|
437
|
+
expect(transport.hasDataChannel("peer-1")).toBe(true)
|
|
438
|
+
|
|
439
|
+
// binaryType should have been set
|
|
440
|
+
expect(dc.binaryType).toBe("arraybuffer")
|
|
441
|
+
|
|
442
|
+
// addEventListener should have been called for the 4 event types
|
|
443
|
+
const eventTypes = dc.addEventListener.mock.calls.map(
|
|
444
|
+
(call: any[]) => call[0],
|
|
445
|
+
)
|
|
446
|
+
expect(eventTypes).toContain("open")
|
|
447
|
+
expect(eventTypes).toContain("close")
|
|
448
|
+
expect(eventTypes).toContain("error")
|
|
449
|
+
expect(eventTypes).toContain("message")
|
|
450
|
+
})
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// 8. Channel close event
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
describe("Message before open (race condition)", () => {
|
|
458
|
+
it("silently drops messages received before sync channel is created", async () => {
|
|
459
|
+
const transport = new WebrtcTransport()
|
|
460
|
+
const ctx = await initializeTransport(transport)
|
|
461
|
+
|
|
462
|
+
const dc = new MockDataChannel("connecting")
|
|
463
|
+
transport.attachDataChannel("peer-1", dc)
|
|
464
|
+
|
|
465
|
+
// Data channel is still connecting — no sync channel exists yet
|
|
466
|
+
expect(transport.channels.size).toBe(0)
|
|
467
|
+
|
|
468
|
+
// Simulate a message arriving before the open event
|
|
469
|
+
const encoded = encodeComplete(cborCodec, TEST_MSG)
|
|
470
|
+
const wrapped = wrapCompleteMessage(encoded)
|
|
471
|
+
dc.emit("message", { data: wrapped })
|
|
472
|
+
|
|
473
|
+
// Message should be silently dropped — no delivery, no error
|
|
474
|
+
expect(ctx.onChannelReceive).not.toHaveBeenCalled()
|
|
475
|
+
|
|
476
|
+
// Now open the channel — subsequent messages should work
|
|
477
|
+
dc.open()
|
|
478
|
+
expect(transport.channels.size).toBe(1)
|
|
479
|
+
|
|
480
|
+
dc.emit("message", { data: wrapped })
|
|
481
|
+
expect(ctx.onChannelReceive).toHaveBeenCalledOnce()
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
describe("Channel close event", () => {
|
|
486
|
+
let transport: WebrtcTransport
|
|
487
|
+
let ctx: ReturnType<typeof createContext>
|
|
488
|
+
|
|
489
|
+
beforeEach(async () => {
|
|
490
|
+
transport = new WebrtcTransport()
|
|
491
|
+
ctx = await initializeTransport(transport)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it("removes sync channel when data channel fires close", () => {
|
|
495
|
+
const dc = new MockDataChannel("open")
|
|
496
|
+
transport.attachDataChannel("peer-1", dc)
|
|
497
|
+
expect(transport.channels.size).toBe(1)
|
|
498
|
+
|
|
499
|
+
dc.close()
|
|
500
|
+
|
|
501
|
+
// The sync channel should be removed, but the attachment tracking remains
|
|
502
|
+
// (the transport removes the internal sync channel but doesn't auto-detach)
|
|
503
|
+
expect(transport.channels.size).toBe(0)
|
|
504
|
+
expect(ctx.onChannelRemoved).toHaveBeenCalled()
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it("removes sync channel when data channel fires error", () => {
|
|
508
|
+
const dc = new MockDataChannel("open")
|
|
509
|
+
transport.attachDataChannel("peer-1", dc)
|
|
510
|
+
expect(transport.channels.size).toBe(1)
|
|
511
|
+
|
|
512
|
+
dc.emit("error", new Error("connection failed"))
|
|
513
|
+
|
|
514
|
+
expect(transport.channels.size).toBe(0)
|
|
515
|
+
expect(ctx.onChannelRemoved).toHaveBeenCalled()
|
|
516
|
+
})
|
|
517
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// data-channel-like — minimal interface for WebRTC-style data channels.
|
|
2
|
+
//
|
|
3
|
+
// Native `RTCDataChannel` satisfies this structurally — no wrapper needed.
|
|
4
|
+
// Libraries like simple-peer can conform via a trivial bridge function.
|
|
5
|
+
//
|
|
6
|
+
// This interface captures the exact surface the transport uses:
|
|
7
|
+
// - readyState (read)
|
|
8
|
+
// - binaryType (write, best-effort hint)
|
|
9
|
+
// - send (call)
|
|
10
|
+
// - addEventListener / removeEventListener (4 event types)
|
|
11
|
+
//
|
|
12
|
+
// It does NOT import any DOM types. The `event: any` parameter avoids
|
|
13
|
+
// coupling to `MessageEvent`, `Event`, etc. — the transport inspects
|
|
14
|
+
// `event.data` at runtime.
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// DataChannelLike — the BYODC contract
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Minimal interface for a WebRTC-style data channel.
|
|
22
|
+
*
|
|
23
|
+
* Native `RTCDataChannel` satisfies this structurally (no wrapper needed).
|
|
24
|
+
* Libraries like simple-peer can conform via a ~20-line bridge function
|
|
25
|
+
* that maps EventEmitter events to addEventListener calls.
|
|
26
|
+
*
|
|
27
|
+
* The transport uses exactly these members — nothing else. This is
|
|
28
|
+
* intentional: the narrower the interface, the easier it is to bridge
|
|
29
|
+
* from any WebRTC library.
|
|
30
|
+
*
|
|
31
|
+
* ## Event types used
|
|
32
|
+
*
|
|
33
|
+
* The transport registers listeners for exactly four event types:
|
|
34
|
+
* - `"open"` — data channel became ready for sending
|
|
35
|
+
* - `"close"` — data channel was closed
|
|
36
|
+
* - `"error"` — data channel encountered an error
|
|
37
|
+
* - `"message"` — data arrived; the transport reads `event.data`
|
|
38
|
+
*
|
|
39
|
+
* ## Ownership contract
|
|
40
|
+
*
|
|
41
|
+
* The transport does NOT own the data channel. Calling
|
|
42
|
+
* `detachDataChannel()` removes the sync channel but does not close
|
|
43
|
+
* the data channel or the peer connection. The application manages
|
|
44
|
+
* the WebRTC connection lifecycle independently.
|
|
45
|
+
*/
|
|
46
|
+
export interface DataChannelLike {
|
|
47
|
+
/**
|
|
48
|
+
* Current state of the data channel.
|
|
49
|
+
*
|
|
50
|
+
* The transport treats `"open"` as sendable; all other values
|
|
51
|
+
* (including `"connecting"`, `"closing"`, `"closed"`) as not sendable.
|
|
52
|
+
*
|
|
53
|
+
* For native `RTCDataChannel`, this is one of:
|
|
54
|
+
* `"connecting" | "open" | "closing" | "closed"`.
|
|
55
|
+
*
|
|
56
|
+
* Wrappers may return any string — the transport only checks `=== "open"`.
|
|
57
|
+
*/
|
|
58
|
+
readonly readyState: string
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Binary type hint for incoming data.
|
|
62
|
+
*
|
|
63
|
+
* The transport writes `"arraybuffer"` on attach as a best-effort hint.
|
|
64
|
+
* It does NOT depend on this being respected — the message handler
|
|
65
|
+
* accepts both `ArrayBuffer` and `Uint8Array` data regardless.
|
|
66
|
+
*
|
|
67
|
+
* For native `RTCDataChannel`, this controls whether `MessageEvent.data`
|
|
68
|
+
* is an `ArrayBuffer` or a `Blob`. For wrappers that ignore this
|
|
69
|
+
* property (e.g. simple-peer bridges), the write is harmless.
|
|
70
|
+
*/
|
|
71
|
+
binaryType: string
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Send binary data through the data channel.
|
|
75
|
+
*
|
|
76
|
+
* The transport always sends `Uint8Array` instances (CBOR-encoded
|
|
77
|
+
* wire frames, optionally fragmented). Native `RTCDataChannel.send`
|
|
78
|
+
* accepts `ArrayBufferView` (which `Uint8Array` satisfies), so
|
|
79
|
+
* conformance is structural.
|
|
80
|
+
*/
|
|
81
|
+
send(data: Uint8Array): void
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Register an event listener.
|
|
85
|
+
*
|
|
86
|
+
* The transport uses this for `"open"`, `"close"`, `"error"`, and
|
|
87
|
+
* `"message"` events. For `"message"` events, the transport reads
|
|
88
|
+
* `event.data` and handles both `ArrayBuffer` and `Uint8Array`.
|
|
89
|
+
*
|
|
90
|
+
* @param type - Event type string
|
|
91
|
+
* @param listener - Callback. The `event` parameter is untyped to
|
|
92
|
+
* avoid coupling to DOM `Event` / `MessageEvent` types.
|
|
93
|
+
*/
|
|
94
|
+
addEventListener(type: string, listener: (event: any) => void): void
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Remove a previously registered event listener.
|
|
98
|
+
*
|
|
99
|
+
* Called during `detachDataChannel()` to clean up all four event
|
|
100
|
+
* listeners. The transport always passes the same function reference
|
|
101
|
+
* that was used in `addEventListener`.
|
|
102
|
+
*/
|
|
103
|
+
removeEventListener(type: string, listener: (event: any) => void): void
|
|
104
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @kyneta/webrtc-transport — BYODC WebRTC data channel transport.
|
|
2
|
+
//
|
|
3
|
+
// Bring Your Own Data Channel: the application manages WebRTC connections
|
|
4
|
+
// (signaling, ICE, media streams). This transport attaches to data channels
|
|
5
|
+
// for kyneta document synchronization.
|
|
6
|
+
//
|
|
7
|
+
// Native RTCDataChannel satisfies DataChannelLike structurally — no wrapper
|
|
8
|
+
// needed. Libraries like simple-peer can conform via a trivial bridge function.
|
|
9
|
+
|
|
10
|
+
export type { DataChannelLike } from "./data-channel-like.js"
|
|
11
|
+
export {
|
|
12
|
+
createWebrtcTransport,
|
|
13
|
+
DEFAULT_FRAGMENT_THRESHOLD,
|
|
14
|
+
WebrtcTransport,
|
|
15
|
+
type WebrtcTransportOptions,
|
|
16
|
+
} from "./webrtc-transport.js"
|