/** * Versioned store snapshots and strict drift-checking hydration. * * Sits *on top of* the simple `serializeStoreState()` / `hydrateStore()` pair * to give applications a way to: * - tag the snapshot with a schema version so a stale client can refuse to * apply server data that no longer matches its store shape; * - opt into strict mode where unknown keys cause a warning; * - selectively serialize / hydrate a subset of stores. * * Backwards compatible: the existing helpers stay untouched and remain the * primary entry-point for simple use cases. * * @module bquery/ssr */ import { getStore, listStores } from '../store/index'; import { isPrototypePollutionKey } from '../core/utils/object'; import { escapeForHtmlAttribute, escapeForScript } from './escape'; const isStateObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const sanitize = (value: Record): Record => { const out: Record = {}; for (const [k, v] of Object.entries(value)) { if (isPrototypePollutionKey(k)) continue; out[k] = v; } return out; }; /** Versioned store snapshot. */ export interface SSRStoreSnapshot { /** Application-defined version string. Stable per schema. */ version: string; /** Map of store ID → sanitized state. */ state: Record>; } /** Result of `serializeStoreSnapshot()`. */ export interface SerializeSnapshotResult { snapshot: SSRStoreSnapshot; /** JSON-serialized snapshot. */ json: string; /** ``; return { snapshot, json, scriptTag }; }; /** Options for `hydrateStoreSnapshot()`. */ export interface HydrateSnapshotOptions { /** * If set, the snapshot's `version` must match this value. Otherwise the * function returns early (and warns when `strict` is true). */ expectedVersion?: string; /** * Strict mode: warn on version mismatch + warn on unknown store IDs (i.e. * the snapshot has IDs that aren't currently registered). Default: `false`. */ strict?: boolean; } /** Result of `hydrateStoreSnapshot()`. */ export interface HydrateSnapshotResult { /** Whether the snapshot was applied. */ applied: boolean; /** Reason when not applied (`'version-mismatch' | 'invalid-shape'`). */ reason?: 'version-mismatch' | 'invalid-shape'; /** IDs that were applied. */ appliedIds: string[]; /** IDs in the snapshot that no store exists for. */ unknownIds: string[]; } const isStoreSnapshot = (value: unknown): value is SSRStoreSnapshot => { if (!isStateObject(value)) return false; const v = (value as { version: unknown }).version; const s = (value as { state: unknown }).state; return typeof v === 'string' && isStateObject(s); }; /** * Applies a previously-serialized `SSRStoreSnapshot` to the registered stores. * * Returns a structured result; never throws on drift unless an explicit error * is thrown by a store's `$patch()` implementation. */ export const hydrateStoreSnapshot = ( snapshot: unknown, options: HydrateSnapshotOptions = {} ): HydrateSnapshotResult => { if (!isStoreSnapshot(snapshot)) { if (options.strict) { console.warn('[bQuery SSR] hydrateStoreSnapshot: snapshot has invalid shape.'); } return { applied: false, reason: 'invalid-shape', appliedIds: [], unknownIds: [] }; } if (typeof options.expectedVersion === 'string' && options.expectedVersion !== snapshot.version) { if (options.strict) { console.warn( `[bQuery SSR] hydrateStoreSnapshot: version mismatch — server="${snapshot.version}" client="${options.expectedVersion}". Skipping.` ); } return { applied: false, reason: 'version-mismatch', appliedIds: [], unknownIds: [] }; } const appliedIds: string[] = []; const unknownIds: string[] = []; for (const [id, state] of Object.entries(snapshot.state)) { if (isPrototypePollutionKey(id) || !isStateObject(state)) continue; const store = getStore<{ $patch?: (partial: Record) => void }>(id); if (!store || typeof store.$patch !== 'function') { unknownIds.push(id); if (options.strict) { console.warn( `[bQuery SSR] hydrateStoreSnapshot: store "${id}" is not registered; skipping.` ); } continue; } store.$patch(sanitize(state)); appliedIds.push(id); } return { applied: appliedIds.length > 0, appliedIds, unknownIds }; }; /** * Reads the snapshot emitted by `serializeStoreSnapshot()` from `window`, * cleans up the global, and returns the parsed `SSRStoreSnapshot`. * * Returns `null` when no snapshot was found or when it has the wrong shape. */ export const readStoreSnapshot = ( globalKey = '__BQUERY_STORE_SNAPSHOT__', scriptId = '__BQUERY_STORE_SNAPSHOT__' ): SSRStoreSnapshot | null => { if (isPrototypePollutionKey(globalKey) || isPrototypePollutionKey(scriptId)) return null; if (typeof window === 'undefined') return null; const raw = (window as unknown as Record)[globalKey]; try { delete (window as unknown as Record)[globalKey]; } catch { (window as unknown as Record)[globalKey] = undefined; } if (typeof document !== 'undefined' && typeof document.getElementById === 'function') { const el = document.getElementById(scriptId); if (el) el.remove(); } if (!isStoreSnapshot(raw)) return null; return raw; };