@camera.ui/rpc 1.0.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.
package/dist/client.js ADDED
@@ -0,0 +1,1863 @@
1
+ import { connect, createInbox, errors, headers } from '@nats-io/transport-node';
2
+ import { Channel, PrivateChannel } from './channel.js';
3
+ import { ChunkingManager, createChunks } from './chunking.js';
4
+ import { decode, encode } from './codec.js';
5
+ import { extractNestedMethodsWithDecorators, extractNestedMethodsWithoutDecorators } from './decorators.js';
6
+ import { RPCException, createError } from './errors.js';
7
+ import { formatErrorObject, handleCallbackRequest, handleNormalRPC, handlePullCallbackRequest, handlePullIteratorRequest, handleStreamRequest } from './handler.js';
8
+ import { RPCService } from './service.js';
9
+ import { ERROR_CODES } from './types.js';
10
+ import { createProxy, createServiceProxy, generateId, isPromise, sleep } from './utils.js';
11
+ export function createRPCClient(options) {
12
+ return new RPCClient(options);
13
+ }
14
+ export class RPCClient {
15
+ options;
16
+ service = new RPCService(this);
17
+ chunkingManager = new ChunkingManager();
18
+ pullIteratorCleanups = new Map();
19
+ callbackCleanups = new Map();
20
+ nc;
21
+ subscriptions = new Map();
22
+ _subscriptionMeta = new Map();
23
+ _maxPayloadSize = 1024 * 1024; // Default 1MB
24
+ connectionPromise;
25
+ closed = false;
26
+ isolatedClients = [];
27
+ pendingRequests = new Map();
28
+ streamHandlers = new Map();
29
+ /**
30
+ * Check connection status
31
+ */
32
+ get isConnected() {
33
+ return this.nc?.isClosed() === false;
34
+ }
35
+ get isClosed() {
36
+ return this.closed;
37
+ }
38
+ /**
39
+ * Get the maximum payload size
40
+ */
41
+ get maxPayloadSize() {
42
+ return this._maxPayloadSize;
43
+ }
44
+ /**
45
+ * Access the underlying NATS connection status events.
46
+ * Yields events like 'reconnect', 'disconnect', 'reconnecting'.
47
+ */
48
+ status() {
49
+ return this.nc?.status();
50
+ }
51
+ /**
52
+ * Active liveness probe via NATS PING/PONG round-trip. Resolves when a PONG
53
+ * arrives, rejects on timeout. Caller treats timeout as "connection is dead"
54
+ * — the underlying socket may still report OPEN in that case (silent-dead
55
+ * TCP). Use a tight timeout (a few seconds) so a stale connection is
56
+ * detected promptly.
57
+ */
58
+ async flush(timeoutMs = 5000) {
59
+ if (!this.nc) {
60
+ throw createError(ERROR_CODES.CONNECTION_CLOSED, 'Not connected');
61
+ }
62
+ let timeoutHandle;
63
+ try {
64
+ await Promise.race([
65
+ this.nc.flush(),
66
+ new Promise((_, reject) => {
67
+ timeoutHandle = setTimeout(() => reject(createError(ERROR_CODES.TIMEOUT, `flush() timed out after ${timeoutMs}ms`)), timeoutMs);
68
+ }),
69
+ ]);
70
+ }
71
+ finally {
72
+ if (timeoutHandle)
73
+ clearTimeout(timeoutHandle);
74
+ }
75
+ }
76
+ constructor(options) {
77
+ this.options = options;
78
+ }
79
+ /**
80
+ * Create a new isolated RPC client
81
+ * @param options - Options for the isolated client
82
+ */
83
+ createIsolatedClient(options) {
84
+ return new RPCClient(options);
85
+ }
86
+ /**
87
+ * Connect to NATS server. Accepts an AbortSignal so callers can cancel
88
+ * an in-flight handshake when another candidate has won.
89
+ * The underlying nats-js connect() does not itself accept a signal,
90
+ * so we wrap it: on abort we synchronously close any late-arriving connection
91
+ * via the fork's abortClose() and reject with AbortError.
92
+ */
93
+ async connect(options) {
94
+ if (options?.signal?.aborted)
95
+ throw new DOMException('Aborted', 'AbortError');
96
+ if (this.nc && !this.nc.isClosed()) {
97
+ // Already connected
98
+ return this.nc;
99
+ }
100
+ const natsOptions = {
101
+ servers: this.options.servers,
102
+ user: this.options.auth?.user,
103
+ pass: this.options.auth?.password,
104
+ name: this.options.name,
105
+ reconnect: this.options.reconnect ?? true,
106
+ maxPingOut: this.options.maxPingOut ?? 2,
107
+ maxReconnectAttempts: this.options.maxReconnectAttempts ?? -1,
108
+ reconnectTimeWait: this.options.reconnectTimeWait ?? 2000,
109
+ reconnectJitter: this.options.reconnectJitter ?? 100,
110
+ reconnectJitterTLS: this.options.reconnectJitterTLS ?? 1000,
111
+ ignoreAuthErrorAbort: this.options.ignoreAuthErrorAbort ?? false,
112
+ pingInterval: this.options.pingInterval ?? 120000,
113
+ pingTimeout: this.options.pingTimeout ?? 0,
114
+ reconnectionDelayMax: this.options.reconnectionDelayMax ?? 0,
115
+ reconnectionRandomizationFactor: this.options.reconnectionRandomizationFactor ?? 0,
116
+ tls: this.options.tls,
117
+ debug: this.options.debug ?? false,
118
+ // noEcho: true,
119
+ noAsyncTraces: true,
120
+ waitOnFirstConnect: this.options.waitOnFirstConnect ?? true,
121
+ signal: options?.signal,
122
+ };
123
+ this.connectionPromise ??= connect(natsOptions);
124
+ const innerPromise = this.connectionPromise;
125
+ try {
126
+ this.nc = await makeAbortableConnect(innerPromise, options?.signal);
127
+ }
128
+ finally {
129
+ this.connectionPromise = undefined;
130
+ }
131
+ this.service.init(this.nc);
132
+ // Get max_payload from server info
133
+ try {
134
+ const serverInfo = this.nc.info;
135
+ this._maxPayloadSize = serverInfo?.max_payload ?? this.options.maxPayloadSize ?? 1024 * 1024;
136
+ }
137
+ catch {
138
+ this._maxPayloadSize = this.options.maxPayloadSize ?? 1024 * 1024;
139
+ }
140
+ // Reserve 8KB for NATS protocol overhead and MsgPack envelope per message
141
+ this._maxPayloadSize = this._maxPayloadSize - 8192;
142
+ // Restore subscriptions after reconnect (from suspend)
143
+ if (this._subscriptionMeta.size > 0) {
144
+ const metas = [...this._subscriptionMeta.values()];
145
+ this._subscriptionMeta.clear();
146
+ for (const meta of metas) {
147
+ await this.subscribe(meta.pattern, meta.handler, meta.options);
148
+ }
149
+ }
150
+ return this.nc;
151
+ }
152
+ /**
153
+ * Disconnect from NATS server
154
+ * Cleans up resources without rejecting pending operations
155
+ */
156
+ async disconnect() {
157
+ this.closed = true;
158
+ // Cleanup pending requests
159
+ for (const [, pending] of this.pendingRequests) {
160
+ if (pending.timeout) {
161
+ clearTimeout(pending.timeout);
162
+ }
163
+ pending.reject(createError(ERROR_CODES.CONNECTION_CLOSED, 'Connection closed'));
164
+ }
165
+ this.pendingRequests.clear();
166
+ // Cleanup stream handlers
167
+ for (const [, handler] of this.streamHandlers) {
168
+ try {
169
+ handler.end();
170
+ }
171
+ catch {
172
+ // Ignore errors during cleanup
173
+ }
174
+ }
175
+ this.streamHandlers.clear();
176
+ await Promise.allSettled(Array.from(this.pullIteratorCleanups.values()).map((cleanup) => cleanup()));
177
+ this.pullIteratorCleanups.clear();
178
+ // Cleanup callbacks
179
+ await Promise.allSettled(Array.from(this.callbackCleanups.values()).map((cleanup) => cleanup()));
180
+ this.callbackCleanups.clear();
181
+ // Unsubscribe all subscriptions
182
+ for (const subs of this.subscriptions.values()) {
183
+ for (const sub of subs) {
184
+ try {
185
+ sub.unsubscribe();
186
+ }
187
+ catch {
188
+ // Ignore errors during cleanup
189
+ }
190
+ }
191
+ }
192
+ this.subscriptions.clear();
193
+ this._subscriptionMeta.clear();
194
+ // Clear chunking manager
195
+ this.chunkingManager = new ChunkingManager();
196
+ // Disconnect isolated clients
197
+ await Promise.allSettled(this.isolatedClients.map((client) => client.disconnect()));
198
+ // Drain and close connection
199
+ if (this.nc) {
200
+ try {
201
+ await this.nc.close();
202
+ }
203
+ catch {
204
+ // Ignore errors
205
+ }
206
+ // Ensure the connection is truly closed. With waitOnFirstConnect: false the
207
+ // underlying WebSocket transport may still be mid-handshake when close() is
208
+ // called. nc.closed() resolves once the NATS protocol is fully shut down,
209
+ // preventing zombie connections that survive a disconnect/reconnect cycle.
210
+ try {
211
+ const timeout = this.options.disconnectTimeout ?? 2_000;
212
+ await Promise.race([this.nc.closed(), new Promise((r) => setTimeout(r, timeout))]);
213
+ }
214
+ catch {
215
+ // Ignore errors
216
+ }
217
+ this.nc = undefined;
218
+ }
219
+ }
220
+ /**
221
+ * Update connection options between suspend() and connect(). Used for token
222
+ * rotation / endpoint switching: subscription metadata is preserved by
223
+ * suspend(), reconfigure() points the next connect() at the new server, and
224
+ * connect() re-subscribes everything on the fresh transport.
225
+ */
226
+ reconfigure(overrides) {
227
+ if (this.nc && !this.nc.isClosed()) {
228
+ throw new Error('Cannot reconfigure while connected. Call suspend() first.');
229
+ }
230
+ if (overrides.servers !== undefined) {
231
+ this.options.servers = overrides.servers;
232
+ }
233
+ if (overrides.auth !== undefined) {
234
+ this.options.auth = overrides.auth;
235
+ }
236
+ for (const child of this.isolatedClients) {
237
+ child.reconfigure(overrides);
238
+ }
239
+ }
240
+ /**
241
+ * Hot-swap the server pool without dropping the live connection.
242
+ * Use this when only the URL needs to change but the existing connection
243
+ * is still authenticated and serving traffic. The new pool kicks in on the next auto-reconnect.
244
+ *
245
+ * For a forced switch (e.g. endpoint host changed), use suspend() +
246
+ * reconfigure() + connect() instead.
247
+ */
248
+ setServers(servers) {
249
+ this.options.servers = servers;
250
+ if (this.nc && !this.nc.isClosed()) {
251
+ this.nc.setServers(servers);
252
+ }
253
+ for (const child of this.isolatedClients) {
254
+ child.setServers(servers);
255
+ }
256
+ }
257
+ /**
258
+ * Force the current dial loop (if any) to immediately retry with the latest
259
+ * server pool — including from a stuck mid-handshake or sleeping-on-delay
260
+ * state. Falls back to standard `reconnect()` on transports that don't ship
261
+ * the fork's `forceReconnect()` (e.g. server-side TCP via npm `@nats-io/nats-core`).
262
+ */
263
+ async forceReconnect() {
264
+ const nc = this.nc;
265
+ if (!nc || nc.isClosed())
266
+ return;
267
+ try {
268
+ if (typeof nc.forceReconnect === 'function') {
269
+ await nc.forceReconnect();
270
+ }
271
+ else if (typeof nc.reconnect === 'function') {
272
+ await nc.reconnect();
273
+ }
274
+ }
275
+ catch {
276
+ // Either method may reject if the connection raced into closed —
277
+ // not actionable from here.
278
+ }
279
+ await Promise.allSettled(this.isolatedClients.map((c) => c.forceReconnect()));
280
+ }
281
+ /**
282
+ * Synchronous force-tear-down without awaiting the transport's close
283
+ * handshake. Use during network changes when the WS is half-open and a
284
+ * normal `close()` would hang for 2s+ on the dead socket. Falls back to
285
+ * fire-and-forget `close()` on transports without the fork patch.
286
+ */
287
+ abortClose(err) {
288
+ this.closed = true;
289
+ for (const [, pending] of this.pendingRequests) {
290
+ if (pending.timeout)
291
+ clearTimeout(pending.timeout);
292
+ pending.reject(createError(ERROR_CODES.CONNECTION_CLOSED, 'Connection closed'));
293
+ }
294
+ this.pendingRequests.clear();
295
+ for (const [, handler] of this.streamHandlers) {
296
+ try {
297
+ handler.end();
298
+ }
299
+ catch {
300
+ // ignore
301
+ }
302
+ }
303
+ this.streamHandlers.clear();
304
+ for (const cleanup of this.pullIteratorCleanups.values()) {
305
+ try {
306
+ cleanup();
307
+ }
308
+ catch {
309
+ // ignore
310
+ }
311
+ }
312
+ this.pullIteratorCleanups.clear();
313
+ for (const cleanup of this.callbackCleanups.values()) {
314
+ try {
315
+ cleanup();
316
+ }
317
+ catch {
318
+ // ignore
319
+ }
320
+ }
321
+ this.callbackCleanups.clear();
322
+ for (const child of this.isolatedClients) {
323
+ try {
324
+ child.abortClose(err);
325
+ }
326
+ catch {
327
+ // ignore
328
+ }
329
+ }
330
+ const nc = this.nc;
331
+ if (nc) {
332
+ try {
333
+ if (typeof nc.abortClose === 'function') {
334
+ nc.abortClose(err);
335
+ }
336
+ else {
337
+ // Server fallback: fire-and-forget. nc.close() returns a Promise we
338
+ // intentionally don't await — caller wants synchronous semantics.
339
+ nc.close().catch(() => { });
340
+ }
341
+ }
342
+ catch {
343
+ // ignore
344
+ }
345
+ this.nc = undefined;
346
+ }
347
+ }
348
+ /**
349
+ * Suspend the connection without clearing subscription metadata.
350
+ * After calling suspend(), connect() will restore previous subscriptions.
351
+ */
352
+ async suspend() {
353
+ // Cleanup pending requests
354
+ for (const [, pending] of this.pendingRequests) {
355
+ if (pending.timeout) {
356
+ clearTimeout(pending.timeout);
357
+ }
358
+ pending.reject(createError(ERROR_CODES.CONNECTION_CLOSED, 'Connection closed'));
359
+ }
360
+ this.pendingRequests.clear();
361
+ // Cleanup stream handlers
362
+ for (const [, handler] of this.streamHandlers) {
363
+ try {
364
+ handler.end();
365
+ }
366
+ catch {
367
+ // Ignore errors during cleanup
368
+ }
369
+ }
370
+ this.streamHandlers.clear();
371
+ await Promise.allSettled(Array.from(this.pullIteratorCleanups.values()).map((cleanup) => cleanup()));
372
+ this.pullIteratorCleanups.clear();
373
+ // Cleanup callbacks
374
+ await Promise.allSettled(Array.from(this.callbackCleanups.values()).map((cleanup) => cleanup()));
375
+ this.callbackCleanups.clear();
376
+ // Unsubscribe all subscriptions
377
+ for (const subs of this.subscriptions.values()) {
378
+ for (const sub of subs) {
379
+ try {
380
+ sub.unsubscribe();
381
+ }
382
+ catch {
383
+ // Ignore errors during cleanup
384
+ }
385
+ }
386
+ }
387
+ this.subscriptions.clear();
388
+ // Clear chunking manager
389
+ this.chunkingManager = new ChunkingManager();
390
+ // Suspend isolated clients
391
+ await Promise.allSettled(this.isolatedClients.map((client) => client.suspend()));
392
+ // Drain and close connection
393
+ if (this.nc) {
394
+ try {
395
+ await this.nc.close();
396
+ }
397
+ catch {
398
+ // Ignore errors
399
+ }
400
+ try {
401
+ const timeout = this.options.disconnectTimeout ?? 2_000;
402
+ await Promise.race([this.nc.closed(), new Promise((r) => setTimeout(r, timeout))]);
403
+ }
404
+ catch {
405
+ // Ignore errors
406
+ }
407
+ this.nc = undefined;
408
+ }
409
+ this.connectionPromise = undefined;
410
+ }
411
+ /**
412
+ * Public publish method
413
+ */
414
+ async publish(subject, data, opts) {
415
+ if (!this.nc) {
416
+ throw new Error('Not connected');
417
+ }
418
+ const encoded = encode(data);
419
+ // Small enough to send directly
420
+ if (encoded.length <= this._maxPayloadSize) {
421
+ this.nc.publish(subject, encoded, opts);
422
+ return;
423
+ }
424
+ // Message is too large, chunk it
425
+ const transferId = generateId();
426
+ // Calculate chunk count without materializing the generator
427
+ const totalChunks = Math.ceil(encoded.length / this._maxPayloadSize);
428
+ // Send header message first (MessagePack encoded)
429
+ const headerMsg = {
430
+ type: 'chunked',
431
+ transferId,
432
+ totalChunks,
433
+ totalSize: encoded.length,
434
+ chunkSize: this._maxPayloadSize,
435
+ };
436
+ // Header message includes original headers if any
437
+ const hdrs = opts?.headers ?? headers();
438
+ hdrs.set('x-chunked-transfer', 'header');
439
+ hdrs.set('x-chunk-id', transferId);
440
+ this.nc.publish(subject, encode(headerMsg), { headers: hdrs, reply: opts?.reply });
441
+ // Send chunks directly from generator (no Array.from() - saves memory)
442
+ let chunkIndex = 0;
443
+ for (const chunk of createChunks(encoded, transferId, this._maxPayloadSize)) {
444
+ const chunkHdrs = headers();
445
+ chunkHdrs.set('x-chunked-transfer', 'chunk');
446
+ chunkHdrs.set('x-chunk-id', transferId);
447
+ chunkHdrs.set('x-chunk-index', chunkIndex.toString());
448
+ // Send raw chunk data (not encoded)
449
+ this.nc.publish(subject, chunk.data, { headers: chunkHdrs, reply: opts?.reply });
450
+ // Yield every 50 chunks to prevent blocking
451
+ if (chunkIndex > 0 && chunkIndex % 50 === 0) {
452
+ await sleep(0);
453
+ }
454
+ chunkIndex++;
455
+ }
456
+ }
457
+ /**
458
+ * Public subscribe method
459
+ */
460
+ async subscribe(pattern, handler, options) {
461
+ if (!this.nc) {
462
+ throw new Error('Not connected');
463
+ }
464
+ // Serialize async handlers via a per-subscription promise chain. This
465
+ // matches Python's behavior (client.py:434 awaits the handler) and is
466
+ // what backpressure-sensitive callers (e.g. pull-callback iterators)
467
+ // rely on: an awaiting handler blocks the next message from being
468
+ // dispatched, which transitively stalls the producer.
469
+ //
470
+ // Sync handlers bypass the chain and stay fire-and-forget.
471
+ let handlerChain = Promise.resolve();
472
+ const runHandler = (data) => {
473
+ let result;
474
+ try {
475
+ result = handler(data);
476
+ }
477
+ catch (error) {
478
+ console.error(`Error in handler for ${pattern}:`, error);
479
+ return;
480
+ }
481
+ if (isPromise(result)) {
482
+ const pending = result;
483
+ handlerChain = handlerChain.then(() => pending).catch((error) => console.error(`Error in handler for ${pattern}:`, error));
484
+ }
485
+ };
486
+ const processMessage = (err, msg) => {
487
+ if (err) {
488
+ console.error(`Subscription error for ${pattern}:`, err);
489
+ return;
490
+ }
491
+ try {
492
+ const chunkType = msg.headers?.get('x-chunked-transfer');
493
+ if (chunkType === 'header') {
494
+ // Chunked transfer header
495
+ const data = decode(msg.data);
496
+ const chunkId = msg.headers?.get('x-chunk-id');
497
+ if (!chunkId || data.transferId !== chunkId) {
498
+ console.error('Invalid chunk header');
499
+ return;
500
+ }
501
+ // Setup chunk assembly with pre-allocated buffer (optimized)
502
+ this.chunkingManager.startReceiving(data.transferId, data.totalChunks, (assembledData) => {
503
+ runHandler(assembledData);
504
+ }, (error) => {
505
+ console.error(`Error assembling chunks for ${pattern}:`, error);
506
+ }, data.totalSize, // Pass totalSize for pre-allocated buffer optimization
507
+ data.chunkSize);
508
+ }
509
+ else if (chunkType === 'chunk') {
510
+ // Chunk data
511
+ const chunkId = msg.headers?.get('x-chunk-id');
512
+ const chunkIndex = parseInt(msg.headers?.get('x-chunk-index') ?? '0');
513
+ if (!chunkId) {
514
+ console.error('Chunk missing chunk ID');
515
+ return;
516
+ }
517
+ // Process raw chunk data
518
+ this.chunkingManager.processChunk({
519
+ id: chunkId,
520
+ chunkIndex,
521
+ data: msg.data,
522
+ isLast: false, // Determined by total chunks from header
523
+ });
524
+ }
525
+ else {
526
+ // Regular message - decode MessagePack data
527
+ const data = decode(msg.data);
528
+ runHandler(data);
529
+ }
530
+ }
531
+ catch (error) {
532
+ console.error(`Error processing message for ${pattern}:`, error);
533
+ }
534
+ };
535
+ const sub = this.nc.subscribe(pattern, {
536
+ ...(options?.queue ? { queue: options.queue } : {}),
537
+ callback: processMessage,
538
+ });
539
+ this.subscriptions.set(pattern, [sub]);
540
+ this._subscriptionMeta.set(pattern, { pattern, handler, options });
541
+ const unsubscribe = () => {
542
+ try {
543
+ sub.unsubscribe();
544
+ }
545
+ catch {
546
+ // Ignore unsubscribe errors
547
+ }
548
+ finally {
549
+ this.subscriptions.delete(pattern);
550
+ this._subscriptionMeta.delete(pattern);
551
+ }
552
+ };
553
+ // Return unsubscribe function
554
+ return unsubscribe;
555
+ }
556
+ /**
557
+ * Native NATS request/reply
558
+ * @param subject - The subject to send the request to
559
+ * @param data - The request data
560
+ * @param options - Request options including timeout and per-call retry override
561
+ */
562
+ async request(subject, data, options) {
563
+ return this.withNoResponderRetry(() => this._requestOnce(subject, data, options), options?.noResponderRetry);
564
+ }
565
+ async _requestOnce(subject, data, options) {
566
+ if (!this.nc) {
567
+ throw new Error('Not connected');
568
+ }
569
+ const timeout = options?.timeout ?? 5000;
570
+ const encoded = encode(data);
571
+ try {
572
+ // Use native NATS request
573
+ const msg = await this.nc.request(subject, encoded, {
574
+ timeout,
575
+ headers: options?.headers,
576
+ noMux: false, // Allow request multiplexing
577
+ });
578
+ // Check for NATS micro service error response
579
+ if (msg.headers?.get('Nats-Service-Error-Code')) {
580
+ const errorCode = msg.headers.get('Nats-Service-Error-Code') || '500';
581
+ const errorMsg = msg.headers.get('Nats-Service-Error') || 'Service error';
582
+ let errorData = null;
583
+ // Try to decode error data
584
+ if (msg.data) {
585
+ try {
586
+ errorData = decode(msg.data);
587
+ }
588
+ catch {
589
+ // Ignore decoding errors
590
+ }
591
+ }
592
+ throw createError(errorCode, errorMsg, errorData);
593
+ }
594
+ const decoded = decode(msg.data);
595
+ // Check if response contains an error field (for request handlers)
596
+ if (decoded?.error) {
597
+ const code = decoded.code ?? ERROR_CODES.INTERNAL_ERROR;
598
+ const message = decoded.error ?? 'Unknown error';
599
+ throw createError(code, message, decoded);
600
+ }
601
+ return decoded;
602
+ }
603
+ catch (error) {
604
+ if (error.code === '503' || error.message?.includes('no responders')) {
605
+ throw createError(ERROR_CODES.NOT_FOUND, 'No responders available');
606
+ }
607
+ if (error.code === 'TIMEOUT' || error.message?.includes('timeout')) {
608
+ throw createError(ERROR_CODES.TIMEOUT, `Request to "${subject}" timed out after ${timeout}ms`);
609
+ }
610
+ throw error;
611
+ }
612
+ }
613
+ /**
614
+ * Retry helper for 503 / no-responder errors. The per-call `override`
615
+ * lets a request that targets a known-flaky responder (e.g. a child
616
+ * process that may be restarting) extend the wait window without
617
+ * affecting the client-wide default.
618
+ */
619
+ async withNoResponderRetry(fn, override) {
620
+ const maxRetries = override?.maxRetries ?? this.options.noResponderRetry?.maxRetries ?? 3;
621
+ const delays = override?.delays ?? this.options.noResponderRetry?.delays ?? [500, 1000, 2000];
622
+ for (let attempt = 0;; attempt++) {
623
+ try {
624
+ return await fn();
625
+ }
626
+ catch (err) {
627
+ const isNoResponder = err instanceof errors.NoRespondersError || (err instanceof RPCException && err.code === ERROR_CODES.NOT_FOUND);
628
+ if (!isNoResponder || attempt >= maxRetries || this.closed) {
629
+ throw err;
630
+ }
631
+ const delay = delays[Math.min(attempt, delays.length - 1)];
632
+ await sleep(delay);
633
+ }
634
+ }
635
+ }
636
+ /**
637
+ * Make an RPC call
638
+ */
639
+ async call(subject, ...args) {
640
+ return this.withNoResponderRetry(() => this._callOnce(subject, ...args));
641
+ }
642
+ /**
643
+ * Make an RPC call (single attempt)
644
+ */
645
+ async _callOnce(subject, ...args) {
646
+ if (!this.isConnected && !this.isClosed) {
647
+ await this.connect();
648
+ }
649
+ if (!this.nc) {
650
+ throw new Error('Not connected');
651
+ }
652
+ const id = generateId();
653
+ const timeout = this.options.timeout ?? 30000;
654
+ // Use different reply patterns for RPC vs service calls
655
+ const replySubject = subject.startsWith('rpc.') ? `rpc.reply.${id}` : `${subject}.reply.${id}`;
656
+ return new Promise(async (resolve, reject) => {
657
+ // Initialize variables
658
+ let sub;
659
+ let unsubscribe;
660
+ // Setup timeout
661
+ const timeoutHandle = setTimeout(() => {
662
+ if (this.pendingRequests.has(id)) {
663
+ this.pendingRequests.delete(id);
664
+ reject(createError(ERROR_CODES.TIMEOUT, `RPC call to "${subject}" timed out after ${timeout}ms`));
665
+ }
666
+ }, timeout);
667
+ // Store pending request
668
+ this.pendingRequests.set(id, {
669
+ resolve,
670
+ reject,
671
+ timeout: timeoutHandle,
672
+ });
673
+ // Unsubscribe function to clean up
674
+ const unsubscribeAll = async () => {
675
+ if (this.pendingRequests.has(id)) {
676
+ const pending = this.pendingRequests.get(id);
677
+ if (pending?.timeout) {
678
+ clearTimeout(pending.timeout);
679
+ }
680
+ this.pendingRequests.delete(id);
681
+ }
682
+ if (sub && !sub.isClosed()) {
683
+ try {
684
+ sub.unsubscribe();
685
+ }
686
+ catch {
687
+ // Ignore unsubscribe errors
688
+ }
689
+ }
690
+ unsubscribe?.();
691
+ };
692
+ // Subscribe to reply
693
+ const handleRpcResponse = async (data) => {
694
+ const response = data;
695
+ if (response.id === id) {
696
+ const pending = this.pendingRequests.get(response.id);
697
+ if (pending) {
698
+ this.pendingRequests.delete(response.id);
699
+ if (pending.timeout)
700
+ clearTimeout(pending.timeout);
701
+ await unsubscribeAll();
702
+ if (response.error) {
703
+ pending.reject(RPCException.fromJSON(response.error));
704
+ }
705
+ else {
706
+ const result = response.result;
707
+ // Attach __methods to result for proxy method discovery
708
+ // Skip binary data types (Uint8Array, ArrayBuffer)
709
+ const isBinaryData = result instanceof Uint8Array || result instanceof ArrayBuffer || (typeof Buffer !== 'undefined' && Buffer.isBuffer(result));
710
+ if (response.__methods && result !== null && typeof result === 'object' && !isBinaryData) {
711
+ result.__methods = response.__methods;
712
+ }
713
+ pending.resolve(result);
714
+ }
715
+ }
716
+ }
717
+ };
718
+ const requestCallback = (err, msg) => {
719
+ // Check for no responders status (empty message with 503 status)
720
+ if (msg && msg.data?.length === 0 && msg.headers?.code === 503) {
721
+ reject(new errors.NoRespondersError(subject));
722
+ unsubscribeAll();
723
+ }
724
+ else if (err) {
725
+ reject(err);
726
+ unsubscribeAll();
727
+ }
728
+ };
729
+ try {
730
+ unsubscribe = await this.subscribe(replySubject, handleRpcResponse);
731
+ const inbox = createInbox();
732
+ sub = this.nc.subscribe(inbox, {
733
+ max: 1,
734
+ callback: requestCallback,
735
+ });
736
+ // Send request
737
+ const message = { id, method: 'call', params: args };
738
+ await this.publish(subject, message, { reply: inbox });
739
+ }
740
+ catch (error) {
741
+ await unsubscribeAll();
742
+ reject(error);
743
+ }
744
+ });
745
+ }
746
+ /**
747
+ * Make a streaming RPC call
748
+ */
749
+ async *callStream(subject, ...args) {
750
+ const maxRetries = this.options.noResponderRetry?.maxRetries ?? 3;
751
+ const delays = this.options.noResponderRetry?.delays ?? [500, 1000, 2000];
752
+ for (let attempt = 0;; attempt++) {
753
+ try {
754
+ yield* this._callStreamOnce(subject, ...args);
755
+ return;
756
+ }
757
+ catch (err) {
758
+ const isNoResponder = err instanceof errors.NoRespondersError || (err instanceof RPCException && err.code === ERROR_CODES.NOT_FOUND);
759
+ if (!isNoResponder || attempt >= maxRetries || this.closed) {
760
+ throw err;
761
+ }
762
+ await sleep(delays[Math.min(attempt, delays.length - 1)]);
763
+ }
764
+ }
765
+ }
766
+ /**
767
+ * Make a streaming RPC call (single attempt).
768
+ *
769
+ * Manual iterator — see callPullIteratorWithCallback for rationale.
770
+ * The push-based stream still parks the client at `await resolver`
771
+ * while waiting for the next stream message; if the server stops
772
+ * sending without an `end` frame, the generator would hang on
773
+ * iter.return(). Force-settling the pending resolver lets return()
774
+ * run cleanup cleanly.
775
+ */
776
+ _callStreamOnce(subject, ...args) {
777
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
778
+ const client = this;
779
+ let started = false;
780
+ let returned = false;
781
+ let cleanedUp = false;
782
+ let ended = false;
783
+ let error = null;
784
+ let id = '';
785
+ let streamSubject = '';
786
+ let sub;
787
+ let unsubscribe;
788
+ const queue = [];
789
+ let resolver = null;
790
+ const settlePendingAsDone = () => {
791
+ const r = resolver;
792
+ if (r) {
793
+ resolver = null;
794
+ r({ value: undefined, done: true });
795
+ }
796
+ };
797
+ const handler = {
798
+ push: (value) => {
799
+ if (ended)
800
+ return;
801
+ if (resolver) {
802
+ const r = resolver;
803
+ resolver = null;
804
+ r({ value, done: false });
805
+ }
806
+ else {
807
+ queue.push(value);
808
+ }
809
+ },
810
+ end: () => {
811
+ ended = true;
812
+ settlePendingAsDone();
813
+ },
814
+ error: (err) => {
815
+ error = err;
816
+ ended = true;
817
+ settlePendingAsDone();
818
+ },
819
+ };
820
+ const cleanupOnce = async () => {
821
+ if (cleanedUp)
822
+ return;
823
+ cleanedUp = true;
824
+ client.streamHandlers.delete(id);
825
+ if (sub && !sub.isClosed()) {
826
+ try {
827
+ sub.unsubscribe();
828
+ }
829
+ catch {
830
+ // ignore
831
+ }
832
+ }
833
+ unsubscribe?.();
834
+ if (!ended) {
835
+ ended = true;
836
+ try {
837
+ await client.publish(`${streamSubject}.cancel`, { id });
838
+ }
839
+ catch {
840
+ // ignore
841
+ }
842
+ }
843
+ };
844
+ const handleStreamMessage = async (msg) => {
845
+ if (msg.id !== id)
846
+ return;
847
+ const h = client.streamHandlers.get(msg.id);
848
+ if (!h)
849
+ return;
850
+ switch (msg.type) {
851
+ case 'data':
852
+ h.push(msg.data);
853
+ break;
854
+ case 'end':
855
+ h.end();
856
+ await cleanupOnce();
857
+ break;
858
+ case 'error':
859
+ h.error(RPCException.fromJSON(msg.error));
860
+ await cleanupOnce();
861
+ break;
862
+ }
863
+ };
864
+ const requestCallback = (err, msg) => {
865
+ if (msg && msg.data?.length === 0 && msg.headers?.code === 503) {
866
+ const h = client.streamHandlers.get(id);
867
+ h?.error(new errors.NoRespondersError(subject));
868
+ cleanupOnce();
869
+ }
870
+ else if (err) {
871
+ const h = client.streamHandlers.get(id);
872
+ h?.error(err);
873
+ cleanupOnce();
874
+ }
875
+ };
876
+ const setup = async () => {
877
+ if (!client.isConnected && !client.isClosed) {
878
+ await client.connect();
879
+ }
880
+ if (!client.nc)
881
+ throw new Error('Not connected');
882
+ id = generateId();
883
+ streamSubject = `stream.${subject}.${id}`;
884
+ client.streamHandlers.set(id, handler);
885
+ unsubscribe = await client.subscribe(streamSubject, handleStreamMessage);
886
+ const inbox = createInbox();
887
+ sub = client.nc.subscribe(inbox, { max: 1, callback: requestCallback });
888
+ const streamParams = { __stream: true, __streamSubject: streamSubject, args };
889
+ const message = { id, method: 'stream', params: streamParams };
890
+ await client.publish(subject, message, { reply: inbox });
891
+ };
892
+ const iter = {
893
+ async next() {
894
+ if (returned)
895
+ return { value: undefined, done: true };
896
+ if (!started) {
897
+ started = true;
898
+ try {
899
+ await setup();
900
+ }
901
+ catch (err) {
902
+ await cleanupOnce();
903
+ throw err;
904
+ }
905
+ if (returned) {
906
+ await cleanupOnce();
907
+ return { value: undefined, done: true };
908
+ }
909
+ }
910
+ if (error) {
911
+ await cleanupOnce();
912
+ throw error;
913
+ }
914
+ if (queue.length > 0) {
915
+ return { value: queue.shift(), done: false };
916
+ }
917
+ if (ended) {
918
+ await cleanupOnce();
919
+ return { value: undefined, done: true };
920
+ }
921
+ const result = await new Promise((resolve, reject) => {
922
+ if (returned) {
923
+ resolve({ value: undefined, done: true });
924
+ return;
925
+ }
926
+ if (ended) {
927
+ resolve({ value: undefined, done: true });
928
+ return;
929
+ }
930
+ if (error) {
931
+ reject(error);
932
+ return;
933
+ }
934
+ resolver = resolve;
935
+ });
936
+ if (returned) {
937
+ await cleanupOnce();
938
+ return { value: undefined, done: true };
939
+ }
940
+ if (result.done) {
941
+ if (error) {
942
+ await cleanupOnce();
943
+ throw error;
944
+ }
945
+ await cleanupOnce();
946
+ return { value: undefined, done: true };
947
+ }
948
+ return { value: result.value, done: false };
949
+ },
950
+ async return(value) {
951
+ if (returned)
952
+ return { value: value, done: true };
953
+ returned = true;
954
+ settlePendingAsDone();
955
+ if (started)
956
+ await cleanupOnce();
957
+ return { value: value, done: true };
958
+ },
959
+ async throw(err) {
960
+ if (returned)
961
+ throw err;
962
+ returned = true;
963
+ settlePendingAsDone();
964
+ if (started)
965
+ await cleanupOnce();
966
+ throw err;
967
+ },
968
+ [Symbol.asyncIterator]() {
969
+ return iter;
970
+ },
971
+ async [Symbol.asyncDispose]() {
972
+ await iter.return();
973
+ },
974
+ };
975
+ return iter;
976
+ }
977
+ /**
978
+ * Make a pull-based iterator RPC call
979
+ */
980
+ callPullIterator(subject, ...args) {
981
+ // Manual iterator — see callPullIteratorWithCallback for rationale.
982
+ // `iter.return()` force-settles the pending response promise so
983
+ // cleanup can run even when the server is parked at yield.
984
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
985
+ const client = this;
986
+ let started = false;
987
+ let returned = false;
988
+ let cleanedUp = false;
989
+ let ended = false;
990
+ let error = null;
991
+ let iteratorId = '';
992
+ let requestSubject = '';
993
+ let responseSubject = '';
994
+ let sub;
995
+ let inbox = '';
996
+ let responseUnsub;
997
+ const responseQueue = [];
998
+ let responseResolver = null;
999
+ const settlePendingAsDone = () => {
1000
+ const r = responseResolver;
1001
+ if (r) {
1002
+ responseResolver = null;
1003
+ r({ id: iteratorId, type: 'done' });
1004
+ }
1005
+ };
1006
+ const cleanupOnce = async () => {
1007
+ if (cleanedUp)
1008
+ return;
1009
+ cleanedUp = true;
1010
+ responseUnsub?.();
1011
+ if (sub && !sub.isClosed()) {
1012
+ try {
1013
+ sub.unsubscribe();
1014
+ }
1015
+ catch {
1016
+ // ignore
1017
+ }
1018
+ }
1019
+ if (!ended) {
1020
+ ended = true;
1021
+ try {
1022
+ const cancelRequest = { id: iteratorId, type: 'cancel' };
1023
+ await client.publish(requestSubject, cancelRequest);
1024
+ }
1025
+ catch {
1026
+ // ignore cleanup errors
1027
+ }
1028
+ }
1029
+ };
1030
+ const setup = async () => {
1031
+ if (!client.isConnected && !client.isClosed) {
1032
+ await client.connect();
1033
+ }
1034
+ if (!client.nc)
1035
+ throw new Error('Not connected');
1036
+ iteratorId = generateId();
1037
+ requestSubject = `_rpc.iterator.${iteratorId}.request`;
1038
+ responseSubject = `_rpc.iterator.${iteratorId}.response`;
1039
+ const initResponse = await client.call(subject, { __pullIterator: true, __iteratorId: iteratorId, args });
1040
+ if (initResponse?.iteratorId !== iteratorId) {
1041
+ throw new Error('Failed to initialize pull iterator');
1042
+ }
1043
+ responseUnsub = await client.subscribe(responseSubject, (msg) => {
1044
+ if (msg.type === 'error') {
1045
+ error = RPCException.fromJSON(msg.error);
1046
+ ended = true;
1047
+ }
1048
+ else if (msg.type === 'done') {
1049
+ ended = true;
1050
+ }
1051
+ if (responseResolver) {
1052
+ const r = responseResolver;
1053
+ responseResolver = null;
1054
+ r(msg);
1055
+ }
1056
+ else {
1057
+ responseQueue.push(msg);
1058
+ }
1059
+ });
1060
+ const requestCallback = (err, msg) => {
1061
+ let isError = false;
1062
+ if (msg && msg.data?.length === 0 && msg.headers?.code === 503) {
1063
+ const e = new errors.NoRespondersError(subject);
1064
+ isError = true;
1065
+ ended = true;
1066
+ error = createError('503', e.message);
1067
+ }
1068
+ else if (err) {
1069
+ isError = true;
1070
+ ended = true;
1071
+ error = createError(ERROR_CODES.INTERNAL_ERROR, err.message);
1072
+ }
1073
+ if (isError) {
1074
+ const response = {
1075
+ type: 'error',
1076
+ id: iteratorId,
1077
+ error: error ? error.toJSON() : undefined,
1078
+ };
1079
+ if (responseResolver) {
1080
+ const r = responseResolver;
1081
+ responseResolver = null;
1082
+ r(response);
1083
+ }
1084
+ else {
1085
+ responseQueue.push(response);
1086
+ }
1087
+ }
1088
+ };
1089
+ inbox = createInbox();
1090
+ sub = client.nc.subscribe(inbox, { max: 1, callback: requestCallback });
1091
+ };
1092
+ const iter = {
1093
+ async next() {
1094
+ if (returned)
1095
+ return { value: undefined, done: true };
1096
+ if (!started) {
1097
+ started = true;
1098
+ try {
1099
+ await setup();
1100
+ }
1101
+ catch (err) {
1102
+ await cleanupOnce();
1103
+ throw err;
1104
+ }
1105
+ if (returned) {
1106
+ await cleanupOnce();
1107
+ return { value: undefined, done: true };
1108
+ }
1109
+ }
1110
+ const nextRequest = { id: iteratorId, type: 'next' };
1111
+ try {
1112
+ await client.publish(requestSubject, nextRequest, { reply: inbox });
1113
+ }
1114
+ catch (err) {
1115
+ await cleanupOnce();
1116
+ throw err;
1117
+ }
1118
+ const response = await new Promise((resolve, reject) => {
1119
+ if (returned) {
1120
+ resolve({ id: iteratorId, type: 'done' });
1121
+ return;
1122
+ }
1123
+ if (responseQueue.length > 0) {
1124
+ resolve(responseQueue.shift());
1125
+ }
1126
+ else if (ended && error) {
1127
+ reject(error);
1128
+ }
1129
+ else if (ended) {
1130
+ resolve({ id: iteratorId, type: 'done' });
1131
+ }
1132
+ else {
1133
+ responseResolver = resolve;
1134
+ }
1135
+ });
1136
+ if (returned) {
1137
+ await cleanupOnce();
1138
+ return { value: undefined, done: true };
1139
+ }
1140
+ if (response.type === 'error') {
1141
+ await cleanupOnce();
1142
+ throw RPCException.fromJSON(response.error);
1143
+ }
1144
+ if (response.type === 'done') {
1145
+ await cleanupOnce();
1146
+ return { value: undefined, done: true };
1147
+ }
1148
+ return { value: response.value, done: false };
1149
+ },
1150
+ async return(value) {
1151
+ if (returned)
1152
+ return { value: value, done: true };
1153
+ returned = true;
1154
+ settlePendingAsDone();
1155
+ if (started)
1156
+ await cleanupOnce();
1157
+ return { value: value, done: true };
1158
+ },
1159
+ async throw(err) {
1160
+ if (returned)
1161
+ throw err;
1162
+ returned = true;
1163
+ settlePendingAsDone();
1164
+ if (started)
1165
+ await cleanupOnce();
1166
+ throw err;
1167
+ },
1168
+ [Symbol.asyncIterator]() {
1169
+ return iter;
1170
+ },
1171
+ async [Symbol.asyncDispose]() {
1172
+ await iter.return();
1173
+ },
1174
+ };
1175
+ return iter;
1176
+ }
1177
+ /**
1178
+ * Pull-iterator-with-callbacks call.
1179
+ *
1180
+ * Combines a client-driven pull iterator (1 RTT per batch) with a oneway
1181
+ * callback channel (fire-and-forget server → client) for low-latency
1182
+ * frame-level data delivery with coarse-grained backpressure.
1183
+ *
1184
+ * The returned async generator yields `undefined` for each batch boundary
1185
+ * the server produces. Meaningful data is dispatched through the provided
1186
+ * callback object.
1187
+ */
1188
+ callPullIteratorWithCallback(subject, callbacks, onewayMethods, ...args) {
1189
+ // Manual iterator implementation (not `async function*`). Rationale:
1190
+ // an async generator parked at an `await` cannot be woken by
1191
+ // `iter.return()` — per spec, return() queues behind the pending
1192
+ // await. When the server is parked at yield and no new response
1193
+ // message is in flight, that await never settles and return() hangs
1194
+ // forever. Implementing the iterator protocol by hand lets us
1195
+ // force-settle the pending response resolver from within return()/
1196
+ // throw() and run cleanup synchronously.
1197
+ //
1198
+ // Setup is deferred to the first next() call to preserve the lazy
1199
+ // semantics of the previous `async function*` implementation —
1200
+ // factories that are never iterated shouldn't open NATS subs.
1201
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1202
+ const client = this;
1203
+ let started = false;
1204
+ let returned = false;
1205
+ let cleanedUp = false;
1206
+ let ended = false;
1207
+ let error = null;
1208
+ let iteratorId = '';
1209
+ let requestSubject = '';
1210
+ let responseSubject = '';
1211
+ let callbackSubject = '';
1212
+ let sub;
1213
+ let inbox = '';
1214
+ let callbackUnsub;
1215
+ let responseUnsub;
1216
+ const responseQueue = [];
1217
+ let responseResolver = null;
1218
+ let callbackChain = Promise.resolve();
1219
+ const settlePendingAsDone = () => {
1220
+ const r = responseResolver;
1221
+ if (r) {
1222
+ responseResolver = null;
1223
+ r({ id: iteratorId, type: 'done' });
1224
+ }
1225
+ };
1226
+ const cleanupOnce = async () => {
1227
+ if (cleanedUp)
1228
+ return;
1229
+ cleanedUp = true;
1230
+ callbackUnsub?.();
1231
+ responseUnsub?.();
1232
+ if (sub && !sub.isClosed()) {
1233
+ try {
1234
+ sub.unsubscribe();
1235
+ }
1236
+ catch {
1237
+ // ignore
1238
+ }
1239
+ }
1240
+ if (!ended) {
1241
+ ended = true;
1242
+ try {
1243
+ const cancelRequest = { id: iteratorId, type: 'cancel' };
1244
+ await client.publish(requestSubject, cancelRequest);
1245
+ }
1246
+ catch {
1247
+ // ignore cleanup errors
1248
+ }
1249
+ }
1250
+ };
1251
+ const setup = async () => {
1252
+ if (!client.isConnected && !client.isClosed) {
1253
+ await client.connect();
1254
+ }
1255
+ if (!client.nc)
1256
+ throw new Error('Not connected');
1257
+ iteratorId = generateId();
1258
+ requestSubject = `_rpc.iterator.${iteratorId}.request`;
1259
+ responseSubject = `_rpc.iterator.${iteratorId}.response`;
1260
+ callbackSubject = `_rpc.cb.${iteratorId}`;
1261
+ const callbackMethods = Object.keys(callbacks).filter((k) => typeof callbacks[k] === 'function');
1262
+ callbackUnsub = await client.subscribe(callbackSubject, (msg) => {
1263
+ const fn = callbacks[msg.method];
1264
+ if (!fn) {
1265
+ console.error(`[rpc] Pull-callback: unknown method '${msg.method}'`);
1266
+ return;
1267
+ }
1268
+ callbackChain = callbackChain.then(async () => {
1269
+ try {
1270
+ await fn(...(msg.args ?? []));
1271
+ }
1272
+ catch (err) {
1273
+ console.error(`[rpc] Pull-callback handler '${msg.method}' threw:`, err);
1274
+ }
1275
+ });
1276
+ });
1277
+ const initParams = {
1278
+ __pullCallback: true,
1279
+ __iteratorId: iteratorId,
1280
+ __callbackSubject: callbackSubject,
1281
+ __callbackMethods: callbackMethods,
1282
+ __onewayMethods: onewayMethods,
1283
+ args,
1284
+ };
1285
+ let initResponse;
1286
+ try {
1287
+ initResponse = await client.call(subject, initParams);
1288
+ }
1289
+ catch (err) {
1290
+ callbackUnsub?.();
1291
+ callbackUnsub = undefined;
1292
+ throw err;
1293
+ }
1294
+ if (initResponse?.iteratorId !== iteratorId) {
1295
+ callbackUnsub?.();
1296
+ callbackUnsub = undefined;
1297
+ throw new Error('Failed to initialize pull-callback iterator');
1298
+ }
1299
+ responseUnsub = await client.subscribe(responseSubject, (msg) => {
1300
+ if (msg.type === 'error') {
1301
+ error = RPCException.fromJSON(msg.error);
1302
+ ended = true;
1303
+ }
1304
+ else if (msg.type === 'done') {
1305
+ ended = true;
1306
+ }
1307
+ if (responseResolver) {
1308
+ const r = responseResolver;
1309
+ responseResolver = null;
1310
+ r(msg);
1311
+ }
1312
+ else {
1313
+ responseQueue.push(msg);
1314
+ }
1315
+ });
1316
+ const requestCallback = (err, msg) => {
1317
+ let isError = false;
1318
+ if (msg && msg.data?.length === 0 && msg.headers?.code === 503) {
1319
+ const e = new errors.NoRespondersError(subject);
1320
+ isError = true;
1321
+ ended = true;
1322
+ error = createError('503', e.message);
1323
+ }
1324
+ else if (err) {
1325
+ isError = true;
1326
+ ended = true;
1327
+ error = createError(ERROR_CODES.INTERNAL_ERROR, err.message);
1328
+ }
1329
+ if (isError) {
1330
+ const response = {
1331
+ type: 'error',
1332
+ id: iteratorId,
1333
+ error: error ? error.toJSON() : undefined,
1334
+ };
1335
+ if (responseResolver) {
1336
+ const r = responseResolver;
1337
+ responseResolver = null;
1338
+ r(response);
1339
+ }
1340
+ else {
1341
+ responseQueue.push(response);
1342
+ }
1343
+ }
1344
+ };
1345
+ inbox = createInbox();
1346
+ sub = client.nc.subscribe(inbox, { max: 1, callback: requestCallback });
1347
+ };
1348
+ const iter = {
1349
+ async next() {
1350
+ if (returned)
1351
+ return { value: undefined, done: true };
1352
+ if (!started) {
1353
+ started = true;
1354
+ try {
1355
+ await setup();
1356
+ }
1357
+ catch (err) {
1358
+ await cleanupOnce();
1359
+ throw err;
1360
+ }
1361
+ if (returned) {
1362
+ await cleanupOnce();
1363
+ return { value: undefined, done: true };
1364
+ }
1365
+ }
1366
+ const nextRequest = { id: iteratorId, type: 'next' };
1367
+ try {
1368
+ await client.publish(requestSubject, nextRequest, { reply: inbox });
1369
+ }
1370
+ catch (err) {
1371
+ await cleanupOnce();
1372
+ throw err;
1373
+ }
1374
+ const response = await new Promise((resolve, reject) => {
1375
+ if (returned) {
1376
+ resolve({ id: iteratorId, type: 'done' });
1377
+ return;
1378
+ }
1379
+ if (responseQueue.length > 0) {
1380
+ resolve(responseQueue.shift());
1381
+ }
1382
+ else if (ended && error) {
1383
+ reject(error);
1384
+ }
1385
+ else if (ended) {
1386
+ resolve({ id: iteratorId, type: 'done' });
1387
+ }
1388
+ else {
1389
+ responseResolver = resolve;
1390
+ }
1391
+ });
1392
+ if (returned) {
1393
+ await cleanupOnce();
1394
+ return { value: undefined, done: true };
1395
+ }
1396
+ if (response.type === 'error') {
1397
+ await cleanupOnce();
1398
+ throw RPCException.fromJSON(response.error);
1399
+ }
1400
+ if (response.type === 'done') {
1401
+ await cleanupOnce();
1402
+ return { value: undefined, done: true };
1403
+ }
1404
+ // 'value' — wait for all callback handlers queued for the batch
1405
+ // to finish. A slow handler stalls here → stalls next()
1406
+ // request → server parks at its own yield. End-to-end backpressure.
1407
+ await callbackChain;
1408
+ return { value: undefined, done: false };
1409
+ },
1410
+ async return(value) {
1411
+ if (returned)
1412
+ return { value: value, done: true };
1413
+ returned = true;
1414
+ settlePendingAsDone();
1415
+ if (started)
1416
+ await cleanupOnce();
1417
+ return { value: value, done: true };
1418
+ },
1419
+ async throw(err) {
1420
+ if (returned)
1421
+ throw err;
1422
+ returned = true;
1423
+ settlePendingAsDone();
1424
+ if (started)
1425
+ await cleanupOnce();
1426
+ throw err;
1427
+ },
1428
+ [Symbol.asyncIterator]() {
1429
+ return iter;
1430
+ },
1431
+ async [Symbol.asyncDispose]() {
1432
+ await iter.return();
1433
+ },
1434
+ };
1435
+ return iter;
1436
+ }
1437
+ /**
1438
+ * Make an RPC call with a callback subscription.
1439
+ * Returns an unsubscribe function.
1440
+ */
1441
+ async callWithCallback(subject, args, callback) {
1442
+ if (!this.isConnected && !this.isClosed) {
1443
+ await this.connect();
1444
+ }
1445
+ if (!this.nc) {
1446
+ throw new Error('Not connected');
1447
+ }
1448
+ const id = generateId();
1449
+ const callbackSubject = `rpc.cb.${id}`;
1450
+ // Subscribe to callback messages
1451
+ const unsub = await this.subscribe(callbackSubject, async (msg) => {
1452
+ if (msg.type === 'data') {
1453
+ try {
1454
+ await callback(msg.data);
1455
+ }
1456
+ catch (err) {
1457
+ console.error('[rpc] Callback error:', err);
1458
+ }
1459
+ }
1460
+ else if (msg.type === 'error') {
1461
+ console.error('[rpc] Callback error:', msg.error);
1462
+ }
1463
+ });
1464
+ // Send RPC request with callback marker
1465
+ const callbackParams = {
1466
+ __callback: true,
1467
+ __callbackSubject: callbackSubject,
1468
+ args,
1469
+ };
1470
+ try {
1471
+ await this.call(subject, callbackParams);
1472
+ }
1473
+ catch (err) {
1474
+ unsub();
1475
+ throw err;
1476
+ }
1477
+ // Return unsubscribe function
1478
+ const unsubscribe = () => {
1479
+ this.publish(`${callbackSubject}.cancel`, { id }).catch(() => { });
1480
+ unsub();
1481
+ };
1482
+ return unsubscribe;
1483
+ }
1484
+ /**
1485
+ * Register RPC handlers
1486
+ */
1487
+ async registerHandler(namespace, handlers, options) {
1488
+ if (!this.nc && !options?.isolatedConnection) {
1489
+ throw new Error('Not connected');
1490
+ }
1491
+ // Use isolated connection if requested
1492
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1493
+ let client = this;
1494
+ if (options?.isolatedConnection) {
1495
+ // Create isolated connection for this handler namespace
1496
+ client = this.createIsolatedClient({
1497
+ ...this.options,
1498
+ name: `${this.options.name}-handler-${namespace}`,
1499
+ });
1500
+ // Connect the isolated client
1501
+ await client.connect();
1502
+ this.isolatedClients.push(client);
1503
+ }
1504
+ const unsubscribers = [];
1505
+ const pullIteratorIds = [];
1506
+ // Extract methods based on option
1507
+ const handlersMap = options?.withoutDecorators ? extractNestedMethodsWithoutDecorators(handlers) : extractNestedMethodsWithDecorators(handlers);
1508
+ const methodNames = Object.keys(handlersMap);
1509
+ for (const [method, handler] of Object.entries(handlersMap)) {
1510
+ const subject = `rpc.${namespace}.${method}`;
1511
+ const unsubscribe = await client.subscribe(subject, async (msg) => {
1512
+ const response = { id: msg.id, __methods: methodNames };
1513
+ try {
1514
+ // Handle stream request
1515
+ if (msg.params?.__stream && msg.params?.__streamSubject) {
1516
+ const streamSubject = msg.params.__streamSubject;
1517
+ const args = msg.params.args ?? [];
1518
+ // Don't await stream requests - they run in background and send data via streamSubject
1519
+ // Awaiting would block the subscription handler and prevent processing of new messages
1520
+ handleStreamRequest(handler, args, streamSubject, msg.id, client).catch((err) => {
1521
+ console.error(`Stream request error for ${method}:`, err);
1522
+ });
1523
+ return; // Don't send RPC response for stream requests
1524
+ }
1525
+ else if (
1526
+ // Check if it's a pull iterator request
1527
+ // Could be direct object or wrapped in array from call()
1528
+ msg.params?.__pullIterator ||
1529
+ (Array.isArray(msg.params) && msg.params[0]?.__pullIterator)) {
1530
+ // Extract pull iterator params
1531
+ const pullParams = msg.params?.__pullIterator ? msg.params : msg.params[0];
1532
+ const args = pullParams.args ?? [];
1533
+ const iteratorId = pullParams.__iteratorId ?? msg.id;
1534
+ const cleanup = await handlePullIteratorRequest(handler, args, iteratorId, client);
1535
+ // Store cleanup function for later
1536
+ client.pullIteratorCleanups.set(iteratorId, cleanup);
1537
+ pullIteratorIds.push(iteratorId);
1538
+ response.result = { iteratorId };
1539
+ // Send response with iterator ID
1540
+ const replySubject = `rpc.reply.${msg.id}`;
1541
+ await client.publish(replySubject, response);
1542
+ }
1543
+ else if (
1544
+ // Check if it's a pull-iterator-with-callbacks request
1545
+ msg.params?.__pullCallback ||
1546
+ (Array.isArray(msg.params) && msg.params[0]?.__pullCallback)) {
1547
+ const pcParams = msg.params?.__pullCallback ? msg.params : msg.params[0];
1548
+ const args = pcParams.args ?? [];
1549
+ const iteratorId = pcParams.__iteratorId ?? msg.id;
1550
+ const callbackSubject = pcParams.__callbackSubject;
1551
+ const callbackMethods = pcParams.__callbackMethods ?? [];
1552
+ const onewayMethods = pcParams.__onewayMethods ?? [];
1553
+ const cleanup = await handlePullCallbackRequest(handler, args, iteratorId, callbackSubject, callbackMethods, onewayMethods, client);
1554
+ client.pullIteratorCleanups.set(iteratorId, cleanup);
1555
+ pullIteratorIds.push(iteratorId);
1556
+ response.result = { iteratorId };
1557
+ const replySubject = `rpc.reply.${msg.id}`;
1558
+ await client.publish(replySubject, response);
1559
+ }
1560
+ else if (
1561
+ // Check if it's a callback subscription request
1562
+ (msg.params && typeof msg.params === 'object' && !Array.isArray(msg.params) && msg.params.__callback && msg.params.__callbackSubject) ||
1563
+ (Array.isArray(msg.params) && msg.params.length > 0 && typeof msg.params[0] === 'object' && msg.params[0]?.__callback)) {
1564
+ // Handle callback subscription request
1565
+ const cbParams = Array.isArray(msg.params) && msg.params[0]?.__callback ? msg.params[0] : msg.params;
1566
+ const callbackSubject = cbParams.__callbackSubject;
1567
+ const cbArgs = cbParams.args ?? [];
1568
+ const cleanup = await handleCallbackRequest(handler, cbArgs, callbackSubject, msg.id, client);
1569
+ client.callbackCleanups.set(msg.id, cleanup);
1570
+ response.result = { ok: true };
1571
+ const replySubject = `rpc.reply.${msg.id}`;
1572
+ await client.publish(replySubject, response);
1573
+ }
1574
+ else {
1575
+ // Normal RPC call
1576
+ const result = await handleNormalRPC(handler, msg.params);
1577
+ response.result = result;
1578
+ // Send response
1579
+ const replySubject = `rpc.reply.${msg.id}`;
1580
+ await client.publish(replySubject, response);
1581
+ }
1582
+ }
1583
+ catch (error) {
1584
+ response.error = formatErrorObject(error);
1585
+ try {
1586
+ const replySubject = `rpc.reply.${msg.id}`;
1587
+ await client.publish(replySubject, response);
1588
+ }
1589
+ catch (publishError) {
1590
+ if (client.isClosed) {
1591
+ return; // Ignore publish errors if client is closed
1592
+ }
1593
+ console.error('Failed to send error response:', publishError);
1594
+ }
1595
+ }
1596
+ }, options?.queue ? { queue: options.queue } : undefined);
1597
+ unsubscribers.push(unsubscribe);
1598
+ }
1599
+ const cleanup = async () => {
1600
+ // Unsubscribe all handlers
1601
+ for (const unsub of unsubscribers) {
1602
+ unsub();
1603
+ }
1604
+ // Cleanup pull iterators
1605
+ await Promise.allSettled(Array.from(client.pullIteratorCleanups.entries()).map(([id, cleanup]) => {
1606
+ client.pullIteratorCleanups.delete(id);
1607
+ return cleanup();
1608
+ }));
1609
+ // Cleanup callbacks
1610
+ await Promise.allSettled(Array.from(client.callbackCleanups.entries()).map(([id, cleanup]) => {
1611
+ client.callbackCleanups.delete(id);
1612
+ return cleanup();
1613
+ }));
1614
+ // Disconnect isolated connection if used
1615
+ if (options?.isolatedConnection) {
1616
+ await client.disconnect();
1617
+ const index = this.isolatedClients.indexOf(client);
1618
+ if (index >= 0) {
1619
+ this.isolatedClients.splice(index, 1);
1620
+ }
1621
+ }
1622
+ };
1623
+ return cleanup;
1624
+ }
1625
+ /**
1626
+ * Setup a request handler (responder)
1627
+ * @param pattern - The subject pattern to listen for requests
1628
+ * @param handler - The handler function that receives data and subject
1629
+ */
1630
+ async onRequest(pattern, handler) {
1631
+ if (!this.nc) {
1632
+ throw new Error('Not connected');
1633
+ }
1634
+ const sub = this.nc.subscribe(pattern, {
1635
+ callback: (_err, msg) => {
1636
+ (async () => {
1637
+ try {
1638
+ // Decode request
1639
+ const data = decode(msg.data);
1640
+ // Call handler with subject
1641
+ const result = await handler(data);
1642
+ // Send response
1643
+ if (msg.reply) {
1644
+ const response = encode(result);
1645
+ msg.respond(response);
1646
+ }
1647
+ }
1648
+ catch (error) {
1649
+ // Send error response
1650
+ if (msg.reply) {
1651
+ const errorResponse = encode({
1652
+ error: error instanceof Error ? error.message : 'Internal error',
1653
+ code: error instanceof RPCException ? error.code : ERROR_CODES.INTERNAL_ERROR,
1654
+ });
1655
+ msg.respond(errorResponse);
1656
+ }
1657
+ console.error(`Error in request handler for ${pattern}:`, error);
1658
+ }
1659
+ })();
1660
+ },
1661
+ });
1662
+ // Return unsubscribe function
1663
+ return () => {
1664
+ sub.unsubscribe();
1665
+ };
1666
+ }
1667
+ /**
1668
+ * Create or join a bidirectional channel
1669
+ * @param channelId - Unique channel identifier
1670
+ * @param options - Optional configuration
1671
+ */
1672
+ async channel(channelId, options) {
1673
+ let client;
1674
+ if (options?.isolatedConnection) {
1675
+ // Create a new isolated client for this channel
1676
+ client = this.createIsolatedClient({
1677
+ ...this.options,
1678
+ name: `${this.options.name}-channel-${channelId}`,
1679
+ });
1680
+ this.isolatedClients.push(client);
1681
+ await client.connect();
1682
+ }
1683
+ else {
1684
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1685
+ client = this;
1686
+ }
1687
+ const channel = new Channel(client, channelId);
1688
+ await channel.init();
1689
+ // Store reference for cleanup if isolated
1690
+ if (options?.isolatedConnection) {
1691
+ channel._isolatedClient = client;
1692
+ }
1693
+ return channel;
1694
+ }
1695
+ /**
1696
+ * Create a private 1:1 channel
1697
+ * @param channelId - Unique channel identifier
1698
+ * @param targetClientId - Target client to connect to
1699
+ * @param options - Optional configuration
1700
+ */
1701
+ async privateChannel(channelId, targetClientId, options) {
1702
+ let client;
1703
+ if (options?.isolatedConnection) {
1704
+ // Create a new isolated client for this channel
1705
+ client = this.createIsolatedClient({
1706
+ ...this.options,
1707
+ name: `${this.options.name}-private-${channelId}`,
1708
+ });
1709
+ this.isolatedClients.push(client);
1710
+ await client.connect();
1711
+ }
1712
+ else {
1713
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1714
+ client = this;
1715
+ }
1716
+ const channel = new PrivateChannel(client, channelId, targetClientId);
1717
+ await channel.init();
1718
+ // Store reference for cleanup if isolated
1719
+ if (options?.isolatedConnection) {
1720
+ channel._isolatedClient = client;
1721
+ }
1722
+ return channel;
1723
+ }
1724
+ // prettier-ignore
1725
+ createProxy(namespace, options) {
1726
+ let client;
1727
+ if (options?.isolatedConnection) {
1728
+ // Create an isolated proxy with its own connection
1729
+ client = this.createIsolatedClient({
1730
+ ...this.options,
1731
+ name: `${this.options.name}-proxy-${namespace}`,
1732
+ });
1733
+ this.isolatedClients.push(client);
1734
+ }
1735
+ else {
1736
+ // Use the current client instance
1737
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1738
+ client = this;
1739
+ }
1740
+ const proxy = createProxy(client, namespace);
1741
+ if (options?.isolatedConnection) {
1742
+ // Store reference for potential cleanup
1743
+ proxy._isolatedClient = client;
1744
+ return {
1745
+ proxy,
1746
+ close: async () => {
1747
+ await client.disconnect();
1748
+ const index = this.isolatedClients.indexOf(client);
1749
+ if (index >= 0)
1750
+ this.isolatedClients.splice(index, 1);
1751
+ },
1752
+ };
1753
+ }
1754
+ else {
1755
+ return proxy;
1756
+ }
1757
+ }
1758
+ async createServiceProxy(serviceName, options) {
1759
+ let client;
1760
+ if (options?.isolatedConnection) {
1761
+ // Create a new isolated client for this service proxy
1762
+ client = this.createIsolatedClient({
1763
+ ...this.options,
1764
+ name: `${this.options.name}-service-${serviceName}`,
1765
+ });
1766
+ this.isolatedClients.push(client);
1767
+ await client.connect();
1768
+ }
1769
+ else {
1770
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1771
+ client = this;
1772
+ }
1773
+ // Discover available services
1774
+ const monitor = client.service.monitor();
1775
+ const services = [];
1776
+ for await (const info of await monitor.info(serviceName)) {
1777
+ services.push(info);
1778
+ }
1779
+ if (services.length === 0) {
1780
+ if (options?.isolatedConnection) {
1781
+ await client.disconnect();
1782
+ const index = this.isolatedClients.indexOf(client);
1783
+ if (index >= 0)
1784
+ this.isolatedClients.splice(index, 1);
1785
+ }
1786
+ throw new Error(`No services found with name: ${serviceName}`);
1787
+ }
1788
+ // Select service (prefer specific ID if provided)
1789
+ const selected = options?.preferredId ? (services.find((s) => s.id === options.preferredId) ?? services[0]) : services[0];
1790
+ // Create the proxy
1791
+ const proxy = createServiceProxy(client, selected, options?.timeout);
1792
+ // If isolated, store reference for potential cleanup
1793
+ if (options?.isolatedConnection) {
1794
+ proxy._isolatedClient = client;
1795
+ return {
1796
+ proxy,
1797
+ close: async () => {
1798
+ await client.disconnect();
1799
+ const index = this.isolatedClients.indexOf(client);
1800
+ if (index >= 0)
1801
+ this.isolatedClients.splice(index, 1);
1802
+ },
1803
+ };
1804
+ }
1805
+ else {
1806
+ return proxy;
1807
+ }
1808
+ }
1809
+ }
1810
+ // Wraps a connect() promise so that an external AbortSignal can synchronously
1811
+ // reject the wait. If the underlying connect resolves AFTER abort, the
1812
+ // resulting NatsConnection is force-closed via the fork's abortClose() (or
1813
+ // the standard close() as a fallback) — otherwise we'd leak a live WS that
1814
+ // nobody owns.
1815
+ function makeAbortableConnect(p, signal) {
1816
+ if (!signal)
1817
+ return p;
1818
+ if (signal.aborted) {
1819
+ p.then((nc) => closeLeaked(nc)).catch(() => { });
1820
+ return Promise.reject(new DOMException('Aborted', 'AbortError'));
1821
+ }
1822
+ return new Promise((resolve, reject) => {
1823
+ let settled = false;
1824
+ const onAbort = () => {
1825
+ if (settled)
1826
+ return;
1827
+ settled = true;
1828
+ p.then((nc) => closeLeaked(nc)).catch(() => { });
1829
+ reject(new DOMException('Aborted', 'AbortError'));
1830
+ };
1831
+ signal.addEventListener('abort', onAbort, { once: true });
1832
+ p.then((val) => {
1833
+ if (settled) {
1834
+ closeLeaked(val);
1835
+ return;
1836
+ }
1837
+ settled = true;
1838
+ signal.removeEventListener('abort', onAbort);
1839
+ resolve(val);
1840
+ }, (err) => {
1841
+ if (settled)
1842
+ return;
1843
+ settled = true;
1844
+ signal.removeEventListener('abort', onAbort);
1845
+ reject(err);
1846
+ });
1847
+ });
1848
+ }
1849
+ function closeLeaked(nc) {
1850
+ const withAbort = nc;
1851
+ try {
1852
+ if (typeof withAbort.abortClose === 'function') {
1853
+ withAbort.abortClose();
1854
+ }
1855
+ else {
1856
+ void nc.close().catch(() => { });
1857
+ }
1858
+ }
1859
+ catch {
1860
+ // ignore
1861
+ }
1862
+ }
1863
+ //# sourceMappingURL=client.js.map