# 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 (
Connection: {isConnected ? '🟢' : 'šŸ”“'}