Files
siprouter/ts/frontend.ts
Juergen Kunz f3e1c96872 initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine
Full-featured SIP router with multi-provider trunking, browser softphone
via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML
noise suppression, Kokoro neural TTS announcements, and a Lit-based
web dashboard with live call monitoring and REST API.
2026-04-09 23:03:55 +00:00

373 lines
14 KiB
TypeScript

/**
* Web dashboard server for the SIP proxy.
*
* Serves a bundled web component frontend (ts_web/) and pushes
* live updates over WebSocket. The frontend is built with
* @design.estate/dees-element web components and bundled with esbuild.
*/
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import https from 'node:https';
import { WebSocketServer, WebSocket } from 'ws';
import type { CallManager } from './call/index.ts';
import { handleWebRtcSignaling } from './webrtcbridge.ts';
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
// ---------------------------------------------------------------------------
// WebSocket broadcast
// ---------------------------------------------------------------------------
const wsClients = new Set<WebSocket>();
function timestamp(): string {
return new Date().toISOString().replace('T', ' ').slice(0, 19);
}
export function broadcastWs(type: string, data: unknown): void {
if (!wsClients.size) return;
const msg = JSON.stringify({ type, data, ts: timestamp() });
for (const ws of wsClients) {
try {
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
} catch {
wsClients.delete(ws);
}
}
}
// ---------------------------------------------------------------------------
// Static file cache (loaded at startup)
// ---------------------------------------------------------------------------
const staticFiles = new Map<string, { data: Buffer; contentType: string }>();
function loadStaticFiles(): void {
const cwd = process.cwd();
// Load index.html
const htmlPath = path.join(cwd, 'html', 'index.html');
try {
const data = fs.readFileSync(htmlPath);
staticFiles.set('/', { data, contentType: 'text/html; charset=utf-8' });
staticFiles.set('/index.html', { data, contentType: 'text/html; charset=utf-8' });
} catch {
const fallback = Buffer.from(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SIP Router</title>
<style>body{margin:0;background:#0f172a;color:#e2e8f0;font-family:system-ui}sipproxy-app{display:block;position:fixed;inset:0;overflow:hidden}</style>
</head><body><sipproxy-app></sipproxy-app><script type="module" src="/bundle.js"></script></body></html>`);
staticFiles.set('/', { data: fallback, contentType: 'text/html; charset=utf-8' });
staticFiles.set('/index.html', { data: fallback, contentType: 'text/html; charset=utf-8' });
}
// Load bundle.js
const bundlePath = path.join(cwd, 'dist_ts_web', 'bundle.js');
try {
const data = fs.readFileSync(bundlePath);
staticFiles.set('/bundle.js', { data, contentType: 'application/javascript; charset=utf-8' });
} catch { /* Bundle not found */ }
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
async function handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
getStatus: () => unknown,
log: (msg: string) => void,
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
onHangupCall: (callId: string) => boolean,
onConfigSaved?: () => void,
callManager?: CallManager,
): Promise<void> {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const method = req.method || 'GET';
// API: status.
if (url.pathname === '/api/status') {
return sendJson(res, getStatus());
}
// API: start call (with optional providerId).
if (url.pathname === '/api/call' && method === 'POST') {
try {
const body = await readJsonBody(req);
const number = body?.number;
if (!number || typeof number !== 'string') {
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
}
const call = onStartCall(number, body?.deviceId, body?.providerId);
if (call) return sendJson(res, { ok: true, callId: call.id });
return sendJson(res, { ok: false, error: 'call origination failed — provider not registered or no ports available' }, 503);
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: hangup.
if (url.pathname === '/api/hangup' && method === 'POST') {
try {
const body = await readJsonBody(req);
const callId = body?.callId;
if (!callId || typeof callId !== 'string') {
return sendJson(res, { ok: false, error: 'missing "callId" field' }, 400);
}
return sendJson(res, { ok: onHangupCall(callId) });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: add leg to call.
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') {
try {
const callId = url.pathname.split('/')[3];
const body = await readJsonBody(req);
if (!body?.deviceId) return sendJson(res, { ok: false, error: 'missing deviceId' }, 400);
const ok = callManager?.addDeviceToCall(callId, body.deviceId) ?? false;
return sendJson(res, { ok });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: add external participant (dial out) to existing call.
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addexternal') && method === 'POST') {
try {
const callId = url.pathname.split('/')[3];
const body = await readJsonBody(req);
if (!body?.number) return sendJson(res, { ok: false, error: 'missing number' }, 400);
const ok = callManager?.addExternalToCall(callId, body.number, body.providerId) ?? false;
return sendJson(res, { ok });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: remove leg from call.
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/removeleg') && method === 'POST') {
try {
const callId = url.pathname.split('/')[3];
const body = await readJsonBody(req);
if (!body?.legId) return sendJson(res, { ok: false, error: 'missing legId' }, 400);
const ok = callManager?.removeLegFromCall(callId, body.legId) ?? false;
return sendJson(res, { ok });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: transfer leg.
if (url.pathname === '/api/transfer' && method === 'POST') {
try {
const body = await readJsonBody(req);
if (!body?.sourceCallId || !body?.legId || !body?.targetCallId) {
return sendJson(res, { ok: false, error: 'missing sourceCallId, legId, or targetCallId' }, 400);
}
const ok = callManager?.transferLeg(body.sourceCallId, body.legId, body.targetCallId) ?? false;
return sendJson(res, { ok });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: get config (sans passwords).
if (url.pathname === '/api/config' && method === 'GET') {
try {
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
const cfg = JSON.parse(raw);
const safe = { ...cfg, providers: cfg.providers?.map((p: any) => ({ ...p, password: '••••••' })) };
return sendJson(res, safe);
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 500);
}
}
// API: update config.
if (url.pathname === '/api/config' && method === 'POST') {
try {
const updates = await readJsonBody(req);
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
const cfg = JSON.parse(raw);
// Update existing providers.
if (updates.providers) {
for (const up of updates.providers) {
const existing = cfg.providers?.find((p: any) => p.id === up.id);
if (existing) {
if (up.displayName !== undefined) existing.displayName = up.displayName;
if (up.password && up.password !== '••••••') existing.password = up.password;
if (up.domain !== undefined) existing.domain = up.domain;
if (up.outboundProxy !== undefined) existing.outboundProxy = up.outboundProxy;
if (up.username !== undefined) existing.username = up.username;
if (up.registerIntervalSec !== undefined) existing.registerIntervalSec = up.registerIntervalSec;
if (up.codecs !== undefined) existing.codecs = up.codecs;
if (up.quirks !== undefined) existing.quirks = up.quirks;
}
}
}
// Add a new provider.
if (updates.addProvider) {
cfg.providers ??= [];
cfg.providers.push(updates.addProvider);
}
// Remove a provider.
if (updates.removeProvider) {
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updates.removeProvider);
// Clean up routing references.
if (cfg.routing?.inbound) delete cfg.routing.inbound[updates.removeProvider];
if (cfg.routing?.ringBrowsers) delete cfg.routing.ringBrowsers[updates.removeProvider];
if (cfg.routing?.outbound?.default === updates.removeProvider) {
cfg.routing.outbound.default = cfg.providers[0]?.id || '';
}
}
if (updates.devices) {
for (const ud of updates.devices) {
const existing = cfg.devices?.find((d: any) => d.id === ud.id);
if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName;
}
}
if (updates.routing) {
if (updates.routing.inbound) cfg.routing.inbound = { ...cfg.routing.inbound, ...updates.routing.inbound };
if (updates.routing.ringBrowsers) cfg.routing.ringBrowsers = { ...cfg.routing.ringBrowsers, ...updates.routing.ringBrowsers };
}
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
log('[config] updated config.json');
onConfigSaved?.();
return sendJson(res, { ok: true });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// Static files.
const file = staticFiles.get(url.pathname);
if (file) {
res.writeHead(200, {
'Content-Type': file.contentType,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
});
res.end(file.data);
return;
}
// SPA fallback.
const index = staticFiles.get('/');
if (index) {
res.writeHead(200, { 'Content-Type': index.contentType });
res.end(index.data);
return;
}
res.writeHead(404);
res.end('Not Found');
}
// ---------------------------------------------------------------------------
// HTTP + WebSocket server (Node.js)
// ---------------------------------------------------------------------------
export function initWebUi(
getStatus: () => unknown,
log: (msg: string) => void,
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
onHangupCall: (callId: string) => boolean,
onConfigSaved?: () => void,
callManager?: CallManager,
): void {
const WEB_PORT = 3060;
loadStaticFiles();
// Serve HTTPS if cert exists, otherwise fall back to HTTP.
const certPath = path.join(process.cwd(), '.nogit', 'cert.pem');
const keyPath = path.join(process.cwd(), '.nogit', 'key.pem');
let useTls = false;
let server: http.Server | https.Server;
try {
const cert = fs.readFileSync(certPath, 'utf8');
const key = fs.readFileSync(keyPath, 'utf8');
server = https.createServer({ cert, key }, (req, res) =>
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager).catch(() => { res.writeHead(500); res.end(); }),
);
useTls = true;
} catch {
server = http.createServer((req, res) =>
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager).catch(() => { res.writeHead(500); res.end(); }),
);
}
// WebSocket server on the same port.
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (socket, req) => {
const remoteIp = req.socket.remoteAddress || null;
wsClients.add(socket);
socket.send(JSON.stringify({ type: 'status', data: getStatus(), ts: timestamp() }));
socket.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type === 'webrtc-accept' && msg.callId) {
log(`[webrtc] browser accepted call ${msg.callId} session=${msg.sessionId || 'none'}`);
const ok = callManager?.acceptBrowserCall(msg.callId, msg.sessionId) ?? false;
log(`[webrtc] acceptBrowserCall result: ${ok}`);
} else if (msg.type === 'webrtc-offer' && msg.sessionId) {
callManager?.handleWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) =>
log(`[webrtc] offer error: ${e.message}`));
} else if (msg.type === 'webrtc-ice' && msg.sessionId) {
callManager?.handleWebRtcIce(msg.sessionId, msg.candidate).catch(() => {});
} else if (msg.type === 'webrtc-hangup' && msg.sessionId) {
callManager?.handleWebRtcHangup(msg.sessionId);
} else if (msg.type?.startsWith('webrtc-')) {
msg._remoteIp = remoteIp;
handleWebRtcSignaling(socket as any, msg);
}
} catch { /* ignore */ }
});
socket.on('close', () => wsClients.delete(socket));
socket.on('error', () => wsClients.delete(socket));
});
server.listen(WEB_PORT, '0.0.0.0', () => {
log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${WEB_PORT}`);
});
setInterval(() => broadcastWs('status', getStatus()), 1000);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function sendJson(res: http.ServerResponse, data: unknown, status = 200): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function readJsonBody(req: http.IncomingMessage): Promise<any> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
catch (e) { reject(e); }
});
req.on('error', reject);
});
}