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.
This commit is contained in:
1 parent
9b8c2a9599
commit
2b950e4953
48 files changed
+4589
-9200
No files matched your search
@@ -0,0 +1,269 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user