import { DurableObject } from "cloudflare:workers"; const MAX_SOCKETS = 200; const MAX_SOCKETS_PER_CLIENT = 5; /** Sockets are closed after this long so the client reconnects and access is re-authorized. */ const AUTH_TTL_MS = 5 * 60 * 1000; const REAUTH_CLOSE_CODE = 4001; type Presence = { name: string; client: string; expiresAt: number }; /** One instance per project: fans out change notifications and presence to connected board viewers. */ export class ProjectRoom extends DurableObject { async fetch(request: Request): Promise { if (request.headers.get("upgrade") !== "websocket") return new Response("Expected websocket", { status: 426 }); this.closeExpired(); if (this.ctx.getWebSockets().length >= MAX_SOCKETS) return new Response("Room is full", { status: 503 }); const name = request.headers.get("x-gitfor-viewer") ?? "anonymous"; const client = request.headers.get("x-gitfor-client") ?? "unknown"; if (this.ctx.getWebSockets(client).length >= MAX_SOCKETS_PER_CLIENT) { return new Response("Too many connections", { status: 429 }); } const pair = new WebSocketPair(); this.ctx.acceptWebSocket(pair[1], [client]); pair[1].serializeAttachment({ name, client, expiresAt: Date.now() + AUTH_TTL_MS } satisfies Presence); this.broadcastPresence(); return new Response(null, { status: 101, webSocket: pair[0] }); } /** Called by server functions after a project mutation so open boards refresh. */ async notifyChanged(actor: string): Promise { this.send(JSON.stringify({ type: "changed", actor })); } async webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): Promise { if (this.expired(socket)) { socket.close(REAUTH_CLOSE_CODE, "Reauthorization required"); return; } if (message === "ping") socket.send("pong"); } async webSocketClose(socket: WebSocket): Promise { socket.close(); this.broadcastPresence(); } private expired(socket: WebSocket): boolean { const presence: Presence | null = socket.deserializeAttachment(); return !presence || typeof presence.expiresAt !== "number" || presence.expiresAt <= Date.now(); } private closeExpired(): void { for (const socket of this.ctx.getWebSockets()) { if (this.expired(socket)) { try { socket.close(REAUTH_CLOSE_CODE, "Reauthorization required"); } catch { continue; } } } } private send(payload: string): void { this.closeExpired(); for (const socket of this.ctx.getWebSockets()) { if (socket.readyState !== WebSocket.OPEN || this.expired(socket)) continue; try { socket.send(payload); } catch { socket.close(); } } } private broadcastPresence(): void { const names = new Set(); for (const socket of this.ctx.getWebSockets()) { if (socket.readyState !== WebSocket.OPEN || this.expired(socket)) continue; const presence: Presence | null = socket.deserializeAttachment(); if (presence) names.add(presence.name); } this.send(JSON.stringify({ type: "presence", viewers: [...names] })); } }