// LeavePulse SDK — ETag cache store (RFC 7232 conditional requests). // // `transport.conditional()` is the raw mechanism: it sends `If-None-Match` and // reports modified / not_modified / not_found, but doesn't remember anything. // This module adds the *policy*: an `EtagStore` keeps `{etag, body}` per key, // and `fetchCached` ties it to the transport — serve the cached body on `304`, // store the new body on `200`. Two stores ship (in-memory, localStorage); // implement `EtagStore` for anything else (SQLite, IndexedDB, …). import { NotFound } from "./errors"; import type { ConditionalResult, Transport, TransportRequest, } from "./transport"; /** A cached conditional response: the validator and the body it belongs to. */ export interface EtagEntry { etag: string; body: T; } /** * Pluggable storage for ETag cache entries. Sync or async — `fetchCached` * awaits every call, so a synchronous store (Map) and an async one (IndexedDB, * SQLite) satisfy the same interface. */ export interface EtagStore { get( key: string, ): EtagEntry | undefined | Promise | undefined>; set(key: string, entry: EtagEntry): void | Promise; delete(key: string): void | Promise; } /** The default cache key for a request: method + path (+ query). */ export function defaultCacheKey(req: TransportRequest): string { const channel = req.channel ?? "platform"; return `${channel} ${req.method} ${req.path}`; } /** In-process cache. Lives for the process; ideal for SSR and tests. */ export class MemoryEtagStore implements EtagStore { private readonly map = new Map(); get(key: string): EtagEntry | undefined { return this.map.get(key) as EtagEntry | undefined; } set(key: string, entry: EtagEntry): void { this.map.set(key, entry); } delete(key: string): void { this.map.delete(key); } } /** * Browser-persistent cache backed by `localStorage`. Entries survive reloads; * a parse/quota failure degrades to a cache miss rather than throwing, so a * full or corrupt store never breaks a request. */ export class LocalStorageEtagStore implements EtagStore { /** @param prefix namespaces keys to avoid clashing with other data. */ constructor( private readonly storage: Storage, private readonly prefix = "lp.etag:", ) {} get(key: string): EtagEntry | undefined { const raw = this.storage.getItem(this.prefix + key); if (raw === null) return undefined; try { return JSON.parse(raw) as EtagEntry; } catch { return undefined; } } set(key: string, entry: EtagEntry): void { try { this.storage.setItem(this.prefix + key, JSON.stringify(entry)); } catch { // Quota exceeded / unavailable — skip caching, the request still works. } } delete(key: string): void { try { this.storage.removeItem(this.prefix + key); } catch { // ignore } } } /** * Browser-persistent cache backed by IndexedDB. Unlike `localStorage` it is * asynchronous and not bound by the ~5 MB string quota, so it suits caching * larger response bodies across sessions. Every operation degrades to a * cache miss / no-op on failure (private mode, blocked storage, version * conflicts), so a broken store never breaks a request. */ export class IndexedDbEtagStore implements EtagStore { private dbPromise: Promise | undefined; /** * @param dbName database name (one per app is plenty). * @param storeName object store holding the etag entries. */ constructor( private readonly dbName = "lp-etag-cache", private readonly storeName = "entries", ) {} private openDb(): Promise { if (this.dbPromise) return this.dbPromise; this.dbPromise = new Promise((resolve) => { const idb = typeof indexedDB !== "undefined" ? indexedDB : undefined; if (!idb) { resolve(null); return; } let request: IDBOpenDBRequest; try { request = idb.open(this.dbName, 1); } catch { resolve(null); return; } request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(this.storeName)) { db.createObjectStore(this.storeName); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => resolve(null); request.onblocked = () => resolve(null); }); return this.dbPromise; } private async withStore( mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest, ): Promise { const db = await this.openDb(); if (!db) return undefined; return new Promise((resolve) => { let req: IDBRequest; try { req = run( db.transaction(this.storeName, mode).objectStore(this.storeName), ); } catch { resolve(undefined); return; } req.onsuccess = () => resolve(req.result as R | undefined); req.onerror = () => resolve(undefined); }); } async get(key: string): Promise | undefined> { return this.withStore>("readonly", (store) => store.get(key)); } async set(key: string, entry: EtagEntry): Promise { await this.withStore("readwrite", (store) => store.put(entry, key)); } async delete(key: string): Promise { await this.withStore("readwrite", (store) => store.delete(key)); } } export interface FetchCachedOptions { /** Override the cache key (default: channel + method + path). */ key?: string; } /** * Fetch a resource through the ETag cache: send the stored validator, return * the cached body unchanged on `304`, store and return the fresh body on `200`, * and return `null` on `404` (evicting any stale entry). */ export async function fetchCached( transport: Transport, store: EtagStore, req: TransportRequest, options: FetchCachedOptions = {}, ): Promise { const key = options.key ?? defaultCacheKey(req); const cached = await store.get(key); const result: ConditionalResult = await transport.conditional({ ...req, ifNoneMatch: cached?.etag, }); switch (result.status) { case "not_modified": { // Server confirmed the cached copy is current — return it. if (cached) return cached.body; // We held a validator but no body (e.g. the store instance differs // between SSR and client hydration, or the entry was evicted). The // `304` carries no body, so re-fetch unconditionally to recover the // full payload instead of reporting a phantom miss (which would // surface as a bogus 404 in fetchCachedOrThrow). const fresh = await transport.conditional(req); if (fresh.status === "modified") { if (fresh.etag) { await store.set(key, { etag: fresh.etag, body: fresh.data }); } return fresh.data; } if (fresh.status === "not_found") { await store.delete(key); return null; } return null; } case "modified": if (result.etag) { await store.set(key, { etag: result.etag, body: result.data }); } return result.data; case "not_found": await store.delete(key); return null; } } /** * Like {@link fetchCached} but for endpoints that always yield a value: a `404` * (null) is surfaced as a {@link NotFound} error, matching what a plain * `transport.request` would throw. Generated GET methods route through this so * their return type stays non-nullable. */ export async function fetchCachedOrThrow( transport: Transport, store: EtagStore, req: TransportRequest, options: FetchCachedOptions = {}, ): Promise { const body = await fetchCached(transport, store, req, options); if (body === null) { throw new NotFound(404, req, null, ""); } return body; }