@nmtjs/client 0.15.0-beta.2 → 0.15.0-beta.4

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.
@@ -0,0 +1,77 @@
1
+ import type { TAnyRouterContract } from '@nmtjs/contract'
2
+
3
+ import type { BaseClientOptions } from '../core.ts'
4
+ import type { ClientTransportFactory } from '../transport.ts'
5
+ import type {
6
+ ClientCallers,
7
+ ClientCallOptions,
8
+ StaticInputContractTypeProvider,
9
+ StaticOutputContractTypeProvider,
10
+ } from '../types.ts'
11
+ import { BaseClient } from '../core.ts'
12
+ import { BaseClientTransformer } from '../transformers.ts'
13
+
14
+ export class StaticClient<
15
+ Transport extends ClientTransportFactory<any, any> = ClientTransportFactory<
16
+ any,
17
+ any
18
+ >,
19
+ RouterContract extends TAnyRouterContract = TAnyRouterContract,
20
+ SafeCall extends boolean = false,
21
+ > extends BaseClient<
22
+ Transport,
23
+ RouterContract,
24
+ SafeCall,
25
+ StaticInputContractTypeProvider,
26
+ StaticOutputContractTypeProvider
27
+ > {
28
+ protected readonly transformer: BaseClientTransformer
29
+
30
+ constructor(
31
+ options: BaseClientOptions<RouterContract, SafeCall>,
32
+ transport: Transport,
33
+ transportOptions: Transport extends ClientTransportFactory<
34
+ any,
35
+ infer Options
36
+ >
37
+ ? Options
38
+ : never,
39
+ ) {
40
+ super(options, transport, transportOptions)
41
+ this.transformer = new BaseClientTransformer()
42
+ }
43
+
44
+ override get call() {
45
+ return this.createProxy(Object.create(null), false) as ClientCallers<
46
+ this['_']['routes'],
47
+ SafeCall,
48
+ false
49
+ >
50
+ }
51
+
52
+ override get stream() {
53
+ return this.createProxy(Object.create(null), true) as ClientCallers<
54
+ this['_']['routes'],
55
+ SafeCall,
56
+ true
57
+ >
58
+ }
59
+
60
+ protected createProxy<T>(
61
+ target: Record<string, unknown>,
62
+ isStream: boolean,
63
+ path: string[] = [],
64
+ ) {
65
+ return new Proxy(target, {
66
+ get: (obj, prop) => {
67
+ if (prop === 'then') return obj
68
+ const newPath = [...path, String(prop)]
69
+ const caller = (
70
+ payload?: unknown,
71
+ options?: Partial<ClientCallOptions>,
72
+ ) => this._call(newPath.join('/'), payload, options)
73
+ return this.createProxy(caller as any, isStream, newPath)
74
+ },
75
+ }) as T
76
+ }
77
+ }
package/src/core.ts ADDED
@@ -0,0 +1,637 @@
1
+ import type { Future, TypeProvider } from '@nmtjs/common'
2
+ import type { TAnyRouterContract } from '@nmtjs/contract'
3
+ import type { ProtocolBlobMetadata, ProtocolVersion } from '@nmtjs/protocol'
4
+ import type {
5
+ BaseClientFormat,
6
+ MessageContext,
7
+ ProtocolVersionInterface,
8
+ ServerMessageTypePayload,
9
+ } from '@nmtjs/protocol/client'
10
+ import { anyAbortSignal, createFuture, MAX_UINT32, noopFn } from '@nmtjs/common'
11
+ import {
12
+ ClientMessageType,
13
+ ConnectionType,
14
+ ErrorCode,
15
+ ProtocolBlob,
16
+ ServerMessageType,
17
+ } from '@nmtjs/protocol'
18
+ import {
19
+ ProtocolError,
20
+ ProtocolServerBlobStream,
21
+ ProtocolServerRPCStream,
22
+ ProtocolServerStream,
23
+ versions,
24
+ } from '@nmtjs/protocol/client'
25
+
26
+ import type { BaseClientTransformer } from './transformers.ts'
27
+ import type { ClientCallResponse, ClientTransportFactory } from './transport.ts'
28
+ import type {
29
+ ClientCallers,
30
+ ClientCallOptions,
31
+ ResolveAPIRouterRoutes,
32
+ } from './types.ts'
33
+ import { EventEmitter } from './events.ts'
34
+ import { ClientStreams, ServerStreams } from './streams.ts'
35
+
36
+ export {
37
+ ErrorCode,
38
+ ProtocolBlob,
39
+ type ProtocolBlobMetadata,
40
+ } from '@nmtjs/protocol'
41
+
42
+ export * from './types.ts'
43
+
44
+ export class ClientError extends ProtocolError {}
45
+
46
+ export type ProtocolClientCall = Future<any> & {
47
+ procedure: string
48
+ signal?: AbortSignal
49
+ }
50
+
51
+ const DEFAULT_RECONNECT_TIMEOUT = 1000
52
+ const DEFAULT_MAX_RECONNECT_TIMEOUT = 60000
53
+
54
+ export interface BaseClientOptions<
55
+ RouterContract extends TAnyRouterContract = TAnyRouterContract,
56
+ SafeCall extends boolean = false,
57
+ > {
58
+ contract: RouterContract
59
+ protocol: ProtocolVersion
60
+ format: BaseClientFormat
61
+ application?: string
62
+ timeout?: number
63
+ autoreconnect?: boolean
64
+ safe?: SafeCall
65
+ }
66
+
67
+ /**
68
+ * @todo Add error logging in ClientStreamPull rejection handler for easier debugging
69
+ * @todo Consider edge case where callId/streamId overflow at MAX_UINT32 with existing entries
70
+ */
71
+ export abstract class BaseClient<
72
+ TransportFactory extends ClientTransportFactory<
73
+ any,
74
+ any
75
+ > = ClientTransportFactory<any, any>,
76
+ RouterContract extends TAnyRouterContract = TAnyRouterContract,
77
+ SafeCall extends boolean = false,
78
+ InputTypeProvider extends TypeProvider = TypeProvider,
79
+ OutputTypeProvider extends TypeProvider = TypeProvider,
80
+ > extends EventEmitter<{
81
+ connected: []
82
+ disconnected: [reason: 'server' | 'client' | (string & {})]
83
+ }> {
84
+ _!: {
85
+ routes: ResolveAPIRouterRoutes<
86
+ RouterContract,
87
+ InputTypeProvider,
88
+ OutputTypeProvider
89
+ >
90
+ safe: SafeCall
91
+ }
92
+
93
+ protected abstract readonly transformer: BaseClientTransformer
94
+
95
+ abstract call: ClientCallers<this['_']['routes'], SafeCall, false>
96
+ abstract stream: ClientCallers<this['_']['routes'], SafeCall, true>
97
+
98
+ protected calls = new Map<number, ProtocolClientCall>()
99
+ protected transport: TransportFactory extends ClientTransportFactory<
100
+ any,
101
+ any,
102
+ infer T
103
+ >
104
+ ? T
105
+ : never
106
+ protected protocol: ProtocolVersionInterface
107
+ protected messageContext!: MessageContext | null
108
+ protected clientStreams = new ClientStreams()
109
+ protected serverStreams = new ServerStreams()
110
+ protected rpcStreams = new ServerStreams()
111
+ protected callId = 0
112
+ protected streamId = 0
113
+ protected cab: AbortController | null = null
114
+ protected reconnectTimeout = DEFAULT_RECONNECT_TIMEOUT
115
+
116
+ #auth: any
117
+
118
+ constructor(
119
+ readonly options: BaseClientOptions<RouterContract, SafeCall>,
120
+ readonly transportFactory: TransportFactory,
121
+ readonly transportOptions: TransportFactory extends ClientTransportFactory<
122
+ any,
123
+ infer U
124
+ >
125
+ ? U
126
+ : never,
127
+ ) {
128
+ super()
129
+
130
+ this.protocol = versions[options.protocol]
131
+
132
+ const { format, protocol } = this.options
133
+
134
+ this.transport = this.transportFactory(
135
+ { protocol, format },
136
+ this.transportOptions,
137
+ ) as any
138
+
139
+ if (
140
+ this.transport.type === ConnectionType.Bidirectional &&
141
+ this.options.autoreconnect
142
+ ) {
143
+ this.on('disconnected', async (reason) => {
144
+ if (reason === 'server') {
145
+ this.connect()
146
+ } else if (reason === 'error') {
147
+ const timeout = new Promise((resolve) =>
148
+ setTimeout(resolve, this.reconnectTimeout),
149
+ )
150
+ const connected = new Promise((_, reject) =>
151
+ this.once('connected', reject),
152
+ )
153
+ this.reconnectTimeout = Math.min(
154
+ this.reconnectTimeout * 2,
155
+ DEFAULT_MAX_RECONNECT_TIMEOUT,
156
+ )
157
+ await Promise.race([timeout, connected]).then(
158
+ this.connect.bind(this),
159
+ noopFn,
160
+ )
161
+ }
162
+ })
163
+
164
+ this.on('connected', () => {
165
+ this.reconnectTimeout = DEFAULT_RECONNECT_TIMEOUT
166
+ })
167
+
168
+ if (globalThis.window) {
169
+ globalThis.window.addEventListener('pageshow', () => {
170
+ if (!this.cab) this.connect()
171
+ })
172
+ }
173
+ }
174
+ }
175
+
176
+ get auth() {
177
+ return this.#auth
178
+ }
179
+
180
+ set auth(value) {
181
+ this.#auth = value
182
+ }
183
+
184
+ async connect() {
185
+ if (this.transport.type === ConnectionType.Bidirectional) {
186
+ this.cab = new AbortController()
187
+ const protocol = this.protocol
188
+ const serverStreams = this.serverStreams
189
+
190
+ const transport = {
191
+ send: (buffer) => {
192
+ this.#send(buffer).catch(noopFn)
193
+ },
194
+ }
195
+ this.messageContext = {
196
+ transport,
197
+ encoder: this.options.format,
198
+ decoder: this.options.format,
199
+ addClientStream: (blob) => {
200
+ const streamId = this.#getStreamId()
201
+ return this.clientStreams.add(blob.source, streamId, blob.metadata)
202
+ },
203
+ addServerStream(streamId, metadata) {
204
+ const stream = new ProtocolServerBlobStream(metadata, {
205
+ pull: (controller) => {
206
+ transport.send(
207
+ protocol.encodeMessage(
208
+ this,
209
+ ClientMessageType.ServerStreamPull,
210
+ { streamId, size: 65535 /* 64kb by default */ },
211
+ ),
212
+ )
213
+ },
214
+ close: () => {
215
+ serverStreams.remove(streamId)
216
+ },
217
+ readableStrategy: { highWaterMark: 0 },
218
+ })
219
+ serverStreams.add(streamId, stream)
220
+ return ({ signal }: { signal?: AbortSignal } = {}) => {
221
+ if (signal)
222
+ signal.addEventListener(
223
+ 'abort',
224
+ () => {
225
+ transport.send(
226
+ protocol.encodeMessage(
227
+ this,
228
+ ClientMessageType.ServerStreamAbort,
229
+ { streamId },
230
+ ),
231
+ )
232
+ serverStreams.abort(streamId)
233
+ },
234
+ { once: true },
235
+ )
236
+ return stream
237
+ }
238
+ },
239
+ streamId: this.#getStreamId.bind(this),
240
+ }
241
+ return this.transport.connect({
242
+ auth: this.auth,
243
+ application: this.options.application,
244
+ onMessage: this.onMessage.bind(this),
245
+ onConnect: this.onConnect.bind(this),
246
+ onDisconnect: this.onDisconnect.bind(this),
247
+ })
248
+ }
249
+ }
250
+
251
+ async disconnect() {
252
+ if (this.transport.type === ConnectionType.Bidirectional) {
253
+ this.cab!.abort()
254
+ await this.transport.disconnect()
255
+ this.messageContext = null
256
+ this.cab = null
257
+ }
258
+ }
259
+
260
+ blob(
261
+ source: Blob | ReadableStream | string | AsyncIterable<Uint8Array>,
262
+ metadata?: ProtocolBlobMetadata,
263
+ ) {
264
+ return ProtocolBlob.from(source, metadata)
265
+ }
266
+
267
+ protected async _call(
268
+ procedure: string,
269
+ payload: any,
270
+ options: ClientCallOptions = {},
271
+ ) {
272
+ const timeout = options.timeout ?? this.options.timeout
273
+ const controller = new AbortController()
274
+
275
+ // attach all abort signals
276
+ const signals: AbortSignal[] = [controller.signal]
277
+
278
+ if (timeout) signals.push(AbortSignal.timeout(timeout))
279
+ if (options.signal) signals.push(options.signal)
280
+ if (this.cab?.signal) signals.push(this.cab.signal)
281
+
282
+ const signal = signals.length ? anyAbortSignal(...signals) : undefined
283
+
284
+ const callId = this.#getCallId()
285
+ const call = createFuture() as ProtocolClientCall
286
+ call.procedure = procedure
287
+ call.signal = signal
288
+
289
+ this.calls.set(callId, call)
290
+
291
+ // Check if signal is already aborted before proceeding
292
+ if (signal?.aborted) {
293
+ this.calls.delete(callId)
294
+ const error = new ProtocolError(
295
+ ErrorCode.ClientRequestError,
296
+ signal.reason,
297
+ )
298
+ call.reject(error)
299
+ } else {
300
+ if (signal) {
301
+ signal.addEventListener(
302
+ 'abort',
303
+ () => {
304
+ call.reject(
305
+ new ProtocolError(ErrorCode.ClientRequestError, signal!.reason),
306
+ )
307
+ if (
308
+ this.transport.type === ConnectionType.Bidirectional &&
309
+ this.messageContext
310
+ ) {
311
+ const buffer = this.protocol.encodeMessage(
312
+ this.messageContext,
313
+ ClientMessageType.RpcAbort,
314
+ { callId },
315
+ )
316
+ this.#send(buffer).catch(noopFn)
317
+ }
318
+ },
319
+ { once: true },
320
+ )
321
+ }
322
+
323
+ try {
324
+ const transformedPayload = this.transformer.encode(procedure, payload)
325
+ if (this.transport.type === ConnectionType.Bidirectional) {
326
+ const buffer = this.protocol.encodeMessage(
327
+ this.messageContext!,
328
+ ClientMessageType.Rpc,
329
+ { callId, procedure, payload: transformedPayload },
330
+ )
331
+ await this.#send(buffer, signal)
332
+ } else {
333
+ const response = await this.transport.call(
334
+ {
335
+ application: this.options.application,
336
+ format: this.options.format,
337
+ auth: this.auth,
338
+ },
339
+ { callId, procedure, payload: transformedPayload },
340
+ { signal, _stream_response: options._stream_response },
341
+ )
342
+ this.#handleCallResponse(callId, response)
343
+ }
344
+ } catch (error) {
345
+ call.reject(error)
346
+ }
347
+ }
348
+
349
+ const result = call.promise.then(
350
+ (value) => {
351
+ if (value instanceof ProtocolServerRPCStream) {
352
+ return value.createAsyncIterable(() => {
353
+ controller.abort()
354
+ })
355
+ }
356
+ controller.abort()
357
+ return value
358
+ },
359
+ (err) => {
360
+ controller.abort()
361
+ throw err
362
+ },
363
+ )
364
+
365
+ if (this.options.safe) {
366
+ return await result
367
+ .then((result) => ({ result }))
368
+ .catch((error) => ({ error }))
369
+ .finally(() => {
370
+ this.calls.delete(callId)
371
+ })
372
+ } else {
373
+ return await result.finally(() => {
374
+ this.calls.delete(callId)
375
+ })
376
+ }
377
+ }
378
+
379
+ protected async onConnect() {
380
+ this.emit('connected')
381
+ }
382
+
383
+ protected async onDisconnect(reason: 'client' | 'server' | (string & {})) {
384
+ this.emit('disconnected', reason)
385
+ this.clientStreams.clear(reason)
386
+ this.serverStreams.clear(reason)
387
+ this.rpcStreams.clear(reason)
388
+ }
389
+
390
+ protected async onMessage(buffer: ArrayBufferView) {
391
+ if (!this.messageContext) return
392
+
393
+ const message = this.protocol.decodeMessage(this.messageContext, buffer)
394
+
395
+ switch (message.type) {
396
+ case ServerMessageType.RpcResponse:
397
+ this.#handleRPCResponseMessage(message)
398
+ break
399
+ case ServerMessageType.RpcStreamResponse:
400
+ this.#handleRPCStreamResponseMessage(message)
401
+ break
402
+ case ServerMessageType.RpcStreamChunk:
403
+ this.rpcStreams.push(message.callId, message.chunk)
404
+ break
405
+ case ServerMessageType.RpcStreamEnd:
406
+ this.rpcStreams.end(message.callId)
407
+ this.calls.delete(message.callId)
408
+ break
409
+ case ServerMessageType.RpcStreamAbort:
410
+ this.rpcStreams.abort(message.callId)
411
+ this.calls.delete(message.callId)
412
+ break
413
+ case ServerMessageType.ServerStreamPush:
414
+ this.serverStreams.push(message.streamId, message.chunk)
415
+ break
416
+ case ServerMessageType.ServerStreamEnd:
417
+ this.serverStreams.end(message.streamId)
418
+ break
419
+ case ServerMessageType.ServerStreamAbort:
420
+ this.serverStreams.abort(message.streamId)
421
+ break
422
+ case ServerMessageType.ClientStreamPull:
423
+ this.clientStreams.pull(message.streamId, message.size).then(
424
+ (chunk) => {
425
+ if (chunk) {
426
+ const buffer = this.protocol.encodeMessage(
427
+ this.messageContext!,
428
+ ClientMessageType.ClientStreamPush,
429
+ { streamId: message.streamId, chunk },
430
+ )
431
+ this.#send(buffer).catch(noopFn)
432
+ } else {
433
+ const buffer = this.protocol.encodeMessage(
434
+ this.messageContext!,
435
+ ClientMessageType.ClientStreamEnd,
436
+ { streamId: message.streamId },
437
+ )
438
+ this.#send(buffer).catch(noopFn)
439
+ this.clientStreams.end(message.streamId)
440
+ }
441
+ },
442
+ () => {
443
+ const buffer = this.protocol.encodeMessage(
444
+ this.messageContext!,
445
+ ClientMessageType.ClientStreamAbort,
446
+ { streamId: message.streamId },
447
+ )
448
+ this.#send(buffer).catch(noopFn)
449
+ this.clientStreams.remove(message.streamId)
450
+ },
451
+ )
452
+ break
453
+ case ServerMessageType.ClientStreamAbort:
454
+ this.clientStreams.abort(message.streamId)
455
+ break
456
+ }
457
+ }
458
+
459
+ #handleRPCResponseMessage(
460
+ message: ServerMessageTypePayload[ServerMessageType.RpcResponse],
461
+ ) {
462
+ const { callId, result, error } = message
463
+ const call = this.calls.get(callId)
464
+ if (!call) return
465
+ if (error) {
466
+ call.reject(new ProtocolError(error.code, error.message, error.data))
467
+ } else {
468
+ try {
469
+ const transformed = this.transformer.decode(call.procedure, result)
470
+ call.resolve(transformed)
471
+ } catch (error) {
472
+ call.reject(
473
+ new ProtocolError(
474
+ ErrorCode.ClientRequestError,
475
+ 'Unable to decode response',
476
+ error,
477
+ ),
478
+ )
479
+ }
480
+ }
481
+ }
482
+
483
+ #handleRPCStreamResponseMessage(
484
+ message: ServerMessageTypePayload[ServerMessageType.RpcStreamResponse],
485
+ ) {
486
+ const call = this.calls.get(message.callId)
487
+ if (message.error) {
488
+ if (!call) return
489
+ call.reject(
490
+ new ProtocolError(
491
+ message.error.code,
492
+ message.error.message,
493
+ message.error.data,
494
+ ),
495
+ )
496
+ } else {
497
+ if (call) {
498
+ const { procedure, signal } = call
499
+ const stream = new ProtocolServerRPCStream({
500
+ start: (controller) => {
501
+ if (signal) {
502
+ if (signal.aborted) controller.error(signal.reason)
503
+ else
504
+ signal.addEventListener(
505
+ 'abort',
506
+ () => {
507
+ controller.error(signal.reason)
508
+ if (this.rpcStreams.has(message.callId)) {
509
+ this.rpcStreams.remove(message.callId)
510
+ this.calls.delete(message.callId)
511
+ if (this.messageContext) {
512
+ const buffer = this.protocol.encodeMessage(
513
+ this.messageContext,
514
+ ClientMessageType.RpcAbort,
515
+ { callId: message.callId, reason: signal.reason },
516
+ )
517
+ this.#send(buffer).catch(noopFn)
518
+ }
519
+ }
520
+ },
521
+ { once: true },
522
+ )
523
+ }
524
+ },
525
+ transform: (chunk) => {
526
+ return this.transformer.decode(
527
+ procedure,
528
+ this.options.format.decode(chunk),
529
+ )
530
+ },
531
+ pull: () => {
532
+ const buffer = this.protocol.encodeMessage(
533
+ this.messageContext!,
534
+ ClientMessageType.RpcPull,
535
+ { callId: message.callId },
536
+ )
537
+ this.#send(buffer).catch(noopFn)
538
+ },
539
+ readableStrategy: { highWaterMark: 0 },
540
+ })
541
+ this.rpcStreams.add(message.callId, stream)
542
+ call.resolve(stream)
543
+ } else {
544
+ // Call not found, but stream response received
545
+ // This can happen if the call was aborted or timed out
546
+ // Need to send an abort for the stream to avoid resource leaks from server side
547
+ if (this.messageContext) {
548
+ const buffer = this.protocol.encodeMessage(
549
+ this.messageContext,
550
+ ClientMessageType.RpcAbort,
551
+ { callId: message.callId },
552
+ )
553
+ this.#send(buffer).catch(noopFn)
554
+ }
555
+ }
556
+ }
557
+ }
558
+
559
+ #handleCallResponse(callId: number, response: ClientCallResponse) {
560
+ const call = this.calls.get(callId)
561
+
562
+ if (response.type === 'rpc_stream') {
563
+ if (call) {
564
+ const stream = new ProtocolServerStream({
565
+ transform: (chunk) => {
566
+ return this.transformer.decode(
567
+ call.procedure,
568
+ this.options.format.decode(chunk),
569
+ )
570
+ },
571
+ })
572
+ this.rpcStreams.add(callId, stream)
573
+ call.resolve(({ signal }: { signal?: AbortSignal }) => {
574
+ response.stream.pipeTo(stream.writable, { signal }).catch(noopFn)
575
+ return stream
576
+ })
577
+ } else {
578
+ // Call not found, but stream response received
579
+ // This can happen if the call was aborted or timed out
580
+ // Need to cancel the stream to avoid resource leaks from server side
581
+ response.stream.cancel().catch(noopFn)
582
+ }
583
+ } else if (response.type === 'blob') {
584
+ if (call) {
585
+ const { metadata, source } = response
586
+ const stream = new ProtocolServerBlobStream(metadata)
587
+ this.serverStreams.add(this.#getStreamId(), stream)
588
+ call.resolve(({ signal }: { signal?: AbortSignal }) => {
589
+ source.pipeTo(stream.writable, { signal }).catch(noopFn)
590
+ return stream
591
+ })
592
+ } else {
593
+ // Call not found, but blob response received
594
+ // This can happen if the call was aborted or timed out
595
+ // Need to cancel the stream to avoid resource leaks from server side
596
+ response.source.cancel().catch(noopFn)
597
+ }
598
+ } else if (response.type === 'rpc') {
599
+ if (!call) return
600
+ try {
601
+ const transformed = this.transformer.decode(
602
+ call.procedure,
603
+ response.result,
604
+ )
605
+ call.resolve(transformed)
606
+ } catch (error) {
607
+ call.reject(
608
+ new ProtocolError(
609
+ ErrorCode.ClientRequestError,
610
+ 'Unable to decode response',
611
+ error,
612
+ ),
613
+ )
614
+ }
615
+ }
616
+ }
617
+
618
+ #send(buffer: ArrayBufferView, signal?: AbortSignal) {
619
+ if (this.transport.type === ConnectionType.Unidirectional)
620
+ throw new Error('Invalid transport type for send')
621
+ return this.transport.send(buffer, { signal })
622
+ }
623
+
624
+ #getStreamId() {
625
+ if (this.streamId >= MAX_UINT32) {
626
+ this.streamId = 0
627
+ }
628
+ return this.streamId++
629
+ }
630
+
631
+ #getCallId() {
632
+ if (this.callId >= MAX_UINT32) {
633
+ this.callId = 0
634
+ }
635
+ return this.callId++
636
+ }
637
+ }