@nmtjs/client 0.15.0-beta.3 → 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.
package/package.json CHANGED
@@ -7,24 +7,25 @@
7
7
  "./static": "./dist/clients/static.js"
8
8
  },
9
9
  "peerDependencies": {
10
- "@nmtjs/common": "0.15.0-beta.3",
11
- "@nmtjs/type": "0.15.0-beta.3",
12
- "@nmtjs/contract": "0.15.0-beta.3",
13
- "@nmtjs/protocol": "0.15.0-beta.3"
10
+ "@nmtjs/type": "0.15.0-beta.4",
11
+ "@nmtjs/common": "0.15.0-beta.4",
12
+ "@nmtjs/protocol": "0.15.0-beta.4",
13
+ "@nmtjs/contract": "0.15.0-beta.4"
14
14
  },
15
15
  "devDependencies": {
16
- "@nmtjs/_tests": "0.15.0-beta.3",
17
- "@nmtjs/type": "0.15.0-beta.3",
18
- "@nmtjs/contract": "0.15.0-beta.3",
19
- "@nmtjs/common": "0.15.0-beta.3",
20
- "@nmtjs/protocol": "0.15.0-beta.3"
16
+ "@nmtjs/_tests": "0.15.0-beta.4",
17
+ "@nmtjs/contract": "0.15.0-beta.4",
18
+ "@nmtjs/common": "0.15.0-beta.4",
19
+ "@nmtjs/protocol": "0.15.0-beta.4",
20
+ "@nmtjs/type": "0.15.0-beta.4"
21
21
  },
22
22
  "files": [
23
23
  "dist",
24
+ "src",
24
25
  "LICENSE.md",
25
26
  "README.md"
26
27
  ],
27
- "version": "0.15.0-beta.3",
28
+ "version": "0.15.0-beta.4",
28
29
  "scripts": {
29
30
  "clean-build": "rm -rf ./dist",
30
31
  "build": "tsc --declaration --sourcemap",
@@ -0,0 +1,133 @@
1
+ import type {
2
+ TAnyProcedureContract,
3
+ TAnyRouterContract,
4
+ TRouteContract,
5
+ } from '@nmtjs/contract'
6
+ import { IsProcedureContract, IsRouterContract } from '@nmtjs/contract'
7
+
8
+ import type { BaseClientOptions } from '../core.ts'
9
+ import type { ClientTransportFactory } from '../transport.ts'
10
+ import type {
11
+ ClientCallers,
12
+ ClientCallOptions,
13
+ RuntimeInputContractTypeProvider,
14
+ RuntimeOutputContractTypeProvider,
15
+ } from '../types.ts'
16
+ import { BaseClient } from '../core.ts'
17
+
18
+ export class RuntimeContractTransformer {
19
+ #procedures = new Map<string, TAnyProcedureContract>()
20
+
21
+ constructor(router: TAnyRouterContract) {
22
+ const registerProcedures = (r: TRouteContract, path: string[] = []) => {
23
+ if (IsRouterContract(r)) {
24
+ for (const [key, route] of Object.entries(r.routes)) {
25
+ registerProcedures(route, [...path, key])
26
+ }
27
+ } else if (IsProcedureContract(r)) {
28
+ const fullName = [...path].join('/')
29
+ this.#procedures.set(fullName, r)
30
+ }
31
+ }
32
+ registerProcedures(router)
33
+ }
34
+
35
+ encode(_procedure: string, payload: any) {
36
+ const procedure = this.#procedures.get(_procedure)
37
+ if (!procedure) throw new Error(`Procedure not found: ${_procedure}`)
38
+ return procedure.input.encode(payload)
39
+ }
40
+
41
+ decode(_procedure: string, payload: any) {
42
+ const procedure = this.#procedures.get(_procedure)
43
+ if (!procedure) throw new Error(`Procedure not found: ${_procedure}`)
44
+ return procedure.output.decode(payload)
45
+ }
46
+ }
47
+
48
+ export class RuntimeClient<
49
+ Transport extends ClientTransportFactory<any, any> = ClientTransportFactory<
50
+ any,
51
+ any
52
+ >,
53
+ RouterContract extends TAnyRouterContract = TAnyRouterContract,
54
+ SafeCall extends boolean = false,
55
+ > extends BaseClient<
56
+ Transport,
57
+ RouterContract,
58
+ SafeCall,
59
+ RuntimeInputContractTypeProvider,
60
+ RuntimeOutputContractTypeProvider
61
+ > {
62
+ protected readonly transformer: RuntimeContractTransformer
63
+
64
+ readonly #procedures = new Map<string, TAnyProcedureContract>()
65
+ readonly #callers!: ClientCallers<this['_']['routes'], SafeCall, boolean>
66
+
67
+ constructor(
68
+ options: BaseClientOptions<RouterContract, SafeCall>,
69
+ transport: Transport,
70
+ transportOptions: Transport extends ClientTransportFactory<
71
+ any,
72
+ infer Options
73
+ >
74
+ ? Options
75
+ : never,
76
+ ) {
77
+ super(options, transport, transportOptions)
78
+
79
+ this.resolveProcedures(this.options.contract)
80
+ this.transformer = new RuntimeContractTransformer(this.options.contract)
81
+ this.#callers = this.buildCallers()
82
+ }
83
+
84
+ override get call() {
85
+ return this.#callers as ClientCallers<this['_']['routes'], SafeCall, false>
86
+ }
87
+
88
+ override get stream() {
89
+ return this.#callers as ClientCallers<this['_']['routes'], SafeCall, true>
90
+ }
91
+
92
+ protected resolveProcedures(router: TAnyRouterContract, path: string[] = []) {
93
+ for (const [key, route] of Object.entries(router.routes)) {
94
+ if (IsRouterContract(route)) {
95
+ this.resolveProcedures(route, [...path, key])
96
+ } else if (IsProcedureContract(route)) {
97
+ const fullName = [...path, key].join('/')
98
+ this.#procedures.set(fullName, route)
99
+ }
100
+ }
101
+ }
102
+
103
+ protected buildCallers(): ClientCallers<
104
+ this['_']['routes'],
105
+ SafeCall,
106
+ boolean
107
+ > {
108
+ const callers: Record<string, any> = Object.create(null)
109
+
110
+ for (const [name, { stream }] of this.#procedures) {
111
+ const parts = name.split('/')
112
+ let current = callers
113
+ for (let i = 0; i < parts.length; i++) {
114
+ const part = parts[i]
115
+ if (i === parts.length - 1) {
116
+ current[part] = (
117
+ payload?: unknown,
118
+ options?: Partial<ClientCallOptions>,
119
+ ) =>
120
+ this._call(name, payload, {
121
+ ...options,
122
+ _stream_response: !!stream,
123
+ })
124
+ } else {
125
+ current[part] = current[part] ?? Object.create(null)
126
+ current = current[part]
127
+ }
128
+ }
129
+ }
130
+
131
+ return callers as ClientCallers<this['_']['routes'], SafeCall, boolean>
132
+ }
133
+ }
@@ -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
+ }
package/src/events.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { Callback } from '@nmtjs/common'
2
+
3
+ export type EventMap = { [K: string]: any[] }
4
+
5
+ // TODO: add errors and promise rejections handling
6
+ /**
7
+ * Thin node-like event emitter wrapper around EventTarget
8
+ */
9
+ export class EventEmitter<
10
+ Events extends EventMap = EventMap,
11
+ EventName extends Extract<keyof Events, string> = Extract<
12
+ keyof Events,
13
+ string
14
+ >,
15
+ > {
16
+ static once<
17
+ T extends EventEmitter,
18
+ E extends T extends EventEmitter<any, infer Event> ? Event : never,
19
+ >(ee: T, event: E) {
20
+ return new Promise((resolve) => ee.once(event, resolve))
21
+ }
22
+
23
+ #target = new EventTarget()
24
+ #listeners = new Map<Callback, Callback>()
25
+
26
+ on<E extends EventName>(
27
+ event: E | (Object & string),
28
+ listener: (...args: Events[E]) => void,
29
+ options?: AddEventListenerOptions,
30
+ ) {
31
+ const wrapper = (event) => listener(...event.detail)
32
+ this.#listeners.set(listener, wrapper)
33
+ this.#target.addEventListener(event, wrapper, { ...options, once: false })
34
+ return () => this.#target.removeEventListener(event, wrapper)
35
+ }
36
+
37
+ once<E extends EventName>(
38
+ event: E | (Object & string),
39
+ listener: (...args: Events[E]) => void,
40
+ options?: AddEventListenerOptions,
41
+ ) {
42
+ return this.on(event, listener, { ...options, once: true })
43
+ }
44
+
45
+ off(event: EventName | (Object & string), listener: Callback) {
46
+ const wrapper = this.#listeners.get(listener)
47
+ if (wrapper) this.#target.removeEventListener(event, wrapper)
48
+ }
49
+
50
+ emit<E extends EventName | (Object & string)>(
51
+ event: E,
52
+ ...args: E extends EventName ? Events[E] : any[]
53
+ ) {
54
+ return this.#target.dispatchEvent(new CustomEvent(event, { detail: args }))
55
+ }
56
+ }
57
+
58
+ export const once = <
59
+ T extends EventEmitter,
60
+ EventMap extends T extends EventEmitter<infer E, any> ? E : never,
61
+ EventName extends T extends EventEmitter<any, infer N> ? N : never,
62
+ >(
63
+ ee: T,
64
+ event: EventName,
65
+ signal?: AbortSignal,
66
+ ) => {
67
+ return new Promise<EventMap[EventName]>((resolve) => {
68
+ ee.once(event, resolve, { signal })
69
+ })
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './core.ts'
2
+ export * from './events.ts'
3
+ export * from './transformers.ts'
4
+ export * from './transport.ts'
5
+ export * from './types.ts'
package/src/streams.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type { ProtocolBlobMetadata } from '@nmtjs/protocol'
2
+ import type { ProtocolServerStreamInterface } from '@nmtjs/protocol/client'
3
+ import { ProtocolClientBlobStream } from '@nmtjs/protocol/client'
4
+
5
+ export class ClientStreams {
6
+ readonly #collection = new Map<number, ProtocolClientBlobStream>()
7
+
8
+ get size() {
9
+ return this.#collection.size
10
+ }
11
+
12
+ get(streamId: number) {
13
+ const stream = this.#collection.get(streamId)
14
+ if (!stream) throw new Error('Stream not found')
15
+ return stream
16
+ }
17
+
18
+ add(
19
+ source: ReadableStream,
20
+ streamId: number,
21
+ metadata: ProtocolBlobMetadata,
22
+ ) {
23
+ const stream = new ProtocolClientBlobStream(source, streamId, metadata)
24
+ this.#collection.set(streamId, stream)
25
+ return stream
26
+ }
27
+
28
+ remove(streamId: number) {
29
+ this.#collection.delete(streamId)
30
+ }
31
+
32
+ async abort(streamId: number, reason?: string) {
33
+ const stream = this.#collection.get(streamId)
34
+ if (!stream) return // Stream already cleaned up
35
+ await stream.abort(reason)
36
+ this.remove(streamId)
37
+ }
38
+
39
+ pull(streamId: number, size: number) {
40
+ const stream = this.get(streamId)
41
+ return stream.read(size)
42
+ }
43
+
44
+ async end(streamId: number) {
45
+ await this.get(streamId).end()
46
+ this.remove(streamId)
47
+ }
48
+
49
+ async clear(reason?: string) {
50
+ if (reason) {
51
+ const abortPromises = [...this.#collection.values()].map((stream) =>
52
+ stream.abort(reason),
53
+ )
54
+ await Promise.all(abortPromises)
55
+ }
56
+ this.#collection.clear()
57
+ }
58
+ }
59
+
60
+ export class ServerStreams<
61
+ T extends ProtocolServerStreamInterface = ProtocolServerStreamInterface,
62
+ > {
63
+ readonly #collection = new Map<number, T>()
64
+ readonly #writers = new Map<number, WritableStreamDefaultWriter>()
65
+
66
+ get size() {
67
+ return this.#collection.size
68
+ }
69
+
70
+ has(streamId: number) {
71
+ return this.#collection.has(streamId)
72
+ }
73
+
74
+ get(streamId: number) {
75
+ const stream = this.#collection.get(streamId)
76
+ if (!stream) throw new Error('Stream not found')
77
+ return stream
78
+ }
79
+
80
+ add(streamId: number, stream: T) {
81
+ this.#collection.set(streamId, stream)
82
+ this.#writers.set(
83
+ streamId,
84
+ stream.writable.getWriter() as WritableStreamDefaultWriter,
85
+ )
86
+ return stream
87
+ }
88
+
89
+ remove(streamId: number) {
90
+ this.#collection.delete(streamId)
91
+ this.#writers.delete(streamId)
92
+ }
93
+
94
+ async abort(streamId: number) {
95
+ if (this.has(streamId)) {
96
+ const writer = this.#writers.get(streamId)
97
+ if (writer) {
98
+ await writer.abort()
99
+ writer.releaseLock()
100
+ }
101
+ this.remove(streamId)
102
+ }
103
+ }
104
+
105
+ async push(streamId: number, chunk: ArrayBufferView) {
106
+ const writer = this.#writers.get(streamId)
107
+ if (writer) {
108
+ return await writer.write(chunk)
109
+ }
110
+ }
111
+
112
+ async end(streamId: number) {
113
+ const writer = this.#writers.get(streamId)
114
+ if (writer) {
115
+ await writer.close()
116
+ writer.releaseLock()
117
+ }
118
+ this.remove(streamId)
119
+ }
120
+
121
+ async clear(reason?: string) {
122
+ if (reason) {
123
+ const abortPromises = [...this.#writers.values()].map((writer) =>
124
+ writer.abort(reason).finally(() => writer.releaseLock()),
125
+ )
126
+ await Promise.allSettled(abortPromises)
127
+ }
128
+ this.#collection.clear()
129
+ this.#writers.clear()
130
+ }
131
+ }
@@ -0,0 +1,8 @@
1
+ export class BaseClientTransformer {
2
+ encode(_procedure: string, payload: any) {
3
+ return payload
4
+ }
5
+ decode(_procedure: string, payload: any) {
6
+ return payload
7
+ }
8
+ }
@@ -0,0 +1,71 @@
1
+ import type {
2
+ ConnectionType,
3
+ ProtocolBlobMetadata,
4
+ ProtocolVersion,
5
+ } from '@nmtjs/protocol'
6
+ import type { BaseClientFormat } from '@nmtjs/protocol/client'
7
+
8
+ export type ClientTransportMessageOptions = {
9
+ signal?: AbortSignal
10
+ _stream_response?: boolean
11
+ }
12
+
13
+ export interface ClientTransportStartParams {
14
+ auth?: string
15
+ application?: string
16
+ onMessage: (message: ArrayBufferView) => any
17
+ onConnect: () => any
18
+ onDisconnect: (reason: 'client' | 'server' | (string & {})) => any
19
+ }
20
+
21
+ export interface ClientTransportRpcParams {
22
+ format: BaseClientFormat
23
+ auth?: string
24
+ application?: string
25
+ }
26
+
27
+ export type ClientCallResponse =
28
+ | { type: 'rpc'; result: ArrayBufferView }
29
+ | { type: 'rpc_stream'; stream: ReadableStream<ArrayBufferView> }
30
+ | {
31
+ type: 'blob'
32
+ metadata: ProtocolBlobMetadata
33
+ source: ReadableStream<ArrayBufferView>
34
+ }
35
+
36
+ export type ClientTransport<T extends ConnectionType = ConnectionType> =
37
+ T extends ConnectionType.Bidirectional
38
+ ? {
39
+ type: ConnectionType.Bidirectional
40
+ connect(params: ClientTransportStartParams): Promise<void>
41
+ disconnect(): Promise<void>
42
+ send(
43
+ message: ArrayBufferView,
44
+ options: ClientTransportMessageOptions,
45
+ ): Promise<void>
46
+ }
47
+ : {
48
+ type: ConnectionType.Unidirectional
49
+ connect?(params: ClientTransportStartParams): Promise<void>
50
+ disconnect?(): Promise<void>
51
+ call(
52
+ client: {
53
+ format: BaseClientFormat
54
+ auth?: string
55
+ application?: string
56
+ },
57
+ rpc: { callId: number; procedure: string; payload: any },
58
+ options: ClientTransportMessageOptions,
59
+ ): Promise<ClientCallResponse>
60
+ }
61
+
62
+ export interface ClientTransportParams {
63
+ protocol: ProtocolVersion
64
+ format: BaseClientFormat
65
+ }
66
+
67
+ export type ClientTransportFactory<
68
+ Type extends ConnectionType,
69
+ Options = unknown,
70
+ Transport extends ClientTransport<Type> = ClientTransport<Type>,
71
+ > = (params: ClientTransportParams, options: Options) => Transport
package/src/types.ts ADDED
@@ -0,0 +1,145 @@
1
+ import type { CallTypeProvider, OneOf, TypeProvider } from '@nmtjs/common'
2
+ import type { TAnyProcedureContract, TAnyRouterContract } from '@nmtjs/contract'
3
+ import type { ProtocolBlobInterface } from '@nmtjs/protocol'
4
+ import type {
5
+ ProtocolError,
6
+ ProtocolServerBlobStream,
7
+ ProtocolServerStreamInterface,
8
+ } from '@nmtjs/protocol/client'
9
+ import type { BaseTypeAny, PlainType, t } from '@nmtjs/type'
10
+
11
+ export const ResolvedType: unique symbol = Symbol('ResolvedType')
12
+ export type ResolvedType = typeof ResolvedType
13
+
14
+ export type ClientCallOptions = {
15
+ timeout?: number
16
+ signal?: AbortSignal
17
+ /**
18
+ * @internal
19
+ */
20
+ _stream_response?: boolean
21
+ }
22
+
23
+ export type ClientOutputType<T> = T extends ProtocolBlobInterface
24
+ ? (options?: { signal?: AbortSignal }) => ProtocolServerBlobStream
25
+ : T extends { [PlainType]?: true }
26
+ ? { [K in keyof Omit<T, PlainType>]: ClientOutputType<T[K]> }
27
+ : T
28
+
29
+ export interface StaticInputContractTypeProvider extends TypeProvider {
30
+ output: this['input'] extends BaseTypeAny
31
+ ? t.infer.decode.input<this['input']>
32
+ : never
33
+ }
34
+
35
+ export interface RuntimeInputContractTypeProvider extends TypeProvider {
36
+ output: this['input'] extends BaseTypeAny
37
+ ? t.infer.encode.input<this['input']>
38
+ : never
39
+ }
40
+
41
+ export interface StaticOutputContractTypeProvider extends TypeProvider {
42
+ output: this['input'] extends BaseTypeAny
43
+ ? ClientOutputType<t.infer.encodeRaw.output<this['input']>>
44
+ : never
45
+ }
46
+
47
+ export interface RuntimeOutputContractTypeProvider extends TypeProvider {
48
+ output: this['input'] extends BaseTypeAny
49
+ ? ClientOutputType<t.infer.decodeRaw.output<this['input']>>
50
+ : never
51
+ }
52
+
53
+ export type AnyResolvedContractProcedure = {
54
+ [ResolvedType]: 'procedure'
55
+ contract: TAnyProcedureContract
56
+ stream: boolean
57
+ input: any
58
+ output: any
59
+ }
60
+
61
+ export type AnyResolvedContractRouter = {
62
+ [ResolvedType]: 'router'
63
+ [key: string]:
64
+ | AnyResolvedContractProcedure
65
+ | { [ResolvedType]: 'router'; [key: string]: AnyResolvedContractProcedure }
66
+ }
67
+
68
+ export type ResolveAPIRouterRoutes<
69
+ T extends TAnyRouterContract,
70
+ InputTypeProvider extends TypeProvider = TypeProvider,
71
+ OutputTypeProvider extends TypeProvider = TypeProvider,
72
+ > = { [ResolvedType]: 'router' } & {
73
+ [K in keyof T['routes']]: T['routes'][K] extends TAnyProcedureContract
74
+ ? {
75
+ [ResolvedType]: 'procedure'
76
+ contract: T['routes'][K]
77
+ stream: T['routes'][K]['stream'] extends true ? true : false
78
+ input: CallTypeProvider<InputTypeProvider, T['routes'][K]['input']>
79
+ output: T['routes'][K]['stream'] extends true
80
+ ? AsyncIterable<
81
+ CallTypeProvider<OutputTypeProvider, T['routes'][K]['output']>
82
+ >
83
+ : CallTypeProvider<OutputTypeProvider, T['routes'][K]['output']>
84
+ }
85
+ : T['routes'][K] extends TAnyRouterContract
86
+ ? ResolveAPIRouterRoutes<
87
+ T['routes'][K],
88
+ InputTypeProvider,
89
+ OutputTypeProvider
90
+ >
91
+ : never
92
+ }
93
+
94
+ export type ResolveContract<
95
+ C extends TAnyRouterContract = TAnyRouterContract,
96
+ InputTypeProvider extends TypeProvider = TypeProvider,
97
+ OutputTypeProvider extends TypeProvider = TypeProvider,
98
+ > = ResolveAPIRouterRoutes<C, InputTypeProvider, OutputTypeProvider>
99
+
100
+ export type ClientCaller<
101
+ Procedure extends AnyResolvedContractProcedure,
102
+ SafeCall extends boolean,
103
+ > = (
104
+ ...args: Procedure['input'] extends t.NeverType
105
+ ? [data?: undefined, options?: Partial<ClientCallOptions>]
106
+ : undefined extends t.infer.encode.input<Procedure['contract']['input']>
107
+ ? [data?: Procedure['input'], options?: Partial<ClientCallOptions>]
108
+ : [data: Procedure['input'], options?: Partial<ClientCallOptions>]
109
+ ) => SafeCall extends true
110
+ ? Promise<OneOf<[{ result: Procedure['output'] }, { error: ProtocolError }]>>
111
+ : Promise<Procedure['output']>
112
+
113
+ type OmitType<T extends object, E> = {
114
+ [K in keyof T as T[K] extends E ? never : K]: T[K]
115
+ }
116
+
117
+ // export type FilterResolvedContractRouter<
118
+ // Resolved extends AnyResolvedContractRouter,
119
+ // Stream extends boolean,
120
+ // > = {
121
+ // [K in keyof Resolved]: Resolved[K] extends AnyResolvedContractProcedure
122
+ // ? Resolved[K]['stream'] extends Stream
123
+ // ? Resolved[K]
124
+ // : never
125
+ // : Resolved[K] extends AnyResolvedContractRouter
126
+ // ? FilterResolvedContractRouter<Resolved[K], Stream>
127
+ // : never
128
+ // }
129
+
130
+ export type ClientCallers<
131
+ Resolved extends AnyResolvedContractRouter,
132
+ SafeCall extends boolean,
133
+ Stream extends boolean,
134
+ > = OmitType<
135
+ {
136
+ [K in keyof Resolved]: Resolved[K] extends AnyResolvedContractProcedure
137
+ ? Stream extends (Resolved[K]['stream'] extends true ? true : false)
138
+ ? ClientCaller<Resolved[K], SafeCall>
139
+ : never
140
+ : Resolved[K] extends AnyResolvedContractRouter
141
+ ? ClientCallers<Resolved[K], SafeCall, Stream>
142
+ : never
143
+ },
144
+ never
145
+ >