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.
Files changed (101) hide show
  1. package/README.md +143 -0
  2. package/dist/chunk-6A7FFDIB.js +221 -0
  3. package/dist/chunk-6A7FFDIB.js.map +1 -0
  4. package/dist/chunk-ANMR5WBH.js +659 -0
  5. package/dist/chunk-ANMR5WBH.js.map +1 -0
  6. package/dist/chunk-BSKNG4V7.js +1199 -0
  7. package/dist/chunk-BSKNG4V7.js.map +1 -0
  8. package/dist/chunk-X7C5CHBO.js +2080 -0
  9. package/dist/chunk-X7C5CHBO.js.map +1 -0
  10. package/dist/chunk-ZLNGOOH2.js +554 -0
  11. package/dist/chunk-ZLNGOOH2.js.map +1 -0
  12. package/dist/cli/index.js +2829 -0
  13. package/dist/cli/index.js.map +1 -0
  14. package/dist/client/index.cjs +2218 -0
  15. package/dist/client/index.d.ts +197 -0
  16. package/dist/client/index.js +20 -0
  17. package/dist/client/index.js.map +1 -0
  18. package/dist/client.iife.js +309 -0
  19. package/dist/core/index.d.ts +78 -0
  20. package/dist/core/index.js +31 -0
  21. package/dist/core/index.js.map +1 -0
  22. package/dist/daemon/index.d.ts +58 -0
  23. package/dist/daemon/index.js +32 -0
  24. package/dist/daemon/index.js.map +1 -0
  25. package/dist/engine-DKloFjvs.d.ts +157 -0
  26. package/dist/index.d.ts +10 -0
  27. package/dist/index.js +50 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/mcp/index.d.ts +11 -0
  30. package/dist/mcp/index.js +12 -0
  31. package/dist/mcp/index.js.map +1 -0
  32. package/dist/notes-CBvN91Wf.d.ts +260 -0
  33. package/dist/projects-CY8ungMt.d.ts +99 -0
  34. package/dist/server-CN-se8td.d.ts +55 -0
  35. package/examples/test-app/index.html +299 -0
  36. package/package.json +52 -7
  37. package/packages/cli/src/index.ts +413 -0
  38. package/packages/client/package.json +28 -0
  39. package/packages/client/src/breadcrumbs.ts +30 -0
  40. package/packages/client/src/client.ts +278 -0
  41. package/packages/client/src/commands/handler.ts +301 -0
  42. package/packages/client/src/config.ts +37 -0
  43. package/packages/client/src/index.ts +50 -0
  44. package/packages/client/src/interceptors/console.ts +68 -0
  45. package/packages/client/src/interceptors/interaction.ts +119 -0
  46. package/packages/client/src/interceptors/navigation.ts +93 -0
  47. package/packages/client/src/interceptors/network.ts +165 -0
  48. package/packages/client/src/interceptors/runtime.ts +65 -0
  49. package/packages/client/src/notes/inspector.ts +943 -0
  50. package/packages/client/src/screenshot/browser-script-driver.ts +192 -0
  51. package/packages/client/src/screenshot/driver.ts +14 -0
  52. package/packages/client/src/transport/websocket.ts +179 -0
  53. package/packages/client/tsconfig.json +8 -0
  54. package/packages/core/dist/index.d.ts +317 -0
  55. package/packages/core/dist/index.js +221 -0
  56. package/packages/core/package.json +23 -0
  57. package/packages/core/src/fingerprint.ts +120 -0
  58. package/packages/core/src/index.ts +9 -0
  59. package/packages/core/src/redaction.ts +139 -0
  60. package/packages/core/src/selector.ts +77 -0
  61. package/packages/core/src/types/commands.ts +101 -0
  62. package/packages/core/src/types/events.ts +108 -0
  63. package/packages/core/src/types/incidents.ts +54 -0
  64. package/packages/core/src/types/notes.ts +106 -0
  65. package/packages/core/src/types/probes.ts +44 -0
  66. package/packages/core/src/types/projects.ts +20 -0
  67. package/packages/core/tsconfig.json +8 -0
  68. package/packages/daemon/src/config.ts +29 -0
  69. package/packages/daemon/src/incidents/engine.ts +211 -0
  70. package/packages/daemon/src/index.ts +18 -0
  71. package/packages/daemon/src/notes/engine.ts +92 -0
  72. package/packages/daemon/src/notes/verification.ts +191 -0
  73. package/packages/daemon/src/server/daemon.ts +112 -0
  74. package/packages/daemon/src/server/http.ts +165 -0
  75. package/packages/daemon/src/server/ws.ts +175 -0
  76. package/packages/daemon/src/session/manager.ts +126 -0
  77. package/packages/daemon/src/storage/db.ts +733 -0
  78. package/packages/daemon/src/storage/screenshot-store.ts +49 -0
  79. package/packages/daemon/src/verification/engine.ts +264 -0
  80. package/packages/mcp/src/handlers.ts +398 -0
  81. package/packages/mcp/src/index.ts +3 -0
  82. package/packages/mcp/src/server.ts +86 -0
  83. package/packages/mcp/src/tools.ts +236 -0
  84. package/src/index.ts +4 -0
  85. package/test/client/interceptors.test.ts +69 -0
  86. package/test/core/fingerprint.test.ts +53 -0
  87. package/test/core/notes.test.ts +66 -0
  88. package/test/core/redaction.test.ts +48 -0
  89. package/test/daemon/incident-engine.test.ts +98 -0
  90. package/test/daemon/notes-storage.test.ts +130 -0
  91. package/test/daemon/notes-verification.test.ts +135 -0
  92. package/test/daemon/storage.test.ts +123 -0
  93. package/test/daemon/verification-engine.test.ts +88 -0
  94. package/test/e2e/daemon-mcp-e2e.test.ts +153 -0
  95. package/test/e2e/visual-notes-e2e.test.ts +205 -0
  96. package/test/mcp/handlers.test.ts +116 -0
  97. package/test/mcp/notes.test.ts +107 -0
  98. package/tsconfig.base.json +17 -0
  99. package/tsconfig.json +26 -0
  100. package/tsup.config.ts +54 -0
  101. 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
+ }