snapreq 0.0.3 → 0.0.5

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