snapreq 0.0.1

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,1029 @@
1
+ // @ts-check
2
+
3
+ import SnapReqWebSocketConnection from "./websocket-connection.js"
4
+ import SnapReqWebSocketChannel from "./websocket-channel.js"
5
+
6
+ const DEFAULT_RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 15000]
7
+
8
+ /**
9
+ * A small WebSocket client that mirrors simple HTTP-style calls and channel
10
+ * subscriptions over `globalThis.WebSocket`, so the same code runs on web, Expo
11
+ * / React Native and Node. Supports optional auto-reconnect with exponential
12
+ * backoff, session resumption and listener re-subscription.
13
+ *
14
+ * Response bodies are returned as raw parsed JSON; apps that need their own
15
+ * (de)serialization should apply it around `post`/`get` and the response
16
+ * `json()`.
17
+ */
18
+ export default class SnapReqWebSocketClient {
19
+ /** @type {Map<string, {reject: (error: unknown) => void, resolve: (response: SnapReqWebSocketResponse) => void}>} */
20
+ pendingRequests
21
+ /** @type {Map<string, {reject: (error: unknown) => void, resolve: (value?: void) => void}>} */
22
+ pendingSubscriptions
23
+ /** @type {Map<string, {callbacks: Set<(payload: any) => void>, channel: string, params: Record<string, any> | undefined, ready: Promise<void>}>} */
24
+ listeners
25
+
26
+ /**
27
+ * @param {object} args - Options object.
28
+ * @param {string} args.url - Full WebSocket URL, e.g. `ws://localhost:3006/websocket`.
29
+ * @param {boolean} [args.autoReconnect] - Enable auto-reconnect with exponential backoff.
30
+ * @param {boolean} [args.debug] - Whether to log debug output.
31
+ * @param {{getIsOnline?: () => boolean | Promise<boolean>, subscribe?: (callback: (isOnline: boolean) => void) => (() => void) | {remove: () => void}}} [args.networkMonitor] - Optional online-state adapter. When provided, auto-reconnect can wait for the network to report online before reconnecting, and open sockets are closed when the monitor reports offline.
32
+ * @param {number[]} [args.reconnectDelays] - Backoff delays in ms (default: [1000, 2000, 4000, 8000, 15000]).
33
+ * @param {{get: () => string | null | undefined | Promise<string | null | undefined>, set: (sessionId: string) => void | Promise<void>, clear: () => void | Promise<void>}} [args.sessionStore] - Optional sessionId persistence hook surviving reloads (localStorage, a cookie, SQLite, etc.).
34
+ */
35
+ constructor({autoReconnect = true, debug = false, networkMonitor, reconnectDelays, sessionStore, url} = /** @type {any} */ ({})) {
36
+ if (!globalThis.WebSocket) throw new Error("WebSocket global is not available")
37
+ if (!url) throw new Error("SnapReqWebSocketClient requires a url")
38
+
39
+ /** @type {boolean} */
40
+ this.autoReconnect = autoReconnect
41
+ this.debug = debug
42
+ /** @type {number | null} */
43
+ this.disconnectedSince = null
44
+ this.pendingRequests = new Map()
45
+ this.pendingSubscriptions = new Map()
46
+ /** @type {number} */
47
+ this.reconnectAttempt = 0
48
+ /** @type {number} */
49
+ this.connectionAttempts = 0
50
+ /** @type {number[]} */
51
+ this.reconnectDelays = reconnectDelays || DEFAULT_RECONNECT_DELAYS
52
+ /** @type {ReturnType<typeof setTimeout> | null} */
53
+ this.reconnectTimer = null
54
+ this.url = url
55
+ this.listeners = new Map()
56
+ this.nextID = 1
57
+ /** @type {(() => void | Promise<void>) | null} */
58
+ this.onReconnect = null
59
+
60
+ /** @type {Record<string, any>} */
61
+ this._metadata = {}
62
+
63
+ /** @type {Map<string, SnapReqWebSocketConnection>} */
64
+ this._connections = new Map()
65
+
66
+ /** @type {Map<string, SnapReqWebSocketChannel>} */
67
+ this._channelSubscriptions = new Map()
68
+
69
+ this._nextConnectionIdSeq = 1
70
+ this._nextSubscriptionIdSeq = 1
71
+
72
+ /** @type {string | null} - sessionId received from `session-established`; sent on reconnect for resumption. */
73
+ this._sessionId = null
74
+
75
+ /** @type {boolean} - true between a reconnect and the session-resumed / session-gone reply. */
76
+ this._awaitingResume = false
77
+
78
+ /** @type {boolean} - true once the current socket has an active session ready for app messages. */
79
+ this._sessionReady = false
80
+
81
+ /** @type {string | null} - provisional session id announced before a resume attempt finishes. */
82
+ this._pendingSessionId = null
83
+
84
+ /** @type {Promise<void> | null} */
85
+ this._sessionReadyPromise = null
86
+
87
+ /** @type {(() => void) | null} */
88
+ this._resolveSessionReady = null
89
+
90
+ /** @type {unknown | null} */
91
+ this._sessionReadyError = null
92
+
93
+ /** @type {{get: () => string | null | undefined | Promise<string | null | undefined>, set: (sessionId: string) => void | Promise<void>, clear: () => void | Promise<void>} | undefined} */
94
+ this._sessionStore = sessionStore
95
+ /** @type {boolean} - true once the sessionStore has been consulted for a restored id. */
96
+ this._sessionStoreRestored = false
97
+
98
+ /** @type {{getIsOnline?: () => boolean | Promise<boolean>, subscribe?: (callback: (isOnline: boolean) => void) => (() => void) | {remove: () => void}} | undefined} */
99
+ this._networkMonitor = networkMonitor
100
+
101
+ /** @type {null | (() => void) | {remove: () => void}} */
102
+ this._networkMonitorSubscription = null
103
+
104
+ /** @type {boolean} */
105
+ this._waitingForOnline = false
106
+ }
107
+
108
+ /** @returns {boolean} - Whether the socket is open. */
109
+ isOpen() {
110
+ return Boolean(this.socket && this.socket.readyState === this.socket.OPEN)
111
+ }
112
+
113
+ /** @returns {boolean} - Whether the session is ready for app messages. */
114
+ isSessionReady() {
115
+ return this._sessionReady
116
+ }
117
+
118
+ /**
119
+ * Opens a 1:1 connection of the given type against the server. Requires the
120
+ * socket to already be connected (call `connect()` first).
121
+ * @param {string} connectionType - Name the server registered the class under.
122
+ * @param {{params?: Record<string, any>, onConnect?: () => void, onMessage?: (body: any) => void, onDisconnect?: () => void, onResume?: () => void, onClose?: (reason: string) => void}} [options] - Connection options.
123
+ * @returns {SnapReqWebSocketConnection} - The connection handle.
124
+ */
125
+ openConnection(connectionType, options = {}) {
126
+ if (!this.isOpen()) throw new Error("Websocket is not open; call connect() first")
127
+
128
+ const connectionId = `c${this._nextConnectionIdSeq++}`
129
+ const connection = new SnapReqWebSocketConnection({
130
+ client: this,
131
+ connectionId,
132
+ connectionType,
133
+ params: options.params,
134
+ onConnect: options.onConnect,
135
+ onMessage: options.onMessage,
136
+ onDisconnect: options.onDisconnect,
137
+ onResume: options.onResume,
138
+ onClose: options.onClose
139
+ })
140
+
141
+ this._connections.set(connectionId, connection)
142
+ this._sendMessage({
143
+ type: "connection-open",
144
+ connectionId,
145
+ connectionType,
146
+ params: options.params || {}
147
+ })
148
+
149
+ return connection
150
+ }
151
+
152
+ /**
153
+ * Drops a connection handle from the registry.
154
+ * @param {string} connectionId - The connection id.
155
+ * @returns {void}
156
+ */
157
+ _removeConnection(connectionId) {
158
+ this._connections.delete(connectionId)
159
+ }
160
+
161
+ /**
162
+ * Subscribes to a named channel. If the socket is not yet open, the
163
+ * subscription is queued and sent once a connection is established.
164
+ * @param {string} channelType - Name the server registered the channel under.
165
+ * @param {{params?: Record<string, any>, lastEventId?: string, onMessage?: (body: any) => void, onDisconnect?: () => void, onResume?: () => void, onClose?: (reason: string) => void}} [options] - Subscription options.
166
+ * @returns {SnapReqWebSocketChannel} - The subscription handle.
167
+ */
168
+ subscribeChannel(channelType, options = {}) {
169
+ const subscriptionId = `s${this._nextSubscriptionIdSeq++}`
170
+ const subscription = new SnapReqWebSocketChannel({
171
+ client: this,
172
+ subscriptionId,
173
+ channelType,
174
+ lastEventId: options.lastEventId,
175
+ params: options.params,
176
+ onMessage: options.onMessage,
177
+ onDisconnect: options.onDisconnect,
178
+ onResume: options.onResume,
179
+ onClose: options.onClose
180
+ })
181
+
182
+ this._channelSubscriptions.set(subscriptionId, subscription)
183
+ this._sendChannelSubscribe(subscription)
184
+
185
+ return subscription
186
+ }
187
+
188
+ /**
189
+ * @param {string} subscriptionId - The subscription id.
190
+ * @returns {void}
191
+ */
192
+ _removeChannelSubscription(subscriptionId) {
193
+ this._channelSubscriptions.delete(subscriptionId)
194
+ }
195
+
196
+ /**
197
+ * @param {SnapReqWebSocketChannel} subscription - The subscription to send.
198
+ * @returns {void}
199
+ */
200
+ _sendChannelSubscribe(subscription) {
201
+ if (!this.isOpen() || !this.isSessionReady() || !subscription._needsSubscribe()) return
202
+
203
+ // Send first and only mark as sent on success. If the socket closes between
204
+ // `isOpen()` and `send()` and `_sendMessage` throws, `_subscribeSent` must
205
+ // stay false so the reconnect path's `_sendPendingChannelSubscriptions()`
206
+ // can retry.
207
+ try {
208
+ this._sendMessage({
209
+ type: "channel-subscribe",
210
+ subscriptionId: subscription.subscriptionId,
211
+ channelType: subscription.channelType,
212
+ params: subscription.params,
213
+ ...(subscription.lastEventId ? {lastEventId: subscription.lastEventId} : {})
214
+ })
215
+ subscription._markSubscribeSent()
216
+ } catch (error) {
217
+ // Transient closed-socket race: leave the subscription retryable so
218
+ // `_sendPendingChannelSubscriptions()` can resend after reconnect.
219
+ if (!this.isOpen()) throw error
220
+
221
+ // Non-recoverable send failure on an open socket (e.g. JSON.stringify
222
+ // failing on BigInt/cyclic params). Close the subscription and remove it
223
+ // so it cannot poison future `_sendPendingChannelSubscriptions()` loops.
224
+ this._channelSubscriptions.delete(subscription.subscriptionId)
225
+ subscription._handleClosed(`send_failed: ${error instanceof Error ? error.message : String(error)}`)
226
+ }
227
+ }
228
+
229
+ /** @returns {void} */
230
+ _sendPendingChannelSubscriptions() {
231
+ for (const subscription of this._channelSubscriptions.values()) {
232
+ this._sendChannelSubscribe(subscription)
233
+ }
234
+ }
235
+
236
+ /** @returns {Promise<boolean>} - Whether the network reports online. */
237
+ async _isOnline() {
238
+ if (!this._networkMonitor?.getIsOnline) return true
239
+
240
+ try {
241
+ return await this._networkMonitor.getIsOnline() !== false
242
+ } catch (error) {
243
+ this._debug("networkMonitor.getIsOnline failed", error)
244
+ return true
245
+ }
246
+ }
247
+
248
+ /** @returns {Promise<boolean>} - Whether reconnect should wait for online. */
249
+ async _shouldWaitForOnline() {
250
+ if (!this._networkMonitor) return false
251
+
252
+ const isOnline = await this._isOnline()
253
+
254
+ if (isOnline) return false
255
+
256
+ this._waitingForOnline = true
257
+ this._cancelPendingReconnect()
258
+ return true
259
+ }
260
+
261
+ /** @returns {void} */
262
+ _ensureNetworkMonitorSubscription() {
263
+ if (!this._networkMonitor?.subscribe || this._networkMonitorSubscription) return
264
+
265
+ this._networkMonitorSubscription = this._networkMonitor.subscribe((isOnline) => {
266
+ if (!this.autoReconnect) return
267
+
268
+ if (isOnline) {
269
+ if (!this._waitingForOnline) return
270
+
271
+ this._waitingForOnline = false
272
+ void this._attemptReconnect()
273
+ return
274
+ }
275
+
276
+ this._waitingForOnline = true
277
+ this._cancelPendingReconnect()
278
+
279
+ if (this.isOpen()) {
280
+ void this.dropConnection()
281
+ }
282
+ })
283
+ }
284
+
285
+ /** @returns {void} */
286
+ _teardownNetworkMonitorSubscription() {
287
+ if (!this._networkMonitorSubscription) return
288
+
289
+ if (typeof this._networkMonitorSubscription === "function") {
290
+ this._networkMonitorSubscription()
291
+ } else {
292
+ this._networkMonitorSubscription.remove()
293
+ }
294
+
295
+ this._networkMonitorSubscription = null
296
+ }
297
+
298
+ /**
299
+ * Sets a global metadata value that is sent to the server. When the socket is
300
+ * open, a metadata update message is sent immediately.
301
+ * @param {string} key - Metadata key.
302
+ * @param {any} value - Metadata value (null to clear).
303
+ * @returns {void}
304
+ */
305
+ setMetadata(key, value) {
306
+ if (value === null || value === undefined) {
307
+ delete this._metadata[key]
308
+ } else {
309
+ this._metadata[key] = value
310
+ }
311
+
312
+ if (this.socket && this.socket.readyState === this.socket.OPEN) {
313
+ this._sendMessage({type: "metadata", data: {...this._metadata}})
314
+ }
315
+ }
316
+
317
+ /** @returns {Record<string, any>} - Current metadata. */
318
+ getMetadata() {
319
+ return {...this._metadata}
320
+ }
321
+
322
+ /**
323
+ * Ensures a WebSocket connection is open. Auto-reconnect and online gating are
324
+ * enabled by default.
325
+ * @param {{autoReconnect?: boolean, waitForOnline?: boolean, resetReconnectState?: boolean}} [options] - Connect options.
326
+ * @returns {Promise<void>} - Resolves once connected and the session is ready.
327
+ */
328
+ async connect({autoReconnect = this.autoReconnect, waitForOnline = true, resetReconnectState = true} = {}) {
329
+ this.autoReconnect = autoReconnect
330
+
331
+ if (this.autoReconnect) {
332
+ this._ensureNetworkMonitorSubscription()
333
+ } else {
334
+ this._waitingForOnline = false
335
+ this._cancelPendingReconnect()
336
+ this._teardownNetworkMonitorSubscription()
337
+ }
338
+
339
+ if (waitForOnline && this.autoReconnect && !await this._isOnline()) {
340
+ this._waitingForOnline = true
341
+ return
342
+ }
343
+
344
+ if (resetReconnectState) {
345
+ this.reconnectAttempt = 0
346
+ }
347
+
348
+ if (this.socket && this.socket.readyState === this.socket.OPEN) return
349
+ if (this.connectPromise) return this.connectPromise
350
+
351
+ this._resetSessionReadyState()
352
+ this._waitingForOnline = false
353
+ this.connectionAttempts += 1
354
+
355
+ this.connectPromise = new Promise((resolve, reject) => {
356
+ this.socket = new WebSocket(this.url)
357
+
358
+ const cleanup = () => {
359
+ this.socket?.removeEventListener("open", onOpen)
360
+ this.socket?.removeEventListener("error", onError)
361
+ }
362
+
363
+ const onOpen = () => {
364
+ cleanup()
365
+ resolve(undefined)
366
+ }
367
+ const onError = (/** @type {Event & {error?: unknown}} */ event) => {
368
+ cleanup()
369
+ const error = event?.error || new Error("Websocket connection error")
370
+ reject(error)
371
+ }
372
+
373
+ this.socket.addEventListener("open", onOpen)
374
+ this.socket.addEventListener("error", onError)
375
+ this.socket.addEventListener("message", this.onMessage)
376
+ this.socket.addEventListener("close", this.onClose)
377
+ })
378
+
379
+ await this.connectPromise
380
+
381
+ // Cold restore from external persistence (sessionStore) on the very first
382
+ // connect: apps wire this up to survive a full page reload.
383
+ if (!this._sessionId && !this._sessionStoreRestored && this._sessionStore) {
384
+ this._sessionStoreRestored = true
385
+
386
+ try {
387
+ const storedId = await this._sessionStore.get()
388
+
389
+ if (typeof storedId === "string" && storedId.length > 0) {
390
+ this._sessionId = storedId
391
+ }
392
+ } catch (error) {
393
+ this._debug("sessionStore.get failed", error)
394
+ }
395
+ }
396
+
397
+ // If we have a cached sessionId from a prior connect, ask the server to
398
+ // resume it. The server replies with either `session-resumed` (state
399
+ // preserved) or `session-gone` (start fresh).
400
+ if (this._sessionId) {
401
+ this._awaitingResume = true
402
+ this._sendMessage({type: "session-resume", sessionId: this._sessionId})
403
+ // Fire onDisconnect on live handles so apps can pause UI work until
404
+ // session-resumed / session-gone arrives.
405
+ for (const connection of this._connections.values()) connection._handleDisconnected()
406
+ for (const subscription of this._channelSubscriptions.values()) subscription._handleDisconnected()
407
+ }
408
+
409
+ if (Object.keys(this._metadata).length > 0) {
410
+ this._sendMessage({type: "metadata", data: {...this._metadata}})
411
+ }
412
+
413
+ await this._waitForSessionReady()
414
+ this.disconnectedSince = null
415
+ }
416
+
417
+ /**
418
+ * Closes the WebSocket and clears pending state.
419
+ * @returns {Promise<void>} - Resolves once closed.
420
+ */
421
+ async close() {
422
+ this.autoReconnect = false
423
+ this._waitingForOnline = false
424
+ this._cancelPendingReconnect()
425
+ this._teardownNetworkMonitorSubscription()
426
+
427
+ if (!this.socket) return
428
+
429
+ if (this.socket.readyState === this.socket.CLOSED) {
430
+ this.socket = undefined
431
+ this.connectPromise = undefined
432
+ this._resetSessionReadyState()
433
+ return
434
+ }
435
+
436
+ await new Promise((resolve) => {
437
+ this.socket?.addEventListener("close", () => resolve(undefined))
438
+ this.socket?.close()
439
+ })
440
+
441
+ this.socket = undefined
442
+ this.connectPromise = undefined
443
+ this._resetSessionReadyState()
444
+ }
445
+
446
+ /**
447
+ * Disables auto-reconnect and closes the WebSocket.
448
+ * @returns {Promise<void>} - Resolves once closed.
449
+ */
450
+ async disconnectAndStopReconnect() {
451
+ await this.close()
452
+ }
453
+
454
+ /**
455
+ * Closes the raw socket without disabling auto-reconnect. Used by tests to
456
+ * simulate an unexpected network drop.
457
+ * @returns {Promise<void>} - Resolves once the socket has closed.
458
+ */
459
+ async dropConnection() {
460
+ if (!this.socket) return
461
+
462
+ await new Promise((resolve) => {
463
+ this.socket?.addEventListener("close", () => resolve(undefined))
464
+ this.socket?.close()
465
+ })
466
+
467
+ this.connectPromise = undefined
468
+ this._resetSessionReadyState()
469
+ }
470
+
471
+ /**
472
+ * Performs a POST request over the WebSocket.
473
+ * @param {string} path - Path.
474
+ * @param {any} [body] - Request body.
475
+ * @param {{headers?: Record<string, string>}} [options] - Request options such as headers.
476
+ * @returns {Promise<SnapReqWebSocketResponse>} - The response.
477
+ */
478
+ async post(path, body, options = {}) {
479
+ return await this.request("POST", path, {...options, body})
480
+ }
481
+
482
+ /**
483
+ * Performs a GET request over the WebSocket.
484
+ * @param {string} path - Path.
485
+ * @param {{headers?: Record<string, string>}} [options] - Request options such as headers.
486
+ * @returns {Promise<SnapReqWebSocketResponse>} - The response.
487
+ */
488
+ async get(path, options = {}) {
489
+ return await this.request("GET", path, options)
490
+ }
491
+
492
+ /**
493
+ * Subscribes to a channel for server-sent events.
494
+ * @param {string} channel - Channel name.
495
+ * @param {(payload: any) => void} callback - Callback function.
496
+ * @returns {() => void} - Unsubscribe function.
497
+ */
498
+ on(channel, callback) {
499
+ return this.subscribe(channel, {}, callback)
500
+ }
501
+
502
+ /**
503
+ * Returns a snapshot of the client's connection state.
504
+ * @returns {{disconnectedSince: number | null, isOpen: boolean, listenerCount: number}} - State snapshot.
505
+ */
506
+ state() {
507
+ return {
508
+ disconnectedSince: this.disconnectedSince,
509
+ isOpen: !!this.socket && this.socket.readyState === this.socket.OPEN,
510
+ listenerCount: this.listeners.size + this._channelSubscriptions.size
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Subscribes to a channel for server-sent events with optional params.
516
+ * @param {string} channel - Channel name.
517
+ * @param {{lastEventId?: string, params?: Record<string, any>}} options - Subscription options.
518
+ * @param {(payload: any, message?: Record<string, any>) => void} callback - Callback function.
519
+ * @returns {(() => void) & {ready: Promise<void>}} - Unsubscribe function with readiness promise.
520
+ */
521
+ subscribe(channel, options, callback) {
522
+ const params = options?.params
523
+ const lastEventId = options?.lastEventId
524
+ const subscriptionKey = this._subscriptionKey(channel, params)
525
+
526
+ if (!this.listeners.has(subscriptionKey)) {
527
+ /** @type {((value?: void) => void) | undefined} */
528
+ let resolveReady
529
+ /** @type {((error: unknown) => void) | undefined} */
530
+ let rejectReady
531
+ const ready = new Promise((resolve, reject) => {
532
+ resolveReady = resolve
533
+ rejectReady = reject
534
+ })
535
+
536
+ this.listeners.set(subscriptionKey, {
537
+ callbacks: new Set(),
538
+ channel,
539
+ params,
540
+ ready
541
+ })
542
+ this.pendingSubscriptions.set(subscriptionKey, {
543
+ reject: rejectReady || (() => {}),
544
+ resolve: resolveReady || (() => {})
545
+ })
546
+
547
+ void this.connect().then(() => {
548
+ this._sendMessage({channel, lastEventId, params, type: "subscribe"})
549
+ }).catch((error) => this._debug("Subscribe failed", error))
550
+ }
551
+
552
+ const listenerEntry = this.listeners.get(subscriptionKey)
553
+
554
+ if (!listenerEntry) throw new Error("Listeners map not initialized")
555
+
556
+ listenerEntry.callbacks.add(callback)
557
+
558
+ const unsubscribe = () => {
559
+ listenerEntry.callbacks.delete(callback)
560
+
561
+ if (listenerEntry.callbacks.size === 0) {
562
+ this.listeners.delete(subscriptionKey)
563
+ }
564
+ }
565
+
566
+ unsubscribe.ready = listenerEntry.ready
567
+
568
+ return unsubscribe
569
+ }
570
+
571
+ /**
572
+ * Subscribes to a channel and waits until the server acknowledges it.
573
+ * @param {string} channel - Channel name.
574
+ * @param {{lastEventId?: string, params?: Record<string, any>}} options - Subscription options.
575
+ * @param {(payload: any, message?: Record<string, any>) => void} callback - Callback function.
576
+ * @returns {Promise<(() => void) & {ready: Promise<void>}>} - Ready unsubscribe handle.
577
+ */
578
+ async subscribeAndWait(channel, options, callback) {
579
+ const unsubscribe = this.subscribe(channel, options, callback)
580
+
581
+ await unsubscribe.ready
582
+
583
+ return unsubscribe
584
+ }
585
+
586
+ /**
587
+ * @param {string} method - HTTP method.
588
+ * @param {string} path - Path.
589
+ * @param {object} [options] - Options object.
590
+ * @param {any} [options.body] - Request body.
591
+ * @param {Record<string, string>} [options.headers] - Header list.
592
+ * @returns {Promise<SnapReqWebSocketResponse>} - The response.
593
+ */
594
+ async request(method, path, {body, headers} = {}) {
595
+ await this.connect()
596
+
597
+ const id = `ws-${this.nextID++}`
598
+ const payload = {
599
+ body,
600
+ headers,
601
+ id,
602
+ method,
603
+ path,
604
+ type: "request"
605
+ }
606
+
607
+ return await new Promise((resolve, reject) => {
608
+ this.pendingRequests.set(id, {resolve, reject})
609
+ this._sendMessage(payload)
610
+ })
611
+ }
612
+
613
+ /**
614
+ * @param {MessageEvent<any>} event - Event payload.
615
+ * @returns {void}
616
+ */
617
+ onMessage = (event) => {
618
+ const raw = typeof event.data === "string" ? event.data : event.data?.toString?.()
619
+
620
+ if (!raw) return
621
+
622
+ /** @type {Record<string, any>} */
623
+ let message
624
+
625
+ try {
626
+ message = JSON.parse(raw)
627
+ } catch (error) {
628
+ this._debug("Failed to parse websocket message", error)
629
+ return
630
+ }
631
+
632
+ const {type} = message
633
+
634
+ if (type === "response") {
635
+ const {id} = message
636
+ const pending = id ? this.pendingRequests.get(id) : undefined
637
+
638
+ if (pending) {
639
+ this.pendingRequests.delete(id)
640
+ pending.resolve(new SnapReqWebSocketResponse(message))
641
+ } else {
642
+ this._debug(`No pending request for response id ${id}`)
643
+ }
644
+ } else if (type === "subscribed") {
645
+ const subscriptionKey = this._subscriptionKey(message.channel, message.params)
646
+ const pendingSubscription = this.pendingSubscriptions.get(subscriptionKey)
647
+
648
+ if (pendingSubscription) {
649
+ this.pendingSubscriptions.delete(subscriptionKey)
650
+ pendingSubscription.resolve()
651
+ }
652
+ } else if (type === "event") {
653
+ const {channel, payload} = message
654
+
655
+ for (const listenerEntry of this.listeners.values()) {
656
+ if (listenerEntry.channel !== channel) continue
657
+
658
+ listenerEntry.callbacks.forEach((/** @type {(payload: any, message?: Record<string, any>) => void} */ callback) => {
659
+ try {
660
+ callback(payload, message)
661
+ } catch (error) {
662
+ this._debug("Listener error", error)
663
+ }
664
+ })
665
+ }
666
+ } else if (type === "replay-gap") {
667
+ const subscriptionKey = this._subscriptionKey(message.channel, message.params)
668
+ const pendingSubscription = this.pendingSubscriptions.get(subscriptionKey)
669
+
670
+ if (pendingSubscription) {
671
+ this.pendingSubscriptions.delete(subscriptionKey)
672
+ pendingSubscription.reject(new Error(`Replay gap for ${message.channel}`))
673
+ }
674
+ } else if (type === "connection-opened") {
675
+ const connection = this._connections.get(message.connectionId)
676
+
677
+ connection?._handleOpened()
678
+ } else if (type === "connection-message") {
679
+ const connection = this._connections.get(message.connectionId)
680
+
681
+ connection?._handleMessage(message.body)
682
+ } else if (type === "connection-closed") {
683
+ const connection = this._connections.get(message.connectionId)
684
+
685
+ if (connection) {
686
+ this._connections.delete(message.connectionId)
687
+ connection._handleClosed(message.reason || "server_close")
688
+ }
689
+ } else if (type === "connection-error") {
690
+ const connection = this._connections.get(message.connectionId)
691
+
692
+ if (connection) {
693
+ this._connections.delete(message.connectionId)
694
+ connection._handleClosed(`error: ${message.message || "connection-error"}`)
695
+ }
696
+ } else if (type === "channel-subscribed") {
697
+ const sub = this._channelSubscriptions.get(message.subscriptionId)
698
+
699
+ sub?._handleSubscribed()
700
+ } else if (type === "channel-message") {
701
+ const sub = this._channelSubscriptions.get(message.subscriptionId)
702
+
703
+ sub?._handleMessage(message.body)
704
+ } else if (type === "channel-unsubscribed") {
705
+ const sub = this._channelSubscriptions.get(message.subscriptionId)
706
+
707
+ if (sub) {
708
+ this._channelSubscriptions.delete(message.subscriptionId)
709
+ sub._handleClosed("server_unsubscribe")
710
+ }
711
+ } else if (type === "channel-error") {
712
+ const sub = this._channelSubscriptions.get(message.subscriptionId)
713
+
714
+ if (sub) {
715
+ this._channelSubscriptions.delete(message.subscriptionId)
716
+ sub._handleClosed(`error: ${message.message || "channel-error"}`)
717
+ }
718
+ } else if (type === "session-established") {
719
+ this._pendingSessionId = typeof message.sessionId === "string" ? message.sessionId : null
720
+
721
+ // First connect: cache sessionId for future resume attempts.
722
+ if (!this._awaitingResume) {
723
+ this._sessionId = this._pendingSessionId
724
+ if (this._sessionId) {
725
+ this._persistSessionId(this._sessionId)
726
+ }
727
+
728
+ this._markSessionReady()
729
+ this._sendPendingChannelSubscriptions()
730
+ }
731
+ } else if (type === "session-resumed") {
732
+ this._awaitingResume = false
733
+ this._pendingSessionId = null
734
+ this._sessionId = message.sessionId
735
+ this._persistSessionId(message.sessionId)
736
+ this._markSessionReady()
737
+ this._sendPendingChannelSubscriptions()
738
+ // Fire onResume on every live handle so user code knows the session came
739
+ // back with state intact.
740
+ for (const connection of this._connections.values()) connection._handleResumed()
741
+ for (const subscription of this._channelSubscriptions.values()) subscription._handleResumed()
742
+ } else if (type === "session-gone") {
743
+ this._awaitingResume = false
744
+ this._sessionId = null
745
+ this._pendingSessionId = null
746
+ this._clearPersistedSessionId()
747
+
748
+ // Tear down every live handle — their server-side counterparts are gone.
749
+ const connections = [...this._connections.values()]
750
+
751
+ this._connections.clear()
752
+ for (const connection of connections) connection._handleClosed("session_gone")
753
+
754
+ const subs = [...this._channelSubscriptions.values()]
755
+
756
+ this._channelSubscriptions.clear()
757
+ for (const subscription of subs) subscription._handleClosed("session_gone")
758
+
759
+ this._markSessionReady()
760
+ } else if (type === "error" && message.id) {
761
+ const pending = this.pendingRequests.get(message.id)
762
+
763
+ if (pending) {
764
+ this.pendingRequests.delete(message.id)
765
+ pending.reject(new Error(message.error || "Unknown websocket error"))
766
+ }
767
+ }
768
+ }
769
+
770
+ /**
771
+ * @param {string} channel - Channel name.
772
+ * @param {Record<string, any> | undefined} params - Subscription params.
773
+ * @returns {string} - Stable subscription key.
774
+ */
775
+ _subscriptionKey(channel, params) {
776
+ return JSON.stringify([channel, params || null])
777
+ }
778
+
779
+ /**
780
+ * Rejects all pending requests when the socket closes. Schedules reconnect if
781
+ * enabled.
782
+ * @returns {void}
783
+ */
784
+ onClose = () => {
785
+ this.disconnectedSince ||= Date.now()
786
+ this._resetSessionReadyState()
787
+
788
+ for (const [id, {reject}] of this.pendingRequests.entries()) {
789
+ reject(new Error(`Websocket closed before response for ${id}`))
790
+ }
791
+
792
+ for (const {reject} of this.pendingSubscriptions.values()) {
793
+ reject(new Error("Websocket closed before subscription acknowledgement"))
794
+ }
795
+
796
+ if (this._sessionId && this.autoReconnect) {
797
+ // Session may resume when we reconnect — keep the handles alive and fire
798
+ // onDisconnect so user code can pause UI work.
799
+ for (const connection of this._connections.values()) connection._handleDisconnected()
800
+ for (const subscription of this._channelSubscriptions.values()) subscription._handleDisconnected()
801
+ } else {
802
+ // No resume path: tear down every live connection / channel sub.
803
+ const connections = [...this._connections.values()]
804
+
805
+ this._connections.clear()
806
+ for (const connection of connections) {
807
+ connection._handleClosed("session_destroyed")
808
+ }
809
+
810
+ const channelSubs = [...this._channelSubscriptions.values()]
811
+
812
+ this._channelSubscriptions.clear()
813
+ for (const subscription of channelSubs) {
814
+ subscription._handleClosed("session_destroyed")
815
+ }
816
+
817
+ this._sessionId = null
818
+ }
819
+
820
+ this.pendingRequests.clear()
821
+ this.pendingSubscriptions.clear()
822
+ this.connectPromise = undefined
823
+
824
+ if (!this.autoReconnect) return
825
+
826
+ void this._shouldWaitForOnline().then((shouldWaitForOnline) => {
827
+ if (!shouldWaitForOnline) {
828
+ this._scheduleReconnect()
829
+ }
830
+ })
831
+ }
832
+
833
+ /**
834
+ * @param {Record<string, any>} payload - Payload data.
835
+ * @returns {void}
836
+ */
837
+ _sendMessage(payload) {
838
+ if (!this.socket || this.socket.readyState !== this.socket.OPEN) {
839
+ throw new Error("Websocket is not open")
840
+ }
841
+
842
+ const json = JSON.stringify(payload)
843
+
844
+ this._debug("Sending", json)
845
+ this.socket.send(json)
846
+ }
847
+
848
+ /** @returns {void} */
849
+ _cancelPendingReconnect() {
850
+ if (this.reconnectTimer) {
851
+ globalThis.clearTimeout(this.reconnectTimer)
852
+ this.reconnectTimer = null
853
+ }
854
+ }
855
+
856
+ /** @returns {void} */
857
+ _scheduleReconnect() {
858
+ this._cancelPendingReconnect()
859
+
860
+ const delay = this.reconnectDelays[Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1)]
861
+
862
+ this.reconnectTimer = globalThis.setTimeout(() => {
863
+ this.reconnectTimer = null
864
+ void this._attemptReconnect()
865
+ }, delay)
866
+
867
+ this.reconnectAttempt += 1
868
+ }
869
+
870
+ /** @returns {Promise<void>} */
871
+ async _attemptReconnect() {
872
+ if (!this.autoReconnect) return
873
+
874
+ if (!await this._isOnline()) {
875
+ this._waitingForOnline = true
876
+ return
877
+ }
878
+
879
+ try {
880
+ this._waitingForOnline = false
881
+ this.connectionAttempts += 1
882
+ await this.connect({autoReconnect: this.autoReconnect, resetReconnectState: false, waitForOnline: false})
883
+ this.reconnectAttempt = 0
884
+ this.disconnectedSince = null
885
+ this._resubscribeActiveListeners()
886
+
887
+ if (typeof this.onReconnect === "function") {
888
+ await this.onReconnect()
889
+ }
890
+ } catch (error) {
891
+ this._debug("Reconnect attempt failed:", error)
892
+
893
+ if (this.autoReconnect) {
894
+ this._scheduleReconnect()
895
+ }
896
+ }
897
+ }
898
+
899
+ /**
900
+ * Re-sends subscribe messages for all active listeners after reconnection.
901
+ * @returns {void}
902
+ */
903
+ _resubscribeActiveListeners() {
904
+ for (const [, listenerEntry] of this.listeners) {
905
+ try {
906
+ this._sendMessage({
907
+ channel: listenerEntry.channel,
908
+ params: listenerEntry.params,
909
+ type: "subscribe"
910
+ })
911
+ } catch (error) {
912
+ this._debug("Re-subscribe failed:", error)
913
+ }
914
+ }
915
+ }
916
+
917
+ /**
918
+ * @param {...any} args - Log arguments.
919
+ * @returns {void}
920
+ */
921
+ _debug(...args) {
922
+ if (!this.debug) return
923
+
924
+ console.debug("[SnapReqWebSocketClient]", ...args)
925
+ }
926
+
927
+ /**
928
+ * @param {string} sessionId - Id to persist through the configured sessionStore.
929
+ * @returns {void}
930
+ */
931
+ _persistSessionId(sessionId) {
932
+ if (!this._sessionStore) return
933
+
934
+ try {
935
+ const result = this._sessionStore.set(sessionId)
936
+
937
+ if (result && typeof result.then === "function") {
938
+ result.catch((/** @type {unknown} */ error) => this._debug("sessionStore.set failed", error))
939
+ }
940
+ } catch (error) {
941
+ this._debug("sessionStore.set failed", error)
942
+ }
943
+ }
944
+
945
+ /** @returns {void} */
946
+ _clearPersistedSessionId() {
947
+ if (!this._sessionStore) return
948
+
949
+ try {
950
+ const result = this._sessionStore.clear()
951
+
952
+ if (result && typeof result.then === "function") {
953
+ result.catch((/** @type {unknown} */ error) => this._debug("sessionStore.clear failed", error))
954
+ }
955
+ } catch (error) {
956
+ this._debug("sessionStore.clear failed", error)
957
+ }
958
+ }
959
+
960
+ /** @returns {Promise<void>} - Resolves once the session is ready. */
961
+ _waitForSessionReady() {
962
+ if (this._sessionReady) return Promise.resolve()
963
+
964
+ if (!this._sessionReadyPromise || !this._resolveSessionReady) {
965
+ this._sessionReadyPromise = new Promise((resolve) => {
966
+ this._resolveSessionReady = resolve
967
+ })
968
+ }
969
+
970
+ return this._sessionReadyPromise.then(() => {
971
+ if (this._sessionReadyError) {
972
+ const error = this._sessionReadyError
973
+
974
+ this._sessionReadyError = null
975
+ throw error
976
+ }
977
+ })
978
+ }
979
+
980
+ /** @returns {void} */
981
+ _markSessionReady() {
982
+ if (this._sessionReady) return
983
+
984
+ this._sessionReady = true
985
+ this._sessionReadyError = null
986
+ this._resolveSessionReady?.()
987
+ this._resolveSessionReady = null
988
+ this._sessionReadyPromise = null
989
+ }
990
+
991
+ /**
992
+ * @param {unknown} [error] - Reason for the reset.
993
+ * @returns {void}
994
+ */
995
+ _resetSessionReadyState(error = new Error("Websocket session readiness was reset")) {
996
+ this._sessionReady = false
997
+ this._pendingSessionId = null
998
+ this._sessionReadyError = error
999
+ this._resolveSessionReady?.()
1000
+ this._sessionReadyPromise = null
1001
+ this._resolveSessionReady = null
1002
+ }
1003
+ }
1004
+
1005
+ /** A response to a request made over the WebSocket transport. */
1006
+ export class SnapReqWebSocketResponse {
1007
+ /**
1008
+ * @param {object} message - The response message.
1009
+ */
1010
+ constructor(message) {
1011
+ const responseMessage = /** @type {{body?: any, headers?: Record<string, any>, id?: string | number | null, statusCode?: number, statusMessage?: string, type?: string}} */ (message)
1012
+
1013
+ this.body = responseMessage.body
1014
+ this.headers = responseMessage.headers || {}
1015
+ this.id = responseMessage.id
1016
+ this.statusCode = responseMessage.statusCode || 200
1017
+ this.statusMessage = responseMessage.statusMessage || "OK"
1018
+ this.type = responseMessage.type
1019
+ }
1020
+
1021
+ /** @returns {any} - The parsed JSON body. */
1022
+ json() {
1023
+ if (typeof this.body !== "string") {
1024
+ throw new Error("Response body is not a string")
1025
+ }
1026
+
1027
+ return JSON.parse(this.body)
1028
+ }
1029
+ }