jevstrudel.git / worker / src / lobby-room.ts

Who is listening now: the lobby, one room for the whole site. Every open page joins it (website/src/jev/lobby.mjs) and says what its tab is doing: which song, playing or not, where it is in it. The room tells every page about every other visible tab, so the jev tab can list who is listening to what, with each song's progress, and a way to listen along.

The Durable Object in lobby.ts is a thin shell around this, as party.ts is around party-room.ts: it owns the WebSockets (the Hibernation API) and hands each event here, so these rules are plain code the tests run in Node. A tab's status lives in its socket's attachment, so the room keeps no storage at all: a tab that leaves takes its status with it.

Who a tab is comes from the Worker, never the page: the route (lobby.ts) reads the session and passes the account's display name and id (so the name opens its profile; accounts are public, data.ts), or none for a visitor who is not signed in, whom every page shows as "a listener". A page may hide its tab (visible: false); a hidden tab is sent to nobody, and is counted only in the total of pages open.

Listening along reuses the listening parties (party-room.ts): a page asks the room to listen to a tab (listen); the room asks that tab, which starts a party on its song, as "👥 listen together" does, and answers with its room (invite); the room passes the room id to the asker only. Party rooms stay the secret they are: nobody else sees one.

26export const LOBBY_PATH = '/jev/lobby';
28export const MAX_TABS = 1000;

A page tells the room when something changes (its song, playing or not, the section), which is a message every few seconds at most; a listen request is a click.

32export const MAX_MESSAGE_BYTES = 2 * 1024;
33export const BURST = 20;
34export const RATE = 1;

The most tabs a welcome lists (the most recently changed); the rest arrive as they change.

37export const MAX_LISTED = 200;
38export const MAX_TITLE = 80;
39export const MAX_SECTION = 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}$/;

What a tab is doing, as the room checked it. song is a site song's id, listener a listener song's (with its title as the page showed it); neither is a tab playing its own code. cycle was measured at at, in the room's clock; length is the song's end in cycles once known, max 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};

What a connection remembers across hibernation (serializeAttachment). group puts an account's tabs together: derived from the account in 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};

A tab as other pages see it.

82export type Tab = { id: string; name: string | null; account: string | null; group: string } & Status;
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;

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;

A page's status message, checked field by field: anything malformed is 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}
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  }

What the data tab says of the lobby (data.ts): how many pages are open, how many tabs are listed, and how many of those play. Counts only: the 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  }

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  }

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  }
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}