claritykit-svelte
Version:
A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility
432 lines (371 loc) • 13.4 kB
JavaScript
/**
* Yjs WebSocket Provider for Block-Level Collaborative Editing
*
* Provides real-time synchronization between clients through WebSocket connection
* to the collaboration server. Handles block-level document management,
* user awareness, and automatic reconnection.
*
* Migrated from BMS components and adapted for ClarityKit patterns.
*/
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
export class YjsWebSocketProvider {
constructor(config = {}) {
this.config = {
serverUrl: config.serverUrl || 'ws://localhost:8001',
reconnectInterval: config.reconnectInterval || 3000,
maxReconnectAttempts: config.maxReconnectAttempts || 10,
heartbeatInterval: config.heartbeatInterval || 30000,
...config
};
this.providers = new Map(); // blockId -> WebsocketProvider
this.documents = new Map(); // blockId -> Y.Doc
this.awareness = new Map(); // blockId -> awareness state
this.reconnectAttempts = new Map(); // blockId -> attempt count
this.connectionStates = new Map(); // blockId -> connection state
this.eventHandlers = new Map(); // blockId -> event handlers
this.userId = config.userId || this.generateUserId();
this.userInfo = config.userInfo || { name: 'Anonymous', color: this.generateUserColor() };
// Initialize event listeners
this.listeners = {};
// Connection state tracking
this.isOnline = navigator.onLine;
this.setupNetworkListeners();
}
/**
* Connect to a block's collaborative session
*/
async connectToBlock(blockId, options = {}) {
if (this.providers.has(blockId)) {
console.warn(`Already connected to block ${blockId}`);
return this.providers.get(blockId);
}
// Create Y.Doc for this block
const ydoc = new Y.Doc();
this.documents.set(blockId, ydoc);
try {
// Create WebSocket provider for this specific block
const provider = new WebsocketProvider(
this.config.serverUrl,
`block-${blockId}`,
ydoc,
{
connect: true,
...options
}
);
// Store provider and related data
this.providers.set(blockId, provider);
this.awareness.set(blockId, provider.awareness);
this.connectionStates.set(blockId, 'connecting');
this.reconnectAttempts.set(blockId, 0);
// Set awareness user info after creation
if (provider.awareness) {
provider.awareness.setLocalState({
user: {
id: this.userId,
...this.userInfo,
cursor: null,
selection: null,
lastSeen: Date.now()
}
});
}
// Set up event listeners for this block
this.setupBlockEventListeners(blockId, provider, ydoc);
return provider;
} catch (error) {
console.error('Failed to create WebsocketProvider:', error);
this.connectionStates.set(blockId, 'error');
throw error;
}
}
/**
* Disconnect from a block's collaborative session
*/
disconnectFromBlock(blockId) {
const provider = this.providers.get(blockId);
if (!provider) {
console.warn(`Not connected to block ${blockId}`);
return;
}
try {
provider.destroy();
} catch (error) {
console.warn('Error destroying provider:', error);
}
// Clean up resources
this.providers.delete(blockId);
this.documents.delete(blockId);
this.awareness.delete(blockId);
this.reconnectAttempts.delete(blockId);
this.connectionStates.delete(blockId);
this.eventHandlers.delete(blockId);
this.emit('block-disconnected', { blockId });
}
/**
* Get the Y.Doc for a specific block
*/
getDocument(blockId) {
return this.documents.get(blockId);
}
/**
* Get the awareness instance for a specific block
*/
getAwareness(blockId) {
return this.awareness.get(blockId);
}
/**
* Get connection state for a block
*/
getConnectionState(blockId) {
return this.connectionStates.get(blockId) || 'disconnected';
}
/**
* Update user awareness information
*/
updateAwareness(blockId, updates) {
const awareness = this.awareness.get(blockId);
if (!awareness) {
console.warn(`No awareness instance for block ${blockId}`);
return;
}
const currentState = awareness.getLocalState() || {};
const newState = {
...currentState,
...updates,
lastSeen: Date.now()
};
try {
awareness.setLocalState(newState);
} catch (error) {
console.warn('Failed to update awareness:', error);
}
}
/**
* Set cursor position for a block
*/
setCursor(blockId, cursor) {
this.updateAwareness(blockId, { cursor });
}
/**
* Set selection range for a block
*/
setSelection(blockId, selection) {
this.updateAwareness(blockId, { selection });
}
/**
* Get all connected users for a block
*/
getConnectedUsers(blockId) {
const awareness = this.awareness.get(blockId);
if (!awareness) return [];
const users = [];
try {
awareness.getStates().forEach((state, clientId) => {
if (clientId !== awareness.clientID && state.user) {
users.push({
clientId,
...state.user,
isActive: Date.now() - (state.lastSeen || 0) < 60000 // Active in last minute
});
}
});
} catch (error) {
console.warn('Failed to get connected users:', error);
}
return users;
}
/**
* Setup event listeners for a block's provider and document
*/
setupBlockEventListeners(blockId, provider, ydoc) {
const handlers = {
onConnectionChange: (event) => {
const connected = event.status === 'connected';
const state = connected ? 'connected' : 'disconnected';
this.connectionStates.set(blockId, state);
if (connected) {
this.reconnectAttempts.set(blockId, 0);
this.emit('block-connected', { blockId });
} else {
this.emit('block-disconnected', { blockId });
this.handleReconnection(blockId);
}
},
onSync: (synced) => {
if (synced) {
this.connectionStates.set(blockId, 'synced');
this.emit('block-synced', { blockId });
}
},
onDocumentUpdate: (update, origin) => {
this.emit('document-updated', { blockId, update, origin });
},
onAwarenessUpdate: ({ added, updated, removed }) => {
this.emit('awareness-updated', {
blockId,
added,
updated,
removed,
users: this.getConnectedUsers(blockId)
});
}
};
try {
// Bind event listeners - y-websocket uses different event patterns
provider.on('status', handlers.onConnectionChange);
provider.on('synced', handlers.onSync);
ydoc.on('update', handlers.onDocumentUpdate);
// Awareness events
if (provider.awareness) {
provider.awareness.on('update', handlers.onAwarenessUpdate);
}
this.eventHandlers.set(blockId, handlers);
} catch (error) {
console.error('Failed to setup event listeners:', error);
}
}
/**
* Handle automatic reconnection for a block
*/
async handleReconnection(blockId) {
if (!this.isOnline) return;
const attempts = this.reconnectAttempts.get(blockId) || 0;
if (attempts >= this.config.maxReconnectAttempts) {
this.emit('reconnection-failed', { blockId, attempts });
return;
}
this.reconnectAttempts.set(blockId, attempts + 1);
this.connectionStates.set(blockId, 'reconnecting');
const delay = this.config.reconnectInterval * Math.pow(1.5, attempts); // Exponential backoff
setTimeout(() => {
const provider = this.providers.get(blockId);
if (provider && !provider.wsconnected) {
this.emit('reconnecting', { blockId, attempt: attempts + 1 });
try {
provider.connect();
} catch (error) {
console.warn('Reconnection attempt failed:', error);
}
}
}, delay);
}
/**
* Setup network connectivity listeners
*/
setupNetworkListeners() {
const handleOnline = () => {
this.isOnline = true;
this.emit('network-online');
// Attempt to reconnect all providers
this.providers.forEach((provider, blockId) => {
if (!provider.wsconnected) {
this.handleReconnection(blockId);
}
});
};
const handleOffline = () => {
this.isOnline = false;
this.emit('network-offline');
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Store references for cleanup
this._networkListeners = { handleOnline, handleOffline };
}
/**
* Get statistics about all connections
*/
getStats() {
const stats = {
totalBlocks: this.providers.size,
connectedBlocks: 0,
syncedBlocks: 0,
totalUsers: 0,
isOnline: this.isOnline
};
this.connectionStates.forEach((state) => {
if (state === 'connected' || state === 'synced') {
stats.connectedBlocks++;
}
if (state === 'synced') {
stats.syncedBlocks++;
}
});
this.awareness.forEach((awareness) => {
try {
stats.totalUsers += Math.max(0, awareness.getStates().size - 1); // Exclude self
} catch (error) {
console.warn('Failed to get awareness stats:', error);
}
});
return stats;
}
/**
* Disconnect from all blocks and cleanup
*/
destroy() {
// Disconnect from all blocks
Array.from(this.providers.keys()).forEach(blockId => {
this.disconnectFromBlock(blockId);
});
// Remove network listeners
if (this._networkListeners) {
window.removeEventListener('online', this._networkListeners.handleOnline);
window.removeEventListener('offline', this._networkListeners.handleOffline);
}
// Clear all maps
this.providers.clear();
this.documents.clear();
this.awareness.clear();
this.reconnectAttempts.clear();
this.connectionStates.clear();
this.eventHandlers.clear();
this.emit('provider-destroyed');
}
/**
* Simple event emitter implementation
*/
emit(event, data) {
if (this.listeners && this.listeners[event]) {
this.listeners[event].forEach(callback => {
try {
callback(data);
} catch (error) {
console.error('Event listener error:', error);
}
});
}
}
on(event, callback) {
if (!this.listeners) this.listeners = {};
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(callback);
}
off(event, callback) {
if (!this.listeners || !this.listeners[event]) return;
const index = this.listeners[event].indexOf(callback);
if (index > -1) {
this.listeners[event].splice(index, 1);
}
}
/**
* Generate a unique user ID
*/
generateUserId() {
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Generate a random user color
*/
generateUserColor() {
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',
'#FECA57', '#FF9FF3', '#54A0FF', '#5F27CD',
'#00D2D3', '#FF9F43', '#EE5A6F', '#0ABDE3'
];
return colors[Math.floor(Math.random() * colors.length)];
}
}
export default YjsWebSocketProvider;