@johnhenry/browsermesh-transport 0.0.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,1082 @@
1
+ import { silentCatch } from './silent-catch.mjs'
2
+ /**
3
+ // STATUS: INTEGRATED — wired into ClawserPod lifecycle, proven via E2E testing
4
+ * clawser-mesh-websocket.js -- WebSocket, WebRTC & WebTransport Adapters.
5
+ *
6
+ * Concrete transport implementations for the BrowserMesh transport
7
+ * abstraction layer. Each adapter wraps a browser API (WebSocket,
8
+ * RTCPeerConnection, WebTransport) behind a unified interface with
9
+ * injectable mocks for testability.
10
+ *
11
+ * Also provides NATTraversal helpers and a TransportFactory that
12
+ * negotiates the best available transport for a given peer pair.
13
+ *
14
+ * Run tests:
15
+ * node --import ./web/test/_setup-globals.mjs --test web/test/clawser-mesh-websocket.test.mjs
16
+ */
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Wire Constants
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export const WS_CONNECT = 0xC6;
23
+ export const WS_MESSAGE = 0xC7;
24
+ export const WS_CLOSE = 0xC8;
25
+ export const WRT_OFFER = 0xC9;
26
+ export const WRT_ANSWER = 0xCA;
27
+ export const WRT_ICE = 0xCB;
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Shared helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Measure byte length of a value.
35
+ * @param {*} data
36
+ * @returns {number}
37
+ */
38
+ function byteLength(data) {
39
+ if (typeof data === 'string') return data.length;
40
+ if (data instanceof ArrayBuffer) return data.byteLength;
41
+ if (ArrayBuffer.isView(data)) return data.byteLength;
42
+ if (typeof data === 'object') return JSON.stringify(data).length;
43
+ return 0;
44
+ }
45
+
46
+ /** Valid event names for WebSocketTransport */
47
+ const WS_EVENTS = Object.freeze(['open', 'message', 'close', 'error', 'reconnect']);
48
+
49
+ /** Valid event names for WebRTCTransport */
50
+ const RTC_EVENTS = Object.freeze(['open', 'message', 'close', 'error', 'ice-candidate']);
51
+
52
+ /** Valid event names for WebTransportTransport */
53
+ const WT_EVENTS = Object.freeze(['open', 'message', 'close', 'error', 'stream']);
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // WebSocketTransport
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * WebSocket-based mesh transport.
61
+ *
62
+ * Wraps a WebSocket connection with reconnection logic, heartbeat
63
+ * keepalive, and stats tracking. The WebSocket constructor is
64
+ * injectable for testing.
65
+ */
66
+ export class WebSocketTransport {
67
+ /** @type {string} */
68
+ #url;
69
+
70
+ /** @type {string[]} */
71
+ #protocols;
72
+
73
+ /** @type {boolean} */
74
+ #reconnect;
75
+
76
+ /** @type {number} */
77
+ #maxReconnectAttempts;
78
+
79
+ /** @type {number} */
80
+ #reconnectDelayMs;
81
+
82
+ /** @type {number} */
83
+ #heartbeatIntervalMs;
84
+
85
+ /** @type {string} */
86
+ #state = 'disconnected';
87
+
88
+ /** @type {object|null} */
89
+ #ws = null;
90
+
91
+ /** @type {Function} */
92
+ #WebSocketCtor;
93
+
94
+ /** @type {number} */
95
+ #reconnectAttempts = 0;
96
+
97
+ /** @type {boolean} */
98
+ #userClosed = false;
99
+
100
+ /** @type {number|null} */
101
+ #heartbeatTimer = null;
102
+
103
+ /** @type {{ open: Function[], message: Function[], close: Function[], error: Function[], reconnect: Function[] }} */
104
+ #callbacks = { open: [], message: [], close: [], error: [], reconnect: [] };
105
+
106
+ /** @type {{ messagesSent: number, messagesReceived: number, bytesIn: number, bytesOut: number, reconnects: number, lastPingMs: number }} */
107
+ #stats = { messagesSent: 0, messagesReceived: 0, bytesIn: 0, bytesOut: 0, reconnects: 0, lastPingMs: 0 };
108
+
109
+ /**
110
+ * @param {object} opts
111
+ * @param {string} opts.url - WebSocket endpoint URL
112
+ * @param {string[]} [opts.protocols] - Sub-protocols
113
+ * @param {boolean} [opts.reconnect=true] - Enable auto-reconnect
114
+ * @param {number} [opts.maxReconnectAttempts=5] - Max reconnection attempts
115
+ * @param {number} [opts.reconnectDelayMs=1000] - Base delay between reconnects
116
+ * @param {number} [opts.heartbeatIntervalMs=30000] - Heartbeat interval
117
+ * @param {Function} [opts._WebSocket] - Injectable WebSocket constructor
118
+ */
119
+ constructor(opts = {}) {
120
+ if (!opts.url) throw new Error('url is required');
121
+ this.#url = opts.url;
122
+ this.#protocols = opts.protocols || [];
123
+ this.#reconnect = opts.reconnect !== undefined ? opts.reconnect : true;
124
+ this.#maxReconnectAttempts = opts.maxReconnectAttempts ?? 5;
125
+ this.#reconnectDelayMs = opts.reconnectDelayMs ?? 1000;
126
+ this.#heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 30000;
127
+ this.#WebSocketCtor = opts._WebSocket || globalThis.WebSocket;
128
+ }
129
+
130
+ // -- Getters ---------------------------------------------------------------
131
+
132
+ /** Transport type identifier. */
133
+ get type() { return 'wsh-ws'; }
134
+
135
+ /** Current connection state. */
136
+ get state() { return this.#state; }
137
+
138
+ /** True when transport is in 'connected' state. */
139
+ get connected() { return this.#state === 'connected'; }
140
+
141
+ /** WebSocket endpoint URL. */
142
+ get url() { return this.#url; }
143
+
144
+ /** Number of reconnection attempts since last successful connect. */
145
+ get reconnectAttempts() { return this.#reconnectAttempts; }
146
+
147
+ /** Whether auto-reconnect is enabled. */
148
+ get reconnectEnabled() { return this.#reconnect; }
149
+
150
+ /** Heartbeat interval in ms. */
151
+ get heartbeatIntervalMs() { return this.#heartbeatIntervalMs; }
152
+
153
+ // -- Public API ------------------------------------------------------------
154
+
155
+ /**
156
+ * Open a WebSocket connection.
157
+ * @returns {Promise<void>}
158
+ */
159
+ async connect() {
160
+ if (this.#state === 'connected' || this.#state === 'connecting') {
161
+ throw new Error('Already connected or connecting');
162
+ }
163
+ this.#userClosed = false;
164
+ this.#state = 'connecting';
165
+
166
+ return new Promise((resolve, reject) => {
167
+ try {
168
+ this.#ws = new this.#WebSocketCtor(this.#url, this.#protocols.length ? this.#protocols : undefined);
169
+ } catch (err) {
170
+ this.#state = 'disconnected';
171
+ return reject(err);
172
+ }
173
+
174
+ const onOpen = () => {
175
+ cleanup();
176
+ this.#state = 'connected';
177
+ this.#reconnectAttempts = 0;
178
+ this._startHeartbeat();
179
+ this._fireEvent('open');
180
+ this.#ws.addEventListener('message', this.#onMessage);
181
+ this.#ws.addEventListener('close', this.#onClose);
182
+ this.#ws.addEventListener('error', this.#onError);
183
+ resolve();
184
+ };
185
+
186
+ const onError = (err) => {
187
+ cleanup();
188
+ this.#state = 'disconnected';
189
+ this._fireEvent('error', err);
190
+ reject(err instanceof Error ? err : new Error('WebSocket connection failed'));
191
+ };
192
+
193
+ const cleanup = () => {
194
+ this.#ws.removeEventListener('open', onOpen);
195
+ this.#ws.removeEventListener('error', onError);
196
+ };
197
+
198
+ this.#ws.addEventListener('open', onOpen);
199
+ this.#ws.addEventListener('error', onError);
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Send data over the WebSocket.
205
+ * @param {*} data
206
+ */
207
+ send(data) {
208
+ if (!this.connected) throw new Error('Not connected');
209
+ this.#ws.send(data);
210
+ this.#stats.messagesSent++;
211
+ this.#stats.bytesOut += byteLength(data);
212
+ }
213
+
214
+ /**
215
+ * Close the WebSocket connection gracefully.
216
+ * @param {number} [code]
217
+ * @param {string} [reason]
218
+ * @returns {Promise<void>}
219
+ */
220
+ async close(code, reason) {
221
+ if (this.#state === 'closed' || this.#state === 'disconnected') return;
222
+ this.#userClosed = true;
223
+ this.#state = 'closing';
224
+ this._stopHeartbeat();
225
+
226
+ if (this.#ws) {
227
+ return new Promise((resolve) => {
228
+ const onClose = () => {
229
+ this.#ws.removeEventListener('close', onClose);
230
+ this.#state = 'closed';
231
+ this._fireEvent('close');
232
+ resolve();
233
+ };
234
+ this.#ws.addEventListener('close', onClose);
235
+ // Remove our general close handler so it doesn't double-fire
236
+ this.#ws.removeEventListener('close', this.#onClose);
237
+ this.#ws.close(code, reason);
238
+ });
239
+ }
240
+ this.#state = 'closed';
241
+ this._fireEvent('close');
242
+ }
243
+
244
+ /**
245
+ * Register an event listener.
246
+ * @param {string} event - One of: 'open', 'message', 'close', 'error', 'reconnect'
247
+ * @param {Function} cb
248
+ */
249
+ on(event, cb) {
250
+ if (!WS_EVENTS.includes(event)) throw new Error(`Unknown event: ${event}`);
251
+ this.#callbacks[event].push(cb);
252
+ }
253
+
254
+ /**
255
+ * Get transport statistics.
256
+ * @returns {{ messagesSent: number, messagesReceived: number, bytesIn: number, bytesOut: number, reconnects: number, lastPingMs: number }}
257
+ */
258
+ getStats() {
259
+ return { ...this.#stats };
260
+ }
261
+
262
+ /**
263
+ * Serialize to a JSON-safe object.
264
+ * @returns {object}
265
+ */
266
+ toJSON() {
267
+ return {
268
+ type: this.type,
269
+ state: this.#state,
270
+ url: this.#url,
271
+ reconnectAttempts: this.#reconnectAttempts,
272
+ stats: this.getStats(),
273
+ };
274
+ }
275
+
276
+ // -- Internal event handlers (arrow fns for stable `this`) -----------------
277
+
278
+ /** @type {(ev: { data: * }) => void} */
279
+ #onMessage = (ev) => {
280
+ const data = ev.data;
281
+ this.#stats.messagesReceived++;
282
+ this.#stats.bytesIn += byteLength(data);
283
+ this._fireEvent('message', data);
284
+ };
285
+
286
+ /** @type {(ev: { code: number, reason: string }) => void} */
287
+ #onClose = (ev) => {
288
+ this._stopHeartbeat();
289
+ if (this.#userClosed) {
290
+ this.#state = 'closed';
291
+ this._fireEvent('close', ev);
292
+ return;
293
+ }
294
+ // Unexpected close — attempt reconnect
295
+ this.#state = 'disconnected';
296
+ this._fireEvent('close', ev);
297
+ if (this.#reconnect) {
298
+ this._handleReconnect();
299
+ }
300
+ };
301
+
302
+ /** @type {(err: *) => void} */
303
+ #onError = (err) => {
304
+ this._fireEvent('error', err);
305
+ };
306
+
307
+ // -- Internal methods ------------------------------------------------------
308
+
309
+ /**
310
+ * Fire all callbacks for a given event.
311
+ * @param {string} event
312
+ * @param {*} [data]
313
+ */
314
+ _fireEvent(event, data) {
315
+ for (const cb of this.#callbacks[event] || []) {
316
+ try { cb(data); } catch (e) { silentCatch('clawser-mesh-websocket', 'swallow-listener-errors', e) }
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Attempt to reconnect with exponential backoff.
322
+ */
323
+ async _handleReconnect() {
324
+ if (this.#reconnectAttempts >= this.#maxReconnectAttempts) {
325
+ // Reconnect budget exhausted — surface a clear error to the user
326
+ // so the UI doesn't silently sit at "disconnected" after 5 retries.
327
+ this._fireEvent('error', new Error(
328
+ `WebSocket reconnect failed after ${this.#maxReconnectAttempts} attempts (giving up)`,
329
+ ));
330
+ return;
331
+ }
332
+
333
+ this.#reconnectAttempts++;
334
+ this.#stats.reconnects++;
335
+ this._fireEvent('reconnect', { attempt: this.#reconnectAttempts });
336
+
337
+ const delay = this.#reconnectDelayMs * Math.pow(2, this.#reconnectAttempts - 1);
338
+ await new Promise(r => setTimeout(r, delay));
339
+
340
+ if (this.#userClosed) return;
341
+
342
+ try {
343
+ await this.connect();
344
+ } catch (err) {
345
+ // connect() failed — retry until budget exhausted, then surface.
346
+ if (this.#reconnect && this.#reconnectAttempts < this.#maxReconnectAttempts) {
347
+ this._handleReconnect();
348
+ } else {
349
+ this._fireEvent('error', err instanceof Error ? err : new Error(
350
+ `WebSocket reconnect failed: ${err?.message || err}`,
351
+ ));
352
+ }
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Start heartbeat ping interval.
358
+ */
359
+ _startHeartbeat() {
360
+ this._stopHeartbeat();
361
+ this.#heartbeatTimer = setInterval(() => {
362
+ if (!this.connected || !this.#ws) return;
363
+ const ping = JSON.stringify({ type: 'ping', ts: Date.now() });
364
+ try {
365
+ this.#ws.send(ping);
366
+ this.#stats.lastPingMs = Date.now();
367
+ } catch (e) { silentCatch('clawser-mesh-websocket', 'ignore-send-errors-during-heartbeat', e) }
368
+ }, this.#heartbeatIntervalMs);
369
+ }
370
+
371
+ /**
372
+ * Stop heartbeat interval.
373
+ */
374
+ _stopHeartbeat() {
375
+ if (this.#heartbeatTimer != null) {
376
+ clearInterval(this.#heartbeatTimer);
377
+ this.#heartbeatTimer = null;
378
+ }
379
+ }
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // WebRTCTransport
384
+ // ---------------------------------------------------------------------------
385
+
386
+ /**
387
+ * WebRTC data channel transport.
388
+ *
389
+ * Establishes a peer connection using an external signaler for
390
+ * offer/answer exchange and ICE candidate trickle. The RTCPeerConnection
391
+ * constructor is injectable for testing.
392
+ */
393
+ export class WebRTCTransport {
394
+ /** @type {string} */
395
+ #localPodId;
396
+
397
+ /** @type {string} */
398
+ #remotePodId;
399
+
400
+ /** @type {object} */
401
+ #signaler;
402
+
403
+ /** @type {object} */
404
+ #config;
405
+
406
+ /** @type {Function} */
407
+ #RTCPeerConnectionCtor;
408
+
409
+ /** @type {string} */
410
+ #state = 'disconnected';
411
+
412
+ /** @type {object|null} */
413
+ #pc = null;
414
+
415
+ /** @type {object|null} */
416
+ #dataChannel = null;
417
+
418
+ /** @type {{ open: Function[], message: Function[], close: Function[], error: Function[], 'ice-candidate': Function[] }} */
419
+ #callbacks = { open: [], message: [], close: [], error: [], 'ice-candidate': [] };
420
+
421
+ /** @type {{ messagesSent: number, messagesReceived: number, bytesIn: number, bytesOut: number, iceState: string }} */
422
+ #stats = { messagesSent: 0, messagesReceived: 0, bytesIn: 0, bytesOut: 0, iceState: 'new' };
423
+
424
+ /**
425
+ * @param {object} opts
426
+ * @param {string} opts.localPodId - Local pod identifier
427
+ * @param {string} opts.remotePodId - Remote pod identifier
428
+ * @param {object} opts.signaler - Signaling channel
429
+ * @param {object} [opts.config] - RTCConfiguration
430
+ * @param {Function} [opts._RTCPeerConnection] - Injectable constructor
431
+ */
432
+ constructor(opts = {}) {
433
+ if (!opts.localPodId) throw new Error('localPodId is required');
434
+ if (!opts.remotePodId) throw new Error('remotePodId is required');
435
+ if (!opts.signaler) throw new Error('signaler is required');
436
+ this.#localPodId = opts.localPodId;
437
+ this.#remotePodId = opts.remotePodId;
438
+ this.#signaler = opts.signaler;
439
+ this.#config = opts.config || {};
440
+ this.#RTCPeerConnectionCtor = opts._RTCPeerConnection || globalThis.RTCPeerConnection;
441
+ }
442
+
443
+ // -- Getters ---------------------------------------------------------------
444
+
445
+ /** Transport type identifier. */
446
+ get type() { return 'webrtc'; }
447
+
448
+ /** Current connection state. */
449
+ get state() { return this.#state; }
450
+
451
+ /** True when transport is in 'connected' state. */
452
+ get connected() { return this.#state === 'connected'; }
453
+
454
+ /** Local pod identifier. */
455
+ get localPodId() { return this.#localPodId; }
456
+
457
+ /** Remote pod identifier. */
458
+ get remotePodId() { return this.#remotePodId; }
459
+
460
+ // -- Public API ------------------------------------------------------------
461
+
462
+ /**
463
+ * Connect as the offerer: create data channel, SDP offer, and wait
464
+ * for answer + data channel open.
465
+ * @returns {Promise<void>}
466
+ */
467
+ async connect() {
468
+ if (this.#state === 'connected' || this.#state === 'connecting') {
469
+ throw new Error('Already connected or connecting');
470
+ }
471
+ this.#state = 'connecting';
472
+
473
+ this.#pc = new this.#RTCPeerConnectionCtor(this.#config);
474
+
475
+ // Listen for local ICE candidates
476
+ this.#pc.addEventListener('icecandidate', (ev) => {
477
+ if (ev.candidate) {
478
+ this.#signaler.sendIceCandidate(this.#remotePodId, ev.candidate);
479
+ this._fireEvent('ice-candidate', ev.candidate);
480
+ }
481
+ });
482
+
483
+ // Listen for connection state changes
484
+ this.#pc.addEventListener('connectionstatechange', () => {
485
+ this.#stats.iceState = this.#pc.iceConnectionState || 'unknown';
486
+ if (this.#pc.connectionState === 'failed' || this.#pc.connectionState === 'closed') {
487
+ if (this.#state !== 'closed' && this.#state !== 'closing') {
488
+ this.#state = 'closed';
489
+ this._fireEvent('close');
490
+ }
491
+ }
492
+ });
493
+
494
+ // Set up signaler listeners for remote ICE candidates
495
+ this.#signaler.onIceCandidate((candidate) => {
496
+ if (this.#pc) {
497
+ this.#pc.addIceCandidate(candidate);
498
+ }
499
+ });
500
+
501
+ // Create data channel and offer
502
+ this.#dataChannel = this.#pc.createDataChannel('mesh', { ordered: true });
503
+ this._attachDataChannelListeners(this.#dataChannel);
504
+
505
+ const offer = await this.#pc.createOffer();
506
+ await this.#pc.setLocalDescription(offer);
507
+ await this.#signaler.sendOffer(this.#remotePodId, offer);
508
+
509
+ // Wait for answer from remote
510
+ return new Promise((resolve, reject) => {
511
+ const timeout = setTimeout(() => {
512
+ reject(new Error('WebRTC answer timeout'));
513
+ }, 30000);
514
+
515
+ this.#signaler.onAnswer(async (answer) => {
516
+ clearTimeout(timeout);
517
+ try {
518
+ await this.#pc.setRemoteDescription(answer);
519
+ } catch (err) {
520
+ this.#state = 'disconnected';
521
+ reject(err);
522
+ return;
523
+ }
524
+ // Wait for data channel to open
525
+ if (this.#dataChannel.readyState === 'open') {
526
+ this.#state = 'connected';
527
+ this._fireEvent('open');
528
+ resolve();
529
+ } else {
530
+ const onDCOpen = () => {
531
+ this.#dataChannel.removeEventListener('open', onDCOpen);
532
+ this.#state = 'connected';
533
+ this._fireEvent('open');
534
+ resolve();
535
+ };
536
+ this.#dataChannel.addEventListener('open', onDCOpen);
537
+ }
538
+ });
539
+ });
540
+ }
541
+
542
+ /**
543
+ * Handle an incoming offer (answerer role).
544
+ * @param {object} offer - SDP offer
545
+ * @returns {Promise<void>}
546
+ */
547
+ async handleOffer(offer) {
548
+ if (!this.#pc) {
549
+ this.#pc = new this.#RTCPeerConnectionCtor(this.#config);
550
+ }
551
+ this.#state = 'connecting';
552
+
553
+ await this.#pc.setRemoteDescription(offer);
554
+ const answer = await this.#pc.createAnswer();
555
+ await this.#pc.setLocalDescription(answer);
556
+ await this.#signaler.sendAnswer(this.#remotePodId, answer);
557
+
558
+ // The data channel will arrive via ondatachannel event
559
+ this.#pc.addEventListener('datachannel', (ev) => {
560
+ this.#dataChannel = ev.channel;
561
+ this._attachDataChannelListeners(this.#dataChannel);
562
+ if (this.#dataChannel.readyState === 'open') {
563
+ this.#state = 'connected';
564
+ this._fireEvent('open');
565
+ }
566
+ });
567
+ }
568
+
569
+ /**
570
+ * Send data over the data channel.
571
+ * @param {*} data
572
+ */
573
+ send(data) {
574
+ if (!this.connected || !this.#dataChannel) throw new Error('Not connected');
575
+ this.#dataChannel.send(data);
576
+ this.#stats.messagesSent++;
577
+ this.#stats.bytesOut += byteLength(data);
578
+ }
579
+
580
+ /**
581
+ * Close the peer connection.
582
+ * @returns {Promise<void>}
583
+ */
584
+ async close() {
585
+ this.#state = 'closing';
586
+ if (this.#dataChannel) {
587
+ try { this.#dataChannel.close(); } catch (e) { silentCatch('clawser-mesh-websocket', 'this', e) }
588
+ }
589
+ if (this.#pc) {
590
+ this.#pc.close();
591
+ }
592
+ this.#state = 'closed';
593
+ this._fireEvent('close');
594
+ }
595
+
596
+ /**
597
+ * Register an event listener.
598
+ * @param {string} event - One of: 'open', 'message', 'close', 'error', 'ice-candidate'
599
+ * @param {Function} cb
600
+ */
601
+ on(event, cb) {
602
+ if (!RTC_EVENTS.includes(event)) throw new Error(`Unknown event: ${event}`);
603
+ this.#callbacks[event].push(cb);
604
+ }
605
+
606
+ /**
607
+ * Get transport statistics.
608
+ * @returns {{ messagesSent: number, messagesReceived: number, bytesIn: number, bytesOut: number, iceState: string }}
609
+ */
610
+ getStats() {
611
+ return { ...this.#stats };
612
+ }
613
+
614
+ /**
615
+ * Serialize to a JSON-safe object.
616
+ * @returns {object}
617
+ */
618
+ toJSON() {
619
+ return {
620
+ type: this.type,
621
+ state: this.#state,
622
+ localPodId: this.#localPodId,
623
+ remotePodId: this.#remotePodId,
624
+ stats: this.getStats(),
625
+ };
626
+ }
627
+
628
+ // -- Internal --------------------------------------------------------------
629
+
630
+ /**
631
+ * Attach event listeners to a data channel.
632
+ * @param {object} dc
633
+ */
634
+ _attachDataChannelListeners(dc) {
635
+ dc.addEventListener('open', () => {
636
+ if (this.#state === 'connecting') {
637
+ this.#state = 'connected';
638
+ this._fireEvent('open');
639
+ }
640
+ });
641
+
642
+ dc.addEventListener('message', (ev) => {
643
+ const data = ev.data;
644
+ this.#stats.messagesReceived++;
645
+ this.#stats.bytesIn += byteLength(data);
646
+ this._fireEvent('message', data);
647
+ });
648
+
649
+ dc.addEventListener('close', () => {
650
+ if (this.#state !== 'closed' && this.#state !== 'closing') {
651
+ this.#state = 'closed';
652
+ this._fireEvent('close');
653
+ }
654
+ });
655
+ }
656
+
657
+ /**
658
+ * Fire all callbacks for a given event.
659
+ * @param {string} event
660
+ * @param {*} [data]
661
+ */
662
+ _fireEvent(event, data) {
663
+ for (const cb of this.#callbacks[event] || []) {
664
+ try { cb(data); } catch (e) { silentCatch('clawser-mesh-websocket', 'swallow-listener-errors', e) }
665
+ }
666
+ }
667
+ }
668
+
669
+ // ---------------------------------------------------------------------------
670
+ // WebTransportTransport
671
+ // ---------------------------------------------------------------------------
672
+
673
+ /**
674
+ * WebTransport (HTTP/3) based mesh transport.
675
+ *
676
+ * Uses datagrams for unreliable messaging and bidirectional streams
677
+ * for reliable ordered communication. The WebTransport constructor
678
+ * is injectable for testing.
679
+ */
680
+ export class WebTransportTransport {
681
+ /** @type {string} */
682
+ #url;
683
+
684
+ /** @type {object[]} */
685
+ #serverCertificateHashes;
686
+
687
+ /** @type {string} */
688
+ #state = 'disconnected';
689
+
690
+ /** @type {object|null} */
691
+ #transport = null;
692
+
693
+ /** @type {object|null} */
694
+ #writer = null;
695
+
696
+ /** @type {Function} */
697
+ #WebTransportCtor;
698
+
699
+ /** @type {{ open: Function[], message: Function[], close: Function[], error: Function[], stream: Function[] }} */
700
+ #callbacks = { open: [], message: [], close: [], error: [], stream: [] };
701
+
702
+ /** @type {{ messagesSent: number, messagesReceived: number, bytesIn: number, bytesOut: number, streams: number }} */
703
+ #stats = { messagesSent: 0, messagesReceived: 0, bytesIn: 0, bytesOut: 0, streams: 0 };
704
+
705
+ /**
706
+ * @param {object} opts
707
+ * @param {string} opts.url - WebTransport endpoint URL
708
+ * @param {object[]} [opts.serverCertificateHashes] - Certificate hashes
709
+ * @param {Function} [opts._WebTransport] - Injectable constructor
710
+ */
711
+ constructor(opts = {}) {
712
+ if (!opts.url) throw new Error('url is required');
713
+ this.#url = opts.url;
714
+ this.#serverCertificateHashes = opts.serverCertificateHashes || [];
715
+ this.#WebTransportCtor = opts._WebTransport || globalThis.WebTransport;
716
+ }
717
+
718
+ // -- Getters ---------------------------------------------------------------
719
+
720
+ /** Transport type identifier. */
721
+ get type() { return 'wsh-wt'; }
722
+
723
+ /** Current connection state. */
724
+ get state() { return this.#state; }
725
+
726
+ /** True when transport is in 'connected' state. */
727
+ get connected() { return this.#state === 'connected'; }
728
+
729
+ /** WebTransport endpoint URL. */
730
+ get url() { return this.#url; }
731
+
732
+ // -- Public API ------------------------------------------------------------
733
+
734
+ /**
735
+ * Establish a WebTransport session.
736
+ * @returns {Promise<void>}
737
+ */
738
+ async connect() {
739
+ if (this.#state === 'connected' || this.#state === 'connecting') {
740
+ throw new Error('Already connected or connecting');
741
+ }
742
+ this.#state = 'connecting';
743
+
744
+ const opts = {};
745
+ if (this.#serverCertificateHashes.length > 0) {
746
+ opts.serverCertificateHashes = this.#serverCertificateHashes;
747
+ }
748
+
749
+ try {
750
+ this.#transport = new this.#WebTransportCtor(this.#url, opts);
751
+ await this.#transport.ready;
752
+ } catch (err) {
753
+ this.#state = 'disconnected';
754
+ throw err;
755
+ }
756
+
757
+ this.#writer = this.#transport.datagrams.writable.getWriter();
758
+ this.#state = 'connected';
759
+ this._fireEvent('open');
760
+
761
+ // Listen for session close
762
+ this.#transport.closed.then(() => {
763
+ if (this.#state !== 'closed' && this.#state !== 'closing') {
764
+ this.#state = 'closed';
765
+ this._fireEvent('close');
766
+ }
767
+ }).catch(() => {
768
+ if (this.#state !== 'closed') {
769
+ this.#state = 'closed';
770
+ this._fireEvent('error', new Error('WebTransport session closed unexpectedly'));
771
+ this._fireEvent('close');
772
+ }
773
+ });
774
+ }
775
+
776
+ /**
777
+ * Send data via datagram.
778
+ * @param {*} data
779
+ * @returns {Promise<void>}
780
+ */
781
+ async send(data) {
782
+ if (!this.connected) throw new Error('Not connected');
783
+ const encoded = typeof data === 'string'
784
+ ? new TextEncoder().encode(data)
785
+ : data;
786
+ await this.#writer.write(encoded);
787
+ this.#stats.messagesSent++;
788
+ this.#stats.bytesOut += byteLength(data);
789
+ }
790
+
791
+ /**
792
+ * Close the WebTransport session.
793
+ * @returns {Promise<void>}
794
+ */
795
+ async close() {
796
+ if (this.#state === 'closed' || this.#state === 'disconnected') return;
797
+ this.#state = 'closing';
798
+ if (this.#writer) {
799
+ try { await this.#writer.close(); } catch (e) { silentCatch('clawser-mesh-websocket', 'this', e) }
800
+ }
801
+ if (this.#transport) {
802
+ try { this.#transport.close(); } catch (e) { silentCatch('clawser-mesh-websocket', 'this', e) }
803
+ }
804
+ this.#state = 'closed';
805
+ this._fireEvent('close');
806
+ }
807
+
808
+ /**
809
+ * Create a new bidirectional stream.
810
+ * @returns {Promise<{ readable: ReadableStream, writable: WritableStream }>}
811
+ */
812
+ async createStream() {
813
+ if (!this.connected) throw new Error('Not connected');
814
+ const stream = await this.#transport.createBidirectionalStream();
815
+ this.#stats.streams++;
816
+ this._fireEvent('stream', stream);
817
+ return stream;
818
+ }
819
+
820
+ /**
821
+ * Register an event listener.
822
+ * @param {string} event - One of: 'open', 'message', 'close', 'error', 'stream'
823
+ * @param {Function} cb
824
+ */
825
+ on(event, cb) {
826
+ if (!WT_EVENTS.includes(event)) throw new Error(`Unknown event: ${event}`);
827
+ this.#callbacks[event].push(cb);
828
+ }
829
+
830
+ /**
831
+ * Get transport statistics.
832
+ * @returns {{ messagesSent: number, messagesReceived: number, bytesIn: number, bytesOut: number, streams: number }}
833
+ */
834
+ getStats() {
835
+ return { ...this.#stats };
836
+ }
837
+
838
+ /**
839
+ * Serialize to a JSON-safe object.
840
+ * @returns {object}
841
+ */
842
+ toJSON() {
843
+ return {
844
+ type: this.type,
845
+ state: this.#state,
846
+ url: this.#url,
847
+ stats: this.getStats(),
848
+ };
849
+ }
850
+
851
+ // -- Internal --------------------------------------------------------------
852
+
853
+ /**
854
+ * Fire all callbacks for a given event.
855
+ * @param {string} event
856
+ * @param {*} [data]
857
+ */
858
+ _fireEvent(event, data) {
859
+ for (const cb of this.#callbacks[event] || []) {
860
+ try { cb(data); } catch (e) { silentCatch('clawser-mesh-websocket', 'swallow-listener-errors', e) }
861
+ }
862
+ }
863
+ }
864
+
865
+ // ---------------------------------------------------------------------------
866
+ // NATTraversal
867
+ // ---------------------------------------------------------------------------
868
+
869
+ /**
870
+ * NAT traversal helper.
871
+ *
872
+ * Provides STUN/TURN server configuration and NAT type detection
873
+ * heuristics for WebRTC connectivity.
874
+ */
875
+ export class NATTraversal {
876
+ /** @type {string[]} */
877
+ #stunServers;
878
+
879
+ /** @type {object[]} */
880
+ #turnServers;
881
+
882
+ /** @type {string} */
883
+ #natType = 'unknown';
884
+
885
+ /**
886
+ * @param {object} [opts]
887
+ * @param {string[]} [opts.stunServers] - STUN server URLs
888
+ * @param {object[]} [opts.turnServers] - TURN server configs with urls, username, credential
889
+ */
890
+ constructor(opts = {}) {
891
+ this.#stunServers = opts.stunServers || ['stun:stun.l.google.com:19302'];
892
+ this.#turnServers = opts.turnServers || [];
893
+ }
894
+
895
+ /**
896
+ * Attempt to determine the public address using STUN.
897
+ *
898
+ * In a browser environment this would use an RTCPeerConnection to
899
+ * gather reflexive candidates. Returns a placeholder when unavailable.
900
+ *
901
+ * @returns {Promise<{ address: string, port: number, type: string }>}
902
+ */
903
+ async getPublicAddress() {
904
+ // In a real implementation, we would create an RTCPeerConnection,
905
+ // gather candidates, and parse the srflx candidate. For now, return
906
+ // a placeholder indicating the API shape.
907
+ return { address: '0.0.0.0', port: 0, type: 'unknown' };
908
+ }
909
+
910
+ /**
911
+ * Request a TURN relay allocation.
912
+ *
913
+ * In a real implementation this would use the TURN protocol to
914
+ * allocate a relay address. Returns a placeholder.
915
+ *
916
+ * @param {object} turnServer - { urls, username, credential }
917
+ * @returns {Promise<{ relayAddress: string, relayPort: number, lifetime: number }>}
918
+ */
919
+ async createRelayAllocation(turnServer) {
920
+ return {
921
+ relayAddress: '0.0.0.0',
922
+ relayPort: 0,
923
+ lifetime: 600,
924
+ };
925
+ }
926
+
927
+ /**
928
+ * Get the detected NAT type.
929
+ *
930
+ * @returns {'full-cone'|'restricted'|'port-restricted'|'symmetric'|'unknown'}
931
+ */
932
+ getNATType() {
933
+ return this.#natType;
934
+ }
935
+
936
+ /**
937
+ * Format ICE servers for RTCConfiguration.
938
+ *
939
+ * @returns {Array<{ urls: string|string[], username?: string, credential?: string }>}
940
+ */
941
+ getIceServers() {
942
+ const servers = [];
943
+ for (const stun of this.#stunServers) {
944
+ servers.push({ urls: stun });
945
+ }
946
+ for (const turn of this.#turnServers) {
947
+ servers.push({
948
+ urls: turn.urls,
949
+ username: turn.username,
950
+ credential: turn.credential,
951
+ });
952
+ }
953
+ return servers;
954
+ }
955
+ }
956
+
957
+ // ---------------------------------------------------------------------------
958
+ // TransportFactory
959
+ // ---------------------------------------------------------------------------
960
+
961
+ /**
962
+ * Factory for creating transport instances.
963
+ *
964
+ * Detects browser support for each transport type and provides a
965
+ * negotiation method that tries transports in preference order.
966
+ */
967
+ export class TransportFactory {
968
+ /** @type {string[]} */
969
+ #preferredOrder;
970
+
971
+ /** @type {NATTraversal|null} */
972
+ #natTraversal;
973
+
974
+ /** @type {Function|null} */
975
+ #WebSocketCtor;
976
+
977
+ /** @type {Function|null} */
978
+ #RTCPeerConnectionCtor;
979
+
980
+ /** @type {Function|null} */
981
+ #WebTransportCtor;
982
+
983
+ /**
984
+ * @param {object} [opts]
985
+ * @param {string[]} [opts.preferredOrder] - Transport preference order
986
+ * @param {NATTraversal} [opts.natTraversal] - NAT traversal helper
987
+ * @param {Function} [opts._WebSocket] - Injectable WebSocket constructor
988
+ * @param {Function} [opts._RTCPeerConnection] - Injectable RTCPeerConnection constructor
989
+ * @param {Function} [opts._WebTransport] - Injectable WebTransport constructor
990
+ */
991
+ constructor(opts = {}) {
992
+ this.#preferredOrder = opts.preferredOrder || ['webrtc', 'wsh-wt', 'wsh-ws'];
993
+ this.#natTraversal = opts.natTraversal || null;
994
+ this.#WebSocketCtor = opts._WebSocket !== undefined ? opts._WebSocket : (globalThis.WebSocket || null);
995
+ this.#RTCPeerConnectionCtor = opts._RTCPeerConnection !== undefined ? opts._RTCPeerConnection : (globalThis.RTCPeerConnection || null);
996
+ this.#WebTransportCtor = opts._WebTransport !== undefined ? opts._WebTransport : (globalThis.WebTransport || null);
997
+ }
998
+
999
+ /** Current preferred transport order (copy). */
1000
+ get preferredOrder() {
1001
+ return [...this.#preferredOrder];
1002
+ }
1003
+
1004
+ /**
1005
+ * Create a transport instance of the specified type.
1006
+ *
1007
+ * @param {string} type - 'webrtc', 'wsh-wt', or 'wsh-ws'
1008
+ * @param {object} opts - Options passed to the transport constructor
1009
+ * @returns {Promise<WebSocketTransport|WebRTCTransport|WebTransportTransport>}
1010
+ */
1011
+ async create(type, opts) {
1012
+ switch (type) {
1013
+ case 'wsh-ws':
1014
+ return new WebSocketTransport({
1015
+ ...opts,
1016
+ _WebSocket: this.#WebSocketCtor,
1017
+ });
1018
+ case 'webrtc':
1019
+ return new WebRTCTransport({
1020
+ ...opts,
1021
+ _RTCPeerConnection: this.#RTCPeerConnectionCtor,
1022
+ });
1023
+ case 'wsh-wt':
1024
+ return new WebTransportTransport({
1025
+ ...opts,
1026
+ _WebTransport: this.#WebTransportCtor,
1027
+ });
1028
+ default:
1029
+ throw new Error(`Unknown transport type: ${type}`);
1030
+ }
1031
+ }
1032
+
1033
+ /**
1034
+ * Detect which transport types are supported in the current
1035
+ * environment.
1036
+ *
1037
+ * @returns {string[]}
1038
+ */
1039
+ getSupportedTypes() {
1040
+ const types = [];
1041
+ if (this.#RTCPeerConnectionCtor) types.push('webrtc');
1042
+ if (this.#WebTransportCtor) types.push('wsh-wt');
1043
+ if (this.#WebSocketCtor) types.push('wsh-ws');
1044
+ return types;
1045
+ }
1046
+
1047
+ /**
1048
+ * Negotiate the best transport for a peer pair.
1049
+ *
1050
+ * Tries each transport type in preference order. Returns the first
1051
+ * successfully created (but not yet connected) transport.
1052
+ *
1053
+ * @param {string} localPodId
1054
+ * @param {string} remotePodId
1055
+ * @param {object} signaler
1056
+ * @param {object} endpointOpts - Map of type -> constructor options
1057
+ * @returns {Promise<WebSocketTransport|WebRTCTransport|WebTransportTransport>}
1058
+ */
1059
+ async negotiate(localPodId, remotePodId, signaler, endpointOpts = {}) {
1060
+ const errors = [];
1061
+ const supported = this.getSupportedTypes();
1062
+
1063
+ for (const type of this.#preferredOrder) {
1064
+ if (!supported.includes(type)) continue;
1065
+ const opts = endpointOpts[type];
1066
+ if (!opts) continue;
1067
+
1068
+ try {
1069
+ const transport = await this.create(type, {
1070
+ ...opts,
1071
+ signaler,
1072
+ localPodId,
1073
+ remotePodId,
1074
+ });
1075
+ return transport;
1076
+ } catch (e) {
1077
+ errors.push({ type, error: e.message });
1078
+ }
1079
+ }
1080
+ throw new Error(`All transports failed: ${JSON.stringify(errors)}`);
1081
+ }
1082
+ }