feat: migrate voice app, add otel/rum, dockerfile, k8s deployment with ingress
This commit is contained in:
1 parent
a0d5a9a2c7
commit
5757156843
8170 files changed
+3144
-2086523
No files matched your search
@@ -0,0 +1,234 @@
|
||||
// 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 '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 };
|
||||
|
||||
|
||||
Reference in new issue
Block a user