Skip to main content
Glama
stateManager.ts12.4 kB
/** * Professional State Management * Centralized state management with persistence, validation, and reactive updates */ import { logger } from './logger'; export type StateListener<T = any> = (newValue: T, oldValue: T, path: string) => void; export interface StateSchema { connection: { isConnected: boolean; connectedTabId: number | null; serverUrl: string; lastConnectedAt: Date | null; reconnectAttempts: number; }; ui: { activeView: 'connect' | 'status' | 'settings'; theme: 'light' | 'dark' | 'auto'; notifications: boolean; autoReconnect: boolean; }; performance: { connectionLatency: number; messageCount: number; errorCount: number; uptime: number; }; user: { preferences: Record<string, any>; recentConnections: Array<{ url: string; timestamp: Date; successful: boolean; }>; }; } export type StatePath = keyof StateSchema | string; interface StateValidationRule<T> { validate: (value: T) => boolean; message: string; } interface StateValidation<T> { [key: string]: StateValidationRule<T>[]; } export class StateManager { private static instance: StateManager; private state: StateSchema; private listeners: Map<string, Set<StateListener>> = new Map(); private validationRules: StateValidation<any> = {}; private persistenceKey = 'browser_manager_state'; private batchUpdates: Map<string, any> = new Map(); private isBatching = false; private constructor() { this.state = this.getInitialState(); this.loadFromStorage(); this.setupValidationRules(); this.setupAutoSave(); } static getInstance(): StateManager { if (!StateManager.instance) { StateManager.instance = new StateManager(); } return StateManager.instance; } private getInitialState(): StateSchema { return { connection: { isConnected: false, connectedTabId: null, serverUrl: '', lastConnectedAt: null, reconnectAttempts: 0 }, ui: { activeView: 'connect', theme: 'auto', notifications: true, autoReconnect: true }, performance: { connectionLatency: 0, messageCount: 0, errorCount: 0, uptime: 0 }, user: { preferences: {}, recentConnections: [] } }; } private setupValidationRules(): void { // Connection validation this.validationRules['connection.serverUrl'] = [ { validate: (url: string) => !url || this.isValidUrl(url), message: 'URL must be a valid WebSocket URL' } ]; this.validationRules['connection.reconnectAttempts'] = [ { validate: (attempts: number) => attempts >= 0 && attempts <= 10, message: 'Reconnect attempts must be between 0 and 10' } ]; // UI validation this.validationRules['ui.theme'] = [ { validate: (theme: string) => ['light', 'dark', 'auto'].includes(theme), message: 'Theme must be one of: light, dark, auto' } ]; // Performance validation this.validationRules['performance.connectionLatency'] = [ { validate: (latency: number) => latency >= 0, message: 'Connection latency must be non-negative' } ]; } private isValidUrl(url: string): boolean { try { const urlObj = new URL(url); return urlObj.protocol === 'ws:' || urlObj.protocol === 'wss:'; } catch { return false; } } private setupAutoSave(): void { // Save to storage every 30 seconds if there are changes setInterval(() => { this.saveToStorage(); }, 30000); // Save on page unload window.addEventListener('beforeunload', () => { this.saveToStorage(); }); } private loadFromStorage(): void { try { const stored = localStorage.getItem(this.persistenceKey); if (stored) { const parsedState = JSON.parse(stored); this.mergeState(parsedState); logger.info('state-manager', 'State loaded from storage'); } } catch (error) { logger.error('state-manager', 'Failed to load state from storage', error as Error); } } private saveToStorage(): void { try { const stateToSave = this.prepareForSerialization(); localStorage.setItem(this.persistenceKey, JSON.stringify(stateToSave)); logger.debug('state-manager', 'State saved to storage'); } catch (error) { logger.error('state-manager', 'Failed to save state to storage', error as Error); } } private prepareForSerialization(): Partial<StateSchema> { // Convert Date objects to ISO strings and limit array sizes const serialized = JSON.parse(JSON.stringify(this.state)); // Limit recent connections to last 20 if (serialized.user?.recentConnections) { serialized.user.recentConnections = serialized.user.recentConnections.slice(-20); } // Convert dates to strings if (serialized.connection?.lastConnectedAt) { serialized.connection.lastConnectedAt = new Date(serialized.connection.lastConnectedAt).toISOString(); } if (serialized.user?.recentConnections) { serialized.user.recentConnections.forEach((conn: any) => { if (conn.timestamp) { conn.timestamp = new Date(conn.timestamp).toISOString(); } }); } return serialized; } private mergeState(newState: Partial<StateSchema>): void { this.batchUpdate(() => { this.deepMerge(this.state, newState); }); } private deepMerge(target: any, source: any): void { for (const key in source) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { if (!target[key] || typeof target[key] !== 'object') { target[key] = {}; } this.deepMerge(target[key], source[key]); } else { this.setValueAtPath(key, source[key], false); } } } getValue<T = any>(path: string, fromState?: any): T { const keys = path.split('.'); let current: any = fromState || this.state; for (const key of keys) { if (current && typeof current === 'object' && key in current) { current = current[key]; } else { return undefined as T; } } // Convert ISO strings back to Date objects if (current && typeof current === 'string' && path.includes('Date') || path.includes('timestamp')) { const date = new Date(current); if (!isNaN(date.getTime())) { return date as T; } } return current as T; } setValue<T = any>(path: string, value: T): boolean { return this.setValueAtPath(path, value, true); } private setValueAtPath<T = any>(path: string, value: T, notify: boolean = true): boolean { // Validate the value if (!this.validateValue(path, value)) { logger.warn('state-manager', `Validation failed for ${path}`, { value }); return false; } const keys = path.split('.'); const lastKey = keys.pop()!; let current: any = this.state; // Navigate to the parent object for (const key of keys) { if (!(key in current) || typeof current[key] !== 'object') { current[key] = {}; } current = current[key]; } const oldValue = current[lastKey]; // Only update if the value actually changed if (!this.deepEqual(oldValue, value)) { current[lastKey] = value; logger.debug('state-manager', `State updated: ${path}`, { oldValue, newValue: value }); if (notify && !this.isBatching) { this.notifyListeners(path, value, oldValue); } else if (this.isBatching) { this.batchUpdates.set(path, { value, oldValue }); } return true; } return false; } private validateValue(path: string, value: any): boolean { const rules = this.validationRules[path]; if (!rules) return true; return rules.every(rule => { const isValid = rule.validate(value); if (!isValid) { logger.warn('state-validation', rule.message, { path, value }); } return isValid; }); } private deepEqual(a: any, b: any): boolean { if (a === b) return true; if (a == null || b == null) return false; if (Array.isArray(a) && Array.isArray(b)) { return a.length === b.length && a.every((val, index) => this.deepEqual(val, b[index])); } if (typeof a === 'object' && typeof b === 'object') { const keysA = Object.keys(a); const keysB = Object.keys(b); return keysA.length === keysB.length && keysA.every(key => this.deepEqual(a[key], b[key])); } return false; } private notifyListeners(path: string, newValue: any, oldValue: any): void { // Notify exact path listeners const exactListeners = this.listeners.get(path); if (exactListeners) { exactListeners.forEach(listener => { try { listener(newValue, oldValue, path); } catch (error) { logger.error('state-manager', 'Error in state listener', error as Error, { path }); } }); } // Notify parent path listeners const pathParts = path.split('.'); for (let i = pathParts.length - 1; i > 0; i--) { const parentPath = pathParts.slice(0, i).join('.'); const parentListeners = this.listeners.get(parentPath); if (parentListeners) { const parentValue = this.getValue(parentPath); parentListeners.forEach(listener => { try { listener(parentValue, this.getValue(parentPath), parentPath); } catch (error) { logger.error('state-manager', 'Error in parent state listener', error as Error, { path: parentPath }); } }); } } } subscribe<T = any>(path: string, listener: StateListener<T>): () => void { if (!this.listeners.has(path)) { this.listeners.set(path, new Set()); } this.listeners.get(path)!.add(listener); // Immediately call the listener with the current value const currentValue = this.getValue<T>(path); setTimeout(() => { listener(currentValue, currentValue, path); }, 0); // Return unsubscribe function return () => { const listeners = this.listeners.get(path); if (listeners) { listeners.delete(listener); if (listeners.size === 0) { this.listeners.delete(path); } } }; } batchUpdate(fn: () => void): void { this.isBatching = true; this.batchUpdates.clear(); try { fn(); } finally { this.isBatching = false; // Notify all batched updates for (const [path, { value, oldValue }] of this.batchUpdates.entries()) { this.notifyListeners(path, value, oldValue); } this.batchUpdates.clear(); } } reset(path?: string): void { if (path) { const initialState = this.getInitialState(); const initialValue = this.getValue(path, initialState); this.setValue(path, initialValue); } else { this.state = this.getInitialState(); this.saveToStorage(); logger.info('state-manager', 'All state reset to initial values'); } } exportState(): string { return JSON.stringify(this.state, null, 2); } importState(stateJson: string): boolean { try { const importedState = JSON.parse(stateJson); this.mergeState(importedState); this.saveToStorage(); logger.info('state-manager', 'State imported successfully'); return true; } catch (error) { logger.error('state-manager', 'Failed to import state', error as Error); return false; } } addValidationRule<T>(path: string, rule: StateValidationRule<T>): void { if (!this.validationRules[path]) { this.validationRules[path] = []; } this.validationRules[path].push(rule); logger.debug('state-manager', `Validation rule added for ${path}`); } getPerformanceMetrics(): { totalListeners: number; totalPaths: number; memoryUsage: number; } { let totalListeners = 0; for (const listeners of this.listeners.values()) { totalListeners += listeners.size; } return { totalListeners, totalPaths: this.listeners.size, memoryUsage: JSON.stringify(this.state).length }; } } export const stateManager = StateManager.getInstance();

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DeamonDev888/Browser-Manager-MCP-Server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server