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