Files
gibbz/src/server/signaling.ts
T
developer 2b950e4953
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 14s
eventsourced-ai live check live check
refactor: port backend and frontend to TypeScript classes (bun + vite)
Backend (src/server, runs on Bun):
- GibbzHttpServer (health, /api/ice, /api/rooms, static, keepalive tuning)
- SignalingServer with Peer/Room/RoomRegistry classes and typed messages
- IceConfigProvider (env STUN/TURN + coturn HMAC ephemeral creds)
- StaticFileServer (dist/ with public/ fallback, traversal guard)
- Tracing (OpenTelemetry SDK), AppConfig; createApp() factory for tests

Frontend (src/client, built by Vite):
- VoiceApp orchestrator implementing VoiceAppContext
- WebRtcManager + PeerSession (perfect negotiation, pc rebuild, quality monitor)
- SignalingClient + ReconnectController, AudioManager, SpatialAudio,
  Microphone/ScreenShare controllers, ScreensUi/RosterUi/SpatialMap/
  SpeakingMonitor, Settings/Welcome dialogs, LayoutManager, MatrixChat

Tooling: bun scripts (dev/start/test/typecheck), vite.config.ts root
src/client with publicDir public/static, vitest-config for CI-less runs,
bun-based Dockerfile (oven/bun:1), Makefile + README updated, legacy
server.js/tracing.js/test.js/public js removed.

Verified locally: tsc --noEmit clean, vitest run 6/6 green, vite build ok,
server smoke-tested serving dist + signaling endpoints under Node. Local
bun binary segfaults (SIGILL) on this host, so 'bun test' itself was not
executable here; tests are vitest-API compatible which bun test maps.
2026-09-24 07:43:04 +00:00

270 lines
7.4 KiB
TypeScript

import crypto from 'node:crypto';
import { WebSocketServer, WebSocket } from 'ws';
import type { IncomingMessage, Server as HttpServer } from 'node:http';
// ---------------------------------------------------------------------------
// Signaling protocol
// client -> server: {type: offer|answer|candidate, to} {type: pos|state}
// server -> client: {type: hello, id, peers} {type: joined|left, ...}
// relayed messages get "from" stamped
// ---------------------------------------------------------------------------
export interface PeerState {
muted: boolean;
deafened: boolean;
sharing: boolean;
}
export interface PeerSnapshot {
id: string;
name: string;
x: number;
y: number;
state: PeerState;
}
interface ClientMessageBase {
type: string;
to?: unknown;
x?: unknown;
y?: unknown;
state?: Partial<PeerState>;
sdp?: unknown;
candidate?: unknown;
name?: unknown;
}
export class Peer {
readonly socket: WebSocket;
readonly id: string;
name: string;
x = 50;
y = 50;
state: PeerState = { muted: false, deafened: false, sharing: false };
constructor(socket: WebSocket, id: string, name: string) {
this.socket = socket;
this.id = id;
this.name = name;
}
get connected(): boolean {
return this.socket.readyState === WebSocket.OPEN;
}
send(payload: unknown): void {
if (this.connected) this.socket.send(JSON.stringify(payload));
}
snapshot(): PeerSnapshot {
return { id: this.id, name: this.name, x: this.x, y: this.y, state: this.state };
}
}
export class Room {
readonly id: string;
private readonly peers = new Map<string, Peer>();
constructor(id: string) {
this.id = id;
}
get size(): number {
return this.peers.size;
}
values(): IterableIterator<Peer> {
return this.peers.values();
}
get(id: string): Peer | undefined {
return this.peers.get(id);
}
has(id: string): boolean {
return this.peers.has(id);
}
add(peer: Peer): void {
this.peers.set(peer.id, peer);
}
delete(id: string): boolean {
return this.peers.delete(id);
}
broadcast(msg: unknown, exceptId?: string): void {
for (const [id, peer] of this.peers) {
if (id !== exceptId && peer.connected) peer.socket.send(JSON.stringify(msg));
}
}
snapshot(): PeerSnapshot[] {
return [...this.peers.values()].map((p) => p.snapshot());
}
}
export class RoomRegistry {
private readonly rooms = new Map<string, Room>();
room(roomId: string): Room {
let room = this.rooms.get(roomId);
if (!room) {
room = new Room(roomId);
this.rooms.set(roomId, room);
}
return room;
}
dropIfEmpty(roomId: string): void {
const room = this.rooms.get(roomId);
if (room && room.size === 0) this.rooms.delete(roomId);
}
allRooms(): IterableIterator<Room> {
return this.rooms.values();
}
allPeers(): Peer[] {
const out: Peer[] = [];
for (const room of this.rooms.values()) out.push(...room.values());
return out;
}
snapshot(): Record<string, PeerSnapshot[]> {
const out: Record<string, PeerSnapshot[]> = {};
for (const [roomId, room] of this.rooms) out[roomId] = room.snapshot();
return out;
}
}
/** WebSocket signaling: room rosters, position/state fan-out and WebRTC relay. */
export class SignalingServer {
readonly wss: WebSocketServer;
private readonly registry: RoomRegistry;
private heartbeat: ReturnType<typeof setInterval>;
constructor(registry: RoomRegistry) {
this.registry = registry;
this.wss = new WebSocketServer({ noServer: true });
// heartbeat: drop dead sockets
this.heartbeat = setInterval(() => this.pingAll(), 30_000);
this.wss.on('close', () => clearInterval(this.heartbeat));
this.wss.on('connection', (socket, req) => this.handleConnection(socket, req));
}
/** Routes /ws upgrades from the HTTP server into the signaling socket. */
attach(server: HttpServer): void {
server.on('upgrade', (req, socket, head) => {
const { pathname } = new URL(req.url || '/', 'http://x');
if (pathname === '/ws') {
this.wss.handleUpgrade(req, socket, head, (ws) => this.wss.emit('connection', ws, req));
} else {
socket.destroy();
}
});
}
stop(): void {
clearInterval(this.heartbeat);
}
private pingAll(): void {
for (const peer of this.registry.allPeers()) {
if (peer.connected) peer.socket.ping();
}
}
broadcastRosters(): void {
const data = JSON.stringify({ type: 'rosters', rooms: this.registry.snapshot() });
for (const client of this.wss.clients) {
if (client.readyState === WebSocket.OPEN) client.send(data);
}
}
private handleConnection(socket: WebSocket, req: IncomingMessage): void {
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 = this.registry.room(roomId);
const client = new Peer(socket, id, name);
room.add(client);
client.send({
type: 'hello',
id,
room: roomId,
peers: [...room.values()].filter((p) => p.id !== id).map((p) => p.snapshot()),
});
room.broadcast({ type: 'joined', peer: client.snapshot() }, id);
this.broadcastRosters();
console.log(`[signaling] ${name} (${id}) joined #${roomId} — ${room.size} in room`);
socket.on('message', (raw) => {
const text = Array.isArray(raw) ? Buffer.concat(raw).toString() : raw.toString();
let msg: ClientMessageBase;
try {
msg = JSON.parse(text) as ClientMessageBase;
} catch {
return;
}
if (!msg || typeof msg.type !== 'string') return;
this.handleMessage(room, client, msg);
});
socket.on('close', () => {
room.delete(id);
this.registry.dropIfEmpty(roomId);
if (room.size > 0) room.broadcast({ type: 'left', id });
this.broadcastRosters();
console.log(`[signaling] ${name} (${id}) left #${roomId} — ${room.size} in room`);
});
}
private handleMessage(room: Room, client: Peer, msg: ClientMessageBase): void {
const send = (payload: unknown): void => client.send(payload);
switch (msg.type) {
case 'ping': {
send({ type: 'pong' });
break;
}
case 'pos': {
const x = Number(msg.x);
const 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));
room.broadcast({ type: 'pos', from: client.id, x: client.x, y: client.y }, client.id);
}
break;
}
case 'state': {
client.state = {
muted: !!msg.state?.muted,
deafened: !!msg.state?.deafened,
sharing: !!msg.state?.sharing,
};
room.broadcast({ type: 'state', from: client.id, state: client.state }, client.id);
this.broadcastRosters();
break;
}
case 'offer':
case 'answer':
case 'candidate':
case 'reset': {
if (typeof msg.to !== 'string' || !room.has(msg.to)) return;
const target = room.get(msg.to);
if (target && target.connected) {
const { to: _to, ...rest } = msg;
target.socket.send(JSON.stringify({ ...rest, from: client.id }));
}
break;
}
default:
break; // ignore unknown types — forward compat
}
}
}