@fyresmith/hive-server 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,46 +1,24 @@
1
1
  import { socketMiddleware } from './auth.js';
2
2
  import * as vault from './vaultManager.js';
3
- import { serverAdapterRegistry } from './adapterRegistry.js';
4
- import { negotiateProtocol, PROTOCOL_V1 } from './collabProtocol.js';
5
- import { createCollabStore } from './collabStore.js';
6
- import { parseMentions } from './mentionUtils.js';
7
- import { sendDiscordWebhook } from './discordWebhook.js';
8
3
 
9
4
  /**
10
- * presenceByFile: file path -> Set of socket IDs
5
+ * presenceByFile: file path Set of socket IDs
11
6
  * @type {Map<string, Set<string>>}
12
7
  */
13
8
  const presenceByFile = new Map();
14
9
 
15
10
  /**
16
- * socketToFiles: socket ID -> Set of file paths
11
+ * socketToFiles: socket ID Set of file paths (for cleanup on disconnect)
17
12
  * @type {Map<string, Set<string>>}
18
13
  */
19
14
  const socketToFiles = new Map();
20
15
 
21
16
  /**
22
- * userBySocket: socket ID -> user object
17
+ * userBySocket: socket ID user object
23
18
  * @type {Map<string, object>}
24
19
  */
25
20
  const userBySocket = new Map();
26
21
 
27
- /**
28
- * presenceStateBySocket: socket ID -> transient presence state
29
- * @type {Map<string, { lastSeenAt: number, protocolVersion: number, activeFile: string|null, cursor: any, viewport: any }>}
30
- */
31
- const presenceStateBySocket = new Map();
32
-
33
- const collabStore = createCollabStore();
34
- const SHA256_HEX_RE = /^[a-f0-9]{64}$/;
35
-
36
- const PRESENCE_STALE_MS = Math.max(
37
- 15000,
38
- parseInt(process.env.HIVE_PRESENCE_STALE_MS ?? '45000', 10) || 45000,
39
- );
40
- const PRESENCE_SWEEP_MS = Math.min(15000, Math.max(5000, Math.trunc(PRESENCE_STALE_MS / 3)));
41
-
42
- let staleSweepTimer = null;
43
-
44
22
  function respond(cb, payload) {
45
23
  if (typeof cb === 'function') cb(payload);
46
24
  }
@@ -60,243 +38,17 @@ function normalizeHexColor(color) {
60
38
  return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed.toLowerCase() : null;
61
39
  }
62
40
 
63
- function normalizeExpectedHash(value) {
64
- if (value === null) return null;
65
- if (typeof value !== 'string') return undefined;
66
- const normalized = value.trim().toLowerCase();
67
- if (!SHA256_HEX_RE.test(normalized)) return undefined;
68
- return normalized;
69
- }
70
-
71
- function parseHistoryLimit(raw) {
72
- if (raw === undefined || raw === null) return 50;
73
- const num = Number(raw);
74
- if (!Number.isFinite(num)) return 50;
75
- const int = Math.trunc(num);
76
- if (int < 1) return 1;
77
- if (int > 500) return 500;
78
- return int;
79
- }
80
-
81
- function actorFromUser(user) {
82
- return {
83
- id: user?.id ?? null,
84
- username: user?.username ?? null,
85
- };
86
- }
87
-
88
- function getWorkspaceKey(filePath) {
89
- if (typeof filePath !== 'string' || filePath.length === 0) return 'root';
90
- const normalized = filePath.replace(/\\/g, '/');
91
- const [head] = normalized.split('/');
92
- return head || 'root';
93
- }
94
-
95
- function normalizeAdapterContent(relPath, content, cb) {
96
- const adapter = serverAdapterRegistry.getByPath(relPath);
97
- if (!adapter) return true;
98
-
99
- const ok = adapter.validateContent(content);
100
- if (!ok) {
101
- respond(cb, {
102
- ok: false,
103
- code: 'BAD_REQUEST',
104
- error: `Invalid payload for adapter ${adapter.adapterId}`,
105
- adapterId: adapter.adapterId,
106
- });
107
- }
108
- return ok;
109
- }
110
-
111
- function ensureState(socketId) {
112
- let state = presenceStateBySocket.get(socketId);
113
- if (!state) {
114
- state = {
115
- lastSeenAt: Date.now(),
116
- protocolVersion: PROTOCOL_V1,
117
- activeFile: null,
118
- cursor: null,
119
- viewport: null,
120
- };
121
- presenceStateBySocket.set(socketId, state);
122
- }
123
- return state;
124
- }
125
-
126
- function touchPresence(socketId, patch = null) {
127
- const state = ensureState(socketId);
128
- state.lastSeenAt = Date.now();
129
- if (patch && typeof patch === 'object') {
130
- if (patch.activeFile === null || typeof patch.activeFile === 'string') {
131
- state.activeFile = patch.activeFile;
132
- }
133
- if (patch.cursor !== undefined) {
134
- state.cursor = patch.cursor;
135
- }
136
- if (patch.viewport !== undefined) {
137
- state.viewport = patch.viewport;
138
- }
139
- if (Number.isInteger(patch.protocolVersion)) {
140
- state.protocolVersion = patch.protocolVersion;
141
- }
142
- }
143
- presenceStateBySocket.set(socketId, state);
144
- return state;
145
- }
146
-
147
- function presenceList() {
148
- const out = [];
149
- for (const [socketId, user] of userBySocket) {
150
- const state = presenceStateBySocket.get(socketId);
151
- const openFiles = [...(socketToFiles.get(socketId) ?? new Set())];
152
- out.push({
153
- socketId,
154
- user,
155
- openFiles,
156
- activeFile: state?.activeFile ?? openFiles[0] ?? null,
157
- cursor: state?.cursor ?? null,
158
- viewport: state?.viewport ?? null,
159
- lastSeenAt: state?.lastSeenAt ?? null,
160
- stale: state ? (Date.now() - state.lastSeenAt) > PRESENCE_STALE_MS : true,
161
- });
162
- }
163
- return out;
164
- }
165
-
166
- function getSocketsByUserId(userId) {
167
- const out = [];
168
- for (const [socketId, user] of userBySocket) {
169
- if (user?.id === userId) out.push(socketId);
170
- }
171
- return out;
172
- }
173
-
174
- function getUsersByMention(mentionUsername) {
175
- const username = String(mentionUsername).toLowerCase();
176
- const seen = new Set();
177
- const users = [];
178
-
179
- for (const user of userBySocket.values()) {
180
- if (!user?.id || !user?.username) continue;
181
- if (String(user.username).toLowerCase() !== username) continue;
182
- if (seen.has(user.id)) continue;
183
- seen.add(user.id);
184
- users.push(user);
185
- }
186
-
187
- return users;
188
- }
189
-
190
- async function maybeDeliverNotifyEvent(io, event) {
191
- const decision = await collabStore.shouldDeliverNotification(event.targetUser.id, event.filePath, event.kind);
192
- if (!decision.deliver) {
193
- return;
194
- }
195
-
196
- const payload = {
197
- ...event,
198
- mode: decision.mode,
199
- ts: Date.now(),
200
- };
201
-
202
- const targetSockets = getSocketsByUserId(event.targetUser.id);
203
- for (const socketId of targetSockets) {
204
- io.to(socketId).emit('collab:notify:event', payload);
205
- }
206
-
207
- await sendDiscordWebhook(payload);
208
- }
209
-
210
- function activityRoomForWorkspace() {
211
- return 'collab:activity:workspace';
212
- }
213
-
214
- function activityRoomForFile(filePath) {
215
- return `collab:activity:file:${filePath}`;
216
- }
217
-
218
- function publishActivity(io, activity) {
219
- io.to(activityRoomForWorkspace()).emit('collab:activity:event', { activity });
220
- if (activity?.filePath) {
221
- io.to(activityRoomForFile(activity.filePath)).emit('collab:activity:event', { activity });
222
- }
223
- }
224
-
225
- async function recordActivity(io, { type, filePath = null, actor = null, payload = null, groupKey = null }) {
226
- const activity = await collabStore.recordActivity({
227
- type,
228
- filePath,
229
- actor,
230
- payload,
231
- groupKey,
232
- });
233
- publishActivity(io, activity);
234
- return activity;
235
- }
236
-
237
- async function getCurrentHash(relPath) {
238
- try {
239
- const currentContent = await vault.readFile(relPath);
240
- return vault.hashContent(currentContent);
241
- } catch (err) {
242
- if (err && err.code === 'ENOENT') return null;
243
- throw err;
244
- }
245
- }
246
-
247
- function ensureStaleSweep(io) {
248
- if (staleSweepTimer) return;
249
-
250
- staleSweepTimer = setInterval(() => {
251
- const now = Date.now();
252
-
253
- for (const [socketId, state] of presenceStateBySocket) {
254
- if (state.protocolVersion < 2) continue;
255
- if (now - state.lastSeenAt <= PRESENCE_STALE_MS) continue;
256
-
257
- const user = userBySocket.get(socketId);
258
- const openFiles = socketToFiles.get(socketId) ?? new Set();
259
-
260
- for (const relPath of openFiles) {
261
- presenceByFile.get(relPath)?.delete(socketId);
262
- io.emit('presence-file-closed', {
263
- relPath,
264
- user,
265
- stale: true,
266
- });
267
- }
268
-
269
- openFiles.clear();
270
- socketToFiles.set(socketId, openFiles);
271
- state.activeFile = null;
272
- state.cursor = null;
273
- state.viewport = null;
274
- state.lastSeenAt = now;
275
-
276
- io.emit('collab:presence:stale', {
277
- user,
278
- socketId,
279
- ts: now,
280
- });
281
- }
282
- }, PRESENCE_SWEEP_MS);
283
-
284
- if (typeof staleSweepTimer.unref === 'function') {
285
- staleSweepTimer.unref();
286
- }
287
- }
288
-
289
41
  /**
290
42
  * Attach all Socket.IO event handlers.
291
43
  *
292
44
  * @param {import('socket.io').Server} io
293
- * @param {() => Set<string>} getActiveRooms - returns encoded docNames currently in Yjs
45
+ * @param {() => Set<string>} getActiveRooms - returns encoded docNames currently in Yjs
294
46
  * @param {(relPath: string, hash: string, excludeSocketId: string|null) => void} broadcastFileUpdated
295
47
  * @param {(relPath: string) => Promise<void>} forceCloseRoom
296
48
  */
297
49
  export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom) {
50
+ // Auth middleware for every socket connection
298
51
  io.use(socketMiddleware);
299
- ensureStaleSweep(io);
300
52
 
301
53
  io.on('connection', (socket) => {
302
54
  const user = socket.user;
@@ -304,99 +56,14 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
304
56
 
305
57
  userBySocket.set(socket.id, user);
306
58
  socketToFiles.set(socket.id, new Set());
307
- touchPresence(socket.id);
308
59
 
60
+ // Notify others this user joined
309
61
  socket.broadcast.emit('user-joined', { user });
310
62
 
311
- socket.on('collab:hello', (payload, cb) => {
312
- try {
313
- const negotiated = negotiateProtocol(payload, serverAdapterRegistry);
314
- const protocolVersion = negotiated.negotiatedProtocol;
315
- touchPresence(socket.id, { protocolVersion });
316
- respond(cb, { ok: true, ...negotiated });
317
- } catch (err) {
318
- respond(cb, { ok: false, error: err?.message ?? String(err) });
319
- }
320
- });
321
-
322
- socket.on('collab:presence:heartbeat', (payload, cb) => {
323
- const relPath = typeof payload?.activeFile === 'string' ? payload.activeFile : null;
324
- if (relPath && !isAllowedPath(relPath)) {
325
- rejectPath(cb, relPath);
326
- return;
327
- }
328
-
329
- const state = touchPresence(socket.id, {
330
- activeFile: relPath,
331
- cursor: payload?.cursor ?? null,
332
- viewport: payload?.viewport ?? null,
333
- });
334
-
335
- socket.broadcast.emit('collab:presence:heartbeat', {
336
- user,
337
- location: {
338
- activeFile: state.activeFile,
339
- cursor: state.cursor,
340
- viewport: state.viewport,
341
- },
342
- ts: state.lastSeenAt,
343
- });
344
-
345
- respond(cb, {
346
- ok: true,
347
- lastSeenAt: state.lastSeenAt,
348
- });
349
- });
350
-
351
- socket.on('collab:presence:list', (_payload, cb) => {
352
- respond(cb, {
353
- ok: true,
354
- users: presenceList(),
355
- });
356
- });
357
-
358
- socket.on('collab:presence:jump', (payload, cb) => {
359
- const userId = payload?.userId;
360
- if (typeof userId !== 'string' || userId.length === 0) {
361
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing userId' });
362
- return;
363
- }
364
-
365
- let target = null;
366
- for (const [socketId, currentUser] of userBySocket) {
367
- if (currentUser?.id !== userId) continue;
368
- const state = presenceStateBySocket.get(socketId);
369
- const openFiles = [...(socketToFiles.get(socketId) ?? new Set())];
370
- const activeFile = state?.activeFile ?? openFiles[0] ?? null;
371
- if (!activeFile) continue;
372
- target = {
373
- user: currentUser,
374
- location: {
375
- activeFile,
376
- cursor: state?.cursor ?? null,
377
- viewport: state?.viewport ?? null,
378
- },
379
- };
380
- break;
381
- }
382
-
383
- if (!target) {
384
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Collaborator location unavailable' });
385
- return;
386
- }
387
-
388
- respond(cb, {
389
- ok: true,
390
- ...target,
391
- });
392
- });
393
-
394
63
  // -----------------------------------------------------------------------
395
- // Legacy sync events (kept for v1 compatibility)
64
+ // vault-sync-request
396
65
  // -----------------------------------------------------------------------
397
-
398
66
  socket.on('vault-sync-request', async (cb) => {
399
- touchPresence(socket.id);
400
67
  try {
401
68
  const manifest = await vault.getManifest();
402
69
  respond(cb, { ok: true, manifest });
@@ -406,8 +73,10 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
406
73
  }
407
74
  });
408
75
 
76
+ // -----------------------------------------------------------------------
77
+ // file-read
78
+ // -----------------------------------------------------------------------
409
79
  socket.on('file-read', async (relPath, cb) => {
410
- touchPresence(socket.id);
411
80
  if (!isAllowedPath(relPath)) {
412
81
  rejectPath(cb, relPath);
413
82
  return;
@@ -422,56 +91,21 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
422
91
  }
423
92
  });
424
93
 
94
+ // -----------------------------------------------------------------------
95
+ // file-write
96
+ // -----------------------------------------------------------------------
425
97
  socket.on('file-write', async (payload, cb) => {
426
- touchPresence(socket.id);
427
98
  const relPath = payload?.relPath;
428
99
  const content = payload?.content;
429
- const expectedHash = normalizeExpectedHash(payload?.expectedHash);
430
100
  if (!isAllowedPath(relPath) || typeof content !== 'string') {
431
101
  rejectPath(cb, relPath);
432
102
  return;
433
103
  }
434
- if (payload?.expectedHash !== undefined && expectedHash === undefined) {
435
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Invalid expectedHash' });
436
- return;
437
- }
438
- if (!normalizeAdapterContent(relPath, content, cb)) {
439
- return;
440
- }
441
-
442
104
  try {
443
- const currentHash = await getCurrentHash(relPath);
444
- if (expectedHash !== undefined && expectedHash !== currentHash) {
445
- respond(cb, {
446
- ok: false,
447
- code: 'CONFLICT',
448
- error: 'Write conflict: file changed on server',
449
- relPath,
450
- currentHash,
451
- });
452
- return;
453
- }
454
-
455
- await vault.writeFile(relPath, content, {
456
- history: {
457
- action: 'write',
458
- source: 'socket',
459
- actor: actorFromUser(user),
460
- },
461
- });
105
+ await vault.writeFile(relPath, content);
462
106
  const hash = vault.hashContent(content);
463
107
  socket.broadcast.emit('file-updated', { relPath, hash, user });
464
- broadcastFileUpdated?.(relPath, hash, socket.id);
465
- respond(cb, { ok: true, hash, previousHash: currentHash });
466
-
467
- await recordActivity(io, {
468
- type: 'edit',
469
- filePath: relPath,
470
- actor: actorFromUser(user),
471
- payload: { source: 'socket', adapterId: serverAdapterRegistry.getByPath(relPath)?.adapterId ?? null },
472
- groupKey: `${user.id}:${relPath}:edit`,
473
- });
474
-
108
+ respond(cb, { ok: true, hash });
475
109
  console.log(`[socket] file-write: ${relPath} by ${user.username}`);
476
110
  } catch (err) {
477
111
  console.error(`[socket] file-write error (${relPath}):`, err);
@@ -479,38 +113,20 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
479
113
  }
480
114
  });
481
115
 
116
+ // -----------------------------------------------------------------------
117
+ // file-create
118
+ // -----------------------------------------------------------------------
482
119
  socket.on('file-create', async (payload, cb) => {
483
- touchPresence(socket.id);
484
120
  const relPath = payload?.relPath;
485
121
  const content = payload?.content;
486
122
  if (!isAllowedPath(relPath) || typeof content !== 'string') {
487
123
  rejectPath(cb, relPath);
488
124
  return;
489
125
  }
490
- if (!normalizeAdapterContent(relPath, content, cb)) {
491
- return;
492
- }
493
-
494
126
  try {
495
- await vault.writeFile(relPath, content, {
496
- history: {
497
- action: 'create',
498
- source: 'socket',
499
- actor: actorFromUser(user),
500
- },
501
- });
502
- const hash = vault.hashContent(content);
127
+ await vault.writeFile(relPath, content);
503
128
  io.emit('file-created', { relPath, user });
504
- respond(cb, { ok: true, hash });
505
-
506
- await recordActivity(io, {
507
- type: 'create',
508
- filePath: relPath,
509
- actor: actorFromUser(user),
510
- payload: { source: 'socket' },
511
- groupKey: `${user.id}:${relPath}:create`,
512
- });
513
-
129
+ respond(cb, { ok: true });
514
130
  console.log(`[socket] file-create: ${relPath} by ${user.username}`);
515
131
  } catch (err) {
516
132
  console.error(`[socket] file-create error (${relPath}):`, err);
@@ -518,35 +134,23 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
518
134
  }
519
135
  });
520
136
 
137
+ // -----------------------------------------------------------------------
138
+ // file-delete
139
+ // -----------------------------------------------------------------------
521
140
  socket.on('file-delete', async (relPath, cb) => {
522
- touchPresence(socket.id);
523
141
  if (!isAllowedPath(relPath)) {
524
142
  rejectPath(cb, relPath);
525
143
  return;
526
144
  }
527
145
  try {
146
+ // Force-close active Yjs room first
528
147
  const docName = encodeURIComponent(relPath);
529
148
  if (getActiveRooms().has(docName)) {
530
149
  await forceCloseRoom(relPath);
531
150
  }
532
- await vault.deleteFile(relPath, {
533
- history: {
534
- action: 'delete',
535
- source: 'socket',
536
- actor: actorFromUser(user),
537
- },
538
- });
151
+ await vault.deleteFile(relPath);
539
152
  io.emit('file-deleted', { relPath, user });
540
153
  respond(cb, { ok: true });
541
-
542
- await recordActivity(io, {
543
- type: 'delete',
544
- filePath: relPath,
545
- actor: actorFromUser(user),
546
- payload: { source: 'socket' },
547
- groupKey: `${user.id}:${relPath}:delete`,
548
- });
549
-
550
154
  console.log(`[socket] file-delete: ${relPath} by ${user.username}`);
551
155
  } catch (err) {
552
156
  console.error(`[socket] file-delete error (${relPath}):`, err);
@@ -554,8 +158,10 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
554
158
  }
555
159
  });
556
160
 
161
+ // -----------------------------------------------------------------------
162
+ // file-rename
163
+ // -----------------------------------------------------------------------
557
164
  socket.on('file-rename', async (payload, cb) => {
558
- touchPresence(socket.id);
559
165
  const oldPath = payload?.oldPath;
560
166
  const newPath = payload?.newPath;
561
167
  if (!isAllowedPath(oldPath) || !isAllowedPath(newPath)) {
@@ -563,506 +169,48 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
563
169
  return;
564
170
  }
565
171
  try {
172
+ // Force-close active Yjs room for old path
566
173
  const docName = encodeURIComponent(oldPath);
567
174
  if (getActiveRooms().has(docName)) {
568
175
  await forceCloseRoom(oldPath);
569
176
  }
570
- await vault.renameFile(oldPath, newPath, {
571
- history: {
572
- source: 'socket',
573
- actor: actorFromUser(user),
574
- },
575
- });
177
+ await vault.renameFile(oldPath, newPath);
576
178
  io.emit('file-renamed', { oldPath, newPath, user });
577
179
  respond(cb, { ok: true });
578
-
579
- await recordActivity(io, {
580
- type: 'rename',
581
- filePath: newPath,
582
- actor: actorFromUser(user),
583
- payload: { oldPath, newPath, source: 'socket' },
584
- groupKey: `${user.id}:${oldPath}:rename`,
585
- });
586
-
587
- console.log(`[socket] file-rename: ${oldPath} -> ${newPath} by ${user.username}`);
180
+ console.log(`[socket] file-rename: ${oldPath} → ${newPath} by ${user.username}`);
588
181
  } catch (err) {
589
182
  console.error(`[socket] file-rename error (${oldPath}):`, err);
590
183
  respond(cb, { ok: false, error: err.message });
591
184
  }
592
185
  });
593
186
 
594
- socket.on('file-history-list', async (payload, cb) => {
595
- touchPresence(socket.id);
596
- const relPath = payload?.relPath;
597
- if (!isAllowedPath(relPath)) {
598
- rejectPath(cb, relPath);
599
- return;
600
- }
601
- try {
602
- const limit = parseHistoryLimit(payload?.limit);
603
- const versions = await vault.listFileHistory(relPath, limit);
604
- respond(cb, { ok: true, versions });
605
- } catch (err) {
606
- console.error(`[socket] file-history-list error (${relPath}):`, err);
607
- respond(cb, { ok: false, error: err.message });
608
- }
609
- });
610
-
611
- socket.on('file-history-read', async (payload, cb) => {
612
- touchPresence(socket.id);
613
- const relPath = payload?.relPath;
614
- const versionId = payload?.versionId;
615
- if (!isAllowedPath(relPath)) {
616
- rejectPath(cb, relPath);
617
- return;
618
- }
619
- if (typeof versionId !== 'string' || versionId.length === 0) {
620
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing versionId' });
621
- return;
622
- }
623
- try {
624
- const version = await vault.readFileHistoryVersion(relPath, versionId);
625
- respond(cb, { ok: true, version });
626
- } catch (err) {
627
- console.error(`[socket] file-history-read error (${relPath} @ ${versionId}):`, err);
628
- respond(cb, { ok: false, error: err.message });
629
- }
630
- });
631
-
187
+ // -----------------------------------------------------------------------
188
+ // presence-file-opened
189
+ // -----------------------------------------------------------------------
632
190
  socket.on('presence-file-opened', (payload) => {
633
191
  const relPath = typeof payload === 'string' ? payload : payload?.relPath;
634
192
  const color = normalizeHexColor(typeof payload === 'string' ? null : payload?.color);
635
193
  if (!isAllowedPath(relPath)) return;
636
-
637
194
  if (!presenceByFile.has(relPath)) presenceByFile.set(relPath, new Set());
638
195
  presenceByFile.get(relPath).add(socket.id);
639
196
  socketToFiles.get(socket.id)?.add(relPath);
640
-
641
- touchPresence(socket.id, { activeFile: relPath });
642
197
  const presenceUser = color ? { ...user, color } : user;
643
198
  socket.broadcast.emit('presence-file-opened', { relPath, user: presenceUser });
644
199
  });
645
200
 
201
+ // -----------------------------------------------------------------------
202
+ // presence-file-closed
203
+ // -----------------------------------------------------------------------
646
204
  socket.on('presence-file-closed', (relPath) => {
647
205
  if (!isAllowedPath(relPath)) return;
648
206
  presenceByFile.get(relPath)?.delete(socket.id);
649
207
  socketToFiles.get(socket.id)?.delete(relPath);
650
-
651
- const state = touchPresence(socket.id);
652
- if (state.activeFile === relPath) {
653
- state.activeFile = null;
654
- }
655
-
656
208
  socket.broadcast.emit('presence-file-closed', { relPath, user });
657
209
  });
658
210
 
659
211
  // -----------------------------------------------------------------------
660
- // Collaboration artifacts / activity / notifications
212
+ // disconnect
661
213
  // -----------------------------------------------------------------------
662
-
663
- socket.on('collab:thread:list', async (payload, cb) => {
664
- touchPresence(socket.id);
665
- const filePath = payload?.filePath;
666
- if (filePath && !isAllowedPath(filePath)) {
667
- rejectPath(cb, filePath);
668
- return;
669
- }
670
-
671
- try {
672
- const threads = await collabStore.listThreads({
673
- filePath: filePath ?? null,
674
- status: payload?.status ?? null,
675
- });
676
- respond(cb, { ok: true, threads });
677
- } catch (err) {
678
- respond(cb, { ok: false, error: err?.message ?? String(err) });
679
- }
680
- });
681
-
682
- socket.on('collab:thread:create', async (payload, cb) => {
683
- touchPresence(socket.id);
684
- const filePath = payload?.filePath;
685
- if (!isAllowedPath(filePath)) {
686
- rejectPath(cb, filePath);
687
- return;
688
- }
689
-
690
- try {
691
- const body = typeof payload?.body === 'string' ? payload.body : '';
692
- const mentions = parseMentions(body);
693
- const thread = await collabStore.createThread({
694
- filePath,
695
- anchor: payload?.anchor ?? null,
696
- author: actorFromUser(user),
697
- body,
698
- mentions,
699
- });
700
-
701
- io.emit('collab:thread:created', { thread });
702
- await recordActivity(io, {
703
- type: 'comment',
704
- filePath,
705
- actor: actorFromUser(user),
706
- payload: { threadId: thread.threadId, kind: 'thread-created' },
707
- groupKey: `${user.id}:${filePath}:comment`,
708
- });
709
-
710
- if (mentions.length > 0) {
711
- for (const mention of mentions) {
712
- const targets = getUsersByMention(mention);
713
- for (const targetUser of targets) {
714
- if (targetUser.id === user.id) continue;
715
- await maybeDeliverNotifyEvent(io, {
716
- kind: 'mention',
717
- filePath,
718
- actor: actorFromUser(user),
719
- targetUser,
720
- threadId: thread.threadId,
721
- body,
722
- });
723
- }
724
- }
725
- }
726
-
727
- respond(cb, { ok: true, thread });
728
- } catch (err) {
729
- respond(cb, { ok: false, error: err?.message ?? String(err) });
730
- }
731
- });
732
-
733
- socket.on('collab:thread:update', async (payload, cb) => {
734
- touchPresence(socket.id);
735
- const threadId = payload?.threadId;
736
- if (typeof threadId !== 'string' || threadId.length === 0) {
737
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId' });
738
- return;
739
- }
740
-
741
- try {
742
- const thread = await collabStore.updateThread({
743
- threadId,
744
- patch: payload?.patch ?? {},
745
- });
746
- if (!thread) {
747
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
748
- return;
749
- }
750
-
751
- io.emit('collab:thread:updated', { thread });
752
- await recordActivity(io, {
753
- type: 'comment',
754
- filePath: thread.filePath,
755
- actor: actorFromUser(user),
756
- payload: { threadId: thread.threadId, kind: 'thread-updated' },
757
- groupKey: `${user.id}:${thread.filePath}:comment`,
758
- });
759
-
760
- respond(cb, { ok: true, thread });
761
- } catch (err) {
762
- respond(cb, { ok: false, error: err?.message ?? String(err) });
763
- }
764
- });
765
-
766
- socket.on('collab:thread:delete', async (payload, cb) => {
767
- touchPresence(socket.id);
768
- const threadId = payload?.threadId;
769
- if (typeof threadId !== 'string' || threadId.length === 0) {
770
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId' });
771
- return;
772
- }
773
-
774
- try {
775
- const thread = await collabStore.deleteThread(threadId);
776
- if (!thread) {
777
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
778
- return;
779
- }
780
-
781
- io.emit('collab:thread:deleted', { thread });
782
- await recordActivity(io, {
783
- type: 'comment',
784
- filePath: thread.filePath,
785
- actor: actorFromUser(user),
786
- payload: { threadId, kind: 'thread-archived' },
787
- groupKey: `${user.id}:${thread.filePath}:comment`,
788
- });
789
-
790
- respond(cb, { ok: true, thread });
791
- } catch (err) {
792
- respond(cb, { ok: false, error: err?.message ?? String(err) });
793
- }
794
- });
795
-
796
- socket.on('collab:comment:create', async (payload, cb) => {
797
- touchPresence(socket.id);
798
- const threadId = payload?.threadId;
799
- const body = typeof payload?.body === 'string' ? payload.body : '';
800
- if (typeof threadId !== 'string' || threadId.length === 0) {
801
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId' });
802
- return;
803
- }
804
- if (body.trim().length === 0) {
805
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing comment body' });
806
- return;
807
- }
808
-
809
- try {
810
- const mentions = parseMentions(body);
811
- const result = await collabStore.createComment({
812
- threadId,
813
- author: actorFromUser(user),
814
- body,
815
- mentions,
816
- });
817
- if (!result) {
818
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
819
- return;
820
- }
821
-
822
- io.emit('collab:comment:created', {
823
- threadId,
824
- comment: result.comment,
825
- thread: result.thread,
826
- });
827
-
828
- await recordActivity(io, {
829
- type: 'comment',
830
- filePath: result.thread.filePath,
831
- actor: actorFromUser(user),
832
- payload: { threadId, commentId: result.comment.commentId, kind: 'comment-created' },
833
- groupKey: `${user.id}:${result.thread.filePath}:comment`,
834
- });
835
-
836
- if (mentions.length > 0) {
837
- for (const mention of mentions) {
838
- const targets = getUsersByMention(mention);
839
- for (const targetUser of targets) {
840
- if (targetUser.id === user.id) continue;
841
- await maybeDeliverNotifyEvent(io, {
842
- kind: 'mention',
843
- filePath: result.thread.filePath,
844
- actor: actorFromUser(user),
845
- targetUser,
846
- threadId,
847
- body,
848
- });
849
- }
850
- }
851
- }
852
-
853
- respond(cb, { ok: true, ...result });
854
- } catch (err) {
855
- respond(cb, { ok: false, error: err?.message ?? String(err) });
856
- }
857
- });
858
-
859
- socket.on('collab:comment:update', async (payload, cb) => {
860
- touchPresence(socket.id);
861
- const threadId = payload?.threadId;
862
- const commentId = payload?.commentId;
863
- const body = typeof payload?.body === 'string' ? payload.body : '';
864
- if (typeof threadId !== 'string' || threadId.length === 0 || typeof commentId !== 'string' || commentId.length === 0) {
865
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId/commentId' });
866
- return;
867
- }
868
-
869
- try {
870
- const result = await collabStore.updateComment({ threadId, commentId, body });
871
- if (!result) {
872
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Comment not found' });
873
- return;
874
- }
875
-
876
- io.emit('collab:comment:updated', {
877
- threadId,
878
- comment: result.comment,
879
- thread: result.thread,
880
- });
881
-
882
- await recordActivity(io, {
883
- type: 'comment',
884
- filePath: result.thread.filePath,
885
- actor: actorFromUser(user),
886
- payload: { threadId, commentId, kind: 'comment-updated' },
887
- groupKey: `${user.id}:${result.thread.filePath}:comment`,
888
- });
889
-
890
- respond(cb, { ok: true, ...result });
891
- } catch (err) {
892
- respond(cb, { ok: false, error: err?.message ?? String(err) });
893
- }
894
- });
895
-
896
- socket.on('collab:comment:delete', async (payload, cb) => {
897
- touchPresence(socket.id);
898
- const threadId = payload?.threadId;
899
- const commentId = payload?.commentId;
900
- if (typeof threadId !== 'string' || threadId.length === 0 || typeof commentId !== 'string' || commentId.length === 0) {
901
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId/commentId' });
902
- return;
903
- }
904
-
905
- try {
906
- const result = await collabStore.deleteComment({ threadId, commentId });
907
- if (!result) {
908
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Comment not found' });
909
- return;
910
- }
911
-
912
- io.emit('collab:comment:deleted', {
913
- threadId,
914
- commentId,
915
- thread: result.thread,
916
- });
917
-
918
- await recordActivity(io, {
919
- type: 'comment',
920
- filePath: result.thread.filePath,
921
- actor: actorFromUser(user),
922
- payload: { threadId, commentId, kind: 'comment-deleted' },
923
- groupKey: `${user.id}:${result.thread.filePath}:comment`,
924
- });
925
-
926
- respond(cb, { ok: true, ...result });
927
- } catch (err) {
928
- respond(cb, { ok: false, error: err?.message ?? String(err) });
929
- }
930
- });
931
-
932
- socket.on('collab:task:set-state', async (payload, cb) => {
933
- touchPresence(socket.id);
934
- const threadId = payload?.threadId;
935
- const status = payload?.status;
936
- if (typeof threadId !== 'string' || threadId.length === 0 || (status !== 'open' && status !== 'resolved')) {
937
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Invalid threadId/status' });
938
- return;
939
- }
940
-
941
- try {
942
- const result = await collabStore.setTaskState({
943
- threadId,
944
- status,
945
- assignee: payload?.assignee ?? null,
946
- dueAt: payload?.dueAt ?? null,
947
- });
948
- if (!result) {
949
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
950
- return;
951
- }
952
-
953
- io.emit('collab:task:updated', {
954
- threadId,
955
- task: result.task,
956
- thread: result.thread,
957
- });
958
-
959
- await recordActivity(io, {
960
- type: 'task',
961
- filePath: result.thread.filePath,
962
- actor: actorFromUser(user),
963
- payload: { threadId, taskId: result.task.taskId, status },
964
- groupKey: `${user.id}:${result.thread.filePath}:task`,
965
- });
966
-
967
- if (result.task.assignee?.username) {
968
- const assigneeName = String(result.task.assignee.username).toLowerCase();
969
- const targets = getUsersByMention(assigneeName);
970
- for (const targetUser of targets) {
971
- if (targetUser.id === user.id) continue;
972
- await maybeDeliverNotifyEvent(io, {
973
- kind: 'task',
974
- filePath: result.thread.filePath,
975
- actor: actorFromUser(user),
976
- targetUser,
977
- threadId,
978
- body: `Task set to ${status}`,
979
- });
980
- }
981
- }
982
-
983
- respond(cb, { ok: true, ...result });
984
- } catch (err) {
985
- respond(cb, { ok: false, error: err?.message ?? String(err) });
986
- }
987
- });
988
-
989
- socket.on('collab:activity:list', async (payload, cb) => {
990
- touchPresence(socket.id);
991
- const filePath = payload?.filePath ?? null;
992
- if (filePath && !isAllowedPath(filePath)) {
993
- rejectPath(cb, filePath);
994
- return;
995
- }
996
-
997
- try {
998
- const result = await collabStore.listActivity({
999
- filePath,
1000
- types: Array.isArray(payload?.types) ? payload.types : null,
1001
- limit: payload?.limit,
1002
- cursor: payload?.cursor ?? null,
1003
- });
1004
- respond(cb, { ok: true, ...result });
1005
- } catch (err) {
1006
- respond(cb, { ok: false, error: err?.message ?? String(err) });
1007
- }
1008
- });
1009
-
1010
- socket.on('collab:activity:subscribe', (payload, cb) => {
1011
- touchPresence(socket.id);
1012
- const scope = payload?.scope === 'file' ? 'file' : 'workspace';
1013
- const filePath = payload?.filePath ?? null;
1014
-
1015
- if (scope === 'file') {
1016
- if (!isAllowedPath(filePath)) {
1017
- rejectPath(cb, filePath);
1018
- return;
1019
- }
1020
- socket.join(activityRoomForFile(filePath));
1021
- }
1022
-
1023
- socket.join(activityRoomForWorkspace());
1024
- respond(cb, { ok: true, scope, filePath: filePath ?? null });
1025
- });
1026
-
1027
- socket.on('collab:notify:preferences:get', async (_payload, cb) => {
1028
- touchPresence(socket.id);
1029
- try {
1030
- const preferences = await collabStore.getNotifyPreferences(user.id);
1031
- respond(cb, { ok: true, preferences });
1032
- } catch (err) {
1033
- respond(cb, { ok: false, error: err?.message ?? String(err) });
1034
- }
1035
- });
1036
-
1037
- socket.on('collab:notify:preferences:set', async (payload, cb) => {
1038
- touchPresence(socket.id);
1039
- const scope = payload?.scope;
1040
- const key = payload?.key ?? null;
1041
- const mode = payload?.mode;
1042
-
1043
- if (scope === 'file' && key && !isAllowedPath(key)) {
1044
- rejectPath(cb, key);
1045
- return;
1046
- }
1047
- if (scope === 'workspace' && typeof key !== 'string') {
1048
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'workspace scope requires key' });
1049
- return;
1050
- }
1051
-
1052
- try {
1053
- const preferences = await collabStore.setNotifyPreference({
1054
- userId: user.id,
1055
- scope,
1056
- key,
1057
- mode,
1058
- });
1059
-
1060
- respond(cb, { ok: true, preferences });
1061
- } catch (err) {
1062
- respond(cb, { ok: false, error: err?.message ?? String(err) });
1063
- }
1064
- });
1065
-
1066
214
  socket.on('disconnect', () => {
1067
215
  console.log(`[socket] Disconnected: ${user.username} (${socket.id})`);
1068
216
  const openFiles = socketToFiles.get(socket.id) ?? new Set();
@@ -1072,7 +220,6 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
1072
220
  }
1073
221
  socketToFiles.delete(socket.id);
1074
222
  userBySocket.delete(socket.id);
1075
- presenceStateBySocket.delete(socket.id);
1076
223
  socket.broadcast.emit('user-left', { user });
1077
224
  });
1078
225
  });