browsertrack 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/dist/chunk-6A7FFDIB.js +221 -0
- package/dist/chunk-6A7FFDIB.js.map +1 -0
- package/dist/chunk-ANMR5WBH.js +659 -0
- package/dist/chunk-ANMR5WBH.js.map +1 -0
- package/dist/chunk-BSKNG4V7.js +1199 -0
- package/dist/chunk-BSKNG4V7.js.map +1 -0
- package/dist/chunk-X7C5CHBO.js +2080 -0
- package/dist/chunk-X7C5CHBO.js.map +1 -0
- package/dist/chunk-ZLNGOOH2.js +554 -0
- package/dist/chunk-ZLNGOOH2.js.map +1 -0
- package/dist/cli/index.js +2829 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/client/index.cjs +2218 -0
- package/dist/client/index.d.ts +197 -0
- package/dist/client/index.js +20 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client.iife.js +309 -0
- package/dist/core/index.d.ts +78 -0
- package/dist/core/index.js +31 -0
- package/dist/core/index.js.map +1 -0
- package/dist/daemon/index.d.ts +58 -0
- package/dist/daemon/index.js +32 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/engine-DKloFjvs.d.ts +157 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/index.d.ts +11 -0
- package/dist/mcp/index.js +12 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/notes-CBvN91Wf.d.ts +260 -0
- package/dist/projects-CY8ungMt.d.ts +99 -0
- package/dist/server-CN-se8td.d.ts +55 -0
- package/examples/test-app/index.html +299 -0
- package/package.json +52 -7
- package/packages/cli/src/index.ts +413 -0
- package/packages/client/package.json +28 -0
- package/packages/client/src/breadcrumbs.ts +30 -0
- package/packages/client/src/client.ts +278 -0
- package/packages/client/src/commands/handler.ts +301 -0
- package/packages/client/src/config.ts +37 -0
- package/packages/client/src/index.ts +50 -0
- package/packages/client/src/interceptors/console.ts +68 -0
- package/packages/client/src/interceptors/interaction.ts +119 -0
- package/packages/client/src/interceptors/navigation.ts +93 -0
- package/packages/client/src/interceptors/network.ts +165 -0
- package/packages/client/src/interceptors/runtime.ts +65 -0
- package/packages/client/src/notes/inspector.ts +943 -0
- package/packages/client/src/screenshot/browser-script-driver.ts +192 -0
- package/packages/client/src/screenshot/driver.ts +14 -0
- package/packages/client/src/transport/websocket.ts +179 -0
- package/packages/client/tsconfig.json +8 -0
- package/packages/core/dist/index.d.ts +317 -0
- package/packages/core/dist/index.js +221 -0
- package/packages/core/package.json +23 -0
- package/packages/core/src/fingerprint.ts +120 -0
- package/packages/core/src/index.ts +9 -0
- package/packages/core/src/redaction.ts +139 -0
- package/packages/core/src/selector.ts +77 -0
- package/packages/core/src/types/commands.ts +101 -0
- package/packages/core/src/types/events.ts +108 -0
- package/packages/core/src/types/incidents.ts +54 -0
- package/packages/core/src/types/notes.ts +106 -0
- package/packages/core/src/types/probes.ts +44 -0
- package/packages/core/src/types/projects.ts +20 -0
- package/packages/core/tsconfig.json +8 -0
- package/packages/daemon/src/config.ts +29 -0
- package/packages/daemon/src/incidents/engine.ts +211 -0
- package/packages/daemon/src/index.ts +18 -0
- package/packages/daemon/src/notes/engine.ts +92 -0
- package/packages/daemon/src/notes/verification.ts +191 -0
- package/packages/daemon/src/server/daemon.ts +112 -0
- package/packages/daemon/src/server/http.ts +165 -0
- package/packages/daemon/src/server/ws.ts +175 -0
- package/packages/daemon/src/session/manager.ts +126 -0
- package/packages/daemon/src/storage/db.ts +733 -0
- package/packages/daemon/src/storage/screenshot-store.ts +49 -0
- package/packages/daemon/src/verification/engine.ts +264 -0
- package/packages/mcp/src/handlers.ts +398 -0
- package/packages/mcp/src/index.ts +3 -0
- package/packages/mcp/src/server.ts +86 -0
- package/packages/mcp/src/tools.ts +236 -0
- package/src/index.ts +4 -0
- package/test/client/interceptors.test.ts +69 -0
- package/test/core/fingerprint.test.ts +53 -0
- package/test/core/notes.test.ts +66 -0
- package/test/core/redaction.test.ts +48 -0
- package/test/daemon/incident-engine.test.ts +98 -0
- package/test/daemon/notes-storage.test.ts +130 -0
- package/test/daemon/notes-verification.test.ts +135 -0
- package/test/daemon/storage.test.ts +123 -0
- package/test/daemon/verification-engine.test.ts +88 -0
- package/test/e2e/daemon-mcp-e2e.test.ts +153 -0
- package/test/e2e/visual-notes-e2e.test.ts +205 -0
- package/test/mcp/handlers.test.ts +116 -0
- package/test/mcp/notes.test.ts +107 -0
- package/tsconfig.base.json +17 -0
- package/tsconfig.json +26 -0
- package/tsup.config.ts +54 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import type { DOMRectJson, NoteStatus, NoteVerificationResult, OverflowCheckResult, VisualNote } from '../../../core/src/index.js';
|
|
3
|
+
import type { NotesEngine } from './engine.js';
|
|
4
|
+
import type { SessionManager } from '../session/manager.js';
|
|
5
|
+
import type { StorageDB } from '../storage/db.js';
|
|
6
|
+
|
|
7
|
+
export class NoteVerificationEngine {
|
|
8
|
+
private db: StorageDB;
|
|
9
|
+
private sessionManager: SessionManager;
|
|
10
|
+
private notesEngine: NotesEngine;
|
|
11
|
+
|
|
12
|
+
constructor(db: StorageDB, sessionManager: SessionManager, notesEngine: NotesEngine) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.sessionManager = sessionManager;
|
|
15
|
+
this.notesEngine = notesEngine;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public async verifyNote(noteId: string, options: { observationWindowMs?: number } = {}): Promise<NoteVerificationResult> {
|
|
19
|
+
const note = this.db.getNote(noteId);
|
|
20
|
+
if (!note) {
|
|
21
|
+
return {
|
|
22
|
+
noteId,
|
|
23
|
+
status: 'FAILED',
|
|
24
|
+
checks: [{ type: 'note_exists', passed: false, details: `Note ${noteId} not found` }],
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
message: `Note ${noteId} not found in database`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 1. Find active session
|
|
31
|
+
let session = this.sessionManager.getActiveSessionForProject(note.projectId);
|
|
32
|
+
if (!session) {
|
|
33
|
+
session = this.sessionManager.getAnyActiveSession();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!session) {
|
|
37
|
+
const result: NoteVerificationResult = {
|
|
38
|
+
noteId,
|
|
39
|
+
status: 'INCONCLUSIVE',
|
|
40
|
+
checks: [],
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
message: 'No active browser session currently connected to verify visual note.',
|
|
43
|
+
};
|
|
44
|
+
this.recordNoteVerification(result, note);
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. Set status to VERIFYING
|
|
49
|
+
this.db.updateNoteStatus(noteId, 'VERIFYING');
|
|
50
|
+
|
|
51
|
+
// 3. Navigation check / route sync
|
|
52
|
+
const pageStateCmd = await this.sessionManager.sendCommand(session.id, {
|
|
53
|
+
id: `cmd_${crypto.randomUUID().slice(0, 8)}`,
|
|
54
|
+
type: 'get_page_state',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const currentRoute = pageStateCmd.result?.route || '/';
|
|
58
|
+
const isRouteMatch = currentRoute === note.route || currentRoute.startsWith(note.route);
|
|
59
|
+
|
|
60
|
+
if (!isRouteMatch && note.route) {
|
|
61
|
+
await this.sessionManager.sendCommand(session.id, {
|
|
62
|
+
id: `cmd_${crypto.randomUUID().slice(0, 8)}`,
|
|
63
|
+
type: 'navigate',
|
|
64
|
+
params: { url: note.route },
|
|
65
|
+
});
|
|
66
|
+
// Wait for navigation observation window
|
|
67
|
+
await new Promise((r) => setTimeout(r, options.observationWindowMs || 1000));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const checks: Array<{ type: string; passed: boolean; details?: string }> = [];
|
|
71
|
+
|
|
72
|
+
checks.push({
|
|
73
|
+
type: 'route_loaded',
|
|
74
|
+
passed: true,
|
|
75
|
+
details: `Route ${note.route || '/'} loaded`,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 4. Target element checks
|
|
79
|
+
const targetSelector = note.target?.selector || (note.type === 'page' ? 'body' : 'body');
|
|
80
|
+
let elementExists = true;
|
|
81
|
+
let elementVisible = true;
|
|
82
|
+
let currentRect: DOMRectJson | undefined;
|
|
83
|
+
let overflowResult: OverflowCheckResult | undefined;
|
|
84
|
+
|
|
85
|
+
if (note.type === 'element' && targetSelector) {
|
|
86
|
+
const queryCmd = await this.sessionManager.sendCommand(session.id, {
|
|
87
|
+
id: `cmd_${crypto.randomUUID().slice(0, 8)}`,
|
|
88
|
+
type: 'query_element',
|
|
89
|
+
params: { selector: targetSelector },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
elementExists = queryCmd.ok && !!queryCmd.result?.exists;
|
|
93
|
+
elementVisible = queryCmd.ok && !!queryCmd.result?.visible;
|
|
94
|
+
currentRect = queryCmd.result?.boundingRect;
|
|
95
|
+
|
|
96
|
+
checks.push({
|
|
97
|
+
type: 'element_exists',
|
|
98
|
+
passed: elementExists,
|
|
99
|
+
details: elementExists ? `Element ${targetSelector} exists in DOM` : `Element ${targetSelector} not found`,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
checks.push({
|
|
103
|
+
type: 'element_visible',
|
|
104
|
+
passed: elementVisible,
|
|
105
|
+
details: elementVisible ? `Element ${targetSelector} is visible` : `Element ${targetSelector} is hidden`,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 5. Layout Overflow Probe
|
|
109
|
+
const overflowCmd = await this.sessionManager.sendCommand(session.id, {
|
|
110
|
+
id: `cmd_${crypto.randomUUID().slice(0, 8)}`,
|
|
111
|
+
type: 'check_overflow',
|
|
112
|
+
params: { selector: targetSelector },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (overflowCmd.ok && overflowCmd.result) {
|
|
116
|
+
const ovf = overflowCmd.result as OverflowCheckResult;
|
|
117
|
+
overflowResult = ovf;
|
|
118
|
+
const isOverflowingNow = !!ovf.overflow;
|
|
119
|
+
|
|
120
|
+
checks.push({
|
|
121
|
+
type: 'no_viewport_overflow',
|
|
122
|
+
passed: !isOverflowingNow,
|
|
123
|
+
details: !isOverflowingNow
|
|
124
|
+
? `Element fits within viewport (width: ${ovf.viewportWidth}px, rect right: ${ovf.rect?.right}px)`
|
|
125
|
+
: `Element overflows viewport by ${ovf.overflowRightPx}px (rect right: ${ovf.rect?.right}px, viewport: ${ovf.viewportWidth}px)`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 6. Capture "after" screenshot
|
|
131
|
+
let afterScreenshotPath: string | undefined;
|
|
132
|
+
const captureCmd = await this.sessionManager.sendCommand(session.id, {
|
|
133
|
+
id: `cmd_${crypto.randomUUID().slice(0, 8)}`,
|
|
134
|
+
type: 'capture_element',
|
|
135
|
+
params: { selector: targetSelector },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (captureCmd.ok && captureCmd.result?.dataUrl) {
|
|
139
|
+
const saved = this.notesEngine.saveNoteScreenshot(note.projectId, note.id, 'after', captureCmd.result.dataUrl);
|
|
140
|
+
if (saved) {
|
|
141
|
+
afterScreenshotPath = saved;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 7. Calculate Geometry Difference
|
|
146
|
+
const beforeRect = note.target?.boundingRect;
|
|
147
|
+
const overflowFixed = overflowResult ? !overflowResult.overflow : undefined;
|
|
148
|
+
|
|
149
|
+
const geometryDiff = {
|
|
150
|
+
before: beforeRect,
|
|
151
|
+
current: currentRect || (overflowResult?.rect as DOMRectJson),
|
|
152
|
+
viewportWidth: overflowResult?.viewportWidth || note.viewport.width,
|
|
153
|
+
overflowFixed,
|
|
154
|
+
overflowPx: overflowResult?.overflowRightPx || 0,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// 8. Verdict
|
|
158
|
+
const anyFailed = checks.some((c) => !c.passed);
|
|
159
|
+
let status: NoteStatus = 'VERIFIED';
|
|
160
|
+
|
|
161
|
+
if (anyFailed) {
|
|
162
|
+
status = 'FAILED';
|
|
163
|
+
} else {
|
|
164
|
+
status = 'VERIFIED';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const verificationResult: NoteVerificationResult = {
|
|
168
|
+
noteId,
|
|
169
|
+
status,
|
|
170
|
+
checks,
|
|
171
|
+
geometryDiff,
|
|
172
|
+
screenshots: {
|
|
173
|
+
before: note.screenshots?.original,
|
|
174
|
+
after: afterScreenshotPath,
|
|
175
|
+
},
|
|
176
|
+
timestamp: new Date().toISOString(),
|
|
177
|
+
message:
|
|
178
|
+
status === 'VERIFIED'
|
|
179
|
+
? 'Visual note verified: target element is present, visible, and layout checks (including viewport overflow) passed.'
|
|
180
|
+
: 'Visual note verification failed: target element missing, hidden, or still overflowing.',
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
this.recordNoteVerification(verificationResult, note);
|
|
184
|
+
return verificationResult;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private recordNoteVerification(result: NoteVerificationResult, note: VisualNote): void {
|
|
188
|
+
this.db.updateNoteStatus(note.id, result.status);
|
|
189
|
+
this.db.insertNoteVerification(result);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import type { DaemonConfig } from '../config.js';
|
|
4
|
+
import { getDaemonConfig } from '../config.js';
|
|
5
|
+
import { IncidentEngine } from '../incidents/engine.js';
|
|
6
|
+
import { NotesEngine } from '../notes/engine.js';
|
|
7
|
+
import { NoteVerificationEngine } from '../notes/verification.js';
|
|
8
|
+
import { SessionManager } from '../session/manager.js';
|
|
9
|
+
import { StorageDB } from '../storage/db.js';
|
|
10
|
+
import { ScreenshotStore } from '../storage/screenshot-store.js';
|
|
11
|
+
import { VerificationEngine } from '../verification/engine.js';
|
|
12
|
+
import { createHttpHandler } from './http.js';
|
|
13
|
+
import { setupWebSocketServer } from './ws.js';
|
|
14
|
+
|
|
15
|
+
export class BrowserTrackDaemon {
|
|
16
|
+
public config: DaemonConfig;
|
|
17
|
+
public db: StorageDB;
|
|
18
|
+
public screenshotStore: ScreenshotStore;
|
|
19
|
+
public sessionManager: SessionManager;
|
|
20
|
+
public incidentEngine: IncidentEngine;
|
|
21
|
+
public notesEngine: NotesEngine;
|
|
22
|
+
public verificationEngine: VerificationEngine;
|
|
23
|
+
public noteVerificationEngine: NoteVerificationEngine;
|
|
24
|
+
|
|
25
|
+
private httpServer: http.Server | null = null;
|
|
26
|
+
private wss: WebSocketServer | null = null;
|
|
27
|
+
private isRunning = false;
|
|
28
|
+
|
|
29
|
+
constructor(config: Partial<DaemonConfig> = {}) {
|
|
30
|
+
this.config = getDaemonConfig(config);
|
|
31
|
+
this.db = new StorageDB(this.config.dbPath);
|
|
32
|
+
this.screenshotStore = new ScreenshotStore(this.config.screenshotsDir);
|
|
33
|
+
this.sessionManager = new SessionManager(this.db);
|
|
34
|
+
this.incidentEngine = new IncidentEngine(this.db, this.screenshotStore);
|
|
35
|
+
this.notesEngine = new NotesEngine(this.db, this.screenshotStore);
|
|
36
|
+
this.verificationEngine = new VerificationEngine(this.db, this.sessionManager, this.screenshotStore);
|
|
37
|
+
this.noteVerificationEngine = new NoteVerificationEngine(this.db, this.sessionManager, this.notesEngine);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public async start(): Promise<void> {
|
|
41
|
+
if (this.isRunning) return;
|
|
42
|
+
|
|
43
|
+
const httpHandler = createHttpHandler(this.db, this.sessionManager, this.config.screenshotsDir);
|
|
44
|
+
this.httpServer = http.createServer(httpHandler);
|
|
45
|
+
|
|
46
|
+
this.wss = new WebSocketServer({ server: this.httpServer });
|
|
47
|
+
setupWebSocketServer(
|
|
48
|
+
this.wss,
|
|
49
|
+
this.db,
|
|
50
|
+
this.sessionManager,
|
|
51
|
+
this.incidentEngine,
|
|
52
|
+
this.notesEngine,
|
|
53
|
+
this.config.maxEventsPerSession,
|
|
54
|
+
this.config.verbose
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
await new Promise<void>((resolve, reject) => {
|
|
58
|
+
this.httpServer!.listen(this.config.port, this.config.host, () => {
|
|
59
|
+
this.isRunning = true;
|
|
60
|
+
if (this.config.verbose) {
|
|
61
|
+
console.log(`[BrowserTrack] Daemon running at http://${this.config.host}:${this.config.port}`);
|
|
62
|
+
}
|
|
63
|
+
resolve();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.httpServer!.once('error', (err) => {
|
|
67
|
+
reject(err);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public async stop(): Promise<void> {
|
|
73
|
+
if (!this.isRunning) return;
|
|
74
|
+
|
|
75
|
+
if (this.wss) {
|
|
76
|
+
for (const client of this.wss.clients) {
|
|
77
|
+
try {
|
|
78
|
+
client.terminate();
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
await new Promise<void>((resolve) => {
|
|
82
|
+
this.wss!.close(() => resolve());
|
|
83
|
+
});
|
|
84
|
+
this.wss = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (this.httpServer) {
|
|
88
|
+
if (typeof (this.httpServer as any).closeAllConnections === 'function') {
|
|
89
|
+
(this.httpServer as any).closeAllConnections();
|
|
90
|
+
}
|
|
91
|
+
await new Promise<void>((resolve) => {
|
|
92
|
+
this.httpServer!.close(() => resolve());
|
|
93
|
+
});
|
|
94
|
+
this.httpServer = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
this.db.close();
|
|
99
|
+
} catch {}
|
|
100
|
+
this.isRunning = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public getStatus(): { isRunning: boolean; host: string; port: number; activeSessions: number; dbPath: string } {
|
|
104
|
+
return {
|
|
105
|
+
isRunning: this.isRunning,
|
|
106
|
+
host: this.config.host,
|
|
107
|
+
port: this.config.port,
|
|
108
|
+
activeSessions: this.sessionManager.getActiveCount(),
|
|
109
|
+
dbPath: this.config.dbPath,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import type { StorageDB } from '../storage/db.js';
|
|
6
|
+
import type { SessionManager } from '../session/manager.js';
|
|
7
|
+
|
|
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;
|
|
12
|
+
|
|
13
|
+
// CORS headers for local development access
|
|
14
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
15
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
16
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
17
|
+
|
|
18
|
+
if (req.method === 'OPTIONS') {
|
|
19
|
+
res.writeHead(204);
|
|
20
|
+
res.end();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 1. Health check
|
|
25
|
+
if (pathname === '/health' || pathname === '/') {
|
|
26
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
27
|
+
res.end(
|
|
28
|
+
JSON.stringify({
|
|
29
|
+
status: 'ok',
|
|
30
|
+
name: 'browsertrack',
|
|
31
|
+
version: '0.1.0',
|
|
32
|
+
activeSessions: sessionManager.getActiveCount(),
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2. Client script tag bundle
|
|
40
|
+
if (pathname === '/client.js' || pathname === '/browserdiag.js') {
|
|
41
|
+
let scriptContent = '';
|
|
42
|
+
try {
|
|
43
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
44
|
+
const __dirname = path.dirname(__filename);
|
|
45
|
+
// Look in dist or adjacent package
|
|
46
|
+
const candidatePaths = [
|
|
47
|
+
path.resolve(__dirname, '../client.iife.js'),
|
|
48
|
+
path.resolve(__dirname, '../../dist/client.iife.js'),
|
|
49
|
+
path.resolve(__dirname, '../../../dist/client.iife.js'),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
for (const p of candidatePaths) {
|
|
53
|
+
if (fs.existsSync(p)) {
|
|
54
|
+
scriptContent = fs.readFileSync(p, 'utf-8');
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {}
|
|
59
|
+
|
|
60
|
+
if (!scriptContent) {
|
|
61
|
+
scriptContent = `console.warn("[BrowserTrack] Standalone client bundle not built yet. Run 'npm run build'.");`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
res.writeHead(200, {
|
|
65
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
66
|
+
'Cache-Control': 'no-cache',
|
|
67
|
+
});
|
|
68
|
+
res.end(scriptContent);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. API - Projects
|
|
73
|
+
if (pathname === '/api/projects') {
|
|
74
|
+
const projects = db.listProjects();
|
|
75
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
76
|
+
res.end(JSON.stringify({ ok: true, projects }));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 4. API - Sessions
|
|
81
|
+
if (pathname === '/api/sessions') {
|
|
82
|
+
const projectId = parsedUrl.searchParams.get('project') || undefined;
|
|
83
|
+
const activeOnly = parsedUrl.searchParams.get('active') === 'true';
|
|
84
|
+
const sessions = db.listSessions(projectId, activeOnly);
|
|
85
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
86
|
+
res.end(JSON.stringify({ ok: true, sessions }));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 5. API - Incidents
|
|
91
|
+
if (pathname === '/api/incidents') {
|
|
92
|
+
const projectId = parsedUrl.searchParams.get('project') || undefined;
|
|
93
|
+
const status = (parsedUrl.searchParams.get('status') as any) || undefined;
|
|
94
|
+
const limit = parseInt(parsedUrl.searchParams.get('limit') || '50', 10);
|
|
95
|
+
const incidents = db.listIncidents({ projectId, status, limit });
|
|
96
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
97
|
+
res.end(JSON.stringify({ ok: true, incidents }));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 6. API - Incident Detail
|
|
102
|
+
if (pathname.startsWith('/api/incidents/')) {
|
|
103
|
+
const incidentId = pathname.replace('/api/incidents/', '');
|
|
104
|
+
const incident = db.getIncident(incidentId);
|
|
105
|
+
if (!incident) {
|
|
106
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
107
|
+
res.end(JSON.stringify({ ok: false, error: 'Incident not found' }));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
111
|
+
res.end(JSON.stringify({ ok: true, incident }));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 7. API - Notes
|
|
116
|
+
if (pathname === '/api/notes') {
|
|
117
|
+
const projectId = parsedUrl.searchParams.get('project') || undefined;
|
|
118
|
+
const status = (parsedUrl.searchParams.get('status') as any) || undefined;
|
|
119
|
+
const limit = parseInt(parsedUrl.searchParams.get('limit') || '50', 10);
|
|
120
|
+
const notes = db.listNotes({ projectId, status, limit });
|
|
121
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
122
|
+
res.end(JSON.stringify({ ok: true, notes }));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 8. API - Note Detail
|
|
127
|
+
if (pathname.startsWith('/api/notes/')) {
|
|
128
|
+
const noteId = pathname.replace('/api/notes/', '');
|
|
129
|
+
const note = db.getNote(noteId);
|
|
130
|
+
if (!note) {
|
|
131
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
132
|
+
res.end(JSON.stringify({ ok: false, error: 'Note not found' }));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
136
|
+
res.end(JSON.stringify({ ok: true, note }));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 9. Screenshots Serving
|
|
141
|
+
if (pathname.startsWith('/screenshots/')) {
|
|
142
|
+
const relativePath = pathname.replace('/screenshots/', '');
|
|
143
|
+
const filePath = path.join(baseScreenshotsDir, relativePath);
|
|
144
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
145
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
146
|
+
const mimeTypes: Record<string, string> = {
|
|
147
|
+
'.webp': 'image/webp',
|
|
148
|
+
'.png': 'image/png',
|
|
149
|
+
'.jpg': 'image/jpeg',
|
|
150
|
+
'.jpeg': 'image/jpeg',
|
|
151
|
+
};
|
|
152
|
+
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' });
|
|
153
|
+
fs.createReadStream(filePath).pipe(res);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
157
|
+
res.end(JSON.stringify({ ok: false, error: 'Screenshot not found' }));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Not found
|
|
162
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
163
|
+
res.end(JSON.stringify({ ok: false, error: 'Not found' }));
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import type { WebSocket, WebSocketServer } from 'ws';
|
|
3
|
+
import type { ClientEventMessage, CommandResponse, HelloMessage } from '../../../core/src/index.js';
|
|
4
|
+
import type { IncidentEngine } from '../incidents/engine.js';
|
|
5
|
+
import type { NotesEngine } from '../notes/engine.js';
|
|
6
|
+
import type { SessionManager } from '../session/manager.js';
|
|
7
|
+
import type { StorageDB } from '../storage/db.js';
|
|
8
|
+
|
|
9
|
+
export function setupWebSocketServer(
|
|
10
|
+
wss: WebSocketServer,
|
|
11
|
+
db: StorageDB,
|
|
12
|
+
sessionManager: SessionManager,
|
|
13
|
+
incidentEngine: IncidentEngine,
|
|
14
|
+
notesEngine?: NotesEngine,
|
|
15
|
+
maxEventsPerSession = 1000,
|
|
16
|
+
verbose = false
|
|
17
|
+
): void {
|
|
18
|
+
wss.on('connection', (ws: WebSocket) => {
|
|
19
|
+
let currentSessionId: string | null = null;
|
|
20
|
+
|
|
21
|
+
ws.on('message', (raw: string | Buffer) => {
|
|
22
|
+
try {
|
|
23
|
+
const text = typeof raw === 'string' ? raw : raw.toString('utf-8');
|
|
24
|
+
const data = JSON.parse(text);
|
|
25
|
+
|
|
26
|
+
// 1. Hello handshake
|
|
27
|
+
if (data.type === 'hello') {
|
|
28
|
+
const hello = data as HelloMessage;
|
|
29
|
+
const sessionId = `sess_${crypto.randomUUID().slice(0, 8)}`;
|
|
30
|
+
currentSessionId = sessionId;
|
|
31
|
+
|
|
32
|
+
// Determine or create project
|
|
33
|
+
let project = hello.projectId ? db.getProject(hello.projectId) : null;
|
|
34
|
+
if (!project && hello.origin) {
|
|
35
|
+
project = db.getProjectByOrigin(hello.origin);
|
|
36
|
+
}
|
|
37
|
+
if (!project) {
|
|
38
|
+
// Auto-create project based on origin or hostname
|
|
39
|
+
let projName = 'default';
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(hello.origin || 'http://localhost');
|
|
42
|
+
projName = url.port ? `app-${url.port}` : url.hostname;
|
|
43
|
+
} catch {
|
|
44
|
+
projName = 'app';
|
|
45
|
+
}
|
|
46
|
+
project = db.upsertProject({
|
|
47
|
+
id: `proj_${crypto.randomUUID().slice(0, 8)}`,
|
|
48
|
+
name: projName,
|
|
49
|
+
origin: hello.origin || 'http://localhost',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const now = new Date().toISOString();
|
|
54
|
+
db.upsertSession({
|
|
55
|
+
id: sessionId,
|
|
56
|
+
projectId: project.id,
|
|
57
|
+
origin: hello.origin || '',
|
|
58
|
+
url: hello.url || '',
|
|
59
|
+
title: hello.title || '',
|
|
60
|
+
userAgent: hello.userAgent || '',
|
|
61
|
+
connectedAt: now,
|
|
62
|
+
lastSeenAt: now,
|
|
63
|
+
active: true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
sessionManager.registerSocket(sessionId, ws, hello.origin || '', project.id);
|
|
67
|
+
|
|
68
|
+
ws.send(
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
type: 'hello_ack',
|
|
71
|
+
sessionId,
|
|
72
|
+
projectId: project.id,
|
|
73
|
+
projectName: project.name,
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (verbose) {
|
|
78
|
+
console.log(`[BrowserTrack] New session connected: ${sessionId} (${project.name} @ ${hello.origin})`);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 2. Client Event (runtime error, console, network, breadcrumb)
|
|
84
|
+
if (data.type === 'event') {
|
|
85
|
+
const eventMsg = data as ClientEventMessage;
|
|
86
|
+
const sessionId = eventMsg.sessionId || currentSessionId;
|
|
87
|
+
if (!sessionId) return;
|
|
88
|
+
|
|
89
|
+
const eventId = `evt_${crypto.randomUUID().slice(0, 8)}`;
|
|
90
|
+
db.insertEvent({
|
|
91
|
+
id: eventId,
|
|
92
|
+
sessionId,
|
|
93
|
+
eventType: eventMsg.eventType,
|
|
94
|
+
payload: eventMsg.payload,
|
|
95
|
+
timestamp: eventMsg.timestamp || Date.now(),
|
|
96
|
+
route: eventMsg.route,
|
|
97
|
+
url: eventMsg.url,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Session retention prune
|
|
101
|
+
db.pruneSessionEvents(sessionId, maxEventsPerSession);
|
|
102
|
+
|
|
103
|
+
// Incident processing
|
|
104
|
+
const incident = incidentEngine.processClientEvent(eventMsg);
|
|
105
|
+
if (incident && verbose) {
|
|
106
|
+
console.log(
|
|
107
|
+
`[BrowserTrack] Incident recorded: ${incident.id} (${incident.type}: ${incident.message}) [${incident.occurrences}x]`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 3. Visual Note Creation
|
|
114
|
+
if (data.type === 'create_note' && notesEngine) {
|
|
115
|
+
const note = notesEngine.createNoteFromClient({
|
|
116
|
+
sessionId: data.sessionId || currentSessionId || '',
|
|
117
|
+
noteType: data.noteType,
|
|
118
|
+
message: data.message,
|
|
119
|
+
route: data.route,
|
|
120
|
+
url: data.url,
|
|
121
|
+
viewport: data.viewport,
|
|
122
|
+
scroll: data.scroll,
|
|
123
|
+
target: data.target,
|
|
124
|
+
elementContext: data.elementContext,
|
|
125
|
+
region: data.region,
|
|
126
|
+
screenshot: data.screenshot,
|
|
127
|
+
incidentId: data.incidentId,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (verbose) {
|
|
131
|
+
console.log(`[BrowserTrack] Visual note created: ${note.id} on ${note.route} ("${note.message}")`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
ws.send(
|
|
135
|
+
JSON.stringify({
|
|
136
|
+
type: 'note_created_ack',
|
|
137
|
+
noteId: note.id,
|
|
138
|
+
status: note.status,
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 4. Command Response
|
|
145
|
+
if (data.type === 'command_response') {
|
|
146
|
+
const res = data.response as CommandResponse;
|
|
147
|
+
const sessionId = data.sessionId || currentSessionId;
|
|
148
|
+
if (sessionId && res) {
|
|
149
|
+
sessionManager.handleCommandResponse(sessionId, res);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
} catch (err: any) {
|
|
154
|
+
if (verbose) {
|
|
155
|
+
console.error('[BrowserTrack] WebSocket message error:', err?.message);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
ws.on('close', () => {
|
|
161
|
+
if (currentSessionId) {
|
|
162
|
+
sessionManager.unregisterSocket(currentSessionId);
|
|
163
|
+
if (verbose) {
|
|
164
|
+
console.log(`[BrowserTrack] Session disconnected: ${currentSessionId}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
ws.on('error', () => {
|
|
170
|
+
if (currentSessionId) {
|
|
171
|
+
sessionManager.unregisterSocket(currentSessionId);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|