The lobby: GET /jev/lobby upgrades to a WebSocket into the site's one
Lobby Durable Object (idFromName('lobby')). The rules are in
lobby-room.ts; this is the route that says who the tab is, and the shell
that owns the sockets.
It uses the Hibernation API, like party.ts: while nothing changes the object is evicted and every socket stays open. Each tab's status is in its socket's attachment, so nothing is lost; only the rate-limit buckets 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';
17export { LOBBY_PATH } from './lobby-room';
The header the route sets and the object reads: who the tab is. The object is reached only through this route, which always sets it.
Tabs of one account are listed together, under a key that does not reveal the account's id.
The per-visitor limit on joining is LOBBY_LIMIT (wrangler.json): a page 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}
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 }