@drakkar.software/starfish-client 3.0.0-alpha.25 → 3.0.0-alpha.26

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
@@ -187,6 +187,8 @@ new StarfishClient({
187
187
  fetch, // optional custom fetch
188
188
  cache, // optional offline-first read-through cache — see below
189
189
  cacheMaxAgeMs, // optional TTL (ms) for cache entries
190
+ cacheFallbackStatuses, // optional — serve cache on 429/5xx (stale-while-revalidate)
191
+ onRevalidated, // optional — called after a background revalidation succeeds
190
192
  })
191
193
  ```
192
194
 
@@ -200,7 +202,8 @@ Pass a `cache` (a `PullCache`: `{ get(k): Promise<string|null>; set(k, v): Promi
200
202
 
201
203
  - **Write-through:** a successful pull stores the raw `{data, hash, timestamp}` keyed by document path.
202
204
  - **Offline fallback:** a pull that fails because the **transport** is unreachable (`fetch` rejects — offline/DNS/timeout) returns the last cached snapshot, tagged so callers can tell it's stale (`pullWasFromCache(result)`).
203
- - **Real HTTP errors propagate:** 404/403/5xx are genuine server answers — the cache is *not* consulted, so "no document yet" and "access denied" keep their meaning.
205
+ - **Real HTTP errors propagate:** 404/403 are genuine server answers — the cache is *not* consulted, so "no document yet" and "access denied" keep their meaning. 429 and 5xx can optionally be caught via `cacheFallbackStatuses` (see below).
206
+ - **Stale-while-revalidate:** set `cacheFallbackStatuses: [429, 500, 502, 503, 504]` to make transient server failures serve the last-synced snapshot immediately and retry in the background (honoring `Retry-After`). When the live response arrives, the cache is updated and `onRevalidated` fires. No snapshot → the error propagates as usual. Do NOT include 403/404 — they are genuine answers, not transient failures.
204
207
  - **`cacheMaxAgeMs`:** an entry older than this is treated as a miss (both cache-first paint and offline fallback); omit for entries that never expire (recommended for offline-first, where any last-synced data beats none).
205
208
  - **Ciphertext-at-rest by construction:** the cache stores the raw server payload, which for E2E (`delegated`) collections is the sealed ciphertext the server holds — never the decrypted form. Decryption happens in memory on read (see `SyncManager.seedFromCache`).
206
209
  - **`client.peekCache(path)`** reads the cached snapshot *without* a network round-trip — the basis for cache-first paint.
@@ -264,8 +264,27 @@ var StarfishHttpError = class extends Error {
264
264
  }
265
265
  };
266
266
 
267
+ // src/fetch.ts
268
+ function parseRetryAfterMs(header, opts) {
269
+ const { fallbackMs, maxMs } = opts;
270
+ const trimmed = header?.trim();
271
+ if (trimmed) {
272
+ const seconds = Number(trimmed);
273
+ if (!isNaN(seconds)) return Math.min(seconds * 1e3, maxMs);
274
+ const date = Date.parse(trimmed);
275
+ if (!isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), maxMs);
276
+ }
277
+ return Math.min(fallbackMs, maxMs);
278
+ }
279
+
267
280
  // src/client.ts
268
281
  var APPEND_DEFAULT_FIELD = "items";
282
+ var MAX_REVALIDATE_ATTEMPTS = 5;
283
+ var REVALIDATE_INITIAL_DELAY_MS = 1e3;
284
+ var REVALIDATE_MAX_DELAY_MS = 3e4;
285
+ function sleep(ms) {
286
+ return new Promise((resolve) => setTimeout(resolve, ms));
287
+ }
269
288
  function pullCacheKey(pathAndQuery) {
270
289
  const q = pathAndQuery.indexOf("?");
271
290
  return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
@@ -292,6 +311,9 @@ var StarfishClient = class {
292
311
  fetch;
293
312
  cache;
294
313
  cacheMaxAgeMs;
314
+ cacheFallbackStatuses;
315
+ onRevalidated;
316
+ revalidating = /* @__PURE__ */ new Set();
295
317
  /**
296
318
  * Installed client-side plugins. Currently stored as inert data; no
297
319
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -304,6 +326,8 @@ var StarfishClient = class {
304
326
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
305
327
  this.cache = options.cache;
306
328
  this.cacheMaxAgeMs = options.cacheMaxAgeMs;
329
+ this.cacheFallbackStatuses = options.cacheFallbackStatuses;
330
+ this.onRevalidated = options.onRevalidated;
307
331
  this.plugins = options.plugins ? [...options.plugins] : [];
308
332
  }
309
333
  /**
@@ -459,7 +483,17 @@ var StarfishClient = class {
459
483
  throw err;
460
484
  }
461
485
  if (!res.ok) {
462
- throw new StarfishHttpError(res.status, await res.text());
486
+ const status = res.status;
487
+ if (cacheKey && this.cacheFallbackStatuses?.includes(status)) {
488
+ const retryAfterHeader = res.headers.get("Retry-After");
489
+ this.scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader);
490
+ const cached = await this.readCache(cacheKey);
491
+ if (cached) {
492
+ void res.body?.cancel();
493
+ return cached;
494
+ }
495
+ }
496
+ throw new StarfishHttpError(status, await res.text());
463
497
  }
464
498
  const result = await res.json();
465
499
  if (appendField !== void 0) {
@@ -478,6 +512,63 @@ var StarfishClient = class {
478
512
  }
479
513
  return result;
480
514
  }
515
+ /** Deduplicated fire-and-forget: starts one revalidation loop per cacheKey. */
516
+ scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader) {
517
+ if (this.revalidating.has(cacheKey)) return;
518
+ this.revalidating.add(cacheKey);
519
+ void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader).finally(() => {
520
+ this.revalidating.delete(cacheKey);
521
+ });
522
+ }
523
+ /**
524
+ * Background revalidation loop for a {@link cacheFallbackStatuses} hit.
525
+ * Retries the pull (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS}
526
+ * times. On a live 2xx response the fresh snapshot is written through to the
527
+ * cache and {@link onRevalidated} fires. Stops early on a non-fallback HTTP
528
+ * status (e.g. 404/403 — the server gave a genuine answer).
529
+ */
530
+ async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter) {
531
+ let retryAfterHeader = firstRetryAfter;
532
+ for (let attempt = 0; attempt < MAX_REVALIDATE_ATTEMPTS; attempt++) {
533
+ const delay = parseRetryAfterMs(retryAfterHeader, {
534
+ fallbackMs: Math.min(
535
+ REVALIDATE_INITIAL_DELAY_MS * Math.pow(2, attempt),
536
+ REVALIDATE_MAX_DELAY_MS
537
+ ),
538
+ maxMs: REVALIDATE_MAX_DELAY_MS
539
+ });
540
+ await sleep(delay);
541
+ try {
542
+ const url = `${this.baseUrl}${pathAndQuery}`;
543
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
544
+ const res = await this.fetch(url, {
545
+ method: "GET",
546
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
547
+ });
548
+ if (res.ok) {
549
+ const result = await res.json();
550
+ if (this.cache) {
551
+ const snapshot = {
552
+ data: result.data,
553
+ hash: result.hash,
554
+ timestamp: result.timestamp,
555
+ cachedAt: Date.now()
556
+ };
557
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
558
+ });
559
+ }
560
+ this.onRevalidated?.(pathAndQuery, result);
561
+ return;
562
+ }
563
+ if (!this.cacheFallbackStatuses?.includes(res.status)) {
564
+ return;
565
+ }
566
+ retryAfterHeader = res.headers.get("Retry-After");
567
+ } catch {
568
+ retryAfterHeader = null;
569
+ }
570
+ }
571
+ }
481
572
  /**
482
573
  * Read the cached snapshot for a document `path` WITHOUT hitting the network —
483
574
  * the basis for cache-first paint (seed the UI from the last-synced snapshot,