browsertrack 0.2.1 → 0.2.3

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 (55) hide show
  1. package/AGENTS.md +9 -6
  2. package/README.md +2 -0
  3. package/dist/{chunk-464D4U2U.js → chunk-5NR5K3ER.js} +299 -44
  4. package/dist/chunk-5NR5K3ER.js.map +1 -0
  5. package/dist/{chunk-3HOXPTM2.js → chunk-G5CIZSQM.js} +808 -53
  6. package/dist/chunk-G5CIZSQM.js.map +1 -0
  7. package/dist/{chunk-6VA7GBAO.js → chunk-ONPW7AYL.js} +144 -12
  8. package/dist/chunk-ONPW7AYL.js.map +1 -0
  9. package/dist/{chunk-INXDWPJW.js → chunk-PG4JJDCV.js} +353 -119
  10. package/dist/chunk-PG4JJDCV.js.map +1 -0
  11. package/dist/cli/index.js +1058 -546
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/client/index.cjs +449 -128
  14. package/dist/client/index.d.ts +2 -0
  15. package/dist/client/index.js +2 -2
  16. package/dist/client.iife.js +121 -15
  17. package/dist/core/index.d.ts +32 -2
  18. package/dist/core/index.js +11 -1
  19. package/dist/daemon/index.d.ts +2 -2
  20. package/dist/daemon/index.js +6 -8
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +16 -7
  23. package/dist/mcp/index.d.ts +1 -1
  24. package/dist/mcp/index.js +7 -4
  25. package/dist/{server-DiVmTrIR.d.ts → server-BRG-RQQP.d.ts} +10 -1
  26. package/docs/cli.md +4 -1
  27. package/docs/getting-started.md +60 -6
  28. package/docs/mcp-reference.md +42 -0
  29. package/package.json +1 -1
  30. package/packages/cli/src/index.ts +247 -151
  31. package/packages/client/src/interceptors/navigation.ts +38 -26
  32. package/packages/client/src/interceptors/network.ts +22 -17
  33. package/packages/client/src/notes/inspector.ts +289 -65
  34. package/packages/client/src/source/resolver.ts +9 -3
  35. package/packages/client/src/transport/websocket.ts +23 -18
  36. package/packages/core/src/index.ts +1 -0
  37. package/packages/core/src/safety.ts +86 -0
  38. package/packages/core/src/selector.ts +110 -15
  39. package/packages/daemon/src/server/daemon.ts +7 -1
  40. package/packages/daemon/src/server/http.ts +125 -5
  41. package/packages/daemon/src/server/ws.ts +33 -29
  42. package/packages/daemon/src/storage/db.ts +57 -35
  43. package/packages/mcp/src/handlers.ts +121 -45
  44. package/packages/mcp/src/server.ts +221 -3
  45. package/test/core/safety.test.ts +106 -0
  46. package/test/core/selector.test.ts +133 -0
  47. package/test/daemon/storage.test.ts +36 -0
  48. package/test/e2e/daemon-mcp-e2e.test.ts +10 -0
  49. package/test/mcp/auto-start.test.ts +87 -0
  50. package/dist/chunk-3HOXPTM2.js.map +0 -1
  51. package/dist/chunk-464D4U2U.js.map +0 -1
  52. package/dist/chunk-6VA7GBAO.js.map +0 -1
  53. package/dist/chunk-7OCOQGDN.js +0 -635
  54. package/dist/chunk-7OCOQGDN.js.map +0 -1
  55. package/dist/chunk-INXDWPJW.js.map +0 -1
@@ -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
+ }
@@ -6,23 +6,26 @@ export interface SelectorOptions {
6
6
  maxClasses?: number;
7
7
  maxOuterHTMLLength?: number;
8
8
  maxInnerTextLength?: number;
9
+ maxParentDepth?: number;
9
10
  }
10
11
 
11
12
  /**
12
13
  * Builds a clean, human-readable semantic selector for a DOM element.
13
- * Prioritizes data-testid/data-test, id, semantic tag + specific class, or concise parent path.
14
+ * Prioritizes data-testid/data-component, id, semantic tag + curated class, aria/role, or concise unique parent path.
14
15
  */
15
16
  export function getSemanticSelector(element: HTMLElement | Element, options: SelectorOptions = {}): string {
16
17
  if (!element || !element.tagName) return 'unknown';
17
18
 
18
19
  const maxClasses = options.maxClasses ?? 2;
20
+ const maxParentDepth = options.maxParentDepth ?? 3;
19
21
 
20
- // 1. Check for standard test attributes
22
+ // 1. Check for standard test or component attributes
21
23
  const testId =
22
24
  element.getAttribute('data-testid') ||
23
25
  element.getAttribute('data-test') ||
24
26
  element.getAttribute('data-cy') ||
25
- element.getAttribute('data-qa');
27
+ element.getAttribute('data-qa') ||
28
+ element.getAttribute('data-component');
26
29
 
27
30
  if (testId) {
28
31
  return `[data-testid="${testId}"]`;
@@ -34,21 +37,43 @@ export function getSemanticSelector(element: HTMLElement | Element, options: Sel
34
37
  return `#${id}`;
35
38
  }
36
39
 
37
- // 3. Check for aria-label or name on buttons/inputs
40
+ const tag = element.tagName.toLowerCase();
41
+
42
+ // 3. Check for aria-label, role, title or name on interactive elements
38
43
  const ariaLabel = element.getAttribute('aria-label');
39
- if (ariaLabel && ariaLabel.length < 30) {
40
- return `${element.tagName.toLowerCase()}[aria-label="${ariaLabel}"]`;
44
+ if (ariaLabel && ariaLabel.length < 40) {
45
+ return `${tag}[aria-label="${ariaLabel}"]`;
46
+ }
47
+
48
+ const role = element.getAttribute('role');
49
+ if (role && !['presentation', 'none'].includes(role)) {
50
+ const roleSelector = `${tag}[role="${role}"]`;
51
+ if (tag === 'button' || tag === 'a' || tag === 'nav' || tag === 'dialog') {
52
+ return roleSelector;
53
+ }
41
54
  }
42
55
 
43
56
  const nameAttr = element.getAttribute('name');
44
57
  if (nameAttr) {
45
- return `${element.tagName.toLowerCase()}[name="${nameAttr}"]`;
58
+ return `${tag}[name="${nameAttr}"]`;
59
+ }
60
+
61
+ const titleAttr = element.getAttribute('title');
62
+ if (titleAttr && titleAttr.length < 30) {
63
+ return `${tag}[title="${titleAttr}"]`;
46
64
  }
47
65
 
48
66
  // 4. Tag + curated classes
49
- const tag = element.tagName.toLowerCase();
50
- const classList = Array.from(element.classList || [])
51
- .filter((c) => !c.startsWith('css-') && !c.startsWith('_') && !/^[a-z0-9]{5,}$/i.test(c)) // filter hashed utility classes
67
+ const rawClassList = Array.from(element.classList || []);
68
+ const classList = rawClassList
69
+ .filter(
70
+ (c) =>
71
+ !c.startsWith('css-') &&
72
+ !c.startsWith('_') &&
73
+ !/^[a-z0-9]{5,}$/i.test(c) && // filter dynamic hashed utility classes
74
+ !c.includes(':') && // Tailwind pseudo-class prefixes (hover:, md:, etc.)
75
+ !c.includes('[') // Tailwind arbitrary value classes
76
+ )
52
77
  .slice(0, maxClasses);
53
78
 
54
79
  if (classList.length > 0) {
@@ -56,17 +81,87 @@ export function getSemanticSelector(element: HTMLElement | Element, options: Sel
56
81
  return `${tag}${classStr}`;
57
82
  }
58
83
 
59
- // 5. If generic element (div, span, p) without attributes, try parent context
60
- if (element.parentElement && element.parentElement !== document.body && element.parentElement !== document.documentElement) {
61
- const parentSelector = getSemanticSelector(element.parentElement, { maxClasses: 1 });
62
- if (parentSelector && parentSelector !== 'unknown' && !parentSelector.includes('>')) {
63
- return `${parentSelector} > ${tag}`;
84
+ // 5. If generic element (div, span, p, li) without attributes, try parent context & nth-of-type
85
+ const hasParent =
86
+ element.parentElement &&
87
+ (typeof document === 'undefined' ||
88
+ (element.parentElement !== document.body && element.parentElement !== document.documentElement));
89
+
90
+ if (hasParent) {
91
+ let suffix = '';
92
+ // Calculate nth-of-type if siblings of same tag exist
93
+ if (element.parentElement.children && element.parentElement.children.length > 1) {
94
+ const sameTagSiblings = Array.from(element.parentElement.children).filter(
95
+ (child: any) => child.tagName && child.tagName.toLowerCase() === tag
96
+ );
97
+ if (sameTagSiblings.length > 1) {
98
+ const index = sameTagSiblings.indexOf(element) + 1;
99
+ if (index > 0) {
100
+ suffix = `:nth-of-type(${index})`;
101
+ }
102
+ }
103
+ }
104
+
105
+ if (maxParentDepth > 0) {
106
+ const parentSelector = getSemanticSelector(element.parentElement, {
107
+ maxClasses: 1,
108
+ maxParentDepth: maxParentDepth - 1,
109
+ });
110
+ if (parentSelector && parentSelector !== 'unknown' && parentSelector !== 'body' && parentSelector !== 'html') {
111
+ return `${parentSelector} > ${tag}${suffix}`;
112
+ }
113
+ }
114
+
115
+ if (suffix) {
116
+ return `${tag}${suffix}`;
64
117
  }
65
118
  }
66
119
 
67
120
  return tag;
68
121
  }
69
122
 
123
+ /**
124
+ * If an element under the cursor is an inline or leaf formatting element
125
+ * (e.g. svg, path, i, b, strong, or an inner span without attributes)
126
+ * inside an interactive container or component, resolves to the parent container.
127
+ */
128
+ export function resolveMeaningfulTarget(element: HTMLElement | Element): HTMLElement {
129
+ if (!element || typeof element !== 'object') {
130
+ return element as HTMLElement;
131
+ }
132
+
133
+ const el = element as HTMLElement;
134
+
135
+ // 1. If element is inside an SVG or is an SVG child (path, circle, rect, g, svg)
136
+ const isSvg = el.tagName.toLowerCase() === 'svg' || el.namespaceURI?.includes('svg');
137
+ if (isSvg || el.closest?.('svg')) {
138
+ const interactiveParent = el.closest?.(
139
+ 'button, a, [role="button"], [role="link"], [role="tab"], summary, label, [data-testid], [data-component]'
140
+ ) as HTMLElement | null;
141
+ if (interactiveParent) return interactiveParent;
142
+ const svgEl = isSvg ? el : (el.closest?.('svg') as HTMLElement | null);
143
+ if (svgEl) return svgEl;
144
+ }
145
+
146
+ // 2. Minor leaf tags (span, i, em, b, strong, small) with no distinct attributes inside interactive element
147
+ const leafTags = ['span', 'i', 'em', 'b', 'strong', 'small', 'mark', 'code'];
148
+ const tag = el.tagName.toLowerCase();
149
+ if (
150
+ leafTags.includes(tag) &&
151
+ !el.id &&
152
+ !el.getAttribute('data-testid') &&
153
+ !el.getAttribute('data-component') &&
154
+ !el.getAttribute('aria-label')
155
+ ) {
156
+ const interactiveParent = el.closest?.(
157
+ 'button, a, [role="button"], [role="link"], [role="tab"], [role="menuitem"], summary, label, [data-testid], [data-component]'
158
+ ) as HTMLElement | null;
159
+ if (interactiveParent) return interactiveParent;
160
+ }
161
+
162
+ return el;
163
+ }
164
+
70
165
  /**
71
166
  * Truncates string to specified max length with ellipsis
72
167
  */
@@ -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
  }