@net-mesh/core 0.19.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 ADDED
@@ -0,0 +1,780 @@
1
+ "use strict";
2
+ // Typed nRPC wrappers + retry / hedge / circuit-breaker helpers.
3
+ //
4
+ // Sits on top of the raw napi `MeshRpc` class (in index.js):
5
+ // translates typed JS objects to/from JSON-encoded Buffers,
6
+ // re-throws errors as typed `RpcError` subclasses, and provides
7
+ // pure-JS implementations of the resilience policies that mirror
8
+ // the Rust SDK's defaults.
9
+ //
10
+ // Usage:
11
+ // import { NetMesh } from '@net-mesh/core'
12
+ // import { TypedMeshRpc, RetryPolicy } from '@net-mesh/core/mesh_rpc'
13
+ //
14
+ // const mesh = await NetMesh.create({ ... })
15
+ // const rpc = TypedMeshRpc.fromMesh(mesh)
16
+ //
17
+ // const handle = rpc.serve('echo', async (req) => req)
18
+ // const reply = await rpc.call(targetId, 'echo', { hello: 'world' })
19
+ //
20
+ // // With retry:
21
+ // const policy = new RetryPolicy({ maxAttempts: 3 })
22
+ // const reply = await rpc.callWithRetry(targetId, 'echo', req, undefined, policy)
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;
25
+ exports.defaultRetryable = defaultRetryable;
26
+ exports.defaultBreakerFailure = defaultBreakerFailure;
27
+ exports.appError = appError;
28
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
29
+ const native = require('./index');
30
+ // Duck-typed message extractor — keeps the "any rejected value
31
+ // with a string `message`" contract uniform across the two
32
+ // modules. The TS source compiles this `import` to a `require`
33
+ // of the sibling `./errors.js`, which `prepublishOnly` emits
34
+ // alongside `mesh_rpc.js`.
35
+ const errors_1 = require("./errors");
36
+ // ============================================================================
37
+ // JSON codec.
38
+ //
39
+ // Default codec for typed wrappers. Matches the Rust SDK's
40
+ // Codec::Json. Encode failure (e.g. circular reference, BigInt
41
+ // in unsupported position) → throw RpcCodecError(direction='encode')
42
+ // BEFORE the call hits the wire. Decode failure on the reply
43
+ // surfaces as RpcCodecError(direction='decode').
44
+ // ============================================================================
45
+ const utf8 = new TextEncoder();
46
+ const utf8d = new TextDecoder('utf-8', { fatal: true });
47
+ function jsonEncode(value) {
48
+ let json;
49
+ try {
50
+ json = JSON.stringify(value);
51
+ }
52
+ catch (e) {
53
+ throw new Error(`nrpc:codec_encode: ${(0, errors_1.extractMessage)(e) || String(e)}`);
54
+ }
55
+ if (json === undefined) {
56
+ // JSON.stringify(undefined) === undefined. nRPC expects bytes.
57
+ throw new Error('nrpc:codec_encode: top-level undefined cannot be serialized');
58
+ }
59
+ return Buffer.from(utf8.encode(json));
60
+ }
61
+ function jsonDecode(buf) {
62
+ try {
63
+ return JSON.parse(utf8d.decode(buf));
64
+ }
65
+ catch (e) {
66
+ throw new Error(`nrpc:codec_decode: ${(0, errors_1.extractMessage)(e) || String(e)}`);
67
+ }
68
+ }
69
+ class TypedMeshRpc {
70
+ _raw;
71
+ /**
72
+ * Build a TypedMeshRpc against a NetMesh. Cheap; returns a
73
+ * new wrapper around an internal raw MeshRpc.
74
+ */
75
+ static fromMesh(mesh) {
76
+ return new TypedMeshRpc(native.MeshRpc.fromMesh(mesh));
77
+ }
78
+ /**
79
+ * Build a TypedMeshRpc from an already-constructed raw MeshRpc.
80
+ * Useful if you need the raw + typed surface side by side.
81
+ */
82
+ constructor(rawMeshRpc) {
83
+ this._raw = rawMeshRpc;
84
+ }
85
+ /** Underlying raw `MeshRpc` for users who want the Buffer-level surface. */
86
+ get raw() {
87
+ return this._raw;
88
+ }
89
+ /**
90
+ * Register a typed handler. The handler receives a decoded
91
+ * `Req` and returns a `Resp` (or a Promise of one). JSON
92
+ * encode/decode happens at the binding boundary; encode
93
+ * failure inside the handler surfaces to the caller as
94
+ * `RpcServerError(status=0x0006 Internal)`.
95
+ */
96
+ serve(service, handler) {
97
+ return this._raw.serve(service, async (reqBuf) => {
98
+ // Decode failures on the request surface to the caller as
99
+ // a canonical typed-bad-request: the Rust binding maps any
100
+ // promise rejection whose message starts with
101
+ // `nrpc:app_error:0x<code>:<body>` to
102
+ // RpcHandlerError::Application { code, message: body },
103
+ // which the fold emits as RpcStatus::Application(code).
104
+ // Status 0x8000 == NRPC_TYPED_BAD_REQUEST per the cross-
105
+ // binding contract pinned in
106
+ // `tests/cross_lang_nrpc/golden_vectors.json`.
107
+ let req;
108
+ try {
109
+ req = jsonDecode(reqBuf);
110
+ }
111
+ catch (e) {
112
+ const body = JSON.stringify({
113
+ error: 'invalid_request',
114
+ detail: (0, errors_1.extractMessage)(e) || String(e),
115
+ });
116
+ throw appError(0x8000, body);
117
+ }
118
+ const resp = await handler(req);
119
+ return jsonEncode(resp);
120
+ });
121
+ }
122
+ /**
123
+ * Direct-addressed typed call. Encodes `req` as JSON, calls,
124
+ * decodes the response. Throws an `RpcError` subclass on
125
+ * failure (matched by the napi prefix → JS class mapping in
126
+ * `errors.js`).
127
+ *
128
+ * Pass `opts.signal` (AbortSignal) for caller-driven
129
+ * cancellation. The wrapper mints a cancel token via the raw
130
+ * binding's `reserveCancelToken()`, attaches an abort listener,
131
+ * and lets the abort fire `cancelCall(token)` to drop the in-
132
+ * flight call (CANCEL fires on the wire, the call rejects with
133
+ * `nrpc:cancelled:`).
134
+ */
135
+ async call(targetNodeId, service, req, opts) {
136
+ const reqBuf = jsonEncode(req);
137
+ const { rawOpts, detach } = wireAbortSignal(this._raw, opts);
138
+ try {
139
+ const respBuf = await this._raw.call(targetNodeId, service, reqBuf, rawOpts);
140
+ return jsonDecode(respBuf);
141
+ }
142
+ finally {
143
+ detach();
144
+ }
145
+ }
146
+ /**
147
+ * Service-discovery typed call. Resolves `service` against the
148
+ * local capability index, picks a target per the routing
149
+ * policy, calls. Throws an `RpcError` subclass on failure.
150
+ */
151
+ async callService(service, req, opts) {
152
+ const reqBuf = jsonEncode(req);
153
+ const { rawOpts, detach } = wireAbortSignal(this._raw, opts);
154
+ try {
155
+ const respBuf = await this._raw.callService(service, reqBuf, rawOpts);
156
+ return jsonDecode(respBuf);
157
+ }
158
+ finally {
159
+ detach();
160
+ }
161
+ }
162
+ /**
163
+ * Open a typed streaming call. Returns a `TypedRpcStream` that
164
+ * yields decoded `Resp` values per `next()` until EOF.
165
+ */
166
+ async callStreaming(targetNodeId, service, req, opts) {
167
+ const reqBuf = jsonEncode(req);
168
+ const inner = await this._raw.callStreaming(targetNodeId, service, reqBuf, opts);
169
+ return new TypedRpcStream(inner);
170
+ }
171
+ /** Pass-through to `MeshRpc.findServiceNodes`. */
172
+ findServiceNodes(service) {
173
+ return this._raw.findServiceNodes(service);
174
+ }
175
+ // ---- resilience helpers --------------------------------------------------
176
+ /**
177
+ * Direct-addressed typed call with retry. See {@link RetryPolicy}.
178
+ */
179
+ async callWithRetry(targetNodeId, service, req, opts, policy) {
180
+ // Encode once and reuse across attempts (matches the Rust
181
+ // SDK's call_typed_with_retry contract).
182
+ const reqBuf = jsonEncode(req);
183
+ const respBuf = await runRetry(policy, async () => {
184
+ return await this._raw.call(targetNodeId, service, reqBuf, opts);
185
+ });
186
+ return jsonDecode(respBuf);
187
+ }
188
+ /**
189
+ * Hedge typed call across the listed targets. First reply
190
+ * (Ok or Err) wins; if every target fails, the surfaced error
191
+ * is the primary's (target index 0) for stable diagnostics.
192
+ */
193
+ async callWithHedgeTo(targets, service, req, opts, policy) {
194
+ const reqBuf = jsonEncode(req);
195
+ const respBuf = await runHedge(policy, targets, async (targetId) => {
196
+ return await this._raw.call(targetId, service, reqBuf, opts);
197
+ });
198
+ return jsonDecode(respBuf);
199
+ }
200
+ }
201
+ exports.TypedMeshRpc = TypedMeshRpc;
202
+ // ============================================================================
203
+ // TypedRpcStream — typed wrapper around the raw RpcStream.
204
+ // ============================================================================
205
+ class TypedRpcStream {
206
+ _raw;
207
+ _done;
208
+ constructor(rawRpcStream) {
209
+ this._raw = rawRpcStream;
210
+ this._done = false;
211
+ }
212
+ /**
213
+ * Pull the next decoded value. Returns `null` on clean EOF.
214
+ * Throws `RpcCodecError(direction='decode')` if a chunk fails
215
+ * to decode (terminates the stream — the underlying CANCEL is
216
+ * fired by the raw RpcStream's drop).
217
+ */
218
+ async next() {
219
+ if (this._done)
220
+ return null;
221
+ let buf;
222
+ try {
223
+ buf = await this._raw.next();
224
+ }
225
+ catch (e) {
226
+ this._done = true;
227
+ throw e; // user catch site classifies via classifyError
228
+ }
229
+ if (buf === null || buf === undefined) {
230
+ this._done = true;
231
+ return null;
232
+ }
233
+ try {
234
+ return jsonDecode(buf);
235
+ }
236
+ catch (e) {
237
+ this._done = true;
238
+ // Close the underlying stream so the server's handler
239
+ // observes CANCEL — no point keeping a stream open whose
240
+ // subsequent chunks we can't decode.
241
+ try {
242
+ await this._raw.close();
243
+ }
244
+ catch {
245
+ /* swallow — best-effort */
246
+ }
247
+ throw e;
248
+ }
249
+ }
250
+ /** Async iterator support: `for await (const chunk of stream) { ... }`. */
251
+ async *[Symbol.asyncIterator]() {
252
+ while (true) {
253
+ const value = await this.next();
254
+ if (value === null)
255
+ return;
256
+ yield value;
257
+ }
258
+ }
259
+ /** Grant `n` flow-control credits to the server pump. */
260
+ async grant(n) {
261
+ await this._raw.grant(n);
262
+ }
263
+ /** `true` if the call set `streamWindowInitial`. */
264
+ async flowControlled() {
265
+ return await this._raw.flowControlled();
266
+ }
267
+ /** Close the stream; emits CANCEL to the server. Idempotent. */
268
+ async close() {
269
+ this._done = true;
270
+ try {
271
+ await this._raw.close();
272
+ }
273
+ catch {
274
+ /* swallow — best-effort */
275
+ }
276
+ }
277
+ }
278
+ exports.TypedRpcStream = TypedRpcStream;
279
+ // ============================================================================
280
+ // RetryPolicy — mirrors net_sdk::mesh_rpc_resilience::RetryPolicy.
281
+ //
282
+ // Defaults: 3 attempts, 50ms initial backoff, doubling per
283
+ // attempt, capped at 1s, full-half jitter on. The retryable
284
+ // predicate skips RpcCodecError + RpcNoRouteError + non-transient
285
+ // RpcServerError statuses (matches Rust's default_retryable).
286
+ // ============================================================================
287
+ const DEFAULT_RETRY = Object.freeze({
288
+ maxAttempts: 3,
289
+ initialBackoffMs: 50,
290
+ maxBackoffMs: 1000,
291
+ backoffMultiplier: 2.0,
292
+ jitter: true,
293
+ });
294
+ // Wire-level RpcStatus codes the default predicate considers
295
+ // transient (server-observed). Matches the Rust SDK's default_retryable.
296
+ const STATUS_INTERNAL = 0x0006;
297
+ const STATUS_BACKPRESSURE = 0x0004;
298
+ const STATUS_TIMEOUT = 0x0003;
299
+ function defaultRetryable(err) {
300
+ // Per Rust SDK: NoRoute + Codec are caller-fixable / terminal
301
+ // and never retried. Timeout (caller-side) and Transport
302
+ // always retry. ServerError retries only for canonical
303
+ // transient statuses.
304
+ //
305
+ // Detection strategy: try `err.name` first (a runtime string,
306
+ // dual-module-safe), then fall back to message-prefix matching
307
+ // for raw napi errors that haven't been classified yet.
308
+ // String / null / undefined rejections short-circuit to "not
309
+ // retryable" — the predicate only fires for object-shaped errors.
310
+ if (err === null || err === undefined || typeof err !== 'object') {
311
+ return false;
312
+ }
313
+ const errAny = err;
314
+ const name = errAny.name ?? '';
315
+ switch (name) {
316
+ case 'RpcNoRouteError':
317
+ case 'RpcCodecError':
318
+ case 'RpcCancelledError':
319
+ // Cancellation is caller-driven — retrying defeats the
320
+ // point. Pinned by `RpcCancelledError`'s class docstring;
321
+ // pre-TS-migration the predicate fell through to the
322
+ // generic `nrpc:` "retry by default" branch and silently
323
+ // re-issued cancelled calls.
324
+ return false;
325
+ case 'RpcTimeoutError':
326
+ case 'RpcTransportError':
327
+ return true;
328
+ case 'RpcServerError': {
329
+ // Prefer err.status (set by RpcServerError constructor);
330
+ // fall back to parsing the message.
331
+ const status = typeof errAny.status === 'number'
332
+ ? errAny.status
333
+ : parseStatusFromMessage((0, errors_1.extractMessage)(err));
334
+ return (status === STATUS_INTERNAL ||
335
+ status === STATUS_BACKPRESSURE ||
336
+ status === STATUS_TIMEOUT);
337
+ }
338
+ }
339
+ // Fall back to message prefix. Use the same duck-typed
340
+ // extractor as classifyError so plain-object rejections with a
341
+ // `message` field classify the same way real Errors do.
342
+ const msg = (0, errors_1.extractMessage)(err);
343
+ if (!msg.startsWith('nrpc:'))
344
+ return false;
345
+ if (msg.startsWith('nrpc:no_route:'))
346
+ return false;
347
+ if (msg.startsWith('nrpc:codec_'))
348
+ return false;
349
+ if (msg.startsWith('nrpc:cancelled:'))
350
+ return false;
351
+ if (msg.startsWith('nrpc:server_error:')) {
352
+ const status = parseStatusFromMessage(msg);
353
+ return (status === STATUS_INTERNAL ||
354
+ status === STATUS_BACKPRESSURE ||
355
+ status === STATUS_TIMEOUT);
356
+ }
357
+ // nrpc:timeout, nrpc:transport, and any future variant → retry.
358
+ return true;
359
+ }
360
+ function parseStatusFromMessage(msg) {
361
+ if (!msg)
362
+ return undefined;
363
+ const m = /status=0x([0-9a-fA-F]+)/.exec(msg);
364
+ return m ? parseInt(m[1], 16) : undefined;
365
+ }
366
+ /**
367
+ * Retry policy. Defaults: 3 attempts, 50ms→1s exponential backoff,
368
+ * full-half jitter on, retryable predicate matches the Rust SDK's
369
+ * `default_retryable` (skips RpcCodecError, RpcNoRouteError, and
370
+ * non-transient ServerError statuses).
371
+ */
372
+ class RetryPolicy {
373
+ maxAttempts;
374
+ initialBackoffMs;
375
+ maxBackoffMs;
376
+ backoffMultiplier;
377
+ jitter;
378
+ retryable;
379
+ constructor(opts) {
380
+ const merged = { ...DEFAULT_RETRY, ...(opts ?? {}) };
381
+ this.maxAttempts = Math.max(1, merged.maxAttempts | 0);
382
+ this.initialBackoffMs = Math.max(0, merged.initialBackoffMs);
383
+ this.maxBackoffMs = Math.max(this.initialBackoffMs, merged.maxBackoffMs);
384
+ this.backoffMultiplier = Math.max(1.0, merged.backoffMultiplier);
385
+ this.jitter = !!merged.jitter;
386
+ const retryable = (opts?.retryable ?? defaultRetryable);
387
+ if (typeof retryable !== 'function') {
388
+ throw new TypeError('RetryPolicy.retryable must be a function (received ' +
389
+ typeof retryable +
390
+ ')');
391
+ }
392
+ this.retryable = retryable;
393
+ }
394
+ /**
395
+ * Compute the backoff for `attempt` (1-indexed). Caps at
396
+ * `maxBackoffMs` AFTER jitter so the cap is a true ceiling.
397
+ */
398
+ computeBackoffMs(attempt) {
399
+ const exp = Math.max(0, attempt - 1);
400
+ const scaled = this.initialBackoffMs * Math.pow(this.backoffMultiplier, exp);
401
+ const preCap = Math.min(this.maxBackoffMs, scaled);
402
+ const jittered = this.jitter ? preCap * (0.5 + 0.5 * Math.random()) : preCap;
403
+ return Math.min(this.maxBackoffMs, jittered);
404
+ }
405
+ }
406
+ exports.RetryPolicy = RetryPolicy;
407
+ async function runRetry(policy, op) {
408
+ let lastErr;
409
+ for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
410
+ try {
411
+ return await op();
412
+ }
413
+ catch (e) {
414
+ lastErr = e;
415
+ if (attempt === policy.maxAttempts || !policy.retryable(e)) {
416
+ throw e;
417
+ }
418
+ const ms = policy.computeBackoffMs(attempt);
419
+ if (ms > 0)
420
+ await sleep(ms);
421
+ }
422
+ }
423
+ // Unreachable under normal control flow (the loop returns or
424
+ // throws), but TS's exhaustiveness requires an explicit throw.
425
+ throw lastErr;
426
+ }
427
+ // ============================================================================
428
+ // HedgePolicy — mirrors net_sdk::mesh_rpc_resilience::HedgePolicy.
429
+ //
430
+ // Fire-then-race: primary at t=0, additional hedges at
431
+ // t = delay * idx. First reply (Ok or Err) wins; if every hedge
432
+ // fails, surfaces the PRIMARY's error deterministically (matches
433
+ // the Rust SDK's M19 fix).
434
+ // ============================================================================
435
+ const DEFAULT_HEDGE = Object.freeze({
436
+ delayMs: 50,
437
+ hedges: 1,
438
+ });
439
+ /**
440
+ * Hedge policy. Fire-then-race: primary at t=0, hedges at
441
+ * t = delayMs * idx. First reply wins; if every hedge fails,
442
+ * the primary's error is surfaced deterministically.
443
+ */
444
+ class HedgePolicy {
445
+ delayMs;
446
+ hedges;
447
+ constructor(opts) {
448
+ const merged = { ...DEFAULT_HEDGE, ...(opts ?? {}) };
449
+ this.delayMs = Math.max(0, merged.delayMs);
450
+ this.hedges = Math.max(0, merged.hedges | 0);
451
+ }
452
+ }
453
+ exports.HedgePolicy = HedgePolicy;
454
+ async function runHedge(policy, targets, op) {
455
+ if (!Array.isArray(targets) || targets.length === 0) {
456
+ // Plain Error with the stable prefix; user's catch site
457
+ // calls classifyError() to get a typed RpcNoRouteError.
458
+ throw new Error('nrpc:no_route: hedge: empty targets list');
459
+ }
460
+ // Hedges = 0 → degrade to a straight call against targets[0].
461
+ if (policy.hedges === 0 || targets.length === 1) {
462
+ return await op(targets[0]);
463
+ }
464
+ // Cap hedges to the available targets (primary + N-1 hedges).
465
+ const fanout = Math.min(targets.length, 1 + policy.hedges);
466
+ const errors = new Array(fanout).fill(undefined);
467
+ let resolved = false;
468
+ let firstOk = null;
469
+ let okIndex = -1;
470
+ // Each launch is a Promise that either:
471
+ // - resolves with { ok: true, value } if op succeeds AND we're first
472
+ // - resolves with { idx, err } if op fails
473
+ // - never resolves if a previous launch already won
474
+ const launches = [];
475
+ for (let i = 0; i < fanout; i++) {
476
+ const idx = i;
477
+ launches.push((async () => {
478
+ if (idx > 0)
479
+ await sleep(policy.delayMs * idx);
480
+ if (resolved)
481
+ return { idx, err: null, skipped: true };
482
+ try {
483
+ const value = await op(targets[idx]);
484
+ if (!resolved) {
485
+ resolved = true;
486
+ firstOk = value;
487
+ okIndex = idx;
488
+ }
489
+ return { idx, err: null, value };
490
+ }
491
+ catch (e) {
492
+ errors[idx] = e;
493
+ return { idx, err: e };
494
+ }
495
+ })());
496
+ }
497
+ await Promise.all(launches);
498
+ if (okIndex >= 0)
499
+ return firstOk;
500
+ // All failed — surface the primary's error (or the lowest-
501
+ // indexed defined error). Deterministic across runs.
502
+ for (let i = 0; i < errors.length; i++) {
503
+ if (errors[i] !== undefined)
504
+ throw errors[i];
505
+ }
506
+ // Shouldn't reach here, but defend against a fanout = 0 edge
507
+ // case that slipped past the cap above.
508
+ throw new Error('nrpc:hedge: drained with no error captured (bug)');
509
+ }
510
+ const STATE_CLOSED = 'closed';
511
+ const STATE_OPEN = 'open';
512
+ const STATE_HALF_OPEN = 'half-open';
513
+ const DEFAULT_BREAKER = Object.freeze({
514
+ failureThreshold: 5,
515
+ resetAfterMs: 30_000,
516
+ successThreshold: 1,
517
+ });
518
+ function defaultBreakerFailure(err) {
519
+ // Mirror default_retryable — same set of "transient infra
520
+ // failures" counts toward tripping; codec / no-route / app
521
+ // errors don't.
522
+ return defaultRetryable(err);
523
+ }
524
+ // BreakerOpenError extends `Error` (not RpcError) so its identity
525
+ // is local to this module — the test imports BreakerOpenError
526
+ // from this same file, so `instanceof` works. Users who catch
527
+ // "any RPC failure" with `instanceof RpcError` should also catch
528
+ // `instanceof BreakerOpenError` separately when using the
529
+ // breaker. The `nrpc:breaker_open:` message prefix lets
530
+ // `classifyError` route this through the generic `RpcError` base
531
+ // for users who want a unified catch.
532
+ class BreakerOpenError extends Error {
533
+ constructor() {
534
+ super('nrpc:breaker_open: circuit breaker is open');
535
+ this.name = 'BreakerOpenError';
536
+ Object.setPrototypeOf(this, BreakerOpenError.prototype);
537
+ }
538
+ }
539
+ exports.BreakerOpenError = BreakerOpenError;
540
+ /**
541
+ * Three-state circuit breaker. Long-lived; instantiate once per
542
+ * logical downstream and share. `breaker.call(() => ...)`
543
+ * composes around any async op.
544
+ */
545
+ class CircuitBreaker {
546
+ failureThreshold;
547
+ resetAfterMs;
548
+ successThreshold;
549
+ failurePredicate;
550
+ _state;
551
+ _consecutiveFailures;
552
+ _consecutiveSuccesses;
553
+ _openedAt;
554
+ _probeInFlight;
555
+ constructor(opts) {
556
+ const merged = { ...DEFAULT_BREAKER, ...(opts ?? {}) };
557
+ this.failureThreshold = Math.max(1, merged.failureThreshold | 0);
558
+ this.resetAfterMs = Math.max(0, merged.resetAfterMs);
559
+ this.successThreshold = Math.max(1, merged.successThreshold | 0);
560
+ const predicate = (opts?.failurePredicate ??
561
+ defaultBreakerFailure);
562
+ if (typeof predicate !== 'function') {
563
+ throw new TypeError('CircuitBreaker.failurePredicate must be a function (received ' +
564
+ typeof predicate +
565
+ ')');
566
+ }
567
+ this.failurePredicate = predicate;
568
+ this._state = STATE_CLOSED;
569
+ this._consecutiveFailures = 0;
570
+ this._consecutiveSuccesses = 0;
571
+ this._openedAt = 0;
572
+ this._probeInFlight = false;
573
+ }
574
+ state() {
575
+ // Lazy "Open → HalfOpen on cooldown elapsed" transition: we
576
+ // probe at admission time, not on a background timer.
577
+ return this._state;
578
+ }
579
+ consecutiveFailures() {
580
+ return this._consecutiveFailures;
581
+ }
582
+ reset() {
583
+ this._state = STATE_CLOSED;
584
+ this._consecutiveFailures = 0;
585
+ this._consecutiveSuccesses = 0;
586
+ this._openedAt = 0;
587
+ this._probeInFlight = false;
588
+ }
589
+ /**
590
+ * Wrap an async op. Returns its value on success, throws on
591
+ * failure (or on rejection). When the breaker is `Open` within
592
+ * its cooldown, throws `BreakerOpenError` without invoking
593
+ * `op`.
594
+ *
595
+ * Both success and failure paths record the outcome through
596
+ * `_recordOutcome`, which is responsible for clearing
597
+ * `_probeInFlight` on the half-open-probe admission. A
598
+ * synchronous throw inside `op` (rare — `await` is always at
599
+ * `op`'s call site) is still routed through the catch arm.
600
+ */
601
+ async call(op) {
602
+ const admission = this._tryAdmit();
603
+ if (admission === 'reject') {
604
+ throw new BreakerOpenError();
605
+ }
606
+ try {
607
+ const value = await op();
608
+ this._recordOutcome(admission, true, undefined);
609
+ return value;
610
+ }
611
+ catch (e) {
612
+ this._recordOutcome(admission, false, e);
613
+ throw e;
614
+ }
615
+ }
616
+ _tryAdmit() {
617
+ if (this._state === STATE_CLOSED)
618
+ return 'closed';
619
+ if (this._state === STATE_OPEN) {
620
+ const elapsed = Date.now() - this._openedAt;
621
+ if (elapsed >= this.resetAfterMs) {
622
+ this._state = STATE_HALF_OPEN;
623
+ this._consecutiveSuccesses = 0;
624
+ this._probeInFlight = true;
625
+ return 'half-open-probe';
626
+ }
627
+ return 'reject';
628
+ }
629
+ // HalfOpen — at most one probe at a time.
630
+ if (this._probeInFlight)
631
+ return 'reject';
632
+ this._probeInFlight = true;
633
+ return 'half-open-probe';
634
+ }
635
+ _recordOutcome(admission, ok, err) {
636
+ if (admission === 'closed') {
637
+ if (ok) {
638
+ this._consecutiveFailures = 0;
639
+ }
640
+ else if (this.failurePredicate(err)) {
641
+ this._consecutiveFailures += 1;
642
+ if (this._consecutiveFailures >= this.failureThreshold) {
643
+ this._state = STATE_OPEN;
644
+ this._openedAt = Date.now();
645
+ this._consecutiveSuccesses = 0;
646
+ }
647
+ }
648
+ return;
649
+ }
650
+ // half-open-probe
651
+ this._probeInFlight = false;
652
+ if (ok) {
653
+ this._consecutiveSuccesses += 1;
654
+ if (this._consecutiveSuccesses >= this.successThreshold) {
655
+ this._state = STATE_CLOSED;
656
+ this._consecutiveFailures = 0;
657
+ this._consecutiveSuccesses = 0;
658
+ this._openedAt = 0;
659
+ }
660
+ }
661
+ else if (this.failurePredicate(err)) {
662
+ // Failed probe → re-open with fresh cooldown.
663
+ this._state = STATE_OPEN;
664
+ this._openedAt = Date.now();
665
+ this._consecutiveFailures = 0;
666
+ this._consecutiveSuccesses = 0;
667
+ }
668
+ // Predicate said "not a failure" (e.g. application error) →
669
+ // leave state HalfOpen for the next probe.
670
+ }
671
+ }
672
+ exports.CircuitBreaker = CircuitBreaker;
673
+ // ============================================================================
674
+ // Helpers.
675
+ // ============================================================================
676
+ function sleep(ms) {
677
+ return new Promise((resolve) => setTimeout(resolve, ms));
678
+ }
679
+ /**
680
+ * Wire an AbortSignal (`opts.signal`) into the raw napi cancel
681
+ * surface. Returns `{ rawOpts, detach }`:
682
+ *
683
+ * - `rawOpts` is the option object to pass to the raw call,
684
+ * with `cancelToken` populated when a signal was provided
685
+ * (the raw napi side does not understand `signal`).
686
+ * - `detach` MUST be called from a `finally` block on the call
687
+ * site to remove the abort listener regardless of
688
+ * success/failure path. Idempotent.
689
+ *
690
+ * If no signal is provided (or the signal is already aborted),
691
+ * the wrapper either short-circuits or returns the rawOpts
692
+ * unchanged so the non-cancellable fast path stays free of
693
+ * tokio-spawn / registry overhead.
694
+ */
695
+ function wireAbortSignal(raw, opts) {
696
+ if (!opts || !opts.signal) {
697
+ return { rawOpts: opts, detach: () => { } };
698
+ }
699
+ const signal = opts.signal;
700
+ // If the signal is already aborted, fail fast — don't even
701
+ // start the call.
702
+ if (signal.aborted) {
703
+ throw new Error('nrpc:cancelled: AbortSignal already aborted');
704
+ }
705
+ // Mint a token, copy opts (drop `signal` since the napi side
706
+ // doesn't know it), attach a listener that calls cancelCall on
707
+ // abort. The listener removes itself on detach so the AbortSignal
708
+ // can be reused for a subsequent call without leaking handlers.
709
+ const token = raw.reserveCancelToken();
710
+ const rawOpts = { ...opts, cancelToken: token };
711
+ delete rawOpts.signal;
712
+ let detached = false;
713
+ const onAbort = () => {
714
+ if (detached)
715
+ return;
716
+ try {
717
+ raw.cancelCall(token);
718
+ }
719
+ catch {
720
+ /* swallow — best-effort */
721
+ }
722
+ };
723
+ signal.addEventListener('abort', onAbort, { once: true });
724
+ return {
725
+ rawOpts,
726
+ detach: () => {
727
+ if (detached)
728
+ return;
729
+ detached = true;
730
+ signal.removeEventListener('abort', onAbort);
731
+ },
732
+ };
733
+ }
734
+ /**
735
+ * Build an Error a typed serve handler can throw to surface a
736
+ * specific application status code to the caller. The Rust
737
+ * binding parses messages of the form
738
+ * `nrpc:app_error:0x<code>:<body>` and maps them to
739
+ * `RpcStatus::Application(code)` — without this prefix the
740
+ * thrown error becomes a generic `RpcStatus::Internal`. Mirrors
741
+ * the Python binding's `RpcAppError(code, body)`.
742
+ *
743
+ * Use cases: typed handlers that want to return 4xx-style
744
+ * application errors (`NRPC_TYPED_BAD_REQUEST`,
745
+ * `NRPC_TYPED_HANDLER_ERROR`, custom app codes >= 0x8000).
746
+ *
747
+ * @example
748
+ * rpc.serve('echo', (req) => {
749
+ * if (typeof req.text !== 'string') {
750
+ * throw appError(NRPC_TYPED_BAD_REQUEST,
751
+ * JSON.stringify({error: 'missing text'}))
752
+ * }
753
+ * return { echo: req.text }
754
+ * })
755
+ */
756
+ function appError(code, body) {
757
+ if (typeof code !== 'number' || code < 0 || code > 0xffff) {
758
+ throw new TypeError(`appError: code must be a 0..=0xFFFF integer (got ${code})`);
759
+ }
760
+ let bodyStr;
761
+ if (typeof body === 'string') {
762
+ bodyStr = body;
763
+ }
764
+ else if (Buffer.isBuffer(body)) {
765
+ bodyStr = body.toString('utf8');
766
+ }
767
+ else {
768
+ bodyStr = String(body ?? '');
769
+ }
770
+ // The Rust parser splits on the FIRST colon after `0x<hex>:`,
771
+ // so the body itself can contain colons safely.
772
+ const codeHex = code.toString(16).padStart(4, '0');
773
+ return new Error(`nrpc:app_error:0x${codeHex}:${bodyStr}`);
774
+ }
775
+ // Status code constants (parallel to NRPC_TYPED_BAD_REQUEST /
776
+ // NRPC_TYPED_HANDLER_ERROR in the Rust SDK).
777
+ /** RpcStatus::Application(0x8000): typed handler bad-request body. */
778
+ exports.NRPC_TYPED_BAD_REQUEST = 0x8000;
779
+ /** RpcStatus::Application(0x8001): typed handler returned `throw`. */
780
+ exports.NRPC_TYPED_HANDLER_ERROR = 0x8001;