// Minimal WebRTC signaling + static file server for the Discord-clone POC. // Load OpenTelemetry tracing and logging before HTTP / network modules require('./tracing'); const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { WebSocketServer } = require('ws'); const PORT = Number(process.env.PORT || 8080); const HOST = process.env.HOST || '0.0.0.0'; const PUBLIC_DIR = path.join(__dirname, 'public'); // --------------------------------------------------------------------------- // ICE configuration: env-driven, full JSON override wins. // STUN_URLS comma-separated stun: urls // TURN_URLS comma-separated turn:/turns: urls // TURN_USERNAME / TURN_CREDENTIAL static creds for all TURN urls // ICE_SERVERS_JSON complete {"iceServers":[...]} override // --------------------------------------------------------------------------- function iceConfig() { if (process.env.ICE_SERVERS_JSON) { try { const parsed = JSON.parse(process.env.ICE_SERVERS_JSON); if (Array.isArray(parsed.iceServers)) return parsed; console.error('ICE_SERVERS_JSON missing iceServers array, falling back to env vars'); } catch (err) { console.error('ICE_SERVERS_JSON invalid JSON:', err.message); } } const urls = (s) => (s || '').split(',').map((x) => x.trim()).filter(Boolean); const stunUrls = urls(process.env.STUN_URLS || 'stun:turn.k6n.net:3478,stun:stun.l.google.com:19302'); const iceServers = stunUrls.map((u) => ({ urls: u })); const turnUrls = urls(process.env.TURN_URLS || 'turn:turn.k6n.net:3478?transport=udp,turn:turn.k6n.net:3478?transport=tcp,turns:turn.k6n.net:5349?transport=tcp'); if (turnUrls.length) { let username = process.env.TURN_USERNAME || ''; let credential = process.env.TURN_CREDENTIAL || process.env.TURN_PASSWORD || ''; // If a TURN secret is provided (coturn use-auth-secret / static-auth-secret), generate ephemeral HMAC credentials const turnSecret = process.env.TURN_SECRET || process.env.TURN_STATIC_AUTH_SECRET || ''; if (turnSecret) { const ttl = Number(process.env.TURN_TTL || 86400); const expiry = Math.floor(Date.now() / 1000) + ttl; username = `${expiry}:gibbz`; const hmac = crypto.createHmac('sha1', turnSecret); hmac.update(username); credential = hmac.digest('base64'); } iceServers.push({ urls: turnUrls, username, credential, }); } return { iceServers }; } // --------------------------------------------------------------------------- // Static files // --------------------------------------------------------------------------- const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', }; function serveStatic(req, res) { const urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname); let rel = urlPath === '/' ? '/index.html' : urlPath; const file = path.normalize(path.join(PUBLIC_DIR, rel)); if (!file.startsWith(PUBLIC_DIR)) { res.writeHead(403).end('forbidden'); return; } fs.readFile(file, (err, data) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found'); return; } res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' }); res.end(data); }); } const server = http.createServer((req, res) => { if (req.url === '/healthz' || req.url === '/readyz' || req.url === '/health') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); return; } if (req.url.startsWith('/api/ice')) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(iceConfig())); return; } serveStatic(req, res); }); // --------------------------------------------------------------------------- // Signaling // client -> server: {type: offer|answer|candidate, to} {type: pos|state} // server -> client: {type: hello, id, peers} {type: joined|left, ...} // relayed messages get "from" stamped // --------------------------------------------------------------------------- const rooms = new Map(); // roomId -> Map(peerId -> client) const wss = new WebSocketServer({ server, path: '/ws' }); function roomPeers(roomId) { let room = rooms.get(roomId); if (!room) { room = new Map(); rooms.set(roomId, room); } return room; } function broadcast(room, msg, exceptId) { const data = JSON.stringify(msg); for (const [id, client] of room) { if (id !== exceptId && client.socket.readyState === 1) client.socket.send(data); } } wss.on('connection', (socket, req) => { const q = new URL(req.url, 'http://x').searchParams; const roomId = (q.get('room') || 'general').slice(0, 64); const name = (q.get('name') || 'anon').slice(0, 32); const id = crypto.randomBytes(4).toString('hex'); const room = roomPeers(roomId); const client = { socket, id, name, x: 50, y: 50, state: { muted: false, deafened: false, sharing: false } }; room.set(id, client); const send = (msg) => { if (socket.readyState === 1) socket.send(JSON.stringify(msg)); }; send({ type: 'hello', id, room: roomId, peers: [...room.values()].filter((p) => p.id !== id).map(snapshot) }); broadcast(room, { type: 'joined', peer: snapshot(client) }, id); console.log(`[signaling] ${name} (${id}) joined #${roomId} — ${room.size} in room`); socket.on('message', (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch { return; } if (!msg || typeof msg.type !== 'string') return; switch (msg.type) { case 'ping': { send({ type: 'pong' }); break; } case 'pos': { const x = Number(msg.x), y = Number(msg.y); if (Number.isFinite(x) && Number.isFinite(y)) { client.x = Math.min(100, Math.max(0, x)); client.y = Math.min(100, Math.max(0, y)); broadcast(room, { type: 'pos', from: id, x: client.x, y: client.y }, id); } break; } case 'state': { client.state = { muted: !!msg.state?.muted, deafened: !!msg.state?.deafened, sharing: !!msg.state?.sharing, }; broadcast(room, { type: 'state', from: id, state: client.state }, id); break; } case 'offer': case 'answer': case 'candidate': { if (!msg.to || !room.has(msg.to)) return; const target = room.get(msg.to); if (target.socket.readyState === 1) { const { to: _to, ...rest } = msg; target.socket.send(JSON.stringify({ ...rest, from: id })); } break; } default: break; // ignore unknown types — forward compat } }); socket.on('close', () => { room.delete(id); if (room.size === 0) rooms.delete(roomId); else broadcast(room, { type: 'left', id }); console.log(`[signaling] ${name} (${id}) left #${roomId} — ${room.size} in room`); }); }); function snapshot(c) { return { id: c.id, name: c.name, x: c.x, y: c.y, state: c.state }; } // heartbeat: drop dead sockets const interval = setInterval(() => { for (const room of rooms.values()) { for (const [, client] of room) { if (client.socket.readyState === 1) { client.socket.ping(); } } } }, 30000); wss.on('close', () => clearInterval(interval)); function startServer(port = PORT, host = HOST) { return new Promise((resolve) => { server.listen(port, host, () => { const ice = iceConfig(); console.log(`voice-poc listening on http://${host}:${port}`); console.log(`ICE servers: ${JSON.stringify(ice.iceServers)}`); resolve(server); }); }); } if (require.main === module) { startServer(); } module.exports = { server, wss, iceConfig, interval, startServer };