@nmtjs/client 0.15.0-beta.43 → 0.15.0-beta.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.ts CHANGED
@@ -7,7 +7,13 @@ import type {
7
7
  ProtocolVersionInterface,
8
8
  ServerMessageTypePayload,
9
9
  } from '@nmtjs/protocol/client'
10
- import { anyAbortSignal, createFuture, MAX_UINT32, noopFn } from '@nmtjs/common'
10
+ import {
11
+ anyAbortSignal,
12
+ createFuture,
13
+ MAX_UINT32,
14
+ noopFn,
15
+ withTimeout,
16
+ } from '@nmtjs/common'
11
17
  import {
12
18
  ClientMessageType,
13
19
  ConnectionType,
@@ -23,6 +29,12 @@ import {
23
29
  versions,
24
30
  } from '@nmtjs/protocol/client'
25
31
 
32
+ import type {
33
+ ClientDisconnectReason,
34
+ ClientPlugin,
35
+ ClientPluginEvent,
36
+ ClientPluginInstance,
37
+ } from './plugins/types.ts'
26
38
  import type { BaseClientTransformer } from './transformers.ts'
27
39
  import type { ClientCallResponse, ClientTransportFactory } from './transport.ts'
28
40
  import type {
@@ -48,8 +60,7 @@ export type ProtocolClientCall = Future<any> & {
48
60
  signal?: AbortSignal
49
61
  }
50
62
 
51
- const DEFAULT_RECONNECT_TIMEOUT = 1000
52
- const DEFAULT_MAX_RECONNECT_TIMEOUT = 60000
63
+ const DEFAULT_RECONNECT_REASON = 'connect_error'
53
64
 
54
65
  export interface BaseClientOptions<
55
66
  RouterContract extends TAnyRouterContract = TAnyRouterContract,
@@ -60,7 +71,7 @@ export interface BaseClientOptions<
60
71
  format: BaseClientFormat
61
72
  application?: string
62
73
  timeout?: number
63
- autoreconnect?: boolean
74
+ plugins?: ClientPlugin[]
64
75
  safe?: SafeCall
65
76
  }
66
77
 
@@ -79,7 +90,8 @@ export abstract class BaseClient<
79
90
  OutputTypeProvider extends TypeProvider = TypeProvider,
80
91
  > extends EventEmitter<{
81
92
  connected: []
82
- disconnected: [reason: 'server' | 'client' | (string & {})]
93
+ disconnected: [reason: ClientDisconnectReason]
94
+ pong: [nonce: number]
83
95
  }> {
84
96
  _!: {
85
97
  routes: ResolveAPIRouterRoutes<
@@ -111,11 +123,20 @@ export abstract class BaseClient<
111
123
  protected callId = 0
112
124
  protected streamId = 0
113
125
  protected cab: AbortController | null = null
114
- protected reconnectTimeout = DEFAULT_RECONNECT_TIMEOUT
115
126
  protected connecting: Promise<void> | null = null
116
- protected state: 'connected' | 'disconnected' = 'disconnected'
117
127
 
118
- #auth: any
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
119
140
 
120
141
  constructor(
121
142
  readonly options: BaseClientOptions<RouterContract, SafeCall>,
@@ -138,48 +159,54 @@ export abstract class BaseClient<
138
159
  this.transportOptions,
139
160
  ) as any
140
161
 
141
- if (
142
- this.transport.type === ConnectionType.Bidirectional &&
143
- this.options.autoreconnect
144
- ) {
145
- this.on('disconnected', async (reason) => {
146
- while (this.state === 'disconnected') {
147
- const timeout = new Promise((resolve) =>
148
- setTimeout(resolve, this.reconnectTimeout),
149
- )
150
- this.reconnectTimeout = Math.min(
151
- this.reconnectTimeout * 2,
152
- DEFAULT_MAX_RECONNECT_TIMEOUT,
153
- )
154
- await timeout
155
- await this.connect().catch(noopFn)
156
- }
157
- })
158
-
159
- this.on('connected', () => {
160
- this.reconnectTimeout = DEFAULT_RECONNECT_TIMEOUT
161
- })
162
+ this.plugins = this.options.plugins?.map((plugin) => plugin(this)) ?? []
163
+ for (const plugin of this.plugins) {
164
+ plugin.onInit?.()
165
+ }
166
+ }
162
167
 
163
- if (globalThis.window) {
164
- globalThis.window.addEventListener('pageshow', () => {
165
- if (this.state === 'disconnected') this.connect()
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?.()
168
173
  }
169
174
  }
170
175
 
176
+ get state() {
177
+ return this._state
178
+ }
179
+
180
+ get lastDisconnectReason() {
181
+ return this._lastDisconnectReason
182
+ }
183
+
184
+ get transportType() {
185
+ return this.transport.type
186
+ }
187
+
188
+ isDisposed() {
189
+ return this._disposed
190
+ }
191
+
192
+ requestReconnect(reason?: string) {
193
+ return this.disconnect({ reconnect: true, reason })
194
+ }
195
+
171
196
  get auth() {
172
- return this.#auth
197
+ return this.authValue
173
198
  }
174
199
 
175
200
  set auth(value) {
176
- this.#auth = value
201
+ this.authValue = value
177
202
  }
178
203
 
179
204
  connect() {
180
- if (this.state === 'connected') return Promise.resolve()
205
+ if (this._state === 'connected') return Promise.resolve()
181
206
  if (this.connecting) return this.connecting
182
207
 
208
+ if (this._disposed) return Promise.reject(new Error('Client is disposed'))
209
+
183
210
  const _connect = async () => {
184
211
  if (this.transport.type === ConnectionType.Bidirectional) {
185
212
  this.cab = new AbortController()
@@ -187,7 +214,7 @@ export abstract class BaseClient<
187
214
  const serverStreams = this.serverStreams
188
215
  const transport = {
189
216
  send: (buffer) => {
190
- this.#send(buffer).catch(noopFn)
217
+ this.send(buffer).catch(noopFn)
191
218
  },
192
219
  }
193
220
  this.messageContext = {
@@ -195,7 +222,7 @@ export abstract class BaseClient<
195
222
  encoder: this.options.format,
196
223
  decoder: this.options.format,
197
224
  addClientStream: (blob) => {
198
- const streamId = this.#getStreamId()
225
+ const streamId = this.getStreamId()
199
226
  return this.clientStreams.add(blob.source, streamId, blob.metadata)
200
227
  },
201
228
  addServerStream(streamId, metadata) {
@@ -234,7 +261,7 @@ export abstract class BaseClient<
234
261
  return stream
235
262
  }
236
263
  },
237
- streamId: this.#getStreamId.bind(this),
264
+ streamId: this.getStreamId.bind(this),
238
265
  }
239
266
  return this.transport.connect({
240
267
  auth: this.auth,
@@ -246,19 +273,46 @@ export abstract class BaseClient<
246
273
  }
247
274
  }
248
275
 
276
+ let emitDisconnectOnFailure: 'server' | 'client' | (string & {}) | null =
277
+ null
278
+
249
279
  this.connecting = _connect()
250
280
  .then(() => {
251
- this.state = 'connected'
281
+ this._state = 'connected'
282
+ })
283
+ .catch((error) => {
284
+ if (this.transport.type === ConnectionType.Bidirectional) {
285
+ emitDisconnectOnFailure = DEFAULT_RECONNECT_REASON
286
+ }
287
+ throw error
252
288
  })
253
289
  .finally(() => {
254
290
  this.connecting = null
291
+
292
+ if (emitDisconnectOnFailure && !this._disposed) {
293
+ this._state = 'disconnected'
294
+ this._lastDisconnectReason = emitDisconnectOnFailure
295
+ void this.onDisconnect(emitDisconnectOnFailure)
296
+ }
255
297
  })
256
298
 
257
299
  return this.connecting
258
300
  }
259
301
 
260
- async disconnect() {
302
+ async disconnect(options: { reconnect?: boolean; reason?: string } = {}) {
261
303
  if (this.transport.type === ConnectionType.Bidirectional) {
304
+ // Ensure connect() won't short-circuit while the transport is closing.
305
+ this._state = 'disconnected'
306
+ this._lastDisconnectReason = 'client'
307
+
308
+ if (options.reconnect) {
309
+ this.clientDisconnectAsReconnect = true
310
+ this.clientDisconnectOverrideReason = options.reason ?? 'server'
311
+ } else {
312
+ this.clientDisconnectAsReconnect = false
313
+ this.clientDisconnectOverrideReason = null
314
+ }
315
+
262
316
  this.cab!.abort()
263
317
  await this.transport.disconnect()
264
318
  this.messageContext = null
@@ -290,12 +344,19 @@ export abstract class BaseClient<
290
344
 
291
345
  const signal = signals.length ? anyAbortSignal(...signals) : undefined
292
346
 
293
- const callId = this.#getCallId()
347
+ const callId = this.getCallId()
294
348
  const call = createFuture() as ProtocolClientCall
295
349
  call.procedure = procedure
296
350
  call.signal = signal
297
351
 
298
352
  this.calls.set(callId, call)
353
+ this.emitClientEvent({
354
+ kind: 'rpc_request',
355
+ timestamp: Date.now(),
356
+ callId,
357
+ procedure,
358
+ body: payload,
359
+ })
299
360
 
300
361
  // Check if signal is already aborted before proceeding
301
362
  if (signal?.aborted) {
@@ -322,7 +383,7 @@ export abstract class BaseClient<
322
383
  ClientMessageType.RpcAbort,
323
384
  { callId },
324
385
  )
325
- this.#send(buffer).catch(noopFn)
386
+ this.send(buffer).catch(noopFn)
326
387
  }
327
388
  },
328
389
  { once: true },
@@ -337,7 +398,7 @@ export abstract class BaseClient<
337
398
  ClientMessageType.Rpc,
338
399
  { callId, procedure, payload: transformedPayload },
339
400
  )
340
- await this.#send(buffer, signal)
401
+ await this.send(buffer, signal)
341
402
  } else {
342
403
  const response = await this.transport.call(
343
404
  {
@@ -348,9 +409,16 @@ export abstract class BaseClient<
348
409
  { callId, procedure, payload: transformedPayload },
349
410
  { signal, _stream_response: options._stream_response },
350
411
  )
351
- this.#handleCallResponse(callId, response)
412
+ this.handleCallResponse(callId, response)
352
413
  }
353
414
  } catch (error) {
415
+ this.emitClientEvent({
416
+ kind: 'rpc_error',
417
+ timestamp: Date.now(),
418
+ callId,
419
+ procedure,
420
+ error,
421
+ })
354
422
  call.reject(error)
355
423
  }
356
424
  }
@@ -362,6 +430,11 @@ export abstract class BaseClient<
362
430
  controller.abort()
363
431
  })
364
432
  }
433
+
434
+ if (options._stream_response && typeof value === 'function') {
435
+ return value
436
+ }
437
+
365
438
  controller.abort()
366
439
  return value
367
440
  },
@@ -386,30 +459,158 @@ export abstract class BaseClient<
386
459
  }
387
460
 
388
461
  protected async onConnect() {
389
- this.state = 'connected'
462
+ this._state = 'connected'
463
+ this._lastDisconnectReason = 'server'
464
+ this.emitClientEvent({
465
+ kind: 'connected',
466
+ timestamp: Date.now(),
467
+ transportType:
468
+ this.transport.type === ConnectionType.Bidirectional
469
+ ? 'bidirectional'
470
+ : 'unidirectional',
471
+ })
472
+ for (const plugin of this.plugins) {
473
+ await plugin.onConnect?.()
474
+ }
390
475
  this.emit('connected')
391
476
  }
392
477
 
393
- protected async onDisconnect(reason: 'client' | 'server' | (string & {})) {
394
- this.state = 'disconnected'
395
- this.emit('disconnected', reason)
396
- this.clientStreams.clear(reason)
397
- this.serverStreams.clear(reason)
398
- this.rpcStreams.clear(reason)
478
+ protected async onDisconnect(reason: ClientDisconnectReason) {
479
+ const effectiveReason =
480
+ reason === 'client' && this.clientDisconnectAsReconnect
481
+ ? (this.clientDisconnectOverrideReason ?? 'server')
482
+ : reason
483
+
484
+ this.clientDisconnectAsReconnect = false
485
+ this.clientDisconnectOverrideReason = null
486
+
487
+ this._state = 'disconnected'
488
+ this._lastDisconnectReason = effectiveReason
489
+ this.emitClientEvent({
490
+ kind: 'disconnected',
491
+ timestamp: Date.now(),
492
+ reason: effectiveReason,
493
+ })
494
+
495
+ // Connection is gone, never keep old message context around.
496
+ this.messageContext = null
497
+
498
+ this.stopAllPendingPings(effectiveReason)
499
+
500
+ // Fail-fast: do not keep pending calls around across disconnects.
501
+ if (this.calls.size) {
502
+ const error = new ProtocolError(
503
+ ErrorCode.ConnectionError,
504
+ 'Disconnected',
505
+ { reason: effectiveReason },
506
+ )
507
+ for (const call of this.calls.values()) {
508
+ call.reject(error)
509
+ }
510
+ this.calls.clear()
511
+ }
512
+
513
+ if (this.cab) {
514
+ try {
515
+ this.cab.abort(reason)
516
+ } catch {
517
+ this.cab.abort()
518
+ }
519
+ this.cab = null
520
+ }
521
+
522
+ this.emit('disconnected', effectiveReason)
523
+
524
+ for (let i = this.plugins.length - 1; i >= 0; i--) {
525
+ await this.plugins[i].onDisconnect?.(effectiveReason)
526
+ }
527
+
528
+ void this.clientStreams.clear(effectiveReason)
529
+ void this.serverStreams.clear(effectiveReason)
530
+ void this.rpcStreams.clear(effectiveReason)
531
+ }
532
+
533
+ protected nextPingNonce() {
534
+ if (this.pingNonce >= MAX_UINT32) this.pingNonce = 0
535
+ return this.pingNonce++
536
+ }
537
+
538
+ ping(timeout: number, signal?: AbortSignal) {
539
+ if (
540
+ !this.messageContext ||
541
+ this.transport.type !== ConnectionType.Bidirectional
542
+ ) {
543
+ return Promise.reject(new Error('Client is not connected'))
544
+ }
545
+
546
+ const nonce = this.nextPingNonce()
547
+ const future = createFuture<void>()
548
+ this.pendingPings.set(nonce, future)
549
+
550
+ const buffer = this.protocol.encodeMessage(
551
+ this.messageContext,
552
+ ClientMessageType.Ping,
553
+ { nonce },
554
+ )
555
+
556
+ return this.send(buffer, signal)
557
+ .then(() =>
558
+ withTimeout(future.promise, timeout, new Error('Heartbeat timeout')),
559
+ )
560
+ .finally(() => {
561
+ this.pendingPings.delete(nonce)
562
+ })
563
+ }
564
+
565
+ protected stopAllPendingPings(reason?: any) {
566
+ if (!this.pendingPings.size) return
567
+ const error = new Error('Heartbeat stopped', { cause: reason })
568
+ for (const pending of this.pendingPings.values()) pending.reject(error)
569
+ this.pendingPings.clear()
399
570
  }
400
571
 
401
572
  protected async onMessage(buffer: ArrayBufferView) {
402
573
  if (!this.messageContext) return
403
574
 
404
575
  const message = this.protocol.decodeMessage(this.messageContext, buffer)
576
+ for (const plugin of this.plugins) {
577
+ plugin.onServerMessage?.(message, buffer)
578
+ }
579
+ this.emitClientEvent({
580
+ kind: 'server_message',
581
+ timestamp: Date.now(),
582
+ messageType: message.type,
583
+ rawByteLength: buffer.byteLength,
584
+ body: message,
585
+ })
405
586
 
406
587
  switch (message.type) {
407
588
  case ServerMessageType.RpcResponse:
408
- this.#handleRPCResponseMessage(message)
589
+ this.handleRPCResponseMessage(message)
409
590
  break
410
591
  case ServerMessageType.RpcStreamResponse:
411
- this.#handleRPCStreamResponseMessage(message)
592
+ this.handleRPCStreamResponseMessage(message)
412
593
  break
594
+ case ServerMessageType.Pong: {
595
+ const pending = this.pendingPings.get(message.nonce)
596
+ if (pending) {
597
+ this.pendingPings.delete(message.nonce)
598
+ pending.resolve()
599
+ }
600
+ this.emit('pong', message.nonce)
601
+ break
602
+ }
603
+ case ServerMessageType.Ping: {
604
+ if (this.messageContext) {
605
+ const buffer = this.protocol.encodeMessage(
606
+ this.messageContext,
607
+ ClientMessageType.Pong,
608
+ { nonce: message.nonce },
609
+ )
610
+ this.send(buffer).catch(noopFn)
611
+ }
612
+ break
613
+ }
413
614
  case ServerMessageType.RpcStreamChunk:
414
615
  this.rpcStreams.push(message.callId, message.chunk)
415
616
  break
@@ -439,14 +640,14 @@ export abstract class BaseClient<
439
640
  ClientMessageType.ClientStreamPush,
440
641
  { streamId: message.streamId, chunk },
441
642
  )
442
- this.#send(buffer).catch(noopFn)
643
+ this.send(buffer).catch(noopFn)
443
644
  } else {
444
645
  const buffer = this.protocol.encodeMessage(
445
646
  this.messageContext!,
446
647
  ClientMessageType.ClientStreamEnd,
447
648
  { streamId: message.streamId },
448
649
  )
449
- this.#send(buffer).catch(noopFn)
650
+ this.send(buffer).catch(noopFn)
450
651
  this.clientStreams.end(message.streamId)
451
652
  }
452
653
  },
@@ -456,7 +657,7 @@ export abstract class BaseClient<
456
657
  ClientMessageType.ClientStreamAbort,
457
658
  { streamId: message.streamId },
458
659
  )
459
- this.#send(buffer).catch(noopFn)
660
+ this.send(buffer).catch(noopFn)
460
661
  this.clientStreams.remove(message.streamId)
461
662
  },
462
663
  )
@@ -467,19 +668,40 @@ export abstract class BaseClient<
467
668
  }
468
669
  }
469
670
 
470
- #handleRPCResponseMessage(
671
+ private handleRPCResponseMessage(
471
672
  message: ServerMessageTypePayload[ServerMessageType.RpcResponse],
472
673
  ) {
473
674
  const { callId, result, error } = message
474
675
  const call = this.calls.get(callId)
475
676
  if (!call) return
476
677
  if (error) {
678
+ this.emitClientEvent({
679
+ kind: 'rpc_error',
680
+ timestamp: Date.now(),
681
+ callId,
682
+ procedure: call.procedure,
683
+ error,
684
+ })
477
685
  call.reject(new ProtocolError(error.code, error.message, error.data))
478
686
  } else {
479
687
  try {
480
688
  const transformed = this.transformer.decode(call.procedure, result)
689
+ this.emitClientEvent({
690
+ kind: 'rpc_response',
691
+ timestamp: Date.now(),
692
+ callId,
693
+ procedure: call.procedure,
694
+ body: transformed,
695
+ })
481
696
  call.resolve(transformed)
482
697
  } catch (error) {
698
+ this.emitClientEvent({
699
+ kind: 'rpc_error',
700
+ timestamp: Date.now(),
701
+ callId,
702
+ procedure: call.procedure,
703
+ error,
704
+ })
483
705
  call.reject(
484
706
  new ProtocolError(
485
707
  ErrorCode.ClientRequestError,
@@ -491,12 +713,19 @@ export abstract class BaseClient<
491
713
  }
492
714
  }
493
715
 
494
- #handleRPCStreamResponseMessage(
716
+ private handleRPCStreamResponseMessage(
495
717
  message: ServerMessageTypePayload[ServerMessageType.RpcStreamResponse],
496
718
  ) {
497
719
  const call = this.calls.get(message.callId)
498
720
  if (message.error) {
499
721
  if (!call) return
722
+ this.emitClientEvent({
723
+ kind: 'rpc_error',
724
+ timestamp: Date.now(),
725
+ callId: message.callId,
726
+ procedure: call.procedure,
727
+ error: message.error,
728
+ })
500
729
  call.reject(
501
730
  new ProtocolError(
502
731
  message.error.code,
@@ -507,6 +736,13 @@ export abstract class BaseClient<
507
736
  } else {
508
737
  if (call) {
509
738
  const { procedure, signal } = call
739
+ this.emitClientEvent({
740
+ kind: 'rpc_response',
741
+ timestamp: Date.now(),
742
+ callId: message.callId,
743
+ procedure,
744
+ stream: true,
745
+ })
510
746
  const stream = new ProtocolServerRPCStream({
511
747
  start: (controller) => {
512
748
  if (signal) {
@@ -525,7 +761,7 @@ export abstract class BaseClient<
525
761
  ClientMessageType.RpcAbort,
526
762
  { callId: message.callId, reason: signal.reason },
527
763
  )
528
- this.#send(buffer).catch(noopFn)
764
+ this.send(buffer).catch(noopFn)
529
765
  }
530
766
  }
531
767
  },
@@ -545,7 +781,7 @@ export abstract class BaseClient<
545
781
  ClientMessageType.RpcPull,
546
782
  { callId: message.callId },
547
783
  )
548
- this.#send(buffer).catch(noopFn)
784
+ this.send(buffer).catch(noopFn)
549
785
  },
550
786
  readableStrategy: { highWaterMark: 0 },
551
787
  })
@@ -561,17 +797,24 @@ export abstract class BaseClient<
561
797
  ClientMessageType.RpcAbort,
562
798
  { callId: message.callId },
563
799
  )
564
- this.#send(buffer).catch(noopFn)
800
+ this.send(buffer).catch(noopFn)
565
801
  }
566
802
  }
567
803
  }
568
804
  }
569
805
 
570
- #handleCallResponse(callId: number, response: ClientCallResponse) {
806
+ private handleCallResponse(callId: number, response: ClientCallResponse) {
571
807
  const call = this.calls.get(callId)
572
808
 
573
809
  if (response.type === 'rpc_stream') {
574
810
  if (call) {
811
+ this.emitClientEvent({
812
+ kind: 'rpc_response',
813
+ timestamp: Date.now(),
814
+ callId,
815
+ procedure: call.procedure,
816
+ stream: true,
817
+ })
575
818
  const stream = new ProtocolServerStream({
576
819
  transform: (chunk) => {
577
820
  return this.transformer.decode(
@@ -582,7 +825,36 @@ export abstract class BaseClient<
582
825
  })
583
826
  this.rpcStreams.add(callId, stream)
584
827
  call.resolve(({ signal }: { signal?: AbortSignal }) => {
585
- response.stream.pipeTo(stream.writable, { signal }).catch(noopFn)
828
+ const reader = response.stream.getReader()
829
+
830
+ let onAbort: (() => void) | undefined
831
+ if (signal) {
832
+ onAbort = () => {
833
+ reader.cancel(signal.reason).catch(noopFn)
834
+ this.rpcStreams.abort(callId).catch(noopFn)
835
+ }
836
+ if (signal.aborted) onAbort()
837
+ else signal.addEventListener('abort', onAbort, { once: true })
838
+ }
839
+
840
+ void (async () => {
841
+ try {
842
+ while (true) {
843
+ const { done, value } = await reader.read()
844
+ if (done) break
845
+ await this.rpcStreams.push(callId, value)
846
+ }
847
+ await this.rpcStreams.end(callId)
848
+ } catch {
849
+ await this.rpcStreams.abort(callId).catch(noopFn)
850
+ } finally {
851
+ reader.releaseLock()
852
+ if (signal && onAbort) {
853
+ signal.removeEventListener('abort', onAbort)
854
+ }
855
+ }
856
+ })()
857
+
586
858
  return stream
587
859
  })
588
860
  } else {
@@ -593,9 +865,16 @@ export abstract class BaseClient<
593
865
  }
594
866
  } else if (response.type === 'blob') {
595
867
  if (call) {
868
+ this.emitClientEvent({
869
+ kind: 'rpc_response',
870
+ timestamp: Date.now(),
871
+ callId,
872
+ procedure: call.procedure,
873
+ stream: true,
874
+ })
596
875
  const { metadata, source } = response
597
876
  const stream = new ProtocolServerBlobStream(metadata)
598
- this.serverStreams.add(this.#getStreamId(), stream)
877
+ this.serverStreams.add(this.getStreamId(), stream)
599
878
  call.resolve(({ signal }: { signal?: AbortSignal }) => {
600
879
  source.pipeTo(stream.writable, { signal }).catch(noopFn)
601
880
  return stream
@@ -613,8 +892,22 @@ export abstract class BaseClient<
613
892
  call.procedure,
614
893
  response.result,
615
894
  )
895
+ this.emitClientEvent({
896
+ kind: 'rpc_response',
897
+ timestamp: Date.now(),
898
+ callId,
899
+ procedure: call.procedure,
900
+ body: transformed,
901
+ })
616
902
  call.resolve(transformed)
617
903
  } catch (error) {
904
+ this.emitClientEvent({
905
+ kind: 'rpc_error',
906
+ timestamp: Date.now(),
907
+ callId,
908
+ procedure: call.procedure,
909
+ error,
910
+ })
618
911
  call.reject(
619
912
  new ProtocolError(
620
913
  ErrorCode.ClientRequestError,
@@ -626,23 +919,32 @@ export abstract class BaseClient<
626
919
  }
627
920
  }
628
921
 
629
- #send(buffer: ArrayBufferView, signal?: AbortSignal) {
922
+ protected send(buffer: ArrayBufferView, signal?: AbortSignal) {
630
923
  if (this.transport.type === ConnectionType.Unidirectional)
631
924
  throw new Error('Invalid transport type for send')
632
925
  return this.transport.send(buffer, { signal })
633
926
  }
634
927
 
635
- #getStreamId() {
928
+ protected getStreamId() {
636
929
  if (this.streamId >= MAX_UINT32) {
637
930
  this.streamId = 0
638
931
  }
639
932
  return this.streamId++
640
933
  }
641
934
 
642
- #getCallId() {
935
+ protected getCallId() {
643
936
  if (this.callId >= MAX_UINT32) {
644
937
  this.callId = 0
645
938
  }
646
939
  return this.callId++
647
940
  }
941
+
942
+ protected emitClientEvent(event: ClientPluginEvent) {
943
+ for (const plugin of this.plugins) {
944
+ try {
945
+ const result = plugin.onClientEvent?.(event)
946
+ Promise.resolve(result).catch(noopFn)
947
+ } catch {}
948
+ }
949
+ }
648
950
  }