@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 +32 -3
- package/dist/cloud-v2/client.d.ts +61 -8
- package/dist/cloud-v2/client.js +113 -22
- package/dist/cloud-v2/directory.d.ts +50 -0
- package/dist/cloud-v2/directory.js +115 -0
- package/dist/cloud-v2/index.d.ts +3 -1
- package/dist/cloud-v2/index.js +1 -0
- package/dist/index.browser.mjs +4 -4
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +183 -19
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +183 -19
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
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
|
-
|
|
35
|
-
|
|
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)
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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>;
|
package/dist/cloud-v2/client.js
CHANGED
|
@@ -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.
|
|
171
|
-
this.at = (
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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,
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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 (
|
|
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
|
|
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
|
+
}
|
package/dist/cloud-v2/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/cloud-v2/index.js
CHANGED
|
@@ -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';
|