1// The lobby: GET /jev/lobby upgrades to a WebSocket into the site's one 2// Lobby Durable Object (`idFromName('lobby')`). The rules are in 3// lobby-room.ts; this is the route that says who the tab is, and the shell 4// that owns the sockets. 5// 6// It uses the Hibernation API, like party.ts: while nothing changes the 7// object is evicted and every socket stays open. Each tab's status is in 8// its socket's attachment, so nothing is lost; only the rate-limit buckets 9// live in memory, and forgetting them only refills them. 10import { DurableObject } from 'cloudflare:workers'; 11import { d1Accounts, type Accounts } from './accounts-store'; 12import { signedIn } from './auth'; 13import type { Env } from './env'; 14import { Lobby as Room, LOBBY_PATH, type Attachment, type Peer } from './lobby-room'; 15import { randomId } from './session'; 16 17export { LOBBY_PATH } from './lobby-room'; 18 19// The header the route sets and the object reads: who the tab is. The 20// object is reached only through this route, which always sets it. 21const WHO = 'X-Jev-Lobby-Who'; 22export type Who = { name: string | null; account: string | null; group: string | null }; 23 24// Tabs of one account are listed together, under a key that does not 25// reveal the account's id. 26export async function groupOf(userId: string): Promise<string> { 27 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`lobby:${userId}`)); 28 return [...new Uint8Array(digest).slice(0, 8)].map((b) => b.toString(16).padStart(2, '0')).join(''); 29} 30 31// The per-visitor limit on joining is LOBBY_LIMIT (wrangler.json): a page 32// joins once, and again only when its network drops. 33export async function lobby( 34 request: Request, 35 env: Pick<Env, 'LOBBY' | 'LOBBY_LIMIT' | 'DB'>, 36 accounts: Accounts = d1Accounts(env.DB), 37): Promise<Response> { 38 if (new URL(request.url).pathname !== LOBBY_PATH) return new Response('not found', { status: 404 }); 39 if (request.headers.get('Upgrade') !== 'websocket') return new Response('WebSocket only', { status: 426 }); 40 const visitor = request.headers.get('CF-Connecting-IP') ?? 'local'; 41 if (!(await env.LOBBY_LIMIT.limit({ key: visitor })).success) { 42 return new Response('too many joins; try again in a minute', { status: 429 }); 43 } 44 // not signed in, or the session cannot be read: "a listener" 45 const user = await signedIn(request, accounts).catch(() => null); 46 const who: Who = user 47 ? { name: user.displayName, account: user.id, group: await groupOf(user.id) } 48 : { name: null, account: null, group: null }; 49 const headers = new Headers(request.headers); 50 headers.set(WHO, JSON.stringify(who)); 51 return env.LOBBY.get(env.LOBBY.idFromName('lobby')).fetch(new Request(request, { headers })); 52} 53 54export class LobbyRoom extends DurableObject<Env> { 55 private room: Room; 56 57 constructor(ctx: DurableObjectState, env: Env) { 58 super(ctx, env); 59 this.room = new Room(() => ctx.getWebSockets() as unknown as Peer[]); 60 } 61 62 async fetch(request: Request): Promise<Response> { 63 const [client, server] = Object.values(new WebSocketPair()); 64 const full = this.room.admit(); 65 if (full) { 66 // as party.ts: say why, and close with a code the page does not retry 67 server.accept(); 68 server.send(JSON.stringify({ t: 'refused', why: full })); 69 server.close(4503, full); 70 return new Response(null, { status: 101, webSocket: client }); 71 } 72 let who: Who = { name: null, account: null, group: null }; 73 try { 74 who = JSON.parse(request.headers.get(WHO) ?? '') as Who; 75 } catch {} 76 this.ctx.acceptWebSocket(server); 77 const id = randomId(12); 78 const attachment: Attachment = { 79 id, 80 name: typeof who.name === 'string' ? who.name : null, 81 account: typeof who.account === 'string' && /^[A-Za-z0-9_-]{22}$/.test(who.account) ? who.account : null, 82 group: typeof who.group === 'string' ? who.group : id, 83 visible: false, 84 changed: Date.now(), 85 status: null, 86 }; 87 server.serializeAttachment(attachment); 88 this.room.joined(server as unknown as Peer); 89 return new Response(null, { status: 101, webSocket: client }); 90 } 91 92 async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { 93 this.room.message(ws as unknown as Peer, message); 94 } 95 96 async webSocketClose(ws: WebSocket) { 97 this.room.left(ws as unknown as Peer); 98 } 99 100 async webSocketError(ws: WebSocket) { 101 this.room.left(ws as unknown as Peer); 102 } 103 104 // Counts for the data tab (data.ts); reads the sockets, stores nothing. 105 summary() { 106 return this.room.summary(); 107 } 108}