@nmtjs/client 0.15.0-beta.9 → 0.15.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 (50) hide show
  1. package/dist/clients/runtime.d.ts +2 -2
  2. package/dist/clients/runtime.js +1 -1
  3. package/dist/clients/runtime.js.map +1 -1
  4. package/dist/clients/static.d.ts +2 -2
  5. package/dist/clients/static.js +6 -3
  6. package/dist/clients/static.js.map +1 -1
  7. package/dist/core.d.ts +38 -8
  8. package/dist/core.js +414 -66
  9. package/dist/core.js.map +1 -1
  10. package/dist/events.d.ts +1 -1
  11. package/dist/events.js.map +1 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +6 -5
  14. package/dist/index.js.map +1 -1
  15. package/dist/plugins/browser.d.ts +2 -0
  16. package/dist/plugins/browser.js +41 -0
  17. package/dist/plugins/browser.js.map +1 -0
  18. package/dist/plugins/heartbeat.d.ts +6 -0
  19. package/dist/plugins/heartbeat.js +86 -0
  20. package/dist/plugins/heartbeat.js.map +1 -0
  21. package/dist/plugins/index.d.ts +5 -0
  22. package/dist/plugins/index.js +6 -0
  23. package/dist/plugins/index.js.map +1 -0
  24. package/dist/plugins/logging.d.ts +9 -0
  25. package/dist/plugins/logging.js +30 -0
  26. package/dist/plugins/logging.js.map +1 -0
  27. package/dist/plugins/reconnect.d.ts +6 -0
  28. package/dist/plugins/reconnect.js +98 -0
  29. package/dist/plugins/reconnect.js.map +1 -0
  30. package/dist/plugins/types.d.ts +63 -0
  31. package/dist/plugins/types.js +2 -0
  32. package/dist/plugins/types.js.map +1 -0
  33. package/dist/streams.d.ts +3 -3
  34. package/dist/streams.js.map +1 -1
  35. package/dist/transformers.js.map +1 -1
  36. package/dist/types.d.ts +1 -4
  37. package/dist/types.js.map +1 -1
  38. package/package.json +27 -17
  39. package/src/clients/runtime.ts +4 -4
  40. package/src/clients/static.ts +9 -13
  41. package/src/core.ts +476 -77
  42. package/src/index.ts +1 -0
  43. package/src/plugins/browser.ts +61 -0
  44. package/src/plugins/heartbeat.ts +111 -0
  45. package/src/plugins/index.ts +5 -0
  46. package/src/plugins/logging.ts +42 -0
  47. package/src/plugins/reconnect.ts +130 -0
  48. package/src/plugins/types.ts +72 -0
  49. package/src/streams.ts +3 -3
  50. package/src/types.ts +1 -17
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,56 +159,63 @@ 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) {
212
+ const client = this
185
213
  this.cab = new AbortController()
186
214
  const protocol = this.protocol
187
215
  const serverStreams = this.serverStreams
188
216
  const transport = {
189
217
  send: (buffer) => {
190
- this.#send(buffer).catch(noopFn)
218
+ this.send(buffer).catch(noopFn)
191
219
  },
192
220
  }
193
221
  this.messageContext = {
@@ -195,12 +223,19 @@ export abstract class BaseClient<
195
223
  encoder: this.options.format,
196
224
  decoder: this.options.format,
197
225
  addClientStream: (blob) => {
198
- const streamId = this.#getStreamId()
226
+ const streamId = this.getStreamId()
199
227
  return this.clientStreams.add(blob.source, streamId, blob.metadata)
200
228
  },
201
229
  addServerStream(streamId, metadata) {
202
230
  const stream = new ProtocolServerBlobStream(metadata, {
203
231
  pull: (controller) => {
232
+ client.emitStreamEvent({
233
+ direction: 'outgoing',
234
+ streamType: 'server_blob',
235
+ action: 'pull',
236
+ streamId,
237
+ byteLength: 65535,
238
+ })
204
239
  transport.send(
205
240
  protocol.encodeMessage(
206
241
  this,
@@ -220,6 +255,12 @@ export abstract class BaseClient<
220
255
  signal.addEventListener(
221
256
  'abort',
222
257
  () => {
258
+ client.emitStreamEvent({
259
+ direction: 'outgoing',
260
+ streamType: 'server_blob',
261
+ action: 'abort',
262
+ streamId,
263
+ })
223
264
  transport.send(
224
265
  protocol.encodeMessage(
225
266
  this,
@@ -234,7 +275,7 @@ export abstract class BaseClient<
234
275
  return stream
235
276
  }
236
277
  },
237
- streamId: this.#getStreamId.bind(this),
278
+ streamId: this.getStreamId.bind(this),
238
279
  }
239
280
  return this.transport.connect({
240
281
  auth: this.auth,
@@ -246,19 +287,46 @@ export abstract class BaseClient<
246
287
  }
247
288
  }
248
289
 
290
+ let emitDisconnectOnFailure: 'server' | 'client' | (string & {}) | null =
291
+ null
292
+
249
293
  this.connecting = _connect()
250
294
  .then(() => {
251
- this.state = 'connected'
295
+ this._state = 'connected'
296
+ })
297
+ .catch((error) => {
298
+ if (this.transport.type === ConnectionType.Bidirectional) {
299
+ emitDisconnectOnFailure = DEFAULT_RECONNECT_REASON
300
+ }
301
+ throw error
252
302
  })
253
303
  .finally(() => {
254
304
  this.connecting = null
305
+
306
+ if (emitDisconnectOnFailure && !this._disposed) {
307
+ this._state = 'disconnected'
308
+ this._lastDisconnectReason = emitDisconnectOnFailure
309
+ void this.onDisconnect(emitDisconnectOnFailure)
310
+ }
255
311
  })
256
312
 
257
313
  return this.connecting
258
314
  }
259
315
 
260
- async disconnect() {
316
+ async disconnect(options: { reconnect?: boolean; reason?: string } = {}) {
261
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
+
262
330
  this.cab!.abort()
263
331
  await this.transport.disconnect()
264
332
  this.messageContext = null
@@ -290,12 +358,19 @@ export abstract class BaseClient<
290
358
 
291
359
  const signal = signals.length ? anyAbortSignal(...signals) : undefined
292
360
 
293
- const callId = this.#getCallId()
361
+ const callId = this.getCallId()
294
362
  const call = createFuture() as ProtocolClientCall
295
363
  call.procedure = procedure
296
364
  call.signal = signal
297
365
 
298
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
+ })
299
374
 
300
375
  // Check if signal is already aborted before proceeding
301
376
  if (signal?.aborted) {
@@ -322,7 +397,7 @@ export abstract class BaseClient<
322
397
  ClientMessageType.RpcAbort,
323
398
  { callId },
324
399
  )
325
- this.#send(buffer).catch(noopFn)
400
+ this.send(buffer).catch(noopFn)
326
401
  }
327
402
  },
328
403
  { once: true },
@@ -337,7 +412,7 @@ export abstract class BaseClient<
337
412
  ClientMessageType.Rpc,
338
413
  { callId, procedure, payload: transformedPayload },
339
414
  )
340
- await this.#send(buffer, signal)
415
+ await this.send(buffer, signal)
341
416
  } else {
342
417
  const response = await this.transport.call(
343
418
  {
@@ -348,9 +423,16 @@ export abstract class BaseClient<
348
423
  { callId, procedure, payload: transformedPayload },
349
424
  { signal, _stream_response: options._stream_response },
350
425
  )
351
- this.#handleCallResponse(callId, response)
426
+ this.handleCallResponse(callId, response)
352
427
  }
353
428
  } catch (error) {
429
+ this.emitClientEvent({
430
+ kind: 'rpc_error',
431
+ timestamp: Date.now(),
432
+ callId,
433
+ procedure,
434
+ error,
435
+ })
354
436
  call.reject(error)
355
437
  }
356
438
  }
@@ -362,6 +444,11 @@ export abstract class BaseClient<
362
444
  controller.abort()
363
445
  })
364
446
  }
447
+
448
+ if (options._stream_response && typeof value === 'function') {
449
+ return value
450
+ }
451
+
365
452
  controller.abort()
366
453
  return value
367
454
  },
@@ -386,100 +473,322 @@ export abstract class BaseClient<
386
473
  }
387
474
 
388
475
  protected async onConnect() {
389
- this.state = 'connected'
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?.()
488
+ }
390
489
  this.emit('connected')
391
490
  }
392
491
 
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)
492
+ protected async onDisconnect(reason: ClientDisconnectReason) {
493
+ const effectiveReason =
494
+ reason === 'client' && this.clientDisconnectAsReconnect
495
+ ? (this.clientDisconnectOverrideReason ?? 'server')
496
+ : reason
497
+
498
+ this.clientDisconnectAsReconnect = false
499
+ this.clientDisconnectOverrideReason = null
500
+
501
+ this._state = 'disconnected'
502
+ this._lastDisconnectReason = effectiveReason
503
+ this.emitClientEvent({
504
+ kind: 'disconnected',
505
+ timestamp: Date.now(),
506
+ reason: effectiveReason,
507
+ })
508
+
509
+ // Connection is gone, never keep old message context around.
510
+ this.messageContext = null
511
+
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) {
528
+ try {
529
+ this.cab.abort(reason)
530
+ } catch {
531
+ this.cab.abort()
532
+ }
533
+ this.cab = null
534
+ }
535
+
536
+ this.emit('disconnected', effectiveReason)
537
+
538
+ for (let i = this.plugins.length - 1; i >= 0; i--) {
539
+ await this.plugins[i].onDisconnect?.(effectiveReason)
540
+ }
541
+
542
+ void this.clientStreams.clear(effectiveReason)
543
+ void this.serverStreams.clear(effectiveReason)
544
+ void this.rpcStreams.clear(effectiveReason)
545
+ }
546
+
547
+ protected nextPingNonce() {
548
+ if (this.pingNonce >= MAX_UINT32) this.pingNonce = 0
549
+ return this.pingNonce++
550
+ }
551
+
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'))
558
+ }
559
+
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
+ )
569
+
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
+ })
577
+ }
578
+
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()
399
584
  }
400
585
 
401
586
  protected async onMessage(buffer: ArrayBufferView) {
402
587
  if (!this.messageContext) return
403
588
 
404
589
  const message = this.protocol.decodeMessage(this.messageContext, buffer)
590
+ for (const plugin of this.plugins) {
591
+ plugin.onServerMessage?.(message, buffer)
592
+ }
593
+ this.emitClientEvent({
594
+ kind: 'server_message',
595
+ timestamp: Date.now(),
596
+ messageType: message.type,
597
+ rawByteLength: buffer.byteLength,
598
+ body: message,
599
+ })
405
600
 
406
601
  switch (message.type) {
407
602
  case ServerMessageType.RpcResponse:
408
- this.#handleRPCResponseMessage(message)
603
+ this.handleRPCResponseMessage(message)
409
604
  break
410
605
  case ServerMessageType.RpcStreamResponse:
411
- this.#handleRPCStreamResponseMessage(message)
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)
412
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
+ }
413
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
+ })
414
636
  this.rpcStreams.push(message.callId, message.chunk)
415
637
  break
416
638
  case ServerMessageType.RpcStreamEnd:
639
+ this.emitStreamEvent({
640
+ direction: 'incoming',
641
+ streamType: 'rpc',
642
+ action: 'end',
643
+ callId: message.callId,
644
+ })
417
645
  this.rpcStreams.end(message.callId)
418
646
  this.calls.delete(message.callId)
419
647
  break
420
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
+ })
421
656
  this.rpcStreams.abort(message.callId)
422
657
  this.calls.delete(message.callId)
423
658
  break
424
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
+ })
425
667
  this.serverStreams.push(message.streamId, message.chunk)
426
668
  break
427
669
  case ServerMessageType.ServerStreamEnd:
670
+ this.emitStreamEvent({
671
+ direction: 'incoming',
672
+ streamType: 'server_blob',
673
+ action: 'end',
674
+ streamId: message.streamId,
675
+ })
428
676
  this.serverStreams.end(message.streamId)
429
677
  break
430
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
+ })
431
686
  this.serverStreams.abort(message.streamId)
432
687
  break
433
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
+ })
434
696
  this.clientStreams.pull(message.streamId, message.size).then(
435
697
  (chunk) => {
436
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
+ })
437
706
  const buffer = this.protocol.encodeMessage(
438
707
  this.messageContext!,
439
708
  ClientMessageType.ClientStreamPush,
440
709
  { streamId: message.streamId, chunk },
441
710
  )
442
- this.#send(buffer).catch(noopFn)
711
+ this.send(buffer).catch(noopFn)
443
712
  } else {
713
+ this.emitStreamEvent({
714
+ direction: 'outgoing',
715
+ streamType: 'client_blob',
716
+ action: 'end',
717
+ streamId: message.streamId,
718
+ })
444
719
  const buffer = this.protocol.encodeMessage(
445
720
  this.messageContext!,
446
721
  ClientMessageType.ClientStreamEnd,
447
722
  { streamId: message.streamId },
448
723
  )
449
- this.#send(buffer).catch(noopFn)
724
+ this.send(buffer).catch(noopFn)
450
725
  this.clientStreams.end(message.streamId)
451
726
  }
452
727
  },
453
728
  () => {
729
+ this.emitStreamEvent({
730
+ direction: 'outgoing',
731
+ streamType: 'client_blob',
732
+ action: 'abort',
733
+ streamId: message.streamId,
734
+ })
454
735
  const buffer = this.protocol.encodeMessage(
455
736
  this.messageContext!,
456
737
  ClientMessageType.ClientStreamAbort,
457
738
  { streamId: message.streamId },
458
739
  )
459
- this.#send(buffer).catch(noopFn)
740
+ this.send(buffer).catch(noopFn)
460
741
  this.clientStreams.remove(message.streamId)
461
742
  },
462
743
  )
463
744
  break
464
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
+ })
465
753
  this.clientStreams.abort(message.streamId)
466
754
  break
467
755
  }
468
756
  }
469
757
 
470
- #handleRPCResponseMessage(
758
+ private handleRPCResponseMessage(
471
759
  message: ServerMessageTypePayload[ServerMessageType.RpcResponse],
472
760
  ) {
473
761
  const { callId, result, error } = message
474
762
  const call = this.calls.get(callId)
475
763
  if (!call) return
476
764
  if (error) {
765
+ this.emitClientEvent({
766
+ kind: 'rpc_error',
767
+ timestamp: Date.now(),
768
+ callId,
769
+ procedure: call.procedure,
770
+ error,
771
+ })
477
772
  call.reject(new ProtocolError(error.code, error.message, error.data))
478
773
  } else {
479
774
  try {
480
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
+ })
481
783
  call.resolve(transformed)
482
784
  } catch (error) {
785
+ this.emitClientEvent({
786
+ kind: 'rpc_error',
787
+ timestamp: Date.now(),
788
+ callId,
789
+ procedure: call.procedure,
790
+ error,
791
+ })
483
792
  call.reject(
484
793
  new ProtocolError(
485
794
  ErrorCode.ClientRequestError,
@@ -491,12 +800,19 @@ export abstract class BaseClient<
491
800
  }
492
801
  }
493
802
 
494
- #handleRPCStreamResponseMessage(
803
+ private handleRPCStreamResponseMessage(
495
804
  message: ServerMessageTypePayload[ServerMessageType.RpcStreamResponse],
496
805
  ) {
497
806
  const call = this.calls.get(message.callId)
498
807
  if (message.error) {
499
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
+ })
500
816
  call.reject(
501
817
  new ProtocolError(
502
818
  message.error.code,
@@ -507,6 +823,13 @@ export abstract class BaseClient<
507
823
  } else {
508
824
  if (call) {
509
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
+ })
510
833
  const stream = new ProtocolServerRPCStream({
511
834
  start: (controller) => {
512
835
  if (signal) {
@@ -525,7 +848,7 @@ export abstract class BaseClient<
525
848
  ClientMessageType.RpcAbort,
526
849
  { callId: message.callId, reason: signal.reason },
527
850
  )
528
- this.#send(buffer).catch(noopFn)
851
+ this.send(buffer).catch(noopFn)
529
852
  }
530
853
  }
531
854
  },
@@ -539,14 +862,6 @@ export abstract class BaseClient<
539
862
  this.options.format.decode(chunk),
540
863
  )
541
864
  },
542
- pull: () => {
543
- const buffer = this.protocol.encodeMessage(
544
- this.messageContext!,
545
- ClientMessageType.RpcPull,
546
- { callId: message.callId },
547
- )
548
- this.#send(buffer).catch(noopFn)
549
- },
550
865
  readableStrategy: { highWaterMark: 0 },
551
866
  })
552
867
  this.rpcStreams.add(message.callId, stream)
@@ -561,17 +876,24 @@ export abstract class BaseClient<
561
876
  ClientMessageType.RpcAbort,
562
877
  { callId: message.callId },
563
878
  )
564
- this.#send(buffer).catch(noopFn)
879
+ this.send(buffer).catch(noopFn)
565
880
  }
566
881
  }
567
882
  }
568
883
  }
569
884
 
570
- #handleCallResponse(callId: number, response: ClientCallResponse) {
885
+ private handleCallResponse(callId: number, response: ClientCallResponse) {
571
886
  const call = this.calls.get(callId)
572
887
 
573
888
  if (response.type === 'rpc_stream') {
574
889
  if (call) {
890
+ this.emitClientEvent({
891
+ kind: 'rpc_response',
892
+ timestamp: Date.now(),
893
+ callId,
894
+ procedure: call.procedure,
895
+ stream: true,
896
+ })
575
897
  const stream = new ProtocolServerStream({
576
898
  transform: (chunk) => {
577
899
  return this.transformer.decode(
@@ -582,7 +904,36 @@ export abstract class BaseClient<
582
904
  })
583
905
  this.rpcStreams.add(callId, stream)
584
906
  call.resolve(({ signal }: { signal?: AbortSignal }) => {
585
- response.stream.pipeTo(stream.writable, { signal }).catch(noopFn)
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
+
586
937
  return stream
587
938
  })
588
939
  } else {
@@ -593,9 +944,16 @@ export abstract class BaseClient<
593
944
  }
594
945
  } else if (response.type === 'blob') {
595
946
  if (call) {
947
+ this.emitClientEvent({
948
+ kind: 'rpc_response',
949
+ timestamp: Date.now(),
950
+ callId,
951
+ procedure: call.procedure,
952
+ stream: true,
953
+ })
596
954
  const { metadata, source } = response
597
955
  const stream = new ProtocolServerBlobStream(metadata)
598
- this.serverStreams.add(this.#getStreamId(), stream)
956
+ this.serverStreams.add(this.getStreamId(), stream)
599
957
  call.resolve(({ signal }: { signal?: AbortSignal }) => {
600
958
  source.pipeTo(stream.writable, { signal }).catch(noopFn)
601
959
  return stream
@@ -609,12 +967,31 @@ export abstract class BaseClient<
609
967
  } else if (response.type === 'rpc') {
610
968
  if (!call) return
611
969
  try {
970
+ const decodedPayload =
971
+ response.result.byteLength === 0
972
+ ? undefined
973
+ : this.options.format.decode(response.result)
974
+
612
975
  const transformed = this.transformer.decode(
613
976
  call.procedure,
614
- response.result,
977
+ decodedPayload,
615
978
  )
979
+ this.emitClientEvent({
980
+ kind: 'rpc_response',
981
+ timestamp: Date.now(),
982
+ callId,
983
+ procedure: call.procedure,
984
+ body: transformed,
985
+ })
616
986
  call.resolve(transformed)
617
987
  } catch (error) {
988
+ this.emitClientEvent({
989
+ kind: 'rpc_error',
990
+ timestamp: Date.now(),
991
+ callId,
992
+ procedure: call.procedure,
993
+ error,
994
+ })
618
995
  call.reject(
619
996
  new ProtocolError(
620
997
  ErrorCode.ClientRequestError,
@@ -626,23 +1003,45 @@ export abstract class BaseClient<
626
1003
  }
627
1004
  }
628
1005
 
629
- #send(buffer: ArrayBufferView, signal?: AbortSignal) {
1006
+ protected send(buffer: ArrayBufferView, signal?: AbortSignal) {
630
1007
  if (this.transport.type === ConnectionType.Unidirectional)
631
1008
  throw new Error('Invalid transport type for send')
632
1009
  return this.transport.send(buffer, { signal })
633
1010
  }
634
1011
 
635
- #getStreamId() {
1012
+ protected emitStreamEvent(
1013
+ event: Omit<
1014
+ Extract<ClientPluginEvent, { kind: 'stream_event' }>,
1015
+ 'kind' | 'timestamp'
1016
+ >,
1017
+ ) {
1018
+ this.emitClientEvent({
1019
+ kind: 'stream_event',
1020
+ timestamp: Date.now(),
1021
+ ...event,
1022
+ })
1023
+ }
1024
+
1025
+ protected getStreamId() {
636
1026
  if (this.streamId >= MAX_UINT32) {
637
1027
  this.streamId = 0
638
1028
  }
639
1029
  return this.streamId++
640
1030
  }
641
1031
 
642
- #getCallId() {
1032
+ protected getCallId() {
643
1033
  if (this.callId >= MAX_UINT32) {
644
1034
  this.callId = 0
645
1035
  }
646
1036
  return this.callId++
647
1037
  }
1038
+
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
+ }
1046
+ }
648
1047
  }