experimental-a2 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/actor-D_54lz_1.d.ts +310 -0
  3. package/dist/actor-D_54lz_1.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +13 -4
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +63 -7
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -4
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +16 -2
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/actor.d.ts +2 -176
  13. package/dist/actor.js +13 -2
  14. package/dist/actor.js.map +1 -1
  15. package/dist/ai-server.d.ts +1 -1
  16. package/dist/ai-server.js +2 -2
  17. package/dist/ai.d.ts +1 -1
  18. package/dist/ai.js +1 -1
  19. package/dist/client-Bf6uSEAk.js +1342 -0
  20. package/dist/client-Bf6uSEAk.js.map +1 -0
  21. package/dist/client-P_NNNRM-.d.ts +243 -0
  22. package/dist/client-P_NNNRM-.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -202
  24. package/dist/client.js +2 -1026
  25. package/dist/errors-DCk6ch5n.js.map +1 -1
  26. package/dist/errors-DvhSXnxk.d.ts +28 -0
  27. package/dist/errors-DvhSXnxk.d.ts.map +1 -0
  28. package/dist/index.d.ts +3 -35
  29. package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
  30. package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
  31. package/dist/platform-B4TnJtWu.js +34 -0
  32. package/dist/platform-B4TnJtWu.js.map +1 -0
  33. package/dist/react.d.ts +3 -1
  34. package/dist/react.d.ts.map +1 -1
  35. package/dist/react.js +2 -1
  36. package/dist/react.js.map +1 -1
  37. package/dist/scheduler-qstash.d.ts +2 -2
  38. package/dist/scheduler-qstash.js +3 -2
  39. package/dist/scheduler-qstash.js.map +1 -1
  40. package/dist/scheduler-vercel.d.ts +2 -2
  41. package/dist/scheduler-vercel.js +2 -2
  42. package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
  43. package/dist/server-Dkz2a84E.js.map +1 -0
  44. package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
  45. package/dist/server-DwPrMqHB.d.ts.map +1 -0
  46. package/dist/server.d.ts +2 -2
  47. package/dist/server.js +1 -1
  48. package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
  49. package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
  50. package/dist/store-N8PXxDAS.js.map +1 -1
  51. package/dist/store-memory.d.ts +1 -1
  52. package/dist/store-memory.js +1 -1
  53. package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
  54. package/dist/store-polling-CmxUbV93.js.map +1 -0
  55. package/dist/store-postgres.d.ts +6 -4
  56. package/dist/store-postgres.d.ts.map +1 -1
  57. package/dist/store-postgres.js +701 -53
  58. package/dist/store-postgres.js.map +1 -1
  59. package/dist/store-presence-polling-C7-XZyW9.js +94 -0
  60. package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
  61. package/dist/store-redis-http.d.ts +2 -2
  62. package/dist/store-redis-http.d.ts.map +1 -1
  63. package/dist/store-redis-http.js +184 -38
  64. package/dist/store-redis-http.js.map +1 -1
  65. package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
  66. package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
  67. package/dist/store-redis.d.ts +5 -13
  68. package/dist/store-redis.d.ts.map +1 -1
  69. package/dist/store-redis.js +27 -271
  70. package/dist/store-redis.js.map +1 -1
  71. package/dist/store-sqlite.d.ts +1 -1
  72. package/dist/store-sqlite.js +1 -1
  73. package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
  74. package/dist/wire-BO5wWCb1.js.map +1 -0
  75. package/docs/actors/04-routes.mdx +40 -8
  76. package/docs/guides/03-react.mdx +52 -10
  77. package/docs/guides/05-production.mdx +54 -5
  78. package/docs/guides/09-presence.mdx +12 -7
  79. package/docs/guides/10-transports.mdx +85 -22
  80. package/docs/reference/01-api.mdx +90 -28
  81. package/docs/reference/02-errors.mdx +45 -11
  82. package/examples/playground/package.json +1 -1
  83. package/package.json +1 -1
  84. package/src/actor-client.ts +114 -14
  85. package/src/actor-react.ts +18 -3
  86. package/src/actor.ts +16 -1
  87. package/src/client-errors.ts +51 -0
  88. package/src/client.ts +577 -175
  89. package/src/errors.ts +4 -9
  90. package/src/index.ts +1 -1
  91. package/src/internal.ts +1 -1
  92. package/src/postgres-notification-scope.ts +134 -0
  93. package/src/postgres-notifications.ts +244 -0
  94. package/src/postgres-pool.ts +65 -0
  95. package/src/postgres-resources.ts +185 -0
  96. package/src/presence-recovery.ts +120 -0
  97. package/src/react.ts +3 -0
  98. package/src/redis-http-subscriptions.ts +199 -0
  99. package/src/server-fetch.ts +51 -23
  100. package/src/server.ts +146 -19
  101. package/src/session-socket.ts +241 -88
  102. package/src/sse.ts +17 -0
  103. package/src/store-polling.ts +32 -12
  104. package/src/store-postgres.ts +292 -75
  105. package/src/store-presence-polling.ts +125 -0
  106. package/src/store-redis-core.ts +37 -30
  107. package/src/store-redis-http.ts +51 -45
  108. package/src/store-redis-notify.ts +464 -0
  109. package/src/store-redis.ts +9 -364
  110. package/src/store.ts +3 -4
  111. package/src/stream-activity.ts +32 -0
  112. package/src/wire.ts +39 -15
  113. package/dist/actor-shared-BACubf4x.d.ts +0 -136
  114. package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
  115. package/dist/actor.d.ts.map +0 -1
  116. package/dist/client.d.ts.map +0 -1
  117. package/dist/client.js.map +0 -1
  118. package/dist/index.d.ts.map +0 -1
  119. package/dist/server-CBET-jSz.js.map +0 -1
  120. package/dist/server-CKY3_lbw.d.ts.map +0 -1
  121. package/dist/store-polling-6DW7F1DT.js.map +0 -1
  122. package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
  123. package/dist/wire--yji6mO3.js.map +0 -1
@@ -0,0 +1,464 @@
1
+ import type { PresencePatch } from './contract.ts'
2
+ import { A2Error } from './errors.ts'
3
+ import { NOTIFY_TIMINGS } from './internal.ts'
4
+ import { defaultSleep, pollingStream, type Sleeper } from './store-polling.ts'
5
+ import { pollPresence } from './store-presence-polling.ts'
6
+ import { presenceRecovery } from './presence-recovery.ts'
7
+ import { createStreamActivity, streamActivities } from './stream-activity.ts'
8
+ import { createRedisStoreCore } from './store-redis-core.ts'
9
+ import type { A2Store, Clock, Event, IdSource } from './store.ts'
10
+
11
+ export type RedisConnection = {
12
+ call(command: string, ...args: Array<string | number>): Promise<unknown>
13
+ duplicate(): RedisConnection
14
+ on(
15
+ event: 'message',
16
+ listener: (channel: string, message: string) => void,
17
+ ): unknown
18
+ disconnect(): void
19
+ }
20
+
21
+ type PresenceWireMessage = {
22
+ src: string
23
+ participant: string
24
+ values: Record<string, unknown>
25
+ seen: number
26
+ at: number
27
+ }
28
+
29
+ type PresenceSubscription = {
30
+ listeners: Set<(patch: PresencePatch) => void>
31
+ poll?: ReturnType<typeof pollPresence> | undefined
32
+ recovery?: ReturnType<typeof presenceRecovery> | undefined
33
+ }
34
+
35
+ export function createRedisNotifyStore(options: {
36
+ client: () => Promise<RedisConnection>
37
+ closeClient: () => Promise<void>
38
+ clock: Clock
39
+ ids: IdSource
40
+ keyPrefix: string
41
+ onReconnect?: (listener: (channel: string) => void) => void
42
+ polling?: () => boolean
43
+ }): A2Store & { close(): Promise<void> } {
44
+ const { client, clock, ids, keyPrefix: prefix } = options
45
+ const notifyChannel = (sessionId: string): string =>
46
+ `${prefix}:${sessionId}:notify`
47
+ const presenceChannel = (ns: string): string => `${prefix}:${ns}:presence`
48
+
49
+ const instanceId = crypto.randomUUID()
50
+ const core = createRedisStoreCore({
51
+ call: async (command, ...args) => {
52
+ const c = await client()
53
+ return c.call(command, ...args)
54
+ },
55
+ clock,
56
+ ids,
57
+ keyPrefix: prefix,
58
+ presenceSource: instanceId,
59
+ })
60
+
61
+ let severed = false
62
+ let subscriber: Promise<RedisConnection> | null = null
63
+ /** channel → parked-feed wake-ups, armed before each catch-up read. */
64
+ const wakers = new Map<string, Set<() => void>>()
65
+ /** channel → refcounted subscription held for each live iterator. */
66
+ const subscriptions = new Map<
67
+ string,
68
+ { refs: number; ready: Promise<unknown> }
69
+ >()
70
+ /**
71
+ * channel → last presence-marker refresh, throttling the SET.
72
+ * Deliberately real time (`Date.now()`), not the injected clock:
73
+ * the marker's PX expiry runs on server real time, and the throttle
74
+ * must tick with the TTL it refreshes or time-traveling tests would
75
+ * desync the two. Process-local and never stored, so the injected
76
+ * clock's every-stored-timestamp pledge is untouched.
77
+ */
78
+ const watched = new Map<string, { at: number; ready: Promise<void> }>()
79
+ const presenceSubscriptions = new Map<string, PresenceSubscription>()
80
+ const pollingStreams = new Set<AsyncIterator<Event>>()
81
+
82
+ options.onReconnect?.((channel) => {
83
+ for (const wake of wakers.get(channel) ?? []) wake()
84
+ void presenceSubscriptions
85
+ .get(channel)
86
+ ?.recovery?.reconnect()
87
+ .catch(() => {})
88
+ })
89
+
90
+ const deliverRemotePresence = ({
91
+ subscription,
92
+ message,
93
+ }: {
94
+ subscription: PresenceSubscription
95
+ message: string
96
+ }): void => {
97
+ let parsed: PresenceWireMessage
98
+ try {
99
+ parsed = JSON.parse(message) as PresenceWireMessage
100
+ } catch {
101
+ return
102
+ }
103
+ if (parsed.src === instanceId) return
104
+ const patch: PresencePatch = {
105
+ participant: parsed.participant,
106
+ values: parsed.values,
107
+ seen: parsed.seen,
108
+ at: new Date(parsed.at),
109
+ }
110
+ subscription.recovery?.update(patch)
111
+ for (const listener of subscription.listeners) listener(patch)
112
+ }
113
+
114
+ const getSubscriber = (): Promise<RedisConnection> => {
115
+ subscriber ??= (async () => {
116
+ const conn = (await client()).duplicate()
117
+ conn.on('message', (channel, message) => {
118
+ // wake() only resolves a promise; removal happens later via
119
+ // disarm(), so iterating the live set is safe.
120
+ const set = wakers.get(channel)
121
+ if (set) {
122
+ for (const wake of set) wake()
123
+ }
124
+ const subscription = presenceSubscriptions.get(channel)
125
+ if (subscription) deliverRemotePresence({ subscription, message })
126
+ })
127
+ return conn
128
+ })()
129
+ return subscriber
130
+ }
131
+
132
+ /**
133
+ * The subscriber, gated on connection readiness — for channel
134
+ * commands only (`close` uses the ungated promise, so it can never
135
+ * hang on an unreachable server). SUBSCRIBE carries redis's
136
+ * ok-loading flag, so ioredis writes it mid-handshake, ahead of the
137
+ * ready check's INFO — which then finds the connection already in
138
+ * subscriber mode and fails the check (a spurious error event plus
139
+ * a reconnect). ECHO has no such flag: it resolves only once the
140
+ * connection is ready, so every channel command chained behind it
141
+ * lands after the check.
142
+ */
143
+ let subscriberGate: Promise<RedisConnection> | null = null
144
+ const gatedSubscriber = (): Promise<RedisConnection> => {
145
+ subscriberGate ??= getSubscriber().then(async (conn) => {
146
+ await conn.call('echo', 'ready')
147
+ return conn
148
+ })
149
+ return subscriberGate
150
+ }
151
+
152
+ const acquireChannel = async (channel: string): Promise<void> => {
153
+ const existing = subscriptions.get(channel)
154
+ if (existing) {
155
+ existing.refs += 1
156
+ try {
157
+ await existing.ready
158
+ } catch (err) {
159
+ existing.refs -= 1
160
+ throw err
161
+ }
162
+ return
163
+ }
164
+ const lease: { refs: number; ready: Promise<void> } = {
165
+ refs: 1,
166
+ // Lowercase on purpose: ioredis keys its subscriber-mode
167
+ // bookkeeping (and reconnect resubscription) on the exact
168
+ // command name.
169
+ ready: gatedSubscriber().then(async (conn) => {
170
+ await conn.call('subscribe', channel)
171
+ if (subscriptions.get(channel) !== lease || severed) return
172
+ const presence = presenceSubscriptions.get(channel)
173
+ if (options.polling?.() && presence) {
174
+ presence.recovery = undefined
175
+ const ns = channel.slice(prefix.length + 1, -':presence'.length)
176
+ presence.poll = pollPresence({
177
+ read: () => core.presence.read(ns),
178
+ deliver: (patch) => {
179
+ for (const listener of presence.listeners) listener(patch)
180
+ },
181
+ })
182
+ await presence.poll.ready
183
+ }
184
+ return undefined
185
+ }),
186
+ }
187
+ subscriptions.set(channel, lease)
188
+ try {
189
+ await lease.ready
190
+ } catch (err) {
191
+ if (subscriptions.get(channel) === lease) {
192
+ subscriptions.delete(channel)
193
+ const presence = presenceSubscriptions.get(channel)
194
+ presence?.poll?.stop()
195
+ if (presence) presence.poll = undefined
196
+ }
197
+ throw err
198
+ }
199
+ }
200
+
201
+ const releaseChannel = (channel: string): void => {
202
+ const lease = subscriptions.get(channel)
203
+ if (!lease) return
204
+ lease.refs -= 1
205
+ if (lease.refs > 0) return
206
+ subscriptions.delete(channel)
207
+ watched.delete(channel)
208
+ void gatedSubscriber()
209
+ .then((conn) => conn.call('unsubscribe', channel))
210
+ .catch(() => {})
211
+ }
212
+
213
+ const armWaker = (
214
+ channel: string,
215
+ ): { wakeup: Promise<void>; wake: () => void; disarm: () => void } => {
216
+ let wake!: () => void
217
+ const wakeup = new Promise<void>((resolve) => {
218
+ wake = resolve // assigned synchronously by the executor
219
+ })
220
+ let set = wakers.get(channel)
221
+ if (!set) {
222
+ set = new Set()
223
+ wakers.set(channel, set)
224
+ }
225
+ set.add(wake)
226
+ return {
227
+ wakeup,
228
+ wake,
229
+ disarm: () => {
230
+ set.delete(wake)
231
+ if (set.size === 0) wakers.delete(channel)
232
+ },
233
+ }
234
+ }
235
+
236
+ return {
237
+ append: core.append,
238
+ read: core.read,
239
+ claimAvailable: core.claimAvailable,
240
+ renewClaims: core.renewClaims,
241
+ completeAttempt: core.completeAttempt,
242
+ failAttempt: core.failAttempt,
243
+ readState: core.readState,
244
+ putSnapshots: core.putSnapshots,
245
+
246
+ presence: {
247
+ async set(ns, participant, values, meta) {
248
+ const patch = await core.presence.set(ns, participant, values, meta)
249
+ if (!patch) return
250
+ const channel = presenceChannel(ns)
251
+ const subscription = presenceSubscriptions.get(channel)
252
+ if (subscription) {
253
+ subscription.poll?.update(patch)
254
+ subscription.recovery?.update(patch)
255
+ for (const listener of subscription.listeners) listener(patch)
256
+ }
257
+ },
258
+
259
+ async read(ns) {
260
+ const channel = presenceChannel(ns)
261
+ await subscriptions.get(channel)?.ready
262
+ return (
263
+ presenceSubscriptions.get(channel)?.recovery?.read() ??
264
+ core.presence.read(ns)
265
+ )
266
+ },
267
+
268
+ subscribe(ns, onPatch) {
269
+ const channel = presenceChannel(ns)
270
+ let subscription = presenceSubscriptions.get(channel)
271
+ if (!subscription) {
272
+ subscription = { listeners: new Set() }
273
+ presenceSubscriptions.set(channel, subscription)
274
+ if (options.onReconnect && !options.polling?.()) {
275
+ const current = subscription
276
+ subscription.recovery = presenceRecovery({
277
+ read: () => core.presence.read(ns),
278
+ deliver: (patch) => {
279
+ for (const listener of current.listeners) listener(patch)
280
+ },
281
+ isActive: () =>
282
+ !severed && presenceSubscriptions.get(channel) === current,
283
+ })
284
+ }
285
+ }
286
+ subscription.listeners.add(onPatch)
287
+ // Fire-and-forget on the shared refcounted subscription: it
288
+ // only carries remote patches, so settling late (or failing)
289
+ // degrades cross-instance latency, never same-process
290
+ // delivery.
291
+ void acquireChannel(channel).catch(() => {})
292
+ let stopped = false
293
+ return () => {
294
+ if (stopped) return
295
+ stopped = true
296
+ subscription.listeners.delete(onPatch)
297
+ if (subscription.listeners.size === 0) {
298
+ subscription.poll?.stop()
299
+ presenceSubscriptions.delete(channel)
300
+ }
301
+ releaseChannel(channel)
302
+ }
303
+ },
304
+ },
305
+
306
+ stream(sessionId, opts) {
307
+ const startAfter = opts?.startAfter ?? 0
308
+ const channel = notifyChannel(sessionId)
309
+
310
+ return {
311
+ [Symbol.asyncIterator](): AsyncIterator<Event> {
312
+ let last = startAfter
313
+ let buffer: Event[] = []
314
+ let closed = false
315
+ let subscribed = false
316
+ let pending: Sleeper | null = null
317
+ let interrupt: (() => void) | null = null
318
+ let poller: AsyncIterator<Event> | undefined
319
+ let stopActivity: (() => void) | undefined
320
+ const activity = createStreamActivity(() => {
321
+ if (poller) streamActivities.get(poller)?.activate()
322
+ })
323
+
324
+ const release = (): void => {
325
+ stopActivity?.()
326
+ if (poller) {
327
+ void poller.return?.()
328
+ pollingStreams.delete(poller)
329
+ }
330
+ if (!subscribed) return
331
+ subscribed = false
332
+ releaseChannel(channel)
333
+ }
334
+
335
+ const iterator: AsyncIterator<Event> = {
336
+ async next(): Promise<IteratorResult<Event>> {
337
+ for (;;) {
338
+ if (closed) {
339
+ release()
340
+ return { value: undefined, done: true }
341
+ }
342
+ const row = buffer.shift()
343
+ if (row) {
344
+ last = row.index
345
+ return { value: row, done: false }
346
+ }
347
+ if (severed) {
348
+ release()
349
+ throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')
350
+ }
351
+ const waker = armWaker(channel)
352
+ interrupt = waker.wake
353
+ try {
354
+ if (!subscribed) {
355
+ // Subscribe before reading: anything appended
356
+ // after the read lands as a wake-up, so nothing
357
+ // can slip between catch-up and park.
358
+ subscribed = true
359
+ // oxlint-disable-next-line no-await-in-loop -- one-time setup
360
+ await acquireChannel(channel)
361
+ if (closed || severed) continue
362
+ }
363
+ if (options.polling?.()) {
364
+ if (!poller) {
365
+ poller = pollingStream(
366
+ (after) => core.readEvents(sessionId, after),
367
+ { startAfter: last },
368
+ )[Symbol.asyncIterator]()
369
+ pollingStreams.add(poller)
370
+ stopActivity = streamActivities
371
+ .get(poller)
372
+ ?.subscribe(activity.setIdle)
373
+ }
374
+ // oxlint-disable-next-line no-await-in-loop -- delegate the fallback to the shared poller
375
+ const result = await poller.next()
376
+ if (!result.done)
377
+ presenceSubscriptions
378
+ .get(presenceChannel(sessionId))
379
+ ?.poll?.wake()
380
+ if (severed)
381
+ throw new A2Error(
382
+ 'STORE_UNAVAILABLE',
383
+ 'the store was closed',
384
+ )
385
+ return result
386
+ }
387
+ // Mark watched before reading, for the same reason:
388
+ // a write after this read sees the marker and
389
+ // publishes. Throttled — one SET per safety period
390
+ // per session per process, shared across feeds.
391
+ let marker = watched.get(channel)
392
+ if (
393
+ !marker ||
394
+ Date.now() - marker.at >= NOTIFY_TIMINGS.safetyReadMs
395
+ ) {
396
+ marker = {
397
+ at: Date.now(),
398
+ ready: core.markWatched(
399
+ sessionId,
400
+ NOTIFY_TIMINGS.safetyReadMs * 3,
401
+ ),
402
+ }
403
+ watched.set(channel, marker)
404
+ }
405
+ // Every viewer waits until writers can observe the marker.
406
+ // oxlint-disable-next-line no-await-in-loop -- live-feed loop
407
+ await marker.ready
408
+ if (closed || severed) continue
409
+ // oxlint-disable-next-line no-await-in-loop -- live-feed loop
410
+ buffer = await core.readEvents(sessionId, last)
411
+ if (buffer.length === 0 && !closed && !severed) {
412
+ pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)
413
+ // oxlint-disable-next-line no-await-in-loop -- live-feed loop
414
+ await Promise.race([waker.wakeup, pending.promise])
415
+ pending.cancel()
416
+ pending = null
417
+ }
418
+ } catch (err) {
419
+ release()
420
+ if (closed) return { value: undefined, done: true }
421
+ if (err instanceof A2Error) throw err
422
+ throw new A2Error(
423
+ 'STORE_UNAVAILABLE',
424
+ 'redis store operation failed',
425
+ { cause: err },
426
+ )
427
+ } finally {
428
+ waker.disarm()
429
+ interrupt = null
430
+ }
431
+ }
432
+ },
433
+ async return(): Promise<IteratorResult<Event>> {
434
+ closed = true
435
+ pending?.cancel()
436
+ interrupt?.() // wakes an in-flight next() immediately
437
+ release()
438
+ return { value: undefined, done: true }
439
+ },
440
+ }
441
+ streamActivities.set(iterator, activity)
442
+ return iterator
443
+ },
444
+ }
445
+ },
446
+
447
+ async close() {
448
+ severed = true
449
+ for (const subscription of presenceSubscriptions.values())
450
+ subscription.poll?.stop()
451
+ presenceSubscriptions.clear()
452
+ for (const poller of pollingStreams) void poller.return?.()
453
+ pollingStreams.clear()
454
+ for (const set of wakers.values()) {
455
+ for (const wake of set) wake()
456
+ }
457
+ if (subscriber) {
458
+ const sub = await subscriber.catch(() => null)
459
+ sub?.disconnect()
460
+ }
461
+ await options.closeClient()
462
+ },
463
+ }
464
+ }