Add public room registry and extract shared CORS headers

Add RoomRegistry durable object for tracking public rooms with
stale-entry cleanup. GameRoom notifies the registry on connect,
disconnect, and creation. Extract duplicated CORS_HEADERS to a
shared cors.ts module and remove dead OPTIONS handlers from DOs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Semmler 2026-03-23 17:41:40 -07:00
parent 99c871ab16
commit 0f2ec86f47
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
5 changed files with 220 additions and 13 deletions

View File

@ -0,0 +1,6 @@
export const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cross-Origin-Resource-Policy": "cross-origin",
};

View File

@ -10,26 +10,41 @@ import {
stampSender, stampSender,
} from "./protocol"; } from "./protocol";
import type { Env } from "./relay"; import type { Env } from "./relay";
import { CORS_HEADERS } from "./cors";
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cross-Origin-Resource-Policy": "cross-origin",
};
export class GameRoom implements DurableObject { export class GameRoom implements DurableObject {
private connections = new Map<number, WebSocket>(); private connections = new Map<number, WebSocket>();
private nextPeerId = 1; private nextPeerId = 1;
private hostPeerId = 0; private hostPeerId = 0;
private maxPlayers = 5; private maxPlayers = 5;
private isPublic = true;
private roomId: string | null = null;
constructor( constructor(
private state: DurableObjectState, private state: DurableObjectState,
private env: Env private env: Env
) {} ) {
state.blockConcurrencyWhile(async () => {
this.isPublic =
(await state.storage.get<boolean>("isPublic")) ?? true;
this.roomId =
(await state.storage.get<string>("roomId")) ?? null;
this.maxPlayers =
(await state.storage.get<number>("maxPlayers")) ?? 5;
});
}
async fetch(request: Request): Promise<Response> { async fetch(request: Request): Promise<Response> {
// Extract roomId from URL path if not yet known
if (!this.roomId) {
const url = new URL(request.url);
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length === 2 && parts[0] === "room") {
this.roomId = parts[1];
await this.state.storage.put("roomId", this.roomId);
}
}
// Handle non-WebSocket requests (HTTP API) // Handle non-WebSocket requests (HTTP API)
if (request.headers.get("Upgrade") !== "websocket") { if (request.headers.get("Upgrade") !== "websocket") {
return this.handleHttpRequest(request); return this.handleHttpRequest(request);
@ -50,6 +65,7 @@ export class GameRoom implements DurableObject {
server.accept(); server.accept();
this.connections.set(peerId, server); this.connections.set(peerId, server);
this.notifyRegistry().catch(() => {});
server.send(createAssignIdMsg(peerId)); server.send(createAssignIdMsg(peerId));
this.assignHostIfNeeded(peerId, server); this.assignHostIfNeeded(peerId, server);
@ -68,14 +84,11 @@ export class GameRoom implements DurableObject {
private async handleHttpRequest(request: Request): Promise<Response> { private async handleHttpRequest(request: Request): Promise<Response> {
const method = request.method.toUpperCase(); const method = request.method.toUpperCase();
if (method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
if (method === "POST") { if (method === "POST") {
try { try {
const body = (await request.json()) as { const body = (await request.json()) as {
maxPlayers?: number; maxPlayers?: number;
isPublic?: boolean;
}; };
const ceiling = this.env.MAX_PLAYERS_CEILING const ceiling = this.env.MAX_PLAYERS_CEILING
? Number(this.env.MAX_PLAYERS_CEILING) ? Number(this.env.MAX_PLAYERS_CEILING)
@ -85,10 +98,22 @@ export class GameRoom implements DurableObject {
2, 2,
Math.min(body.maxPlayers, ceiling) Math.min(body.maxPlayers, ceiling)
); );
await this.state.storage.put(
"maxPlayers",
this.maxPlayers
);
}
if (body.isPublic !== undefined) {
this.isPublic = body.isPublic;
await this.state.storage.put(
"isPublic",
this.isPublic
);
} }
} catch { } catch {
// Ignore parse errors, keep defaults // Ignore parse errors, keep defaults
} }
this.notifyRegistry().catch(() => {});
return new Response( return new Response(
JSON.stringify({ maxPlayers: this.maxPlayers }), JSON.stringify({ maxPlayers: this.maxPlayers }),
{ {
@ -105,6 +130,7 @@ export class GameRoom implements DurableObject {
JSON.stringify({ JSON.stringify({
players: this.connections.size, players: this.connections.size,
maxPlayers: this.maxPlayers, maxPlayers: this.maxPlayers,
isPublic: this.isPublic,
}), }),
{ {
headers: { headers: {
@ -133,6 +159,7 @@ export class GameRoom implements DurableObject {
private handleDisconnect(peerId: number): void { private handleDisconnect(peerId: number): void {
this.connections.delete(peerId); this.connections.delete(peerId);
this.broadcast(createLeaveMsg(peerId)); this.broadcast(createLeaveMsg(peerId));
this.notifyRegistry().catch(() => {});
if (peerId === this.hostPeerId) { if (peerId === this.hostPeerId) {
this.electNewHost(); this.electNewHost();
@ -217,4 +244,21 @@ export class GameRoom implements DurableObject {
this.broadcast(createHostAssignMsg(this.hostPeerId)); this.broadcast(createHostAssignMsg(this.hostPeerId));
} }
} }
private async notifyRegistry(): Promise<void> {
if (!this.isPublic || !this.roomId) return;
const id = this.env.ROOM_REGISTRY.idFromName("global");
const registry = this.env.ROOM_REGISTRY.get(id);
await registry.fetch(
new Request("https://registry/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: this.roomId,
players: this.connections.size,
maxPlayers: this.maxPlayers,
}),
})
);
}
} }

View File

@ -1,15 +1,30 @@
export { GameRoom } from "./gameroom"; export { GameRoom } from "./gameroom";
export { RoomRegistry } from "./roomregistry";
export interface Env { export interface Env {
GAME_ROOM: DurableObjectNamespace; GAME_ROOM: DurableObjectNamespace;
ROOM_REGISTRY: DurableObjectNamespace;
MAX_PLAYERS_CEILING?: number; MAX_PLAYERS_CEILING?: number;
} }
import { CORS_HEADERS } from "./cors";
export default { export default {
async fetch(request: Request, env: Env): Promise<Response> { async fetch(request: Request, env: Env): Promise<Response> {
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
const url = new URL(request.url); const url = new URL(request.url);
const pathParts = url.pathname.split("/").filter(Boolean); const pathParts = url.pathname.split("/").filter(Boolean);
// Route: /rooms (public room listing)
if (url.pathname === "/rooms" && request.method === "GET") {
const id = env.ROOM_REGISTRY.idFromName("global");
const registry = env.ROOM_REGISTRY.get(id);
return registry.fetch(request);
}
// Route: /room/:roomId // Route: /room/:roomId
if (pathParts.length === 2 && pathParts[0] === "room") { if (pathParts.length === 2 && pathParts[0] === "room") {
const roomId = pathParts[1]; const roomId = pathParts[1];

View File

@ -0,0 +1,137 @@
import type { Env } from "./relay";
import { CORS_HEADERS } from "./cors";
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
const ALARM_INTERVAL_MS = 60 * 1000; // 60 seconds
const MAX_LISTED_ROOMS = 5;
interface RoomEntry {
roomId: string;
players: number;
maxPlayers: number;
createdAt: number;
updatedAt: number;
}
export class RoomRegistry implements DurableObject {
constructor(
private state: DurableObjectState,
private env: Env
) {}
async fetch(request: Request): Promise<Response> {
const method = request.method.toUpperCase();
if (method === "GET") {
return this.handleList();
}
if (method === "POST") {
return this.handleUpsert(request);
}
return new Response("Method Not Allowed", {
status: 405,
headers: CORS_HEADERS,
});
}
private async handleList(): Promise<Response> {
const entries = await this.getAllRooms();
entries.sort((a, b) => {
if (b.players !== a.players) return b.players - a.players;
return b.createdAt - a.createdAt;
});
const rooms = entries.slice(0, MAX_LISTED_ROOMS).map((e) => ({
roomId: e.roomId,
players: e.players,
maxPlayers: e.maxPlayers,
}));
return new Response(JSON.stringify({ rooms }), {
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
});
}
private async handleUpsert(request: Request): Promise<Response> {
try {
const body = (await request.json()) as {
roomId: string;
players: number;
maxPlayers: number;
};
const key = `room:${body.roomId}`;
const existing =
await this.state.storage.get<RoomEntry>(key);
const now = Date.now();
const entry: RoomEntry = {
roomId: body.roomId,
players: body.players,
maxPlayers: body.maxPlayers,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
await this.state.storage.put(key, entry);
await this.ensureAlarm();
return new Response(JSON.stringify({ ok: true }), {
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
});
} catch {
return new Response("Bad Request", {
status: 400,
headers: CORS_HEADERS,
});
}
}
async alarm(): Promise<void> {
const entries = await this.getAllRooms();
const now = Date.now();
const staleKeys: string[] = [];
for (const entry of entries) {
if (now - entry.updatedAt > STALE_THRESHOLD_MS) {
staleKeys.push(`room:${entry.roomId}`);
}
}
if (staleKeys.length > 0) {
await this.state.storage.delete(staleKeys);
}
const remaining = entries.length - staleKeys.length;
if (remaining > 0) {
await this.state.storage.setAlarm(
Date.now() + ALARM_INTERVAL_MS
);
}
}
private async getAllRooms(): Promise<RoomEntry[]> {
const map = await this.state.storage.list<RoomEntry>({
prefix: "room:",
});
return [...map.values()];
}
private async ensureAlarm(): Promise<void> {
const currentAlarm = await this.state.storage.getAlarm();
if (currentAlarm === null) {
await this.state.storage.setAlarm(
Date.now() + ALARM_INTERVAL_MS
);
}
}
}

View File

@ -6,7 +6,8 @@ preview_urls = false
[durable_objects] [durable_objects]
bindings = [ bindings = [
{ name = "GAME_ROOM", class_name = "GameRoom" } { name = "GAME_ROOM", class_name = "GameRoom" },
{ name = "ROOM_REGISTRY", class_name = "RoomRegistry" }
] ]
[vars] [vars]
@ -15,3 +16,7 @@ MAX_PLAYERS_CEILING = 64
[[migrations]] [[migrations]]
tag = "v1" tag = "v1"
new_classes = ["GameRoom"] new_classes = ["GameRoom"]
[[migrations]]
tag = "v2"
new_classes = ["RoomRegistry"]