@calimero-network/mero-js 27.4.0 → 27.5.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/dist/index.mjs CHANGED
@@ -1248,6 +1248,85 @@ async function fetchAttestedKey(baseUrl, policy, fetchImpl = (input, init) => fe
1248
1248
  return transportKey;
1249
1249
  }
1250
1250
 
1251
+ // src/cloud-v2/directory.ts
1252
+ var DEFAULT_DIRECTORY = "https://manager.cloud.calimero.network/api/v2/directory";
1253
+ var DEFAULT_REFRESH_SECONDS = 60;
1254
+ var DirectoryError = class extends Error {
1255
+ constructor(cluster, reason, message, status, cause) {
1256
+ super(`directory for cluster ${cluster}: ${message}`);
1257
+ this.cluster = cluster;
1258
+ this.reason = reason;
1259
+ this.status = status;
1260
+ this.cause = cause;
1261
+ this.name = "DirectoryError";
1262
+ }
1263
+ };
1264
+ function directoryUrl(directory, cluster) {
1265
+ return `${directory.replace(/\/+$/, "")}/${encodeURIComponent(cluster)}`;
1266
+ }
1267
+ async function readDirectory(directory, cluster, fetcher) {
1268
+ let response;
1269
+ try {
1270
+ response = await fetcher(directoryUrl(directory, cluster), { method: "GET", headers: { accept: "application/json" } });
1271
+ } catch (error) {
1272
+ throw new DirectoryError(cluster, "unreachable", `could not be reached: ${String(error)}`, void 0, error);
1273
+ }
1274
+ if (response.status === 404) throw new DirectoryError(cluster, "not-found", "unknown cluster", 404);
1275
+ if (!response.ok) throw new DirectoryError(cluster, "unreachable", `answered ${response.status}`, response.status);
1276
+ let body;
1277
+ try {
1278
+ body = await response.json();
1279
+ } catch (error) {
1280
+ throw new DirectoryError(cluster, "bad-response", "answered something other than JSON", response.status, error);
1281
+ }
1282
+ const listing = body;
1283
+ if (!listing || typeof listing !== "object" || !Array.isArray(listing.nodes)) {
1284
+ throw new DirectoryError(cluster, "bad-response", "answered no node list", response.status);
1285
+ }
1286
+ const nodes = [];
1287
+ const seen = /* @__PURE__ */ new Set();
1288
+ for (const entry of listing.nodes) {
1289
+ const node = usable(entry);
1290
+ if (node && !seen.has(node.url)) {
1291
+ seen.add(node.url);
1292
+ nodes.push(node);
1293
+ }
1294
+ }
1295
+ if (nodes.length === 0) throw new DirectoryError(cluster, "empty", "lists no usable node", response.status);
1296
+ const after = listing.refreshAfterSeconds;
1297
+ const refreshAfterSeconds = typeof after === "number" && Number.isFinite(after) && after >= 1 ? after : DEFAULT_REFRESH_SECONDS;
1298
+ return { nodes, refreshAfterSeconds };
1299
+ }
1300
+ function usable(entry) {
1301
+ if (!entry || typeof entry !== "object") return void 0;
1302
+ const { url: url2, zone, weight } = entry;
1303
+ if (typeof url2 !== "string" || typeof weight !== "number" || !Number.isInteger(weight) || weight < 1) return void 0;
1304
+ let parsed;
1305
+ try {
1306
+ parsed = new URL(url2);
1307
+ } catch {
1308
+ return void 0;
1309
+ }
1310
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return void 0;
1311
+ return { url: url2.replace(/\/+$/, ""), zone: typeof zone === "string" ? zone : void 0, weight };
1312
+ }
1313
+ function orderNodes(nodes, zone, random) {
1314
+ const near = zone === void 0 ? [] : nodes.filter((node) => node.zone === zone);
1315
+ const far = zone === void 0 ? nodes : nodes.filter((node) => node.zone !== zone);
1316
+ return [...weightedOrder(near, random), ...weightedOrder(far, random)];
1317
+ }
1318
+ function weightedOrder(nodes, random) {
1319
+ const left = [...nodes];
1320
+ const order = [];
1321
+ while (left.length > 0) {
1322
+ let roll = random() * left.reduce((sum, node) => sum + node.weight, 0);
1323
+ let pick = 0;
1324
+ while (pick < left.length - 1 && roll >= left[pick].weight) roll -= left[pick++].weight;
1325
+ order.push(left.splice(pick, 1)[0].url);
1326
+ }
1327
+ return order;
1328
+ }
1329
+
1251
1330
  // src/cloud-v2/errors.ts
1252
1331
  var CalimeroError = class extends Error {
1253
1332
  constructor(status, code, message, details = {}) {
@@ -1634,7 +1713,7 @@ function retryDelayMs(retry, options) {
1634
1713
  function ceremony(path) {
1635
1714
  return path === "auth/refresh" || path.startsWith("auth/passkey/");
1636
1715
  }
1637
- var CalimeroClient = class {
1716
+ var CalimeroClient = class _CalimeroClient {
1638
1717
  constructor(options) {
1639
1718
  /** The one in use: an index into {@link nodeUrls}. */
1640
1719
  this.at = 0;
@@ -1657,10 +1736,40 @@ var CalimeroClient = class {
1657
1736
  };
1658
1737
  this.raw = raw;
1659
1738
  }
1660
- /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
1739
+ /**
1740
+ * A client for the Calimero Cloud cluster named `cluster`: its node list read
1741
+ * from the public node directory, those in `zone` first, each group in
1742
+ * weighted-random order. The directory only picks which nodes to try: each is
1743
+ * attested under `policy` before anything is sent to it. Throws
1744
+ * {@link DirectoryError} when the directory names no usable node.
1745
+ *
1746
+ * The list is read again in the background after a failover, and at the
1747
+ * first request once the directory's `refreshAfterSeconds` has passed.
1748
+ */
1749
+ static async connect(options) {
1750
+ const directory = options.directory ?? DEFAULT_DIRECTORY;
1751
+ const random = options.random ?? Math.random;
1752
+ const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
1753
+ const listing = await readDirectory(directory, options.cluster, fetcher);
1754
+ const [baseUrl, ...nodes] = orderNodes(listing.nodes, options.zone, random);
1755
+ const client = new _CalimeroClient({ baseUrl, nodes, policy: options.policy, fetch: options.fetch, retry: options.retry });
1756
+ client.source = {
1757
+ directory,
1758
+ cluster: options.cluster,
1759
+ zone: options.zone,
1760
+ random,
1761
+ due: Date.now() + listing.refreshAfterSeconds * 1e3
1762
+ };
1763
+ return client;
1764
+ }
1765
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` or a failover moved the client on. */
1661
1766
  get nodeUrl() {
1662
1767
  return this.nodeUrls[this.at];
1663
1768
  }
1769
+ /** Every node this client may use, in the order it moves through them. */
1770
+ get nodes() {
1771
+ return [...this.nodeUrls];
1772
+ }
1664
1773
  /** The sealed channel to `url`, attested under the client's policy before its first request. */
1665
1774
  channel(url2) {
1666
1775
  let sealed = this.channels.get(url2);
@@ -1681,9 +1790,46 @@ var CalimeroClient = class {
1681
1790
  */
1682
1791
  moveOn(from) {
1683
1792
  if (this.nodeUrls.length < 2) return false;
1684
- if (this.at === from) this.at = (from + 1) % this.nodeUrls.length;
1793
+ if (this.nodeUrl === from) this.at = (this.at + 1) % this.nodeUrls.length;
1685
1794
  return true;
1686
1795
  }
1796
+ /**
1797
+ * Fail over from node `from`, where a request's retries ran out, to the next
1798
+ * node it has not `tried`. False when it has tried them all. A request whose
1799
+ * node another request moved away from already goes to that node, if untried.
1800
+ */
1801
+ failOver(from, tried) {
1802
+ if (this.nodeUrl !== from && !tried.has(this.nodeUrl)) return true;
1803
+ const count = this.nodeUrls.length;
1804
+ for (let step = 1; step < count; step++) {
1805
+ const next = (this.at + step) % count;
1806
+ if (!tried.has(this.nodeUrls[next])) {
1807
+ this.at = next;
1808
+ this.reread();
1809
+ return true;
1810
+ }
1811
+ }
1812
+ return false;
1813
+ }
1814
+ /**
1815
+ * Read the node list again from the directory, in the background, for a
1816
+ * client made by {@link connect}: the node in use stays first, then the
1817
+ * directory's order; a node no longer listed is dropped. A failed read is
1818
+ * ignored. One read at a time.
1819
+ */
1820
+ reread() {
1821
+ const source = this.source;
1822
+ if (!source || source.reading) return;
1823
+ source.reading = readDirectory(source.directory, source.cluster, this.raw).then((listing) => {
1824
+ const current = this.nodeUrl;
1825
+ const listed = orderNodes(listing.nodes, source.zone, source.random);
1826
+ this.nodeUrls = [current, ...listed.filter((url2) => url2 !== current)];
1827
+ this.at = 0;
1828
+ source.due = Date.now() + listing.refreshAfterSeconds * 1e3;
1829
+ }).catch(() => void 0).finally(() => {
1830
+ source.reading = void 0;
1831
+ });
1832
+ }
1687
1833
  /** Authenticate every request with an API key. */
1688
1834
  useApiKey(key) {
1689
1835
  this.auth = key;
@@ -1711,6 +1857,7 @@ var CalimeroClient = class {
1711
1857
  headers["content-type"] = "application/json";
1712
1858
  payload = JSON.stringify(body);
1713
1859
  }
1860
+ if (this.source && Date.now() >= this.source.due) this.reread();
1714
1861
  const url2 = this.nodeUrl;
1715
1862
  try {
1716
1863
  return await this.channel(url2)(`${url2}/${path}`, { method, headers, body: payload, signal });
@@ -1742,7 +1889,7 @@ var CalimeroClient = class {
1742
1889
  let refreshed = false;
1743
1890
  let moved = false;
1744
1891
  for (; ; ) {
1745
- const [token, at] = [this.auth, this.at];
1892
+ const [token, at] = [this.auth, this.nodeUrl];
1746
1893
  const response = await this.request(method, path, body, raw, contentType, signal);
1747
1894
  if (response.ok) return response;
1748
1895
  const error = await refusal(response);
@@ -1778,25 +1925,40 @@ var CalimeroClient = class {
1778
1925
  * {@link json} for a call or a view (D70): while the node answers 503 or
1779
1926
  * cannot be reached, the same request, with the same idempotency key, is sent
1780
1927
  * again after {@link retryDelayMs} (or the pause the node asked for, if
1781
- * longer), at most `retries` times. Every other answer is final. The last
1928
+ * longer), at most `retries` times. Every other answer is final.
1929
+ *
1930
+ * When a node's retries run out, the request fails over to the next node the
1931
+ * client knows and its retries start again there, with the same key: the
1932
+ * key is kept in the app's replicated log, so the cluster answers it once
1933
+ * whichever node it reaches. Each node is tried once per request. The last
1782
1934
  * failure surfaces as {@link UnavailableError}: the node's 503, or reason
1783
1935
  * `network` for a connection that failed, whose `committed` is `'unknown'`.
1784
- * `retries` 0 sends the request exactly once.
1936
+ *
1937
+ * `once` sends the request exactly once, to the node in use (the confirmed
1938
+ * attempt of {@link callConfirmed}, whose challenge is single-use).
1785
1939
  */
1786
- async retried(method, path, body, retries = this.retry.retries) {
1787
- for (let attempt = 0; ; attempt++) {
1940
+ async retried(method, path, body, once = false) {
1941
+ const retries = once ? 0 : this.retry.retries;
1942
+ const tried = /* @__PURE__ */ new Set();
1943
+ for (; ; ) {
1788
1944
  let unavailable;
1789
- try {
1790
- return await this.json(method, path, body);
1791
- } catch (error) {
1792
- if (error instanceof UnavailableError) unavailable = error;
1793
- else if (error instanceof TypeError && this.networkFailures.has(error)) {
1794
- unavailable = new UnavailableError("NETWORK", `the node could not be reached: ${error.message}`, {}, void 0, error);
1795
- } else throw error;
1945
+ let node = this.nodeUrl;
1946
+ for (let attempt = 0; ; attempt++) {
1947
+ node = this.nodeUrl;
1948
+ tried.add(node);
1949
+ try {
1950
+ return await this.json(method, path, body);
1951
+ } catch (error) {
1952
+ if (error instanceof UnavailableError) unavailable = error;
1953
+ else if (error instanceof TypeError && this.networkFailures.has(error)) {
1954
+ unavailable = new UnavailableError("NETWORK", `the node could not be reached: ${error.message}`, {}, void 0, error);
1955
+ } else throw error;
1956
+ }
1957
+ if (attempt >= retries) break;
1958
+ const backoff = retryDelayMs(attempt, this.retry);
1959
+ await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1e3)));
1796
1960
  }
1797
- if (attempt >= retries) throw unavailable;
1798
- const backoff = retryDelayMs(attempt, this.retry);
1799
- await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1e3)));
1961
+ if (once || !this.failOver(node, tried)) throw unavailable;
1800
1962
  }
1801
1963
  }
1802
1964
  post(path, body = {}) {
@@ -2091,7 +2253,7 @@ var CalimeroClient = class {
2091
2253
  path,
2092
2254
  { method, args, idempotencyKey: key },
2093
2255
  passkey,
2094
- (body) => this.retried("POST", path, body, body.confirm === void 0 ? this.retry.retries : 0)
2256
+ (body) => this.retried("POST", path, body, body.confirm !== void 0)
2095
2257
  );
2096
2258
  }
2097
2259
  /** Run a read-only method: strong by default, or stale from any replica. */
@@ -2899,8 +3061,10 @@ export {
2899
3061
  ConfirmRequired,
2900
3062
  ConsentStale,
2901
3063
  DEFAULT_COLLATERAL_MIRROR,
3064
+ DEFAULT_DIRECTORY,
2902
3065
  DEFAULT_ORG_CAPABILITIES,
2903
3066
  DEFAULT_RELEASE_MIRROR,
3067
+ DirectoryError,
2904
3068
  GITHUB_RELEASES,
2905
3069
  HANDSHAKE_PATH,
2906
3070
  IdempotencyMismatch,