@synet/realtime
Version:
Realtime Communication server/client implementations
279 lines (240 loc) • 7.77 kB
text/typescript
import WebSocket, { WebSocketServer } from "ws";
import type { IncomingMessage } from "node:http";
import crypto from "node:crypto";
import type {
RealtimeServer,
RealtimeServerOptions,
RealtimeServerStats,
ClientConnectedEventData,
ClientDisconnectedEventData,
MessageReceivedEventData,
ServerEventType,
RealtimeEvent,
Topic,
} from "@synet/patterns/realtime/server";
import type { Logger } from "@synet/logger";
/**
* WebSocket-specific options
*/
export interface WebSocketServerOptions {
port?: number;
host?: string;
heartbeat?: {
enabled: boolean;
intervalMs: number;
timeoutMs: number;
};
}
export class WebSocketRealtimeServer implements RealtimeServer {
private wsServer?: WebSocketServer;
private clients: Map<string, WebSocket> = new Map();
private subscriptions: Map<Topic, Set<string>> = new Map(); // topic -> client ids
private stats: RealtimeServerStats;
private startTime: number = Date.now();
private eventHandlers: Map<string, Set<(data: unknown) => void>> = new Map();
constructor(
private options: RealtimeServerOptions<WebSocketServerOptions> = {},
private logger?: Logger, // Use console for simplicity, can be replaced with a proper Logger interface
) {
this.stats = {
connectedClients: 0,
totalTopics: 0,
messagesSent: 0,
messagesReceived: 0,
uptime: 0,
};
}
async start(): Promise<void> {
const port = this.options.transportOptions?.port || 3030;
this.wsServer = new WebSocketServer({ port });
this.wsServer.on("connection", (socket: WebSocket, request:IncomingMessage) => {
this.handleNewConnection(socket, request);
});
this.logger?.info(`Websocket server listening on port ${port}`);
}
async stop(): Promise<void> {
if (!this.wsServer) return;
// Close all client connections
const disconnectPromises = Array.from(this.clients.values()).map(
(client) =>
new Promise<void>((resolve) => {
client.close();
resolve();
}),
);
await Promise.all(disconnectPromises);
// Close server
return new Promise((resolve) => {
if (this.wsServer) {
this.wsServer.close(() => {
this.clients.clear();
this.subscriptions.clear();
resolve();
});
} else {
resolve();
}
});
}
async broadcast(topic: Topic, event: RealtimeEvent): Promise<void> {
const clientIds = this.subscriptions.get(topic);
if (!clientIds || clientIds.size === 0) {
this.logger?.info(`No subscribers for topic: ${topic}`);
return;
}
const message = JSON.stringify(event);
let sentCount = 0;
for (const clientId of clientIds) {
const client = this.clients.get(clientId);
if (client && client.readyState === WebSocket.OPEN) {
try {
client.send(message);
sentCount++;
} catch (error) {
console.error(`Failed to send to client ${clientId}:`, error);
}
}
}
this.stats.messagesSent += sentCount;
this.logger?.debug(
`Broadcasted to ${topic}: ${sentCount} clients received event`,
);
}
private handleNewConnection(
socket: WebSocket,
request: IncomingMessage,
): void {
const clientId = crypto.randomUUID();
this.clients.set(clientId, socket);
// Parse topic from URL path or query parameter
const url = new URL(request.url || "/", `http://${request.headers.host}`);
const topic =
url.searchParams.get("topic") ||
url.pathname.split("/").filter(Boolean)[0] ||
"default";
this.subscribeClientToTopic(clientId, topic);
this.logger?.info(
`Client ${clientId} connected and subscribed to: ${topic}`,
);
const eventData: ClientConnectedEventData = {
clientId,
topic,
timestamp: new Date(),
metadata: {
ip: request.socket.remoteAddress,
userAgent: request.headers["user-agent"] as string | undefined,
},
};
// Emit with proper typing
this.emit("client.connected", eventData);
// Handle client disconnection
socket.on("close", () => {
this.handleClientDisconnection(clientId);
});
// Handle client messages (minimal - just for subscribe/unsubscribe)
socket.on("message", (data: RealtimeEvent) => {
try {
const message = JSON.parse(data.toString());
if (message.type === "subscribe" && message.topic) {
this.subscribeClientToTopic(clientId, message.topic);
} else if (message.type === "unsubscribe" && message.topic) {
this.unsubscribeClientFromTopic(clientId, message.topic);
}
// Note: We don't handle 'publish' here - that's for bidirectional later
} catch (error) {
console.error("Error processing client message:", error);
}
});
// Send connection confirmation
socket.send(
JSON.stringify({
id: crypto.randomUUID(),
type: "connection.established",
source: "server",
timestamp: new Date(),
data: { clientId, subscribedTopic: topic },
}),
);
}
private subscribeClientToTopic(clientId: string, topic: Topic): void {
if (!this.subscriptions.has(topic)) {
this.subscriptions.set(topic, new Set());
}
const subscribers = this.subscriptions.get(topic);
if (subscribers) {
subscribers.add(clientId);
}
this.logger?.info(`Client ${clientId} subscribed to topic: ${topic}`);
}
private unsubscribeClientFromTopic(clientId: string, topic: Topic): void {
const subscribers = this.subscriptions.get(topic);
if (subscribers) {
subscribers.delete(clientId);
if (subscribers.size === 0) {
this.subscriptions.delete(topic);
}
}
this.logger?.info(`Client ${clientId} unsubscribed from topic: ${topic}`);
}
private handleClientDisconnection(clientId: string): void {
this.logger?.info(`Client ${clientId} disconnected`);
const subscribedTopics: Topic[] = [];
for (const [topic, subscribers] of this.subscriptions.entries()) {
if (subscribers.has(clientId)) {
subscribedTopics.push(topic);
}
}
this.clients.delete(clientId);
// Remove from all topic subscriptions
for (const [topic, subscribers] of this.subscriptions.entries()) {
subscribers.delete(clientId);
if (subscribers.size === 0) {
this.subscriptions.delete(topic);
}
}
// Create properly typed event data
const eventData: ClientDisconnectedEventData = {
clientId,
topics: subscribedTopics,
timestamp: new Date(),
};
// Emit with proper typing
this.emit("client.disconnected", eventData);
}
getStats(): RealtimeServerStats {
return {
...this.stats,
connectedClients: this.clients.size,
totalTopics: this.subscriptions.size,
uptime: Date.now() - this.startTime,
};
}
on<T extends ServerEventType>(
event: T,
handler: (data: unknown) => void,
): void {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set());
}
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.add(handler);
}
}
/**
* Emit an event to all registered handlers
* @param event The event type
* @param data The event data
*/
private emit(event: ServerEventType, data: unknown): void {
const handlers = this.eventHandlers.get(event) || new Set();
for (const handler of handlers) {
try {
handler(data);
} catch (error) {
console.error(`Error in event handler for ${event}:`, error);
}
}
}
// ... other methods (getStats, on, stop, etc.)
}