UNPKG

@klastra/ksync

Version:

Real-time sync engine for modern applications with 300k+ ops/sec performance

1,009 lines 37.9 kB
import { EventEmitter } from 'events'; import { KSyncError } from './types.js'; import { MemoryStorage } from './storage/memory.js'; import { IndexedDBStorage } from './storage/indexeddb.js'; import { generateId } from './utils.js'; const log = (debug, category, ...args) => { if (debug === true || (typeof debug === 'object' && debug[category])) { console.log(`[kSync:${category}]`, ...args); } }; export class KSync extends EventEmitter { config; storage; syncClient; events = []; materializedState = null; version = 0; isConnected = false; isOnline = typeof navigator !== 'undefined' && navigator.onLine !== undefined ? navigator.onLine : true; messageQueue = []; presenceMap = new Map(); streamStates = new Map(); // Performance optimization state eventBatchTimeout; pendingEvents = []; materializationCache = new Map(); lastMaterializationHash = ''; performanceMetrics = { eventsProcessed: 0, averageProcessingTime: 0, materializationCount: 0, averageMaterializationTime: 0 }; // FIXED: Track timers/intervals for proper cleanup performanceInterval; onlineListener; offlineListener; constructor(config = {}) { super(); this.config = this.resolveConfig(config); this.initializeStorage(); // Note: initializeSync is now called lazily when needed to avoid require() in constructor this.setupOfflineHandling(); this.setupPerformanceMonitoring(); log(this.config.debug, 'events', 'KSync initialized with config:', this.config); } // === PUBLIC API === /** * Send an event to the system * @param type Event type - use namespaced names like 'chat.message' for organization * @param data Event data - will be validated if schema is defined * @param options Additional options for this specific event */ async send(type, data, options) { const startTime = performance.now(); const event = { id: generateId(), type, data, timestamp: Date.now(), version: ++this.version, userId: this.config.userId, ...(options?.metadata && { metadata: options.metadata }), ...(options?.ttl && { ttl: options.ttl }), ...(options?.priority && { priority: options.priority }) }; log(this.config.debug, 'events', 'Sending event:', { type, eventId: event.id }); // Add to local events immediately (optimistic update) await this.addEvent(event); // Handle sync based on configuration if (this.config.sync.enabled) { await this.ensureSyncInitialized(); log(this.config.debug, 'sync', `Sync check: syncClient=${!!this.syncClient}, connected=${this.isConnected}, online=${this.isOnline}`); if (this.syncClient && this.isConnected && this.isOnline) { try { await this.sendToServer(event); log(this.config.debug, 'sync', 'Event synced successfully:', event.id); if (options?.requireSync) { // Event has been successfully synced return; } } catch (error) { log(this.config.debug, 'sync', 'Sync failed, queuing event:', error); this.queueEvent(event); } } else { // Queue for later sync this.queueEvent(event); log(this.config.debug, 'offline', 'Event queued for offline sync:', event.id); } } // Always track performance metrics regardless of sync status this.updatePerformanceMetrics('eventProcessing', performance.now() - startTime); } /** * Listen to events of a specific type * @param type Event type to listen for * @param listener Callback function that receives the event data * @returns Unsubscribe function */ on(type, listener) { super.on(type, listener); return this; } /** * Listen to an event once * @param type Event type to listen for * @param listener Callback function * @returns This instance for chaining */ once(type, listener) { super.once(type, listener); return this; } /** * Remove event listener * @param type Event type * @param listener Listener function to remove * @returns This instance for chaining */ off(type, listener) { super.off(type, listener); return this; } /** * Get current materialized state * @param forceMaterialize Force re-materialization even if cached * @returns Current state or null if no materializer is configured */ getState(forceMaterialize = false) { if (!this.config.state.materializer) { log(this.config.debug, 'events', 'No materializer configured, returning raw events'); return { events: this.events }; } if (forceMaterialize || this.needsRematerialization()) { this.materialize(); } return this.materializedState; } /** * Get all events (backward compatibility) */ getEvents() { return [...this.events]; } /** * Initialize the instance (backward compatibility) */ async initialize(config, syncClient) { if (syncClient) { this.syncClient = syncClient; } if (this.config.sync.enabled && this.config.serverUrl) { await this.connect(); } } /** * Define a schema (backward compatibility - no-op for now) */ defineSchema(name, schema) { log(this.config.debug, 'events', `Schema defined: ${name}`); // No-op for backward compatibility } /** * Define a materializer (backward compatibility) */ defineMaterializer(name, materializer) { if (!this.config.state.materializer) { // If no materializer set, use this one this.config.state.materializer = materializer; } log(this.config.debug, 'events', `Materializer defined: ${name}`); } /** * Close/cleanup (backward compatibility) */ async close() { await this.disconnect(); } /** * Update presence (backward compatibility) */ async updatePresence(data) { await this.setPresence(data); } /** * Listen to presence updates (backward compatibility) */ onPresence(listener) { this.on('presence-update', listener); } /** * Get system status and metrics * @returns Comprehensive status information */ getStatus() { return { // Connection status connected: this.isConnected, online: this.isOnline, serverUrl: this.config.serverUrl, // Data status events: this.events.length, queued: this.messageQueue.length, version: this.version, // User context room: this.config.room, userId: this.config.userId, // Performance metrics performance: { ...this.performanceMetrics }, // Feature status features: { syncEnabled: this.config.sync.enabled, offlineEnabled: this.config.offline.enabled, presenceEnabled: this.config.features.presence, streamingEnabled: this.config.features.streaming } }; } /** * Manually trigger sync (useful for manual sync mode or troubleshooting) * @param options Sync options */ async sync(options) { if (!this.config.sync.enabled) { throw new KSyncError('Sync is disabled', 'SYNC_DISABLED'); } if (!this.isOnline) { throw new KSyncError('Cannot sync while offline', 'OFFLINE'); } if (!this.isConnected || options?.force) { await this.connect(); } const startTime = performance.now(); const result = await this.sendQueuedEvents(); const duration = performance.now() - startTime; log(this.config.debug, 'sync', `Sync completed in ${duration.toFixed(2)}ms:`, result); return result; } /** * Join a room (for multi-room applications) * @param room Room name to join * @param options Room join options */ async joinRoom(room, options) { const previousRoom = this.config.room; this.config.room = room; if (this.syncClient && this.isConnected) { if (options?.leaveCurrentRoom && previousRoom !== room) { await this.syncClient.send({ type: 'leave', room: previousRoom, data: { userId: this.config.userId } }); } await this.syncClient.send({ type: 'join', room: room, data: { userId: this.config.userId, syncHistory: options?.syncHistory !== false } }); } log(this.config.debug, 'sync', `Joined room: ${room}`); this.emit('room-changed', { from: previousRoom, to: room }); } /** * Set user presence information * @param info Presence information to set */ async setPresence(info) { if (!this.config.features.presence) { throw new KSyncError('Presence feature is disabled', 'FEATURE_DISABLED'); } const presence = { userId: this.config.userId, status: 'online', lastSeen: Date.now(), ...info }; this.presenceMap.set(this.config.userId, presence); await this.send('presence-update', presence); log(this.config.debug, 'events', 'Presence updated:', presence); } /** * Get presence information for all users * @param filter Optional filter function * @returns Array of presence information */ getPresence(filter) { const allPresence = Array.from(this.presenceMap.values()); return filter ? allPresence.filter(filter) : allPresence; } /** * Start a data stream (useful for AI responses, live updates, etc.) * @param streamId Unique stream identifier * @param options Stream configuration */ async startStream(streamId, options) { if (!this.config.features.streaming) { throw new KSyncError('Streaming feature is disabled', 'FEATURE_DISABLED'); } this.streamStates.set(streamId, { active: true, startTime: Date.now(), ...options }); await this.send('stream-start', { streamId, ...options }); this.emit('stream-start', { streamId, ...options }); // Auto-end stream if configured if (options?.autoEnd) { setTimeout(() => { if (this.streamStates.has(streamId)) { this.endStream(streamId); } }, options.autoEnd); } log(this.config.debug, 'events', 'Stream started:', streamId); } /** * Send data chunk to a stream * @param streamId Stream identifier * @param chunk Data chunk to send */ async streamChunk(streamId, chunk) { if (!this.streamStates.has(streamId)) { throw new KSyncError('Stream not found', 'STREAM_NOT_FOUND'); } const streamData = { streamId, data: chunk.data, metadata: chunk.metadata, complete: chunk.complete }; await this.send('stream-chunk', streamData); this.emit('stream-chunk', streamData); } /** * End a stream * @param streamId Stream identifier * @param data Optional final data */ async endStream(streamId, data) { this.streamStates.delete(streamId); const streamData = { streamId, data, endTime: Date.now() }; await this.send('stream-end', streamData); this.emit('stream-end', streamData); log(this.config.debug, 'events', 'Stream ended:', streamId); } /** * Get all active streams * @returns Array of active stream IDs and their metadata */ getActiveStreams() { return Array.from(this.streamStates.entries()).map(([streamId, metadata]) => ({ streamId, metadata })); } /** * Connect to the sync server (usually automatic) * @param options Connection options */ async connect(options) { if (!this.config.sync.enabled || !this.config.serverUrl) { throw new KSyncError('Sync is not configured', 'SYNC_NOT_CONFIGURED'); } if (this.isConnected && !options?.force) { return; } await this.ensureSyncInitialized(); if (!this.syncClient) { throw new KSyncError('Failed to initialize sync client', 'SYNC_INIT_FAILED'); } const startTime = performance.now(); await this.syncClient.connect(); const duration = performance.now() - startTime; log(this.config.debug, 'sync', `Connected in ${duration.toFixed(2)}ms`); // Auto-join room if (this.config.room) { await this.joinRoom(this.config.room); } // Auto-authenticate if (this.config.auth.token || this.config.auth.provider) { await this.authenticate(); } // Send queued events await this.sendQueuedEvents(); } /** * Disconnect from the sync server */ async disconnect() { // FIXED: Proper cleanup to prevent tests hanging // Clean up sync client if (this.syncClient) { await this.syncClient.disconnect(); } this.isConnected = false; // Clean up timers and intervals if (this.eventBatchTimeout) { clearTimeout(this.eventBatchTimeout); this.eventBatchTimeout = undefined; } if (this.performanceInterval) { clearInterval(this.performanceInterval); this.performanceInterval = undefined; } // Clean up event listeners if (typeof window !== 'undefined') { if (this.onlineListener) { window.removeEventListener('online', this.onlineListener); this.onlineListener = undefined; } if (this.offlineListener) { window.removeEventListener('offline', this.offlineListener); this.offlineListener = undefined; } } // Clean up all event listeners from EventEmitter this.removeAllListeners(); log(this.config.debug, 'sync', 'Disconnected from server and cleaned up resources'); this.emit('disconnected'); } /** * Clear all events and reset state * @param options Clear options */ async clear(options) { const opts = { events: true, state: true, queue: true, storage: false, ...options }; if (opts.events) { this.events = []; this.version = 0; } if (opts.state) { this.materializedState = null; this.materializationCache.clear(); } if (opts.queue) { this.messageQueue = []; } if (opts.storage) { await this.storage.clear(); } log(this.config.debug, 'events', 'Cleared data:', opts); this.emit('cleared', opts); } // === PRIVATE METHODS === resolveConfig(config) { const userId = config.userId || config.clientId || this.generateUserId(); const serverUrl = config.serverUrl || ''; const hasServer = Boolean(serverUrl); return { serverUrl, room: config.room || 'default', userId, clientId: userId, // Backward compatibility auth: { token: config.auth?.token, provider: config.auth?.provider, type: config.auth?.type || 'bearer' }, storage: { type: config.storage?.type || 'memory', instance: config.storage?.instance, options: { persistEvents: config.storage?.options?.persistEvents ?? true, maxEvents: config.storage?.options?.maxEvents || 10000, compression: config.storage?.options?.compression || false } }, sync: { // Enable sync by default if serverUrl is provided enabled: config.sync?.enabled ?? hasServer, client: config.sync?.client, // Default to realtime mode for better UX mode: config.sync?.mode || 'realtime', options: { autoReconnect: config.sync?.options?.autoReconnect ?? true, maxReconnectAttempts: config.sync?.options?.maxReconnectAttempts || 10, reconnectDelay: config.sync?.options?.reconnectDelay || 1000, heartbeatInterval: config.sync?.options?.heartbeatInterval || 30000 } }, performance: { batchSize: config.performance?.batchSize || 50, // Smaller batches for better responsiveness batchDelay: config.performance?.batchDelay || 5, // Faster batching materializationCaching: config.performance?.materializationCaching ?? true, compressionThreshold: config.performance?.compressionThreshold || 1024 }, offline: { enabled: config.offline?.enabled ?? true, queueSize: config.offline?.queueSize || 1000, persistence: config.offline?.persistence ?? true, syncOnReconnect: config.offline?.syncOnReconnect ?? true }, state: { materializer: config.state?.materializer, enableCaching: config.state?.enableCaching ?? true, // Enable auto-materialization by default for better UX autoMaterialize: config.state?.autoMaterialize ?? true }, debug: typeof config.debug === 'boolean' ? { events: config.debug, sync: config.debug, performance: config.debug, storage: config.debug } : { events: config.debug?.events || false, sync: config.debug?.sync || false, performance: config.debug?.performance || false, storage: config.debug?.storage || false }, features: { presence: config.features?.presence ?? true, streaming: config.features?.streaming ?? true, encryption: config.features?.encryption || false } }; } initializeStorage() { if (this.config.storage.instance) { this.storage = this.config.storage.instance; return; } switch (this.config.storage.type) { case 'memory': this.storage = new MemoryStorage(); break; case 'indexeddb': this.storage = new IndexedDBStorage(); break; case 'file': // TODO: Implement file storage this.storage = new MemoryStorage(); log(this.config.debug, 'storage', 'File storage not implemented, using memory'); break; default: this.storage = new MemoryStorage(); } log(this.config.debug, 'storage', `Initialized ${this.config.storage.type} storage`); } async ensureSyncInitialized() { if (!this.config.sync.enabled || this.syncClient) return; // Initialize WebSocket client if serverUrl is provided and no custom client if (this.config.serverUrl && !this.config.sync.client) { const { WebSocketSyncClient } = await import('./sync/websocket-client.js'); this.syncClient = new WebSocketSyncClient(this.config.serverUrl, this.config.sync.options.maxReconnectAttempts, this.config.sync.options.reconnectDelay, this.config.debug.sync); this.setupSyncHandlers(); } else if (this.config.sync.client) { this.syncClient = this.config.sync.client; this.setupSyncHandlers(); } log(this.config.debug, 'sync', 'Sync client initialized'); } setupOfflineHandling() { if (!this.config.offline.enabled || typeof window === 'undefined') return; // FIXED: Track event listeners for proper cleanup this.onlineListener = () => { this.isOnline = true; log(this.config.debug, 'offline', 'Back online'); if (this.config.offline.syncOnReconnect && this.config.sync.enabled) { this.connect().then(() => this.sync()).catch(error => { log(this.config.debug, 'sync', 'Reconnect sync failed:', error); }); } this.emit('online'); }; this.offlineListener = () => { this.isOnline = false; log(this.config.debug, 'offline', 'Gone offline'); this.emit('offline'); }; window.addEventListener('online', this.onlineListener); window.addEventListener('offline', this.offlineListener); } setupPerformanceMonitoring() { if (!this.config.debug.performance) return; // FIXED: Track interval for proper cleanup this.performanceInterval = setInterval(() => { // Only log if there's actual activity if (this.performanceMetrics.eventsProcessed > 0 || this.performanceMetrics.materializationCount > 0) { log(this.config.debug, 'performance', 'Metrics:', this.performanceMetrics); } }, 60000); } async addEvent(event) { // Add to pending batch this.pendingEvents.push(event); // Trigger batch processing if (this.pendingEvents.length >= this.config.performance.batchSize) { await this.flushPendingEvents(); } else { this.debounceBatchFlush(); } } debounceBatchFlush() { if (this.eventBatchTimeout) { clearTimeout(this.eventBatchTimeout); } this.eventBatchTimeout = setTimeout(() => { this.flushPendingEvents(); }, this.config.performance.batchDelay); } async flushPendingEvents() { if (this.pendingEvents.length === 0) return; const eventsToProcess = [...this.pendingEvents]; this.pendingEvents = []; // Add to main events array this.events.push(...eventsToProcess); // Trim events if over limit if (this.events.length > this.config.storage.options.maxEvents) { const trimCount = this.events.length - this.config.storage.options.maxEvents; this.events = this.events.slice(trimCount); log(this.config.debug, 'storage', `Trimmed ${trimCount} old events`); } // Store in persistent storage if (this.config.storage.options.persistEvents) { try { await this.storage.saveEvents(eventsToProcess); log(this.config.debug, 'storage', `Saved ${eventsToProcess.length} events`); } catch (error) { log(this.config.debug, 'storage', 'Failed to save events:', error); } } // Emit events eventsToProcess.forEach(event => { this.emit(event.type, event.data, event); this.emit('event', event); }); // Auto-materialize if enabled if (this.config.state.autoMaterialize && this.config.state.materializer) { this.materialize(); } // Update performance metrics this.updatePerformanceMetrics('eventsProcessed', eventsToProcess.length); // Invalidate materialization cache this.lastMaterializationHash = ''; } materialize() { if (!this.config.state.materializer) return; const startTime = performance.now(); // Check cache first if (this.config.performance.materializationCaching) { const eventsHash = this.getEventsHash(); if (this.materializationCache.has(eventsHash)) { this.materializedState = this.materializationCache.get(eventsHash); this.lastMaterializationHash = eventsHash; return; } } // Materialize state this.materializedState = this.config.state.materializer(this.events); const duration = performance.now() - startTime; // Cache result if (this.config.performance.materializationCaching) { const eventsHash = this.getEventsHash(); this.materializationCache.set(eventsHash, this.materializedState); this.lastMaterializationHash = eventsHash; // Limit cache size if (this.materializationCache.size > 10) { const firstKey = this.materializationCache.keys().next().value; if (firstKey !== undefined) { this.materializationCache.delete(firstKey); } } } this.updatePerformanceMetrics('materialization', duration); log(this.config.debug, 'performance', `Materialized ${this.events.length} events in ${duration.toFixed(2)}ms`); } needsRematerialization() { if (!this.config.performance.materializationCaching) return true; return this.lastMaterializationHash !== this.getEventsHash(); } getEventsHash() { return `${this.events.length}-${this.version}`; } queueEvent(event) { this.messageQueue.push(event); // Limit queue size if (this.messageQueue.length > this.config.offline.queueSize) { this.messageQueue = this.messageQueue.slice(-this.config.offline.queueSize); log(this.config.debug, 'offline', `Queue size limited to ${this.config.offline.queueSize} events`); } } async sendQueuedEvents() { if (!this.syncClient || !this.isConnected || this.messageQueue.length === 0) { return { synced: 0, errors: 0 }; } const events = [...this.messageQueue]; this.messageQueue = []; let synced = 0; let errors = 0; // Send in batches for better performance const batches = []; for (let i = 0; i < events.length; i += this.config.performance.batchSize) { batches.push(events.slice(i, i + this.config.performance.batchSize)); } for (const batch of batches) { try { await this.syncClient.send({ type: 'event-batch', data: batch }); synced += batch.length; log(this.config.debug, 'sync', `Synced batch of ${batch.length} events`); } catch (error) { errors += batch.length; log(this.config.debug, 'sync', 'Batch sync failed:', error); // Put events back at front of queue this.messageQueue.unshift(...batch); break; } } return { synced, errors }; } async sendToServer(event) { if (!this.syncClient) { throw new KSyncError('Not connected', 'NOT_CONNECTED'); } if (!this.isConnected) { log(this.config.debug, 'sync', 'WARNING: Sending event but not connected, queueing'); this.queueEvent(event); return; } log(this.config.debug, 'sync', `Sending event to server: ${event.type} in room ${this.config.room}`); await this.syncClient.send({ type: 'event', room: this.config.room, data: event }); log(this.config.debug, 'sync', `Event sent to server successfully: ${event.type}`); } async authenticate() { if (!this.syncClient || !this.isConnected) return; let token = this.config.auth.token; if (this.config.auth.provider) { token = await this.config.auth.provider(); } if (token) { await this.syncClient.send({ type: 'auth', data: { token, type: this.config.auth.type, userId: this.config.userId } }); log(this.config.debug, 'sync', 'Authenticated successfully'); } } setupSyncHandlers() { if (!this.syncClient) return; this.syncClient.onMessage((message) => { this.handleSyncMessage(message); }); this.syncClient.onConnect(() => { this.isConnected = true; log(this.config.debug, 'sync', 'Connected to server'); this.emit('connected'); }); this.syncClient.onDisconnect(() => { this.isConnected = false; log(this.config.debug, 'sync', 'Disconnected from server'); this.emit('disconnected'); }); } async handleSyncMessage(message) { switch (message.type) { case 'event': if (message.data) { // Check if this is our own event (prevent duplication) const isOwnEvent = message.data._meta?.userId === this.config.userId; if (!isOwnEvent) { await this.addEvent(message.data); } // Always emit the event for listening, but don't double-store our own events this.emit(message.data.type, message.data.data, message.data); } break; case 'event-batch': if (Array.isArray(message.data)) { for (const event of message.data) { const isOwnEvent = event._meta?.userId === this.config.userId; if (!isOwnEvent) { await this.addEvent(event); } // Always emit for listening this.emit(event.type, event.data, event); } } break; case 'sync': case 'sync-response': if (Array.isArray(message.events)) { for (const event of message.events) { // For sync responses, check if we already have this event const existingEvent = this.events.find(e => e.id === event.id); if (!existingEvent) { await this.addEvent(event); } } } break; case 'presence-update': if (message.data && this.config.features.presence) { this.presenceMap.set(message.data.userId, message.data); this.emit('presence-update', message.data); } break; case 'stream-start': case 'stream-chunk': case 'stream-end': if (message.data && this.config.features.streaming) { this.emit(message.type, message.data); } break; default: log(this.config.debug, 'sync', 'Unknown message type:', message.type); } } updatePerformanceMetrics(type, value) { switch (type) { case 'eventProcessing': this.performanceMetrics.eventsProcessed++; this.performanceMetrics.averageProcessingTime = this.performanceMetrics.averageProcessingTime === 0 ? value : (this.performanceMetrics.averageProcessingTime + value) / 2; break; case 'materialization': this.performanceMetrics.materializationCount++; this.performanceMetrics.averageMaterializationTime = this.performanceMetrics.averageMaterializationTime === 0 ? value : (this.performanceMetrics.averageMaterializationTime + value) / 2; break; case 'eventsProcessed': this.performanceMetrics.eventsProcessed += value; break; } // Log for debugging in test environment if (this.config.debug.performance) { log(this.config.debug, 'performance', `Metrics updated - ${type}: ${value}`, this.performanceMetrics); } } generateUserId() { return `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } } // === CONVENIENCE FACTORY FUNCTIONS === /** * Create a KSync instance with sensible defaults * @param config Configuration options * @returns KSync instance */ export function createKSync(config) { return new KSync(config); } /** * Create a KSync instance optimized for chat applications * @param room Chat room name * @param config Additional configuration * @returns KSync instance configured for chat */ export function createChat(room, config) { return new KSync({ room, // FIXED: Don't force server URL or sync - let user configure serverUrl: config?.serverUrl, features: { presence: true, streaming: false, ...config?.features }, sync: { // FIXED: Only enable sync if serverUrl is provided enabled: config?.sync?.enabled ?? Boolean(config?.serverUrl), mode: 'realtime', options: { autoReconnect: true, maxReconnectAttempts: 10, reconnectDelay: 1000, ...config?.sync?.options }, ...config?.sync }, state: { autoMaterialize: true, materializer: (events) => { const messages = events.filter(e => e.type === 'message').map(e => e.data); return { messages, messageCount: messages.length }; }, ...config?.state }, debug: config?.debug ?? false, ...config }); } /** * Create a KSync instance optimized for todo/task applications * @param config Configuration options * @returns KSync instance configured for todos */ export function createTodos(config) { return new KSync({ room: 'todos', // FIXED: Only enable sync if serverUrl is provided serverUrl: config?.serverUrl, offline: { enabled: true, queueSize: 5000, persistence: true, ...config?.offline }, storage: { type: 'indexeddb', options: { persistEvents: true, maxEvents: 50000, ...config?.storage?.options }, ...config?.storage }, sync: { enabled: config?.sync?.enabled ?? Boolean(config?.serverUrl), ...config?.sync }, debug: config?.debug ?? false, // FIXED: Don't force debug ...config }); } /** * Create a KSync instance optimized for multiplayer games * @param gameId Game identifier * @param config Additional configuration * @returns KSync instance configured for gaming */ export function createGame(gameId, config) { return new KSync({ room: `game-${gameId}`, // FIXED: Only enable sync if serverUrl is provided serverUrl: config?.serverUrl, performance: { batchSize: 50, batchDelay: 5, ...config?.performance }, features: { presence: true, streaming: true, ...config?.features }, sync: { enabled: config?.sync?.enabled ?? Boolean(config?.serverUrl), ...config?.sync }, debug: config?.debug ?? false, // FIXED: Don't force debug ...config }); } /** * Create a KSync instance optimized for AI applications with streaming * @param conversationId Conversation identifier * @param config Additional configuration * @returns KSync instance configured for AI */ export function createAI(conversationId, config) { return new KSync({ room: conversationId || 'ai-chat', // FIXED: Only enable sync if serverUrl is provided serverUrl: config?.serverUrl, features: { streaming: true, presence: false, ...config?.features }, performance: { batchSize: 10, batchDelay: 1, ...config?.performance }, sync: { enabled: config?.sync?.enabled ?? Boolean(config?.serverUrl), ...config?.sync }, debug: config?.debug ?? false, // FIXED: Don't force debug ...config }); } //# sourceMappingURL=core.js.map