Files
psxbase/server/src/ws/signaling.ts
T
ryanfitzpatrickio 8a96ede9f2 Initial public release of PSX Adventure Engine
Browser reference stack for PSX-era third-person adventure: fixed cameras,
inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular
character harness. Ships the Ashgrove Precinct Level 1 investigation demo with
a full cast and nine linked rooms.
2026-07-31 06:32:43 -05:00

241 lines
7.7 KiB
TypeScript

import type { Server } from "node:http";
import { WebSocketServer, type WebSocket } from "ws";
import type { PeerInfo, SignalMessage, SignalPayload } from "@psx/shared";
import { validateSignalMessage } from "@psx/shared";
import { verifyToken } from "../auth.js";
import type { LobbyRecord, Store } from "../db/store.js";
/**
* SDP/ICE exchange — GDD §8.
*
* The server is a relay and nothing more. It never sees gameplay: once peers
* have exchanged descriptors they talk over data channels directly, and this
* socket carries only membership changes for the rest of the session.
*
* Two rules make the relay safe to expose:
* 1. `from` on a relayed signal is stamped from the authenticated socket,
* never read from the payload, so peers cannot impersonate one another.
* 2. A socket may only address peers inside the lobby it authenticated into.
*/
interface Connection {
socket: WebSocket;
peerId: number;
userId: string;
lobbyCode: string;
isHost: boolean;
alive: boolean;
}
const HEARTBEAT_INTERVAL_MS = 30_000;
export class SignalingServer {
private readonly wss: WebSocketServer;
/** lobby code → peer id → connection */
private readonly rooms = new Map<string, Map<number, Connection>>();
private readonly heartbeat: NodeJS.Timeout;
constructor(
server: Server,
private readonly store: Store,
path = "/ws",
) {
this.wss = new WebSocketServer({ server, path });
this.wss.on("connection", (socket) => this.onConnection(socket));
// Half-open TCP connections would otherwise linger as phantom lobby members.
this.heartbeat = setInterval(() => {
for (const room of this.rooms.values()) {
for (const connection of room.values()) {
if (!connection.alive) {
connection.socket.terminate();
continue;
}
connection.alive = false;
connection.socket.ping();
}
}
}, HEARTBEAT_INTERVAL_MS);
}
private onConnection(socket: WebSocket): void {
let connection: Connection | null = null;
socket.on("pong", () => {
if (connection) connection.alive = true;
});
socket.on("message", async (raw) => {
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
return send(socket, { t: "error", message: "malformed JSON" });
}
const validation = validateSignalMessage(parsed);
if (!validation.ok) {
return send(socket, { t: "error", message: validation.errors.join("; ") });
}
const message = validation.value;
// Everything except `hello` requires an authenticated socket.
if (!connection && message.t !== "hello") {
return send(socket, { t: "error", message: "expected hello first" });
}
switch (message.t) {
case "hello": {
if (connection) return send(socket, { t: "error", message: "already authenticated" });
connection = await this.authenticate(socket, message.token, message.lobbyCode);
break;
}
case "signal":
this.relaySignal(connection!, message.to, message.payload);
break;
case "ready":
await this.setReady(connection!, message.isReady);
break;
default:
send(socket, { t: "error", message: `unsupported message: ${message.t}` });
}
});
socket.on("close", () => {
if (connection) void this.disconnect(connection);
});
}
private async authenticate(
socket: WebSocket,
token: string,
lobbyCode: string,
): Promise<Connection | null> {
const verified = verifyToken(token);
if (!verified) {
send(socket, { t: "error", message: "invalid or expired token" });
socket.close();
return null;
}
const lobby = await this.store.findLobbyByCode(lobbyCode);
if (!lobby || lobby.status === "closed") {
send(socket, { t: "error", message: "lobby not found or closed" });
socket.close();
return null;
}
// Membership is established over REST (`POST /lobbies/:code/join`) so that
// capacity and duplicate-join rules live in exactly one place.
const member = lobby.players.find((player) => player.userId === verified.userId);
if (!member) {
send(socket, { t: "error", message: "join the lobby before connecting" });
socket.close();
return null;
}
const room = this.rooms.get(lobby.code) ?? new Map<number, Connection>();
this.rooms.set(lobby.code, room);
// A reconnect from the same peer supersedes the stale socket.
room.get(member.peerId)?.socket.terminate();
const connection: Connection = {
socket,
peerId: member.peerId,
userId: verified.userId,
lobbyCode: lobby.code,
isHost: verified.userId === lobby.hostId,
alive: true,
};
room.set(member.peerId, connection);
send(socket, {
t: "welcome",
peerId: connection.peerId,
isHost: connection.isHost,
peers: this.peerList(lobby, room),
});
this.broadcast(
lobby.code,
{ t: "peer-joined", peer: this.peerInfo(lobby, member.peerId) },
connection.peerId,
);
return connection;
}
private relaySignal(from: Connection, to: number, payload: SignalPayload): void {
const target = this.rooms.get(from.lobbyCode)?.get(to);
if (!target) return;
send(target.socket, {
t: "signal",
// Stamped from the authenticated socket — never trusted from the payload.
from: from.peerId,
to,
payload,
});
}
private async setReady(connection: Connection, isReady: boolean): Promise<void> {
const lobby = await this.store.setReady(connection.lobbyCode, connection.userId, isReady);
if (!lobby) return;
this.broadcast(connection.lobbyCode, {
t: "peer-updated",
peer: this.peerInfo(lobby, connection.peerId),
});
}
private async disconnect(connection: Connection): Promise<void> {
const room = this.rooms.get(connection.lobbyCode);
// Guard against a superseded socket removing its replacement.
if (room?.get(connection.peerId)?.socket === connection.socket) {
room.delete(connection.peerId);
}
if (room && room.size === 0) this.rooms.delete(connection.lobbyCode);
await this.store.leaveLobby(connection.lobbyCode, connection.userId);
this.broadcast(connection.lobbyCode, { t: "peer-left", peerId: connection.peerId });
// The authoritative simulation lived in the host's browser, so its departure
// ends the session for everyone rather than silently freezing the world.
if (connection.isHost) {
this.broadcast(connection.lobbyCode, { t: "error", message: "the host left the session" });
for (const remaining of room?.values() ?? []) remaining.socket.close();
this.rooms.delete(connection.lobbyCode);
}
}
private peerInfo(lobby: LobbyRecord, peerId: number): PeerInfo {
const player = lobby.players.find((entry) => entry.peerId === peerId);
return {
peerId,
displayName: player?.displayName ?? `Player ${peerId}`,
isHost: player ? player.userId === lobby.hostId : false,
isReady: player?.isReady ?? false,
};
}
private peerList(lobby: LobbyRecord, room: Map<number, Connection>): PeerInfo[] {
return [...room.keys()].map((peerId) => this.peerInfo(lobby, peerId));
}
private broadcast(lobbyCode: string, message: SignalMessage, exceptPeerId?: number): void {
for (const [peerId, connection] of this.rooms.get(lobbyCode) ?? []) {
if (peerId === exceptPeerId) continue;
send(connection.socket, message);
}
}
close(): void {
clearInterval(this.heartbeat);
this.wss.close();
}
}
function send(socket: WebSocket, message: SignalMessage): void {
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
}