diff --git a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js index 445434c14..61f550f3c 100644 --- a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js @@ -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 (
@@ -99,7 +130,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => { 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 }) => {
- {isConnectionActive && ( + {connectionStatus === 'connected' && (
-
+
handleDisconnect(e, true)}> {
)} - {!isConnectionActive && ( + {connectionStatus !== 'connected' && (
{
- {isConnectionActive &&
} + {connectionStatus === CONNECTION_STATUS.CONNECTED &&
} ); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 41ba37cdf..eb4f7f3e3 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -1469,6 +1469,7 @@ export const newWsRequest = (params) => (dispatch, getState) => { request: { url: requestUrl, method: requestMethod, + params: [], body: body ?? { mode: 'ws', ws: [ 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 173c0fc31..d362e0961 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -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]); diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index 89a19a2a3..721b3bd7b 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -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} - 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); + }); +}; diff --git a/packages/bruno-electron/src/ipc/network/ws-event-handlers.js b/packages/bruno-electron/src/ipc/network/ws-event-handlers.js index 25b4c38db..8fd168c85 100644 --- a/packages/bruno-electron/src/ipc/network/ws-event-handlers.js +++ b/packages/bruno-electron/src/ipc/network/ws-event-handlers.js @@ -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 = { diff --git a/packages/bruno-requests/src/ws/ws-client.js b/packages/bruno-requests/src/ws/ws-client.js index 32b11b079..cb6fee6d7 100644 --- a/packages/bruno-requests/src/ws/ws-client.js +++ b/packages/bruno-requests/src/ws/ws-client.js @@ -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 }; diff --git a/tests/websockets/persistence.spec.ts b/tests/websockets/persistence.spec.ts index e32cf9ba8..29ebabe62 100644 --- a/tests/websockets/persistence.spec.ts +++ b/tests/websockets/persistence.spec.ts @@ -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) => {