/** * Presence tracking (TASK-0093, doc §5) — the server's room-scoped peer * state + broadcast shaping. * * One {@link PresenceTracker} per server process. Peers are keyed by * `(roomKey, userId)`: a user with two tabs in the same room shows ONE * avatar (the second tab's state wins — both tabs are the same person); * tabs in DIFFERENT rooms are separate entries (different pageIds). * * Identity is server-stamped: the entry's `user` is built from the * socket's authenticated handshake id + the name resolved by the * handshake's meta seam (the display name — never an email) + the shared * `presenceColor(userId)` hash (the server and every client agree on the * color without negotiating; the client-supplied presence frame carries * ONLY `cursor`/`viewing` — impersonation is impossible). * * The > 50 peer cap (doc §5.1 / `REALTIME_LIMITS.awarenessPeerCap`): a * broadcast for a room over the cap is shaped to AVATARS-ONLY — `cursor` * and `viewing` are stripped, `user` + `lastSeen` stay. The strip is * applied per-broadcast by {@link PresenceTracker.broadcastFor} (the * stored state is never mutated — a room that drops back to ≤ 50 peers * carries full cursors again on the next update). * * Prune: a peer silent for longer than `pruneAfterMs` (90 s — the doc * §5.1 awareness timeout, wired from `REALTIME_LIMITS.awarenessPruneMs`) * is dropped; the server's heartbeat loop calls {@link PresenceTracker.prune} * and broadcasts the pruned set (the clients drop peers not re-asserted * by a later roster/`presence` frame — best-effort, doc §5.2). */ import { presenceColor } from "@factory/shared/presence"; import { REALTIME_LIMITS, type PresenceBroadcast, type PresenceCursor, } from "@factory/shared/realtime/frames"; /** One peer's server-side presence state (roomKey + userId → entry). */ export interface PresenceEntry { /** Server-stamped identity (never client-supplied). */ readonly userId: string; readonly name: string; readonly color: string; /** The cursor (null = cleared / hidden — tab blurred, or never set). */ cursor: PresenceCursor | null; /** False when the peer's tab is hidden (the cursor hides, the avatar stays). */ viewing: boolean; /** The epoch ms of the last `presence` frame from this peer (prune basis). */ lastSeen: number; } /** What a client sent in a `presence` frame (state only — no identity). */ export interface PresenceUpdate { cursor?: PresenceCursor | null; viewing?: boolean; } export interface PresenceTrackerOpts { /** Injectable clock (deterministic prune tests; rule 25). */ readonly now?: () => number; /** Silence before a peer is pruned (default 90 s, doc §5.1). */ readonly pruneAfterMs?: number; /** The fan-out cap (default 50 — doc §5.1 avatars-only beyond it). */ readonly peerCap?: number; } export class PresenceTracker { private readonly now: () => number; private readonly pruneAfterMs: number; private readonly peerCap: number; /** roomKey → userId → entry. */ private readonly rooms = new Map>(); constructor(opts: PresenceTrackerOpts = {}) { this.now = opts.now ?? (() => Date.now()); this.pruneAfterMs = opts.pruneAfterMs ?? REALTIME_LIMITS.awarenessPruneMs; this.peerCap = opts.peerCap ?? REALTIME_LIMITS.awarenessPeerCap; } /** * Upsert a peer's state (a `presence` frame or a join). `user` is the * server-stamped identity (id from the handshake, name from the meta * seam, color from the shared hash). Returns the (new or updated) * entry — the caller broadcasts `broadcastFor(entry)`. */ upsert( roomKey: string, user: { id: string; name?: string }, update: PresenceUpdate, ): PresenceEntry { let room = this.rooms.get(roomKey); if (room === undefined) { room = new Map(); this.rooms.set(roomKey, room); } const t = this.now(); const existing = room.get(user.id); const entry: PresenceEntry = { userId: user.id, name: user.name ?? existing?.name ?? "Guest (shared)", color: presenceColor(user.id), cursor: update.cursor === undefined ? (existing?.cursor ?? null) : update.cursor, viewing: update.viewing ?? existing?.viewing ?? true, lastSeen: t, }; room.set(user.id, entry); return entry; } /** Remove a peer (explicit `presence-leave` or a socket close). */ remove(roomKey: string, userId: string): void { const room = this.rooms.get(roomKey); if (room === undefined) return; room.delete(userId); if (room.size === 0) this.rooms.delete(roomKey); } /** The room's live peer count (the cap's basis — users, not sockets). */ count(roomKey: string): number { return this.rooms.get(roomKey)?.size ?? 0; } /** The room's current entries (a copy — safe to iterate). */ peers(roomKey: string): readonly PresenceEntry[] { const room = this.rooms.get(roomKey); if (room === undefined) return []; return [...room.values()]; } /** * Shape one peer's state into the wire broadcast for a room. The cap * rule (doc §5.1): a room with MORE than `peerCap` peers broadcasts * avatars-only — `cursor` and `viewing` are stripped, `user` + * `lastSeen` stay. At ≤ `peerCap` the full state (cursor included) * is carried. */ broadcastFor(roomKey: string, entry: PresenceEntry): PresenceBroadcast { const avatarsOnly = this.count(roomKey) > this.peerCap; return { type: "presence", pageId: roomKey, user: { id: entry.userId, name: entry.name, color: entry.color }, cursor: avatarsOnly ? null : (entry.cursor ?? null), viewing: avatarsOnly ? undefined : entry.viewing, lastSeen: entry.lastSeen, }; } /** * Drop peers silent for longer than `pruneAfterMs`. Returns * `roomKey → pruned entries` (the caller broadcasts each pruned peer's * removal; a client that never re-hears a peer before its own prune * window drops it locally — doc §5.2 best-effort). */ prune(): Map { const t = this.now(); const pruned = new Map(); for (const [roomKey, room] of this.rooms) { const dropped: PresenceEntry[] = []; for (const [userId, entry] of room) { if (t - entry.lastSeen > this.pruneAfterMs) { room.delete(userId); dropped.push(entry); } } if (dropped.length > 0) pruned.set(roomKey, dropped); if (room.size === 0) this.rooms.delete(roomKey); } return pruned; } /** Drop every entry for a room (room GC / drain). */ removeRoom(roomKey: string): void { this.rooms.delete(roomKey); } /** Total live entries across rooms (metrics / tests). */ get size(): number { let n = 0; for (const room of this.rooms.values()) n += room.size; return n; } }