@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
package/src/relay.mjs
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clawser-mesh-relay.js -- Relay Client for Peer Discovery & Signal Forwarding.
|
|
3
|
+
*
|
|
4
|
+
* Two paths:
|
|
5
|
+
* 1. MockRelayServer — in-memory, used by tests and offline scenarios.
|
|
6
|
+
* Construction/connect parity with the real path.
|
|
7
|
+
* 2. Real WebSocket — connects to a relay server URL and serializes
|
|
8
|
+
* the same protocol surface (register / announce / find / signal /
|
|
9
|
+
* peer_announce). Auto-reconnects with exponential backoff.
|
|
10
|
+
*
|
|
11
|
+
* Wire protocol (JSON over WS, mirrors MockRelayServer methods):
|
|
12
|
+
* client → server:
|
|
13
|
+
* {type:'register', fingerprint}
|
|
14
|
+
* {type:'announce', fingerprint, capabilities}
|
|
15
|
+
* {type:'find', requestId, query} → expects find_response
|
|
16
|
+
* {type:'signal', from, to, signal}
|
|
17
|
+
* server → client:
|
|
18
|
+
* {type:'peer_announce', fingerprint, capabilities}
|
|
19
|
+
* {type:'signal', from, signal}
|
|
20
|
+
* {type:'find_response', requestId, peers}
|
|
21
|
+
* {type:'error', message}
|
|
22
|
+
*
|
|
23
|
+
* Run tests:
|
|
24
|
+
* node --import ./web/test/_setup-globals.mjs --test web/test/clawser-mesh-relay.test.mjs
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Constants
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** @type {readonly string[]} */
|
|
32
|
+
const RELAY_STATES = Object.freeze([
|
|
33
|
+
'disconnected',
|
|
34
|
+
'connecting',
|
|
35
|
+
'connected',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// MockRelayServer
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* In-memory relay server for testing.
|
|
44
|
+
* Tracks connected clients and forwards signals between them.
|
|
45
|
+
*/
|
|
46
|
+
export class MockRelayServer {
|
|
47
|
+
/** @type {Map<string, MeshRelayClient>} fingerprint -> client */
|
|
48
|
+
#clients = new Map();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register a client with the relay.
|
|
52
|
+
*
|
|
53
|
+
* @param {MeshRelayClient} client
|
|
54
|
+
*/
|
|
55
|
+
registerClient(client) {
|
|
56
|
+
this.#clients.set(client.fingerprint, client);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Remove a client by fingerprint.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} fingerprint
|
|
63
|
+
* @returns {boolean} true if the client existed
|
|
64
|
+
*/
|
|
65
|
+
removeClient(fingerprint) {
|
|
66
|
+
return this.#clients.delete(fingerprint);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get all currently connected peers as descriptors.
|
|
71
|
+
*
|
|
72
|
+
* @returns {Array<{ fingerprint: string, capabilities: string[], endpoint: string|null }>}
|
|
73
|
+
*/
|
|
74
|
+
getConnectedPeers() {
|
|
75
|
+
return [...this.#clients.values()].map(c => ({
|
|
76
|
+
fingerprint: c.fingerprint,
|
|
77
|
+
capabilities: [...c._announcedCapabilities],
|
|
78
|
+
endpoint: c._endpoint,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Find peers matching a query.
|
|
84
|
+
* Supports filtering by `capability` (string) -- returns peers whose
|
|
85
|
+
* capabilities array includes the value.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} [query]
|
|
88
|
+
* @param {string} [query.capability] - Required capability
|
|
89
|
+
* @returns {Array<{ fingerprint: string, capabilities: string[], endpoint: string|null }>}
|
|
90
|
+
*/
|
|
91
|
+
findPeers(query = {}) {
|
|
92
|
+
let peers = this.getConnectedPeers();
|
|
93
|
+
if (query.capability) {
|
|
94
|
+
peers = peers.filter(p => p.capabilities.includes(query.capability));
|
|
95
|
+
}
|
|
96
|
+
return peers;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Forward a signaling message from one client to another.
|
|
101
|
+
* Delivers via the target client's internal signal handler if connected.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} fromFingerprint
|
|
104
|
+
* @param {string} toFingerprint
|
|
105
|
+
* @param {*} signal - Signal data (SDP offer/answer, ICE candidate, etc.)
|
|
106
|
+
* @returns {boolean} true if the signal was delivered
|
|
107
|
+
*/
|
|
108
|
+
forwardSignal(fromFingerprint, toFingerprint, signal) {
|
|
109
|
+
const target = this.#clients.get(toFingerprint);
|
|
110
|
+
if (!target) return false;
|
|
111
|
+
target._deliverSignal(fromFingerprint, signal);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Notify all connected clients about a peer announcement.
|
|
117
|
+
* Skips the announcing client itself.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} fingerprint - The announcing peer
|
|
120
|
+
* @param {string[]} capabilities
|
|
121
|
+
*/
|
|
122
|
+
broadcastPresence(fingerprint, capabilities) {
|
|
123
|
+
for (const [fp, client] of this.#clients) {
|
|
124
|
+
if (fp === fingerprint) continue;
|
|
125
|
+
client._deliverPeerAnnounce({ fingerprint, capabilities });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @returns {number} */
|
|
130
|
+
get size() {
|
|
131
|
+
return this.#clients.size;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// MeshRelayClient
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Client for connecting to a signaling/relay server.
|
|
141
|
+
*
|
|
142
|
+
* Handles peer discovery and signal forwarding for establishing
|
|
143
|
+
* direct peer-to-peer connections (WebRTC offers/answers, ICE candidates).
|
|
144
|
+
*
|
|
145
|
+
* State machine: disconnected -> connecting -> connected -> disconnected
|
|
146
|
+
*/
|
|
147
|
+
export class MeshRelayClient {
|
|
148
|
+
/** @type {string} */
|
|
149
|
+
#relayUrl;
|
|
150
|
+
|
|
151
|
+
/** @type {string} */
|
|
152
|
+
#fingerprint;
|
|
153
|
+
|
|
154
|
+
/** @type {string} */
|
|
155
|
+
#state = 'disconnected';
|
|
156
|
+
|
|
157
|
+
/** @type {MockRelayServer|null} */
|
|
158
|
+
#server = null;
|
|
159
|
+
|
|
160
|
+
/** @type {Function} */
|
|
161
|
+
#onLog;
|
|
162
|
+
|
|
163
|
+
/** @type {Function[]} */
|
|
164
|
+
#signalCallbacks = [];
|
|
165
|
+
|
|
166
|
+
/** @type {Function[]} */
|
|
167
|
+
#peerAnnounceCallbacks = [];
|
|
168
|
+
|
|
169
|
+
/** @type {Function[]} */
|
|
170
|
+
#connectCallbacks = [];
|
|
171
|
+
|
|
172
|
+
/** @type {Function[]} */
|
|
173
|
+
#disconnectCallbacks = [];
|
|
174
|
+
|
|
175
|
+
/** @type {Function[]} */
|
|
176
|
+
#errorCallbacks = [];
|
|
177
|
+
|
|
178
|
+
/** @type {string[]} Exposed for MockRelayServer to read. */
|
|
179
|
+
_announcedCapabilities = [];
|
|
180
|
+
|
|
181
|
+
/** @type {string|null} Exposed for MockRelayServer to read. */
|
|
182
|
+
_endpoint = null;
|
|
183
|
+
|
|
184
|
+
/** @type {number} */
|
|
185
|
+
#knownPeerCount = 0;
|
|
186
|
+
|
|
187
|
+
/** @type {WebSocket|null} Real WS used when no MockRelayServer is given. */
|
|
188
|
+
#ws = null;
|
|
189
|
+
|
|
190
|
+
/** @type {boolean} True after .disconnect() / consumer-initiated close. */
|
|
191
|
+
#userClosed = false;
|
|
192
|
+
|
|
193
|
+
/** @type {number} */
|
|
194
|
+
#reconnectAttempts = 0;
|
|
195
|
+
|
|
196
|
+
/** @type {number} */
|
|
197
|
+
#maxReconnectAttempts;
|
|
198
|
+
|
|
199
|
+
/** @type {number} */
|
|
200
|
+
#reconnectDelayMs;
|
|
201
|
+
|
|
202
|
+
/** @type {boolean} */
|
|
203
|
+
#autoReconnect;
|
|
204
|
+
|
|
205
|
+
/** @type {Function|null} - WebSocket constructor override (Node tests). */
|
|
206
|
+
#WebSocketCtor;
|
|
207
|
+
|
|
208
|
+
/** @type {Map<string, {resolve:Function, reject:Function, timer:any}>} */
|
|
209
|
+
#pendingFinds = new Map();
|
|
210
|
+
|
|
211
|
+
/** @type {number} */
|
|
212
|
+
#findSeq = 0;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {object} opts
|
|
216
|
+
* @param {string} opts.relayUrl - WebSocket endpoint for the relay
|
|
217
|
+
* @param {{ fingerprint: string }} opts.identity - Local identity
|
|
218
|
+
* @param {Function} [opts.onLog] - Logging callback (level, msg)
|
|
219
|
+
* @param {Function} [opts.WebSocket] - Override constructor (Node tests)
|
|
220
|
+
* @param {number} [opts.maxReconnectAttempts=5]
|
|
221
|
+
* @param {number} [opts.reconnectDelayMs=500] - Base delay; exp-backoff multiplies
|
|
222
|
+
* @param {boolean} [opts.autoReconnect=true]
|
|
223
|
+
*/
|
|
224
|
+
constructor({ relayUrl, identity, onLog, WebSocket: WSCtor, maxReconnectAttempts, reconnectDelayMs, autoReconnect } = {}) {
|
|
225
|
+
if (!relayUrl || typeof relayUrl !== 'string') {
|
|
226
|
+
throw new Error('relayUrl is required and must be a non-empty string');
|
|
227
|
+
}
|
|
228
|
+
if (!identity || !identity.fingerprint) {
|
|
229
|
+
throw new Error('identity with fingerprint is required');
|
|
230
|
+
}
|
|
231
|
+
this.#relayUrl = relayUrl;
|
|
232
|
+
this.#fingerprint = identity.fingerprint;
|
|
233
|
+
this.#onLog = onLog || (() => {});
|
|
234
|
+
this.#WebSocketCtor = WSCtor || (typeof WebSocket !== 'undefined' ? WebSocket : null);
|
|
235
|
+
this.#maxReconnectAttempts = maxReconnectAttempts ?? 5;
|
|
236
|
+
this.#reconnectDelayMs = reconnectDelayMs ?? 500;
|
|
237
|
+
this.#autoReconnect = autoReconnect !== false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// -- Accessors ------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** Relay server URL. */
|
|
243
|
+
get relayUrl() {
|
|
244
|
+
return this.#relayUrl;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Local fingerprint. */
|
|
248
|
+
get fingerprint() {
|
|
249
|
+
return this.#fingerprint;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Current connection state. */
|
|
253
|
+
get state() {
|
|
254
|
+
return this.#state;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** True when connected to the relay. */
|
|
258
|
+
get connected() {
|
|
259
|
+
return this.#state === 'connected';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// -- Lifecycle ------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Connect to the relay server.
|
|
266
|
+
*
|
|
267
|
+
* Two modes:
|
|
268
|
+
* - With a `MockRelayServer` argument: in-memory test path.
|
|
269
|
+
* - Without one: opens a real WebSocket to `relayUrl`. Resolves
|
|
270
|
+
* after the WS open + register handshake, or rejects on failure.
|
|
271
|
+
*
|
|
272
|
+
* @param {MockRelayServer} [mockServer] - Mock server instance for testing
|
|
273
|
+
* @returns {Promise<void>}
|
|
274
|
+
*/
|
|
275
|
+
async connect(mockServer) {
|
|
276
|
+
if (this.#state === 'connected') return;
|
|
277
|
+
this.#userClosed = false;
|
|
278
|
+
this.#state = 'connecting';
|
|
279
|
+
this.#onLog(2, `Connecting to relay: ${this.#relayUrl}`);
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
if (mockServer) {
|
|
283
|
+
this.#server = mockServer;
|
|
284
|
+
mockServer.registerClient(this);
|
|
285
|
+
this.#state = 'connected';
|
|
286
|
+
this.#onLog(2, 'Connected to relay (mock)');
|
|
287
|
+
this.#fire(this.#connectCallbacks);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
await this.#connectRealWs();
|
|
291
|
+
this.#state = 'connected';
|
|
292
|
+
this.#reconnectAttempts = 0;
|
|
293
|
+
this.#onLog(2, 'Connected to relay (ws)');
|
|
294
|
+
this.#fire(this.#connectCallbacks);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
this.#state = 'disconnected';
|
|
297
|
+
this.#fire(this.#errorCallbacks, err);
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Disconnect from the relay server.
|
|
304
|
+
*/
|
|
305
|
+
disconnect() {
|
|
306
|
+
if (this.#state === 'disconnected') return;
|
|
307
|
+
this.#userClosed = true;
|
|
308
|
+
if (this.#server) {
|
|
309
|
+
this.#server.removeClient(this.#fingerprint);
|
|
310
|
+
this.#server = null;
|
|
311
|
+
}
|
|
312
|
+
if (this.#ws) {
|
|
313
|
+
try { this.#sendWs({ type: 'unregister', fingerprint: this.#fingerprint }); } catch { /* ignore */ }
|
|
314
|
+
try { this.#ws.close(); } catch { /* ignore */ }
|
|
315
|
+
this.#ws = null;
|
|
316
|
+
}
|
|
317
|
+
// Reject any in-flight finds so callers don't hang.
|
|
318
|
+
for (const [, p] of this.#pendingFinds) {
|
|
319
|
+
try { clearTimeout(p.timer); } catch { /* ignore */ }
|
|
320
|
+
p.reject(new Error('relay disconnected'));
|
|
321
|
+
}
|
|
322
|
+
this.#pendingFinds.clear();
|
|
323
|
+
this.#state = 'disconnected';
|
|
324
|
+
this._announcedCapabilities = [];
|
|
325
|
+
this.#knownPeerCount = 0;
|
|
326
|
+
this.#onLog(2, 'Disconnected from relay');
|
|
327
|
+
this.#fire(this.#disconnectCallbacks);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Open the real WebSocket and run the register handshake. Returns
|
|
332
|
+
* when the server has accepted our register message (or `open` if
|
|
333
|
+
* the server is silent).
|
|
334
|
+
* @returns {Promise<void>}
|
|
335
|
+
*/
|
|
336
|
+
async #connectRealWs() {
|
|
337
|
+
if (!this.#WebSocketCtor) {
|
|
338
|
+
throw new Error('WebSocket is not available in this environment');
|
|
339
|
+
}
|
|
340
|
+
return new Promise((resolve, reject) => {
|
|
341
|
+
let settled = false;
|
|
342
|
+
let ws;
|
|
343
|
+
try { ws = new this.#WebSocketCtor(this.#relayUrl); }
|
|
344
|
+
catch (e) { reject(e); return; }
|
|
345
|
+
this.#ws = ws;
|
|
346
|
+
|
|
347
|
+
const onOpen = () => {
|
|
348
|
+
// Send register immediately on open. Server may or may not ack.
|
|
349
|
+
this.#sendWs({ type: 'register', fingerprint: this.#fingerprint });
|
|
350
|
+
if (!settled) { settled = true; resolve(); }
|
|
351
|
+
};
|
|
352
|
+
const onMessage = (ev) => this.#handleWsMessage(ev);
|
|
353
|
+
const onErr = (ev) => {
|
|
354
|
+
const err = ev?.error || new Error('relay ws error');
|
|
355
|
+
if (!settled) { settled = true; reject(err); return; }
|
|
356
|
+
// Post-open errors propagate via callbacks.
|
|
357
|
+
this.#fire(this.#errorCallbacks, err);
|
|
358
|
+
};
|
|
359
|
+
const onClose = () => {
|
|
360
|
+
// Detach so re-opens get fresh handlers.
|
|
361
|
+
try { ws.removeEventListener?.('open', onOpen); } catch { /* ignore */ }
|
|
362
|
+
try { ws.removeEventListener?.('message', onMessage); } catch { /* ignore */ }
|
|
363
|
+
try { ws.removeEventListener?.('error', onErr); } catch { /* ignore */ }
|
|
364
|
+
try { ws.removeEventListener?.('close', onClose); } catch { /* ignore */ }
|
|
365
|
+
if (!settled) { settled = true; reject(new Error('relay ws closed before open')); return; }
|
|
366
|
+
if (this.#userClosed) {
|
|
367
|
+
this.#state = 'disconnected';
|
|
368
|
+
this.#fire(this.#disconnectCallbacks);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
this.#state = 'disconnected';
|
|
372
|
+
this.#fire(this.#disconnectCallbacks);
|
|
373
|
+
if (this.#autoReconnect) this.#scheduleReconnect();
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// Support both Node `ws` library (.on) and browser (.addEventListener).
|
|
377
|
+
if (typeof ws.addEventListener === 'function') {
|
|
378
|
+
ws.addEventListener('open', onOpen);
|
|
379
|
+
ws.addEventListener('message', onMessage);
|
|
380
|
+
ws.addEventListener('error', onErr);
|
|
381
|
+
ws.addEventListener('close', onClose);
|
|
382
|
+
} else if (typeof ws.on === 'function') {
|
|
383
|
+
ws.on('open', onOpen);
|
|
384
|
+
ws.on('message', (data) => onMessage({ data: typeof data === 'string' ? data : data.toString() }));
|
|
385
|
+
ws.on('error', (err) => onErr({ error: err }));
|
|
386
|
+
ws.on('close', onClose);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
#scheduleReconnect() {
|
|
392
|
+
if (this.#reconnectAttempts >= this.#maxReconnectAttempts) {
|
|
393
|
+
this.#fire(this.#errorCallbacks, new Error(
|
|
394
|
+
`Relay reconnect failed after ${this.#maxReconnectAttempts} attempts (giving up)`,
|
|
395
|
+
));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
this.#reconnectAttempts++;
|
|
399
|
+
const delay = this.#reconnectDelayMs * Math.pow(2, this.#reconnectAttempts - 1);
|
|
400
|
+
setTimeout(() => {
|
|
401
|
+
if (this.#userClosed) return;
|
|
402
|
+
this.connect().catch(() => { /* errors already fired */ });
|
|
403
|
+
}, delay);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#sendWs(msg) {
|
|
407
|
+
if (!this.#ws) return;
|
|
408
|
+
try { this.#ws.send(JSON.stringify(msg)); }
|
|
409
|
+
catch (e) { this.#onLog(3, `relay send failed: ${e?.message || e}`); }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#handleWsMessage(ev) {
|
|
413
|
+
let msg;
|
|
414
|
+
try { msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data; }
|
|
415
|
+
catch { return; }
|
|
416
|
+
if (!msg || typeof msg.type !== 'string') return;
|
|
417
|
+
switch (msg.type) {
|
|
418
|
+
case 'peer_announce':
|
|
419
|
+
this.#fire(this.#peerAnnounceCallbacks, {
|
|
420
|
+
fingerprint: msg.fingerprint,
|
|
421
|
+
capabilities: msg.capabilities || [],
|
|
422
|
+
});
|
|
423
|
+
break;
|
|
424
|
+
case 'signal':
|
|
425
|
+
this.#fire(this.#signalCallbacks, msg.from, msg.signal);
|
|
426
|
+
break;
|
|
427
|
+
case 'find_response': {
|
|
428
|
+
const pending = this.#pendingFinds.get(msg.requestId);
|
|
429
|
+
if (pending) {
|
|
430
|
+
clearTimeout(pending.timer);
|
|
431
|
+
this.#pendingFinds.delete(msg.requestId);
|
|
432
|
+
pending.resolve(msg.peers || []);
|
|
433
|
+
}
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
case 'error':
|
|
437
|
+
this.#fire(this.#errorCallbacks, new Error(msg.message || 'relay error'));
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// -- Presence & Discovery -------------------------------------------------
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Announce this peer's presence and capabilities to the relay.
|
|
446
|
+
*
|
|
447
|
+
* @param {string[]} capabilities - List of capability strings to advertise
|
|
448
|
+
*/
|
|
449
|
+
announcePresence(capabilities) {
|
|
450
|
+
this.#assertConnected();
|
|
451
|
+
this._announcedCapabilities = [...capabilities];
|
|
452
|
+
this.#onLog(
|
|
453
|
+
2,
|
|
454
|
+
`Announced presence with capabilities: ${capabilities.join(', ')}`,
|
|
455
|
+
);
|
|
456
|
+
if (this.#server) {
|
|
457
|
+
this.#server.broadcastPresence(this.#fingerprint, capabilities);
|
|
458
|
+
} else if (this.#ws) {
|
|
459
|
+
this.#sendWs({
|
|
460
|
+
type: 'announce',
|
|
461
|
+
fingerprint: this.#fingerprint,
|
|
462
|
+
capabilities: [...capabilities],
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Query the relay for peers matching criteria.
|
|
469
|
+
*
|
|
470
|
+
* @param {object} [query]
|
|
471
|
+
* @param {string} [query.capability] - Required capability
|
|
472
|
+
* @returns {Promise<Array<{ fingerprint: string, capabilities: string[], endpoint: string|null }>>}
|
|
473
|
+
*/
|
|
474
|
+
async findPeers(query = {}) {
|
|
475
|
+
this.#assertConnected();
|
|
476
|
+
if (this.#server) {
|
|
477
|
+
// Mock path
|
|
478
|
+
const peers = this.#server.findPeers(query)
|
|
479
|
+
.filter(p => p.fingerprint !== this.#fingerprint);
|
|
480
|
+
this.#knownPeerCount = peers.length;
|
|
481
|
+
return peers;
|
|
482
|
+
}
|
|
483
|
+
if (this.#ws) {
|
|
484
|
+
// Real WS path: send find request, await response keyed by requestId.
|
|
485
|
+
const requestId = `find_${++this.#findSeq}`;
|
|
486
|
+
const result = await new Promise((resolve, reject) => {
|
|
487
|
+
const timer = setTimeout(() => {
|
|
488
|
+
this.#pendingFinds.delete(requestId);
|
|
489
|
+
reject(new Error(`relay findPeers timed out after 5s`));
|
|
490
|
+
}, 5000);
|
|
491
|
+
this.#pendingFinds.set(requestId, { resolve, reject, timer });
|
|
492
|
+
this.#sendWs({ type: 'find', requestId, query });
|
|
493
|
+
});
|
|
494
|
+
const filtered = result.filter(p => p.fingerprint !== this.#fingerprint);
|
|
495
|
+
this.#knownPeerCount = filtered.length;
|
|
496
|
+
return filtered;
|
|
497
|
+
}
|
|
498
|
+
return [];
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// -- Signal Forwarding ----------------------------------------------------
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Forward a signaling message to a target peer via the relay.
|
|
505
|
+
*
|
|
506
|
+
* @param {string} targetFingerprint - Recipient's fingerprint
|
|
507
|
+
* @param {*} signal - Signal data (SDP offer/answer, ICE candidate, etc.)
|
|
508
|
+
* @returns {boolean} true if the signal was delivered
|
|
509
|
+
*/
|
|
510
|
+
forwardSignal(targetFingerprint, signal) {
|
|
511
|
+
this.#assertConnected();
|
|
512
|
+
this.#onLog(2, `Forwarding signal to ${targetFingerprint}`);
|
|
513
|
+
if (this.#server) {
|
|
514
|
+
return this.#server.forwardSignal(
|
|
515
|
+
this.#fingerprint,
|
|
516
|
+
targetFingerprint,
|
|
517
|
+
signal,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
if (this.#ws) {
|
|
521
|
+
// Real WS path: server-side relay does the routing. We optimistically
|
|
522
|
+
// return true; the server reports `error` asynchronously if the
|
|
523
|
+
// target is offline.
|
|
524
|
+
this.#sendWs({
|
|
525
|
+
type: 'signal',
|
|
526
|
+
from: this.#fingerprint,
|
|
527
|
+
to: targetFingerprint,
|
|
528
|
+
signal,
|
|
529
|
+
});
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// -- Event Registration ---------------------------------------------------
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Register a callback for incoming signals from other peers.
|
|
539
|
+
* Callback receives (fromFingerprint, signal).
|
|
540
|
+
*
|
|
541
|
+
* @param {Function} cb
|
|
542
|
+
*/
|
|
543
|
+
onSignal(cb) {
|
|
544
|
+
this.#signalCallbacks.push(cb);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Register a callback for peer presence announcements.
|
|
549
|
+
* Callback receives ({ fingerprint, capabilities }).
|
|
550
|
+
*
|
|
551
|
+
* @param {Function} cb
|
|
552
|
+
*/
|
|
553
|
+
onPeerAnnounce(cb) {
|
|
554
|
+
this.#peerAnnounceCallbacks.push(cb);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Register a callback for when the relay connection is established.
|
|
559
|
+
*
|
|
560
|
+
* @param {Function} cb
|
|
561
|
+
*/
|
|
562
|
+
onConnect(cb) {
|
|
563
|
+
this.#connectCallbacks.push(cb);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Register a callback for when the relay connection is closed.
|
|
568
|
+
*
|
|
569
|
+
* @param {Function} cb
|
|
570
|
+
*/
|
|
571
|
+
onDisconnect(cb) {
|
|
572
|
+
this.#disconnectCallbacks.push(cb);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Register a callback for relay errors.
|
|
577
|
+
*
|
|
578
|
+
* @param {Function} cb
|
|
579
|
+
*/
|
|
580
|
+
onError(cb) {
|
|
581
|
+
this.#errorCallbacks.push(cb);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// -- Serialization --------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Serialize to a JSON-safe object (no callbacks/handles).
|
|
588
|
+
*
|
|
589
|
+
* @returns {object}
|
|
590
|
+
*/
|
|
591
|
+
toJSON() {
|
|
592
|
+
return {
|
|
593
|
+
relayUrl: this.#relayUrl,
|
|
594
|
+
fingerprint: this.#fingerprint,
|
|
595
|
+
connected: this.connected,
|
|
596
|
+
state: this.#state,
|
|
597
|
+
capabilities: [...this._announcedCapabilities],
|
|
598
|
+
knownPeerCount: this.#knownPeerCount,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// -- Internal (used by MockRelayServer) -----------------------------------
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Deliver an incoming signal from another peer.
|
|
606
|
+
* Called by MockRelayServer.forwardSignal().
|
|
607
|
+
*
|
|
608
|
+
* @param {string} fromFingerprint
|
|
609
|
+
* @param {*} signal
|
|
610
|
+
*/
|
|
611
|
+
_deliverSignal(fromFingerprint, signal) {
|
|
612
|
+
this.#fire(this.#signalCallbacks, fromFingerprint, signal);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Deliver a peer presence announcement.
|
|
617
|
+
* Called by MockRelayServer.broadcastPresence().
|
|
618
|
+
*
|
|
619
|
+
* @param {{ fingerprint: string, capabilities: string[] }} info
|
|
620
|
+
*/
|
|
621
|
+
_deliverPeerAnnounce(info) {
|
|
622
|
+
this.#fire(this.#peerAnnounceCallbacks, info);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// -- Private Helpers ------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Assert the client is connected. Throws if not.
|
|
629
|
+
*/
|
|
630
|
+
#assertConnected() {
|
|
631
|
+
if (this.#state !== 'connected') {
|
|
632
|
+
throw new Error('Not connected to relay');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Fire all callbacks in a list, swallowing listener errors.
|
|
638
|
+
*
|
|
639
|
+
* @param {Function[]} callbacks
|
|
640
|
+
* @param {...*} args
|
|
641
|
+
*/
|
|
642
|
+
#fire(callbacks, ...args) {
|
|
643
|
+
for (const cb of callbacks) {
|
|
644
|
+
try {
|
|
645
|
+
cb(...args);
|
|
646
|
+
} catch {
|
|
647
|
+
/* listener errors do not propagate */
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export { RELAY_STATES };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* silent-catch.mjs — debug-gated structured logger for the
|
|
3
|
+
* `try { … } catch { /* ignore *\/ }` pattern.
|
|
4
|
+
*
|
|
5
|
+
* Vendored from clawser-silent-catch.mjs (fully standalone, no other
|
|
6
|
+
* clawser-app imports) so this package doesn't need to reach into web/.
|
|
7
|
+
*
|
|
8
|
+
* Replaces silent catches with an opt-in inspectable record. Default
|
|
9
|
+
* behaviour is unchanged (silent), so this is a drop-in replacement.
|
|
10
|
+
* Users surface the events by enabling debug mode:
|
|
11
|
+
*
|
|
12
|
+
* localStorage.setItem('clawser_debug', 'true') // persistent
|
|
13
|
+
* // or, in DevTools: clawserDebug.enable()
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
let enabled = false;
|
|
17
|
+
try {
|
|
18
|
+
if (typeof localStorage !== 'undefined') {
|
|
19
|
+
enabled = localStorage.getItem('clawser_debug') === 'true';
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// localStorage may throw in privacy / sandbox contexts — treat as disabled
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Re-check the localStorage flag; called by clawserDebug.enable/disable. */
|
|
26
|
+
export function refreshSilentCatchState() {
|
|
27
|
+
try {
|
|
28
|
+
if (typeof localStorage !== 'undefined') {
|
|
29
|
+
enabled = localStorage.getItem('clawser_debug') === 'true';
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
enabled = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Log a silent-catch event. No-op unless debug mode is enabled.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} module — file/component (e.g. 'clawser-pod')
|
|
40
|
+
* @param {string} operation — what was attempted (e.g. 'relay-disconnect')
|
|
41
|
+
* @param {*} error — the caught value
|
|
42
|
+
* @param {object} [context] — extra structured fields
|
|
43
|
+
*/
|
|
44
|
+
export function silentCatch(module, operation, error, context) {
|
|
45
|
+
// Re-read the flag each call so toggles in DevTools take effect without
|
|
46
|
+
// restart. Cheap (one localStorage hit per silent catch in debug mode;
|
|
47
|
+
// catches happen rarely on hot paths).
|
|
48
|
+
if (!enabled) {
|
|
49
|
+
refreshSilentCatchState();
|
|
50
|
+
if (!enabled) return;
|
|
51
|
+
}
|
|
52
|
+
const entry = { module, operation, error: error?.message || String(error) };
|
|
53
|
+
if (context) Object.assign(entry, context);
|
|
54
|
+
console.warn('[clawser:silent-catch]', entry);
|
|
55
|
+
}
|