/** * SIP proxy bootstrap. * * Spawns the Rust proxy-engine, wires runtime state/event handling, * and starts the web dashboard plus browser signaling layer. */ import fs from 'node:fs'; import path from 'node:path'; import { loadConfig, type IAppConfig } from './config.ts'; import { broadcastWs, initWebUi } from './frontend.ts'; import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts'; import { VoiceboxManager } from './voicebox.ts'; import { initProxyEngine, configureProxyEngine, hangupCall, makeCall, shutdownProxyEngine, webrtcOffer, webrtcIce, webrtcLink, webrtcClose, } from './proxybridge.ts'; import { registerProxyEventHandlers } from './runtime/proxy-events.ts'; import { StatusStore } from './runtime/status-store.ts'; import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts'; let appConfig: IAppConfig = loadConfig(); const LOG_PATH = path.join(process.cwd(), 'sip_trace.log'); const startTime = Date.now(); const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const statusStore = new StatusStore(appConfig); const webRtcLinks = new WebRtcLinkManager(); const voiceboxManager = new VoiceboxManager(log); voiceboxManager.init(appConfig.voiceboxes ?? []); initWebRtcSignaling({ log }); function now(): string { return new Date().toISOString().replace('T', ' ').slice(0, 19); } function log(message: string): void { const line = `${now()} ${message}\n`; fs.appendFileSync(LOG_PATH, line); process.stdout.write(line); broadcastWs('log', { message }); } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function buildProxyConfig(config: IAppConfig): Record { return { proxy: config.proxy, providers: config.providers, devices: config.devices, routing: config.routing, voiceboxes: config.voiceboxes ?? [], ivr: config.ivr, }; } function getStatus() { return statusStore.buildStatusSnapshot( instanceId, startTime, getAllBrowserDeviceIds(), voiceboxManager.getAllUnheardCounts(), ); } function requestWebRtcLink(callId: string, sessionId: string, media: IProviderMediaInfo): void { log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${callId} media=${media.addr}:${media.port} pt=${media.sipPt}`); void webrtcLink(sessionId, callId, media.addr, media.port, media.sipPt).then((ok) => { log(`[webrtc] link result: ${ok}`); }); } async function configureRuntime(config: IAppConfig): Promise { return configureProxyEngine(buildProxyConfig(config)); } async function reloadConfig(): Promise { try { const previousConfig = appConfig; const nextConfig = loadConfig(); appConfig = nextConfig; statusStore.updateConfig(nextConfig); voiceboxManager.init(nextConfig.voiceboxes ?? []); if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) { log('[config] proxy.lanPort changed; restart required for SIP socket rebinding'); } if (nextConfig.proxy.webUiPort !== previousConfig.proxy.webUiPort) { log('[config] proxy.webUiPort changed; restart required for web UI rebinding'); } const configured = await configureRuntime(nextConfig); if (configured) { log('[config] reloaded - proxy engine reconfigured'); } else { log('[config] reload failed - proxy engine rejected config'); } } catch (error: unknown) { log(`[config] reload failed: ${errorMessage(error)}`); } } async function startProxyEngine(): Promise { const started = await initProxyEngine(log); if (!started) { log('[FATAL] failed to start proxy engine'); process.exit(1); } registerProxyEventHandlers({ log, statusStore, voiceboxManager, webRtcLinks, getBrowserDeviceIds: getAllBrowserDeviceIds, sendToBrowserDevice, broadcast: broadcastWs, onLinkWebRtcSession: requestWebRtcLink, onCloseWebRtcSession: (sessionId) => { void webrtcClose(sessionId); }, }); const configured = await configureRuntime(appConfig); if (!configured) { log('[FATAL] failed to configure proxy engine'); process.exit(1); } const providerList = appConfig.providers.map((provider) => provider.displayName).join(', '); const deviceList = appConfig.devices.map((device) => device.displayName).join(', '); log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`); } initWebUi({ port: appConfig.proxy.webUiPort, getStatus, log, onStartCall: (number, deviceId, providerId) => { log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`); void makeCall(number, deviceId, providerId).then((callId) => { if (callId) { log(`[dashboard] call started: ${callId}`); statusStore.noteDashboardCallStarted(callId, number, providerId); } else { log(`[dashboard] call failed for ${number}`); } }); return { id: `pending-${Date.now()}` }; }, onHangupCall: (callId) => { void hangupCall(callId); return true; }, onConfigSaved: reloadConfig, voiceboxManager, onWebRtcOffer: async (sessionId, sdp, ws) => { log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`); if (!sdp || typeof sdp !== 'string' || sdp.length < 10) { log(`[webrtc] WARNING: invalid SDP (type=${typeof sdp}), skipping offer`); return; } log(`[webrtc] sending offer to Rust (${sdp.length}b)...`); const result = await webrtcOffer(sessionId, sdp); log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`); if (result?.sdp) { ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp })); log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`); return; } log('[webrtc] ERROR: no answer SDP from Rust'); }, onWebRtcIce: async (sessionId, candidate) => { await webrtcIce(sessionId, candidate); }, onWebRtcClose: async (sessionId) => { webRtcLinks.removeSession(sessionId); await webrtcClose(sessionId); }, onWebRtcAccept: (callId, sessionId) => { log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`); const pendingMedia = webRtcLinks.acceptCall(callId, sessionId); if (pendingMedia) { requestWebRtcLink(callId, sessionId, pendingMedia); return; } log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`); }, }); void startProxyEngine(); process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); }); process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); });