@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 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@next
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 a release candidate
17
- (`0.1.0-rc.1`, npm dist-tag `next`): APIs may still change.
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
@@ -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. */
@@ -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 (request.kind === "open")
463
- this.#move(ctx, "CLOSE");
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" && ctx.snapshotPending)
468
- this.#serial(() => this.#afterSnapshot(ctx));
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.0-rc.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": "next"
42
+ "tag": "latest"
43
43
  },
44
44
  "dependencies": {
45
- "@openlfcp/core": "^0.1.0-rc.1",
46
- "@openlfcp/crypto": "^0.1.0-rc.1",
47
- "@openlfcp/storage": "^0.1.0-rc.1",
48
- "@openlfcp/wire": "^0.1.0-rc.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
  }