@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/package.json +31 -0
- package/src/channel-relay.mjs +225 -0
- package/src/cross-origin.mjs +543 -0
- package/src/gateway.mjs +627 -0
- package/src/index.mjs +12 -0
- package/src/relay.mjs +653 -0
- package/src/silent-catch.mjs +55 -0
- package/src/streams.mjs +627 -0
- package/src/transport.mjs +357 -0
- package/src/webrtc.mjs +773 -0
- package/src/websocket.mjs +1082 -0
- package/src/webtransport.mjs +216 -0
- package/src/wisp-client.mjs +747 -0
- package/src/wisp.mjs +348 -0
- package/src/wsh-bridge.mjs +242 -0
|
@@ -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
|
+
}
|