diff --git a/extensions/src/multiplayer/server/cors.ts b/extensions/src/multiplayer/server/cors.ts new file mode 100644 index 00000000..ca97ceea --- /dev/null +++ b/extensions/src/multiplayer/server/cors.ts @@ -0,0 +1,6 @@ +export const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Cross-Origin-Resource-Policy": "cross-origin", +}; diff --git a/extensions/src/multiplayer/server/gameroom.ts b/extensions/src/multiplayer/server/gameroom.ts index c0f0d01f..e8d3cea3 100644 --- a/extensions/src/multiplayer/server/gameroom.ts +++ b/extensions/src/multiplayer/server/gameroom.ts @@ -10,26 +10,41 @@ import { stampSender, } from "./protocol"; import type { Env } from "./relay"; - -const CORS_HEADERS: Record = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - "Cross-Origin-Resource-Policy": "cross-origin", -}; +import { CORS_HEADERS } from "./cors"; export class GameRoom implements DurableObject { private connections = new Map(); private nextPeerId = 1; private hostPeerId = 0; private maxPlayers = 5; + private isPublic = true; + private roomId: string | null = null; constructor( private state: DurableObjectState, private env: Env - ) {} + ) { + state.blockConcurrencyWhile(async () => { + this.isPublic = + (await state.storage.get("isPublic")) ?? true; + this.roomId = + (await state.storage.get("roomId")) ?? null; + this.maxPlayers = + (await state.storage.get("maxPlayers")) ?? 5; + }); + } async fetch(request: Request): Promise { + // 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) if (request.headers.get("Upgrade") !== "websocket") { return this.handleHttpRequest(request); @@ -50,6 +65,7 @@ export class GameRoom implements DurableObject { server.accept(); this.connections.set(peerId, server); + this.notifyRegistry().catch(() => {}); server.send(createAssignIdMsg(peerId)); this.assignHostIfNeeded(peerId, server); @@ -68,14 +84,11 @@ export class GameRoom implements DurableObject { private async handleHttpRequest(request: Request): Promise { const method = request.method.toUpperCase(); - if (method === "OPTIONS") { - return new Response(null, { status: 204, headers: CORS_HEADERS }); - } - if (method === "POST") { try { const body = (await request.json()) as { maxPlayers?: number; + isPublic?: boolean; }; const ceiling = this.env.MAX_PLAYERS_CEILING ? Number(this.env.MAX_PLAYERS_CEILING) @@ -85,10 +98,22 @@ export class GameRoom implements DurableObject { 2, 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 { // Ignore parse errors, keep defaults } + this.notifyRegistry().catch(() => {}); return new Response( JSON.stringify({ maxPlayers: this.maxPlayers }), { @@ -105,6 +130,7 @@ export class GameRoom implements DurableObject { JSON.stringify({ players: this.connections.size, maxPlayers: this.maxPlayers, + isPublic: this.isPublic, }), { headers: { @@ -133,6 +159,7 @@ export class GameRoom implements DurableObject { private handleDisconnect(peerId: number): void { this.connections.delete(peerId); this.broadcast(createLeaveMsg(peerId)); + this.notifyRegistry().catch(() => {}); if (peerId === this.hostPeerId) { this.electNewHost(); @@ -217,4 +244,21 @@ export class GameRoom implements DurableObject { this.broadcast(createHostAssignMsg(this.hostPeerId)); } } + + private async notifyRegistry(): Promise { + 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, + }), + }) + ); + } } diff --git a/extensions/src/multiplayer/server/relay.ts b/extensions/src/multiplayer/server/relay.ts index ff0ec416..1bdbd62c 100644 --- a/extensions/src/multiplayer/server/relay.ts +++ b/extensions/src/multiplayer/server/relay.ts @@ -1,15 +1,30 @@ export { GameRoom } from "./gameroom"; +export { RoomRegistry } from "./roomregistry"; export interface Env { GAME_ROOM: DurableObjectNamespace; + ROOM_REGISTRY: DurableObjectNamespace; MAX_PLAYERS_CEILING?: number; } +import { CORS_HEADERS } from "./cors"; + export default { async fetch(request: Request, env: Env): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + const url = new URL(request.url); 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 if (pathParts.length === 2 && pathParts[0] === "room") { const roomId = pathParts[1]; diff --git a/extensions/src/multiplayer/server/roomregistry.ts b/extensions/src/multiplayer/server/roomregistry.ts new file mode 100644 index 00000000..660cc53b --- /dev/null +++ b/extensions/src/multiplayer/server/roomregistry.ts @@ -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 { + 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 { + 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 { + 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(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 { + 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 { + const map = await this.state.storage.list({ + prefix: "room:", + }); + return [...map.values()]; + } + + private async ensureAlarm(): Promise { + const currentAlarm = await this.state.storage.getAlarm(); + if (currentAlarm === null) { + await this.state.storage.setAlarm( + Date.now() + ALARM_INTERVAL_MS + ); + } + } +} diff --git a/extensions/src/multiplayer/server/wrangler.toml b/extensions/src/multiplayer/server/wrangler.toml index f421e611..e52937c3 100644 --- a/extensions/src/multiplayer/server/wrangler.toml +++ b/extensions/src/multiplayer/server/wrangler.toml @@ -6,7 +6,8 @@ preview_urls = false [durable_objects] bindings = [ - { name = "GAME_ROOM", class_name = "GameRoom" } + { name = "GAME_ROOM", class_name = "GameRoom" }, + { name = "ROOM_REGISTRY", class_name = "RoomRegistry" } ] [vars] @@ -15,3 +16,7 @@ MAX_PLAYERS_CEILING = 64 [[migrations]] tag = "v1" new_classes = ["GameRoom"] + +[[migrations]] +tag = "v2" +new_classes = ["RoomRegistry"]