network-capture.ts
179 lines · browse/src/network-capture.ts at 3f79d81
1/**
2 * Network response body capture — SizeCappedBuffer + capture lifecycle.
3 *
4 * Architecture:
5 * page.on('response') listener → filter by URL pattern → store body
6 * SizeCappedBuffer: evicts oldest entries when total size exceeds cap
7 * Export: writes JSONL file (one response per line)
8 *
9 * Memory management:
10 * - 50MB total buffer cap (configurable)
11 * - 5MB per-entry body cap (larger responses stored as metadata only)
12 * - Binary responses stored as base64
13 * - Text responses stored as-is
14 */
16import * as fs from 'fs';
17import type { Response as PlaywrightResponse } from 'playwright';
19export interface CapturedResponse {
20 url: string;
21 status: number;
22 headers: Record<string, string>;
23 body: string;
24 contentType: string;
25 timestamp: number;
26 size: number;
27 bodyTruncated: boolean;
28}
30const MAX_BUFFER_SIZE = 50 * 1024 * 1024; // 50MB total
31const MAX_ENTRY_SIZE = 5 * 1024 * 1024; // 5MB per response body
33export class SizeCappedBuffer {
34 private entries: CapturedResponse[] = [];
35 private totalSize = 0;
36 private readonly maxSize: number;
38 constructor(maxSize = MAX_BUFFER_SIZE) {
39 this.maxSize = maxSize;
40 }
42 push(entry: CapturedResponse): void {
43 // Evict oldest entries until we have room
44 while (this.entries.length > 0 && this.totalSize + entry.size > this.maxSize) {
45 const evicted = this.entries.shift()!;
46 this.totalSize -= evicted.size;
47 }
48 this.entries.push(entry);
49 this.totalSize += entry.size;
50 }
52 toArray(): CapturedResponse[] {
53 return [...this.entries];
54 }
56 get length(): number {
57 return this.entries.length;
58 }
60 get byteSize(): number {
61 return this.totalSize;
62 }
64 clear(): void {
65 this.entries = [];
66 this.totalSize = 0;
67 }
69 /** Export to JSONL file. */
70 exportToFile(filePath: string): number {
71 const lines = this.entries.map(e => JSON.stringify(e));
72 fs.writeFileSync(filePath, lines.join('\n') + '\n');
73 return this.entries.length;
74 }
76 /** Summary of captured responses (URL, status, size). */
77 summary(): string {
78 if (this.entries.length === 0) return 'No captured responses.';
79 const lines = this.entries.map((e, i) =>
80 ` [${i + 1}] ${e.status} ${e.url.slice(0, 100)} (${Math.round(e.size / 1024)}KB${e.bodyTruncated ? ', truncated' : ''})`
81 );
82 return `${this.entries.length} responses (${Math.round(this.totalSize / 1024)}KB total):\n${lines.join('\n')}`;
83 }
84}
86/** Global capture state. */
87let captureBuffer = new SizeCappedBuffer();
88let captureActive = false;
89let captureFilter: RegExp | null = null;
90let captureListener: ((response: PlaywrightResponse) => Promise<void>) | null = null;
92export function isCaptureActive(): boolean {
93 return captureActive;
94}
96export function getCaptureBuffer(): SizeCappedBuffer {
97 return captureBuffer;
98}
100/** Create the response listener function. */
101function createResponseListener(filter: RegExp | null): (response: PlaywrightResponse) => Promise<void> {
102 return async (response: PlaywrightResponse) => {
103 const url = response.url();
104 if (filter && !filter.test(url)) return;
106 // Skip non-content responses (redirects, 204, etc.)
107 const status = response.status();
108 if (status === 204 || status === 301 || status === 302 || status === 304) return;
110 const contentType = response.headers()['content-type'] || '';
111 let body = '';
112 let bodySize = 0;
113 let truncated = false;
115 try {
116 const rawBody = await response.body();
117 bodySize = rawBody.length;
119 if (bodySize > MAX_ENTRY_SIZE) {
120 truncated = true;
121 body = '';
122 } else if (contentType.includes('json') || contentType.includes('text') || contentType.includes('xml') || contentType.includes('html')) {
123 body = rawBody.toString('utf-8');
124 } else {
125 body = rawBody.toString('base64');
126 }
127 } catch {
128 // Response body may be unavailable (e.g., streaming, aborted)
129 body = '';
130 truncated = true;
131 }
133 const entry: CapturedResponse = {
134 url,
135 status,
136 headers: response.headers(),
137 body,
138 contentType,
139 timestamp: Date.now(),
140 size: bodySize,
141 bodyTruncated: truncated,
142 };
144 captureBuffer.push(entry);
145 };
146}
148/** Start capturing response bodies. */
149export function startCapture(filterPattern?: string): { filter: string | null } {
150 captureFilter = filterPattern ? new RegExp(filterPattern) : null;
151 captureActive = true;
152 captureListener = createResponseListener(captureFilter);
153 return { filter: filterPattern || null };
154}
156/** Get the active listener (to attach to page). */
157export function getCaptureListener(): ((response: PlaywrightResponse) => Promise<void>) | null {
158 return captureListener;
159}
161/** Stop capturing. */
162export function stopCapture(): { count: number; sizeKB: number } {
163 captureActive = false;
164 captureListener = null;
165 return {
166 count: captureBuffer.length,
167 sizeKB: Math.round(captureBuffer.byteSize / 1024),
168 };
169}
171/** Clear the capture buffer. */
172export function clearCapture(): void {
173 captureBuffer.clear();
174}
176/** Export captured responses to JSONL file. */
177export function exportCapture(filePath: string): number {
178 return captureBuffer.exportToFile(filePath);
179}