jevstrudel.git / worker / src / lobby-room.ts
1// Who is listening now: the lobby, one room for the whole site. Every open
2// page joins it (website/src/jev/lobby.mjs) and says what its tab is doing:
3// which song, playing or not, where it is in it. The room tells every page
4// about every other visible tab, so the jev tab can list who is listening
5// to what, with each song's progress, and a way to listen along.
6//
7// The Durable Object in lobby.ts is a thin shell around this, as party.ts is
8// around party-room.ts: it owns the WebSockets (the Hibernation API) and
9// hands each event here, so these rules are plain code the tests run in
10// Node. A tab's status lives in its socket's attachment, so the room keeps
11// no storage at all: a tab that leaves takes its status with it.
12//
13// Who a tab is comes from the Worker, never the page: the route (lobby.ts)
14// reads the session and passes the account's display name and id (so the
15// name opens its profile; accounts are public, data.ts), or none for a
16// visitor who is not signed in, whom every page shows as "a listener". A
17// page may hide its tab (`visible: false`); a hidden tab is sent to nobody,
18// and is counted only in the total of pages open.
19//
20// Listening along reuses the listening parties (party-room.ts): a page asks
21// the room to listen to a tab (`listen`); the room asks that tab, which
22// starts a party on its song, as "👥 listen together" does, and answers
23// with its room (`invite`); the room passes the room id to the asker only.
24// Party rooms stay the secret they are: nobody else sees one.
25
26export const LOBBY_PATH = '/jev/lobby';
27
28export const MAX_TABS = 1000;
29// A page tells the room when something changes (its song, playing or not,
30// the section), which is a message every few seconds at most; a listen
31// request is a click.
32export const MAX_MESSAGE_BYTES = 2 * 1024;
33export const BURST = 20;
34export const RATE = 1;
35// The most tabs a welcome lists (the most recently changed); the rest
36// arrive as they change.
37export const MAX_LISTED = 200;
38export const MAX_TITLE = 80;
39export const MAX_SECTION = 40;
40
41const SONG = /^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/;
42const LISTENER = /^[A-Za-z0-9_-]{1,64}$/;
43const ROOM = /^[A-Za-z0-9_-]{16,43}$/;
44const TAB = /^[A-Za-z0-9_-]{8,32}$/;
45
46// What a tab is doing, as the room checked it. `song` is a site song's id,
47// `listener` a listener song's (with its `title` as the page showed it);
48// neither is a tab playing its own code. `cycle` was measured at `at`, in
49// the room's clock; `length` is the song's end in cycles once known, `max`
50// the most it can last while Jev has not ended it.
51export type Status = {
52  song: string | null;
53  listener: string | null;
54  title: string | null;
55  edited: boolean;
56  playing: boolean;
57  cycle: number | null;
58  cps: number | null;
59  at: number;
60  length: number | null;
61  max: number | null;
62  section: string | null;
63};
64
65// What a connection remembers across hibernation (serializeAttachment).
66// `group` puts an account's tabs together: derived from the account in
67// lobby.ts, or the tab's own id for someone not signed in.
68export type Attachment = {
69  id: string;
70  name: string | null;
71  // the signed-in account, so its name opens its profile; null otherwise
72  account?: string | null;
73  group: string;
74  visible: boolean;
75  changed: number;
76  status: Status | null;
77  // the tab this one last asked to listen to, until it answers
78  asked?: string | null;
79};
80
81// A tab as other pages see it.
82export type Tab = { id: string; name: string | null; account: string | null; group: string } & Status;
83
84export interface Peer {
85  send(message: string): void;
86  close(code?: number, reason?: string): void;
87  deserializeAttachment(): unknown;
88  serializeAttachment(value: unknown): void;
89}
90
91type Message = Record<string, unknown>;
92const isObject = (x: unknown): x is Message => typeof x === 'object' && x !== null && !Array.isArray(x);
93const num = (x: unknown, min: number, max: number) =>
94  typeof x === 'number' && Number.isFinite(x) && x >= min && x <= max ? x : null;
95// Text another page shows: one line, bounded. Pages render it as text.
96const line = (x: unknown, max: number) =>
97  typeof x === 'string' && x.trim() ? x.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, max) : null;
98
99// A page's status message, checked field by field: anything malformed is
100// left out, never passed on.
101export function statusOf(m: Message, now: number): Status {
102  const song = typeof m.song === 'string' && SONG.test(m.song) ? m.song : null;
103  const listener = !song && typeof m.listener === 'string' && LISTENER.test(m.listener) ? m.listener : null;
104  const playing = m.playing === true;
105  const cps = num(m.cps, 0.01, 100);
106  return {
107    song,
108    listener,
109    title: listener ? line(m.title, MAX_TITLE) : null,
110    edited: Boolean(song) && m.edited === true,
111    playing,
112    cycle: playing ? num(m.cycle, 0, 1e6) : null,
113    cps: playing ? cps : null,
114    at: now,
115    length: num(m.length, 1, 1e5),
116    max: num(m.max, 1, 1e5),
117    section: line(m.section, MAX_SECTION),
118  };
119}
120
121type Bucket = { tokens: number; at: number };
122
123export class Lobby {
124  private buckets = new WeakMap<Peer, Bucket>();
125
126  constructor(
127    // every open connection, the one an event is for included
128    private peers: () => Peer[],
129    private now: () => number = Date.now,
130  ) {}
131
132  private me(peer: Peer): Attachment | null {
133    return (peer.deserializeAttachment() as Attachment | null) ?? null;
134  }
135
136  private tabOf(a: Attachment): Tab | null {
137    if (!a.visible || !a.status) return null;
138    return { id: a.id, name: a.name, account: a.account ?? null, group: a.group, ...a.status };
139  }
140
141  private send(peer: Peer, message: unknown) {
142    try {
143      peer.send(JSON.stringify(message));
144    } catch {
145      // closing already; its close event tidies up
146    }
147  }
148
149  private broadcast(message: unknown, except?: Peer) {
150    const text = JSON.stringify(message);
151    for (const p of this.peers()) {
152      if (p === except) continue;
153      try {
154        p.send(text);
155      } catch {}
156    }
157  }
158
159  private count(except?: Peer) {
160    return { t: 'count', n: this.peers().filter((p) => p !== except).length };
161  }
162
163  // What the data tab says of the lobby (data.ts): how many pages are open,
164  // how many tabs are listed, and how many of those play. Counts only: the
165  // listed tabs themselves are what every page's lobby already shows.
166  summary() {
167    const tabs = this.peers()
168      .map((p) => this.me(p))
169      .map((a) => (a ? this.tabOf(a) : null))
170      .filter((t): t is Tab => t !== null);
171    return { open: this.peers().length, listed: tabs.length, playing: tabs.filter((t) => t.playing).length };
172  }
173
174  // Whether another connection may join: null when it may, else why.
175  admit(): string | null {
176    return this.peers().length >= MAX_TABS ? `the lobby is full (${MAX_TABS})` : null;
177  }
178
179  // A connection has been accepted, its attachment already set.
180  joined(peer: Peer) {
181    const me = this.me(peer)!;
182    const tabs = this.peers()
183      .filter((p) => p !== peer)
184      .map((p) => this.me(p))
185      .filter((a): a is Attachment => Boolean(a))
186      .sort((a, b) => b.changed - a.changed)
187      .map((a) => this.tabOf(a))
188      .filter((t): t is Tab => Boolean(t))
189      .slice(0, MAX_LISTED);
190    this.send(peer, { t: 'welcome', id: me.id, now: this.now(), tabs, n: this.peers().length });
191    this.broadcast(this.count(), peer);
192  }
193
194  left(peer: Peer) {
195    const me = this.me(peer);
196    if (me?.visible && me.status) this.broadcast({ t: 'gone', id: me.id }, peer);
197    this.broadcast(this.count(peer), peer);
198  }
199
200  private allow(peer: Peer): boolean {
201    const now = this.now();
202    const b = this.buckets.get(peer) ?? { tokens: BURST, at: now };
203    b.tokens = Math.min(BURST, b.tokens + ((now - b.at) / 1000) * RATE);
204    b.at = now;
205    this.buckets.set(peer, b);
206    if (b.tokens < 1) return false;
207    b.tokens -= 1;
208    return true;
209  }
210
211  message(peer: Peer, data: string | ArrayBuffer) {
212    const size = typeof data === 'string' ? new TextEncoder().encode(data).length : data.byteLength;
213    if (typeof data !== 'string' || size > MAX_MESSAGE_BYTES) {
214      this.send(peer, { t: 'error', why: `messages are JSON text of at most ${MAX_MESSAGE_BYTES} bytes` });
215      return;
216    }
217    if (!this.allow(peer)) return;
218    let m: unknown;
219    try {
220      m = JSON.parse(data);
221    } catch {
222      return;
223    }
224    const me = this.me(peer);
225    if (!isObject(m) || !me) return;
226
227    switch (m.t) {
228      case 'status': {
229        const status = statusOf(m, this.now());
230        const wasShown = me.visible && me.status;
231        const next: Attachment = { ...me, visible: m.visible !== false, status, changed: this.now() };
232        peer.serializeAttachment(next);
233        const tab = this.tabOf(next);
234        if (tab) this.broadcast({ t: 'tab', now: this.now(), tab }, peer);
235        else if (wasShown) this.broadcast({ t: 'gone', id: me.id }, peer);
236        return;
237      }
238
239      case 'listen': {
240        // ask a visible tab playing a site song to start a party
241        if (typeof m.to !== 'string' || !TAB.test(m.to) || m.to === me.id) return;
242        const target = this.peers().find((p) => this.me(p)?.id === m.to);
243        const tab = target && this.tabOf(this.me(target)!);
244        if (!target || !tab) return this.send(peer, { t: 'error', to: m.to, why: 'that tab is not listed any more' });
245        if (!tab.song || !tab.playing) {
246          return this.send(peer, { t: 'error', to: m.to, why: 'only a site song that is playing can be listened along to' });
247        }
248        peer.serializeAttachment({ ...me, asked: tab.id });
249        this.send(target, { t: 'listen', from: me.id });
250        return;
251      }
252
253      case 'invite': {
254        // a tab's answer to `listen`: its party's room, for the asker only,
255        // and only one who asked this tab (so no tab can send another into
256        // a party it did not ask for)
257        if (typeof m.to !== 'string' || !TAB.test(m.to) || typeof m.room !== 'string' || !ROOM.test(m.room)) return;
258        if (!me.visible) return;
259        const asker = this.peers().find((p) => this.me(p)?.id === m.to);
260        const theirs = asker && this.me(asker);
261        if (!asker || !theirs || theirs.asked !== me.id) return;
262        asker.serializeAttachment({ ...theirs, asked: null });
263        this.send(asker, { t: 'join', from: me.id, room: m.room });
264        return;
265      }
266
267      case 'ping':
268        // the room's clock, for the page's estimate of everyone's progress
269        if (typeof m.c === 'number') this.send(peer, { t: 'pong', c: m.c, s: this.now() });
270        return;
271    }
272  }
273}