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.
- package/AGENTS.md +9 -6
- package/README.md +2 -0
- package/dist/{chunk-464D4U2U.js → chunk-5NR5K3ER.js} +299 -44
- package/dist/chunk-5NR5K3ER.js.map +1 -0
- package/dist/{chunk-3HOXPTM2.js → chunk-G5CIZSQM.js} +808 -53
- package/dist/chunk-G5CIZSQM.js.map +1 -0
- package/dist/{chunk-6VA7GBAO.js → chunk-ONPW7AYL.js} +144 -12
- package/dist/chunk-ONPW7AYL.js.map +1 -0
- package/dist/{chunk-INXDWPJW.js → chunk-PG4JJDCV.js} +353 -119
- package/dist/chunk-PG4JJDCV.js.map +1 -0
- package/dist/cli/index.js +1058 -546
- package/dist/cli/index.js.map +1 -1
- package/dist/client/index.cjs +449 -128
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -2
- package/dist/client.iife.js +121 -15
- package/dist/core/index.d.ts +32 -2
- package/dist/core/index.js +11 -1
- package/dist/daemon/index.d.ts +2 -2
- package/dist/daemon/index.js +6 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +16 -7
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +7 -4
- package/dist/{server-DiVmTrIR.d.ts → server-BRG-RQQP.d.ts} +10 -1
- package/docs/cli.md +4 -1
- package/docs/getting-started.md +60 -6
- package/docs/mcp-reference.md +42 -0
- package/package.json +1 -1
- package/packages/cli/src/index.ts +247 -151
- package/packages/client/src/interceptors/navigation.ts +38 -26
- package/packages/client/src/interceptors/network.ts +22 -17
- package/packages/client/src/notes/inspector.ts +289 -65
- package/packages/client/src/source/resolver.ts +9 -3
- package/packages/client/src/transport/websocket.ts +23 -18
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/safety.ts +86 -0
- package/packages/core/src/selector.ts +110 -15
- package/packages/daemon/src/server/daemon.ts +7 -1
- package/packages/daemon/src/server/http.ts +125 -5
- package/packages/daemon/src/server/ws.ts +33 -29
- package/packages/daemon/src/storage/db.ts +57 -35
- package/packages/mcp/src/handlers.ts +121 -45
- package/packages/mcp/src/server.ts +221 -3
- package/test/core/safety.test.ts +106 -0
- package/test/core/selector.test.ts +133 -0
- package/test/daemon/storage.test.ts +36 -0
- package/test/e2e/daemon-mcp-e2e.test.ts +10 -0
- package/test/mcp/auto-start.test.ts +87 -0
- package/dist/chunk-3HOXPTM2.js.map +0 -1
- package/dist/chunk-464D4U2U.js.map +0 -1
- package/dist/chunk-6VA7GBAO.js.map +0 -1
- package/dist/chunk-7OCOQGDN.js +0 -635
- package/dist/chunk-7OCOQGDN.js.map +0 -1
- 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-
|
|
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
|
-
|
|
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 <
|
|
40
|
-
return `${
|
|
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 `${
|
|
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
|
|
50
|
-
const classList =
|
|
51
|
-
.filter(
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
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
|
-
|
|
9
|
-
return (
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
})
|
|
232
|
-
);
|
|
233
|
+
safeWsSend(ws, {
|
|
234
|
+
type: 'notes_sync',
|
|
235
|
+
notes: allNotes,
|
|
236
|
+
});
|
|
233
237
|
}
|
|
234
238
|
return;
|
|
235
239
|
}
|