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.
241 lines
7.7 KiB
TypeScript
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));
|
|
}
|