jevstrudel.git / worker / src / party-directory.ts
1// Which listening parties are live now, for the data tab (data.ts): the
2// rules of the site's one PartyDirectory Durable Object (party.ts), as
3// plain code the tests run in Node.
4//
5// A party room cannot be listed (each is its own object, named by a room id
6// only its people know), so every room tells the directory when its people
7// or its song change (party-room.ts's `changed`), and the directory keeps
8// one entry per live room: its song, how many are in it, whether the host
9// is, and since when. Keyed by the room's Durable Object id, a hash of the
10// room's name that cannot be turned back into it, so the directory never
11// holds the join link: a party is counted and described. A room whose host
12// chose to be seen (the lobby's "show me
13// here", website/src/jev/party.mjs) is `listed`: its entry then carries its
14// object id, which /jev/parties/<id> (party.ts) turns into the room to join
15// by asking the room itself, which answers only while it is listed. An
16// unlisted party is counted and described, never joinable from here.
17// An empty room removes its entry; one that stops reporting (a
18// room lost in a crash) is dropped once its last report is STALE_MS old.
19// Live coordination only, like the rooms: nothing here outlives a party.
20
21export type Live = { people: number; host: boolean; song: string | null; listed?: boolean };
22export type Entry = Live & { since: number; seen: number };
23
24export const STALE_MS = 6 * 60 * 60 * 1000;
25export const MAX_ENTRIES = 1000;
26const KEY = /^[0-9a-f]{64}$/;
27
28// The part of a Durable Object's storage the directory uses.
29export interface Storage {
30  get<T>(key: string): Promise<T | undefined>;
31  put(key: string, value: unknown): Promise<void>;
32  delete(key: string): Promise<boolean>;
33  list<T>(options: { prefix: string; limit?: number }): Promise<Map<string, T>>;
34}
35
36// A room's report: its entry kept or updated, or removed when empty.
37export async function note(storage: Storage, room: string, live: Live | null, now: number): Promise<void> {
38  if (!KEY.test(room)) return;
39  const key = `p:${room}`;
40  if (!live || live.people < 1) {
41    await storage.delete(key);
42    return;
43  }
44  const before = await storage.get<Entry>(key);
45  const entry: Entry = {
46    people: Math.min(Math.max(Math.floor(live.people), 0), 1000),
47    host: Boolean(live.host),
48    song: typeof live.song === 'string' ? live.song.slice(0, 129) : null,
49    listed: live.listed === true,
50    since: before?.since ?? now,
51    seen: now,
52  };
53  await storage.put(key, entry);
54}
55
56// Every live party, the longest-running first; stale entries are removed.
57export type Listed = { people: number; host: boolean; song: string | null; since: number; listed: boolean; id?: string };
58export async function live(storage: Storage, now: number): Promise<Listed[]> {
59  const entries = await storage.list<Entry>({ prefix: 'p:', limit: MAX_ENTRIES });
60  const out: Listed[] = [];
61  for (const [key, e] of entries) {
62    if (now - e.seen > STALE_MS) {
63      await storage.delete(key);
64      continue;
65    }
66    const listed = e.listed === true;
67    out.push({ people: e.people, host: e.host, song: e.song, since: e.since, listed, ...(listed ? { id: key.slice(2) } : {}) });
68  }
69  return out.sort((a, b) => a.since - b.since);
70}