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.
The most tabs a welcome lists (the most recently changed); the rest arrive as they change.
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.
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.
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.
Whether another connection may join: null when it may, else why.
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}