Skip to content

Commit 9caef9e

Browse files
Fix: WS variable interpolation (usebruno#6184)
* feat: add variable interpolation support for WebSocket requests - Add WebSocket body interpolation in interpolateVars function - Interpolate URL, headers, and all messages in request.body.ws array with full variable context - Refactor sendWsRequest to use main process preparation (removes duplication) - Add mode property to wsRequest object for proper request type detection - Ensure consistent variable precedence matching HTTP/gRPC requests - Centralize all interpolation logic in main process via prepareWsRequest * Add Playwright tests for WebSocket variable interpolation - Add tests for URL interpolation (wss://echo.{{url}}.org) - Add tests for message content interpolation ({"test": "{{data}}"}) - Update test fixtures to use wss://echo.websocket.org echo server - Add WEBSOCKET_FLOWS.md documentation - Refactor queueWsMessage to handle variable interpolation in main process * removed ws flow documentation * chore: updated the network/index.js file to reduce merge conflicts by moving around code * fix: added collection and item to WsQueryUrl Editor to fix available variable highlight * chore: remove unnecessary whitespace in WebSocket event handlers * feat: add automatic WebSocket reconnection on URL variable changes - Detect changes to interpolated URL (including variable changes) - Automatically disconnect and reconnect when interpolated URL changes - Add debouncing (400ms) to prevent excessive reconnections - Track previous interpolated URL to avoid unnecessary reconnects - Store interpolated URL when connection becomes active - Improve error handling and cleanup * chore: removing diff * Add WebSocket connection status IPC method - Add connectionStatus() method to WsClient that returns detailed status ('disconnected', 'connecting', 'connected') instead of boolean - Add renderer:ws:connection-status IPC handler in electron layer - Add getWsConnectionStatus() utility function in network utils - Provides more granular connection state information for UI components * refactor: improve WebSocket connection status tracking in WsQueryUrl - Replace boolean isConnectionActive with connectionStatus state ('disconnected', 'connecting', 'connected') - Add useWsConnectionStatus hook to poll connection status every 2 seconds - Refactor connection handlers: handleConnect, handleDisconnect, handleReconnect - Update to use getWsConnectionStatus instead of isWsConnectionActive for more granular status - Improve reconnect logic to handle URL variable interpolation changes - Add proper connection status indicators in UI (connecting state with pulse animation) * fix: improve WebSocket URL handling and request initialization - Fix WebSocket URL state management by reading directly from item instead of local state - Add handleUrlChange function to properly dispatch URL changes - Fix interpolated URL change detection logic in useEffect - Initialize params array for new WebSocket requests to prevent undefined errors - Ensure params array is initialized when URL changes in draft/request - Remove console.log statements and unused imports - Update persistence test replacement URL to avoid port conflicts These changes ensure WebSocket requests properly handle URL changes and maintain consistent state between draft and saved requests. * feat: refactor WebSocket connection status handling --------- Co-authored-by: Sid <siddharth@usebruno.com>
1 parent 8930580 commit 9caef9e

File tree

7 files changed

+128
-56
lines changed

7 files changed

+128
-56
lines changed

packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js

Lines changed: 84 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,94 @@
11
import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
2-
import { IconWebSocket } from 'components/Icons/Grpc';
32
import classnames from 'classnames';
43
import SingleLineEditor from 'components/SingleLineEditor/index';
54
import { requestUrlChanged } from 'providers/ReduxStore/slices/collections';
65
import { wsConnectOnly, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
76
import { useTheme } from 'providers/Theme';
8-
import React, { useEffect, useState, useMemo } from 'react';
7+
import React, { useEffect, useState, useMemo, useRef } from 'react';
98
import toast from 'react-hot-toast';
109
import { useDispatch } from 'react-redux';
11-
import { getPropertyFromDraftOrRequest } from 'utils/collections';
1210
import { isMacOS } from 'utils/common/platform';
1311
import { hasRequestChanges } from 'utils/collections';
14-
import { closeWsConnection, isWsConnectionActive } from 'utils/network/index';
12+
import { closeWsConnection, getWsConnectionStatus } from 'utils/network/index';
1513
import StyledWrapper from './StyledWrapper';
14+
import { interpolateUrl } from 'utils/url';
15+
import { getAllVariables } from 'utils/collections';
16+
import useDebounce from 'hooks/useDebounce';
1617
import get from 'lodash/get';
1718

19+
const CONNECTION_STATUS = {
20+
CONNECTING: 'connecting',
21+
CONNECTED: 'connected',
22+
DISCONNECTED: 'disconnected'
23+
};
24+
25+
const useWsConnectionStatus = (requestId) => {
26+
const [connectionStatus, setConnectionStatus] = useState(CONNECTION_STATUS.DISCONNECTED);
27+
useEffect(() => {
28+
const checkConnectionStatus = async () => {
29+
const result = await getWsConnectionStatus(requestId);
30+
setConnectionStatus(result?.status ?? CONNECTION_STATUS.DISCONNECTED);
31+
};
32+
checkConnectionStatus();
33+
const interval = setInterval(checkConnectionStatus, 2000);
34+
return () => clearInterval(interval);
35+
}, [requestId]);
36+
return [connectionStatus, setConnectionStatus];
37+
};
38+
1839
const WsQueryUrl = ({ item, collection, handleRun }) => {
1940
const dispatch = useDispatch();
2041
const { theme, displayedTheme } = useTheme();
21-
const [isConnectionActive, setIsConnectionActive] = useState(false);
2242
// TODO: reaper, better state for connecting
23-
const [isConnecting, setIsConnecting] = useState(false);
24-
const url = getPropertyFromDraftOrRequest(item, 'request.url');
25-
const response = item.draft ? get(item, 'draft.response', {}) : get(item, 'response', {});
2643
const saveShortcut = isMacOS() ? '⌘S' : 'Ctrl+S';
2744
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
2845

29-
const showConnectingPulse = isConnecting && response.status !== 'CLOSED';
46+
const [connectionStatus, setConnectionStatus] = useWsConnectionStatus(item.uid);
47+
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
3048

31-
// Check connection status
32-
useEffect(() => {
33-
const checkConnectionStatus = async () => {
34-
try {
35-
const result = await isWsConnectionActive(item.uid);
36-
const active = Boolean(result.isActive);
37-
setIsConnectionActive(active);
38-
setIsConnecting(false);
39-
} catch (error) {
40-
setIsConnectionActive(false);
41-
setIsConnecting(false);
42-
}
43-
};
49+
const allVariables = useMemo(() => {
50+
return getAllVariables(collection, item);
51+
}, [collection, item]);
4452

45-
checkConnectionStatus();
46-
const interval = setInterval(checkConnectionStatus, 2000);
47-
return () => clearInterval(interval);
48-
}, [item.uid]);
53+
const interpolatedURL = useMemo(() => {
54+
if (!url) return '';
55+
return interpolateUrl({ url, variables: allVariables }) || '';
56+
}, [url, allVariables]);
4957

50-
const onUrlChange = (value) => {
51-
closeWsConnection(item.uid);
52-
dispatch(requestUrlChanged({
53-
url: value,
54-
itemUid: item.uid,
55-
collectionUid: collection.uid
56-
}));
57-
};
58+
// Debounce interpolated URL to avoid excessive reconnections
59+
const debouncedInterpolatedURL = useDebounce(interpolatedURL, 400);
60+
const previousDeboundedInterpolatedURL = useRef(debouncedInterpolatedURL);
5861

59-
const handleCloseConnection = (e) => {
60-
e.stopPropagation();
62+
const handleConnect = async () => {
63+
dispatch(wsConnectOnly(item, collection.uid));
64+
previousDeboundedInterpolatedURL.current = debouncedInterpolatedURL;
65+
};
6166

67+
const handleDisconnect = async (e, notify) => {
68+
e && e.stopPropagation();
6269
closeWsConnection(item.uid)
6370
.then(() => {
64-
toast.success('WebSocket connection closed');
65-
setIsConnectionActive(false);
66-
setIsConnecting(false);
71+
notify && toast.success('WebSocket connection closed');
72+
setConnectionStatus('disconnected');
6773
})
6874
.catch((err) => {
6975
console.error('Failed to close WebSocket connection:', err);
70-
toast.error('Failed to close WebSocket connection');
76+
notify && toast.error('Failed to close WebSocket connection');
7177
});
7278
};
7379

80+
const handleReconnect = async (e) => {
81+
e && e.stopPropagation();
82+
try {
83+
handleDisconnect(e, false);
84+
setTimeout(() => {
85+
handleConnect(e, false);
86+
}, 2000);
87+
} catch (err) {
88+
console.error('Failed to re-connect WebSocket connection', err);
89+
}
90+
};
91+
7492
const handleRunClick = async (e) => {
7593
e.stopPropagation();
7694
if (!url) {
@@ -80,15 +98,28 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
8098
handleRun(e);
8199
};
82100

83-
const handleConnect = (e) => {
84-
setIsConnecting(true);
85-
dispatch(wsConnectOnly(item, collection.uid));
86-
};
87-
88101
const onSave = (finalValue) => {
89102
dispatch(saveRequest(item.uid, collection.uid));
90103
};
91104

105+
const handleUrlChange = (value) => {
106+
const finalUrl = value?.trim() ?? value;
107+
console.log('finalUrl: ', finalUrl);
108+
dispatch(requestUrlChanged({
109+
itemUid: item.uid,
110+
collectionUid: collection.uid,
111+
url: finalUrl
112+
}));
113+
};
114+
115+
// Detect interpolated URL changes and reconnect if connection is active
116+
useEffect(() => {
117+
if (connectionStatus !== 'connected') return;
118+
if (previousDeboundedInterpolatedURL.current === debouncedInterpolatedURL) return;
119+
if (debouncedInterpolatedURL === '') return;
120+
handleReconnect();
121+
}, [debouncedInterpolatedURL, connectionStatus]);
122+
92123
return (
93124
<StyledWrapper>
94125
<div className="flex items-center h-full">
@@ -99,7 +130,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
99130
<SingleLineEditor
100131
value={url}
101132
onSave={(finalValue) => onSave(finalValue)}
102-
onChange={onUrlChange}
133+
onChange={handleUrlChange}
103134
placeholder="ws://localhost:8080 or wss://example.com"
104135
className="w-full"
105136
theme={displayedTheme}
@@ -127,9 +158,9 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
127158
</span>
128159
</div>
129160

130-
{isConnectionActive && (
161+
{connectionStatus === 'connected' && (
131162
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
132-
<div className="infotip" onClick={handleCloseConnection}>
163+
<div className="infotip" onClick={(e) => handleDisconnect(e, true)}>
133164
<IconPlugConnectedX
134165
color={theme.colors.text.danger}
135166
strokeWidth={1.5}
@@ -141,15 +172,13 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
141172
</div>
142173
)}
143174

144-
{!isConnectionActive && (
175+
{connectionStatus !== 'connected' && (
145176
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
146177
<div className="infotip" onClick={handleConnect}>
147178
<IconPlugConnected
148-
className={
149-
classnames('cursor-pointer', {
150-
'animate-pulse': showConnectingPulse
151-
})
152-
}
179+
className={classnames('cursor-pointer', {
180+
'animate-pulse': connectionStatus === CONNECTION_STATUS.CONNECTING
181+
})}
153182
color={theme.colors.text.green}
154183
strokeWidth={1.5}
155184
size={22}
@@ -166,7 +195,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
166195
</div>
167196
</div>
168197

169-
{isConnectionActive && <div className="connection-status-strip"></div>}
198+
{connectionStatus === CONNECTION_STATUS.CONNECTED && <div className="connection-status-strip"></div>}
170199
</StyledWrapper>
171200
);
172201
};

packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1469,6 +1469,7 @@ export const newWsRequest = (params) => (dispatch, getState) => {
14691469
request: {
14701470
url: requestUrl,
14711471
method: requestMethod,
1472+
params: [],
14721473
body: body ?? {
14731474
mode: 'ws',
14741475
ws: [

packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,8 @@ export const collectionsSlice = createSlice({
780780
item.draft = cloneDeep(item);
781781
}
782782
item.draft.request.url = action.payload.url;
783+
item.draft.request.params = item?.draft?.request?.params ?? [];
784+
item.request.params = item?.request?.params ?? [];
783785

784786
const parts = splitOnFirst(item?.draft?.request?.url, '?');
785787
const urlQueryParams = parseQueryParams(parts[1]);

packages/bruno-app/src/utils/network/index.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,15 @@ export const isWsConnectionActive = async (requestId) => {
345345
ipcRenderer.invoke('renderer:ws:is-connection-active', requestId).then(resolve).catch(reject);
346346
});
347347
};
348+
349+
/**
350+
* Get the connection status of a WebSocket connection
351+
* @param {string} requestId - The request ID to get the connection status of
352+
* @returns {Promise<Object>} - The result of the get operation
353+
*/
354+
export const getWsConnectionStatus = async (requestId) => {
355+
return new Promise((resolve, reject) => {
356+
const { ipcRenderer } = window;
357+
ipcRenderer.invoke('renderer:ws:connection-status', requestId).then(resolve).catch(reject);
358+
});
359+
};

packages/bruno-electron/src/ipc/network/ws-event-handlers.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,21 @@ const registerWsEventHandlers = (window) => {
349349
return { success: false, error: error.message, isActive: false };
350350
}
351351
});
352+
353+
/**
354+
* Get the connection status of a connection
355+
* @param {string} requestId - The request ID to get the connection status of
356+
* @returns {string} - The connection status
357+
*/
358+
ipcMain.handle('renderer:ws:connection-status', (event, requestId) => {
359+
try {
360+
const status = wsClient.connectionStatus(requestId);
361+
return { success: true, status };
362+
} catch (error) {
363+
console.error('Error getting WebSocket connection status:', error);
364+
return { success: false, error: error.message, status: 'disconnected' };
365+
}
366+
});
352367
};
353368

354369
module.exports = {

packages/bruno-requests/src/ws/ws-client.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,19 @@ class WsClient {
360360
});
361361
}
362362
}
363+
364+
/**
365+
* Get the connection status of a connection
366+
* @param {string} requestId - The request ID to get the connection status of
367+
* @returns {string} - The connection status
368+
*/
369+
// Returns "disconnected", "connecting", "connected"
370+
connectionStatus(requestId) {
371+
const connectionMeta = this.activeConnections.get(requestId);
372+
if (connectionMeta?.connection?.readyState === ws.WebSocket.CONNECTING) return 'connecting';
373+
if (connectionMeta?.connection?.readyState === ws.WebSocket.OPEN) return 'connected';
374+
return 'disconnected';
375+
}
363376
}
364377

365378
export { WsClient };

tests/websockets/persistence.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ test.describe.serial('persistence', () => {
3535
});
3636

3737
test('save new websocket url', async ({ pageWithUserData: page }) => {
38-
const replacementUrl = 'ws://localhost:8082';
38+
const replacementUrl = 'ws://localhost:8083';
3939
const locators = buildWebsocketCommonLocators(page);
4040

4141
const clearText = async (text: string) => {

0 commit comments

Comments
 (0)