CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
pr-presence.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 25b1ff7 | 1 | /** |
| 2 | * Real-time collaborative PR review presence. | |
| 3 | * | |
| 4 | * Maintains an in-memory room map keyed by PR id. Each room tracks every | |
| 5 | * connected reviewer's current line hover and typing state. The module is | |
| 6 | * self-contained: no DB, no Redis — purely ephemeral per-process state. | |
| 7 | * | |
| 8 | * Rooms are automatically created on first join and swept clean when the | |
| 9 | * last user leaves. A background sweep evicts sessions whose lastSeen | |
| 10 | * timestamp is older than STALE_THRESHOLD_MS (30 seconds). | |
| 11 | * | |
| 12 | * Public API: | |
| 13 | * joinRoom(prId, sessionId, user) | |
| 14 | * leaveRoom(prId, sessionId) | |
| 15 | * updatePresence(prId, sessionId, line, typing) | |
| 16 | * getRoomUsers(prId) | |
| 17 | * broadcastToRoom(prId, message, excludeSession?) | |
| 18 | * registerSocket(prId, sessionId, ws) | |
| 19 | * unregisterSocket(prId, sessionId) | |
| 20 | */ | |
| 21 | ||
| 22 | export type PresenceUser = { | |
| 23 | userId: string; | |
| 24 | username: string; | |
| 25 | colour: string; | |
| 26 | line: number | null; | |
| 27 | typing: boolean; | |
| 28 | lastSeen: number; // Date.now() | |
| 29 | }; | |
| 30 | ||
| 31 | type WsLike = { | |
| 32 | send: (data: string) => void; | |
| 33 | readyState?: number; | |
| 34 | }; | |
| 35 | ||
| 36 | // Room: sessionId → PresenceUser | |
| 37 | const rooms = new Map<string, Map<string, PresenceUser>>(); | |
| 38 | ||
| 39 | // Socket registry: prId → sessionId → WebSocket | |
| 40 | const sockets = new Map<string, Map<string, WsLike>>(); | |
| 41 | ||
| 42 | const COLOURS = [ | |
| 43 | "#e74c3c", | |
| 44 | "#3498db", | |
| 45 | "#2ecc71", | |
| 46 | "#f39c12", | |
| 47 | "#9b59b6", | |
| 48 | "#1abc9c", | |
| 49 | "#e67e22", | |
| 50 | "#34495e", | |
| 51 | ]; | |
| 52 | ||
| 53 | const STALE_THRESHOLD_MS = 30_000; | |
| 54 | const SWEEP_INTERVAL_MS = 15_000; | |
| 55 | ||
| 56 | /** Stable colour assignment: hash userId → one of 8 preset colours. */ | |
| 57 | function assignColour(userId: string): string { | |
| 58 | let hash = 0; | |
| 59 | for (let i = 0; i < userId.length; i++) { | |
| 60 | hash = (hash * 31 + userId.charCodeAt(i)) >>> 0; | |
| 61 | } | |
| 62 | return COLOURS[hash % COLOURS.length]; | |
| 63 | } | |
| 64 | ||
| 65 | function getRoom(prId: string): Map<string, PresenceUser> { | |
| 66 | let room = rooms.get(prId); | |
| 67 | if (!room) { | |
| 68 | room = new Map(); | |
| 69 | rooms.set(prId, room); | |
| 70 | } | |
| 71 | return room; | |
| 72 | } | |
| 73 | ||
| 74 | function getSockets(prId: string): Map<string, WsLike> { | |
| 75 | let map = sockets.get(prId); | |
| 76 | if (!map) { | |
| 77 | map = new Map(); | |
| 78 | sockets.set(prId, map); | |
| 79 | } | |
| 80 | return map; | |
| 81 | } | |
| 82 | ||
| 83 | /** Add or refresh a user's presence in a room. */ | |
| 84 | export function joinRoom( | |
| 85 | prId: string, | |
| 86 | sessionId: string, | |
| 87 | user: { userId: string; username: string } | |
| 88 | ): PresenceUser { | |
| 89 | const room = getRoom(prId); | |
| 90 | const existing = room.get(sessionId); | |
| 91 | const entry: PresenceUser = { | |
| 92 | userId: user.userId, | |
| 93 | username: user.username, | |
| 94 | colour: existing?.colour ?? assignColour(user.userId), | |
| 95 | line: existing?.line ?? null, | |
| 96 | typing: existing?.typing ?? false, | |
| 97 | lastSeen: Date.now(), | |
| 98 | }; | |
| 99 | room.set(sessionId, entry); | |
| 100 | return entry; | |
| 101 | } | |
| 102 | ||
| 103 | /** Remove a session from a room; prune empty rooms. */ | |
| 104 | export function leaveRoom(prId: string, sessionId: string): void { | |
| 105 | const room = rooms.get(prId); | |
| 106 | if (!room) return; | |
| 107 | room.delete(sessionId); | |
| 108 | if (room.size === 0) rooms.delete(prId); | |
| 109 | ||
| 110 | const ss = sockets.get(prId); | |
| 111 | if (ss) { | |
| 112 | ss.delete(sessionId); | |
| 113 | if (ss.size === 0) sockets.delete(prId); | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | /** Update cursor line / typing state and refresh lastSeen. */ | |
| 118 | export function updatePresence( | |
| 119 | prId: string, | |
| 120 | sessionId: string, | |
| 121 | line: number | null, | |
| 122 | typing: boolean | |
| 123 | ): PresenceUser | null { | |
| 124 | const room = rooms.get(prId); | |
| 125 | if (!room) return null; | |
| 126 | const entry = room.get(sessionId); | |
| 127 | if (!entry) return null; | |
| 128 | entry.line = line; | |
| 129 | entry.typing = typing; | |
| 130 | entry.lastSeen = Date.now(); | |
| 131 | return entry; | |
| 132 | } | |
| 133 | ||
| 134 | /** Ping-only: refresh lastSeen without changing line/typing. */ | |
| 135 | export function pingSession(prId: string, sessionId: string): void { | |
| 136 | const room = rooms.get(prId); | |
| 137 | if (!room) return; | |
| 138 | const entry = room.get(sessionId); | |
| 139 | if (entry) entry.lastSeen = Date.now(); | |
| 140 | } | |
| 141 | ||
| 142 | /** Snapshot of all current users in a room (excluding stale sessions). */ | |
| 143 | export function getRoomUsers(prId: string): Array<PresenceUser & { sessionId: string }> { | |
| 144 | const room = rooms.get(prId); | |
| 145 | if (!room) return []; | |
| 146 | const now = Date.now(); | |
| 147 | return Array.from(room.entries()) | |
| 148 | .filter(([, u]) => now - u.lastSeen < STALE_THRESHOLD_MS) | |
| 149 | .map(([sessionId, u]) => ({ ...u, sessionId })); | |
| 150 | } | |
| 151 | ||
| 152 | /** Register a WebSocket connection for a session. */ | |
| 153 | export function registerSocket(prId: string, sessionId: string, ws: WsLike): void { | |
| 154 | getSockets(prId).set(sessionId, ws); | |
| 155 | } | |
| 156 | ||
| 157 | /** Remove a WebSocket registration for a session. */ | |
| 158 | export function unregisterSocket(prId: string, sessionId: string): void { | |
| 159 | const ss = sockets.get(prId); | |
| 160 | if (!ss) return; | |
| 161 | ss.delete(sessionId); | |
| 162 | if (ss.size === 0) sockets.delete(prId); | |
| 163 | } | |
| 164 | ||
| 165 | /** | |
| 166 | * Broadcast a JSON message to every connected WebSocket in a room. | |
| 167 | * If excludeSession is set, that session's socket is skipped (suppress echo). | |
| 168 | */ | |
| 169 | export function broadcastToRoom( | |
| 170 | prId: string, | |
| 171 | message: unknown, | |
| 172 | excludeSession?: string | |
| 173 | ): void { | |
| 174 | const ss = sockets.get(prId); | |
| 175 | if (!ss) return; | |
| 176 | const payload = JSON.stringify(message); | |
| 177 | for (const [sessionId, ws] of ss) { | |
| 178 | if (excludeSession && sessionId === excludeSession) continue; | |
| 179 | // 1 = OPEN | |
| 180 | if (ws.readyState !== undefined && ws.readyState !== 1) continue; | |
| 181 | try { | |
| 182 | ws.send(payload); | |
| 183 | } catch { | |
| 184 | // Stale socket — will be cleaned up by sweep or disconnect handler. | |
| 185 | } | |
| 186 | } | |
| 187 | } | |
| 188 | ||
| 189 | // --------------------------------------------------------------------------- | |
| 190 | // Background sweep — evict stale sessions every 15 s | |
| 191 | // --------------------------------------------------------------------------- | |
| 192 | ||
| 193 | function sweep(): void { | |
| 194 | const now = Date.now(); | |
| 195 | for (const [prId, room] of rooms) { | |
| 196 | for (const [sessionId, user] of room) { | |
| 197 | if (now - user.lastSeen >= STALE_THRESHOLD_MS) { | |
| 198 | // Notify room peers before evicting. | |
| 199 | broadcastToRoom(prId, { type: "leave", sessionId }, sessionId); | |
| 200 | room.delete(sessionId); | |
| 201 | const ss = sockets.get(prId); | |
| 202 | if (ss) ss.delete(sessionId); | |
| 203 | } | |
| 204 | } | |
| 205 | if (room.size === 0) rooms.delete(prId); | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | // Start sweep loop — fire-and-forget, never throws. | |
| 210 | const _sweepInterval = setInterval(sweep, SWEEP_INTERVAL_MS); | |
| 211 | // Allow Bun/Node to exit cleanly even if this interval is live. | |
| 212 | if (typeof _sweepInterval === "object" && _sweepInterval !== null) { | |
| 213 | (_sweepInterval as NodeJS.Timeout).unref?.(); | |
| 214 | } |