mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-30 01:36:24 +01:00 
			
		
		
		
	
		
			
	
	
		
			1792 lines
		
	
	
		
			48 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
		
		
			
		
	
	
			1792 lines
		
	
	
		
			48 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
|  | # WebSocket API Documentation
 | ||
|  | 
 | ||
|  | ## Table of Contents
 | ||
|  | 1. [Introduction](#introduction) | ||
|  | 2. [Connection Setup](#connection-setup) | ||
|  | 3. [Authentication](#authentication) | ||
|  | 4. [Message Format](#message-format) | ||
|  | 5. [Event Types](#event-types) | ||
|  | 6. [Real-time Synchronization](#real-time-synchronization) | ||
|  | 7. [Custom Event Broadcasting](#custom-event-broadcasting) | ||
|  | 8. [Client Implementation Examples](#client-implementation-examples) | ||
|  | 9. [Debugging WebSocket Connections](#debugging-websocket-connections) | ||
|  | 10. [Best Practices](#best-practices) | ||
|  | 11. [Error Handling](#error-handling) | ||
|  | 12. [Performance Optimization](#performance-optimization) | ||
|  | 
 | ||
|  | ## Introduction
 | ||
|  | 
 | ||
|  | The Trilium WebSocket API provides real-time bidirectional communication between the server and clients. It's primarily used for: | ||
|  | 
 | ||
|  | - **Real-time synchronization** of note changes across multiple clients | ||
|  | - **Live collaboration** features | ||
|  | - **Push notifications** for events | ||
|  | - **Streaming updates** for long-running operations | ||
|  | - **Frontend script execution** from backend | ||
|  | 
 | ||
|  | ### Key Features
 | ||
|  | 
 | ||
|  | - Automatic reconnection with exponential backoff | ||
|  | - Message queuing during disconnection | ||
|  | - Event-based architecture | ||
|  | - Support for custom event types | ||
|  | - Built-in heartbeat/ping mechanism | ||
|  | 
 | ||
|  | ### WebSocket URL
 | ||
|  | 
 | ||
|  | ``` | ||
|  | ws://localhost:8080   // Local development | ||
|  | wss://your-server.com  // Production with SSL | ||
|  | ``` | ||
|  | 
 | ||
|  | ## Connection Setup
 | ||
|  | 
 | ||
|  | ### Basic Connection
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | // JavaScript - Basic WebSocket connection | ||
|  | const ws = new WebSocket('ws://localhost:8080'); | ||
|  | 
 | ||
|  | ws.onopen = (event) => { | ||
|  |     console.log('Connected to Trilium WebSocket'); | ||
|  | }; | ||
|  | 
 | ||
|  | ws.onmessage = (event) => { | ||
|  |     const message = JSON.parse(event.data); | ||
|  |     console.log('Received:', message); | ||
|  | }; | ||
|  | 
 | ||
|  | ws.onerror = (error) => { | ||
|  |     console.error('WebSocket error:', error); | ||
|  | }; | ||
|  | 
 | ||
|  | ws.onclose = (event) => { | ||
|  |     console.log('Disconnected from WebSocket'); | ||
|  | }; | ||
|  | ``` | ||
|  | 
 | ||
|  | ### Advanced Connection Manager
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | class TriliumWebSocketManager { | ||
|  |     constructor(url, options = {}) { | ||
|  |         this.url = url; | ||
|  |         this.options = { | ||
|  |             reconnectInterval: 5000, | ||
|  |             maxReconnectInterval: 30000, | ||
|  |             reconnectDecay: 1.5, | ||
|  |             timeoutInterval: 2000, | ||
|  |             maxReconnectAttempts: null, | ||
|  |             ...options | ||
|  |         }; | ||
|  |          | ||
|  |         this.ws = null; | ||
|  |         this.forcedClose = false; | ||
|  |         this.reconnectAttempts = 0; | ||
|  |         this.messageQueue = []; | ||
|  |         this.eventHandlers = new Map(); | ||
|  |         this.reconnectTimer = null; | ||
|  |         this.pingTimer = null; | ||
|  |     } | ||
|  |      | ||
|  |     connect() { | ||
|  |         this.ws = new WebSocket(this.url); | ||
|  |          | ||
|  |         this.ws.onopen = (event) => { | ||
|  |             console.log('WebSocket connected'); | ||
|  |             this.onOpen(event); | ||
|  |         }; | ||
|  |          | ||
|  |         this.ws.onmessage = (event) => { | ||
|  |             this.onMessage(event); | ||
|  |         }; | ||
|  |          | ||
|  |         this.ws.onerror = (error) => { | ||
|  |             console.error('WebSocket error:', error); | ||
|  |             this.onError(error); | ||
|  |         }; | ||
|  |          | ||
|  |         this.ws.onclose = (event) => { | ||
|  |             console.log('WebSocket closed'); | ||
|  |             this.onClose(event); | ||
|  |         }; | ||
|  |     } | ||
|  |      | ||
|  |     onOpen(event) { | ||
|  |         this.reconnectAttempts = 0; | ||
|  |          | ||
|  |         // Send queued messages | ||
|  |         while (this.messageQueue.length > 0) { | ||
|  |             const message = this.messageQueue.shift(); | ||
|  |             this.send(message); | ||
|  |         } | ||
|  |          | ||
|  |         // Start ping timer | ||
|  |         this.startPing(); | ||
|  |          | ||
|  |         // Emit open event | ||
|  |         this.emit('open', event); | ||
|  |     } | ||
|  |      | ||
|  |     onMessage(event) { | ||
|  |         try { | ||
|  |             const message = JSON.parse(event.data); | ||
|  |              | ||
|  |             // Handle different message types | ||
|  |             if (message.type === 'pong') { | ||
|  |                 this.handlePong(message); | ||
|  |             } else { | ||
|  |                 this.emit('message', message); | ||
|  |                  | ||
|  |                 // Emit specific event type | ||
|  |                 if (message.type) { | ||
|  |                     this.emit(message.type, message.data || message); | ||
|  |                 } | ||
|  |             } | ||
|  |         } catch (error) { | ||
|  |             console.error('Failed to parse message:', error); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     onError(error) { | ||
|  |         this.emit('error', error); | ||
|  |     } | ||
|  |      | ||
|  |     onClose(event) { | ||
|  |         this.ws = null; | ||
|  |          | ||
|  |         if (!this.forcedClose) { | ||
|  |             this.reconnect(); | ||
|  |         } | ||
|  |          | ||
|  |         this.stopPing(); | ||
|  |         this.emit('close', event); | ||
|  |     } | ||
|  |      | ||
|  |     reconnect() { | ||
|  |         if (this.options.maxReconnectAttempts &&  | ||
|  |             this.reconnectAttempts >= this.options.maxReconnectAttempts) { | ||
|  |             this.emit('max-reconnects'); | ||
|  |             return; | ||
|  |         } | ||
|  |          | ||
|  |         this.reconnectAttempts++; | ||
|  |          | ||
|  |         const timeout = Math.min( | ||
|  |             this.options.reconnectInterval * Math.pow( | ||
|  |                 this.options.reconnectDecay, | ||
|  |                 this.reconnectAttempts - 1 | ||
|  |             ), | ||
|  |             this.options.maxReconnectInterval | ||
|  |         ); | ||
|  |          | ||
|  |         console.log(`Reconnecting in ${timeout}ms (attempt ${this.reconnectAttempts})`); | ||
|  |          | ||
|  |         this.reconnectTimer = setTimeout(() => { | ||
|  |             console.log('Reconnecting...'); | ||
|  |             this.connect(); | ||
|  |         }, timeout); | ||
|  |          | ||
|  |         this.emit('reconnecting', { | ||
|  |             attempt: this.reconnectAttempts, | ||
|  |             timeout | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     send(data) { | ||
|  |         if (this.ws && this.ws.readyState === WebSocket.OPEN) { | ||
|  |             const message = typeof data === 'string' ? data : JSON.stringify(data); | ||
|  |             this.ws.send(message); | ||
|  |         } else { | ||
|  |             // Queue message for later | ||
|  |             this.messageQueue.push(data); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     startPing() { | ||
|  |         this.pingTimer = setInterval(() => { | ||
|  |             if (this.ws && this.ws.readyState === WebSocket.OPEN) { | ||
|  |                 this.send({ type: 'ping', timestamp: Date.now() }); | ||
|  |             } | ||
|  |         }, 30000); // Ping every 30 seconds | ||
|  |     } | ||
|  |      | ||
|  |     stopPing() { | ||
|  |         if (this.pingTimer) { | ||
|  |             clearInterval(this.pingTimer); | ||
|  |             this.pingTimer = null; | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     handlePong(message) { | ||
|  |         const latency = Date.now() - message.timestamp; | ||
|  |         this.emit('latency', latency); | ||
|  |     } | ||
|  |      | ||
|  |     on(event, handler) { | ||
|  |         if (!this.eventHandlers.has(event)) { | ||
|  |             this.eventHandlers.set(event, []); | ||
|  |         } | ||
|  |         this.eventHandlers.get(event).push(handler); | ||
|  |     } | ||
|  |      | ||
|  |     off(event, handler) { | ||
|  |         const handlers = this.eventHandlers.get(event); | ||
|  |         if (handlers) { | ||
|  |             const index = handlers.indexOf(handler); | ||
|  |             if (index !== -1) { | ||
|  |                 handlers.splice(index, 1); | ||
|  |             } | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     emit(event, data) { | ||
|  |         const handlers = this.eventHandlers.get(event); | ||
|  |         if (handlers) { | ||
|  |             handlers.forEach(handler => { | ||
|  |                 try { | ||
|  |                     handler(data); | ||
|  |                 } catch (error) { | ||
|  |                     console.error(`Error in event handler for ${event}:`, error); | ||
|  |                 } | ||
|  |             }); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     close() { | ||
|  |         this.forcedClose = true; | ||
|  |          | ||
|  |         if (this.reconnectTimer) { | ||
|  |             clearTimeout(this.reconnectTimer); | ||
|  |             this.reconnectTimer = null; | ||
|  |         } | ||
|  |          | ||
|  |         if (this.ws) { | ||
|  |             this.ws.close(); | ||
|  |         } | ||
|  |          | ||
|  |         this.stopPing(); | ||
|  |     } | ||
|  |      | ||
|  |     getState() { | ||
|  |         if (!this.ws) { | ||
|  |             return 'DISCONNECTED'; | ||
|  |         } | ||
|  |          | ||
|  |         switch (this.ws.readyState) { | ||
|  |             case WebSocket.CONNECTING: | ||
|  |                 return 'CONNECTING'; | ||
|  |             case WebSocket.OPEN: | ||
|  |                 return 'CONNECTED'; | ||
|  |             case WebSocket.CLOSING: | ||
|  |                 return 'CLOSING'; | ||
|  |             case WebSocket.CLOSED: | ||
|  |                 return 'DISCONNECTED'; | ||
|  |             default: | ||
|  |                 return 'UNKNOWN'; | ||
|  |         } | ||
|  |     } | ||
|  | } | ||
|  | ``` | ||
|  | 
 | ||
|  | ## Authentication
 | ||
|  | 
 | ||
|  | WebSocket connections inherit authentication from the HTTP session or require token-based auth. | ||
|  | 
 | ||
|  | ### Session-Based Authentication
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | // Session auth (cookies must be included) | ||
|  | const ws = new WebSocket('ws://localhost:8080', { | ||
|  |     headers: { | ||
|  |         'Cookie': document.cookie  // Include session cookie | ||
|  |     } | ||
|  | }); | ||
|  | ``` | ||
|  | 
 | ||
|  | ### Token-Based Authentication
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | // Send auth token after connection | ||
|  | class AuthenticatedWebSocket { | ||
|  |     constructor(url, token) { | ||
|  |         this.url = url; | ||
|  |         this.token = token; | ||
|  |         this.authenticated = false; | ||
|  |     } | ||
|  |      | ||
|  |     connect() { | ||
|  |         this.ws = new WebSocket(this.url); | ||
|  |          | ||
|  |         this.ws.onopen = () => { | ||
|  |             // Send authentication message | ||
|  |             this.send({ | ||
|  |                 type: 'auth', | ||
|  |                 token: this.token | ||
|  |             }); | ||
|  |         }; | ||
|  |          | ||
|  |         this.ws.onmessage = (event) => { | ||
|  |             const message = JSON.parse(event.data); | ||
|  |              | ||
|  |             if (message.type === 'auth-success') { | ||
|  |                 this.authenticated = true; | ||
|  |                 this.onAuthenticated(); | ||
|  |             } else if (message.type === 'auth-error') { | ||
|  |                 this.onAuthError(message.error); | ||
|  |             } else if (this.authenticated) { | ||
|  |                 this.handleMessage(message); | ||
|  |             } | ||
|  |         }; | ||
|  |     } | ||
|  |      | ||
|  |     send(data) { | ||
|  |         if (this.ws && this.ws.readyState === WebSocket.OPEN) { | ||
|  |             this.ws.send(JSON.stringify(data)); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     onAuthenticated() { | ||
|  |         console.log('WebSocket authenticated'); | ||
|  |     } | ||
|  |      | ||
|  |     onAuthError(error) { | ||
|  |         console.error('Authentication failed:', error); | ||
|  |     } | ||
|  |      | ||
|  |     handleMessage(message) { | ||
|  |         // Handle authenticated messages | ||
|  |     } | ||
|  | } | ||
|  | ``` | ||
|  | 
 | ||
|  | ## Message Format
 | ||
|  | 
 | ||
|  | ### Standard Message Structure
 | ||
|  | 
 | ||
|  | ```typescript | ||
|  | interface WebSocketMessage { | ||
|  |     type: string;           // Message type identifier | ||
|  |     data?: any;            // Message payload | ||
|  |     timestamp?: number;    // Unix timestamp | ||
|  |     id?: string;          // Message ID for tracking | ||
|  |     error?: string;       // Error message if applicable | ||
|  | } | ||
|  | ``` | ||
|  | 
 | ||
|  | ### Common Message Types
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | // Incoming messages from server | ||
|  | const incomingMessages = { | ||
|  |     // Synchronization | ||
|  |     'sync': { | ||
|  |         type: 'sync', | ||
|  |         data: { | ||
|  |             entityChanges: [], | ||
|  |             lastSyncedPush: 12345 | ||
|  |         } | ||
|  |     }, | ||
|  |      | ||
|  |     // Entity changes | ||
|  |     'entity-changes': { | ||
|  |         type: 'entity-changes', | ||
|  |         data: [ | ||
|  |             { | ||
|  |                 entityName: 'notes', | ||
|  |                 entityId: 'noteId123', | ||
|  |                 action: 'update', | ||
|  |                 entity: { /* note data */ } | ||
|  |             } | ||
|  |         ] | ||
|  |     }, | ||
|  |      | ||
|  |     // Note events | ||
|  |     'note-created': { | ||
|  |         type: 'note-created', | ||
|  |         data: { | ||
|  |             noteId: 'newNoteId', | ||
|  |             title: 'New Note', | ||
|  |             parentNoteId: 'parentId' | ||
|  |         } | ||
|  |     }, | ||
|  |      | ||
|  |     'note-updated': { | ||
|  |         type: 'note-updated', | ||
|  |         data: { | ||
|  |             noteId: 'noteId123', | ||
|  |             changes: { title: 'Updated Title' } | ||
|  |         } | ||
|  |     }, | ||
|  |      | ||
|  |     'note-deleted': { | ||
|  |         type: 'note-deleted', | ||
|  |         data: { | ||
|  |             noteId: 'deletedNoteId' | ||
|  |         } | ||
|  |     }, | ||
|  |      | ||
|  |     // Tree structure changes | ||
|  |     'refresh-tree': { | ||
|  |         type: 'refresh-tree', | ||
|  |         data: { | ||
|  |             noteId: 'affectedNoteId' | ||
|  |         } | ||
|  |     }, | ||
|  |      | ||
|  |     // Script execution | ||
|  |     'frontend-script': { | ||
|  |         type: 'frontend-script', | ||
|  |         data: { | ||
|  |             script: 'console.log("Hello from backend")', | ||
|  |             params: { key: 'value' } | ||
|  |         } | ||
|  |     }, | ||
|  |      | ||
|  |     // Progress updates | ||
|  |     'progress-update': { | ||
|  |         type: 'progress-update', | ||
|  |         data: { | ||
|  |             taskId: 'task123', | ||
|  |             progress: 75, | ||
|  |             message: 'Processing...' | ||
|  |         } | ||
|  |     }, | ||
|  |      | ||
|  |     // LLM streaming | ||
|  |     'llm-stream': { | ||
|  |         type: 'llm-stream', | ||
|  |         chatNoteId: 'chatNote123', | ||
|  |         content: 'Streaming response...', | ||
|  |         done: false | ||
|  |     } | ||
|  | }; | ||
|  | 
 | ||
|  | // Outgoing messages to server | ||
|  | const outgoingMessages = { | ||
|  |     // Keep-alive ping | ||
|  |     'ping': { | ||
|  |         type: 'ping', | ||
|  |         timestamp: Date.now() | ||
|  |     }, | ||
|  |      | ||
|  |     // Client logging | ||
|  |     'log-error': { | ||
|  |         type: 'log-error', | ||
|  |         error: 'Error message', | ||
|  |         stack: 'Stack trace' | ||
|  |     }, | ||
|  |      | ||
|  |     'log-info': { | ||
|  |         type: 'log-info', | ||
|  |         info: 'Information message' | ||
|  |     }, | ||
|  |      | ||
|  |     // Custom events | ||
|  |     'custom-event': { | ||
|  |         type: 'custom-event', | ||
|  |         data: { | ||
|  |             eventName: 'user-action', | ||
|  |             payload: { /* custom data */ } | ||
|  |         } | ||
|  |     } | ||
|  | }; | ||
|  | ``` | ||
|  | 
 | ||
|  | ## Event Types
 | ||
|  | 
 | ||
|  | ### System Events
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | class TriliumEventHandler { | ||
|  |     constructor(wsManager) { | ||
|  |         this.wsManager = wsManager; | ||
|  |         this.setupEventHandlers(); | ||
|  |     } | ||
|  |      | ||
|  |     setupEventHandlers() { | ||
|  |         // Connection events | ||
|  |         this.wsManager.on('open', () => { | ||
|  |             console.log('Connected to Trilium'); | ||
|  |             this.onConnect(); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('close', () => { | ||
|  |             console.log('Disconnected from Trilium'); | ||
|  |             this.onDisconnect(); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('error', (error) => { | ||
|  |             console.error('WebSocket error:', error); | ||
|  |             this.onError(error); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('reconnecting', (info) => { | ||
|  |             console.log(`Reconnecting... Attempt ${info.attempt}`); | ||
|  |             this.onReconnecting(info); | ||
|  |         }); | ||
|  |          | ||
|  |         // Trilium-specific events | ||
|  |         this.wsManager.on('sync', (data) => { | ||
|  |             this.handleSync(data); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('entity-changes', (changes) => { | ||
|  |             this.handleEntityChanges(changes); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('note-created', (note) => { | ||
|  |             this.handleNoteCreated(note); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('note-updated', (update) => { | ||
|  |             this.handleNoteUpdated(update); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('note-deleted', (deletion) => { | ||
|  |             this.handleNoteDeleted(deletion); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('refresh-tree', (data) => { | ||
|  |             this.handleTreeRefresh(data); | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     onConnect() { | ||
|  |         // Update UI to show connected status | ||
|  |         this.updateConnectionStatus('connected'); | ||
|  |     } | ||
|  |      | ||
|  |     onDisconnect() { | ||
|  |         // Update UI to show disconnected status | ||
|  |         this.updateConnectionStatus('disconnected'); | ||
|  |     } | ||
|  |      | ||
|  |     onError(error) { | ||
|  |         // Handle error | ||
|  |         this.showError(error.message); | ||
|  |     } | ||
|  |      | ||
|  |     onReconnecting(info) { | ||
|  |         // Show reconnection status | ||
|  |         this.updateConnectionStatus(`reconnecting (${info.attempt})`); | ||
|  |     } | ||
|  |      | ||
|  |     handleSync(data) { | ||
|  |         console.log('Sync data received:', data); | ||
|  |         // Process synchronization data | ||
|  |         if (data.entityChanges && data.entityChanges.length > 0) { | ||
|  |             this.processSyncChanges(data.entityChanges); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     handleEntityChanges(changes) { | ||
|  |         console.log('Entity changes:', changes); | ||
|  |          | ||
|  |         changes.forEach(change => { | ||
|  |             switch (change.entityName) { | ||
|  |                 case 'notes': | ||
|  |                     this.processNoteChange(change); | ||
|  |                     break; | ||
|  |                 case 'branches': | ||
|  |                     this.processBranchChange(change); | ||
|  |                     break; | ||
|  |                 case 'attributes': | ||
|  |                     this.processAttributeChange(change); | ||
|  |                     break; | ||
|  |             } | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     handleNoteCreated(note) { | ||
|  |         console.log('Note created:', note); | ||
|  |         // Update local cache | ||
|  |         this.addNoteToCache(note); | ||
|  |         // Update UI | ||
|  |         this.addNoteToTree(note); | ||
|  |     } | ||
|  |      | ||
|  |     handleNoteUpdated(update) { | ||
|  |         console.log('Note updated:', update); | ||
|  |         // Update local cache | ||
|  |         this.updateNoteInCache(update.noteId, update.changes); | ||
|  |         // Update UI if note is visible | ||
|  |         if (this.isNoteVisible(update.noteId)) { | ||
|  |             this.refreshNoteDisplay(update.noteId); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     handleNoteDeleted(deletion) { | ||
|  |         console.log('Note deleted:', deletion); | ||
|  |         // Remove from cache | ||
|  |         this.removeNoteFromCache(deletion.noteId); | ||
|  |         // Update UI | ||
|  |         this.removeNoteFromTree(deletion.noteId); | ||
|  |     } | ||
|  |      | ||
|  |     handleTreeRefresh(data) { | ||
|  |         console.log('Tree refresh requested:', data); | ||
|  |         // Refresh tree structure | ||
|  |         this.refreshTreeBranch(data.noteId); | ||
|  |     } | ||
|  |      | ||
|  |     // Placeholder methods for UI updates | ||
|  |     updateConnectionStatus(status) { /* ... */ } | ||
|  |     showError(message) { /* ... */ } | ||
|  |     processSyncChanges(changes) { /* ... */ } | ||
|  |     processNoteChange(change) { /* ... */ } | ||
|  |     processBranchChange(change) { /* ... */ } | ||
|  |     processAttributeChange(change) { /* ... */ } | ||
|  |     addNoteToCache(note) { /* ... */ } | ||
|  |     addNoteToTree(note) { /* ... */ } | ||
|  |     updateNoteInCache(noteId, changes) { /* ... */ } | ||
|  |     isNoteVisible(noteId) { /* ... */ } | ||
|  |     refreshNoteDisplay(noteId) { /* ... */ } | ||
|  |     removeNoteFromCache(noteId) { /* ... */ } | ||
|  |     removeNoteFromTree(noteId) { /* ... */ } | ||
|  |     refreshTreeBranch(noteId) { /* ... */ } | ||
|  | } | ||
|  | ``` | ||
|  | 
 | ||
|  | ## Real-time Synchronization
 | ||
|  | 
 | ||
|  | ### Sync Protocol Implementation
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | class TriliumSyncManager { | ||
|  |     constructor(wsManager) { | ||
|  |         this.wsManager = wsManager; | ||
|  |         this.lastSyncedPush = null; | ||
|  |         this.pendingChanges = []; | ||
|  |         this.syncInProgress = false; | ||
|  |          | ||
|  |         this.setupSyncHandlers(); | ||
|  |     } | ||
|  |      | ||
|  |     setupSyncHandlers() { | ||
|  |         this.wsManager.on('sync', (data) => { | ||
|  |             this.handleIncomingSync(data); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('sync-complete', (data) => { | ||
|  |             this.onSyncComplete(data); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('sync-error', (error) => { | ||
|  |             this.onSyncError(error); | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     async handleIncomingSync(syncData) { | ||
|  |         console.log('Processing sync data:', syncData); | ||
|  |          | ||
|  |         this.syncInProgress = true; | ||
|  |          | ||
|  |         try { | ||
|  |             // Process entity changes in order | ||
|  |             for (const change of syncData.entityChanges) { | ||
|  |                 await this.processEntityChange(change); | ||
|  |             } | ||
|  |              | ||
|  |             // Update sync position | ||
|  |             this.lastSyncedPush = syncData.lastSyncedPush; | ||
|  |              | ||
|  |             // Send acknowledgment | ||
|  |             this.wsManager.send({ | ||
|  |                 type: 'sync-ack', | ||
|  |                 lastSyncedPush: this.lastSyncedPush | ||
|  |             }); | ||
|  |              | ||
|  |         } catch (error) { | ||
|  |             console.error('Sync processing error:', error); | ||
|  |             this.wsManager.send({ | ||
|  |                 type: 'sync-error', | ||
|  |                 error: error.message, | ||
|  |                 lastSyncedPush: this.lastSyncedPush | ||
|  |             }); | ||
|  |         } finally { | ||
|  |             this.syncInProgress = false; | ||
|  |             this.processPendingChanges(); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     async processEntityChange(change) { | ||
|  |         const { entityName, entityId, action, entity } = change; | ||
|  |          | ||
|  |         console.log(`Processing ${action} for ${entityName}:${entityId}`); | ||
|  |          | ||
|  |         switch (entityName) { | ||
|  |             case 'notes': | ||
|  |                 await this.processNoteChange(action, entityId, entity); | ||
|  |                 break; | ||
|  |             case 'branches': | ||
|  |                 await this.processBranchChange(action, entityId, entity); | ||
|  |                 break; | ||
|  |             case 'attributes': | ||
|  |                 await this.processAttributeChange(action, entityId, entity); | ||
|  |                 break; | ||
|  |             case 'note_contents': | ||
|  |                 await this.processContentChange(action, entityId, entity); | ||
|  |                 break; | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     async processNoteChange(action, noteId, noteData) { | ||
|  |         switch (action) { | ||
|  |             case 'create': | ||
|  |                 await this.createNote(noteId, noteData); | ||
|  |                 break; | ||
|  |             case 'update': | ||
|  |                 await this.updateNote(noteId, noteData); | ||
|  |                 break; | ||
|  |             case 'delete': | ||
|  |                 await this.deleteNote(noteId); | ||
|  |                 break; | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     async createNote(noteId, noteData) { | ||
|  |         // Add to local database/cache | ||
|  |         await localDB.notes.add({ | ||
|  |             ...noteData, | ||
|  |             noteId, | ||
|  |             syncVersion: this.lastSyncedPush | ||
|  |         }); | ||
|  |          | ||
|  |         // Emit event for UI update | ||
|  |         this.emit('note-created', { noteId, noteData }); | ||
|  |     } | ||
|  |      | ||
|  |     async updateNote(noteId, updates) { | ||
|  |         // Update local database/cache | ||
|  |         await localDB.notes.update(noteId, { | ||
|  |             ...updates, | ||
|  |             syncVersion: this.lastSyncedPush | ||
|  |         }); | ||
|  |          | ||
|  |         // Emit event for UI update | ||
|  |         this.emit('note-updated', { noteId, updates }); | ||
|  |     } | ||
|  |      | ||
|  |     async deleteNote(noteId) { | ||
|  |         // Remove from local database/cache | ||
|  |         await localDB.notes.delete(noteId); | ||
|  |          | ||
|  |         // Emit event for UI update | ||
|  |         this.emit('note-deleted', { noteId }); | ||
|  |     } | ||
|  |      | ||
|  |     // Send local changes to server | ||
|  |     async pushLocalChanges() { | ||
|  |         if (this.syncInProgress) { | ||
|  |             return; | ||
|  |         } | ||
|  |          | ||
|  |         const localChanges = await this.getLocalChanges(); | ||
|  |          | ||
|  |         if (localChanges.length === 0) { | ||
|  |             return; | ||
|  |         } | ||
|  |          | ||
|  |         this.wsManager.send({ | ||
|  |             type: 'push-changes', | ||
|  |             changes: localChanges, | ||
|  |             lastSyncedPull: this.lastSyncedPull | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     async getLocalChanges() { | ||
|  |         // Get changes from local database that haven't been synced | ||
|  |         const changes = await localDB.changes | ||
|  |             .where('syncVersion') | ||
|  |             .above(this.lastSyncedPush || 0) | ||
|  |             .toArray(); | ||
|  |          | ||
|  |         return changes; | ||
|  |     } | ||
|  |      | ||
|  |     processPendingChanges() { | ||
|  |         if (this.pendingChanges.length > 0 && !this.syncInProgress) { | ||
|  |             const changes = this.pendingChanges.splice(0); | ||
|  |             this.handleIncomingSync({ entityChanges: changes }); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     emit(event, data) { | ||
|  |         // Emit events to application | ||
|  |         window.dispatchEvent(new CustomEvent(`trilium:${event}`, { detail: data })); | ||
|  |     } | ||
|  | } | ||
|  | ``` | ||
|  | 
 | ||
|  | ### Conflict Resolution
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | class ConflictResolver { | ||
|  |     constructor(syncManager) { | ||
|  |         this.syncManager = syncManager; | ||
|  |     } | ||
|  |      | ||
|  |     async resolveConflict(localEntity, remoteEntity) { | ||
|  |         // Compare timestamps | ||
|  |         const localTime = new Date(localEntity.utcDateModified).getTime(); | ||
|  |         const remoteTime = new Date(remoteEntity.utcDateModified).getTime(); | ||
|  |          | ||
|  |         if (localTime === remoteTime) { | ||
|  |             // Same timestamp, compare content | ||
|  |             return this.resolveByContent(localEntity, remoteEntity); | ||
|  |         } | ||
|  |          | ||
|  |         // Default: last-write-wins | ||
|  |         if (remoteTime > localTime) { | ||
|  |             return { | ||
|  |                 winner: 'remote', | ||
|  |                 entity: remoteEntity, | ||
|  |                 backup: localEntity | ||
|  |             }; | ||
|  |         } else { | ||
|  |             return { | ||
|  |                 winner: 'local', | ||
|  |                 entity: localEntity, | ||
|  |                 backup: remoteEntity | ||
|  |             }; | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     resolveByContent(localEntity, remoteEntity) { | ||
|  |         // Create three-way merge if possible | ||
|  |         const baseEntity = this.getBaseEntity(localEntity.entityId); | ||
|  |          | ||
|  |         if (baseEntity) { | ||
|  |             return this.threeWayMerge(baseEntity, localEntity, remoteEntity); | ||
|  |         } | ||
|  |          | ||
|  |         // Fall back to manual resolution | ||
|  |         return this.promptUserResolution(localEntity, remoteEntity); | ||
|  |     } | ||
|  |      | ||
|  |     threeWayMerge(base, local, remote) { | ||
|  |         // Implement three-way merge logic | ||
|  |         const merged = { ...base }; | ||
|  |          | ||
|  |         // Merge each property | ||
|  |         for (const key in local) { | ||
|  |             if (local[key] !== base[key] && remote[key] !== base[key]) { | ||
|  |                 // Both changed - conflict | ||
|  |                 if (local[key] === remote[key]) { | ||
|  |                     // Same change | ||
|  |                     merged[key] = local[key]; | ||
|  |                 } else { | ||
|  |                     // Different changes - need resolution | ||
|  |                     merged[key] = this.mergeProperty(key, base[key], local[key], remote[key]); | ||
|  |                 } | ||
|  |             } else if (local[key] !== base[key]) { | ||
|  |                 // Only local changed | ||
|  |                 merged[key] = local[key]; | ||
|  |             } else if (remote[key] !== base[key]) { | ||
|  |                 // Only remote changed | ||
|  |                 merged[key] = remote[key]; | ||
|  |             } | ||
|  |         } | ||
|  |          | ||
|  |         return { | ||
|  |             winner: 'merged', | ||
|  |             entity: merged, | ||
|  |             localChanges: this.diff(base, local), | ||
|  |             remoteChanges: this.diff(base, remote) | ||
|  |         }; | ||
|  |     } | ||
|  |      | ||
|  |     mergeProperty(key, base, local, remote) { | ||
|  |         // Property-specific merge strategies | ||
|  |         switch (key) { | ||
|  |             case 'content': | ||
|  |                 // For content, try text merge | ||
|  |                 return this.mergeText(base, local, remote); | ||
|  |             case 'attributes': | ||
|  |                 // For attributes, merge arrays | ||
|  |                 return this.mergeArrays(base, local, remote); | ||
|  |             default: | ||
|  |                 // Default to remote for safety | ||
|  |                 return remote; | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     async promptUserResolution(local, remote) { | ||
|  |         // Show conflict resolution UI | ||
|  |         const resolution = await this.showConflictDialog({ | ||
|  |             local, | ||
|  |             remote, | ||
|  |             diff: this.diff(local, remote) | ||
|  |         }); | ||
|  |          | ||
|  |         return resolution; | ||
|  |     } | ||
|  |      | ||
|  |     diff(obj1, obj2) { | ||
|  |         const changes = {}; | ||
|  |          | ||
|  |         for (const key in obj2) { | ||
|  |             if (obj1[key] !== obj2[key]) { | ||
|  |                 changes[key] = { | ||
|  |                     old: obj1[key], | ||
|  |                     new: obj2[key] | ||
|  |                 }; | ||
|  |             } | ||
|  |         } | ||
|  |          | ||
|  |         return changes; | ||
|  |     } | ||
|  | } | ||
|  | ``` | ||
|  | 
 | ||
|  | ## Custom Event Broadcasting
 | ||
|  | 
 | ||
|  | ### Creating Custom Events
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | class CustomEventBroadcaster { | ||
|  |     constructor(wsManager) { | ||
|  |         this.wsManager = wsManager; | ||
|  |         this.eventListeners = new Map(); | ||
|  |     } | ||
|  |      | ||
|  |     // Broadcast event to all connected clients | ||
|  |     broadcast(eventName, data) { | ||
|  |         this.wsManager.send({ | ||
|  |             type: 'custom-broadcast', | ||
|  |             eventName, | ||
|  |             data, | ||
|  |             timestamp: Date.now() | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     // Send event to specific clients | ||
|  |     sendToClients(clientIds, eventName, data) { | ||
|  |         this.wsManager.send({ | ||
|  |             type: 'targeted-broadcast', | ||
|  |             targets: clientIds, | ||
|  |             eventName, | ||
|  |             data, | ||
|  |             timestamp: Date.now() | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     // Subscribe to custom events | ||
|  |     subscribe(eventName, handler) { | ||
|  |         if (!this.eventListeners.has(eventName)) { | ||
|  |             this.eventListeners.set(eventName, []); | ||
|  |         } | ||
|  |          | ||
|  |         this.eventListeners.get(eventName).push(handler); | ||
|  |          | ||
|  |         // Register with server | ||
|  |         this.wsManager.send({ | ||
|  |             type: 'subscribe', | ||
|  |             eventName | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     // Unsubscribe from events | ||
|  |     unsubscribe(eventName, handler) { | ||
|  |         const handlers = this.eventListeners.get(eventName); | ||
|  |         if (handlers) { | ||
|  |             const index = handlers.indexOf(handler); | ||
|  |             if (index !== -1) { | ||
|  |                 handlers.splice(index, 1); | ||
|  |             } | ||
|  |              | ||
|  |             if (handlers.length === 0) { | ||
|  |                 this.eventListeners.delete(eventName); | ||
|  |                  | ||
|  |                 // Unregister with server | ||
|  |                 this.wsManager.send({ | ||
|  |                     type: 'unsubscribe', | ||
|  |                     eventName | ||
|  |                 }); | ||
|  |             } | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     // Handle incoming custom events | ||
|  |     handleCustomEvent(message) { | ||
|  |         const { eventName, data } = message; | ||
|  |         const handlers = this.eventListeners.get(eventName); | ||
|  |          | ||
|  |         if (handlers) { | ||
|  |             handlers.forEach(handler => { | ||
|  |                 try { | ||
|  |                     handler(data); | ||
|  |                 } catch (error) { | ||
|  |                     console.error(`Error handling custom event ${eventName}:`, error); | ||
|  |                 } | ||
|  |             }); | ||
|  |         } | ||
|  |     } | ||
|  | } | ||
|  | 
 | ||
|  | // Usage example | ||
|  | const broadcaster = new CustomEventBroadcaster(wsManager); | ||
|  | 
 | ||
|  | // Subscribe to custom events | ||
|  | broadcaster.subscribe('user-joined', (data) => { | ||
|  |     console.log(`User ${data.username} joined`); | ||
|  | }); | ||
|  | 
 | ||
|  | broadcaster.subscribe('collaborative-edit', (data) => { | ||
|  |     console.log(`Edit on note ${data.noteId}: ${data.change}`); | ||
|  | }); | ||
|  | 
 | ||
|  | // Broadcast custom event | ||
|  | broadcaster.broadcast('user-action', { | ||
|  |     action: 'viewed-note', | ||
|  |     noteId: 'abc123', | ||
|  |     userId: 'user456' | ||
|  | }); | ||
|  | ``` | ||
|  | 
 | ||
|  | ### Collaborative Features
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | class CollaborationManager { | ||
|  |     constructor(wsManager, userId) { | ||
|  |         this.wsManager = wsManager; | ||
|  |         this.userId = userId; | ||
|  |         this.activeSessions = new Map(); | ||
|  |         this.cursorPositions = new Map(); | ||
|  |          | ||
|  |         this.setupCollaborationHandlers(); | ||
|  |     } | ||
|  |      | ||
|  |     setupCollaborationHandlers() { | ||
|  |         this.wsManager.on('collab-session-started', (data) => { | ||
|  |             this.handleSessionStarted(data); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('collab-user-joined', (data) => { | ||
|  |             this.handleUserJoined(data); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('collab-user-left', (data) => { | ||
|  |             this.handleUserLeft(data); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('collab-cursor-update', (data) => { | ||
|  |             this.handleCursorUpdate(data); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('collab-selection-update', (data) => { | ||
|  |             this.handleSelectionUpdate(data); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('collab-content-change', (data) => { | ||
|  |             this.handleContentChange(data); | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     startCollaborationSession(noteId) { | ||
|  |         this.wsManager.send({ | ||
|  |             type: 'start-collab-session', | ||
|  |             noteId, | ||
|  |             userId: this.userId | ||
|  |         }); | ||
|  |          | ||
|  |         const session = { | ||
|  |             noteId, | ||
|  |             users: new Set([this.userId]), | ||
|  |             startTime: Date.now() | ||
|  |         }; | ||
|  |          | ||
|  |         this.activeSessions.set(noteId, session); | ||
|  |          | ||
|  |         return session; | ||
|  |     } | ||
|  |      | ||
|  |     joinCollaborationSession(noteId) { | ||
|  |         this.wsManager.send({ | ||
|  |             type: 'join-collab-session', | ||
|  |             noteId, | ||
|  |             userId: this.userId | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     leaveCollaborationSession(noteId) { | ||
|  |         this.wsManager.send({ | ||
|  |             type: 'leave-collab-session', | ||
|  |             noteId, | ||
|  |             userId: this.userId | ||
|  |         }); | ||
|  |          | ||
|  |         this.activeSessions.delete(noteId); | ||
|  |     } | ||
|  |      | ||
|  |     sendCursorPosition(noteId, position) { | ||
|  |         this.wsManager.send({ | ||
|  |             type: 'collab-cursor-update', | ||
|  |             noteId, | ||
|  |             userId: this.userId, | ||
|  |             position | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     sendSelectionUpdate(noteId, selection) { | ||
|  |         this.wsManager.send({ | ||
|  |             type: 'collab-selection-update', | ||
|  |             noteId, | ||
|  |             userId: this.userId, | ||
|  |             selection | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     sendContentChange(noteId, change) { | ||
|  |         this.wsManager.send({ | ||
|  |             type: 'collab-content-change', | ||
|  |             noteId, | ||
|  |             userId: this.userId, | ||
|  |             change | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     handleSessionStarted(data) { | ||
|  |         const { noteId, users } = data; | ||
|  |          | ||
|  |         const session = { | ||
|  |             noteId, | ||
|  |             users: new Set(users), | ||
|  |             startTime: Date.now() | ||
|  |         }; | ||
|  |          | ||
|  |         this.activeSessions.set(noteId, session); | ||
|  |          | ||
|  |         // Update UI to show collaboration indicators | ||
|  |         this.showCollaborationIndicator(noteId, users); | ||
|  |     } | ||
|  |      | ||
|  |     handleUserJoined(data) { | ||
|  |         const { noteId, userId, userInfo } = data; | ||
|  |         const session = this.activeSessions.get(noteId); | ||
|  |          | ||
|  |         if (session) { | ||
|  |             session.users.add(userId); | ||
|  |             this.showUserJoinedNotification(userInfo); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     handleUserLeft(data) { | ||
|  |         const { noteId, userId } = data; | ||
|  |         const session = this.activeSessions.get(noteId); | ||
|  |          | ||
|  |         if (session) { | ||
|  |             session.users.delete(userId); | ||
|  |             this.removeUserCursor(userId); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     handleCursorUpdate(data) { | ||
|  |         const { userId, position } = data; | ||
|  |          | ||
|  |         if (userId !== this.userId) { | ||
|  |             this.cursorPositions.set(userId, position); | ||
|  |             this.updateUserCursor(userId, position); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     handleSelectionUpdate(data) { | ||
|  |         const { userId, selection } = data; | ||
|  |          | ||
|  |         if (userId !== this.userId) { | ||
|  |             this.updateUserSelection(userId, selection); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     handleContentChange(data) { | ||
|  |         const { noteId, userId, change } = data; | ||
|  |          | ||
|  |         if (userId !== this.userId) { | ||
|  |             this.applyRemoteChange(noteId, change); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     // UI update methods (implement based on your UI framework) | ||
|  |     showCollaborationIndicator(noteId, users) { /* ... */ } | ||
|  |     showUserJoinedNotification(userInfo) { /* ... */ } | ||
|  |     removeUserCursor(userId) { /* ... */ } | ||
|  |     updateUserCursor(userId, position) { /* ... */ } | ||
|  |     updateUserSelection(userId, selection) { /* ... */ } | ||
|  |     applyRemoteChange(noteId, change) { /* ... */ } | ||
|  | } | ||
|  | ``` | ||
|  | 
 | ||
|  | ## Client Implementation Examples
 | ||
|  | 
 | ||
|  | ### React Hook
 | ||
|  | 
 | ||
|  | ```jsx | ||
|  | // useWebSocket.js | ||
|  | import { useEffect, useRef, useState, useCallback } from 'react'; | ||
|  | 
 | ||
|  | export function useTriliumWebSocket(url, options = {}) { | ||
|  |     const [isConnected, setIsConnected] = useState(false); | ||
|  |     const [lastMessage, setLastMessage] = useState(null); | ||
|  |     const [error, setError] = useState(null); | ||
|  |      | ||
|  |     const wsManager = useRef(null); | ||
|  |     const messageHandlers = useRef(new Map()); | ||
|  |      | ||
|  |     useEffect(() => { | ||
|  |         wsManager.current = new TriliumWebSocketManager(url, options); | ||
|  |          | ||
|  |         wsManager.current.on('open', () => { | ||
|  |             setIsConnected(true); | ||
|  |             setError(null); | ||
|  |         }); | ||
|  |          | ||
|  |         wsManager.current.on('close', () => { | ||
|  |             setIsConnected(false); | ||
|  |         }); | ||
|  |          | ||
|  |         wsManager.current.on('error', (err) => { | ||
|  |             setError(err); | ||
|  |         }); | ||
|  |          | ||
|  |         wsManager.current.on('message', (msg) => { | ||
|  |             setLastMessage(msg); | ||
|  |              | ||
|  |             // Call registered handlers | ||
|  |             const handler = messageHandlers.current.get(msg.type); | ||
|  |             if (handler) { | ||
|  |                 handler(msg.data || msg); | ||
|  |             } | ||
|  |         }); | ||
|  |          | ||
|  |         wsManager.current.connect(); | ||
|  |          | ||
|  |         return () => { | ||
|  |             wsManager.current.close(); | ||
|  |         }; | ||
|  |     }, [url]); | ||
|  |      | ||
|  |     const sendMessage = useCallback((message) => { | ||
|  |         if (wsManager.current) { | ||
|  |             wsManager.current.send(message); | ||
|  |         } | ||
|  |     }, []); | ||
|  |      | ||
|  |     const subscribe = useCallback((messageType, handler) => { | ||
|  |         messageHandlers.current.set(messageType, handler); | ||
|  |          | ||
|  |         return () => { | ||
|  |             messageHandlers.current.delete(messageType); | ||
|  |         }; | ||
|  |     }, []); | ||
|  |      | ||
|  |     return { | ||
|  |         isConnected, | ||
|  |         lastMessage, | ||
|  |         error, | ||
|  |         sendMessage, | ||
|  |         subscribe | ||
|  |     }; | ||
|  | } | ||
|  | 
 | ||
|  | // Usage in React component | ||
|  | function TriliumNoteEditor({ noteId }) { | ||
|  |     const { isConnected, sendMessage, subscribe } = useTriliumWebSocket( | ||
|  |         'ws://localhost:8080' | ||
|  |     ); | ||
|  |      | ||
|  |     const [content, setContent] = useState(''); | ||
|  |      | ||
|  |     useEffect(() => { | ||
|  |         // Subscribe to note updates | ||
|  |         const unsubscribe = subscribe('note-updated', (data) => { | ||
|  |             if (data.noteId === noteId) { | ||
|  |                 setContent(data.content); | ||
|  |             } | ||
|  |         }); | ||
|  |          | ||
|  |         return unsubscribe; | ||
|  |     }, [noteId, subscribe]); | ||
|  |      | ||
|  |     const handleContentChange = (newContent) => { | ||
|  |         setContent(newContent); | ||
|  |          | ||
|  |         // Send update via WebSocket | ||
|  |         sendMessage({ | ||
|  |             type: 'update-note', | ||
|  |             noteId, | ||
|  |             content: newContent | ||
|  |         }); | ||
|  |     }; | ||
|  |      | ||
|  |     return ( | ||
|  |         <div> | ||
|  |             <div>Connection: {isConnected ? '🟢' : '🔴'}</div> | ||
|  |             <textarea | ||
|  |                 value={content} | ||
|  |                 onChange={(e) => handleContentChange(e.target.value)} | ||
|  |             /> | ||
|  |         </div> | ||
|  |     ); | ||
|  | } | ||
|  | ``` | ||
|  | 
 | ||
|  | ### Vue 3 Composable
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | // useTriliumWebSocket.js | ||
|  | import { ref, onMounted, onUnmounted } from 'vue'; | ||
|  | 
 | ||
|  | export function useTriliumWebSocket(url, options = {}) { | ||
|  |     const isConnected = ref(false); | ||
|  |     const lastMessage = ref(null); | ||
|  |     const error = ref(null); | ||
|  |      | ||
|  |     let wsManager = null; | ||
|  |     const messageHandlers = new Map(); | ||
|  |      | ||
|  |     onMounted(() => { | ||
|  |         wsManager = new TriliumWebSocketManager(url, options); | ||
|  |          | ||
|  |         wsManager.on('open', () => { | ||
|  |             isConnected.value = true; | ||
|  |             error.value = null; | ||
|  |         }); | ||
|  |          | ||
|  |         wsManager.on('close', () => { | ||
|  |             isConnected.value = false; | ||
|  |         }); | ||
|  |          | ||
|  |         wsManager.on('error', (err) => { | ||
|  |             error.value = err; | ||
|  |         }); | ||
|  |          | ||
|  |         wsManager.on('message', (msg) => { | ||
|  |             lastMessage.value = msg; | ||
|  |              | ||
|  |             const handler = messageHandlers.get(msg.type); | ||
|  |             if (handler) { | ||
|  |                 handler(msg.data || msg); | ||
|  |             } | ||
|  |         }); | ||
|  |          | ||
|  |         wsManager.connect(); | ||
|  |     }); | ||
|  |      | ||
|  |     onUnmounted(() => { | ||
|  |         if (wsManager) { | ||
|  |             wsManager.close(); | ||
|  |         } | ||
|  |     }); | ||
|  |      | ||
|  |     const sendMessage = (message) => { | ||
|  |         if (wsManager) { | ||
|  |             wsManager.send(message); | ||
|  |         } | ||
|  |     }; | ||
|  |      | ||
|  |     const subscribe = (messageType, handler) => { | ||
|  |         messageHandlers.set(messageType, handler); | ||
|  |          | ||
|  |         return () => { | ||
|  |             messageHandlers.delete(messageType); | ||
|  |         }; | ||
|  |     }; | ||
|  |      | ||
|  |     return { | ||
|  |         isConnected, | ||
|  |         lastMessage, | ||
|  |         error, | ||
|  |         sendMessage, | ||
|  |         subscribe | ||
|  |     }; | ||
|  | } | ||
|  | ``` | ||
|  | 
 | ||
|  | ### Angular Service
 | ||
|  | 
 | ||
|  | ```typescript | ||
|  | // trilium-websocket.service.ts | ||
|  | import { Injectable, OnDestroy } from '@angular/core'; | ||
|  | import { BehaviorSubject, Observable, Subject } from 'rxjs'; | ||
|  | import { filter, map } from 'rxjs/operators'; | ||
|  | 
 | ||
|  | @Injectable({ | ||
|  |     providedIn: 'root' | ||
|  | }) | ||
|  | export class TriliumWebSocketService implements OnDestroy { | ||
|  |     private wsManager: TriliumWebSocketManager | null = null; | ||
|  |     private isConnected$ = new BehaviorSubject<boolean>(false); | ||
|  |     private messages$ = new Subject<any>(); | ||
|  |     private error$ = new Subject<Error>(); | ||
|  |      | ||
|  |     constructor() {} | ||
|  |      | ||
|  |     connect(url: string, options: any = {}): void { | ||
|  |         this.wsManager = new TriliumWebSocketManager(url, options); | ||
|  |          | ||
|  |         this.wsManager.on('open', () => { | ||
|  |             this.isConnected$.next(true); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('close', () => { | ||
|  |             this.isConnected$.next(false); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('error', (error) => { | ||
|  |             this.error$.next(error); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('message', (message) => { | ||
|  |             this.messages$.next(message); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.connect(); | ||
|  |     } | ||
|  |      | ||
|  |     disconnect(): void { | ||
|  |         if (this.wsManager) { | ||
|  |             this.wsManager.close(); | ||
|  |             this.wsManager = null; | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     send(message: any): void { | ||
|  |         if (this.wsManager) { | ||
|  |             this.wsManager.send(message); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     getConnectionStatus(): Observable<boolean> { | ||
|  |         return this.isConnected$.asObservable(); | ||
|  |     } | ||
|  |      | ||
|  |     getMessages(): Observable<any> { | ||
|  |         return this.messages$.asObservable(); | ||
|  |     } | ||
|  |      | ||
|  |     getMessagesByType(type: string): Observable<any> { | ||
|  |         return this.messages$.pipe( | ||
|  |             filter(msg => msg.type === type), | ||
|  |             map(msg => msg.data || msg) | ||
|  |         ); | ||
|  |     } | ||
|  |      | ||
|  |     getErrors(): Observable<Error> { | ||
|  |         return this.error$.asObservable(); | ||
|  |     } | ||
|  |      | ||
|  |     ngOnDestroy(): void { | ||
|  |         this.disconnect(); | ||
|  |     } | ||
|  | } | ||
|  | ``` | ||
|  | 
 | ||
|  | ## Debugging WebSocket Connections
 | ||
|  | 
 | ||
|  | ### Debug Logger
 | ||
|  | 
 | ||
|  | ```javascript | ||
|  | class WebSocketDebugger { | ||
|  |     constructor(wsManager, options = {}) { | ||
|  |         this.wsManager = wsManager; | ||
|  |         this.options = { | ||
|  |             logMessages: true, | ||
|  |             logEvents: true, | ||
|  |             logErrors: true, | ||
|  |             maxLogSize: 100, | ||
|  |             ...options | ||
|  |         }; | ||
|  |          | ||
|  |         this.logs = []; | ||
|  |         this.stats = { | ||
|  |             messagesSent: 0, | ||
|  |             messagesReceived: 0, | ||
|  |             bytesReceived: 0, | ||
|  |             errors: 0, | ||
|  |             reconnects: 0, | ||
|  |             latency: [] | ||
|  |         }; | ||
|  |          | ||
|  |         this.setupDebugHandlers(); | ||
|  |     } | ||
|  |      | ||
|  |     setupDebugHandlers() { | ||
|  |         // Intercept send method | ||
|  |         const originalSend = this.wsManager.send.bind(this.wsManager); | ||
|  |         this.wsManager.send = (data) => { | ||
|  |             this.logOutgoing(data); | ||
|  |             this.stats.messagesSent++; | ||
|  |             return originalSend(data); | ||
|  |         }; | ||
|  |          | ||
|  |         // Log incoming messages | ||
|  |         this.wsManager.on('message', (message) => { | ||
|  |             this.logIncoming(message); | ||
|  |             this.stats.messagesReceived++; | ||
|  |         }); | ||
|  |          | ||
|  |         // Log events | ||
|  |         this.wsManager.on('open', () => { | ||
|  |             this.logEvent('Connected'); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('close', (event) => { | ||
|  |             this.logEvent(`Disconnected (code: ${event.code})`); | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('error', (error) => { | ||
|  |             this.logError(error); | ||
|  |             this.stats.errors++; | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('reconnecting', (info) => { | ||
|  |             this.logEvent(`Reconnecting (attempt ${info.attempt})`); | ||
|  |             this.stats.reconnects++; | ||
|  |         }); | ||
|  |          | ||
|  |         this.wsManager.on('latency', (latency) => { | ||
|  |             this.stats.latency.push(latency); | ||
|  |             if (this.stats.latency.length > 100) { | ||
|  |                 this.stats.latency.shift(); | ||
|  |             } | ||
|  |         }); | ||
|  |     } | ||
|  |      | ||
|  |     logOutgoing(data) { | ||
|  |         if (this.options.logMessages) { | ||
|  |             this.addLog('OUT', data); | ||
|  |             console.log('%c→ OUT', 'color: #4CAF50', data); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     logIncoming(data) { | ||
|  |         if (this.options.logMessages) { | ||
|  |             this.addLog('IN', data); | ||
|  |             console.log('%c← IN', 'color: #2196F3', data); | ||
|  |         } | ||
|  |          | ||
|  |         // Track data size | ||
|  |         const size = JSON.stringify(data).length; | ||
|  |         this.stats.bytesReceived += size; | ||
|  |     } | ||
|  |      | ||
|  |     logEvent(event) { | ||
|  |         if (this.options.logEvents) { | ||
|  |             this.addLog('EVENT', event); | ||
|  |             console.log('%c● EVENT', 'color: #FF9800', event); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     logError(error) { | ||
|  |         if (this.options.logErrors) { | ||
|  |             this.addLog('ERROR', error); | ||
|  |             console.error('WebSocket Error:', error); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     addLog(type, data) { | ||
|  |         const log = { | ||
|  |             type, | ||
|  |             data, | ||
|  |             timestamp: new Date().toISOString() | ||
|  |         }; | ||
|  |          | ||
|  |         this.logs.push(log); | ||
|  |          | ||
|  |         // Limit log size | ||
|  |         if (this.logs.length > this.options.maxLogSize) { | ||
|  |             this.logs.shift(); | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     getStats() { | ||
|  |         const avgLatency = this.stats.latency.length > 0 | ||
|  |             ? this.stats.latency.reduce((a, b) => a + b, 0) / this.stats.latency.length | ||
|  |             : 0; | ||
|  |          | ||
|  |         return { | ||
|  |             ...this.stats, | ||
|  |             averageLatency: avgLatency, | ||
|  |             currentLatency: this.stats.latency[this.stats.latency.length - 1] || 0 | ||
|  |         }; | ||
|  |     } | ||
|  |      | ||
|  |     getLogs(filter = null) { | ||
|  |         if (filter) { | ||
|  |             return this.logs.filter(log => log.type === filter); | ||
|  |         } | ||
|  |         return this.logs; | ||
|  |     } | ||
|  |      | ||
|  |     exportLogs() { | ||
|  |         const data = { | ||
|  |             logs: this.logs, | ||
|  |             stats: this.getStats(), | ||
|  |             timestamp: new Date().toISOString() | ||
|  |         }; | ||
|  |          | ||
|  |         const blob = new Blob([JSON.stringify(data, null, 2)], { | ||
|  |             type: 'application/json' | ||
|  |         }); | ||
|  |          | ||
|  |         const url = URL.createObjectURL(blob); | ||
|  |         const a = document.createElement('a'); | ||
|  |         a.href = url; | ||
|  |         a.download = `websocket-debug-${Date.now()}.json`; | ||
|  |         a.click(); | ||
|  |          | ||
|  |         URL.revokeObjectURL(url); | ||
|  |     } | ||
|  |      | ||
|  |     createDebugPanel() { | ||
|  |         const panel = document.createElement('div'); | ||
|  |         panel.id = 'ws-debug-panel'; | ||
|  |         panel.innerHTML = ` | ||
|  |             <style> | ||
|  |                 #ws-debug-panel { | ||
|  |                     position: fixed; | ||
|  |                     bottom: 0; | ||
|  |                     right: 0; | ||
|  |                     width: 400px; | ||
|  |                     height: 300px; | ||
|  |                     background: #1e1e1e; | ||
|  |                     color: #fff; | ||
|  |                     font-family: monospace; | ||
|  |                     font-size: 12px; | ||
|  |                     z-index: 10000; | ||
|  |                     display: flex; | ||
|  |                     flex-direction: column; | ||
|  |                 } | ||
|  |                 #ws-debug-header { | ||
|  |                     padding: 10px; | ||
|  |                     background: #2d2d2d; | ||
|  |                     display: flex; | ||
|  |                     justify-content: space-between; | ||
|  |                 } | ||
|  |                 #ws-debug-stats { | ||
|  |                     padding: 10px; | ||
|  |                     border-bottom: 1px solid #444; | ||
|  |                 } | ||
|  |                 #ws-debug-logs { | ||
|  |                     flex: 1; | ||
|  |                     overflow-y: auto; | ||
|  |                     padding: 10px; | ||
|  |                 } | ||
|  |                 .ws-log-entry { | ||
|  |                     margin-bottom: 5px; | ||
|  |                     padding: 5px; | ||
|  |                     background: #2d2d2d; | ||
|  |                     border-radius: 3px; | ||
|  |                 } | ||
|  |                 .ws-log-out { border-left: 3px solid #4CAF50; } | ||
|  |                 .ws-log-in { border-left: 3px solid #2196F3; } | ||
|  |                 .ws-log-event { border-left: 3px solid #FF9800; } | ||
|  |                 .ws-log-error { border-left: 3px solid #f44336; } | ||
|  |             </style> | ||
|  |             <div id="ws-debug-header"> | ||
|  |                 <span>WebSocket Debug</span> | ||
|  |                 <button onclick="this.parentElement.parentElement.remove()">✕</button> | ||
|  |             </div> | ||
|  |             <div id="ws-debug-stats"></div> | ||
|  |             <div id="ws-debug-logs"></div> | ||
|  |         `; | ||
|  |          | ||
|  |         document.body.appendChild(panel); | ||
|  |          | ||
|  |         // Update stats periodically | ||
|  |         setInterval(() => { | ||
|  |             this.updateDebugPanel(); | ||
|  |         }, 1000); | ||
|  |     } | ||
|  |      | ||
|  |     updateDebugPanel() { | ||
|  |         const statsEl = document.getElementById('ws-debug-stats'); | ||
|  |         const logsEl = document.getElementById('ws-debug-logs'); | ||
|  |          | ||
|  |         if (!statsEl || !logsEl) return; | ||
|  |          | ||
|  |         const stats = this.getStats(); | ||
|  |         statsEl.innerHTML = ` | ||
|  |             <div>Sent: ${stats.messagesSent} | Received: ${stats.messagesReceived}</div> | ||
|  |             <div>Bytes: ${(stats.bytesReceived / 1024).toFixed(2)} KB</div> | ||
|  |             <div>Latency: ${stats.currentLatency}ms (avg: ${stats.averageLatency.toFixed(1)}ms)</div> | ||
|  |             <div>Errors: ${stats.errors} | Reconnects: ${stats.reconnects}</div> | ||
|  |         `; | ||
|  |          | ||
|  |         // Show recent logs | ||
|  |         const recentLogs = this.logs.slice(-10); | ||
|  |         logsEl.innerHTML = recentLogs.map(log => ` | ||
|  |             <div class="ws-log-entry ws-log-${log.type.toLowerCase()}"> | ||
|  |                 <small>${new Date(log.timestamp).toLocaleTimeString()}</small> | ||
|  |                 ${log.type}: ${typeof log.data === 'object' ? JSON.stringify(log.data).substring(0, 100) : log.data} | ||
|  |             </div> | ||
|  |         `).join(''); | ||
|  |     } | ||
|  | } | ||
|  | 
 | ||
|  | // Usage | ||
|  | const debugger = new WebSocketDebugger(wsManager, { | ||
|  |     logMessages: true, | ||
|  |     logEvents: true, | ||
|  |     logErrors: true | ||
|  | }); | ||
|  | 
 | ||
|  | // Create visual debug panel | ||
|  | debugger.createDebugPanel(); | ||
|  | 
 | ||
|  | // Export logs for analysis | ||
|  | debugger.exportLogs(); | ||
|  | ``` | ||
|  | 
 | ||
|  | ## Best Practices
 | ||
|  | 
 | ||
|  | ### 1. Connection Management
 | ||
|  | 
 | ||
|  | - Always implement reconnection logic with exponential backoff | ||
|  | - Handle connection state changes gracefully | ||
|  | - Queue messages during disconnection | ||
|  | - Implement heartbeat/ping mechanism | ||
|  | 
 | ||
|  | ### 2. Message Handling
 | ||
|  | 
 | ||
|  | - Always validate incoming message format | ||
|  | - Use structured message types | ||
|  | - Implement message acknowledgment for critical operations | ||
|  | - Handle message ordering and deduplication | ||
|  | 
 | ||
|  | ### 3. Error Recovery
 | ||
|  | 
 | ||
|  | - Implement comprehensive error handling | ||
|  | - Log errors for debugging | ||
|  | - Provide user feedback for connection issues | ||
|  | - Implement fallback mechanisms | ||
|  | 
 | ||
|  | ### 4. Performance
 | ||
|  | 
 | ||
|  | - Batch messages when possible | ||
|  | - Implement message compression for large payloads | ||
|  | - Use binary frames for file transfers | ||
|  | - Limit message frequency (throttle/debounce) | ||
|  | 
 | ||
|  | ### 5. Security
 | ||
|  | 
 | ||
|  | - Always use WSS (WebSocket Secure) in production | ||
|  | - Validate all incoming data | ||
|  | - Implement rate limiting | ||
|  | - Use authentication tokens with expiration | ||
|  | 
 | ||
|  | ## Conclusion
 | ||
|  | 
 | ||
|  | The Trilium WebSocket API provides powerful real-time capabilities for building responsive, collaborative applications. Key takeaways: | ||
|  | 
 | ||
|  | 1. **Use WebSocket for real-time features** - synchronization, collaboration, live updates | ||
|  | 2. **Implement robust connection management** - reconnection, queuing, state handling | ||
|  | 3. **Handle all message types** - system events, custom events, errors | ||
|  | 4. **Debug thoroughly** - use logging, monitoring, and debugging tools | ||
|  | 5. **Follow best practices** - security, performance, error handling | ||
|  | 
 | ||
|  | For more information: | ||
|  | - [Internal API Reference](./Internal%20API%20Reference.md) | ||
|  | - [ETAPI Complete Guide](./ETAPI%20Complete%20Guide.md) | ||
|  | - [API Client Libraries](./API%20Client%20Libraries.md) |