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}