Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

pr-presence.tsBlame214 lines · 1 contributor
25b1ff7Claude1/**
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
22export 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
31type WsLike = {
32 send: (data: string) => void;
33 readyState?: number;
34};
35
36// Room: sessionId → PresenceUser
37const rooms = new Map<string, Map<string, PresenceUser>>();
38
39// Socket registry: prId → sessionId → WebSocket
40const sockets = new Map<string, Map<string, WsLike>>();
41
42const COLOURS = [
43 "#e74c3c",
44 "#3498db",
45 "#2ecc71",
46 "#f39c12",
47 "#9b59b6",
48 "#1abc9c",
49 "#e67e22",
50 "#34495e",
51];
52
53const STALE_THRESHOLD_MS = 30_000;
54const SWEEP_INTERVAL_MS = 15_000;
55
56/** Stable colour assignment: hash userId → one of 8 preset colours. */
57function 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
65function 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
74function 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. */
84export 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. */
104export 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. */
118export 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. */
135export 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). */
143export 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. */
153export 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. */
158export 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 */
169export 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
193function 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.
210const _sweepInterval = setInterval(sweep, SWEEP_INTERVAL_MS);
211// Allow Bun/Node to exit cleanly even if this interval is live.
212if (typeof _sweepInterval === "object" && _sweepInterval !== null) {
213 (_sweepInterval as NodeJS.Timeout).unref?.();
214}