/** * Store state serialization for SSR. * * Provides utilities to serialize store state into a `' * ``` * * @example * ```ts * // Serialize only specific stores * const { scriptTag } = serializeStoreState({ storeIds: ['counter'] }); * ``` */ export const serializeStoreState = (options: SerializeOptions = {}): SerializeResult => { const { scriptId = '__BQUERY_STORE_STATE__', globalKey = '__BQUERY_INITIAL_STATE__', storeIds, serialize = JSON.stringify, } = options; if (isPrototypePollutionKey(globalKey)) { throw new Error( `serializeStoreState: invalid globalKey "${globalKey}" - prototype-pollution keys are not allowed.` ); } if (isPrototypePollutionKey(scriptId)) { throw new Error( `serializeStoreState: invalid scriptId "${scriptId}" - prototype-pollution keys are not allowed.` ); } const ids = storeIds ?? listStores(); const stateMap = Object.create(null) as Record>; for (const id of ids) { if (isPrototypePollutionKey(id)) { continue; } const store = getStore<{ $state: Record }>(id); if (store) { stateMap[id] = sanitizeHydrationState(store.$state); } } const stateJson = serialize(stateMap); if (typeof stateJson !== 'string') { throw new Error('serializeStoreState: custom serialize function must return a string.'); } if (serialize !== JSON.stringify) { let parsedStateJson: unknown; try { parsedStateJson = JSON.parse(stateJson); } catch { throw new Error('serializeStoreState: custom serialize function returned invalid JSON.'); } if (!isStoreStateObject(parsedStateJson)) { throw new Error( 'serializeStoreState: custom serialize function must return a JSON object string.' ); } } const escapedJson = escapeForScript(stateJson); const escapedGlobalKey = escapeForScript(JSON.stringify(globalKey)); const escapedScriptId = escapeForHtmlAttribute(scriptId); const scriptTag = ``; return { stateJson, scriptTag }; }; /** * Deserializes store state from the global variable set by the SSR script tag. * * Call this on the client before creating stores to pre-populate them with * server-rendered state. After deserialization, the script tag and global * variable are cleaned up automatically. * * @param globalKey - The global variable name where state was serialized * @param scriptId - The ID of the SSR script tag to remove after hydration * @returns The deserialized state map, or an empty object if not found * * @example * ```ts * import { deserializeStoreState } from '@bquery/bquery/ssr'; * * // Call before creating stores * const state = deserializeStoreState(); * // state = { counter: { count: 42 } } * ``` */ export const deserializeStoreState = ( globalKey = '__BQUERY_INITIAL_STATE__', scriptId = '__BQUERY_STORE_STATE__' ): DeserializedStoreState => { if (isPrototypePollutionKey(globalKey)) { throw new Error( `deserializeStoreState: invalid globalKey "${globalKey}" - prototype-pollution keys are not allowed.` ); } if (isPrototypePollutionKey(scriptId)) { throw new Error( `deserializeStoreState: invalid scriptId "${scriptId}" - prototype-pollution keys are not allowed.` ); } if (typeof window === 'undefined') { return {}; } const state = (window as unknown as Record)[globalKey]; if (!state) { return {}; } // Clean up global variable try { delete (window as unknown as Record)[globalKey]; } catch { // In strict mode on some environments, delete may fail (window as unknown as Record)[globalKey] = undefined; } // Clean up script tag if (typeof document !== 'undefined' && typeof document.getElementById === 'function') { const scriptEl = document.getElementById(scriptId); if (scriptEl) { scriptEl.remove(); } } if (!isStoreStateObject(state)) { return {}; } for (const value of Object.values(state)) { if (!isStoreStateObject(value)) { return {}; } } const sanitizedStateMap = Object.create(null) as DeserializedStoreState; for (const [storeId, storeState] of Object.entries(state)) { if (isPrototypePollutionKey(storeId) || !isStoreStateObject(storeState)) { continue; } sanitizedStateMap[storeId] = sanitizeHydrationState(storeState); } return sanitizedStateMap; }; /** * Hydrates a store with pre-serialized state from SSR. * * If the store exists and has a `$patch` method, this applies the * deserialized state as a patch. Otherwise, the state is ignored. * * @param storeId - The store ID to hydrate * @param state - The plain state object to apply * * @example * ```ts * import { hydrateStore, deserializeStoreState } from '@bquery/bquery/ssr'; * import { createStore } from '@bquery/bquery/store'; * * // 1. Deserialize state from SSR script tag * const ssrState = deserializeStoreState(); * * // 2. Create store (gets initial values from factory) * const store = createStore({ * id: 'counter', * state: () => ({ count: 0 }), * }); * * // 3. Apply SSR state * if (ssrState.counter) { * hydrateStore('counter', ssrState.counter); * } * // store.count is now 42 (from SSR) * ``` */ export const hydrateStore = (storeId: string, state: Record): void => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const store = getStore<{ $patch?: (partial: any) => void }>(storeId); if (store && typeof store.$patch === 'function') { store.$patch(sanitizeHydrationState(state)); } }; /** * Hydrates all stores at once from a deserialized state map. * * Convenience wrapper that calls `hydrateStore` for each entry in the state map. * * @param stateMap - Map of store IDs to their state objects * * @example * ```ts * import { hydrateStores, deserializeStoreState } from '@bquery/bquery/ssr'; * * const ssrState = deserializeStoreState(); * hydrateStores(ssrState); * ``` */ export const hydrateStores = (stateMap: DeserializedStoreState): void => { for (const [storeId, state] of Object.entries(stateMap)) { hydrateStore(storeId, state); } };