Fix: WS variable interpolation (#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>
This commit is contained in:
Chirag Chandrashekhar
2025-12-03 15:52:52 +05:30
committed by GitHub
parent 893058067d
commit 9caef9e573
7 changed files with 128 additions and 56 deletions

View File

@@ -1,76 +1,94 @@
import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
import { IconWebSocket } from 'components/Icons/Grpc';
import classnames from 'classnames';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { requestUrlChanged } from 'providers/ReduxStore/slices/collections';
import { wsConnectOnly, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useRef } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { getPropertyFromDraftOrRequest } from 'utils/collections';
import { isMacOS } from 'utils/common/platform';
import { hasRequestChanges } from 'utils/collections';
import { closeWsConnection, isWsConnectionActive } from 'utils/network/index';
import { closeWsConnection, getWsConnectionStatus } from 'utils/network/index';
import StyledWrapper from './StyledWrapper';
import { interpolateUrl } from 'utils/url';
import { getAllVariables } from 'utils/collections';
import useDebounce from 'hooks/useDebounce';
import get from 'lodash/get';
const CONNECTION_STATUS = {
CONNECTING: 'connecting',
CONNECTED: 'connected',
DISCONNECTED: 'disconnected'
};
const useWsConnectionStatus = (requestId) => {
const [connectionStatus, setConnectionStatus] = useState(CONNECTION_STATUS.DISCONNECTED);
useEffect(() => {
const checkConnectionStatus = async () => {
const result = await getWsConnectionStatus(requestId);
setConnectionStatus(result?.status ?? CONNECTION_STATUS.DISCONNECTED);
};
checkConnectionStatus();
const interval = setInterval(checkConnectionStatus, 2000);
return () => clearInterval(interval);
}, [requestId]);
return [connectionStatus, setConnectionStatus];
};
const WsQueryUrl = ({ item, collection, handleRun }) => {
const dispatch = useDispatch();
const { theme, displayedTheme } = useTheme();
const [isConnectionActive, setIsConnectionActive] = useState(false);
// TODO: reaper, better state for connecting
const [isConnecting, setIsConnecting] = useState(false);
const url = getPropertyFromDraftOrRequest(item, 'request.url');
const response = item.draft ? get(item, 'draft.response', {}) : get(item, 'response', {});
const saveShortcut = isMacOS() ? '⌘S' : 'Ctrl+S';
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
const showConnectingPulse = isConnecting && response.status !== 'CLOSED';
const [connectionStatus, setConnectionStatus] = useWsConnectionStatus(item.uid);
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
// Check connection status
useEffect(() => {
const checkConnectionStatus = async () => {
try {
const result = await isWsConnectionActive(item.uid);
const active = Boolean(result.isActive);
setIsConnectionActive(active);
setIsConnecting(false);
} catch (error) {
setIsConnectionActive(false);
setIsConnecting(false);
}
};
const allVariables = useMemo(() => {
return getAllVariables(collection, item);
}, [collection, item]);
checkConnectionStatus();
const interval = setInterval(checkConnectionStatus, 2000);
return () => clearInterval(interval);
}, [item.uid]);
const interpolatedURL = useMemo(() => {
if (!url) return '';
return interpolateUrl({ url, variables: allVariables }) || '';
}, [url, allVariables]);
const onUrlChange = (value) => {
closeWsConnection(item.uid);
dispatch(requestUrlChanged({
url: value,
itemUid: item.uid,
collectionUid: collection.uid
}));
// Debounce interpolated URL to avoid excessive reconnections
const debouncedInterpolatedURL = useDebounce(interpolatedURL, 400);
const previousDeboundedInterpolatedURL = useRef(debouncedInterpolatedURL);
const handleConnect = async () => {
dispatch(wsConnectOnly(item, collection.uid));
previousDeboundedInterpolatedURL.current = debouncedInterpolatedURL;
};
const handleCloseConnection = (e) => {
e.stopPropagation();
const handleDisconnect = async (e, notify) => {
e && e.stopPropagation();
closeWsConnection(item.uid)
.then(() => {
toast.success('WebSocket connection closed');
setIsConnectionActive(false);
setIsConnecting(false);
notify && toast.success('WebSocket connection closed');
setConnectionStatus('disconnected');
})
.catch((err) => {
console.error('Failed to close WebSocket connection:', err);
toast.error('Failed to close WebSocket connection');
notify && toast.error('Failed to close WebSocket connection');
});
};
const handleReconnect = async (e) => {
e && e.stopPropagation();
try {
handleDisconnect(e, false);
setTimeout(() => {
handleConnect(e, false);
}, 2000);
} catch (err) {
console.error('Failed to re-connect WebSocket connection', err);
}
};
const handleRunClick = async (e) => {
e.stopPropagation();
if (!url) {
@@ -80,15 +98,28 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
handleRun(e);
};
const handleConnect = (e) => {
setIsConnecting(true);
dispatch(wsConnectOnly(item, collection.uid));
};
const onSave = (finalValue) => {
dispatch(saveRequest(item.uid, collection.uid));
};
const handleUrlChange = (value) => {
const finalUrl = value?.trim() ?? value;
console.log('finalUrl: ', finalUrl);
dispatch(requestUrlChanged({
itemUid: item.uid,
collectionUid: collection.uid,
url: finalUrl
}));
};
// Detect interpolated URL changes and reconnect if connection is active
useEffect(() => {
if (connectionStatus !== 'connected') return;
if (previousDeboundedInterpolatedURL.current === debouncedInterpolatedURL) return;
if (debouncedInterpolatedURL === '') return;
handleReconnect();
}, [debouncedInterpolatedURL, connectionStatus]);
return (
<StyledWrapper>
<div className="flex items-center h-full">
@@ -99,7 +130,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
<SingleLineEditor
value={url}
onSave={(finalValue) => onSave(finalValue)}
onChange={onUrlChange}
onChange={handleUrlChange}
placeholder="ws://localhost:8080 or wss://example.com"
className="w-full"
theme={displayedTheme}
@@ -127,9 +158,9 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
</span>
</div>
{isConnectionActive && (
{connectionStatus === 'connected' && (
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
<div className="infotip" onClick={handleCloseConnection}>
<div className="infotip" onClick={(e) => handleDisconnect(e, true)}>
<IconPlugConnectedX
color={theme.colors.text.danger}
strokeWidth={1.5}
@@ -141,15 +172,13 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
</div>
)}
{!isConnectionActive && (
{connectionStatus !== 'connected' && (
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
<div className="infotip" onClick={handleConnect}>
<IconPlugConnected
className={
classnames('cursor-pointer', {
'animate-pulse': showConnectingPulse
})
}
className={classnames('cursor-pointer', {
'animate-pulse': connectionStatus === CONNECTION_STATUS.CONNECTING
})}
color={theme.colors.text.green}
strokeWidth={1.5}
size={22}
@@ -166,7 +195,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
</div>
</div>
{isConnectionActive && <div className="connection-status-strip"></div>}
{connectionStatus === CONNECTION_STATUS.CONNECTED && <div className="connection-status-strip"></div>}
</StyledWrapper>
);
};

View File

@@ -1469,6 +1469,7 @@ export const newWsRequest = (params) => (dispatch, getState) => {
request: {
url: requestUrl,
method: requestMethod,
params: [],
body: body ?? {
mode: 'ws',
ws: [

View File

@@ -780,6 +780,8 @@ export const collectionsSlice = createSlice({
item.draft = cloneDeep(item);
}
item.draft.request.url = action.payload.url;
item.draft.request.params = item?.draft?.request?.params ?? [];
item.request.params = item?.request?.params ?? [];
const parts = splitOnFirst(item?.draft?.request?.url, '?');
const urlQueryParams = parseQueryParams(parts[1]);

View File

@@ -345,3 +345,15 @@ export const isWsConnectionActive = async (requestId) => {
ipcRenderer.invoke('renderer:ws:is-connection-active', requestId).then(resolve).catch(reject);
});
};
/**
* Get the connection status of a WebSocket connection
* @param {string} requestId - The request ID to get the connection status of
* @returns {Promise<Object>} - The result of the get operation
*/
export const getWsConnectionStatus = async (requestId) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:ws:connection-status', requestId).then(resolve).catch(reject);
});
};

View File

@@ -349,6 +349,21 @@ const registerWsEventHandlers = (window) => {
return { success: false, error: error.message, isActive: false };
}
});
/**
* Get the connection status of a connection
* @param {string} requestId - The request ID to get the connection status of
* @returns {string} - The connection status
*/
ipcMain.handle('renderer:ws:connection-status', (event, requestId) => {
try {
const status = wsClient.connectionStatus(requestId);
return { success: true, status };
} catch (error) {
console.error('Error getting WebSocket connection status:', error);
return { success: false, error: error.message, status: 'disconnected' };
}
});
};
module.exports = {

View File

@@ -360,6 +360,19 @@ class WsClient {
});
}
}
/**
* Get the connection status of a connection
* @param {string} requestId - The request ID to get the connection status of
* @returns {string} - The connection status
*/
// Returns "disconnected", "connecting", "connected"
connectionStatus(requestId) {
const connectionMeta = this.activeConnections.get(requestId);
if (connectionMeta?.connection?.readyState === ws.WebSocket.CONNECTING) return 'connecting';
if (connectionMeta?.connection?.readyState === ws.WebSocket.OPEN) return 'connected';
return 'disconnected';
}
}
export { WsClient };

View File

@@ -35,7 +35,7 @@ test.describe.serial('persistence', () => {
});
test('save new websocket url', async ({ pageWithUserData: page }) => {
const replacementUrl = 'ws://localhost:8082';
const replacementUrl = 'ws://localhost:8083';
const locators = buildWebsocketCommonLocators(page);
const clearText = async (text: string) => {