@truenas/api-client 7.0.0 → 7.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.
@@ -1,4 +1,4 @@
1
- import { BehaviorSubject, filter, switchMap, catchError, EMPTY, tap, map, finalize, take, Subject, distinctUntilChanged, of, throwError, concat, retry, startWith, shareReplay, takeUntil, interval, Observable, timeout, timer, race, toArray, from, defer, firstValueFrom, share, merge, takeWhile } from 'rxjs';
1
+ import { BehaviorSubject, filter, switchMap, catchError, EMPTY, tap, map, finalize, take, Subject, distinctUntilChanged, of, startWith, concat, throwError, retry, race, timer, skip, takeUntil, shareReplay, interval, Observable, timeout, TimeoutError, toArray, from, defer, firstValueFrom, share, merge, takeWhile } from 'rxjs';
2
2
  import { webSocket } from 'rxjs/webSocket';
3
3
  import { map as map$1, catchError as catchError$1, shareReplay as shareReplay$1 } from 'rxjs/operators';
4
4
 
@@ -2902,6 +2902,9 @@ function getHttpError(reason) {
2902
2902
  }
2903
2903
  return "";
2904
2904
  }
2905
+ function getCloseMessage(code, reason) {
2906
+ return isHttpStatusError(reason) ? getHttpError(reason) : getWebSocketError(code);
2907
+ }
2905
2908
  function getWebSocketError(code) {
2906
2909
  switch (code) {
2907
2910
  case 1e3:
@@ -2961,14 +2964,12 @@ var tenSeconds = 10 * 1e3;
2961
2964
  var twentySeconds = 20 * 1e3;
2962
2965
  var TrueNasConnection = class {
2963
2966
  constructor(initialEnabled, hostnames, systemUuid, websocketPath, systemName, retryDelay = tenSeconds, maxRetry = 3, logger = noopLogger, protocol = "https:") {
2964
- this.hostnames = hostnames;
2965
2967
  this.systemUuid = systemUuid;
2966
2968
  this.websocketPath = websocketPath;
2967
2969
  this.systemName = systemName;
2968
2970
  this.retryDelay = retryDelay;
2969
2971
  this.maxRetry = maxRetry;
2970
2972
  this.logger = logger;
2971
- this.protocol = protocol;
2972
2973
  // compatibility properties
2973
2974
  this.opened = new BehaviorSubject(false);
2974
2975
  this.closed = new Subject();
@@ -2984,6 +2985,19 @@ var TrueNasConnection = class {
2984
2985
  * flip it via `setEnabled()` (the app maps `SystemState.Active -> true`).
2985
2986
  */
2986
2987
  this.enabled$ = new BehaviorSubject(false);
2988
+ /** Replaced in the constructor, before anything subscribes. See `setEndpoint()`. */
2989
+ this.endpoint$ = new BehaviorSubject(resolveEndpoint([], "https:"));
2990
+ /** The endpoint the last attempt raced; a different one starts with `closed`. */
2991
+ this.lastAttempted = null;
2992
+ /** Protected so the testing entry's `FakeConnection` can script closes. */
2993
+ this.closesSubject = new Subject();
2994
+ /**
2995
+ * Every socket close this client did not cause itself: failed attempts, lost
2996
+ * connections and refusals, each with its code. Tearing a socket down through
2997
+ * `setEnabled(false)`, `setEndpoint()` or `close()` does not report one, nor
2998
+ * does an attempt that times out unanswered after 10 s, as nothing closed.
2999
+ */
3000
+ this.closes$ = this.closesSubject.asObservable();
2987
3001
  /**
2988
3002
  * observable which emits the current gate value and only emits again when it changes.
2989
3003
  */
@@ -2993,30 +3007,18 @@ var TrueNasConnection = class {
2993
3007
  */
2994
3008
  this.connection$ = this.enabledChange$.pipe(
2995
3009
  switchMap((enabled) => {
2996
- if (enabled) {
2997
- return this.connect().pipe(
2998
- map((conn) => makeActiveConnection(conn.ws, conn.hostname)),
2999
- // A refusal becomes a value here rather than an error, so it never
3000
- // reaches the `retry` below and nothing reconnects on its own.
3001
- //
3002
- // Handled at this depth on purpose: letting it out completes the
3003
- // pipeline and unsubscribes `enabledChange$`, and `setEnabled` is the
3004
- // documented way an app asks for another attempt once the network
3005
- // changes. Not retrying is a statement about what this client does on
3006
- // its own; it is not a reason to refuse the caller asking again.
3007
- catchError(
3008
- (err) => isTerminalClose(err) ? of(
3009
- makeConnectionError(
3010
- err.message,
3011
- err.hostname,
3012
- err.closeCode,
3013
- err.closeReason
3014
- )
3015
- ) : throwError(() => err)
3016
- )
3017
- );
3010
+ if (!enabled) {
3011
+ return of(closedConnection);
3018
3012
  }
3019
- return of(closedConnection);
3013
+ return this.endpoint$.pipe(
3014
+ distinctUntilChanged(sameEndpoint),
3015
+ switchMap((endpoint) => {
3016
+ const changed = this.lastAttempted !== null && !sameEndpoint(this.lastAttempted, endpoint);
3017
+ this.lastAttempted = endpoint;
3018
+ const attempt = this.attempt(endpoint);
3019
+ return changed ? attempt.pipe(startWith(closedConnection)) : attempt;
3020
+ })
3021
+ );
3020
3022
  }),
3021
3023
  switchMap((connection) => {
3022
3024
  if (connection.state !== "active") {
@@ -3047,16 +3049,27 @@ var TrueNasConnection = class {
3047
3049
  err.closeReason
3048
3050
  )
3049
3051
  ),
3050
- // since this error will immediately get caught by `retry` there's no reason to build a real error.
3051
- throwError(() => null)
3052
+ throwError(() => err)
3052
3053
  );
3053
3054
  }),
3054
- retry(),
3055
+ // A lost live socket reconnects at once. A cycle that never opened waits
3056
+ // `retryDelay` like any other attempt; without it the next cycle's first
3057
+ // attempt followed the last one back to back. Nothing upstream is
3058
+ // subscribed during the wait, so a gate or endpoint change ends it early.
3059
+ retry({
3060
+ delay: (err) => err?.wasOpen ? of(null) : race(
3061
+ timer(this.retryDelay),
3062
+ this.enabledChange$.pipe(skip(1)),
3063
+ this.endpoint$.pipe(skip(1))
3064
+ )
3065
+ }),
3055
3066
  // start with a closed connection.
3056
3067
  startWith(closedConnection),
3068
+ // Above `shareReplay`, whose `refCount: false` subscription would otherwise
3069
+ // outlive `close()` and let a pending retry wait open another socket.
3070
+ takeUntil(this.closeConnection),
3057
3071
  // prevent multiple subscriptions from re-evaluating the entire pipeline.
3058
- shareReplay({ bufferSize: 1, refCount: false }),
3059
- takeUntil(this.closeConnection)
3072
+ shareReplay({ bufferSize: 1, refCount: false })
3060
3073
  );
3061
3074
  /**
3062
3075
  * observable which is either an instance of the current `TrueNasSocket` used for the connection
@@ -3121,6 +3134,7 @@ var TrueNasConnection = class {
3121
3134
  catchError(() => EMPTY)
3122
3135
  ))
3123
3136
  );
3137
+ this.endpoint$.next(resolveEndpoint(hostnames, protocol));
3124
3138
  this.ws$.pipe(
3125
3139
  switchMap((ws) => ws ? interval(twentySeconds).pipe(map(() => ws)) : EMPTY),
3126
3140
  takeUntil(this.closeConnection)
@@ -3134,6 +3148,29 @@ var TrueNasConnection = class {
3134
3148
  this.lastErrorMessage$.subscribe((msg) => this.lastErrorMessage.next(msg));
3135
3149
  this.enabled$.next(initialEnabled);
3136
3150
  }
3151
+ /** The hostnames and protocol the connection currently points at. */
3152
+ get endpoint() {
3153
+ return this.endpoint$.value;
3154
+ }
3155
+ /** The appliance's scheme; changed through `setEndpoint()`. */
3156
+ get protocol() {
3157
+ return this.endpoint$.value.protocol;
3158
+ }
3159
+ /**
3160
+ * Re-points the connection, e.g. after the appliance's GUI address or
3161
+ * protocol changes. Any open socket is closed and, while enabled, the new
3162
+ * hostnames are raced at once; an unchanged endpoint does nothing. The
3163
+ * websocket path stays, so a different API version needs a new client.
3164
+ */
3165
+ setEndpoint(endpoint) {
3166
+ if (endpoint.hostnames.length === 0) {
3167
+ throw new Error("Cannot point the connection at an empty hostnames array");
3168
+ }
3169
+ const next = resolveEndpoint(endpoint.hostnames, endpoint.protocol ?? this.protocol);
3170
+ if (sameEndpoint(next, this.endpoint$.value)) return;
3171
+ this.connectionAttempts.next(0);
3172
+ this.endpoint$.next(next);
3173
+ }
3137
3174
  /**
3138
3175
  * whether the connection has exhausted its retries — the **cumulative** snapshot, read
3139
3176
  * synchronously. (Formerly `hasConnectionError()`; renamed to disambiguate it from the
@@ -3143,7 +3180,7 @@ var TrueNasConnection = class {
3143
3180
  * OR when an error message is currently set. Ported from the source (tncui) behavior.
3144
3181
  */
3145
3182
  hasExhaustedRetries() {
3146
- const attemptsExhausted = this.connectionAttempts.value > this.hostnames.length * this.maxRetry;
3183
+ const attemptsExhausted = this.connectionAttempts.value > this.endpoint.hostnames.length * this.maxRetry;
3147
3184
  const hasErrorMessage = this.lastErrorMessage.value !== null;
3148
3185
  return attemptsExhausted || hasErrorMessage;
3149
3186
  }
@@ -3183,14 +3220,15 @@ var TrueNasConnection = class {
3183
3220
  close() {
3184
3221
  this.closeConnection.next();
3185
3222
  this.closeConnection.complete();
3223
+ this.closesSubject.complete();
3186
3224
  }
3187
3225
  /**
3188
3226
  * helper method which creates an `Observable` which emits an `ActiveConnection`
3189
3227
  * after it establishes a connection to the given hostname. the observable will emit an
3190
3228
  * error if the connection is never established and will not complete until unsubscribed from or closed.
3191
3229
  */
3192
- createSocket(hostname) {
3193
- const url = `${socketScheme(this.protocol)}//${hostname}${this.websocketPath}`;
3230
+ createSocket(hostname, protocol) {
3231
+ const url = `${socketScheme(protocol)}//${hostname}${this.websocketPath}`;
3194
3232
  let hasOpened = false;
3195
3233
  return new Observable((subscriber) => {
3196
3234
  const ws = new TrueNasSocket({
@@ -3205,16 +3243,22 @@ var TrueNasConnection = class {
3205
3243
  closeObserver: {
3206
3244
  next: (event) => {
3207
3245
  const reason = event.reason || "";
3208
- let errorMessage;
3209
- if (isHttpStatusError(reason)) {
3210
- errorMessage = getHttpError(reason);
3211
- } else {
3212
- errorMessage = getWebSocketError(event.code);
3246
+ const errorMessage = getCloseMessage(event.code, reason);
3247
+ if (!subscriber.closed) {
3248
+ this.connectionAttempts.next(this.connectionAttempts.value + 1);
3249
+ this.closesSubject.next({
3250
+ code: event.code,
3251
+ reason,
3252
+ message: errorMessage,
3253
+ hostname,
3254
+ wasOpen: hasOpened,
3255
+ refused: event.code === policyViolationCloseCode
3256
+ });
3213
3257
  }
3214
- this.connectionAttempts.next(this.connectionAttempts.value + 1);
3215
- subscriber.error(
3216
- makeConnectionError(errorMessage, hostname, event.code, reason)
3217
- );
3258
+ subscriber.error({
3259
+ ...makeConnectionError(errorMessage, hostname, event.code, reason),
3260
+ wasOpen: hasOpened
3261
+ });
3218
3262
  }
3219
3263
  }
3220
3264
  });
@@ -3228,8 +3272,17 @@ var TrueNasConnection = class {
3228
3272
  };
3229
3273
  }).pipe(
3230
3274
  // retry logic:
3231
- // * if a connection is not established in 10 seconds, consider that an error
3232
- timeout({ first: tenSeconds }),
3275
+ // * if a connection is not established in 10 seconds, consider that an error.
3276
+ // Counted here: rxjs never closes a socket still connecting, so no
3277
+ // close event reliably follows, and one that does finds `subscriber`
3278
+ // closed. Nothing on `closes$` either: nothing closed.
3279
+ timeout({
3280
+ first: tenSeconds,
3281
+ with: (info) => {
3282
+ this.connectionAttempts.next(this.connectionAttempts.value + 1);
3283
+ return throwError(() => new TimeoutError(info));
3284
+ }
3285
+ }),
3233
3286
  retry({
3234
3287
  // * while still *establishing*: wait `retryDelay` before trying again, giving up
3235
3288
  // after `maxRetry` retries.
@@ -3247,12 +3300,36 @@ var TrueNasConnection = class {
3247
3300
  })
3248
3301
  );
3249
3302
  }
3303
+ /** One attempt at `endpoint`: the race, with a refusal turned into a value. */
3304
+ attempt(endpoint) {
3305
+ return this.connect(endpoint).pipe(
3306
+ map((conn) => makeActiveConnection(conn.ws, conn.hostname)),
3307
+ // A refusal becomes a value here rather than an error, so it never
3308
+ // reaches the `retry` in `connection$` and nothing reconnects on its own.
3309
+ //
3310
+ // Handled at this depth on purpose: letting it out completes the
3311
+ // pipeline and unsubscribes `enabledChange$`, and `setEnabled` (or
3312
+ // `setEndpoint`) is how an app asks for another attempt once the network
3313
+ // changes. Not retrying is a statement about what this client does on
3314
+ // its own; it is not a reason to refuse the caller asking again.
3315
+ catchError(
3316
+ (err) => isTerminalClose(err) ? of(
3317
+ makeConnectionError(
3318
+ err.message,
3319
+ err.hostname,
3320
+ err.closeCode,
3321
+ err.closeReason
3322
+ )
3323
+ ) : throwError(() => err)
3324
+ )
3325
+ );
3326
+ }
3250
3327
  /**
3251
3328
  * helper function which actually performs the parallel connection `race`.
3252
3329
  */
3253
- connect() {
3330
+ connect(endpoint) {
3254
3331
  return race(
3255
- this.hostnames.map(this.createSocket.bind(this))
3332
+ endpoint.hostnames.map((hostname) => this.createSocket(hostname, endpoint.protocol))
3256
3333
  ).pipe(
3257
3334
  tap((conn) => this.logger.debug(`TrueNas socket opened to ${conn.hostname}.`)),
3258
3335
  takeUntil(this.closeConnection)
@@ -3273,11 +3350,13 @@ var makeConnectionError = (message, hostname, closeCode, closeReason) => ({
3273
3350
  state: "error"
3274
3351
  });
3275
3352
  var isTerminalClose = (err) => err?.closeCode === policyViolationCloseCode;
3353
+ var resolveEndpoint = (hostnames, protocol) => Object.freeze({ hostnames: Object.freeze([...hostnames]), protocol });
3354
+ var sameEndpoint = (a, b) => a.protocol === b.protocol && a.hostnames.length === b.hostnames.length && a.hostnames.every((hostname, i) => hostname === b.hostnames[i]);
3276
3355
  var closedConnection = { state: "closed" };
3277
3356
 
3278
3357
  // src/client/truenas-api-client.ts
3279
3358
  var TrueNasApiClient = class {
3280
- constructor(uuid, hostnames, version, enabled, systemName, logger = noopLogger, protocol = "https:") {
3359
+ constructor(uuid, hostnames, version, enabled, systemName, logger = noopLogger, protocol = "https:", reconnect = {}) {
3281
3360
  this.uuid = uuid;
3282
3361
  this.hostnames = hostnames;
3283
3362
  this.version = version;
@@ -3285,6 +3364,7 @@ var TrueNasApiClient = class {
3285
3364
  this.systemName = systemName;
3286
3365
  this.logger = logger;
3287
3366
  this.protocol = protocol;
3367
+ this.reconnect = reconnect;
3288
3368
  this.connection = this.createConnection();
3289
3369
  this.authenticator = this.createAuthenticator();
3290
3370
  this.api = this.createApi();
@@ -3323,10 +3403,8 @@ var TrueNasApiClient = class {
3323
3403
  this.uuid,
3324
3404
  websocketPath,
3325
3405
  this.systemName,
3326
- void 0,
3327
- // retryDelay (use default)
3328
- void 0,
3329
- // maxRetry (use default)
3406
+ this.reconnect.retryDelay,
3407
+ this.reconnect.maxRetry,
3330
3408
  this.logger,
3331
3409
  this.protocol
3332
3410
  );
@@ -4182,7 +4260,8 @@ function instantiateClientForVersion(version, opts, logger) {
4182
4260
  enabled,
4183
4261
  systemName,
4184
4262
  logger,
4185
- opts.protocol
4263
+ opts.protocol,
4264
+ { retryDelay: opts.retryDelay, maxRetry: opts.maxRetry }
4186
4265
  );
4187
4266
  }
4188
4267
  function errorMessageOrDefault(error, fallback) {
@@ -4201,6 +4280,6 @@ var UserRole = /* @__PURE__ */ ((UserRole2) => {
4201
4280
  return UserRole2;
4202
4281
  })(UserRole || {});
4203
4282
 
4204
- export { AppState, AuthError, AuthErrorCode, AuthResponseType, InvalidVersionResponseError, JobState, NoCompatibleVersionsError, SUPPORTED_API_VERSIONS, TrueNasApiClient, TrueNasApiClientV2510, TrueNasApiClientV26, TrueNasApiClientV27, TrueNasAuthMechanism, TrueNasAuthenticator, TrueNasConnection, UserRole, VersionCompatibility, VersionDiscovery, VersionDiscoveryError, VersionDiscoveryNetworkError, VersionDiscoveryTimeoutError, VersionEndpointNotFoundError, VersionTooNewError, VersionTooOldError, clientClassFor, consoleLogger, createTrueNasClient, getApiErrorMessage, isJobFinished, noopLogger, parseApiVersion, v25_10_0_exports, v25_10_1_exports, v25_10_2_exports, v25_10_3_exports, v25_10_4_exports, v25_10_5_exports, v26_0_0_exports, v27_0_0_exports };
4205
- //# sourceMappingURL=chunk-SAJ5HZHK.js.map
4206
- //# sourceMappingURL=chunk-SAJ5HZHK.js.map
4283
+ export { AppState, AuthError, AuthErrorCode, AuthResponseType, InvalidVersionResponseError, JobState, NoCompatibleVersionsError, SUPPORTED_API_VERSIONS, TrueNasApiClient, TrueNasApiClientV2510, TrueNasApiClientV26, TrueNasApiClientV27, TrueNasAuthMechanism, TrueNasAuthenticator, TrueNasConnection, UserRole, VersionCompatibility, VersionDiscovery, VersionDiscoveryError, VersionDiscoveryNetworkError, VersionDiscoveryTimeoutError, VersionEndpointNotFoundError, VersionTooNewError, VersionTooOldError, clientClassFor, consoleLogger, createTrueNasClient, getApiErrorMessage, getCloseMessage, isJobFinished, noopLogger, parseApiVersion, policyViolationCloseCode, v25_10_0_exports, v25_10_1_exports, v25_10_2_exports, v25_10_3_exports, v25_10_4_exports, v25_10_5_exports, v26_0_0_exports, v27_0_0_exports };
4284
+ //# sourceMappingURL=chunk-NXXESRJ6.js.map
4285
+ //# sourceMappingURL=chunk-NXXESRJ6.js.map