UNPKG

b0nes

Version:

Zero-dependency component library and SSR/SSG framework

466 lines (395 loc) 13.5 kB
/** * b0nes Store - Reactive State Management * Functional, zero-dependency state store with computed values and middleware * * @example * const store = createStore({ * state: { count: 0 }, * actions: { * increment: (state) => ({ count: state.count + 1 }), * decrement: (state) => ({ count: state.count - 1 }) * } * }); */ /** * Creates a reactive state store * @param {Object} config - Store configuration * @param {Object} config.state - Initial state * @param {Object} [config.actions={}] - Action functions * @param {Object} [config.getters={}] - Computed getters * @param {Array} [config.middleware=[]] - Middleware functions * @returns {Object} Store instance */ export const createStore = ({ state: initialState, actions = {}, getters = {}, middleware = [] }) => { // Validate initial state if (!initialState || typeof initialState !== 'object') { throw new Error('[Store] Initial state must be an object'); } // Private state (closure) let state = deepFreeze({ ...initialState }); const subscribers = new Set(); const history = []; const maxHistory = 50; // Cache for computed getters const getterCache = new Map(); let previousState = null; /** * Deep freeze objects to prevent mutations * @param {*} obj - Object to freeze * @returns {*} Frozen object */ function deepFreeze(obj) { if (obj === null || typeof obj !== 'object') return obj; Object.freeze(obj); Object.getOwnPropertyNames(obj).forEach(prop => { if (obj[prop] !== null && typeof obj[prop] === 'object' && !Object.isFrozen(obj[prop])) { deepFreeze(obj[prop]); } }); return obj; } /** * Get current state (immutable) * @returns {Object} Current state */ const getState = () => state; /** * Get state at a specific path * @param {string} path - Dot-notation path (e.g., 'user.profile.name') * @returns {*} Value at path */ const get = (path) => { if (!path) return state; const keys = path.split('.'); let value = state; for (const key of keys) { if (value === null || value === undefined) return undefined; value = value[key]; } return value; }; /** * Subscribe to state changes * @param {Function} listener - Callback function * @param {Object} [options] - Subscription options * @param {string} [options.path] - Only notify on changes to this path * @returns {Function} Unsubscribe function */ const subscribe = (listener, options = {}) => { if (typeof listener !== 'function') { throw new Error('[Store] Listener must be a function'); } const subscription = { listener, options }; subscribers.add(subscription); return () => subscribers.delete(subscription); }; /** * Notify subscribers of state change * @param {Object} change - Change details */ const notify = (change) => { subscribers.forEach(({ listener, options }) => { try { // Filter by path if specified if (options.path) { const oldValue = get.call({ state: previousState }, options.path); const newValue = get(options.path); if (oldValue === newValue) return; // No change to this path } listener(change); } catch (error) { console.error('[Store] Listener error:', error); } }); }; /** * Dispatch an action to update state * @param {string} actionName - Name of action to dispatch * @param {*} [payload] - Payload data for action * @returns {Object} Updated state */ const dispatch = (actionName, payload) => { const action = actions[actionName]; if (!action) { console.error(`[Store] Action "${actionName}" not found`); return state; } if (typeof action !== 'function') { console.error(`[Store] Action "${actionName}" must be a function`); return state; } // Run through middleware chain let middlewareChain = [...middleware]; const executeMiddleware = (index) => { if (index >= middlewareChain.length) { // All middleware executed, run the action return action(state, payload); } const mw = middlewareChain[index]; return mw({ getState, dispatch, action: actionName, payload }, () => executeMiddleware(index + 1)); }; try { // Execute middleware chain const updates = executeMiddleware(0); if (!updates || typeof updates !== 'object') { console.warn(`[Store] Action "${actionName}" must return an object`); return state; } // Store previous state for comparison previousState = state; // Merge updates with state const newState = deepFreeze({ ...state, ...updates }); // Record change const change = { action: actionName, payload, previousState, state: newState, timestamp: Date.now() }; // Update state state = newState; // Clear getter cache getterCache.clear(); // Add to history history.push(change); if (history.length > maxHistory) { history.shift(); } // Notify subscribers notify(change); return state; } catch (error) { console.error(`[Store] Error in action "${actionName}":`, error); return state; } }; /** * Get a computed value * @param {string} getterName - Name of getter * @returns {*} Computed value */ const computed = (getterName) => { const getter = getters[getterName]; if (!getter) { console.error(`[Store] Getter "${getterName}" not found`); return undefined; } // Check cache if (getterCache.has(getterName)) { return getterCache.get(getterName); } try { const value = getter(state); getterCache.set(getterName, value); return value; } catch (error) { console.error(`[Store] Error in getter "${getterName}":`, error); return undefined; } }; /** * Reset store to initial state */ const reset = () => { previousState = state; state = deepFreeze({ ...initialState }); getterCache.clear(); history.length = 0; notify({ action: 'RESET', previousState, state, timestamp: Date.now() }); }; /** * Get change history * @returns {Array} State change history */ const getHistory = () => [...history]; /** * Time travel to a previous state * @param {number} index - History index (0 = oldest) */ const timeTravel = (index) => { if (index < 0 || index >= history.length) { console.error(`[Store] Invalid history index: ${index}`); return; } const change = history[index]; previousState = state; state = deepFreeze({ ...change.state }); getterCache.clear(); notify({ action: 'TIME_TRAVEL', previousState, state, timestamp: Date.now() }); }; return { // State access getState, get, // Actions dispatch, // Computed values computed, // Subscriptions subscribe, // Utilities reset, getHistory, timeTravel }; }; /** * Create a store module (for organizing large stores) * @param {Object} module - Module configuration * @returns {Object} Module definition */ export const createModule = ({ state, actions, getters }) => { return { state, actions, getters }; }; /** * Combine multiple store modules * @param {Object} modules - Named modules * @returns {Object} Combined store configuration */ export const combineModules = (modules) => { const state = {}; const actions = {}; const getters = {}; Object.entries(modules).forEach(([name, module]) => { // Namespace state state[name] = module.state; // Namespace actions Object.entries(module.actions || {}).forEach(([actionName, actionFn]) => { actions[`${name}/${actionName}`] = (globalState, payload) => { const moduleState = globalState[name]; const updates = actionFn(moduleState, payload); return { [name]: { ...moduleState, ...updates } }; }; }); // Namespace getters Object.entries(module.getters || {}).forEach(([getterName, getterFn]) => { getters[`${name}/${getterName}`] = (globalState) => { return getterFn(globalState[name]); }; }); }); return { state, actions, getters }; }; /** * Create middleware function * @param {Function} fn - Middleware function * @returns {Function} Middleware */ export const createMiddleware = (fn) => fn; /** * Logger middleware - logs all state changes */ export const loggerMiddleware = ({ getState, action, payload }, next) => { console.group(`[Store] ${action}`); console.log('Payload:', payload); console.log('State before:', getState()); const result = next(); console.log('State after:', getState()); console.groupEnd(); return result; }; /** * Persistence middleware - saves state to localStorage * @param {string} key - localStorage key * @returns {Function} Middleware */ export const persistenceMiddleware = (key) => { return ({ getState }, next) => { const result = next(); try { localStorage.setItem(key, JSON.stringify(getState())); } catch (error) { console.error('[Store] Persistence error:', error); } return result; }; }; /** * Load persisted state from localStorage * @param {string} key - localStorage key * @returns {Object|null} Persisted state */ export const loadPersistedState = (key) => { try { const data = localStorage.getItem(key); return data ? JSON.parse(data) : null; } catch (error) { console.error('[Store] Load persisted state error:', error); return null; } }; /** * DevTools middleware - integrates with Redux DevTools */ export const devToolsMiddleware = ({ getState, action, payload }, next) => { const result = next(); // Send to Redux DevTools if available if (window.__REDUX_DEVTOOLS_EXTENSION__) { window.__REDUX_DEVTOOLS_EXTENSION__.send( { type: action, payload }, getState() ); } return result; }; /** * Async action helper * @param {Function} asyncFn - Async function * @returns {Function} Action that dispatches loading/success/error */ export const createAsyncAction = (asyncFn) => { return async (state, payload) => { try { const result = await asyncFn(state, payload); return result; } catch (error) { console.error('[Store] Async action error:', error); return { error: error.message }; } }; }; /** * Connect store to FSM for coordinated state management * @param {Object} store - Store instance * @param {Object} fsm - FSM instance * @returns {Function} Disconnect function */ export const connectStoreToFSM = (store, fsm) => { // Subscribe to FSM state changes and update store const unsubFSM = fsm.subscribe((transition) => { store.dispatch('fsm/setState', { currentState: transition.to, previousState: transition.from, event: transition.event }); }); // Subscribe to store changes that might trigger FSM events const unsubStore = store.subscribe((change) => { // Check if there's an FSM event to trigger const fsmEvent = change.state?.fsmEvent; if (fsmEvent && fsm.can(fsmEvent)) { fsm.send(fsmEvent); } }); // Return disconnect function return () => { unsubFSM(); unsubStore(); }; };