@johnhenry/browsermesh-transport 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,747 @@
1
+ import { silentCatch } from './silent-catch.mjs'
2
+ /**
3
+ * clawser-wisp.mjs -- WISP (WebSocket Internet Subprotocol) Transport.
4
+ *
5
+ * Multiplexes TCP streams over a single WebSocket connection to a WISP
6
+ * relay server. Each stream gets a unique 32-bit stream ID. Messages
7
+ * are binary frames with a type byte + stream ID + payload.
8
+ *
9
+ * WISP frame format (little-endian):
10
+ * [type:u8][streamId:u32][payload:...]
11
+ *
12
+ * Message types:
13
+ * 0x01 CONNECT — client→relay: open TCP stream (payload = host\0 + port:u16)
14
+ * 0x02 DATA — bidirectional: stream data
15
+ * 0x03 CONTINUE — relay→client: flow control (payload = buffer_remaining:u32)
16
+ * 0x04 CLOSE — bidirectional: close stream (payload = reason:u8)
17
+ * 0x05 INFO — relay→client: server info (WISP v2 extension)
18
+ *
19
+ * Can be used standalone for tunneling or as the backing transport for
20
+ * clawser-wisp-transport.mjs (WSH adapter).
21
+ *
22
+ * Run tests:
23
+ * node --import ./web/test/_setup-globals.mjs --test web/test/clawser-wisp.test.mjs
24
+ */
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Constants
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** WISP frame types */
31
+ export const WISP_CONNECT = 0x01
32
+ export const WISP_DATA = 0x02
33
+ export const WISP_CONTINUE = 0x03
34
+ export const WISP_CLOSE = 0x04
35
+ export const WISP_INFO = 0x05
36
+
37
+ /** Close reasons */
38
+ export const CLOSE_REASON_NORMAL = 0x00
39
+ export const CLOSE_REASON_REFUSED = 0x01
40
+ export const CLOSE_REASON_THROTTLED = 0x02
41
+ export const CLOSE_REASON_UNREACHABLE = 0x03
42
+ export const CLOSE_REASON_TIMEOUT = 0x04
43
+ export const CLOSE_REASON_ERROR = 0x05
44
+
45
+ /** Client states */
46
+ const STATES = Object.freeze(['disconnected', 'connecting', 'connected', 'closing', 'closed'])
47
+
48
+ /** Valid event names for WispClient */
49
+ const CLIENT_EVENTS = Object.freeze(['open', 'close', 'error', 'reconnect', 'info'])
50
+
51
+ /** Valid event names for WispStream */
52
+ const STREAM_EVENTS = Object.freeze(['data', 'close', 'error', 'continue'])
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Frame encoding / decoding
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Encode a WISP frame.
60
+ *
61
+ * @example
62
+ * const frame = encodeFrame(WISP_DATA, 42, new Uint8Array([1, 2, 3]))
63
+ *
64
+ * @param {number} type - Frame type byte
65
+ * @param {number} streamId - 32-bit stream ID
66
+ * @param {Uint8Array} [payload] - Optional payload bytes
67
+ * @returns {Uint8Array}
68
+ */
69
+ export const encodeFrame = (type, streamId, payload) => {
70
+ const payloadLen = payload ? payload.byteLength : 0
71
+ const buf = new Uint8Array(5 + payloadLen)
72
+ const view = new DataView(buf.buffer)
73
+ view.setUint8(0, type)
74
+ view.setUint32(1, streamId, true) // little-endian
75
+ if (payload) buf.set(payload, 5)
76
+ return buf
77
+ }
78
+
79
+ /**
80
+ * Decode a WISP frame from binary data.
81
+ *
82
+ * @example
83
+ * const { type, streamId, payload } = decodeFrame(data)
84
+ *
85
+ * @param {ArrayBuffer|Uint8Array} data - Raw frame bytes
86
+ * @returns {{ type: number, streamId: number, payload: Uint8Array }}
87
+ */
88
+ export const decodeFrame = (data) => {
89
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
90
+ if (bytes.byteLength < 5) throw new Error('WISP frame too short')
91
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
92
+ const type = view.getUint8(0)
93
+ const streamId = view.getUint32(1, true)
94
+ const payload = bytes.slice(5)
95
+ return { type, streamId, payload }
96
+ }
97
+
98
+ /**
99
+ * Encode a CONNECT payload: host (null-terminated UTF-8) + port (u16 LE).
100
+ *
101
+ * @param {string} host
102
+ * @param {number} port
103
+ * @returns {Uint8Array}
104
+ */
105
+ export const encodeConnectPayload = (host, port) => {
106
+ const encoder = new TextEncoder()
107
+ const hostBytes = encoder.encode(host)
108
+ const buf = new Uint8Array(hostBytes.byteLength + 1 + 2)
109
+ buf.set(hostBytes, 0)
110
+ buf[hostBytes.byteLength] = 0x00 // null terminator
111
+ const view = new DataView(buf.buffer)
112
+ view.setUint16(hostBytes.byteLength + 1, port, true)
113
+ return buf
114
+ }
115
+
116
+ /**
117
+ * Decode a CONNECT payload.
118
+ *
119
+ * @param {Uint8Array} payload
120
+ * @returns {{ host: string, port: number }}
121
+ */
122
+ export const decodeConnectPayload = (payload) => {
123
+ const nullIdx = payload.indexOf(0x00)
124
+ if (nullIdx === -1) throw new Error('Invalid CONNECT payload: no null terminator')
125
+ const decoder = new TextDecoder()
126
+ const host = decoder.decode(payload.slice(0, nullIdx))
127
+ const view = new DataView(payload.buffer, payload.byteOffset + nullIdx + 1, 2)
128
+ const port = view.getUint16(0, true)
129
+ return { host, port }
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // WispStream
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * A single multiplexed TCP stream within a WISP connection.
138
+ *
139
+ * @example
140
+ * const stream = await client.connect('example.com', 80)
141
+ * stream.onData((data) => console.log('received', data))
142
+ * stream.write(new TextEncoder().encode('GET / HTTP/1.0\r\n\r\n'))
143
+ * stream.close()
144
+ */
145
+ export class WispStream {
146
+ /** @type {number} */
147
+ #id
148
+
149
+ /** @type {string} */
150
+ #host
151
+
152
+ /** @type {number} */
153
+ #port
154
+
155
+ /** @type {boolean} */
156
+ #closed = false
157
+
158
+ /** @type {number} */
159
+ #bufferRemaining = 0
160
+
161
+ /** @type {Uint8Array[]} */
162
+ #writeQueue = []
163
+
164
+ /** @type {Function|null} */
165
+ #sendFrame
166
+
167
+ /** @type {{ data: Function[], close: Function[], error: Function[], continue: Function[] }} */
168
+ #callbacks = { data: [], close: [], error: [], continue: [] }
169
+
170
+ /**
171
+ * @param {number} id - Stream ID
172
+ * @param {string} host - Target host
173
+ * @param {number} port - Target port
174
+ * @param {Function} sendFrame - Callback to send frames via the parent client
175
+ */
176
+ constructor(id, host, port, sendFrame) {
177
+ this.#id = id
178
+ this.#host = host
179
+ this.#port = port
180
+ this.#sendFrame = sendFrame
181
+ }
182
+
183
+ /** Stream ID. */
184
+ get id() { return this.#id }
185
+
186
+ /** Target host. */
187
+ get host() { return this.#host }
188
+
189
+ /** Target port. */
190
+ get port() { return this.#port }
191
+
192
+ /** Whether the stream has been closed. */
193
+ get closed() { return this.#closed }
194
+
195
+ /** Remaining buffer space reported by relay. */
196
+ get bufferRemaining() { return this.#bufferRemaining }
197
+
198
+ /**
199
+ * Write data to the stream.
200
+ *
201
+ * @example
202
+ * stream.write(new TextEncoder().encode('hello'))
203
+ * stream.write(new Uint8Array([0x01, 0x02]))
204
+ *
205
+ * @param {Uint8Array|ArrayBuffer|string} data
206
+ */
207
+ write(data) {
208
+ if (this.#closed) throw new Error(`Stream ${this.#id} is closed`)
209
+ let bytes
210
+ if (typeof data === 'string') {
211
+ bytes = new TextEncoder().encode(data)
212
+ } else if (data instanceof ArrayBuffer) {
213
+ bytes = new Uint8Array(data)
214
+ } else {
215
+ bytes = data
216
+ }
217
+ this.#sendFrame(encodeFrame(WISP_DATA, this.#id, bytes))
218
+ }
219
+
220
+ /**
221
+ * Register callback for incoming data.
222
+ * @param {(data: Uint8Array) => void} cb
223
+ */
224
+ onData(cb) { this.#callbacks.data.push(cb) }
225
+
226
+ /**
227
+ * Register callback for stream close.
228
+ * @param {(reason: number) => void} cb
229
+ */
230
+ onClose(cb) { this.#callbacks.close.push(cb) }
231
+
232
+ /**
233
+ * Register callback for stream errors.
234
+ * @param {(err: Error) => void} cb
235
+ */
236
+ onError(cb) { this.#callbacks.error.push(cb) }
237
+
238
+ /**
239
+ * Register callback for CONTINUE (flow control) messages.
240
+ * @param {(bufferRemaining: number) => void} cb
241
+ */
242
+ onContinue(cb) { this.#callbacks.continue.push(cb) }
243
+
244
+ /**
245
+ * Close the stream gracefully.
246
+ * @param {number} [reason=CLOSE_REASON_NORMAL]
247
+ */
248
+ close(reason = CLOSE_REASON_NORMAL) {
249
+ if (this.#closed) return
250
+ this.#closed = true
251
+ const payload = new Uint8Array([reason])
252
+ this.#sendFrame(encodeFrame(WISP_CLOSE, this.#id, payload))
253
+ this._fireEvent('close', reason)
254
+ }
255
+
256
+ // -- Internal methods (called by WispClient) --------------------------------
257
+
258
+ /**
259
+ * Handle incoming DATA frame.
260
+ * @param {Uint8Array} payload
261
+ * @internal
262
+ */
263
+ _handleData(payload) {
264
+ if (this.#closed) return
265
+ this._fireEvent('data', payload)
266
+ }
267
+
268
+ /**
269
+ * Handle incoming CONTINUE frame.
270
+ * @param {Uint8Array} payload
271
+ * @internal
272
+ */
273
+ _handleContinue(payload) {
274
+ if (payload.byteLength >= 4) {
275
+ const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)
276
+ this.#bufferRemaining = view.getUint32(0, true)
277
+ }
278
+ this._fireEvent('continue', this.#bufferRemaining)
279
+
280
+ // flush write queue if buffer space available
281
+ while (this.#writeQueue.length > 0 && this.#bufferRemaining > 0) {
282
+ const queued = this.#writeQueue.shift()
283
+ this.#sendFrame(encodeFrame(WISP_DATA, this.#id, queued))
284
+ this.#bufferRemaining--
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Handle remote close.
290
+ * @param {number} reason
291
+ * @internal
292
+ */
293
+ _handleClose(reason) {
294
+ this.#closed = true
295
+ this.#sendFrame = () => {} // no-op after close
296
+ this._fireEvent('close', reason)
297
+ }
298
+
299
+ /**
300
+ * Force-close without sending a frame (used during disconnect).
301
+ * @internal
302
+ */
303
+ _forceClose() {
304
+ this.#closed = true
305
+ this.#sendFrame = () => {}
306
+ this._fireEvent('close', CLOSE_REASON_ERROR)
307
+ }
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-wisp', 'swallow-listener-errors', e) }
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Serialize to a JSON-safe object.
322
+ * @returns {object}
323
+ */
324
+ toJSON() {
325
+ return {
326
+ id: this.#id,
327
+ host: this.#host,
328
+ port: this.#port,
329
+ closed: this.#closed,
330
+ bufferRemaining: this.#bufferRemaining,
331
+ }
332
+ }
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // WispClient
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /**
340
+ * WISP client — connects to a relay server and multiplexes TCP streams.
341
+ *
342
+ * @example
343
+ * const client = new WispClient({
344
+ * url: 'wss://wisp-relay.example.com/',
345
+ * _WebSocket: MockWebSocket, // for testing
346
+ * })
347
+ * await client.connect()
348
+ *
349
+ * const stream = await client.open('httpbin.org', 80)
350
+ * stream.onData((data) => console.log(new TextDecoder().decode(data)))
351
+ * stream.write(new TextEncoder().encode('GET / HTTP/1.0\r\nHost: httpbin.org\r\n\r\n'))
352
+ */
353
+ export class WispClient {
354
+ /** @type {string} */
355
+ #url
356
+
357
+ /** @type {string} */
358
+ #state = 'disconnected'
359
+
360
+ /** @type {object|null} */
361
+ #ws = null
362
+
363
+ /** @type {Function} */
364
+ #WebSocketCtor
365
+
366
+ /** @type {boolean} */
367
+ #reconnect
368
+
369
+ /** @type {number} */
370
+ #maxReconnectAttempts
371
+
372
+ /** @type {number} */
373
+ #reconnectDelayMs
374
+
375
+ /** @type {number} */
376
+ #reconnectAttempts = 0
377
+
378
+ /** @type {boolean} */
379
+ #userClosed = false
380
+
381
+ /** @type {number} */
382
+ #nextStreamId = 1
383
+
384
+ /** @type {Map<number, WispStream>} */
385
+ #streams = new Map()
386
+
387
+ /** @type {{ open: Function[], close: Function[], error: Function[], reconnect: Function[], info: Function[] }} */
388
+ #callbacks = { open: [], close: [], error: [], reconnect: [], info: [] }
389
+
390
+ /** @type {{ messagesSent: number, messagesReceived: number, bytesIn: number, bytesOut: number, reconnects: number, streamsOpened: number, streamsClosed: number }} */
391
+ #stats = {
392
+ messagesSent: 0,
393
+ messagesReceived: 0,
394
+ bytesIn: 0,
395
+ bytesOut: 0,
396
+ reconnects: 0,
397
+ streamsOpened: 0,
398
+ streamsClosed: 0,
399
+ }
400
+
401
+ /** @type {object|null} */
402
+ #serverInfo = null
403
+
404
+ /**
405
+ * @param {object} opts
406
+ * @param {string} opts.url - WISP relay WebSocket URL
407
+ * @param {boolean} [opts.reconnect=true] - Enable auto-reconnect
408
+ * @param {number} [opts.maxReconnectAttempts=5] - Max reconnection attempts
409
+ * @param {number} [opts.reconnectDelayMs=1000] - Base delay between reconnects
410
+ * @param {Function} [opts._WebSocket] - Injectable WebSocket constructor (for testing)
411
+ */
412
+ constructor(opts = {}) {
413
+ if (!opts.url) throw new Error('url is required')
414
+ this.#url = opts.url
415
+ this.#reconnect = opts.reconnect !== undefined ? opts.reconnect : true
416
+ this.#maxReconnectAttempts = opts.maxReconnectAttempts ?? 5
417
+ this.#reconnectDelayMs = opts.reconnectDelayMs ?? 1000
418
+ this.#WebSocketCtor = opts._WebSocket || globalThis.WebSocket
419
+ }
420
+
421
+ // -- Getters ---------------------------------------------------------------
422
+
423
+ /** WISP relay URL. */
424
+ get url() { return this.#url }
425
+
426
+ /** Current connection state. */
427
+ get state() { return this.#state }
428
+
429
+ /** True when client is connected. */
430
+ get connected() { return this.#state === 'connected' }
431
+
432
+ /** Number of active (non-closed) streams. */
433
+ get activeStreams() { return this.#streams.size }
434
+
435
+ /** Reconnection attempts since last successful connect. */
436
+ get reconnectAttempts() { return this.#reconnectAttempts }
437
+
438
+ /** Whether auto-reconnect is enabled. */
439
+ get reconnectEnabled() { return this.#reconnect }
440
+
441
+ /** Server info received from relay (WISP v2). */
442
+ get serverInfo() { return this.#serverInfo }
443
+
444
+ // -- Public API ------------------------------------------------------------
445
+
446
+ /**
447
+ * Connect to the WISP relay server.
448
+ * @returns {Promise<void>}
449
+ */
450
+ async connect() {
451
+ if (this.#state === 'connected' || this.#state === 'connecting') {
452
+ throw new Error('Already connected or connecting')
453
+ }
454
+ this.#userClosed = false
455
+ this.#state = 'connecting'
456
+
457
+ return new Promise((resolve, reject) => {
458
+ try {
459
+ this.#ws = new this.#WebSocketCtor(this.#url)
460
+ if (this.#ws.binaryType !== undefined) {
461
+ this.#ws.binaryType = 'arraybuffer'
462
+ }
463
+ } catch (err) {
464
+ this.#state = 'disconnected'
465
+ return reject(err)
466
+ }
467
+
468
+ const onOpen = () => {
469
+ cleanup()
470
+ this.#state = 'connected'
471
+ this.#reconnectAttempts = 0
472
+ this.#ws.addEventListener('message', this.#onMessage)
473
+ this.#ws.addEventListener('close', this.#onClose)
474
+ this.#ws.addEventListener('error', this.#onError)
475
+ this._fireEvent('open')
476
+ resolve()
477
+ }
478
+
479
+ const onError = (err) => {
480
+ cleanup()
481
+ this.#state = 'disconnected'
482
+ this._fireEvent('error', err)
483
+ reject(err instanceof Error ? err : new Error('WebSocket connection failed'))
484
+ }
485
+
486
+ const cleanup = () => {
487
+ this.#ws.removeEventListener('open', onOpen)
488
+ this.#ws.removeEventListener('error', onError)
489
+ }
490
+
491
+ this.#ws.addEventListener('open', onOpen)
492
+ this.#ws.addEventListener('error', onError)
493
+ })
494
+ }
495
+
496
+ /**
497
+ * Open a new TCP stream through the WISP relay.
498
+ *
499
+ * @example
500
+ * const stream = await client.open('example.com', 443)
501
+ *
502
+ * @param {string} host - Target hostname
503
+ * @param {number} port - Target port (1-65535)
504
+ * @returns {WispStream}
505
+ */
506
+ open(host, port) {
507
+ if (!this.connected) throw new Error('Not connected')
508
+ if (!host || typeof host !== 'string') throw new Error('host is required')
509
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
510
+ throw new Error('port must be an integer between 1 and 65535')
511
+ }
512
+
513
+ const streamId = this.#nextStreamId++
514
+ const sendFrame = (frame) => this.#sendRaw(frame)
515
+ const stream = new WispStream(streamId, host, port, sendFrame)
516
+ this.#streams.set(streamId, stream)
517
+ this.#stats.streamsOpened++
518
+
519
+ // send CONNECT frame
520
+ const payload = encodeConnectPayload(host, port)
521
+ this.#sendRaw(encodeFrame(WISP_CONNECT, streamId, payload))
522
+
523
+ return stream
524
+ }
525
+
526
+ /**
527
+ * Close the client and all active streams.
528
+ * @returns {Promise<void>}
529
+ */
530
+ async close() {
531
+ if (this.#state === 'closed' || this.#state === 'disconnected') return
532
+ this.#userClosed = true
533
+ this.#state = 'closing'
534
+
535
+ // close all streams
536
+ for (const stream of this.#streams.values()) {
537
+ if (!stream.closed) {
538
+ stream._forceClose()
539
+ this.#stats.streamsClosed++
540
+ }
541
+ }
542
+ this.#streams.clear()
543
+
544
+ if (this.#ws) {
545
+ return new Promise((resolve) => {
546
+ const onClose = () => {
547
+ this.#ws.removeEventListener('close', onClose)
548
+ this.#state = 'closed'
549
+ this._fireEvent('close')
550
+ resolve()
551
+ }
552
+ this.#ws.addEventListener('close', onClose)
553
+ this.#ws.removeEventListener('close', this.#onClose)
554
+ this.#ws.close(1000, 'client shutdown')
555
+ })
556
+ }
557
+ this.#state = 'closed'
558
+ this._fireEvent('close')
559
+ }
560
+
561
+ /**
562
+ * Get a stream by ID.
563
+ * @param {number} streamId
564
+ * @returns {WispStream|undefined}
565
+ */
566
+ getStream(streamId) {
567
+ return this.#streams.get(streamId)
568
+ }
569
+
570
+ /**
571
+ * Register an event listener.
572
+ * @param {string} event - One of: 'open', 'close', 'error', 'reconnect', 'info'
573
+ * @param {Function} cb
574
+ */
575
+ on(event, cb) {
576
+ if (!CLIENT_EVENTS.includes(event)) throw new Error(`Unknown event: ${event}`)
577
+ this.#callbacks[event].push(cb)
578
+ }
579
+
580
+ /**
581
+ * Get client statistics.
582
+ * @returns {object}
583
+ */
584
+ getStats() {
585
+ return { ...this.#stats }
586
+ }
587
+
588
+ /**
589
+ * Serialize to a JSON-safe object.
590
+ * @returns {object}
591
+ */
592
+ toJSON() {
593
+ return {
594
+ url: this.#url,
595
+ state: this.#state,
596
+ activeStreams: this.#streams.size,
597
+ reconnectAttempts: this.#reconnectAttempts,
598
+ stats: this.getStats(),
599
+ serverInfo: this.#serverInfo,
600
+ }
601
+ }
602
+
603
+ // -- Internal event handlers (arrow fns for stable `this`) -----------------
604
+
605
+ /** @type {(ev: { data: * }) => void} */
606
+ #onMessage = (ev) => {
607
+ const raw = ev.data
608
+ this.#stats.messagesReceived++
609
+
610
+ let bytes
611
+ if (raw instanceof ArrayBuffer) {
612
+ bytes = new Uint8Array(raw)
613
+ } else if (raw instanceof Uint8Array) {
614
+ bytes = raw
615
+ } else {
616
+ // unexpected text frame — ignore
617
+ return
618
+ }
619
+
620
+ this.#stats.bytesIn += bytes.byteLength
621
+
622
+ let frame
623
+ try {
624
+ frame = decodeFrame(bytes)
625
+ } catch {
626
+ this._fireEvent('error', new Error('Malformed WISP frame'))
627
+ return
628
+ }
629
+
630
+ const { type, streamId, payload } = frame
631
+
632
+ switch (type) {
633
+ case WISP_DATA: {
634
+ const stream = this.#streams.get(streamId)
635
+ if (stream) stream._handleData(payload)
636
+ break
637
+ }
638
+ case WISP_CONTINUE: {
639
+ const stream = this.#streams.get(streamId)
640
+ if (stream) stream._handleContinue(payload)
641
+ break
642
+ }
643
+ case WISP_CLOSE: {
644
+ const stream = this.#streams.get(streamId)
645
+ if (stream) {
646
+ const reason = payload.byteLength > 0 ? payload[0] : CLOSE_REASON_NORMAL
647
+ stream._handleClose(reason)
648
+ this.#streams.delete(streamId)
649
+ this.#stats.streamsClosed++
650
+ }
651
+ break
652
+ }
653
+ case WISP_INFO: {
654
+ try {
655
+ const decoder = new TextDecoder()
656
+ this.#serverInfo = JSON.parse(decoder.decode(payload))
657
+ this._fireEvent('info', this.#serverInfo)
658
+ } catch {
659
+ // non-JSON info — store raw
660
+ this.#serverInfo = payload
661
+ this._fireEvent('info', payload)
662
+ }
663
+ break
664
+ }
665
+ default:
666
+ // Unknown frame type — ignore for forward compatibility
667
+ break
668
+ }
669
+ }
670
+
671
+ /** @type {(ev: *) => void} */
672
+ #onClose = (ev) => {
673
+ // force-close all active streams
674
+ for (const stream of this.#streams.values()) {
675
+ if (!stream.closed) {
676
+ stream._forceClose()
677
+ this.#stats.streamsClosed++
678
+ }
679
+ }
680
+ this.#streams.clear()
681
+
682
+ if (this.#userClosed) {
683
+ this.#state = 'closed'
684
+ this._fireEvent('close', ev)
685
+ return
686
+ }
687
+
688
+ this.#state = 'disconnected'
689
+ this._fireEvent('close', ev)
690
+ if (this.#reconnect) {
691
+ this._handleReconnect()
692
+ }
693
+ }
694
+
695
+ /** @type {(err: *) => void} */
696
+ #onError = (err) => {
697
+ this._fireEvent('error', err)
698
+ }
699
+
700
+ // -- Internal methods ------------------------------------------------------
701
+
702
+ /**
703
+ * Send raw bytes over the WebSocket.
704
+ * @param {Uint8Array} data
705
+ */
706
+ #sendRaw(data) {
707
+ if (!this.connected || !this.#ws) throw new Error('Not connected')
708
+ this.#ws.send(data)
709
+ this.#stats.messagesSent++
710
+ this.#stats.bytesOut += data.byteLength
711
+ }
712
+
713
+ /**
714
+ * Attempt reconnection with exponential backoff.
715
+ */
716
+ async _handleReconnect() {
717
+ if (this.#reconnectAttempts >= this.#maxReconnectAttempts) return
718
+
719
+ this.#reconnectAttempts++
720
+ this.#stats.reconnects++
721
+ this._fireEvent('reconnect', { attempt: this.#reconnectAttempts })
722
+
723
+ const delay = this.#reconnectDelayMs * Math.pow(2, this.#reconnectAttempts - 1)
724
+ await new Promise(r => setTimeout(r, delay))
725
+
726
+ if (this.#userClosed) return
727
+
728
+ try {
729
+ await this.connect()
730
+ } catch {
731
+ if (this.#reconnect && this.#reconnectAttempts < this.#maxReconnectAttempts) {
732
+ this._handleReconnect()
733
+ }
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Fire all callbacks for a given event.
739
+ * @param {string} event
740
+ * @param {*} [data]
741
+ */
742
+ _fireEvent(event, data) {
743
+ for (const cb of this.#callbacks[event] || []) {
744
+ try { cb(data) } catch (e) { silentCatch('clawser-wisp', 'swallow-listener-errors', e) }
745
+ }
746
+ }
747
+ }