UNPKG

@stainless-code/persist

Version:

Hydration-aware persistence middleware for reactive stores (storage × codec seams, TanStack Store adapters, React hydration hook)

421 lines (420 loc) 13.4 kB
//#region src/persist-core.ts /** * Build a `PersistRegistry`. `clearAll` uses `allSettled` + * rethrow-first-rejection semantics: one throwing backend can't skip the * remaining clears, and the caller still sees the first failure. */ function createPersistRegistry() { const clears = /* @__PURE__ */ new Set(); return { register(clearStorage) { clears.add(clearStorage); return () => clears.delete(clearStorage); }, async clearAll() { const rejected = (await Promise.allSettled([...clears].map((clear) => Promise.resolve().then(clear)))).find((r) => r.status === "rejected"); if (rejected) throw rejected.reason; } }; } /** * JSON codec — no `Set` / `Map` / `Date` round-trip. Accepts the standard * `JSON.parse` reviver / `JSON.stringify` replacer. */ const jsonCodec = (options) => ({ encode: (value) => JSON.stringify(value, options?.replacer), decode: (raw) => JSON.parse(raw, options?.reviver) }); /** * Identity codec for structured-clone backends (`TRaw = StorageValue<S>`): * the backend stores the envelope natively — IndexedDB's structured-clone * algorithm round-trips `Set` / `Map` / `Date` without any serialization, so * string codecs (JSON / seroval) become pure overhead there. Never use with * string-only backends (`localStorage`). */ const identityCodec = () => ({ encode: (value) => value, decode: (raw) => raw }); /** * The one shared `PersistStorage` plumbing: getStorage try-guard (returns * `undefined` when the backend is unavailable), sync-vs-Promise branching on * `getItem`, and unified corrupt-payload handling (decode throw → `null` + * optional key removal via `clearCorruptOnFailure`). Pass any * {@link StorageCodec} to swap the serialization format without * reimplementing this layer. * * @example * ```ts * // custom codec (superjson, devalue, compression, encryption, …) * const storage = createStorage<Prefs>( * () => localStorage, * { encode: superjson.stringify, decode: superjson.parse }, * { clearCorruptOnFailure: true }, * ); * ``` */ function createStorage(getStorage, codec, options) { let storage; try { storage = getStorage(); } catch { return; } const parseStored = (name, raw) => { if (raw === null) return null; try { return codec.decode(raw); } catch { if (options?.clearCorruptOnFailure) try { const removal = storage.removeItem(name); if (removal instanceof Promise) removal.catch(() => {}); } catch {} return null; } }; return { getItem(name) { const raw = storage.getItem(name) ?? null; if (raw instanceof Promise) return raw.then((value) => parseStored(name, value ?? null)); return parseStored(name, raw); }, setItem(name, value) { return storage.setItem(name, codec.encode(value)); }, removeItem(name) { return storage.removeItem(name); }, raw: storage }; } /** * Build a JSON-encoded `PersistStorage` (no `Set`/`Map`/`Date` round-trip). * The backend is the argument — `() => localStorage`, `() => sessionStorage`, * or any custom `StateStorage`. For corrupt-payload self-heal * (`clearCorruptOnFailure`), use * `createStorage(getStorage, jsonCodec(options), { clearCorruptOnFailure })` * directly — this factory's options are codec-only (reviver/replacer). */ function createJSONStorage(getStorage, options) { return createStorage(getStorage, jsonCodec(options)); } /** * Default merge: shallow-spread persisted over current. Single definition * referenced by both the options initializer and the hydrate fallback. */ const shallowSpreadMerge = (persistedState, currentState) => ({ ...currentState, ...persistedState }); /** * Spread `patch` into `base` skipping explicit `undefined` values, so a caller * passing `{ merge: undefined }` to `setOptions` can't clobber an existing * (or default) value — only real values win. */ function mergeDefined(base, patch) { const result = { ...base }; for (const key of Object.keys(patch)) if (patch[key] !== void 0) result[key] = patch[key]; return result; } function createNoopApi(options) { return { setOptions() {}, clearStorage() {}, rehydrate() {}, hasHydrated: () => true, onHydrate: () => () => {}, onFinishHydration: () => () => {}, getOptions: () => options, destroy() {} }; } function resolveDefaultStorage(options) { if (options.storage) return options.storage; if (typeof localStorage === "undefined") return void 0; return createJSONStorage(() => localStorage); } /** * Attach persist to any `PersistableSource`: hydrate from storage on create, * subscribe-write on every `setState`. Returns the lifecycle `PersistApi`. * No-op `PersistApi` (always hydrated) when storage is unavailable (SSR / tests). */ function persistSource(source, baseOptions) { let options = mergeDefined({ version: 0, merge: shallowSpreadMerge }, baseOptions); let storage = resolveDefaultStorage(options); const reportError = (error, phase) => { if (options.onError) options.onError(error, { name: options.name, phase }); else if (process.env.NODE_ENV !== "production") console.error(`[persistStore] ${phase} error for '${options.name}':`, error); }; if (!storage) { const message = `[persistStore] Unable to persist '${options.name}' — storage unavailable.`; if (options.onError) options.onError(new Error(message), { name: options.name, phase: "hydrate" }); else if (process.env.NODE_ENV !== "production") console.warn(message); return createNoopApi(options); } let hasHydratedFlag = false; let hydrationVersion = 0; const hydrationListeners = /* @__PURE__ */ new Set(); const finishHydrationListeners = /* @__PURE__ */ new Set(); const getPersistedSlice = () => { return (options.partialize ?? ((s) => s))(source.getState()); }; let writeGeneration = 0; const buildEnvelope = (state) => ({ state, version: options.version, timestamp: Date.now(), ...options.buster !== void 0 ? { buster: options.buster } : {} }); const attemptWrite = (state) => storage.setItem(options.name, buildEnvelope(state)); const retryLoop = async (state, firstError, generation) => { let current = state; let error = firstError; let errorCount = 0; while (true) { if (generation !== writeGeneration) return; const retryWrite = options.retryWrite; if (!retryWrite) { reportError(error, "write"); return; } errorCount++; const next = await retryWrite({ state: current, error, errorCount }); if (generation !== writeGeneration) return; if (next === void 0) { reportError(error, "write"); return; } current = next; try { await attemptWrite(current); return; } catch (nextError) { error = nextError; } } }; const writeGuarded = (state, generation) => { try { const result = attemptWrite(state); if (result instanceof Promise) return result.catch((error) => { if (!options.retryWrite) throw error; return retryLoop(state, error, generation); }); } catch (error) { if (!options.retryWrite) throw error; return retryLoop(state, error, generation); } }; const writeToStorage = () => { const state = getPersistedSlice(); const generation = ++writeGeneration; if (options.skipPersist?.(state)) return storage.removeItem(options.name); return writeGuarded(state, generation); }; const writeSafe = () => { try { const result = writeToStorage(); if (result instanceof Promise) return result.catch((error) => reportError(error, "write")); } catch (error) { reportError(error, "write"); } }; let throttleTimer; const cancelPendingWrite = () => { if (throttleTimer === void 0) return; clearTimeout(throttleTimer); throttleTimer = void 0; }; const flushPendingWrite = (mode) => { if (throttleTimer === void 0) return; cancelPendingWrite(); if (mode === "noRetry") { try { const result = attemptWrite(getPersistedSlice()); if (result instanceof Promise) result.catch((error) => reportError(error, "write")); } catch (error) { reportError(error, "write"); } return; } writeSafe(); }; const scheduleWrite = () => { if (options.throttleMs === void 0) { writeSafe(); return; } if (options.skipPersist?.(getPersistedSlice())) { cancelPendingWrite(); writeSafe(); return; } writeGeneration++; if (throttleTimer !== void 0) return; throttleTimer = setTimeout(() => { throttleTimer = void 0; if (!hasHydratedFlag) return; writeSafe(); }, options.throttleMs); }; const sourceSubscription = source.subscribe(() => { if (!hasHydratedFlag) return; scheduleWrite(); }); let crossTabListener; let crossTabTarget; if (options.crossTab) { const target = options.crossTabEventTarget ?? (typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : void 0); if (target) { crossTabListener = (event) => { if (event.key !== options.name) return; const raw = storage?.raw; if (raw !== void 0 && event.storageArea != null && event.storageArea !== raw) return; if (event.newValue === null && options.onCrossTabRemove) { try { options.onCrossTabRemove(); } catch (error) { reportError(error, "crossTab"); } return; } api.rehydrate(); }; target.addEventListener("storage", crossTabListener); crossTabTarget = target; } } const isStoredExpired = (stored) => { if (options.maxAge !== void 0 && Date.now() - (stored.timestamp ?? 0) > options.maxAge) return true; return options.buster !== void 0 && stored.buster !== options.buster; }; const resolveStoredState = async (stored) => { if (typeof stored.version === "number" && stored.version !== options.version) { if (options.migrate) return { state: await options.migrate(stored.state, stored.version), migrated: true }; reportError(/* @__PURE__ */ new Error(`[persistStore] State loaded from storage couldn't be migrated — no migrate function provided`), "migrate"); return { state: void 0, migrated: false }; } return { state: stored.state, migrated: false }; }; const beginHydration = () => { for (const cb of hydrationListeners) cb(source.getState()); return options.onRehydrateStorage?.(source.getState()) || void 0; }; const partitionExpired = (stored) => { const expired = stored != null && isStoredExpired(stored); return { expired, live: stored != null && !expired ? stored : null }; }; const applyResolvedState = (migratedState) => { if (migratedState === void 0) return; const merge = options.merge ?? shallowSpreadMerge; source.setState(() => merge(migratedState, source.getState())); }; const settleHydration = (postRehydrationCallback, failure) => { hasHydratedFlag = true; if (failure) postRehydrationCallback?.(void 0, failure.error); else postRehydrationCallback?.(source.getState(), void 0); for (const cb of finishHydrationListeners) cb(source.getState()); }; const hydrate = async () => { if (!storage) return; const currentVersion = ++hydrationVersion; hasHydratedFlag = false; const hadPendingWrite = throttleTimer !== void 0; cancelPendingWrite(); writeGeneration++; let postRehydrationCallback; let errorPhase = "hydrate"; try { postRehydrationCallback = beginHydration(); const stored = await storage.getItem(options.name); if (currentVersion !== hydrationVersion) return; const { expired, live } = partitionExpired(stored); if (expired) { await storage.removeItem(options.name); if (currentVersion !== hydrationVersion) return; } errorPhase = "migrate"; const resolved = live ? await resolveStoredState(live) : { state: void 0, migrated: false }; errorPhase = "hydrate"; if (currentVersion !== hydrationVersion) return; applyResolvedState(resolved.state); if (resolved.migrated) await writeSafe(); if (currentVersion !== hydrationVersion) return; settleHydration(postRehydrationCallback); if (hadPendingWrite) scheduleWrite(); } catch (error) { if (currentVersion !== hydrationVersion) return; reportError(error, errorPhase); if (hasHydratedFlag) return; try { settleHydration(postRehydrationCallback, { error }); } catch (settleError) { reportError(settleError, "hydrate"); } finally { if (hadPendingWrite) scheduleWrite(); } } }; if (!options.skipHydration) hydrate(); const unregisterClear = options.registry?.register(() => api.clearStorage()); const api = { setOptions(newOptions) { options = mergeDefined(options, newOptions); if (newOptions.storage) storage = newOptions.storage; }, clearStorage() { return storage?.removeItem(options.name); }, rehydrate: hydrate, hasHydrated: () => hasHydratedFlag, onHydrate(fn) { hydrationListeners.add(fn); return () => hydrationListeners.delete(fn); }, onFinishHydration(fn) { finishHydrationListeners.add(fn); return () => finishHydrationListeners.delete(fn); }, getOptions: () => options, destroy() { sourceSubscription.unsubscribe(); unregisterClear?.(); if (crossTabListener && crossTabTarget) crossTabTarget.removeEventListener("storage", crossTabListener); flushPendingWrite("noRetry"); hydrationVersion++; writeGeneration++; } }; return api; } //#endregion export { jsonCodec as a, identityCodec as i, createPersistRegistry as n, persistSource as o, createStorage as r, createJSONStorage as t };