Files
gibbz/server.js
T
mats dee8ef4f7c
Build and Publish / Metadata (push) Successful in 1s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 24s
Show users in voice channels before joining
2026-09-22 10:45:08 +02:00

267 lines
8.7 KiB
JavaScript

// 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 STATIC_DIR = fs.existsSync(path.join(__dirname, 'dist'))
? path.join(__dirname, 'dist')
: 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(STATIC_DIR, rel));
if (!file.startsWith(STATIC_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;
}
if (req.url.startsWith('/api/rooms')) {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });
res.end(JSON.stringify(allRoomsSnapshot()));
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);
}
}
function allRoomsSnapshot() {
const out = {};
for (const [rId, r] of rooms.entries()) {
out[rId] = [...r.values()].map(snapshot);
}
return out;
}
function broadcastRosters() {
const snap = allRoomsSnapshot();
const data = JSON.stringify({ type: 'rosters', rooms: snap });
for (const client of wss.clients) {
if (client.readyState === 1) {
client.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);
broadcastRosters();
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);
broadcastRosters();
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 });
broadcastRosters();
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, allRoomsSnapshot, interval, startServer };