From fc5093eab4ca2bc7e14f4a3f3dabf7e3300699aa Mon Sep 17 00:00:00 2001 From: DaviXavier <47609623+davirxavier@users.noreply.github.com> Date: Wed, 12 Nov 2025 07:26:26 -0300 Subject: [PATCH 1/3] fix: #1884 - Fixes infinite loading issue for text/event-stream requests (#4472) * #1884 - Add support for text/event-stream content-type * #1884 - Fix bugs with streaming Fix bug when streaming response is not ok Fix bug when clearing response of streaming request Show text signaling that the response is being streamed in the reponse status Update response size when new data is streamed in * #1884 - Fix multiple requests when spamming send button * #1884 - Add time counter for streamed response and fix final time * #1884 - Run post script only at end of streamed request * #1884 - add support for automatic "upgrade" to streaming data * #1884 - adjustments for stopwatch in stream implementation and remove unused imports * #1884 - fix imports indentation in useIpcEvents.js * #1884 - remove stream data ended export function from collections --------- Co-authored-by: Siddharth Gelera --- .../components/RequestPane/QueryUrl/index.js | 12 +- .../src/components/RequestTabPanel/index.js | 31 +- .../ResponseStopWatch/StyledWrapper.js | 10 + .../ResponsePane/ResponseStopWatch/index.js | 27 + .../ResponsePane/StatusCode/index.js | 4 +- .../src/components/ResponsePane/index.js | 29 +- .../src/providers/App/useIpcEvents.js | 18 +- .../ReduxStore/slices/collections/index.js | 130 +++-- packages/bruno-app/src/utils/network/index.js | 63 ++- .../bruno-electron/src/ipc/network/index.js | 475 ++++++++++-------- 10 files changed, 472 insertions(+), 327 deletions(-) create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/index.js diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js index bcfd07f24..2c0fb3dd1 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js @@ -5,7 +5,7 @@ import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/sli import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import HttpMethodSelector from './HttpMethodSelector'; import { useTheme } from 'providers/Theme'; -import { IconDeviceFloppy, IconArrowRight, IconCode } from '@tabler/icons'; +import { IconDeviceFloppy, IconArrowRight, IconCode, IconX } from '@tabler/icons'; import SingleLineEditor from 'components/SingleLineEditor'; import { isMacOS } from 'utils/common/platform'; import { hasRequestChanges } from 'utils/collections'; @@ -87,7 +87,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
gRPC
- + ) : ( )} @@ -149,7 +149,13 @@ const QueryUrl = ({ item, collection, handleRun }) => { Save ({saveShortcut}) - + { + item.response?.hasStreamRunning ? ( + + ) : ( + + ) + } {generateCodeItemModalOpen && ( diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index f7880e509..a347c1d0a 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -10,7 +10,7 @@ import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane'; import Welcome from 'components/Welcome'; import { findItemInCollection } from 'utils/collections'; import { updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs'; -import { sendRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import RequestNotFound from './RequestNotFound'; import QueryUrl from 'components/RequestPane/QueryUrl/index'; import GrpcQueryUrl from 'components/RequestPane/GrpcQueryUrl/index'; @@ -74,8 +74,7 @@ const RequestTabPanel = () => { const screenWidth = useSelector((state) => state.app.screenWidth); let asideWidth = useSelector((state) => state.app.leftSidebarWidth); const [leftPaneWidth, setLeftPaneWidth] = useState( - focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2 - ); // 2.2 is intentional to make both panes appear to be of equal width + focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2); // 2.2 is intentional to make both panes appear to be of equal width const [topPaneHeight, setTopPaneHeight] = useState(focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT); const [dragging, setDragging] = useState(false); const dragOffset = useRef({ x: 0, y: 0 }); @@ -141,12 +140,10 @@ const RequestTabPanel = () => { setDragging(false); if (!isVerticalLayout) { const mainRect = mainSectionRef.current.getBoundingClientRect(); - dispatch( - updateRequestPaneTabWidth({ - uid: activeTabUid, - requestPaneWidth: e.clientX - mainRect.left - }) - ); + dispatch(updateRequestPaneTabWidth({ + uid: activeTabUid, + requestPaneWidth: e.clientX - mainRect.left + })); } } }; @@ -263,11 +260,17 @@ const RequestTabPanel = () => { return; } - dispatch(sendRequest(item, collection.uid)).catch((err) => - toast.custom((t) => toast.dismiss(t.id)} />, { - duration: 5000 - }) - ); + if (item.response?.hasStreamRunning) { + dispatch(cancelRequest(item.cancelTokenUid, item, collection)).catch((err) => + toast.custom((t) => toast.dismiss(t.id)} />, { + duration: 5000 + })); + } else if (item.requestState !== 'sending' && item.requestState !== 'queued') { + dispatch(sendRequest(item, collection.uid)).catch((err) => + toast.custom((t) => toast.dismiss(t.id)} />, { + duration: 5000 + })); + } }; // TODO: reaper, improve selection of panes diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/StyledWrapper.js new file mode 100644 index 000000000..c2fe19cfb --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/StyledWrapper.js @@ -0,0 +1,10 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + font-size: 0.75rem; + font-weight: 600; + color: ${(props) => props.theme.requestTabPanel.responseStatus}; + text-align: center; +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/index.js new file mode 100644 index 000000000..ef96bc891 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/index.js @@ -0,0 +1,27 @@ +import React, { useState, useEffect } from 'react'; +import StyledWrapper from './StyledWrapper'; + +const ResponseStopWatch = ({ startMillis }) => { + const [milliseconds, setMilliseconds] = useState(startMillis); + + const tickInterval = 100; + const tick = () => { + setMilliseconds(_milliseconds => _milliseconds + tickInterval); + }; + + useEffect(() => { + let timerID = setInterval(() => { + tick() + }, tickInterval); + return () => { + clearTimeout(timerID); + }; + }, []); + + let seconds = milliseconds / 1000; + let secondsFormatted = `${seconds.toFixed(1)}s`; + let width = secondsFormatted.length * 0.4; // Calculate width manually to stop parent layout from "flickering" by changing width too fast + return {secondsFormatted}; +}; + +export default React.memo(ResponseStopWatch); diff --git a/packages/bruno-app/src/components/ResponsePane/StatusCode/index.js b/packages/bruno-app/src/components/ResponsePane/StatusCode/index.js index 222aad9e8..302b9a5e2 100644 --- a/packages/bruno-app/src/components/ResponsePane/StatusCode/index.js +++ b/packages/bruno-app/src/components/ResponsePane/StatusCode/index.js @@ -4,7 +4,7 @@ import statusCodePhraseMap from './get-status-code-phrase'; import StyledWrapper from './StyledWrapper'; // Todo: text-error class is not getting pulled in for 500 errors -const StatusCode = ({ status, statusText }) => { +const StatusCode = ({ status, statusText, isStreaming }) => { const getTabClassname = (status) => { return classnames('ml-2', { 'text-ok': status >= 100 && status < 200, @@ -17,7 +17,7 @@ const StatusCode = ({ status, statusText }) => { return ( - {status} {statusText || statusCodePhraseMap[status]} + {status} {statusText || statusCodePhraseMap[status]} {isStreaming ? ' - STREAMING' : null} ); }; diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js index 6feb98bdb..2f173280d 100644 --- a/packages/bruno-app/src/components/ResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/index.js @@ -21,6 +21,8 @@ import ResponseClear from 'src/components/ResponsePane/ResponseClear'; import ResponseBookmark from 'src/components/ResponsePane/ResponseBookmark'; import SkippedRequest from './SkippedRequest'; import ClearTimeline from './ClearTimeline/index'; +import StopWatch from 'components/StopWatch'; +import ResponseStopWatch from 'components/ResponsePane/ResponseStopWatch'; import ResponseLayoutToggle from './ResponseLayoutToggle'; import HeightBoundContainer from 'ui/HeightBoundContainer'; @@ -87,15 +89,17 @@ const ResponsePane = ({ item, collection }) => { return ; } case 'timeline': { - return ; + return ; } case 'tests': { - return ; + return ( + + ); } default: { @@ -184,7 +188,10 @@ const ResponsePane = ({ item, collection }) => { - + + {item.response?.hasStreamRunning ? ( + + ) : } @@ -193,7 +200,7 @@ const ResponsePane = ({ item, collection }) => { ) : null}
{ onClose={() => setShowScriptErrorCard(false)} /> )} -
+
{!item?.response ? ( - focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? ( + focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? ( { dispatch(processEnvUpdateEvent(val)); }); - const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => { - console[val.type](...val.args); + const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => { + console[val.type](...val.args); dispatch(addLog({ type: val.type, args: val.args, @@ -188,6 +190,14 @@ const useIpcEvents = () => { dispatch(collectionAddOauth2CredentialsByUrl(payload)); }); + const removeHttpStreamNewDataListener = ipcRenderer.on('main:http-stream-new-data', (val) => { + dispatch(streamDataReceived(val)); + }); + + const removeHttpStreamEndListener = ipcRenderer.on('main:http-stream-end', (val) => { + dispatch(requestCancelled(val)); + }); + const removeCollectionLoadingStateListener = ipcRenderer.on('main:collection-loading-state-updated', (val) => { dispatch(updateCollectionLoadingState(val)); }); @@ -212,6 +222,8 @@ const useIpcEvents = () => { removeGlobalEnvironmentsUpdatesListener(); removeSnapshotHydrationListener(); removeCollectionOauth2CredentialsUpdatesListener(); + removeHttpStreamNewDataListener(); + removeHttpStreamEndListener(); removeCollectionLoadingStateListener(); removePersistentEnvVariablesUpdateListener(); removeSystemResourcesListener(); 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 e6c7ae880..eafba26bd 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -83,8 +83,8 @@ const initiatedGrpcResponse = { isError: false, duration: 0, responses: [], - timestamp: Date.now(), -} + timestamp: Date.now() +}; const initiatedWsResponse = { status: 'PENDING', @@ -380,7 +380,15 @@ export const collectionsSlice = createSlice({ if (collection) { const item = findItemInCollection(collection, itemUid); if (item) { - item.response = null; + if (item.response?.hasStreamRunning) { + item.response.hasStreamRunning = null; + + const startTimestamp = item.requestSent.timestamp; + item.response.duration = startTimestamp ? Date.now() - startTimestamp : item.response.duration; + } else { + item.response = null; + } + item.cancelTokenUid = null; item.requestUid = null; item.requestStartTime = null; @@ -389,22 +397,22 @@ export const collectionsSlice = createSlice({ }, responseReceived: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); - + if (collection) { const item = findItemInCollection(collection, action.payload.itemUid); if (item) { item.requestState = 'received'; item.response = action.payload.response; - item.cancelTokenUid = null; + item.cancelTokenUid = item.response.hasStreamRunning ? item.cancelTokenUid : null; item.requestStartTime = null; if (!collection.timeline) { collection.timeline = []; } - + // Ensure timestamp is a number (milliseconds since epoch) - const timestamp = item?.requestSent?.timestamp instanceof Date - ? item.requestSent.timestamp.getTime() + const timestamp = item?.requestSent?.timestamp instanceof Date + ? item.requestSent.timestamp.getTime() : item?.requestSent?.timestamp || Date.now(); // Append the new timeline entry with numeric timestamp @@ -427,7 +435,7 @@ export const collectionsSlice = createSlice({ const { itemUid, collectionUid, eventType, eventData } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); if (!collection) return; - + const item = findItemInCollection(collection, itemUid); if (!item) return; const request = item.draft ? item.draft.request : item.request; @@ -447,7 +455,7 @@ export const collectionsSlice = createSlice({ } collection.timeline.push({ - type: "request", + type: 'request', eventType: eventType, // Add the specific gRPC event type collectionUid: collection.uid, folderUid: null, @@ -456,36 +464,34 @@ export const collectionsSlice = createSlice({ data: { request: eventData || item.requestSent || item.request, timestamp: Date.now(), - eventData: eventData, + eventData: eventData } }); - }, grpcResponseReceived: (state, action) => { const { itemUid, collectionUid, eventType, eventData } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); - + if (!collection) return; const item = findItemInCollection(collection, itemUid); if (!item) return; - + // Get current response state or create initial state - const currentResponse = item.response || initiatedGrpcResponse + const currentResponse = item.response || initiatedGrpcResponse; const timestamp = item?.requestSent?.timestamp; let updatedResponse = { ...currentResponse, duration: Date.now() - (timestamp || Date.now()) }; - // Process based on event type switch (eventType) { case 'response': const { error, res } = eventData; - + // Handle error if present if (error) { const errorCode = error.code || 2; // Default to UNKNOWN if no code - + updatedResponse.error = error.details || 'gRPC error occurred'; updatedResponse.statusCode = errorCode; updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN'; @@ -494,72 +500,72 @@ export const collectionsSlice = createSlice({ } // Add response to list - updatedResponse.responses = res - ? [...(currentResponse?.responses || []), res] + updatedResponse.responses = res + ? [...(currentResponse?.responses || []), res] : [...(currentResponse?.responses || [])]; break; - + case 'metadata': updatedResponse.headers = eventData.metadata; updatedResponse.metadata = eventData.metadata; break; - + case 'status': // Extract status info const statusCode = eventData.status?.code; const statusDetails = eventData.status?.details; const statusMetadata = eventData.status?.metadata; - + // Set status based on actual code and details updatedResponse.statusCode = statusCode; updatedResponse.statusText = grpcStatusCodes[statusCode] || 'UNKNOWN'; updatedResponse.statusDescription = statusDetails; updatedResponse.statusDetails = eventData.status; - + // Store trailers (status metadata) if (statusMetadata) { updatedResponse.trailers = statusMetadata; } - + // Handle error status (non-zero code) if (statusCode !== 0) { updatedResponse.isError = true; updatedResponse.error = statusDetails || `gRPC error with code ${statusCode} (${updatedResponse.statusText})`; } - + break; - + case 'error': // Extract error details const errorCode = eventData.error?.code || 2; // Default to UNKNOWN if no code const errorDetails = eventData.error?.details || eventData.error?.message; const errorMetadata = eventData.error?.metadata; - + updatedResponse.isError = true; updatedResponse.error = errorDetails || 'Unknown gRPC error'; updatedResponse.statusCode = errorCode; updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN'; updatedResponse.statusDescription = errorDetails; - + // Store error metadata as trailers if present if (errorMetadata) { updatedResponse.trailers = errorMetadata; } - + break; - + case 'end': - state.activeConnections = state.activeConnections.filter(id => id !== itemUid); + state.activeConnections = state.activeConnections.filter((id) => id !== itemUid); break; - + case 'cancel': updatedResponse.statusCode = 1; // CANCELLED updatedResponse.statusText = 'CANCELLED'; updatedResponse.statusDescription = 'Stream cancelled by client or server'; - state.activeConnections = state.activeConnections.filter(id => id !== itemUid); + state.activeConnections = state.activeConnections.filter((id) => id !== itemUid); break; } - + item.requestState = 'received'; item.response = updatedResponse; @@ -570,7 +576,7 @@ export const collectionsSlice = createSlice({ // Append the new timeline entry with specific gRPC event type collection.timeline.push({ - type: "request", + type: 'request', eventType: eventType, // Add the specific gRPC event type collectionUid: collection.uid, folderUid: null, @@ -580,7 +586,7 @@ export const collectionsSlice = createSlice({ request: item.requestSent || item.request, response: updatedResponse, eventData: eventData, // Store the original event data - timestamp: Date.now(), + timestamp: Date.now() } }); }, @@ -590,6 +596,12 @@ export const collectionsSlice = createSlice({ if (collection) { const item = findItemInCollection(collection, action.payload.itemUid); if (item) { + if (item.response && item.response.hasStreamRunning) { + item.response.data = ''; + item.response.size = 0; + return; + } + item.response = null; } } @@ -916,7 +928,7 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - const existingOtherParams = item.draft.request.params?.filter(p => p.type !== 'query') || []; + const existingOtherParams = item.draft.request.params?.filter((p) => p.type !== 'query') || []; const newQueryParams = map(params, ({ name = '', value = '', enabled = true }) => ({ uid: uuid(), name, @@ -930,9 +942,7 @@ export const collectionsSlice = createSlice({ // Update the request URL to reflect the new query params const parts = splitOnFirst(item.draft.request.url, '?'); - const query = stringifyQueryParams( - filter(item.draft.request.params, (p) => p.enabled && p.type === 'query') - ); + const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled && p.type === 'query')); // If there are enabled query params, append them to the URL if (query && query.length) { @@ -1163,7 +1173,7 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - item.draft.request.headers = map(action.payload.headers, ({name = '', value = '', enabled = true}) => ({ + item.draft.request.headers = map(action.payload.headers, ({ name = '', value = '', enabled = true }) => ({ uid: uuid(), name: name, value: value, @@ -1205,8 +1215,8 @@ export const collectionsSlice = createSlice({ if (!folder || !isItemAFolder(folder)) { return; } - - folder.root.request.headers = map(headers, ({name = '', value = '', enabled = true}) => ({ + + folder.root.request.headers = map(headers, ({ name = '', value = '', enabled = true }) => ({ uid: uuid(), name: name, value: value, @@ -1487,7 +1497,7 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - + switch (item.draft.request.body.mode) { case 'json': { item.draft.request.body.json = action.payload.content; @@ -1624,7 +1634,7 @@ export const collectionsSlice = createSlice({ const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (collection) { - const item = findItemInCollection(collection, action.payload.itemUid); + const item = findItemInCollection(collection, action.payload.itemUid); if (item && isItemARequest(item)) { if (!item.draft) { @@ -1875,7 +1885,7 @@ export const collectionsSlice = createSlice({ break; case 'ntlm': set(collection, 'draft.root.request.auth.ntlm', action.payload.content); - break; + break; case 'oauth2': set(collection, 'draft.root.request.auth.oauth2', action.payload.content); break; @@ -2604,7 +2614,7 @@ export const collectionsSlice = createSlice({ const { requestUid, itemUid, collectionUid } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); if (!collection) return; - + const item = findItemInCollection(collection, itemUid); if (!item) return; @@ -2637,7 +2647,7 @@ export const collectionsSlice = createSlice({ item.postResponseScriptErrorMessage = action.payload.errorMessage; } - if(type === 'test-script-execution') { + if (type === 'test-script-execution') { item.testScriptErrorMessage = action.payload.errorMessage; } @@ -2652,7 +2662,7 @@ export const collectionsSlice = createSlice({ if (type === 'request-sent') { const { cancelTokenUid, requestSent } = action.payload; item.requestSent = requestSent; - + // sometimes the response is received before the request-sent event arrives if (item.requestState === 'queued') { item.requestState = 'sending'; @@ -2669,12 +2679,12 @@ export const collectionsSlice = createSlice({ const { results } = action.payload; item.testResults = results; } - + if (type === 'test-results-pre-request') { const { results } = action.payload; item.preRequestTestResults = results; } - + if (type === 'test-results-post-response') { const { results } = action.payload; item.postResponseTestResults = results; @@ -2788,7 +2798,7 @@ export const collectionsSlice = createSlice({ if (collection) { collection.runnerResult = null; - collection.runnerTags = { include: [], exclude: [] } + collection.runnerTags = { include: [], exclude: [] }; collection.runnerTagsEnabled = false; collection.runnerConfiguration = null; } @@ -2927,7 +2937,7 @@ export const collectionsSlice = createSlice({ updateFolderAuthMode: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null; - + if (folder) { if (!folder.draft) { folder.draft = cloneDeep(folder.root); @@ -2936,7 +2946,16 @@ export const collectionsSlice = createSlice({ set(folder, 'draft.request.auth.mode', action.payload.mode); } }, + streamDataReceived: (state, action) => { + const { itemUid, collectionUid, data } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (collection) { + const item = findItemInCollection(collection, itemUid); + item.response.data = data.data + (item.response.data || ''); + item.response.size = data.data?.length + (item.response.size || 0); + } + }, addRequestTag: (state, action) => { const { tag, collectionUid, itemUid } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); @@ -2978,7 +2997,7 @@ export const collectionsSlice = createSlice({ updateCollectionTagsList: (state, action) => { const { collectionUid } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); - + if (collection) { collection.allTags = getUniqueTagsFromItems(collection.items); } @@ -3298,6 +3317,7 @@ export const { updateRequestDocs, updateFolderDocs, moveCollection, + streamDataReceived, collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByUrl, collectionGetOauth2CredentialsByUrl, diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index 7c1e3b74a..c788a1a93 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -20,7 +20,8 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV status: response.status, statusText: response.statusText, duration: response.duration, - timeline: response.timeline + timeline: response.timeline, + hasStreamRunning: response.hasStreamRunning }); }) .catch((err) => reject(err)); @@ -31,19 +32,17 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV export const sendGrpcRequest = async (item, collection, environment, runtimeVariables) => { return new Promise((resolve, reject) => { startGrpcRequest(item, collection, environment, runtimeVariables) - .then((initialState) => { - // Return an initial state object to update the UI - // The real response data will be handled by event listeners - resolve({ - ...initialState, - timeline: [] - }); - }) - .catch((err) => reject(err)); + .then((initialState) => { + // Return an initial state object to update the UI + // The real response data will be handled by event listeners + resolve({ + ...initialState, + timeline: [] + }); + }) + .catch((err) => reject(err)); }); -} - - +}; const sendHttpRequest = async (item, collection, environment, runtimeVariables) => { return new Promise((resolve, reject) => { @@ -83,19 +82,19 @@ export const startGrpcRequest = async (item, collection, environment, runtimeVar return new Promise((resolve, reject) => { const { ipcRenderer } = window; const request = item.draft ? item.draft : item; - + ipcRenderer.invoke('grpc:start-connection', { - request, - collection, - environment, + request, + collection, + environment, runtimeVariables }) - .then(() => { - resolve(); - }) - .catch(err => { - reject(err); - }); + .then(() => { + resolve(); + }) + .catch((err) => { + reject(err); + }); }); }; @@ -188,7 +187,7 @@ export const isGrpcConnectionActive = async (connectionId) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; ipcRenderer.invoke('grpc:is-connection-active', connectionId) - .then(response => { + .then((response) => { if (response.success) { resolve(response.isActive); } else { @@ -197,7 +196,7 @@ export const isGrpcConnectionActive = async (connectionId) => { resolve(false); } }) - .catch(err => { + .catch((err) => { console.error('Failed to check connection status:', err); // On error, assume the connection is not active resolve(false); @@ -215,14 +214,14 @@ export const isGrpcConnectionActive = async (connectionId) => { export const generateGrpcSampleMessage = async (methodPath, existingMessage = null, options = {}) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; - - ipcRenderer.invoke('grpc:generate-sample-message', { - methodPath, - existingMessage, - options + + ipcRenderer.invoke('grpc:generate-sample-message', { + methodPath, + existingMessage, + options }) - .then(resolve) - .catch(reject); + .then(resolve) + .catch(reject); }); }; diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 611182a27..14956c035 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -69,16 +69,44 @@ const getJsSandboxRuntime = (collection) => { return 'vm2'; }; -const configureRequest = async ( - collectionUid, +const isStream = (headers) => { + return headers.get('content-type') === 'text/event-stream'; +}; + +const promisifyStream = async (stream, abortController, closeOnFirst) => { + const chunks = []; + + return new Promise((resolve, reject) => { + const doResolve = () => { + const fullBuffer = Buffer.concat(chunks); + resolve(fullBuffer.buffer.slice(fullBuffer.byteOffset, fullBuffer.byteOffset + fullBuffer.byteLength)); + }; + + stream.on('data', (chunk) => { + chunks.push(chunk); + + if (closeOnFirst) { + doResolve(); + + if (abortController) { + abortController.abort(); + } + } + }); + + stream.on('close', doResolve); + stream.on('error', err => reject(err)); + }); +}; + +const configureRequest = async (collectionUid, collection, request, envVars, runtimeVariables, processEnvVars, collectionPath, - globalEnvironmentVariables -) => { + globalEnvironmentVariables) => { const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; if (!protocolRegex.test(request.url)) { request.url = `http://${request.url}`; @@ -97,7 +125,7 @@ const configureRequest = async ( // Get followRedirects setting, default to true for backward compatibility const followRedirects = request.settings?.followRedirects ?? true; - + // Get maxRedirects from request settings, fallback to request.maxRedirects, then default to 5 let requestMaxRedirects = request.settings?.maxRedirects ?? request.maxRedirects ?? 5; @@ -138,14 +166,12 @@ const configureRequest = async ( request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header' && credentials?.access_token) { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim(); - } - else { + } else { try { const url = new URL(request.url); url?.searchParams?.set(tokenQueryKey, credentials?.access_token); request.url = url?.toString(); - } - catch(error) {} + } catch (error) {} } break; case 'implicit': @@ -154,8 +180,7 @@ const configureRequest = async ( request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header') { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; - } - else { + } else { try { const url = new URL(request.url); url?.searchParams?.set(tokenQueryKey, credentials?.access_token); @@ -217,8 +242,7 @@ const configureRequest = async ( if (preferencesUtil.shouldSendCookies()) { const cookieString = getCookieStringForUrl(request.url); if (cookieString && typeof cookieString === 'string' && cookieString.length) { - const existingCookieHeaderName = Object.keys(request.headers).find( - name => name.toLowerCase() === 'cookie' + const existingCookieHeaderName = Object.keys(request.headers).find((name) => name.toLowerCase() === 'cookie' ); const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : ''; @@ -282,8 +306,7 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col // Precedence: runtimeVars > requestVariables > folderVars > envVars > collectionVariables > globalEnvironmentVars const processEnvVars = getProcessEnvVars(collection.uid); - const resolvedVars = merge( - {}, + const resolvedVars = merge({}, globalEnvironmentVars, collectionVariables, envVars, @@ -296,8 +319,7 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col ...processEnvVars } } - } - ); + }); const collectionRoot = collection?.draft?.root || collection?.root || {}; const request = prepareGqlIntrospectionRequest(endpoint, resolvedVars, _request, collectionRoot); @@ -314,16 +336,14 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col const collectionPath = collection.pathname; - const axiosInstance = await configureRequest( - collection.uid, + const axiosInstance = await configureRequest(collection.uid, collection, request, envVars, collection.runtimeVariables, processEnvVars, collectionPath, - collection.globalEnvironmentVariables - ); + collection.globalEnvironmentVariables); const response = await axiosInstance(request); @@ -358,10 +378,10 @@ const registerNetworkIpc = (mainWindow) => { }; const notifyScriptExecution = ({ - channel, // 'main:run-request-event' | 'main:run-folder-event' - basePayload, // request-level or runner-level identifiers - scriptType, // 'pre-request' | 'post-response' | 'test' - error // optional Error + channel, // 'main:run-request-event' | 'main:run-folder-event' + basePayload, // request-level or runner-level identifiers + scriptType, // 'pre-request' | 'post-response' | 'test' + error // optional Error }) => { mainWindow.webContents.send(channel, { type: `${scriptType}-script-execution`, @@ -370,8 +390,7 @@ const registerNetworkIpc = (mainWindow) => { }); }; - const runPreRequest = async ( - request, + const runPreRequest = async (request, requestUid, envVars, collectionPath, @@ -380,11 +399,10 @@ const registerNetworkIpc = (mainWindow) => { runtimeVariables, processEnvVars, scriptingConfig, - runRequestByItemPathname - ) => { + runRequestByItemPathname) => { // run pre-request script let scriptResult; - const collectionName = collection?.name + const collectionName = collection?.name; const requestScript = get(request, 'script.req'); if (requestScript?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); @@ -462,8 +480,7 @@ const registerNetworkIpc = (mainWindow) => { return scriptResult; }; - const runPostResponse = async ( - request, + const runPostResponse = async (request, response, requestUid, envVars, @@ -520,7 +537,7 @@ const registerNetworkIpc = (mainWindow) => { // run post-response script const responseScript = get(request, 'script.res'); let scriptResult; - const collectionName = collection?.name + const collectionName = collection?.name; if (responseScript?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); scriptResult = await scriptRuntime.runResponseScript( @@ -595,7 +612,9 @@ const registerNetworkIpc = (mainWindow) => { const abortController = new AbortController(); const request = await prepareRequest(item, collection, abortController); request.__bruno__executionMode = 'standalone'; + request.responseType = "stream"; const brunoConfig = getBrunoConfig(collectionUid, collection); + const scriptingConfig = get(brunoConfig, 'scripts', {}); scriptingConfig.runtime = getJsSandboxRuntime(collection); @@ -606,8 +625,7 @@ const registerNetworkIpc = (mainWindow) => { let preRequestScriptResult = null; let preRequestError = null; try { - preRequestScriptResult = await runPreRequest( - request, + preRequestScriptResult = await runPreRequest(request, requestUid, envVars, collectionPath, @@ -616,8 +634,7 @@ const registerNetworkIpc = (mainWindow) => { runtimeVariables, processEnvVars, scriptingConfig, - runRequestByItemPathname - ); + runRequestByItemPathname); } catch (error) { preRequestError = error; } @@ -642,16 +659,14 @@ const registerNetworkIpc = (mainWindow) => { if (preRequestError) { return Promise.reject(preRequestError); } - const axiosInstance = await configureRequest( - collectionUid, + const axiosInstance = await configureRequest(collectionUid, collection, request, envVars, runtimeVariables, processEnvVars, collectionPath, - collection.globalEnvironmentVariables - ); + collection.globalEnvironmentVariables); const { data: requestData, dataBuffer: requestDataBuffer } = parseDataFromRequest(request); @@ -668,8 +683,9 @@ const registerNetworkIpc = (mainWindow) => { method: request.method, headers: headersSent, data: requestData, - dataBuffer: requestDataBuffer - } + dataBuffer: requestDataBuffer, + timestamp: Date.now() + }; !runInBackground && mainWindow.webContents.send('main:run-request-event', { type: 'request-sent', @@ -695,6 +711,11 @@ const registerNetworkIpc = (mainWindow) => { try { /** @type {import('axios').AxiosResponse} */ response = await axiosInstance(request); + request.isStream = isStream(response.headers); + + if (!request.isStream) { + response.data = await promisifyStream(response.data); + } // Prevents the duration on leaking to the actual result responseTime = response.headers.get('request-duration'); @@ -719,6 +740,11 @@ const registerNetworkIpc = (mainWindow) => { // Prevents the duration on leaking to the actual result responseTime = response.headers.get('request-duration'); response.headers.delete('request-duration'); + + request.isStream = isStream(response.headers); + if (!request.isStream) { + response.data = await promisifyStream(response.data); + } } else { await executeRequestOnFailHandler(request, error); @@ -729,16 +755,21 @@ const registerNetworkIpc = (mainWindow) => { statusText: error.statusText, error: error.message || ERROR_OCCURRED_WHILE_EXECUTING_REQUEST, timeline: error.timeline - } + }; } } // Continue with the rest of the request lifecycle - post response vars, script, assertions, tests - const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson); - response.data = data; - response.dataBuffer = dataBuffer; + if (request.isStream) { + response.stream = response.data; + } + const { data, dataBuffer } = request.isStream + ? { data: '', dataBuffer: new ArrayBuffer(0) } + : parseDataFromResponse(response, request.__brunoDisableParsingResponseJson); + + response.data = data; response.responseTime = responseTime; // save cookies @@ -754,138 +785,140 @@ const registerNetworkIpc = (mainWindow) => { let postResponseScriptResult = null; let postResponseError = null; - try { - postResponseScriptResult = await runPostResponse( - request, - response, - requestUid, - envVars, - collectionPath, - collection, - collectionUid, - runtimeVariables, - processEnvVars, - scriptingConfig, - runRequestByItemPathname - ); - } catch (error) { - console.error('Post-response script error:', error); - postResponseError = error; - } - - if (postResponseScriptResult?.results) { - mainWindow.webContents.send('main:run-request-event', { - type: 'test-results-post-response', - results: postResponseScriptResult.results, - itemUid: item.uid, - requestUid, - collectionUid - }); - } - - !runInBackground && notifyScriptExecution({ - channel: 'main:run-request-event', - basePayload: { requestUid, collectionUid, itemUid: item.uid }, - scriptType: 'post-response', - error: postResponseError - }); - - // run assertions - const assertions = get(request, 'assertions'); - if (assertions) { - const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime }); - const results = assertRuntime.runAssertions( - assertions, - request, - response, - envVars, - runtimeVariables, - processEnvVars - ); - - !runInBackground && mainWindow.webContents.send('main:run-request-event', { - type: 'assertion-results', - results: results, - itemUid: item.uid, - requestUid, - collectionUid - }); - } - - const testFile = get(request, 'tests'); - const collectionName = collection?.name - if (typeof testFile === 'string') { - const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime }); - let testResults = null; - let testError = null; - + const runPostScripts = async () => { try { - testResults = await testRuntime.runTests( - decomment(testFile), - request, + postResponseScriptResult = await runPostResponse(request, response, + requestUid, envVars, - runtimeVariables, collectionPath, - onConsoleLog, + collection, + collectionUid, + runtimeVariables, processEnvVars, scriptingConfig, - runRequestByItemPathname, - collectionName - ); + runRequestByItemPathname); } catch (error) { - testError = error; - - if (error.partialResults) { - testResults = error.partialResults; - } else { - testResults = { - request, - envVariables: envVars, - runtimeVariables, - globalEnvironmentVariables: request?.globalEnvironmentVariables || {}, - results: [], - nextRequestName: null - }; - } + console.error('Post-response script error:', error); + postResponseError = error; } - !runInBackground && mainWindow.webContents.send('main:run-request-event', { - type: 'test-results', - results: testResults.results, - itemUid: item.uid, - requestUid, - collectionUid - }); - - mainWindow.webContents.send('main:script-environment-update', { - envVariables: testResults.envVariables, - runtimeVariables: testResults.runtimeVariables, - requestUid, - collectionUid - }); - - mainWindow.webContents.send('main:persistent-env-variables-update', { - persistentEnvVariables: testResults.persistentEnvVariables, - collectionUid - }); - - mainWindow.webContents.send('main:global-environment-variables-update', { - globalEnvironmentVariables: testResults.globalEnvironmentVariables - }); - - collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables; + if (postResponseScriptResult?.results) { + mainWindow.webContents.send('main:run-request-event', { + type: 'test-results-post-response', + results: postResponseScriptResult.results, + itemUid: item.uid, + requestUid, + collectionUid + }); + } !runInBackground && notifyScriptExecution({ channel: 'main:run-request-event', basePayload: { requestUid, collectionUid, itemUid: item.uid }, - scriptType: 'test', - error: testError + scriptType: 'post-response', + error: postResponseError }); - const domainsWithCookiesTest = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest))); - cookiesStore.saveCookieJar(); + // run assertions + const assertions = get(request, 'assertions'); + if (assertions) { + const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime }); + const results = assertRuntime.runAssertions(assertions, + request, + response, + envVars, + runtimeVariables, + processEnvVars); + + !runInBackground && mainWindow.webContents.send('main:run-request-event', { + type: 'assertion-results', + results: results, + itemUid: item.uid, + requestUid, + collectionUid + }); + } + + const testFile = get(request, 'tests'); + const collectionName = collection?.name; + if (typeof testFile === 'string') { + const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime }); + let testResults = null; + let testError = null; + + try { + testResults = await testRuntime.runTests(decomment(testFile), + request, + response, + envVars, + runtimeVariables, + collectionPath, + onConsoleLog, + processEnvVars, + scriptingConfig, + runRequestByItemPathname, + collectionName); + } catch (error) { + testError = error; + + if (error.partialResults) { + testResults = error.partialResults; + } else { + testResults = { + request, + envVariables: envVars, + runtimeVariables, + globalEnvironmentVariables: request?.globalEnvironmentVariables || {}, + results: [], + nextRequestName: null + }; + } + } + + !runInBackground && mainWindow.webContents.send('main:run-request-event', { + type: 'test-results', + results: testResults.results, + itemUid: item.uid, + requestUid, + collectionUid + }); + + mainWindow.webContents.send('main:script-environment-update', { + envVariables: testResults.envVariables, + runtimeVariables: testResults.runtimeVariables, + requestUid, + collectionUid + }); + + mainWindow.webContents.send('main:persistent-env-variables-update', { + persistentEnvVariables: testResults.persistentEnvVariables, + collectionUid + }); + + mainWindow.webContents.send('main:global-environment-variables-update', { + globalEnvironmentVariables: testResults.globalEnvironmentVariables + }); + + collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables; + + !runInBackground && notifyScriptExecution({ + channel: 'main:run-request-event', + basePayload: { requestUid, collectionUid, itemUid: item.uid }, + scriptType: 'test', + error: testError + }); + + const domainsWithCookiesTest = await getDomainsWithCookies(); + mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest))); + cookiesStore.saveCookieJar(); + } + }; + + if (request.isStream) { + response.stream.on('close', () => runPostScripts().then()); + } else { + await runPostScripts(); } return { @@ -893,8 +926,8 @@ const registerNetworkIpc = (mainWindow) => { statusText: response.statusText, headers: response.headers, data: response.data, - dataBuffer: response.dataBuffer.toString('base64'), - size: Buffer.byteLength(response.dataBuffer), + stream: request.isStream ? response.stream : null, + cancelTokenUid: cancelTokenUid, duration: responseTime ?? 0, url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null, timeline: response.timeline @@ -917,7 +950,27 @@ const registerNetworkIpc = (mainWindow) => { const collectionUid = collection.uid; const envVars = getEnvVars(environment); const processEnvVars = getProcessEnvVars(collectionUid); - return await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false }); + const response = await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false }); + if (response.stream) { + const stream = response.stream; + response.stream = undefined; + response.hasStreamRunning = response.status >= 200 && response.status < 300; + + stream.on('data', newData => { + const parsed = parseDataFromResponse({ data: newData, headers: {} }); + mainWindow.webContents.send('main:http-stream-new-data', {collectionUid, itemUid: item.uid, data: parsed}); + }); + + stream.on('close', () => { + if (!cancelTokens[response.cancelTokenUid]) { + return; + } + + mainWindow.webContents.send('main:http-stream-end', {collectionUid, itemUid: item.uid}); + deleteCancelToken(response.cancelTokenUid); + }); + } + return response; }); ipcMain.handle('clear-oauth2-cache', async (event, uid, url, credentialsId) => { @@ -935,8 +988,9 @@ const registerNetworkIpc = (mainWindow) => { ipcMain.handle('cancel-http-request', async (event, cancelTokenUid) => { return new Promise((resolve, reject) => { if (cancelTokenUid && cancelTokens[cancelTokenUid]) { - cancelTokens[cancelTokenUid].abort(); + const abortController = cancelTokens[cancelTokenUid]; deleteCancelToken(cancelTokenUid); + abortController.abort(); // Ensure the on stream end event is called after the token is deleted resolve(); } else { reject(new Error('cancel token not found')); @@ -945,7 +999,7 @@ const registerNetworkIpc = (mainWindow) => { }); // handler for fetch-gql-schema - ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler) + ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler); ipcMain.handle( 'renderer:run-collection-folder', @@ -960,10 +1014,17 @@ const registerNetworkIpc = (mainWindow) => { const envVars = getEnvVars(environment); const processEnvVars = getProcessEnvVars(collectionUid); let stopRunnerExecution = false; + let currentAbortController; const abortController = new AbortController(); saveCancelToken(cancelTokenUid, abortController); + abortController.signal.addEventListener('abort', () => { + if (currentAbortController) { + currentAbortController.abort(); + } + }); + const runRequestByItemPathname = async (relativeItemPathname) => { return new Promise(async (resolve, reject) => { let itemPathname = path.join(collection?.pathname, relativeItemPathname); @@ -972,7 +1033,7 @@ const registerNetworkIpc = (mainWindow) => { } const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname)); if(_item) { - const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true }); + const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true }); resolve(res); } reject(`bru.runRequest: invalid request path - ${itemPathname}`); @@ -1004,9 +1065,8 @@ const registerNetworkIpc = (mainWindow) => { } }); - // sort requests by seq property - folderRequests = sortByNameThenSequence(folderRequests) + folderRequests = sortByNameThenSequence(folderRequests); } // Filter requests based on tags @@ -1015,7 +1075,7 @@ const registerNetworkIpc = (mainWindow) => { const excludeTags = tags.exclude ? tags.exclude : []; folderRequests = folderRequests.filter(({ tags: requestTags = [], draft }) => { requestTags = draft?.tags || requestTags || []; - return isRequestTagsIncluded(requestTags, includeTags, excludeTags) + return isRequestTagsIncluded(requestTags, includeTags, excludeTags); }); } @@ -1068,15 +1128,14 @@ const registerNetworkIpc = (mainWindow) => { const request = await prepareRequest(item, collection, abortController); request.__bruno__executionMode = 'runner'; - + const requestUid = uuid(); try { let preRequestScriptResult; let preRequestError = null; try { - preRequestScriptResult = await runPreRequest( - request, + preRequestScriptResult = await runPreRequest(request, requestUid, envVars, collectionPath, @@ -1085,8 +1144,7 @@ const registerNetworkIpc = (mainWindow) => { runtimeVariables, processEnvVars, scriptingConfig, - runRequestByItemPathname - ); + runRequestByItemPathname); } catch (error) { console.error('Pre-request script error:', error); preRequestError = error; @@ -1154,8 +1212,9 @@ const registerNetworkIpc = (mainWindow) => { method: request.method, headers: headersSent, data: requestData, - dataBuffer: requestDataBuffer - } + dataBuffer: requestDataBuffer, + timestamp: Date.now() + }; // todo: // i have no clue why electron can't send the request object @@ -1166,9 +1225,11 @@ const registerNetworkIpc = (mainWindow) => { ...eventData }); - request.signal = abortController.signal; - const axiosInstance = await configureRequest( - collectionUid, + currentAbortController = new AbortController(); + request.signal = currentAbortController.signal; + request.responseType = 'stream'; + + const axiosInstance = await configureRequest(collectionUid, collection, request, envVars, @@ -1185,7 +1246,7 @@ const registerNetworkIpc = (mainWindow) => { collectionUid, credentialsId: request?.oauth2Credentials?.credentialsId, ...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }), - debugInfo: request?.oauth2Credentials?.debugInfo, + debugInfo: request?.oauth2Credentials?.debugInfo }); collection.oauth2Credentials = updateCollectionOauth2Credentials({ @@ -1213,6 +1274,10 @@ const registerNetworkIpc = (mainWindow) => { /** @type {import('axios').AxiosResponse} */ response = await axiosInstance(request); + + request.isStream = isStream(response.headers); + response.data = await promisifyStream(response.data, currentAbortController, true); + timeEnd = Date.now(); const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson); @@ -1254,6 +1319,9 @@ const registerNetworkIpc = (mainWindow) => { } if (error?.response) { + request.isStream = isStream(error.response.headers); + error.response.data = await promisifyStream(error.response.data, currentAbortController, true); + const { data, dataBuffer } = parseDataFromResponse(error.response); error.response.responseTime = error.response.headers.get('request-duration'); error.response.headers.delete('request-duration'); @@ -1270,7 +1338,7 @@ const registerNetworkIpc = (mainWindow) => { size: Buffer.byteLength(dataBuffer), data: error.response.data, responseTime: error.response.responseTime, - timeline: error.response.timeline, + timeline: error.response.timeline }; // if we get a response from the server, we consider it as a success @@ -1291,8 +1359,7 @@ const registerNetworkIpc = (mainWindow) => { let postResponseScriptResult; let postResponseError = null; try { - postResponseScriptResult = await runPostResponse( - request, + postResponseScriptResult = await runPostResponse(request, response, requestUid, envVars, @@ -1302,8 +1369,7 @@ const registerNetworkIpc = (mainWindow) => { runtimeVariables, processEnvVars, scriptingConfig, - runRequestByItemPathname - ); + runRequestByItemPathname); } catch (error) { console.error('Post-response script error:', error); postResponseError = error; @@ -1340,14 +1406,12 @@ const registerNetworkIpc = (mainWindow) => { const assertions = get(item, 'request.assertions'); if (assertions) { const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime }); - const results = assertRuntime.runAssertions( - assertions, + const results = assertRuntime.runAssertions(assertions, request, response, envVars, runtimeVariables, - processEnvVars - ); + processEnvVars); mainWindow.webContents.send('main:run-folder-event', { type: 'assertion-results', @@ -1358,15 +1422,14 @@ const registerNetworkIpc = (mainWindow) => { } const testFile = get(request, 'tests'); - const collectionName = collection?.name + const collectionName = collection?.name; if (typeof testFile === 'string') { let testResults = null; let testError = null; try { const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime }); - testResults = await testRuntime.runTests( - decomment(testFile), + testResults = await testRuntime.runTests(decomment(testFile), request, response, envVars, @@ -1376,11 +1439,10 @@ const registerNetworkIpc = (mainWindow) => { processEnvVars, scriptingConfig, runRequestByItemPathname, - collectionName - ); + collectionName); } catch (error) { testError = error; - + if (error.partialResults) { testResults = error.partialResults; } else { @@ -1414,7 +1476,7 @@ const registerNetworkIpc = (mainWindow) => { mainWindow.webContents.send('main:global-environment-variables-update', { globalEnvironmentVariables: testResults.globalEnvironmentVariables }); - + collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables; notifyScriptExecution({ @@ -1443,7 +1505,7 @@ const registerNetworkIpc = (mainWindow) => { collectionUid, folderUid, statusText: 'collection run was terminated!', - runCompletionTime: new Date().toISOString(), + runCompletionTime: new Date().toISOString() }); break; } @@ -1460,7 +1522,7 @@ const registerNetworkIpc = (mainWindow) => { if (nextRequestIdx >= 0) { currentRequestIndex = nextRequestIdx; } else { - console.error("Could not find request with name '" + nextRequestName + "'"); + console.error('Could not find request with name \'' + nextRequestName + '\''); currentRequestIndex++; } } else { @@ -1473,10 +1535,10 @@ const registerNetworkIpc = (mainWindow) => { type: 'testrun-ended', collectionUid, folderUid, - runCompletionTime: new Date().toISOString(), + runCompletionTime: new Date().toISOString() }); } catch (error) { - console.log("error", error); + console.log('error', error); deleteCancelToken(cancelTokenUid); mainWindow.webContents.send('main:run-folder-event', { type: 'testrun-ended', @@ -1573,14 +1635,13 @@ const executeRequestOnFailHandler = async (request, error) => { } }; - const registerAllNetworkIpc = (mainWindow) => { registerNetworkIpc(mainWindow); registerGrpcEventHandlers(mainWindow); registerWsEventHandlers(mainWindow); -} +}; -module.exports = registerAllNetworkIpc +module.exports = registerAllNetworkIpc; module.exports.configureRequest = configureRequest; module.exports.getCertsAndProxyConfig = getCertsAndProxyConfig; module.exports.fetchGqlSchemaHandler = fetchGqlSchemaHandler; From efad149afcb1cf13dec22b33e82367df81f78242 Mon Sep 17 00:00:00 2001 From: "Siddharth Gelera (reaper)" Date: Fri, 14 Nov 2025 16:57:29 +0530 Subject: [PATCH 2/3] HTTP stream enhancements (#6077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add stop request button in api url bar * docs: add farsi translation * fix: handle escaped forward slashes by fast-json-format library upgrade * refactor: change ui to use one from Websockets * chore: cleanup * fix: lint issues * Replace IconPlayerStop with IconSquareRoundedX * update json request and response formatting logic * chore: format changes * chore: remove un-needed diffs * chore: sanitize * bugfix(#5939): curl import fails for custom content-types * chore: remove un-needed diffs * chore: enhance response handling for streaming * fix: disable requestid check for tests and assertions to be updated after streaming result * chore: housekeeping * fix: streamline loading and cancel request icon logic * chore: formatting * fix: multiple co-pilot changes * fix: handle in folders * feat: add WaitGroup utility for managing concurrent tasks * refactor: remove WaitGroup utility and clean up network IPC logic * refactor: remove unused setTimeout import and clean up post script execution * refactor: clean up post-response script execution logic * undiff * re-align * refactor: streamline post-response script execution - Cleaned up formatting and improved readability of the post-response script execution logic. - Consolidated parameters in function calls for consistency. * fix: keep original dataBuffer for saving response --------- Co-authored-by: adarshajit Co-authored-by: sajadoncode Co-authored-by: lohit-bruno Co-authored-by: Bijin A B Co-authored-by: Pragadesh-45 Co-authored-by: Anoop M D Co-authored-by: Anoop M D Co-authored-by: Dawid Góra --- contributing.md | 6 + docs/contributing/contributing_fa.md | 92 ++++++++ docs/publishing/publishing_fa.md | 8 + docs/readme/readme_fa.md | 143 +++++++++++++ package-lock.json | 9 +- packages/bruno-app/package.json | 3 +- .../RequestPane/GraphQLVariables/index.js | 5 +- .../components/RequestPane/GrpcBody/index.js | 5 +- .../components/RequestPane/QueryUrl/index.js | 29 ++- .../src/components/RequestTabPanel/index.js | 15 +- .../ResponsePane/ResponseBookmark/index.js | 26 ++- .../ResponsePane/ResponseStopWatch/index.js | 8 +- .../WsResponsePane/WSMessagesList/index.js | 2 +- .../src/components/ResponsePane/index.js | 39 ++-- .../ReduxStore/slices/collections/index.js | 127 ++++++----- packages/bruno-app/src/utils/common/index.js | 7 +- .../bruno-app/src/utils/common/index.spec.js | 8 +- .../bruno-app/src/utils/curl/content-type.js | 29 +++ .../bruno-app/src/utils/curl/curl-to-json.js | 3 +- .../src/utils/curl/curl-to-json.spec.js | 33 +++ packages/bruno-app/src/utils/curl/index.js | 15 +- packages/bruno-app/src/utils/network/index.js | 63 +++--- .../bruno-electron/src/ipc/network/index.js | 200 ++++++++++-------- publishing.md | 1 + readme.md | 2 + .../fixtures/collection/request.bru | 3 +- .../json-response-formatting.spec.ts | 3 + 27 files changed, 643 insertions(+), 241 deletions(-) create mode 100644 docs/contributing/contributing_fa.md create mode 100644 docs/publishing/publishing_fa.md create mode 100644 docs/readme/readme_fa.md create mode 100644 packages/bruno-app/src/utils/curl/content-type.js diff --git a/contributing.md b/contributing.md index 0c6a7d4a6..6206d59f5 100644 --- a/contributing.md +++ b/contributing.md @@ -16,6 +16,7 @@ | [日本語](docs/contributing/contributing_ja.md) | [हिंदी](docs/contributing/contributing_hi.md) | [Dutch](docs/contributing/contributing_nl.md) +| [فارسی](docs/contributing/contributing_fa.md) ## Let's make Bruno better, together!! @@ -74,6 +75,7 @@ npm run build:bruno-filestore # bundle js sandbox libraries npm run sandbox:bundle-libraries --workspace=packages/bruno-js ``` + ##### Option 2 ```bash @@ -94,18 +96,22 @@ npm run dev:electron ``` ##### Option 2 + ```bash # run electron and react app concurrently npm run dev ``` #### Customize Electron `userData` path + If `ELECTRON_USER_DATA_PATH` env-variable is present and its development mode, then `userData` path is modified accordingly. e.g. + ```sh ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron ``` + This will create a `bruno-test` folder on your Desktop and use it as the `userData` path. ### Troubleshooting diff --git a/docs/contributing/contributing_fa.md b/docs/contributing/contributing_fa.md new file mode 100644 index 000000000..5316a48a9 --- /dev/null +++ b/docs/contributing/contributing_fa.md @@ -0,0 +1,92 @@ +[English](../../contributing.md) + +## با هم، Bruno را بهتر می‌کنیم! + +خوشحالم که قصد دارید Bruno را بهبود ببخشید. در ادامه قوانین و راهنماها برای راه‌اندازی Bruno روی سیستم شما آورده شده است. + +### فناوری‌های استفاده‌شده + +به فارسی برونو Bruno با استفاده از Next.js و React ساخته شده است. همچنین از Electron برای بسته‌بندی نسخه دسکتاپ (که امکان مجموعه‌های محلی را فراهم می‌کند) استفاده می‌کنیم. + +کتابخانه‌هایی که استفاده می‌کنیم: + +- CSS - Tailwind استایل +- Codemirror - ویرایشگر کد +- Redux - مدیریت وضعیت +- Tabler Icons - آیکون‌ها +- formik - فرم‌ها +- Yup اعتبارسنجی اسکیمـا +- axios - کلاینت درخواست +- chokidar - پایش‌گر سیستم فایل + +### پیش‌نیازها + +شما به [نود v20.x یا اخرین نسخه پایدار](https://nodejs.org/en/) و npm 8.x نیاز دارید. در این پروژه از فضای کاری npm (npm workspaces) استفاده می‌کنیم. + +### شروع به کدنویسی + +برای راه‌اندازی محیط توسعه محلی به فایل [مستندات توسعه](docs/development_fa.md) مراجعه کنید: + +### ارسال Pull Request + +1 - لطفاً Pull Requestها (PR) را کوتاه و متمرکز نگه دارید و تنها یک هدف مشخص را دنبال کنند.
+2 - لطفاً از فرمت نام‌گذاری شاخه‌ها استفاده کنید: + +- feature/[name]: این شاخه باید شامل یک قابلیت مشخص باشد. + - feature/dark-mode : مثال +- bugfix/[name]: این شاخه باید تنها شامل رفع یک باگ مشخص باشد. + - bugfix/bug-1 : مثال + +## توسعه + +به فارسی برونو یا Bruno به‌صورت یک اپلیکیشن «سنگین» توسعه داده می‌شود. برای اجرا باید ابتدا Next.js را در یک پنجره ترمینال اجرا کنید و سپس اپلیکیشن Electron را در پنجره ترمینال دیگری راه‌اندازی نمایید. + +### نیازمندی توسعه + +- NodeJS v18 + +### اجرای محلی + +```bash +# از ورژن NodeJS 18 استفاده کنید +nvm use + +# نصب وابستگی‌ها +npm i --legacy-peer-deps + +# ساخت مستندات GraphQL +npm run build:graphql-docs + +# ساخت bruno-query +npm run build:bruno-query + +# اجرای اپ Next (ترمینال 1) +npm run dev:web + +# اجرای اپ Electron (ترمینال 2) +npm run dev:electron +``` + +### عیب‌یابی + +ممکن است هنگام اجرای `npm install` خطای `Unsupported platform` ببینید. برای رفع این مشکل، پوشه `node_modules` و فایل `package-lock.json` را حذف کرده و سپس دوباره `npm install` را اجرا کنید. این کار معمولاً همه پکیج‌های لازم را نصب می‌کند. + +```shell +# حذف پوشه node_modules در زیردایرکتوری‌ها +find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do + rm -rf "$dir" +done + +# حذف فایل package-lock.json در زیردایرکتوری‌ها +find . -type f -name "package-lock.json" -delete +``` + +### تست‌ها + +```bash +# اجرای تست‌های schema مربوط به bruno +npm test --workspace=packages/bruno-schema + +# اجرای تست‌ها در همه فضاهای کاری (در صورت وجود) +npm test --workspaces --if-present +``` diff --git a/docs/publishing/publishing_fa.md b/docs/publishing/publishing_fa.md new file mode 100644 index 000000000..6d24240c8 --- /dev/null +++ b/docs/publishing/publishing_fa.md @@ -0,0 +1,8 @@ +[English](../../publishing.md) + +### انتشار Bruno در یک پکیج منیجر جدید + +اگرچه کد ما متن‌باز است و همه می‌توانند از آن استفاده کنند، لطفاً قبل از انتشار Bruno در مدیر بسته‌های جدید با ما تماس بگیرید. به عنوان سازنده Bruno، علامت تجاری `Bruno` را برای این پروژه دارم و مایلم توزیع آن را مدیریت کنم. اگر دوست دارید Bruno را در یک مدیر بسته جدید ببینید، لطفاً یک issue در گیت‌هاب ثبت کنید. + +اگرچه بیشتر قابلیت‌های ما رایگان و متن‌باز هستند (شامل REST و GraphQL Apis)، +ما تلاش می‌کنیم بین اصول متن‌باز و توسعه پایدار تعادل مناسبی برقرار کنیم - https://github.com/usebruno/bruno/discussions/269 diff --git a/docs/readme/readme_fa.md b/docs/readme/readme_fa.md new file mode 100644 index 000000000..3e495d426 --- /dev/null +++ b/docs/readme/readme_fa.md @@ -0,0 +1,143 @@ +
+ + +### برونو یا Bruno - محیط توسعه متن باز برای تست و توسعه API ها + +[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml) +[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse) +[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno) +[![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com) +[![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads) + +[English](../../readme.md) +| [Українська](./readme_ua.md) +| [Русский](./readme_ru.md) +| [Türkçe](./readme_tr.md) +| [Deutsch](./readme_de.md) +| [Français](./readme_fr.md) +| [Português (BR)](./readme_pt_br.md) +| [한국어](./readme_kr.md) +| [বাংলা](./readme_bn.md) +| [Español](./readme_es.md) +| **فارسی** +| [Română](./readme_ro.md) +| [Polski](./readme_pl.md) +| [简体中文](./readme_cn.md) +| [正體中文](./readme_zhtw.md) +| [العربية](./readme_ar.md) +| [日本語](./readme_ja.md) +| [ქართული](./readme_ka.md) + +برونو یک کلاینت API جدید و نوآورانه است که هدفش تغییر وضعیت فعلی ابزارهایی مانند Postman و سایر ابزارهای مشابه است. + +برونو مجموعه‌های شما را مستقیماً در یک پوشه روی فایل‌سیستم شما ذخیره می‌کند. ما از یک زبان نشانه‌گذاری ساده به نام Bru برای ذخیره اطلاعات درخواست‌های API استفاده می‌کنیم. + +شما می‌توانید برای همکاری روی مجموعه‌های API خود، از Git یا هر سیستم کنترل نسخه دلخواهتان استفاده کنید. + +برونو فقط به صورت آفلاین کار می‌کند. هیچ برنامه‌ای برای اضافه کردن همگام‌سازی ابری به برونو در آینده وجود ندارد. ما به حریم خصوصی داده‌های شما اهمیت می‌دهیم و معتقدیم که باید روی دستگاه خودتان باقی بمانند. می‌توانید چشم‌انداز بلندمدت ما را مطالعه کنید. [اینجا (به انگلیسی)](https://github.com/usebruno/bruno/discussions/269) + +📢 جدیدترین ارائه ما را در کنفرانس India FOSS 3.0 تماشا کنید. +[اینجا](https://www.youtube.com/watch?v=7bSMFpbcPiY) + +![bruno](/assets/images/landing-2.png)

+ +### نصب + +برونو به صورت یک فایل باینری برای دانلود در دسترس است. [بر روی وبسایت ما](https://www.usebruno.com/downloads) برای مک لینکوس و ویندوز. + +همچنین می‌توانید برونو را از طریق مدیر بسته‌هایی مانند Homebrew، Chocolatey، Snap و Apt نصب کنید. + +```sh +# بر روی مک از طریق brew +brew install bruno + +# بر روی ویندوز از طریق Chocolatey +choco install bruno + +# بر روی لینوکس از طریق Snap +snap install bruno + +# بر روی لینوکس از طریق Apt +sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg curl +curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \ + | gpg --dearmor \ + | sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null +sudo chmod 644 /etc/apt/keyrings/bruno.gpg +echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \ + | sudo tee /etc/apt/sources.list.d/bruno.list +sudo apt update && sudo apt install bruno +``` + +### روی پلتفرم‌های مختلف کار می‌کند 🖥️ + +![bruno](/assets/images/run-anywhere.png)

+ +### همکاری از طریق گیت 👩‍💻🧑‍💻 + +یا هر سیستم کنترل نسخه‌ای که ترجیح می‌دهید + +![bruno](/assets/images/version-control.png)

+ +### لینک‌های مهم 📌 + +- [آخرین نسخه پایدار ما](https://github.com/usebruno/bruno/discussions/269) +- [نقشه راه](https://github.com/usebruno/bruno/discussions/384) +- [مستندات](https://docs.usebruno.com) +- [وبسایت](https://www.usebruno.com) +- [اشتراک ها](https://www.usebruno.com/pricing) +- [دانلود](https://www.usebruno.com/downloads) + +### ویدیوها 🎥 + +- [تجربه ها](https://github.com/usebruno/bruno/discussions/343) +- [مرکز دانش](https://github.com/usebruno/bruno/discussions/386) +- [اسکریپ مانیا](https://github.com/usebruno/bruno/discussions/385) + +### حمایت ❤️ + +جوون! اگر این پروژه را دوست دارید، روی دکمه ⭐ کلیک کنید! + +### تجربه‌های به اشتراک گذاشته‌شده 📣 + +اگر برونو به شما یا تیمتان کمک کرده است، لطفاً فراموش نکنید تجربه‌های خود را به اشتراک بگذارید. [تجربه‌های خود را در بحث گیت‌هاب ما به اشتراک بگذارید](https://github.com/usebruno/bruno/discussions/343). + +### انتشار برونو در یک پکیچ منیجر جدید + +لطفا چک بکنید [اینجارو](../../publishing.md) برای اطلاعات بیشتر. + +### مشارکت 👩‍💻🧑‍💻 + +خوشحالم که می‌خواهید برونو را بهتر کنید. لطفا [راهنمای مشارکت را بررسی کنید](../contributing/contributing_fa.md). + +حتی اگر نمی‌توانید از طریق کدنویسی مشارکت کنید، در گزارش باگ‌ها و درخواست قابلیت‌های جدید که به حل نیازهای شما کمک می‌کند تردید نکنید. + +### نویسنده ها + + + +### در ارتباط باشید 🌐 + +[𝕏 (تویتر)](https://twitter.com/use_bruno)
+[وبسایت](https://www.usebruno.com)
+[دیسکورد](https://discord.com/invite/KgcZUncpjq)
+[لینکدین](https://www.linkedin.com/company/usebruno) + +### برند + +**نام** + +به فارسی برونو - `Bruno` یک علامت تجاری ثبت‌شده متعلق به [Anoop M D](https://www.helloanoop.com/) + +**لوگو** + +لوگو توسط [OpenMoji](https://openmoji.org/library/emoji-1F436/) ساخته شده است. مجوز: CC [BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) + +### مجوز 📄 + +[MIT](../../license.md) diff --git a/package-lock.json b/package-lock.json index 746bccd2a..b54d8eb5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14235,9 +14235,9 @@ } }, "node_modules/fast-json-format": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/fast-json-format/-/fast-json-format-0.3.0.tgz", - "integrity": "sha512-B95psGYXJ5XItmxLR6JFcQRQafDyfy8ecHiV/jWCJF9oCIA9/o+wt89cGW61D04xf07yCpIaevvCQbgeJ9w8lQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/fast-json-format/-/fast-json-format-0.4.0.tgz", + "integrity": "sha512-HEomBtr2fYaVX3iaRdcVLU7Qd3SQhCYvXlMMM9RNaihfIaj5bIC7ADqw/bAPSg/uyX6FIBPq69ioXq0B4Cb6eA==", "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -26874,7 +26874,7 @@ "dompurify": "^3.2.4", "escape-html": "^1.0.3", "fast-fuzzy": "^1.12.0", - "fast-json-format": "~0.3.0", + "fast-json-format": "~0.4.0", "file": "^0.2.2", "file-dialog": "^0.0.8", "file-saver": "^2.0.5", @@ -26883,6 +26883,7 @@ "graphiql": "3.7.1", "graphql": "^16.6.0", "graphql-request": "^3.7.0", + "hexy": "^0.3.5", "httpsnippet": "^3.0.9", "i18next": "24.1.2", "idb": "^7.0.0", diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index 09b5f2741..1959a15dc 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -28,7 +28,7 @@ "dompurify": "^3.2.4", "escape-html": "^1.0.3", "fast-fuzzy": "^1.12.0", - "fast-json-format": "~0.3.0", + "fast-json-format": "~0.4.0", "file": "^0.2.2", "file-dialog": "^0.0.8", "file-saver": "^2.0.5", @@ -37,6 +37,7 @@ "graphiql": "3.7.1", "graphql": "^16.6.0", "graphql-request": "^3.7.0", + "hexy": "^0.3.5", "httpsnippet": "^3.0.9", "i18next": "24.1.2", "idb": "^7.0.0", diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js index 0a7fd98c9..6ab530ebd 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js @@ -6,9 +6,9 @@ import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/colle import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; import StyledWrapper from './StyledWrapper'; -import { format, applyEdits } from 'jsonc-parser'; import { IconWand } from '@tabler/icons'; import toast from 'react-hot-toast'; +import { prettifyJsonString } from 'utils/common/index'; const GraphQLVariables = ({ variables, item, collection }) => { const dispatch = useDispatch(); @@ -19,8 +19,7 @@ const GraphQLVariables = ({ variables, item, collection }) => { const onPrettify = () => { if (!variables) return; try { - const edits = format(variables, undefined, { tabSize: 2, insertSpaces: true }); - const prettyVariables = applyEdits(variables, edits); + const prettyVariables = prettifyJsonString(variables); dispatch( updateRequestGraphqlVariables({ variables: prettyVariables, diff --git a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js index e17bdb074..0e1eb8d0b 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js @@ -12,9 +12,9 @@ import StyledWrapper from './StyledWrapper'; import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash, IconChevronDown, IconChevronUp } from '@tabler/icons'; import ToolHint from 'components/ToolHint/index'; import { toastError } from 'utils/common/error'; -import { format, applyEdits } from 'jsonc-parser'; import toast from 'react-hot-toast' import { getAbsoluteFilePath } from 'utils/common/path'; +import { prettifyJsonString } from 'utils/common/index'; const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => { const dispatch = useDispatch(); @@ -130,8 +130,7 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCol const onPrettify = () => { try { - const edits = format(content, undefined, { tabSize: 2, insertSpaces: true }); - const prettyBodyJson = applyEdits(content, edits); + const prettyBodyJson = prettifyJsonString(content); const currentMessages = [...(body.grpc || [])]; currentMessages[index] = { diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js index 2c0fb3dd1..afaef13f3 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js @@ -2,10 +2,10 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import get from 'lodash/get'; import { useDispatch } from 'react-redux'; import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections'; -import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { cancelRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import HttpMethodSelector from './HttpMethodSelector'; import { useTheme } from 'providers/Theme'; -import { IconDeviceFloppy, IconArrowRight, IconCode, IconX } from '@tabler/icons'; +import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons'; import SingleLineEditor from 'components/SingleLineEditor'; import { isMacOS } from 'utils/common/platform'; import { hasRequestChanges } from 'utils/collections'; @@ -22,6 +22,7 @@ const QueryUrl = ({ item, collection, handleRun }) => { const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S'; const editorRef = useRef(null); const isGrpc = item.type === 'grpc-request'; + const isLoading = ['queued', 'sending'].includes(item.requestState); const [methodSelectorWidth, setMethodSelectorWidth] = useState(90); const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false); @@ -80,6 +81,10 @@ const QueryUrl = ({ item, collection, handleRun }) => { } }; + const handleCancelRequest = () => { + dispatch(cancelRequest(item.cancelTokenUid, item, collection)); + }; + return (
@@ -87,7 +92,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
gRPC
- + ) : ( )} @@ -149,11 +154,23 @@ const QueryUrl = ({ item, collection, handleRun }) => { Save ({saveShortcut})
+ { - item.response?.hasStreamRunning ? ( - + isLoading || item.response?.stream?.running ? ( + ) : ( - + ) }
diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index a347c1d0a..f8d3aba83 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -74,7 +74,8 @@ const RequestTabPanel = () => { const screenWidth = useSelector((state) => state.app.screenWidth); let asideWidth = useSelector((state) => state.app.leftSidebarWidth); const [leftPaneWidth, setLeftPaneWidth] = useState( - focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2); // 2.2 is intentional to make both panes appear to be of equal width + focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2 + ); // 2.2 is intentional to make both panes appear to be of equal width const [topPaneHeight, setTopPaneHeight] = useState(focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT); const [dragging, setDragging] = useState(false); const dragOffset = useRef({ x: 0, y: 0 }); @@ -140,10 +141,12 @@ const RequestTabPanel = () => { setDragging(false); if (!isVerticalLayout) { const mainRect = mainSectionRef.current.getBoundingClientRect(); - dispatch(updateRequestPaneTabWidth({ - uid: activeTabUid, - requestPaneWidth: e.clientX - mainRect.left - })); + dispatch( + updateRequestPaneTabWidth({ + uid: activeTabUid, + requestPaneWidth: e.clientX - mainRect.left + }) + ); } } }; @@ -260,7 +263,7 @@ const RequestTabPanel = () => { return; } - if (item.response?.hasStreamRunning) { + if (item.response?.stream?.running) { dispatch(cancelRequest(item.cancelTokenUid, item, collection)).catch((err) => toast.custom((t) => toast.dismiss(t.id)} />, { duration: 5000 diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js index b00440c3a..9efe51f2e 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js @@ -12,12 +12,25 @@ import { getInitialExampleName } from 'utils/collections/index'; import classnames from 'classnames'; import StyledWrapper from './StyledWrapper'; +const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => { + if (isStreamingResponse) { + return 'Response Examples aren\'t supported in streaming responses yet.'; + } + + if (isResponseTooLarge) { + return 'Response size exceeds 5MB limit. Cannot save as example.'; + } + + return 'Save current response as example'; +}; + const ResponseBookmark = ({ item, collection, responseSize }) => { const dispatch = useDispatch(); const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false); const response = item.response || {}; const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB + const isStreamingResponse = response.stream; // Only show for HTTP requests if (item.type !== 'http-request') { @@ -96,19 +109,22 @@ const ResponseBookmark = ({ item, collection, responseSize }) => { toast.success(`Example "${name}" created successfully`); }; + const disabledMessage = getTitleText({ + isResponseTooLarge, + isStreamingResponse + }); + return ( <>
{ onClose={() => setShowScriptErrorCard(false)} /> )} -
+
{!item?.response ? ( - focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? ( + focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? ( { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); - + if (collection) { const item = findItemInCollection(collection, action.payload.itemUid); if (item) { item.requestState = 'received'; item.response = action.payload.response; - item.cancelTokenUid = item.response.hasStreamRunning ? item.cancelTokenUid : null; + item.cancelTokenUid = item.response.stream?.running ? item.cancelTokenUid : null; item.requestStartTime = null; if (!collection.timeline) { collection.timeline = []; } - + // Ensure timestamp is a number (milliseconds since epoch) - const timestamp = item?.requestSent?.timestamp instanceof Date - ? item.requestSent.timestamp.getTime() + const timestamp = item?.requestSent?.timestamp instanceof Date + ? item.requestSent.timestamp.getTime() : item?.requestSent?.timestamp || Date.now(); // Append the new timeline entry with numeric timestamp @@ -435,7 +436,7 @@ export const collectionsSlice = createSlice({ const { itemUid, collectionUid, eventType, eventData } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); if (!collection) return; - + const item = findItemInCollection(collection, itemUid); if (!item) return; const request = item.draft ? item.draft.request : item.request; @@ -455,7 +456,7 @@ export const collectionsSlice = createSlice({ } collection.timeline.push({ - type: 'request', + type: "request", eventType: eventType, // Add the specific gRPC event type collectionUid: collection.uid, folderUid: null, @@ -464,34 +465,36 @@ export const collectionsSlice = createSlice({ data: { request: eventData || item.requestSent || item.request, timestamp: Date.now(), - eventData: eventData + eventData: eventData, } }); + }, grpcResponseReceived: (state, action) => { const { itemUid, collectionUid, eventType, eventData } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); - + if (!collection) return; const item = findItemInCollection(collection, itemUid); if (!item) return; - + // Get current response state or create initial state - const currentResponse = item.response || initiatedGrpcResponse; + const currentResponse = item.response || initiatedGrpcResponse const timestamp = item?.requestSent?.timestamp; let updatedResponse = { ...currentResponse, duration: Date.now() - (timestamp || Date.now()) }; + // Process based on event type switch (eventType) { case 'response': const { error, res } = eventData; - + // Handle error if present if (error) { const errorCode = error.code || 2; // Default to UNKNOWN if no code - + updatedResponse.error = error.details || 'gRPC error occurred'; updatedResponse.statusCode = errorCode; updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN'; @@ -500,72 +503,72 @@ export const collectionsSlice = createSlice({ } // Add response to list - updatedResponse.responses = res - ? [...(currentResponse?.responses || []), res] + updatedResponse.responses = res + ? [...(currentResponse?.responses || []), res] : [...(currentResponse?.responses || [])]; break; - + case 'metadata': updatedResponse.headers = eventData.metadata; updatedResponse.metadata = eventData.metadata; break; - + case 'status': // Extract status info const statusCode = eventData.status?.code; const statusDetails = eventData.status?.details; const statusMetadata = eventData.status?.metadata; - + // Set status based on actual code and details updatedResponse.statusCode = statusCode; updatedResponse.statusText = grpcStatusCodes[statusCode] || 'UNKNOWN'; updatedResponse.statusDescription = statusDetails; updatedResponse.statusDetails = eventData.status; - + // Store trailers (status metadata) if (statusMetadata) { updatedResponse.trailers = statusMetadata; } - + // Handle error status (non-zero code) if (statusCode !== 0) { updatedResponse.isError = true; updatedResponse.error = statusDetails || `gRPC error with code ${statusCode} (${updatedResponse.statusText})`; } - + break; - + case 'error': // Extract error details const errorCode = eventData.error?.code || 2; // Default to UNKNOWN if no code const errorDetails = eventData.error?.details || eventData.error?.message; const errorMetadata = eventData.error?.metadata; - + updatedResponse.isError = true; updatedResponse.error = errorDetails || 'Unknown gRPC error'; updatedResponse.statusCode = errorCode; updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN'; updatedResponse.statusDescription = errorDetails; - + // Store error metadata as trailers if present if (errorMetadata) { updatedResponse.trailers = errorMetadata; } - + break; - + case 'end': - state.activeConnections = state.activeConnections.filter((id) => id !== itemUid); + state.activeConnections = state.activeConnections.filter(id => id !== itemUid); break; - + case 'cancel': updatedResponse.statusCode = 1; // CANCELLED updatedResponse.statusText = 'CANCELLED'; updatedResponse.statusDescription = 'Stream cancelled by client or server'; - state.activeConnections = state.activeConnections.filter((id) => id !== itemUid); + state.activeConnections = state.activeConnections.filter(id => id !== itemUid); break; } - + item.requestState = 'received'; item.response = updatedResponse; @@ -576,7 +579,7 @@ export const collectionsSlice = createSlice({ // Append the new timeline entry with specific gRPC event type collection.timeline.push({ - type: 'request', + type: "request", eventType: eventType, // Add the specific gRPC event type collectionUid: collection.uid, folderUid: null, @@ -586,7 +589,7 @@ export const collectionsSlice = createSlice({ request: item.requestSent || item.request, response: updatedResponse, eventData: eventData, // Store the original event data - timestamp: Date.now() + timestamp: Date.now(), } }); }, @@ -596,12 +599,11 @@ export const collectionsSlice = createSlice({ if (collection) { const item = findItemInCollection(collection, action.payload.itemUid); if (item) { - if (item.response && item.response.hasStreamRunning) { + if (item.response && item.response.stream?.running) { item.response.data = ''; item.response.size = 0; return; } - item.response = null; } } @@ -928,7 +930,7 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - const existingOtherParams = item.draft.request.params?.filter((p) => p.type !== 'query') || []; + const existingOtherParams = item.draft.request.params?.filter(p => p.type !== 'query') || []; const newQueryParams = map(params, ({ name = '', value = '', enabled = true }) => ({ uid: uuid(), name, @@ -942,7 +944,9 @@ export const collectionsSlice = createSlice({ // Update the request URL to reflect the new query params const parts = splitOnFirst(item.draft.request.url, '?'); - const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled && p.type === 'query')); + const query = stringifyQueryParams( + filter(item.draft.request.params, (p) => p.enabled && p.type === 'query') + ); // If there are enabled query params, append them to the URL if (query && query.length) { @@ -1173,7 +1177,7 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - item.draft.request.headers = map(action.payload.headers, ({ name = '', value = '', enabled = true }) => ({ + item.draft.request.headers = map(action.payload.headers, ({name = '', value = '', enabled = true}) => ({ uid: uuid(), name: name, value: value, @@ -1215,8 +1219,8 @@ export const collectionsSlice = createSlice({ if (!folder || !isItemAFolder(folder)) { return; } - - folder.root.request.headers = map(headers, ({ name = '', value = '', enabled = true }) => ({ + + folder.root.request.headers = map(headers, ({name = '', value = '', enabled = true}) => ({ uid: uuid(), name: name, value: value, @@ -1497,7 +1501,7 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - + switch (item.draft.request.body.mode) { case 'json': { item.draft.request.body.json = action.payload.content; @@ -1634,7 +1638,7 @@ export const collectionsSlice = createSlice({ const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (collection) { - const item = findItemInCollection(collection, action.payload.itemUid); + const item = findItemInCollection(collection, action.payload.itemUid); if (item && isItemARequest(item)) { if (!item.draft) { @@ -1885,7 +1889,7 @@ export const collectionsSlice = createSlice({ break; case 'ntlm': set(collection, 'draft.root.request.auth.ntlm', action.payload.content); - break; + break; case 'oauth2': set(collection, 'draft.root.request.auth.oauth2', action.payload.content); break; @@ -2614,7 +2618,7 @@ export const collectionsSlice = createSlice({ const { requestUid, itemUid, collectionUid } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); if (!collection) return; - + const item = findItemInCollection(collection, itemUid); if (!item) return; @@ -2647,7 +2651,7 @@ export const collectionsSlice = createSlice({ item.postResponseScriptErrorMessage = action.payload.errorMessage; } - if (type === 'test-script-execution') { + if(type === 'test-script-execution') { item.testScriptErrorMessage = action.payload.errorMessage; } @@ -2662,7 +2666,7 @@ export const collectionsSlice = createSlice({ if (type === 'request-sent') { const { cancelTokenUid, requestSent } = action.payload; item.requestSent = requestSent; - + // sometimes the response is received before the request-sent event arrives if (item.requestState === 'queued') { item.requestState = 'sending'; @@ -2679,12 +2683,12 @@ export const collectionsSlice = createSlice({ const { results } = action.payload; item.testResults = results; } - + if (type === 'test-results-pre-request') { const { results } = action.payload; item.preRequestTestResults = results; } - + if (type === 'test-results-post-response') { const { results } = action.payload; item.postResponseTestResults = results; @@ -2798,7 +2802,7 @@ export const collectionsSlice = createSlice({ if (collection) { collection.runnerResult = null; - collection.runnerTags = { include: [], exclude: [] }; + collection.runnerTags = { include: [], exclude: [] } collection.runnerTagsEnabled = false; collection.runnerConfiguration = null; } @@ -2937,7 +2941,7 @@ export const collectionsSlice = createSlice({ updateFolderAuthMode: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null; - + if (folder) { if (!folder.draft) { folder.draft = cloneDeep(folder.root); @@ -2952,7 +2956,16 @@ export const collectionsSlice = createSlice({ if (collection) { const item = findItemInCollection(collection, itemUid); - item.response.data = data.data + (item.response.data || ''); + if (data.data) { + item.response.data ||= []; + item.response.data = [{ + type: 'incoming', + message: data.data, + messageHexdump: hexdump(data.data), + timestamp: Date.now() + }].concat(item.response.data); + } + item.response.dataBuffer = Buffer.concat([Buffer.from(item.response.dataBuffer), Buffer.from(data.dataBuffer)]); item.response.size = data.data?.length + (item.response.size || 0); } }, @@ -2997,7 +3010,7 @@ export const collectionsSlice = createSlice({ updateCollectionTagsList: (state, action) => { const { collectionUid } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); - + if (collection) { collection.allTags = getUniqueTagsFromItems(collection.items); } diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js index 8b191b0a6..e94fd8be6 100644 --- a/packages/bruno-app/src/utils/common/index.js +++ b/packages/bruno-app/src/utils/common/index.js @@ -2,6 +2,7 @@ import { customAlphabet } from 'nanoid'; import xmlFormat from 'xml-formatter'; import { JSONPath } from 'jsonpath-plus'; import fastJsonFormat from 'fast-json-format'; +import { format, applyEdits } from 'jsonc-parser'; import { patternHasher } from '@usebruno/common/utils'; // a customized version of nanoid without using _ and - @@ -294,7 +295,7 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres } try { - return prettifyJsonString(rawData); + return fastJsonFormat(rawData); } catch (error) {} if (typeof data === 'string') { @@ -326,9 +327,11 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres export const prettifyJsonString = (jsonDataString) => { if (typeof jsonDataString !== 'string') return jsonDataString; + try { const { hashed, restore } = patternHasher(jsonDataString); - const formattedJsonDataStringHashed = fastJsonFormat(hashed); + const edits = format(hashed, undefined, { tabSize: 2, insertSpaces: true }); + const formattedJsonDataStringHashed = applyEdits(hashed, edits); const formattedJsonDataString = restore(formattedJsonDataStringHashed); return formattedJsonDataString; } catch (error) { diff --git a/packages/bruno-app/src/utils/common/index.spec.js b/packages/bruno-app/src/utils/common/index.spec.js index 8ed85932e..55958b954 100644 --- a/packages/bruno-app/src/utils/common/index.spec.js +++ b/packages/bruno-app/src/utils/common/index.spec.js @@ -218,16 +218,16 @@ describe('common utils', () => { }); test('should format complex json string', () => { - const input = `{"id": 123456789123456789123456789,"name": "Test 'JSON' Data with "quotes" — Pretty Print ","active": true,"price": 199.9999999,"decimals": 1.00,"nullValue": null,"unicodeText": "こんにちは世界 ","escapedCharacters": "Line1\nLine2\tTabbed\"Quoted\" and 'single quoted' with 'code' style","nestedObject": { "level1": { "level2": { "emptyArray": [], "specialChars": "@#$%^&*()_+-=[]{}|;':,./<>?~", "booleanValues": [ true, false, true ], "numbers": [ 0, -1, 1.23e10, 3.1415926535 ] } }},"mixedArray": [ "string with 'apostrophe'", 42, false, null, { "innerObj": { "keyWithQuotes": "value containing \`backticks\` and 'single quotes'", "nestedArray": [ { "a": "O'Reilly" }{ "b": "'inline code'" }, [ "deep", "array", { "c": "contains 'quotes'" } ] ] } }],"nonStringVariable": {{nonStringVar}},"withBrunoVariable": "{{string}} '{{with}}' "{{variety}}" of '{{variables}}'","dateExample": "2025-11-07T12:34:56Z","regexExample": "^([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$","urls": { "website": "https://example.com?param='value'&flag='true'", "escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'"},"multiLineString": "This is a long text\nthat spans multiple\nlines with \`backticks\` 'quotes' and 'code' snippets "}`; + const input = `{"id": 123456789123456789123456789,"name": "Test 'JSON' Data with \"quotes\" — Pretty Print ","active": true,"price": 199.9999999,"decimals": 1.00,"nullValue": null,"unicodeText": "こんにちは世界 ","escapedCharacters": "Line1\\nLine2\\tTabbed\"Quoted\" and 'single quoted' with 'code' style","nestedObject": { "level1": { "level2": { "emptyArray": [], "specialChars": "@#$%^&*()_+-=[]{}|;':,./<>?~", "booleanValues": [ true, false, true ], "numbers": [ 0, -1, 1.23e10, 3.1415926535 ] } }},"mixedArray": [ "string with 'apostrophe'", 42, false, null, { "innerObj": { "keyWithQuotes": "value containing \`backticks\` and 'single quotes'", "nestedArray": [ { "a": "O'Reilly" }{ "b": "'inline code'" }, [ "deep", "array", { "c": "contains 'quotes'" } ] ] } }],"nonStringVariable": {{nonStringVar}},"withBrunoVariable": "{{string}} '{{with}}' "{{variety}}" of '{{variables}}'","dateExample": "2025-11-07T12:34:56Z","regexExample": "^([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$","urls": { "website": "https://example.com?param='value'&flag='true'", "escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'"},"multiLineString": "This is a long text\\nthat spans multiple\\nlines with \`backticks\` 'quotes' and 'code' snippets "}`; const expectedOutput = `{ "id": 123456789123456789123456789, - "name": "Test 'JSON' Data with "quotes" — Pretty Print ", + "name": "Test 'JSON' Data with \"quotes\" — Pretty Print ", "active": true, "price": 199.9999999, "decimals": 1.00, "nullValue": null, "unicodeText": "こんにちは世界 ", - "escapedCharacters": "Line1\nLine2\tTabbed\"Quoted\" and 'single quoted' with 'code' style", + "escapedCharacters": "Line1\\nLine2\\tTabbed\"Quoted\" and 'single quoted' with 'code' style", "nestedObject": { "level1": { "level2": { @@ -280,7 +280,7 @@ describe('common utils', () => { "website": "https://example.com?param='value'&flag='true'", "escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'" }, - "multiLineString": "This is a long text\nthat spans multiple\nlines with \`backticks\` 'quotes' and 'code' snippets " + "multiLineString": "This is a long text\\nthat spans multiple\\nlines with \`backticks\` 'quotes' and 'code' snippets " }`; expect(prettifyJsonString(input)).toBe(expectedOutput); }); diff --git a/packages/bruno-app/src/utils/curl/content-type.js b/packages/bruno-app/src/utils/curl/content-type.js new file mode 100644 index 000000000..0a1e54610 --- /dev/null +++ b/packages/bruno-app/src/utils/curl/content-type.js @@ -0,0 +1,29 @@ +const normalizeContentType = (contentType) => { + if (!contentType || typeof contentType !== 'string') { + return ''; + } + + return contentType.toLowerCase(); +}; + +export const isJsonLikeContentType = (contentType) => { + const normalized = normalizeContentType(contentType); + + return normalized.includes('application/json') || normalized.includes('+json'); +}; + +export const isXmlLikeContentType = (contentType) => { + const normalized = normalizeContentType(contentType); + + return normalized.includes('application/xml') || normalized.includes('+xml') || normalized.includes('text/xml'); +}; + +export const isPlainTextContentType = (contentType) => { + const normalized = normalizeContentType(contentType); + + return normalized.includes('text/plain'); +}; + +export const isStructuredContentType = (contentType) => { + return isJsonLikeContentType(contentType) || isXmlLikeContentType(contentType) || isPlainTextContentType(contentType); +}; diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.js b/packages/bruno-app/src/utils/curl/curl-to-json.js index 21daf8283..24269f9a9 100644 --- a/packages/bruno-app/src/utils/curl/curl-to-json.js +++ b/packages/bruno-app/src/utils/curl/curl-to-json.js @@ -10,6 +10,7 @@ import parseCurlCommand from './parse-curl'; import * as querystring from 'query-string'; import * as jsesc from 'jsesc'; import { buildQueryString } from '@usebruno/common/utils'; +import { isStructuredContentType } from './content-type'; function getContentType(headers = {}) { const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type'); @@ -34,7 +35,7 @@ function getDataString(request) { const contentType = getContentType(request.headers); - if (contentType && (contentType.includes('application/json') || contentType.includes('application/xml') || contentType.includes('text/plain'))) { + if (isStructuredContentType(contentType)) { return { data: request.data }; } diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.spec.js b/packages/bruno-app/src/utils/curl/curl-to-json.spec.js index 058064391..c4133a3e0 100644 --- a/packages/bruno-app/src/utils/curl/curl-to-json.spec.js +++ b/packages/bruno-app/src/utils/curl/curl-to-json.spec.js @@ -120,4 +120,37 @@ describe('curlToJson', () => { ] }); }); + + it('should parse custom json content-types', () => { + const curlCommand = `curl 'https://api.example.com/test' + -H 'content-type: application/x.custom+json;version=1' + --data-raw '{"test":"data"}' + `; + + const result = curlToJson(curlCommand); + + expect(result).toEqual({ + url: 'https://api.example.com/test', + raw_url: 'https://api.example.com/test', + method: 'post', + headers: { + 'content-type': 'application/x.custom+json;version=1' + }, + data: '{"test":"data"}' + }); + }); + + it('should parse vendor tree json content-types', () => { + const curlCommand = `curl --request POST \\ + --url https://api.example.com/orders/42/preferences \\ + --header 'accept: */*' \\ + --header 'content-type: application/vnd.vendor+json' \\ + --data '{\\n "data": {\\n "type": "order-preferences",\\n "attributes": {\\n "notes": "Leave at door",\\n "priority": true\\n }\\n }\\n}'`; + + const result = curlToJson(curlCommand); + expect(result.data).toContain('"type": "order-preferences"'); + expect(result.data).toContain('"notes": "Leave at door"'); + expect(result.data).toContain('"priority": true'); + expect(result.headers['content-type']).toBe('application/vnd.vendor+json'); + }); }); diff --git a/packages/bruno-app/src/utils/curl/index.js b/packages/bruno-app/src/utils/curl/index.js index 3fa30a95f..866df7b32 100644 --- a/packages/bruno-app/src/utils/curl/index.js +++ b/packages/bruno-app/src/utils/curl/index.js @@ -1,6 +1,7 @@ import { forOwn } from 'lodash'; import curlToJson from './curl-to-json'; import { prettifyJsonString } from 'utils/common/index'; +import { isJsonLikeContentType, isPlainTextContentType, isXmlLikeContentType } from './content-type'; export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-request') => { const parseFormData = (parsedBody) => { @@ -59,25 +60,27 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque }; if (parsedBody && contentType && typeof contentType === 'string') { - if (requestType === 'graphql-request' && (contentType.includes('application/json') || contentType.includes('application/graphql'))) { + const normalizedContentType = contentType.toLowerCase(); + + if (requestType === 'graphql-request' && (isJsonLikeContentType(contentType) || normalizedContentType.includes('application/graphql'))) { body.mode = 'graphql'; body.graphql = parseGraphQL(parsedBody); } else if (requestType === 'http-request' && request.isDataBinary) { body.mode = 'file'; body.file = parsedBody; - }else if (contentType.includes('application/json')) { + } else if (isJsonLikeContentType(contentType)) { body.mode = 'json'; body.json = prettifyJsonString(parsedBody); - } else if (contentType.includes('xml')) { + } else if (isXmlLikeContentType(contentType) || normalizedContentType.includes('xml')) { body.mode = 'xml'; body.xml = parsedBody; - } else if (contentType.includes('application/x-www-form-urlencoded')) { + } else if (normalizedContentType.includes('application/x-www-form-urlencoded')) { body.mode = 'formUrlEncoded'; body.formUrlEncoded = parseFormData(parsedBody); - } else if (contentType.includes('multipart/form-data')) { + } else if (normalizedContentType.includes('multipart/form-data')) { body.mode = 'multipartForm'; body.multipartForm = parsedBody; - } else if (contentType.includes('text/plain')) { + } else if (isPlainTextContentType(contentType)) { body.mode = 'text'; body.text = parsedBody; } diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index c788a1a93..f5cfd7ba4 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -10,6 +10,7 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV if (response?.error) { resolve(response) } + resolve({ state: 'success', data: response.data, @@ -21,7 +22,7 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV statusText: response.statusText, duration: response.duration, timeline: response.timeline, - hasStreamRunning: response.hasStreamRunning + stream: response.stream }); }) .catch((err) => reject(err)); @@ -32,17 +33,19 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV export const sendGrpcRequest = async (item, collection, environment, runtimeVariables) => { return new Promise((resolve, reject) => { startGrpcRequest(item, collection, environment, runtimeVariables) - .then((initialState) => { - // Return an initial state object to update the UI - // The real response data will be handled by event listeners - resolve({ - ...initialState, - timeline: [] - }); - }) - .catch((err) => reject(err)); + .then((initialState) => { + // Return an initial state object to update the UI + // The real response data will be handled by event listeners + resolve({ + ...initialState, + timeline: [] + }); + }) + .catch((err) => reject(err)); }); -}; +} + + const sendHttpRequest = async (item, collection, environment, runtimeVariables) => { return new Promise((resolve, reject) => { @@ -82,19 +85,19 @@ export const startGrpcRequest = async (item, collection, environment, runtimeVar return new Promise((resolve, reject) => { const { ipcRenderer } = window; const request = item.draft ? item.draft : item; - + ipcRenderer.invoke('grpc:start-connection', { - request, - collection, - environment, + request, + collection, + environment, runtimeVariables }) - .then(() => { - resolve(); - }) - .catch((err) => { - reject(err); - }); + .then(() => { + resolve(); + }) + .catch(err => { + reject(err); + }); }); }; @@ -187,7 +190,7 @@ export const isGrpcConnectionActive = async (connectionId) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; ipcRenderer.invoke('grpc:is-connection-active', connectionId) - .then((response) => { + .then(response => { if (response.success) { resolve(response.isActive); } else { @@ -196,7 +199,7 @@ export const isGrpcConnectionActive = async (connectionId) => { resolve(false); } }) - .catch((err) => { + .catch(err => { console.error('Failed to check connection status:', err); // On error, assume the connection is not active resolve(false); @@ -214,14 +217,14 @@ export const isGrpcConnectionActive = async (connectionId) => { export const generateGrpcSampleMessage = async (methodPath, existingMessage = null, options = {}) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; - - ipcRenderer.invoke('grpc:generate-sample-message', { - methodPath, - existingMessage, - options + + ipcRenderer.invoke('grpc:generate-sample-message', { + methodPath, + existingMessage, + options }) - .then(resolve) - .catch(reject); + .then(resolve) + .catch(reject); }); }; diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 14956c035..4fa7c2274 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -69,8 +69,9 @@ const getJsSandboxRuntime = (collection) => { return 'vm2'; }; -const isStream = (headers) => { - return headers.get('content-type') === 'text/event-stream'; +const hasStreamHeaders = (headers) => { + const headerSplit = (headers.get('content-type') ?? '').split(';').map((d) => d.trim()); + return headerSplit.indexOf('text/event-stream') > -1; }; const promisifyStream = async (stream, abortController, closeOnFirst) => { @@ -95,18 +96,20 @@ const promisifyStream = async (stream, abortController, closeOnFirst) => { }); stream.on('close', doResolve); - stream.on('error', err => reject(err)); + stream.on('error', (err) => reject(err)); }); }; -const configureRequest = async (collectionUid, +const configureRequest = async ( + collectionUid, collection, request, envVars, runtimeVariables, processEnvVars, collectionPath, - globalEnvironmentVariables) => { + globalEnvironmentVariables +) => { const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; if (!protocolRegex.test(request.url)) { request.url = `http://${request.url}`; @@ -125,7 +128,7 @@ const configureRequest = async (collectionUid, // Get followRedirects setting, default to true for backward compatibility const followRedirects = request.settings?.followRedirects ?? true; - + // Get maxRedirects from request settings, fallback to request.maxRedirects, then default to 5 let requestMaxRedirects = request.settings?.maxRedirects ?? request.maxRedirects ?? 5; @@ -166,12 +169,14 @@ const configureRequest = async (collectionUid, request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header' && credentials?.access_token) { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim(); - } else { + } + else { try { const url = new URL(request.url); url?.searchParams?.set(tokenQueryKey, credentials?.access_token); request.url = url?.toString(); - } catch (error) {} + } + catch(error) {} } break; case 'implicit': @@ -180,7 +185,8 @@ const configureRequest = async (collectionUid, request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header') { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; - } else { + } + else { try { const url = new URL(request.url); url?.searchParams?.set(tokenQueryKey, credentials?.access_token); @@ -242,7 +248,8 @@ const configureRequest = async (collectionUid, if (preferencesUtil.shouldSendCookies()) { const cookieString = getCookieStringForUrl(request.url); if (cookieString && typeof cookieString === 'string' && cookieString.length) { - const existingCookieHeaderName = Object.keys(request.headers).find((name) => name.toLowerCase() === 'cookie' + const existingCookieHeaderName = Object.keys(request.headers).find( + name => name.toLowerCase() === 'cookie' ); const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : ''; @@ -306,7 +313,8 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col // Precedence: runtimeVars > requestVariables > folderVars > envVars > collectionVariables > globalEnvironmentVars const processEnvVars = getProcessEnvVars(collection.uid); - const resolvedVars = merge({}, + const resolvedVars = merge( + {}, globalEnvironmentVars, collectionVariables, envVars, @@ -319,7 +327,8 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col ...processEnvVars } } - }); + } + ); const collectionRoot = collection?.draft?.root || collection?.root || {}; const request = prepareGqlIntrospectionRequest(endpoint, resolvedVars, _request, collectionRoot); @@ -336,14 +345,16 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col const collectionPath = collection.pathname; - const axiosInstance = await configureRequest(collection.uid, + const axiosInstance = await configureRequest( + collection.uid, collection, request, envVars, collection.runtimeVariables, processEnvVars, collectionPath, - collection.globalEnvironmentVariables); + collection.globalEnvironmentVariables + ); const response = await axiosInstance(request); @@ -378,10 +389,10 @@ const registerNetworkIpc = (mainWindow) => { }; const notifyScriptExecution = ({ - channel, // 'main:run-request-event' | 'main:run-folder-event' - basePayload, // request-level or runner-level identifiers - scriptType, // 'pre-request' | 'post-response' | 'test' - error // optional Error + channel, // 'main:run-request-event' | 'main:run-folder-event' + basePayload, // request-level or runner-level identifiers + scriptType, // 'pre-request' | 'post-response' | 'test' + error // optional Error }) => { mainWindow.webContents.send(channel, { type: `${scriptType}-script-execution`, @@ -390,7 +401,8 @@ const registerNetworkIpc = (mainWindow) => { }); }; - const runPreRequest = async (request, + const runPreRequest = async ( + request, requestUid, envVars, collectionPath, @@ -399,10 +411,11 @@ const registerNetworkIpc = (mainWindow) => { runtimeVariables, processEnvVars, scriptingConfig, - runRequestByItemPathname) => { + runRequestByItemPathname + ) => { // run pre-request script let scriptResult; - const collectionName = collection?.name; + const collectionName = collection?.name const requestScript = get(request, 'script.req'); if (requestScript?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); @@ -480,7 +493,8 @@ const registerNetworkIpc = (mainWindow) => { return scriptResult; }; - const runPostResponse = async (request, + const runPostResponse = async ( + request, response, requestUid, envVars, @@ -537,7 +551,7 @@ const registerNetworkIpc = (mainWindow) => { // run post-response script const responseScript = get(request, 'script.res'); let scriptResult; - const collectionName = collection?.name; + const collectionName = collection?.name if (responseScript?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); scriptResult = await scriptRuntime.runResponseScript( @@ -612,9 +626,11 @@ const registerNetworkIpc = (mainWindow) => { const abortController = new AbortController(); const request = await prepareRequest(item, collection, abortController); request.__bruno__executionMode = 'standalone'; - request.responseType = "stream"; + request.responseType = 'stream'; + // flag to see if the stream needs to be handled as an actual stream or + // is it just a data stream from axios + let isResponseStream = false; const brunoConfig = getBrunoConfig(collectionUid, collection); - const scriptingConfig = get(brunoConfig, 'scripts', {}); scriptingConfig.runtime = getJsSandboxRuntime(collection); @@ -625,7 +641,8 @@ const registerNetworkIpc = (mainWindow) => { let preRequestScriptResult = null; let preRequestError = null; try { - preRequestScriptResult = await runPreRequest(request, + preRequestScriptResult = await runPreRequest( + request, requestUid, envVars, collectionPath, @@ -634,7 +651,8 @@ const registerNetworkIpc = (mainWindow) => { runtimeVariables, processEnvVars, scriptingConfig, - runRequestByItemPathname); + runRequestByItemPathname + ); } catch (error) { preRequestError = error; } @@ -659,14 +677,16 @@ const registerNetworkIpc = (mainWindow) => { if (preRequestError) { return Promise.reject(preRequestError); } - const axiosInstance = await configureRequest(collectionUid, + const axiosInstance = await configureRequest( + collectionUid, collection, request, envVars, runtimeVariables, processEnvVars, collectionPath, - collection.globalEnvironmentVariables); + collection.globalEnvironmentVariables + ); const { data: requestData, dataBuffer: requestDataBuffer } = parseDataFromRequest(request); @@ -685,7 +705,7 @@ const registerNetworkIpc = (mainWindow) => { data: requestData, dataBuffer: requestDataBuffer, timestamp: Date.now() - }; + } !runInBackground && mainWindow.webContents.send('main:run-request-event', { type: 'request-sent', @@ -707,13 +727,13 @@ const registerNetworkIpc = (mainWindow) => { }); } - let response, responseTime; + let response, responseTime, axiosDataStream; try { /** @type {import('axios').AxiosResponse} */ response = await axiosInstance(request); - request.isStream = isStream(response.headers); + isResponseStream = hasStreamHeaders(response.headers); - if (!request.isStream) { + if (!isResponseStream) { response.data = await promisifyStream(response.data); } @@ -740,9 +760,8 @@ const registerNetworkIpc = (mainWindow) => { // Prevents the duration on leaking to the actual result responseTime = response.headers.get('request-duration'); response.headers.delete('request-duration'); - - request.isStream = isStream(response.headers); - if (!request.isStream) { + isResponseStream = hasStreamHeaders(response.headers); + if (!isResponseStream) { response.data = await promisifyStream(response.data); } } else { @@ -755,21 +774,21 @@ const registerNetworkIpc = (mainWindow) => { statusText: error.statusText, error: error.message || ERROR_OCCURRED_WHILE_EXECUTING_REQUEST, timeline: error.timeline - }; + } } } // Continue with the rest of the request lifecycle - post response vars, script, assertions, tests - - if (request.isStream) { - response.stream = response.data; + if (isResponseStream) { + axiosDataStream = response.data; } - const { data, dataBuffer } = request.isStream - ? { data: '', dataBuffer: new ArrayBuffer(0) } + const { data, dataBuffer } = isResponseStream + ? { data: '', dataBuffer: Buffer.alloc(0) } : parseDataFromResponse(response, request.__brunoDisableParsingResponseJson); - response.data = data; + response.dataBuffer = dataBuffer; + response.responseTime = responseTime; // save cookies @@ -783,9 +802,9 @@ const registerNetworkIpc = (mainWindow) => { mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies))); cookiesStore.saveCookieJar(); - let postResponseScriptResult = null; - let postResponseError = null; const runPostScripts = async () => { + let postResponseScriptResult = null; + let postResponseError = null; try { postResponseScriptResult = await runPostResponse(request, response, @@ -914,9 +933,8 @@ const registerNetworkIpc = (mainWindow) => { cookiesStore.saveCookieJar(); } }; - - if (request.isStream) { - response.stream.on('close', () => runPostScripts().then()); + if (isResponseStream) { + axiosDataStream.on('close', () => runPostScripts().then()); } else { await runPostScripts(); } @@ -926,8 +944,10 @@ const registerNetworkIpc = (mainWindow) => { statusText: response.statusText, headers: response.headers, data: response.data, - stream: request.isStream ? response.stream : null, + dataBuffer: response.dataBuffer.toString('base64'), + stream: isResponseStream ? axiosDataStream : null, cancelTokenUid: cancelTokenUid, + size: Buffer.byteLength(response.dataBuffer), duration: responseTime ?? 0, url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null, timeline: response.timeline @@ -953,12 +973,11 @@ const registerNetworkIpc = (mainWindow) => { const response = await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false }); if (response.stream) { const stream = response.stream; - response.stream = undefined; - response.hasStreamRunning = response.status >= 200 && response.status < 300; + response.stream = { running: response.status >= 200 && response.status < 300 }; - stream.on('data', newData => { + stream.on('data', (newData) => { const parsed = parseDataFromResponse({ data: newData, headers: {} }); - mainWindow.webContents.send('main:http-stream-new-data', {collectionUid, itemUid: item.uid, data: parsed}); + mainWindow.webContents.send('main:http-stream-new-data', { collectionUid, itemUid: item.uid, data: parsed }); }); stream.on('close', () => { @@ -966,7 +985,7 @@ const registerNetworkIpc = (mainWindow) => { return; } - mainWindow.webContents.send('main:http-stream-end', {collectionUid, itemUid: item.uid}); + mainWindow.webContents.send('main:http-stream-end', { collectionUid, itemUid: item.uid }); deleteCancelToken(response.cancelTokenUid); }); } @@ -990,7 +1009,7 @@ const registerNetworkIpc = (mainWindow) => { if (cancelTokenUid && cancelTokens[cancelTokenUid]) { const abortController = cancelTokens[cancelTokenUid]; deleteCancelToken(cancelTokenUid); - abortController.abort(); // Ensure the on stream end event is called after the token is deleted + abortController.abort(); resolve(); } else { reject(new Error('cancel token not found')); @@ -999,7 +1018,7 @@ const registerNetworkIpc = (mainWindow) => { }); // handler for fetch-gql-schema - ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler); + ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler) ipcMain.handle( 'renderer:run-collection-folder', @@ -1033,7 +1052,7 @@ const registerNetworkIpc = (mainWindow) => { } const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname)); if(_item) { - const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true }); + const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true }); resolve(res); } reject(`bru.runRequest: invalid request path - ${itemPathname}`); @@ -1065,8 +1084,9 @@ const registerNetworkIpc = (mainWindow) => { } }); + // sort requests by seq property - folderRequests = sortByNameThenSequence(folderRequests); + folderRequests = sortByNameThenSequence(folderRequests) } // Filter requests based on tags @@ -1075,7 +1095,7 @@ const registerNetworkIpc = (mainWindow) => { const excludeTags = tags.exclude ? tags.exclude : []; folderRequests = folderRequests.filter(({ tags: requestTags = [], draft }) => { requestTags = draft?.tags || requestTags || []; - return isRequestTagsIncluded(requestTags, includeTags, excludeTags); + return isRequestTagsIncluded(requestTags, includeTags, excludeTags) }); } @@ -1128,14 +1148,15 @@ const registerNetworkIpc = (mainWindow) => { const request = await prepareRequest(item, collection, abortController); request.__bruno__executionMode = 'runner'; - + const requestUid = uuid(); try { let preRequestScriptResult; let preRequestError = null; try { - preRequestScriptResult = await runPreRequest(request, + preRequestScriptResult = await runPreRequest( + request, requestUid, envVars, collectionPath, @@ -1144,7 +1165,8 @@ const registerNetworkIpc = (mainWindow) => { runtimeVariables, processEnvVars, scriptingConfig, - runRequestByItemPathname); + runRequestByItemPathname + ); } catch (error) { console.error('Pre-request script error:', error); preRequestError = error; @@ -1214,7 +1236,7 @@ const registerNetworkIpc = (mainWindow) => { data: requestData, dataBuffer: requestDataBuffer, timestamp: Date.now() - }; + } // todo: // i have no clue why electron can't send the request object @@ -1228,8 +1250,8 @@ const registerNetworkIpc = (mainWindow) => { currentAbortController = new AbortController(); request.signal = currentAbortController.signal; request.responseType = 'stream'; - - const axiosInstance = await configureRequest(collectionUid, + const axiosInstance = await configureRequest( + collectionUid, collection, request, envVars, @@ -1246,7 +1268,7 @@ const registerNetworkIpc = (mainWindow) => { collectionUid, credentialsId: request?.oauth2Credentials?.credentialsId, ...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }), - debugInfo: request?.oauth2Credentials?.debugInfo + debugInfo: request?.oauth2Credentials?.debugInfo, }); collection.oauth2Credentials = updateCollectionOauth2Credentials({ @@ -1274,10 +1296,7 @@ const registerNetworkIpc = (mainWindow) => { /** @type {import('axios').AxiosResponse} */ response = await axiosInstance(request); - - request.isStream = isStream(response.headers); - response.data = await promisifyStream(response.data, currentAbortController, true); - + response.data = await promisifyStream(response.data, currentAbortController, false); timeEnd = Date.now(); const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson); @@ -1319,9 +1338,7 @@ const registerNetworkIpc = (mainWindow) => { } if (error?.response) { - request.isStream = isStream(error.response.headers); error.response.data = await promisifyStream(error.response.data, currentAbortController, true); - const { data, dataBuffer } = parseDataFromResponse(error.response); error.response.responseTime = error.response.headers.get('request-duration'); error.response.headers.delete('request-duration'); @@ -1338,7 +1355,7 @@ const registerNetworkIpc = (mainWindow) => { size: Buffer.byteLength(dataBuffer), data: error.response.data, responseTime: error.response.responseTime, - timeline: error.response.timeline + timeline: error.response.timeline, }; // if we get a response from the server, we consider it as a success @@ -1359,7 +1376,8 @@ const registerNetworkIpc = (mainWindow) => { let postResponseScriptResult; let postResponseError = null; try { - postResponseScriptResult = await runPostResponse(request, + postResponseScriptResult = await runPostResponse( + request, response, requestUid, envVars, @@ -1369,7 +1387,8 @@ const registerNetworkIpc = (mainWindow) => { runtimeVariables, processEnvVars, scriptingConfig, - runRequestByItemPathname); + runRequestByItemPathname + ); } catch (error) { console.error('Post-response script error:', error); postResponseError = error; @@ -1406,12 +1425,14 @@ const registerNetworkIpc = (mainWindow) => { const assertions = get(item, 'request.assertions'); if (assertions) { const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime }); - const results = assertRuntime.runAssertions(assertions, + const results = assertRuntime.runAssertions( + assertions, request, response, envVars, runtimeVariables, - processEnvVars); + processEnvVars + ); mainWindow.webContents.send('main:run-folder-event', { type: 'assertion-results', @@ -1422,14 +1443,15 @@ const registerNetworkIpc = (mainWindow) => { } const testFile = get(request, 'tests'); - const collectionName = collection?.name; + const collectionName = collection?.name if (typeof testFile === 'string') { let testResults = null; let testError = null; try { const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime }); - testResults = await testRuntime.runTests(decomment(testFile), + testResults = await testRuntime.runTests( + decomment(testFile), request, response, envVars, @@ -1439,10 +1461,11 @@ const registerNetworkIpc = (mainWindow) => { processEnvVars, scriptingConfig, runRequestByItemPathname, - collectionName); + collectionName + ); } catch (error) { testError = error; - + if (error.partialResults) { testResults = error.partialResults; } else { @@ -1476,7 +1499,7 @@ const registerNetworkIpc = (mainWindow) => { mainWindow.webContents.send('main:global-environment-variables-update', { globalEnvironmentVariables: testResults.globalEnvironmentVariables }); - + collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables; notifyScriptExecution({ @@ -1505,7 +1528,7 @@ const registerNetworkIpc = (mainWindow) => { collectionUid, folderUid, statusText: 'collection run was terminated!', - runCompletionTime: new Date().toISOString() + runCompletionTime: new Date().toISOString(), }); break; } @@ -1522,7 +1545,7 @@ const registerNetworkIpc = (mainWindow) => { if (nextRequestIdx >= 0) { currentRequestIndex = nextRequestIdx; } else { - console.error('Could not find request with name \'' + nextRequestName + '\''); + console.error("Could not find request with name '" + nextRequestName + "'"); currentRequestIndex++; } } else { @@ -1535,10 +1558,10 @@ const registerNetworkIpc = (mainWindow) => { type: 'testrun-ended', collectionUid, folderUid, - runCompletionTime: new Date().toISOString() + runCompletionTime: new Date().toISOString(), }); } catch (error) { - console.log('error', error); + console.log("error", error); deleteCancelToken(cancelTokenUid); mainWindow.webContents.send('main:run-folder-event', { type: 'testrun-ended', @@ -1635,13 +1658,14 @@ const executeRequestOnFailHandler = async (request, error) => { } }; + const registerAllNetworkIpc = (mainWindow) => { registerNetworkIpc(mainWindow); registerGrpcEventHandlers(mainWindow); registerWsEventHandlers(mainWindow); -}; +} -module.exports = registerAllNetworkIpc; +module.exports = registerAllNetworkIpc module.exports.configureRequest = configureRequest; module.exports.getCertsAndProxyConfig = getCertsAndProxyConfig; module.exports.fetchGqlSchemaHandler = fetchGqlSchemaHandler; diff --git a/publishing.md b/publishing.md index 458077b20..cfac63958 100644 --- a/publishing.md +++ b/publishing.md @@ -10,6 +10,7 @@ | [正體中文](docs/publishing/publishing_zhtw.md) | [日本語](docs/publishing/publishing_ja.md) | [Nederlands](docs/publishing/publishing_nl.md) +| [فارسی](docs/publishing/publishing_fa.md) ### Publishing Bruno to a new package manager diff --git a/readme.md b/readme.md index 8a1bad84c..ea55920ed 100644 --- a/readme.md +++ b/readme.md @@ -29,6 +29,7 @@ | [日本語](docs/readme/readme_ja.md) | [ქართული](docs/readme/readme_ka.md) | [Nederlands](docs/readme/readme_nl.md) +| [فارسی](docs/readme/readme_fa.md) Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there. @@ -52,6 +53,7 @@ We strive to strike a harmonious balance between [open-source principles and sus You can explore our [paid versions](https://www.usebruno.com/pricing) to see if there are additional features that you or your team may find useful!
## Table of Contents + - [Installation](#installation) - [Features](#features) - [Run across multiple platforms 🖥️](#run-across-multiple-platforms-%EF%B8%8F) diff --git a/tests/response/json-response-formatting/fixtures/collection/request.bru b/tests/response/json-response-formatting/fixtures/collection/request.bru index 3b27e47e1..46442f2e4 100644 --- a/tests/response/json-response-formatting/fixtures/collection/request.bru +++ b/tests/response/json-response-formatting/fixtures/collection/request.bru @@ -13,6 +13,7 @@ post { body:json { { "bigint": 1736184243098437392, - "unicode": ["\u4e00","\u4e8c","\u4e09"] + "unicode": ["\u4e00","\u4e8c","\u4e09"], + "forwardslashes": "\/url\/path\/" } } diff --git a/tests/response/json-response-formatting/json-response-formatting.spec.ts b/tests/response/json-response-formatting/json-response-formatting.spec.ts index 7f77d738f..29154d762 100644 --- a/tests/response/json-response-formatting/json-response-formatting.spec.ts +++ b/tests/response/json-response-formatting/json-response-formatting.spec.ts @@ -35,6 +35,9 @@ test.describe.serial('JSON Response Formatting', () => { await expect(responseBody).toContainText('一'); await expect(responseBody).toContainText('二'); await expect(responseBody).toContainText('三'); + + // The response should handle escaped forward slashes + await expect(responseBody).toContainText('/url/path/'); }); }); }); From 0a188575a0ce97926b50485db8b5e09457c067a4 Mon Sep 17 00:00:00 2001 From: Sid Date: Mon, 17 Nov 2025 13:03:11 +0530 Subject: [PATCH 3/3] fix: update request cancel icon --- .../bruno-app/src/components/RequestPane/QueryUrl/index.js | 6 ++++-- packages/bruno-app/src/themes/dark.js | 1 + packages/bruno-app/src/themes/light.js | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js index c83977ef8..b5c761a7c 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js @@ -81,7 +81,9 @@ const QueryUrl = ({ item, collection, handleRun }) => { } }; - const handleCancelRequest = () => { + const handleCancelRequest = (e) => { + e.preventDefault(); + e.stopPropagation(); dispatch(cancelRequest(item.cancelTokenUid, item, collection)); }; @@ -148,7 +150,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
{isLoading || item.response?.stream?.running ? (