@net-mesh/core 0.21.0 → 0.23.0

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/mesh_rpc.js CHANGED
@@ -21,10 +21,28 @@
21
21
  // const policy = new RetryPolicy({ maxAttempts: 3 })
22
22
  // const reply = await rpc.callWithRetry(targetId, 'echo', req, undefined, policy)
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.NRPC_TYPED_HANDLER_ERROR = exports.NRPC_TYPED_BAD_REQUEST = exports.CircuitBreaker = exports.BreakerOpenError = exports.HedgePolicy = exports.RetryPolicy = exports.TypedRpcStream = exports.TypedMeshRpc = void 0;
24
+ exports.NRPC_TYPED_HANDLER_ERROR = exports.NRPC_TYPED_BAD_REQUEST = exports.CircuitBreaker = exports.BreakerOpenError = exports.HedgePolicy = exports.RetryPolicy = exports.TypedResponseSink = exports.TypedDuplexStream = exports.TypedDuplexSink = exports.TypedDuplexCall = exports.TypedRequestStream = exports.TypedClientStreamCall = exports.TypedRpcStream = exports.TypedMeshRpc = exports.DIRECTION_INBOUND = exports.DIRECTION_OUTBOUND = exports.STATUS_KIND_CANCELED = exports.STATUS_KIND_TIMEOUT = exports.STATUS_KIND_ERROR = exports.STATUS_KIND_OK = void 0;
25
25
  exports.defaultRetryable = defaultRetryable;
26
26
  exports.defaultBreakerFailure = defaultBreakerFailure;
27
27
  exports.appError = appError;
28
+ exports.rawEventToTyped = rawEventToTyped;
29
+ /**
30
+ * Status-kind discriminants emitted on `RawRpcCallEvent.statusKind`
31
+ * and consumed when normalizing into the tagged-union
32
+ * {@link RpcCallStatus}. Prefer these constants over hard-coding
33
+ * the string literal in `if (evt.statusKind === '...')` checks —
34
+ * a typo on the literal silently never fires.
35
+ *
36
+ * Named `STATUS_KIND_*` to disambiguate from the wire-level
37
+ * `STATUS_*` u16 status codes used by `RpcServerError`.
38
+ */
39
+ exports.STATUS_KIND_OK = 'ok';
40
+ exports.STATUS_KIND_ERROR = 'error';
41
+ exports.STATUS_KIND_TIMEOUT = 'timeout';
42
+ exports.STATUS_KIND_CANCELED = 'canceled';
43
+ /** Direction-kind discriminants on `RawRpcCallEvent.direction`. */
44
+ exports.DIRECTION_OUTBOUND = 'outbound';
45
+ exports.DIRECTION_INBOUND = 'inbound';
28
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
47
  const native = require('./index');
30
48
  // Duck-typed message extractor — keeps the "any rejected value
@@ -172,6 +190,147 @@ class TypedMeshRpc {
172
190
  findServiceNodes(service) {
173
191
  return this._raw.findServiceNodes(service);
174
192
  }
193
+ // ---- client-streaming (S2-B1) -------------------------------------------
194
+ /**
195
+ * Open a typed client-streaming call. Returns a
196
+ * `TypedClientStreamCall<Req, Resp>` — push each request via
197
+ * `send(value)`, then `finish()` to drain the terminal
198
+ * response.
199
+ *
200
+ * Cancellation (v3 / C-A1): `opts.signal` is wired end-to-end
201
+ * via the same `wireAbortSignal` helper unary calls use.
202
+ * `signal.aborted` triggers the substrate's
203
+ * `MeshNode::cancel(token)`, which drops the pending
204
+ * client-stream entry — the next `send()` / `finish()` observes
205
+ * a stream-closed error within bounded time, and the call's
206
+ * Drop emits CANCEL on the wire. Invoking `typedCall.close()`
207
+ * explicitly is still supported as the imperative cancel
208
+ * surface.
209
+ */
210
+ async callClientStream(targetNodeId, service, opts) {
211
+ const { rawOpts, detach } = wireAbortSignal(this._raw, opts);
212
+ try {
213
+ const rawCall = await this._raw.callClientStream(targetNodeId, service, rawOpts);
214
+ return new TypedClientStreamCall(rawCall, detach);
215
+ }
216
+ catch (err) {
217
+ detach();
218
+ throw err;
219
+ }
220
+ }
221
+ /**
222
+ * Register a typed client-streaming handler. The handler
223
+ * receives a `TypedRequestStream<Req>` (auto-decodes each
224
+ * inbound chunk to `Req`) and returns the terminal response
225
+ * `Resp` (encoded back to JSON before reaching the caller).
226
+ *
227
+ * Decode failure on a chunk surfaces from `stream.next()` as
228
+ * `nrpc:codec_decode:` — the handler may catch it (e.g. skip
229
+ * the bad chunk) or let it propagate. Letting it propagate
230
+ * surfaces to the caller as `RpcServerError(Internal)`; the
231
+ * handler may instead wrap it with `appError(NRPC_TYPED_BAD_REQUEST,
232
+ * ...)` to signal a typed bad-request status code.
233
+ */
234
+ serveClientStream(service, handler) {
235
+ return this._raw.serveClientStream(service, async (rawStream) => {
236
+ const typedStream = new TypedRequestStream(rawStream);
237
+ const resp = await handler(typedStream);
238
+ return jsonEncode(resp);
239
+ });
240
+ }
241
+ // ---- duplex (S2-B2) ----------------------------------------------------
242
+ /**
243
+ * Open a typed duplex call. Returns a `TypedDuplexCall<Req,
244
+ * Resp>` — push typed requests via `send`, pull typed responses
245
+ * via `next` (or `for await`), or `intoSplit` to separate the
246
+ * halves for the encoder-task / decoder-task pattern.
247
+ *
248
+ * Cancellation (v3 / C-A1): `opts.signal` is wired through to
249
+ * the substrate's cancel-token primitive — same pattern as
250
+ * `callClientStream`. Aborting the signal drops both halves
251
+ * cleanly; the receive side observes EOF on its next pull and
252
+ * Drop emits CANCEL on the wire. `typedCall.close()` is still
253
+ * supported as the imperative cancel surface.
254
+ */
255
+ async callDuplex(targetNodeId, service, opts) {
256
+ const { rawOpts, detach } = wireAbortSignal(this._raw, opts);
257
+ try {
258
+ const rawCall = await this._raw.callDuplex(targetNodeId, service, rawOpts);
259
+ return new TypedDuplexCall(rawCall, detach);
260
+ }
261
+ catch (err) {
262
+ detach();
263
+ throw err;
264
+ }
265
+ }
266
+ /**
267
+ * Register a typed duplex handler. The user signature is
268
+ * `(stream: TypedRequestStream<Req>, sink: TypedResponseSink<Resp>) => Promise<void>`
269
+ * — the wrapper destructures the napi-side `[stream, sink]`
270
+ * tuple internally so the user handler never sees the array
271
+ * form. Handler return is `void`; the substrate emits the
272
+ * terminal frame automatically. To surface a typed Application
273
+ * status, throw `appError(code, body)` from the handler.
274
+ */
275
+ serveDuplex(service, handler) {
276
+ return this._raw.serveDuplex(service, async ([rawStream, rawSink]) => {
277
+ const typedStream = new TypedRequestStream(rawStream);
278
+ const typedSink = new TypedResponseSink(rawSink);
279
+ await handler(typedStream, typedSink);
280
+ // The napi side discards the terminal Buffer for duplex
281
+ // handlers (only the Ok/Err outcome matters — see
282
+ // NodeDuplexRpcHandler at bindings/node/src/mesh_rpc.rs).
283
+ // Return an empty Buffer so the JS signature is satisfied.
284
+ return DUPLEX_TERMINAL_SENTINEL;
285
+ });
286
+ }
287
+ // ---- observer + metrics (S2-B3) ----------------------------------------
288
+ /**
289
+ * Install (pass a handler) or clear (pass `null`) the caller-side
290
+ * nRPC observer. The handler fires once per completed outbound
291
+ * RPC with a decoded {@link RpcCallEvent} — the tagged
292
+ * `status` discriminator is reconstructed from the napi POD's
293
+ * flat `statusKind` / `statusMessage` fields.
294
+ *
295
+ * **Callback contract (locked decision #1).** The handler fires
296
+ * synchronously from the substrate's dispatch path via a TSFN in
297
+ * `NonBlocking` mode — if the Node event loop is wedged, events
298
+ * are **dropped**, not buffered. Callbacks must therefore be
299
+ * cheap: push into a queue or ring buffer for slow consumers;
300
+ * do not do work inline. A bounded-mpsc + drop-counter layer is
301
+ * a deliberate post-v1 follow-up.
302
+ *
303
+ * **Mid-call swap.** The substrate uses `ArcSwap` for the
304
+ * observer slot, so swaps are atomic — but a swap mid-call
305
+ * means some events fire against the old handler, some against
306
+ * the new. Set the observer once at startup for clean
307
+ * semantics.
308
+ *
309
+ * v1 only emits `direction === 'outbound'` events; the
310
+ * server-side `'inbound'` hook is a planned follow-up.
311
+ */
312
+ setObserver(handler) {
313
+ if (handler === null) {
314
+ this._raw.setObserver(null);
315
+ return;
316
+ }
317
+ // Normalize the napi POD's flat `statusKind` / `statusMessage`
318
+ // into the TS-idiomatic tagged `RpcCallStatus` discriminator
319
+ // before reaching user code.
320
+ this._raw.setObserver((raw) => {
321
+ handler(rawEventToTyped(raw));
322
+ });
323
+ }
324
+ /**
325
+ * Snapshot the per-service nRPC metrics registry. Cheap — one
326
+ * DashMap iteration on the substrate side. Safe to collect on
327
+ * every Prometheus scrape. The returned object is a plain POD
328
+ * (no class wrapping); read fields directly or feed into your
329
+ * own exporter.
330
+ */
331
+ metricsSnapshot() {
332
+ return this._raw.metricsSnapshot();
333
+ }
175
334
  // ---- resilience helpers --------------------------------------------------
176
335
  /**
177
336
  * Direct-addressed typed call with retry. See {@link RetryPolicy}.
@@ -277,6 +436,486 @@ class TypedRpcStream {
277
436
  }
278
437
  exports.TypedRpcStream = TypedRpcStream;
279
438
  // ============================================================================
439
+ // TypedClientStreamCall + TypedRequestStream (S2-B1).
440
+ //
441
+ // Client-streaming: caller pushes typed Reqs via `send`, then
442
+ // `finish` awaits a single terminal Resp.
443
+ //
444
+ // Server-side handler shape mirrors the unary `serve`'s decode
445
+ // boundary — chunks decode lazily inside `TypedRequestStream.next`
446
+ // so a single malformed chunk doesn't poison the entire stream
447
+ // (the handler can catch and skip).
448
+ //
449
+ // Cancellation contract (locked decision #2): v1 supports
450
+ // `close()`-only cancellation. AbortSignal / cancel-token wiring
451
+ // across streaming shapes is a deliberate post-v1 follow-up.
452
+ // ============================================================================
453
+ /**
454
+ * Typed client-streaming call handle. Push typed requests via
455
+ * `send`, then await the terminal response with `finish`. Drop
456
+ * / `close` fires CANCEL via the underlying raw call's drop.
457
+ */
458
+ class TypedClientStreamCall {
459
+ _raw;
460
+ _detachSignal;
461
+ /**
462
+ * `detachSignal` is the cleanup function returned by
463
+ * `wireAbortSignal`. Run on `close()` (or on call resolution
464
+ * via `finish()`) so the AbortSignal listener doesn't outlive
465
+ * the call.
466
+ */
467
+ constructor(rawCall, detachSignal = () => { }) {
468
+ this._raw = rawCall;
469
+ this._detachSignal = detachSignal;
470
+ }
471
+ /** Underlying raw call for users who want the Buffer-level surface. */
472
+ get raw() {
473
+ return this._raw;
474
+ }
475
+ /**
476
+ * Encode `value` as JSON and push it as one request chunk.
477
+ * Throws `nrpc:codec_encode:` if encoding fails (the chunk is
478
+ * NOT sent in that case).
479
+ */
480
+ async send(value) {
481
+ const buf = jsonEncode(value);
482
+ await this._raw.send(buf);
483
+ }
484
+ /**
485
+ * Close the upload direction and await the terminal response.
486
+ * Consumes the call — subsequent `send` / `finish` throw
487
+ * `stream_closed`. Decode failure on the terminal Buffer
488
+ * surfaces as `nrpc:codec_decode:`.
489
+ */
490
+ async finish() {
491
+ try {
492
+ const buf = await this._raw.finish();
493
+ return jsonDecode(buf);
494
+ }
495
+ finally {
496
+ this._detachSignal();
497
+ }
498
+ }
499
+ /** Server-assigned `call_id`. */
500
+ async callId() {
501
+ return await this._raw.callId();
502
+ }
503
+ /** `true` if the call was opened with `requestWindowInitial`. */
504
+ async flowControlled() {
505
+ return await this._raw.flowControlled();
506
+ }
507
+ /**
508
+ * Close without finishing. Fires CANCEL on the wire if the
509
+ * initial REQUEST has flown. Idempotent. A concurrent in-flight
510
+ * `send()` awaiting flow-control credit observes `stream_closed`.
511
+ *
512
+ * Detaches the `AbortSignal` listener (if one was wired by
513
+ * `TypedMeshRpc.callClientStream(opts.signal)`) so the
514
+ * signal can be reused for a subsequent call.
515
+ */
516
+ async close() {
517
+ this._detachSignal();
518
+ try {
519
+ await this._raw.close();
520
+ }
521
+ catch {
522
+ /* swallow — best-effort */
523
+ }
524
+ }
525
+ }
526
+ exports.TypedClientStreamCall = TypedClientStreamCall;
527
+ /**
528
+ * Typed inbound request stream surfaced to client-streaming +
529
+ * duplex server handlers. Drain via `next()` until it returns
530
+ * `null` (clean EOF) or implements `AsyncIterable<Req>` for
531
+ * `for await (const req of stream)` style.
532
+ *
533
+ * Decode failure on a chunk surfaces as `nrpc:codec_decode:` —
534
+ * the handler may catch and skip, or let it propagate to abort
535
+ * the call. The underlying raw stream is closed on decode
536
+ * failure so subsequent `next()` returns `null`.
537
+ */
538
+ class TypedRequestStream {
539
+ _raw;
540
+ _done;
541
+ constructor(rawStream) {
542
+ this._raw = rawStream;
543
+ this._done = false;
544
+ }
545
+ /** Underlying raw stream for users who need the Buffer-level surface. */
546
+ get raw() {
547
+ return this._raw;
548
+ }
549
+ /** Caller's peer origin hash (`0n` on the loopback fast path). */
550
+ get callerOrigin() {
551
+ return this._raw.callerOrigin;
552
+ }
553
+ /** Server-assigned `call_id`. */
554
+ get callId() {
555
+ return this._raw.callId;
556
+ }
557
+ /**
558
+ * Caller's declared deadline as a Unix-nanoseconds absolute
559
+ * timestamp. `0n` means no deadline was declared.
560
+ */
561
+ get deadlineNs() {
562
+ return this._raw.deadlineNs;
563
+ }
564
+ /**
565
+ * Initial-REQUEST headers carried by the caller. Each entry is
566
+ * `[name, value]` with `name` lowercase.
567
+ */
568
+ get headers() {
569
+ return this._raw.headers;
570
+ }
571
+ /**
572
+ * Pull the next decoded request. Returns `null` on clean EOF.
573
+ * Throws `nrpc:codec_decode:` on decode failure (the underlying
574
+ * raw stream is marked closed; subsequent `next()` returns
575
+ * `null`).
576
+ */
577
+ async next() {
578
+ if (this._done)
579
+ return null;
580
+ let buf;
581
+ try {
582
+ buf = await this._raw.next();
583
+ }
584
+ catch (e) {
585
+ this._done = true;
586
+ throw e;
587
+ }
588
+ if (buf === null || buf === undefined) {
589
+ this._done = true;
590
+ return null;
591
+ }
592
+ try {
593
+ return jsonDecode(buf);
594
+ }
595
+ catch (e) {
596
+ // Mark done so subsequent next() returns null — refuse to
597
+ // continue draining a stream whose framing is broken.
598
+ this._done = true;
599
+ throw e;
600
+ }
601
+ }
602
+ /** Async iterator support: `for await (const req of stream) { ... }`. */
603
+ async *[Symbol.asyncIterator]() {
604
+ while (true) {
605
+ const value = await this.next();
606
+ if (value === null)
607
+ return;
608
+ yield value;
609
+ }
610
+ }
611
+ }
612
+ exports.TypedRequestStream = TypedRequestStream;
613
+ // ============================================================================
614
+ // TypedDuplexCall + TypedDuplexSink + TypedDuplexStream +
615
+ // TypedResponseSink (S2-B2).
616
+ //
617
+ // Duplex: caller pushes Reqs and pulls Resps concurrently on a
618
+ // single call. `intoSplit` separates the halves for the encoder-
619
+ // task / decoder-task pattern where each half lives in its own
620
+ // async task.
621
+ //
622
+ // Cancellation contract (locked decision #2): v1 `.close()`-only,
623
+ // same as client-streaming.
624
+ // ============================================================================
625
+ /**
626
+ * Constant sentinel returned by the duplex serve-handler shim.
627
+ * The napi `NodeDuplexRpcHandler` discards the terminal Buffer
628
+ * (only the Ok/Err outcome matters for duplex), so a stable
629
+ * empty Buffer is enough.
630
+ */
631
+ const DUPLEX_TERMINAL_SENTINEL = Buffer.alloc(0);
632
+ /**
633
+ * Typed duplex call handle. Push typed requests via `send`,
634
+ * pull typed responses via `next` or `for await`, or call
635
+ * `intoSplit` to peel off independent sink + stream halves.
636
+ *
637
+ * After `intoSplit` returns, this call is "consumed" — calling
638
+ * `send` / `finishSending` / `next` / `close` on it throws.
639
+ */
640
+ class TypedDuplexCall {
641
+ _raw;
642
+ _done;
643
+ _detachSignal;
644
+ /**
645
+ * `detachSignal` is the cleanup function returned by
646
+ * `wireAbortSignal`. Run on `close()` (or transferred to one
647
+ * of the split halves on `intoSplit`) so the AbortSignal
648
+ * listener doesn't outlive the call.
649
+ */
650
+ constructor(rawCall, detachSignal = () => { }) {
651
+ this._raw = rawCall;
652
+ this._done = false;
653
+ this._detachSignal = detachSignal;
654
+ }
655
+ /** Underlying raw call for users who want the Buffer-level surface. */
656
+ get raw() {
657
+ return this._raw;
658
+ }
659
+ /** Encode + push one request chunk. */
660
+ async send(value) {
661
+ const buf = jsonEncode(value);
662
+ await this._raw.send(buf);
663
+ }
664
+ /** Close the upload direction (emit REQUEST_END). */
665
+ async finishSending() {
666
+ await this._raw.finishSending();
667
+ }
668
+ /**
669
+ * Pull the next decoded response. Returns `null` on clean EOF.
670
+ * Decode failure throws `nrpc:codec_decode:` and closes the
671
+ * underlying duplex call — subsequent `next()` returns `null`.
672
+ */
673
+ async next() {
674
+ if (this._done)
675
+ return null;
676
+ let buf;
677
+ try {
678
+ buf = await this._raw.next();
679
+ }
680
+ catch (e) {
681
+ this._done = true;
682
+ throw e;
683
+ }
684
+ if (buf === null || buf === undefined) {
685
+ this._done = true;
686
+ return null;
687
+ }
688
+ try {
689
+ return jsonDecode(buf);
690
+ }
691
+ catch (e) {
692
+ this._done = true;
693
+ try {
694
+ await this._raw.close();
695
+ }
696
+ catch {
697
+ /* swallow — best-effort */
698
+ }
699
+ throw e;
700
+ }
701
+ }
702
+ /** Async iterator support over the response stream. */
703
+ async *[Symbol.asyncIterator]() {
704
+ while (true) {
705
+ const value = await this.next();
706
+ if (value === null)
707
+ return;
708
+ yield value;
709
+ }
710
+ }
711
+ /**
712
+ * Split the duplex into independent typed sink + stream halves.
713
+ * After return, this `TypedDuplexCall` is consumed — subsequent
714
+ * `send` / `finishSending` / `next` throw `stream_closed`.
715
+ * CANCEL fires only when BOTH split halves drop without
716
+ * observing the response stream's terminal frame.
717
+ *
718
+ * The AbortSignal listener (if one was wired) transfers to the
719
+ * sink half — the sink is the half that issues the upload,
720
+ * which is typically dropped first. Wherever the listener
721
+ * ends up, it stays attached until that half's `close()` runs;
722
+ * the stream half can still observe cancel-mid-flight via the
723
+ * substrate's cancel-token primitive even if the sink half's
724
+ * listener has already detached.
725
+ */
726
+ async intoSplit() {
727
+ const [rawSink, rawStream] = await this._raw.intoSplit();
728
+ this._done = true;
729
+ return [
730
+ new TypedDuplexSink(rawSink, this._detachSignal),
731
+ new TypedDuplexStream(rawStream),
732
+ ];
733
+ }
734
+ /** Server-assigned `call_id`. */
735
+ async callId() {
736
+ return await this._raw.callId();
737
+ }
738
+ /**
739
+ * `true` if the call was opened with non-`None`
740
+ * `requestWindowInitial`. Reports the UPLOAD-direction
741
+ * flow-control state.
742
+ */
743
+ async flowControlled() {
744
+ return await this._raw.flowControlled();
745
+ }
746
+ /**
747
+ * Close without observing the response terminator. Fires
748
+ * CANCEL on the wire. Idempotent. Concurrent in-flight
749
+ * `send()` awaiting credit observes `stream_closed`.
750
+ *
751
+ * Detaches the `AbortSignal` listener (if one was wired by
752
+ * `TypedMeshRpc.callDuplex(opts.signal)`) so the signal can
753
+ * be reused for a subsequent call.
754
+ */
755
+ async close() {
756
+ this._done = true;
757
+ this._detachSignal();
758
+ try {
759
+ await this._raw.close();
760
+ }
761
+ catch {
762
+ /* swallow — best-effort */
763
+ }
764
+ }
765
+ }
766
+ exports.TypedDuplexCall = TypedDuplexCall;
767
+ /** Send-half of a typed duplex call after `intoSplit`. */
768
+ class TypedDuplexSink {
769
+ _raw;
770
+ _detachSignal;
771
+ constructor(rawSink, detachSignal = () => { }) {
772
+ this._raw = rawSink;
773
+ this._detachSignal = detachSignal;
774
+ }
775
+ /** Underlying raw sink for Buffer-level access. */
776
+ get raw() {
777
+ return this._raw;
778
+ }
779
+ /** Encode + push one request chunk. */
780
+ async send(value) {
781
+ const buf = jsonEncode(value);
782
+ await this._raw.send(buf);
783
+ }
784
+ /** Close the upload direction (emit REQUEST_END). */
785
+ async finish() {
786
+ await this._raw.finish();
787
+ }
788
+ /** Server-assigned `call_id`. */
789
+ async callId() {
790
+ return await this._raw.callId();
791
+ }
792
+ /** `true` if the call was opened with `requestWindowInitial`. */
793
+ async flowControlled() {
794
+ return await this._raw.flowControlled();
795
+ }
796
+ /**
797
+ * Close without emitting REQUEST_END. Idempotent. Concurrent
798
+ * in-flight `send()` awaiting credit observes `stream_closed`.
799
+ *
800
+ * Detaches the AbortSignal listener (if one was transferred from
801
+ * the parent `TypedDuplexCall` via `intoSplit`).
802
+ */
803
+ async close() {
804
+ this._detachSignal();
805
+ try {
806
+ await this._raw.close();
807
+ }
808
+ catch {
809
+ /* swallow — best-effort */
810
+ }
811
+ }
812
+ }
813
+ exports.TypedDuplexSink = TypedDuplexSink;
814
+ /** Receive-half of a typed duplex call after `intoSplit`. */
815
+ class TypedDuplexStream {
816
+ _raw;
817
+ _done;
818
+ constructor(rawStream) {
819
+ this._raw = rawStream;
820
+ this._done = false;
821
+ }
822
+ /** Underlying raw stream for Buffer-level access. */
823
+ get raw() {
824
+ return this._raw;
825
+ }
826
+ /**
827
+ * Pull the next decoded response. Returns `null` on clean EOF.
828
+ * Decode failure throws `nrpc:codec_decode:` and closes the
829
+ * underlying stream.
830
+ */
831
+ async next() {
832
+ if (this._done)
833
+ return null;
834
+ let buf;
835
+ try {
836
+ buf = await this._raw.next();
837
+ }
838
+ catch (e) {
839
+ this._done = true;
840
+ throw e;
841
+ }
842
+ if (buf === null || buf === undefined) {
843
+ this._done = true;
844
+ return null;
845
+ }
846
+ try {
847
+ return jsonDecode(buf);
848
+ }
849
+ catch (e) {
850
+ this._done = true;
851
+ try {
852
+ await this._raw.close();
853
+ }
854
+ catch {
855
+ /* swallow — best-effort */
856
+ }
857
+ throw e;
858
+ }
859
+ }
860
+ /** Async iterator support over the response stream. */
861
+ async *[Symbol.asyncIterator]() {
862
+ while (true) {
863
+ const value = await this.next();
864
+ if (value === null)
865
+ return;
866
+ yield value;
867
+ }
868
+ }
869
+ /** Server-assigned `call_id`. */
870
+ async callId() {
871
+ return await this._raw.callId();
872
+ }
873
+ /** Close the stream. Idempotent. */
874
+ async close() {
875
+ this._done = true;
876
+ try {
877
+ await this._raw.close();
878
+ }
879
+ catch {
880
+ /* swallow — best-effort */
881
+ }
882
+ }
883
+ }
884
+ exports.TypedDuplexStream = TypedDuplexStream;
885
+ /**
886
+ * Typed outbound response sink for duplex server handlers.
887
+ * Non-async — mirrors `RawResponseSink.send`. Returns `true`
888
+ * when the chunk was enqueued; `false` if the underlying sink
889
+ * is closed. Encode failure throws `nrpc:codec_encode:` and the
890
+ * chunk is NOT sent.
891
+ *
892
+ * Flow control: the underlying sink `try_send`s into a bounded
893
+ * 1024-chunk mpsc; bursts past the credit window are dropped
894
+ * (counted by `streaming_chunks_dropped_total`). Pace your
895
+ * `send` calls via REQUEST_GRANT cadence for lossless flow
896
+ * control.
897
+ */
898
+ class TypedResponseSink {
899
+ _raw;
900
+ constructor(rawSink) {
901
+ this._raw = rawSink;
902
+ }
903
+ /** Underlying raw sink for Buffer-level access. */
904
+ get raw() {
905
+ return this._raw;
906
+ }
907
+ /**
908
+ * Encode + emit one response chunk. Returns `true` on
909
+ * successful enqueue; `false` if the sink has been closed.
910
+ * Throws `nrpc:codec_encode:` on encoding failure.
911
+ */
912
+ send(value) {
913
+ const buf = jsonEncode(value);
914
+ return this._raw.send(buf);
915
+ }
916
+ }
917
+ exports.TypedResponseSink = TypedResponseSink;
918
+ // ============================================================================
280
919
  // RetryPolicy — mirrors net_sdk::mesh_rpc_resilience::RetryPolicy.
281
920
  //
282
921
  // Defaults: 3 attempts, 50ms initial backoff, doubling per
@@ -783,3 +1422,38 @@ function appError(code, body) {
783
1422
  exports.NRPC_TYPED_BAD_REQUEST = 0x8000;
784
1423
  /** RpcStatus::Application(0x8001): typed handler returned `throw`. */
785
1424
  exports.NRPC_TYPED_HANDLER_ERROR = 0x8001;
1425
+ /**
1426
+ * Convert the napi POD shape (`RawRpcCallEvent` with flat
1427
+ * `statusKind` / `statusMessage` fields) into the TS-idiomatic
1428
+ * tagged `RpcCallEvent`. Exported for tests that need to
1429
+ * synthesize observer events; production code receives already-
1430
+ * decoded events via `TypedMeshRpc.setObserver`.
1431
+ */
1432
+ function rawEventToTyped(raw) {
1433
+ let status;
1434
+ switch (raw.statusKind) {
1435
+ case exports.STATUS_KIND_OK:
1436
+ status = { kind: 'ok' };
1437
+ break;
1438
+ case exports.STATUS_KIND_ERROR:
1439
+ status = { kind: 'error', message: raw.statusMessage ?? '' };
1440
+ break;
1441
+ case exports.STATUS_KIND_TIMEOUT:
1442
+ status = { kind: 'timeout' };
1443
+ break;
1444
+ case exports.STATUS_KIND_CANCELED:
1445
+ status = { kind: 'canceled' };
1446
+ break;
1447
+ }
1448
+ return {
1449
+ caller: raw.caller,
1450
+ callee: raw.callee,
1451
+ method: raw.method,
1452
+ latencyMs: raw.latencyMs,
1453
+ status,
1454
+ requestBytes: raw.requestBytes,
1455
+ responseBytes: raw.responseBytes,
1456
+ direction: raw.direction,
1457
+ tsUnixMs: raw.tsUnixMs,
1458
+ };
1459
+ }