jevstrudel.git / worker / src / lobby.ts
lobby.tsannotatedlobby.tssource108 lines · 4.4 KB · raw
1// The lobby: GET /jev/lobby upgrades to a WebSocket into the site's one
2// Lobby Durable Object (`idFromName('lobby')`). The rules are in
3// lobby-room.ts; this is the route that says who the tab is, and the shell
4// that owns the sockets.
5//
6// It uses the Hibernation API, like party.ts: while nothing changes the
7// object is evicted and every socket stays open. Each tab's status is in
8// its socket's attachment, so nothing is lost; only the rate-limit buckets
9// live in memory, and forgetting them only refills them.
10import { DurableObject } from 'cloudflare:workers';
11import { d1Accounts, type Accounts } from './accounts-store';
12import { signedIn } from './auth';
13import type { Env } from './env';
14import { Lobby as Room, LOBBY_PATH, type Attachment, type Peer } from './lobby-room';
15import { randomId } from './session';
16
17export { LOBBY_PATH } from './lobby-room';
18
19// The header the route sets and the object reads: who the tab is. The
20// object is reached only through this route, which always sets it.
21const WHO = 'X-Jev-Lobby-Who';
22export type Who = { name: string | null; account: string | null; group: string | null };
23
24// Tabs of one account are listed together, under a key that does not
25// reveal the account's id.
26export async function groupOf(userId: string): Promise<string> {
27  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`lobby:${userId}`));
28  return [...new Uint8Array(digest).slice(0, 8)].map((b) => b.toString(16).padStart(2, '0')).join('');
29}
30
31// The per-visitor limit on joining is LOBBY_LIMIT (wrangler.json): a page
32// joins once, and again only when its network drops.
33export async function lobby(
34  request: Request,
35  env: Pick<Env, 'LOBBY' | 'LOBBY_LIMIT' | 'DB'>,
36  accounts: Accounts = d1Accounts(env.DB),
37): Promise<Response> {
38  if (new URL(request.url).pathname !== LOBBY_PATH) return new Response('not found', { status: 404 });
39  if (request.headers.get('Upgrade') !== 'websocket') return new Response('WebSocket only', { status: 426 });
40  const visitor = request.headers.get('CF-Connecting-IP') ?? 'local';
41  if (!(await env.LOBBY_LIMIT.limit({ key: visitor })).success) {
42    return new Response('too many joins; try again in a minute', { status: 429 });
43  }
44  // not signed in, or the session cannot be read: "a listener"
45  const user = await signedIn(request, accounts).catch(() => null);
46  const who: Who = user
47    ? { name: user.displayName, account: user.id, group: await groupOf(user.id) }
48    : { name: null, account: null, group: null };
49  const headers = new Headers(request.headers);
50  headers.set(WHO, JSON.stringify(who));
51  return env.LOBBY.get(env.LOBBY.idFromName('lobby')).fetch(new Request(request, { headers }));
52}
53
54export class LobbyRoom extends DurableObject<Env> {
55  private room: Room;
56
57  constructor(ctx: DurableObjectState, env: Env) {
58    super(ctx, env);
59    this.room = new Room(() => ctx.getWebSockets() as unknown as Peer[]);
60  }
61
62  async fetch(request: Request): Promise<Response> {
63    const [client, server] = Object.values(new WebSocketPair());
64    const full = this.room.admit();
65    if (full) {
66      // as party.ts: say why, and close with a code the page does not retry
67      server.accept();
68      server.send(JSON.stringify({ t: 'refused', why: full }));
69      server.close(4503, full);
70      return new Response(null, { status: 101, webSocket: client });
71    }
72    let who: Who = { name: null, account: null, group: null };
73    try {
74      who = JSON.parse(request.headers.get(WHO) ?? '') as Who;
75    } catch {}
76    this.ctx.acceptWebSocket(server);
77    const id = randomId(12);
78    const attachment: Attachment = {
79      id,
80      name: typeof who.name === 'string' ? who.name : null,
81      account: typeof who.account === 'string' && /^[A-Za-z0-9_-]{22}$/.test(who.account) ? who.account : null,
82      group: typeof who.group === 'string' ? who.group : id,
83      visible: false,
84      changed: Date.now(),
85      status: null,
86    };
87    server.serializeAttachment(attachment);
88    this.room.joined(server as unknown as Peer);
89    return new Response(null, { status: 101, webSocket: client });
90  }
91
92  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
93    this.room.message(ws as unknown as Peer, message);
94  }
95
96  async webSocketClose(ws: WebSocket) {
97    this.room.left(ws as unknown as Peer);
98  }
99
100  async webSocketError(ws: WebSocket) {
101    this.room.left(ws as unknown as Peer);
102  }
103
104  // Counts for the data tab (data.ts); reads the sockets, stores nothing.
105  summary() {
106    return this.room.summary();
107  }
108}