/** * Network interceptor — capture HTTP requests/responses * * @module network */ import type { NetworkRequest } from './types.js'; // DOM type declarations for ESLint type RequestInfo = string | Request | URL; type HeadersInit = Headers | Record | string[][]; export interface NetworkInterceptorOptions { /** URL patterns to include (default: all) */ includePatterns?: RegExp[]; /** URL patterns to exclude */ excludePatterns?: RegExp[]; /** Max request body size to capture (default: 100KB) */ maxBodySize?: number; /** Whether to capture request headers (default: true) */ captureRequestHeaders?: boolean; /** Whether to capture response headers (default: true) */ captureResponseHeaders?: boolean; /** Sanitize header values matching these patterns */ sensitiveHeaderPatterns?: RegExp[]; } /** * Interceptor for capturing network requests */ export class NetworkInterceptor { private options: Required; private originalFetch: typeof fetch; private isActive = false; private pendingRequests = new Map(); private onRequest: (request: NetworkRequest) => void; constructor( onRequest: (request: NetworkRequest) => void, options: NetworkInterceptorOptions = {} ) { this.onRequest = onRequest; this.options = { includePatterns: options.includePatterns ?? [], excludePatterns: options.excludePatterns ?? [], maxBodySize: options.maxBodySize ?? 100 * 1024, captureRequestHeaders: options.captureRequestHeaders ?? true, captureResponseHeaders: options.captureResponseHeaders ?? true, sensitiveHeaderPatterns: options.sensitiveHeaderPatterns ?? [ /authorization/i, /cookie/i, /token/i, /api-key/i, ], }; this.originalFetch = globalThis.fetch.bind(globalThis); } /** * Start intercepting fetch calls */ start(): void { if (this.isActive) return; this.isActive = true; globalThis.fetch = this.interceptedFetch.bind(this); } /** * Stop intercepting fetch calls */ stop(): void { if (!this.isActive) return; this.isActive = false; globalThis.fetch = this.originalFetch; } /** * Check if URL should be captured */ private shouldCapture(url: string): boolean { // Check excludes first for (const pattern of this.options.excludePatterns) { if (pattern.test(url)) return false; } // If includes specified, must match one if (this.options.includePatterns.length > 0) { for (const pattern of this.options.includePatterns) { if (pattern.test(url)) return true; } return false; } return true; } /** * Sanitize headers */ private sanitizeHeaders( headers: HeadersInit | undefined ): Record { const sanitized: Record = {}; const headerEntries = headers instanceof Headers ? Array.from(headers.entries()) : typeof headers === 'object' && headers !== null ? Object.entries(headers) : []; for (const [key, value] of headerEntries) { const isSensitive = this.options.sensitiveHeaderPatterns.some(p => p.test(key) ); sanitized[key] = isSensitive ? '[REDACTED]' : value; } return sanitized; } /** * Generate request ID */ private generateId(): string { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; } /** * Intercepted fetch implementation */ private async interceptedFetch( input: RequestInfo | URL, init?: RequestInit ): Promise { const url = input.toString(); const shouldCapture = this.shouldCapture(url); const requestId = this.generateId(); const startTime = new Date().toISOString(); // Create request record if capturing if (shouldCapture) { const request: NetworkRequest = { id: requestId, url: url.slice(0, 2048), // Limit URL length method: (init?.method ?? 'GET').toUpperCase(), requestHeaders: this.options.captureRequestHeaders ? this.sanitizeHeaders(init?.headers) : {}, startTime, }; // Capture request body if present and not too large if (init?.body && typeof init.body === 'string') { if (init.body.length <= this.options.maxBodySize) { request.requestBody = init.body.slice(0, this.options.maxBodySize); } } this.pendingRequests.set(requestId, request); } try { const response = await this.originalFetch(input, init); // Update with response info if capturing if (shouldCapture) { const request = this.pendingRequests.get(requestId); if (request) { request.status = response.status; request.endTime = new Date().toISOString(); request.durationMs = new Date(request.endTime).getTime() - new Date(startTime).getTime(); if (this.options.captureResponseHeaders) { request.responseHeaders = this.sanitizeHeaders( Object.fromEntries(response.headers.entries()) ); } // Don't capture response body (too large/complex) // Just record that we received a response this.pendingRequests.delete(requestId); this.onRequest(request); } } return response; } catch (error) { // Record error if capturing if (shouldCapture) { const request = this.pendingRequests.get(requestId); if (request) { request.endTime = new Date().toISOString(); request.durationMs = new Date(request.endTime).getTime() - new Date(startTime).getTime(); request.error = error instanceof Error ? error.message : String(error); this.pendingRequests.delete(requestId); this.onRequest(request); } } throw error; } } /** * Check if interceptor is active */ isRunning(): boolean { return this.isActive; } }