1// Listening parties: GET /jev/party/<room> upgrades to a WebSocket into that 2// room's Durable Object (one per room, `idFromName(room)`). The room's rules 3// are in party-room.ts; this is the shell that owns the sockets. 4// 5// It uses the Hibernation API (ctx.acceptWebSocket and the webSocket* 6// handlers), so a room whose song is between sections is evicted from 7// memory while every socket stays open, and costs no duration. What must 8// survive that is in the room's storage (the current performance) and in 9// each socket's attachment (its role); only the rate-limit buckets live in 10// memory, and forgetting them only refills them. 11// 12// Each room tells the site's one PartyDirectory (below) when its people or 13// song change, so the data tab can say which parties are live without 14// listing rooms, which cannot be listed (party-directory.ts). 15import { DurableObject } from 'cloudflare:workers'; 16import type { Env } from './env'; 17import { live, note, type Live, type Storage as DirectoryStorage } from './party-directory'; 18import { admit, ROOM, ROOM_HEADER, Room, type Attachment, type Peer, type Storage } from './party-room'; 19 20export { party, PARTY_PREFIX } from './party-room'; 21 22// The directory's one object. 23export const directory = (env: Pick<Env, 'PARTY_DIRECTORY'>) => 24 env.PARTY_DIRECTORY.get(env.PARTY_DIRECTORY.idFromName('parties')); 25 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 } 93 94 // The room's name, to join it from the list: only while the party is on 95 // and its host chose to be listed. 96 async listedRoom(): Promise<string | null> { 97 if (!this.ctx.getWebSockets().length) return null; 98 if ((await this.ctx.storage.get<boolean>('listed')) !== true) return null; 99 return (await this.ctx.storage.get<string>('name')) ?? null; 100 } 101 102 async webSocketClose(ws: WebSocket) { 103 // the runtime answers the close frame itself (web_socket_auto_reply_to_close) 104 await this.room.left(ws as unknown as Peer); 105 } 106 107 async webSocketError(ws: WebSocket) { 108 await this.room.left(ws as unknown as Peer); 109 } 110} 111 112// Which parties are live: one object for the site (`idFromName('parties')`), 113// its rules in party-directory.ts. It holds no room's name; a listed 114// room's entry has its object id, which `parties` below turns into the room 115// 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} 125 126// GET /jev/parties: the live parties, for the listening tab; a listed one 127// with its `id`. GET /jev/parties/<id>: that party's room to join 128// ({ room }), or 404 once it is over or not listed. Per visitor 129// 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}