feat(websocket): Add TypedRouter WebSocket integration, connection registry, peer tagging and broadcast APIs
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -127,3 +127,13 @@ export class ServerNotRunningError extends Error {
|
||||
this.name = 'ServerNotRunningError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when WebSocket configuration is invalid
|
||||
*/
|
||||
export class WebSocketConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'WebSocketConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Uses Web Standards API (Request/Response) for cross-platform compatibility
|
||||
*/
|
||||
|
||||
import type { TypedRouter } from '@api.global/typedrequest';
|
||||
|
||||
// =============================================================================
|
||||
// HTTP Types
|
||||
// =============================================================================
|
||||
@@ -168,18 +170,55 @@ export interface IWebSocketPeer {
|
||||
context: IRequestContext;
|
||||
/** Custom per-peer data storage */
|
||||
data: Map<string, unknown>;
|
||||
/** Tags for connection filtering/grouping */
|
||||
tags: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket event hooks
|
||||
*/
|
||||
export interface IWebSocketHooks {
|
||||
/** Called when WebSocket connection opens */
|
||||
onOpen?: (peer: IWebSocketPeer) => void | Promise<void>;
|
||||
/** Called when message received. Mutually exclusive with typedRouter. */
|
||||
onMessage?: (peer: IWebSocketPeer, message: IWebSocketMessage) => void | Promise<void>;
|
||||
/** Called when WebSocket connection closes */
|
||||
onClose?: (peer: IWebSocketPeer, code: number, reason: string) => void | Promise<void>;
|
||||
/** Called on WebSocket error */
|
||||
onError?: (peer: IWebSocketPeer, error: Error) => void | Promise<void>;
|
||||
/** Called when ping received */
|
||||
onPing?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>;
|
||||
/** Called when pong received */
|
||||
onPong?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* TypedRouter for type-safe RPC over WebSocket.
|
||||
* Mutually exclusive with onMessage - cannot use both.
|
||||
* When set, enables connection registry and broadcast methods.
|
||||
*/
|
||||
typedRouter?: TypedRouter;
|
||||
|
||||
/**
|
||||
* Called when connection is established and registered.
|
||||
* Only available when typedRouter is configured.
|
||||
* Use this to tag connections for filtering.
|
||||
*/
|
||||
onConnectionOpen?: (peer: IWebSocketPeer) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when connection is closed and unregistered.
|
||||
* Only available when typedRouter is configured.
|
||||
*/
|
||||
onConnectionClose?: (peer: IWebSocketPeer) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal callbacks for adapter-to-SmartServe communication
|
||||
* @internal
|
||||
*/
|
||||
export interface IWebSocketConnectionCallbacks {
|
||||
onRegister: (peer: IWebSocketPeer) => void;
|
||||
onUnregister: (peerId: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user