@truenas/api-client 4.0.0 → 4.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.
package/dist/index.d.cts CHANGED
@@ -30429,9 +30429,10 @@ interface CreateClientOptions {
30429
30429
  *
30430
30430
  * Omitting it against a plaintext appliance is the quieter failure and the
30431
30431
  * likelier one, since it is the case this option exists for. Discovery tries
30432
- * `https`, `fetch` rejects, and that is indistinguishable from the CORS block
30433
- * v25.10.0 has on `/api/versions` — so the fallback fires and the caller gets
30434
- * a client pinned to v25.10.0, warned about only in the log.
30432
+ * `https` and `fetch` rejects, which reads exactly like the CORS block
30433
+ * v25.10.0 has on `/api/versions`. The reachability probe now runs on the same
30434
+ * scheme and fails the same way, so the mismatch is reported as unreachable
30435
+ * rather than quietly answered with a v25.10.0 client.
30435
30436
  */
30436
30437
  protocol?: ApplianceProtocol;
30437
30438
  }
@@ -30545,11 +30546,18 @@ interface CreateClientOptions {
30545
30546
  * @returns a Promise that resolves with the created client, or rejects with a
30546
30547
  * {@link VersionDiscoveryError} subclass (or a client-selection error).
30547
30548
  * Rejects if version discovery on all hostnames *fails* and is not recoverable.
30548
- * Note that when the selected failure is a `VersionDiscoveryNetworkError`,
30549
- * this function attempts to use a fallback API version (see `FALLBACK_VERSION`)
30550
- * because network errors are actually expected on 25.10.0 systems due to a CORS
30551
- * bug. A network error alongside a version-compatibility error or a 404 does
30552
- * *not* reach the fallback — see `selectRepresentativeFailure`.
30549
+ *
30550
+ * A `VersionDiscoveryNetworkError` is not by itself a verdict: `fetch` reports
30551
+ * a CORS refusal, an absent appliance, a bad name and the wrong scheme
30552
+ * identically. So that failure opens a reachability probe and a second
30553
+ * discovery attempt. A retry that succeeds is used; one that fails with a
30554
+ * specific error raises that instead; an appliance that answers nothing
30555
+ * rejects with the network error. Only one that answers the probe and still
30556
+ * will not serve `/api/versions` — which is what 25.10.0 looks like from a
30557
+ * browser — falls back to `FALLBACK_VERSION`.
30558
+ *
30559
+ * A network error alongside a version-compatibility error or a 404 does
30560
+ * *not* reach any of that — see `selectRepresentativeFailure`.
30553
30561
  */
30554
30562
  declare function createTrueNasClient<V extends SupportedApiVersion>(opts: CreateClientOptions & {
30555
30563
  version: V;
@@ -30694,6 +30702,13 @@ declare class TrueNasApiClientV27 extends TrueNasApiClient<ApiDirectory> {
30694
30702
  protected createOperations(): OperationMappings;
30695
30703
  }
30696
30704
 
30705
+ /**
30706
+ * What a reachability probe found. Three states rather than two: "we asked and
30707
+ * got silence" and "there was no way to ask" refuse the CORS fallback for
30708
+ * different reasons, and collapsing them into one `false` is the conflation
30709
+ * this whole change exists to undo.
30710
+ */
30711
+ type Reachability = 'reachable' | 'silent' | 'cannot-ask';
30697
30712
  /**
30698
30713
  * Discovers available API versions from TrueNAS systems.
30699
30714
  *
@@ -30708,8 +30723,10 @@ declare class TrueNasApiClientV27 extends TrueNasApiClient<ApiDirectory> {
30708
30723
  * service. Because `fetch` resolves (rather than rejects) on non-2xx responses and
30709
30724
  * throws a `TypeError` on network/CORS/unreachable failures, the error contract
30710
30725
  * differs from the original: a network/CORS/unreachable failure surfaces as a
30711
- * {@link VersionDiscoveryNetworkError} — the sentinel the client factory keys on for
30712
- * its CORS fallback (replacing the old `HttpErrorResponse.status === 0` check).
30726
+ * {@link VersionDiscoveryNetworkError} (replacing the old
30727
+ * `HttpErrorResponse.status === 0` check). That error names a symptom with
30728
+ * several causes, so it opens the client factory's disambiguation rather than
30729
+ * deciding it — see {@link VersionDiscovery.probeReachable}.
30713
30730
  */
30714
30731
  declare class VersionDiscovery {
30715
30732
  private readonly logger;
@@ -30717,6 +30734,19 @@ declare class VersionDiscovery {
30717
30734
  private versionCache;
30718
30735
  constructor(logger?: Logger, protocol?: ApplianceProtocol);
30719
30736
  private versionsUrl;
30737
+ /**
30738
+ * Whether the appliance answers at all, asked in a way CORS cannot block.
30739
+ *
30740
+ * A `no-cors` request yields a response the page may not read, but its
30741
+ * *arrival* is the whole answer here: something served it. That separates the
30742
+ * two failures `fetch` reports identically — a box that refused to share its
30743
+ * versions with this origin, and a box that is not there.
30744
+ *
30745
+ * `cannot-ask` where CORS is not enforced: nothing is blocking anything, so a
30746
+ * failed fetch already means unreachable and a probe would only repeat what
30747
+ * discovery just found. It is not a quieter way of saying "no".
30748
+ */
30749
+ probeReachable(hostname: string): Promise<Reachability>;
30720
30750
  /**
30721
30751
  * Discovers the API version for a given hostname.
30722
30752
  *
package/dist/index.d.ts CHANGED
@@ -30429,9 +30429,10 @@ interface CreateClientOptions {
30429
30429
  *
30430
30430
  * Omitting it against a plaintext appliance is the quieter failure and the
30431
30431
  * likelier one, since it is the case this option exists for. Discovery tries
30432
- * `https`, `fetch` rejects, and that is indistinguishable from the CORS block
30433
- * v25.10.0 has on `/api/versions` — so the fallback fires and the caller gets
30434
- * a client pinned to v25.10.0, warned about only in the log.
30432
+ * `https` and `fetch` rejects, which reads exactly like the CORS block
30433
+ * v25.10.0 has on `/api/versions`. The reachability probe now runs on the same
30434
+ * scheme and fails the same way, so the mismatch is reported as unreachable
30435
+ * rather than quietly answered with a v25.10.0 client.
30435
30436
  */
30436
30437
  protocol?: ApplianceProtocol;
30437
30438
  }
@@ -30545,11 +30546,18 @@ interface CreateClientOptions {
30545
30546
  * @returns a Promise that resolves with the created client, or rejects with a
30546
30547
  * {@link VersionDiscoveryError} subclass (or a client-selection error).
30547
30548
  * Rejects if version discovery on all hostnames *fails* and is not recoverable.
30548
- * Note that when the selected failure is a `VersionDiscoveryNetworkError`,
30549
- * this function attempts to use a fallback API version (see `FALLBACK_VERSION`)
30550
- * because network errors are actually expected on 25.10.0 systems due to a CORS
30551
- * bug. A network error alongside a version-compatibility error or a 404 does
30552
- * *not* reach the fallback — see `selectRepresentativeFailure`.
30549
+ *
30550
+ * A `VersionDiscoveryNetworkError` is not by itself a verdict: `fetch` reports
30551
+ * a CORS refusal, an absent appliance, a bad name and the wrong scheme
30552
+ * identically. So that failure opens a reachability probe and a second
30553
+ * discovery attempt. A retry that succeeds is used; one that fails with a
30554
+ * specific error raises that instead; an appliance that answers nothing
30555
+ * rejects with the network error. Only one that answers the probe and still
30556
+ * will not serve `/api/versions` — which is what 25.10.0 looks like from a
30557
+ * browser — falls back to `FALLBACK_VERSION`.
30558
+ *
30559
+ * A network error alongside a version-compatibility error or a 404 does
30560
+ * *not* reach any of that — see `selectRepresentativeFailure`.
30553
30561
  */
30554
30562
  declare function createTrueNasClient<V extends SupportedApiVersion>(opts: CreateClientOptions & {
30555
30563
  version: V;
@@ -30694,6 +30702,13 @@ declare class TrueNasApiClientV27 extends TrueNasApiClient<ApiDirectory> {
30694
30702
  protected createOperations(): OperationMappings;
30695
30703
  }
30696
30704
 
30705
+ /**
30706
+ * What a reachability probe found. Three states rather than two: "we asked and
30707
+ * got silence" and "there was no way to ask" refuse the CORS fallback for
30708
+ * different reasons, and collapsing them into one `false` is the conflation
30709
+ * this whole change exists to undo.
30710
+ */
30711
+ type Reachability = 'reachable' | 'silent' | 'cannot-ask';
30697
30712
  /**
30698
30713
  * Discovers available API versions from TrueNAS systems.
30699
30714
  *
@@ -30708,8 +30723,10 @@ declare class TrueNasApiClientV27 extends TrueNasApiClient<ApiDirectory> {
30708
30723
  * service. Because `fetch` resolves (rather than rejects) on non-2xx responses and
30709
30724
  * throws a `TypeError` on network/CORS/unreachable failures, the error contract
30710
30725
  * differs from the original: a network/CORS/unreachable failure surfaces as a
30711
- * {@link VersionDiscoveryNetworkError} — the sentinel the client factory keys on for
30712
- * its CORS fallback (replacing the old `HttpErrorResponse.status === 0` check).
30726
+ * {@link VersionDiscoveryNetworkError} (replacing the old
30727
+ * `HttpErrorResponse.status === 0` check). That error names a symptom with
30728
+ * several causes, so it opens the client factory's disambiguation rather than
30729
+ * deciding it — see {@link VersionDiscovery.probeReachable}.
30713
30730
  */
30714
30731
  declare class VersionDiscovery {
30715
30732
  private readonly logger;
@@ -30717,6 +30734,19 @@ declare class VersionDiscovery {
30717
30734
  private versionCache;
30718
30735
  constructor(logger?: Logger, protocol?: ApplianceProtocol);
30719
30736
  private versionsUrl;
30737
+ /**
30738
+ * Whether the appliance answers at all, asked in a way CORS cannot block.
30739
+ *
30740
+ * A `no-cors` request yields a response the page may not read, but its
30741
+ * *arrival* is the whole answer here: something served it. That separates the
30742
+ * two failures `fetch` reports identically — a box that refused to share its
30743
+ * versions with this origin, and a box that is not there.
30744
+ *
30745
+ * `cannot-ask` where CORS is not enforced: nothing is blocking anything, so a
30746
+ * failed fetch already means unreachable and a probe would only repeat what
30747
+ * discovery just found. It is not a quieter way of saying "no".
30748
+ */
30749
+ probeReachable(hostname: string): Promise<Reachability>;
30720
30750
  /**
30721
30751
  * Discovers the API version for a given hostname.
30722
30752
  *
package/dist/index.js CHANGED
@@ -2387,8 +2387,11 @@ var apiVersionConfig = {
2387
2387
  * - DNS lookup failure
2388
2388
  * - Server unreachable
2389
2389
  *
2390
- * Using this fallback means systems with genuine network issues will still
2391
- * attempt connection (and fail during WebSocket handshake instead of immediately).
2390
+ * Which is why it is no longer reached on all of them. A discovery failure is
2391
+ * now put to a reachability probe and a second attempt first, and only an
2392
+ * appliance that answers the probe and still will not serve `/api/versions`
2393
+ * is assumed to be this version. One that answers nothing is reported as
2394
+ * unreachable rather than pinned to a version nobody confirmed.
2392
2395
  */
2393
2396
  FALLBACK_VERSION: "v25.10.0"
2394
2397
  };
@@ -3751,6 +3754,12 @@ var InvalidVersionResponseError = class extends VersionDiscoveryError {
3751
3754
  }
3752
3755
  };
3753
3756
  var discoveryTimeoutMs = 5e3;
3757
+ var probeTimeoutMs = 5e3;
3758
+ function corsIsEnforced() {
3759
+ const hasDocument = typeof window !== "undefined" && typeof window.document !== "undefined";
3760
+ const inWorker = typeof globalThis.WorkerGlobalScope !== "undefined";
3761
+ return hasDocument || inWorker;
3762
+ }
3754
3763
  function hasErrorName(error, expected) {
3755
3764
  return typeof error === "object" && error !== null && "name" in error && error.name === expected;
3756
3765
  }
@@ -3763,6 +3772,42 @@ var VersionDiscovery = class {
3763
3772
  versionsUrl(hostname) {
3764
3773
  return `${httpScheme(this.protocol)}//${hostname}/api/versions`;
3765
3774
  }
3775
+ /**
3776
+ * Whether the appliance answers at all, asked in a way CORS cannot block.
3777
+ *
3778
+ * A `no-cors` request yields a response the page may not read, but its
3779
+ * *arrival* is the whole answer here: something served it. That separates the
3780
+ * two failures `fetch` reports identically — a box that refused to share its
3781
+ * versions with this origin, and a box that is not there.
3782
+ *
3783
+ * `cannot-ask` where CORS is not enforced: nothing is blocking anything, so a
3784
+ * failed fetch already means unreachable and a probe would only repeat what
3785
+ * discovery just found. It is not a quieter way of saying "no".
3786
+ */
3787
+ async probeReachable(hostname) {
3788
+ if (!corsIsEnforced()) return "cannot-ask";
3789
+ const url = this.versionsUrl(hostname);
3790
+ const controller = new AbortController();
3791
+ const timer2 = setTimeout(() => controller.abort(), probeTimeoutMs);
3792
+ try {
3793
+ await fetch(url, {
3794
+ mode: "no-cors",
3795
+ cache: "no-store",
3796
+ signal: controller.signal
3797
+ });
3798
+ this.logger.info("Reachability probe answered", { hostname, url });
3799
+ return "reachable";
3800
+ } catch (error) {
3801
+ this.logger.warn("Reachability probe got no answer", {
3802
+ hostname,
3803
+ url,
3804
+ error
3805
+ });
3806
+ return "silent";
3807
+ } finally {
3808
+ clearTimeout(timer2);
3809
+ }
3810
+ }
3766
3811
  /**
3767
3812
  * Discovers the API version for a given hostname.
3768
3813
  *
@@ -4022,30 +4067,81 @@ async function createTrueNasClient(opts) {
4022
4067
  });
4023
4068
  throw error;
4024
4069
  }
4025
- const fallbackVersionString = apiVersionConfig.FALLBACK_VERSION;
4026
- const fallbackVersion = parseApiVersion(fallbackVersionString);
4027
- if (!fallbackVersion) {
4028
- logger.error("Invalid fallback version configuration", {
4029
- uuid: uuid.slice(0, 8),
4030
- hostnames: hostnames.join(", "),
4031
- fallbackVersion: fallbackVersionString
4032
- });
4033
- throw error;
4070
+ const reachable = await probeAnyHostname(hostnames, versionDiscovery);
4071
+ if (reachable === "silent") {
4072
+ await sleep(unreachableRetryDelayMs);
4034
4073
  }
4035
- logger.warn(
4036
- "Version discovery failed with a network error (possible CORS or network issue), falling back to assumed version",
4037
- {
4074
+ try {
4075
+ const retryWinner = await discoverVersionFromAnyHostname(
4076
+ hostnames,
4077
+ versionDiscovery
4078
+ );
4079
+ logger.info("Version discovery succeeded on retry", {
4038
4080
  uuid: uuid.slice(0, 8),
4039
- hostnames: hostnames.join(", "),
4040
- fallbackVersion: fallbackVersionString,
4041
- originalError: errorMessage,
4042
- warning: "A network error has multiple causes (CORS, network down, DNS failure). The connection may still fail during the WebSocket handshake."
4081
+ hostname: retryWinner.hostname,
4082
+ version: retryWinner.version.version,
4083
+ firstError: errorMessage
4084
+ });
4085
+ version = retryWinner.version;
4086
+ } catch (retryError) {
4087
+ if (!(retryError instanceof VersionDiscoveryNetworkError)) {
4088
+ throw retryError;
4043
4089
  }
4044
- );
4045
- version = fallbackVersion;
4090
+ if (reachable !== "reachable") {
4091
+ logger.error(
4092
+ "Version discovery failed and the appliance did not answer a reachability probe; not assuming a version",
4093
+ {
4094
+ uuid: uuid.slice(0, 8),
4095
+ hostnames: hostnames.join(", "),
4096
+ probe: reachable === "silent" ? "no answer" : "not applicable",
4097
+ error: errorMessageOrDefault(retryError, "Unknown error")
4098
+ }
4099
+ );
4100
+ throw retryError;
4101
+ }
4102
+ const fallbackVersionString = apiVersionConfig.FALLBACK_VERSION;
4103
+ const fallbackVersion = parseApiVersion(fallbackVersionString);
4104
+ if (!fallbackVersion) {
4105
+ logger.error("Invalid fallback version configuration", {
4106
+ uuid: uuid.slice(0, 8),
4107
+ hostnames: hostnames.join(", "),
4108
+ fallbackVersion: fallbackVersionString
4109
+ });
4110
+ throw retryError;
4111
+ }
4112
+ logger.warn(
4113
+ "Appliance is reachable but version discovery is still blocked; falling back to assumed version",
4114
+ {
4115
+ uuid: uuid.slice(0, 8),
4116
+ hostnames: hostnames.join(", "),
4117
+ fallbackVersion: fallbackVersionString,
4118
+ originalError: errorMessage,
4119
+ warning: "The appliance answered a probe but not /api/versions, which is what v25.10.0 looks like from a browser. If it is newer than that, this client is now pinned to the wrong surface."
4120
+ }
4121
+ );
4122
+ version = fallbackVersion;
4123
+ }
4046
4124
  }
4047
4125
  return instantiateClientForVersion(version, opts, logger);
4048
4126
  }
4127
+ var unreachableRetryDelayMs = 2500;
4128
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4129
+ async function probeAnyHostname(hostnames, versionDiscovery) {
4130
+ const probes = hostnames.map(
4131
+ (hostname) => versionDiscovery.probeReachable(hostname)
4132
+ );
4133
+ const remaining = new Set(probes);
4134
+ let sawSilence = false;
4135
+ while (remaining.size > 0) {
4136
+ const settled = await Promise.race(
4137
+ [...remaining].map((probe) => probe.then((result) => ({ probe, result })))
4138
+ );
4139
+ if (settled.result === "reachable") return "reachable";
4140
+ if (settled.result === "silent") sawSilence = true;
4141
+ remaining.delete(settled.probe);
4142
+ }
4143
+ return sawSilence ? "silent" : "cannot-ask";
4144
+ }
4049
4145
  async function discoverVersionFromAnyHostname(hostnames, versionDiscovery) {
4050
4146
  const attempts = hostnames.map(
4051
4147
  (hostname) => firstValueFrom(versionDiscovery.discoverVersion(hostname)).then(