UNPKG

b0nes

Version:

Zero-dependency component library and SSR/SSG framework

576 lines (512 loc) 18.2 kB
// // FSM = Finite State Machine // // Lets figure this out with a demo for a multi step form // (function fsm() { // // State // const b0nesFSMNode = document.querySelector('[data-bones-fsm]'); // const states = { // "START": { // template: "<h1>FSM Demo</h1><button id='actionBtn' onclick='trigger('NEXT')'>Perform Action</button>", // url: "/demo/fsm/start", // on: { // "RESET": { // state: "START", // url: "/demo/fsm/start" // }, // "NEXT": { // state: "STEP2", // url: '/demo/fsm/step2' // }, // } // }, // "STEP2": { // template: "<h1>Step 2</h1>\ // <button id='actionBtn1' onclick=`trigger('BACK')`>Perform Action1</button>\ // <button id='actionBtn2' onclick=`trigger('SUCCESS')`>Perform Action2</button>", // url: "/demo/fsm/step2", // on: { // "BACK": { // state: "START", // url: "/demo/fsm/start", // }, // "SEND": { // state: "SUCCESS", // url: '/demo/fsm/success' // } // } // }, // "SUCCESS": { // template: "<h1>Step 2</h1><button id='actionBtn'>Perform Action</button>", // url: "/demo/fsm/success", // } // } // let currentState = null; // const setup = () => { // b0nesFSMNode.innerHTML = currentState.template; // currentState = states["START"] // } // const trigger = (action) => { // const newState = currentState.on[action].state; // const transitionTo = newState.url; // currentState = states[newState]; // window.history.pushState(null, "", transitionTo); // //render(); // } // })(); /** * b0nes FSM (Finite State Machine) * Functional approach using closures for state management * Perfect for SPAs, UI flows, and complex state transitions * * @example * const authFSM = createFSM({ * initial: 'logged-out', * states: { * 'logged-out': { * on: { LOGIN: 'logging-in' } * }, * 'logging-in': { * on: { SUCCESS: 'logged-in', FAILURE: 'logged-out' } * }, * 'logged-in': { * on: { LOGOUT: 'logged-out' } * } * } * }); */ /** * Creates a finite state machine * @param {Object} config - FSM configuration * @param {string} config.initial - Initial state name * @param {Object} config.states - State definitions * @param {Object} [config.context={}] - Initial context data * @returns {Object} FSM instance with methods */ export const createFSM = ({ initial, states, context = {} }) => { // Validate configuration if (!initial || !states || !states[initial]) { throw new Error('[FSM] Invalid configuration: initial state must exist in states'); } // Private state (closure) let currentState = initial; let currentContext = { ...context }; const listeners = new Set(); const history = []; const maxHistory = 50; // Prevent memory leaks /** * Get current state * @returns {string} Current state name */ const getState = () => currentState; /** * Get current context * @returns {Object} Current context data */ const getContext = () => ({ ...currentContext }); /** * Get state history * @returns {Array} State transition history */ const getHistory = () => [...history]; /** * Check if in a specific state * @param {string} stateName - State to check * @returns {boolean} */ const is = (stateName) => currentState === stateName; /** * Check if transition is possible * @param {string} event - Event name * @returns {boolean} */ const can = (event) => { const stateConfig = states[currentState]; return stateConfig?.on?.[event] !== undefined; }; /** * Subscribe to state changes * @param {Function} listener - Callback function * @returns {Function} Unsubscribe function */ const subscribe = (listener) => { if (typeof listener !== 'function') { throw new Error('[FSM] Listener must be a function'); } listeners.add(listener); return () => listeners.delete(listener); }; /** * Notify all listeners * @param {Object} transition - Transition details */ const notify = (transition) => { listeners.forEach(listener => { try { listener(transition); } catch (error) { console.error('[FSM] Listener error:', error); } }); }; /** * Execute state actions * @param {Object} actions - Actions to execute * @param {Object} eventData - Event data */ const executeActions = (actions, eventData) => { if (!actions) return; // onEntry action if (actions.onEntry && typeof actions.onEntry === 'function') { try { const result = actions.onEntry(currentContext, eventData); if (result !== undefined) { currentContext = { ...currentContext, ...result }; } } catch (error) { console.error('[FSM] onEntry error:', error); } } }; /** * Send an event to transition state * @param {string} event - Event name * @param {*} [data] - Optional event data * @returns {Object} Transition result */ const send = (event, data) => { const stateConfig = states[currentState]; const nextState = stateConfig?.on?.[event]; // Check if transition is valid if (!nextState) { console.warn(`[FSM] No transition for event "${event}" in state "${currentState}"`); return { success: false, from: currentState, to: currentState, event, data }; } // Handle conditional transitions (guards) let targetState = nextState; if (typeof nextState === 'function') { targetState = nextState(currentContext, data); if (!states[targetState]) { console.error(`[FSM] Invalid target state: "${targetState}"`); return { success: false, from: currentState, to: currentState, event, data }; } } // Execute onExit action if (stateConfig.actions?.onExit) { try { const result = stateConfig.actions.onExit(currentContext, data); if (result !== undefined) { currentContext = { ...currentContext, ...result }; } } catch (error) { console.error('[FSM] onExit error:', error); } } // Record transition const transition = { success: true, from: currentState, to: targetState, event, data, timestamp: Date.now() }; // Update state const previousState = currentState; currentState = targetState; // Execute onEntry action const nextStateConfig = states[targetState]; executeActions(nextStateConfig.actions, data); // Add to history history.push(transition); if (history.length > maxHistory) { history.shift(); // Remove oldest } // Notify listeners notify(transition); return transition; }; /** * Reset to initial state * @param {Object} [newContext] - Optional new context */ const reset = (newContext) => { const previousState = currentState; currentState = initial; currentContext = newContext ? { ...newContext } : { ...context }; history.length = 0; notify({ success: true, from: previousState, to: initial, event: 'RESET', timestamp: Date.now() }); }; /** * Update context without changing state * @param {Object|Function} updater - New context or updater function */ const updateContext = (updater) => { if (typeof updater === 'function') { currentContext = { ...currentContext, ...updater(currentContext) }; } else { currentContext = { ...currentContext, ...updater }; } }; /** * Get all possible transitions from current state * @returns {Array<string>} Available events */ const getAvailableEvents = () => { const stateConfig = states[currentState]; return Object.keys(stateConfig?.on || {}); }; /** * Visualize FSM as mermaid diagram * @returns {string} Mermaid diagram syntax */ const toMermaid = () => { let diagram = 'stateDiagram-v2\n'; diagram += ` [*] --> ${initial}\n`; Object.entries(states).forEach(([stateName, stateConfig]) => { if (stateConfig.on) { Object.entries(stateConfig.on).forEach(([event, target]) => { const targetState = typeof target === 'function' ? 'conditional' : target; diagram += ` ${stateName} --> ${targetState}: ${event}\n`; }); } }); return diagram; }; // Return public API return { // State queries getState, getContext, getHistory, is, can, getAvailableEvents, // State transitions send, reset, updateContext, // Subscriptions subscribe, // Utilities toMermaid }; }; /** * Compose multiple FSMs * Useful for complex workflows with parallel states * @param {Object} machines - Named FSM instances * @returns {Object} Composed FSM */ export const composeFSM = (machines) => { const states = {}; const subscriptions = []; // Get all states const getAllStates = () => { const result = {}; Object.entries(machines).forEach(([name, fsm]) => { result[name] = fsm.getState(); }); return result; }; // Get all contexts const getAllContexts = () => { const result = {}; Object.entries(machines).forEach(([name, fsm]) => { result[name] = fsm.getContext(); }); return result; }; // Subscribe to all machines const subscribe = (listener) => { Object.entries(machines).forEach(([name, fsm]) => { const unsub = fsm.subscribe((transition) => { listener({ machine: name, ...transition }); }); subscriptions.push(unsub); }); return () => { subscriptions.forEach(unsub => unsub()); subscriptions.length = 0; }; }; // Send to specific machine const send = (machineName, event, data) => { const fsm = machines[machineName]; if (!fsm) { console.error(`[ComposedFSM] Machine "${machineName}" not found`); return { success: false }; } return fsm.send(event, data); }; // Broadcast to all machines const broadcast = (event, data) => { const results = {}; Object.entries(machines).forEach(([name, fsm]) => { if (fsm.can(event)) { results[name] = fsm.send(event, data); } }); return results; }; return { getAllStates, getAllContexts, subscribe, send, broadcast, machines // Direct access if needed }; }; /** * Create a router FSM for SPA navigation * @param {Array<Object>} routes - Route definitions. Each route object should have: * - `name`: (string) Unique name for the route/state. * - `url`: (string) The URL path for this route. * - `template`: (string) The HTML string to render for this route. * - `onEnter`: (Function, optional) Callback when entering this route. * - `onExit`: (Function, optional) Callback when exiting this route. * @returns {Object} An object containing the Router FSM instance and the original routes array. */ export const createRouterFSM = (routes) => { const states = {}; const routeUrlMap = new Map(routes.map(r => [r.url, r.name])); // Map URL to state name let initialRouteName = routes[0]?.name || 'home'; // Default fallback // Determine initial state based on current URL const currentPath = window.location.pathname; for (const route of routes) { // Simple exact match for now. Can be extended with urlPattern for dynamic routes. if (route.url === currentPath) { initialRouteName = route.name; break; } } routes.forEach(route => { states[route.name] = { on: {}, // Transitions will be added below actions: { onEntry: (context, data) => { if (route.onEnter) { return route.onEnter(context, data); } }, onExit: (context, data) => { if (route.onExit) { return route.onExit(context, data); } } } }; // Connect all routes bidirectionally with GOTO_ events routes.forEach(otherRoute => { if (route.name !== otherRoute.name) { states[route.name].on[`GOTO_${otherRoute.name.toUpperCase()}`] = otherRoute.name; } }); }); const routerFSM = createFSM({ initial: initialRouteName, states, context: { routes } // Store routes in context for potential use in onEntry/onExit }); return { fsm: routerFSM, routes: routes // Return the original routes array for the connector }; }; /** * Connects an FSM (typically a router FSM) to a DOM element for rendering and URL updates. * This acts as the "view" layer for the state machine. * @param {Object} fsm - The FSM instance from createFSM. * @param {HTMLElement} rootEl - The DOM element to render templates into. * @param {Array<Object>} routes - The original array of route definitions, containing `name`, `url`, and `template`. * @returns {Function} A cleanup function to unsubscribe and remove event listeners. */ export const connectFSMtoDOM = (fsm, rootEl, routes) => { if (!rootEl) { console.error('[FSM Connector] Root element not found.'); return () => {}; // Return a no-op cleanup function } // Create a quick lookup map for route details const routeMap = new Map(routes.map(r => [r.name, r])); const routeUrlMap = new Map(routes.map(r => [r.url, r.name])); /** * Renders the template and updates the URL for a given state. * @param {string} stateName - The name of the state to render. */ const render = (stateName) => { const route = routeMap.get(stateName); if (!route) { console.error(`[FSM Connector] No route config found for state: ${stateName}`); return; } // Render template if it exists if (route.template) { rootEl.innerHTML = route.template; } // Update URL if it exists and is different from current browser URL if (route.url && window.location.pathname !== route.url) { window.history.pushState({ fsmState: stateName }, '', route.url); } }; // Use event delegation to handle UI events that trigger FSM transitions const clickHandler = (e) => { const target = e.target.closest('[data-fsm-event]'); if (target) { e.preventDefault(); const event = target.dataset.fsmEvent; if (fsm.can(event)) { fsm.send(event); } else { console.warn(`[FSM Connector] FSM cannot transition with event "${event}" from state "${fsm.getState()}"`); } } }; rootEl.addEventListener('click', clickHandler); // Subscribe to FSM state changes to trigger renders const unsubscribe = fsm.subscribe((transition) => { render(transition.to); }); // Initial render of the starting state render(fsm.getState()); // Handle browser history navigation (back/forward buttons) const handlePopState = (event) => { const newPath = window.location.pathname; const targetStateName = routeUrlMap.get(newPath); if (targetStateName) { // If the FSM is already in this state, just re-render (e.g., if context changed) if (fsm.is(targetStateName)) { render(targetStateName); } else { // Attempt to transition to the state from history via a GOTO event const eventName = `GOTO_${targetStateName.toUpperCase()}`; if (fsm.can(eventName)) { fsm.send(eventName); } else { console.warn(`[FSM Connector] Cannot transition to ${targetStateName} via popstate. Event ${eventName} not found.`); // Fallback: just render the template if FSM can't transition render(targetStateName); } } } else { console.warn(`[FSM Connector] No route found for URL: ${newPath} on popstate.`); } }; window.addEventListener('popstate', handlePopState); // Return a cleanup function to prevent memory leaks return () => { unsubscribe(); rootEl.removeEventListener('click', clickHandler); window.removeEventListener('popstate', handlePopState); }; };