1
30
31import { presenceColor } from "@factory/shared/presence";
32import {
33 REALTIME_LIMITS,
34 type PresenceBroadcast,
35 type PresenceCursor,
36} from "@factory/shared/realtime/frames";
37
38
39export interface PresenceEntry {
40
41 readonly userId: string;
42 readonly name: string;
43 readonly color: string;
44
45 cursor: PresenceCursor | null;
46
47 viewing: boolean;
48
49 lastSeen: number;
50}
51
52
53export interface PresenceUpdate {
54 cursor?: PresenceCursor | null;
55 viewing?: boolean;
56}
57
58export interface PresenceTrackerOpts {
59
60 readonly now?: () => number;
61
62 readonly pruneAfterMs?: number;
63
64 readonly peerCap?: number;
65}
66
67export class PresenceTracker {
68 private readonly now: () => number;
69 private readonly pruneAfterMs: number;
70 private readonly peerCap: number;
71
72 private readonly rooms = new Map<string, Map<string, PresenceEntry>>();
73
74 constructor(opts: PresenceTrackerOpts = {}) {
75 this.now = opts.now ?? (() => Date.now());
76 this.pruneAfterMs = opts.pruneAfterMs ?? REALTIME_LIMITS.awarenessPruneMs;
77 this.peerCap = opts.peerCap ?? REALTIME_LIMITS.awarenessPeerCap;
78 }
79
80
86 upsert(
87 roomKey: string,
88 user: { id: string; name?: string },
89 update: PresenceUpdate,
90 ): PresenceEntry {
91 let room = this.rooms.get(roomKey);
92 if (room === undefined) {
93 room = new Map<string, PresenceEntry>();
94 this.rooms.set(roomKey, room);
95 }
96 const t = this.now();
97 const existing = room.get(user.id);
98 const entry: PresenceEntry = {
99 userId: user.id,
100 name: user.name ?? existing?.name ?? "Guest (shared)",
101 color: presenceColor(user.id),
102 cursor: update.cursor === undefined ? (existing?.cursor ?? null) : update.cursor,
103 viewing: update.viewing ?? existing?.viewing ?? true,
104 lastSeen: t,
105 };
106 room.set(user.id, entry);
107 return entry;
108 }
109
110
111 remove(roomKey: string, userId: string): void {
112 const room = this.rooms.get(roomKey);
113 if (room === undefined) return;
114 room.delete(userId);
115 if (room.size === 0) this.rooms.delete(roomKey);
116 }
117
118
119 count(roomKey: string): number {
120 return this.rooms.get(roomKey)?.size ?? 0;
121 }
122
123
124 peers(roomKey: string): readonly PresenceEntry[] {
125 const room = this.rooms.get(roomKey);
126 if (room === undefined) return [];
127 return [...room.values()];
128 }
129
130
137 broadcastFor(roomKey: string, entry: PresenceEntry): PresenceBroadcast {
138 const avatarsOnly = this.count(roomKey) > this.peerCap;
139 return {
140 type: "presence",
141 pageId: roomKey,
142 user: { id: entry.userId, name: entry.name, color: entry.color },
143 cursor: avatarsOnly ? null : (entry.cursor ?? null),
144 viewing: avatarsOnly ? undefined : entry.viewing,
145 lastSeen: entry.lastSeen,
146 };
147 }
148
149
155 prune(): Map<string, readonly PresenceEntry[]> {
156 const t = this.now();
157 const pruned = new Map<string, PresenceEntry[]>();
158 for (const [roomKey, room] of this.rooms) {
159 const dropped: PresenceEntry[] = [];
160 for (const [userId, entry] of room) {
161 if (t - entry.lastSeen > this.pruneAfterMs) {
162 room.delete(userId);
163 dropped.push(entry);
164 }
165 }
166 if (dropped.length > 0) pruned.set(roomKey, dropped);
167 if (room.size === 0) this.rooms.delete(roomKey);
168 }
169 return pruned;
170 }
171
172
173 removeRoom(roomKey: string): void {
174 this.rooms.delete(roomKey);
175 }
176
177
178 get size(): number {
179 let n = 0;
180 for (const room of this.rooms.values()) n += room.size;
181 return n;
182 }
183}
184