browsertrack 0.2.1 → 0.2.2

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.
Files changed (52) hide show
  1. package/AGENTS.md +9 -6
  2. package/README.md +2 -0
  3. package/dist/{chunk-INXDWPJW.js → chunk-4HRLW6YF.js} +161 -106
  4. package/dist/chunk-4HRLW6YF.js.map +1 -0
  5. package/dist/{chunk-3HOXPTM2.js → chunk-AYSVE6NG.js} +808 -53
  6. package/dist/chunk-AYSVE6NG.js.map +1 -0
  7. package/dist/{chunk-6VA7GBAO.js → chunk-QRZ57ME3.js} +70 -2
  8. package/dist/chunk-QRZ57ME3.js.map +1 -0
  9. package/dist/{chunk-464D4U2U.js → chunk-TWEYRBDU.js} +279 -43
  10. package/dist/chunk-TWEYRBDU.js.map +1 -0
  11. package/dist/cli/index.js +1038 -545
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/client/index.cjs +184 -104
  14. package/dist/client/index.js +2 -2
  15. package/dist/client.iife.js +9 -9
  16. package/dist/core/index.d.ts +24 -1
  17. package/dist/core/index.js +9 -1
  18. package/dist/daemon/index.d.ts +2 -2
  19. package/dist/daemon/index.js +6 -8
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +14 -7
  22. package/dist/mcp/index.d.ts +1 -1
  23. package/dist/mcp/index.js +7 -4
  24. package/dist/{server-DiVmTrIR.d.ts → server-DjV7RWQM.d.ts} +9 -1
  25. package/docs/cli.md +4 -1
  26. package/docs/getting-started.md +60 -6
  27. package/docs/mcp-reference.md +42 -0
  28. package/package.json +1 -1
  29. package/packages/cli/src/index.ts +247 -151
  30. package/packages/client/src/interceptors/navigation.ts +38 -26
  31. package/packages/client/src/interceptors/network.ts +22 -17
  32. package/packages/client/src/notes/inspector.ts +81 -48
  33. package/packages/client/src/source/resolver.ts +9 -3
  34. package/packages/client/src/transport/websocket.ts +23 -18
  35. package/packages/core/src/index.ts +1 -0
  36. package/packages/core/src/safety.ts +86 -0
  37. package/packages/daemon/src/server/daemon.ts +7 -1
  38. package/packages/daemon/src/server/http.ts +125 -5
  39. package/packages/daemon/src/server/ws.ts +33 -29
  40. package/packages/daemon/src/storage/db.ts +57 -35
  41. package/packages/mcp/src/handlers.ts +114 -45
  42. package/packages/mcp/src/server.ts +202 -2
  43. package/test/core/safety.test.ts +106 -0
  44. package/test/daemon/storage.test.ts +36 -0
  45. package/test/e2e/daemon-mcp-e2e.test.ts +10 -0
  46. package/test/mcp/auto-start.test.ts +87 -0
  47. package/dist/chunk-3HOXPTM2.js.map +0 -1
  48. package/dist/chunk-464D4U2U.js.map +0 -1
  49. package/dist/chunk-6VA7GBAO.js.map +0 -1
  50. package/dist/chunk-7OCOQGDN.js +0 -635
  51. package/dist/chunk-7OCOQGDN.js.map +0 -1
  52. package/dist/chunk-INXDWPJW.js.map +0 -1
@@ -1050,9 +1050,16 @@ export class NoteInspector {
1050
1050
 
1051
1051
  const currentPath = window.location.pathname;
1052
1052
  // Filter open notes on this route or global
1053
- const activeNotes = this.savedNotes.filter(
1054
- (n) => n.status === 'OPEN' && (n.route === currentPath || !n.route || n.route === '/' || window.location.href.includes(n.route))
1055
- );
1053
+ const activeNotes = this.savedNotes.filter((n) => {
1054
+ if (n.status !== 'OPEN') return false;
1055
+ if (!n.route) return true;
1056
+ if (n.route === currentPath) return true;
1057
+ // Support hash or query routes (e.g. /#modal or /?tab=settings)
1058
+ if (n.route.includes('#') || n.route.includes('?')) {
1059
+ return window.location.href.includes(n.route);
1060
+ }
1061
+ return false;
1062
+ });
1056
1063
 
1057
1064
  const pageNotes: VisualNote[] = [];
1058
1065
 
@@ -1343,81 +1350,103 @@ export class NoteInspector {
1343
1350
  private setupListeners(): void {
1344
1351
  // 1. Mouse move: hover highlight in element mode or with Alt key
1345
1352
  const onMouseMove = (e: MouseEvent) => {
1346
- if (this.isHidden || this.modalOverlay || this.cardOverlay || this.isDraggingRegion || this.activeMode === 'region') return;
1347
-
1348
- // Don't highlight elements if mouse is over our own inspector UI
1349
- const path = e.composedPath ? e.composedPath() : [];
1350
- if (this.container && path.includes(this.container)) {
1351
- this.hideHighlight();
1352
- return;
1353
- }
1353
+ try {
1354
+ if (this.isHidden || this.modalOverlay || this.cardOverlay || this.isDraggingRegion || this.activeMode === 'region') return;
1354
1355
 
1355
- if (e.altKey || this.activeMode === 'element') {
1356
- const target = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
1357
- if (target && target !== this.container && !this.container?.contains(target)) {
1358
- this.hoveredElement = target;
1359
- this.updateHighlight(target);
1356
+ // Don't highlight elements if mouse is over our own inspector UI
1357
+ const path = e.composedPath ? e.composedPath() : [];
1358
+ if (this.container && path.includes(this.container)) {
1359
+ this.hideHighlight();
1360
1360
  return;
1361
1361
  }
1362
- }
1363
1362
 
1364
- if (!e.altKey && this.activeMode !== 'element') {
1365
- this.hideHighlight();
1363
+ if (e.altKey || this.activeMode === 'element') {
1364
+ const target = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
1365
+ if (target && target !== this.container && !this.container?.contains(target)) {
1366
+ this.hoveredElement = target;
1367
+ this.updateHighlight(target);
1368
+ return;
1369
+ }
1370
+ }
1371
+
1372
+ if (!e.altKey && this.activeMode !== 'element') {
1373
+ this.hideHighlight();
1374
+ }
1375
+ } catch {
1376
+ // Defensive: never let inspector hover crash host app
1366
1377
  }
1367
1378
  };
1368
1379
 
1369
1380
  // 2. Click: Alt+Click or Element mode click selects element and opens editor
1370
1381
  const onClick = (e: MouseEvent) => {
1371
- if (this.isHidden || this.modalOverlay || this.cardOverlay) return;
1382
+ try {
1383
+ if (this.isHidden || this.modalOverlay || this.cardOverlay) return;
1372
1384
 
1373
- // If clicking inside inspector toolbar or container, do NOT intercept
1374
- const path = e.composedPath ? e.composedPath() : [];
1375
- if (this.container && path.includes(this.container)) {
1376
- return;
1377
- }
1385
+ // If clicking inside inspector toolbar or container, do NOT intercept
1386
+ const path = e.composedPath ? e.composedPath() : [];
1387
+ if (this.container && path.includes(this.container)) {
1388
+ return;
1389
+ }
1378
1390
 
1379
- if (this.activeMode === 'region') return;
1391
+ if (this.activeMode === 'region') return;
1380
1392
 
1381
- if (e.altKey || this.activeMode === 'element') {
1382
- e.preventDefault();
1383
- e.stopPropagation();
1393
+ if (e.altKey || this.activeMode === 'element') {
1394
+ e.preventDefault();
1395
+ e.stopPropagation();
1384
1396
 
1385
- const target = (this.hoveredElement || document.elementFromPoint(e.clientX, e.clientY)) as HTMLElement | null;
1386
- if (target && target !== this.container && !this.container?.contains(target)) {
1387
- this.selectedElement = target;
1388
- this.openNoteEditor(target, 'element');
1389
- if (this.activeMode === 'element' && !this.activeScenario) {
1390
- this.setMode('idle');
1397
+ const target = (this.hoveredElement || document.elementFromPoint(e.clientX, e.clientY)) as HTMLElement | null;
1398
+ if (target && target !== this.container && !this.container?.contains(target)) {
1399
+ this.selectedElement = target;
1400
+ this.openNoteEditor(target, 'element');
1401
+ if (this.activeMode === 'element' && !this.activeScenario) {
1402
+ this.setMode('idle');
1403
+ }
1391
1404
  }
1392
1405
  }
1406
+ } catch {
1407
+ // Defensive
1393
1408
  }
1394
1409
  };
1395
1410
 
1396
1411
  // 3. Key handling: Escape cancels active modes, Alt release hides highlight
1397
1412
  const onKeyDown = (e: KeyboardEvent) => {
1398
- if (e.key === 'Escape') {
1399
- if (this.cardOverlay && this.cardOverlay.parentElement && this.shadowRoot) {
1400
- this.shadowRoot.removeChild(this.cardOverlay);
1401
- this.cardOverlay = null;
1402
- } else if (this.activeMode === 'region' || this.activeMode === 'element') {
1403
- if (!this.activeScenario) {
1404
- this.setMode('idle');
1413
+ try {
1414
+ if (e.key === 'Escape') {
1415
+ if (this.cardOverlay && this.cardOverlay.parentElement && this.shadowRoot) {
1416
+ this.shadowRoot.removeChild(this.cardOverlay);
1417
+ this.cardOverlay = null;
1418
+ } else if (this.activeMode === 'region' || this.activeMode === 'element') {
1419
+ if (!this.activeScenario) {
1420
+ this.setMode('idle');
1421
+ }
1405
1422
  }
1406
1423
  }
1424
+ } catch {
1425
+ // Defensive
1407
1426
  }
1408
1427
  };
1409
1428
 
1410
1429
  const onKeyUp = (e: KeyboardEvent) => {
1411
- if (e.key === 'Alt' && !this.modalOverlay && !this.cardOverlay && this.activeMode !== 'element') {
1412
- this.hideHighlight();
1430
+ try {
1431
+ if (e.key === 'Alt' && !this.modalOverlay && !this.cardOverlay && this.activeMode !== 'element') {
1432
+ this.hideHighlight();
1433
+ }
1434
+ } catch {
1435
+ // Defensive
1413
1436
  }
1414
1437
  };
1415
1438
 
1416
1439
  // 4. Scroll & resize: reposition markers accurately
1417
1440
  const onScrollOrResize = () => {
1418
- requestAnimationFrame(() => {
1419
- this.updateMarkerPositions();
1420
- });
1441
+ try {
1442
+ requestAnimationFrame(() => {
1443
+ try {
1444
+ this.updateMarkerPositions();
1445
+ } catch {}
1446
+ });
1447
+ } catch {
1448
+ // Defensive
1449
+ }
1421
1450
  };
1422
1451
 
1423
1452
  window.addEventListener('mousemove', onMouseMove, { capture: true, passive: true });
@@ -2005,7 +2034,11 @@ export class NoteInspector {
2005
2034
  region.width,
2006
2035
  region.height
2007
2036
  );
2008
- resolve(canvas.toDataURL('image/png'));
2037
+ try {
2038
+ resolve(canvas.toDataURL('image/png'));
2039
+ } catch {
2040
+ resolve(dataUrl);
2041
+ }
2009
2042
  };
2010
2043
  img.onerror = () => resolve(dataUrl);
2011
2044
  img.src = dataUrl;
@@ -76,7 +76,9 @@ function resolveReactComponent(el: HTMLElement): ComponentSourceInfo | undefined
76
76
 
77
77
  // Walk up fiber return tree to discover composite components and hierarchy
78
78
  let curr = hostFiber;
79
- while (curr) {
79
+ let depth = 0;
80
+ while (curr && depth < 50) {
81
+ depth++;
80
82
  const type = curr.type;
81
83
  const name = getReactComponentName(type);
82
84
 
@@ -146,7 +148,9 @@ function resolveVueComponent(el: HTMLElement): ComponentSourceInfo | undefined {
146
148
  const hierarchy: string[] = [];
147
149
 
148
150
  let curr = vueParent;
149
- while (curr) {
151
+ let depth = 0;
152
+ while (curr && depth < 50) {
153
+ depth++;
150
154
  const cType = curr.type || {};
151
155
  const cName = cType.name || cType.__name || cType.displayName;
152
156
  if (cName && !hierarchy.includes(cName)) {
@@ -192,7 +196,9 @@ function resolveVueComponent(el: HTMLElement): ComponentSourceInfo | undefined {
192
196
  function resolveSvelteComponent(el: HTMLElement): ComponentSourceInfo | undefined {
193
197
  try {
194
198
  let curr: HTMLElement | null = el;
195
- while (curr) {
199
+ let depth = 0;
200
+ while (curr && depth < 50) {
201
+ depth++;
196
202
  const meta = (curr as any).__svelte_meta;
197
203
  if (meta && meta.loc) {
198
204
  return {
@@ -1,4 +1,5 @@
1
1
  import type { HelloMessage, ClientCommand, CommandResponse } from '../../../core/src/index.js';
2
+ import { safeJsonStringify } from '../../../core/src/index.js';
2
3
  import type { ClientCommandHandler } from '../commands/handler.js';
3
4
 
4
5
  export interface TransportOptions {
@@ -111,15 +112,19 @@ export class WebSocketTransport {
111
112
  }
112
113
 
113
114
  public send(payload: any): void {
114
- const raw = JSON.stringify(payload);
115
- if (this.isConnected()) {
116
- try {
117
- this.ws!.send(raw);
118
- } catch {
115
+ try {
116
+ const raw = safeJsonStringify(payload);
117
+ if (this.isConnected()) {
118
+ try {
119
+ this.ws!.send(raw);
120
+ } catch {
121
+ this.enqueue(raw);
122
+ }
123
+ } else {
119
124
  this.enqueue(raw);
120
125
  }
121
- } else {
122
- this.enqueue(raw);
126
+ } catch {
127
+ // Defensive: never let payload serialization crash caller
123
128
  }
124
129
  }
125
130
 
@@ -148,18 +153,18 @@ export class WebSocketTransport {
148
153
  private sendHello(): void {
149
154
  if (typeof window === 'undefined') return;
150
155
 
151
- const hello: HelloMessage = {
152
- type: 'hello',
153
- origin: window.location.origin,
154
- url: window.location.href,
155
- title: document.title,
156
- userAgent: navigator.userAgent,
157
- timestamp: Date.now(),
158
- projectId: this.projectId,
159
- };
160
-
161
156
  try {
162
- this.ws!.send(JSON.stringify(hello));
157
+ const hello: HelloMessage = {
158
+ type: 'hello',
159
+ origin: window.location.origin,
160
+ url: window.location.href,
161
+ title: document.title,
162
+ userAgent: navigator.userAgent,
163
+ timestamp: Date.now(),
164
+ projectId: this.projectId,
165
+ };
166
+
167
+ this.ws!.send(safeJsonStringify(hello));
163
168
  } catch {
164
169
  // Defensive
165
170
  }
@@ -7,3 +7,4 @@ export * from './types/notes.js';
7
7
  export * from './fingerprint.js';
8
8
  export * from './redaction.js';
9
9
  export * from './selector.js';
10
+ export * from './safety.js';
@@ -0,0 +1,86 @@
1
+ /**
2
+ * BrowserTrack Safety & Error-Resilience Utilities
3
+ * Provides bulletproof try/catch wrappers, circular-safe JSON serialization, and defensive helpers.
4
+ */
5
+
6
+ /**
7
+ * Safely parses a JSON string with fallback. Never throws.
8
+ */
9
+ export function safeJsonParse<T>(raw: any, fallback: T): T {
10
+ if (raw === null || raw === undefined) return fallback;
11
+ if (typeof raw !== 'string') return typeof raw === 'object' ? (raw as T) : fallback;
12
+ try {
13
+ return JSON.parse(raw);
14
+ } catch {
15
+ return fallback;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Safely serializes an object to JSON, handling circular references and non-serializable values.
21
+ * Never throws "TypeError: Converting circular structure to JSON".
22
+ */
23
+ export function safeJsonStringify(val: any, fallback = '{}'): string {
24
+ if (val === undefined) return fallback;
25
+ try {
26
+ const seen = new WeakSet();
27
+ return JSON.stringify(val, (key, value) => {
28
+ if (typeof value === 'object' && value !== null) {
29
+ if (seen.has(value)) {
30
+ return '[Circular]';
31
+ }
32
+ seen.add(value);
33
+ }
34
+ if (typeof value === 'bigint') {
35
+ return value.toString();
36
+ }
37
+ return value;
38
+ });
39
+ } catch {
40
+ try {
41
+ return JSON.stringify(String(val));
42
+ } catch {
43
+ return fallback;
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Executes a synchronous function within an isolated try/catch block.
50
+ * Returns the function result, or fallback if an exception was thrown.
51
+ */
52
+ export function safeExecute<T>(fn: () => T, fallback: T, onError?: (err: any) => void): T {
53
+ try {
54
+ return fn();
55
+ } catch (err: any) {
56
+ if (onError) {
57
+ try {
58
+ onError(err);
59
+ } catch {}
60
+ }
61
+ return fallback;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Executes an asynchronous promise or async function safely without unhandled rejection.
67
+ */
68
+ export async function safeAsync<T>(
69
+ action: Promise<T> | (() => Promise<T>),
70
+ fallback: T,
71
+ onError?: (err: any) => void
72
+ ): Promise<T> {
73
+ try {
74
+ if (typeof action === 'function') {
75
+ return await action();
76
+ }
77
+ return await action;
78
+ } catch (err: any) {
79
+ if (onError) {
80
+ try {
81
+ onError(err);
82
+ } catch {}
83
+ }
84
+ return fallback;
85
+ }
86
+ }
@@ -40,7 +40,13 @@ export class BrowserTrackDaemon {
40
40
  public async start(): Promise<void> {
41
41
  if (this.isRunning) return;
42
42
 
43
- const httpHandler = createHttpHandler(this.db, this.sessionManager, this.config.screenshotsDir);
43
+ const httpHandler = createHttpHandler(
44
+ this.db,
45
+ this.sessionManager,
46
+ this.config.screenshotsDir,
47
+ this.verificationEngine,
48
+ this.noteVerificationEngine
49
+ );
44
50
  this.httpServer = http.createServer(httpHandler);
45
51
 
46
52
  this.wss = new WebSocketServer({ server: this.httpServer });
@@ -4,11 +4,59 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import type { StorageDB } from '../storage/db.js';
6
6
  import type { SessionManager } from '../session/manager.js';
7
+ import type { VerificationEngine } from '../verification/engine.js';
8
+ import type { NoteVerificationEngine } from '../notes/verification.js';
7
9
 
8
- export function createHttpHandler(db: StorageDB, sessionManager: SessionManager, baseScreenshotsDir: string) {
9
- return (req: http.IncomingMessage, res: http.ServerResponse) => {
10
- const parsedUrl = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
11
- const pathname = parsedUrl.pathname;
10
+ function readJsonBody(req: http.IncomingMessage, res: http.ServerResponse, maxSizeBytes = 10 * 1024 * 1024): Promise<any> {
11
+ return new Promise((resolve, reject) => {
12
+ let body = '';
13
+ let isTooLarge = false;
14
+
15
+ req.on('data', (chunk) => {
16
+ if (isTooLarge) return;
17
+ body += chunk;
18
+ if (body.length > maxSizeBytes) {
19
+ isTooLarge = true;
20
+ if (!res.headersSent) {
21
+ res.writeHead(413, { 'Content-Type': 'application/json' });
22
+ res.end(JSON.stringify({ ok: false, error: 'Payload too large' }));
23
+ }
24
+ req.destroy();
25
+ reject(new Error('Payload too large'));
26
+ }
27
+ });
28
+
29
+ req.on('end', () => {
30
+ if (isTooLarge) return;
31
+ try {
32
+ const parsed = body ? JSON.parse(body) : {};
33
+ resolve(parsed);
34
+ } catch (err: any) {
35
+ if (!res.headersSent) {
36
+ res.writeHead(400, { 'Content-Type': 'application/json' });
37
+ res.end(JSON.stringify({ ok: false, error: 'Malformed JSON payload' }));
38
+ }
39
+ reject(err);
40
+ }
41
+ });
42
+
43
+ req.on('error', (err) => {
44
+ reject(err);
45
+ });
46
+ });
47
+ }
48
+
49
+ export function createHttpHandler(
50
+ db: StorageDB,
51
+ sessionManager: SessionManager,
52
+ baseScreenshotsDir: string,
53
+ verificationEngine?: VerificationEngine,
54
+ noteVerificationEngine?: NoteVerificationEngine
55
+ ) {
56
+ return async (req: http.IncomingMessage, res: http.ServerResponse) => {
57
+ try {
58
+ const parsedUrl = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
59
+ const pathname = parsedUrl.pathname;
12
60
 
13
61
  // CORS headers for local development access
14
62
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -158,8 +206,80 @@ export function createHttpHandler(db: StorageDB, sessionManager: SessionManager,
158
206
  return;
159
207
  }
160
208
 
209
+ // 10. API - Dispatch Live Command to Browser Session
210
+ if (pathname === '/api/command' && req.method === 'POST') {
211
+ try {
212
+ const data = await readJsonBody(req, res);
213
+ const sessionId = data.sessionId || sessionManager.getAnyActiveSession()?.id;
214
+ if (!sessionId) {
215
+ res.writeHead(400, { 'Content-Type': 'application/json' });
216
+ res.end(JSON.stringify({ ok: false, error: 'No active browser session connected' }));
217
+ return;
218
+ }
219
+ const cmdRes = await sessionManager.sendCommand(sessionId, data.command, data.timeoutMs || 5000);
220
+ res.writeHead(200, { 'Content-Type': 'application/json' });
221
+ res.end(JSON.stringify(cmdRes));
222
+ } catch (err: any) {
223
+ if (!res.headersSent) {
224
+ res.writeHead(500, { 'Content-Type': 'application/json' });
225
+ res.end(JSON.stringify({ ok: false, error: err?.message || String(err) }));
226
+ }
227
+ }
228
+ return;
229
+ }
230
+
231
+ // 11. API - Verify Incident
232
+ if (pathname === '/api/verify/incident' && req.method === 'POST') {
233
+ try {
234
+ if (!verificationEngine) {
235
+ res.writeHead(500, { 'Content-Type': 'application/json' });
236
+ res.end(JSON.stringify({ ok: false, error: 'Verification engine not attached to daemon' }));
237
+ return;
238
+ }
239
+ const data = await readJsonBody(req, res);
240
+ const result = await verificationEngine.verifyIncident(data.incidentId, data.options);
241
+ res.writeHead(200, { 'Content-Type': 'application/json' });
242
+ res.end(JSON.stringify({ ok: true, result }));
243
+ } catch (err: any) {
244
+ if (!res.headersSent) {
245
+ res.writeHead(500, { 'Content-Type': 'application/json' });
246
+ res.end(JSON.stringify({ ok: false, error: err?.message || String(err) }));
247
+ }
248
+ }
249
+ return;
250
+ }
251
+
252
+ // 12. API - Verify Note
253
+ if (pathname === '/api/verify/note' && req.method === 'POST') {
254
+ try {
255
+ if (!noteVerificationEngine) {
256
+ res.writeHead(500, { 'Content-Type': 'application/json' });
257
+ res.end(JSON.stringify({ ok: false, error: 'Note verification engine not attached to daemon' }));
258
+ return;
259
+ }
260
+ const data = await readJsonBody(req, res);
261
+ const result = await noteVerificationEngine.verifyNote(data.noteId, data.options);
262
+ res.writeHead(200, { 'Content-Type': 'application/json' });
263
+ res.end(JSON.stringify({ ok: true, result }));
264
+ } catch (err: any) {
265
+ if (!res.headersSent) {
266
+ res.writeHead(500, { 'Content-Type': 'application/json' });
267
+ res.end(JSON.stringify({ ok: false, error: err?.message || String(err) }));
268
+ }
269
+ }
270
+ return;
271
+ }
272
+
161
273
  // Not found
162
274
  res.writeHead(404, { 'Content-Type': 'application/json' });
163
275
  res.end(JSON.stringify({ ok: false, error: 'Not found' }));
164
- };
276
+ } catch (err: any) {
277
+ if (!res.headersSent) {
278
+ try {
279
+ res.writeHead(500, { 'Content-Type': 'application/json' });
280
+ res.end(JSON.stringify({ ok: false, error: err?.message || 'Internal Server Error' }));
281
+ } catch {}
282
+ }
283
+ }
284
+ };
165
285
  }
@@ -1,11 +1,23 @@
1
1
  import crypto from 'node:crypto';
2
2
  import type { WebSocket, WebSocketServer } from 'ws';
3
3
  import type { ClientEventMessage, CommandResponse, HelloMessage } from '../../../core/src/index.js';
4
+ import { safeJsonStringify } from '../../../core/src/index.js';
4
5
  import type { IncidentEngine } from '../incidents/engine.js';
5
6
  import type { NotesEngine } from '../notes/engine.js';
6
7
  import type { SessionManager } from '../session/manager.js';
7
8
  import type { StorageDB } from '../storage/db.js';
8
9
 
10
+ function safeWsSend(ws: WebSocket, payload: any): boolean {
11
+ if (ws.readyState !== 1 /* WebSocket.OPEN */) return false;
12
+ try {
13
+ const raw = typeof payload === 'string' ? payload : safeJsonStringify(payload);
14
+ ws.send(raw);
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
9
21
  export function setupWebSocketServer(
10
22
  wss: WebSocketServer,
11
23
  db: StorageDB,
@@ -65,23 +77,19 @@ export function setupWebSocketServer(
65
77
 
66
78
  sessionManager.registerSocket(sessionId, ws, hello.origin || '', project.id);
67
79
 
68
- ws.send(
69
- JSON.stringify({
70
- type: 'hello_ack',
71
- sessionId,
72
- projectId: project.id,
73
- projectName: project.name,
74
- })
75
- );
80
+ safeWsSend(ws, {
81
+ type: 'hello_ack',
82
+ sessionId,
83
+ projectId: project.id,
84
+ projectName: project.name,
85
+ });
76
86
 
77
87
  // Sync existing notes for this project on load
78
88
  const existingNotes = db.listNotes({ projectId: project.id, limit: 100 });
79
- ws.send(
80
- JSON.stringify({
81
- type: 'notes_sync',
82
- notes: existingNotes,
83
- })
84
- );
89
+ safeWsSend(ws, {
90
+ type: 'notes_sync',
91
+ notes: existingNotes,
92
+ });
85
93
 
86
94
  if (verbose) {
87
95
  console.log(`[BrowserTrack] New session connected: ${sessionId} (${project.name} @ ${hello.origin})`);
@@ -147,15 +155,13 @@ export function setupWebSocketServer(
147
155
  );
148
156
  }
149
157
 
150
- ws.send(
151
- JSON.stringify({
152
- type: 'note_created_ack',
153
- noteId: note.id,
154
- status: note.status,
155
- scenarioId: note.scenarioId,
156
- stepNumber: note.stepNumber,
157
- })
158
- );
158
+ safeWsSend(ws, {
159
+ type: 'note_created_ack',
160
+ noteId: note.id,
161
+ status: note.status,
162
+ scenarioId: note.scenarioId,
163
+ stepNumber: note.stepNumber,
164
+ });
159
165
 
160
166
  // Broadcast updated notes to all active browser tabs in this project
161
167
  const allNotes = db.listNotes({ projectId: note.projectId, limit: 100 });
@@ -224,12 +230,10 @@ export function setupWebSocketServer(
224
230
  const projectId = data.projectId || session?.projectId;
225
231
  if (projectId) {
226
232
  const allNotes = db.listNotes({ projectId, limit: 100 });
227
- ws.send(
228
- JSON.stringify({
229
- type: 'notes_sync',
230
- notes: allNotes,
231
- })
232
- );
233
+ safeWsSend(ws, {
234
+ type: 'notes_sync',
235
+ notes: allNotes,
236
+ });
233
237
  }
234
238
  return;
235
239
  }