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.
Files changed (110) hide show
  1. package/.github/workflows/docs.yml +54 -0
  2. package/AGENTS.md +133 -0
  3. package/README.md +143 -0
  4. package/dist/chunk-6VA7GBAO.js +246 -0
  5. package/dist/chunk-6VA7GBAO.js.map +1 -0
  6. package/dist/chunk-G2Y3CXCY.js +659 -0
  7. package/dist/chunk-G2Y3CXCY.js.map +1 -0
  8. package/dist/chunk-ILRYKMME.js +1228 -0
  9. package/dist/chunk-ILRYKMME.js.map +1 -0
  10. package/dist/chunk-SKCMT2DE.js +2641 -0
  11. package/dist/chunk-SKCMT2DE.js.map +1 -0
  12. package/dist/chunk-SPCIROIU.js +616 -0
  13. package/dist/chunk-SPCIROIU.js.map +1 -0
  14. package/dist/cli/index.js +2942 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/client/index.cjs +2780 -0
  17. package/dist/client/index.d.ts +211 -0
  18. package/dist/client/index.js +20 -0
  19. package/dist/client/index.js.map +1 -0
  20. package/dist/client.iife.js +630 -0
  21. package/dist/core/index.d.ts +86 -0
  22. package/dist/core/index.js +37 -0
  23. package/dist/core/index.js.map +1 -0
  24. package/dist/daemon/index.d.ts +58 -0
  25. package/dist/daemon/index.js +32 -0
  26. package/dist/daemon/index.js.map +1 -0
  27. package/dist/engine-CeT9URuN.d.ts +161 -0
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.js +56 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/mcp/index.d.ts +11 -0
  32. package/dist/mcp/index.js +12 -0
  33. package/dist/mcp/index.js.map +1 -0
  34. package/dist/notes-CBvN91Wf.d.ts +260 -0
  35. package/dist/projects-CY8ungMt.d.ts +99 -0
  36. package/dist/server-Dd8NX2Mk.d.ts +55 -0
  37. package/docboot.config.js +16 -0
  38. package/docs/cli.md +51 -0
  39. package/docs/closed-loop-verification.md +57 -0
  40. package/docs/getting-started.md +122 -0
  41. package/docs/incidents-diagnostics.md +61 -0
  42. package/docs/index.md +46 -0
  43. package/docs/mcp-reference.md +128 -0
  44. package/docs/security-privacy.md +32 -0
  45. package/docs/visual-notes.md +67 -0
  46. package/examples/test-app/index.html +299 -0
  47. package/package.json +56 -7
  48. package/packages/cli/src/index.ts +413 -0
  49. package/packages/client/package.json +28 -0
  50. package/packages/client/src/breadcrumbs.ts +30 -0
  51. package/packages/client/src/client.ts +278 -0
  52. package/packages/client/src/commands/handler.ts +301 -0
  53. package/packages/client/src/config.ts +37 -0
  54. package/packages/client/src/index.ts +50 -0
  55. package/packages/client/src/interceptors/console.ts +68 -0
  56. package/packages/client/src/interceptors/interaction.ts +119 -0
  57. package/packages/client/src/interceptors/navigation.ts +93 -0
  58. package/packages/client/src/interceptors/network.ts +165 -0
  59. package/packages/client/src/interceptors/runtime.ts +65 -0
  60. package/packages/client/src/notes/inspector.ts +1553 -0
  61. package/packages/client/src/screenshot/browser-script-driver.ts +192 -0
  62. package/packages/client/src/screenshot/driver.ts +14 -0
  63. package/packages/client/src/transport/websocket.ts +194 -0
  64. package/packages/client/tsconfig.json +8 -0
  65. package/packages/core/package.json +26 -0
  66. package/packages/core/src/fingerprint.ts +120 -0
  67. package/packages/core/src/index.ts +9 -0
  68. package/packages/core/src/redaction.ts +174 -0
  69. package/packages/core/src/selector.ts +77 -0
  70. package/packages/core/src/types/commands.ts +101 -0
  71. package/packages/core/src/types/events.ts +108 -0
  72. package/packages/core/src/types/incidents.ts +54 -0
  73. package/packages/core/src/types/notes.ts +106 -0
  74. package/packages/core/src/types/probes.ts +44 -0
  75. package/packages/core/src/types/projects.ts +20 -0
  76. package/packages/core/tsconfig.json +8 -0
  77. package/packages/daemon/src/config.ts +29 -0
  78. package/packages/daemon/src/incidents/engine.ts +211 -0
  79. package/packages/daemon/src/index.ts +18 -0
  80. package/packages/daemon/src/notes/engine.ts +92 -0
  81. package/packages/daemon/src/notes/verification.ts +191 -0
  82. package/packages/daemon/src/server/daemon.ts +112 -0
  83. package/packages/daemon/src/server/http.ts +165 -0
  84. package/packages/daemon/src/server/ws.ts +246 -0
  85. package/packages/daemon/src/session/manager.ts +147 -0
  86. package/packages/daemon/src/storage/db.ts +742 -0
  87. package/packages/daemon/src/storage/screenshot-store.ts +49 -0
  88. package/packages/daemon/src/verification/engine.ts +264 -0
  89. package/packages/mcp/src/handlers.ts +398 -0
  90. package/packages/mcp/src/index.ts +3 -0
  91. package/packages/mcp/src/server.ts +86 -0
  92. package/packages/mcp/src/tools.ts +236 -0
  93. package/src/index.ts +4 -0
  94. package/test/client/interceptors.test.ts +130 -0
  95. package/test/core/fingerprint.test.ts +53 -0
  96. package/test/core/notes.test.ts +66 -0
  97. package/test/core/redaction.test.ts +60 -0
  98. package/test/daemon/incident-engine.test.ts +98 -0
  99. package/test/daemon/notes-storage.test.ts +130 -0
  100. package/test/daemon/notes-verification.test.ts +135 -0
  101. package/test/daemon/storage.test.ts +123 -0
  102. package/test/daemon/verification-engine.test.ts +88 -0
  103. package/test/e2e/daemon-mcp-e2e.test.ts +153 -0
  104. package/test/e2e/visual-notes-e2e.test.ts +205 -0
  105. package/test/mcp/handlers.test.ts +116 -0
  106. package/test/mcp/notes.test.ts +107 -0
  107. package/tsconfig.base.json +17 -0
  108. package/tsconfig.json +26 -0
  109. package/tsup.config.ts +54 -0
  110. 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,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"]
8
+ }
@@ -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';