jevstrudel.git / worker / src / party.ts
party.tsannotatedparty.tssource155 lines · 6.7 KB · raw
1// Listening parties: GET /jev/party/<room> upgrades to a WebSocket into that
2// room's Durable Object (one per room, `idFromName(room)`). The room's rules
3// are in party-room.ts; this is the shell that owns the sockets.
4//
5// It uses the Hibernation API (ctx.acceptWebSocket and the webSocket*
6// handlers), so a room whose song is between sections is evicted from
7// memory while every socket stays open, and costs no duration. What must
8// survive that is in the room's storage (the current performance) and in
9// each socket's attachment (its role); only the rate-limit buckets live in
10// memory, and forgetting them only refills them.
11//
12// Each room tells the site's one PartyDirectory (below) when its people or
13// song change, so the data tab can say which parties are live without
14// listing rooms, which cannot be listed (party-directory.ts).
15import { DurableObject } from 'cloudflare:workers';
16import type { Env } from './env';
17import { live, note, type Live, type Storage as DirectoryStorage } from './party-directory';
18import { admit, ROOM, ROOM_HEADER, Room, type Attachment, type Peer, type Storage } from './party-room';
19
20export { party, PARTY_PREFIX } from './party-room';
21
22// The directory's one object.
23export const directory = (env: Pick<Env, 'PARTY_DIRECTORY'>) =>
24  env.PARTY_DIRECTORY.get(env.PARTY_DIRECTORY.idFromName('parties'));
25
26export class Party extends DurableObject<Env> {
27  private room: Room;
28
29  constructor(ctx: DurableObjectState, env: Env) {
30    super(ctx, env);
31    // the report never delays the room, and a directory that fails only
32    // leaves the data tab a party short
33    const report = (l: Live | null) =>
34      ctx.waitUntil(
35        directory(env)
36          .note(ctx.id.toString(), l)
37          .catch((e: unknown) => console.error({ event: 'jev.party.directory', error: String(e) })),
38      );
39    this.room = new Room(
40      ctx.storage as unknown as Storage,
41      () => ctx.getWebSockets() as unknown as Peer[],
42      Date.now,
43      report,
44      // a name is this room's when it names this object; never read from a
45      // page as it is
46      (name) => env.PARTY.idFromName(name).equals(ctx.id),
47    );
48  }
49
50  async fetch(request: Request): Promise<Response> {
51    const params = new URL(request.url).searchParams;
52    const role = params.get('role');
53    const key = params.get('key');
54    const refused = await admit(this.ctx.storage as unknown as Storage, this.ctx.getWebSockets() as unknown as Peer[], {
55      role,
56      key,
57    });
58    const [client, server] = Object.values(new WebSocketPair());
59    if (refused) {
60      // A browser cannot read why a WebSocket upgrade was refused, and would
61      // retry forever: accept it outside the room, say why, and close with
62      // 4000 + the status (4404: no party here), which the page does not
63      // retry. Nothing of the room sees it.
64      server.accept();
65      server.send(JSON.stringify({ t: 'refused', status: refused.status, why: refused.why }));
66      server.close(4000 + refused.status, refused.why);
67      return new Response(null, { status: 101, webSocket: client });
68    }
69
70    if (role === 'host') {
71      // listed or not is the host's choice ("show me here"), made as it
72      // connects and changed mid-party by its `listed` message (the room's
73      // rules); a listed room keeps its name to give someone joining from
74      // the list (listedRoom)
75      const name = request.headers.get(ROOM_HEADER);
76      if (params.get('listed') === '1' && name && ROOM.test(name)) {
77        await this.ctx.storage.put({ listed: true, name }, { allowUnconfirmed: true });
78      } else {
79        await this.ctx.storage.put({ listed: false }, { allowUnconfirmed: true });
80        await this.ctx.storage.delete('name', { allowUnconfirmed: true });
81      }
82    }
83    this.ctx.acceptWebSocket(server);
84    const attachment: Attachment = { role: role as Attachment['role'], id: crypto.randomUUID() };
85    server.serializeAttachment(attachment);
86    await this.room.joined(server as unknown as Peer, attachment.role, key);
87    return new Response(null, { status: 101, webSocket: client });
88  }
89
90  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
91    await this.room.message(ws as unknown as Peer, message);
92  }
93
94  // The room's name, to join it from the list: only while the party is on
95  // and its host chose to be listed.
96  async listedRoom(): Promise<string | null> {
97    if (!this.ctx.getWebSockets().length) return null;
98    if ((await this.ctx.storage.get<boolean>('listed')) !== true) return null;
99    return (await this.ctx.storage.get<string>('name')) ?? null;
100  }
101
102  async webSocketClose(ws: WebSocket) {
103    // the runtime answers the close frame itself (web_socket_auto_reply_to_close)
104    await this.room.left(ws as unknown as Peer);
105  }
106
107  async webSocketError(ws: WebSocket) {
108    await this.room.left(ws as unknown as Peer);
109  }
110}
111
112// Which parties are live: one object for the site (`idFromName('parties')`),
113// its rules in party-directory.ts. It holds no room's name; a listed
114// room's entry has its object id, which `parties` below turns into the room
115// to join by asking the room.
116export class PartyDirectory extends DurableObject<Env> {
117  async note(room: string, l: Live | null): Promise<void> {
118    await note(this.ctx.storage as unknown as DirectoryStorage, room, l, Date.now());
119  }
120
121  async live() {
122    return live(this.ctx.storage as unknown as DirectoryStorage, Date.now());
123  }
124}
125
126// GET /jev/parties: the live parties, for the listening tab; a listed one
127// with its `id`. GET /jev/parties/<id>: that party's room to join
128// ({ room }), or 404 once it is over or not listed. Per visitor
129// PARTY_LIMIT, as joining a party is.
130export const PARTIES_PATH = '/jev/parties';
131const OBJECT_ID = /^[0-9a-f]{64}$/;
132export async function parties(
133  request: Request,
134  env: Pick<Env, 'PARTY' | 'PARTY_DIRECTORY' | 'PARTY_LIMIT'>,
135): Promise<Response> {
136  const answer = (status: number, body: unknown) =>
137    Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } });
138  if (request.method !== 'GET') return answer(405, { error: 'GET only' });
139  const visitor = request.headers.get('CF-Connecting-IP') ?? 'local';
140  if (!(await env.PARTY_LIMIT.limit({ key: visitor })).success) {
141    return answer(429, { error: 'too many requests; try again in a minute' });
142  }
143  const rest = new URL(request.url).pathname.slice(PARTIES_PATH.length);
144  if (rest === '' || rest === '/') return answer(200, { parties: await directory(env).live() });
145  const id = rest.slice(1);
146  if (!OBJECT_ID.test(id)) return answer(404, { error: 'no such party' });
147  let stub;
148  try {
149    stub = env.PARTY.get(env.PARTY.idFromString(id));
150  } catch {
151    return answer(404, { error: 'no such party' });
152  }
153  const room = await stub.listedRoom();
154  return room ? answer(200, { room }) : answer(404, { error: 'this party is over, or not listed' });
155}