@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/README.md CHANGED
@@ -31,8 +31,9 @@ quotes and is loaded on first use.
31
31
  ```ts
32
32
  import { CalimeroClient, browserPasskey } from '@calimero-network/mero-js';
33
33
 
34
- const client = new CalimeroClient({
35
- baseUrl: 'https://eu1.cloud.calimero.network',
34
+ // Reach a Calimero Cloud cluster by name.
35
+ const client = await CalimeroClient.connect({
36
+ cluster: 'eu-shared-1',
36
37
  // Trust the signed mero-tee release the node names, from this one up.
37
38
  policy: { release: { minReleaseVersion: '2.3.109' } },
38
39
  });
@@ -52,6 +53,34 @@ A development cluster (`merod local`, `merod run --dev`) sends mock quotes,
52
53
  which pass only with `policy: { allowMock: true }`. Nothing is read from the
53
54
  environment.
54
55
 
56
+ ## Connecting to a cluster
57
+
58
+ `CalimeroClient.connect({ cluster, policy })` is the recommended way to reach a
59
+ Calimero Cloud cluster. It reads the cluster's nodes from the public node
60
+ directory (`DEFAULT_DIRECTORY`, `https://manager.cloud.calimero.network/api/v2/directory`;
61
+ `directory` names another), puts those in `zone` first when you give one, and
62
+ orders each group at random, weighted by the directory's weight. A cluster the
63
+ directory does not know, or one with no usable node, throws `DirectoryError`.
64
+
65
+ The directory only picks which nodes to try, and in what order. It is not
66
+ trusted: every node is attested under **your** `policy` before anything is
67
+ sent to it, so a wrong or tampered list can only make connecting fail.
68
+
69
+ Any node of a cluster serves any request. While the node in use answers `503`
70
+ or cannot be reached, a call or a view is retried there (`retry`); when those
71
+ retries run out it fails over to the next node and starts them again there,
72
+ with the **same** idempotency key, which the app's replicated log answers once
73
+ cluster-wide. Each node is tried once per request; when all have failed, the
74
+ last `UnavailableError` reaches you. After a failover, and once the directory's
75
+ `refreshAfterSeconds` has passed (checked at the next request), the client
76
+ reads the directory again in the background: the node in use stays first,
77
+ nodes no longer listed are dropped. `client.nodeUrl` is the node in use,
78
+ `client.nodes` the list.
79
+
80
+ For a fixed list of nodes or a cluster you run yourself, construct the client
81
+ directly: `new CalimeroClient({ baseUrl, nodes, policy })`. It fails over
82
+ between `baseUrl` and `nodes` the same way, and never reads a directory.
83
+
55
84
  ## What the client checks
56
85
 
57
86
  Before it sends anything to a node, as core's Rust client does (`Measurements::Release`):
@@ -71,7 +100,7 @@ Pinned measurements go through `policy.verifyQuote` (`createQuoteVerifier`).
71
100
  - **Credentials**: `signUp`, `signIn`, `refresh`, `signOut`, `signOutEverywhere`, `useSession`, `useApiKey`, `me`, `usage`, `addPasskey`, `labelPasskey`, `removePasskey`, `createApiKey`, `listApiKeys`, `revokeApiKey`.
72
101
  - **Packages**: `installPackage`, `uploadPackage`, `packages`, `package`, `uninstallPackage`; `uploadBundle` (raw WASM, development clusters only).
73
102
  - **Apps**: `createApp`, `listApps`, `getApp`, `deleteApp`, `setAccess`, `setAppMetadata`, `appStorage`, `appAbi`, `upgradeApp`, `transferApp`.
74
- - **Calls**: `call`, `callConfirmed`, `view`, `events`. A call always carries an idempotency key, and is sent again with it while the node answers `503`.
103
+ - **Calls**: `call`, `callConfirmed`, `view`, `events`. A call always carries an idempotency key, and is sent again with it while the node answers `503`, then to the next node.
75
104
  - **Blobs and presence**: `uploadBlob`, `downloadBlob`, `listBlobs`, `blobInfo`, `deleteBlob`; `publishPresence`, `presence`, `presenceStream`.
76
105
  - **Organizations**: `createOrg`, `getOrg`, `deleteOrg`, `setOrgMetadata`, `setMember`, `removeMember`, `setMemberMetadata`, `setCapabilities` (`ORG_CAPABILITIES`), teams, `invite`, `redeemInvite`.
77
106
  - **Owner decisions** (passkey approval, never from an app's frontend): `upgradeOrg`, `upgradeRun`, `retryUpgradeRun`, `approveRelease`, `approveOutbound`, `bindPublisher`, `upgradePublished`.
@@ -160,9 +160,10 @@ export interface CalimeroClientOptions {
160
160
  baseUrl: string;
161
161
  /**
162
162
  * Other nodes of the same cluster, in the order to move to them when a node
163
- * answers `421 WRONG_NODE` (its release may not serve this account). Each is
164
- * attested under the same `policy` before anything is sent to it. Without
165
- * them a `421` reaches the caller as {@link WrongNode}.
163
+ * answers `421 WRONG_NODE` (its release may not serve this account), or stays
164
+ * unavailable to a call or a view through all its retries. Each is attested
165
+ * under the same `policy` before anything is sent to it. Without them a `421`
166
+ * reaches the caller as {@link WrongNode}.
166
167
  */
167
168
  nodes?: string[];
168
169
  policy: CloudPolicy;
@@ -171,9 +172,26 @@ export interface CalimeroClientOptions {
171
172
  /** How calls and views retry a 503 or a failed connection. */
172
173
  retry?: CloudRetryOptions;
173
174
  }
175
+ /** How {@link CalimeroClient.connect} reaches a Calimero Cloud cluster by name. */
176
+ export interface CalimeroConnectOptions {
177
+ /** The cluster's name, e.g. `eu-shared-1`. */
178
+ cluster: string;
179
+ /** What every node must attest to before anything is sent to it. */
180
+ policy: CloudPolicy;
181
+ /** The node directory to ask. Default {@link DEFAULT_DIRECTORY}. */
182
+ directory?: string;
183
+ /** Try the nodes in this zone (e.g. `europe-west4-a`) first. */
184
+ zone?: string;
185
+ /** Fetch for the directory, attestation and envelopes. Defaults to global `fetch`. */
186
+ fetch?: typeof fetch;
187
+ /** How calls and views retry a 503 or a failed connection. */
188
+ retry?: CloudRetryOptions;
189
+ /** Randomness for the weighted node order, in [0, 1). Defaults to `Math.random`. */
190
+ random?: () => number;
191
+ }
174
192
  export declare class CalimeroClient {
175
- /** Every node this client may use, `baseUrl` first. */
176
- private readonly nodeUrls;
193
+ /** Every node this client may use, `baseUrl` first (or as the directory last ordered them). */
194
+ private nodeUrls;
177
195
  /** The one in use: an index into {@link nodeUrls}. */
178
196
  private at;
179
197
  /** A sealed fetch per node, each attesting its own node on first use. */
@@ -188,9 +206,24 @@ export declare class CalimeroClient {
188
206
  private readonly retry;
189
207
  /** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
190
208
  private readonly networkFailures;
209
+ /** The directory the node list came from, for a client made by {@link connect}. */
210
+ private source;
211
+ /**
212
+ * A client for the Calimero Cloud cluster named `cluster`: its node list read
213
+ * from the public node directory, those in `zone` first, each group in
214
+ * weighted-random order. The directory only picks which nodes to try: each is
215
+ * attested under `policy` before anything is sent to it. Throws
216
+ * {@link DirectoryError} when the directory names no usable node.
217
+ *
218
+ * The list is read again in the background after a failover, and at the
219
+ * first request once the directory's `refreshAfterSeconds` has passed.
220
+ */
221
+ static connect(options: CalimeroConnectOptions): Promise<CalimeroClient>;
191
222
  constructor(options: CalimeroClientOptions);
192
- /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
223
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` or a failover moved the client on. */
193
224
  get nodeUrl(): string;
225
+ /** Every node this client may use, in the order it moves through them. */
226
+ get nodes(): string[];
194
227
  /** The sealed channel to `url`, attested under the client's policy before its first request. */
195
228
  private channel;
196
229
  /**
@@ -199,6 +232,19 @@ export declare class CalimeroClient {
199
232
  * refuse after another request moved on already does not move again.
200
233
  */
201
234
  private moveOn;
235
+ /**
236
+ * Fail over from node `from`, where a request's retries ran out, to the next
237
+ * node it has not `tried`. False when it has tried them all. A request whose
238
+ * node another request moved away from already goes to that node, if untried.
239
+ */
240
+ private failOver;
241
+ /**
242
+ * Read the node list again from the directory, in the background, for a
243
+ * client made by {@link connect}: the node in use stays first, then the
244
+ * directory's order; a node no longer listed is dropped. A failed read is
245
+ * ignored. One read at a time.
246
+ */
247
+ private reread;
202
248
  /** Authenticate every request with an API key. */
203
249
  useApiKey(key: string): void;
204
250
  useSession(session: CalimeroSession): void;
@@ -241,10 +287,17 @@ export declare class CalimeroClient {
241
287
  * {@link json} for a call or a view (D70): while the node answers 503 or
242
288
  * cannot be reached, the same request, with the same idempotency key, is sent
243
289
  * again after {@link retryDelayMs} (or the pause the node asked for, if
244
- * longer), at most `retries` times. Every other answer is final. The last
290
+ * longer), at most `retries` times. Every other answer is final.
291
+ *
292
+ * When a node's retries run out, the request fails over to the next node the
293
+ * client knows and its retries start again there, with the same key: the
294
+ * key is kept in the app's replicated log, so the cluster answers it once
295
+ * whichever node it reaches. Each node is tried once per request. The last
245
296
  * failure surfaces as {@link UnavailableError}: the node's 503, or reason
246
297
  * `network` for a connection that failed, whose `committed` is `'unknown'`.
247
- * `retries` 0 sends the request exactly once.
298
+ *
299
+ * `once` sends the request exactly once, to the node in use (the confirmed
300
+ * attempt of {@link callConfirmed}, whose challenge is single-use).
248
301
  */
249
302
  private retried;
250
303
  post<T = Record<string, unknown>>(path: string, body?: unknown): Promise<T>;
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { SealedTransportError, createSealedFetch } from '../sealed/sealed.js';
13
13
  import { fetchAttestedKey } from './attestation.js';
14
+ import { DEFAULT_DIRECTORY, orderNodes, readDirectory } from './directory.js';
14
15
  import { CalimeroError, ConfirmCancelled, ConfirmRequired, LoginRequired, UnavailableError, WrongNode, errorFrom, } from './errors.js';
15
16
  import { b64url, fromB64url } from './passkey.js';
16
17
  import { confirmationCode, pageQuery, pendingApprovalsFrom, } from './approvals.js';
@@ -114,6 +115,32 @@ function ceremony(path) {
114
115
  return path === 'auth/refresh' || path.startsWith('auth/passkey/');
115
116
  }
116
117
  export class CalimeroClient {
118
+ /**
119
+ * A client for the Calimero Cloud cluster named `cluster`: its node list read
120
+ * from the public node directory, those in `zone` first, each group in
121
+ * weighted-random order. The directory only picks which nodes to try: each is
122
+ * attested under `policy` before anything is sent to it. Throws
123
+ * {@link DirectoryError} when the directory names no usable node.
124
+ *
125
+ * The list is read again in the background after a failover, and at the
126
+ * first request once the directory's `refreshAfterSeconds` has passed.
127
+ */
128
+ static async connect(options) {
129
+ const directory = options.directory ?? DEFAULT_DIRECTORY;
130
+ const random = options.random ?? Math.random;
131
+ const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
132
+ const listing = await readDirectory(directory, options.cluster, fetcher);
133
+ const [baseUrl, ...nodes] = orderNodes(listing.nodes, options.zone, random);
134
+ const client = new CalimeroClient({ baseUrl, nodes, policy: options.policy, fetch: options.fetch, retry: options.retry });
135
+ client.source = {
136
+ directory,
137
+ cluster: options.cluster,
138
+ zone: options.zone,
139
+ random,
140
+ due: Date.now() + listing.refreshAfterSeconds * 1000,
141
+ };
142
+ return client;
143
+ }
117
144
  constructor(options) {
118
145
  /** The one in use: an index into {@link nodeUrls}. */
119
146
  this.at = 0;
@@ -142,10 +169,14 @@ export class CalimeroClient {
142
169
  };
143
170
  this.raw = raw;
144
171
  }
145
- /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
172
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` or a failover moved the client on. */
146
173
  get nodeUrl() {
147
174
  return this.nodeUrls[this.at];
148
175
  }
176
+ /** Every node this client may use, in the order it moves through them. */
177
+ get nodes() {
178
+ return [...this.nodeUrls];
179
+ }
149
180
  /** The sealed channel to `url`, attested under the client's policy before its first request. */
150
181
  channel(url) {
151
182
  let sealed = this.channels.get(url);
@@ -167,10 +198,52 @@ export class CalimeroClient {
167
198
  moveOn(from) {
168
199
  if (this.nodeUrls.length < 2)
169
200
  return false;
170
- if (this.at === from)
171
- this.at = (from + 1) % this.nodeUrls.length;
201
+ if (this.nodeUrl === from)
202
+ this.at = (this.at + 1) % this.nodeUrls.length;
172
203
  return true;
173
204
  }
205
+ /**
206
+ * Fail over from node `from`, where a request's retries ran out, to the next
207
+ * node it has not `tried`. False when it has tried them all. A request whose
208
+ * node another request moved away from already goes to that node, if untried.
209
+ */
210
+ failOver(from, tried) {
211
+ if (this.nodeUrl !== from && !tried.has(this.nodeUrl))
212
+ return true;
213
+ const count = this.nodeUrls.length;
214
+ for (let step = 1; step < count; step++) {
215
+ const next = (this.at + step) % count;
216
+ if (!tried.has(this.nodeUrls[next])) {
217
+ this.at = next;
218
+ this.reread();
219
+ return true;
220
+ }
221
+ }
222
+ return false;
223
+ }
224
+ /**
225
+ * Read the node list again from the directory, in the background, for a
226
+ * client made by {@link connect}: the node in use stays first, then the
227
+ * directory's order; a node no longer listed is dropped. A failed read is
228
+ * ignored. One read at a time.
229
+ */
230
+ reread() {
231
+ const source = this.source;
232
+ if (!source || source.reading)
233
+ return;
234
+ source.reading = readDirectory(source.directory, source.cluster, this.raw)
235
+ .then((listing) => {
236
+ const current = this.nodeUrl;
237
+ const listed = orderNodes(listing.nodes, source.zone, source.random);
238
+ this.nodeUrls = [current, ...listed.filter((url) => url !== current)];
239
+ this.at = 0;
240
+ source.due = Date.now() + listing.refreshAfterSeconds * 1000;
241
+ })
242
+ .catch(() => undefined)
243
+ .finally(() => {
244
+ source.reading = undefined;
245
+ });
246
+ }
174
247
  /** Authenticate every request with an API key. */
175
248
  useApiKey(key) {
176
249
  this.auth = key;
@@ -200,6 +273,8 @@ export class CalimeroClient {
200
273
  headers['content-type'] = 'application/json';
201
274
  payload = JSON.stringify(body);
202
275
  }
276
+ if (this.source && Date.now() >= this.source.due)
277
+ this.reread();
203
278
  const url = this.nodeUrl;
204
279
  try {
205
280
  return await this.channel(url)(`${url}/${path}`, { method, headers, body: payload, signal });
@@ -232,7 +307,7 @@ export class CalimeroClient {
232
307
  let refreshed = false;
233
308
  let moved = false;
234
309
  for (;;) {
235
- const [token, at] = [this.auth, this.at];
310
+ const [token, at] = [this.auth, this.nodeUrl];
236
311
  const response = await this.request(method, path, body, raw, contentType, signal);
237
312
  if (response.ok)
238
313
  return response;
@@ -272,30 +347,46 @@ export class CalimeroClient {
272
347
  * {@link json} for a call or a view (D70): while the node answers 503 or
273
348
  * cannot be reached, the same request, with the same idempotency key, is sent
274
349
  * again after {@link retryDelayMs} (or the pause the node asked for, if
275
- * longer), at most `retries` times. Every other answer is final. The last
350
+ * longer), at most `retries` times. Every other answer is final.
351
+ *
352
+ * When a node's retries run out, the request fails over to the next node the
353
+ * client knows and its retries start again there, with the same key: the
354
+ * key is kept in the app's replicated log, so the cluster answers it once
355
+ * whichever node it reaches. Each node is tried once per request. The last
276
356
  * failure surfaces as {@link UnavailableError}: the node's 503, or reason
277
357
  * `network` for a connection that failed, whose `committed` is `'unknown'`.
278
- * `retries` 0 sends the request exactly once.
358
+ *
359
+ * `once` sends the request exactly once, to the node in use (the confirmed
360
+ * attempt of {@link callConfirmed}, whose challenge is single-use).
279
361
  */
280
- async retried(method, path, body, retries = this.retry.retries) {
281
- for (let attempt = 0;; attempt++) {
362
+ async retried(method, path, body, once = false) {
363
+ const retries = once ? 0 : this.retry.retries;
364
+ const tried = new Set();
365
+ for (;;) {
282
366
  let unavailable;
283
- try {
284
- return await this.json(method, path, body);
285
- }
286
- catch (error) {
287
- if (error instanceof UnavailableError)
288
- unavailable = error;
289
- else if (error instanceof TypeError && this.networkFailures.has(error)) {
290
- unavailable = new UnavailableError('NETWORK', `the node could not be reached: ${error.message}`, {}, undefined, error);
367
+ let node = this.nodeUrl;
368
+ for (let attempt = 0;; attempt++) {
369
+ node = this.nodeUrl;
370
+ tried.add(node);
371
+ try {
372
+ return await this.json(method, path, body);
291
373
  }
292
- else
293
- throw error;
374
+ catch (error) {
375
+ if (error instanceof UnavailableError)
376
+ unavailable = error;
377
+ else if (error instanceof TypeError && this.networkFailures.has(error)) {
378
+ unavailable = new UnavailableError('NETWORK', `the node could not be reached: ${error.message}`, {}, undefined, error);
379
+ }
380
+ else
381
+ throw error;
382
+ }
383
+ if (attempt >= retries)
384
+ break;
385
+ const backoff = retryDelayMs(attempt, this.retry);
386
+ await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1000)));
294
387
  }
295
- if (attempt >= retries)
388
+ if (once || !this.failOver(node, tried))
296
389
  throw unavailable;
297
- const backoff = retryDelayMs(attempt, this.retry);
298
- await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1000)));
299
390
  }
300
391
  }
301
392
  post(path, body = {}) {
@@ -600,7 +691,7 @@ export class CalimeroClient {
600
691
  const key = idempotencyKey ?? newIdempotencyKey();
601
692
  // The confirmed attempt goes out exactly once: its challenge is single-use,
602
693
  // so a resend would be refused on the challenge, not answered from the key.
603
- return this.confirmed('POST', path, { method, args, idempotencyKey: key }, passkey, (body) => this.retried('POST', path, body, body.confirm === undefined ? this.retry.retries : 0));
694
+ return this.confirmed('POST', path, { method, args, idempotencyKey: key }, passkey, (body) => this.retried('POST', path, body, body.confirm !== undefined));
604
695
  }
605
696
  /** Run a read-only method: strong by default, or stale from any replica. */
606
697
  view(app, method, args = {}, options = {}) {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The Calimero Cloud node directory: which nodes of a cluster take traffic now,
3
+ * and in what order this client tries them.
4
+ *
5
+ * `GET {directory}/{cluster}` (no auth) answers
6
+ * `{ cluster, nodes: [{ id, url, zone, weight }], refreshAfterSeconds }`. The
7
+ * directory is not trusted for security: it only picks which nodes to try.
8
+ * Every node is attested under the caller's own policy before anything is sent
9
+ * to it, so a bad list can only make connecting fail.
10
+ */
11
+ /** The Calimero Cloud control plane's public node directory. */
12
+ export declare const DEFAULT_DIRECTORY = "https://manager.cloud.calimero.network/api/v2/directory";
13
+ /** Why {@link DirectoryError} was thrown. */
14
+ export type DirectoryFailure = 'unreachable' | 'not-found' | 'bad-response' | 'empty';
15
+ /**
16
+ * The directory could not name a node to try: it could not be reached or
17
+ * answered an error (`unreachable`), does not know the cluster (`not-found`,
18
+ * a 404), answered something that is not a listing (`bad-response`), or listed
19
+ * no usable node (`empty`).
20
+ */
21
+ export declare class DirectoryError extends Error {
22
+ readonly cluster: string;
23
+ readonly reason: DirectoryFailure;
24
+ readonly status?: number;
25
+ readonly cause?: unknown;
26
+ constructor(cluster: string, reason: DirectoryFailure, message: string, status?: number, cause?: unknown);
27
+ }
28
+ /** One usable node of a listing. */
29
+ export interface DirectoryNode {
30
+ url: string;
31
+ zone?: string;
32
+ weight: number;
33
+ }
34
+ export interface DirectoryListing {
35
+ nodes: DirectoryNode[];
36
+ refreshAfterSeconds: number;
37
+ }
38
+ /** The URL of `cluster`'s listing in `directory`. */
39
+ export declare function directoryUrl(directory: string, cluster: string): string;
40
+ /**
41
+ * Read `cluster`'s listing, keeping only the entries with an http(s) URL and a
42
+ * positive integer weight (the first of a repeated URL).
43
+ */
44
+ export declare function readDirectory(directory: string, cluster: string, fetcher: typeof fetch): Promise<DirectoryListing>;
45
+ /**
46
+ * The order to try `nodes` in: those in `zone` first (when given), each group
47
+ * in weighted-random order, a node's weight its relative chance to come next.
48
+ * `random` answers in [0, 1), as `Math.random` does.
49
+ */
50
+ export declare function orderNodes(nodes: DirectoryNode[], zone: string | undefined, random: () => number): string[];
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The Calimero Cloud node directory: which nodes of a cluster take traffic now,
3
+ * and in what order this client tries them.
4
+ *
5
+ * `GET {directory}/{cluster}` (no auth) answers
6
+ * `{ cluster, nodes: [{ id, url, zone, weight }], refreshAfterSeconds }`. The
7
+ * directory is not trusted for security: it only picks which nodes to try.
8
+ * Every node is attested under the caller's own policy before anything is sent
9
+ * to it, so a bad list can only make connecting fail.
10
+ */
11
+ /** The Calimero Cloud control plane's public node directory. */
12
+ export const DEFAULT_DIRECTORY = 'https://manager.cloud.calimero.network/api/v2/directory';
13
+ /** How long a listing stands when the directory does not say. */
14
+ const DEFAULT_REFRESH_SECONDS = 60;
15
+ /**
16
+ * The directory could not name a node to try: it could not be reached or
17
+ * answered an error (`unreachable`), does not know the cluster (`not-found`,
18
+ * a 404), answered something that is not a listing (`bad-response`), or listed
19
+ * no usable node (`empty`).
20
+ */
21
+ export class DirectoryError extends Error {
22
+ constructor(cluster, reason, message, status, cause) {
23
+ super(`directory for cluster ${cluster}: ${message}`);
24
+ this.cluster = cluster;
25
+ this.reason = reason;
26
+ this.status = status;
27
+ this.cause = cause;
28
+ this.name = 'DirectoryError';
29
+ }
30
+ }
31
+ /** The URL of `cluster`'s listing in `directory`. */
32
+ export function directoryUrl(directory, cluster) {
33
+ return `${directory.replace(/\/+$/, '')}/${encodeURIComponent(cluster)}`;
34
+ }
35
+ /**
36
+ * Read `cluster`'s listing, keeping only the entries with an http(s) URL and a
37
+ * positive integer weight (the first of a repeated URL).
38
+ */
39
+ export async function readDirectory(directory, cluster, fetcher) {
40
+ let response;
41
+ try {
42
+ response = await fetcher(directoryUrl(directory, cluster), { method: 'GET', headers: { accept: 'application/json' } });
43
+ }
44
+ catch (error) {
45
+ throw new DirectoryError(cluster, 'unreachable', `could not be reached: ${String(error)}`, undefined, error);
46
+ }
47
+ if (response.status === 404)
48
+ throw new DirectoryError(cluster, 'not-found', 'unknown cluster', 404);
49
+ if (!response.ok)
50
+ throw new DirectoryError(cluster, 'unreachable', `answered ${response.status}`, response.status);
51
+ let body;
52
+ try {
53
+ body = await response.json();
54
+ }
55
+ catch (error) {
56
+ throw new DirectoryError(cluster, 'bad-response', 'answered something other than JSON', response.status, error);
57
+ }
58
+ const listing = body;
59
+ if (!listing || typeof listing !== 'object' || !Array.isArray(listing.nodes)) {
60
+ throw new DirectoryError(cluster, 'bad-response', 'answered no node list', response.status);
61
+ }
62
+ const nodes = [];
63
+ const seen = new Set();
64
+ for (const entry of listing.nodes) {
65
+ const node = usable(entry);
66
+ if (node && !seen.has(node.url)) {
67
+ seen.add(node.url);
68
+ nodes.push(node);
69
+ }
70
+ }
71
+ if (nodes.length === 0)
72
+ throw new DirectoryError(cluster, 'empty', 'lists no usable node', response.status);
73
+ const after = listing.refreshAfterSeconds;
74
+ const refreshAfterSeconds = typeof after === 'number' && Number.isFinite(after) && after >= 1 ? after : DEFAULT_REFRESH_SECONDS;
75
+ return { nodes, refreshAfterSeconds };
76
+ }
77
+ function usable(entry) {
78
+ if (!entry || typeof entry !== 'object')
79
+ return undefined;
80
+ const { url, zone, weight } = entry;
81
+ if (typeof url !== 'string' || typeof weight !== 'number' || !Number.isInteger(weight) || weight < 1)
82
+ return undefined;
83
+ let parsed;
84
+ try {
85
+ parsed = new URL(url);
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
91
+ return undefined;
92
+ return { url: url.replace(/\/+$/, ''), zone: typeof zone === 'string' ? zone : undefined, weight };
93
+ }
94
+ /**
95
+ * The order to try `nodes` in: those in `zone` first (when given), each group
96
+ * in weighted-random order, a node's weight its relative chance to come next.
97
+ * `random` answers in [0, 1), as `Math.random` does.
98
+ */
99
+ export function orderNodes(nodes, zone, random) {
100
+ const near = zone === undefined ? [] : nodes.filter((node) => node.zone === zone);
101
+ const far = zone === undefined ? nodes : nodes.filter((node) => node.zone !== zone);
102
+ return [...weightedOrder(near, random), ...weightedOrder(far, random)];
103
+ }
104
+ function weightedOrder(nodes, random) {
105
+ const left = [...nodes];
106
+ const order = [];
107
+ while (left.length > 0) {
108
+ let roll = random() * left.reduce((sum, node) => sum + node.weight, 0);
109
+ let pick = 0;
110
+ while (pick < left.length - 1 && roll >= left[pick].weight)
111
+ roll -= left[pick++].weight;
112
+ order.push(left.splice(pick, 1)[0].url);
113
+ }
114
+ return order;
115
+ }
@@ -1,7 +1,9 @@
1
1
  export { CalimeroClient } from './client.js';
2
+ export { DEFAULT_DIRECTORY, DirectoryError } from './directory.js';
3
+ export type { DirectoryFailure } from './directory.js';
2
4
  export { AppError, ArgsTooLarge, CalimeroError, ConfirmCancelled, ConfirmRequired, IdempotencyMismatch, LoginRequired, ConsentStale, NotFound, ReleaseDeadlineError, TrapError, UnavailableError, WrongNode, errorFrom, } from './errors.js';
3
5
  export type { Committed } from './errors.js';
4
- export type { AppInfo, BlobInfo, CallResult, CalimeroClientOptions, CloudEvent, CalimeroSession, PackageInfo, PackageInstalled, CloudRetryOptions, OrgInfo, OrgUpgradeTarget, PresenceEntry, UpgradeRun, ViewResult, } from './client.js';
6
+ export type { AppInfo, BlobInfo, CallResult, CalimeroClientOptions, CalimeroConnectOptions, CloudEvent, CalimeroSession, PackageInfo, PackageInstalled, CloudRetryOptions, OrgInfo, OrgUpgradeTarget, PresenceEntry, UpgradeRun, ViewResult, } from './client.js';
5
7
  export { claimedRelease, fetchAttestedKey, freshCollateral, MOCK_QUOTE_HEADER } from './attestation.js';
6
8
  export type { AttestationDoc, CloudPolicy, CloudReleasePolicy } from './attestation.js';
7
9
  export { browserPasskey, b64url, fromB64url } from './passkey.js';
@@ -1,4 +1,5 @@
1
1
  export { CalimeroClient } from './client.js';
2
+ export { DEFAULT_DIRECTORY, DirectoryError } from './directory.js';
2
3
  export { AppError, ArgsTooLarge, CalimeroError, ConfirmCancelled, ConfirmRequired, IdempotencyMismatch, LoginRequired, ConsentStale, NotFound, ReleaseDeadlineError, TrapError, UnavailableError, WrongNode, errorFrom, } from './errors.js';
3
4
  export { claimedRelease, fetchAttestedKey, freshCollateral, MOCK_QUOTE_HEADER } from './attestation.js';
4
5
  export { browserPasskey, b64url, fromB64url } from './passkey.js';