import { DurableObject } from 'cloudflare:workers' import { NotificationType } from './notification-types' import type { Env } from './context' /** * Durable Object hosting the SignalR notifications hub. A single global instance * holds the shared hub state (one process, shared across connections). * * It speaks the SignalR JSON Hub Protocol over a hibernatable WebSocket: * 1. negotiate happens in the worker; the client then opens a WS to `/hub/v1`. * 2. handshake: client sends `{"protocol":"json","version":1}␞`, we reply `{}␞`. * 3. framed messages are `␞` (0x1e) delimited JSON; type 1 = invocation, * 3 = completion, 6 = ping, 7 = close. * * Connection/subscription state lives in SQLite so it survives hibernation: * - `connection_owner(connectionId, playerId)` — who each socket belongs to, * from the token the worker validated at connect. This is how a player's own * notifications reach them; the client never subscribes to itself. * - `subscriptions(connectionId, playerId)` — *other* players a connection asked * for updates about, both the connection→players and (queried the other way) * the player→connections maps. * - `pending(id, playerId, payload)` — the per-player queue delivered once a * player is reachable again. */ /** SignalR record separator (0x1e) that terminates every protocol message. */ const RS = '\u001e' interface SocketState { connectionId: string handshakeDone: boolean /** The player this socket belongs to, from the token validated at connect. */ playerId?: number } interface HubMessage { type: number target?: string invocationId?: string arguments?: unknown[] } /** * How the worker tells the DO which player a connecting socket belongs to, having * validated the connect request's token. The worker sets it on every connect it lets * through and refuses the rest, so a client can't present its own and be believed. */ export const OWNER_HEADER = 'x-recflare-connection-owner' /** * Read the player ids off a `SubscribeToPlayers` invocation. SignalR gives no schema, so * all three plausible spellings are accepted — an options object * (`[{playerIds:[1,2]}]`), a single array argument (`[[1,2]]`), and varargs * (`[1,2]`) — rather than guessing which one the client uses. * * `null` means the argument didn't resolve to a list of ids at all, which the caller * must not treat as an empty subscription; `[]` is a real "subscribe to nobody". */ function parsePlayerIds(args: unknown[] | undefined): number[] | null { if (args === undefined || args.length === 0) return [] const first = args[0] const candidate = typeof first === 'object' && first !== null && !Array.isArray(first) ? (first as { playerIds?: unknown }).playerIds : Array.isArray(first) ? first : args if (!Array.isArray(candidate)) return null // Ids arriving as strings ("153") still count — the wire format is the client's // choice, and the subscriptions table is what has to be numeric. const ids = candidate .map((value) => typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN ) .filter((value) => Number.isInteger(value)) return ids.length === 0 && candidate.length > 0 ? null : ids } /** What {@link NotificationsHub.inspect} reports — see it for what each field is for. */ export interface HubState { connections: Array<{ connectionId: string /** Whether a socket for this connectionId is still held by the DO. */ live: boolean handshakeDone: boolean /** * The player this connection belongs to. Null only for a connection that predates * authenticated connects and hasn't closed yet — a new one always has an owner. */ playerId: number | null /** Other players this connection subscribed to updates for. */ playerIds: number[] }> /** Queued-while-offline notifications, newest payload per player for identification. */ pending: Array<{ playerId: number; count: number; latest: string }> } /** The Coach system account — the `FromPlayerId` on a coach message (see coachMessageAll). */ const COACH_PLAYER_ID = 1 /** The Message `Type` a coach/system message carries (a Message-model enum, not a NotificationType). */ const COACH_MESSAGE_TYPE = 100 export class NotificationsHub extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) void ctx.blockConcurrencyWhile(async () => { this.ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS subscriptions ( connectionId TEXT NOT NULL, playerId INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sub_conn ON subscriptions(connectionId); CREATE INDEX IF NOT EXISTS idx_sub_player ON subscriptions(playerId); CREATE TABLE IF NOT EXISTS pending ( id INTEGER PRIMARY KEY AUTOINCREMENT, playerId INTEGER NOT NULL, payload TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_pending_player ON pending(playerId); CREATE TABLE IF NOT EXISTS connection_owner ( connectionId TEXT PRIMARY KEY, playerId INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_owner_player ON connection_owner(playerId); `) }) } /** WebSocket upgrade entrypoint — the worker forwards `/hub/v1` here. */ override async fetch(request: Request): Promise { if ((request.headers.get('Upgrade') ?? '').toLowerCase() !== 'websocket') { return new Response('Expected a WebSocket upgrade request', { status: 426 }) } const url = new URL(request.url) // negotiate handed the client this id as `connectionToken`/`connectionId`. const connectionId = url.searchParams.get('id') || crypto.randomUUID() // Who this socket belongs to, established by the worker from the connect // request's token (see OWNER_HEADER). The worker rejects a connect it can't // identify and always sets the header itself, so an absent one means the DO was // reached by some other path — not something to serve a socket to. const playerId = Number.parseInt(request.headers.get(OWNER_HEADER) ?? '', 10) if (!Number.isInteger(playerId)) { return new Response('Unidentified connection', { status: 401 }) } const pair = new WebSocketPair() const server = pair[1] // Tag by connectionId so we can find this socket via getWebSockets(id). this.ctx.acceptWebSocket(server, [connectionId]) server.serializeAttachment({ connectionId, handshakeDone: false, playerId, } satisfies SocketState) this.ctx.storage.sql.exec( 'INSERT OR REPLACE INTO connection_owner (connectionId, playerId) VALUES (?, ?)', connectionId, playerId ) return new Response(null, { status: 101, webSocket: pair[0] }) } override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { const text = typeof message === 'string' ? message : new TextDecoder().decode(message) const state = ws.deserializeAttachment() as SocketState | null if (!state) return for (const record of text.split(RS)) { if (record.length === 0) continue if (!state.handshakeDone) { // First frame is the SignalR handshake request. this.completeHandshake(ws, record, state) continue } let msg: HubMessage try { msg = JSON.parse(record) as HubMessage } catch { continue } this.handleMessage(ws, state.connectionId, msg) } } override async webSocketClose(ws: WebSocket): Promise { const state = ws.deserializeAttachment() as SocketState | null if (state) { // Mirrors OnDisconnected: drop this connection's subscriptions, which // also removes it from every player's connection set. this.ctx.storage.sql.exec( 'DELETE FROM subscriptions WHERE connectionId = ?', state.connectionId ) this.ctx.storage.sql.exec( 'DELETE FROM connection_owner WHERE connectionId = ?', state.connectionId ) } try { ws.close() } catch { // already closed } } // ---- SignalR protocol ---------------------------------------------------- private completeHandshake(ws: WebSocket, record: string, state: SocketState): void { let protocol = 'json' try { protocol = (JSON.parse(record) as { protocol?: string }).protocol ?? 'json' } catch { // fall through to the json default } if (protocol !== 'json') { ws.send(JSON.stringify({ error: `Unsupported protocol '${protocol}'` }) + RS) ws.close(1002, 'unsupported protocol') return } // Empty object = handshake success. ws.send('{}' + RS) state.handshakeDone = true ws.serializeAttachment(state) // Send "OnConnect" to the caller after connecting. ws.send(this.invocation('OnConnect', [])) // Deliver whatever piled up while this player was away. Done here rather than at // accept because SignalR won't read invocation frames sent before the handshake // reply, and only here because the client never calls SubscribeToPlayers — with // the flush living solely in there, a queued notification was stranded forever. if (state.playerId !== undefined) this.flushPending(ws, state.playerId) } /** Send and clear a player's queued notifications on `ws`. */ private flushPending(ws: WebSocket, playerId: number): void { const pending = this.ctx.storage.sql .exec<{ payload: string }>('SELECT payload FROM pending WHERE playerId = ? ORDER BY id', playerId) .toArray() if (pending.length === 0) return for (const row of pending) ws.send(this.invocation('Notification', [row.payload])) this.ctx.storage.sql.exec('DELETE FROM pending WHERE playerId = ?', playerId) } private handleMessage(ws: WebSocket, connectionId: string, msg: HubMessage): void { switch (msg.type) { case 1: // Invocation this.handleInvocation(ws, connectionId, msg) break case 6: // Ping — echo to keep the connection alive. ws.send(JSON.stringify({ type: 6 }) + RS) break case 7: // Close ws.close() break default: break } } private handleInvocation(ws: WebSocket, connectionId: string, msg: HubMessage): void { switch (msg.target) { case 'SubscribeToPlayers': { const playerIds = parsePlayerIds(msg.arguments) // An argument we can't read is not "subscribe to nobody": subscribing // replaces the connection's whole set, so acting on a misread would wipe // a working connection to zero and silently strand every push. if (playerIds === null) { console.warn('hub: unreadable SubscribeToPlayers argument', { connectionId, arguments: JSON.stringify(msg.arguments), }) } else { this.subscribeToPlayers(ws, connectionId, playerIds) } if (msg.invocationId) ws.send(this.completion(msg.invocationId, null)) break } case 'GetSubscriptions': { const players = this.getSubscribedPlayers(connectionId) if (msg.invocationId) ws.send(this.completion(msg.invocationId, players)) break } default: // Logged, not just answered with an error completion: a hub method we // don't implement is a client expectation we've missed, and the client // gives no sign of it. console.warn('hub: unknown invocation target', { connectionId, target: msg.target, arguments: JSON.stringify(msg.arguments), }) if (msg.invocationId) { ws.send(this.completionError(msg.invocationId, `Unknown method '${msg.target}'`)) } break } } private subscribeToPlayers(ws: WebSocket, connectionId: string, playerIds: number[]): void { const unique = [...new Set(playerIds)] // Replace this connection's subscription set. this.ctx.storage.sql.exec('DELETE FROM subscriptions WHERE connectionId = ?', connectionId) for (const playerId of unique) { this.ctx.storage.sql.exec( 'INSERT INTO subscriptions (connectionId, playerId) VALUES (?, ?)', connectionId, playerId ) } // Flush any notifications queued while these players were offline. for (const playerId of unique) this.flushPending(ws, playerId) } /** * Every connection that receives notifications for a player. Two ways to qualify: the * connection *is* that player (established from the token at connect), or it * subscribed to them. The client only ever uses the first — it never calls * SubscribeToPlayers — so a player's own notifications reach them through * connection_owner, and subscriptions carry other players' updates. */ private connectionIdsFor(playerId: number): string[] { return this.ctx.storage.sql .exec<{ connectionId: string }>( `SELECT connectionId FROM connection_owner WHERE playerId = ?1 UNION SELECT connectionId FROM subscriptions WHERE playerId = ?1`, playerId ) .toArray() .map((r) => r.connectionId) } private getSubscribedPlayers(connectionId: string): number[] { return this.ctx.storage.sql .exec<{ playerId: number }>( 'SELECT DISTINCT playerId FROM subscriptions WHERE connectionId = ?', connectionId ) .toArray() .map((r) => r.playerId) } // ---- Server → client RPC (callable from other workers) ------------------- /** * Send a notification to a player's connections, queueing it if the player * isn't currently connected (mirrors `SendNotificationToPlayer`). */ async notifyPlayer( playerId: number, notificationType: string | number, data?: Record ): Promise<{ delivered: number; queued: boolean }> { const payload = this.buildNotificationPayload(notificationType, data) const delivered = this.deliverToPlayer(playerId, payload) if (delivered === 0) { // Distinguishes the two ways this fails: no connection is registered for the // player at all, versus one is registered but has no live socket behind it. console.warn('hub: notification queued, nobody to deliver to', { playerId, notificationType, connectionIds: this.connectionIdsFor(playerId), liveSockets: this.ctx.getWebSockets().length, }) } if (delivered === 0) { this.ctx.storage.sql.exec( 'INSERT INTO pending (playerId, payload) VALUES (?, ?)', playerId, payload ) return { delivered: 0, queued: true } } return { delivered, queued: false } } /** * Send a notification to a player's live sockets and, unlike {@link notifyPlayer}, * NEVER queue it when they're offline — the ephemeral "SendWebsocket" send. For * high-frequency, transient frames whose value is gone by the next reconnect (the * presence heartbeat response, sent on every beat): queueing those would pile up * unbounded in `pending` and then deliver a burst of stale frames when the player * next connects. Returns how many live sockets received it (0 = nobody was * connected, and it was dropped rather than stored). */ async notifyPlayerEphemeral( playerId: number, notificationType: string | number, data?: Record ): Promise<{ delivered: number }> { const payload = this.buildNotificationPayload(notificationType, data) return { delivered: this.deliverToPlayer(playerId, payload) } } /** * Ephemeral send (see {@link notifyPlayerEphemeral}) to many players at once — the * batch the presence fan-out needs. When a player changes rooms, every online friend * gets one SubscriptionUpdatePresence and an offline friend gets nothing (a SignalR * group send reaches only connected clients), so the same identical payload is built * once and delivered to each in a single RPC round-trip rather than one call per * friend. Returns the total live sockets reached across all of them. */ async notifyPlayersEphemeral( playerIds: number[], notificationType: string | number, data?: Record ): Promise<{ delivered: number }> { const payload = this.buildNotificationPayload(notificationType, data) let delivered = 0 for (const playerId of playerIds) delivered += this.deliverToPlayer(playerId, payload) return { delivered } } /** * Send a "coach" message to every connected client (mirrors the reference * `SendCoachMessageAll`, using the hub's live connections as the online set): each * handshaken socket gets a `MessageReceived` notification carrying a Message from * the Coach account (player 1). Online-only — nothing is queued or persisted, and * it's a broadcast, so the Message has no per-recipient `ToPlayerId`. Returns how * many connected clients were messaged. */ async coachMessageAll(content: string): Promise<{ sent: number }> { const payload = this.buildNotificationPayload(NotificationType.MessageReceived, { FromPlayerId: COACH_PLAYER_ID, Type: COACH_MESSAGE_TYPE, Data: content, }) return { sent: this.broadcastToConnected(payload) } } /** Broadcast a notification to every connected (handshaken) client. */ async broadcast( notificationType: string | number, data?: Record ): Promise<{ delivered: number }> { return { delivered: this.broadcastToConnected(this.buildNotificationPayload(notificationType, data)), } } /** * Dump the hub's routing state for debugging delivery. A notification only reaches a * player through a `subscriptions` row (see {@link deliverToPlayer}), so when a push * doesn't arrive this answers the two questions that matter: is the player subscribed * on a live connection, and is the frame sitting in `pending` instead? * * Connections are listed even when one side is missing — a socket that has yet to * subscribe (`playerIds: []`) and a subscription set whose socket is gone * (`live: false`, a close we never saw) are both delivery failures worth seeing. */ async inspect(): Promise { const live = new Map() for (const ws of this.ctx.getWebSockets()) { const state = ws.deserializeAttachment() as SocketState | null if (state) live.set(state.connectionId, state.handshakeDone) } const subscribed = new Map() const rows = this.ctx.storage.sql .exec<{ connectionId: string playerId: number }>('SELECT connectionId, playerId FROM subscriptions ORDER BY connectionId, playerId') .toArray() for (const row of rows) { const players = subscribed.get(row.connectionId) ?? [] players.push(row.playerId) subscribed.set(row.connectionId, players) } const owners = new Map( this.ctx.storage.sql .exec<{ connectionId: string playerId: number }>('SELECT connectionId, playerId FROM connection_owner') .toArray() .map((row) => [row.connectionId, row.playerId] as const) ) const connections = [...new Set([...live.keys(), ...subscribed.keys(), ...owners.keys()])].map( (connectionId) => ({ connectionId, live: live.has(connectionId), handshakeDone: live.get(connectionId) ?? false, playerId: owners.get(connectionId) ?? null, playerIds: subscribed.get(connectionId) ?? [], }) ) const pending = this.ctx.storage.sql .exec<{ playerId: number; count: number; latest: string }>( `SELECT playerId, COUNT(*) AS count, (SELECT payload FROM pending AS newest WHERE newest.playerId = pending.playerId ORDER BY id DESC LIMIT 1) AS latest FROM pending GROUP BY playerId ORDER BY playerId` ) .toArray() return { connections, pending } } /** * Drop queued notifications — for one player, or (`playerId` omitted) the whole * queue. Anything pending is delivered the moment that player next subscribes, so a * frame queued by a bug that has since been fixed would otherwise arrive, out of * context, at the next reconnect. Returns how many were discarded. */ async clearPending(playerId?: number): Promise<{ cleared: number }> { // Counted first rather than read off the cursor: the delete's own row count isn't // reported, and this is an admin-facing number we want to be exact. const [counted] = playerId === undefined ? this.ctx.storage.sql .exec<{ count: number }>('SELECT COUNT(*) AS count FROM pending') .toArray() : this.ctx.storage.sql .exec<{ count: number }>('SELECT COUNT(*) AS count FROM pending WHERE playerId = ?', playerId) .toArray() if (playerId === undefined) this.ctx.storage.sql.exec('DELETE FROM pending') else this.ctx.storage.sql.exec('DELETE FROM pending WHERE playerId = ?', playerId) return { cleared: counted?.count ?? 0 } } // ---- Helpers ------------------------------------------------------------- /** * Send an already-built `Notification` payload to every live socket of a player's * subscribed connections; returns how many sockets received it (0 = offline). The * shared send path for {@link notifyPlayer} and {@link coachMessageAll}. */ private deliverToPlayer(playerId: number, payload: string): number { const connectionIds = this.connectionIdsFor(playerId) let delivered = 0 for (const connectionId of connectionIds) { for (const ws of this.ctx.getWebSockets(connectionId)) { ws.send(this.invocation('Notification', [payload])) delivered++ } } return delivered } /** * Send an already-built `Notification` payload to every connected (handshaken) * socket; returns how many received it. Shared by {@link broadcast} and * {@link coachMessageAll}. */ private broadcastToConnected(payload: string): number { let delivered = 0 for (const ws of this.ctx.getWebSockets()) { const state = ws.deserializeAttachment() as SocketState | null if (!state?.handshakeDone) continue ws.send(this.invocation('Notification', [payload])) delivered++ } return delivered } /** * Build the `Notification` argument: a JSON string `{ Id, Msg }` * (null values are dropped from `Msg`). `Id` is a client-defined tag — a * string name (e.g. "AccountUpdate") or a numeric code. It is always emitted * as a string: the client dispatches on a string `Id`, so a numeric frame * (e.g. the `NotificationType` enum values sent by `econ`) would otherwise be * silently dropped. */ private buildNotificationPayload( notificationType: string | number, data?: Record ): string { const msg: Record = {} if (data) { for (const [key, value] of Object.entries(data)) { if (value === null || value === undefined) continue msg[key] = value } } return JSON.stringify({ Id: String(notificationType), Msg: msg }) } private invocation(target: string, args: unknown[]): string { return JSON.stringify({ type: 1, target, arguments: args }) + RS } private completion(invocationId: string, result: unknown): string { return JSON.stringify({ type: 3, invocationId, result }) + RS } private completionError(invocationId: string, error: string): string { return JSON.stringify({ type: 3, invocationId, error }) + RS } }