diff --git a/packages/bruno-app/src/components/AppView/index.js b/packages/bruno-app/src/components/AppView/index.js index 19ff24341d8..bf4bfdcd155 100644 --- a/packages/bruno-app/src/components/AppView/index.js +++ b/packages/bruno-app/src/components/AppView/index.js @@ -221,7 +221,7 @@ const AppView = ({ item, collection, code }) => { statusText: result.statusText, headers: result.headers, data: result.data, - dataBuffer: result.dataBuffer, + bodyRef: result.bodyRef || null, size: result.size, duration: result.duration, timeline: serializeTimeline(result.timeline) diff --git a/packages/bruno-app/src/components/CollectionApp/index.js b/packages/bruno-app/src/components/CollectionApp/index.js index 29c9678ea38..a30ea4f8b14 100644 --- a/packages/bruno-app/src/components/CollectionApp/index.js +++ b/packages/bruno-app/src/components/CollectionApp/index.js @@ -266,7 +266,7 @@ const CollectionApp = ({ item, collection }) => { statusText: result.statusText, headers: result.headers, data: result.data, - dataBuffer: result.dataBuffer, + bodyRef: result.bodyRef || null, size: result.size, duration: result.duration, timeline: serializeTimeline(result.timeline) diff --git a/packages/bruno-app/src/components/ResponsePane/ClearTimeline/index.js b/packages/bruno-app/src/components/ResponsePane/ClearTimeline/index.js index 0f8a07a91d6..3ab32ce020c 100644 --- a/packages/bruno-app/src/components/ResponsePane/ClearTimeline/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ClearTimeline/index.js @@ -1,7 +1,7 @@ import React from 'react'; import { useDispatch } from 'react-redux'; import StyledWrapper from './StyledWrapper'; -import { clearRequestTimeline } from 'providers/ReduxStore/slices/collections/index'; +import { clearRequestTimeline } from 'providers/ReduxStore/slices/collections'; const ClearTimeline = ({ collection, item }) => { const dispatch = useDispatch(); diff --git a/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js index b82cd4115ab..ae381f463cb 100644 --- a/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js +++ b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js @@ -5,15 +5,38 @@ import get from 'lodash/get'; import StyledWrapper from './StyledWrapper'; import { formatSize } from 'utils/common/index'; import Button from 'ui/Button/index'; +import { getResponseBodyClient } from 'utils/response-body'; -const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => { +/** Show inline below this size; warning UI above (bytes). */ +export const SHOW_INLINE_BYTES = 10 * 1024 * 1024; + +/** View-from-disk allowed at or below this; Download only above (bytes). */ +export const VIEW_MAX_BYTES = 50 * 1024 * 1024; + +const LargeResponseWarning = ({ + item, + responseSize, + onRevealResponse, + canView = true, + revealLoading = false +}) => { const { ipcRenderer } = window; const response = item.response || {}; + const canDownload = Boolean(response.bodyRef) && !response.stream?.running; + const canCopy = response.data != null; const downloadResponseToFile = () => { + if (!canDownload) return; return new Promise((resolve, reject) => { - ipcRenderer - .invoke('renderer:save-response-to-file', response, item.requestSent.url, item.pathname) + const savePromise = response.bodyRef + ? getResponseBodyClient().save(response.bodyRef, { + url: item?.requestSent?.url, + pathname: item.pathname, + headers: response.headers + }) + : ipcRenderer.invoke('renderer:save-response-to-file', response, item.requestSent.url, item.pathname); + + savePromise .then((result) => { if (result && result.success) { toast.success('Response downloaded to file'); @@ -21,13 +44,14 @@ const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => { resolve(); }) .catch((err) => { - toast.error(get(err, 'error.message') || 'Something went wrong!'); + toast.error(get(err, 'error.message') || get(err, 'message') || 'Something went wrong!'); reject(err); }); }); }; const copyResponse = () => { + if (!canCopy) return; try { const textToCopy = typeof response.data === 'string' ? response.data @@ -54,9 +78,15 @@ const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => { Large Response Warning
- Handling responses over {formatSize(10 * 1024 * 1024)} could degrade performance. + Handling responses over {formatSize(SHOW_INLINE_BYTES)} could degrade performance.
Size of current response: {formatSize(responseSize)} + {!canView ? ( + <> +
+ Responses over {formatSize(VIEW_MAX_BYTES)} can only be downloaded. + + ) : null}
@@ -65,20 +95,22 @@ const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => { icon={} iconPosition="left" onClick={onRevealResponse} - title="Show response content" + disabled={!canView || revealLoading} + title={canView ? 'Show response content' : 'Response is too large to view in-app'} color="secondary" size="sm" > - View + {revealLoading ? 'Loading…' : 'View'} @@ -86,7 +118,7 @@ const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => { icon={} iconPosition="left" onClick={copyResponse} - disabled={!response.data} + disabled={!canCopy} title="Copy response to clipboard" color="secondary" size="sm" diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js index 0f4d03c6378..4ce9213ff70 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js @@ -28,11 +28,11 @@ const QueryResultFilter = ({ filter, filterExpanded, onChange, onExpandChange, m }; const infotipText = useMemo(() => { - if (mode.includes('json')) { + if (mode?.includes('json')) { return 'Filter with JSONPath'; } - if (mode.includes('xml')) { + if (mode?.includes('xml')) { return 'Filter with XPath'; } @@ -40,11 +40,11 @@ const QueryResultFilter = ({ filter, filterExpanded, onChange, onExpandChange, m }, [mode]); const placeholderText = useMemo(() => { - if (mode.includes('json')) { + if (mode?.includes('json')) { return '$.store.books..author'; } - if (mode.includes('xml')) { + if (mode?.includes('xml')) { return '/store/books//author'; } diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/TextPreview.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/TextPreview.js index 859279e7fe1..536aa0f5c74 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/TextPreview.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/TextPreview.js @@ -17,6 +17,19 @@ const TextPreview = memo(({ data, onLinkClick }) => { return String(data); } } + if (typeof data === 'string') { + // Historical preview for JSON bodies was JSON.stringify(parsedObject) (compact). + // Under bodyRef we may receive the raw UTF-8 string instead — normalize for preview. + try { + const parsed = JSON.parse(data); + if (parsed !== null && typeof parsed === 'object') { + return JSON.stringify(parsed); + } + } catch { + /* not JSON — show as-is */ + } + return data; + } return String(data); }, [data]); diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js index 9070c9f0af8..64796e83a11 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js @@ -29,7 +29,8 @@ const QueryResultPreview = ({ previewMode, disableRunEventListener, displayedTheme, - docKey + docKey, + mediaSrc }) => { const preferences = useSelector((state) => state.app.preferences); const dispatch = useDispatch(); @@ -84,12 +85,15 @@ const QueryResultPreview = ({ return ; } case 'preview-image': { - return ; + const src = mediaSrc || (dataBuffer ? `data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}` : null); + return src ? : null; } case 'preview-pdf': { + const file = mediaSrc || (dataBuffer ? `data:application/pdf;base64,${dataBuffer}` : null); + if (!file) return null; return (
- + {Array.from(new Array(numPages), (el, index) => ( ))} @@ -98,11 +102,15 @@ const QueryResultPreview = ({ ); } case 'preview-audio': { - return ( -
) : null} - ) : isLargeResponse && !showLargeResponse ? ( + ) : showLargeWarning ? ( setShowLargeResponse(true)} + canView={canViewLargeResponse} + revealLoading={textBody.loading} + onRevealResponse={handleRevealResponse} /> ) : (
@@ -206,7 +266,7 @@ const QueryResult = ({
- {queryFilterEnabled && ( - - )}
+ {queryFilterEnabled ? ( + + ) : null} )} diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseActions.disabled.spec.js b/packages/bruno-app/src/components/ResponsePane/ResponseActions.disabled.spec.js new file mode 100644 index 00000000000..fe80f30f9a2 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseActions.disabled.spec.js @@ -0,0 +1,77 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from 'styled-components'; +import ResponseCopy from './ResponseCopy/index'; +import ResponseDownload from './ResponseDownload/index'; + +jest.mock('react-hot-toast', () => ({ + __esModule: true, + default: { success: jest.fn(), error: jest.fn() } +})); + +jest.mock('utils/response-body', () => ({ + getResponseBodyClient: () => ({ + save: jest.fn() + }) +})); + +const theme = { + text: '#111', + colors: { + text: { muted: '#888' } + }, + font: { + size: { + base: '13px', + sm: '0.75rem' + } + }, + dropdown: { + iconColor: '#666', + hoverBg: '#eee' + }, + workspace: { + button: { + bg: '#f5f5f5' + } + }, + requestTabPanel: { + responseStatus: '#666' + } +}; + +const renderWithTheme = (ui) => render({ui}); + +describe('response actions disabled for file-backed / missing bodies', () => { + test('copy is disabled when data is null (file-backed)', () => { + renderWithTheme( + + ); + expect(screen.getByTestId('response-copy-btn')).toHaveAttribute('aria-disabled', 'true'); + }); + + test('copy is enabled when in-memory data is present', () => { + renderWithTheme( + + ); + expect(screen.getByTestId('response-copy-btn')).not.toHaveAttribute('aria-disabled', 'true'); + }); + + test('download is disabled without bodyRef', () => { + renderWithTheme(); + expect(screen.getByTestId('response-download-btn')).toHaveAttribute('aria-disabled', 'true'); + }); + + test('download is disabled while stream is running', () => { + renderWithTheme( + + ); + expect(screen.getByTestId('response-download-btn')).toHaveAttribute('aria-disabled', 'true'); + }); + + test('download is enabled with bodyRef and idle stream', () => { + renderWithTheme(); + expect(screen.getByTestId('response-download-btn')).not.toHaveAttribute('aria-disabled', 'true'); + }); +}); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js index 5f2cadd2a4d..b5576cf8ae1 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js @@ -9,6 +9,7 @@ import toast from 'react-hot-toast'; import CreateExampleModal from 'components/ResponseExample/CreateExampleModal'; import { getExampleBodyType } from 'utils/responseBodyProcessor'; import { detectContentTypeFromBase64 } from 'utils/response'; +import { getResponseBodyClient } from 'utils/response-body'; import { getInitialExampleName } from 'utils/collections/index'; import classnames from 'classnames'; import StyledWrapper from './StyledWrapper'; @@ -80,14 +81,27 @@ const ResponseBookmark = forwardRef(({ item, collection, responseSize, children })) : []; + let data = response.data; + let dataBuffer = response.dataBuffer; + + if (response.bodyRef && dataBuffer == null) { + try { + const result = await getResponseBodyClient().read(response.bodyRef, { encoding: 'base64' }); + dataBuffer = result?.dataBuffer; + } catch (err) { + toast.error(err?.error?.message || err?.message || 'Failed to load response body'); + return; + } + } + const contentTypeHeader = headersArray.find((h) => h.name?.toLowerCase() === 'content-type'); const contentType = contentTypeHeader?.value?.toLowerCase() || ''; - const sniffedMime = detectContentTypeFromBase64(response.dataBuffer); + const sniffedMime = detectContentTypeFromBase64(dataBuffer); const bodyType = getExampleBodyType(contentType, sniffedMime); const content = bodyType === 'binary' - ? response.dataBuffer - : formatResponse(response.data, response.dataBuffer, bodyType); + ? dataBuffer + : formatResponse(data, dataBuffer, bodyType); const exampleData = { name: name, diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js index d57a72bb68e..41ae0cd96df 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js @@ -2,7 +2,7 @@ import React, { forwardRef, useImperativeHandle, useRef } from 'react'; import { IconEraser } from '@tabler/icons'; import { useDispatch } from 'react-redux'; import StyledWrapper from './StyledWrapper'; -import { responseCleared } from 'providers/ReduxStore/slices/collections/index'; +import { responseCleared } from 'providers/ReduxStore/slices/collections'; import ActionIcon from 'ui/ActionIcon/index'; // Hook to get clear response function diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js index 46bbd743fb4..5c2752625cc 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js @@ -7,20 +7,20 @@ import ActionIcon from 'ui/ActionIcon/index'; import { formatResponse } from 'utils/common'; // Helper function to get text to copy -const getTextToCopy = (selectedTab, selectedFormat, data, dataBuffer) => { +const getTextToCopy = (selectedTab, selectedFormat, data) => { // If preview is on, copy raw data (what's shown in TextPreview) if (selectedTab === 'preview') { return typeof data === 'string' ? data : JSON.stringify(data, null, 2); } - // If editor is on, copy formatted data based on selected format - if (selectedFormat && data && dataBuffer) { - return formatResponse(data, dataBuffer, selectedFormat, null); + // If editor is on, copy formatted data based on selected format (no dataBuffer in Redux) + if (selectedFormat && data != null) { + return formatResponse(data, null, selectedFormat, null); } return typeof data === 'string' ? data : JSON.stringify(data, null, 2); }; // Hook to get copy response function -export const useResponseCopy = (item, selectedFormat, selectedTab, data, dataBuffer) => { +export const useResponseCopy = (item, selectedFormat, selectedTab, data) => { const [copied, setCopied] = useState(false); useEffect(() => { @@ -34,20 +34,20 @@ export const useResponseCopy = (item, selectedFormat, selectedTab, data, dataBuf const copyResponse = useCallback(async () => { try { - const textToCopy = getTextToCopy(selectedTab, selectedFormat, data, dataBuffer); + const textToCopy = getTextToCopy(selectedTab, selectedFormat, data); await navigator.clipboard.writeText(textToCopy); toast.success('Response copied to clipboard'); setCopied(true); } catch (error) { toast.error('Failed to copy response'); } - }, [selectedTab, selectedFormat, data, dataBuffer]); + }, [selectedTab, selectedFormat, data]); - return { copyResponse, copied, hasData: !!data }; + return { copyResponse, copied, hasData: data != null }; }; -const ResponseCopy = forwardRef(({ item, children, selectedFormat, selectedTab, data, dataBuffer }, ref) => { - const { copyResponse, copied, hasData } = useResponseCopy(item, selectedFormat, selectedTab, data, dataBuffer); +const ResponseCopy = forwardRef(({ item, children, selectedFormat, selectedTab, data }, ref) => { + const { copyResponse, copied, hasData } = useResponseCopy(item, selectedFormat, selectedTab, data); const elementRef = useRef(null); const isDisabled = !hasData ? true : false; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js index f92386a8bed..56584c67907 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js @@ -5,11 +5,12 @@ import get from 'lodash/get'; import { IconDownload } from '@tabler/icons'; import classnames from 'classnames'; import ActionIcon from 'ui/ActionIcon/index'; +import { getResponseBodyClient } from 'utils/response-body'; const ResponseDownload = forwardRef(({ item, children }, ref) => { const { ipcRenderer } = window; const response = item.response || {}; - const isDisabled = !response.dataBuffer || response.stream?.running; + const isDisabled = !response.bodyRef || response.stream?.running; const elementRef = useRef(null); useImperativeHandle(ref, () => ({ @@ -22,8 +23,15 @@ const ResponseDownload = forwardRef(({ item, children }, ref) => { return; } return new Promise((resolve, reject) => { - ipcRenderer - .invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname) + const savePromise = response.bodyRef + ? getResponseBodyClient().save(response.bodyRef, { + url: item?.requestSent?.url, + pathname: item.pathname, + headers: response.headers + }) + : ipcRenderer.invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname); + + savePromise .then((result) => { if (result && result.success) { toast.success('Response downloaded to file'); @@ -31,7 +39,7 @@ const ResponseDownload = forwardRef(({ item, children }, ref) => { resolve(); }) .catch((err) => { - toast.error(get(err, 'error.message') || 'Something went wrong!'); + toast.error(get(err, 'error.message') || get(err, 'message') || 'Something went wrong!'); reject(err); }); }); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js b/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js index b2f5680ef1c..c95154db951 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js @@ -37,7 +37,7 @@ const MenuIcon = forwardRef((props, ref) => ( MenuIcon.displayName = 'MenuIcon'; -const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, selectedTab, data, dataBuffer }) => { +const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, selectedTab, data }) => { const { orientation } = useResponseLayoutToggle(); // Refs to access child component imperative handles (click, isDisabled) @@ -160,7 +160,6 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s selectedFormat={selectedFormat} selectedTab={selectedTab} data={data} - dataBuffer={dataBuffer} /> {item.type !== 'graphql-request' && } diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js b/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js index 22b0182eef7..fc20112b009 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js @@ -115,5 +115,29 @@ describe('ResponseSize', () => { expect(element.textContent).toMatch(/^\d+\.\d+KB$/); expect(element).toHaveAttribute('title', `${size.toLocaleString()}B`); }); + + it('should render megabytes', () => { + const size = 2 * 1024 * 1024; + renderWithTheme(); + const element = screen.getByText(/2\.0MB/); + expect(element).toBeInTheDocument(); + expect(element).toHaveAttribute('title', `${size.toLocaleString()}B`); + }); + + it('should render gigabytes', () => { + const size = 3 * 1024 * 1024 * 1024; + renderWithTheme(); + const element = screen.getByText(/3\.0GB/); + expect(element).toBeInTheDocument(); + expect(element).toHaveAttribute('title', `${size.toLocaleString()}B`); + }); + + it('should render terabytes', () => { + const size = 1.5 * 1024 * 1024 * 1024 * 1024; + renderWithTheme(); + const element = screen.getByText(/1\.50TB/); + expect(element).toBeInTheDocument(); + expect(element).toHaveAttribute('title', `${size.toLocaleString()}B`); + }); }); }); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js index 0be35709748..601353d0e6a 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js @@ -1,26 +1,33 @@ import React from 'react'; import StyledWrapper from './StyledWrapper'; -const ResponseSize = ({ size }) => { - if (!Number.isFinite(size)) { - return null; +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']; + +const formatSize = (size) => { + if (size <= 1024) { + return size + 'B'; + } + + let value = size; + let unit = 0; + while (value >= 1024 && unit < UNITS.length - 1) { + value /= 1024; + unit += 1; } - let sizeToDisplay = ''; + const whole = Math.floor(value); + const decimal = Math.round((value - whole).toFixed(2) * 100); + return whole + '.' + decimal + UNITS[unit]; +}; - // If size is greater than 1024 bytes, format as KB - if (size > 1024) { - let kb = Math.floor(size / 1024); - let decimal = Math.round(((size % 1024) / 1024).toFixed(2) * 100); - sizeToDisplay = kb + '.' + decimal + 'KB'; - } else { - // If size is less than or equal to 1024 bytes, display as bytes (B) - sizeToDisplay = size + 'B'; +const ResponseSize = ({ size }) => { + if (!Number.isFinite(size)) { + return null; } return ( - {sizeToDisplay} + {formatSize(size)} ); }; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js index 7ad58cf3353..38e171132e4 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js @@ -2,9 +2,12 @@ import { useState } from 'react'; import { IconChevronDown, IconChevronRight } from '@tabler/icons'; import QueryResponse from 'components/ResponsePane/QueryResponse/index'; -const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type }) => { +const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type, response }) => { const [isOpen, setIsOpen] = useState(true); - const hasBody = !!(data || dataBuffer); + const bodyRef = response?.bodyRef; + const hasBody = !!(data || dataBuffer || bodyRef); + // QueryResult reads bodyRef from item.response + const itemForPreview = bodyRef ? { ...item, response: response || item?.response } : item; return (
@@ -24,7 +27,7 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type }) hasBody ? (
{ }; const Response = ({ collection, response, item }) => { - let { status, statusCode, statusText, dataBuffer, headers, data, error, duration, size } = response || {}; - if (!dataBuffer) { + let { status, statusCode, statusText, dataBuffer, headers, data, error, duration, size, bodyRef } = response || {}; + // Large / file-backed bodies use bodyRef (protocol / download) instead of synthesizing a buffer. + if (!dataBuffer && data != null && !bodyRef) { dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64'); } @@ -64,6 +65,7 @@ const Response = ({ collection, response, item }) => { headers={headers} item={item} type="response" + response={response} /> ); diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/legacy-body.spec.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/legacy-body.spec.js new file mode 100644 index 00000000000..112a4f98126 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/legacy-body.spec.js @@ -0,0 +1,46 @@ +/** + * Characterization: legacy timeline entries without bodyRef still synthesize + * a base64 dataBuffer from inlined `data` so BodyBlock can preview them. + */ +describe('legacy timeline response body synthesis', () => { + const synthesizeLegacyDataBuffer = (response) => { + let { dataBuffer, data, bodyRef } = response || {}; + if (!dataBuffer && data != null && !bodyRef) { + const safeStringifyJSONIfNotString = (obj) => { + if (obj === null || obj === undefined) return ''; + if (typeof obj === 'string') return obj; + try { + return JSON.stringify(obj); + } catch (e) { + return '[Unserializable Object]'; + } + }; + dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64'); + } + return dataBuffer; + }; + + test('synthesizes base64 from inline data when bodyRef is absent', () => { + const buf = synthesizeLegacyDataBuffer({ data: { hello: 'world' } }); + expect(buf).toBe(Buffer.from(JSON.stringify({ hello: 'world' })).toString('base64')); + }); + + test('does not synthesize when bodyRef is present (file-backed path)', () => { + expect( + synthesizeLegacyDataBuffer({ + data: null, + bodyRef: 'body-1', + dataBuffer: undefined + }) + ).toBeUndefined(); + }); + + test('preserves existing dataBuffer', () => { + expect( + synthesizeLegacyDataBuffer({ + data: 'ignored', + dataBuffer: 'abc123' + }) + ).toBe('abc123'); + }); +}); diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js index eea06d16e05..24a6e138046 100644 --- a/packages/bruno-app/src/components/ResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/index.js @@ -42,6 +42,7 @@ const ResponsePane = ({ item, collection }) => { const focusedTab = find(tabs, (t) => t.uid === activeTabUid); // Initialize format and tab only once when data loads. + // Prefer headers; dataBuffer is optional (legacy / magic-byte sniff only). const { initialFormat, initialTab, contentType } = useInitialResponseFormat(response?.dataBuffer, response?.headers); const previewFormatOptions = useResponsePreviewFormatOptions(response?.dataBuffer, response?.headers); @@ -103,17 +104,8 @@ const ResponsePane = ({ item, collection }) => { if (typeof response.size === 'number') { return response.size; } - - if (!response.dataBuffer) return 0; - - try { - // dataBuffer is base64 encoded, so we need to calculate the actual size - const buffer = Buffer.from(response.dataBuffer, 'base64'); - return buffer.length; - } catch (error) { - return 0; - } - }, [response.size, response.dataBuffer]); + return 0; + }, [response.size]); const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0; const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage; @@ -162,7 +154,6 @@ const ResponsePane = ({ item, collection }) => { item={item} collection={collection} data={response.data} - dataBuffer={response.dataBuffer} headers={response.headers} error={response.error} key={item.filename} @@ -278,7 +269,6 @@ const ResponsePane = ({ item, collection }) => { selectedFormat={selectedFormat} selectedTab={selectedViewTab} data={response.data} - dataBuffer={response.dataBuffer} /> ) : null}
diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js index 318f49487b8..6eb69b7b630 100644 --- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js +++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js @@ -44,12 +44,11 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { case 'response': { return ( { isReRunningRef.current = false; clearStoredRunnerExchanges(); - dispatch( - resetCollectionRunner({ - collectionUid: collection.uid - }) - ); + dispatch(resetCollectionRunner({ collectionUid: collection.uid })); setDelay(null); }; diff --git a/packages/bruno-app/src/hooks/useResponseBody.js b/packages/bruno-app/src/hooks/useResponseBody.js new file mode 100644 index 00000000000..9f6b8a1dc17 --- /dev/null +++ b/packages/bruno-app/src/hooks/useResponseBody.js @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getResponseBodyClient } from 'utils/response-body'; + +/** + * Lazy read of a main-process response body by bodyRef. + * @param {{ bodyRef?: string|null, contentType?: string, mode?: 'text'|'blob' }} options + */ +export const useResponseBody = ({ bodyRef, contentType, mode = 'text' } = {}) => { + const [data, setData] = useState(null); + const [objectUrl, setObjectUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const generationRef = useRef(0); + const objectUrlRef = useRef(null); + + const revokeObjectUrl = useCallback(() => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } + setObjectUrl(null); + }, []); + + const reset = useCallback(() => { + generationRef.current += 1; + setData(null); + setError(null); + setLoading(false); + revokeObjectUrl(); + }, [revokeObjectUrl]); + + useEffect(() => { + reset(); + }, [bodyRef, reset]); + + useEffect(() => () => revokeObjectUrl(), [revokeObjectUrl]); + + const load = useCallback(async () => { + if (!bodyRef) { + return false; + } + + const gen = generationRef.current; + setLoading(true); + setError(null); + + try { + if (mode === 'blob') { + const result = await getResponseBodyClient().read(bodyRef, { encoding: 'bytes' }); + if (gen !== generationRef.current) { + return false; + } + const bytes = result?.bytes; + const blob = new Blob([bytes], { + type: contentType || result?.contentType || 'application/octet-stream' + }); + revokeObjectUrl(); + const url = URL.createObjectURL(blob); + objectUrlRef.current = url; + setObjectUrl(url); + } else { + const result = await getResponseBodyClient().read(bodyRef); + if (gen !== generationRef.current) { + return false; + } + setData(result?.data ?? ''); + } + return true; + } catch (err) { + if (gen !== generationRef.current) { + return false; + } + setError(err); + return false; + } finally { + if (gen === generationRef.current) { + setLoading(false); + } + } + }, [bodyRef, contentType, mode, revokeObjectUrl]); + + return { load, data, objectUrl, loading, error, reset }; +}; diff --git a/packages/bruno-app/src/hooks/useResponseBody.spec.js b/packages/bruno-app/src/hooks/useResponseBody.spec.js new file mode 100644 index 00000000000..bb8cbe67e0f --- /dev/null +++ b/packages/bruno-app/src/hooks/useResponseBody.spec.js @@ -0,0 +1,89 @@ +import { renderHook, act } from '@testing-library/react'; +import { useResponseBody } from './useResponseBody'; + +const read = jest.fn(); + +jest.mock('utils/response-body', () => ({ + getResponseBodyClient: () => ({ read }) +})); + +describe('useResponseBody', () => { + beforeEach(() => { + read.mockReset(); + global.URL.createObjectURL = jest.fn(() => 'blob:mock'); + global.URL.revokeObjectURL = jest.fn(); + }); + + it('does not read until load is called', () => { + const { result } = renderHook(() => useResponseBody({ bodyRef: 'b1', mode: 'text' })); + expect(read).not.toHaveBeenCalled(); + expect(result.current.data).toBeNull(); + }); + + it('loads text on demand', async () => { + read.mockResolvedValueOnce({ data: 'hello', size: 5 }); + const { result } = renderHook(() => useResponseBody({ bodyRef: 'b1', mode: 'text' })); + + let ok; + await act(async () => { + ok = await result.current.load(); + }); + + expect(ok).toBe(true); + expect(result.current.data).toBe('hello'); + }); + + it('creates blob object URL in blob mode', async () => { + read.mockResolvedValueOnce({ bytes: Uint8Array.from([1, 2, 3]), size: 3, contentType: 'image/png' }); + const { result } = renderHook(() => + useResponseBody({ bodyRef: 'b2', contentType: 'image/png', mode: 'blob' }) + ); + + await act(async () => { + await result.current.load(); + }); + + expect(URL.createObjectURL).toHaveBeenCalled(); + expect(result.current.objectUrl).toBe('blob:mock'); + }); + + it('ignores stale read after bodyRef change', async () => { + let resolveRead; + read.mockImplementation( + () => + new Promise((resolve) => { + resolveRead = () => resolve({ data: 'late', size: 4 }); + }) + ); + + const { result, rerender } = renderHook( + ({ bodyRef }) => useResponseBody({ bodyRef, mode: 'text' }), + { initialProps: { bodyRef: 'b-old' } } + ); + + await act(async () => { + result.current.load(); + }); + + rerender({ bodyRef: 'b-new' }); + + await act(async () => { + resolveRead(); + await Promise.resolve(); + }); + + expect(result.current.data).toBeNull(); + }); + + it('revokes object URL on unmount', async () => { + read.mockResolvedValueOnce({ bytes: Uint8Array.from([1]), size: 1 }); + const { result, unmount } = renderHook(() => useResponseBody({ bodyRef: 'b3', mode: 'blob' })); + + await act(async () => { + await result.current.load(); + }); + + unmount(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock'); + }); +}); diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 81a5830c91d..8ed5f773778 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -249,6 +249,9 @@ const useIpcEvents = () => { dispatch(collectionRenamedEvent(val)); }); + // Dispatch folder-run events synchronously first. Awaiting pins before dispatch + // reorders IPC (assertion/test results overtake response-received) and leaves + // items stuck in `running`, which under-counts passed runner results. const removeRunFolderEventListener = ipcRenderer.on('main:run-folder-event', (val) => { // Folder runs reuse the workspace baseline across N requests; clear it // per request so request N's global-env update doesn't diff against @@ -256,6 +259,7 @@ const useIpcEvents = () => { if (val.type === 'testrun-started' || val.type === 'request-queued') { dispatch(_clearScriptGlobalEnvBaseline()); } + dispatch(runFolderEvent(val)); }); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 69cad0c2565..fc874616fc8 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -34,7 +34,6 @@ import { uuid, waitForNextTick } from 'utils/common'; import { cancelNetworkRequest, connectWS, sendGrpcRequest, sendNetworkRequest, sendWsRequest } from 'utils/network/index'; import { callIpc } from 'utils/common/ipc'; import brunoClipboard from 'utils/bruno-clipboard'; - import { collectionAddEnvFileEvent as _collectionAddEnvFileEvent, createCollection as _createCollection, @@ -50,6 +49,9 @@ import { requestCancelled, resetRunResults, responseReceived, + responseCleared, + clearRequestTimeline, + resetCollectionRunner, updateLastAction, setCollectionSecurityConfig, updateCollectionVersion as _updateCollectionVersion, @@ -676,7 +678,7 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { }); } else { sendNetworkRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables) - .then((response) => { + .then(async (response) => { const { requestSent, ...responseData } = response; // Ensure any timestamps in the response are converted to numbers const serializedResponse = { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 8289658fe9c..96cf9d4996e 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -681,6 +681,10 @@ export const collectionsSlice = createSlice({ ? timelineRequest.timestamp.getTime() : timelineRequest?.timestamp || Date.now(); + if (action.payload.response?.postResponseScriptErrorMessage) { + item.postResponseScriptErrorMessage = action.payload.response.postResponseScriptErrorMessage; + } + // Append the new timeline entry with numeric timestamp collection.timeline.push({ type: 'request', diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js index d3d1c5c2754..85aa3a798d5 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js @@ -39,7 +39,7 @@ const scriptedRequestEvent = (overrides = {}) => ({ timestamp: 1000, data: { request: { method: 'GET', url: 'https://example.com/ping', headers: {}, data: undefined }, - response: { statusCode: 200, statusText: 'OK', headers: {}, data: 'ok', dataBuffer: '', size: 0, duration: 1 } + response: { statusCode: 200, statusText: 'OK', headers: {}, data: 'ok', bodyRef: null, size: 0, duration: 1 } }, ...overrides }); diff --git a/packages/bruno-app/src/utils/common/format-response.spec.js b/packages/bruno-app/src/utils/common/format-response.spec.js index 59e0aed6cf7..a20c06b5fd7 100644 --- a/packages/bruno-app/src/utils/common/format-response.spec.js +++ b/packages/bruno-app/src/utils/common/format-response.spec.js @@ -9,10 +9,9 @@ describe('formatResponse', () => { }; describe('invalid inputs', () => { - it('should return empty string for invalid inputs', () => { + it('should return empty string when data or mode is missing', () => { const invalidCases = [ [undefined, 'dGVzdA==', 'json'], - [{ test: 'data' }, null, 'json'], [{ test: 'data' }, 'dGVzdA==', null], [undefined, undefined, undefined] ]; @@ -23,6 +22,11 @@ describe('formatResponse', () => { expect(typeof result).toBe('string'); }); }); + + it('should format from structured data when buffer is absent (bodyRef path)', () => { + const result = formatResponse({ test: 'data' }, null, 'json'); + expect(result).toBe('{\n "test": "data"\n}'); + }); }); describe('JSON mode', () => { diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js index 3ba0ea1bcaa..8cd712135e8 100644 --- a/packages/bruno-app/src/utils/common/index.js +++ b/packages/bruno-app/src/utils/common/index.js @@ -237,7 +237,89 @@ const applyJSONPathFilter = (data, filter) => { }; export const formatResponse = (data, dataBufferString, mode, filter, bufferThreshold = LARGE_BUFFER_THRESHOLD) => { - if (data === undefined || !dataBufferString || !mode) { + if (data === undefined || !mode) { + return ''; + } + + // Prefer structured/string data when no buffer is available (bodyRef architecture) + if (!dataBufferString) { + if (mode.includes('hex')) { + if (typeof data === 'string' && isHexFormat(data)) { + return data; + } + try { + const asString = typeof data === 'string' ? data : safeStringifyJSON(data); + return formatHexView(Buffer.from(asString || '', 'utf8')); + } catch (_) { + return ''; + } + } + + if (mode.includes('base64')) { + if (typeof data === 'string') { + return Buffer.from(data, 'utf8').toString('base64'); + } + if (data != null) { + const asString = safeStringifyJSON(data); + return Buffer.from(asString || '', 'utf8').toString('base64'); + } + return ''; + } + + if (mode.includes('json')) { + try { + if (filter) { + const parsed = typeof data === 'string' ? safeParseJSON(data) : data; + return safeStringifyJSON(applyJSONPathFilter(parsed, filter), true); + } + } catch (error) { /* ignore */ } + if (typeof data === 'string') { + try { + return fastJsonFormat(data); + } catch (_) { + return data; + } + } + const stringified = safeStringifyJSON(data, true); + return typeof stringified === 'string' ? stringified : String(data); + } + + if (mode.includes('xml')) { + if (typeof data === 'string') { + const parsed = safeParseXML(data, { collapseContent: true }); + return typeof parsed === 'string' ? parsed : safeStringifyJSON(parsed, true); + } + return typeof data === 'string' ? data : safeStringifyJSON(data, true); + } + + if (mode.includes('html')) { + if (typeof data === 'string') { + try { + return prettifyHtmlString(data); + } catch (_) { + return data; + } + } + return data != null ? String(typeof data === 'object' ? safeStringifyJSON(data) : data) : ''; + } + + if (mode.includes('javascript')) { + if (typeof data === 'string') { + try { + return prettifyJavaScriptString(data); + } catch (_) { + return data; + } + } + return data != null ? String(typeof data === 'object' ? safeStringifyJSON(data) : data) : ''; + } + + // raw / text / fallback + if (typeof data === 'string') return data; + if (data != null) { + const stringified = safeStringifyJSON(data, true); + return typeof stringified === 'string' ? stringified : String(data); + } return ''; } @@ -345,28 +427,47 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres return data; } - // Data is not in hex format, encode it to hex - try { - const dataBuffer = Buffer.from(dataBufferString, 'base64'); - const hexView = formatHexView(dataBuffer); - return hexView; - } catch (error) { - // If buffer conversion fails, try to encode the string data directly - if (typeof data === 'string') { - try { - const stringBuffer = Buffer.from(data, 'utf8'); - return formatHexView(stringBuffer); - } catch (stringError) { - return ''; - } + // Prefer base64 buffer when present; otherwise encode structured/string data + if (dataBufferString) { + try { + const dataBuffer = Buffer.from(dataBufferString, 'base64'); + return formatHexView(dataBuffer); + } catch (error) { + /* fall through */ + } + } + + if (typeof data === 'string') { + try { + return formatHexView(Buffer.from(data, 'utf8')); + } catch (stringError) { + return ''; + } + } + if (data != null) { + try { + const asString = typeof data === 'string' ? data : safeStringifyJSON(data); + return formatHexView(Buffer.from(asString || '', 'utf8')); + } catch (_) { + return ''; } - return ''; } + return ''; } - // Handle base64 format - return base64 string as-is + // Handle base64 format — use dataBuffer when present; otherwise encode from data if (mode.includes('base64')) { - return dataBufferString; + if (dataBufferString) { + return dataBufferString; + } + if (typeof data === 'string') { + return Buffer.from(data, 'utf8').toString('base64'); + } + if (data != null) { + const asString = typeof data === 'string' ? data : safeStringifyJSON(data); + return Buffer.from(asString || '', 'utf8').toString('base64'); + } + return ''; } // Handle raw format - return data as-is without any formatting diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index 8a85c12ec26..9d78cffaeb5 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -6,20 +6,14 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV // if there is an error, we return the response object as is if (response?.error) { resolve(response); + return; } + // bodyRef is source of truth; never put dataBuffer in Redux + const { mapNetworkResponseToRedux } = require('utils/response-body'); resolve({ state: 'success', - data: response.data, - // Note that the Buffer is encoded as a base64 string, because Buffers / TypedArrays are not allowed in the redux store - dataBuffer: response.dataBuffer, - headers: response.headers, - size: response.size, - status: response.status, - statusText: response.statusText, - duration: response.duration, - timeline: response.timeline, - stream: response.stream, + ...mapNetworkResponseToRedux(response), requestSent: response.requestSent }); }) diff --git a/packages/bruno-app/src/utils/response-body/client.js b/packages/bruno-app/src/utils/response-body/client.js new file mode 100644 index 00000000000..910d33e1ae9 --- /dev/null +++ b/packages/bruno-app/src/utils/response-body/client.js @@ -0,0 +1,35 @@ +export const RESPONSE_BODY_CHANNELS = { + SAVE: 'renderer:response-body-save', + READ: 'renderer:response-body-read' +}; + +/** + * @param {{ invoke: (channel: string, ...args: any[]) => Promise }} ipcPort + */ +export const createResponseBodyClient = (ipcPort) => { + if (!ipcPort || typeof ipcPort.invoke !== 'function') { + throw new Error('createResponseBodyClient requires an IpcPort with invoke()'); + } + + return { + save(bodyRef, { url, pathname, headers } = {}) { + return ipcPort.invoke(RESPONSE_BODY_CHANNELS.SAVE, { bodyRef, url, pathname, headers }); + }, + + read(bodyRef, options) { + return ipcPort.invoke(RESPONSE_BODY_CHANNELS.READ, bodyRef, options); + } + }; +}; + +let defaultClient = null; + +export const getResponseBodyClient = () => { + if (!defaultClient) { + const { ipcRenderer } = window; + defaultClient = createResponseBodyClient({ + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args) + }); + } + return defaultClient; +}; diff --git a/packages/bruno-app/src/utils/response-body/client.spec.js b/packages/bruno-app/src/utils/response-body/client.spec.js new file mode 100644 index 00000000000..7c6ffe0ded4 --- /dev/null +++ b/packages/bruno-app/src/utils/response-body/client.spec.js @@ -0,0 +1,40 @@ +import { createResponseBodyClient } from './client'; +import { mapNetworkResponseToRedux } from './index'; + +describe('response-body client ring', () => { + test('mapNetworkResponseToRedux strips dataBuffer', () => { + const mapped = mapNetworkResponseToRedux({ + status: 200, + bodyRef: 'r1', + data: { ok: true }, + dataBuffer: 'aaaa', + size: 12 + }); + expect(mapped.dataBuffer).toBeUndefined(); + expect(mapped.bodyRef).toBe('r1'); + expect(mapped.data).toEqual({ ok: true }); + }); + + test('client save/read forward to IpcPort', async () => { + const calls = []; + const ipc = { + invoke: async (channel, ...args) => { + calls.push([channel, ...args]); + if (channel.includes('read')) return { data: 'hello', size: 5 }; + return { success: true }; + } + }; + const client = createResponseBodyClient(ipc); + await client.save('b1', { url: 'https://x' }); + expect(await client.read('b1')).toEqual({ data: 'hello', size: 5 }); + expect(await client.read('b1', { encoding: 'base64' })).toEqual({ data: 'hello', size: 5 }); + expect(calls.map((c) => c[0])).toEqual([ + 'renderer:response-body-save', + 'renderer:response-body-read', + 'renderer:response-body-read' + ]); + expect(calls[1][1]).toBe('b1'); + expect(calls[2][1]).toBe('b1'); + expect(calls[2][2]).toEqual({ encoding: 'base64' }); + }); +}); diff --git a/packages/bruno-app/src/utils/response-body/index.js b/packages/bruno-app/src/utils/response-body/index.js new file mode 100644 index 00000000000..13bca4f3402 --- /dev/null +++ b/packages/bruno-app/src/utils/response-body/index.js @@ -0,0 +1,24 @@ +import { createResponseBodyClient, getResponseBodyClient } from './client'; + +/** Map IPC/network response into Redux-safe shape: never dataBuffer. */ +export const mapNetworkResponseToRedux = (response = {}) => { + if (!response || typeof response !== 'object') { + return response; + } + + const { + dataBuffer, // eslint-disable-line no-unused-vars -- intentionally stripped + ...rest + } = response; + + return { + ...rest, + bodyRef: response.bodyRef || null, + size: typeof response.size === 'number' ? response.size : 0 + }; +}; + +export { + createResponseBodyClient, + getResponseBodyClient +}; diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 3e0777ed75c..926d0156283 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -17,6 +17,7 @@ if (isDev) { const { format } = require('url'); const { BrowserWindow, app, session, Menu, globalShortcut, ipcMain, nativeTheme, shell } = require('electron'); const { setContentSecurityPolicy } = require('electron-util'); +const { createResponseBodyService } = require('./services/response-body'); if (isDev && process.env.ELECTRON_USER_DATA_PATH) { console.debug('`ELECTRON_USER_DATA_PATH` found, modifying `userData` path: \n' @@ -509,6 +510,10 @@ app.on('ready', async () => { }); }); + // Response body store (spill / range-read) — before network IPC uses it + const responseBodyService = createResponseBodyService(); + responseBodyService.registerIpc(mainWindow); + // register all ipc handlers registerNetworkIpc(mainWindow); registerGlobalEnvironmentsIpc(mainWindow, globalEnvironmentsManager); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 77a860a6855..b06431068c6 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -5,7 +5,6 @@ const { applyOAuth1ToRequest } = require('@usebruno/requests'); const { buildScriptedEntry } = require('@usebruno/requests').scripting; const qs = require('qs'); const decomment = require('decomment'); -const contentDispositionParser = require('content-disposition'); const mime = require('mime-types'); const { ipcMain } = require('electron'); const { each, get, extend, cloneDeep, merge } = require('lodash'); @@ -39,6 +38,84 @@ const { cookiesStore } = require('../../store/cookies'); const registerGrpcEventHandlers = require('./grpc-event-handlers'); const { registerWsEventHandlers } = require('./ws-event-handlers'); const { getCertsAndProxyConfig, buildCertsAndProxyConfig } = require('./cert-utils'); +const { + getResponseBodyService, + SHOW_INLINE_BYTES, + populateResponseDataForScripts +} = require('../../services/response-body'); + +const getContentTypeHeader = (headers = {}) => { + const entries = typeof headers === 'object' ? Object.entries(headers) : []; + const found = entries.find(([name]) => String(name).toLowerCase() === 'content-type'); + return found ? found[1] : undefined; +}; + +const isTextLikeContentType = (contentType) => { + if (!contentType) return true; + const c = String(contentType).toLowerCase(); + return ( + c.includes('json') + || c.includes('text/') + || c.includes('xml') + || c.includes('javascript') + || c.includes('urlencoded') + || c.includes('graphql') + ); +}; + +/** + * Ingest axios response stream into ResponseBodyStore; attach bodyRef + parsed data from dual-writer buffer. + */ +const ingestAxiosResponseBody = async (response, { disableParsingResponseJson } = {}) => { + const bodyService = getResponseBodyService(); + const contentType = getContentTypeHeader(response.headers); + const ingested = await bodyService.store.ingestStream(response.data, { + contentType, + headers: response.headers + }); + + response.bodyRef = ingested.bodyRef; + response.size = ingested.size; + + populateResponseDataForScripts( + bodyService.store, + response, + parseDataFromResponse, + disableParsingResponseJson + ); + + return ingested; +}; + +const shouldIncludeParsedDataInIpc = (response) => { + if (typeof response.size !== 'number' || response.size > SHOW_INLINE_BYTES) return false; + return isTextLikeContentType(getContentTypeHeader(response.headers)); +}; + +/** + * Prefer raw UTF-8 from the store for IPC display so large JSON integers / exact bytes + * are preserved (JSON.parse would lose BigInt precision). + */ +const getDataForIpc = (response) => { + if (!shouldIncludeParsedDataInIpc(response)) { + return undefined; + } + try { + const buf = getResponseBodyService().store.getBufferForScripts(response.bodyRef); + return buf.toString('utf8'); + } catch (_) { + return response.data; + } +}; + +const discardIngestedBodyBuffer = (response) => { + if (!response?.bodyRef) return; + try { + getResponseBodyService().store.discardBuffer(response.bodyRef); + } catch (_) { + /* ignore */ + } +}; const { easterEggResponse } = require('../../utils/woof'); const { createRunnerExchangeEmitters } = require('./runner-exchange'); const { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils; @@ -828,7 +905,7 @@ const registerNetworkIpc = (mainWindow) => { statusText: res.statusText, headers: res.headers, data: res.data, - dataBuffer: res.dataBuffer, + bodyRef: res.bodyRef || null, size: res.size, duration: res.duration, timeline: res.timeline @@ -1046,7 +1123,9 @@ const registerNetworkIpc = (mainWindow) => { isResponseStream = hasStreamHeaders(response.headers); if (!isResponseStream) { - response.data = await promisifyStream(response.data); + await ingestAxiosResponseBody(response, { + disableParsingResponseJson: request.__brunoDisableParsingResponseJson + }); } // Prevents the duration on leaking to the actual result @@ -1074,7 +1153,9 @@ const registerNetworkIpc = (mainWindow) => { response.headers.delete('request-duration'); isResponseStream = hasStreamHeaders(response.headers); if (!isResponseStream) { - response.data = await promisifyStream(response.data); + await ingestAxiosResponseBody(response, { + disableParsingResponseJson: request.__brunoDisableParsingResponseJson + }); } } else { await executeRequestOnFailHandler(request, error, (onFailScriptResult) => { @@ -1097,11 +1178,11 @@ const registerNetworkIpc = (mainWindow) => { axiosDataStream = response.data; } - const { data, dataBuffer } = isResponseStream - ? { data: '', dataBuffer: Buffer.alloc(0) } - : parseDataFromResponse(response, request.__brunoDisableParsingResponseJson); - response.data = data; - response.dataBuffer = dataBuffer; + if (isResponseStream) { + response.data = ''; + response.dataBuffer = Buffer.alloc(0); + } + // non-stream: already parsed/attached in ingestAxiosResponseBody response.responseTime = responseTime; @@ -1276,20 +1357,28 @@ const registerNetworkIpc = (mainWindow) => { await runPostScripts(); } + const ipcData = getDataForIpc(response); + if (!isResponseStream) { + discardIngestedBodyBuffer(response); + } + return { status: response.status, statusText: response.statusText, headers: response.headers, - data: response.data, + data: ipcData !== undefined ? ipcData : (isResponseStream ? response.data : undefined), stream: isResponseStream ? axiosDataStream : null, sseChunks: isResponseStream ? sseChunks : null, cancelTokenUid: cancelTokenUid, - dataBuffer: response.dataBuffer.toString('base64'), - size: Buffer.byteLength(response.dataBuffer), + bodyRef: response.bodyRef || null, + size: typeof response.size === 'number' + ? response.size + : (response.dataBuffer ? Buffer.byteLength(response.dataBuffer) : 0), duration: responseTime ?? 0, url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null, timeline: response.timeline, - requestSent + requestSent, + postResponseScriptErrorMessage: response.postResponseScriptErrorMessage }; } catch (error) { deleteCancelToken(cancelTokenUid); @@ -1526,7 +1615,7 @@ const registerNetworkIpc = (mainWindow) => { statusText: res.statusText, headers: res.headers, data: res.data, - dataBuffer: res.dataBuffer, + bodyRef: res.bodyRef || null, size: res.size, duration: res.duration, timeline: res.timeline @@ -1879,12 +1968,11 @@ const registerNetworkIpc = (mainWindow) => { /** @type {import('axios').AxiosResponse} */ response = await axiosInstance(refreshExplicitHeaderNames(request)); - response.data = await promisifyStream(response.data, currentAbortController, false); + await ingestAxiosResponseBody(response, { + disableParsingResponseJson: request.__brunoDisableParsingResponseJson + }); timeEnd = Date.now(); - const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson); - response.data = data; - response.dataBuffer = dataBuffer; response.responseTime = response.headers.get('request-duration'); response.headers.delete('request-duration'); @@ -1905,9 +1993,9 @@ const registerNetworkIpc = (mainWindow) => { statusText: response.statusText, headers: response.headers, duration: timeEnd - timeStart, - dataBuffer: dataBuffer.toString('base64'), - size: Buffer.byteLength(dataBuffer), - data: response.data, + bodyRef: response.bodyRef || null, + size: response.size || 0, + data: getDataForIpc(response), responseTime: response.responseTime, timeline: response.timeline, url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null @@ -1921,12 +2009,9 @@ const registerNetworkIpc = (mainWindow) => { } if (error?.response) { - error.response.data = await promisifyStream(error.response.data, currentAbortController, false); - const { data, dataBuffer } = parseDataFromResponse(error.response); + await ingestAxiosResponseBody(error.response); error.response.responseTime = error.response.headers.get('request-duration'); error.response.headers.delete('request-duration'); - error.response.data = data; - error.response.dataBuffer = dataBuffer; // save cookies (4XX/5XX responses can also set cookies) if (preferencesUtil.shouldStoreCookies()) { @@ -1934,23 +2019,26 @@ const registerNetworkIpc = (mainWindow) => { } timeEnd = Date.now(); - response = { - status: error.response.status, - statusText: error.response.statusText, - headers: error.response.headers, - duration: timeEnd - timeStart, - dataBuffer: dataBuffer.toString('base64'), - size: Buffer.byteLength(dataBuffer), - data: error.response.data, - responseTime: error.response.responseTime, - timeline: error.response.timeline - }; + // Keep the ingested axios response for scripts/assertions (parsed `.data`). + // Only the runner IPC payload should use getDataForIpc(). + response = error.response; + response.duration = timeEnd - timeStart; // if we get a response from the server, we consider it as a success sendRunnerResponseReceived({ requestUid, error: error ? error.message : 'An error occurred while running the request', - responseReceived: response, + responseReceived: { + status: response.status, + statusText: response.statusText, + headers: response.headers, + duration: response.duration, + bodyRef: response.bodyRef || null, + size: response.size || 0, + data: getDataForIpc(response), + responseTime: response.responseTime, + timeline: response.timeline + }, eventData }); } else { @@ -2116,6 +2204,8 @@ const registerNetworkIpc = (mainWindow) => { const domainsWithCookiesTest = await getDomainsWithCookies(); mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest))); } + + discardIngestedBodyBuffer(response); } catch (error) { mainWindow.webContents.send('main:run-folder-event', { type: 'error', @@ -2180,55 +2270,36 @@ const registerNetworkIpc = (mainWindow) => { } ); - // save response to file + // save response to file — prefer bodyRef from ResponseBodyStore (no base64 round-trip) ipcMain.handle('renderer:save-response-to-file', async (event, response, url, pathname) => { try { - const getHeaderValue = (headerName) => { - const headersArray = typeof response.headers === 'object' ? Object.entries(response.headers) : []; - - if (headersArray.length > 0) { - const header = headersArray.find((header) => header[0] === headerName); - if (header && header.length > 1) { - return header[1]; - } - } - }; - - const getFileNameFromContentDispositionHeader = () => { - const contentDisposition = getHeaderValue('content-disposition'); - try { - const disposition = contentDispositionParser.parse(contentDisposition); - return disposition && disposition.parameters['filename']; - } catch (error) { } - }; - - const getFileNameFromUrlPath = () => { - const lastPathLevel = new URL(url).pathname.split('/').pop(); - if (lastPathLevel && /\..+/.exec(lastPathLevel)) { - return lastPathLevel; + const bodyService = getResponseBodyService(); + if (response?.bodyRef) { + const { resolveResponseSaveDefaultPath } = require('../../utils/response-save-filename'); + const defaultPath = resolveResponseSaveDefaultPath({ + headers: response.headers, + url, + pathname + }); + const filePath = await chooseFileToSave(mainWindow, defaultPath); + if (!filePath) { + return { success: false, cancelled: true }; } - }; + await bodyService.store.saveToPath(response.bodyRef, filePath); + return { success: true, filePath }; + } - const getFileNameBasedOnContentTypeHeader = () => { - const contentType = getHeaderValue('content-type'); - const extension = (contentType && mime.extension(contentType)) || 'txt'; - return `response.${extension}`; - }; + // Legacy fallback: base64 dataBuffer (should not be used for new responses) + const { resolveResponseSaveFilename, getHeaderValue } = require('../../utils/response-save-filename'); const getEncodingFormat = () => { - const contentType = getHeaderValue('content-type'); + const contentType = getHeaderValue(response.headers, 'content-type'); const extension = mime.extension(contentType) || 'txt'; return ['json', 'xml', 'html', 'yml', 'yaml', 'txt'].includes(extension) ? 'utf-8' : 'base64'; }; - const determineFileName = () => { - return ( - getFileNameFromContentDispositionHeader() || getFileNameFromUrlPath() || getFileNameBasedOnContentTypeHeader() - ); - }; - const dirPath = path.dirname(pathname); - const fileName = determineFileName(); + const fileName = resolveResponseSaveFilename({ headers: response.headers, url }); const filePath = await chooseFileToSave(mainWindow, path.join(dirPath, fileName)); if (filePath) { const encoding = getEncodingFormat(); diff --git a/packages/bruno-electron/src/services/response-body/constants.js b/packages/bruno-electron/src/services/response-body/constants.js new file mode 100644 index 00000000000..b57dea38357 --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/constants.js @@ -0,0 +1,10 @@ +/** Inline Show over IPC when response size is at or below this (bytes). */ +const SHOW_INLINE_BYTES = 10 * 1024 * 1024; + +/** View-from-disk allowed at or below this; above → Download only (bytes). */ +const VIEW_MAX_BYTES = 50 * 1024 * 1024; + +module.exports = { + SHOW_INLINE_BYTES, + VIEW_MAX_BYTES +}; diff --git a/packages/bruno-electron/src/services/response-body/errors.js b/packages/bruno-electron/src/services/response-body/errors.js new file mode 100644 index 00000000000..967390a7b4a --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/errors.js @@ -0,0 +1,48 @@ +class ResponseBodyError extends Error { + constructor(message, code) { + super(message); + this.name = 'ResponseBodyError'; + this.code = code; + } +} + +class BodyNotFoundError extends ResponseBodyError { + constructor(bodyRef) { + super(`Response body not found: ${bodyRef}`, 'BODY_NOT_FOUND'); + this.name = 'BodyNotFoundError'; + this.bodyRef = bodyRef; + } +} + +class BodyTooLargeForViewError extends ResponseBodyError { + constructor(bodyRef, size, maxBytes) { + super( + `Response body is too large to view in-app (${size} bytes; max ${maxBytes}). Download the response instead.`, + 'BODY_TOO_LARGE_FOR_VIEW' + ); + this.name = 'BodyTooLargeForViewError'; + this.bodyRef = bodyRef; + this.size = size; + this.maxBytes = maxBytes; + } +} + +class BodyTooLargeForScriptError extends ResponseBodyError { + constructor(bodyRef, size, maxBytes) { + super( + `Response body is too large to use in scripts (${size} bytes; max ${maxBytes}). Download the response instead.`, + 'BODY_TOO_LARGE_FOR_SCRIPT' + ); + this.name = 'BodyTooLargeForScriptError'; + this.bodyRef = bodyRef; + this.size = size; + this.maxBytes = maxBytes; + } +} + +module.exports = { + ResponseBodyError, + BodyNotFoundError, + BodyTooLargeForViewError, + BodyTooLargeForScriptError +}; diff --git a/packages/bruno-electron/src/services/response-body/index.js b/packages/bruno-electron/src/services/response-body/index.js new file mode 100644 index 00000000000..b6c46a918b4 --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/index.js @@ -0,0 +1,58 @@ +const { createResponseBodyStore } = require('./store'); +const { createNodeFileSystem } = require('./node-fs'); +const { purgeResponseBodiesDirectory } = require('./paths'); +const { registerResponseBodyIpc, CHANNELS } = require('./ipc'); +const { SHOW_INLINE_BYTES, VIEW_MAX_BYTES } = require('./constants'); +const { + BodyNotFoundError, + BodyTooLargeForViewError, + BodyTooLargeForScriptError, + ResponseBodyError +} = require('./errors'); +const { populateResponseDataForScripts } = require('./script-access'); + +let singleton = null; + +const createResponseBodyService = (options = {}) => { + if (singleton && !options.fresh) { + return singleton; + } + + const spillDir = options.spillDir || purgeResponseBodiesDirectory(); + const fs = options.fs || createNodeFileSystem(); + const store = createResponseBodyStore({ + fs, + spillDir, + idGen: options.idGen + }); + + singleton = { + store, + spillDir, + registerIpc(mainWindow) { + return registerResponseBodyIpc(mainWindow, store); + } + }; + + return singleton; +}; + +const getResponseBodyService = () => { + if (!singleton) { + return createResponseBodyService(); + } + return singleton; +}; + +module.exports = { + createResponseBodyService, + getResponseBodyService, + CHANNELS, + SHOW_INLINE_BYTES, + VIEW_MAX_BYTES, + BodyNotFoundError, + BodyTooLargeForViewError, + BodyTooLargeForScriptError, + populateResponseDataForScripts, + ResponseBodyError +}; diff --git a/packages/bruno-electron/src/services/response-body/ipc-protocol.spec.js b/packages/bruno-electron/src/services/response-body/ipc-protocol.spec.js new file mode 100644 index 00000000000..e9fcd03030f --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/ipc-protocol.spec.js @@ -0,0 +1,108 @@ +describe('response-body IPC adapter', () => { + let store; + let handlers; + let chooseFileToSave; + + beforeEach(() => { + jest.resetModules(); + handlers = {}; + chooseFileToSave = jest.fn(async () => '/out/saved.bin'); + + jest.doMock('electron', () => ({ + ipcMain: { + handle: (channel, fn) => { + handlers[channel] = fn; + } + } + }), { virtual: true }); + + jest.doMock('content-disposition', () => ({ + parse: () => ({ parameters: {} }) + }), { virtual: true }); + + jest.doMock('mime-types', () => ({ + extension: () => 'txt' + }), { virtual: true }); + + jest.doMock('../../utils/filesystem', () => ({ + chooseFileToSave: (...args) => chooseFileToSave(...args) + })); + + const { createResponseBodyStore } = require('./store'); + const { createMemoryFileSystem } = require('./memory-fs'); + const { registerResponseBodyIpc } = require('./ipc'); + + store = createResponseBodyStore({ + fs: createMemoryFileSystem(), + spillDir: '/spill', + idGen: (() => { + let n = 0; + return () => `b-${++n}`; + })() + }); + registerResponseBodyIpc({}, store); + }); + + afterEach(() => { + jest.dontMock('electron'); + jest.dontMock('content-disposition'); + jest.dontMock('mime-types'); + jest.dontMock('../../utils/filesystem'); + }); + + test('save / read round-trip (utf8, base64, bytes)', async () => { + const { CHANNELS } = require('./ipc'); + const { bodyRef } = await store.putBuffer(Buffer.from('hello')); + + const readResult = await handlers[CHANNELS.READ]({}, bodyRef); + expect(readResult).toEqual({ data: 'hello', size: 5, contentType: null }); + + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const { bodyRef: pngRef } = await store.putBuffer(png); + const base64Result = await handlers[CHANNELS.READ]({}, pngRef, { encoding: 'base64' }); + expect(base64Result).toEqual({ + dataBuffer: png.toString('base64'), + size: png.length, + contentType: null + }); + expect(base64Result.data).toBeUndefined(); + + const bytesResult = await handlers[CHANNELS.READ]({}, pngRef, { encoding: 'bytes' }); + expect(bytesResult.size).toBe(png.length); + expect(Buffer.from(bytesResult.bytes)).toEqual(png); + + const saveResult = await handlers[CHANNELS.SAVE]({}, { + bodyRef, + url: 'https://example.com/a.txt', + headers: { 'content-type': 'text/plain' } + }); + expect(saveResult).toEqual({ success: true, filePath: '/out/saved.bin' }); + expect(chooseFileToSave).toHaveBeenCalled(); + }); + + test('read works after discardBuffer (file-backed)', async () => { + const { CHANNELS } = require('./ipc'); + const { bodyRef } = await store.putBuffer(Buffer.from('from-disk')); + store.discardBuffer(bodyRef); + expect(() => store.getBufferForScripts(bodyRef)).toThrow(/discarded/); + + const readResult = await handlers[CHANNELS.READ]({}, bodyRef); + expect(readResult).toEqual({ data: 'from-disk', size: 8, contentType: null }); + }); + + test('read rejects bodies larger than VIEW_MAX_BYTES', async () => { + const { CHANNELS } = require('./ipc'); + const { VIEW_MAX_BYTES } = require('./constants'); + const { BodyTooLargeForViewError } = require('./errors'); + const { bodyRef } = await store.putBuffer(Buffer.alloc(VIEW_MAX_BYTES + 1, 0x61)); + await expect(handlers[CHANNELS.READ]({}, bodyRef)).rejects.toBeInstanceOf(BodyTooLargeForViewError); + }); + + test('save cancelled returns cancelled flag', async () => { + const { CHANNELS } = require('./ipc'); + chooseFileToSave.mockResolvedValueOnce(null); + const { bodyRef } = await store.putBuffer(Buffer.from('x')); + const result = await handlers[CHANNELS.SAVE]({}, { bodyRef }); + expect(result).toEqual({ success: false, cancelled: true }); + }); +}); diff --git a/packages/bruno-electron/src/services/response-body/ipc.js b/packages/bruno-electron/src/services/response-body/ipc.js new file mode 100644 index 00000000000..9ef0e9ea6a3 --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/ipc.js @@ -0,0 +1,61 @@ +const { ipcMain } = require('electron'); +const { chooseFileToSave } = require('../../utils/filesystem'); +const { resolveResponseSaveDefaultPath } = require('../../utils/response-save-filename'); +const { VIEW_MAX_BYTES } = require('./constants'); +const { BodyTooLargeForViewError } = require('./errors'); + +const CHANNELS = { + SAVE: 'renderer:response-body-save', + READ: 'renderer:response-body-read' +}; + +const registerResponseBodyIpc = (mainWindow, store) => { + ipcMain.handle(CHANNELS.READ, async (_event, bodyRef, options = {}) => { + const stat = store.getStat(bodyRef); + if (stat.size > VIEW_MAX_BYTES) { + throw new BodyTooLargeForViewError(bodyRef, stat.size, VIEW_MAX_BYTES); + } + + const buf = await store.readRange(bodyRef, 0, stat.size); + const payload = { + size: stat.size, + contentType: stat.contentType || null + }; + + const encoding = options?.encoding; + if (encoding === 'base64') { + payload.dataBuffer = buf.toString('base64'); + } else if (encoding === 'bytes') { + payload.bytes = Uint8Array.from(buf); + } else { + payload.data = buf.toString('utf8'); + } + + return payload; + }); + + ipcMain.handle(CHANNELS.SAVE, async (_event, { bodyRef, url, pathname, headers } = {}) => { + try { + store.getStat(bodyRef); + } catch (err) { + return Promise.reject(err); + } + + const defaultPath = resolveResponseSaveDefaultPath({ headers, url, pathname }); + const filePath = await chooseFileToSave(mainWindow, defaultPath); + + if (!filePath) { + return { success: false, cancelled: true }; + } + + await store.saveToPath(bodyRef, filePath); + return { success: true, filePath }; + }); + + return CHANNELS; +}; + +module.exports = { + CHANNELS, + registerResponseBodyIpc +}; diff --git a/packages/bruno-electron/src/services/response-body/memory-fs.js b/packages/bruno-electron/src/services/response-body/memory-fs.js new file mode 100644 index 00000000000..e15657aa085 --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/memory-fs.js @@ -0,0 +1,127 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +/** + * In-memory + optional disk FileSystemPort for unit tests. + * When `rootDir` is set, file ops use the real filesystem under that root. + */ +const createMemoryFileSystem = (rootDir) => { + const memFiles = new Map(); + + const resolve = (filePath) => { + if (!rootDir) return filePath; + return path.isAbsolute(filePath) ? filePath : path.join(rootDir, filePath); + }; + + return { + async mkdirp(dir) { + if (rootDir) { + await fs.promises.mkdir(resolve(dir), { recursive: true }); + } + }, + + async writeFile(filePath, data) { + const p = resolve(filePath); + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); + if (rootDir) { + await fs.promises.mkdir(path.dirname(p), { recursive: true }); + await fs.promises.writeFile(p, buf); + } else { + memFiles.set(p, buf); + } + }, + + createWriteStream(filePath) { + const p = resolve(filePath); + if (rootDir) { + fs.mkdirSync(path.dirname(p), { recursive: true }); + return fs.createWriteStream(p); + } + + const chunks = []; + const { Writable } = require('node:stream'); + const stream = new Writable({ + write(chunk, _enc, cb) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + cb(); + }, + final(cb) { + memFiles.set(p, Buffer.concat(chunks)); + cb(); + } + }); + return stream; + }, + + createReadStream(filePath) { + const p = resolve(filePath); + if (rootDir) { + return fs.createReadStream(p); + } + const { Readable } = require('node:stream'); + const buf = memFiles.get(p) || Buffer.alloc(0); + return Readable.from(buf); + }, + + async readFileRange(filePath, { position = 0, length } = {}) { + const p = resolve(filePath); + let buf; + if (rootDir) { + const fh = await fs.promises.open(p, 'r'); + try { + const size = length ?? (await fh.stat()).size - position; + buf = Buffer.alloc(Math.max(0, size)); + await fh.read(buf, 0, buf.length, position); + } finally { + await fh.close(); + } + } else { + const full = memFiles.get(p) || Buffer.alloc(0); + const len = length ?? full.length - position; + buf = full.subarray(position, position + Math.max(0, len)); + } + return buf; + }, + + async readFile(filePath) { + const p = resolve(filePath); + if (rootDir) { + return fs.promises.readFile(p); + } + return memFiles.get(p) || Buffer.alloc(0); + }, + + async copyFile(src, dest) { + const s = resolve(src); + const d = resolve(dest); + if (rootDir) { + await fs.promises.copyFile(s, d); + return; + } + memFiles.set(d, Buffer.from(memFiles.get(s) || [])); + }, + + async unlink(filePath) { + const p = resolve(filePath); + if (rootDir) { + await fs.promises.unlink(p); + return; + } + memFiles.delete(p); + }, + + existsSync(filePath) { + const p = resolve(filePath); + if (rootDir) { + return fs.existsSync(p); + } + return memFiles.has(p); + }, + + _memFiles: memFiles + }; +}; + +module.exports = { + createMemoryFileSystem +}; diff --git a/packages/bruno-electron/src/services/response-body/node-fs.js b/packages/bruno-electron/src/services/response-body/node-fs.js new file mode 100644 index 00000000000..dd9fc23dd7b --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/node-fs.js @@ -0,0 +1,61 @@ +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const path = require('node:path'); + +/** + * Real filesystem FileSystemPort for ResponseBodyStore. + */ +const createNodeFileSystem = () => ({ + async mkdirp(dir) { + await fsp.mkdir(dir, { recursive: true }); + }, + + async writeFile(filePath, data) { + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile(filePath, data); + }, + + createWriteStream(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + return fs.createWriteStream(filePath); + }, + + createReadStream(filePath) { + return fs.createReadStream(filePath); + }, + + async readFileRange(filePath, { position = 0, length } = {}) { + const fh = await fsp.open(filePath, 'r'); + try { + const stat = await fh.stat(); + const size = length == null ? Math.max(0, stat.size - position) : length; + const buf = Buffer.alloc(Math.max(0, size)); + if (buf.length === 0) return buf; + const { bytesRead } = await fh.read(buf, 0, buf.length, position); + return bytesRead === buf.length ? buf : buf.subarray(0, bytesRead); + } finally { + await fh.close(); + } + }, + + async readFile(filePath) { + return fsp.readFile(filePath); + }, + + async copyFile(src, dest) { + await fsp.mkdir(path.dirname(dest), { recursive: true }); + await fsp.copyFile(src, dest); + }, + + async unlink(filePath) { + await fsp.unlink(filePath); + }, + + existsSync(filePath) { + return fs.existsSync(filePath); + } +}); + +module.exports = { + createNodeFileSystem +}; diff --git a/packages/bruno-electron/src/services/response-body/paths.js b/packages/bruno-electron/src/services/response-body/paths.js new file mode 100644 index 00000000000..3fde8140e0c --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/paths.js @@ -0,0 +1,35 @@ +const path = require('node:path'); +const fs = require('node:fs'); + +/** + * Own helpers for response-body spill dir. Do NOT reuse getTransient* from collection.js. + * @param {{ getUserDataPath?: () => string }} [deps] + */ +const getResponseBodiesDirectoryBase = (deps = {}) => { + const getUserDataPath + = deps.getUserDataPath + || (() => require('electron').app.getPath('userData')); + return path.join(getUserDataPath(), 'tmp', 'response-bodies'); +}; + + +const purgeResponseBodiesDirectory = (deps = {}) => { + const base = getResponseBodiesDirectoryBase(deps); + if (fs.existsSync(base)) { + fs.rmSync(base, { recursive: true, force: true }); + } + fs.mkdirSync(base, { recursive: true }); + return base; +}; + +const ensureResponseBodiesDirectory = (deps = {}) => { + const base = getResponseBodiesDirectoryBase(deps); + fs.mkdirSync(base, { recursive: true }); + return base; +}; + +module.exports = { + getResponseBodiesDirectoryBase, + ensureResponseBodiesDirectory, + purgeResponseBodiesDirectory +}; diff --git a/packages/bruno-electron/src/services/response-body/paths.spec.js b/packages/bruno-electron/src/services/response-body/paths.spec.js new file mode 100644 index 00000000000..b201bf67ebf --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/paths.spec.js @@ -0,0 +1,46 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { + getResponseBodiesDirectoryBase, + ensureResponseBodiesDirectory, + purgeResponseBodiesDirectory +} = require('./paths'); + +describe('response-body paths adapter', () => { + test('resolves under userData/tmp/response-bodies', () => { + expect(getResponseBodiesDirectoryBase({ getUserDataPath: () => '/mock-user-data' })).toBe( + path.join('/mock-user-data', 'tmp', 'response-bodies') + ); + }); + + test('purgeResponseBodiesDirectory removes prior files and recreates empty dir', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-rb-purge-')); + const base = path.join(tmpRoot, 'tmp', 'response-bodies'); + fs.mkdirSync(base, { recursive: true }); + fs.writeFileSync(path.join(base, 'old-body'), 'stale'); + + const dir = purgeResponseBodiesDirectory({ getUserDataPath: () => tmpRoot }); + expect(dir).toBe(base); + expect(fs.existsSync(dir)).toBe(true); + expect(fs.readdirSync(dir)).toEqual([]); + + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + test('ensureResponseBodiesDirectory creates the directory', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-rb-paths-')); + const dir = ensureResponseBodiesDirectory({ getUserDataPath: () => tmpRoot }); + expect(dir).toBe(path.join(tmpRoot, 'tmp', 'response-bodies')); + expect(fs.existsSync(dir)).toBe(true); + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + test('does not import collection transient helpers', () => { + const src = fs.readFileSync(require.resolve('./paths'), 'utf8'); + expect(src).not.toMatch(/require\(['"].*collection/); + expect(src).not.toMatch(/\bgetTransientDirectory\b/); + expect(src).not.toMatch(/\bensureTransientDirectory\b/); + expect(src).not.toMatch(/\bgetTransientPath\b/); + }); +}); diff --git a/packages/bruno-electron/src/services/response-body/script-access.js b/packages/bruno-electron/src/services/response-body/script-access.js new file mode 100644 index 00000000000..eb751185b37 --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/script-access.js @@ -0,0 +1,31 @@ +const { VIEW_MAX_BYTES } = require('./constants'); +const { BodyTooLargeForScriptError } = require('./errors'); + +/** + * Fill response.data / dataBuffer for post-response scripts from the dual-writer RAM buffer. + * Bodies larger than VIEW_MAX_BYTES stay file-backed only: RAM is discarded and + * response.scriptBodyError is set so res.getBody() can throw a clear error. + */ +const populateResponseDataForScripts = (store, response, parseDataFromResponse, disableParsingResponseJson) => { + if (typeof response.size === 'number' && response.size > VIEW_MAX_BYTES) { + store.discardBuffer(response.bodyRef); + response.data = undefined; + response.dataBuffer = undefined; + response.scriptBodyError = new BodyTooLargeForScriptError( + response.bodyRef, + response.size, + VIEW_MAX_BYTES + ).message; + return; + } + + const buffer = store.getBufferForScripts(response.bodyRef); + response.data = buffer; + const parsed = parseDataFromResponse(response, disableParsingResponseJson); + response.data = parsed.data; + response.dataBuffer = parsed.dataBuffer; +}; + +module.exports = { + populateResponseDataForScripts +}; diff --git a/packages/bruno-electron/src/services/response-body/script-gate.spec.js b/packages/bruno-electron/src/services/response-body/script-gate.spec.js new file mode 100644 index 00000000000..50f7fe9b933 --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/script-gate.spec.js @@ -0,0 +1,43 @@ +const { createResponseBodyStore } = require('./store'); +const { createMemoryFileSystem } = require('./memory-fs'); +const { VIEW_MAX_BYTES } = require('./constants'); +const { populateResponseDataForScripts } = require('./script-access'); + +const parsePassthrough = (response) => ({ + data: response.data?.toString?.('utf8') ?? response.data, + dataBuffer: Buffer.isBuffer(response.data) ? response.data.toString('base64') : '' +}); + +describe('script access (dual-writer)', () => { + test('bodies within VIEW_MAX remain script-accessible', async () => { + const store = createResponseBodyStore({ + fs: createMemoryFileSystem(), + spillDir: '/spill', + idGen: () => 'ok-1' + }); + const { bodyRef, size } = await store.putBuffer(Buffer.from('ok')); + const response = { bodyRef, size }; + populateResponseDataForScripts(store, response, parsePassthrough); + expect(response.data).toBe('ok'); + expect(response.scriptBodyError).toBeUndefined(); + }); + + test('bodies larger than VIEW_MAX set scriptBodyError and discard RAM', async () => { + const store = createResponseBodyStore({ + fs: createMemoryFileSystem(), + spillDir: '/spill', + idGen: () => 'big-1' + }); + const { bodyRef, size } = await store.putBuffer(Buffer.alloc(VIEW_MAX_BYTES + 1, 0x61)); + const response = { bodyRef, size }; + populateResponseDataForScripts(store, response, parsePassthrough); + + expect(response.data).toBeUndefined(); + expect(response.dataBuffer).toBeUndefined(); + expect(response.scriptBodyError).toMatch(/too large to use in scripts/); + expect(response.scriptBodyError).toMatch(String(VIEW_MAX_BYTES)); + expect(() => store.getBufferForScripts(bodyRef)).toThrow(/discarded/); + // Spill file still readable for Download / View IPC paths that use readRange + expect((await store.readRange(bodyRef)).length).toBe(VIEW_MAX_BYTES + 1); + }); +}); diff --git a/packages/bruno-electron/src/services/response-body/store.js b/packages/bruno-electron/src/services/response-body/store.js new file mode 100644 index 00000000000..78c3ff7aaf8 --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/store.js @@ -0,0 +1,203 @@ +const { randomUUID } = require('node:crypto'); +const path = require('node:path'); +const { BodyNotFoundError } = require('./errors'); + +const defaultIdGen = () => randomUUID(); + +const writeChunk = async (writeStream, buf) => { + if (!writeStream.write(buf)) { + await new Promise((resolve, reject) => { + writeStream.once('drain', resolve); + writeStream.once('error', reject); + }); + } +}; + +/** + * Pure response-body store (no Electron). + * Every ingest writes to a spill file; RAM buffer is optional and may be discarded after request processing. + */ +const createResponseBodyStore = ({ + fs, + spillDir, + idGen = defaultIdGen +} = {}) => { + if (!fs) { + throw new Error('createResponseBodyStore requires a FileSystemPort'); + } + if (!spillDir) { + throw new Error('createResponseBodyStore requires spillDir'); + } + + /** @type {Map} */ + const entries = new Map(); + + const ensureSpillDir = async () => { + await fs.mkdirp(spillDir); + }; + + const filePathFor = (bodyRef) => path.join(spillDir, bodyRef); + + const getEntry = (bodyRef) => { + const entry = entries.get(bodyRef); + if (!entry) { + throw new BodyNotFoundError(bodyRef); + } + return entry; + }; + + const destroyEntry = async (bodyRef) => { + const entry = entries.get(bodyRef); + if (!entry) return; + entries.delete(bodyRef); + if (entry.filePath && fs.existsSync(entry.filePath)) { + await fs.unlink(entry.filePath); + } + }; + + const finalizeEntry = (bodyRef, { buffer, filePath, size, contentType, headers }) => { + entries.set(bodyRef, { + filePath, + buffer, + size, + contentType, + headers + }); + return { + bodyRef, + size, + contentType + }; + }; + + /** + * Ingest a Node Readable via dual writers: file + in-memory response buffer. + */ + const ingestStream = async (readable, { contentType, headers } = {}) => { + if (!readable) { + throw new Error('ingestStream requires a Readable stream'); + } + + const bodyRef = idGen(); + const chunks = []; + let size = 0; + const destPath = filePathFor(bodyRef); + + await ensureSpillDir(); + const writeStream = fs.createWriteStream(destPath); + + try { + for await (const chunk of readable) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buf.length; + chunks.push(buf); + await writeChunk(writeStream, buf); + } + + await new Promise((resolve, reject) => { + writeStream.end((err) => (err ? reject(err) : resolve())); + }); + + return finalizeEntry(bodyRef, { + buffer: Buffer.concat(chunks, size), + filePath: destPath, + size, + contentType, + headers + }); + } catch (err) { + writeStream.destroy(); + if (fs.existsSync(destPath)) { + try { + await fs.unlink(destPath); + } catch (_) { + /* ignore */ + } + } + throw err; + } + }; + + const putBuffer = async (buffer, { contentType, headers } = {}) => { + const bodyRef = idGen(); + const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || []); + const size = buf.length; + const destPath = filePathFor(bodyRef); + + await ensureSpillDir(); + await fs.writeFile(destPath, buf); + + return finalizeEntry(bodyRef, { + buffer: buf, + filePath: destPath, + size, + contentType, + headers + }); + }; + + const getStat = (bodyRef) => { + const entry = getEntry(bodyRef); + return { + size: entry.size, + contentType: entry.contentType + }; + }; + + const readRange = async (bodyRef, offset = 0, length) => { + const entry = getEntry(bodyRef); + const start = Math.max(0, offset | 0); + if (start >= entry.size) { + return Buffer.alloc(0); + } + const maxLen = entry.size - start; + const len = length == null ? maxLen : Math.min(Math.max(0, length | 0), maxLen); + + if (entry.buffer) { + return entry.buffer.subarray(start, start + len); + } + + return fs.readFileRange(entry.filePath, { position: start, length: len }); + }; + + const getBufferForScripts = (bodyRef) => { + const entry = getEntry(bodyRef); + if (!entry.buffer) { + throw new Error(`Response body buffer discarded for ${bodyRef}`); + } + return entry.buffer; + }; + + const discardBuffer = (bodyRef) => { + const entry = entries.get(bodyRef); + if (!entry) return; + entry.buffer = null; + }; + + const saveToPath = async (bodyRef, destPath) => { + const entry = getEntry(bodyRef); + await fs.copyFile(entry.filePath, destPath); + }; + + const getFilePath = (bodyRef) => { + const entry = getEntry(bodyRef); + return entry.filePath || null; + }; + + return { + ingestStream, + putBuffer, + getStat, + readRange, + getBufferForScripts, + discardBuffer, + getFilePath, + saveToPath, + destroyEntry, + _entries: entries + }; +}; + +module.exports = { + createResponseBodyStore +}; diff --git a/packages/bruno-electron/src/services/response-body/store.spec.js b/packages/bruno-electron/src/services/response-body/store.spec.js new file mode 100644 index 00000000000..02f65bb7277 --- /dev/null +++ b/packages/bruno-electron/src/services/response-body/store.spec.js @@ -0,0 +1,111 @@ +const { Readable } = require('node:stream'); +const path = require('node:path'); +const { createResponseBodyStore } = require('./store'); +const { createMemoryFileSystem } = require('./memory-fs'); +const { BodyNotFoundError } = require('./errors'); + +describe('ResponseBodyStore', () => { + let idSeq; + let store; + let fs; + const spillDir = path.join(path.sep, 'spill'); + + beforeEach(() => { + idSeq = 0; + fs = createMemoryFileSystem(); + store = createResponseBodyStore({ + fs, + spillDir, + idGen: () => `body-${++idSeq}` + }); + }); + + const streamFrom = (data) => Readable.from([Buffer.from(data)]); + const spillPath = (bodyRef) => path.join(spillDir, bodyRef); + + test('dual-writes small bodies to memory and file', async () => { + const result = await store.ingestStream(streamFrom('hello world'), { + contentType: 'text/plain' + }); + + expect(result).toMatchObject({ + bodyRef: 'body-1', + size: 11 + }); + expect(fs.existsSync(spillPath('body-1'))).toBe(true); + expect(store.getBufferForScripts(result.bodyRef)).toEqual(Buffer.from('hello world')); + expect(await store.readRange(result.bodyRef, 0, 5)).toEqual(Buffer.from('hello')); + expect(store.getFilePath(result.bodyRef)).toBe(spillPath('body-1')); + }); + + test('dual-writes large streams to memory and file', async () => { + const payload = 'x'.repeat(150); + const result = await store.ingestStream(streamFrom(payload)); + + expect(result.size).toBe(150); + expect(fs.existsSync(spillPath('body-1'))).toBe(true); + expect(store.getBufferForScripts(result.bodyRef)).toEqual(Buffer.from(payload)); + expect(await store.readRange(result.bodyRef, 0, 10)).toEqual(Buffer.from('x'.repeat(10))); + expect(await store.readRange(result.bodyRef, 140, 20)).toEqual(Buffer.from('x'.repeat(10))); + }); + + test('putBuffer always writes file and keeps buffer', async () => { + const result = await store.putBuffer(Buffer.from('y'.repeat(120))); + expect(result.bodyRef).toBe('body-1'); + expect(fs.existsSync(spillPath('body-1'))).toBe(true); + expect(store.getBufferForScripts(result.bodyRef)).toEqual(Buffer.from('y'.repeat(120))); + expect(await store.readRange(result.bodyRef)).toEqual(Buffer.from('y'.repeat(120))); + }); + + test('saveToPath copies from file', async () => { + const outMem = path.join(path.sep, 'out', 'mem.txt'); + const outFile = path.join(path.sep, 'out', 'file.txt'); + const mem = await store.putBuffer(Buffer.from('abc')); + await store.saveToPath(mem.bodyRef, outMem); + expect(await fs.readFile(outMem)).toEqual(Buffer.from('abc')); + + const file = await store.putBuffer(Buffer.from('z'.repeat(150))); + await store.saveToPath(file.bodyRef, outFile); + expect(await fs.readFile(outFile)).toEqual(Buffer.from('z'.repeat(150))); + }); + + test('discardBuffer drops RAM but readRange still reads from file', async () => { + const { bodyRef } = await store.putBuffer(Buffer.from('spilled')); + store.discardBuffer(bodyRef); + expect(() => store.getBufferForScripts(bodyRef)).toThrow(/discarded/); + expect(store.getStat(bodyRef).size).toBe(7); + expect(await store.readRange(bodyRef)).toEqual(Buffer.from('spilled')); + }); + + test('scripts can read dual-written bodies of any size', async () => { + const { bodyRef } = await store.putBuffer(Buffer.from('z'.repeat(150))); + expect(store.getBufferForScripts(bodyRef)).toEqual(Buffer.from('z'.repeat(150))); + }); + + test('missing bodyRef throws BodyNotFoundError', async () => { + expect(() => store.getStat('missing')).toThrow(BodyNotFoundError); + }); + + test('readRange at EOF returns empty buffer', async () => { + const { bodyRef } = await store.putBuffer(Buffer.from('abc')); + expect(await store.readRange(bodyRef, 10, 5)).toEqual(Buffer.alloc(0)); + }); + + test('ingestStream cleans up partial file on mid-stream error', async () => { + let chunkCount = 0; + const failing = new Readable({ + read() { + chunkCount += 1; + if (chunkCount === 1) { + this.push(Buffer.from('x'.repeat(120))); + return; + } + this.destroy(new Error('boom')); + } + }); + + await expect(store.ingestStream(failing)).rejects.toThrow('boom'); + expect(fs.existsSync(spillPath('body-1'))).toBe(false); + expect(() => store.getStat('body-1')).toThrow(BodyNotFoundError); + }); +}); diff --git a/packages/bruno-electron/src/utils/response-save-filename.js b/packages/bruno-electron/src/utils/response-save-filename.js new file mode 100644 index 00000000000..e2ed4df1f15 --- /dev/null +++ b/packages/bruno-electron/src/utils/response-save-filename.js @@ -0,0 +1,48 @@ +const path = require('node:path'); +const contentDispositionParser = require('content-disposition'); +const mime = require('mime-types'); + +const getHeaderValue = (headers, headerName) => { + const headersObj = headers && typeof headers === 'object' ? headers : {}; + const entry = Object.entries(headersObj).find(([name]) => name === headerName); + return entry ? entry[1] : undefined; +}; + +/** + * Resolve a default download filename from response headers / request URL. + */ +const resolveResponseSaveFilename = ({ headers, url } = {}) => { + try { + const disposition = contentDispositionParser.parse(getHeaderValue(headers, 'content-disposition')); + if (disposition?.parameters?.filename) { + return disposition.parameters.filename; + } + } catch (_) { + /* ignore */ + } + + try { + const lastPathLevel = new URL(url).pathname.split('/').pop(); + if (lastPathLevel && /\..+/.exec(lastPathLevel)) { + return lastPathLevel; + } + } catch (_) { + /* ignore */ + } + + const contentType = getHeaderValue(headers, 'content-type'); + const extension = (contentType && mime.extension(contentType)) || 'txt'; + return `response.${extension}`; +}; + +const resolveResponseSaveDefaultPath = ({ headers, url, pathname } = {}) => { + const fileName = resolveResponseSaveFilename({ headers, url }); + if (!pathname) return fileName; + return path.join(path.dirname(pathname), fileName); +}; + +module.exports = { + getHeaderValue, + resolveResponseSaveFilename, + resolveResponseSaveDefaultPath +}; diff --git a/packages/bruno-electron/src/utils/response-save-filename.spec.js b/packages/bruno-electron/src/utils/response-save-filename.spec.js new file mode 100644 index 00000000000..7d352e5edee --- /dev/null +++ b/packages/bruno-electron/src/utils/response-save-filename.spec.js @@ -0,0 +1,41 @@ +const { + resolveResponseSaveFilename, + resolveResponseSaveDefaultPath +} = require('./response-save-filename'); + +describe('resolveResponseSaveFilename', () => { + test('prefers content-disposition filename', () => { + expect( + resolveResponseSaveFilename({ + headers: { 'content-disposition': 'attachment; filename="report.json"' }, + url: 'https://example.com/ignored.bin' + }) + ).toBe('report.json'); + }); + + test('falls back to url path then content-type', () => { + expect( + resolveResponseSaveFilename({ + headers: {}, + url: 'https://example.com/files/data.csv' + }) + ).toBe('data.csv'); + + expect( + resolveResponseSaveFilename({ + headers: { 'content-type': 'application/json' }, + url: 'https://example.com/api' + }) + ).toBe('response.json'); + }); + + test('resolveResponseSaveDefaultPath joins pathname directory', () => { + expect( + resolveResponseSaveDefaultPath({ + headers: { 'content-type': 'text/plain' }, + url: 'https://example.com/x', + pathname: '/collections/demo/req.bru' + }) + ).toMatch(/response\.txt$/); + }); +}); diff --git a/packages/bruno-js/src/bruno-response.js b/packages/bruno-js/src/bruno-response.js index 7757066d16e..08e44af8648 100644 --- a/packages/bruno-js/src/bruno-response.js +++ b/packages/bruno-js/src/bruno-response.js @@ -43,6 +43,9 @@ class BrunoResponse { } getBody() { + if (this.res?.scriptBodyError) { + throw new Error(this.res.scriptBodyError); + } return this.res ? this.res.data : null; } @@ -118,6 +121,9 @@ class BrunoResponse { } getDataBuffer() { + if (this.res?.scriptBodyError) { + throw new Error(this.res.scriptBodyError); + } return this.res ? this.res.dataBuffer : null; } } diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js index d2c807f02fa..9c8fb8017eb 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js @@ -97,7 +97,11 @@ const addBrunoResponseShimToContext = (vm, res) => { getHeaders.dispose(); let getBody = vm.newFunction('getBody', function () { - return marshallToVm(res.getBody(), vm); + try { + return marshallToVm(res.getBody(), vm); + } catch (error) { + throw vm.newError(error.message || String(error)); + } }); vm.setProp(resFn, 'getBody', getBody); getBody.dispose(); diff --git a/packages/bruno-js/tests/bruno-response-get-body.spec.js b/packages/bruno-js/tests/bruno-response-get-body.spec.js new file mode 100644 index 00000000000..f8537f2652c --- /dev/null +++ b/packages/bruno-js/tests/bruno-response-get-body.spec.js @@ -0,0 +1,37 @@ +const BrunoResponse = require('../src/bruno-response'); + +describe('BrunoResponse getBody script size gate', () => { + it('returns data when scriptBodyError is absent', () => { + const res = new BrunoResponse({ + status: 200, + statusText: 'OK', + headers: {}, + data: { ok: true } + }); + expect(res.getBody()).toEqual({ ok: true }); + }); + + it('throws scriptBodyError from getBody', () => { + const message = 'Response body is too large to use in scripts (52428801 bytes; max 52428800). Download the response instead.'; + const res = new BrunoResponse({ + status: 200, + statusText: 'OK', + headers: {}, + data: undefined, + scriptBodyError: message + }); + expect(() => res.getBody()).toThrow(message); + }); + + it('throws scriptBodyError from getDataBuffer', () => { + const message = 'Response body is too large to use in scripts'; + const res = new BrunoResponse({ + status: 200, + statusText: 'OK', + headers: {}, + data: undefined, + scriptBodyError: message + }); + expect(() => res.getDataBuffer()).toThrow(message); + }); +}); diff --git a/packages/bruno-requests/src/scripting/scripted-entry.spec.ts b/packages/bruno-requests/src/scripting/scripted-entry.spec.ts index c0959805d4d..3deaf88dcaa 100644 --- a/packages/bruno-requests/src/scripting/scripted-entry.spec.ts +++ b/packages/bruno-requests/src/scripting/scripted-entry.spec.ts @@ -62,7 +62,7 @@ describe('buildScriptedEntry', () => { expect(entry.response?.headers).toEqual({ 'content-type': 'application/json', 'x-trace': 'abc' }); }); - test('encodes string body to base64 dataBuffer and derives size/duration when not supplied', () => { + test('derives size/duration from data when not supplied (no dataBuffer)', () => { const entry = buildScriptedEntry({ request: { method: 'GET', url: 'https://example.com' }, response: { status: 200, statusText: 'OK', headers: {}, data: 'hello' }, @@ -71,12 +71,14 @@ describe('buildScriptedEntry', () => { completedAt: 15 }); - expect(entry.response?.dataBuffer).toBe(Buffer.from('hello').toString('base64')); + expect(entry.response?.dataBuffer).toBeUndefined(); + expect(entry.response?.data).toBe('hello'); + expect(entry.response?.bodyRef).toBeNull(); expect(entry.response?.size).toBe(Buffer.from('hello').length); expect(entry.response?.duration).toBe(10); }); - test('JSON-stringifies object body for dataBuffer when not provided', () => { + test('JSON object body keeps data and derives size without dataBuffer', () => { const body = { foo: 'bar' }; const entry = buildScriptedEntry({ request: { method: 'GET', url: 'https://example.com' }, @@ -86,19 +88,20 @@ describe('buildScriptedEntry', () => { completedAt: 0 }); - expect(entry.response?.dataBuffer).toBe(Buffer.from(JSON.stringify(body)).toString('base64')); + expect(entry.response?.dataBuffer).toBeUndefined(); + expect(entry.response?.data).toEqual(body); + expect(entry.response?.size).toBe(Buffer.byteLength(JSON.stringify(body))); }); - test('honors explicit dataBuffer / size / duration on response', () => { - const explicitBuffer = Buffer.from('payload').toString('base64'); + test('honors bodyRef / size / duration on response and never embeds dataBuffer', () => { const entry = buildScriptedEntry({ request: { method: 'GET', url: 'https://example.com' }, response: { status: 200, statusText: 'OK', headers: {}, - data: 'ignored-for-size', - dataBuffer: explicitBuffer, + data: undefined, + bodyRef: 'body-abc', size: 999, duration: 123 }, @@ -107,7 +110,8 @@ describe('buildScriptedEntry', () => { completedAt: 50 }); - expect(entry.response?.dataBuffer).toBe(explicitBuffer); + expect(entry.response?.dataBuffer).toBeUndefined(); + expect(entry.response?.bodyRef).toBe('body-abc'); expect(entry.response?.size).toBe(999); expect(entry.response?.duration).toBe(123); }); diff --git a/packages/bruno-requests/src/scripting/send-request.ts b/packages/bruno-requests/src/scripting/send-request.ts index 55fcc145d4b..ed8a074eac8 100644 --- a/packages/bruno-requests/src/scripting/send-request.ts +++ b/packages/bruno-requests/src/scripting/send-request.ts @@ -20,7 +20,7 @@ type SendRequestEntry = { statusText: string; headers: Record; data: any; - dataBuffer: string; + bodyRef: string | null; size: number; duration: number; timeline: any[] | null; @@ -42,7 +42,7 @@ type ScriptedEntryResponseInput = { statusText?: string; headers?: any; data?: any; - dataBuffer?: string; + bodyRef?: string | null; size?: number; duration?: number; timeline?: any[]; @@ -68,20 +68,20 @@ const toPlainHeaders = (headers: any): Record => { return out; }; -// Build dataBuffer eagerly so the Timeline's CodeMirror can size itself on mount. -const toResponseDataBuffer = (data: any): string => { +const deriveSizeFromData = (data: any): number => { try { - if (data === null || data === undefined) return ''; - if (typeof data === 'string') return Buffer.from(data).toString('base64'); - if (Buffer.isBuffer(data)) return data.toString('base64'); - if (data instanceof ArrayBuffer) return Buffer.from(new Uint8Array(data)).toString('base64'); - return Buffer.from(JSON.stringify(data)).toString('base64'); + if (data === null || data === undefined) return 0; + if (typeof data === 'string') return Buffer.byteLength(data); + if (Buffer.isBuffer(data)) return data.length; + if (data instanceof ArrayBuffer) return data.byteLength; + return Buffer.byteLength(JSON.stringify(data)); } catch (_) { - return ''; + return 0; } }; // Shared with bruno-electron's runRequest so both produce identical entries. +// Never embed dataBuffer — use bodyRef for store-backed bodies, otherwise inline `data` only. const buildScriptedEntry = ({ request, response, @@ -91,16 +91,16 @@ const buildScriptedEntry = ({ }: BuildScriptedEntryArgs): SendRequestEntry => { let respPayload: SendRequestEntry['response'] = null; if (response) { - const dataBuffer = response.dataBuffer ?? toResponseDataBuffer(response.data); + const bodyRef = response.bodyRef || null; respPayload = { statusCode: typeof response.status === 'number' ? response.status : 0, statusText: response.statusText ?? '', headers: toPlainHeaders(response.headers), data: response.data, - dataBuffer, + bodyRef, size: typeof response.size === 'number' ? response.size - : (dataBuffer ? Buffer.from(dataBuffer, 'base64').length : 0), + : deriveSizeFromData(response.data), duration: typeof response.duration === 'number' ? response.duration : (completedAt - startedAt), diff --git a/packages/bruno-tests/src/large-payload/index.js b/packages/bruno-tests/src/large-payload/index.js index 4b7e928dd55..ccf8facb448 100644 --- a/packages/bruno-tests/src/large-payload/index.js +++ b/packages/bruno-tests/src/large-payload/index.js @@ -3,16 +3,64 @@ const router = express.Router(); const FILLER = 'bruno large payload '; const DEFAULT_SIZE = 1024 * 1024; -const MAX_SIZE = 64 * 1024 * 1024; +const MAX_SIZE = 128 * 1024 * 1024; -// Responds with `?size` bytes of plain text (default 1 MB, clamped to 0-64 MB). -// Useful for exercising large-response handling in the app and the sandboxes. +const clampSize = (requested) => { + const n = parseInt(requested, 10); + return Math.min(Math.max(Number.isNaN(n) ? DEFAULT_SIZE : n, 0), MAX_SIZE); +}; + +const textPayload = (size) => FILLER.repeat(Math.ceil(size / FILLER.length)).slice(0, size); + +// Responds with `?size` bytes of plain text (default 1 MB, clamped to 0-128 MB). router.get('/', (req, res) => { - const requested = parseInt(req.query.size, 10); - const size = Math.min(Math.max(Number.isNaN(requested) ? DEFAULT_SIZE : requested, 0), MAX_SIZE); + const size = clampSize(req.query.size); + res.setHeader('content-type', 'text/plain; charset=utf-8'); + res.send(textPayload(size)); +}); + +// JSON body of approximately `?size` bytes. +router.get('/json', (req, res) => { + const size = clampSize(req.query.size); + const prefix = '{"ok":true,"payload":"'; + const suffix = '"}'; + const inner = Math.max(0, size - prefix.length - suffix.length); + res.setHeader('content-type', 'application/json; charset=utf-8'); + res.send(prefix + 'x'.repeat(inner) + suffix); +}); + +// Deterministic binary (application/octet-stream) for protocol / download tests. +router.get('/bytes', (req, res) => { + const size = clampSize(req.query.size); + const buf = Buffer.alloc(size, 0xab); + res.setHeader('content-type', 'application/octet-stream'); + res.setHeader('content-length', String(size)); + res.end(buf); +}); + +// Minimal valid-ish PDF header + padding for media protocol preview. +router.get('/pdf', (req, res) => { + const size = clampSize(req.query.size || 1024); + const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n'); + const body = Buffer.alloc(Math.max(0, size - header.length), 0x20); + const buf = Buffer.concat([header, body]); + res.setHeader('content-type', 'application/pdf'); + res.setHeader('content-length', String(buf.length)); + res.end(buf); +}); - res.setHeader('content-type', 'text/plain'); - res.send(FILLER.repeat(Math.ceil(size / FILLER.length)).slice(0, size)); +// Tiny PNG (1x1) — optional size pads trailing bytes (invalid PNG but fine for protocol smoke). +router.get('/png', (req, res) => { + // 1x1 red PNG + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' + ); + const size = clampSize(req.query.size || png.length); + const buf = size <= png.length ? png : Buffer.concat([png, Buffer.alloc(size - png.length, 0)]); + res.setHeader('content-type', 'image/png'); + res.setHeader('content-length', String(buf.length)); + res.end(buf); }); module.exports = router; diff --git a/tests/response/large-response-crash-prevention.spec.ts b/tests/response/large-response-crash-prevention.spec.ts index ccb03e9bb7d..b0a825008b3 100644 --- a/tests/response/large-response-crash-prevention.spec.ts +++ b/tests/response/large-response-crash-prevention.spec.ts @@ -1,40 +1,60 @@ import { test, expect } from '../../playwright'; -import { closeAllCollections, createCollection, createRequest } from '../utils/page/actions'; +import { closeAllCollections, createCollection, createRequest, sendRequest } from '../utils/page/actions'; -test.describe('Large Response Crash/High Memory Usage Prevention', () => { - // Increase timeout to 1 minute for all tests in this describe block, default is 30 seconds. - // Prevents tests from failing due to timeout while waiting for the response, especially on slower internet connections. - test.setTimeout(1 * 60 * 1000); // 1 minute +const UNDER_10MB_URL = 'http://localhost:8081/api/large-payload?size=5242880'; // 5 MB +const BETWEEN_10_50MB_URL = 'http://localhost:8081/api/large-payload?size=15728640'; // 15 MB +const OVER_50MB_URL = 'http://localhost:8081/api/large-payload?size=57671680'; // ~55 MB + +test.describe('Large response handling', () => { + test.setTimeout(3 * 60 * 1000); test.afterAll(async ({ page }) => { - // cleanup: close all collections await closeAllCollections(page); }); - test('Show appropriate warning for responses over 10MB', async ({ page, createTmpDir }) => { - const collectionName = 'size-warning-test'; - const requestName = 'large-response'; + test('under 10MB text shows in response pane without Large Response Warning', async ({ page, createTmpDir }) => { + const collectionName = 'large-under-show'; + await createCollection(page, collectionName, await createTmpDir(collectionName)); + await createRequest(page, 'small-large', collectionName, { url: UNDER_10MB_URL }); + + await sendRequest(page, 200); + + await expect(page.getByText('Large Response Warning')).toHaveCount(0); + await expect(page.getByTestId('response-preview-container')).toBeVisible({ timeout: 90000 }); + await expect(page.getByTestId('response-preview-container')).toContainText('bruno large payload', { timeout: 90000 }); + }); - // Create collection (auto-opens the collection) + test('10–50MB text shows Large Response Warning with View and Download', async ({ page, createTmpDir }) => { + const collectionName = 'large-viewable'; await createCollection(page, collectionName, await createTmpDir(collectionName)); + await createRequest(page, 'mid-large', collectionName, { url: BETWEEN_10_50MB_URL }); + + await sendRequest(page, 200); - // Create request using the dialog/modal flow - await createRequest(page, requestName, collectionName, { - url: 'https://samples.json-format.com/employees/json/employees_50MB.json' - }); + await expect(page.getByText('Large Response Warning')).toBeVisible({ timeout: 120000 }); + const viewBtn = page.getByRole('button', { name: /^View$/i }); + await expect(viewBtn).toBeVisible(); + await expect(viewBtn).toBeEnabled(); + await expect(page.getByTestId('large-response-download-btn')).toBeVisible(); + await expect(page.getByTestId('response-preview-container')).toHaveCount(0); - // Send request - const sendButton = page.getByTestId('send-arrow-icon'); - await sendButton.click(); + await viewBtn.click(); + await expect(page.getByTestId('response-preview-container')).toBeVisible({ timeout: 90000 }); + await expect(page.getByTestId('response-preview-container')).toContainText('bruno large payload', { timeout: 90000 }); + }); - // Verify warning appears - await expect(page.getByText('Large Response Warning')).toBeVisible({ timeout: 60000 }); + test('over 50MB text shows Large Response Warning with Download only', async ({ page, createTmpDir }) => { + const collectionName = 'large-too-big'; + await createCollection(page, collectionName, await createTmpDir(collectionName)); + await createRequest(page, 'huge-text', collectionName, { url: OVER_50MB_URL }); - // Verify warning content - await expect(page.getByText('Handling responses over')).toBeVisible(); - await expect(page.getByText('could degrade performance')).toBeVisible(); + await sendRequest(page, 200); - // Verify action button - await expect(page.getByRole('button', { name: 'View', exact: true })).toBeVisible(); + await expect(page.getByText('Large Response Warning')).toBeVisible({ timeout: 120000 }); + await expect(page.getByTestId('large-response-download-btn')).toBeVisible(); + const viewBtn = page.getByRole('button', { name: /^View$/i }); + await expect(viewBtn).toBeVisible(); + await expect(viewBtn).toBeDisabled(); + await expect(page.getByTestId('response-preview-container')).toHaveCount(0); }); }); diff --git a/tests/response/large-response-media.spec.ts b/tests/response/large-response-media.spec.ts new file mode 100644 index 00000000000..141791a8d96 --- /dev/null +++ b/tests/response/large-response-media.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '../../playwright'; +import { closeAllCollections, createCollection, createRequest, sendRequest } from '../utils/page/actions'; + +test.describe('Large response media preview', () => { + test.setTimeout(2 * 60 * 1000); + + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + test('PNG preview uses protocol path without Large Response Warning', async ({ page, createTmpDir }) => { + const collectionName = 'large-media-png'; + await createCollection(page, collectionName, await createTmpDir(collectionName)); + await createRequest(page, 'png', collectionName, { + url: 'http://localhost:8081/api/large-payload/png' + }); + + await sendRequest(page, 200); + + await expect(page.getByText('Large Response Warning')).toHaveCount(0); + await expect(page.getByTestId('response-preview-container')).toBeVisible({ timeout: 60000 }); + // Image preview should render an img (protocol or data URL) + await expect(page.getByTestId('response-preview-container').locator('img')).toBeVisible({ timeout: 60000 }); + }); +});