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.
- package/README.md +144 -0
- package/package.json +39 -0
- package/src/capabilities.js +31 -0
- package/src/errors.js +69 -0
- package/src/headers.js +92 -0
- package/src/index.js +22 -0
- package/src/request.js +104 -0
- package/src/response.js +156 -0
- package/src/retry.js +105 -0
- package/src/snap-req.js +248 -0
- package/src/transports/fetch-transport.js +128 -0
- package/src/transports/node-transport.js +323 -0
- package/src/transports/select.js +73 -0
- package/src/transports/xhr-transport.js +112 -0
- package/src/websocket/websocket-channel.js +176 -0
- package/src/websocket/websocket-client.js +1029 -0
- package/src/websocket/websocket-connection.js +154 -0
|
@@ -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
|
+
}
|