@truenas/api-client 9.0.4 → 9.0.6

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.
@@ -3177,6 +3177,157 @@ function getWebSocketError(code) {
3177
3177
  return "Network connection failed - Check IP address and connectivity";
3178
3178
  }
3179
3179
  }
3180
+ var CallLimiter = class {
3181
+ constructor(limit, write) {
3182
+ this.limit = limit;
3183
+ this.write = write;
3184
+ /**
3185
+ * Each request awaiting an answer, with the handle `write` gave for it. That
3186
+ * handle closes once the frame is on a socket: until then the connection is
3187
+ * still holding it for one, and giving up on it takes it back.
3188
+ */
3189
+ this.inFlight = /* @__PURE__ */ new Map();
3190
+ this.waiting = [];
3191
+ /**
3192
+ * Calls that were waiting when a socket was lost, kept apart from `waiting`
3193
+ * until `release()`. A call sent after the loss, the sign-in among them, then
3194
+ * never waits behind one: nothing would write it, and `release()` waits on it.
3195
+ */
3196
+ this.heldCalls = [];
3197
+ }
3198
+ /** Requests written or being written, and not answered yet. */
3199
+ get inFlightCount() {
3200
+ return this.inFlight.size;
3201
+ }
3202
+ /** Requests waiting for a place, held ones included. */
3203
+ get waitingCount() {
3204
+ return this.waiting.length + this.heldCalls.length;
3205
+ }
3206
+ /** Whether calls are being kept from a lost socket until `release()`. */
3207
+ get held() {
3208
+ return this.heldCalls.length > 0;
3209
+ }
3210
+ /**
3211
+ * Sends `message` now if there is room, or queues it. Frames that are not
3212
+ * requests pass straight through. Unsubscribing the returned handle takes
3213
+ * the call back if it has not been written yet.
3214
+ *
3215
+ * New calls are not held back by a lost socket, only by the limit: the frames
3216
+ * that sign a new socket in come through here, and the held calls are
3217
+ * waiting on exactly those.
3218
+ */
3219
+ send(message) {
3220
+ if (!isRequest(message)) {
3221
+ return this.write(message);
3222
+ }
3223
+ const call = { id: message.id, message, handle: new rxjs.Subscription() };
3224
+ if (this.inFlight.size < this.limit) {
3225
+ this.start(call);
3226
+ return call.handle;
3227
+ }
3228
+ this.waiting.push(call);
3229
+ call.handle.add(() => {
3230
+ remove(this.waiting, call);
3231
+ remove(this.heldCalls, call);
3232
+ });
3233
+ return call.handle;
3234
+ }
3235
+ /**
3236
+ * Counts a request the connection wrote to a socket itself, such as its
3237
+ * keepalive `core.ping`, or returns `false` without counting it when there is
3238
+ * no room. A request written that way is not the limiter's to queue: the
3239
+ * caller decides what not sending it means.
3240
+ */
3241
+ tryTrack(id) {
3242
+ if (this.inFlight.size >= this.limit) {
3243
+ return false;
3244
+ }
3245
+ this.inFlight.set(id, rxjs.Subscription.EMPTY);
3246
+ return true;
3247
+ }
3248
+ /** Frees the place of the request this frame answers, if it is one. */
3249
+ received(message) {
3250
+ if (message.id === void 0 || message.id === null || message.method) {
3251
+ return;
3252
+ }
3253
+ this.finish(String(message.id));
3254
+ }
3255
+ /**
3256
+ * What was written on the lost socket is lost with it, answers included, so
3257
+ * those places are freed. A frame the connection has not written yet is still
3258
+ * its to send on the next socket, so it stays counted. Calls still waiting are
3259
+ * held until `release()`: the new socket starts signed out, and a call sent on
3260
+ * it before the session is back is refused.
3261
+ */
3262
+ socketLost() {
3263
+ this.inFlight.forEach((sending, id) => {
3264
+ if (sending.closed) {
3265
+ this.inFlight.delete(id);
3266
+ }
3267
+ });
3268
+ this.heldCalls.push(...this.waiting.splice(0));
3269
+ }
3270
+ /**
3271
+ * Lets held calls go out again, once the session they were made in is back.
3272
+ * They were sent before anything still waiting, so they go first.
3273
+ */
3274
+ release() {
3275
+ this.waiting.unshift(...this.heldCalls.splice(0));
3276
+ this.startWaiting();
3277
+ }
3278
+ /**
3279
+ * Takes back every call held from a lost socket, unsent, and returns their
3280
+ * ids so the caller can answer them. Does nothing while nothing is held.
3281
+ */
3282
+ takeHeld() {
3283
+ return this.heldCalls.splice(0).map(({ id }) => id);
3284
+ }
3285
+ // The three below call each other in a ring.
3286
+ start({ id, message, handle }) {
3287
+ this.inFlight.set(id, rxjs.Subscription.EMPTY);
3288
+ let sending;
3289
+ try {
3290
+ sending = this.write(message);
3291
+ } catch (error) {
3292
+ this.finish(id);
3293
+ throw error;
3294
+ }
3295
+ if (!this.inFlight.has(id)) {
3296
+ return;
3297
+ }
3298
+ this.inFlight.set(id, sending);
3299
+ handle.add(() => {
3300
+ const isWritten = sending.closed;
3301
+ sending.unsubscribe();
3302
+ if (!isWritten && this.inFlight.get(id) === sending) {
3303
+ this.finish(id);
3304
+ }
3305
+ });
3306
+ }
3307
+ startWaiting() {
3308
+ while (this.inFlight.size < this.limit) {
3309
+ const next = this.waiting.shift();
3310
+ if (!next) {
3311
+ return;
3312
+ }
3313
+ this.start(next);
3314
+ }
3315
+ }
3316
+ finish(id) {
3317
+ if (this.inFlight.delete(id)) {
3318
+ this.startWaiting();
3319
+ }
3320
+ }
3321
+ };
3322
+ function remove(calls, call) {
3323
+ const index = calls.indexOf(call);
3324
+ if (index >= 0) {
3325
+ calls.splice(index, 1);
3326
+ }
3327
+ }
3328
+ function isRequest(message) {
3329
+ return message.id !== void 0 && message.id !== null && Boolean(message.method);
3330
+ }
3180
3331
  var TrueNasSocket = class {
3181
3332
  constructor(config) {
3182
3333
  this.config = config;
@@ -3205,7 +3356,7 @@ function socketScheme(protocol) {
3205
3356
  var tenSeconds = 10 * 1e3;
3206
3357
  var twentySeconds = 20 * 1e3;
3207
3358
  var TrueNasConnection = class {
3208
- constructor(initialEnabled, hostnames, systemUuid, websocketPath, systemName, retryDelay = tenSeconds, maxRetry = 3, logger = noopLogger, protocol = "https:", interceptor) {
3359
+ constructor(initialEnabled, hostnames, systemUuid, websocketPath, systemName, retryDelay = tenSeconds, maxRetry = 3, logger = noopLogger, protocol = "https:", interceptor, maxConcurrentCalls) {
3209
3360
  this.systemUuid = systemUuid;
3210
3361
  this.websocketPath = websocketPath;
3211
3362
  this.systemName = systemName;
@@ -3213,6 +3364,7 @@ var TrueNasConnection = class {
3213
3364
  this.maxRetry = maxRetry;
3214
3365
  this.logger = logger;
3215
3366
  this.interceptor = interceptor;
3367
+ this.maxConcurrentCalls = maxConcurrentCalls;
3216
3368
  // compatibility properties
3217
3369
  this.opened = new rxjs.BehaviorSubject(false);
3218
3370
  this.closed = new rxjs.Subject();
@@ -3376,16 +3528,39 @@ var TrueNasConnection = class {
3376
3528
  rxjs.catchError(() => rxjs.EMPTY)
3377
3529
  ))
3378
3530
  );
3379
- this.messages$ = interceptor?.injected$ ? rxjs.merge(this.socketMessages$, interceptor.injected$.pipe(rxjs.observeOn(rxjs.asapScheduler), rxjs.share())) : this.socketMessages$;
3531
+ /** Answers given here, to calls ended without being sent. See `refuseHeldCalls()`. */
3532
+ this.refusals$ = new rxjs.Subject();
3533
+ if (maxConcurrentCalls !== void 0 && !(Number.isInteger(maxConcurrentCalls) && maxConcurrentCalls > 0)) {
3534
+ throw new Error(`maxConcurrentCalls must be a positive integer, got ${String(maxConcurrentCalls)}`);
3535
+ }
3536
+ this.limiter = maxConcurrentCalls === void 0 ? null : new CallLimiter(maxConcurrentCalls, (message) => this.writeToNextSocket(message));
3537
+ this.messages$ = interceptor?.injected$ ? rxjs.merge(this.socketMessages$, this.refusals$, interceptor.injected$.pipe(rxjs.observeOn(rxjs.asapScheduler), rxjs.share())) : rxjs.merge(this.socketMessages$, this.refusals$);
3380
3538
  this.endpoint$.next(resolveEndpoint(hostnames, protocol));
3381
3539
  this.ws$.pipe(
3382
3540
  rxjs.switchMap((ws) => ws ? rxjs.interval(twentySeconds).pipe(rxjs.map(() => ws)) : rxjs.EMPTY),
3383
3541
  rxjs.takeUntil(this.closeConnection)
3384
3542
  ).subscribe((ws) => {
3385
3543
  const ping = createJsonRpcMessage("core.ping");
3544
+ if (this.limiter && !this.limiter.tryTrack(ping.id ?? "")) {
3545
+ this.logger.debug("Skipping core.ping: every concurrent call place is taken");
3546
+ return;
3547
+ }
3386
3548
  this.interceptor?.outgoing?.(ping);
3387
3549
  ws.next(ping);
3388
3550
  });
3551
+ if (this.limiter) {
3552
+ const limiter = this.limiter;
3553
+ this.socketMessages$.pipe(rxjs.takeUntil(this.closeConnection)).subscribe((message) => {
3554
+ limiter.received(message);
3555
+ });
3556
+ let wasOpen = false;
3557
+ this.opened$.pipe(rxjs.takeUntil(this.closeConnection)).subscribe((isOpen) => {
3558
+ if (wasOpen && !isOpen) {
3559
+ limiter.socketLost();
3560
+ }
3561
+ wasOpen = isOpen;
3562
+ });
3563
+ }
3389
3564
  if (interceptor?.incoming) {
3390
3565
  const incoming = interceptor.incoming.bind(interceptor);
3391
3566
  this.messages$.pipe(rxjs.takeUntil(this.closeConnection)).subscribe((message) => incoming(message));
@@ -3456,11 +3631,46 @@ var TrueNasConnection = class {
3456
3631
  * sends a message over the current websocket OR queues it to send once
3457
3632
  * the next websocket is opened. messages sent via `send` will not be lost until after
3458
3633
  * the next `closed$` emission.
3634
+ *
3635
+ * With `maxConcurrentCalls` set, a request beyond the limit waits for an
3636
+ * answer to make room. Unsubscribing the returned handle takes back a frame
3637
+ * not written yet, waiting or queued.
3459
3638
  */
3460
3639
  send(message) {
3461
3640
  if (this.interceptor?.outgoing?.(message) === false) {
3462
3641
  return rxjs.Subscription.EMPTY;
3463
3642
  }
3643
+ return this.limiter ? this.limiter.send(message) : this.writeToNextSocket(message);
3644
+ }
3645
+ /**
3646
+ * Lets calls held from a lost socket go out again. The client calls this
3647
+ * whenever the session is authenticated, so a consumer does not need to;
3648
+ * a no-op without `maxConcurrentCalls`, or with nothing held.
3649
+ *
3650
+ * Calls waiting for a place when a socket is lost are held, not sent on the
3651
+ * next one: a new socket starts signed out, so middleware would refuse them
3652
+ * with `ENOTAUTHENTICATED`.
3653
+ */
3654
+ releaseHeldCalls() {
3655
+ this.limiter?.release();
3656
+ }
3657
+ /**
3658
+ * Ends every call held from a lost socket without sending it, answering each
3659
+ * on `messages()` with the refusal middleware would have given it on a socket
3660
+ * that is not signed in: `ENOTAUTHENTICATED`. Returns how many it ended.
3661
+ *
3662
+ * For an app whose next sign-in may be a different user than the one who made
3663
+ * those calls, e.g. a password prompt after the session was lost. Call it
3664
+ * before that sign-in; otherwise the calls go out once it succeeds. A no-op
3665
+ * while nothing is held, so a healthy session's waiting calls are not touched.
3666
+ */
3667
+ refuseHeldCalls() {
3668
+ const ids = this.limiter?.takeHeld() ?? [];
3669
+ ids.forEach((id) => this.refusals$.next(notAuthenticatedRefusal(id)));
3670
+ return ids.length;
3671
+ }
3672
+ /** Writes to the current socket, or to the next one once it opens. */
3673
+ writeToNextSocket(message) {
3464
3674
  return this.ws$.pipe(
3465
3675
  rxjs.filter((ws) => ws !== null),
3466
3676
  rxjs.take(1)
@@ -3604,6 +3814,22 @@ var makeConnectionError = (message, hostname, closeCode, closeReason) => ({
3604
3814
  var isTerminalClose = (err) => err?.closeCode === policyViolationCloseCode;
3605
3815
  var resolveEndpoint = (hostnames, protocol) => Object.freeze({ hostnames: Object.freeze([...hostnames]), protocol });
3606
3816
  var sameEndpoint = (a, b) => a.protocol === b.protocol && a.hostnames.length === b.hostnames.length && a.hostnames.every((hostname, i) => hostname === b.hostnames[i]);
3817
+ var notAuthenticatedRefusal = (id) => ({
3818
+ jsonrpc: "2.0",
3819
+ id,
3820
+ error: {
3821
+ code: -32001,
3822
+ message: "Method call error",
3823
+ data: {
3824
+ // `ErrnoMixin.ENOTAUTHENTICATED` in truenas_api_client.
3825
+ error: 207,
3826
+ errname: "ENOTAUTHENTICATED",
3827
+ reason: "The session this call was made in ended before it could be sent.",
3828
+ extra: null,
3829
+ trace: null
3830
+ }
3831
+ }
3832
+ });
3607
3833
  var closedConnection = { state: "closed" };
3608
3834
 
3609
3835
  // src/client/truenas-api-client.ts
@@ -3621,6 +3847,11 @@ var TrueNasApiClient = class {
3621
3847
  this.authenticator = this.createAuthenticator();
3622
3848
  this.api = this.createApi();
3623
3849
  this.ops = this.createOperations();
3850
+ this.authenticator.authenticated$.pipe(
3851
+ rxjs.distinctUntilChanged(),
3852
+ rxjs.filter(Boolean),
3853
+ rxjs.takeUntil(this.connection.closeConnection)
3854
+ ).subscribe(() => this.connection.releaseHeldCalls());
3624
3855
  }
3625
3856
  /**
3626
3857
  * Get current connection status.
@@ -3659,7 +3890,8 @@ var TrueNasApiClient = class {
3659
3890
  this.reconnect.maxRetry,
3660
3891
  this.logger,
3661
3892
  this.protocol,
3662
- this.reconnect.interceptor
3893
+ this.reconnect.interceptor,
3894
+ this.reconnect.maxConcurrentCalls
3663
3895
  );
3664
3896
  }
3665
3897
  /**
@@ -4524,7 +4756,12 @@ function instantiateClientForVersion(version, opts, logger) {
4524
4756
  systemName,
4525
4757
  logger,
4526
4758
  opts.protocol,
4527
- { retryDelay: opts.retryDelay, maxRetry: opts.maxRetry, interceptor: opts.interceptor }
4759
+ {
4760
+ retryDelay: opts.retryDelay,
4761
+ maxRetry: opts.maxRetry,
4762
+ interceptor: opts.interceptor,
4763
+ maxConcurrentCalls: opts.maxConcurrentCalls
4764
+ }
4528
4765
  );
4529
4766
  }
4530
4767
  function errorMessageOrDefault(error, fallback) {
@@ -4586,5 +4823,5 @@ exports.v25_10_4_exports = v25_10_4_exports;
4586
4823
  exports.v25_10_5_exports = v25_10_5_exports;
4587
4824
  exports.v27_0_0_exports = v27_0_0_exports;
4588
4825
  exports.v28_0_0_exports = v28_0_0_exports;
4589
- //# sourceMappingURL=chunk-WAVCPALL.cjs.map
4590
- //# sourceMappingURL=chunk-WAVCPALL.cjs.map
4826
+ //# sourceMappingURL=chunk-UOTSHE2J.cjs.map
4827
+ //# sourceMappingURL=chunk-UOTSHE2J.cjs.map