jevstrudel.git / worker / src / party.ts
party.tsannotatedparty.tssource155 lines · 6.7 KB · raw

Listening parties: GET /jev/party/<room> upgrades to a WebSocket into that room's Durable Object (one per room, idFromName(room)). The room's rules are in party-room.ts; this is the shell that owns the sockets.

It uses the Hibernation API (ctx.acceptWebSocket and the webSocket* handlers), so a room whose song is between sections is evicted from memory while every socket stays open, and costs no duration. What must survive that is in the room's storage (the current performance) and in each socket's attachment (its role); only the rate-limit buckets live in memory, and forgetting them only refills them.

Each room tells the site's one PartyDirectory (below) when its people or song change, so the data tab can say which parties are live without 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';
20export { party, PARTY_PREFIX } from './party-room';

The directory's one object.

23export const directory = (env: Pick<Env, 'PARTY_DIRECTORY'>) =>
24  env.PARTY_DIRECTORY.get(env.PARTY_DIRECTORY.idFromName('parties'));
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  }

The room's name, to join it from the list: only while the party is on 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  }
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}

Which parties are live: one object for the site (idFromName('parties')), its rules in party-directory.ts. It holds no room's name; a listed room's entry has its object id, which parties below turns into the room 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}

GET /jev/parties: the live parties, for the listening tab; a listed one with its id. GET /jev/parties/<id>: that party's room to join ({ room }), or 404 once it is over or not listed. Per visitor 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}