Files
bruno/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js
Chirag Chandrashekhar 9caef9e573 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>
2025-12-03 15:52:52 +05:30

204 lines
7.4 KiB
JavaScript

import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
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, useRef } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { isMacOS } from 'utils/common/platform';
import { hasRequestChanges } from 'utils/collections';
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();
// TODO: reaper, better state for connecting
const saveShortcut = isMacOS() ? '⌘S' : 'Ctrl+S';
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
const [connectionStatus, setConnectionStatus] = useWsConnectionStatus(item.uid);
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
const allVariables = useMemo(() => {
return getAllVariables(collection, item);
}, [collection, item]);
const interpolatedURL = useMemo(() => {
if (!url) return '';
return interpolateUrl({ url, variables: allVariables }) || '';
}, [url, allVariables]);
// 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 handleDisconnect = async (e, notify) => {
e && e.stopPropagation();
closeWsConnection(item.uid)
.then(() => {
notify && toast.success('WebSocket connection closed');
setConnectionStatus('disconnected');
})
.catch((err) => {
console.error('Failed to close WebSocket connection:', err);
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) {
toast.error('Please enter a valid WebSocket URL');
return;
}
handleRun(e);
};
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">
<div className="flex items-center input-container flex-1 w-full input-container pr-2 h-full relative">
<div className="flex items-center justify-center w-16">
<span className="text-xs font-bold method-ws">WS</span>
</div>
<SingleLineEditor
value={url}
onSave={(finalValue) => onSave(finalValue)}
onChange={handleUrlChange}
placeholder="ws://localhost:8080 or wss://example.com"
className="w-full"
theme={displayedTheme}
onRun={handleRun}
collection={collection}
item={item}
/>
<div className="flex items-center h-full mr-2 cursor-pointer">
<div
className="infotip mr-3"
onClick={(e) => {
e.stopPropagation();
if (!hasChanges) return;
onSave();
}}
>
<IconDeviceFloppy
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotip-text text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{connectionStatus === 'connected' && (
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
<div className="infotip" onClick={(e) => handleDisconnect(e, true)}>
<IconPlugConnectedX
color={theme.colors.text.danger}
strokeWidth={1.5}
size={22}
className="cursor-pointer"
/>
<span className="infotip-text text-xs">Close Connection</span>
</div>
</div>
)}
{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': connectionStatus === CONNECTION_STATUS.CONNECTING
})}
color={theme.colors.text.green}
strokeWidth={1.5}
size={22}
/>
<span className="infotip-text text-xs">Connect</span>
</div>
</div>
)}
<div data-testid="run-button" className="cursor-pointer" onClick={handleRunClick}>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
</div>
</div>
</div>
</div>
{connectionStatus === CONNECTION_STATUS.CONNECTED && <div className="connection-status-strip"></div>}
</StyledWrapper>
);
};
export default WsQueryUrl;