@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/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
+ }