@nmtjs/client 0.15.3 → 0.16.0-beta.2

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 +65 -0
  2. package/dist/client.js +98 -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 +36 -83
  11. package/dist/core.js +315 -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 +564 -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 +218 -0
  42. package/src/clients/runtime.ts +93 -79
  43. package/src/clients/static.ts +46 -38
  44. package/src/core.ts +408 -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 +778 -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,351 @@ 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
+ autoConnect?: boolean
47
+ plugins?: ClientPlugin[]
48
+ }
55
49
 
56
50
  export class ClientError extends ProtocolError {}
57
51
 
58
- export type ProtocolClientCall = Future<any> & {
59
- procedure: string
60
- signal?: AbortSignal
52
+ const DEFAULT_RECONNECT_TIMEOUT = 1000
53
+ const DEFAULT_MAX_RECONNECT_TIMEOUT = 60000
54
+ const DEFAULT_CONNECT_ERROR_REASON = 'connect_error'
55
+
56
+ const sleep = (ms: number, signal?: AbortSignal) => {
57
+ return new Promise<void>((resolve) => {
58
+ if (signal?.aborted) return resolve()
59
+
60
+ const timer = setTimeout(resolve, ms)
61
+ signal?.addEventListener(
62
+ 'abort',
63
+ () => {
64
+ clearTimeout(timer)
65
+ resolve()
66
+ },
67
+ { once: true },
68
+ )
69
+ })
61
70
  }
62
71
 
63
- const DEFAULT_RECONNECT_REASON = 'connect_error'
72
+ const computeReconnectDelay = (ms: number) => {
73
+ if (globalThis.window) {
74
+ const jitter = Math.floor(ms * 0.2 * Math.random())
75
+ return ms + jitter
76
+ }
64
77
 
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
78
+ return ms
76
79
  }
77
80
 
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<{
81
+ export class ClientCore extends EventEmitter<{
82
+ message: [message: unknown, raw: ArrayBufferView]
92
83
  connected: []
93
84
  disconnected: [reason: ClientDisconnectReason]
85
+ state_changed: [state: ConnectionState, previous: ConnectionState]
94
86
  pong: [nonce: number]
95
87
  }> {
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
88
+ readonly protocol: ProtocolVersionInterface
89
+ readonly format: BaseClientFormat
90
+ readonly application?: string
91
+ readonly autoConnect: boolean
92
+
93
+ auth: any
94
+ messageContext: MessageContext | null = null
95
+
96
+ #state: ConnectionState = 'idle'
97
+ #messageContextFactory: (() => MessageContext) | null = null
98
+ #cab: AbortController | null = null
99
+ #connecting: Promise<void> | null = null
100
+ #disposed = false
101
+ #plugins: ClientPluginInstance[] = []
102
+ #lastDisconnectReason: ClientDisconnectReason = 'server'
103
+ #clientDisconnectAsReconnect = false
104
+ #clientDisconnectOverrideReason: ClientDisconnectReason | null = null
105
+ #reconnectConfig: ReconnectConfig | null = null
106
+ #reconnectPauseReasons = new Set<string>()
107
+ #reconnectController: AbortController | null = null
108
+ #reconnectPromise: Promise<void> | null = null
109
+ #reconnectTimeout = DEFAULT_RECONNECT_TIMEOUT
110
+ #reconnectImmediate = false
140
111
 
141
112
  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,
113
+ options: ClientCoreOptions,
114
+ readonly transport: ClientTransport,
150
115
  ) {
151
116
  super()
152
117
 
153
118
  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
- }
119
+ this.format = options.format
120
+ this.application = options.application
121
+ this.autoConnect = options.autoConnect ?? false
174
122
  }
175
123
 
176
124
  get state() {
177
- return this._state
125
+ return this.#state
178
126
  }
179
127
 
180
128
  get lastDisconnectReason() {
181
- return this._lastDisconnectReason
129
+ return this.#lastDisconnectReason
182
130
  }
183
131
 
184
132
  get transportType() {
185
133
  return this.transport.type
186
134
  }
187
135
 
188
- isDisposed() {
189
- return this._disposed
136
+ get connectionSignal() {
137
+ return this.#cab?.signal
190
138
  }
191
139
 
192
- requestReconnect(reason?: string) {
193
- return this.disconnect({ reconnect: true, reason })
140
+ isDisposed() {
141
+ return this.#disposed
194
142
  }
195
143
 
196
- get auth() {
197
- return this.authValue
144
+ shouldConnectOnCall() {
145
+ return (
146
+ this.autoConnect &&
147
+ !this.#disposed &&
148
+ this.#lastDisconnectReason !== 'client' &&
149
+ (this.#state === 'idle' ||
150
+ this.#state === 'connecting' ||
151
+ this.#state === 'disconnected')
152
+ )
198
153
  }
199
154
 
200
- set auth(value) {
201
- this.authValue = value
202
- }
155
+ initPlugins(plugins: ClientPlugin[] = [], context: ClientPluginContext) {
156
+ if (this.#plugins.length > 0) return
203
157
 
204
- 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
- }
158
+ this.#plugins = plugins.map((plugin) => plugin(context))
159
+ for (const plugin of this.#plugins) {
160
+ plugin.onInit?.()
288
161
  }
162
+ }
289
163
 
290
- let emitDisconnectOnFailure: 'server' | 'client' | (string & {}) | null =
291
- null
164
+ setMessageContextFactory(factory: () => MessageContext) {
165
+ this.#messageContextFactory = factory
166
+ }
292
167
 
293
- this.connecting = _connect()
294
- .then(() => {
295
- this._state = 'connected'
296
- })
297
- .catch((error) => {
298
- if (this.transport.type === ConnectionType.Bidirectional) {
299
- emitDisconnectOnFailure = DEFAULT_RECONNECT_REASON
300
- }
301
- throw error
302
- })
303
- .finally(() => {
304
- this.connecting = null
168
+ configureReconnect(config: ReconnectConfig | null) {
169
+ this.#reconnectConfig = config
170
+ this.#reconnectTimeout = config?.initialTimeout ?? DEFAULT_RECONNECT_TIMEOUT
171
+ this.#reconnectImmediate = false
305
172
 
306
- if (emitDisconnectOnFailure && !this._disposed) {
307
- this._state = 'disconnected'
308
- this._lastDisconnectReason = emitDisconnectOnFailure
309
- void this.onDisconnect(emitDisconnectOnFailure)
310
- }
311
- })
173
+ if (!config) {
174
+ this.#cancelReconnectLoop()
175
+ return
176
+ }
312
177
 
313
- return this.connecting
178
+ if (
179
+ this.transport.type === ConnectionType.Bidirectional &&
180
+ this.#state === 'disconnected' &&
181
+ this.#lastDisconnectReason !== 'client'
182
+ ) {
183
+ this.#ensureReconnectLoop()
184
+ }
314
185
  }
315
186
 
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
- }
329
-
330
- this.cab!.abort()
331
- await this.transport.disconnect()
332
- this.messageContext = null
333
- this.cab = null
187
+ setReconnectPauseReason(reason: string, active: boolean) {
188
+ if (active) {
189
+ this.#reconnectPauseReasons.add(reason)
190
+ } else {
191
+ this.#reconnectPauseReasons.delete(reason)
334
192
  }
335
193
  }
336
194
 
337
- blob(
338
- source: Blob | ReadableStream | string | AsyncIterable<Uint8Array>,
339
- metadata?: ProtocolBlobMetadata,
340
- ) {
341
- return ProtocolBlob.from(source, metadata)
195
+ triggerReconnect() {
196
+ if (
197
+ this.#disposed ||
198
+ !this.#reconnectConfig ||
199
+ this.transport.type !== ConnectionType.Bidirectional
200
+ ) {
201
+ return
202
+ }
203
+
204
+ this.#reconnectImmediate = true
205
+
206
+ if (this.#state === 'disconnected' || this.#state === 'idle') {
207
+ this.#ensureReconnectLoop()
208
+ }
342
209
  }
343
210
 
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()
211
+ connect() {
212
+ if (this.#disposed) {
213
+ return Promise.reject(new Error('Client is disposed'))
214
+ }
351
215
 
352
- // attach all abort signals
353
- const signals: AbortSignal[] = [controller.signal]
216
+ if (this.#state === 'connected') return Promise.resolve()
217
+ if (this.#connecting) return this.#connecting
354
218
 
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)
219
+ if (this.transport.type === ConnectionType.Unidirectional) {
220
+ return this.#handleConnected()
221
+ }
358
222
 
359
- const signal = signals.length ? anyAbortSignal(...signals) : undefined
223
+ if (!this.#messageContextFactory) {
224
+ return Promise.reject(
225
+ new Error('Message context factory is not configured'),
226
+ )
227
+ }
360
228
 
361
- const callId = this.getCallId()
362
- const call = createFuture() as ProtocolClientCall
363
- call.procedure = procedure
364
- call.signal = signal
229
+ this.#setState('connecting')
230
+ this.#cab = new AbortController()
231
+ this.messageContext = this.#messageContextFactory()
232
+
233
+ this.#connecting = this.transport
234
+ .connect({
235
+ auth: this.auth,
236
+ application: this.application,
237
+ onMessage: (message) => {
238
+ void this.#onMessage(message)
239
+ },
240
+ onConnect: () => {
241
+ void this.#handleConnected()
242
+ },
243
+ onDisconnect: (reason) => {
244
+ void this.#handleDisconnected(reason)
245
+ },
246
+ })
247
+ .catch(async (error) => {
248
+ this.messageContext = null
249
+ this.#cab = null
250
+ await this.#handleDisconnected(DEFAULT_CONNECT_ERROR_REASON)
251
+ throw error
252
+ })
253
+ .finally(() => {
254
+ this.#connecting = null
255
+ })
365
256
 
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
- })
257
+ return this.#connecting
258
+ }
374
259
 
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
+ async disconnect(reason: ClientDisconnectReason = 'client') {
261
+ this.#cancelReconnectLoop()
406
262
 
407
- 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)
437
- }
263
+ if (this.transport.type === ConnectionType.Unidirectional) {
264
+ await this.#handleDisconnected(reason)
265
+ return
438
266
  }
439
267
 
440
- const result = call.promise.then(
441
- (value) => {
442
- if (value instanceof ProtocolServerRPCStream) {
443
- return value.createAsyncIterable(() => {
444
- controller.abort()
445
- })
446
- }
268
+ if (this.#state === 'idle' || this.#state === 'disconnected') {
269
+ this.#lastDisconnectReason = reason
270
+ this.#setState('disconnected')
271
+ return
272
+ }
447
273
 
448
- if (options._stream_response && typeof value === 'function') {
449
- return value
450
- }
274
+ this.#setState('disconnecting')
451
275
 
452
- controller.abort()
453
- return value
454
- },
455
- (err) => {
456
- controller.abort()
457
- throw err
458
- },
459
- )
276
+ if (this.#cab && !this.#cab.signal.aborted) {
277
+ try {
278
+ this.#cab.abort(reason)
279
+ } catch {
280
+ this.#cab.abort()
281
+ }
282
+ }
460
283
 
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
- })
284
+ try {
285
+ await this.transport.disconnect()
286
+
287
+ if (this.#state === 'disconnecting') {
288
+ await this.#handleDisconnected(reason)
289
+ }
290
+ } catch (error) {
291
+ await this.#handleDisconnected(reason)
292
+ throw error
472
293
  }
473
294
  }
474
295
 
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?.()
296
+ requestReconnect(reason: ClientDisconnectReason = 'server') {
297
+ if (this.transport.type !== ConnectionType.Bidirectional) {
298
+ return Promise.resolve()
488
299
  }
489
- this.emit('connected')
490
- }
491
300
 
492
- protected async onDisconnect(reason: ClientDisconnectReason) {
493
- const effectiveReason =
494
- reason === 'client' && this.clientDisconnectAsReconnect
495
- ? (this.clientDisconnectOverrideReason ?? 'server')
496
- : reason
301
+ this.#clientDisconnectAsReconnect = true
302
+ this.#clientDisconnectOverrideReason = reason
497
303
 
498
- this.clientDisconnectAsReconnect = false
499
- this.clientDisconnectOverrideReason = null
304
+ return this.disconnect('client')
305
+ }
500
306
 
501
- this._state = 'disconnected'
502
- this._lastDisconnectReason = effectiveReason
503
- this.emitClientEvent({
504
- kind: 'disconnected',
505
- timestamp: Date.now(),
506
- reason: effectiveReason,
507
- })
307
+ dispose() {
308
+ if (this.#disposed) return
508
309
 
509
- // Connection is gone, never keep old message context around.
310
+ this.#disposed = true
311
+ this.#cancelReconnectLoop()
510
312
  this.messageContext = null
511
313
 
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) {
314
+ if (this.#cab && !this.#cab.signal.aborted) {
528
315
  try {
529
- this.cab.abort(reason)
316
+ this.#cab.abort('dispose')
530
317
  } catch {
531
- this.cab.abort()
318
+ this.#cab.abort()
532
319
  }
533
- this.cab = null
534
320
  }
535
321
 
536
- this.emit('disconnected', effectiveReason)
537
-
538
- for (let i = this.plugins.length - 1; i >= 0; i--) {
539
- await this.plugins[i].onDisconnect?.(effectiveReason)
322
+ if (
323
+ this.transport.type === ConnectionType.Bidirectional &&
324
+ (this.#state === 'connecting' || this.#state === 'connected')
325
+ ) {
326
+ void this.transport.disconnect().catch(noopFn)
540
327
  }
541
328
 
542
- void this.clientStreams.clear(effectiveReason)
543
- void this.serverStreams.clear(effectiveReason)
544
- void this.rpcStreams.clear(effectiveReason)
329
+ for (let i = this.#plugins.length - 1; i >= 0; i--) {
330
+ this.#plugins[i].dispose?.()
331
+ }
545
332
  }
546
333
 
547
- protected nextPingNonce() {
548
- if (this.pingNonce >= MAX_UINT32) this.pingNonce = 0
549
- return this.pingNonce++
334
+ send(buffer: ArrayBufferView, signal?: AbortSignal) {
335
+ if (this.transport.type !== ConnectionType.Bidirectional) {
336
+ throw new Error('Invalid transport type for send')
337
+ }
338
+
339
+ return this.transport.send(buffer, { signal })
550
340
  }
551
341
 
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'))
342
+ transportCall(
343
+ context: TransportCallContext,
344
+ rpc: TransportRpcParams,
345
+ options: TransportCallOptions,
346
+ ): Promise<TransportCallResponse> {
347
+ if (this.transport.type !== ConnectionType.Unidirectional) {
348
+ throw new Error('Invalid transport type for call')
558
349
  }
559
350
 
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
- )
351
+ return this.transport.call(context, rpc, options)
352
+ }
569
353
 
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
- })
354
+ emitClientEvent(event: ClientPluginEvent) {
355
+ for (const plugin of this.#plugins) {
356
+ try {
357
+ const result = plugin.onClientEvent?.(event)
358
+ Promise.resolve(result).catch(noopFn)
359
+ } catch {}
360
+ }
577
361
  }
578
362
 
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()
363
+ emitStreamEvent(event: StreamEvent) {
364
+ this.emitClientEvent({
365
+ kind: 'stream_event',
366
+ timestamp: Date.now(),
367
+ ...event,
368
+ })
584
369
  }
585
370
 
586
- protected async onMessage(buffer: ArrayBufferView) {
371
+ async #onMessage(buffer: ArrayBufferView) {
587
372
  if (!this.messageContext) return
588
373
 
589
374
  const message = this.protocol.decodeMessage(this.messageContext, buffer)
590
- for (const plugin of this.plugins) {
375
+
376
+ for (const plugin of this.#plugins) {
591
377
  plugin.onServerMessage?.(message, buffer)
592
378
  }
379
+
593
380
  this.emitClientEvent({
594
381
  kind: 'server_message',
595
382
  timestamp: Date.now(),
@@ -598,450 +385,170 @@ export abstract class BaseClient<
598
385
  body: message,
599
386
  })
600
387
 
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
- }
388
+ this.emit('message', message, buffer)
756
389
  }
757
390
 
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
- }
391
+ async #handleConnected() {
392
+ this.#reconnectTimeout =
393
+ this.#reconnectConfig?.initialTimeout ?? DEFAULT_RECONNECT_TIMEOUT
394
+ this.#reconnectImmediate = false
395
+
396
+ this.#setState('connected')
397
+ this.#lastDisconnectReason = 'server'
398
+
399
+ this.emitClientEvent({
400
+ kind: 'connected',
401
+ timestamp: Date.now(),
402
+ transportType:
403
+ this.transport.type === ConnectionType.Bidirectional
404
+ ? 'bidirectional'
405
+ : 'unidirectional',
406
+ })
407
+
408
+ for (const plugin of this.#plugins) {
409
+ await plugin.onConnect?.()
800
410
  }
411
+
412
+ this.emit('connected')
801
413
  }
802
414
 
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)
415
+ async #handleDisconnected(reason: ClientDisconnectReason) {
416
+ const effectiveReason =
417
+ reason === 'client' && this.#clientDisconnectAsReconnect
418
+ ? (this.#clientDisconnectOverrideReason ?? 'server')
419
+ : reason
420
+
421
+ this.#clientDisconnectAsReconnect = false
422
+ this.#clientDisconnectOverrideReason = null
423
+
424
+ const shouldSkip =
425
+ this.#state === 'disconnected' &&
426
+ this.messageContext === null &&
427
+ this.#lastDisconnectReason === effectiveReason
428
+
429
+ this.messageContext = null
430
+
431
+ if (this.#cab) {
432
+ if (!this.#cab.signal.aborted) {
433
+ try {
434
+ this.#cab.abort(reason)
435
+ } catch {
436
+ this.#cab.abort()
880
437
  }
881
438
  }
439
+ this.#cab = null
882
440
  }
883
- }
884
441
 
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
- }
442
+ if (shouldSkip) return
443
+
444
+ this.#lastDisconnectReason = effectiveReason
445
+ this.#setState('disconnected')
446
+
447
+ this.emitClientEvent({
448
+ kind: 'disconnected',
449
+ timestamp: Date.now(),
450
+ reason: effectiveReason,
451
+ })
452
+
453
+ this.emit('disconnected', effectiveReason)
454
+
455
+ for (let i = this.#plugins.length - 1; i >= 0; i--) {
456
+ await this.#plugins[i].onDisconnect?.(effectiveReason)
1003
457
  }
1004
- }
1005
458
 
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 })
459
+ if (this.#shouldReconnect(effectiveReason)) {
460
+ this.#ensureReconnectLoop()
461
+ }
1010
462
  }
1011
463
 
1012
- protected emitStreamEvent(
1013
- event: Omit<
1014
- Extract<ClientPluginEvent, { kind: 'stream_event' }>,
1015
- 'kind' | 'timestamp'
1016
- >,
1017
- ) {
464
+ #setState(next: ConnectionState) {
465
+ if (next === this.#state) return
466
+
467
+ const previous = this.#state
468
+ this.#state = next
469
+
1018
470
  this.emitClientEvent({
1019
- kind: 'stream_event',
471
+ kind: 'state_changed',
1020
472
  timestamp: Date.now(),
1021
- ...event,
473
+ state: next,
474
+ previous,
1022
475
  })
476
+
477
+ this.emit('state_changed', next, previous)
1023
478
  }
1024
479
 
1025
- protected getStreamId() {
1026
- if (this.streamId >= MAX_UINT32) {
1027
- this.streamId = 0
1028
- }
1029
- return this.streamId++
480
+ #shouldReconnect(reason: ClientDisconnectReason) {
481
+ return (
482
+ !this.#disposed &&
483
+ !!this.#reconnectConfig &&
484
+ this.transport.type === ConnectionType.Bidirectional &&
485
+ reason !== 'client'
486
+ )
1030
487
  }
1031
488
 
1032
- protected getCallId() {
1033
- if (this.callId >= MAX_UINT32) {
1034
- this.callId = 0
1035
- }
1036
- return this.callId++
489
+ #cancelReconnectLoop() {
490
+ this.#reconnectImmediate = false
491
+ this.#reconnectController?.abort()
492
+ this.#reconnectController = null
493
+ this.#reconnectPromise = null
1037
494
  }
1038
495
 
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
- }
496
+ #ensureReconnectLoop() {
497
+ if (this.#reconnectPromise || !this.#reconnectConfig) return
498
+
499
+ const signal = new AbortController()
500
+ this.#reconnectController = signal
501
+
502
+ this.#reconnectPromise = (async () => {
503
+ while (
504
+ !signal.signal.aborted &&
505
+ !this.#disposed &&
506
+ this.#reconnectConfig &&
507
+ (this.#state === 'disconnected' || this.#state === 'idle') &&
508
+ this.#lastDisconnectReason !== 'client'
509
+ ) {
510
+ if (this.#reconnectPauseReasons.size) {
511
+ await sleep(1000, signal.signal)
512
+ continue
513
+ }
514
+
515
+ const delay = this.#reconnectImmediate
516
+ ? 0
517
+ : computeReconnectDelay(this.#reconnectTimeout)
518
+ this.#reconnectImmediate = false
519
+
520
+ if (delay > 0) {
521
+ await sleep(delay, signal.signal)
522
+ }
523
+
524
+ const currentState = this.state
525
+
526
+ if (
527
+ signal.signal.aborted ||
528
+ this.#disposed ||
529
+ !this.#reconnectConfig ||
530
+ currentState === 'connected' ||
531
+ currentState === 'connecting'
532
+ ) {
533
+ break
534
+ }
535
+
536
+ const previousTimeout = this.#reconnectTimeout
537
+
538
+ await this.connect().catch(noopFn)
539
+
540
+ if (this.state !== 'connected' && this.#reconnectConfig) {
541
+ this.#reconnectTimeout = Math.min(
542
+ previousTimeout * 2,
543
+ this.#reconnectConfig.maxTimeout ?? DEFAULT_MAX_RECONNECT_TIMEOUT,
544
+ )
545
+ }
546
+ }
547
+ })().finally(() => {
548
+ if (this.#reconnectController === signal) {
549
+ this.#reconnectController = null
550
+ }
551
+ this.#reconnectPromise = null
552
+ })
1046
553
  }
1047
554
  }