diff --git a/package-lock.json b/package-lock.json index 77c4a94..9ee731a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lo_event", - "version": "0.0.5", + "version": "0.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lo_event", - "version": "0.0.5", + "version": "0.0.7", "license": "SEE LICENSE IN LICENSE.TXT", "dependencies": { "lodash": "^4.17.21", diff --git a/package.json b/package.json index a42490a..bdf09f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lo_event", - "version": "0.0.5", + "version": "0.0.7", "description": "Event logging library for the Learning Observer", "main": "dist/loEvent.js", "types": "dist/loEvent.d.ts", @@ -61,6 +61,18 @@ "./types": { "types": "./dist/types.d.ts", "import": "./dist/types.js" + }, + "./hooks": { + "types": "./dist/hooks.d.ts", + "import": "./dist/hooks.js" + } + }, + "peerDependencies": { + "react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true } }, "scripts": { diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..7b4a5ee --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,48 @@ +/** + * React hooks for lo_event persistence status. + * + * These use useSyncExternalStore against a plain module-level store + * in reduxLogger (NOT Redux state — see reduxLogger.ts for rationale). + * + * Import from 'lo_event/hooks' — this entry point depends on React. + */ +import { useSyncExternalStore } from 'react'; +import { + subscribeStatus, + getSaveStatus, + getConnected, + getLoaded, +} from './reduxLogger.js'; + +export type { SaveStatus } from './reduxLogger.js'; + +/** + * Whether the current state has been persisted. + * + * 'saved' — all changes have been sent to the server / localStorage + * 'modified' — changes exist, debounce timer running + */ +export function useSaved() { + return useSyncExternalStore(subscribeStatus, getSaveStatus, () => 'saved' as const); +} + +/** + * WebSocket connection status. + * + * true — connected + * false — disconnected (show offline indicator) + * null — no WebSocket configured (don't show indicator) + */ +export function useConnected() { + return useSyncExternalStore(subscribeStatus, getConnected, () => null); +} + +/** + * Whether initialization is complete (fetch_blob has resolved or + * no persistence is configured). + * + * Use this to gate the UI — show a loading screen until true. + */ +export function useLoaded() { + return useSyncExternalStore(subscribeStatus, getLoaded, () => false); +} diff --git a/src/reduxLogger.ts b/src/reduxLogger.ts index 780ebf6..bdb7da7 100644 --- a/src/reduxLogger.ts +++ b/src/reduxLogger.ts @@ -32,17 +32,94 @@ declare global { } } +// ============================================================================= +// Types +// ============================================================================= + interface ReduxAction extends JSONObject { redux_type: string; type: string; payload: JSONValue; } +export type SaveStatus = 'saved' | 'modified' | 'error'; + +/** + * Options for the Redux logger's persistence behavior. + * + * serializeForSave: Called before every save (server and localStorage). + * Receives the full Redux state, returns the subset to persist. + * Default: identity (persist everything). + * + * deserializeOnLoad: Called when a fetch_blob response arrives. + * Receives the raw blob from the server and the current Redux state. + * Returns the blob to merge (via shallow spread) into current state. + * Default: identity (merge entire blob). + */ +export interface ReduxLoggerOptions { + serializeForSave?: (state: JSONObject) => JSONObject; + deserializeOnLoad?: (blob: JSONObject, currentState: JSONObject) => JSONObject; + /** + * Cross-tab state sync via redux-state-sync. Default: false (off). + * + * - false (default): nothing is broadcast. + * - true: broadcast every action to other store instances in the same + * browser — EXCEPT lo_event's own lifecycle actions (see below). + * - { predicate }: broadcast only actions `predicate(action)` approves + * (still minus the lifecycle actions). Lets the app drop events that must + * not cross tabs — e.g. content-load events, which are per-tab. + * + * Regardless of true/predicate, lo_event NEVER broadcasts its own lifecycle + * actions (SET_STATE — a full-state replace from blob restore — and + * LOCKFIELDS), since those would clobber or duplicate state across tabs. + * + * Off by default because, unfiltered, lifecycle/content actions (and + * non-idempotent reactive effects) echo between tabs and corrupt state. + */ + stateSync?: boolean | { predicate?: (action: ReduxAction) => boolean }; +} + +// ============================================================================= +// Module state +// ============================================================================= + const EMIT_EVENT = 'EMIT_EVENT'; const EMIT_LOCKFIELDS = 'EMIT_LOCKFIELDS'; const EMIT_SET_STATE = 'SET_STATE'; let IS_LOADED = false; +let _options: ReduxLoggerOptions = {}; + +// Cross-tab state sync gating (see ReduxLoggerOptions.stateSync). +// The middleware's predicate reads _stateSyncEnabled at dispatch time, so +// toggling this after the store is created takes effect immediately. The +// incoming-message listener is attached lazily and only when enabled, so a +// disabled store neither sends nor receives. Default off (opt-in). +let _stateSyncEnabled = false; +let _stateSyncListenerAttached = false; +// Optional app-supplied filter for which actions to broadcast (see +// ReduxLoggerOptions.stateSync). null = broadcast all (minus lifecycle). +let _stateSyncPredicate: ((action: ReduxAction) => boolean) | null = null; + +// Whether to broadcast a given action to other tabs. lo_event NEVER broadcasts +// its own lifecycle actions: SET_STATE (full-state replace from blob restore) +// and LOCKFIELDS would clobber/duplicate state across tabs. The app predicate +// filters the rest (e.g. dropping per-tab content-load events). +function shouldBroadcast (action: ReduxAction): boolean { + if (!_stateSyncEnabled) return false; + if (action.redux_type === EMIT_SET_STATE || action.redux_type === EMIT_LOCKFIELDS) return false; + return _stateSyncPredicate ? _stateSyncPredicate(action) : true; +} + +function ensureStateSyncListener () { + // Browser-only: initMessageListener uses the BroadcastChannel, which is + // not available (and not meaningful) server-side. + if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') return; + if (_stateSyncEnabled && !_stateSyncListenerAttached) { + initMessageListener(store); + _stateSyncListenerAttached = true; + } +} // TODO: Import debugLog and use those functions. const DEBUG = false; @@ -53,35 +130,102 @@ function debug_log (...args: unknown[]) { } } +// ============================================================================= +// Persistence status — plain external store (NOT in Redux) +// +// These are metadata about the save machinery, not application state. +// Keeping them outside Redux avoids cross-tab dispatch loops via +// redux-state-sync and keeps the store subscription read-only. +// +// React consumers use useSyncExternalStore via the hooks in hooks.ts. +// ============================================================================= + +let _saveStatus: SaveStatus = 'saved'; +let _connected: boolean | null = null; // null = no websocket configured + +// Monotonic token: incremented on each save_blob dispatch, compared against +// the token echoed back in save_blob_ack. Status is 'saved' only when the +// server has confirmed the most recent save. +let _saveToken = 0; +let _ackedToken = 0; + +const _statusListeners = new Set<() => void>(); + +function notifyStatusListeners () { + _statusListeners.forEach(fn => fn()); +} + +function markModified () { + if (_saveStatus !== 'modified') { + _saveStatus = 'modified'; + notifyStatusListeners(); + } +} + +function markSaved () { + if (_saveStatus !== 'saved') { + _saveStatus = 'saved'; + notifyStatusListeners(); + } +} + +// The server reported a save failure. Distinct from 'modified' so the UI can +// tell "still saving" from "save failed". A subsequent change re-enters +// 'modified' (markModified), and the next successful ack clears it to 'saved'. +function markError () { + if (_saveStatus !== 'error') { + _saveStatus = 'error'; + notifyStatusListeners(); + } +} + +function setConnected (value: boolean) { + if (_connected !== value) { + _connected = value; + notifyStatusListeners(); + } +} + +/** Subscribe to persistence status changes (save status, connected, loaded). */ +export function subscribeStatus (listener: () => void): () => void { + _statusListeners.add(listener); + return () => { _statusListeners.delete(listener); }; +} + +/** Snapshot of save status for useSyncExternalStore. */ +export function getSaveStatus (): SaveStatus { return _saveStatus; } + +/** Snapshot of connection status. null = no websocket, true/false = connected/disconnected. */ +export function getConnected (): boolean | null { return _connected; } + +/** Snapshot of loaded status (fetch_blob resolved or no persistence). */ +export function getLoaded (): boolean { return IS_LOADED; } + +// ============================================================================= +// Load / Save +// ============================================================================= + /** * Update the redux logger's state with `data`. - * This is fired when consuming a custom `fetch_blob` - * event. + * This is fired when consuming a custom `fetch_blob` event. */ export function handleLoadState (data: unknown) { IS_LOADED = true; const state = store.getState() as JSONObject; if (data) { - setState( - { - ...state, - ...data, - settings: { - ...(state.settings as JSONObject), - reduxStoreStatus: IS_LOADED - } - }); + const blob = _options.deserializeOnLoad + ? _options.deserializeOnLoad(data as JSONObject, state) + : data as JSONObject; + setState({ ...state, ...blob }); } else { debug_log('No data provided while handling state from server, continuing.'); - setState( - { - ...state, - settings: { - ...(state.settings as JSONObject), - reduxStoreStatus: IS_LOADED - } - }); } + // After loading, state matches the server — reset tokens and mark saved. + // markSaved() AFTER setState so the subscription's markModified() fires + // first (synchronously from the dispatch), then we correct it here. + _ackedToken = _saveToken; + markSaved(); + notifyStatusListeners(); // loaded changed } async function saveStateToLocalStorage (state: JSONObject) { @@ -92,7 +236,8 @@ async function saveStateToLocalStorage (state: JSONObject) { try { const KEY = (state?.settings as JSONObject)?.reduxID as string || 'redux'; - const serializedState = JSON.stringify(state); + const toSave = _options.serializeForSave ? _options.serializeForSave(state) : state; + const serializedState = JSON.stringify(toSave); localStorage.setItem(KEY, serializedState); } catch (e) { // Ignore @@ -100,8 +245,7 @@ async function saveStateToLocalStorage (state: JSONObject) { } /** - * Dispatch a `save_blob` event on the redux - * logger. + * Dispatch a `save_blob` event on the redux logger. */ async function saveStateToServer (state: JSONObject) { if (!IS_LOADED) { @@ -110,15 +254,28 @@ async function saveStateToServer (state: JSONObject) { } try { - // console.log("dispatching save_blob") - util.dispatchCustomEvent('save_blob', { detail: state }); - // store.dispatch('save_blob', { detail: state }); + const toSave = _options.serializeForSave ? _options.serializeForSave(state) : state; + _saveToken++; + util.dispatchCustomEvent('save_blob', { detail: { blob: toSave, token: _saveToken } }); + // Don't markSaved() here — wait for save_blob_ack from the server. } catch (e) { - // Ignore debug_log('Error in dispatch', { e }); } } +/** + * Immediately flush any pending debounced saves. + * Available for programmatic use (e.g. beforeunload handlers). + */ +export function saveNow () { + debouncedSaveStateToLocalStorage.flush(); + debouncedSaveStateToServer.flush(); +} + +// ============================================================================= +// Action creators +// ============================================================================= + // Action creator function This is a little bit messy, since we // duplicate type from the payload. It's not clear if this is a good // idea. We used to have `type` be set to the current contents of @@ -150,6 +307,10 @@ const emitSetState = (state: JSONObject): ReduxAction => { }; }; +// ============================================================================= +// Reducers +// ============================================================================= + function store_last_event_reducer (state: JSONObject = {}, action: JSONObject): JSONObject { const a = action as ReduxAction; return { ...state, event: a.payload }; @@ -271,6 +432,10 @@ const reducer = (state: JSONObject = {}, action: ReduxAction): JSONObject => { return state; }; +// ============================================================================= +// Store +// ============================================================================= + const eventQueue: unknown[] = []; const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || redux.compose; @@ -281,13 +446,31 @@ const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOO // back to thunk. // const presistedState = loadState(); +// Cross-tab sync is a browser concept and createStateSyncMiddleware() +// constructs a BroadcastChannel eagerly. In Node (server-side, SSR) that +// hits broadcast-channel's filesystem fallback and throws, so we only add +// the middleware in a browser. Note: a config object MUST include `channel` +// — redux-state-sync replaces its whole defaultConfig with the passed config, +// so omitting it yields `new BroadcastChannel(undefined)` and crashes. +const _baseMiddleware: redux.Middleware[] = [((thunk as any).default || thunk) as redux.Middleware]; +if (typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined') { + // predicate gates outgoing broadcasts; the incoming listener is attached + // separately in ensureStateSyncListener(). Both respect _stateSyncEnabled. + _baseMiddleware.push(createStateSyncMiddleware({ + channel: 'redux_state_sync', + predicate: (action: any) => shouldBroadcast(action as ReduxAction), + }) as redux.Middleware); +} + export let store: redux.Store> = redux.createStore( reducer as unknown as redux.Reducer>, { event: null } as unknown as JSONObject, // Base state - composeEnhancers(redux.applyMiddleware(((thunk as any).default || thunk) as redux.Middleware, createStateSyncMiddleware() as redux.Middleware)) + composeEnhancers(redux.applyMiddleware(..._baseMiddleware)) ); -initMessageListener(store); +// initMessageListener is attached lazily by reduxLogger() when stateSync is +// enabled — see ensureStateSyncListener(). Attaching it unconditionally here +// would make a disabled store still receive (and respond to) broadcasts. let promise: (Promise & { resolve?: (value: unknown) => void }) | null = null; let previousEventString: string | null = null; @@ -316,13 +499,8 @@ function composeReducers(...reducers: ReducerFn[]): ReducerFn { export function setState(state: JSONObject) { debug_log('Set state called'); if (Object.keys(state).length === 0) { - const storeState = store.getState() as JSONObject; - state = { - settings: { - ...(storeState.settings as JSONObject), - reduxStoreStatus: IS_LOADED - } - }; + debug_log('setState called with empty object — ignoring'); + return; } store.dispatch(emitSetState(state) as unknown as redux.Action); } @@ -335,11 +513,18 @@ const debouncedSaveStateToServer = debounce((state: JSONObject) => { saveStateToServer(state); }, 1000); +// ============================================================================= +// Store initialization & subscription +// ============================================================================= + function initializeStore () { + // The subscription is read-only — it never dispatches to the store. + // Save status lives in a plain module-level variable (see above), + // avoiding cross-tab loops via redux-state-sync. store.subscribe(() => { const state = store.getState() as JSONObject; - // we use debounce to save the state once every second - // for better performances in case multiple changes occur in a short time + + markModified(); debouncedSaveStateToLocalStorage(state); debouncedSaveStateToServer(state); @@ -364,16 +549,41 @@ function initializeStore () { // to have this behind a flag later. eventQueue.push(event); } - for (const i in eventSubscribers) { - eventSubscribers[i](event); + for (const subscriber of eventSubscribers) { + subscriber(event); } }); + + // Flush any pending saves when the page is about to close. + // The IndexedDB-backed queue in websocketLogger survives page close, + // so even if the browser terminates before the WebSocket send completes, + // the blob is persisted locally and transmitted on the next page load. + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + saveNow(); + }); + } } -export function reduxLogger (subscribers?: Array<(event: unknown) => void>, initialState: JSONObject | null = null): Logger { +// ============================================================================= +// Logger factory +// ============================================================================= + +export function reduxLogger (subscribers?: Array<(event: unknown) => void>, options: ReduxLoggerOptions = {}): Logger { if (subscribers != null) { eventSubscribers = subscribers; } + _options = options; + + // Opt-in (default false). `true` or a predicate enables it; a predicate also + // filters which actions broadcast (see shouldBroadcast). When enabled, attach + // the incoming listener (once); when disabled, the predicate stops all + // outgoing broadcasts and we never attach the listener, so nothing is received. + _stateSyncEnabled = options.stateSync != null && options.stateSync !== false; + _stateSyncPredicate = (typeof options.stateSync === 'object' && options.stateSync !== null) + ? (options.stateSync.predicate ?? null) + : null; + ensureStateSyncListener(); const logEvent: Logger = function (event: string) { store.dispatch(emitEvent(event) as unknown as redux.Action); @@ -391,10 +601,6 @@ export function reduxLogger (subscribers?: Array<(event: unknown) => void>, init logEvent.getLockFields = function () { return lockFields; }; - // do we want to initialize the store here? We set it to the stored state in create store - // if (initialState) { - // } - return logEvent; } @@ -446,6 +652,35 @@ export function handleAuth (user: unknown) { })) as unknown as redux.Action); } -// Start listening for fetch +// ============================================================================= +// CustomEvent listeners +// ============================================================================= + util.consumeCustomEvent('fetch_blob', handleLoadState); util.consumeCustomEvent('auth', handleAuth); + +// Connection status from websocketLogger +util.consumeCustomEvent('lo_connection_status', (data: unknown) => { + const { connected } = data as { connected: boolean }; + setConnected(connected); +}); + +// Server acknowledgment of a save_blob write. +// Only mark saved if this ack is for the most recent save — stale acks +// (from earlier saves) are ignored because a newer save is still pending. +util.consumeCustomEvent('save_blob_ack', (data: unknown) => { + const { token } = data as { token: number }; + if (token > _ackedToken) { + _ackedToken = token; + } + if (_ackedToken >= _saveToken) { + markSaved(); + } +}); + +// Server reported a save_blob write failure. Don't advance _ackedToken — the +// blob did not persist — and surface the failure so the UI isn't stuck looking +// like a save is merely in progress. +util.consumeCustomEvent('save_blob_nack', () => { + markError(); +}); diff --git a/src/websocketLogger.ts b/src/websocketLogger.ts index 617a382..503e5d4 100644 --- a/src/websocketLogger.ts +++ b/src/websocketLogger.ts @@ -81,8 +81,10 @@ export function websocketLogger (server: string | WsHostOverrides = {}): Logger } else { READY = true; failures = 0; + util.dispatchCustomEvent('lo_connection_status', { detail: { connected: true } }); await socketClosed(); READY = false; + util.dispatchCustomEvent('lo_connection_status', { detail: { connected: false } }); } } } @@ -164,6 +166,12 @@ export function websocketLogger (server: string | WsHostOverrides = {}): Logger case 'fetch_blob': util.dispatchCustomEvent('fetch_blob', { detail: response.data }); break; + case 'save_blob_ack': + util.dispatchCustomEvent('save_blob_ack', { detail: { token: response.token } }); + break; + case 'save_blob_nack': + util.dispatchCustomEvent('save_blob_nack', { detail: { token: response.token } }); + break; default: debug.info(`Received response we do not yet handle: ${JSON.stringify(response)}`); break; @@ -218,8 +226,9 @@ export function websocketLogger (server: string | WsHostOverrides = {}): Logger queue.enqueue(data); }; - function handleSaveBlob (blob: unknown) { - queue.enqueue(JSON.stringify({ event: 'save_blob', blob })); + function handleSaveBlob (data: unknown) { + const { blob, token } = data as { blob: unknown; token: number }; + queue.enqueue(JSON.stringify({ event: 'save_blob', blob, token })); } util.consumeCustomEvent('save_blob', handleSaveBlob); diff --git a/tsup.config.ts b/tsup.config.ts index 31e2c4e..eafac2f 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ memoryQueue: 'src/memoryQueue.ts', indexeddbQueue: 'src/indexeddbQueue.ts', types: 'src/types.ts', + hooks: 'src/hooks.ts', 'metadata/browserinfo': 'src/metadata/browserinfo.ts', 'metadata/chromeauth': 'src/metadata/chromeauth.ts', 'metadata/storage': 'src/metadata/storage.ts', @@ -24,7 +25,14 @@ export default defineConfig({ dts: true, sourcemap: true, clean: true, - splitting: false, + // splitting MUST stay true: several entry points (e.g. hooks.ts) re-export + // stateful module-level singletons from reduxLogger.ts (save status, the + // status-listener set, the redux store). With splitting:false, tsup inlines + // a SEPARATE copy of reduxLogger into each entry, so e.g. useSaved() in + // hooks.js reads a different _saveStatus than the store subscription in + // reduxLogger.js updates — the indicator gets stuck. Sharing a chunk keeps + // those singletons singular across entry points. + splitting: true, target: 'es2022', - external: ['ws', 'redux', 'redux-thunk', 'redux-state-sync', 'lodash'], + external: ['ws', 'redux', 'redux-thunk', 'redux-state-sync', 'lodash', 'react'], });