mirror of
https://github.com/isledecomp/isle-portable.git
synced 2026-05-02 02:23:56 +00:00
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:
parent
99c871ab16
commit
0f2ec86f47
6
extensions/src/multiplayer/server/cors.ts
Normal file
6
extensions/src/multiplayer/server/cors.ts
Normal 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",
|
||||
};
|
||||
@ -10,26 +10,41 @@ import {
|
||||
stampSender,
|
||||
} from "./protocol";
|
||||
import type { Env } from "./relay";
|
||||
|
||||
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",
|
||||
};
|
||||
import { CORS_HEADERS } from "./cors";
|
||||
|
||||
export class GameRoom implements DurableObject {
|
||||
private connections = new Map<number, WebSocket>();
|
||||
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<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> {
|
||||
// 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<Response> {
|
||||
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<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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Response> {
|
||||
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];
|
||||
|
||||
137
extensions/src/multiplayer/server/roomregistry.ts
Normal file
137
extensions/src/multiplayer/server/roomregistry.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user