import * as plugins from './smartsocket.plugins.js'; import * as pluginsTyped from './smartsocket.pluginstyped.js'; // used in case no other server is supplied import { Smartsocket } from './smartsocket.classes.smartsocket.js'; import { logger } from './smartsocket.logging.js'; /** * class SocketServer * handles the WebSocket server in standalone mode, or provides hooks for smartserve integration */ export class SocketServer { private smartsocket: Smartsocket; private httpServer: pluginsTyped.http.Server | pluginsTyped.https.Server; private wsServer: pluginsTyped.ws.WebSocketServer; /** * whether httpServer is standalone (created by us) */ private standaloneServer = false; constructor(smartSocketInstance: Smartsocket) { this.smartsocket = smartSocketInstance; } /** * Starts listening to incoming websocket connections (standalone mode). * If no port is specified, this is a no-op (hooks mode via smartserve). */ public async start() { // If no port specified, we're in hooks mode - nothing to start if (!this.smartsocket.options.port) { return; } // Standalone mode - create our own HTTP server and WebSocket server const done = plugins.smartpromise.defer(); const httpModule = await this.smartsocket.smartenv.getSafeNodeModule('http'); const wsModule = await this.smartsocket.smartenv.getSafeNodeModule('ws'); this.httpServer = httpModule.createServer(); this.standaloneServer = true; // Create WebSocket server attached to HTTP server this.wsServer = new wsModule.WebSocketServer({ server: this.httpServer }); this.wsServer.on('connection', (ws: pluginsTyped.ws.WebSocket) => { this.smartsocket.handleNewConnection(ws); }); this.httpServer.listen(this.smartsocket.options.port, () => { logger.log( 'success', `Server started in standalone mode on port ${this.smartsocket.options.port}` ); done.resolve(); }); await done.promise; } /** * closes the server */ public async stop() { const done = plugins.smartpromise.defer(); let resolved = false; if (this.wsServer) { // Close all WebSocket connections this.wsServer.clients.forEach((client) => { client.terminate(); }); this.wsServer.close(); this.wsServer = null; } if (this.httpServer && this.standaloneServer) { const resolveOnce = () => { if (!resolved) { resolved = true; this.httpServer = null; this.standaloneServer = false; done.resolve(); } }; this.httpServer.close(() => { resolveOnce(); }); // Add a timeout in case close callback doesn't fire const timeoutId = setTimeout(() => { resolveOnce(); }, 2000); // Ensure timeout doesn't keep process alive if (timeoutId.unref) { timeoutId.unref(); } } else { done.resolve(); } await done.promise; } /** * Returns WebSocket hooks for integration with smartserve. * Pass these hooks to SmartServe's websocket config. */ public getSmartserveWebSocketHooks(): pluginsTyped.ISmartserveWebSocketHooks { return { onOpen: async (peer: pluginsTyped.ISmartserveWebSocketPeer) => { // Create a wrapper that adapts ISmartserveWebSocketPeer to WebSocket-like interface const wsLikeSocket = this.createWsLikeFromPeer(peer); await this.smartsocket.handleNewConnection(wsLikeSocket as any); }, onMessage: async (peer: pluginsTyped.ISmartserveWebSocketPeer, message: pluginsTyped.ISmartserveWebSocketMessage) => { // Dispatch message to the SocketConnection via the adapter const adapter = peer.data.get('smartsocket_adapter') as any; if (adapter) { let textData: string | undefined; if (message.type === 'text' && message.text) { textData = message.text; } else if (message.type === 'binary' && message.data) { // Convert binary to text (Buffer/Uint8Array to string) textData = new TextDecoder().decode(message.data); } if (textData) { adapter.dispatchMessage(textData); } } }, onClose: async (peer: pluginsTyped.ISmartserveWebSocketPeer, code: number, reason: string) => { // Dispatch close to the SocketConnection via the adapter const adapter = peer.data.get('smartsocket_adapter') as any; if (adapter) { adapter.dispatchClose(); } }, onError: async (peer: pluginsTyped.ISmartserveWebSocketPeer, error: Error) => { // Dispatch error to the SocketConnection via the adapter const adapter = peer.data.get('smartsocket_adapter') as any; if (adapter) { adapter.dispatchError(); } }, }; } /** * Creates a WebSocket-like object from a smartserve peer * This allows our SocketConnection to work with both native WebSocket and smartserve peers */ private createWsLikeFromPeer(peer: pluginsTyped.ISmartserveWebSocketPeer): any { const messageListeners: Array<(event: any) => void> = []; const closeListeners: Array<() => void> = []; const errorListeners: Array<() => void> = []; // Store the adapter on the peer for message routing peer.data.set('smartsocket_adapter', { dispatchMessage: (data: string) => { messageListeners.forEach((listener) => { listener({ data }); }); }, dispatchClose: () => { closeListeners.forEach((listener) => listener()); }, dispatchError: () => { errorListeners.forEach((listener) => listener()); }, }); return { readyState: peer.readyState, send: (data: string) => peer.send(data), close: (code?: number, reason?: string) => peer.close(code, reason), addEventListener: (event: string, listener: any) => { if (event === 'message') { messageListeners.push(listener); } else if (event === 'close') { closeListeners.push(listener); } else if (event === 'error') { errorListeners.push(listener); } }, removeEventListener: (event: string, listener: any) => { if (event === 'message') { const idx = messageListeners.indexOf(listener); if (idx >= 0) messageListeners.splice(idx, 1); } else if (event === 'close') { const idx = closeListeners.indexOf(listener); if (idx >= 0) closeListeners.splice(idx, 1); } else if (event === 'error') { const idx = errorListeners.indexOf(listener); if (idx >= 0) errorListeners.splice(idx, 1); } }, }; } }