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,192 @@
|
|
|
1
|
+
import { snapdom } from '@zumer/snapdom';
|
|
2
|
+
import type { ScreenshotDriver, ScreenshotResult } from './driver.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Modern DOM snapshot driver using @zumer/snapdom with graceful fallbacks.
|
|
6
|
+
* Converts DOM subtrees and elements into WebP / PNG data URLs.
|
|
7
|
+
*/
|
|
8
|
+
export class BrowserScriptScreenshotDriver implements ScreenshotDriver {
|
|
9
|
+
public name = 'BrowserScriptScreenshotDriver';
|
|
10
|
+
|
|
11
|
+
public async captureSelector(selector: string): Promise<ScreenshotResult> {
|
|
12
|
+
try {
|
|
13
|
+
if (typeof document === 'undefined') {
|
|
14
|
+
return { ok: false, reason: 'NO_DOCUMENT_ENVIRONMENT' };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const el = document.querySelector(selector);
|
|
18
|
+
if (!el) {
|
|
19
|
+
return { ok: false, reason: `ELEMENT_NOT_FOUND: ${selector}` };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return await this.captureElement(el);
|
|
23
|
+
} catch (err: any) {
|
|
24
|
+
return { ok: false, reason: err?.message || 'UNKNOWN_CAPTURE_ERROR' };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public async captureElement(element: HTMLElement | Element): Promise<ScreenshotResult> {
|
|
29
|
+
try {
|
|
30
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
31
|
+
return { ok: false, reason: 'NO_BROWSER_ENVIRONMENT' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rect = element.getBoundingClientRect();
|
|
35
|
+
const width = Math.max(Math.round(rect.width), 10);
|
|
36
|
+
const height = Math.max(Math.round(rect.height), 10);
|
|
37
|
+
|
|
38
|
+
// Attempt capture with snapdom
|
|
39
|
+
const capturePromise = (async () => {
|
|
40
|
+
try {
|
|
41
|
+
// Prefer WebP export if supported, otherwise fallback to PNG
|
|
42
|
+
let imgElement: HTMLImageElement | undefined = undefined;
|
|
43
|
+
let format: 'webp' | 'png' = 'webp';
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
if (typeof snapdom === 'function') {
|
|
47
|
+
const captureRes = await snapdom(element as any, { scale: 1, embedFonts: false });
|
|
48
|
+
if (captureRes && typeof captureRes.toWebp === 'function') {
|
|
49
|
+
imgElement = await captureRes.toWebp();
|
|
50
|
+
} else if (captureRes && typeof captureRes.toPng === 'function') {
|
|
51
|
+
imgElement = await captureRes.toPng();
|
|
52
|
+
format = 'png';
|
|
53
|
+
} else if (captureRes?.url) {
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
dataUrl: captureRes.url,
|
|
57
|
+
format: 'webp' as const,
|
|
58
|
+
width,
|
|
59
|
+
height,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
} else if ((snapdom as any)?.toWebp) {
|
|
63
|
+
imgElement = await (snapdom as any).toWebp(element, {
|
|
64
|
+
scale: 1,
|
|
65
|
+
embedFonts: false,
|
|
66
|
+
});
|
|
67
|
+
} else if ((snapdom as any)?.toPng) {
|
|
68
|
+
imgElement = await (snapdom as any).toPng(element, {
|
|
69
|
+
scale: 1,
|
|
70
|
+
embedFonts: false,
|
|
71
|
+
});
|
|
72
|
+
format = 'png';
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Fall through to fallback
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (imgElement && imgElement.src) {
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
dataUrl: imgElement.src,
|
|
82
|
+
format: imgElement.src.startsWith('data:image/webp') ? ('webp' as const) : format,
|
|
83
|
+
width: imgElement.naturalWidth || width,
|
|
84
|
+
height: imgElement.naturalHeight || height,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
} catch (snapErr: any) {
|
|
88
|
+
const errMsg = String(snapErr?.message || snapErr);
|
|
89
|
+
if (errMsg.includes('CORS') || errMsg.includes('SecurityError') || errMsg.includes('tainted')) {
|
|
90
|
+
return { ok: false, reason: 'CROSS_ORIGIN_RESOURCE' };
|
|
91
|
+
}
|
|
92
|
+
// Fallback to minimal canvas capture below
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Fallback: minimal inline SVG ForeignObject capture
|
|
96
|
+
return await this.captureWithSvgFallback(element, width, height);
|
|
97
|
+
})();
|
|
98
|
+
|
|
99
|
+
// 3 second timeout protection
|
|
100
|
+
const timeoutPromise = new Promise<ScreenshotResult>((resolve) => {
|
|
101
|
+
setTimeout(() => resolve({ ok: false, reason: 'CAPTURE_TIMEOUT' }), 3000);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return await Promise.race([capturePromise, timeoutPromise]);
|
|
105
|
+
} catch (err: any) {
|
|
106
|
+
return { ok: false, reason: err?.message || 'CAPTURE_EXCEPTION' };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async captureWithSvgFallback(
|
|
111
|
+
element: HTMLElement | Element,
|
|
112
|
+
width: number,
|
|
113
|
+
height: number
|
|
114
|
+
): Promise<ScreenshotResult> {
|
|
115
|
+
try {
|
|
116
|
+
const clone = element.cloneNode(true) as HTMLElement;
|
|
117
|
+
const computed = window.getComputedStyle(element);
|
|
118
|
+
let cssText = '';
|
|
119
|
+
for (let i = 0; i < computed.length; i++) {
|
|
120
|
+
const prop = computed[i];
|
|
121
|
+
cssText += `${prop}:${computed.getPropertyValue(prop)};`;
|
|
122
|
+
}
|
|
123
|
+
clone.style.cssText = cssText;
|
|
124
|
+
clone.style.margin = '0';
|
|
125
|
+
clone.style.position = 'static';
|
|
126
|
+
|
|
127
|
+
const svg = `
|
|
128
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
129
|
+
<foreignObject width="100%" height="100%">
|
|
130
|
+
<div xmlns="http://www.w3.org/1999/xhtml" style="width:${width}px;height:${height}px;">
|
|
131
|
+
${clone.outerHTML}
|
|
132
|
+
</div>
|
|
133
|
+
</foreignObject>
|
|
134
|
+
</svg>
|
|
135
|
+
`.trim();
|
|
136
|
+
|
|
137
|
+
const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
|
|
138
|
+
const url = URL.createObjectURL(svgBlob);
|
|
139
|
+
|
|
140
|
+
return await new Promise<ScreenshotResult>((resolve) => {
|
|
141
|
+
const img = new Image();
|
|
142
|
+
img.onload = () => {
|
|
143
|
+
try {
|
|
144
|
+
const canvas = document.createElement('canvas');
|
|
145
|
+
canvas.width = width;
|
|
146
|
+
canvas.height = height;
|
|
147
|
+
const ctx = canvas.getContext('2d');
|
|
148
|
+
if (!ctx) {
|
|
149
|
+
URL.revokeObjectURL(url);
|
|
150
|
+
resolve({ ok: false, reason: 'CANVAS_CONTEXT_UNAVAILABLE' });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
ctx.drawImage(img, 0, 0);
|
|
155
|
+
URL.revokeObjectURL(url);
|
|
156
|
+
|
|
157
|
+
let dataUrl: string;
|
|
158
|
+
try {
|
|
159
|
+
dataUrl = canvas.toDataURL('image/webp', 0.85);
|
|
160
|
+
} catch {
|
|
161
|
+
dataUrl = canvas.toDataURL('image/png');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
resolve({
|
|
165
|
+
ok: true,
|
|
166
|
+
dataUrl,
|
|
167
|
+
format: dataUrl.startsWith('data:image/webp') ? 'webp' : 'png',
|
|
168
|
+
width,
|
|
169
|
+
height,
|
|
170
|
+
});
|
|
171
|
+
} catch (e: any) {
|
|
172
|
+
URL.revokeObjectURL(url);
|
|
173
|
+
if (e?.name === 'SecurityError' || String(e).includes('tainted')) {
|
|
174
|
+
resolve({ ok: false, reason: 'CROSS_ORIGIN_RESOURCE' });
|
|
175
|
+
} else {
|
|
176
|
+
resolve({ ok: false, reason: e?.message || 'CANVAS_EXPORT_FAILED' });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
img.onerror = () => {
|
|
182
|
+
URL.revokeObjectURL(url);
|
|
183
|
+
resolve({ ok: false, reason: 'IMAGE_LOAD_FAILED' });
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
img.src = url;
|
|
187
|
+
});
|
|
188
|
+
} catch (e: any) {
|
|
189
|
+
return { ok: false, reason: e?.message || 'FALLBACK_CAPTURE_FAILED' };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ScreenshotResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
dataUrl?: string;
|
|
4
|
+
format?: 'webp' | 'png' | 'jpeg';
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
reason?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ScreenshotDriver {
|
|
11
|
+
name: string;
|
|
12
|
+
captureElement(element: HTMLElement | Element): Promise<ScreenshotResult>;
|
|
13
|
+
captureSelector(selector: string): Promise<ScreenshotResult>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { HelloMessage, ClientCommand, CommandResponse } from '../../../core/src/index.js';
|
|
2
|
+
import type { ClientCommandHandler } from '../commands/handler.js';
|
|
3
|
+
|
|
4
|
+
export interface TransportOptions {
|
|
5
|
+
url: string;
|
|
6
|
+
projectId?: string;
|
|
7
|
+
debug?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class WebSocketTransport {
|
|
11
|
+
private wsUrl: string;
|
|
12
|
+
private projectId?: string;
|
|
13
|
+
private debug: boolean;
|
|
14
|
+
private ws: WebSocket | null = null;
|
|
15
|
+
private sessionId: string | null = null;
|
|
16
|
+
private queue: string[] = [];
|
|
17
|
+
private maxQueueSize = 50;
|
|
18
|
+
private reconnectAttempts = 0;
|
|
19
|
+
private maxReconnectDelay = 10000;
|
|
20
|
+
private reconnectTimer: any = null;
|
|
21
|
+
private commandHandler?: ClientCommandHandler;
|
|
22
|
+
private messageListeners: ((msg: any) => void)[] = [];
|
|
23
|
+
private isDestroyed = false;
|
|
24
|
+
|
|
25
|
+
constructor(options: TransportOptions) {
|
|
26
|
+
this.wsUrl = options.url || 'ws://127.0.0.1:7331';
|
|
27
|
+
this.projectId = options.projectId;
|
|
28
|
+
this.debug = !!options.debug;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public setCommandHandler(handler: ClientCommandHandler): void {
|
|
32
|
+
this.commandHandler = handler;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public onMessage(listener: (msg: any) => void): () => void {
|
|
36
|
+
this.messageListeners.push(listener);
|
|
37
|
+
return () => {
|
|
38
|
+
this.messageListeners = this.messageListeners.filter((l) => l !== listener);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public getSessionId(): string | null {
|
|
43
|
+
return this.sessionId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public isConnected(): boolean {
|
|
47
|
+
return this.ws !== null && this.ws.readyState === (typeof WebSocket !== 'undefined' ? WebSocket.OPEN : 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public connect(): void {
|
|
51
|
+
if (this.isDestroyed || typeof WebSocket === 'undefined') return;
|
|
52
|
+
if (this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
this.ws = new WebSocket(this.wsUrl);
|
|
58
|
+
|
|
59
|
+
this.ws.onopen = () => {
|
|
60
|
+
this.reconnectAttempts = 0;
|
|
61
|
+
this.sendHello();
|
|
62
|
+
this.flushQueue();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
this.ws.onmessage = async (event: MessageEvent) => {
|
|
66
|
+
try {
|
|
67
|
+
const raw = typeof event.data === 'string' ? event.data : '';
|
|
68
|
+
if (!raw) return;
|
|
69
|
+
const msg = JSON.parse(raw);
|
|
70
|
+
|
|
71
|
+
// Dispatch to subscribers (e.g. NoteInspector for notes_sync)
|
|
72
|
+
for (const listener of this.messageListeners) {
|
|
73
|
+
try {
|
|
74
|
+
listener(msg);
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (msg.type === 'hello_ack') {
|
|
79
|
+
this.sessionId = msg.sessionId;
|
|
80
|
+
if (this.debug) {
|
|
81
|
+
console.log('[BrowserTrack] Connected, session ID:', this.sessionId);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (msg.type === 'command' && this.commandHandler) {
|
|
87
|
+
const command: ClientCommand = msg.command;
|
|
88
|
+
const res: CommandResponse = await this.commandHandler.executeCommand(command);
|
|
89
|
+
this.send({
|
|
90
|
+
type: 'command_response',
|
|
91
|
+
sessionId: this.sessionId,
|
|
92
|
+
response: res,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Defensive
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.ws.onerror = () => {
|
|
101
|
+
// Silent fail if daemon is offline
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
this.ws.onclose = () => {
|
|
105
|
+
this.ws = null;
|
|
106
|
+
this.scheduleReconnect();
|
|
107
|
+
};
|
|
108
|
+
} catch {
|
|
109
|
+
this.scheduleReconnect();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public send(payload: any): void {
|
|
114
|
+
const raw = JSON.stringify(payload);
|
|
115
|
+
if (this.isConnected()) {
|
|
116
|
+
try {
|
|
117
|
+
this.ws!.send(raw);
|
|
118
|
+
} catch {
|
|
119
|
+
this.enqueue(raw);
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
this.enqueue(raw);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private enqueue(raw: string): void {
|
|
127
|
+
if (this.queue.length >= this.maxQueueSize) {
|
|
128
|
+
this.queue.shift();
|
|
129
|
+
}
|
|
130
|
+
this.queue.push(raw);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private flushQueue(): void {
|
|
134
|
+
if (!this.isConnected()) return;
|
|
135
|
+
while (this.queue.length > 0) {
|
|
136
|
+
const item = this.queue.shift();
|
|
137
|
+
if (item) {
|
|
138
|
+
try {
|
|
139
|
+
this.ws!.send(item);
|
|
140
|
+
} catch {
|
|
141
|
+
this.queue.unshift(item);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private sendHello(): void {
|
|
149
|
+
if (typeof window === 'undefined') return;
|
|
150
|
+
|
|
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
|
+
try {
|
|
162
|
+
this.ws!.send(JSON.stringify(hello));
|
|
163
|
+
} catch {
|
|
164
|
+
// Defensive
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private scheduleReconnect(): void {
|
|
169
|
+
if (this.isDestroyed || this.reconnectTimer) return;
|
|
170
|
+
|
|
171
|
+
this.reconnectAttempts++;
|
|
172
|
+
const delay = Math.min(1000 * Math.pow(1.5, this.reconnectAttempts), this.maxReconnectDelay);
|
|
173
|
+
|
|
174
|
+
this.reconnectTimer = setTimeout(() => {
|
|
175
|
+
this.reconnectTimer = null;
|
|
176
|
+
this.connect();
|
|
177
|
+
}, delay);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
public destroy(): void {
|
|
181
|
+
this.isDestroyed = true;
|
|
182
|
+
if (this.reconnectTimer) {
|
|
183
|
+
clearTimeout(this.reconnectTimer);
|
|
184
|
+
this.reconnectTimer = null;
|
|
185
|
+
}
|
|
186
|
+
if (this.ws) {
|
|
187
|
+
try {
|
|
188
|
+
this.ws.close();
|
|
189
|
+
} catch {}
|
|
190
|
+
this.ws = null;
|
|
191
|
+
}
|
|
192
|
+
this.queue = [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@browserdiag/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Core types, schemas, fingerprinting, and utilities for browserdiag",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
16
|
+
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
17
|
+
"clean": "rm -rf dist"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@visulima/redact": "^4.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"tsup": "^8.3.6",
|
|
24
|
+
"typescript": "^5.7.3"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error fingerprinting and normalization utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface FingerprintInput {
|
|
6
|
+
type: string;
|
|
7
|
+
message: string;
|
|
8
|
+
sourceFile?: string;
|
|
9
|
+
line?: number;
|
|
10
|
+
column?: number;
|
|
11
|
+
stack?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalizes error messages by stripping dynamic content like UUIDs, timestamps,
|
|
16
|
+
* hex addresses, and file timestamps.
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeErrorMessage(message: string): string {
|
|
19
|
+
if (!message) return 'unknown_error';
|
|
20
|
+
|
|
21
|
+
return message
|
|
22
|
+
// Normalize string representation
|
|
23
|
+
.trim()
|
|
24
|
+
// Replace hex addresses (e.g. 0x7ffee4b2a8)
|
|
25
|
+
.replace(/0x[0-9a-fA-F]+/g, '0x<HEX>')
|
|
26
|
+
// Replace UUIDs
|
|
27
|
+
.replace(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g, '<UUID>')
|
|
28
|
+
// Replace timestamp query params like ?t=1719283748 or ?v=1.2.3
|
|
29
|
+
.replace(/\?[tv]=[\w.-]+/g, '')
|
|
30
|
+
// Replace ISO timestamps
|
|
31
|
+
.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?/g, '<TIMESTAMP>')
|
|
32
|
+
// Replace numbers in quotes/brackets if likely IDs (e.g., user "12345")
|
|
33
|
+
.replace(/#\d+/g, '#<ID>')
|
|
34
|
+
// Normalize whitespaces
|
|
35
|
+
.replace(/\s+/g, ' ');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalizes source filenames by removing origin and cache-busting query strings.
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeSourceFile(filename?: string): string {
|
|
42
|
+
if (!filename) return 'unknown_source';
|
|
43
|
+
|
|
44
|
+
let cleaned = filename.trim();
|
|
45
|
+
// Strip protocol and origin if present (e.g. http://localhost:5173/src/App.tsx?t=123)
|
|
46
|
+
try {
|
|
47
|
+
if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) {
|
|
48
|
+
const url = new URL(cleaned);
|
|
49
|
+
cleaned = url.pathname;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// fallback string manipulation
|
|
53
|
+
cleaned = cleaned.replace(/^https?:\/\/[^/]+/, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Remove query parameters
|
|
57
|
+
cleaned = cleaned.split('?')[0].split('#')[0];
|
|
58
|
+
|
|
59
|
+
// Normalize windows path separators
|
|
60
|
+
cleaned = cleaned.replace(/\\/g, '/');
|
|
61
|
+
|
|
62
|
+
return cleaned || 'unknown_source';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extracts source location from stack if not explicitly provided
|
|
67
|
+
*/
|
|
68
|
+
export function extractSourceFromStack(stack?: string): { file: string; line: number; column?: number } | null {
|
|
69
|
+
if (!stack) return null;
|
|
70
|
+
|
|
71
|
+
const lines = stack.split('\n');
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
// Match standard stack line: at functionName (http://localhost:5173/src/main.tsx:15:3) or at http://...:15:3
|
|
74
|
+
const match = line.match(/(?:at\s+(?:.*?\s+\()?)?(https?:\/\/[^\s)]+|file:\/\/[^\s)]+|\/[^\s)]+):(\d+):(\d+)\)?/);
|
|
75
|
+
if (match) {
|
|
76
|
+
return {
|
|
77
|
+
file: normalizeSourceFile(match[1]),
|
|
78
|
+
line: parseInt(match[2], 10),
|
|
79
|
+
column: parseInt(match[3], 10),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fast synchronous djb2-based hash for universal client/server runtime
|
|
88
|
+
*/
|
|
89
|
+
function djb2Hash(str: string): string {
|
|
90
|
+
let hash = 5381;
|
|
91
|
+
for (let i = 0; i < str.length; i++) {
|
|
92
|
+
hash = (hash * 33) ^ str.charCodeAt(i);
|
|
93
|
+
}
|
|
94
|
+
// Convert to unsigned 32-bit hex string
|
|
95
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Computes a deterministic fingerprint for an error
|
|
100
|
+
*/
|
|
101
|
+
export function computeFingerprint(input: FingerprintInput): string {
|
|
102
|
+
const normType = (input.type || 'Error').trim().toLowerCase();
|
|
103
|
+
const normMsg = normalizeErrorMessage(input.message);
|
|
104
|
+
|
|
105
|
+
let sourceFile = normalizeSourceFile(input.sourceFile);
|
|
106
|
+
let line = input.line || 0;
|
|
107
|
+
|
|
108
|
+
if ((sourceFile === 'unknown_source' || line === 0) && input.stack) {
|
|
109
|
+
const extracted = extractSourceFromStack(input.stack);
|
|
110
|
+
if (extracted) {
|
|
111
|
+
sourceFile = extracted.file;
|
|
112
|
+
line = extracted.line;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rawKey = `${normType}::${normMsg}::${sourceFile}::${line}`;
|
|
117
|
+
const hash = djb2Hash(rawKey);
|
|
118
|
+
|
|
119
|
+
return `fp_${hash}`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './types/events.js';
|
|
2
|
+
export * from './types/incidents.js';
|
|
3
|
+
export * from './types/commands.js';
|
|
4
|
+
export * from './types/probes.js';
|
|
5
|
+
export * from './types/projects.js';
|
|
6
|
+
export * from './types/notes.js';
|
|
7
|
+
export * from './fingerprint.js';
|
|
8
|
+
export * from './redaction.js';
|
|
9
|
+
export * from './selector.js';
|