Files
gibbz/voice-poc/server.js
T

200 lines
6.7 KiB
JavaScript

// Minimal WebRTC signaling + static file server for the Discord-clone POC.
// POC shortcut: no auth, no persistence, in-memory rooms, no rate limiting.
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 iceServers = urls(process.env.STUN_URLS || 'stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302')
.map((u) => ({ urls: u }));
const turnUrls = urls(process.env.TURN_URLS);
if (turnUrls.length) {
iceServers.push({
urls: turnUrls,
username: process.env.TURN_USERNAME || '',
credential: process.env.TURN_CREDENTIAL || process.env.TURN_PASSWORD || '',
});
}
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.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));
server.listen(PORT, HOST, () => {
const ice = iceConfig();
console.log(`voice-poc listening on http://${HOST}:${PORT}`);
console.log(`ICE servers: ${JSON.stringify(ice.iceServers)}`);
});