sveltekit-sync
Version:
Local-first sync engine for SvelteKit
482 lines (481 loc) • 16.8 kB
JavaScript
import { EventEmitter } from './event-emitter.js';
import { EphemeralStore } from './ephemeral-store.js';
/**
* Server-side realtime connection manager.
* Manages SSE connections and broadcasts operations to connected clients.
*/
export class RealtimeServer extends EventEmitter {
config;
connections = new Map();
userConnections = new Map();
channelSubscriptions = new Map(); // channel -> connectionIds
heartbeatInterval = null;
encoder = new TextEncoder();
// Ephemeral stores
presenceStore;
ephemeralStore;
constructor(config = {}) {
super();
this.config = this.resolveConfig(config);
// Initialize ephemeral stores
this.presenceStore = new EphemeralStore({
ttl: config.presenceTtl ?? 60000,
onExpire: (entry) => this.handlePresenceExpire(entry)
});
this.ephemeralStore = new EphemeralStore({
ttl: config.ephemeralTtl ?? 60000
});
// Set up internal event handlers for presence
this.setupPresenceHandlers();
// Set up generic ephemeral data handlers
this.setupEphemeralHandlers();
if (this.config.enabled && this.config.heartbeatInterval > 0) {
this.startHeartbeat();
}
}
resolveConfig(config) {
return {
enabled: config.enabled ?? true,
heartbeatInterval: config.heartbeatInterval ?? 30000,
connectionTimeout: config.connectionTimeout ?? 0,
maxConnectionsPerUser: config.maxConnectionsPerUser ?? 5,
authenticate: config.authenticate ?? (async () => null),
allowedTables: config.allowedTables ?? [],
presenceTtl: config.presenceTtl ?? 60000,
ephemeralTtl: config.ephemeralTtl ?? 60000,
};
}
/**
* Update configuration at runtime
*/
configure(config) {
const wasEnabled = this.config.enabled;
this.config = { ...this.config, ...config };
// Handle heartbeat changes
if (this.config.enabled && !wasEnabled) {
this.startHeartbeat();
}
else if (!this.config.enabled && wasEnabled) {
this.stopHeartbeat();
this.disconnectAll();
}
}
/**
* Get active connection count
*/
getConnectionCount() {
return this.connections.size;
}
/**
* Get connections for a specific user
*/
getUserConnections(userId) {
const connectionIds = this.userConnections.get(userId);
if (!connectionIds)
return [];
return Array.from(connectionIds)
.map(id => this.connections.get(id))
.filter((conn) => conn !== undefined);
}
/**
* Create an SSE response for a client connection.
* Use this in your API route handler.
*/
createConnection(connectionId, userId, clientId, tables = []) {
if (!this.config.enabled) {
return new Response('Realtime disabled', { status: 503 });
}
// Check max connections per user
const userConns = this.userConnections.get(userId);
if (userConns && userConns.size >= this.config.maxConnectionsPerUser) {
// Remove oldest connection
const oldestId = userConns.values().next().value;
if (oldestId) {
this.removeConnection(oldestId);
}
}
// Filter tables to allowed ones
const allowedTables = this.config.allowedTables.length > 0
? tables.filter(t => this.config.allowedTables.includes(t))
: tables;
const stream = new ReadableStream({
start: (controller) => {
const connection = {
id: connectionId,
userId,
clientId,
tables: allowedTables,
controller,
createdAt: Date.now(),
lastActivity: Date.now(),
};
this.addConnection(connection);
// Send connected event
this.sendToConnection(connectionId, {
type: 'connected',
data: { connectionId, tables: allowedTables },
timestamp: Date.now(),
});
},
cancel: () => {
this.removeConnection(connectionId);
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx buffering
},
});
}
/**
* Broadcast operations to all relevant connected clients.
* Call this after processing push operations.
*/
broadcast(operations, excludeClientId) {
if (!this.config.enabled || operations.length === 0)
return;
const tables = [...new Set(operations.map(op => op.table))];
for (const connection of this.connections.values()) {
// Skip the client that originated the operations
if (excludeClientId && connection.clientId === excludeClientId) {
continue;
}
// Filter operations for tables this connection is subscribed to
const relevantOps = connection.tables.length === 0
? operations // Empty tables = all tables
: operations.filter(op => connection.tables.includes(op.table));
if (relevantOps.length > 0) {
this.sendToConnection(connection.id, {
type: 'operations',
data: { operations: relevantOps, tables },
timestamp: Date.now(),
});
}
}
this.emit('broadcast', { operations, tables });
}
/**
* Send operations to a specific user's connections
*/
sendToUser(userId, operations) {
const connections = this.getUserConnections(userId);
for (const connection of connections) {
const relevantOps = connection.tables.length === 0
? operations
: operations.filter(op => connection.tables.includes(op.table));
if (relevantOps.length > 0) {
this.sendToConnection(connection.id, {
type: 'operations',
data: { operations: relevantOps, tables: [...new Set(relevantOps.map(op => op.table))] },
timestamp: Date.now()
});
}
}
}
/**
* Send a custom event to all connections
*/
sendtoAll(type, data) {
const event = {
type,
data,
timestamp: Date.now()
};
for (const connectionId of this.connections.keys()) {
this.sendToConnection(connectionId, event);
}
}
/**
* Disconenct all clients
*/
disconnectAll() {
for (const connectionId of this.connections.keys()) {
this.removeConnection(connectionId);
}
}
/**
* Handle incoming client message (from POST requests)
*/
handleClientMessage(message, userId, clientId) {
const { type, data } = message;
switch (type) {
case 'presence:join':
case 'presence:update':
this.handlePresenceUpdate(data, userId, clientId);
break;
case 'presence:leave':
this.handlePresenceLeave(data, userId, clientId);
break;
case 'ephemeral':
this.handleEphemeralData(data, userId, clientId);
break;
case 'channel:join':
this.handleChannelJoin(data, userId, clientId);
break;
case 'channel:leave':
this.handleChannelLeave(data, userId, clientId);
break;
default:
console.warn(`Unknown message type: ${type}`);
}
}
/**
* Clean up resources
*/
destroy() {
this.stopHeartbeat();
this.disconnectAll();
this.presenceStore.destroy();
this.ephemeralStore.destroy();
this.removeAllListeners();
}
// Presence handlers
setupPresenceHandlers() {
// No-op for now, presence is handled via handleClientMessage
}
setupEphemeralHandlers() {
// No-op for now, ephemeral data is handled via handleClientMessage
}
handlePresenceUpdate(data, userId, clientId) {
const { channel, state } = data;
const key = `${channel}:${userId}:${clientId}`;
// Check if this is a new presence (join) or update
const existing = this.presenceStore.getEntry(key);
const isJoin = !existing;
// Store/update presence
this.presenceStore.set(key, {
data: state,
userId,
clientId,
channel
});
// Broadcast to other clients in the channel
const eventType = isJoin ? 'presence:join' : 'presence:update';
const event = {
userId,
clientId,
channel,
state
};
this.broadcastToChannel(channel, {
type: eventType,
data: event,
timestamp: Date.now()
}, clientId);
}
handlePresenceLeave(data, userId, clientId) {
const { channel } = data;
const key = `${channel}:${userId}:${clientId}`;
// Remove presence
this.presenceStore.delete(key);
// Broadcast leave event
const event = {
userId,
clientId,
channel
};
this.broadcastToChannel(channel, {
type: 'presence:leave',
data: event,
timestamp: Date.now()
});
}
handlePresenceExpire(entry) {
// Broadcast leave event when presence expires
const event = {
userId: entry.userId,
clientId: entry.clientId,
channel: entry.channel
};
this.broadcastToChannel(entry.channel, {
type: 'presence:leave',
data: event,
timestamp: Date.now()
});
}
handleEphemeralData(data, userId, clientId) {
const { channel, event: eventName, data: eventData } = data;
// Store ephemeral data
const key = `${channel}:${eventName}:${Date.now()}:${Math.random()}`;
this.ephemeralStore.set(key, {
data: eventData,
userId,
clientId,
channel
});
// Broadcast to channel
this.broadcastToChannel(channel, {
type: 'ephemeral',
data: {
channel,
event: eventName,
data: eventData,
userId,
clientId
},
timestamp: Date.now()
}, clientId);
}
handleChannelJoin(data, userId, clientId) {
const { channel } = data;
// Find connection for this client
const connectionId = this.findConnectionId(userId, clientId);
if (!connectionId)
return;
// Add to channel subscriptions
if (!this.channelSubscriptions.has(channel)) {
this.channelSubscriptions.set(channel, new Set());
}
this.channelSubscriptions.get(channel).add(connectionId);
// Send current presence state for this channel
this.sendPresenceSync(connectionId, channel);
}
handleChannelLeave(data, userId, clientId) {
const { channel } = data;
// Find connection for this client
const connectionId = this.findConnectionId(userId, clientId);
if (!connectionId)
return;
// Remove from channel subscriptions
const subs = this.channelSubscriptions.get(channel);
if (subs) {
subs.delete(connectionId);
if (subs.size === 0) {
this.channelSubscriptions.delete(channel);
}
}
}
sendPresenceSync(connectionId, channel) {
const presenceEntries = this.presenceStore.getByChannel(channel);
const presence = {};
for (const entry of presenceEntries) {
const key = `${entry.userId}`;
presence[key] = entry.data;
}
const event = {
channel,
presence
};
this.sendToConnection(connectionId, {
type: 'presence:sync',
data: event,
timestamp: Date.now()
});
}
broadcastToChannel(channel, event, excludeClientId) {
const subs = this.channelSubscriptions.get(channel);
if (!subs)
return;
for (const connectionId of subs) {
const connection = this.connections.get(connectionId);
if (!connection)
continue;
// Skip the client that originated the event
if (excludeClientId && connection.clientId === excludeClientId) {
continue;
}
this.sendToConnection(connectionId, event);
}
}
findConnectionId(userId, clientId) {
const userConns = this.userConnections.get(userId);
if (!userConns)
return null;
for (const connId of userConns) {
const conn = this.connections.get(connId);
if (conn && conn.clientId === clientId) {
return connId;
}
}
return null;
}
addConnection(connection) {
this.connections.set(connection.id, connection);
if (!this.userConnections.has(connection.userId)) {
this.userConnections.set(connection.userId, new Set());
}
this.userConnections.get(connection.userId).add(connection.id);
this.emit('connected', connection);
}
removeConnection(connectionId) {
const connection = this.connections.get(connectionId);
if (!connection)
return;
try {
connection.controller.close();
}
catch {
//Connection may alr3ady be closed
}
this.connections.delete(connectionId);
const userConns = this.userConnections.get(connection.userId);
if (userConns) {
userConns.delete(connectionId);
if (userConns.size === 0) {
this.userConnections.delete(connection.userId);
}
}
this.emit('disconnected', connection);
}
sendToConnection(connectionId, event) {
const connection = this.connections.get(connectionId);
if (!connection)
return;
try {
const eventId = `${Date.now}-${Math.random().toString(36).substr(2, 9)}`;
const message = this.formatSSEMessage(event, eventId);
connection.controller.enqueue(this.encoder.encode(message));
connection.lastActivity = Date.now();
}
catch (error) {
console.error(`Failed to send to connection ${connectionId}`, error);
this.removeConnection(connectionId);
}
}
formatSSEMessage(event, id) {
let message = '';
if (id) {
message += `id:${id}\n`;
}
message += `event: ${event.type}\n`;
message += `data: ${JSON.stringify(event.data)}\n\n`;
return message;
}
startHeartbeat() {
this.stopHeartbeat();
this.heartbeatInterval = setInterval(() => {
const event = {
type: 'heartbeat',
data: { timestamp: Date.now() },
timestamp: Date.now()
};
for (const connectionId of this.connections.keys()) {
this.sendToConnection(connectionId, event);
}
// Cleanup stale conenctions
if (this.config.connectionTimeout > 0) {
const now = Date.now();
for (const [id, conn] of this.connections.entries()) {
if (now - conn.lastActivity > this.config.connectionTimeout) {
this.removeConnection(id);
}
}
}
}, this.config.heartbeatInterval);
}
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
}
/**
* Create a realtime server with the given configuration
*/
export function createRealtimeServer(config) {
return new RealtimeServer(config);
}