@hpkv/zustand-multiplayer
Version:
A multiplayer middleware for Zustand using HPKV
1,425 lines (1,410 loc) • 54 kB
JavaScript
/**
* @license @hpkv/zustand-multiplayer v0.6.1
* Copyright (c) 2025 HPKV Team
* This source code is licensed under the MIT license.
*/
import { ConnectionState, WebsocketTokenManager, HPKVClientFactory } from '@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 === ConnectionState.DISCONNECTED) {
this.hasHydrated = false;
this.updateMultiplayerState({ hasHydrated: false });
}
this.updateMultiplayerState({ connectionState: state });
if (state === 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 === ConnectionState.CONNECTED) {
this.updateMultiplayerState({ connectionState: 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: 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
var LogLevel;
(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";
})(LogLevel || (LogLevel = {}));
class Logger {
constructor(level = LogLevel.INFO) {
this.level = level;
if (level < LogLevel.DEBUG || level > LogLevel.NONE) {
this.level = 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(LogLevel.DEBUG)) {
console.debug(this.formatMessage('DEBUG', message, { timestamp: Date.now(), ...context }));
}
}
info(message, context = {}) {
if (this.shouldLog(LogLevel.INFO)) {
console.info(this.formatMessage('INFO', message, { timestamp: Date.now(), ...context }));
}
}
warn(message, context = {}) {
if (this.shouldLog(LogLevel.WARN)) {
console.warn(this.formatMessage('WARN', message, { timestamp: Date.now(), ...context }));
}
}
error(message, error, context = {}) {
if (this.shouldLog(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 = 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 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 === 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(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 = 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(ConnectionState.CONNECTED),
disconnected: () => this.notifyConnectionListeners(ConnectionState.DISCONNECTED),
reconnecting: () => this.notifyConnectionListeners(ConnectionState.RECONNECTING),
reconnectFailed: () => this.notifyConnectionListeners(ConnectionState.DISCONNECTED),
error: () => {
this.logger.debug('WebSocket connection error occurred', {
operation: 'connection-error',
clientId: this.clientId,
});
this.notifyConnectionListeners(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 normalized options with safe defaults
*/
function normalizeOptions(options, nonFunctionKeys) {
const zFactor = Math.min(Math.max(MIN_Z_FACTOR, options.zFactor ?? DEFAULT_Z_FACTOR), MAX_Z_FACTOR);
return {
sync: options.sync ?? nonFunctionKeys,
logLevel: LogLevel.INFO,
...o