The listening party's room (party-room.ts), with fake sockets and an in-memory stand-in for the Durable Object's storage.
6function memoryStorage(): Storage & { map: Map<string, unknown>; confirmed: number } { 7 const map = new Map<string, unknown>(); 8 const store = { 9 map, 10 // writes that would hold the room's broadcasts until on disk 11 confirmed: 0, 12 async get<T>(key: string) { 13 return structuredClone(map.get(key)) as T | undefined; 14 }, 15 async put(entries: Record<string, unknown>, options?: { allowUnconfirmed?: boolean }) { 16 if (!options?.allowUnconfirmed) store.confirmed++; 17 for (const [k, v] of Object.entries(entries)) map.set(k, structuredClone(v)); 18 }, 19 async delete(keys: string[], options?: { allowUnconfirmed?: boolean }) { 20 if (!options?.allowUnconfirmed) store.confirmed++; 21 let n = 0; 22 for (const k of keys) n += map.delete(k) ? 1 : 0; 23 return n; 24 }, 25 async list<T>({ prefix }: { prefix: string }) { 26 const keys = [...map.keys()].filter((k) => k.startsWith(prefix)).sort(); 27 return new Map(keys.map((k) => [k, structuredClone(map.get(k)) as T])); 28 }, 29 async deleteAll(options?: { allowUnconfirmed?: boolean }) { 30 if (!options?.allowUnconfirmed) store.confirmed++; 31 map.clear(); 32 }, 33 }; 34 return store; 35} 36 37type Fake = Peer & { got: any[]; closed?: [number | undefined, string | undefined] }; 38const fake = (role: 'host' | 'guest'): Fake => { 39 const got: any[] = []; 40 return { 41 got, 42 send: (m: string) => void got.push(JSON.parse(m)), 43 close(code, reason) { 44 this.closed = [code, reason]; 45 }, 46 deserializeAttachment: () => ({ role, id: role }), 47 }; 48}; 49 50const HOST_KEY = 'host-key-0123456789abcdef'; 51const OWN_ROOM = 'the-own-room-0123456789'; 52 53function setup() { 54 const storage = memoryStorage(); 55 const peers: Fake[] = []; 56 let now = 1_000_000; 57 // what the room tells the party directory (party-directory.ts) 58 const reports: unknown[] = []; 59 // this room's own name, as party.ts proves it (idFromName(name) is this object) 60 const room = new Room(storage, () => peers, () => now, (live) => reports.push(live), (name) => name === OWN_ROOM); 61 const join = async (role: 'host' | 'guest', key: string | null = role === 'host' ? HOST_KEY : null) => { 62 const refused = await admit(storage, peers, { role, key }); 63 if (refused) return refused; 64 const p = fake(role); 65 peers.push(p); 66 await room.joined(p, role, key); 67 return p; 68 }; 69 const leave = async (p: Fake) => { 70 await room.left(p); 71 peers.splice(peers.indexOf(p), 1); 72 }; 73 const say = (p: Fake, m: unknown) => room.message(p, JSON.stringify(m)); 74 return { storage, peers, room, join, leave, say, reports, tick: (ms: number) => (now += ms) }; 75} 76 77const last = (p: Fake, t: string) => [...p.got].reverse().find((m) => m.t === t); 78 79describe('admit: who may join a room', () => { 80 it('lets a host start a party and refuses a guest where there is none', async () => { 81 const { join } = setup(); 82 expect(await join('guest')).toEqual({ status: 404, why: expect.stringMatching(/no party/) }); 83 const host = (await join('host')) as Fake; 84 expect(last(host, 'welcome')).toMatchObject({ role: 'host', people: 1, host: true, run: null }); 85 expect(await join('guest')).toHaveProperty('got'); 86 }); 87 88 it('keeps the party its first host’s: another key is refused, the same key reconnects', async () => { 89 const { join } = setup(); 90 const host = (await join('host')) as Fake; 91 await join('guest'); 92 expect(await join('host', 'someone-else-0123456789')).toEqual({ status: 403, why: expect.any(String) }); 93 expect(await join('host', null)).toEqual({ status: 400, why: expect.any(String) }); 94 const again = (await join('host')) as Fake; 95 expect(last(again, 'welcome').role).toBe('host'); 96 expect(host.closed?.[0]).toBe(4000); // the old connection is replaced 97 expect(await join('bystander' as never)).toEqual({ status: 400, why: expect.any(String) }); 98 }); 99 100 it(`holds at most ${MAX_PEOPLE}`, async () => { 101 const { join } = setup(); 102 await join('host'); 103 for (let i = 1; i < MAX_PEOPLE; i++) expect(await join('guest')).toHaveProperty('got'); 104 expect(await join('guest')).toEqual({ status: 503, why: expect.stringMatching(/full/) }); 105 }); 106}); 107 108describe('Room: one performance, heard the same by everyone', () => { 109 const decision = { status: 'answered', values: { section: { type: 'choice', choice: 'drop' } } }; 110 111 it('passes the host’s run, clock and decisions to guests, and keeps them for a late joiner', async () => { 112 const { join, say } = setup(); 113 const host = (await join('host')) as Fake; 114 const early = (await join('guest')) as Fake; 115 expect(last(host, 'people')).toEqual({ t: 'people', n: 2, host: true }); 116 117 await say(host, { t: 'run', song: 'jev/lightning-in-a-bottle', code: 'abc' }); 118 const run = last(host, 'run').run; 119 expect(last(early, 'run')).toEqual({ t: 'run', run, song: 'jev/lightning-in-a-bottle', code: 'abc' }); 120 await say(host, { t: 'decision', run, view: 0, segment: 0, d: decision }); 121 await say(host, { t: 'clock', run, startedAt: 5000, cps: 0.75 }); 122 await say(host, { t: 'decision', run, view: 1, segment: 1, d: decision }); 123 expect(early.got.filter((m) => m.t === 'decision').map((m) => [m.view, m.segment])).toEqual([ 124 [0, 0], 125 [1, 1], 126 ]); 127 expect(last(early, 'clock')).toEqual({ t: 'clock', run, startedAt: 5000, cps: 0.75 }); 128 129 const late = (await join('guest')) as Fake; 130 const welcome = last(late, 'welcome'); 131 expect(welcome).toMatchObject({ run: { run, song: 'jev/lightning-in-a-bottle' }, clock: { startedAt: 5000 } }); 132 expect(welcome.decisions).toEqual([ 133 { run, view: 0, segment: 0, d: decision }, 134 { run, view: 1, segment: 1, d: decision }, 135 ]); 136 // the host never hears its own messages back, except the run's number 137 expect(host.got.filter((m) => m.t === 'decision')).toEqual([]); 138 }); 139 140 it('accepts a performance only from the host, and only for the current run', async () => { 141 const { join, say } = setup(); 142 const host = (await join('host')) as Fake; 143 const guest = (await join('guest')) as Fake; 144 await say(guest, { t: 'run', song: 'jev/x', code: '' }); 145 expect(last(host, 'run')).toBeUndefined(); 146 await say(host, { t: 'run', song: 'jev/x', code: '' }); 147 const run = last(host, 'run').run; 148 await say(guest, { t: 'decision', run, view: 0, segment: 0, d: decision }); 149 await say(host, { t: 'decision', run: run + 1, view: 0, segment: 0, d: decision }); 150 await say(host, { t: 'decision', run, view: 99, segment: 0, d: decision }); 151 await say(host, { t: 'decision', run, view: 0, segment: 1000, d: decision }); 152 await say(host, { t: 'run', song: 'not a song', code: '' }); 153 expect(guest.got.filter((m) => m.t === 'decision' || (m.t === 'run' && m.song !== 'jev/x'))).toEqual([]); 154 }); 155 156 it('starts each performance clean: a new run drops the last one’s decisions, clock and reactions', async () => { 157 const { join, say, storage } = setup(); 158 const host = (await join('host')) as Fake; 159 await say(host, { t: 'run', song: 'jev/a', code: '' }); 160 let run = last(host, 'run').run; 161 await say(host, { t: 'decision', run, view: 0, segment: 0, d: decision }); 162 await say(host, { t: 'clock', run, startedAt: 1, cps: 1 }); 163 await say(host, { t: 'react', segment: 0, kind: 'fire' }); 164 await say(host, { t: 'run', song: 'jev/b', code: '' }); 165 run = last(host, 'run').run; 166 expect(run).toBe(2); 167 expect([...storage.map.keys()].sort()).toEqual(['host', 'run']); 168 }); 169 170 it('counts reactions from everyone and tells everyone the totals', async () => { 171 const { join, say } = setup(); 172 const host = (await join('host')) as Fake; 173 const a = (await join('guest')) as Fake; 174 const b = (await join('guest')) as Fake; 175 await say(a, { t: 'react', segment: 2, kind: 'fire' }); 176 expect(last(host, 'reactions')).toBeUndefined(); // nothing is playing yet 177 await say(host, { t: 'run', song: 'jev/a', code: '' }); 178 await say(a, { t: 'react', segment: 2, kind: 'fire' }); 179 await say(b, { t: 'react', segment: 2, kind: 'fire' }); 180 await say(b, { t: 'react', segment: 2, kind: 'sleep' }); 181 await say(b, { t: 'react', segment: 2, kind: 'boo' }); 182 for (const p of [host, a, b]) expect(last(p, 'reactions')).toEqual({ t: 'reactions', segment: 2, fire: 2, sleep: 1 }); 183 const c = (await join('guest')) as Fake; 184 expect(last(c, 'welcome').reactions).toEqual({ 2: { fire: 2, sleep: 1 } }); 185 }); 186 187 it('answers a ping with the room’s clock', async () => { 188 const { join, say } = setup(); 189 const host = (await join('host')) as Fake; 190 await say(host, { t: 'ping', c: 42 }); 191 expect(last(host, 'pong')).toEqual({ t: 'pong', c: 42, s: 1_000_000 }); 192 }); 193 194 it('tells guests when the host stops, and when the host leaves and returns', async () => { 195 const { join, leave, say } = setup(); 196 const host = (await join('host')) as Fake; 197 const guest = (await join('guest')) as Fake; 198 await say(host, { t: 'run', song: 'jev/a', code: '' }); 199 const run = last(host, 'run').run; 200 await say(host, { t: 'clock', run, startedAt: 1, cps: 1 }); 201 await say(host, { t: 'end', run }); 202 expect(last(guest, 'end')).toEqual({ t: 'end', run }); 203 await leave(host); 204 expect(last(guest, 'people')).toEqual({ t: 'people', n: 1, host: false }); 205 const back = (await join('host')) as Fake; 206 expect(last(back, 'welcome')).toMatchObject({ run: { run }, clock: null }); 207 expect(last(guest, 'people')).toEqual({ t: 'people', n: 2, host: true }); 208 }); 209 210 it('never holds a broadcast for a write to reach disk', async () => { 211 const { join, leave, say, storage } = setup(); 212 const host = (await join('host')) as Fake; 213 const guest = (await join('guest')) as Fake; 214 await say(host, { t: 'run', song: 'jev/a', code: '' }); 215 const run = last(host, 'run').run; 216 await say(host, { t: 'clock', run, startedAt: 1, cps: 1 }); 217 await say(host, { t: 'decision', run, view: 0, segment: 0, d: { status: 'answered' } }); 218 await say(guest, { t: 'react', segment: 0, kind: 'fire' }); 219 await say(host, { t: 'end', run }); 220 await leave(guest); 221 await leave(host); 222 expect(storage.confirmed).toBe(0); 223 }); 224 225 it('forgets the party when the last person leaves', async () => { 226 const { join, leave, say, storage } = setup(); 227 const host = (await join('host')) as Fake; 228 const guest = (await join('guest')) as Fake; 229 await say(host, { t: 'run', song: 'jev/a', code: '' }); 230 await leave(host); 231 expect(storage.map.size).toBeGreaterThan(0); 232 await leave(guest); 233 expect(storage.map.size).toBe(0); 234 // and a room found holding a stale party with nobody in it starts clean 235 storage.map.set('host', 'stale'); 236 expect(await join('host', 'a-new-host-0123456789')).toHaveProperty('got'); 237 }); 238}); 239 240describe('Room: what it tells the party directory', () => { 241 it('reports its people and song as they change, and nothing once empty', async () => { 242 const { join, leave, say, reports } = setup(); 243 const host = (await join('host')) as Fake; 244 expect(reports.at(-1)).toEqual({ people: 1, host: true, song: null, listed: false }); 245 const guest = (await join('guest')) as Fake; 246 expect(reports.at(-1)).toEqual({ people: 2, host: true, song: null, listed: false }); 247 await say(host, { t: 'run', song: 'jev/a', code: '' }); 248 expect(reports.at(-1)).toEqual({ people: 2, host: true, song: 'jev/a', listed: false }); 249 await leave(host); 250 expect(reports.at(-1)).toEqual({ people: 1, host: false, song: 'jev/a', listed: false }); 251 await leave(guest); 252 expect(reports.at(-1)).toBeNull(); 253 // never the room's name or its host's key 254 expect(JSON.stringify(reports)).not.toContain(HOST_KEY); 255 }); 256}); 257 258describe('Room: a listed party', () => { 259 it('says it is listed when its host chose to be (party.ts stores the choice)', async () => { 260 const { join, reports, storage } = setup(); 261 await join('host'); 262 storage.map.set('listed', true); 263 await join('guest'); 264 expect(reports.at(-1)).toEqual({ people: 2, host: true, song: null, listed: true }); 265 }); 266 267 it('leaves and rejoins the list at once when its host changes "show me here" mid-party, guests staying in', async () => { 268 const { join, say, reports, storage } = setup(); 269 const host = (await join('host')) as Fake; 270 const guest = (await join('guest')) as Fake; 271 storage.map.set('listed', true); 272 storage.map.set('name', OWN_ROOM); 273 await say(host, { t: 'listed', listed: false }); 274 expect(reports.at(-1)).toEqual({ people: 2, host: true, song: null, listed: false }); 275 expect(storage.map.has('name')).toBe(false); 276 expect(guest.closed).toBeUndefined(); 277 await say(host, { t: 'listed', listed: true, room: OWN_ROOM }); 278 expect(reports.at(-1)).toEqual({ people: 2, host: true, song: null, listed: true }); 279 expect(storage.map.get('name')).toBe(OWN_ROOM); 280 // the report never carries the name 281 expect(JSON.stringify(reports)).not.toContain(OWN_ROOM); 282 }); 283 284 it("takes a name only when it is the room's own, and the choice only from the host", async () => { 285 const { join, say, reports, storage } = setup(); 286 const host = (await join('host')) as Fake; 287 const guest = (await join('guest')) as Fake; 288 const before = reports.length; 289 await say(host, { t: 'listed', listed: true, room: 'someone-elses-room-0123' }); 290 await say(host, { t: 'listed', listed: true }); 291 await say(guest, { t: 'listed', listed: true, room: OWN_ROOM }); 292 await say(host, { t: 'listed', listed: 'yes', room: OWN_ROOM }); 293 expect(reports.length).toBe(before); 294 expect(storage.map.has('name')).toBe(false); 295 expect(storage.map.get('listed')).toBeUndefined(); 296 }); 297}); 298 299describe('Room: limits', () => { 300 it(`refuses a message over ${MAX_MESSAGE_BYTES} bytes, and binary`, async () => { 301 const { join, room } = setup(); 302 const host = (await join('host')) as Fake; 303 await room.message(host, JSON.stringify({ t: 'ping', c: 1, pad: 'x'.repeat(MAX_MESSAGE_BYTES) })); 304 expect(last(host, 'pong')).toBeUndefined(); 305 expect(last(host, 'error').why).toMatch(/at most/); 306 await room.message(host, new ArrayBuffer(8)); 307 expect(host.got.filter((m) => m.t === 'error')).toHaveLength(2); 308 }); 309 310 it(`drops messages past a burst of ${BURST}, warns once, and refills with time`, async () => { 311 const { join, say, tick } = setup(); 312 const host = (await join('host')) as Fake; 313 for (let i = 0; i < BURST + 10; i++) await say(host, { t: 'ping', c: i }); 314 expect(host.got.filter((m) => m.t === 'pong')).toHaveLength(BURST); 315 expect(host.got.filter((m) => m.t === 'error')).toHaveLength(1); 316 tick(1000); 317 await say(host, { t: 'ping', c: 999 }); 318 expect(last(host, 'pong').c).toBe(999); 319 }); 320}); 321 322describe('party: the route into a room', () => { 323 const ROOM = 'Zm9vYmFyYmF6cXV4MTIzNA'; 324 const env = (success = true) => { 325 const reached: string[] = []; 326 return { 327 reached, 328 PARTY_LIMIT: { limit: async () => ({ success }) }, 329 PARTY: { 330 idFromName: (name: string) => name, 331 get: (id: string) => ({ fetch: async () => (reached.push(id), new Response('room')) }), 332 }, 333 }; 334 }; 335 const ws = (path: string, upgrade = true) => 336 new Request(`https://jevstrudel.example${path}`, { headers: upgrade ? { Upgrade: 'websocket' } : {} }); 337 338 it('reaches the room named in the path, one Durable Object per room', async () => { 339 const e = env(); 340 expect(await (await party(ws(`/jev/party/${ROOM}?role=guest`), e as never)).text()).toBe('room'); 341 expect(e.reached).toEqual([ROOM]); 342 }); 343 344 it('refuses a malformed room, a plain request, and a visitor over the limit', async () => { 345 expect((await party(ws('/jev/party/short'), env() as never)).status).toBe(404); 346 expect((await party(ws('/jev/party/../../x'), env() as never)).status).toBe(404); 347 expect((await party(ws(`/jev/party/${ROOM}`, false), env() as never)).status).toBe(426); 348 expect((await party(ws(`/jev/party/${ROOM}`), env(false) as never)).status).toBe(429); 349 }); 350});