UNPKG

@hpkv/zustand-multiplayer

Version:

A multiplayer middleware for Zustand using HPKV

1,420 lines (1,404 loc) 54.5 kB
/** * @license @hpkv/zustand-multiplayer v0.6.1 * Copyright (c) 2025 HPKV Team * This source code is licensed under the MIT license. */ 'use strict'; var websocketClient = require('@hpkv/websocket-client'); /** * Simple cache implementation for storage key caching */ class Cache { constructor(maxSize = 1000, ttl = 300000) { this.data = new Map(); this.maxSize = maxSize; this.ttl = ttl; } get(key) { const entry = this.data.get(key); if (!entry) return undefined; if (Date.now() > entry.expires) { this.data.delete(key); return undefined; } return entry.value; } set(key, value) { if (this.data.size >= this.maxSize) { const firstKey = this.data.keys().next().value; if (firstKey !== undefined) { this.data.delete(firstKey); } } this.data.set(key, { value, expires: Date.now() + this.ttl, }); } clear() { this.data.clear(); } get size() { return this.data.size; } } class CacheManager { constructor() { this.storageKeyCache = new Cache(); } static getInstance() { if (!CacheManager.instance) { CacheManager.instance = new CacheManager(); } return CacheManager.instance; } clearAll() { this.storageKeyCache.clear(); } } function getCacheManager() { return CacheManager.getInstance(); } /** * Manages storage keys and key mappings for the multiplayer. */ class StorageKeyManager { constructor(namespace, zFactor) { this.cacheManager = getCacheManager(); this.namespacedPrefix = zFactor !== undefined ? `${namespace}-${zFactor}` : namespace; } /** * Create a storage key from a path array with caching */ createStorageKey(path) { const pathKey = path.join(':'); const cacheKey = `${this.namespacedPrefix}:${pathKey}`; const cached = this.cacheManager.storageKeyCache.get(cacheKey); if (cached !== undefined) { return cached; } const result = `${this.namespacedPrefix}:${pathKey}`; this.cacheManager.storageKeyCache.set(cacheKey, result); return result; } /** * Parse a storage key to extract path information */ parseStorageKey(storageKey) { const prefix = `${this.namespacedPrefix}:`; let keyToParse = storageKey; if (storageKey.startsWith(prefix)) { keyToParse = storageKey.substring(prefix.length); } const segments = keyToParse.split(':'); return { segments, depth: segments.length, isNested: segments.length > 1, }; } /** * Prefixes a key with the namespace */ getFullKey(key) { return `${this.namespacedPrefix}:${key}`; } /** * Removes the namespace prefix from a full key */ getKeyWithoutPrefix(fullKey) { const prefix = `${this.namespacedPrefix}:`; if (fullKey.startsWith(prefix)) { return fullKey.substring(prefix.length); } return fullKey; } /** * Gets the namespace for this key manager */ getNamespace() { return this.namespacedPrefix; } /** * Creates a range query pattern for namespace */ getNamespaceRange() { return { start: `${this.namespacedPrefix}:`, end: `${this.namespacedPrefix}:\xff`, }; } /** * Clear the storage key cache */ clearCache() { this.cacheManager.storageKeyCache.clear(); } } // ============================================================================ // STATE MANAGEMENT // ============================================================================ /** Default Z-factor for nested state detection */ const DEFAULT_Z_FACTOR = 2; /** Maximum allowed Z-factor value */ const MAX_Z_FACTOR = 10; /** Minimum allowed Z-factor value */ const MIN_Z_FACTOR = 0; // ============================================================================ // NETWORK & RETRY // ============================================================================ /** Default network timeout in milliseconds */ const DEFAULT_TIMEOUT = 5000; /** Default connection retry delay in milliseconds */ const DEFAULT_RETRY_DELAY = 1000; /** Default backoff factor for network operations */ const DEFAULT_BACKOFF_FACTOR = 2; /** Maximum retry attempts for network operations */ const MAX_RETRY_ATTEMPTS = 3; // ============================================================================ // AUTHENTICATION // ============================================================================ /** Token expiry time in milliseconds (2 hours) */ const TOKEN_EXPIRY_TIME = 2 * 60 * 60 * 1000; /** Token refresh buffer time in milliseconds (15 minutes before expiry) */ const TOKEN_REFRESH_BUFFER = 15 * 60 * 1000; // ============================================================================ // PERFORMANCE // ============================================================================ /** Maximum number of operations to track for performance monitoring */ const MAX_OPERATION_HISTORY = 5; // ============================================================================ // CORE UTILITIES // ============================================================================ /** * Generates a cryptographically secure unique client identifier * @returns A unique client identifier string */ function generateClientId() { if (typeof crypto !== 'undefined' && crypto.getRandomValues) { const array = new Uint8Array(16); crypto.getRandomValues(array); const randomString = Array.from(array, byte => byte.toString(36)) .join('') .substring(0, 15); return `client_${Date.now()}_${randomString}`; } return `client_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; } /** * Normalizes an error to ensure it's an Error instance * @param error The error to normalize * @returns A normalized Error instance */ function normalizeError(error) { return error instanceof Error ? error : new Error(String(error)); } /** * Creates a delay promise * @param ms Milliseconds to delay * @returns Promise that resolves after the delay */ function createDelay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Safely clears a timeout * @param timeoutId The timeout ID to clear */ function clearTimeoutSafely(timeoutId) { if (timeoutId) { clearTimeout(timeoutId); } } /** * Escapes special characters in a string for use in a regular expression * @param string The string to escape * @returns The escaped string */ function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Type guard to check if a value is a plain object * @param value The value to check * @returns True if the value is a plain object */ function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } /** * Manages state diffing and comparison operations for efficient synchronization */ class StateDiffManager { calculateDiff(oldValue, newValue) { if (oldValue === undefined || !isPlainObject(oldValue) || !isPlainObject(newValue)) { return { type: 'full', data: newValue }; } const diff = this.calculateObjectDiff(oldValue, newValue); return Object.keys(diff).length > 0 ? { type: 'diff', data: diff } : { type: 'full', data: newValue }; } calculateObjectDiff(oldObj, newObj) { const diff = {}; const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]); for (const key of allKeys) { const diffValue = this.getDiffForKey(key, oldObj, newObj); if (diffValue !== undefined) { diff[key] = diffValue; } } return diff; } getDiffForKey(key, oldObj, newObj) { const hasOldKey = key in oldObj; const hasNewKey = key in newObj; if (hasOldKey && !hasNewKey) return null; // Deletion if (!hasOldKey && hasNewKey) return newObj[key]; // Addition const oldValue = oldObj[key]; const newValue = newObj[key]; if (this.isDeepEqual(oldValue, newValue)) return undefined; // No change // Nested object diff if (isPlainObject(oldValue) && isPlainObject(newValue)) { const nestedDiff = this.calculateObjectDiff(oldValue, newValue); return Object.keys(nestedDiff).length > 0 ? nestedDiff : undefined; } return newValue; // Value change } isDeepEqual(value1, value2) { if (value1 === value2) return true; if (value1 === null || value2 === null) return value1 === value2; if (typeof value1 !== typeof value2) return false; if (Array.isArray(value1) && Array.isArray(value2)) { return (value1.length === value2.length && value1.every((item, index) => this.isDeepEqual(item, value2[index]))); } if (typeof value1 === 'object' && typeof value2 === 'object') { const keys1 = Object.keys(value1); const keys2 = Object.keys(value2); return (keys1.length === keys2.length && keys1.every(key => key in value2 && this.isDeepEqual(value1[key], value2[key]))); } return false; } } /** * Manages state merging and update operations for the multiplayer system */ class StateMerger { constructor(zFactor = DEFAULT_Z_FACTOR) { this.zFactor = zFactor; } buildStateUpdate(path, value, currentState) { const segments = path.split('.'); const isDeleting = value === null || value === undefined; // Use smart merge for deep paths beyond zFactor if (segments.length > this.zFactor && !isDeleting) { return this.buildMergeUpdate(segments, value, currentState); } return this.buildDirectUpdate(currentState, segments, value, isDeleting); } extractPaths(obj, parentPath, maxDepth, currentDepth = 0) { if (currentDepth >= maxDepth || !isPlainObject(obj)) { return [{ path: parentPath, value: obj }]; } const paths = []; for (const [key, value] of Object.entries(obj)) { const currentPath = [...parentPath, key]; if (value === null || !isPlainObject(value)) { paths.push({ path: currentPath, value }); } else { paths.push(...this.extractPaths(value, currentPath, maxDepth, currentDepth + 1)); } } return paths; } mergeObjects(current, incoming) { const result = { ...current }; for (const [key, value] of Object.entries(incoming)) { if (value === null || value === undefined) { delete result[key]; } else { result[key] = value; } } return result; } buildDirectUpdate(currentState, segments, value, isDeleting) { const fieldName = segments[0]; if (segments.length === 1) { return { [fieldName]: value }; } const clonedField = this.cloneValue(currentState[fieldName]) || {}; this.applyNestedUpdate(clonedField, segments.slice(1), value, isDeleting); return { [fieldName]: clonedField }; } setNestedValue(obj, segments, value) { if (segments.length === 0) return; let current = obj; for (let i = 0; i < segments.length - 1; i++) { const segment = segments[i]; if (!isPlainObject(current[segment])) { current[segment] = {}; } current = current[segment]; } current[segments[segments.length - 1]] = value; } buildMergeUpdate(segments, value, currentState) { const currentValue = this.getNestedValue(currentState, segments); if (isPlainObject(currentValue) && isPlainObject(value)) { const merged = this.mergeObjects(currentValue, value); return this.buildDirectUpdate(currentState, segments, merged, false); } return this.buildDirectUpdate(currentState, segments, value, false); } getNestedValue(state, segments) { var _a; let current = state[segments[0]]; for (let i = 1; i <= this.zFactor && i < segments.length; i++) { // Check if object is extensible before adding properties if (!Object.isExtensible(current)) { // Return a copy of the non-extensible object to allow merging current = { ...current }; } current[_a = segments[i]] ?? (current[_a] = {}); current = current[segments[i]]; } return current; } cloneValue(value) { if (isPlainObject(value)) { return JSON.parse(JSON.stringify(value)); } return {}; } applyNestedUpdate(target, segments, value, isDeleting) { const parent = this.navigateToParent(target, segments); const lastSegment = segments[segments.length - 1]; if (isDeleting) { delete parent.current[lastSegment]; this.cleanupEmptyParents(parent.path); } else { parent.current[lastSegment] = value; } } navigateToParent(target, segments) { let current = target; const path = []; for (let i = 0; i < segments.length - 1; i++) { const segment = segments[i]; if (!isPlainObject(current[segment])) { current[segment] = {}; } path.push({ obj: current, key: segment }); current = current[segment]; } return { current, path }; } cleanupEmptyParents(path) { for (let i = path.length - 1; i >= 0; i--) { const { obj, key } = path[i]; const value = obj[key]; if (isPlainObject(value) && Object.keys(value).length === 0) { delete obj[key]; } else { break; } } } } // ============================================================================ // ORCHESTRATOR // ============================================================================ class Orchestrator { constructor(client, options, api, performanceMonitor, logger) { this.client = client; this.options = options; this.api = api; this.performanceMonitor = performanceMonitor; this.logger = logger; this.cleanupFunctions = []; this.isHydrating = false; this.hasHydrated = false; this.keyManager = new StorageKeyManager(options.namespace, options.zFactor); this.diffManager = new StateDiffManager(); this.merger = new StateMerger(options.zFactor ?? DEFAULT_Z_FACTOR); this.setupEventListeners(); this.logger.debug('Orchestrator initialized', { operation: 'orchestrator-init', namespace: options.namespace, zFactor: options.zFactor ?? DEFAULT_Z_FACTOR, }); } // ============================================================================ // EVENT HANDLING // ============================================================================ setupEventListeners() { const connectionListener = (state) => { void this.handleConnectionStateChange(state); }; const removeConnectionListener = this.client.addConnectionListener(connectionListener); this.cleanupFunctions.push(removeConnectionListener); const removeChangeListener = this.client.addChangeListener((event) => { void this.handleRemoteChange(event); }); this.cleanupFunctions.push(removeChangeListener); } async handleConnectionStateChange(state) { this.logger.info(`Connection state changed to ${state}`); if (state === websocketClient.ConnectionState.DISCONNECTED) { this.hasHydrated = false; this.updateMultiplayerState({ hasHydrated: false }); } this.updateMultiplayerState({ connectionState: state }); if (state === websocketClient.ConnectionState.CONNECTED && !this.hasHydrated) { await this.hydrate(); } } // ============================================================================ // STATE SYNCHRONIZATION // ============================================================================ /** * Handle local state changes and sync them to remote storage * This is called whenever the local store is updated */ async handleLocalStateChange(partial, replace) { const oldState = this.api.getState(); if (replace) { this.api.setState(partial, replace); } else { this.api.setState(partial, replace); } const newState = this.api.getState(); await this.syncToRemote(newState, oldState); } /** * Handle remote state changes received from other clients * Merges the remote changes into the local state */ handleRemoteChange(event) { const path = this.parseStorageKey(event.key); this.logger.debug('Received remote state update', { path }); const currentState = this.api.getState(); const update = this.merger.buildStateUpdate(path, event.value, currentState); this.api.setState(update, false); } /** * Sync local state changes to remote storage * Compares old and new state to determine what needs to be synced */ async syncToRemote(newState, oldState) { const syncFields = this.options.sync ?? []; const syncOperations = this.buildSyncOperations(newState, oldState, syncFields); if (syncOperations.length > 0) { this.logger.debug(`Syncing ${syncOperations.length} state updates to remote storage`); try { await Promise.all(syncOperations); this.updateMultiplayerState({ performanceMetrics: this.performanceMonitor.getMetrics() }); } catch (error) { this.logger.error('Failed to sync changes to remote storage', error); throw error; } } } /** * Build the list of sync operations needed to sync state changes */ buildSyncOperations(newState, oldState, syncFields) { const operations = []; const zFactor = this.options.zFactor ?? DEFAULT_Z_FACTOR; for (const field of syncFields) { const changes = this.detectFieldChanges(field, newState, oldState, zFactor); operations.push(...changes.deletions, ...changes.updates); } return operations; } /** * Detect changes in a specific field between old and new state */ detectFieldChanges(field, newState, oldState, zFactor) { const fieldStr = String(field); const currentValue = newState[field]; const previousValue = oldState[field]; // Skip unchanged fields, functions, and system fields if (this.shouldSkipField(fieldStr, currentValue, previousValue)) { return { deletions: [], updates: [] }; } // Extract paths from old and new values and compare them to find changes const oldPaths = this.extractFieldPaths(previousValue, fieldStr, zFactor); const newPaths = this.extractFieldPaths(currentValue, fieldStr, zFactor); return this.comparePathsForChanges(oldPaths, newPaths); } /** * Check if a field should be skipped during sync */ shouldSkipField(fieldStr, currentValue, previousValue) { return (currentValue === previousValue || typeof currentValue === 'function' || fieldStr === 'multiplayer'); } /** * Extract paths from a field value */ extractFieldPaths(value, fieldStr, zFactor) { return value !== undefined ? this.merger.extractPaths(value, [fieldStr], zFactor) : []; } /** * Compare old and new paths to find deletions and updates */ comparePathsForChanges(oldPaths, newPaths) { const oldPathMap = new Map(oldPaths.map(p => [p.path.join('.'), p.value])); const newPathMap = new Map(newPaths.map(p => [p.path.join('.'), p.value])); const operations = { deletions: [], updates: [] }; // Process deletions and updates in single pass for (const [pathKey, _oldValue] of oldPathMap) { if (!newPathMap.has(pathKey)) { operations.deletions.push(this.client.removeItem(this.createStorageKey(pathKey))); } } for (const [pathKey, newValue] of newPathMap) { const oldValue = oldPathMap.get(pathKey); if (oldValue !== newValue) { const diff = this.diffManager.calculateDiff(oldValue, newValue); operations.updates.push(this.client.setItem(this.createStorageKey(pathKey), diff.data)); } } return operations; } // ============================================================================ // STATE HYDRATION // ============================================================================ /** * Hydrate the local state from remote storage */ async hydrate() { if (this.isHydrating) { return; } this.isHydrating = true; try { const hydratedState = await this.loadRemoteState(); this.api.setState(hydratedState, false); this.completeHydration(); } catch (error) { this.logger.error('State hydration failed', error); throw error; } finally { this.isHydrating = false; } } /** * Load and reconstruct state from remote storage */ async loadRemoteState() { const allItems = await this.client.getAllItems(); const hydratedState = {}; for (const [key, value] of allItems.entries()) { const path = this.parseStorageKey(key); const pathSegments = path.split('.'); this.merger.setNestedValue(hydratedState, pathSegments, value); } return hydratedState; } /** * Complete the hydration process and update metrics */ completeHydration() { this.hasHydrated = true; this.updateMultiplayerState({ hasHydrated: true, performanceMetrics: this.performanceMonitor.getMetrics(), }); } // ============================================================================ // STORAGE KEY UTILITIES // ============================================================================ /** * Create a storage key from a state path */ createStorageKey(path) { const pathArray = path.split('.'); return this.keyManager.createStorageKey(pathArray); } /** * Parse a storage key back to a state path */ parseStorageKey(key) { const statePath = this.keyManager.parseStorageKey(key); return statePath.segments.join('.'); } updateMultiplayerState(updates) { this.api.setState(state => { const stateWithMultiplayer = state; return { ...state, multiplayer: { ...stateWithMultiplayer.multiplayer, ...updates }, }; }, false); } // ============================================================================ // PUBLIC API // ============================================================================ async clearStorage() { this.logger.info('Clearing store...'); await this.client.clear(); } async connect() { try { await this.client.ensureConnection(); if (this.client.getConnectionStatus()?.connectionState === websocketClient.ConnectionState.CONNECTED) { this.updateMultiplayerState({ connectionState: websocketClient.ConnectionState.CONNECTED }); } } catch (error) { this.logger.error('Failed to connect to remote storage', error); throw error; } } async disconnect() { await this.client.close(); this.updateMultiplayerState({ connectionState: websocketClient.ConnectionState.DISCONNECTED }); } destroy() { this.cleanup(); return Promise.resolve(); } getConnectionStatus() { return this.client.getConnectionStatus(); } getMetrics() { return this.performanceMonitor.getMetrics(); } cleanup() { this.cleanupFunctions.forEach(cleanup => cleanup()); this.cleanupFunctions.length = 0; this.performanceMonitor.cleanup?.(); } } // Logger implementation exports.LogLevel = void 0; (function (LogLevel) { LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG"; LogLevel[LogLevel["INFO"] = 1] = "INFO"; LogLevel[LogLevel["WARN"] = 2] = "WARN"; LogLevel[LogLevel["ERROR"] = 3] = "ERROR"; LogLevel[LogLevel["NONE"] = 4] = "NONE"; })(exports.LogLevel || (exports.LogLevel = {})); class Logger { constructor(level = exports.LogLevel.INFO) { this.level = level; if (level < exports.LogLevel.DEBUG || level > exports.LogLevel.NONE) { this.level = exports.LogLevel.INFO; } else { this.level = level; } } shouldLog(level) { return level >= this.level; } formatMessage(level, message, context) { const timestamp = new Date(context.timestamp).toISOString(); const clientId = context.clientId ? `[${context.clientId}]` : ''; const operation = context.operation ? `(${context.operation})` : ''; return `${timestamp} ${level} ${clientId}${operation}: ${message}`; } debug(message, context = {}) { if (this.shouldLog(exports.LogLevel.DEBUG)) { console.debug(this.formatMessage('DEBUG', message, { timestamp: Date.now(), ...context })); } } info(message, context = {}) { if (this.shouldLog(exports.LogLevel.INFO)) { console.info(this.formatMessage('INFO', message, { timestamp: Date.now(), ...context })); } } warn(message, context = {}) { if (this.shouldLog(exports.LogLevel.WARN)) { console.warn(this.formatMessage('WARN', message, { timestamp: Date.now(), ...context })); } } error(message, error, context = {}) { if (this.shouldLog(exports.LogLevel.ERROR)) { const errorMessage = error ? `${message}: ${error.message}` : message; console.error(this.formatMessage('ERROR', errorMessage, { timestamp: Date.now(), ...context })); if (error?.stack) { console.error(error.stack); } } } setLevel(level) { this.level = level; } getLevel() { return this.level; } } /** * Creates a new logger instance with the specified log level * @param level The log level for the logger * @returns A new Logger instance */ function createLogger(level = exports.LogLevel.INFO) { return new Logger(level); } class PerformanceMonitor { constructor() { this.metrics = { averageSyncTime: 0, }; this.syncTimes = []; } recordSyncTime(duration) { this.syncTimes.push(duration); if (this.syncTimes.length > MAX_OPERATION_HISTORY) { this.syncTimes.shift(); } this.metrics.averageSyncTime = this.syncTimes.length > 0 ? this.syncTimes.reduce((sum, time) => sum + time, 0) / this.syncTimes.length : 0; } getMetrics() { return { ...this.metrics }; } /** * Cleanup performance monitoring data */ cleanup() { this.syncTimes.length = 0; this.metrics.averageSyncTime = 0; } } /** * Utility to help generate WebSocket tokens for HPKV */ class TokenHelper { /** * Creates a new TokenHelper instance * * @param apiKey HPKV API key * @param baseUrl HPKV base URL */ constructor(apiKey, baseUrl) { this.tokenManager = new websocketClient.WebsocketTokenManager(apiKey, baseUrl); } /** * Generate a token for a store with the given namespace and keys */ async generateTokenForStore(namespace, subscribedKeysAnPatterns) { const token = await this.tokenManager.generateToken({ subscribePatterns: [...subscribedKeysAnPatterns], accessPattern: `^${escapeRegExp(namespace)}:.*$`, }); return token; } /** * Process a token request and return a token response * * @param requestData The request data object or string * @returns TokenResponse object with the generated token */ async processTokenRequest(requestData) { try { let parsedRequest; if (typeof requestData === 'string') { try { parsedRequest = JSON.parse(requestData); } catch { throw new Error('Invalid request: Could not parse request data'); } } else { parsedRequest = requestData; } const { namespace, subscribedKeysAndPatterns } = parsedRequest; if (!namespace || typeof namespace !== 'string') { throw new Error('Invalid request: namespace is required and must be a string'); } const token = await this.generateTokenForStore(namespace, subscribedKeysAndPatterns ?? []); return { namespace, token }; } catch (error) { throw error instanceof Error ? error : new Error('Unknown error during token generation'); } } } class SecureTokenCache { constructor() { this.tokenData = null; this.isRefreshing = false; } set(token, expiresAt) { this.tokenData = { token, expiresAt }; } get() { return this.tokenData; } clear() { if (this.tokenData) { // Secure token clearing - overwrite with random data before nullifying const tokenLength = this.tokenData.token.length; this.tokenData.token = Array(tokenLength) .fill(0) .map(() => Math.random().toString(36).charAt(0)) .join(''); this.tokenData.token = ''; this.tokenData = null; } } isValid() { return this.tokenData !== null && Date.now() < this.tokenData.expiresAt; } setRefreshing(refreshing) { this.isRefreshing = refreshing; } getRefreshing() { return this.isRefreshing; } } /** * token manager - handles token generation, caching, and refresh */ class TokenManager { constructor(options) { this.secureTokenCache = new SecureTokenCache(); this.tokenRefreshTimer = null; this.tokenRefreshPromise = null; this.options = options; } /** * Sets a callback to be called when token refresh is needed */ setTokenRefreshCallback(callback) { this.onTokenRefresh = callback; } /** * Generates a WebSocket token with appropriate access permissions * Thread-safe with race condition protection * @returns Generated token string */ async generateToken() { if (this.secureTokenCache.isValid()) { const cached = this.secureTokenCache.get(); return cached.token; } if (this.tokenRefreshPromise) { return this.tokenRefreshPromise; } this.secureTokenCache.setRefreshing(true); this.tokenRefreshPromise = this.performTokenGeneration(); try { const token = await this.tokenRefreshPromise; return token; } finally { this.secureTokenCache.setRefreshing(false); this.tokenRefreshPromise = null; } } /** * Performs the actual token generation logic */ async performTokenGeneration() { this.secureTokenCache.clear(); this.clearTokenRefreshTimer(); let token; if (this.options.apiKey) { const tokenHelper = new TokenHelper(this.options.apiKey, this.options.apiBaseUrl); const fullSubscribedKeys = this.options.subscribedKeys.map(key => this.options.keyManager.getFullKey(key)); token = await tokenHelper.generateTokenForStore(this.options.keyManager.getNamespace(), fullSubscribedKeys); } else if (this.options.tokenGenerationUrl) { token = await this.fetchToken(); } else { throw new Error('either apiKey or tokenGenerationUrl are required'); } // Cache token with configured expiry time const expiresAt = Date.now() + TOKEN_EXPIRY_TIME; this.secureTokenCache.set(token, expiresAt); // Schedule refresh with configured buffer time before expiry const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER; const refreshDelay = refreshAt - Date.now(); if (refreshDelay > 0) { this.tokenRefreshTimer = setTimeout(() => { this.refreshToken().catch(() => { // Ignore refresh errors }); }, refreshDelay); } return token; } /** * Proactively refreshes the token and reconnects if needed * Protected against race conditions */ async refreshToken() { if (this.secureTokenCache.getRefreshing()) { return; } if (this.onTokenRefresh) { await this.onTokenRefresh(); } } /** * Clears the token refresh timer */ clearTokenRefreshTimer() { clearTimeoutSafely(this.tokenRefreshTimer); this.tokenRefreshTimer = null; } /** * Fetches token from a custom token generation URL */ async fetchToken() { return this.options.retryManager.executeWithRetry(async () => { const response = await fetch(this.options.tokenGenerationUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ namespace: this.options.keyManager.getNamespace(), subscribedKeysAndPatterns: this.options.subscribedKeys.map(key => this.options.keyManager.getFullKey(key)), }), }); if (!response.ok) { throw new Error(`Failed to get token: ${response.status} ${response.statusText}`); } const data = (await response.json()); return data.token; }, 'fetchToken'); } /** * Clears the token cache and stops refresh timers */ clear() { this.clearTokenRefreshTimer(); this.secureTokenCache.clear(); } /** * Gets the current token if valid */ getCurrentToken() { if (this.secureTokenCache.isValid()) { const cached = this.secureTokenCache.get(); return cached?.token ?? null; } return null; } /** * Checks if a token refresh is currently in progress */ isRefreshing() { return this.secureTokenCache.getRefreshing(); } } class RetryManager { constructor(config) { this.config = config; } async executeWithRetry(operation, operationName) { let lastError; for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) { try { return await operation(); } catch (error) { lastError = error; if (attempt === this.config.maxRetries) { break; } const delay = Math.min(this.config.baseDelay * Math.pow(this.config.backoffFactor, attempt), this.config.maxDelay); await createDelay(delay); } } throw lastError ?? new Error(`${operationName} failed`); } } function createDefaultRetryConfig() { return { maxRetries: MAX_RETRY_ATTEMPTS, baseDelay: DEFAULT_RETRY_DELAY, maxDelay: DEFAULT_TIMEOUT, backoffFactor: DEFAULT_BACKOFF_FACTOR, }; } function createRetryManager(config) { const fullConfig = { ...createDefaultRetryConfig(), ...config }; return new RetryManager(fullConfig); } /** * HPKV storage implementation focused on core functionality * Clean and maintainable implementation without unnecessary abstractions */ class HPKVStorage { constructor(options, subscribedKeys, logger, performanceMonitor) { this.options = options; this.subscribedKeys = subscribedKeys; this.logger = logger; this.performanceMonitor = performanceMonitor; // Core client and subscription this.client = null; this.subscriptionId = null; this.connectionPromise = null; this.changeListeners = new Set(); this.connectionListeners = new Set(); this.cleanupCallbacks = new Set(); this.isDestroyed = false; if (!options.namespace) { throw new Error('namespace is required'); } this.clientId = generateClientId(); this.retryManager = createRetryManager(); this.keyManager = new StorageKeyManager(options.namespace, options.zFactor); this.tokenManager = new TokenManager({ namespace: options.namespace, apiBaseUrl: options.apiBaseUrl, apiKey: options.apiKey, tokenGenerationUrl: options.tokenGenerationUrl, subscribedKeys: this.subscribedKeys, keyManager: this.keyManager, retryManager: this.retryManager, logger: this.logger, clientId: this.clientId, }); this.tokenManager.setTokenRefreshCallback(() => this.handleTokenRefresh()); } // ============================================================================ // PUBLIC API // ============================================================================ getClientId() { return this.clientId; } addChangeListener(listener) { this.changeListeners.add(listener); return () => this.changeListeners.delete(listener); } addConnectionListener(listener) { this.connectionListeners.add(listener); return () => this.connectionListeners.delete(listener); } async ensureConnection() { if (this.isDestroyed) { throw new Error('Storage has been destroyed'); } if (this.connectionPromise) { return this.connectionPromise; } const stats = this.client?.getConnectionStats(); if (this.client && stats?.connectionState === websocketClient.ConnectionState.CONNECTED) { return; } this.connectionPromise = this.connectInternal().finally(() => { this.connectionPromise = null; }); return this.connectionPromise; } getConnectionStatus() { return this.client?.getConnectionStats() ?? null; } async getAllItems() { await this.ensureConnection(); if (!this.client) { throw new Error('No connection available'); } const result = new Map(); const namespaceRange = this.keyManager.getNamespaceRange(); let startKey = namespaceRange.start; let hasMore = true; while (hasMore) { const response = await this.client.range(startKey, namespaceRange.end); if (response.records) { for (const record of response.records) { try { const storedValue = JSON.parse(record.value); result.set(record.key, storedValue.value); } catch { result.set(record.key, record.value); } } if (response.truncated && response.records.length > 0) { const lastRecord = response.records[response.records.length - 1]; startKey = `${lastRecord.key}\0`; } else { hasMore = false; } } else { hasMore = false; } } return result; } async setItem(key, value) { await this.ensureConnection(); if (!this.client) { throw new Error('No connection available'); } const storedValue = { value, clientId: this.clientId, timestamp: Date.now(), }; const startTime = Date.now(); const response = await this.client.set(key, JSON.stringify(storedValue), true); const syncTime = Date.now() - startTime; if (!response.success) { throw new Error(`Failed to store item in the database: ${response.code}`); } this.performanceMonitor.recordSyncTime(syncTime); } async removeItem(key) { await this.ensureConnection(); const response = await this.client?.delete(key); if (!response?.success) { throw new Error(`Failed to remove item: ${response?.code}`); } } async clear() { const items = await this.getAllItems(); const removePromises = Array.from(items.keys()).map(key => this.removeItem(key)); await Promise.all(removePromises); } async close() { if (this.client) { await this.client.disconnect(); this.notifyConnectionListeners(websocketClient.ConnectionState.DISCONNECTED); } this.cleanup(); } async destroy() { this.isDestroyed = true; await this.close(); } // ============================================================================ // PRIVATE METHODS // ============================================================================ async connectInternal() { return this.retryManager.executeWithRetry(async () => { const token = await this.tokenManager.generateToken(); this.client = websocketClient.HPKVClientFactory.createSubscriptionClient(token, this.options.apiBaseUrl, { maxReconnectAttempts: 5, maxDelayBetweenReconnects: 60000, jitterMs: 1000, initialDelayBetweenReconnects: 500, throttling: { enabled: this.options.rateLimit !== undefined, rateLimit: this.options.rateLimit, }, }); this.setupClientEventHandlers(); await this.client.connect(); this.setupSubscriptions(); }, 'connectInternal'); } setupClientEventHandlers() { if (!this.client) return; const events = { connected: () => this.notifyConnectionListeners(websocketClient.ConnectionState.CONNECTED), disconnected: () => this.notifyConnectionListeners(websocketClient.ConnectionState.DISCONNECTED), reconnecting: () => this.notifyConnectionListeners(websocketClient.ConnectionState.RECONNECTING), reconnectFailed: () => this.notifyConnectionListeners(websocketClient.ConnectionState.DISCONNECTED), error: () => { this.logger.debug('WebSocket connection error occurred', { operation: 'connection-error', clientId: this.clientId, }); this.notifyConnectionListeners(websocketClient.ConnectionState.DISCONNECTED); }, }; Object.entries(events).forEach(([event, handler]) => { this.client.on(event, handler); }); this.cleanupCallbacks.add(() => { if (this.client) { Object.entries(events).forEach(([event, handler]) => { this.client.off(event, handler); }); } }); } setupSubscriptions() { if (!this.client) return; this.subscriptionId = this.client.subscribe((data) => { if (!data.key || data.value === undefined) { return; } const keyWithoutPrefix = this.keyManager.getKeyWithoutPrefix(data.key); let actualValue = data.value; try { if (typeof data.value === 'string') { const storedValue = JSON.parse(data.value); if (storedValue.clientId === this.getClientId()) { return; } actualValue = storedValue.value; } } catch { actualValue = data.value; } this.notifyChangeListeners({ key: keyWithoutPrefix, value: actualValue, timestamp: data.timestamp, }); }); this.cleanupCallbacks.add(() => { if (this.client && this.subscriptionId) { this.client.unsubscribe(this.subscriptionId); this.subscriptionId = null; } }); } async handleTokenRefresh() { if (this.client) { await this.client.disconnect(); this.client.destroy(); this.client = null; } await this.ensureConnection(); } notifyChangeListeners(event) { this.changeListeners.forEach(listener => { listener(event); }); } notifyConnectionListeners(state) { this.connectionListeners.forEach(listener => { listener(state); }); } cleanup() { this.cleanupCallbacks.forEach(cleanup => cleanup()); this.cleanupCallbacks.clear(); this.changeListeners.clear(); this.connectionListeners.clear(); } } /** * Validates that required authentication options are provided */ function validateAuthenticationOptions(options) { if (!options.apiKey && !options.tokenGenerationUrl) { throw new Error('Either apiKey or tokenGenerationUrl must be provided for authentication'); } } /** * Validates namespace configuration */ function validateNamespace(namespace) { if (!namespace || typeof namespace !== 'string') { throw new Error('Namespace must be a non-empty string'); } if (namespace.length < 1 || namespace.length > 100) { throw new Error('Namespace must be between 1 and 100 characters'); } const invalidChars = /[^a-zA-Z0-9_-]/; if (invalidChars.test(namespace)) { throw new Error('Namespace can only contain alphanumeric characters, underscores, and hyphens'); } } /** * Validates API base URL */ function validateApiBaseUrl(apiBaseUrl) { if (!apiBaseUrl || typeof apiBaseUrl !== 'string') { throw new Error('API base URL must be a non-empty string'); } try { const url = new URL(apiBaseUrl); if (!['http:', 'https:'].includes(url.protocol)) { throw new Error('API base URL must use HTTP or HTTPS protocol'); } } catch (error) { throw new Error(`Invalid API base URL format : ${normalizeError(error).message}`); } } /** * Validates Z-factor configuration */ function validateZFactor(zFactor) { if (zFactor === undefined || zFactor === null) { return DEFAULT_Z_FACTOR; } if (typeof zFactor !== 'number' || !Number.isInteger(zFactor)) { throw new Error('Z-factor must be an integer'); } if (zFactor < MIN_Z_FACTOR || zFactor > MAX_Z_FACTOR) { throw new Error(`Z-factor must be between ${MIN_Z_FACTOR} and ${MAX_Z_FACTOR}`); } return zFactor; } /** * Validates the sync array option */ function validateSyncArray(sync) { if (sync && !Array.isArray(sync)) { throw new Error('sync must be an array'); } } /** * Comprehensive validation of all multiplayer options */ function validateMultiplayerOptions(options) { const errors = []; const warnings = []; try { validateAuthenticationOptions(options); validateNamespace(options.namespace); validateApiBaseUrl(options.apiBaseUrl); validateSyncArray(options.sync); options.zFactor = validateZFactor(options.zFactor); } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); } return { isValid: errors.length === 0, errors, warnings, }; } /** * Validates multiplayer options, throwing on validation failure */ function validateOptions(options) { const result = validateMultiplayerOptions(options); if (!result.isValid) { throw new Error(`Configuration validation failed: ${result.errors.join(', ')}`); } if (result.warnings.length > 0 && typeof console !== 'undefined') { console.warn('Zustand Multiplayer Configuration Warnings:', result.warnings); } return options; } // ============================================================================ // HELPER FUNCTIONS // ============================================================================ /** * Creates normalize