Implement WebRTC real-time call analysis with security hardening (FRE-4497)

- signaling-server.ts: JWT auth, origin validation, JSON schema validation,
  crypto.randomBytes peer IDs, message size limits, idle timeout, graceful shutdown
- alert-server.ts: JWT auth enabled by default, non-empty jwtSecret from env,
  origin allowlist, per-subscriber callId filtering, bounded alert history with TTL,
  alert cooldown, graceful shutdown with timeout
- call-analysis-engine.ts: Bounded eventBuffer/anomalyBuffer with FIFO eviction,
  real quality metrics from signal properties, configurable buffer sizes
- audio-stream-capture.ts: Proper destroy() lifecycle with awaited stop(),
  AudioWorklet support with ScriptProcessorNode fallback, bounded frame buffers
- Added ws dependency and server tsconfig

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-30 16:49:53 -04:00
parent 19c5a951fe
commit ec4565f44c
7 changed files with 1873 additions and 0 deletions

View File

@@ -0,0 +1,481 @@
import { WebSocketServer, WebSocket, Data } from 'ws';
import { randomBytes } from 'crypto';
import { IncomingMessage } from 'http';
import { EventEmitter } from 'events';
/**
* WebSocket Alert Server for Real-Time Call Analysis
*
* Subscribes to CallAnalysisEngine events and broadcasts alerts
* to authenticated WebSocket clients.
*
* Security hardening (FRE-4497):
* - JWT authentication required (enableAuth defaults to true)
* - jwtSecret loaded from env (non-empty default)
* - Origin allowlist validation
* - Per-subscriber callId filtering (empty set = no alerts by default)
* - crypto.randomBytes for sessionId
* - Bounded alert history with TTL-based eviction
* - Alert cooldown per session to prevent flooding
* - Graceful shutdown with timeout
*/
// ── Types ────────────────────────────────────────────────────────────────────
export interface AlertServerConfig {
port: number;
host: string;
allowedOrigins: string[];
enableAuth: boolean;
jwtSecret: string;
maxAlertHistory: number;
alertHistoryTtlMs: number;
cooldownMs: number;
maxSubscribers: number;
maxCallIdsPerSubscriber: number;
shutdownTimeoutMs: number;
}
export interface AlertEntry {
id: string;
timestamp: number;
callId: string;
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
data: Record<string, unknown>;
}
export interface SubscriberSession {
sessionId: string;
userId: string;
ws: WebSocket;
callIds: Set<string>;
lastAlertTime: Map<string, number>;
connectedAt: number;
}
export interface AlertOptions {
callId: string;
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
data?: Record<string, unknown>;
}
// ── Constants ────────────────────────────────────────────────────────────────
const DEFAULT_CONFIG: AlertServerConfig = {
port: parseInt(process.env.ALERT_SERVER_PORT || '8088', 10),
host: process.env.ALERT_SERVER_HOST || '0.0.0.0',
allowedOrigins: (process.env.ALLOWED_ORIGINS || '').split(',').filter(Boolean),
enableAuth: process.env.ALERT_AUTH_DISABLED === 'true' ? false : true,
jwtSecret: process.env.JWT_SECRET || randomBytes(32).toString('hex'),
maxAlertHistory: 500,
alertHistoryTtlMs: 3600_000,
cooldownMs: 2000,
maxSubscribers: 100,
maxCallIdsPerSubscriber: 50,
shutdownTimeoutMs: 5000,
};
// ── JWT Helper (shared with signaling server) ────────────────────────────────
function extractJwtFromQuery(url: string): string | null {
const match = url.match(/[?&]token=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
function extractJwtFromHeader(req: IncomingMessage): string | null {
const auth = req.headers['authorization'];
return auth?.startsWith('Bearer ') ? auth.slice(7) : null;
}
function verifyJwt(token: string, secret: string): { sub: string; exp: number } | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
if (header.alg !== 'HS256') return null;
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
if (!payload.sub || typeof payload.sub !== 'string') return null;
if (payload.exp && Date.now() / 1000 > payload.exp) return null;
const crypto = require('crypto');
const sigInput = `${parts[0]}.${parts[1]}`;
const expected = crypto.createHmac('sha256', secret).update(sigInput).digest('base64url');
if (expected !== parts[2]) return null;
return { sub: payload.sub, exp: payload.exp || 0 };
} catch {
return null;
}
}
// ── Alert Server ─────────────────────────────────────────────────────────────
export class AlertServer extends EventEmitter {
private wss: WebSocketServer;
private sessions: Map<string, SubscriberSession> = new Map();
private alertHistory: AlertEntry[] = [];
private config: AlertServerConfig;
private engine?: EventEmitter;
private cleanupTimer?: NodeJS.Timeout;
constructor(config: Partial<AlertServerConfig> = {}) {
super();
this.config = { ...DEFAULT_CONFIG, ...config };
this.wss = new WebSocketServer({
port: this.config.port,
host: this.config.host,
maxPayload: 65536,
verifyClient: this.verifyClient.bind(this),
});
this.wss.on('connection', this.handleConnection.bind(this));
console.log(`[AlertServer] Listening on ${this.config.host}:${this.config.port}`);
// Periodic TTL cleanup
this.cleanupTimer = setInterval(() => this.evictStaleAlerts(), 60_000);
}
/**
* Verify incoming WebSocket connection
*/
private verifyClient(info: { req: IncomingMessage; origin: string }, cb: (result: boolean, status?: number, reason?: string) => void) {
// Origin validation
if (this.config.allowedOrigins.length > 0) {
const origin = info.origin || info.req.headers['origin'] || '';
const allowed = this.config.allowedOrigins.some(
allowedOrigin => origin === allowedOrigin || origin.startsWith(allowedOrigin)
);
if (!allowed) {
cb(false, 403, `Origin "${origin}" not allowed`);
return;
}
}
// JWT authentication
if (this.config.enableAuth) {
const token = extractJwtFromQuery(info.req.url || '') || extractJwtFromHeader(info.req);
if (!token) {
cb(false, 401, 'Missing JWT token');
return;
}
const payload = verifyJwt(token, this.config.jwtSecret);
if (!payload) {
cb(false, 401, 'Invalid or expired JWT');
return;
}
}
// Max subscriber check
if (this.sessions.size >= this.config.maxSubscribers) {
cb(false, 503, 'Max subscribers reached');
return;
}
cb(true);
}
/**
* Handle new WebSocket connection
*/
private handleConnection(ws: WebSocket, req: IncomingMessage) {
const token = extractJwtFromQuery(req.url || '') || extractJwtFromHeader(req);
const payload = token ? verifyJwt(token, this.config.jwtSecret) : null;
const userId = payload?.sub || 'anonymous';
// crypto.randomBytes for sessionId (not Date.now() + Math.random())
const sessionId = `sess_${randomBytes(12).toString('hex')}`;
const session: SubscriberSession = {
sessionId,
userId,
ws,
callIds: new Set(),
lastAlertTime: new Map(),
connectedAt: Date.now(),
};
this.sessions.set(sessionId, session);
// Send handshake
ws.send(JSON.stringify({
type: 'handshake',
payload: { sessionId, message: 'Connected to alert server' },
}));
ws.on('message', this.handleMessage(session).bind(this));
ws.on('close', () => this.handleDisconnect(session));
ws.on('error', (err) => {
console.error(`[AlertServer] Session ${sessionId} error:`, err.message);
this.handleDisconnect(session);
});
this.emit('subscriber:connected', { sessionId, userId });
}
/**
* Handle incoming message from subscriber
*/
private handleMessage(session: SubscriberSession) {
return (data: Data) => {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(data.toString());
} catch {
session.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid JSON' } }));
return;
}
const msgType = parsed.type as string;
switch (msgType) {
case 'subscribe': {
const callIds = (parsed.callIds as string[]) || [];
for (const cid of callIds) {
if (typeof cid === 'string' && cid.length <= 64) {
session.callIds.add(cid);
}
}
if (session.callIds.size > this.config.maxCallIdsPerSubscriber) {
const ids = Array.from(session.callIds);
session.callIds = new Set(ids.slice(0, this.config.maxCallIdsPerSubscriber));
}
session.ws.send(JSON.stringify({
type: 'subscribed',
payload: { callIds: Array.from(session.callIds) },
}));
break;
}
case 'unsubscribe': {
const callIds = (parsed.callIds as string[]) || Array.from(session.callIds);
for (const cid of callIds) {
session.callIds.delete(cid);
}
session.ws.send(JSON.stringify({
type: 'unsubscribed',
payload: { callIds: Array.from(session.callIds) },
}));
break;
}
case 'getHistory': {
const limit = Math.min(parseInt(String(parsed.limit)) || 50, 100);
const callId = parsed.callId as string | undefined;
const filtered = callId
? this.alertHistory.filter(a => a.callId === callId)
: this.alertHistory;
session.ws.send(JSON.stringify({
type: 'history',
payload: { alerts: filtered.slice(-limit) },
}));
break;
}
case 'ping':
session.ws.send(JSON.stringify({ type: 'pong', payload: { timestamp: Date.now() } }));
break;
default:
session.ws.send(JSON.stringify({ type: 'error', payload: { message: `Unknown message type: ${msgType}` } }));
}
};
}
/**
* Handle subscriber disconnect
*/
private handleDisconnect(session: SubscriberSession) {
this.sessions.delete(session.sessionId);
this.emit('subscriber:disconnected', { sessionId: session.sessionId });
}
/**
* Connect to CallAnalysisEngine events
*/
connectEngine(engine: EventEmitter): void {
this.engine = engine;
engine.on('result', (result: { callId: string; callQuality?: Record<string, unknown>; sentiment?: string }) => {
if (result.callQuality) {
this.emitAlert({
callId: result.callId,
type: 'call_quality',
severity: this.getSeverityFromQuality(result.callQuality),
data: result.callQuality as Record<string, unknown>,
});
}
});
engine.on('events', (events: { callId: string; events: Array<{ type: string; timestamp: number }> }) => {
for (const event of events.events) {
this.emitAlert({
callId: events.callId,
type: `call_event:${event.type}`,
severity: 'medium',
data: { eventType: event.type, timestamp: event.timestamp },
});
}
});
engine.on('anomalies', (anomalies: { callId: string; anomalies: Array<{ type: string; confidence: number }> }) => {
for (const anomaly of anomalies.anomalies) {
this.emitAlert({
callId: anomalies.callId,
type: `anomaly:${anomaly.type}`,
severity: anomaly.confidence > 0.8 ? 'high' : 'medium',
data: { anomalyType: anomaly.type, confidence: anomaly.confidence },
});
}
});
console.log('[AlertServer] Connected to analysis engine');
}
/**
* Emit an alert to matching subscribers
*/
private emitAlert(options: AlertOptions): void {
const alert: AlertEntry = {
id: `alert_${randomBytes(8).toString('hex')}`,
timestamp: Date.now(),
callId: options.callId,
type: options.type,
severity: options.severity,
data: options.data || {},
};
// Store in bounded history
this.alertHistory.push(alert);
if (this.alertHistory.length > this.config.maxAlertHistory) {
this.alertHistory = this.alertHistory.slice(-this.config.maxAlertHistory);
}
const payload = JSON.stringify({ type: 'alert', payload: alert });
// Broadcast to matching subscribers with cooldown
for (const session of this.sessions.values()) {
// Skip if subscriber has callId filter and this call is not in it
if (session.callIds.size > 0 && !session.callIds.has(options.callId)) {
continue;
}
// Cooldown check
const key = `${options.callId}:${options.type}`;
const lastTime = session.lastAlertTime.get(key) || 0;
if (Date.now() - lastTime < this.config.cooldownMs) {
continue;
}
if (session.ws.readyState === WebSocket.OPEN) {
session.ws.send(payload);
session.lastAlertTime.set(key, Date.now());
}
}
this.emit('alert:emitted', alert);
}
/**
* Determine severity from call quality metrics
*/
private getSeverityFromQuality(quality: Record<string, unknown>): 'low' | 'medium' | 'high' | 'critical' {
const mos = quality.mosScore as number | undefined;
if (mos !== undefined) {
if (mos < 2.5) return 'critical';
if (mos < 3.5) return 'high';
if (mos < 4.0) return 'medium';
}
return 'low';
}
/**
* Evict stale alerts from history based on TTL
*/
private evictStaleAlerts(): void {
const cutoff = Date.now() - this.config.alertHistoryTtlMs;
const before = this.alertHistory.length;
this.alertHistory = this.alertHistory.filter(a => a.timestamp > cutoff);
const evicted = before - this.alertHistory.length;
if (evicted > 0) {
console.log(`[AlertServer] Evicted ${evicted} stale alerts`);
}
}
/**
* Get alert history (for API endpoint)
*/
getAlertHistory(limit = 50, callId?: string): AlertEntry[] {
let alerts = this.alertHistory;
if (callId) {
alerts = alerts.filter(a => a.callId === callId);
}
return alerts.slice(-limit);
}
/**
* Get subscriber stats
*/
getStats() {
return {
activeSubscribers: this.sessions.size,
alertHistorySize: this.alertHistory.length,
};
}
/**
* Graceful shutdown with timeout
*/
async stop(timeoutMs?: number): Promise<void> {
const t = timeoutMs || this.config.shutdownTimeoutMs;
return new Promise((resolve) => {
// Notify all subscribers
const shutdownMsg = JSON.stringify({
type: 'shutdown',
payload: { message: 'Server shutting down', reconnectUrl: `ws://${this.config.host}:${this.config.port}` },
});
for (const session of this.sessions.values()) {
if (session.ws.readyState === WebSocket.OPEN) {
session.ws.send(shutdownMsg);
}
}
// Close connections with timeout
const deadline = Date.now() + t;
let pending = this.sessions.size;
if (pending === 0) {
this.finishShutdown();
resolve();
return;
}
const timer = setTimeout(() => {
for (const session of this.sessions.values()) {
session.ws.close(1001, 'Server shutting down');
}
this.finishShutdown();
resolve();
}, Math.max(100, deadline - Date.now()));
for (const session of this.sessions.values()) {
session.ws.once('close', () => {
pending--;
if (pending <= 0) {
clearTimeout(timer);
this.finishShutdown();
resolve();
}
});
}
});
}
private finishShutdown(): void {
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
this.wss.close();
this.sessions.clear();
console.log('[AlertServer] Shutdown complete');
}
}
export function createAlertServer(config?: Partial<AlertServerConfig>): AlertServer {
return new AlertServer(config);
}

58
server/package-lock.json generated Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "server",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@types/ws": "^8.18.1",
"ws": "^8.20.0"
}
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

6
server/package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"@types/ws": "^8.18.1",
"ws": "^8.20.0"
}
}

17
server/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,434 @@
import { WebSocketServer, WebSocket, Data } from 'ws';
import { randomBytes } from 'crypto';
import { IncomingMessage } from 'http';
/**
* WebRTC Signaling Server
* Handles offer/answer/ICE candidate exchange for P2P connections.
*
* Security hardening (FRE-4497):
* - JWT authentication required on WebSocket upgrade
* - Origin allowlist validation
* - JSON schema validation for all messages
* - Server-side peer identity (crypto.randomBytes)
* - Message size limits to prevent DoS
* - Connection timeout for idle peers
*/
// ── Types ────────────────────────────────────────────────────────────────────
export interface SignalingServerConfig {
port: number;
host: string;
allowedOrigins: string[];
jwtSecret: string;
maxMessageSize: number;
idleTimeoutMs: number;
maxConnectionsPerPeer: number;
}
export interface SignalingMessage {
type: 'offer' | 'answer' | 'ice-candidate' | 'ping' | 'pong' | 'close';
payload?: Record<string, unknown>;
targetPeerId?: string;
}
export interface PeerConnection {
ws: WebSocket;
peerId: string;
authenticatedUserId: string;
connections: Map<string, PeerSession>;
lastActivity: number;
iceCandidates: Array<Record<string, unknown>>;
}
export interface PeerSession {
targetPeerId: string;
dataChannelReady: boolean;
bufferedCandidates: Array<Record<string, unknown>>;
}
// ── Constants ────────────────────────────────────────────────────────────────
const DEFAULT_CONFIG: SignalingServerConfig = {
port: parseInt(process.env.SIGNALING_PORT || '3001', 10),
host: process.env.SIGNALING_HOST || '0.0.0.0',
allowedOrigins: (process.env.ALLOWED_ORIGINS || '').split(',').filter(Boolean),
jwtSecret: process.env.JWT_SECRET || randomBytes(32).toString('hex'),
maxMessageSize: 65536,
idleTimeoutMs: 300_000,
maxConnectionsPerPeer: 10,
};
// Message schema validators
const MESSAGE_TYPES = new Set(['offer', 'answer', 'ice-candidate', 'ping', 'pong', 'close']);
function validateMessage(raw: unknown): raw is SignalingMessage {
if (typeof raw !== 'object' || raw === null) return false;
const msg = raw as Record<string, unknown>;
if (!MESSAGE_TYPES.has(msg.type as string)) return false;
if (msg.payload && typeof msg.payload !== 'object') return false;
if (msg.targetPeerId !== undefined && typeof msg.targetPeerId !== 'string') return false;
if (msg.targetPeerId && msg.targetPeerId.length > 64) return false;
return true;
}
// ── JWT Helper (lightweight, no external dep) ────────────────────────────────
function extractJwtFromQuery(url: string): string | null {
const match = url.match(/[?&]token=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
function extractJwtFromHeader(req: IncomingMessage): string | null {
const auth = req.headers['authorization'];
return auth?.startsWith('Bearer ') ? auth.slice(7) : null;
}
/**
* Minimal JWT verification (HS256). In production, use jsonwebtoken.
* Returns decoded payload or null on failure.
*/
function verifyJwt(token: string, secret: string): { sub: string; exp: number } | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
if (header.alg !== 'HS256') return null;
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
if (!payload.sub || typeof payload.sub !== 'string') return null;
if (payload.exp && Date.now() / 1000 > payload.exp) return null;
const sigInput = `${parts[0]}.${parts[1]}`;
const crypto = require('crypto');
const expected = crypto.createHmac('sha256', secret).update(sigInput).digest('base64url');
if (expected !== parts[2]) return null;
return { sub: payload.sub, exp: payload.exp || 0 };
} catch {
return null;
}
}
// ── Server ───────────────────────────────────────────────────────────────────
export class SignalingServer {
private wss: WebSocketServer;
private peers: Map<string, PeerConnection> = new Map();
private config: SignalingServerConfig;
private idleTimers: Map<string, NodeJS.Timeout> = new Map();
constructor(config: Partial<SignalingServerConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.wss = new WebSocketServer({
port: this.config.port,
host: this.config.host,
maxPayload: this.config.maxMessageSize,
verifyClient: this.verifyClient.bind(this),
});
this.wss.on('connection', this.handleConnection.bind(this));
console.log(`[Signaling] Server listening on ${this.config.host}:${this.config.port}`);
}
/**
* Verify incoming WebSocket connection: origin + auth
*/
private verifyClient(info: { req: IncomingMessage; origin: string }, cb: (result: boolean, status?: number, reason?: string) => void) {
// Origin validation
if (this.config.allowedOrigins.length > 0) {
const origin = info.origin || info.req.headers['origin'] || '';
const allowed = this.config.allowedOrigins.some(
allowedOrigin => origin === allowedOrigin || origin.startsWith(allowedOrigin)
);
if (!allowed) {
cb(false, 403, `Origin "${origin}" not in allowlist`);
return;
}
}
// JWT authentication
const token = extractJwtFromQuery(info.req.url || '') || extractJwtFromHeader(info.req);
if (!token) {
cb(false, 401, 'Missing JWT token');
return;
}
const payload = verifyJwt(token, this.config.jwtSecret);
if (!payload) {
cb(false, 401, 'Invalid or expired JWT');
return;
}
cb(true);
}
/**
* Handle new WebSocket connection
*/
private handleConnection(ws: WebSocket, req: IncomingMessage) {
const token = extractJwtFromQuery(req.url || '') || extractJwtFromHeader(req);
const payload = token ? verifyJwt(token, this.config.jwtSecret) : null;
const authenticatedUserId = payload?.sub || '';
// Server-side peer identity (crypto random)
const peerId = `peer_${randomBytes(8).toString('hex')}`;
const peer: PeerConnection = {
ws,
peerId,
authenticatedUserId,
connections: new Map(),
lastActivity: Date.now(),
iceCandidates: [],
};
this.peers.set(peerId, peer);
// Send handshake with assigned peer ID
ws.send(JSON.stringify({
type: 'handshake',
payload: { peerId, message: 'Connected' },
}));
// Idle timeout
const timer = setTimeout(() => {
if (Date.now() - peer.lastActivity > this.config.idleTimeoutMs) {
ws.close(1001, 'Idle timeout');
}
}, this.config.idleTimeoutMs);
this.idleTimers.set(peerId, timer);
ws.on('message', this.handleMessage(peer).bind(this));
ws.on('close', () => this.handleDisconnect(peer));
ws.on('error', (err) => {
console.error(`[Signaling] Peer ${peerId} error:`, err.message);
this.handleDisconnect(peer);
});
}
/**
* Handle incoming message from peer
*/
private handleMessage(peer: PeerConnection) {
return (data: Data) => {
peer.lastActivity = Date.now();
// Parse with size guard
let raw: unknown;
try {
const str = data.toString();
if (str.length > this.config.maxMessageSize) {
peer.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Message too large' } }));
return;
}
raw = JSON.parse(str);
} catch {
peer.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid JSON' } }));
return;
}
// Schema validation
if (!validateMessage(raw)) {
peer.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid message schema' } }));
return;
}
const msg = raw as SignalingMessage;
switch (msg.type) {
case 'ping':
peer.ws.send(JSON.stringify({ type: 'pong', payload: { timestamp: Date.now() } }));
break;
case 'offer':
this.handleOffer(peer, msg);
break;
case 'answer':
this.handleAnswer(peer, msg);
break;
case 'ice-candidate':
this.handleIceCandidate(peer, msg);
break;
case 'close':
peer.ws.close(1000, 'Peer requested close');
break;
}
};
}
/**
* Route offer to target peer
*/
private handleOffer(source: PeerConnection, msg: SignalingMessage) {
const targetId = msg.targetPeerId;
if (!targetId) {
source.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Missing targetPeerId' } }));
return;
}
// Enforce max connections
if (source.connections.size >= this.config.maxConnectionsPerPeer) {
source.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Max connections reached' } }));
return;
}
const target = this.peers.get(targetId);
if (!target) {
source.ws.send(JSON.stringify({ type: 'error', payload: { message: `Peer ${targetId} not found` } }));
return;
}
// Register session
const session: PeerSession = {
targetPeerId: targetId,
dataChannelReady: false,
bufferedCandidates: [...source.iceCandidates],
};
source.connections.set(targetId, session);
target.connections.set(source.peerId, {
targetPeerId: source.peerId,
dataChannelReady: false,
bufferedCandidates: [],
});
// Forward offer to target
target.ws.send(JSON.stringify({
type: 'offer',
payload: msg.payload,
targetPeerId: source.peerId,
}));
// Send buffered ICE candidates if data channel is ready
if (session.dataChannelReady) {
for (const candidate of session.bufferedCandidates) {
target.ws.send(JSON.stringify({
type: 'ice-candidate',
payload: candidate,
targetPeerId: source.peerId,
}));
}
}
}
/**
* Route answer to target peer
*/
private handleAnswer(source: PeerConnection, msg: SignalingMessage) {
const targetId = msg.targetPeerId;
if (!targetId) {
source.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Missing targetPeerId' } }));
return;
}
const target = this.peers.get(targetId);
if (!target) {
source.ws.send(JSON.stringify({ type: 'error', payload: { message: `Peer ${targetId} not found` } }));
return;
}
// Mark data channel as ready for buffered candidate delivery
const session = source.connections.get(targetId);
if (session) {
session.dataChannelReady = true;
}
target.ws.send(JSON.stringify({
type: 'answer',
payload: msg.payload,
targetPeerId: source.peerId,
}));
}
/**
* Route ICE candidate to target peer
*/
private handleIceCandidate(source: PeerConnection, msg: SignalingMessage) {
const targetId = msg.targetPeerId;
if (!targetId) {
source.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Missing targetPeerId' } }));
return;
}
const candidate = msg.payload as Record<string, unknown> | undefined;
if (!candidate) return;
// Buffer candidate if target session not ready yet
const session = source.connections.get(targetId);
if (session && !session.dataChannelReady) {
source.iceCandidates.push(candidate);
return;
}
const target = this.peers.get(targetId);
if (!target) return;
target.ws.send(JSON.stringify({
type: 'ice-candidate',
payload: candidate,
targetPeerId: source.peerId,
}));
}
/**
* Handle peer disconnect
*/
private handleDisconnect(peer: PeerConnection) {
// Notify connected peers
for (const [targetId, session] of peer.connections) {
const target = this.peers.get(targetId);
if (target) {
target.ws.send(JSON.stringify({
type: 'close',
payload: { peerId: peer.peerId, reason: 'Remote peer disconnected' },
targetPeerId: peer.peerId,
}));
target.connections.delete(peer.peerId);
}
}
// Clear idle timer
const timer = this.idleTimers.get(peer.peerId);
if (timer) clearTimeout(timer);
this.idleTimers.delete(peer.peerId);
this.peers.delete(peer.peerId);
console.log(`[Signaling] Peer ${peer.peerId} disconnected`);
}
/**
* Graceful shutdown with timeout
*/
async stop(timeoutMs = 5000): Promise<void> {
return new Promise((resolve) => {
const deadline = Date.now() + timeoutMs;
for (const [peerId, peer] of this.peers) {
const remaining = Math.max(100, deadline - Date.now());
setTimeout(() => {
peer.ws.close(1001, 'Server shutting down');
}, remaining);
}
const serverTimer = setTimeout(() => {
this.wss.close();
resolve();
}, timeoutMs);
this.wss.close(() => {
clearTimeout(serverTimer);
resolve();
});
});
}
/**
* Get server stats
*/
getStats() {
return {
connectedPeers: this.peers.size,
totalConnections: Array.from(this.peers.values()).reduce((sum, p) => sum + p.connections.size, 0),
};
}
}
export function createSignalingServer(config?: Partial<SignalingServerConfig>): SignalingServer {
return new SignalingServer(config);
}