86 lines · 3.1 KB
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<Env> {
  async fetch(request: Request): Promise<Response> {
    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<void> {
    this.send(JSON.stringify({ type: "changed", actor }));
  }

  async webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): Promise<void> {
    if (this.expired(socket)) {
      socket.close(REAUTH_CLOSE_CODE, "Reauthorization required");
      return;
    }
    if (message === "ping") socket.send("pong");
  }