@nmtjs/client 0.15.2 → 0.16.0-beta.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.
Files changed (56) hide show
  1. package/dist/client.d.ts +64 -0
  2. package/dist/client.js +97 -0
  3. package/dist/client.js.map +1 -0
  4. package/dist/clients/runtime.d.ts +6 -12
  5. package/dist/clients/runtime.js +58 -57
  6. package/dist/clients/runtime.js.map +1 -1
  7. package/dist/clients/static.d.ts +4 -9
  8. package/dist/clients/static.js +20 -20
  9. package/dist/clients/static.js.map +1 -1
  10. package/dist/core.d.ts +33 -83
  11. package/dist/core.js +305 -690
  12. package/dist/core.js.map +1 -1
  13. package/dist/events.d.ts +0 -1
  14. package/dist/events.js +74 -11
  15. package/dist/events.js.map +1 -1
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.js +4 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/layers/ping.d.ts +6 -0
  20. package/dist/layers/ping.js +65 -0
  21. package/dist/layers/ping.js.map +1 -0
  22. package/dist/layers/rpc.d.ts +19 -0
  23. package/dist/layers/rpc.js +521 -0
  24. package/dist/layers/rpc.js.map +1 -0
  25. package/dist/layers/streams.d.ts +20 -0
  26. package/dist/layers/streams.js +194 -0
  27. package/dist/layers/streams.js.map +1 -0
  28. package/dist/plugins/browser.js +28 -9
  29. package/dist/plugins/browser.js.map +1 -1
  30. package/dist/plugins/heartbeat.js +10 -10
  31. package/dist/plugins/heartbeat.js.map +1 -1
  32. package/dist/plugins/index.d.ts +1 -1
  33. package/dist/plugins/index.js +0 -1
  34. package/dist/plugins/index.js.map +1 -1
  35. package/dist/plugins/reconnect.js +11 -94
  36. package/dist/plugins/reconnect.js.map +1 -1
  37. package/dist/plugins/types.d.ts +27 -11
  38. package/dist/transport.d.ts +49 -31
  39. package/dist/types.d.ts +21 -5
  40. package/package.json +10 -10
  41. package/src/client.ts +216 -0
  42. package/src/clients/runtime.ts +93 -79
  43. package/src/clients/static.ts +46 -38
  44. package/src/core.ts +394 -901
  45. package/src/events.ts +113 -14
  46. package/src/index.ts +4 -0
  47. package/src/layers/ping.ts +99 -0
  48. package/src/layers/rpc.ts +725 -0
  49. package/src/layers/streams.ts +277 -0
  50. package/src/plugins/browser.ts +39 -9
  51. package/src/plugins/heartbeat.ts +10 -10
  52. package/src/plugins/index.ts +8 -1
  53. package/src/plugins/reconnect.ts +12 -119
  54. package/src/plugins/types.ts +30 -13
  55. package/src/transport.ts +75 -46
  56. package/src/types.ts +33 -8
package/src/core.ts CHANGED
@@ -1,49 +1,30 @@
1
- import type { Future, TypeProvider } from '@nmtjs/common'
2
- import type { TAnyRouterContract } from '@nmtjs/contract'
3
- import type { ProtocolBlobMetadata, ProtocolVersion } from '@nmtjs/protocol'
1
+ import type { ProtocolVersion } from '@nmtjs/protocol'
4
2
  import type {
5
3
  BaseClientFormat,
6
4
  MessageContext,
7
5
  ProtocolVersionInterface,
8
- ServerMessageTypePayload,
9
- } from '@nmtjs/protocol/client'
10
- import {
11
- anyAbortSignal,
12
- createFuture,
13
- MAX_UINT32,
14
- noopFn,
15
- withTimeout,
16
- } from '@nmtjs/common'
17
- import {
18
- ClientMessageType,
19
- ConnectionType,
20
- ErrorCode,
21
- ProtocolBlob,
22
- ServerMessageType,
23
- } from '@nmtjs/protocol'
24
- import {
25
- ProtocolError,
26
- ProtocolServerBlobStream,
27
- ProtocolServerRPCStream,
28
- ProtocolServerStream,
29
- versions,
30
6
  } from '@nmtjs/protocol/client'
7
+ import { noopFn } from '@nmtjs/common'
8
+ import { ConnectionType } from '@nmtjs/protocol'
9
+ import { ProtocolError, versions } from '@nmtjs/protocol/client'
31
10
 
32
11
  import type {
33
- ClientDisconnectReason,
34
12
  ClientPlugin,
13
+ ClientPluginContext,
35
14
  ClientPluginEvent,
36
15
  ClientPluginInstance,
16
+ ReconnectConfig,
17
+ StreamEvent,
37
18
  } from './plugins/types.ts'
38
- import type { BaseClientTransformer } from './transformers.ts'
39
- import type { ClientCallResponse, ClientTransportFactory } from './transport.ts'
40
19
  import type {
41
- ClientCallers,
42
- ClientCallOptions,
43
- ResolveAPIRouterRoutes,
44
- } from './types.ts'
20
+ ClientDisconnectReason,
21
+ ClientTransport,
22
+ TransportCallContext,
23
+ TransportCallOptions,
24
+ TransportCallResponse,
25
+ TransportRpcParams,
26
+ } from './transport.ts'
45
27
  import { EventEmitter } from './events.ts'
46
- import { ClientStreams, ServerStreams } from './streams.ts'
47
28
 
48
29
  export {
49
30
  ErrorCode,
@@ -51,545 +32,337 @@ export {
51
32
  type ProtocolBlobMetadata,
52
33
  } from '@nmtjs/protocol'
53
34
 
54
- export * from './types.ts'
35
+ export type ConnectionState =
36
+ | 'idle'
37
+ | 'connecting'
38
+ | 'connected'
39
+ | 'disconnecting'
40
+ | 'disconnected'
41
+
42
+ export interface ClientCoreOptions {
43
+ protocol: ProtocolVersion
44
+ format: BaseClientFormat
45
+ application?: string
46
+ plugins?: ClientPlugin[]
47
+ }
55
48
 
56
49
  export class ClientError extends ProtocolError {}
57
50
 
58
- export type ProtocolClientCall = Future<any> & {
59
- procedure: string
60
- signal?: AbortSignal
51
+ const DEFAULT_RECONNECT_TIMEOUT = 1000
52
+ const DEFAULT_MAX_RECONNECT_TIMEOUT = 60000
53
+ const DEFAULT_CONNECT_ERROR_REASON = 'connect_error'
54
+
55
+ const sleep = (ms: number, signal?: AbortSignal) => {
56
+ return new Promise<void>((resolve) => {
57
+ if (signal?.aborted) return resolve()
58
+
59
+ const timer = setTimeout(resolve, ms)
60
+ signal?.addEventListener(
61
+ 'abort',
62
+ () => {
63
+ clearTimeout(timer)
64
+ resolve()
65
+ },
66
+ { once: true },
67
+ )
68
+ })
61
69
  }
62
70
 
63
- const DEFAULT_RECONNECT_REASON = 'connect_error'
71
+ const computeReconnectDelay = (ms: number) => {
72
+ if (globalThis.window) {
73
+ const jitter = Math.floor(ms * 0.2 * Math.random())
74
+ return ms + jitter
75
+ }
64
76
 
65
- export interface BaseClientOptions<
66
- RouterContract extends TAnyRouterContract = TAnyRouterContract,
67
- SafeCall extends boolean = false,
68
- > {
69
- contract: RouterContract
70
- protocol: ProtocolVersion
71
- format: BaseClientFormat
72
- application?: string
73
- timeout?: number
74
- plugins?: ClientPlugin[]
75
- safe?: SafeCall
77
+ return ms
76
78
  }
77
79
 
78
- /**
79
- * @todo Add error logging in ClientStreamPull rejection handler for easier debugging
80
- * @todo Consider edge case where callId/streamId overflow at MAX_UINT32 with existing entries
81
- */
82
- export abstract class BaseClient<
83
- TransportFactory extends ClientTransportFactory<
84
- any,
85
- any
86
- > = ClientTransportFactory<any, any>,
87
- RouterContract extends TAnyRouterContract = TAnyRouterContract,
88
- SafeCall extends boolean = false,
89
- InputTypeProvider extends TypeProvider = TypeProvider,
90
- OutputTypeProvider extends TypeProvider = TypeProvider,
91
- > extends EventEmitter<{
80
+ export class ClientCore extends EventEmitter<{
81
+ message: [message: unknown, raw: ArrayBufferView]
92
82
  connected: []
93
83
  disconnected: [reason: ClientDisconnectReason]
84
+ state_changed: [state: ConnectionState, previous: ConnectionState]
94
85
  pong: [nonce: number]
95
86
  }> {
96
- _!: {
97
- routes: ResolveAPIRouterRoutes<
98
- RouterContract,
99
- InputTypeProvider,
100
- OutputTypeProvider
101
- >
102
- safe: SafeCall
103
- }
104
-
105
- protected abstract readonly transformer: BaseClientTransformer
106
-
107
- abstract call: ClientCallers<this['_']['routes'], SafeCall, false>
108
- abstract stream: ClientCallers<this['_']['routes'], SafeCall, true>
109
-
110
- protected calls = new Map<number, ProtocolClientCall>()
111
- protected transport: TransportFactory extends ClientTransportFactory<
112
- any,
113
- any,
114
- infer T
115
- >
116
- ? T
117
- : never
118
- protected protocol: ProtocolVersionInterface
119
- protected messageContext!: MessageContext | null
120
- protected clientStreams = new ClientStreams()
121
- protected serverStreams = new ServerStreams()
122
- protected rpcStreams = new ServerStreams()
123
- protected callId = 0
124
- protected streamId = 0
125
- protected cab: AbortController | null = null
126
- protected connecting: Promise<void> | null = null
127
-
128
- protected _state: 'connected' | 'disconnected' = 'disconnected'
129
- protected _lastDisconnectReason: ClientDisconnectReason = 'server'
130
- protected _disposed = false
131
-
132
- protected pingNonce = 0
133
- protected pendingPings = new Map<number, Future<void>>()
134
- protected plugins: ClientPluginInstance[] = []
135
-
136
- private clientDisconnectAsReconnect = false
137
- private clientDisconnectOverrideReason: string | null = null
138
-
139
- private authValue: any
87
+ readonly protocol: ProtocolVersionInterface
88
+ readonly format: BaseClientFormat
89
+ readonly application?: string
90
+
91
+ auth: any
92
+ messageContext: MessageContext | null = null
93
+
94
+ #state: ConnectionState = 'idle'
95
+ #messageContextFactory: (() => MessageContext) | null = null
96
+ #cab: AbortController | null = null
97
+ #connecting: Promise<void> | null = null
98
+ #disposed = false
99
+ #plugins: ClientPluginInstance[] = []
100
+ #lastDisconnectReason: ClientDisconnectReason = 'server'
101
+ #clientDisconnectAsReconnect = false
102
+ #clientDisconnectOverrideReason: ClientDisconnectReason | null = null
103
+ #reconnectConfig: ReconnectConfig | null = null
104
+ #reconnectPauseReasons = new Set<string>()
105
+ #reconnectController: AbortController | null = null
106
+ #reconnectPromise: Promise<void> | null = null
107
+ #reconnectTimeout = DEFAULT_RECONNECT_TIMEOUT
108
+ #reconnectImmediate = false
140
109
 
141
110
  constructor(
142
- readonly options: BaseClientOptions<RouterContract, SafeCall>,
143
- readonly transportFactory: TransportFactory,
144
- readonly transportOptions: TransportFactory extends ClientTransportFactory<
145
- any,
146
- infer U
147
- >
148
- ? U
149
- : never,
111
+ options: ClientCoreOptions,
112
+ readonly transport: ClientTransport,
150
113
  ) {
151
114
  super()
152
115
 
153
116
  this.protocol = versions[options.protocol]
154
-
155
- const { format, protocol } = this.options
156
-
157
- this.transport = this.transportFactory(
158
- { protocol, format },
159
- this.transportOptions,
160
- ) as any
161
-
162
- this.plugins = this.options.plugins?.map((plugin) => plugin(this)) ?? []
163
- for (const plugin of this.plugins) {
164
- plugin.onInit?.()
165
- }
166
- }
167
-
168
- dispose() {
169
- this._disposed = true
170
- this.stopAllPendingPings('dispose')
171
- for (let i = this.plugins.length - 1; i >= 0; i--) {
172
- this.plugins[i].dispose?.()
173
- }
117
+ this.format = options.format
118
+ this.application = options.application
174
119
  }
175
120
 
176
121
  get state() {
177
- return this._state
122
+ return this.#state
178
123
  }
179
124
 
180
125
  get lastDisconnectReason() {
181
- return this._lastDisconnectReason
126
+ return this.#lastDisconnectReason
182
127
  }
183
128
 
184
129
  get transportType() {
185
130
  return this.transport.type
186
131
  }
187
132
 
133
+ get connectionSignal() {
134
+ return this.#cab?.signal
135
+ }
136
+
188
137
  isDisposed() {
189
- return this._disposed
138
+ return this.#disposed
139
+ }
140
+
141
+ initPlugins(plugins: ClientPlugin[] = [], context: ClientPluginContext) {
142
+ if (this.#plugins.length > 0) return
143
+
144
+ this.#plugins = plugins.map((plugin) => plugin(context))
145
+ for (const plugin of this.#plugins) {
146
+ plugin.onInit?.()
147
+ }
190
148
  }
191
149
 
192
- requestReconnect(reason?: string) {
193
- return this.disconnect({ reconnect: true, reason })
150
+ setMessageContextFactory(factory: () => MessageContext) {
151
+ this.#messageContextFactory = factory
152
+ }
153
+
154
+ configureReconnect(config: ReconnectConfig | null) {
155
+ this.#reconnectConfig = config
156
+ this.#reconnectTimeout = config?.initialTimeout ?? DEFAULT_RECONNECT_TIMEOUT
157
+ this.#reconnectImmediate = false
158
+
159
+ if (!config) {
160
+ this.#cancelReconnectLoop()
161
+ return
162
+ }
163
+
164
+ if (
165
+ this.transport.type === ConnectionType.Bidirectional &&
166
+ this.#state === 'disconnected' &&
167
+ this.#lastDisconnectReason !== 'client'
168
+ ) {
169
+ this.#ensureReconnectLoop()
170
+ }
194
171
  }
195
172
 
196
- get auth() {
197
- return this.authValue
173
+ setReconnectPauseReason(reason: string, active: boolean) {
174
+ if (active) {
175
+ this.#reconnectPauseReasons.add(reason)
176
+ } else {
177
+ this.#reconnectPauseReasons.delete(reason)
178
+ }
198
179
  }
199
180
 
200
- set auth(value) {
201
- this.authValue = value
181
+ triggerReconnect() {
182
+ if (
183
+ this.#disposed ||
184
+ !this.#reconnectConfig ||
185
+ this.transport.type !== ConnectionType.Bidirectional
186
+ ) {
187
+ return
188
+ }
189
+
190
+ this.#reconnectImmediate = true
191
+
192
+ if (this.#state === 'disconnected' || this.#state === 'idle') {
193
+ this.#ensureReconnectLoop()
194
+ }
202
195
  }
203
196
 
204
197
  connect() {
205
- if (this._state === 'connected') return Promise.resolve()
206
- if (this.connecting) return this.connecting
207
-
208
- if (this._disposed) return Promise.reject(new Error('Client is disposed'))
209
-
210
- const _connect = async () => {
211
- if (this.transport.type === ConnectionType.Bidirectional) {
212
- const client = this
213
- this.cab = new AbortController()
214
- const protocol = this.protocol
215
- const serverStreams = this.serverStreams
216
- const transport = {
217
- send: (buffer) => {
218
- this.send(buffer).catch(noopFn)
219
- },
220
- }
221
- this.messageContext = {
222
- transport,
223
- encoder: this.options.format,
224
- decoder: this.options.format,
225
- addClientStream: (blob) => {
226
- const streamId = this.getStreamId()
227
- return this.clientStreams.add(blob.source, streamId, blob.metadata)
228
- },
229
- addServerStream(streamId, metadata) {
230
- const stream = new ProtocolServerBlobStream(metadata, {
231
- pull: (controller) => {
232
- client.emitStreamEvent({
233
- direction: 'outgoing',
234
- streamType: 'server_blob',
235
- action: 'pull',
236
- streamId,
237
- byteLength: 65535,
238
- })
239
- transport.send(
240
- protocol.encodeMessage(
241
- this,
242
- ClientMessageType.ServerStreamPull,
243
- { streamId, size: 65535 /* 64kb by default */ },
244
- ),
245
- )
246
- },
247
- close: () => {
248
- serverStreams.remove(streamId)
249
- },
250
- readableStrategy: { highWaterMark: 0 },
251
- })
252
- serverStreams.add(streamId, stream)
253
- return ({ signal }: { signal?: AbortSignal } = {}) => {
254
- if (signal)
255
- signal.addEventListener(
256
- 'abort',
257
- () => {
258
- client.emitStreamEvent({
259
- direction: 'outgoing',
260
- streamType: 'server_blob',
261
- action: 'abort',
262
- streamId,
263
- })
264
- transport.send(
265
- protocol.encodeMessage(
266
- this,
267
- ClientMessageType.ServerStreamAbort,
268
- { streamId },
269
- ),
270
- )
271
- serverStreams.abort(streamId)
272
- },
273
- { once: true },
274
- )
275
- return stream
276
- }
277
- },
278
- streamId: this.getStreamId.bind(this),
279
- }
280
- return this.transport.connect({
281
- auth: this.auth,
282
- application: this.options.application,
283
- onMessage: this.onMessage.bind(this),
284
- onConnect: this.onConnect.bind(this),
285
- onDisconnect: this.onDisconnect.bind(this),
286
- })
287
- }
198
+ if (this.#disposed) {
199
+ return Promise.reject(new Error('Client is disposed'))
288
200
  }
289
201
 
290
- let emitDisconnectOnFailure: 'server' | 'client' | (string & {}) | null =
291
- null
202
+ if (this.#state === 'connected') return Promise.resolve()
203
+ if (this.#connecting) return this.#connecting
204
+
205
+ if (this.transport.type === ConnectionType.Unidirectional) {
206
+ return this.#handleConnected()
207
+ }
292
208
 
293
- this.connecting = _connect()
294
- .then(() => {
295
- this._state = 'connected'
209
+ if (!this.#messageContextFactory) {
210
+ return Promise.reject(
211
+ new Error('Message context factory is not configured'),
212
+ )
213
+ }
214
+
215
+ this.#setState('connecting')
216
+ this.#cab = new AbortController()
217
+ this.messageContext = this.#messageContextFactory()
218
+
219
+ this.#connecting = this.transport
220
+ .connect({
221
+ auth: this.auth,
222
+ application: this.application,
223
+ onMessage: (message) => {
224
+ void this.#onMessage(message)
225
+ },
226
+ onConnect: () => {
227
+ void this.#handleConnected()
228
+ },
229
+ onDisconnect: (reason) => {
230
+ void this.#handleDisconnected(reason)
231
+ },
296
232
  })
297
- .catch((error) => {
298
- if (this.transport.type === ConnectionType.Bidirectional) {
299
- emitDisconnectOnFailure = DEFAULT_RECONNECT_REASON
300
- }
233
+ .catch(async (error) => {
234
+ this.messageContext = null
235
+ this.#cab = null
236
+ await this.#handleDisconnected(DEFAULT_CONNECT_ERROR_REASON)
301
237
  throw error
302
238
  })
303
239
  .finally(() => {
304
- this.connecting = null
305
-
306
- if (emitDisconnectOnFailure && !this._disposed) {
307
- this._state = 'disconnected'
308
- this._lastDisconnectReason = emitDisconnectOnFailure
309
- void this.onDisconnect(emitDisconnectOnFailure)
310
- }
240
+ this.#connecting = null
311
241
  })
312
242
 
313
- return this.connecting
243
+ return this.#connecting
314
244
  }
315
245
 
316
- async disconnect(options: { reconnect?: boolean; reason?: string } = {}) {
317
- if (this.transport.type === ConnectionType.Bidirectional) {
318
- // Ensure connect() won't short-circuit while the transport is closing.
319
- this._state = 'disconnected'
320
- this._lastDisconnectReason = 'client'
321
-
322
- if (options.reconnect) {
323
- this.clientDisconnectAsReconnect = true
324
- this.clientDisconnectOverrideReason = options.reason ?? 'server'
325
- } else {
326
- this.clientDisconnectAsReconnect = false
327
- this.clientDisconnectOverrideReason = null
328
- }
246
+ async disconnect(reason: ClientDisconnectReason = 'client') {
247
+ this.#cancelReconnectLoop()
329
248
 
330
- this.cab!.abort()
331
- await this.transport.disconnect()
332
- this.messageContext = null
333
- this.cab = null
249
+ if (this.transport.type === ConnectionType.Unidirectional) {
250
+ await this.#handleDisconnected(reason)
251
+ return
334
252
  }
335
- }
336
-
337
- blob(
338
- source: Blob | ReadableStream | string | AsyncIterable<Uint8Array>,
339
- metadata?: ProtocolBlobMetadata,
340
- ) {
341
- return ProtocolBlob.from(source, metadata)
342
- }
343
-
344
- protected async _call(
345
- procedure: string,
346
- payload: any,
347
- options: ClientCallOptions = {},
348
- ) {
349
- const timeout = options.timeout ?? this.options.timeout
350
- const controller = new AbortController()
351
-
352
- // attach all abort signals
353
- const signals: AbortSignal[] = [controller.signal]
354
253
 
355
- if (timeout) signals.push(AbortSignal.timeout(timeout))
356
- if (options.signal) signals.push(options.signal)
357
- if (this.cab?.signal) signals.push(this.cab.signal)
358
-
359
- const signal = signals.length ? anyAbortSignal(...signals) : undefined
360
-
361
- const callId = this.getCallId()
362
- const call = createFuture() as ProtocolClientCall
363
- call.procedure = procedure
364
- call.signal = signal
254
+ if (this.#state === 'idle' || this.#state === 'disconnected') {
255
+ this.#lastDisconnectReason = reason
256
+ this.#setState('disconnected')
257
+ return
258
+ }
365
259
 
366
- this.calls.set(callId, call)
367
- this.emitClientEvent({
368
- kind: 'rpc_request',
369
- timestamp: Date.now(),
370
- callId,
371
- procedure,
372
- body: payload,
373
- })
374
-
375
- // Check if signal is already aborted before proceeding
376
- if (signal?.aborted) {
377
- this.calls.delete(callId)
378
- const error = new ProtocolError(
379
- ErrorCode.ClientRequestError,
380
- signal.reason,
381
- )
382
- call.reject(error)
383
- } else {
384
- if (signal) {
385
- signal.addEventListener(
386
- 'abort',
387
- () => {
388
- call.reject(
389
- new ProtocolError(ErrorCode.ClientRequestError, signal!.reason),
390
- )
391
- if (
392
- this.transport.type === ConnectionType.Bidirectional &&
393
- this.messageContext
394
- ) {
395
- const buffer = this.protocol.encodeMessage(
396
- this.messageContext,
397
- ClientMessageType.RpcAbort,
398
- { callId },
399
- )
400
- this.send(buffer).catch(noopFn)
401
- }
402
- },
403
- { once: true },
404
- )
405
- }
260
+ this.#setState('disconnecting')
406
261
 
262
+ if (this.#cab && !this.#cab.signal.aborted) {
407
263
  try {
408
- const transformedPayload = this.transformer.encode(procedure, payload)
409
- if (this.transport.type === ConnectionType.Bidirectional) {
410
- const buffer = this.protocol.encodeMessage(
411
- this.messageContext!,
412
- ClientMessageType.Rpc,
413
- { callId, procedure, payload: transformedPayload },
414
- )
415
- await this.send(buffer, signal)
416
- } else {
417
- const response = await this.transport.call(
418
- {
419
- application: this.options.application,
420
- format: this.options.format,
421
- auth: this.auth,
422
- },
423
- { callId, procedure, payload: transformedPayload },
424
- { signal, _stream_response: options._stream_response },
425
- )
426
- this.handleCallResponse(callId, response)
427
- }
428
- } catch (error) {
429
- this.emitClientEvent({
430
- kind: 'rpc_error',
431
- timestamp: Date.now(),
432
- callId,
433
- procedure,
434
- error,
435
- })
436
- call.reject(error)
264
+ this.#cab.abort(reason)
265
+ } catch {
266
+ this.#cab.abort()
437
267
  }
438
268
  }
439
269
 
440
- const result = call.promise.then(
441
- (value) => {
442
- if (value instanceof ProtocolServerRPCStream) {
443
- return value.createAsyncIterable(() => {
444
- controller.abort()
445
- })
446
- }
447
-
448
- if (options._stream_response && typeof value === 'function') {
449
- return value
450
- }
451
-
452
- controller.abort()
453
- return value
454
- },
455
- (err) => {
456
- controller.abort()
457
- throw err
458
- },
459
- )
270
+ try {
271
+ await this.transport.disconnect()
460
272
 
461
- if (this.options.safe) {
462
- return await result
463
- .then((result) => ({ result }))
464
- .catch((error) => ({ error }))
465
- .finally(() => {
466
- this.calls.delete(callId)
467
- })
468
- } else {
469
- return await result.finally(() => {
470
- this.calls.delete(callId)
471
- })
273
+ if (this.#state === 'disconnecting') {
274
+ await this.#handleDisconnected(reason)
275
+ }
276
+ } catch (error) {
277
+ await this.#handleDisconnected(reason)
278
+ throw error
472
279
  }
473
280
  }
474
281
 
475
- protected async onConnect() {
476
- this._state = 'connected'
477
- this._lastDisconnectReason = 'server'
478
- this.emitClientEvent({
479
- kind: 'connected',
480
- timestamp: Date.now(),
481
- transportType:
482
- this.transport.type === ConnectionType.Bidirectional
483
- ? 'bidirectional'
484
- : 'unidirectional',
485
- })
486
- for (const plugin of this.plugins) {
487
- await plugin.onConnect?.()
282
+ requestReconnect(reason: ClientDisconnectReason = 'server') {
283
+ if (this.transport.type !== ConnectionType.Bidirectional) {
284
+ return Promise.resolve()
488
285
  }
489
- this.emit('connected')
490
- }
491
286
 
492
- protected async onDisconnect(reason: ClientDisconnectReason) {
493
- const effectiveReason =
494
- reason === 'client' && this.clientDisconnectAsReconnect
495
- ? (this.clientDisconnectOverrideReason ?? 'server')
496
- : reason
287
+ this.#clientDisconnectAsReconnect = true
288
+ this.#clientDisconnectOverrideReason = reason
497
289
 
498
- this.clientDisconnectAsReconnect = false
499
- this.clientDisconnectOverrideReason = null
290
+ return this.disconnect('client')
291
+ }
500
292
 
501
- this._state = 'disconnected'
502
- this._lastDisconnectReason = effectiveReason
503
- this.emitClientEvent({
504
- kind: 'disconnected',
505
- timestamp: Date.now(),
506
- reason: effectiveReason,
507
- })
293
+ dispose() {
294
+ if (this.#disposed) return
508
295
 
509
- // Connection is gone, never keep old message context around.
296
+ this.#disposed = true
297
+ this.#cancelReconnectLoop()
510
298
  this.messageContext = null
511
299
 
512
- this.stopAllPendingPings(effectiveReason)
513
-
514
- // Fail-fast: do not keep pending calls around across disconnects.
515
- if (this.calls.size) {
516
- const error = new ProtocolError(
517
- ErrorCode.ConnectionError,
518
- 'Disconnected',
519
- { reason: effectiveReason },
520
- )
521
- for (const call of this.calls.values()) {
522
- call.reject(error)
523
- }
524
- this.calls.clear()
525
- }
526
-
527
- if (this.cab) {
300
+ if (this.#cab && !this.#cab.signal.aborted) {
528
301
  try {
529
- this.cab.abort(reason)
302
+ this.#cab.abort('dispose')
530
303
  } catch {
531
- this.cab.abort()
304
+ this.#cab.abort()
532
305
  }
533
- this.cab = null
534
306
  }
535
307
 
536
- this.emit('disconnected', effectiveReason)
537
-
538
- for (let i = this.plugins.length - 1; i >= 0; i--) {
539
- await this.plugins[i].onDisconnect?.(effectiveReason)
308
+ if (
309
+ this.transport.type === ConnectionType.Bidirectional &&
310
+ (this.#state === 'connecting' || this.#state === 'connected')
311
+ ) {
312
+ void this.transport.disconnect().catch(noopFn)
540
313
  }
541
314
 
542
- void this.clientStreams.clear(effectiveReason)
543
- void this.serverStreams.clear(effectiveReason)
544
- void this.rpcStreams.clear(effectiveReason)
315
+ for (let i = this.#plugins.length - 1; i >= 0; i--) {
316
+ this.#plugins[i].dispose?.()
317
+ }
545
318
  }
546
319
 
547
- protected nextPingNonce() {
548
- if (this.pingNonce >= MAX_UINT32) this.pingNonce = 0
549
- return this.pingNonce++
320
+ send(buffer: ArrayBufferView, signal?: AbortSignal) {
321
+ if (this.transport.type !== ConnectionType.Bidirectional) {
322
+ throw new Error('Invalid transport type for send')
323
+ }
324
+
325
+ return this.transport.send(buffer, { signal })
550
326
  }
551
327
 
552
- ping(timeout: number, signal?: AbortSignal) {
553
- if (
554
- !this.messageContext ||
555
- this.transport.type !== ConnectionType.Bidirectional
556
- ) {
557
- return Promise.reject(new Error('Client is not connected'))
328
+ transportCall(
329
+ context: TransportCallContext,
330
+ rpc: TransportRpcParams,
331
+ options: TransportCallOptions,
332
+ ): Promise<TransportCallResponse> {
333
+ if (this.transport.type !== ConnectionType.Unidirectional) {
334
+ throw new Error('Invalid transport type for call')
558
335
  }
559
336
 
560
- const nonce = this.nextPingNonce()
561
- const future = createFuture<void>()
562
- this.pendingPings.set(nonce, future)
563
-
564
- const buffer = this.protocol.encodeMessage(
565
- this.messageContext,
566
- ClientMessageType.Ping,
567
- { nonce },
568
- )
337
+ return this.transport.call(context, rpc, options)
338
+ }
569
339
 
570
- return this.send(buffer, signal)
571
- .then(() =>
572
- withTimeout(future.promise, timeout, new Error('Heartbeat timeout')),
573
- )
574
- .finally(() => {
575
- this.pendingPings.delete(nonce)
576
- })
340
+ emitClientEvent(event: ClientPluginEvent) {
341
+ for (const plugin of this.#plugins) {
342
+ try {
343
+ const result = plugin.onClientEvent?.(event)
344
+ Promise.resolve(result).catch(noopFn)
345
+ } catch {}
346
+ }
577
347
  }
578
348
 
579
- protected stopAllPendingPings(reason?: any) {
580
- if (!this.pendingPings.size) return
581
- const error = new Error('Heartbeat stopped', { cause: reason })
582
- for (const pending of this.pendingPings.values()) pending.reject(error)
583
- this.pendingPings.clear()
349
+ emitStreamEvent(event: StreamEvent) {
350
+ this.emitClientEvent({
351
+ kind: 'stream_event',
352
+ timestamp: Date.now(),
353
+ ...event,
354
+ })
584
355
  }
585
356
 
586
- protected async onMessage(buffer: ArrayBufferView) {
357
+ async #onMessage(buffer: ArrayBufferView) {
587
358
  if (!this.messageContext) return
588
359
 
589
360
  const message = this.protocol.decodeMessage(this.messageContext, buffer)
590
- for (const plugin of this.plugins) {
361
+
362
+ for (const plugin of this.#plugins) {
591
363
  plugin.onServerMessage?.(message, buffer)
592
364
  }
365
+
593
366
  this.emitClientEvent({
594
367
  kind: 'server_message',
595
368
  timestamp: Date.now(),
@@ -598,450 +371,170 @@ export abstract class BaseClient<
598
371
  body: message,
599
372
  })
600
373
 
601
- switch (message.type) {
602
- case ServerMessageType.RpcResponse:
603
- this.handleRPCResponseMessage(message)
604
- break
605
- case ServerMessageType.RpcStreamResponse:
606
- this.handleRPCStreamResponseMessage(message)
607
- break
608
- case ServerMessageType.Pong: {
609
- const pending = this.pendingPings.get(message.nonce)
610
- if (pending) {
611
- this.pendingPings.delete(message.nonce)
612
- pending.resolve()
613
- }
614
- this.emit('pong', message.nonce)
615
- break
616
- }
617
- case ServerMessageType.Ping: {
618
- if (this.messageContext) {
619
- const buffer = this.protocol.encodeMessage(
620
- this.messageContext,
621
- ClientMessageType.Pong,
622
- { nonce: message.nonce },
623
- )
624
- this.send(buffer).catch(noopFn)
625
- }
626
- break
627
- }
628
- case ServerMessageType.RpcStreamChunk:
629
- this.emitStreamEvent({
630
- direction: 'incoming',
631
- streamType: 'rpc',
632
- action: 'push',
633
- callId: message.callId,
634
- byteLength: message.chunk.byteLength,
635
- })
636
- this.rpcStreams.push(message.callId, message.chunk)
637
- break
638
- case ServerMessageType.RpcStreamEnd:
639
- this.emitStreamEvent({
640
- direction: 'incoming',
641
- streamType: 'rpc',
642
- action: 'end',
643
- callId: message.callId,
644
- })
645
- this.rpcStreams.end(message.callId)
646
- this.calls.delete(message.callId)
647
- break
648
- case ServerMessageType.RpcStreamAbort:
649
- this.emitStreamEvent({
650
- direction: 'incoming',
651
- streamType: 'rpc',
652
- action: 'abort',
653
- callId: message.callId,
654
- reason: message.reason,
655
- })
656
- this.rpcStreams.abort(message.callId)
657
- this.calls.delete(message.callId)
658
- break
659
- case ServerMessageType.ServerStreamPush:
660
- this.emitStreamEvent({
661
- direction: 'incoming',
662
- streamType: 'server_blob',
663
- action: 'push',
664
- streamId: message.streamId,
665
- byteLength: message.chunk.byteLength,
666
- })
667
- this.serverStreams.push(message.streamId, message.chunk)
668
- break
669
- case ServerMessageType.ServerStreamEnd:
670
- this.emitStreamEvent({
671
- direction: 'incoming',
672
- streamType: 'server_blob',
673
- action: 'end',
674
- streamId: message.streamId,
675
- })
676
- this.serverStreams.end(message.streamId)
677
- break
678
- case ServerMessageType.ServerStreamAbort:
679
- this.emitStreamEvent({
680
- direction: 'incoming',
681
- streamType: 'server_blob',
682
- action: 'abort',
683
- streamId: message.streamId,
684
- reason: message.reason,
685
- })
686
- this.serverStreams.abort(message.streamId)
687
- break
688
- case ServerMessageType.ClientStreamPull:
689
- this.emitStreamEvent({
690
- direction: 'incoming',
691
- streamType: 'client_blob',
692
- action: 'pull',
693
- streamId: message.streamId,
694
- byteLength: message.size,
695
- })
696
- this.clientStreams.pull(message.streamId, message.size).then(
697
- (chunk) => {
698
- if (chunk) {
699
- this.emitStreamEvent({
700
- direction: 'outgoing',
701
- streamType: 'client_blob',
702
- action: 'push',
703
- streamId: message.streamId,
704
- byteLength: chunk.byteLength,
705
- })
706
- const buffer = this.protocol.encodeMessage(
707
- this.messageContext!,
708
- ClientMessageType.ClientStreamPush,
709
- { streamId: message.streamId, chunk },
710
- )
711
- this.send(buffer).catch(noopFn)
712
- } else {
713
- this.emitStreamEvent({
714
- direction: 'outgoing',
715
- streamType: 'client_blob',
716
- action: 'end',
717
- streamId: message.streamId,
718
- })
719
- const buffer = this.protocol.encodeMessage(
720
- this.messageContext!,
721
- ClientMessageType.ClientStreamEnd,
722
- { streamId: message.streamId },
723
- )
724
- this.send(buffer).catch(noopFn)
725
- this.clientStreams.end(message.streamId)
726
- }
727
- },
728
- () => {
729
- this.emitStreamEvent({
730
- direction: 'outgoing',
731
- streamType: 'client_blob',
732
- action: 'abort',
733
- streamId: message.streamId,
734
- })
735
- const buffer = this.protocol.encodeMessage(
736
- this.messageContext!,
737
- ClientMessageType.ClientStreamAbort,
738
- { streamId: message.streamId },
739
- )
740
- this.send(buffer).catch(noopFn)
741
- this.clientStreams.remove(message.streamId)
742
- },
743
- )
744
- break
745
- case ServerMessageType.ClientStreamAbort:
746
- this.emitStreamEvent({
747
- direction: 'incoming',
748
- streamType: 'client_blob',
749
- action: 'abort',
750
- streamId: message.streamId,
751
- reason: message.reason,
752
- })
753
- this.clientStreams.abort(message.streamId)
754
- break
755
- }
374
+ this.emit('message', message, buffer)
756
375
  }
757
376
 
758
- private handleRPCResponseMessage(
759
- message: ServerMessageTypePayload[ServerMessageType.RpcResponse],
760
- ) {
761
- const { callId, result, error } = message
762
- const call = this.calls.get(callId)
763
- if (!call) return
764
- if (error) {
765
- this.emitClientEvent({
766
- kind: 'rpc_error',
767
- timestamp: Date.now(),
768
- callId,
769
- procedure: call.procedure,
770
- error,
771
- })
772
- call.reject(new ProtocolError(error.code, error.message, error.data))
773
- } else {
774
- try {
775
- const transformed = this.transformer.decode(call.procedure, result)
776
- this.emitClientEvent({
777
- kind: 'rpc_response',
778
- timestamp: Date.now(),
779
- callId,
780
- procedure: call.procedure,
781
- body: transformed,
782
- })
783
- call.resolve(transformed)
784
- } catch (error) {
785
- this.emitClientEvent({
786
- kind: 'rpc_error',
787
- timestamp: Date.now(),
788
- callId,
789
- procedure: call.procedure,
790
- error,
791
- })
792
- call.reject(
793
- new ProtocolError(
794
- ErrorCode.ClientRequestError,
795
- 'Unable to decode response',
796
- error,
797
- ),
798
- )
799
- }
377
+ async #handleConnected() {
378
+ this.#reconnectTimeout =
379
+ this.#reconnectConfig?.initialTimeout ?? DEFAULT_RECONNECT_TIMEOUT
380
+ this.#reconnectImmediate = false
381
+
382
+ this.#setState('connected')
383
+ this.#lastDisconnectReason = 'server'
384
+
385
+ this.emitClientEvent({
386
+ kind: 'connected',
387
+ timestamp: Date.now(),
388
+ transportType:
389
+ this.transport.type === ConnectionType.Bidirectional
390
+ ? 'bidirectional'
391
+ : 'unidirectional',
392
+ })
393
+
394
+ for (const plugin of this.#plugins) {
395
+ await plugin.onConnect?.()
800
396
  }
397
+
398
+ this.emit('connected')
801
399
  }
802
400
 
803
- private handleRPCStreamResponseMessage(
804
- message: ServerMessageTypePayload[ServerMessageType.RpcStreamResponse],
805
- ) {
806
- const call = this.calls.get(message.callId)
807
- if (message.error) {
808
- if (!call) return
809
- this.emitClientEvent({
810
- kind: 'rpc_error',
811
- timestamp: Date.now(),
812
- callId: message.callId,
813
- procedure: call.procedure,
814
- error: message.error,
815
- })
816
- call.reject(
817
- new ProtocolError(
818
- message.error.code,
819
- message.error.message,
820
- message.error.data,
821
- ),
822
- )
823
- } else {
824
- if (call) {
825
- const { procedure, signal } = call
826
- this.emitClientEvent({
827
- kind: 'rpc_response',
828
- timestamp: Date.now(),
829
- callId: message.callId,
830
- procedure,
831
- stream: true,
832
- })
833
- const stream = new ProtocolServerRPCStream({
834
- start: (controller) => {
835
- if (signal) {
836
- if (signal.aborted) controller.error(signal.reason)
837
- else
838
- signal.addEventListener(
839
- 'abort',
840
- () => {
841
- controller.error(signal.reason)
842
- if (this.rpcStreams.has(message.callId)) {
843
- this.rpcStreams.remove(message.callId)
844
- this.calls.delete(message.callId)
845
- if (this.messageContext) {
846
- const buffer = this.protocol.encodeMessage(
847
- this.messageContext,
848
- ClientMessageType.RpcAbort,
849
- { callId: message.callId, reason: signal.reason },
850
- )
851
- this.send(buffer).catch(noopFn)
852
- }
853
- }
854
- },
855
- { once: true },
856
- )
857
- }
858
- },
859
- transform: (chunk) => {
860
- return this.transformer.decode(
861
- procedure,
862
- this.options.format.decode(chunk),
863
- )
864
- },
865
- readableStrategy: { highWaterMark: 0 },
866
- })
867
- this.rpcStreams.add(message.callId, stream)
868
- call.resolve(stream)
869
- } else {
870
- // Call not found, but stream response received
871
- // This can happen if the call was aborted or timed out
872
- // Need to send an abort for the stream to avoid resource leaks from server side
873
- if (this.messageContext) {
874
- const buffer = this.protocol.encodeMessage(
875
- this.messageContext,
876
- ClientMessageType.RpcAbort,
877
- { callId: message.callId },
878
- )
879
- this.send(buffer).catch(noopFn)
401
+ async #handleDisconnected(reason: ClientDisconnectReason) {
402
+ const effectiveReason =
403
+ reason === 'client' && this.#clientDisconnectAsReconnect
404
+ ? (this.#clientDisconnectOverrideReason ?? 'server')
405
+ : reason
406
+
407
+ this.#clientDisconnectAsReconnect = false
408
+ this.#clientDisconnectOverrideReason = null
409
+
410
+ const shouldSkip =
411
+ this.#state === 'disconnected' &&
412
+ this.messageContext === null &&
413
+ this.#lastDisconnectReason === effectiveReason
414
+
415
+ this.messageContext = null
416
+
417
+ if (this.#cab) {
418
+ if (!this.#cab.signal.aborted) {
419
+ try {
420
+ this.#cab.abort(reason)
421
+ } catch {
422
+ this.#cab.abort()
880
423
  }
881
424
  }
425
+ this.#cab = null
882
426
  }
883
- }
884
427
 
885
- private handleCallResponse(callId: number, response: ClientCallResponse) {
886
- const call = this.calls.get(callId)
887
-
888
- if (response.type === 'rpc_stream') {
889
- if (call) {
890
- this.emitClientEvent({
891
- kind: 'rpc_response',
892
- timestamp: Date.now(),
893
- callId,
894
- procedure: call.procedure,
895
- stream: true,
896
- })
897
- const stream = new ProtocolServerStream({
898
- transform: (chunk) => {
899
- return this.transformer.decode(
900
- call.procedure,
901
- this.options.format.decode(chunk),
902
- )
903
- },
904
- })
905
- this.rpcStreams.add(callId, stream)
906
- call.resolve(({ signal }: { signal?: AbortSignal }) => {
907
- const reader = response.stream.getReader()
908
-
909
- let onAbort: (() => void) | undefined
910
- if (signal) {
911
- onAbort = () => {
912
- reader.cancel(signal.reason).catch(noopFn)
913
- this.rpcStreams.abort(callId).catch(noopFn)
914
- }
915
- if (signal.aborted) onAbort()
916
- else signal.addEventListener('abort', onAbort, { once: true })
917
- }
918
-
919
- void (async () => {
920
- try {
921
- while (true) {
922
- const { done, value } = await reader.read()
923
- if (done) break
924
- await this.rpcStreams.push(callId, value)
925
- }
926
- await this.rpcStreams.end(callId)
927
- } catch {
928
- await this.rpcStreams.abort(callId).catch(noopFn)
929
- } finally {
930
- reader.releaseLock()
931
- if (signal && onAbort) {
932
- signal.removeEventListener('abort', onAbort)
933
- }
934
- }
935
- })()
936
-
937
- return stream
938
- })
939
- } else {
940
- // Call not found, but stream response received
941
- // This can happen if the call was aborted or timed out
942
- // Need to cancel the stream to avoid resource leaks from server side
943
- response.stream.cancel().catch(noopFn)
944
- }
945
- } else if (response.type === 'blob') {
946
- if (call) {
947
- this.emitClientEvent({
948
- kind: 'rpc_response',
949
- timestamp: Date.now(),
950
- callId,
951
- procedure: call.procedure,
952
- stream: true,
953
- })
954
- const { metadata, source } = response
955
- const stream = new ProtocolServerBlobStream(metadata)
956
- this.serverStreams.add(this.getStreamId(), stream)
957
- call.resolve(({ signal }: { signal?: AbortSignal }) => {
958
- source.pipeTo(stream.writable, { signal }).catch(noopFn)
959
- return stream
960
- })
961
- } else {
962
- // Call not found, but blob response received
963
- // This can happen if the call was aborted or timed out
964
- // Need to cancel the stream to avoid resource leaks from server side
965
- response.source.cancel().catch(noopFn)
966
- }
967
- } else if (response.type === 'rpc') {
968
- if (!call) return
969
- try {
970
- const decodedPayload =
971
- response.result.byteLength === 0
972
- ? undefined
973
- : this.options.format.decode(response.result)
974
-
975
- const transformed = this.transformer.decode(
976
- call.procedure,
977
- decodedPayload,
978
- )
979
- this.emitClientEvent({
980
- kind: 'rpc_response',
981
- timestamp: Date.now(),
982
- callId,
983
- procedure: call.procedure,
984
- body: transformed,
985
- })
986
- call.resolve(transformed)
987
- } catch (error) {
988
- this.emitClientEvent({
989
- kind: 'rpc_error',
990
- timestamp: Date.now(),
991
- callId,
992
- procedure: call.procedure,
993
- error,
994
- })
995
- call.reject(
996
- new ProtocolError(
997
- ErrorCode.ClientRequestError,
998
- 'Unable to decode response',
999
- error,
1000
- ),
1001
- )
1002
- }
428
+ if (shouldSkip) return
429
+
430
+ this.#lastDisconnectReason = effectiveReason
431
+ this.#setState('disconnected')
432
+
433
+ this.emitClientEvent({
434
+ kind: 'disconnected',
435
+ timestamp: Date.now(),
436
+ reason: effectiveReason,
437
+ })
438
+
439
+ this.emit('disconnected', effectiveReason)
440
+
441
+ for (let i = this.#plugins.length - 1; i >= 0; i--) {
442
+ await this.#plugins[i].onDisconnect?.(effectiveReason)
1003
443
  }
1004
- }
1005
444
 
1006
- protected send(buffer: ArrayBufferView, signal?: AbortSignal) {
1007
- if (this.transport.type === ConnectionType.Unidirectional)
1008
- throw new Error('Invalid transport type for send')
1009
- return this.transport.send(buffer, { signal })
445
+ if (this.#shouldReconnect(effectiveReason)) {
446
+ this.#ensureReconnectLoop()
447
+ }
1010
448
  }
1011
449
 
1012
- protected emitStreamEvent(
1013
- event: Omit<
1014
- Extract<ClientPluginEvent, { kind: 'stream_event' }>,
1015
- 'kind' | 'timestamp'
1016
- >,
1017
- ) {
450
+ #setState(next: ConnectionState) {
451
+ if (next === this.#state) return
452
+
453
+ const previous = this.#state
454
+ this.#state = next
455
+
1018
456
  this.emitClientEvent({
1019
- kind: 'stream_event',
457
+ kind: 'state_changed',
1020
458
  timestamp: Date.now(),
1021
- ...event,
459
+ state: next,
460
+ previous,
1022
461
  })
462
+
463
+ this.emit('state_changed', next, previous)
1023
464
  }
1024
465
 
1025
- protected getStreamId() {
1026
- if (this.streamId >= MAX_UINT32) {
1027
- this.streamId = 0
1028
- }
1029
- return this.streamId++
466
+ #shouldReconnect(reason: ClientDisconnectReason) {
467
+ return (
468
+ !this.#disposed &&
469
+ !!this.#reconnectConfig &&
470
+ this.transport.type === ConnectionType.Bidirectional &&
471
+ reason !== 'client'
472
+ )
1030
473
  }
1031
474
 
1032
- protected getCallId() {
1033
- if (this.callId >= MAX_UINT32) {
1034
- this.callId = 0
1035
- }
1036
- return this.callId++
475
+ #cancelReconnectLoop() {
476
+ this.#reconnectImmediate = false
477
+ this.#reconnectController?.abort()
478
+ this.#reconnectController = null
479
+ this.#reconnectPromise = null
1037
480
  }
1038
481
 
1039
- protected emitClientEvent(event: ClientPluginEvent) {
1040
- for (const plugin of this.plugins) {
1041
- try {
1042
- const result = plugin.onClientEvent?.(event)
1043
- Promise.resolve(result).catch(noopFn)
1044
- } catch {}
1045
- }
482
+ #ensureReconnectLoop() {
483
+ if (this.#reconnectPromise || !this.#reconnectConfig) return
484
+
485
+ const signal = new AbortController()
486
+ this.#reconnectController = signal
487
+
488
+ this.#reconnectPromise = (async () => {
489
+ while (
490
+ !signal.signal.aborted &&
491
+ !this.#disposed &&
492
+ this.#reconnectConfig &&
493
+ (this.#state === 'disconnected' || this.#state === 'idle') &&
494
+ this.#lastDisconnectReason !== 'client'
495
+ ) {
496
+ if (this.#reconnectPauseReasons.size) {
497
+ await sleep(1000, signal.signal)
498
+ continue
499
+ }
500
+
501
+ const delay = this.#reconnectImmediate
502
+ ? 0
503
+ : computeReconnectDelay(this.#reconnectTimeout)
504
+ this.#reconnectImmediate = false
505
+
506
+ if (delay > 0) {
507
+ await sleep(delay, signal.signal)
508
+ }
509
+
510
+ const currentState = this.state
511
+
512
+ if (
513
+ signal.signal.aborted ||
514
+ this.#disposed ||
515
+ !this.#reconnectConfig ||
516
+ currentState === 'connected' ||
517
+ currentState === 'connecting'
518
+ ) {
519
+ break
520
+ }
521
+
522
+ const previousTimeout = this.#reconnectTimeout
523
+
524
+ await this.connect().catch(noopFn)
525
+
526
+ if (this.state !== 'connected' && this.#reconnectConfig) {
527
+ this.#reconnectTimeout = Math.min(
528
+ previousTimeout * 2,
529
+ this.#reconnectConfig.maxTimeout ?? DEFAULT_MAX_RECONNECT_TIMEOUT,
530
+ )
531
+ }
532
+ }
533
+ })().finally(() => {
534
+ if (this.#reconnectController === signal) {
535
+ this.#reconnectController = null
536
+ }
537
+ this.#reconnectPromise = null
538
+ })
1046
539
  }
1047
540
  }