import {PairingRoom, ROOM_TTL, token, validToken} from './room.js'; import {Leaderboard, leaderboardJson, leaderboardRequest, leaderboardFailure, readSubmission, durableLeaderboardStorage} from './leaderboard.js'; const json = (body, status = 200) => Response.json(body, {status, headers: {'Cache-Control': 'no-store'}}); const socketAdapter = socket => ({ send: value => socket.send(value), close: (code, reason) => socket.close(code, reason), meta: () => socket.deserializeAttachment(), attach: value => socket.serializeAttachment(value), }); export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === '/api/leaderboard' || url.pathname.startsWith('/api/leaderboard/')) { if (url.pathname !== '/api/leaderboard') return leaderboardJson({error: 'Leaderboard not found.'}, 404); if (!['GET', 'POST'].includes(request.method)) return leaderboardJson({error: 'Use GET or POST.'}, 405, {Allow: 'GET, POST'}); try { let forwarded = request; if (request.method === 'POST') { if (request.headers.get('Origin') !== url.origin) return leaderboardJson({error: 'Submit scores from the game screen.'}, 403); // Finish reading the bounded body before forwarding. Otherwise an early // Object rejection can leave Workers piping a consumed request stream. const value = await readSubmission(request), headers = new Headers(request.headers); headers.delete('Content-Length'); forwarded = new Request(request.url, {method: 'POST', headers, body: JSON.stringify(value)}); } return await env.LEADERBOARD.get(env.LEADERBOARD.idFromName('global-v1')).fetch(forwarded); } catch (error) { return leaderboardFailure(error); } } if (!url.pathname.startsWith('/api/controller')) return env.ASSETS.fetch(request); if (request.headers.get('Origin') !== url.origin) return json({error: 'Open pairing from the game screen.'}, 403); if (url.pathname === '/api/controller' && request.method === 'POST') { const id = token(), config = {hostKey: token(), joinKey: token(), expires: Date.now() + ROOM_TTL}; const room = env.CONTROLLER_ROOMS.get(env.CONTROLLER_ROOMS.idFromName(id)); await room.fetch(new Request('https://room/init', {method: 'POST', body: JSON.stringify(config)})); return json({id, ...config}); } const id = url.pathname.match(/^\/api\/controller\/([a-f0-9]{32})$/)?.[1]; if (!validToken(id) || request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') return json({error: 'Pairing not found.'}, 404); return env.CONTROLLER_ROOMS.get(env.CONTROLLER_ROOMS.idFromName(id)).fetch(request); }, }; // Independent of controller rooms: leaderboard storage has no pairing TTL. export class LeaderboardStore { constructor(ctx) { this.board = new Leaderboard(durableLeaderboardStorage(ctx.storage)); } fetch(request) { return leaderboardRequest(request, this.board, request.headers.get('CF-Connecting-IP') ?? 'unknown'); } } export class ControllerRoom { constructor(ctx) { this.ctx = ctx; } async fetch(request) { if (new URL(request.url).pathname === '/init') { if (await this.ctx.storage.get('config')) return json({error: 'Already paired'}, 409); const config = await request.json(); await this.ctx.storage.put('config', config); await this.ctx.storage.setAlarm(Date.now() + 10000); return json({ok: true}); } const config = await this.ctx.storage.get('config'); if (!config || Date.now() >= config.expires) return json({error: 'Pairing expired.'}, 410); if (this.ctx.getWebSockets().length >= 4) return json({error: 'Pairing busy.'}, 409); const [client, server] = Object.values(new WebSocketPair()); server.serializeAttachment({role: null, created: Date.now()}); this.ctx.acceptWebSocket(server); await this.ctx.storage.setAlarm(Date.now() + 10000); return new Response(null, {status: 101, webSocket: client}); } async room(exclude) { const config = await this.ctx.storage.get('config'); const sockets = this.ctx.getWebSockets().filter(socket => socket !== exclude).map(socketAdapter); return new PairingRoom(config ?? {expires: 0}, sockets); } async webSocketMessage(socket, message) { (await this.room()).receive(socketAdapter(socket), message); } async webSocketClose(socket) { (await this.room(socket)).closed(socketAdapter(socket)); } async webSocketError(socket) { socket.close(1011, 'Connection lost'); await this.webSocketClose(socket); } async alarm() { const config = await this.ctx.storage.get('config'); if (!config || Date.now() >= config.expires) { for (const socket of this.ctx.getWebSockets()) socket.close(1000, 'Pairing expired'); await this.ctx.storage.deleteAll(); return; } for (const socket of this.ctx.getWebSockets()) { const meta = socket.deserializeAttachment(); if (!meta.role && Date.now() - meta.created >= 10000) socket.close(1008, 'Pairing timed out'); } await this.ctx.storage.setAlarm(this.ctx.getWebSockets().some(socket => !socket.deserializeAttachment().role) ? Date.now() + 10000 : config.expires); } }