detached 6e58ca3

fix(sse): Fix status bug and optimize performance with caching

commit
6e58ca3538e7c00da9c2c1acd2ef26a375b176fa
author
Kit · · 2025-07-03 20:08 UTC
committer
Kit · · 2025-07-03 20:11 UTC
parent
64ad42e
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
6 files · +275 -52
M package.json +1 -1
@@ -19,7 +19,7 @@
1919 "test": "npm run test:unit -- --run",
2020 "db:generate": "npx drizzle-kit generate",
2121 "db:push": "npx drizzle-kit push",
22- "db:push-prod": "TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push",
22+ "db:push-prod": "bash -c 'source .env && TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push'",
2323 "db:flush": "tsx utils/db-flush.ts"
2424 },
2525 "devDependencies": {
@@ -19,7 +19,7 @@
19 "test": "npm run test:unit -- --run",19 "test": "npm run test:unit -- --run",
20 "db:generate": "npx drizzle-kit generate",20 "db:generate": "npx drizzle-kit generate",
21 "db:push": "npx drizzle-kit push",21 "db:push": "npx drizzle-kit push",
22- "db:push-prod": "TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push",22+ "db:push-prod": "bash -c 'source .env && TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push'",
23 "db:flush": "tsx utils/db-flush.ts"23 "db:flush": "tsx utils/db-flush.ts"
24 },24 },
25 "devDependencies": {25 "devDependencies": {
A src/lib/buddyListCache.ts +55 -0
@@ -0,0 +1,55 @@
1+import { createLogger } from './utils/logger.server';
2+import type { SafeUser } from './types/chat';
3+
4+const log = createLogger('buddy-list-cache');
5+
6+class BuddyListCache {
7+ private cache: SafeUser[] | null = null;
8+ private lastHash: string = '';
9+ private lastFetch: number = 0;
10+ private isDirty: boolean = true; // Force initial fetch
11+
12+ // Mark cache as needing refresh
13+ invalidate(): void {
14+ this.isDirty = true;
15+ log.debug('Buddy list cache invalidated');
16+ }
17+
18+ // Check if we need to fetch new data
19+ needsRefresh(): boolean {
20+ return this.isDirty || this.cache === null;
21+ }
22+
23+ // Update cache with new data
24+ update(users: SafeUser[]): boolean {
25+ const newHash = JSON.stringify(users);
26+ const hasChanged = newHash !== this.lastHash;
27+
28+ if (hasChanged) {
29+ this.cache = users;
30+ this.lastHash = newHash;
31+ this.lastFetch = Date.now();
32+ this.isDirty = false;
33+
34+ log.debug('Buddy list cache updated', {
35+ userCount: users.length,
36+ changed: hasChanged
37+ });
38+ }
39+
40+ return hasChanged;
41+ }
42+
43+ // Get cached data
44+ get(): SafeUser[] | null {
45+ return this.cache;
46+ }
47+
48+ // Get cache age in milliseconds
49+ getAge(): number {
50+ return this.cache ? Date.now() - this.lastFetch : Infinity;
51+ }
52+}
53+
54+// Export singleton instance
55+export const buddyListCache = new BuddyListCache();⏎̸
@@ -0,0 +1,55 @@
1+import { createLogger } from './utils/logger.server';
2+import type { SafeUser } from './types/chat';
3+
4+const log = createLogger('buddy-list-cache');
5+
6+class BuddyListCache {
7+ private cache: SafeUser[] | null = null;
8+ private lastHash: string = '';
9+ private lastFetch: number = 0;
10+ private isDirty: boolean = true; // Force initial fetch
11+
12+ // Mark cache as needing refresh
13+ invalidate(): void {
14+ this.isDirty = true;
15+ log.debug('Buddy list cache invalidated');
16+ }
17+
18+ // Check if we need to fetch new data
19+ needsRefresh(): boolean {
20+ return this.isDirty || this.cache === null;
21+ }
22+
23+ // Update cache with new data
24+ update(users: SafeUser[]): boolean {
25+ const newHash = JSON.stringify(users);
26+ const hasChanged = newHash !== this.lastHash;
27+
28+ if (hasChanged) {
29+ this.cache = users;
30+ this.lastHash = newHash;
31+ this.lastFetch = Date.now();
32+ this.isDirty = false;
33+
34+ log.debug('Buddy list cache updated', {
35+ userCount: users.length,
36+ changed: hasChanged
37+ });
38+ }
39+
40+ return hasChanged;
41+ }
42+
43+ // Get cached data
44+ get(): SafeUser[] | null {
45+ return this.cache;
46+ }
47+
48+ // Get cache age in milliseconds
49+ getAge(): number {
50+ return this.cache ? Date.now() - this.lastFetch : Infinity;
51+ }
52+}
53+
54+// Export singleton instance
55+export const buddyListCache = new BuddyListCache();⏎̸
A src/lib/sseConnectionTracker.ts +90 -0
@@ -0,0 +1,90 @@
1+import { createLogger } from './utils/logger.server';
2+
3+const log = createLogger('sse-connection-tracker');
4+
5+interface ConnectionInfo {
6+ userId: string;
7+ connectedAt: number;
8+ lastActivity: number;
9+}
10+
11+class SSEConnectionTracker {
12+ private connections = new Map<string, ConnectionInfo>();
13+
14+ addConnection(userId: string): boolean {
15+ const existing = this.connections.get(userId);
16+ if (existing) {
17+ log.debug('User already has active connection', { userId });
18+ // Update last activity but don't trigger status update
19+ existing.lastActivity = Date.now();
20+ return false; // Connection already exists
21+ }
22+
23+ this.connections.set(userId, {
24+ userId,
25+ connectedAt: Date.now(),
26+ lastActivity: Date.now()
27+ });
28+
29+ log.debug('New SSE connection tracked', {
30+ userId,
31+ totalConnections: this.connections.size
32+ });
33+ return true; // New connection
34+ }
35+
36+ removeConnection(userId: string): boolean {
37+ const deleted = this.connections.delete(userId);
38+ if (deleted) {
39+ log.debug('SSE connection removed', {
40+ userId,
41+ remainingConnections: this.connections.size
42+ });
43+ }
44+ return deleted;
45+ }
46+
47+ hasConnection(userId: string): boolean {
48+ return this.connections.has(userId);
49+ }
50+
51+ updateActivity(userId: string): void {
52+ const connection = this.connections.get(userId);
53+ if (connection) {
54+ connection.lastActivity = Date.now();
55+ }
56+ }
57+
58+ getActiveUserIds(): string[] {
59+ return Array.from(this.connections.keys());
60+ }
61+
62+ getConnectionCount(): number {
63+ return this.connections.size;
64+ }
65+
66+ // Clean up stale connections (e.g., if server didn't properly close them)
67+ cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] {
68+ const now = Date.now();
69+ const staleUserIds: string[] = [];
70+
71+ for (const [userId, info] of this.connections.entries()) {
72+ if (now - info.lastActivity > maxInactivityMs) {
73+ this.connections.delete(userId);
74+ staleUserIds.push(userId);
75+ }
76+ }
77+
78+ if (staleUserIds.length > 0) {
79+ log.info('Cleaned up stale connections', {
80+ count: staleUserIds.length,
81+ userIds: staleUserIds
82+ });
83+ }
84+
85+ return staleUserIds;
86+ }
87+}
88+
89+// Export singleton instance
90+export const sseConnectionTracker = new SSEConnectionTracker();⏎̸
@@ -0,0 +1,90 @@
1+import { createLogger } from './utils/logger.server';
2+
3+const log = createLogger('sse-connection-tracker');
4+
5+interface ConnectionInfo {
6+ userId: string;
7+ connectedAt: number;
8+ lastActivity: number;
9+}
10+
11+class SSEConnectionTracker {
12+ private connections = new Map<string, ConnectionInfo>();
13+
14+ addConnection(userId: string): boolean {
15+ const existing = this.connections.get(userId);
16+ if (existing) {
17+ log.debug('User already has active connection', { userId });
18+ // Update last activity but don't trigger status update
19+ existing.lastActivity = Date.now();
20+ return false; // Connection already exists
21+ }
22+
23+ this.connections.set(userId, {
24+ userId,
25+ connectedAt: Date.now(),
26+ lastActivity: Date.now()
27+ });
28+
29+ log.debug('New SSE connection tracked', {
30+ userId,
31+ totalConnections: this.connections.size
32+ });
33+ return true; // New connection
34+ }
35+
36+ removeConnection(userId: string): boolean {
37+ const deleted = this.connections.delete(userId);
38+ if (deleted) {
39+ log.debug('SSE connection removed', {
40+ userId,
41+ remainingConnections: this.connections.size
42+ });
43+ }
44+ return deleted;
45+ }
46+
47+ hasConnection(userId: string): boolean {
48+ return this.connections.has(userId);
49+ }
50+
51+ updateActivity(userId: string): void {
52+ const connection = this.connections.get(userId);
53+ if (connection) {
54+ connection.lastActivity = Date.now();
55+ }
56+ }
57+
58+ getActiveUserIds(): string[] {
59+ return Array.from(this.connections.keys());
60+ }
61+
62+ getConnectionCount(): number {
63+ return this.connections.size;
64+ }
65+
66+ // Clean up stale connections (e.g., if server didn't properly close them)
67+ cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] {
68+ const now = Date.now();
69+ const staleUserIds: string[] = [];
70+
71+ for (const [userId, info] of this.connections.entries()) {
72+ if (now - info.lastActivity > maxInactivityMs) {
73+ this.connections.delete(userId);
74+ staleUserIds.push(userId);
75+ }
76+ }
77+
78+ if (staleUserIds.length > 0) {
79+ log.info('Cleaned up stale connections', {
80+ count: staleUserIds.length,
81+ userIds: staleUserIds
82+ });
83+ }
84+
85+ return staleUserIds;
86+ }
87+}
88+
89+// Export singleton instance
90+export const sseConnectionTracker = new SSEConnectionTracker();⏎̸
M src/routes/api/rooms/[roomId]/+server.ts +3 -3
@@ -40,7 +40,7 @@ export async function GET({ params, url, locals }): Promise<Response> {
4040 // Process users to mark them as offline if they've timed out
4141 const processedUsers = normalizedUsers.map(
4242 (user: SafeUser & { lastSeen: number }) => {
43- const shouldBeOffline = user.status === 'online' && user.lastSeen < timeoutThreshold;
43+ const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold;
4444 return {
4545 ...user,
4646 status: shouldBeOffline ? 'offline' : user.status
@@ -48,13 +48,13 @@ export async function GET({ params, url, locals }): Promise<Response> {
4848 }
4949 );
5050
51- // Find users who were originally online but now marked offline
51+ // Find users who were originally non-offline but now marked offline
5252 const usersToUpdate = processedUsers.filter(
5353 (user: SafeUser & { lastSeen: number }) =>
5454 user.status === 'offline' &&
5555 normalizedUsers.find(
5656 (u: SafeUser & { lastSeen: number }) => u.id === user.id
57- )?.status === 'online'
57+ )?.status !== 'offline'
5858 );
5959
6060 if (usersToUpdate.length > 0) {
@@ -40,7 +40,7 @@ export async function GET({ params, url, locals }): Promise<Response> {
40 // Process users to mark them as offline if they've timed out40 // Process users to mark them as offline if they've timed out
41 const processedUsers = normalizedUsers.map(41 const processedUsers = normalizedUsers.map(
42 (user: SafeUser & { lastSeen: number }) => {42 (user: SafeUser & { lastSeen: number }) => {
43- const shouldBeOffline = user.status === 'online' && user.lastSeen < timeoutThreshold;43+ const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold;
44 return {44 return {
45 ...user,45 ...user,
46 status: shouldBeOffline ? 'offline' : user.status46 status: shouldBeOffline ? 'offline' : user.status
@@ -48,13 +48,13 @@ export async function GET({ params, url, locals }): Promise<Response> {
48 }48 }
49 );49 );
50 50
51- // Find users who were originally online but now marked offline51+ // Find users who were originally non-offline but now marked offline
52 const usersToUpdate = processedUsers.filter(52 const usersToUpdate = processedUsers.filter(
53 (user: SafeUser & { lastSeen: number }) =>53 (user: SafeUser & { lastSeen: number }) =>
54 user.status === 'offline' &&54 user.status === 'offline' &&
55 normalizedUsers.find(55 normalizedUsers.find(
56 (u: SafeUser & { lastSeen: number }) => u.id === user.id56 (u: SafeUser & { lastSeen: number }) => u.id === user.id
57- )?.status === 'online'57+ )?.status !== 'offline'
58 );58 );
59 59
60 if (usersToUpdate.length > 0) {60 if (usersToUpdate.length > 0) {
M src/routes/api/sse/+server.ts +122 -48
@@ -8,9 +8,11 @@ import type { RequestHandler } from './$types';
88 import { sseEmitter } from '$lib/sseEmitter';
99 import db from '$lib/db/db.server';
1010 import { users } from '$lib/db/schema';
11-import { eq, and, lt } from 'drizzle-orm';
11+import { eq, and, lt, ne } from 'drizzle-orm';
1212 import { createSafeUser } from '$lib/types/chat';
1313 import { createLogger } from '$lib/utils/logger.server';
14+import { sseConnectionTracker } from '$lib/sseConnectionTracker';
15+import { buddyListCache } from '$lib/buddyListCache';
1416
1517 const log = createLogger('sse-server');
1618
@@ -52,7 +54,7 @@ function startTimeoutCheck() {
5254 })
5355 .where(
5456 and(
55- eq(users.status, 'online'), // Only update online users
57+ ne(users.status, 'offline'), // Update any non-offline users
5658 lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently
5759 )
5860 )
@@ -67,6 +69,9 @@ function startTimeoutCheck() {
6769 usersUpdated: result.length,
6870 timeoutThreshold: new Date(timeoutThreshold).toISOString()
6971 });
72+
73+ // Invalidate buddy list cache when users timeout
74+ buddyListCache.invalidate();
7075 }
7176 } catch (error) {
7277 log.error('Error during timeout check', { error });
@@ -84,19 +89,35 @@ function startBuddyListUpdateInterval() {
8489 const now = Date.now();
8590 // Only fetch and broadcast if 10 seconds have passed since last update
8691 if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) {
87- log.debug('Broadcasting buddy list update...');
88- // Query the complete buddy list from the database.
89- const buddyList = await db.select().from(users);
90- // Use createSafeUser to properly sanitize user data
91- const safeBuddyList = buddyList.map(user => createSafeUser(user));
92-
93- // Only broadcast if data has changed
94- if (JSON.stringify(safeBuddyList) !== JSON.stringify(lastBuddyListData)) {
95- lastBuddyListData = safeBuddyList;
96- sseEmitter.emit('sse', {
97- type: 'buddyListUpdate',
98- data: safeBuddyList
99- });
92+ // Only query database if cache is invalid or doesn't exist
93+ if (buddyListCache.needsRefresh()) {
94+ log.debug('Fetching buddy list from database (cache miss)...');
95+ // Query the complete buddy list from the database.
96+ const buddyList = await db.select().from(users);
97+ // Use createSafeUser to properly sanitize user data
98+ const safeBuddyList = buddyList.map(user => createSafeUser(user));
99+
100+ // Update cache and check if data changed
101+ const hasChanged = buddyListCache.update(safeBuddyList);
102+
103+ if (hasChanged) {
104+ log.debug('Broadcasting buddy list update (data changed)...');
105+ sseEmitter.emit('sse', {
106+ type: 'buddyListUpdate',
107+ data: safeBuddyList
108+ });
109+ }
110+ } else {
111+ // Use cached data
112+ const cachedData = buddyListCache.get();
113+ if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) {
114+ log.debug('Broadcasting buddy list update from cache...');
115+ lastBuddyListData = cachedData;
116+ sseEmitter.emit('sse', {
117+ type: 'buddyListUpdate',
118+ data: cachedData
119+ });
120+ }
100121 }
101122 lastBuddyListUpdate = now;
102123 }
@@ -106,10 +127,38 @@ function startBuddyListUpdateInterval() {
106127 }, 1000); // Check every second but only broadcast every 10 seconds
107128 }
108129
130+// Add periodic cleanup for stale connections
131+let connectionCleanupInterval: ReturnType<typeof setInterval>;
132+
133+function startConnectionCleanup() {
134+ if (connectionCleanupInterval) {
135+ clearInterval(connectionCleanupInterval);
136+ }
137+
138+ connectionCleanupInterval = setInterval(() => {
139+ const staleUserIds = sseConnectionTracker.cleanupStaleConnections();
140+ if (staleUserIds.length > 0) {
141+ // Mark stale connections as offline
142+ staleUserIds.forEach(async (userId) => {
143+ try {
144+ await db.update(users)
145+ .set({ status: 'offline', lastSeen: Date.now() })
146+ .where(eq(users.id, userId));
147+
148+ buddyListCache.invalidate();
149+ } catch (error) {
150+ log.error('Error updating stale user status', { userId, error });
151+ }
152+ });
153+ }
154+ }, 60000); // Run every minute
155+}
156+
109157 // <<< NEW: Ensure intervals are started only once >>>
110158 if (!globalThis.__sseIntervalsStarted) {
111159 startTimeoutCheck();
112160 startBuddyListUpdateInterval();
161+ startConnectionCleanup();
113162 globalThis.__sseIntervalsStarted = true;
114163 log.info('Started global intervals for timeout and buddy list updates');
115164 } else {
@@ -121,18 +170,21 @@ if (typeof process !== 'undefined') {
121170 process.on('beforeExit', () => {
122171 if (timeoutInterval) clearInterval(timeoutInterval);
123172 if (buddyListInterval) clearInterval(buddyListInterval);
173+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
124174 });
125175
126176 // Also handle SIGTERM and SIGINT
127177 process.on('SIGTERM', () => {
128178 if (timeoutInterval) clearInterval(timeoutInterval);
129179 if (buddyListInterval) clearInterval(buddyListInterval);
180+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
130181 process.exit(0);
131182 });
132183
133184 process.on('SIGINT', () => {
134185 if (timeoutInterval) clearInterval(timeoutInterval);
135186 if (buddyListInterval) clearInterval(buddyListInterval);
187+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
136188 process.exit(0);
137189 });
138190 }
@@ -160,29 +212,42 @@ export const GET: RequestHandler = async ({ request, locals, }) => {
160212
161213 const now = Date.now();
162214
163- try {
164- await db.update(users)
165- .set({
166- status: 'online',
167- lastSeen: now
168- })
169- .where(eq(users.id, userId));
170-
171- log.info('User status updated to online', {
172- userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,
173- timestamp: new Date(now).toISOString()
174- });
215+ // Only update status if this is a new connection
216+ const isNewConnection = sseConnectionTracker.addConnection(userId);
217+
218+ if (isNewConnection) {
219+ try {
220+ await db.update(users)
221+ .set({
222+ status: 'online',
223+ lastSeen: now
224+ })
225+ .where(eq(users.id, userId));
175226
176- sseEmitter.emit('sse', {
177- type: 'userStatusUpdate',
178- data: {
179- userId,
180- status: 'online',
181- lastSeen: now
182- }
227+ log.info('User status updated to online', {
228+ userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,
229+ timestamp: new Date(now).toISOString()
230+ });
231+
232+ // Invalidate buddy list cache when user status changes
233+ buddyListCache.invalidate();
234+
235+ sseEmitter.emit('sse', {
236+ type: 'userStatusUpdate',
237+ data: {
238+ userId,
239+ status: 'online',
240+ lastSeen: now
241+ }
242+ });
243+ } catch (error) {
244+ log.error('Error updating user status', { error });
245+ sseConnectionTracker.removeConnection(userId);
246+ }
247+ } else {
248+ log.debug('User already has active SSE connection, skipping status update', {
249+ userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`
183250 });
184- } catch (error) {
185- log.error('Error updating user status', { error });
186251 }
187252
188253 const stream = new ReadableStream({
@@ -225,19 +290,28 @@ export const GET: RequestHandler = async ({ request, locals, }) => {
225290 const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;
226291 log.info('Connection closed', { userId: maskedUserId, reason });
227292
228- const now = Date.now();
229- await db.update(users)
230- .set({ status: 'offline', lastSeen: now })
231- .where(eq(users.id, userId));
293+ // Remove from connection tracker
294+ const wasTracked = sseConnectionTracker.removeConnection(userId);
232295
233- sseEmitter.emit('sse', {
234- type: 'userStatusUpdate',
235- data: {
236- userId,
237- status: 'offline',
238- lastSeen: now
239- }
240- });
296+ // Only update database if this was the last connection
297+ if (wasTracked && !sseConnectionTracker.hasConnection(userId)) {
298+ const now = Date.now();
299+ await db.update(users)
300+ .set({ status: 'offline', lastSeen: now })
301+ .where(eq(users.id, userId));
302+
303+ // Invalidate buddy list cache when user goes offline
304+ buddyListCache.invalidate();
305+
306+ sseEmitter.emit('sse', {
307+ type: 'userStatusUpdate',
308+ data: {
309+ userId,
310+ status: 'offline',
311+ lastSeen: now
312+ }
313+ });
314+ }
241315 }
242316 });
243317
@@ -8,9 +8,11 @@ import type { RequestHandler } from './$types';
8 import { sseEmitter } from '$lib/sseEmitter';8 import { sseEmitter } from '$lib/sseEmitter';
9 import db from '$lib/db/db.server';9 import db from '$lib/db/db.server';
10 import { users } from '$lib/db/schema';10 import { users } from '$lib/db/schema';
11-import { eq, and, lt } from 'drizzle-orm';11+import { eq, and, lt, ne } from 'drizzle-orm';
12 import { createSafeUser } from '$lib/types/chat';12 import { createSafeUser } from '$lib/types/chat';
13 import { createLogger } from '$lib/utils/logger.server';13 import { createLogger } from '$lib/utils/logger.server';
14+import { sseConnectionTracker } from '$lib/sseConnectionTracker';
15+import { buddyListCache } from '$lib/buddyListCache';
14 16
15 const log = createLogger('sse-server');17 const log = createLogger('sse-server');
16 18
@@ -52,7 +54,7 @@ function startTimeoutCheck() {
52 })54 })
53 .where(55 .where(
54 and(56 and(
55- eq(users.status, 'online'), // Only update online users57+ ne(users.status, 'offline'), // Update any non-offline users
56 lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently58 lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently
57 )59 )
58 )60 )
@@ -67,6 +69,9 @@ function startTimeoutCheck() {
67 usersUpdated: result.length,69 usersUpdated: result.length,
68 timeoutThreshold: new Date(timeoutThreshold).toISOString()70 timeoutThreshold: new Date(timeoutThreshold).toISOString()
69 });71 });
72+
73+ // Invalidate buddy list cache when users timeout
74+ buddyListCache.invalidate();
70 }75 }
71 } catch (error) {76 } catch (error) {
72 log.error('Error during timeout check', { error });77 log.error('Error during timeout check', { error });
@@ -84,19 +89,35 @@ function startBuddyListUpdateInterval() {
84 const now = Date.now();89 const now = Date.now();
85 // Only fetch and broadcast if 10 seconds have passed since last update90 // Only fetch and broadcast if 10 seconds have passed since last update
86 if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) {91 if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) {
87- log.debug('Broadcasting buddy list update...');92+ // Only query database if cache is invalid or doesn't exist
88- // Query the complete buddy list from the database.93+ if (buddyListCache.needsRefresh()) {
89- const buddyList = await db.select().from(users);94+ log.debug('Fetching buddy list from database (cache miss)...');
90- // Use createSafeUser to properly sanitize user data95+ // Query the complete buddy list from the database.
91- const safeBuddyList = buddyList.map(user => createSafeUser(user));96+ const buddyList = await db.select().from(users);
92- 97+ // Use createSafeUser to properly sanitize user data
93- // Only broadcast if data has changed98+ const safeBuddyList = buddyList.map(user => createSafeUser(user));
94- if (JSON.stringify(safeBuddyList) !== JSON.stringify(lastBuddyListData)) {99+
95- lastBuddyListData = safeBuddyList;100+ // Update cache and check if data changed
96- sseEmitter.emit('sse', {101+ const hasChanged = buddyListCache.update(safeBuddyList);
97- type: 'buddyListUpdate',102+
98- data: safeBuddyList103+ if (hasChanged) {
99- });104+ log.debug('Broadcasting buddy list update (data changed)...');
105+ sseEmitter.emit('sse', {
106+ type: 'buddyListUpdate',
107+ data: safeBuddyList
108+ });
109+ }
110+ } else {
111+ // Use cached data
112+ const cachedData = buddyListCache.get();
113+ if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) {
114+ log.debug('Broadcasting buddy list update from cache...');
115+ lastBuddyListData = cachedData;
116+ sseEmitter.emit('sse', {
117+ type: 'buddyListUpdate',
118+ data: cachedData
119+ });
120+ }
100 }121 }
101 lastBuddyListUpdate = now;122 lastBuddyListUpdate = now;
102 }123 }
@@ -106,10 +127,38 @@ function startBuddyListUpdateInterval() {
106 }, 1000); // Check every second but only broadcast every 10 seconds127 }, 1000); // Check every second but only broadcast every 10 seconds
107 }128 }
108 129
130+// Add periodic cleanup for stale connections
131+let connectionCleanupInterval: ReturnType<typeof setInterval>;
132+
133+function startConnectionCleanup() {
134+ if (connectionCleanupInterval) {
135+ clearInterval(connectionCleanupInterval);
136+ }
137+
138+ connectionCleanupInterval = setInterval(() => {
139+ const staleUserIds = sseConnectionTracker.cleanupStaleConnections();
140+ if (staleUserIds.length > 0) {
141+ // Mark stale connections as offline
142+ staleUserIds.forEach(async (userId) => {
143+ try {
144+ await db.update(users)
145+ .set({ status: 'offline', lastSeen: Date.now() })
146+ .where(eq(users.id, userId));
147+
148+ buddyListCache.invalidate();
149+ } catch (error) {
150+ log.error('Error updating stale user status', { userId, error });
151+ }
152+ });
153+ }
154+ }, 60000); // Run every minute
155+}
156+
109 // <<< NEW: Ensure intervals are started only once >>>157 // <<< NEW: Ensure intervals are started only once >>>
110 if (!globalThis.__sseIntervalsStarted) {158 if (!globalThis.__sseIntervalsStarted) {
111 startTimeoutCheck();159 startTimeoutCheck();
112 startBuddyListUpdateInterval();160 startBuddyListUpdateInterval();
161+ startConnectionCleanup();
113 globalThis.__sseIntervalsStarted = true;162 globalThis.__sseIntervalsStarted = true;
114 log.info('Started global intervals for timeout and buddy list updates');163 log.info('Started global intervals for timeout and buddy list updates');
115 } else {164 } else {
@@ -121,18 +170,21 @@ if (typeof process !== 'undefined') {
121 process.on('beforeExit', () => {170 process.on('beforeExit', () => {
122 if (timeoutInterval) clearInterval(timeoutInterval);171 if (timeoutInterval) clearInterval(timeoutInterval);
123 if (buddyListInterval) clearInterval(buddyListInterval);172 if (buddyListInterval) clearInterval(buddyListInterval);
173+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
124 });174 });
125 175
126 // Also handle SIGTERM and SIGINT176 // Also handle SIGTERM and SIGINT
127 process.on('SIGTERM', () => {177 process.on('SIGTERM', () => {
128 if (timeoutInterval) clearInterval(timeoutInterval);178 if (timeoutInterval) clearInterval(timeoutInterval);
129 if (buddyListInterval) clearInterval(buddyListInterval);179 if (buddyListInterval) clearInterval(buddyListInterval);
180+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
130 process.exit(0);181 process.exit(0);
131 });182 });
132 183
133 process.on('SIGINT', () => {184 process.on('SIGINT', () => {
134 if (timeoutInterval) clearInterval(timeoutInterval);185 if (timeoutInterval) clearInterval(timeoutInterval);
135 if (buddyListInterval) clearInterval(buddyListInterval);186 if (buddyListInterval) clearInterval(buddyListInterval);
187+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
136 process.exit(0);188 process.exit(0);
137 });189 });
138 }190 }
@@ -160,29 +212,42 @@ export const GET: RequestHandler = async ({ request, locals, }) => {
160 212
161 const now = Date.now();213 const now = Date.now();
162 214
163- try {215+ // Only update status if this is a new connection
164- await db.update(users)216+ const isNewConnection = sseConnectionTracker.addConnection(userId);
165- .set({ 217+
166- status: 'online',218+ if (isNewConnection) {
167- lastSeen: now 219+ try {
168- })220+ await db.update(users)
169- .where(eq(users.id, userId));221+ .set({
170-222+ status: 'online',
171- log.info('User status updated to online', {223+ lastSeen: now
172- userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,224+ })
173- timestamp: new Date(now).toISOString()225+ .where(eq(users.id, userId));
174- });
175 226
176- sseEmitter.emit('sse', {227+ log.info('User status updated to online', {
177- type: 'userStatusUpdate',228+ userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,
178- data: {229+ timestamp: new Date(now).toISOString()
179- userId,230+ });
180- status: 'online',231+
181- lastSeen: now232+ // Invalidate buddy list cache when user status changes
182- }233+ buddyListCache.invalidate();
234+
235+ sseEmitter.emit('sse', {
236+ type: 'userStatusUpdate',
237+ data: {
238+ userId,
239+ status: 'online',
240+ lastSeen: now
241+ }
242+ });
243+ } catch (error) {
244+ log.error('Error updating user status', { error });
245+ sseConnectionTracker.removeConnection(userId);
246+ }
247+ } else {
248+ log.debug('User already has active SSE connection, skipping status update', {
249+ userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`
183 });250 });
184- } catch (error) {
185- log.error('Error updating user status', { error });
186 }251 }
187 252
188 const stream = new ReadableStream({253 const stream = new ReadableStream({
@@ -225,19 +290,28 @@ export const GET: RequestHandler = async ({ request, locals, }) => {
225 const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;290 const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;
226 log.info('Connection closed', { userId: maskedUserId, reason });291 log.info('Connection closed', { userId: maskedUserId, reason });
227 292
228- const now = Date.now();293+ // Remove from connection tracker
229- await db.update(users)294+ const wasTracked = sseConnectionTracker.removeConnection(userId);
230- .set({ status: 'offline', lastSeen: now })
231- .where(eq(users.id, userId));
232 295
233- sseEmitter.emit('sse', { 296+ // Only update database if this was the last connection
234- type: 'userStatusUpdate', 297+ if (wasTracked && !sseConnectionTracker.hasConnection(userId)) {
235- data: { 298+ const now = Date.now();
236- userId, 299+ await db.update(users)
237- status: 'offline', 300+ .set({ status: 'offline', lastSeen: now })
238- lastSeen: now 301+ .where(eq(users.id, userId));
239- } 302+
240- });303+ // Invalidate buddy list cache when user goes offline
304+ buddyListCache.invalidate();
305+
306+ sseEmitter.emit('sse', {
307+ type: 'userStatusUpdate',
308+ data: {
309+ userId,
310+ status: 'offline',
311+ lastSeen: now
312+ }
313+ });
314+ }
241 }315 }
242 });316 });
243 317
M src/routes/api/status/+server.ts +4 -0
@@ -5,6 +5,7 @@ import { validateSessionToken, generateSessionToken, createSession } from '$lib/
55 import { setSessionTokenCookie } from '$lib/api/session.cookie';
66 import { createSafeUser } from '$lib/types/chat';
77 import { createLogger } from '$lib/utils/logger.server';
8+import { buddyListCache } from '$lib/buddyListCache';
89 import type { RequestHandler } from './$types';
910
1011 const log = createLogger('status-server');
@@ -46,6 +47,9 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
4647 .returning()
4748 .get();
4849 log.debug('Database updated successfully', { userId: maskedUserId, status });
50+
51+ // Invalidate buddy list cache when status changes
52+ buddyListCache.invalidate();
4953
5054 // Renew session if status is online and session expires in less than 1 day
5155 if (status === 'online') {
@@ -5,6 +5,7 @@ import { validateSessionToken, generateSessionToken, createSession } from '$lib/
5 import { setSessionTokenCookie } from '$lib/api/session.cookie';5 import { setSessionTokenCookie } from '$lib/api/session.cookie';
6 import { createSafeUser } from '$lib/types/chat';6 import { createSafeUser } from '$lib/types/chat';
7 import { createLogger } from '$lib/utils/logger.server';7 import { createLogger } from '$lib/utils/logger.server';
8+import { buddyListCache } from '$lib/buddyListCache';
8 import type { RequestHandler } from './$types';9 import type { RequestHandler } from './$types';
9 10
10 const log = createLogger('status-server');11 const log = createLogger('status-server');
@@ -46,6 +47,9 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
46 .returning()47 .returning()
47 .get();48 .get();
48 log.debug('Database updated successfully', { userId: maskedUserId, status });49 log.debug('Database updated successfully', { userId: maskedUserId, status });
50+
51+ // Invalidate buddy list cache when status changes
52+ buddyListCache.invalidate();
49 53
50 // Renew session if status is online and session expires in less than 1 day54 // Renew session if status is online and session expires in less than 1 day
51 if (status === 'online') {55 if (status === 'online') {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close