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.
- package/AGENTS.md +9 -6
- package/README.md +2 -0
- package/dist/{chunk-INXDWPJW.js → chunk-4HRLW6YF.js} +161 -106
- package/dist/chunk-4HRLW6YF.js.map +1 -0
- package/dist/{chunk-3HOXPTM2.js → chunk-AYSVE6NG.js} +808 -53
- package/dist/chunk-AYSVE6NG.js.map +1 -0
- package/dist/{chunk-6VA7GBAO.js → chunk-QRZ57ME3.js} +70 -2
- package/dist/chunk-QRZ57ME3.js.map +1 -0
- package/dist/{chunk-464D4U2U.js → chunk-TWEYRBDU.js} +279 -43
- package/dist/chunk-TWEYRBDU.js.map +1 -0
- package/dist/cli/index.js +1038 -545
- package/dist/cli/index.js.map +1 -1
- package/dist/client/index.cjs +184 -104
- package/dist/client/index.js +2 -2
- package/dist/client.iife.js +9 -9
- package/dist/core/index.d.ts +24 -1
- package/dist/core/index.js +9 -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 +14 -7
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +7 -4
- package/dist/{server-DiVmTrIR.d.ts → server-DjV7RWQM.d.ts} +9 -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 +81 -48
- 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/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 +114 -45
- package/packages/mcp/src/server.ts +202 -2
- package/test/core/safety.test.ts +106 -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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
1356
|
-
const
|
|
1357
|
-
if (
|
|
1358
|
-
this.
|
|
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
|
-
|
|
1365
|
-
|
|
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
|
-
|
|
1382
|
+
try {
|
|
1383
|
+
if (this.isHidden || this.modalOverlay || this.cardOverlay) return;
|
|
1372
1384
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
1391
|
+
if (this.activeMode === 'region') return;
|
|
1380
1392
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1393
|
+
if (e.altKey || this.activeMode === 'element') {
|
|
1394
|
+
e.preventDefault();
|
|
1395
|
+
e.stopPropagation();
|
|
1384
1396
|
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
1399
|
-
if (
|
|
1400
|
-
this.
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
if (
|
|
1404
|
-
this.
|
|
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
|
-
|
|
1412
|
-
this.
|
|
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
|
-
|
|
1419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
}
|
|
122
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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(
|
|
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
|
}
|