browser-flow-tracker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/server.js ADDED
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ // MCP server exposing browser-flow-tracker to Claude / Cursor.
3
+ //
4
+ // Tools:
5
+ // list_browsers - which Chromium browsers are installed
6
+ // start_tracking - launch or attach, begin recording (one session at a time)
7
+ // get_flow - live snapshot of the current recording (without stopping)
8
+ // stop_tracking - stop, write JSON/HAR/Markdown, return the flow
9
+
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { z } from 'zod';
13
+ import { TrackingSession } from '../src/session.js';
14
+ import { detectInstalled } from '../src/browsers.js';
15
+
16
+ let current = null; // the single active TrackingSession
17
+
18
+ function json(data) {
19
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
20
+ }
21
+ function err(message) {
22
+ return { isError: true, content: [{ type: 'text', text: message }] };
23
+ }
24
+
25
+ const server = new McpServer(
26
+ { name: 'browser-flow-tracker', version: '0.1.0' },
27
+ {
28
+ instructions: [
29
+ 'Use these tools whenever the user wants to record, analyze, or document the',
30
+ 'API / network flow of a web page or app module (e.g. "what APIs does this call",',
31
+ '"document the checkout flow", "track the requests on this page").',
32
+ '',
33
+ 'ALWAYS use these tools for that job. Do NOT hand-roll browser automation, do NOT',
34
+ 'launch browsers or poke at debug ports via the shell, and do NOT try to analyze',
35
+ 'network traffic yourself — start_tracking handles launching/attaching and picks a',
36
+ 'free port automatically.',
37
+ '',
38
+ 'The capture is USER-DRIVEN: call start_tracking (launch a fresh window OR attach to',
39
+ "the user's browser), then STOP and let the USER navigate/click through the real flow",
40
+ 'themselves. Do not auto-navigate the flow for them. When they say they are done, call',
41
+ 'stop_tracking and turn the returned flow into a document. Use get_flow for a live peek.',
42
+ 'The launched window uses a persistent profile, so a login done once is remembered.',
43
+ ].join('\n'),
44
+ },
45
+ );
46
+
47
+ server.registerTool(
48
+ 'list_browsers',
49
+ {
50
+ description: 'List Chromium-family browsers installed on this machine that can be tracked (Brave, Chrome, Arc, Edge, etc.). Safari/Firefox are not supported (no CDP).',
51
+ inputSchema: {},
52
+ },
53
+ async () => json({ browsers: detectInstalled() }),
54
+ );
55
+
56
+ server.registerTool(
57
+ 'start_tracking',
58
+ {
59
+ description:
60
+ 'Start recording a page\'s API/network flow. Either launch a fresh browser (launch=true, requires browser id) or attach to a browser already running with --remote-debugging-port (attach mode). Only one session runs at a time.',
61
+ inputSchema: {
62
+ launch: z.boolean().optional().describe('Launch a fresh browser with a throwaway profile. If false/omitted, attach to a running browser.'),
63
+ browser: z.string().optional().describe('Browser id for launch mode: brave | arc | chrome | edge | vivaldi | opera | chromium'),
64
+ port: z.number().optional().describe('CDP port (default 9222).'),
65
+ url: z.string().optional().describe('URL to open (launch mode).'),
66
+ urlMatch: z.string().optional().describe('When attaching, pick the tab whose URL contains this substring.'),
67
+ headless: z.boolean().optional().describe('Launch headless (launch mode).'),
68
+ includeNoise: z.boolean().optional().describe('Also keep filtered static/analytics requests.'),
69
+ redact: z.boolean().optional().describe('Redact auth/cookie headers (default true).'),
70
+ outDir: z.string().optional().describe('Where to write output files if the user closes the browser (default ./recordings).'),
71
+ name: z.string().optional().describe('Output file basename used on auto-finalize (default flow-<timestamp>).'),
72
+ title: z.string().optional().describe('Title for the generated Markdown document.'),
73
+ },
74
+ },
75
+ async (args) => {
76
+ if (current?.active) return err('A tracking session is already active. Call stop_tracking first.');
77
+ if (args.launch && !args.browser) return err('launch=true requires a browser id. Call list_browsers.');
78
+ current = new TrackingSession({
79
+ launch: Boolean(args.launch),
80
+ browser: args.browser,
81
+ port: args.port ?? 9222,
82
+ url: args.url,
83
+ urlMatch: args.urlMatch,
84
+ headless: Boolean(args.headless),
85
+ includeNoise: Boolean(args.includeNoise),
86
+ redact: args.redact !== false,
87
+ // Defaults used to write files automatically if the user closes the browser.
88
+ outDir: args.outDir,
89
+ name: args.name,
90
+ title: args.title,
91
+ });
92
+ try {
93
+ const info = await current.start();
94
+ const hint = info.reusedExisting
95
+ ? 'Reused the already-open recording window (your login is preserved). Now let the USER navigate the flow; call get_flow to peek or stop_tracking when they are done.'
96
+ : 'A browser window is open and recording. Do NOT navigate it yourself — let the USER click through the real flow, then call get_flow to peek or stop_tracking to finish. (Persistent profile: any login is remembered next time.)';
97
+ return json({ status: 'recording', ...info, hint });
98
+ } catch (e) {
99
+ current = null;
100
+ return err(`Failed to start tracking: ${e.message}`);
101
+ }
102
+ },
103
+ );
104
+
105
+ server.registerTool(
106
+ 'get_flow',
107
+ {
108
+ description: 'Return a live snapshot of the API calls captured so far in the current session, without stopping it.',
109
+ inputSchema: {
110
+ full: z.boolean().optional().describe('Include full request/response bodies and headers (default: summary only).'),
111
+ },
112
+ },
113
+ async ({ full }) => {
114
+ // If the user closed the browser, the session auto-finalized and wrote files.
115
+ if (current && !current.active && current.finalized) {
116
+ const f = current.finalized;
117
+ return json({
118
+ status: 'ended',
119
+ reason: current.closedByUser ? 'browser closed by user' : 'stopped',
120
+ files: f.files,
121
+ stats: f.session.stats,
122
+ note: 'The recording is finished and files are written. Read the .flow.json to build the doc.',
123
+ });
124
+ }
125
+ if (!current?.active) return err('No active tracking session. Call start_tracking first.');
126
+ const snap = current.snapshot();
127
+ if (full) return json(snap);
128
+ const summary = snap.flow
129
+ .filter((e) => e.category !== 'document')
130
+ .map((e) => ({ i: e.index, method: e.method, url: `${e.host}${e.path}`, status: e.status, ms: e.durationMs }));
131
+ return json({ stats: snap.stats, calls: summary });
132
+ },
133
+ );
134
+
135
+ server.registerTool(
136
+ 'stop_tracking',
137
+ {
138
+ description: 'Stop the current recording, write JSON + HAR + Markdown outputs to disk, and return the full normalized flow plus file paths.',
139
+ inputSchema: {
140
+ outDir: z.string().optional().describe('Output directory (default ./recordings).'),
141
+ name: z.string().optional().describe('Output file basename (default flow-<timestamp>).'),
142
+ title: z.string().optional().describe('Title for the generated Markdown document.'),
143
+ closeBrowser: z.boolean().optional().describe('Close the browser if it was launched by this tool.'),
144
+ },
145
+ },
146
+ async (args) => {
147
+ if (!current) return err('No session to stop.');
148
+ if (args.title) current.opts.title = args.title;
149
+ try {
150
+ const { session, files, closedByUser } = await current.stop({
151
+ outDir: args.outDir,
152
+ name: args.name,
153
+ closeBrowser: args.closeBrowser,
154
+ });
155
+ // Return a COMPACT summary + file paths only. Full detail (bodies/headers)
156
+ // can be huge, so it lives in the .flow.json file — read that to build the doc.
157
+ const calls = session.flow
158
+ .filter((e) => e.category !== 'document')
159
+ .map((e) => ({ i: e.index, method: e.method, url: `${e.host}${e.path}`, status: e.status, ms: e.durationMs }));
160
+ const result = {
161
+ status: closedByUser ? 'ended (browser closed)' : 'stopped',
162
+ files,
163
+ stats: session.stats,
164
+ target: session.target,
165
+ calls,
166
+ note: 'Full request/response detail is in the .flow.json file — read it from disk to write the doc.',
167
+ };
168
+ current = null;
169
+ return json(result);
170
+ } catch (e) {
171
+ return err(`Failed to stop: ${e.message}`);
172
+ }
173
+ },
174
+ );
175
+
176
+ const transport = new StdioServerTransport();
177
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "browser-flow-tracker",
3
+ "version": "0.1.0",
4
+ "description": "Record and document the API flow of any Chromium browser page (Brave, Chrome, Arc, Edge) via the Chrome DevTools Protocol. Ships as a local MCP server for Claude/Cursor and a CLI. Emits structured JSON + HAR + a Markdown flow-doc.",
5
+ "type": "module",
6
+ "bin": {
7
+ "browser-flow-tracker": "mcp/server.js",
8
+ "bft": "bin/bft.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "mcp",
13
+ "src",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "start": "node bin/bft.js",
19
+ "mcp": "node mcp/server.js"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "model-context-protocol",
27
+ "claude",
28
+ "cursor",
29
+ "api",
30
+ "network",
31
+ "cdp",
32
+ "chrome-devtools-protocol",
33
+ "har",
34
+ "network-flow",
35
+ "api-documentation"
36
+ ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/devggaurav/web-Api-scrapper.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/devggaurav/web-Api-scrapper/issues"
43
+ },
44
+ "homepage": "https://github.com/devggaurav/web-Api-scrapper#readme",
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dependencies": {
49
+ "chrome-remote-interface": "^0.33.3",
50
+ "@modelcontextprotocol/sdk": "^1.0.4",
51
+ "zod": "^4.4.3"
52
+ },
53
+ "license": "MIT"
54
+ }
@@ -0,0 +1,104 @@
1
+ // Browser detection + launch helpers for Chromium-family browsers on macOS,
2
+ // Linux and Windows. All of these speak the Chrome DevTools Protocol (CDP),
3
+ // which is what the recorder attaches to.
4
+
5
+ import { spawn } from 'node:child_process';
6
+ import { existsSync, mkdtempSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ // Per-platform candidate binary paths, keyed by a short browser id.
11
+ const BINARIES = {
12
+ darwin: {
13
+ brave: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
14
+ chrome: [
15
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
16
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
17
+ ],
18
+ // Arc ships a Chromium engine; the CDP endpoint works the same way.
19
+ arc: ['/Applications/Arc.app/Contents/MacOS/Arc'],
20
+ edge: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
21
+ vivaldi: ['/Applications/Vivaldi.app/Contents/MacOS/Vivaldi'],
22
+ opera: ['/Applications/Opera.app/Contents/MacOS/Opera'],
23
+ chromium: ['/Applications/Chromium.app/Contents/MacOS/Chromium'],
24
+ },
25
+ linux: {
26
+ brave: ['/usr/bin/brave-browser', '/usr/bin/brave'],
27
+ chrome: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable'],
28
+ edge: ['/usr/bin/microsoft-edge'],
29
+ vivaldi: ['/usr/bin/vivaldi'],
30
+ opera: ['/usr/bin/opera'],
31
+ chromium: ['/usr/bin/chromium', '/usr/bin/chromium-browser'],
32
+ },
33
+ win32: {
34
+ brave: ['C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe'],
35
+ chrome: [
36
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
37
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
38
+ ],
39
+ edge: ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'],
40
+ },
41
+ };
42
+
43
+ // Browsers that are Chromium-based but not covered here (Safari/WebKit,
44
+ // Firefox/Gecko) do not expose CDP and need the proxy engine instead.
45
+ export const UNSUPPORTED_CDP = {
46
+ safari: 'Safari uses WebKit and does not speak the Chrome DevTools Protocol. Use proxy mode (roadmap) or record in a Chromium browser.',
47
+ firefox: 'Firefox uses a different remote protocol. Use proxy mode (roadmap) or record in a Chromium browser.',
48
+ };
49
+
50
+ export function resolveBrowser(id) {
51
+ const platform = process.platform;
52
+ const table = BINARIES[platform];
53
+ if (!table) throw new Error(`Unsupported platform: ${platform}`);
54
+ const candidates = table[id];
55
+ if (!candidates) {
56
+ if (UNSUPPORTED_CDP[id]) throw new Error(UNSUPPORTED_CDP[id]);
57
+ throw new Error(`Unknown browser id "${id}". Known: ${Object.keys(table).join(', ')}`);
58
+ }
59
+ const bin = candidates.find((p) => existsSync(p));
60
+ if (!bin) {
61
+ throw new Error(
62
+ `Could not find ${id} at any known path for ${platform}:\n ${candidates.join('\n ')}`,
63
+ );
64
+ }
65
+ return bin;
66
+ }
67
+
68
+ // Which known browsers are actually installed on this machine.
69
+ export function detectInstalled() {
70
+ const platform = process.platform;
71
+ const table = BINARIES[platform] || {};
72
+ const found = [];
73
+ for (const [id, candidates] of Object.entries(table)) {
74
+ const bin = candidates.find((p) => existsSync(p));
75
+ if (bin) found.push({ id, bin });
76
+ }
77
+ return found;
78
+ }
79
+
80
+ /**
81
+ * Launch a Chromium browser with remote debugging enabled and return the
82
+ * child process. Uses a throwaway user-data-dir by default so it never
83
+ * collides with the user's normal profile (which refuses --remote-debugging-port
84
+ * if already running).
85
+ */
86
+ export function launchBrowser({ id, port = 9222, url, userDataDir, headless = false } = {}) {
87
+ const bin = resolveBrowser(id);
88
+ const dataDir = userDataDir || mkdtempSync(join(tmpdir(), `bft-${id}-`));
89
+ const args = [
90
+ `--remote-debugging-port=${port}`,
91
+ `--user-data-dir=${dataDir}`,
92
+ '--no-first-run',
93
+ '--no-default-browser-check',
94
+ '--remote-allow-origins=*',
95
+ // Persistent profiles otherwise nag "Chrome didn't shut down correctly".
96
+ '--disable-session-crashed-bubble',
97
+ '--hide-crash-restore-bubble',
98
+ ];
99
+ if (headless) args.push('--headless=new');
100
+ if (url) args.push(url);
101
+
102
+ const child = spawn(bin, args, { stdio: 'ignore', detached: false });
103
+ return { child, port, dataDir, bin };
104
+ }
@@ -0,0 +1,200 @@
1
+ // Core capture engine. Attaches to a running Chromium browser over the
2
+ // Chrome DevTools Protocol and records the network flow of a page target in
3
+ // order, with request/response metadata and (bounded) bodies.
4
+
5
+ import CDP from 'chrome-remote-interface';
6
+
7
+ const DEFAULT_MAX_BODY = 512 * 1024; // 512 KB per body, to stay sane
8
+
9
+ // Wait until the CDP endpoint is reachable (a freshly launched browser needs
10
+ // a moment to open its debugging port).
11
+ async function waitForEndpoint(port, host, timeoutMs = 15000) {
12
+ const deadline = Date.now() + timeoutMs;
13
+ let lastErr;
14
+ while (Date.now() < deadline) {
15
+ try {
16
+ const targets = await CDP.List({ port, host });
17
+ if (targets.some((t) => t.type === 'page')) return targets;
18
+ } catch (err) {
19
+ lastErr = err;
20
+ }
21
+ await new Promise((r) => setTimeout(r, 250));
22
+ }
23
+ throw new Error(
24
+ `No CDP page target on ${host}:${port} within ${timeoutMs}ms.` +
25
+ (lastErr ? ` Last error: ${lastErr.message}` : ''),
26
+ );
27
+ }
28
+
29
+ export class CdpRecorder {
30
+ constructor({ port = 9222, host = 'localhost', maxBodyBytes = DEFAULT_MAX_BODY, targetFilter } = {}) {
31
+ this.port = port;
32
+ this.host = host;
33
+ this.maxBodyBytes = maxBodyBytes;
34
+ this.targetFilter = targetFilter; // optional (target) => boolean to pick a tab
35
+ this.client = null;
36
+ this.seq = 0;
37
+ this.startWall = null;
38
+ this.startedAt = null;
39
+ // requestId -> record
40
+ this.records = new Map();
41
+ // preserve emission order
42
+ this.order = [];
43
+ this.target = null;
44
+ }
45
+
46
+ async start() {
47
+ const targets = await waitForEndpoint(this.port, this.host);
48
+ const pages = targets.filter((t) => t.type === 'page' && !t.url.startsWith('devtools://'));
49
+ let chosen = pages;
50
+ if (this.targetFilter) chosen = pages.filter(this.targetFilter);
51
+ const target = chosen[0] || pages[0];
52
+ if (!target) throw new Error('No suitable page target to attach to.');
53
+ this.target = target;
54
+
55
+ this.client = await CDP({ port: this.port, host: this.host, target });
56
+ const { Network, Page } = this.client;
57
+
58
+ await Network.enable({ maxTotalBufferSize: 100_000_000, maxResourceBufferSize: 20_000_000 });
59
+ try { await Page.enable(); } catch { /* Page domain optional */ }
60
+
61
+ this.startedAt = new Date().toISOString();
62
+
63
+ Network.requestWillBeSent((p) => this._onRequest(p));
64
+ Network.responseReceived((p) => this._onResponse(p));
65
+ Network.loadingFinished((p) => this._onFinished(p));
66
+ Network.loadingFailed((p) => this._onFailed(p));
67
+ Network.webSocketCreated((p) => this._onWebSocket(p));
68
+
69
+ // Fires when the browser/tab is closed by the user (the CDP socket drops).
70
+ // Lets the session auto-finalize and write files on browser close.
71
+ this.client.on('disconnect', () => {
72
+ if (!this.stopping) this.onDisconnect?.();
73
+ });
74
+
75
+ return { target: { id: target.id, url: target.url, title: target.title } };
76
+ }
77
+
78
+ // Navigate the attached target. Used by launch mode so the recorder is
79
+ // listening before the page fires its first request (avoids missing early calls).
80
+ async navigate(url) {
81
+ if (!this.client) throw new Error('Recorder not started.');
82
+ await this.client.Page.navigate({ url });
83
+ }
84
+
85
+ _rec(requestId) {
86
+ let rec = this.records.get(requestId);
87
+ if (!rec) {
88
+ rec = { requestId, index: this.seq++ };
89
+ this.records.set(requestId, rec);
90
+ this.order.push(requestId);
91
+ }
92
+ return rec;
93
+ }
94
+
95
+ _onRequest(p) {
96
+ const rec = this._rec(p.requestId);
97
+ const r = p.request;
98
+ // A redirect reuses the requestId; keep the redirect chain instead of losing it.
99
+ if (rec.method && p.redirectResponse) {
100
+ rec.redirects = rec.redirects || [];
101
+ rec.redirects.push({ url: rec.url, status: p.redirectResponse.status });
102
+ }
103
+ rec.method = r.method;
104
+ rec.url = r.url;
105
+ rec.resourceType = p.type;
106
+ rec.requestHeaders = r.headers || {};
107
+ rec.requestBody = r.postData;
108
+ rec.hasPostData = r.hasPostData || Boolean(r.postData);
109
+ rec.wallTime = p.wallTime;
110
+ rec.timestamp = p.timestamp;
111
+ rec.initiator = p.initiator ? { type: p.initiator.type } : undefined;
112
+ if (this.startWall == null && p.wallTime) this.startWall = p.wallTime;
113
+ }
114
+
115
+ _onResponse(p) {
116
+ const rec = this._rec(p.requestId);
117
+ const res = p.response;
118
+ rec.status = res.status;
119
+ rec.statusText = res.statusText;
120
+ rec.responseHeaders = res.headers || {};
121
+ rec.mimeType = res.mimeType;
122
+ rec.remoteAddress = res.remoteIPAddress ? `${res.remoteIPAddress}:${res.remotePort}` : undefined;
123
+ rec.fromCache = res.fromDiskCache || res.fromServiceWorker || false;
124
+ rec.protocol = res.protocol;
125
+ if (res.timing) {
126
+ rec.timing = res.timing;
127
+ rec.responseTs = p.timestamp;
128
+ }
129
+ }
130
+
131
+ async _onFinished(p) {
132
+ const rec = this._rec(p.requestId);
133
+ rec.encodedDataLength = p.encodedDataLength;
134
+ rec.finishedTs = p.timestamp;
135
+ if (rec.timestamp != null && p.timestamp != null) {
136
+ rec.durationMs = Math.max(0, (p.timestamp - rec.timestamp) * 1000);
137
+ }
138
+ await this._captureBody(rec);
139
+ }
140
+
141
+ _onFailed(p) {
142
+ const rec = this._rec(p.requestId);
143
+ rec.failed = true;
144
+ rec.errorText = p.errorText;
145
+ rec.canceled = p.canceled;
146
+ rec.finishedTs = p.timestamp;
147
+ if (rec.timestamp != null && p.timestamp != null) {
148
+ rec.durationMs = Math.max(0, (p.timestamp - rec.timestamp) * 1000);
149
+ }
150
+ }
151
+
152
+ _onWebSocket(p) {
153
+ const rec = this._rec(p.requestId);
154
+ rec.method = 'WS';
155
+ rec.url = p.url;
156
+ rec.resourceType = 'WebSocket';
157
+ }
158
+
159
+ async _captureBody(rec) {
160
+ if (rec.responseBody !== undefined) return;
161
+ try {
162
+ const { body, base64Encoded } = await this.client.Network.getResponseBody({
163
+ requestId: rec.requestId,
164
+ });
165
+ if (base64Encoded) {
166
+ rec.responseBodyBase64 = true;
167
+ rec.responseBody = body.length > this.maxBodyBytes ? '[binary body omitted]' : body;
168
+ } else if (body.length > this.maxBodyBytes) {
169
+ rec.responseBody = body.slice(0, this.maxBodyBytes);
170
+ rec.responseBodyTruncated = true;
171
+ } else {
172
+ rec.responseBody = body;
173
+ }
174
+ } catch {
175
+ // Body may be unavailable (evicted, 204, websocket, etc.) — that's fine.
176
+ }
177
+ }
178
+
179
+ // Snapshot of everything captured so far, in emission order.
180
+ getRecords() {
181
+ return this.order.map((id) => this.records.get(id)).filter(Boolean);
182
+ }
183
+
184
+ async stop() {
185
+ this.stopping = true; // suppress the disconnect->auto-finalize path
186
+ // Give any in-flight loadingFinished handlers a beat to grab bodies.
187
+ await new Promise((r) => setTimeout(r, 200));
188
+ const records = this.getRecords();
189
+ if (this.client) {
190
+ try { await this.client.close(); } catch { /* ignore */ }
191
+ this.client = null;
192
+ }
193
+ return {
194
+ startedAt: this.startedAt,
195
+ stoppedAt: new Date().toISOString(),
196
+ target: this.target ? { id: this.target.id, url: this.target.url, title: this.target.title } : null,
197
+ records,
198
+ };
199
+ }
200
+ }
@@ -0,0 +1,62 @@
1
+ // Minimal HAR 1.2 exporter so recordings can be opened in Chrome DevTools,
2
+ // Charles, Insomnia, Postman, etc.
3
+
4
+ function headerArray(headers) {
5
+ return Object.entries(headers || {}).map(([name, value]) => ({ name, value: String(value) }));
6
+ }
7
+
8
+ function queryArray(query) {
9
+ return Object.entries(query || {}).map(([name, value]) => ({ name, value: String(value) }));
10
+ }
11
+
12
+ export function toHar(records, meta = {}) {
13
+ const entries = records
14
+ .filter((r) => r.url && r.method !== 'WS')
15
+ .map((r) => {
16
+ const reqBody = r.requestBody;
17
+ const resBody = r.responseBody;
18
+ return {
19
+ startedDateTime: meta.startedAt || new Date(0).toISOString(),
20
+ time: r.durationMs || 0,
21
+ request: {
22
+ method: r.method || 'GET',
23
+ url: r.url,
24
+ httpVersion: 'HTTP/1.1',
25
+ headers: headerArray(r.requestHeaders),
26
+ queryString: queryArray(r.query),
27
+ cookies: [],
28
+ headersSize: -1,
29
+ bodySize: reqBody ? -1 : 0,
30
+ ...(reqBody != null
31
+ ? { postData: { mimeType: 'application/json', text: typeof reqBody === 'string' ? reqBody : JSON.stringify(reqBody) } }
32
+ : {}),
33
+ },
34
+ response: {
35
+ status: r.status || 0,
36
+ statusText: r.statusText || '',
37
+ httpVersion: r.protocol || 'HTTP/1.1',
38
+ headers: headerArray(r.responseHeaders),
39
+ cookies: [],
40
+ content: {
41
+ size: r.sizeBytes || -1,
42
+ mimeType: (r.responseHeaders && (r.responseHeaders['content-type'] || r.responseHeaders['Content-Type'])) || '',
43
+ text: resBody == null ? '' : typeof resBody === 'string' ? resBody : JSON.stringify(resBody),
44
+ },
45
+ redirectURL: '',
46
+ headersSize: -1,
47
+ bodySize: -1,
48
+ },
49
+ cache: {},
50
+ timings: { send: 0, wait: r.durationMs || 0, receive: 0 },
51
+ };
52
+ });
53
+
54
+ return {
55
+ log: {
56
+ version: '1.2',
57
+ creator: { name: 'browser-flow-tracker', version: '0.1.0' },
58
+ pages: [],
59
+ entries,
60
+ },
61
+ };
62
+ }