mirror of
https://github.com/isledecomp/isle-portable.git
synced 2026-05-02 02:23:56 +00:00
Refactor relay server into separate modules, fix Docker signal handling
Split relay.ts into protocol.ts (constants, binary helpers), gameroom.ts (Durable Object), and a thin relay.ts entry point. Replace magic numbers with named constants matching protocol.h. Run wrangler directly as PID 1 in Docker so Ctrl+C shuts down gracefully instead of being swallowed by npx.
This commit is contained in:
parent
c760db50a9
commit
dac40932a6
@ -5,8 +5,10 @@ WORKDIR /app
|
||||
COPY extensions/src/multiplayer/server/package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY extensions/src/multiplayer/server/relay.ts extensions/src/multiplayer/server/wrangler.toml ./
|
||||
COPY extensions/src/multiplayer/server/*.ts extensions/src/multiplayer/server/wrangler.toml ./
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["npx", "wrangler", "dev", "--ip", "0.0.0.0", "--port", "8787"]
|
||||
# Run wrangler directly as PID 1 so it receives SIGINT (Ctrl+C)
|
||||
# and shuts down gracefully. Using npx as PID 1 swallows signals.
|
||||
CMD ["node_modules/.bin/wrangler", "dev", "--ip", "0.0.0.0", "--port", "8787"]
|
||||
|
||||
161
extensions/src/multiplayer/server/gameroom.ts
Normal file
161
extensions/src/multiplayer/server/gameroom.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import {
|
||||
HEADER_SIZE,
|
||||
MSG_REQUEST_SNAPSHOT,
|
||||
MSG_WORLD_EVENT_REQUEST,
|
||||
MSG_WORLD_SNAPSHOT,
|
||||
SNAPSHOT_MIN_SIZE,
|
||||
createAssignIdMsg,
|
||||
createHostAssignMsg,
|
||||
createLeaveMsg,
|
||||
readTargetPeerId,
|
||||
stampSender,
|
||||
} from "./protocol";
|
||||
import type { Env } from "./relay";
|
||||
|
||||
export class GameRoom implements DurableObject {
|
||||
private connections = new Map<number, WebSocket>();
|
||||
private nextPeerId = 1;
|
||||
private hostPeerId = 0;
|
||||
|
||||
constructor(
|
||||
private state: DurableObjectState,
|
||||
private env: Env
|
||||
) {}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
if (request.headers.get("Upgrade") !== "websocket") {
|
||||
return new Response("Expected WebSocket", { status: 426 });
|
||||
}
|
||||
|
||||
const pair = new WebSocketPair();
|
||||
const [client, server] = [pair[0], pair[1]];
|
||||
|
||||
const peerId = this.nextPeerId++;
|
||||
|
||||
server.accept();
|
||||
this.connections.set(peerId, server);
|
||||
|
||||
server.send(createAssignIdMsg(peerId));
|
||||
this.assignHostIfNeeded(peerId, server);
|
||||
|
||||
server.addEventListener("message", (event) =>
|
||||
this.handleMessage(event, peerId)
|
||||
);
|
||||
|
||||
const handleDisconnect = () => this.handleDisconnect(peerId);
|
||||
server.addEventListener("close", handleDisconnect);
|
||||
server.addEventListener("error", handleDisconnect);
|
||||
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
|
||||
// ---- Connection lifecycle ----
|
||||
|
||||
private assignHostIfNeeded(peerId: number, ws: WebSocket): void {
|
||||
if (this.hostPeerId === 0 || !this.connections.has(this.hostPeerId)) {
|
||||
this.hostPeerId = peerId;
|
||||
this.broadcast(createHostAssignMsg(this.hostPeerId));
|
||||
} else {
|
||||
this.trySend(ws, createHostAssignMsg(this.hostPeerId));
|
||||
}
|
||||
}
|
||||
|
||||
private handleDisconnect(peerId: number): void {
|
||||
this.connections.delete(peerId);
|
||||
this.broadcast(createLeaveMsg(peerId));
|
||||
|
||||
if (peerId === this.hostPeerId) {
|
||||
this.electNewHost();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Message routing ----
|
||||
|
||||
private handleMessage(event: MessageEvent, peerId: number): void {
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new Uint8Array(event.data);
|
||||
if (data.length < HEADER_SIZE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msgType = data[0];
|
||||
const stamped = stampSender(data, peerId);
|
||||
|
||||
if (
|
||||
msgType === MSG_REQUEST_SNAPSHOT ||
|
||||
msgType === MSG_WORLD_EVENT_REQUEST
|
||||
) {
|
||||
this.sendToHost(stamped);
|
||||
} else if (
|
||||
msgType === MSG_WORLD_SNAPSHOT &&
|
||||
data.length >= SNAPSHOT_MIN_SIZE
|
||||
) {
|
||||
this.sendToTarget(stamped);
|
||||
} else {
|
||||
this.broadcastExcept(stamped.buffer, peerId);
|
||||
}
|
||||
}
|
||||
|
||||
private sendToHost(data: Uint8Array): void {
|
||||
const hostWs = this.connections.get(this.hostPeerId);
|
||||
if (hostWs) {
|
||||
this.trySend(hostWs, data.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private sendToTarget(data: Uint8Array): void {
|
||||
const targetId = readTargetPeerId(data);
|
||||
const targetWs = this.connections.get(targetId);
|
||||
if (targetWs) {
|
||||
if (!this.trySend(targetWs, data.buffer)) {
|
||||
this.connections.delete(targetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Broadcasting ----
|
||||
|
||||
private broadcast(msg: ArrayBuffer): void {
|
||||
for (const ws of this.connections.values()) {
|
||||
this.trySend(ws, msg);
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastExcept(msg: ArrayBuffer, excludePeerId: number): void {
|
||||
for (const [id, ws] of this.connections) {
|
||||
if (id !== excludePeerId) {
|
||||
if (!this.trySend(ws, msg)) {
|
||||
this.connections.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private trySend(ws: WebSocket, data: ArrayBuffer): boolean {
|
||||
try {
|
||||
ws.send(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Host election ----
|
||||
|
||||
private electNewHost(): void {
|
||||
this.hostPeerId = 0;
|
||||
|
||||
for (const id of this.connections.keys()) {
|
||||
if (this.hostPeerId === 0 || id < this.hostPeerId) {
|
||||
this.hostPeerId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hostPeerId > 0) {
|
||||
this.broadcast(createHostAssignMsg(this.hostPeerId));
|
||||
}
|
||||
}
|
||||
}
|
||||
64
extensions/src/multiplayer/server/protocol.ts
Normal file
64
extensions/src/multiplayer/server/protocol.ts
Normal file
@ -0,0 +1,64 @@
|
||||
// Protocol constants — must stay in sync with protocol.h
|
||||
|
||||
// MessageHeader binary layout: type(1) + peerId(4) + sequence(4) = 9 bytes
|
||||
export const HEADER_SIZE = 9;
|
||||
export const PEER_ID_OFFSET = 1;
|
||||
export const SEQUENCE_OFFSET = 5;
|
||||
|
||||
// Message types the relay inspects for routing decisions.
|
||||
// All other types are broadcast to every peer in the room.
|
||||
export const MSG_LEAVE = 2;
|
||||
export const MSG_HOST_ASSIGN = 4;
|
||||
export const MSG_REQUEST_SNAPSHOT = 5;
|
||||
export const MSG_WORLD_SNAPSHOT = 6;
|
||||
export const MSG_WORLD_EVENT_REQUEST = 8;
|
||||
export const MSG_ASSIGN_ID = 0xff;
|
||||
|
||||
// AssignIdMsg: compact server-only message — type(1) + peerId(4)
|
||||
const ASSIGN_ID_SIZE = 1 + 4;
|
||||
|
||||
// HostAssignMsg: header(9) + hostPeerId(4)
|
||||
const HOST_ASSIGN_SIZE = HEADER_SIZE + 4;
|
||||
|
||||
// WorldSnapshotMsg: header(9) + targetPeerId(4) + dataLength(2) + data...
|
||||
export const SNAPSHOT_TARGET_OFFSET = HEADER_SIZE;
|
||||
export const SNAPSHOT_MIN_SIZE = HEADER_SIZE + 4 + 2;
|
||||
|
||||
export function createAssignIdMsg(peerId: number): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(ASSIGN_ID_SIZE);
|
||||
const view = new DataView(buf);
|
||||
view.setUint8(0, MSG_ASSIGN_ID);
|
||||
view.setUint32(1, peerId, true);
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function createHostAssignMsg(hostPeerId: number): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(HOST_ASSIGN_SIZE);
|
||||
const view = new DataView(buf);
|
||||
view.setUint8(0, MSG_HOST_ASSIGN);
|
||||
view.setUint32(PEER_ID_OFFSET, 0, true);
|
||||
view.setUint32(SEQUENCE_OFFSET, 0, true);
|
||||
view.setUint32(HEADER_SIZE, hostPeerId, true);
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function createLeaveMsg(peerId: number): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(HEADER_SIZE);
|
||||
const view = new DataView(buf);
|
||||
view.setUint8(0, MSG_LEAVE);
|
||||
view.setUint32(PEER_ID_OFFSET, peerId, true);
|
||||
view.setUint32(SEQUENCE_OFFSET, 0, true);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Copy a message and stamp the sender's peerId into the header. */
|
||||
export function stampSender(data: Uint8Array, peerId: number): Uint8Array {
|
||||
const stamped = new Uint8Array(data.length);
|
||||
stamped.set(data);
|
||||
new DataView(stamped.buffer).setUint32(PEER_ID_OFFSET, peerId, true);
|
||||
return stamped;
|
||||
}
|
||||
|
||||
export function readTargetPeerId(data: Uint8Array): number {
|
||||
return new DataView(data.buffer).getUint32(SNAPSHOT_TARGET_OFFSET, true);
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
export { GameRoom } from "./gameroom";
|
||||
|
||||
export interface Env {
|
||||
GAME_ROOM: DurableObjectNamespace;
|
||||
}
|
||||
@ -25,180 +27,3 @@ export default {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
};
|
||||
|
||||
// Message types matching protocol.h
|
||||
const MSG_LEAVE = 2;
|
||||
const MSG_HOST_ASSIGN = 4;
|
||||
const MSG_REQUEST_SNAPSHOT = 5;
|
||||
const MSG_WORLD_SNAPSHOT = 6;
|
||||
const MSG_WORLD_EVENT_REQUEST = 8;
|
||||
const MSG_ASSIGN_ID = 0xff;
|
||||
|
||||
export class GameRoom implements DurableObject {
|
||||
private connections: Map<string, WebSocket> = new Map();
|
||||
private nextPeerId: number = 1;
|
||||
private hostPeerId: number = 0;
|
||||
|
||||
constructor(
|
||||
private state: DurableObjectState,
|
||||
private env: Env
|
||||
) {}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
if (request.headers.get("Upgrade") !== "websocket") {
|
||||
return new Response("Expected WebSocket", { status: 426 });
|
||||
}
|
||||
|
||||
const pair = new WebSocketPair();
|
||||
const [client, server] = [pair[0], pair[1]];
|
||||
|
||||
const peerId = this.nextPeerId++;
|
||||
const peerIdStr = String(peerId);
|
||||
|
||||
server.accept();
|
||||
this.connections.set(peerIdStr, server);
|
||||
|
||||
// Send the peer its assigned ID as the first message
|
||||
const idMsg = new ArrayBuffer(5);
|
||||
const view = new DataView(idMsg);
|
||||
view.setUint8(0, MSG_ASSIGN_ID);
|
||||
view.setUint32(1, peerId, true); // little-endian peer ID
|
||||
server.send(idMsg);
|
||||
|
||||
// Assign host if none exists (first peer becomes host)
|
||||
if (this.hostPeerId === 0 || !this.connections.has(String(this.hostPeerId))) {
|
||||
this.hostPeerId = peerId;
|
||||
this.broadcastHostAssign();
|
||||
} else {
|
||||
// Send current host assignment to the new peer only
|
||||
this.sendHostAssign(server);
|
||||
}
|
||||
|
||||
server.addEventListener("message", (event) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new Uint8Array(event.data);
|
||||
if (data.length < 9) {
|
||||
return; // Too short for header
|
||||
}
|
||||
|
||||
const msgType = data[0];
|
||||
|
||||
// Stamp the peerId into the message header (bytes 1-4)
|
||||
const stamped = new Uint8Array(data.length);
|
||||
stamped.set(data);
|
||||
new DataView(stamped.buffer).setUint32(1, peerId, true);
|
||||
|
||||
if (msgType === MSG_REQUEST_SNAPSHOT || msgType === MSG_WORLD_EVENT_REQUEST) {
|
||||
// Route to host only
|
||||
const hostWs = this.connections.get(String(this.hostPeerId));
|
||||
if (hostWs) {
|
||||
try {
|
||||
hostWs.send(stamped.buffer);
|
||||
} catch {
|
||||
// Host disconnected; will be handled by close event
|
||||
}
|
||||
}
|
||||
} else if (msgType === MSG_WORLD_SNAPSHOT && data.length >= 15) {
|
||||
// Route to the target peer only (targetPeerId at offset 9)
|
||||
const targetId = new DataView(stamped.buffer).getUint32(9, true);
|
||||
const targetWs = this.connections.get(String(targetId));
|
||||
if (targetWs) {
|
||||
try {
|
||||
targetWs.send(stamped.buffer);
|
||||
} catch {
|
||||
this.connections.delete(String(targetId));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Broadcast to all other peers in this room
|
||||
for (const [id, ws] of this.connections) {
|
||||
if (id !== peerIdStr) {
|
||||
try {
|
||||
ws.send(stamped.buffer);
|
||||
} catch {
|
||||
this.connections.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleDisconnect = () => {
|
||||
this.connections.delete(peerIdStr);
|
||||
|
||||
// Broadcast LEAVE message to remaining peers
|
||||
const leaveMsg = new ArrayBuffer(9);
|
||||
const leaveView = new DataView(leaveMsg);
|
||||
leaveView.setUint8(0, MSG_LEAVE);
|
||||
leaveView.setUint32(1, peerId, true);
|
||||
leaveView.setUint32(5, 0, true); // sequence 0
|
||||
|
||||
for (const [, ws] of this.connections) {
|
||||
try {
|
||||
ws.send(leaveMsg);
|
||||
} catch {
|
||||
// Ignore send errors on cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Host migration: if the disconnected peer was the host, assign a new one
|
||||
if (peerId === this.hostPeerId) {
|
||||
this.electNewHost();
|
||||
}
|
||||
};
|
||||
|
||||
server.addEventListener("close", handleDisconnect);
|
||||
server.addEventListener("error", handleDisconnect);
|
||||
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
|
||||
private electNewHost(): void {
|
||||
// Pick the lowest peer ID from remaining connections
|
||||
let lowestId = 0;
|
||||
for (const idStr of this.connections.keys()) {
|
||||
const id = parseInt(idStr, 10);
|
||||
if (lowestId === 0 || id < lowestId) {
|
||||
lowestId = id;
|
||||
}
|
||||
}
|
||||
|
||||
this.hostPeerId = lowestId;
|
||||
if (lowestId > 0) {
|
||||
this.broadcastHostAssign();
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastHostAssign(): void {
|
||||
const msg = this.createHostAssignMsg();
|
||||
for (const [, ws] of this.connections) {
|
||||
try {
|
||||
ws.send(msg);
|
||||
} catch {
|
||||
// Ignore send errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendHostAssign(ws: WebSocket): void {
|
||||
try {
|
||||
ws.send(this.createHostAssignMsg());
|
||||
} catch {
|
||||
// Ignore send errors
|
||||
}
|
||||
}
|
||||
|
||||
private createHostAssignMsg(): ArrayBuffer {
|
||||
// MessageHeader (9 bytes) + hostPeerId (4 bytes) = 13 bytes
|
||||
const msg = new ArrayBuffer(13);
|
||||
const view = new DataView(msg);
|
||||
view.setUint8(0, MSG_HOST_ASSIGN); // type
|
||||
view.setUint32(1, 0, true); // peerId (server, so 0)
|
||||
view.setUint32(5, 0, true); // sequence
|
||||
view.setUint32(9, this.hostPeerId, true); // hostPeerId
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user