browsertrack 0.0.1 → 0.1.1
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/.github/workflows/docs.yml +54 -0
- package/AGENTS.md +133 -0
- package/README.md +143 -0
- package/dist/chunk-6VA7GBAO.js +246 -0
- package/dist/chunk-6VA7GBAO.js.map +1 -0
- package/dist/chunk-G2Y3CXCY.js +659 -0
- package/dist/chunk-G2Y3CXCY.js.map +1 -0
- package/dist/chunk-ILRYKMME.js +1228 -0
- package/dist/chunk-ILRYKMME.js.map +1 -0
- package/dist/chunk-SKCMT2DE.js +2641 -0
- package/dist/chunk-SKCMT2DE.js.map +1 -0
- package/dist/chunk-SPCIROIU.js +616 -0
- package/dist/chunk-SPCIROIU.js.map +1 -0
- package/dist/cli/index.js +2942 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/client/index.cjs +2780 -0
- package/dist/client/index.d.ts +211 -0
- package/dist/client/index.js +20 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client.iife.js +630 -0
- package/dist/core/index.d.ts +86 -0
- package/dist/core/index.js +37 -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-CeT9URuN.d.ts +161 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +56 -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-Dd8NX2Mk.d.ts +55 -0
- package/docboot.config.js +16 -0
- package/docs/cli.md +51 -0
- package/docs/closed-loop-verification.md +57 -0
- package/docs/getting-started.md +122 -0
- package/docs/incidents-diagnostics.md +61 -0
- package/docs/index.md +46 -0
- package/docs/mcp-reference.md +128 -0
- package/docs/security-privacy.md +32 -0
- package/docs/visual-notes.md +67 -0
- package/examples/test-app/index.html +299 -0
- package/package.json +56 -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 +1553 -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 +194 -0
- package/packages/client/tsconfig.json +8 -0
- package/packages/core/package.json +26 -0
- package/packages/core/src/fingerprint.ts +120 -0
- package/packages/core/src/index.ts +9 -0
- package/packages/core/src/redaction.ts +174 -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 +246 -0
- package/packages/daemon/src/session/manager.ts +147 -0
- package/packages/daemon/src/storage/db.ts +742 -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 +130 -0
- package/test/core/fingerprint.test.ts +53 -0
- package/test/core/notes.test.ts +66 -0
- package/test/core/redaction.test.ts +60 -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,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,246 @@
|
|
|
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
|
+
// Sync existing notes for this project on load
|
|
78
|
+
const existingNotes = db.listNotes({ projectId: project.id, limit: 100 });
|
|
79
|
+
ws.send(
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
type: 'notes_sync',
|
|
82
|
+
notes: existingNotes,
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (verbose) {
|
|
87
|
+
console.log(`[BrowserTrack] New session connected: ${sessionId} (${project.name} @ ${hello.origin})`);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 2. Client Event (runtime error, console, network, breadcrumb)
|
|
93
|
+
if (data.type === 'event') {
|
|
94
|
+
const eventMsg = data as ClientEventMessage;
|
|
95
|
+
const sessionId = eventMsg.sessionId || currentSessionId;
|
|
96
|
+
if (!sessionId) return;
|
|
97
|
+
|
|
98
|
+
const eventId = `evt_${crypto.randomUUID().slice(0, 8)}`;
|
|
99
|
+
db.insertEvent({
|
|
100
|
+
id: eventId,
|
|
101
|
+
sessionId,
|
|
102
|
+
eventType: eventMsg.eventType,
|
|
103
|
+
payload: eventMsg.payload,
|
|
104
|
+
timestamp: eventMsg.timestamp || Date.now(),
|
|
105
|
+
route: eventMsg.route,
|
|
106
|
+
url: eventMsg.url,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Session retention prune
|
|
110
|
+
db.pruneSessionEvents(sessionId, maxEventsPerSession);
|
|
111
|
+
|
|
112
|
+
// Incident processing
|
|
113
|
+
const incident = incidentEngine.processClientEvent(eventMsg);
|
|
114
|
+
if (incident && verbose) {
|
|
115
|
+
console.log(
|
|
116
|
+
`[BrowserTrack] Incident recorded: ${incident.id} (${incident.type}: ${incident.message}) [${incident.occurrences}x]`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 3. Visual Note Creation
|
|
123
|
+
if (data.type === 'create_note' && notesEngine) {
|
|
124
|
+
const note = notesEngine.createNoteFromClient({
|
|
125
|
+
sessionId: data.sessionId || currentSessionId || '',
|
|
126
|
+
noteType: data.noteType,
|
|
127
|
+
message: data.message,
|
|
128
|
+
route: data.route,
|
|
129
|
+
url: data.url,
|
|
130
|
+
viewport: data.viewport,
|
|
131
|
+
scroll: data.scroll,
|
|
132
|
+
target: data.target,
|
|
133
|
+
elementContext: data.elementContext,
|
|
134
|
+
region: data.region,
|
|
135
|
+
screenshot: data.screenshot,
|
|
136
|
+
incidentId: data.incidentId,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (verbose) {
|
|
140
|
+
console.log(`[BrowserTrack] Visual note created: ${note.id} on ${note.route} ("${note.message}")`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ws.send(
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
type: 'note_created_ack',
|
|
146
|
+
noteId: note.id,
|
|
147
|
+
status: note.status,
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Broadcast updated notes to all active browser tabs in this project
|
|
152
|
+
const allNotes = db.listNotes({ projectId: note.projectId, limit: 100 });
|
|
153
|
+
sessionManager.broadcastToProject(note.projectId, {
|
|
154
|
+
type: 'notes_sync',
|
|
155
|
+
notes: allNotes,
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 4. Visual Note Status / Delete Actions
|
|
161
|
+
if (data.type === 'resolve_note' && data.noteId) {
|
|
162
|
+
const note = db.getNote(data.noteId);
|
|
163
|
+
if (note) {
|
|
164
|
+
db.updateNoteStatus(data.noteId, 'RESOLVED');
|
|
165
|
+
const allNotes = db.listNotes({ projectId: note.projectId, limit: 100 });
|
|
166
|
+
sessionManager.broadcastToProject(note.projectId, {
|
|
167
|
+
type: 'notes_sync',
|
|
168
|
+
notes: allNotes,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (data.type === 'reopen_note' && data.noteId) {
|
|
175
|
+
const note = db.getNote(data.noteId);
|
|
176
|
+
if (note) {
|
|
177
|
+
db.updateNoteStatus(data.noteId, 'OPEN');
|
|
178
|
+
const allNotes = db.listNotes({ projectId: note.projectId, limit: 100 });
|
|
179
|
+
sessionManager.broadcastToProject(note.projectId, {
|
|
180
|
+
type: 'notes_sync',
|
|
181
|
+
notes: allNotes,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (data.type === 'delete_note' && data.noteId) {
|
|
188
|
+
const note = db.getNote(data.noteId);
|
|
189
|
+
if (note) {
|
|
190
|
+
db.deleteNote(data.noteId);
|
|
191
|
+
const allNotes = db.listNotes({ projectId: note.projectId, limit: 100 });
|
|
192
|
+
sessionManager.broadcastToProject(note.projectId, {
|
|
193
|
+
type: 'notes_sync',
|
|
194
|
+
notes: allNotes,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (data.type === 'get_notes') {
|
|
201
|
+
const session = currentSessionId ? db.getSession(currentSessionId) : null;
|
|
202
|
+
const projectId = data.projectId || session?.projectId;
|
|
203
|
+
if (projectId) {
|
|
204
|
+
const allNotes = db.listNotes({ projectId, limit: 100 });
|
|
205
|
+
ws.send(
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
type: 'notes_sync',
|
|
208
|
+
notes: allNotes,
|
|
209
|
+
})
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 5. Command Response
|
|
216
|
+
if (data.type === 'command_response') {
|
|
217
|
+
const res = data.response as CommandResponse;
|
|
218
|
+
const sessionId = data.sessionId || currentSessionId;
|
|
219
|
+
if (sessionId && res) {
|
|
220
|
+
sessionManager.handleCommandResponse(sessionId, res);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
} catch (err: any) {
|
|
225
|
+
if (verbose) {
|
|
226
|
+
console.error('[BrowserTrack] WebSocket message error:', err?.message);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
ws.on('close', () => {
|
|
232
|
+
if (currentSessionId) {
|
|
233
|
+
sessionManager.unregisterSocket(currentSessionId);
|
|
234
|
+
if (verbose) {
|
|
235
|
+
console.log(`[BrowserTrack] Session disconnected: ${currentSessionId}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
ws.on('error', () => {
|
|
241
|
+
if (currentSessionId) {
|
|
242
|
+
sessionManager.unregisterSocket(currentSessionId);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { WebSocket } from 'ws';
|
|
2
|
+
import type { ClientCommand, CommandResponse, Session } from '../../../core/src/index.js';
|
|
3
|
+
import type { StorageDB } from '../storage/db.js';
|
|
4
|
+
|
|
5
|
+
interface ActiveSessionSocket {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
ws: WebSocket;
|
|
8
|
+
origin: string;
|
|
9
|
+
projectId: string;
|
|
10
|
+
pendingCommands: Map<
|
|
11
|
+
string,
|
|
12
|
+
{
|
|
13
|
+
resolve: (res: CommandResponse) => void;
|
|
14
|
+
reject: (err: Error) => void;
|
|
15
|
+
timer: any;
|
|
16
|
+
}
|
|
17
|
+
>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class SessionManager {
|
|
21
|
+
private activeSockets: Map<string, ActiveSessionSocket> = new Map();
|
|
22
|
+
private db: StorageDB;
|
|
23
|
+
|
|
24
|
+
constructor(db: StorageDB) {
|
|
25
|
+
this.db = db;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public registerSocket(sessionId: string, ws: WebSocket, origin: string, projectId: string): void {
|
|
29
|
+
this.activeSockets.set(sessionId, {
|
|
30
|
+
sessionId,
|
|
31
|
+
ws,
|
|
32
|
+
origin,
|
|
33
|
+
projectId,
|
|
34
|
+
pendingCommands: new Map(),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public unregisterSocket(sessionId: string): void {
|
|
39
|
+
const active = this.activeSockets.get(sessionId);
|
|
40
|
+
if (active) {
|
|
41
|
+
for (const pending of active.pendingCommands.values()) {
|
|
42
|
+
clearTimeout(pending.timer);
|
|
43
|
+
pending.reject(new Error('Session disconnected before command completed'));
|
|
44
|
+
}
|
|
45
|
+
active.pendingCommands.clear();
|
|
46
|
+
this.activeSockets.delete(sessionId);
|
|
47
|
+
this.db.deactivateSession(sessionId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public handleCommandResponse(sessionId: string, response: CommandResponse): void {
|
|
52
|
+
const active = this.activeSockets.get(sessionId);
|
|
53
|
+
if (!active) return;
|
|
54
|
+
|
|
55
|
+
const pending = active.pendingCommands.get(response.id);
|
|
56
|
+
if (pending) {
|
|
57
|
+
clearTimeout(pending.timer);
|
|
58
|
+
active.pendingCommands.delete(response.id);
|
|
59
|
+
pending.resolve(response);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public async sendCommand<T = any>(sessionId: string, command: ClientCommand<T>, timeoutMs = 5000): Promise<CommandResponse> {
|
|
64
|
+
const active = this.activeSockets.get(sessionId);
|
|
65
|
+
if (!active || active.ws.readyState !== 1 /* WebSocket.OPEN */) {
|
|
66
|
+
return {
|
|
67
|
+
id: command.id,
|
|
68
|
+
ok: false,
|
|
69
|
+
error: `Session ${sessionId} is not actively connected`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return new Promise<CommandResponse>((resolve, reject) => {
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
active.pendingCommands.delete(command.id);
|
|
76
|
+
resolve({
|
|
77
|
+
id: command.id,
|
|
78
|
+
ok: false,
|
|
79
|
+
error: `Command ${command.type} timed out after ${timeoutMs}ms`,
|
|
80
|
+
});
|
|
81
|
+
}, timeoutMs);
|
|
82
|
+
|
|
83
|
+
active.pendingCommands.set(command.id, {
|
|
84
|
+
resolve,
|
|
85
|
+
reject,
|
|
86
|
+
timer,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
active.ws.send(
|
|
91
|
+
JSON.stringify({
|
|
92
|
+
type: 'command',
|
|
93
|
+
command,
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
} catch (err: any) {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
active.pendingCommands.delete(command.id);
|
|
99
|
+
resolve({
|
|
100
|
+
id: command.id,
|
|
101
|
+
ok: false,
|
|
102
|
+
error: err?.message || 'Failed to send command over WebSocket',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public getActiveSessionForProject(projectId: string): Session | null {
|
|
109
|
+
for (const active of this.activeSockets.values()) {
|
|
110
|
+
if (active.projectId === projectId) {
|
|
111
|
+
return this.db.getSession(active.sessionId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public getAnyActiveSession(): Session | null {
|
|
118
|
+
const first = this.activeSockets.keys().next().value;
|
|
119
|
+
if (!first) return null;
|
|
120
|
+
return this.db.getSession(first);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public getActiveCount(): number {
|
|
124
|
+
return this.activeSockets.size;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
public broadcastToProject(projectId: string, message: any): void {
|
|
128
|
+
const raw = typeof message === 'string' ? message : JSON.stringify(message);
|
|
129
|
+
for (const active of this.activeSockets.values()) {
|
|
130
|
+
if (active.projectId === projectId && active.ws.readyState === 1 /* OPEN */) {
|
|
131
|
+
try {
|
|
132
|
+
active.ws.send(raw);
|
|
133
|
+
} catch {}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
public sendToSession(sessionId: string, message: any): void {
|
|
139
|
+
const active = this.activeSockets.get(sessionId);
|
|
140
|
+
if (active && active.ws.readyState === 1) {
|
|
141
|
+
try {
|
|
142
|
+
const raw = typeof message === 'string' ? message : JSON.stringify(message);
|
|
143
|
+
active.ws.send(raw);
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|