@openlfcp/client 0.1.0-rc.1 → 0.1.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/README.md +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/sync-client.d.ts +41 -0
- package/dist/sync-client.js +80 -5
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
An LFCP client on top of the other packages: the WebSocket session (`SyncClient`), Control and Data Plane synchronization, the outbound queue, applying received units to a Data Profile (`DataUnitApplier`) with the crash-loop breaker, invitations, Snapshots and storage helpers.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
npm install @openlfcp/client
|
|
6
|
+
npm install @openlfcp/client
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
ESM only; Node.js 24 or later (browsers and Electron for every package except `@openlfcp/storage-node`).
|
|
@@ -13,8 +13,8 @@ ESM only; Node.js 24 or later (browsers and Electron for every package except `@
|
|
|
13
13
|
Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
|
|
14
14
|
subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
|
|
15
15
|
at `mvp-0.1-baseline.8`, not every deferred WIRE-01 feature. It does not
|
|
16
|
-
claim full LFCP-WIRE-01 conformance. This is
|
|
17
|
-
(`0.1.
|
|
16
|
+
claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
|
|
17
|
+
(`0.1.1`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
|
|
18
18
|
|
|
19
19
|
## Links
|
|
20
20
|
|
package/dist/index.d.ts
CHANGED
|
@@ -11,5 +11,5 @@ export { createQueuedSnapshot, outboundItem, queueControlRecord, queueKeyEpoch,
|
|
|
11
11
|
export { type ResourcePhase, type ResourcePhaseEvent, resourcePhaseTransition, } from "./resource-state.js";
|
|
12
12
|
export { type CreatedSnapshot, type CreateSnapshotOptions, createSnapshot } from "./snapshot.js";
|
|
13
13
|
export { adoptStoredDeks, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
|
|
14
|
-
export { defaultReconnect, type ReconnectPolicy, type ResourceBinding, type SnapshotBinding, SyncClient, type SyncClientOptions, type SyncEvent, startSyncDriver, } from "./sync-client.js";
|
|
14
|
+
export { defaultReconnect, type ReconnectPolicy, type ResourceBinding, type ResourceRefusal, type SnapshotBinding, SyncClient, type SyncClientOptions, type SyncEvent, startSyncDriver, TERMINAL_RESOURCE_CODES, } from "./sync-client.js";
|
|
15
15
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -11,5 +11,5 @@ export { createQueuedSnapshot, outboundItem, queueControlRecord, queueKeyEpoch,
|
|
|
11
11
|
export { resourcePhaseTransition, } from "./resource-state.js";
|
|
12
12
|
export { createSnapshot } from "./snapshot.js";
|
|
13
13
|
export { adoptStoredDeks, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
|
|
14
|
-
export { defaultReconnect, SyncClient, startSyncDriver, } from "./sync-client.js";
|
|
14
|
+
export { defaultReconnect, SyncClient, startSyncDriver, TERMINAL_RESOURCE_CODES, } from "./sync-client.js";
|
|
15
15
|
//# sourceMappingURL=index.js.map
|
package/dist/sync-client.d.ts
CHANGED
|
@@ -153,7 +153,46 @@ export type SyncEvent = {
|
|
|
153
153
|
readonly resourceId?: ResourceId;
|
|
154
154
|
readonly code: string;
|
|
155
155
|
readonly message: string;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The server refused the Resource for good (see ResourceRefusal): it is
|
|
159
|
+
* CLOSED and is not opened again until open() is called for it.
|
|
160
|
+
*/
|
|
161
|
+
| {
|
|
162
|
+
readonly type: "resource-refused";
|
|
163
|
+
readonly resourceId: ResourceId;
|
|
164
|
+
readonly refusal: ResourceRefusal;
|
|
156
165
|
};
|
|
166
|
+
/**
|
|
167
|
+
* Why a server will not sync a Resource with this session, from a NACK
|
|
168
|
+
* whose code no retry can change (TERMINAL_RESOURCE_CODES): the server does
|
|
169
|
+
* not host it (§41: purged, never hosted, a restored server), it was
|
|
170
|
+
* tombstoned (§24), the session Principal may not read it (§41, §84: never
|
|
171
|
+
* granted or revoked), or the request itself is not acceptable to it. The
|
|
172
|
+
* Resource stays CLOSED; open() asks again.
|
|
173
|
+
*/
|
|
174
|
+
export interface ResourceRefusal {
|
|
175
|
+
/** The §62 name, e.g. RESOURCE_NOT_HOSTED. */
|
|
176
|
+
readonly code: string;
|
|
177
|
+
/** The server's diagnostic, if any (never key material, §60). */
|
|
178
|
+
readonly diagnostic?: string;
|
|
179
|
+
/** The server that refused (the session URL). */
|
|
180
|
+
readonly url: string;
|
|
181
|
+
/** Which request was refused: "open", "control", "keys", "data-get", "data-have" or "snapshot". */
|
|
182
|
+
readonly request: string;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* §62 codes that end syncing a Resource on this server when they refuse
|
|
186
|
+
* RESOURCE_OPEN or one of its reads (CONTROL_GET, KEY_PACKAGE_GET, DATA_GET,
|
|
187
|
+
* DATA_HAVE, SNAPSHOT_GET). Repeating the same request cannot change them:
|
|
188
|
+
* the server's hosting or the Resource's Control state must change first,
|
|
189
|
+
* and the application decides when to ask again. Every other code is
|
|
190
|
+
* transient for these requests (RATE_LIMITED, INTERNAL_ERROR, QUOTA_EXCEEDED,
|
|
191
|
+
* unknown codes) and is retried with the ReconnectPolicy's backoff, except
|
|
192
|
+
* where the request has its own fallback: KEY_PACKAGE_GET → KEY_BLOCKED,
|
|
193
|
+
* SNAPSHOT_GET → replaying the units (§29.2).
|
|
194
|
+
*/
|
|
195
|
+
export declare const TERMINAL_RESOURCE_CODES: ReadonlySet<string>;
|
|
157
196
|
export declare class SyncClient {
|
|
158
197
|
#private;
|
|
159
198
|
constructor(options: SyncClientOptions);
|
|
@@ -163,6 +202,8 @@ export declare class SyncClient {
|
|
|
163
202
|
idle(): Promise<void>;
|
|
164
203
|
get connectionState(): ClientConnectionState;
|
|
165
204
|
resourceState(resource: ResourceId): ResourcePhase;
|
|
205
|
+
/** Why the server refused the Resource for good, or null (see ResourceRefusal). */
|
|
206
|
+
resourceRefusal(resource: ResourceId): ResourceRefusal | null;
|
|
166
207
|
/** Connects, and reconnects after losses (per the ReconnectPolicy) until stop(). */
|
|
167
208
|
start(): void;
|
|
168
209
|
/** Closes the connection and stops reconnecting. Local state stays usable. */
|
package/dist/sync-client.js
CHANGED
|
@@ -11,6 +11,26 @@ import { resourcePhaseTransition, } from "./resource-state.js";
|
|
|
11
11
|
import { adoptStoredDeks, dekResolver, loadControlChain, saveControlChain, saveControlConflict, } from "./storage.js";
|
|
12
12
|
/** 1 s, doubling, at most 60 s, forever. */
|
|
13
13
|
export const defaultReconnect = (attempt) => Math.min(60_000, 1000 * 2 ** (attempt - 1));
|
|
14
|
+
/**
|
|
15
|
+
* §62 codes that end syncing a Resource on this server when they refuse
|
|
16
|
+
* RESOURCE_OPEN or one of its reads (CONTROL_GET, KEY_PACKAGE_GET, DATA_GET,
|
|
17
|
+
* DATA_HAVE, SNAPSHOT_GET). Repeating the same request cannot change them:
|
|
18
|
+
* the server's hosting or the Resource's Control state must change first,
|
|
19
|
+
* and the application decides when to ask again. Every other code is
|
|
20
|
+
* transient for these requests (RATE_LIMITED, INTERNAL_ERROR, QUOTA_EXCEEDED,
|
|
21
|
+
* unknown codes) and is retried with the ReconnectPolicy's backoff, except
|
|
22
|
+
* where the request has its own fallback: KEY_PACKAGE_GET → KEY_BLOCKED,
|
|
23
|
+
* SNAPSHOT_GET → replaying the units (§29.2).
|
|
24
|
+
*/
|
|
25
|
+
export const TERMINAL_RESOURCE_CODES = new Set([
|
|
26
|
+
"RESOURCE_NOT_HOSTED",
|
|
27
|
+
"RESOURCE_NOT_FOUND",
|
|
28
|
+
"RESOURCE_TOMBSTONED",
|
|
29
|
+
"AUTHORIZATION_FAILED",
|
|
30
|
+
"PROTOCOL_UNSUPPORTED",
|
|
31
|
+
"MALFORMED_MESSAGE",
|
|
32
|
+
"PROFILE_UNSUPPORTED",
|
|
33
|
+
]);
|
|
14
34
|
const NACK_NAME = new Map(Object.entries(ERROR_CODE).map(([k, v]) => [v, k]));
|
|
15
35
|
const SUBSCRIBE_DATA_AND_CONTROL = 3n;
|
|
16
36
|
const iso = (ms) => new Date(ms).toISOString();
|
|
@@ -103,6 +123,10 @@ export class SyncClient {
|
|
|
103
123
|
resourceState(resource) {
|
|
104
124
|
return this.#resources.get(toHex(resource))?.state ?? "CLOSED";
|
|
105
125
|
}
|
|
126
|
+
/** Why the server refused the Resource for good, or null (see ResourceRefusal). */
|
|
127
|
+
resourceRefusal(resource) {
|
|
128
|
+
return this.#resources.get(toHex(resource))?.refusal ?? null;
|
|
129
|
+
}
|
|
106
130
|
/** Connects, and reconnects after losses (per the ReconnectPolicy) until stop(). */
|
|
107
131
|
start() {
|
|
108
132
|
if (this.#trapped)
|
|
@@ -144,10 +168,17 @@ export class SyncClient {
|
|
|
144
168
|
covered: [],
|
|
145
169
|
unitsSinceSnapshot: 0,
|
|
146
170
|
lastRound: "",
|
|
171
|
+
refusal: null,
|
|
172
|
+
refusedAttempt: 0,
|
|
173
|
+
reopenAt: null,
|
|
147
174
|
};
|
|
148
175
|
this.#resources.set(key, ctx);
|
|
149
176
|
}
|
|
150
177
|
ctx.wanted = true;
|
|
178
|
+
// An explicit open asks the server again after a refusal.
|
|
179
|
+
ctx.refusal = null;
|
|
180
|
+
ctx.refusedAttempt = 0;
|
|
181
|
+
ctx.reopenAt = null;
|
|
151
182
|
if (this.#connection.state === "READY")
|
|
152
183
|
this.#serial(() => this.#sendOpen(ctx));
|
|
153
184
|
}
|
|
@@ -202,6 +233,12 @@ export class SyncClient {
|
|
|
202
233
|
this.#expireRequests(now);
|
|
203
234
|
this.#serial(async () => {
|
|
204
235
|
for (const ctx of this.#resources.values()) {
|
|
236
|
+
if (ctx.reopenAt !== null &&
|
|
237
|
+
now >= ctx.reopenAt &&
|
|
238
|
+
ctx.state === "CLOSED" &&
|
|
239
|
+
ctx.wanted &&
|
|
240
|
+
this.#connection.state === "READY")
|
|
241
|
+
await this.#sendOpen(ctx);
|
|
205
242
|
if (ctx.state === "LIVE" && now - ctx.lastHave >= this.#antiEntropyMs)
|
|
206
243
|
this.#sendHave(ctx, now);
|
|
207
244
|
if (ctx.state === "KEY_BLOCKED" && now - ctx.lastKeyRequest >= this.#antiEntropyMs)
|
|
@@ -280,6 +317,8 @@ export class SyncClient {
|
|
|
280
317
|
if (next === undefined)
|
|
281
318
|
return false;
|
|
282
319
|
ctx.state = next;
|
|
320
|
+
if (next === "LIVE")
|
|
321
|
+
ctx.refusedAttempt = 0;
|
|
283
322
|
this.#emit({ type: "resource-state", resourceId: ctx.binding.resourceId, state: next });
|
|
284
323
|
if (next === "LIVE" && ctx.controlStale) {
|
|
285
324
|
ctx.controlStale = false;
|
|
@@ -338,6 +377,7 @@ export class SyncClient {
|
|
|
338
377
|
ctx.fetched = [];
|
|
339
378
|
ctx.controlTarget = null;
|
|
340
379
|
ctx.early = [];
|
|
380
|
+
ctx.reopenAt = null;
|
|
341
381
|
this.#request({ kind: "open", resource: toHex(R) }, createMessage("RESOURCE_OPEN", {
|
|
342
382
|
resourceId: R,
|
|
343
383
|
heads,
|
|
@@ -457,15 +497,50 @@ export class SyncClient {
|
|
|
457
497
|
}
|
|
458
498
|
const ctx = this.#resources.get(request.resource);
|
|
459
499
|
this.#error(code, `${request.kind} refused${m.body.diagnostic ? `: ${m.body.diagnostic}` : ""}`, ctx?.binding.resourceId);
|
|
460
|
-
if (ctx === undefined)
|
|
500
|
+
if (ctx === undefined || request.kind === "close")
|
|
461
501
|
return;
|
|
462
|
-
if (
|
|
463
|
-
this.#
|
|
502
|
+
if (TERMINAL_RESOURCE_CODES.has(code)) {
|
|
503
|
+
this.#refuse(ctx, {
|
|
504
|
+
code,
|
|
505
|
+
url: this.#o.url,
|
|
506
|
+
request: request.kind,
|
|
507
|
+
...(m.body.diagnostic === undefined ? {} : { diagnostic: m.body.diagnostic }),
|
|
508
|
+
});
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
464
511
|
if (request.kind === "keys")
|
|
465
512
|
this.#keyBlocked(ctx);
|
|
466
513
|
// A Snapshot is an optimization (§29.2): without it, replay the units.
|
|
467
|
-
if (request.kind === "snapshot"
|
|
468
|
-
|
|
514
|
+
else if (request.kind === "snapshot") {
|
|
515
|
+
if (ctx.snapshotPending)
|
|
516
|
+
this.#serial(() => this.#afterSnapshot(ctx));
|
|
517
|
+
}
|
|
518
|
+
// A transient refusal of the open or of a sync round: the Resource
|
|
519
|
+
// would otherwise sit there until the next reconnect. Start over after
|
|
520
|
+
// a backoff. A refused DATA_HAVE is simply sent again (§69).
|
|
521
|
+
else if (request.kind !== "data-have")
|
|
522
|
+
this.#retryLater(ctx);
|
|
523
|
+
}
|
|
524
|
+
/** The server refused the Resource for good: CLOSED, not reopened until open() asks again. */
|
|
525
|
+
#refuse(ctx, refusal) {
|
|
526
|
+
const subscribed = ctx.state !== "CLOSED" && ctx.state !== "OPENING";
|
|
527
|
+
ctx.wanted = false;
|
|
528
|
+
ctx.reopenAt = null;
|
|
529
|
+
ctx.refusal = Object.freeze(refusal);
|
|
530
|
+
// Stop any pushes the server may still send for it (§43).
|
|
531
|
+
if (subscribed && this.#connection.state === "READY")
|
|
532
|
+
this.#request({ kind: "close", resource: toHex(ctx.binding.resourceId) }, createMessage("RESOURCE_CLOSE", { resourceId: ctx.binding.resourceId }));
|
|
533
|
+
this.#move(ctx, "CLOSE");
|
|
534
|
+
this.#emit({ type: "resource-refused", resourceId: ctx.binding.resourceId, refusal });
|
|
535
|
+
}
|
|
536
|
+
/** CLOSED now, opened again after the ReconnectPolicy's delay for this Resource's attempt. */
|
|
537
|
+
#retryLater(ctx) {
|
|
538
|
+
if (ctx.state !== "CLOSED" && ctx.state !== "OPENING" && this.#connection.state === "READY")
|
|
539
|
+
this.#request({ kind: "close", resource: toHex(ctx.binding.resourceId) }, createMessage("RESOURCE_CLOSE", { resourceId: ctx.binding.resourceId }));
|
|
540
|
+
this.#move(ctx, "CLOSE");
|
|
541
|
+
ctx.refusedAttempt += 1;
|
|
542
|
+
const delay = (this.#o.reconnect ?? defaultReconnect)(ctx.refusedAttempt);
|
|
543
|
+
ctx.reopenAt = delay === null ? null : this.#o.now() + delay;
|
|
469
544
|
}
|
|
470
545
|
// -------------------------------------------------------------------------
|
|
471
546
|
// Control (§44-§47, §67)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "LFCP client: session, Control Plane and Data Plane synchronization.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
],
|
|
40
40
|
"publishConfig": {
|
|
41
41
|
"access": "public",
|
|
42
|
-
"tag": "
|
|
42
|
+
"tag": "latest"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@openlfcp/core": "^0.1.
|
|
46
|
-
"@openlfcp/
|
|
47
|
-
"@openlfcp/
|
|
48
|
-
"@openlfcp/wire": "^0.1.
|
|
45
|
+
"@openlfcp/core": "^0.1.1",
|
|
46
|
+
"@openlfcp/storage": "^0.1.1",
|
|
47
|
+
"@openlfcp/crypto": "^0.1.1",
|
|
48
|
+
"@openlfcp/wire": "^0.1.1"
|
|
49
49
|
}
|
|
50
50
|
}
|