feat(websocket): Add TypedRouter WebSocket integration, connection registry, peer tagging and broadcast APIs

This commit is contained in:
2025-12-02 12:13:46 +00:00
parent fddba44a5f
commit 643a6cec55
12 changed files with 387 additions and 40 deletions

View File

@@ -13,8 +13,10 @@ import type {
IInterceptOptions,
TRequestInterceptor,
TResponseInterceptor,
IWebSocketPeer,
IWebSocketConnectionCallbacks,
} from './smartserve.interfaces.js';
import { HttpError, RouteNotFoundError, ServerAlreadyRunningError } from './smartserve.errors.js';
import { HttpError, RouteNotFoundError, ServerAlreadyRunningError, WebSocketConfigError } from './smartserve.errors.js';
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
import { ControllerRegistry, type ICompiledRoute } from '../decorators/index.js';
import { FileServer } from '../files/index.js';
@@ -42,12 +44,31 @@ export class SmartServe {
private fileServer: FileServer | null = null;
private webdavHandler: WebDAVHandler | null = null;
/** WebSocket connection registry (only active when typedRouter is set) */
private wsConnections: Map<string, IWebSocketPeer> | null = null;
constructor(options: ISmartServeOptions) {
// Validate websocket configuration - mutual exclusivity
if (options.websocket) {
const { typedRouter, onMessage } = options.websocket;
if (typedRouter && onMessage) {
throw new WebSocketConfigError(
'Cannot use both typedRouter and onMessage. ' +
'typedRouter handles message routing automatically.'
);
}
}
this.options = {
hostname: '0.0.0.0',
...options,
};
// Initialize connection registry only when typedRouter is configured
if (this.options.websocket?.typedRouter) {
this.wsConnections = new Map();
}
// Initialize file server if static options provided
if (this.options.static) {
this.fileServer = new FileServer(this.options.static);
@@ -90,8 +111,37 @@ export class SmartServe {
throw new ServerAlreadyRunningError();
}
// Prepare options with internal callbacks if typedRouter is configured
let adapterOptions = this.options;
if (this.options.websocket?.typedRouter && this.wsConnections) {
// Clone options and add internal callbacks for adapter communication
const connectionCallbacks: IWebSocketConnectionCallbacks = {
onRegister: (peer: IWebSocketPeer) => {
this.wsConnections!.set(peer.id, peer);
this.options.websocket?.onConnectionOpen?.(peer);
},
onUnregister: (peerId: string) => {
const peer = this.wsConnections!.get(peerId);
if (peer) {
this.wsConnections!.delete(peerId);
this.options.websocket?.onConnectionClose?.(peer);
}
},
};
adapterOptions = {
...this.options,
websocket: {
...this.options.websocket,
// Internal property for adapter communication (not part of public API)
_connectionCallbacks: connectionCallbacks,
} as typeof this.options.websocket & { _connectionCallbacks: IWebSocketConnectionCallbacks },
};
}
// Create adapter for current runtime
this.adapter = await AdapterFactory.createAdapter(this.options);
this.adapter = await AdapterFactory.createAdapter(adapterOptions);
// Create request handler
const handler = this.createRequestHandler();
@@ -127,6 +177,70 @@ export class SmartServe {
return this.instance !== null;
}
// ===========================================================================
// WebSocket Connection Management (only available with typedRouter)
// ===========================================================================
/**
* Get all active WebSocket connections
* Only available when typedRouter is configured
*/
getWebSocketConnections(): IWebSocketPeer[] {
if (!this.wsConnections) {
return [];
}
return Array.from(this.wsConnections.values());
}
/**
* Get WebSocket connections filtered by tag
* Only available when typedRouter is configured
*/
getWebSocketConnectionsByTag(tag: string): IWebSocketPeer[] {
if (!this.wsConnections) {
return [];
}
return Array.from(this.wsConnections.values()).filter((peer) => peer.tags.has(tag));
}
/**
* Broadcast message to all WebSocket connections
* Only available when typedRouter is configured
*/
broadcastWebSocket(data: string | object): void {
if (!this.wsConnections) {
return;
}
const message = typeof data === 'string' ? data : JSON.stringify(data);
for (const peer of this.wsConnections.values()) {
try {
peer.send(message);
} catch (error) {
console.error(`Failed to send to peer ${peer.id}:`, error);
}
}
}
/**
* Broadcast message to WebSocket connections with specific tag
* Only available when typedRouter is configured
*/
broadcastWebSocketByTag(tag: string, data: string | object): void {
if (!this.wsConnections) {
return;
}
const message = typeof data === 'string' ? data : JSON.stringify(data);
for (const peer of this.wsConnections.values()) {
if (peer.tags.has(tag)) {
try {
peer.send(message);
} catch (error) {
console.error(`Failed to send to peer ${peer.id}:`, error);
}
}
}
}
/**
* Create the main request handler
*/