Listening parties: GET /jev/party/<room> upgrades to a WebSocket into that
room's Durable Object (one per room, idFromName(room)). The room's rules
are in party-room.ts; this is the shell that owns the sockets.
It uses the Hibernation API (ctx.acceptWebSocket and the webSocket* handlers), so a room whose song is between sections is evicted from memory while every socket stays open, and costs no duration. What must survive that is in the room's storage (the current performance) and in each socket's attachment (its role); only the rate-limit buckets live in memory, and forgetting them only refills them.
Each room tells the site's one PartyDirectory (below) when its people or song change, so the data tab can say which parties are live without listing rooms, which cannot be listed (party-directory.ts).
20export { party, PARTY_PREFIX } from './party-room';
The directory's one object.
26export class Party extends DurableObject<Env> { 27 private room: Room; 28 29 constructor(ctx: DurableObjectState, env: Env) { 30 super(ctx, env); 31 // the report never delays the room, and a directory that fails only 32 // leaves the data tab a party short 33 const report = (l: Live | null) => 34 ctx.waitUntil( 35 directory(env) 36 .note(ctx.id.toString(), l) 37 .catch((e: unknown) => console.error({ event: 'jev.party.directory', error: String(e) })), 38 ); 39 this.room = new Room( 40 ctx.storage as unknown as Storage, 41 () => ctx.getWebSockets() as unknown as Peer[], 42 Date.now, 43 report, 44 // a name is this room's when it names this object; never read from a 45 // page as it is 46 (name) => env.PARTY.idFromName(name).equals(ctx.id), 47 ); 48 } 49 50 async fetch(request: Request): Promise<Response> { 51 const params = new URL(request.url).searchParams; 52 const role = params.get('role'); 53 const key = params.get('key'); 54 const refused = await admit(this.ctx.storage as unknown as Storage, this.ctx.getWebSockets() as unknown as Peer[], { 55 role, 56 key, 57 }); 58 const [client, server] = Object.values(new WebSocketPair()); 59 if (refused) { 60 // A browser cannot read why a WebSocket upgrade was refused, and would 61 // retry forever: accept it outside the room, say why, and close with 62 // 4000 + the status (4404: no party here), which the page does not 63 // retry. Nothing of the room sees it. 64 server.accept(); 65 server.send(JSON.stringify({ t: 'refused', status: refused.status, why: refused.why })); 66 server.close(4000 + refused.status, refused.why); 67 return new Response(null, { status: 101, webSocket: client }); 68 } 69 70 if (role === 'host') { 71 // listed or not is the host's choice ("show me here"), made as it 72 // connects and changed mid-party by its `listed` message (the room's 73 // rules); a listed room keeps its name to give someone joining from 74 // the list (listedRoom) 75 const name = request.headers.get(ROOM_HEADER); 76 if (params.get('listed') === '1' && name && ROOM.test(name)) { 77 await this.ctx.storage.put({ listed: true, name }, { allowUnconfirmed: true }); 78 } else { 79 await this.ctx.storage.put({ listed: false }, { allowUnconfirmed: true }); 80 await this.ctx.storage.delete('name', { allowUnconfirmed: true }); 81 } 82 } 83 this.ctx.acceptWebSocket(server); 84 const attachment: Attachment = { role: role as Attachment['role'], id: crypto.randomUUID() }; 85 server.serializeAttachment(attachment); 86 await this.room.joined(server as unknown as Peer, attachment.role, key); 87 return new Response(null, { status: 101, webSocket: client }); 88 } 89 90 async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { 91 await this.room.message(ws as unknown as Peer, message); 92 }
The room's name, to join it from the list: only while the party is on and its host chose to be listed.
Which parties are live: one object for the site (idFromName('parties')),
its rules in party-directory.ts. It holds no room's name; a listed
room's entry has its object id, which parties below turns into the room
to join by asking the room.
116export class PartyDirectory extends DurableObject<Env> { 117 async note(room: string, l: Live | null): Promise<void> { 118 await note(this.ctx.storage as unknown as DirectoryStorage, room, l, Date.now()); 119 } 120 121 async live() { 122 return live(this.ctx.storage as unknown as DirectoryStorage, Date.now()); 123 } 124}
GET /jev/parties: the live parties, for the listening tab; a listed one
with its id. GET /jev/parties/<id>: that party's room to join
({ room }), or 404 once it is over or not listed. Per visitor
PARTY_LIMIT, as joining a party is.
130export const PARTIES_PATH = '/jev/parties'; 131const OBJECT_ID = /^[0-9a-f]{64}$/; 132export async function parties( 133 request: Request, 134 env: Pick<Env, 'PARTY' | 'PARTY_DIRECTORY' | 'PARTY_LIMIT'>, 135): Promise<Response> { 136 const answer = (status: number, body: unknown) => 137 Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } }); 138 if (request.method !== 'GET') return answer(405, { error: 'GET only' }); 139 const visitor = request.headers.get('CF-Connecting-IP') ?? 'local'; 140 if (!(await env.PARTY_LIMIT.limit({ key: visitor })).success) { 141 return answer(429, { error: 'too many requests; try again in a minute' }); 142 } 143 const rest = new URL(request.url).pathname.slice(PARTIES_PATH.length); 144 if (rest === '' || rest === '/') return answer(200, { parties: await directory(env).live() }); 145 const id = rest.slice(1); 146 if (!OBJECT_ID.test(id)) return answer(404, { error: 'no such party' }); 147 let stub; 148 try { 149 stub = env.PARTY.get(env.PARTY.idFromString(id)); 150 } catch { 151 return answer(404, { error: 'no such party' }); 152 } 153 const room = await stub.listedRoom(); 154 return room ? answer(200, { room }) : answer(404, { error: 'this party is over, or not listed' }); 155}