From 791a7318b6f171854e60829c82f4b2685709f013 Mon Sep 17 00:00:00 2001 From: Krish Soni Date: Tue, 8 Jul 2025 22:33:54 +0530 Subject: [PATCH] feat: enhance network request tracking with detailed timing and size information for waterfall view --- chrome-extension/src/background/index.ts | 166 ++++++++-- .../src/components/waterfall/index.ts | 1 + .../components/waterfall/waterfall-view.tsx | 303 ++++++++++++++++++ pages/content-ui/src/content.tsx | 55 +++- pages/content-ui/src/types/network.ts | 17 + .../interceptors/network/fetch.interceptor.ts | 99 +++++- .../interceptors/network/xhr.interceptor.ts | 84 ++++- 7 files changed, 667 insertions(+), 58 deletions(-) create mode 100644 pages/content-ui/src/components/waterfall/index.ts create mode 100644 pages/content-ui/src/components/waterfall/waterfall-view.tsx create mode 100644 pages/content-ui/src/types/network.ts diff --git a/chrome-extension/src/background/index.ts b/chrome-extension/src/background/index.ts index 8f1d5c8a..6404e43e 100644 --- a/chrome-extension/src/background/index.ts +++ b/chrome-extension/src/background/index.ts @@ -183,23 +183,150 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => { }); /** - * @todo - * there is an scenario when tabId is -1, - * but we know the requestId and we can use it to populate the right request data - * - * related to all 3 web req states + * Enhanced network request tracking with detailed timing information + * for waterfall view support */ -// Listener for onCompleted +// Store request timing data +const requestTimingMap = new Map< + string, + { + startTime: number; + requestStart?: number; + responseStart?: number; + requestSize?: number; + } +>(); + +// Listener for onBeforeRequest - captures initial request data +chrome.webRequest.onBeforeRequest.addListener( + (request: chrome.webRequest.WebRequestBodyDetails) => { + const requestKey = `${request.tabId}-${request.requestId}`; + + // Calculate request size + let requestSize = 0; + if (request.requestBody) { + if (request.requestBody.formData) { + // Estimate FormData size + Object.entries(request.requestBody.formData).forEach(([key, values]) => { + requestSize += new Blob([key]).size; + values.forEach(value => { + requestSize += new Blob([value]).size; + }); + }); + } else if (request.requestBody.raw) { + // Calculate raw data size + request.requestBody.raw.forEach(chunk => { + requestSize += chunk.bytes ? chunk.bytes.byteLength : 0; + }); + } + } + + // Store timing data + requestTimingMap.set(requestKey, { + startTime: request.timeStamp, + requestSize, + }); + + addOrMergeRecords(request.tabId, { + recordType: 'network', + source: 'background', + requestSize, + timing: { + startTime: request.timeStamp, + }, + ...structuredClone(request), + }); + }, + { urls: [''] }, + ['requestBody'], +); + +// Listener for onBeforeSendHeaders - captures request headers and timing +chrome.webRequest.onBeforeSendHeaders.addListener( + (request: chrome.webRequest.WebRequestHeadersDetails) => { + const requestKey = `${request.tabId}-${request.requestId}`; + const timingData = requestTimingMap.get(requestKey); + + if (timingData) { + timingData.requestStart = request.timeStamp; + } + + addOrMergeRecords(request.tabId, { + recordType: 'network', + source: 'background', + timing: { + requestStart: request.timeStamp, + ...timingData, + }, + ...structuredClone(request), + }); + }, + { urls: [''] }, + ['requestHeaders'], +); + +// Listener for onResponseStarted - captures response start timing +chrome.webRequest.onResponseStarted.addListener( + (request: chrome.webRequest.WebResponseDetails) => { + const requestKey = `${request.tabId}-${request.requestId}`; + const timingData = requestTimingMap.get(requestKey); + + if (timingData) { + timingData.responseStart = request.timeStamp; + } + + addOrMergeRecords(request.tabId, { + recordType: 'network', + source: 'background', + timing: { + responseStart: request.timeStamp, + ...timingData, + }, + ...structuredClone(request), + }); + }, + { urls: [''] }, + ['responseHeaders'], +); + +// Listener for onCompleted - captures final timing and response data chrome.webRequest.onCompleted.addListener( (request: chrome.webRequest.WebResponseCacheDetails) => { + const requestKey = `${request.tabId}-${request.requestId}`; + const timingData = requestTimingMap.get(requestKey); + + let duration = 0; + let responseSize = 0; + + if (timingData) { + duration = request.timeStamp - timingData.startTime; + + // Try to get response size from headers + const responseHeaders = request.responseHeaders || []; + const contentLengthHeader = responseHeaders.find(h => h.name.toLowerCase() === 'content-length'); + if (contentLengthHeader && contentLengthHeader.value) { + responseSize = parseInt(contentLengthHeader.value, 10) || 0; + } + } + const clonedRequest = structuredClone(request); addOrMergeRecords(clonedRequest.tabId, { recordType: 'network', source: 'background', + duration, + responseSize, + timing: { + responseEnd: request.timeStamp, + duration, + ...timingData, + }, ...clonedRequest, }); + // Clean up timing data to prevent memory leaks + requestTimingMap.delete(requestKey); + if (clonedRequest.statusCode >= 400) { addOrMergeRecords(clonedRequest.tabId, { timestamp: Date.now(), @@ -220,30 +347,5 @@ chrome.webRequest.onCompleted.addListener( } }, { urls: [''] }, -); - -// Listener for onBeforeRequest -chrome.webRequest.onBeforeRequest.addListener( - (request: chrome.webRequest.WebRequestBodyDetails) => { - addOrMergeRecords(request.tabId, { - recordType: 'network', - source: 'background', - ...structuredClone(request), - }); - }, - { urls: [''] }, - ['requestBody'], -); - -// Listener for onBeforeSendHeaders -chrome.webRequest.onBeforeSendHeaders.addListener( - (request: chrome.webRequest.WebRequestHeadersDetails) => { - addOrMergeRecords(request.tabId, { - recordType: 'network', - source: 'background', - ...structuredClone(request), - }); - }, - { urls: [''] }, - ['requestHeaders'], + ['responseHeaders'], ); diff --git a/pages/content-ui/src/components/waterfall/index.ts b/pages/content-ui/src/components/waterfall/index.ts new file mode 100644 index 00000000..8f17adeb --- /dev/null +++ b/pages/content-ui/src/components/waterfall/index.ts @@ -0,0 +1 @@ +export { default } from './waterfall-view'; diff --git a/pages/content-ui/src/components/waterfall/waterfall-view.tsx b/pages/content-ui/src/components/waterfall/waterfall-view.tsx new file mode 100644 index 00000000..fe74bc82 --- /dev/null +++ b/pages/content-ui/src/components/waterfall/waterfall-view.tsx @@ -0,0 +1,303 @@ +import { useMemo, useState } from 'react'; + +import { ScrollArea, Card, CardHeader, CardTitle, CardContent, Icon, Button, Separator } from '@extension/ui'; + +import type { NetworkRecord } from '../../types/network'; + +interface WaterfallViewProps { + records: NetworkRecord[]; +} + +const WaterfallView: React.FC = ({ records }) => { + const [sortBy, setSortBy] = useState<'timestamp' | 'duration' | 'size'>('timestamp'); + const [showDetails, setShowDetails] = useState(null); + + // Filter and process network records + const networkRecords = useMemo(() => { + const netRecords = records + .filter(record => record.recordType === 'network' && record.url) + .map(record => ({ + ...record, + id: + ((record as Record).requestId as string) || + `${record.url}-${record.timestamp || Date.now()}`, + size: (record.requestSize || 0) + (record.responseSize || 0), + })) as (NetworkRecord & { id: string; size: number })[]; + + // Debug: Log a few records to see their structure + if (netRecords.length > 0) { + console.log('Sample network records:', netRecords.slice(0, 3)); + } + + // Sort records + return netRecords.sort((a, b) => { + switch (sortBy) { + case 'duration': + return (b.duration || 0) - (a.duration || 0); + case 'size': + return b.size - a.size; + case 'timestamp': + default: + return (a.timestamp || 0) - (b.timestamp || 0); + } + }); + }, [records, sortBy]); + + // Calculate timing bounds for waterfall chart + const timingBounds = useMemo(() => { + if (networkRecords.length === 0) return { min: 0, max: 0 }; + + const timestamps = networkRecords + .map(record => record.timing?.requestStart || record.timestamp || 0) + .filter(t => t > 0); + + const endTimes = networkRecords + .map(record => { + const start = record.timing?.requestStart || record.timestamp || 0; + const duration = record.duration || record.timing?.duration || 0; + return start + duration; + }) + .filter(t => t > 0); + + return { + min: Math.min(...timestamps), + max: Math.max(...endTimes), + }; + }, [networkRecords]); + + const formatSize = (bytes: number) => { + if (!bytes) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; + }; + + const formatDuration = (ms: number) => { + if (!ms) return '0ms'; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(2)}s`; + }; + + const getStatusColor = (status: number) => { + if (!status || status === 0) return 'bg-gray-400 dark:bg-gray-500'; // Unknown/missing status + if (status >= 200 && status < 300) return 'bg-emerald-500 dark:bg-emerald-400'; + if (status >= 300 && status < 400) return 'bg-amber-500 dark:bg-amber-400'; + if (status >= 400 && status < 500) return 'bg-orange-500 dark:bg-orange-400'; + if (status >= 500) return 'bg-red-500 dark:bg-red-400'; + return 'bg-slate-500 dark:bg-slate-400'; + }; + + const getTimingBarStyle = (record: NetworkRecord & { id: string; size: number }) => { + const totalRange = timingBounds.max - timingBounds.min; + if (totalRange === 0) return { left: '0%', width: '20%' }; // Show a small default bar + + const startTime = record.timing?.requestStart || record.timestamp || 0; + const duration = record.duration || record.timing?.duration || 50; // Minimum 50ms for visibility + + const leftPercent = ((startTime - timingBounds.min) / totalRange) * 100; + const widthPercent = Math.max((duration / totalRange) * 100, 2); // Minimum 2% width for visibility + + return { + left: `${Math.max(0, leftPercent)}%`, + width: `${Math.min(100 - leftPercent, widthPercent)}%`, + }; + }; + + if (networkRecords.length === 0) { + return ( + + + + + Network Waterfall + + + +
No network requests captured
+
+
+ ); + } + + return ( + + +
+ + + Network Waterfall ({networkRecords.length} requests) + +
+ + + +
+
+
+ +
+
+
Request
+
Status
+
Size
+
Time
+
Waterfall
+
+
+ + {networkRecords.map(record => ( +
+
setShowDetails(showDetails === record.id ? null : record.id)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setShowDetails(showDetails === record.id ? null : record.id); + } + }} + role="button" + tabIndex={0} + aria-expanded={showDetails === record.id} + aria-label={`Toggle details for ${record.method} ${record.url}`}> + {/* Request URL and Method */} +
+
+ {record.method} {record.url ? new URL(record.url).pathname : 'Unknown'} +
+
+ {record.url ? new URL(record.url).host : 'Unknown'} +
+
+ + {/* Status */} +
+
+
+ {record.status || '—'} +
+
+ + {/* Size */} +
+
{formatSize(record.size)}
+ {(record.requestSize || record.responseSize) && ( +
+ ↑{formatSize(record.requestSize || 0)} ↓{formatSize(record.responseSize || 0)} +
+ )} +
+ + {/* Duration */} +
+
{formatDuration(record.duration || 0)}
+
+ + {/* Waterfall Bar */} +
+
+
+ {/* Show a minimal bar even if no timing data */} + {!record.duration && !record.timing?.duration && ( +
+ )} +
+
+
+ + {/* Expanded Details */} + {showDetails === record.id && ( +
+ +
+
+

Request Details

+
+
+ Method:{' '} + {record.method} +
+
+ URL:{' '} + {record.url} +
+
+ Status:{' '} + {record.status} +
+
+ Source:{' '} + {record.source} +
+
+
+
+

Timing & Size

+
+
+ Duration:{' '} + {formatDuration(record.duration || 0)} +
+
+ Request Size:{' '} + {formatSize(record.requestSize || 0)} +
+
+ Response Size:{' '} + {formatSize(record.responseSize || 0)} +
+
+ Total Size:{' '} + {formatSize(record.size)} +
+ {record.timing && ( + <> +
+ Start:{' '} + + {record.timing.requestStart + ? new Date(record.timing.requestStart).toLocaleTimeString() + : 'N/A'} + +
+
+ End:{' '} + + {record.timing.requestEnd + ? new Date(record.timing.requestEnd).toLocaleTimeString() + : 'N/A'} + +
+ + )} +
+
+
+
+ )} +
+ ))} + + + + ); +}; + +export default WaterfallView; diff --git a/pages/content-ui/src/content.tsx b/pages/content-ui/src/content.tsx index e6d7ac68..80f8af94 100644 --- a/pages/content-ui/src/content.tsx +++ b/pages/content-ui/src/content.tsx @@ -1,14 +1,16 @@ -import { memo, useMemo, useState } from 'react'; +import { memo, useMemo, useState, useEffect } from 'react'; import { APP_BASE_URL } from '@extension/env'; import { t } from '@extension/i18n'; import type { Workspace } from '@extension/shared'; import { AuthMethod } from '@extension/shared'; import { useCreateSliceMutation, useGetUserDetailsQuery } from '@extension/store'; -import { Button, DialogLegacy, Icon, Textarea, Tooltip, TooltipContent, TooltipTrigger, toast } from '@extension/ui'; +import { Button, DialogLegacy, Icon, Textarea, toast } from '@extension/ui'; import AnnotationContainer from './components/annotation/annotation-container'; +import WaterfallView from './components/waterfall/waterfall-view'; import { useViewportSize } from './hooks'; +import type { NetworkRecord } from './types/network'; import { base64ToFile, createJsonFile } from './utils'; import { getCanvasElement } from './utils/annotation'; @@ -19,8 +21,10 @@ const Content = ({ screenshots, onClose }: { onClose: () => void; screenshots: { const [showRightSection, setShowRightSection] = useState(true); const [isCreateLoading, setIsCreateLoading] = useState(false); const [description, setDescription] = useState(''); + const [activeTab, setActiveTab] = useState<'annotation' | 'network'>('annotation'); + const [networkRecords, setNetworkRecords] = useState([]); - const { isLoading, isError, data: user } = useGetUserDetailsQuery(); + const { data: user } = useGetUserDetailsQuery(); const [createSlice] = useCreateSliceMutation(); const isGuest = useMemo(() => user?.authMethod === AuthMethod.GUEST, [user?.authMethod]); @@ -36,6 +40,20 @@ const Content = ({ screenshots, onClose }: { onClose: () => void; screenshots: { [user?.organization?.workspaces], ); + // Load network records when component mounts or when switching to network tab + useEffect(() => { + if (activeTab === 'network') { + getRecords() + .then((records: unknown) => { + setNetworkRecords((records as NetworkRecord[]) || []); + }) + .catch(error => { + console.error('Failed to load network records:', error); + setNetworkRecords([]); + }); + } + }, [activeTab]); + const handleToggleMaximize = () => setIsMaximized(!isMaximized); const handleToggleRightSection = () => setShowRightSection(value => !value); @@ -59,7 +77,7 @@ const Content = ({ screenshots, onClose }: { onClose: () => void; screenshots: { setIsCreateLoading(true); try { - const records: any = await getRecords(); + const records: NetworkRecord[] = (await getRecords()) as NetworkRecord[]; if (records?.length) { const jsonFile = createJsonFile(records.flat(), 'records.json'); @@ -165,9 +183,34 @@ const Content = ({ screenshots, onClose }: { onClose: () => void; screenshots: { className={`flex ${ showRightSidebar ? 'sm:w-[70%]' : 'w-full' } mt-10 flex-col justify-center bg-gray-50 px-4 pb-4 pt-5 sm:mt-0 sm:p-6 dark:bg-black`}> - {/* Content Section */} + {/* Tab Navigation */} +
+ + +
- + {/* Content Section */} + {activeTab === 'annotation' ? ( + + ) : ( + + )} {/* Footer Section */}
diff --git a/pages/content-ui/src/types/network.ts b/pages/content-ui/src/types/network.ts new file mode 100644 index 00000000..ede6dc0e --- /dev/null +++ b/pages/content-ui/src/types/network.ts @@ -0,0 +1,17 @@ +export interface NetworkRecord { + recordType: string; + url?: string; + method?: string; + status?: number; + duration?: number; + requestSize?: number; + responseSize?: number; + timing?: { + requestStart: number; + requestEnd: number; + duration: number; + }; + timestamp?: number; + source?: string; + [key: string]: unknown; +} diff --git a/pages/content/src/interceptors/network/fetch.interceptor.ts b/pages/content/src/interceptors/network/fetch.interceptor.ts index 60c58642..4350997c 100644 --- a/pages/content/src/interceptors/network/fetch.interceptor.ts +++ b/pages/content/src/interceptors/network/fetch.interceptor.ts @@ -7,28 +7,14 @@ interface FetchOptions extends RequestInit { body?: BodyInit | null; } -interface ParsedResponse { - recordType: string; - source: string; - method: string; - url: string; - queryParams: Record; - requestHeaders: HeadersInit; - requestBody: BodyInit | null; - responseHeaders: Record; - responseBody: string | object; - requestStart: string; - requestEnd: string; - status: number; -} - // Fetch Interceptor -export const interceptFetch = (): void => { +const interceptFetch = (): void => { const originalFetch = window.fetch; window.fetch = async function (...args: [RequestInfo | URL, FetchOptions?]): Promise { const [url, options] = args; const startTime = new Date().toISOString(); + const startTimestamp = performance.now(); try { const method = options?.method || 'GET'; @@ -36,9 +22,14 @@ export const interceptFetch = (): void => { const queryParams = extractQueryParams(url.toString()); const requestBody = options?.body || null; + // Calculate request size + const requestSize = calculateRequestSize(requestBody, requestHeaders); + // Initiate the fetch request const response = await originalFetch.apply(this, args); const endTime = new Date().toISOString(); + const endTimestamp = performance.now(); + const duration = endTimestamp - startTimestamp; // Check if the response is large or a binary stream before cloning const contentType = response.headers.get('Content-Type'); @@ -49,6 +40,9 @@ export const interceptFetch = (): void => { const isLargeResponse = response.headers.get('Content-Length') && parseInt(response.headers.get('Content-Length')!, 10) > 1000000; // Arbitrary size limit (1MB) + // Calculate response size + const responseSize = calculateResponseSize(response); + // Clone the response for body parsing (only for non-binary and small responses) const responseClone = response.clone(); @@ -94,6 +88,15 @@ export const interceptFetch = (): void => { requestStart: startTime, requestEnd: endTime, status: responseClone.status, + // Enhanced waterfall data + duration, + requestSize, + responseSize, + timing: { + requestStart: startTimestamp, + requestEnd: endTimestamp, + duration, + }, }; safePostMessage('ADD_RECORD', { @@ -132,3 +135,67 @@ export const interceptFetch = (): void => { } }; }; + +// Helper function to calculate request size +const calculateRequestSize = (body: BodyInit | null, headers: HeadersInit): number => { + let size = 0; + + if (body) { + if (typeof body === 'string') { + size = new Blob([body]).size; + } else if (body instanceof FormData) { + // Approximate size for FormData (not exact due to boundaries) + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const entries = Array.from((body as any).entries()) as [string, string | File][]; + size = entries.reduce((total: number, [key, value]) => { + const keySize = new Blob([key]).size; + const valueSize = typeof value === 'string' ? new Blob([value]).size : (value as File)?.size || 0; + return total + keySize + valueSize; + }, 0); + } catch { + // Fallback if FormData.entries() is not available + size = 0; + } + } else if (body instanceof Blob) { + size = body.size; + } else if (body instanceof ArrayBuffer) { + size = body.byteLength; + } else if (body instanceof URLSearchParams) { + size = new Blob([body.toString()]).size; + } + } + + // Add approximate header size + if (headers) { + try { + const headersObj = + headers instanceof Headers + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + Object.fromEntries((headers as any).entries()) + : (headers as Record); + size += Object.entries(headersObj).reduce((total, [key, value]) => { + return total + new Blob([`${key}: ${value}\r\n`]).size; + }, 0); + } catch { + // Fallback if headers processing fails + console.warn('Failed to calculate header size'); + } + } + + return size; +}; + +// Helper function to calculate response size +const calculateResponseSize = (response: Response): number => { + const contentLength = response.headers.get('Content-Length'); + if (contentLength) { + return parseInt(contentLength, 10); + } + + // If no Content-Length header, we can't determine size without consuming the response + // Return 0 and let the waterfall view handle unknown sizes + return 0; +}; + +export { interceptFetch }; diff --git a/pages/content/src/interceptors/network/xhr.interceptor.ts b/pages/content/src/interceptors/network/xhr.interceptor.ts index 2f58e487..ff44d3fb 100644 --- a/pages/content/src/interceptors/network/xhr.interceptor.ts +++ b/pages/content/src/interceptors/network/xhr.interceptor.ts @@ -6,6 +6,7 @@ interface RequestDetails { url: string; requestStart: string; requestBody: Document | XMLHttpRequestBodyInit | null; + requestStartTimestamp: number; } // Extend the XMLHttpRequest type to include custom properties @@ -14,7 +15,7 @@ interface ExtendedXMLHttpRequest extends XMLHttpRequest { } // XMLHttpRequest Interceptor -export const interceptXHR = (): void => { +const interceptXHR = (): void => { const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; @@ -23,6 +24,7 @@ export const interceptXHR = (): void => { this: ExtendedXMLHttpRequest, method: string, url: string | URL, + // eslint-disable-next-line @typescript-eslint/no-explicit-any ...rest: any[] ): void { this._requestDetails = { @@ -30,8 +32,10 @@ export const interceptXHR = (): void => { url: url.toString(), requestStart: new Date().toISOString(), requestBody: null, + requestStartTimestamp: performance.now(), }; - originalOpen.apply(this, [method, url, ...rest]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (originalOpen as any).apply(this, [method, url, ...rest]); }; // Intercept XMLHttpRequest send method @@ -45,10 +49,13 @@ export const interceptXHR = (): void => { const originalOnReadyStateChange = this.onreadystatechange; - this.onreadystatechange = function (this: ExtendedXMLHttpRequest, ...args: any[]): void { + this.onreadystatechange = function (this: ExtendedXMLHttpRequest, event: Event): void { if (this.readyState === 4 && this._requestDetails) { // Request completed const endTime = new Date().toISOString(); + const endTimestamp = performance.now(); + const duration = endTimestamp - this._requestDetails.requestStartTimestamp; + const rawHeaders = this.getAllResponseHeaders(); const responseHeaders = rawHeaders .split('\r\n') @@ -57,6 +64,12 @@ export const interceptXHR = (): void => { const { requestBody } = this._requestDetails; + // Calculate request size + const requestSize = calculateXHRRequestSize(requestBody); + + // Calculate response size + const responseSize = calculateXHRResponseSize(this); + // Check for large or binary content (skip cloning and parsing for binary data) const contentType = this.getResponseHeader('Content-Type'); const isBinary = @@ -91,6 +104,15 @@ export const interceptXHR = (): void => { status: this.status, responseHeaders, responseBody, + // Enhanced waterfall data + duration, + requestSize, + responseSize, + timing: { + requestStart: this._requestDetails.requestStartTimestamp, + requestEnd: endTimestamp, + duration, + }, }; safePostMessage('ADD_RECORD', { @@ -128,10 +150,64 @@ export const interceptXHR = (): void => { // Call the original onreadystatechange handler if defined if (originalOnReadyStateChange) { - originalOnReadyStateChange.apply(this, args); + originalOnReadyStateChange.call(this, event); } }; originalSend.apply(this, [body]); }; }; + +// Helper function to calculate XHR request size +const calculateXHRRequestSize = (body: Document | XMLHttpRequestBodyInit | null): number => { + let size = 0; + + if (body) { + if (typeof body === 'string') { + size = new Blob([body]).size; + } else if (body instanceof FormData) { + // Approximate size for FormData (not exact due to boundaries) + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const entries = Array.from((body as any).entries()) as [string, string | File][]; + size = entries.reduce((total: number, [key, value]) => { + const keySize = new Blob([key]).size; + const valueSize = typeof value === 'string' ? new Blob([value]).size : (value as File)?.size || 0; + return total + keySize + valueSize; + }, 0); + } catch { + // Fallback if FormData.entries() is not available + size = 0; + } + } else if (body instanceof Blob) { + size = body.size; + } else if (body instanceof ArrayBuffer) { + size = body.byteLength; + } else if (body instanceof URLSearchParams) { + size = new Blob([body.toString()]).size; + } else if (body instanceof Document) { + // For XML documents, serialize to get approximate size + size = new Blob([new XMLSerializer().serializeToString(body)]).size; + } + } + + return size; +}; + +// Helper function to calculate XHR response size +const calculateXHRResponseSize = (xhr: XMLHttpRequest): number => { + const contentLength = xhr.getResponseHeader('Content-Length'); + if (contentLength) { + return parseInt(contentLength, 10); + } + + // If no Content-Length header, try to estimate from response text + if (xhr.responseText) { + return new Blob([xhr.responseText]).size; + } + + // Return 0 for unknown sizes + return 0; +}; + +export { interceptXHR };