mirror of
https://github.com/zadam/trilium.git
synced 2025-10-28 08:46:43 +01:00
48 KiB
Vendored
48 KiB
Vendored
WebSocket API Documentation
Table of Contents
- Introduction
- Connection Setup
- Authentication
- Message Format
- Event Types
- Real-time Synchronization
- Custom Event Broadcasting
- Client Implementation Examples
- Debugging WebSocket Connections
- Best Practices
- Error Handling
- 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 - 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
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
// Session auth (cookies must be included)
const ws = new WebSocket('ws://localhost:8080', {
headers: {
'Cookie': document.cookie // Include session cookie
}
});
Token-Based Authentication
// 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
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
// 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
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
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
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
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
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
// 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
// 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
// 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
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:
- Use WebSocket for real-time features - synchronization, collaboration, live updates
- Implement robust connection management - reconnection, queuing, state handling
- Handle all message types - system events, custom events, errors
- Debug thoroughly - use logging, monitoring, and debugging tools
- Follow best practices - security, performance, error handling
For more information: