From c1853e613bb130afa4c9f51e2641cfd235844953 Mon Sep 17 00:00:00 2001 From: "Siddharth Gelera (reaper)" Date: Tue, 7 Oct 2025 21:03:09 +0530 Subject: [PATCH] Feat: Websocket Support (#5480) * init * fix: header saving in ws * fix: retrieve auth value correctly * feat: ws settings * fix: text for inherited auth * feat: pass down options/settings for ws * fix: handle run handling on url * fix: send initial message * fix: fix header movement and minor cleanup * fix: message queue * refactor: faster flushing * feat: ws tab specific additions close tab should close connection `ws` shown in the tab * chore: remove unused icon * feat: simplify query URL rendering * fix: only add to settings if they were added * chore: revert to original * fix: restyle web ui * feat: implement WebSocket response sorting and enhance message handling - Added WSResponseSortOrder component for toggling message sort order. - Updated WSMessagesList to accept and utilize sort order. - Refactored message handling to use 'type' instead of 'direction'. - Enhanced response state management to include sort order. * feat: enhance WebSocket handling with redirect and upgrade events - Added support for 'ws:redirect' and 'ws:upgrade' events in the WebSocket client. - Updated WSResponseHeaders to format headers correctly. - Modified WSResponsePane to display headers in the response. - Improved message handling in the Redux slice for WebSocket events. * fix: correct fallback for URL retrieval in bruRequestToJson * feat: enhance WebSocket message handling and styling - Add new styling for incoming messages in StyledWrapper. - Update WSMessagesList to handle message sorting and focus. - Refactor response sort order handling in WSResponseSortOrder. - Improve WebSocket connection management in ws-client. * fix: adjust styling for message display * fix: imports for ws files * fix: visually simplify the message list * chore: pkg updates * fix: remove unused content-type check in WebSocket request preparation * fix: avoid duplicate messages avoid message getting queued and sent twice * feat: beautify the code editor in each message * feat(websockets): add websocket tests * tests(websockets): move it a folder up * fix: hexdump on sent messages * fix: make the view a lot more compact * feat: enhance WebSocket message handling and styling * formatting fixes - batch 1 * chore: formatting fixes batch 2 * chore: format changes batch 3 * chore: format settings batch 4 * chore: clean up * chore: for now avoid oauth2 * chore: formatting changes batch 6 * test(websocket): add headers handling in tests - Implemented logic to send headers in websocket messages. - Added tests for websocket connections and message handling. - Created locators for common elements in websocket tests. * chore: cleanup * test(websocket): refactor to use constant for BRU_FILE_NAME Updated the test cases to utilize a constant for the BRU_FILE_NAME regex pattern for better maintainability and readability. * test(websocket): update BRU_FILE_NAME to use regex Changed BRU_FILE_NAME from a string to a regex pattern for better matching. * fix(ws-client): rename timeout to handshakeTimeout Updated the WebSocket connection options to use 'handshakeTimeout' instead of 'timeout' for clarity. * chore: cleanup * fix(ShareCollection): update non-exportable request types handling Refactor hasGrpcRequests to hasNonExportableRequestTypes, returning an object with a flag and types of requests that will not be exported. * feat: inherit timeout from app prefs * fix: faster queue * feat: add WSRequestBodyMode component and language detection - Introduced a new component for selecting request body modes (JSON, XML, TEXT). - Implemented auto-detection of language for the request body content. - Created a styled wrapper for improved UI presentation. * feat: enhance WebSocket message handling with decoder support - Added decoder field to WebSocket messages in various components. - Updated prettify functionality to handle XML and JSON formats. - Modified Redux state to include decoder information. - Adjusted schema validation to accommodate decoder field. * refactor: replace decoder with type in WebSocket message handling * fix: use `body` directly * chore: reset formatting * chore: reformat * chore: reformat * chore: reformat * chore: reformat * chore: base format * chore: fix lang constructs * chore: fix message queue flush logic Ensure that the flushQueue method checks for the existence of the message queue before processing. Refactor WebSocket test fixtures for better readability by correcting indentation and structure. * fix: typo * chore: lint fixes * chore: lint fixes * chore: rediff utils * chore: rediff utils * chore: remove from CLI * chore: rediff utils * chore: rediff utils * chore: rediff utils * chore: rediff utils * chore: fix formatting * tests(websocket): add websocket persistence tests * chore: format * feat(eslint): add TypeScript support and update test file patterns * fix: turn off single line jsx expressions * revert lang `ws` removal * chore: reformat * feat: better subprotocol support and tests * chore: reformat * chore: reformat * clean up ununsed components * refactor: locators, tests, new request design * chore: close app for each test to start afresh * Revert "chore: close app for each test to start afresh" This reverts commit 5c2e3bec81d18c9713abb619c5d0587581f81406. * refactor: simplify dropdown mode selection * chore: remove unused changes * refactor: simplify * chore: simplify * fix: loading pulse animation * refactor: update lodash import syntax * fix: comments and sanitisation * refactor: rename BRU_FILE_NAME to BRU_REQ_NAME for consistency Updated variable names across websocket tests to improve clarity and maintain consistency in naming conventions. * fix: null check for the initialisation of websocket client * fix: add poller to check for saved state * fix: variable message time check for tests * fix: force wait for elements * fix: use nth locators instead of wait (draft attempt) * chore: reformat * fix: update beta preferences to include websocket support * feat: GA * feat: rename `connectionTimeout` to `timeout` and better form * feat: update WebSocket IPC channel names to use 'renderer' prefix * feat: add 'oauth2' to supported authentication modes * chore: add default `json` type in ws * test: add tests for bruToJson and jsonToBru parsers - Implemented smoke tests for the bruToJson parser to validate message inference and settings. * refactor: improve timeout handling in WebSocket client --------- Co-authored-by: Siddharth Gelera Co-authored-by: Sid --- eslint.config.js | 2 +- package-lock.json | 314 ++++++++++---- .../src/components/CodeEditor/index.js | 10 +- .../Protobuf/StyledWrapper.js | 2 +- .../WSRequestPane/StyledWrapper.js | 34 ++ .../WSRequestPane/WSAuth/StyledWrapper.js | 9 + .../WSRequestPane/WSAuth/WSAuthMode/index.js | 85 ++++ .../RequestPane/WSRequestPane/WSAuth/index.js | 129 ++++++ .../RequestPane/WSRequestPane/index.js | 120 ++++++ .../WSSettingsPane/StyledWrapper.js | 29 ++ .../RequestPane/WSSettingsPane/index.js | 135 ++++++ .../WsBody/BodyMode/StyledWrapper.js | 30 ++ .../RequestPane/WsBody/BodyMode/index.js | 58 +++ .../WsBody/SingleWSMessage/index.js | 203 +++++++++ .../RequestPane/WsBody/StyledWrapper.js | 53 +++ .../components/RequestPane/WsBody/index.js | 116 ++++++ .../RequestPane/WsQueryUrl/StyledWrapper.js | 102 +++++ .../RequestPane/WsQueryUrl/index.js | 170 ++++++++ .../src/components/RequestTabPanel/index.js | 43 +- .../RequestTabs/RequestTab/index.js | 37 +- .../components/ResponsePane/Timeline/index.js | 2 +- .../WsResponsePane/StyledWrapper.js | 58 +++ .../WSMessagesList/StyledWrapper.js | 44 ++ .../WsResponsePane/WSMessagesList/index.js | 204 +++++++++ .../WSResponseHeaders/StyledWrapper.js | 31 ++ .../WsResponsePane/WSResponseHeaders/index.js | 43 ++ .../WSResponseSortOrder/StyledWrapper.js | 8 + .../WSResponseSortOrder/index.js | 30 ++ .../WSStatusCode/StyledWrapper.js | 22 + .../WSStatusCode/get-ws-status-code-phrase.js | 20 + .../WsResponsePane/WSStatusCode/index.js | 25 ++ .../ResponsePane/WsResponsePane/index.js | 153 +++++++ .../src/components/ShareCollection/index.js | 22 +- .../RequestMethod/StyledWrapper.js | 3 + .../CollectionItem/RequestMethod/index.js | 54 ++- .../components/Sidebar/NewRequest/index.js | 152 ++++--- packages/bruno-app/src/pages/Bruno/index.js | 12 +- .../src/providers/ReduxStore/slices/app.js | 3 - .../ReduxStore/slices/collections/actions.js | 101 ++++- .../ReduxStore/slices/collections/index.js | 189 ++++++++- .../src/providers/ReduxStore/slices/tabs.js | 2 +- packages/bruno-app/src/themes/dark.js | 15 +- packages/bruno-app/src/themes/light.js | 13 +- .../src/utils/codemirror/lang-detect.js | 34 ++ .../bruno-app/src/utils/collections/export.js | 6 +- .../bruno-app/src/utils/collections/index.js | 34 +- .../bruno-app/src/utils/importers/common.js | 9 +- packages/bruno-app/src/utils/network/index.js | 112 +++++ .../src/utils/network/ws-event-listeners.js | 115 +++++ packages/bruno-app/src/utils/tabs/index.js | 2 +- packages/bruno-cli/src/utils/bru.js | 10 + packages/bruno-electron/package.json | 1 + packages/bruno-electron/src/ipc/collection.js | 11 +- .../bruno-electron/src/ipc/network/index.js | 2 + .../src/ipc/network/ws-event-handlers.js | 307 ++++++++++++++ .../bruno-filestore/src/formats/bru/index.ts | 44 +- packages/bruno-lang/v2/src/bruToJson.js | 58 ++- packages/bruno-lang/v2/src/jsonToBru.js | 50 ++- .../bruno-lang/v2/tests/bruToJson.spec.js | 48 +++ .../bruno-lang/v2/tests/jsonToBru.spec.js | 56 +++ packages/bruno-requests/package.json | 2 + packages/bruno-requests/rollup.config.js | 2 +- packages/bruno-requests/src/index.ts | 3 +- packages/bruno-requests/src/ws/ws-client.js | 394 ++++++++++++++++++ .../bruno-schema/src/collections/index.js | 75 +++- packages/bruno-tests/package.json | 3 +- packages/bruno-tests/src/index.js | 9 +- packages/bruno-tests/src/ws/index.js | 74 ++++ tests/utils/page/locators.ts | 26 ++ tests/utils/wait.ts | 13 + tests/websockets/connection.spec.ts | 67 +++ tests/websockets/fixtures/collection/base.bru | 18 + .../websockets/fixtures/collection/bruno.json | 5 + .../ws-test-request-with-headers.bru | 23 + .../ws-test-request-with-subproto.bru | 22 + .../fixtures/collection/ws-test-request.bru | 19 + tests/websockets/headers.spec.ts | 20 + .../init-user-data/collection-security.json | 10 + .../init-user-data/preferences.json | 9 + tests/websockets/persistence.spec.ts | 68 +++ tests/websockets/subproto.spec.ts | 53 +++ 81 files changed, 4469 insertions(+), 232 deletions(-) create mode 100644 packages/bruno-app/src/components/RequestPane/WSRequestPane/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/WSAuthMode/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/WSSettingsPane/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/WSSettingsPane/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/WsBody/BodyMode/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/WsBody/BodyMode/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/WsBody/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/WsBody/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/WsQueryUrl/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseHeaders/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseHeaders/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/get-ws-status-code-phrase.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js create mode 100644 packages/bruno-app/src/utils/codemirror/lang-detect.js create mode 100644 packages/bruno-app/src/utils/network/ws-event-listeners.js create mode 100644 packages/bruno-electron/src/ipc/network/ws-event-handlers.js create mode 100644 packages/bruno-lang/v2/tests/bruToJson.spec.js create mode 100644 packages/bruno-lang/v2/tests/jsonToBru.spec.js create mode 100644 packages/bruno-requests/src/ws/ws-client.js create mode 100644 packages/bruno-tests/src/ws/index.js create mode 100644 tests/utils/page/locators.ts create mode 100644 tests/utils/wait.ts create mode 100644 tests/websockets/connection.spec.ts create mode 100644 tests/websockets/fixtures/collection/base.bru create mode 100644 tests/websockets/fixtures/collection/bruno.json create mode 100644 tests/websockets/fixtures/collection/ws-test-request-with-headers.bru create mode 100644 tests/websockets/fixtures/collection/ws-test-request-with-subproto.bru create mode 100644 tests/websockets/fixtures/collection/ws-test-request.bru create mode 100644 tests/websockets/headers.spec.ts create mode 100644 tests/websockets/init-user-data/collection-security.json create mode 100644 tests/websockets/init-user-data/preferences.json create mode 100644 tests/websockets/persistence.spec.ts create mode 100644 tests/websockets/subproto.spec.ts diff --git a/eslint.config.js b/eslint.config.js index 671b6f363..cdeb10f29 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -60,9 +60,9 @@ module.exports = runESMImports().then(() => defineConfig([ '@stylistic/function-call-spacing': ['error', 'never'], '@stylistic/multiline-ternary': ['off'], '@stylistic/padding-line-between-statements': ['off'], - '@stylistic/jsx-one-expression-per-line': ['off'], '@stylistic/semi-style': ['error', 'last'], '@stylistic/max-len': ['off'], + '@stylistic/jsx-one-expression-per-line': ['off'] }, }, { diff --git a/package-lock.json b/package-lock.json index 52fd51301..5218ddf05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1610,7 +1610,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz", "integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -1628,7 +1628,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -1645,7 +1645,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1663,7 +1663,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@babel/helper-member-expression-to-functions": { @@ -1734,7 +1734,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -1809,7 +1809,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.25.9", @@ -1852,7 +1852,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -1869,7 +1869,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -1885,7 +1885,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -1901,7 +1901,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -1919,7 +1919,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -1954,7 +1954,7 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2053,7 +2053,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2069,7 +2069,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2251,7 +2251,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", @@ -2268,7 +2268,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2284,7 +2284,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2302,7 +2302,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", @@ -2320,7 +2320,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2336,7 +2336,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2368,7 +2368,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", @@ -2385,7 +2385,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -2406,7 +2406,7 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -2416,7 +2416,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2433,7 +2433,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2449,7 +2449,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2466,7 +2466,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2482,7 +2482,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2499,7 +2499,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2515,7 +2515,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2531,7 +2531,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2563,7 +2563,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2580,7 +2580,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", @@ -2598,7 +2598,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2614,7 +2614,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2630,7 +2630,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2646,7 +2646,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2662,7 +2662,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -2695,7 +2695,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -2714,7 +2714,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -2731,7 +2731,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2748,7 +2748,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2779,7 +2779,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2795,7 +2795,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", @@ -2813,7 +2813,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2830,7 +2830,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2862,7 +2862,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2894,7 +2894,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -2912,7 +2912,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2997,7 +2997,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -3014,7 +3014,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3031,7 +3031,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3047,7 +3047,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3063,7 +3063,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -3080,7 +3080,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3096,7 +3096,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3112,7 +3112,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3147,7 +3147,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3163,7 +3163,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3180,7 +3180,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3197,7 +3197,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3214,7 +3214,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.26.0", @@ -3315,7 +3315,7 @@ "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -7985,6 +7985,7 @@ "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", @@ -8004,6 +8005,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -8016,6 +8018,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1", @@ -8030,6 +8033,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, "license": "MIT" }, "node_modules/@testing-library/jest-dom": { @@ -8110,6 +8114,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, "license": "MIT" }, "node_modules/@types/babel__core": { @@ -8364,6 +8369,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, "license": "MIT" }, "node_modules/@types/lodash": { @@ -8395,6 +8401,7 @@ "version": "12.2.3", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/linkify-it": "*", @@ -8405,6 +8412,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -9452,6 +9460,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "dequal": "^2.0.3" @@ -9819,7 +9828,7 @@ "version": "0.4.12", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.22.6", @@ -9834,7 +9843,7 @@ "version": "0.10.6", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.2", @@ -9848,7 +9857,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.3" @@ -10008,6 +10017,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -11598,7 +11617,7 @@ "version": "3.39.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "browserslist": "^4.24.2" @@ -12648,6 +12667,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, "license": "MIT" }, "node_modules/dom-converter": { @@ -13652,7 +13672,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -14367,6 +14387,13 @@ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", "license": "MIT" }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -15430,6 +15457,18 @@ "he": "bin/he" } }, + "node_modules/hexy": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.3.5.tgz", + "integrity": "sha512-UCP7TIZPXz5kxYJnNOym+9xaenxCLor/JyhKieo8y8/bJWunGh9xbhy3YrgYJUQ87WwfXGm05X330DszOfINZw==", + "license": "MIT", + "bin": { + "hexy": "bin/hexy_cmd.js" + }, + "engines": { + "node": ">=10.4" + } + }, "node_modules/hey-listen": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", @@ -16248,7 +16287,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -18499,7 +18538,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/lodash.flow": { @@ -18637,11 +18676,34 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, "license": "MIT", "bin": { "lz-string": "bin/bin.js" } }, + "node_modules/macos-export-certificate-and-key": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/macos-export-certificate-and-key/-/macos-export-certificate-and-key-1.2.4.tgz", + "integrity": "sha512-y5QZEywlBNKd+EhPZ1Hz1FmDbbeQKtuVHJaTlawdl7vXw9bi/4tJB2xSMwX4sMVcddy3gbQ8K0IqXAi2TpDo2g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^4.3.0" + } + }, + "node_modules/macos-export-certificate-and-key/node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "license": "MIT", + "optional": true + }, "node_modules/magic-string": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", @@ -20081,7 +20143,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -22433,14 +22495,14 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/regenerate-unicode-properties": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -22459,7 +22521,7 @@ "version": "0.15.2", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.4" @@ -22469,7 +22531,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2", @@ -22487,14 +22549,14 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/regjsparser": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.0.2" @@ -22507,7 +22569,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -22663,7 +22725,7 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -24875,7 +24937,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -24955,6 +25017,16 @@ "jscat": "bundle.js" } }, + "node_modules/system-ca": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/system-ca/-/system-ca-2.0.1.tgz", + "integrity": "sha512-9ZDV9yl8ph6Op67wDGPr4LykX86usE9x3le+XZSHfVMiiVJ5IRgmCWjLgxyz35ju9H3GDIJJZm4ogAeIfN5cQQ==", + "license": "Apache-2.0", + "optionalDependencies": { + "macos-export-certificate-and-key": "^1.2.0", + "win-export-certificate-and-key": "^2.1.0" + } + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -25742,7 +25814,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -25803,7 +25875,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -25813,7 +25885,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", @@ -25827,7 +25899,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -25837,7 +25909,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -26431,6 +26503,28 @@ "dev": true, "license": "MIT" }, + "node_modules/win-export-certificate-and-key": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/win-export-certificate-and-key/-/win-export-certificate-and-key-2.1.0.tgz", + "integrity": "sha512-WeMLa/2uNZcS/HWGKU2G1Gzeh3vHpV/UFvwLhJLKxPHYFAbubxxVcJbqmPXaqySWK1Ymymh16zKK5WYIJ3zgzA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^3.1.0" + } + }, + "node_modules/win-export-certificate-and-key/node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT", + "optional": true + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -30168,6 +30262,7 @@ "form-data": "^4.0.0", "fs-extra": "^10.1.0", "graphql": "^16.6.0", + "hexy": "^0.3.5", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "iconv-lite": "^0.6.3", @@ -31979,7 +32074,9 @@ "axios": "^1.9.0", "grpc-reflection-js": "^0.3.0", "is-ip": "^5.0.1", - "tough-cookie": "^6.0.0" + "system-ca": "^2.0.1", + "tough-cookie": "^6.0.0", + "ws": "^8.18.3" }, "devDependencies": { "@babel/preset-env": "^7.22.0", @@ -32038,6 +32135,27 @@ "node": ">=16" } }, + "packages/bruno-requests/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/bruno-schema": { "name": "@usebruno/schema", "version": "0.7.0", @@ -32082,7 +32200,8 @@ "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "ws": "^8.18.3" } }, "packages/bruno-tests/node_modules/axios": { @@ -32202,6 +32321,27 @@ ], "license": "MIT" }, + "packages/bruno-tests/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/bruno-toml": { "name": "@usebruno/toml", "version": "0.1.0", diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 4a8f0591a..d5609395d 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -45,7 +45,7 @@ export default class CodeEditor extends React.Component { const editor = (this.editor = CodeMirror(this._node, { value: this.props.value || '', lineNumbers: true, - lineWrapping: true, + lineWrapping: this.props.enableLineWrapping ?? true, tabSize: TAB_SIZE, mode: this.props.mode || 'application/ld+json', brunoVarInfo: { @@ -237,6 +237,14 @@ export default class CodeEditor extends React.Component { this.editor.scrollTo(null, this.props.initialScroll); } + if (this.props.enableLineWrapping !== prevProps.enableLineWrapping) { + this.editor.setOption('lineWrapping', this.props.enableLineWrapping); + } + + if (this.props.mode !== prevProps.mode) { + this.editor.setOption('mode', this.props.mode); + } + this.ignoreChangeEvent = false; } diff --git a/packages/bruno-app/src/components/CollectionSettings/Protobuf/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Protobuf/StyledWrapper.js index 21adc36e1..8ba4be3dc 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Protobuf/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Protobuf/StyledWrapper.js @@ -10,4 +10,4 @@ const StyledWrapper = styled.div` } `; -export default StyledWrapper; \ No newline at end of file +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/StyledWrapper.js new file mode 100644 index 000000000..e6a766672 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/StyledWrapper.js @@ -0,0 +1,34 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + div.tabs { + div.tab { + padding: 6px 0px; + border: none; + border-bottom: solid 2px transparent; + margin-right: 1.25rem; + color: var(--color-tab-inactive); + cursor: pointer; + + &:focus, + &:active, + &:focus-within, + &:focus-visible, + &:target { + outline: none !important; + box-shadow: none !important; + } + + &.active { + color: ${(props) => props.theme.tabs.active.color} !important; + border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important; + } + + .content-indicator { + color: ${(props) => props.theme.text} + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/StyledWrapper.js new file mode 100644 index 000000000..f76b0d9a4 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/StyledWrapper.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + .inherit-mode-text { + color: ${(props) => props.theme.colors.text.yellow}; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/WSAuthMode/index.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/WSAuthMode/index.js new file mode 100644 index 000000000..0c4202ef1 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/WSAuthMode/index.js @@ -0,0 +1,85 @@ +import React, { useRef, forwardRef } from 'react'; +import get from 'lodash/get'; +import { IconCaretDown } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; +import { useDispatch } from 'react-redux'; +import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections'; +import { humanizeRequestAuthMode } from 'utils/collections'; +import StyledWrapper from '../../../Auth/AuthMode/StyledWrapper'; + +const AUTH_MODES = [ + { + name: 'Basic Auth', + mode: 'basic' + }, + { + name: 'Bearer Token', + mode: 'bearer' + }, + { + name: 'API Key', + mode: 'apikey' + }, + { + name: 'OAuth2', + mode: 'oauth2' + }, + { + name: 'Inherit', + mode: 'inherit' + }, + { + name: 'No Auth', + mode: 'none' + } +]; + +const WSAuthMode = ({ item, collection }) => { + const dispatch = useDispatch(); + const dropdownTippyRef = useRef(); + const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); + const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode'); + + const Icon = forwardRef((props, ref) => { + return ( +
+ {humanizeRequestAuthMode(authMode)} + {' '} + +
+ ); + }); + + const onModeChange = (value) => { + dispatch(updateRequestAuthMode({ + itemUid: item.uid, + collectionUid: collection.uid, + mode: value + })); + }; + + const onClickHandler = (mode) => { + dropdownTippyRef?.current?.hide(); + onModeChange(mode); + }; + + return ( + +
+ } placement="bottom-end"> + {AUTH_MODES.map((authMode) => ( +
onClickHandler(authMode.mode)} + > + {authMode.name} +
+ ))} +
+
+
+ ); +}; + +export default WSAuthMode; diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js new file mode 100644 index 000000000..59d1bae32 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js @@ -0,0 +1,129 @@ +import React, { useEffect } from 'react'; +import get from 'lodash/get'; +import { useDispatch } from 'react-redux'; +import WSAuthMode from './WSAuthMode'; +import BearerAuth from '../../Auth/BearerAuth'; +import BasicAuth from '../../Auth/BasicAuth'; +import ApiKeyAuth from '../../Auth/ApiKeyAuth'; +import StyledWrapper from './StyledWrapper'; +import { humanizeRequestAuthMode } from 'utils/collections'; +import { getTreePathFromCollectionToItem } from 'utils/collections/index'; +import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; + +const supportedAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit']; + +const WSAuth = ({ item, collection }) => { + const dispatch = useDispatch(); + const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode'); + const requestTreePath = getTreePathFromCollectionToItem(collection, item); + + const request = item.draft + ? get(item, 'draft.request', {}) + : get(item, 'request', {}); + + const save = () => { + return saveRequest(item.uid, collection.uid); + }; + + // Reset to 'none' if current auth mode is not supported + useEffect(() => { + if (authMode && !supportedAuthModes.includes(authMode)) { + dispatch(updateRequestAuthMode({ + itemUid: item.uid, + collectionUid: collection.uid, + mode: 'none' + })); + } + }, [authMode, collection.uid, dispatch, item.uid]); + + const getEffectiveAuthSource = () => { + if (authMode !== 'inherit') return null; + + const collectionAuth = get(collection, 'root.request.auth'); + let effectiveSource = { + type: 'collection', + name: 'Collection', + auth: collectionAuth + }; + + // Check folders in reverse to find the closest auth configuration + for (let i of [...requestTreePath].reverse()) { + if (i.type === 'folder') { + const folderAuth = get(i, 'root.request.auth'); + if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') { + effectiveSource = { + type: 'folder', + name: i.name, + auth: folderAuth + }; + break; + } + } + } + + return effectiveSource; + }; + + const getAuthView = () => { + switch (authMode) { + case 'basic': { + return ; + } + case 'bearer': { + return ; + } + case 'apikey': { + return ; + } + case 'oauth2': { + return ( + <> +
+
+ OAuth 2 not yet supported by WebSockets. Using no auth instead. +
+
+ + ); + } + case 'inherit': { + const source = getEffectiveAuthSource(); + + // Only show inherited auth if it's one of the supported types + if (source && supportedAuthModes.includes(source.auth?.mode)) { + return ( + <> +
+
Auth inherited from {source.name}:
+
{humanizeRequestAuthMode(source.auth?.mode)}
+
+ + ); + } else { + return ( + <> +
+
Inherited auth not supported by WebSockets. Using no auth instead.
+
+ + ); + } + } + default: { + return null; + } + } + }; + + return ( + +
+ +
+ {getAuthView()} +
+ ); +}; + +export default WSAuth; diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js new file mode 100644 index 000000000..068b1e7e4 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js @@ -0,0 +1,120 @@ +import classnames from 'classnames'; +import Documentation from 'components/Documentation/index'; +import RequestHeaders from 'components/RequestPane/RequestHeaders'; +import StatusDot from 'components/StatusDot/index'; +import { find } from 'lodash'; +import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import HeightBoundContainer from 'ui/HeightBoundContainer'; +import { getPropertyFromDraftOrRequest } from 'utils/collections/index'; +import WsBody from '../WsBody/index'; +import StyledWrapper from './StyledWrapper'; +import WSAuth from './WSAuth'; +import WSSettingsPane from '../WSSettingsPane/index'; + +const WSRequestPane = ({ item, collection, handleRun }) => { + const dispatch = useDispatch(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + + const selectTab = (tab) => { + dispatch(updateRequestPaneTab({ + uid: item.uid, + requestPaneTab: tab + })); + }; + + const getTabPanel = (tab) => { + switch (tab) { + case 'body': { + return ( + + ); + } + case 'headers': { + return ; + } + case 'settings': { + return ; + } + case 'auth': { + return ; + } + case 'docs': { + return ; + } + default: { + return
404 | Not found
; + } + } + }; + + if (!activeTabUid) { + return
Something went wrong
; + } + + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) { + return
An error occurred!
; + } + + const getTabClassname = (tabName) => { + return classnames(`tab select-none ${tabName}`, { + active: tabName === focusedTab.requestPaneTab + }); + }; + + const isMultipleContentTab = ['script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab); + const headers = getPropertyFromDraftOrRequest(item, 'request.headers'); + const docs = getPropertyFromDraftOrRequest(item, 'request.docs'); + const auth = getPropertyFromDraftOrRequest(item, 'request.auth'); + + const activeHeadersLength = headers.filter((header) => header.enabled).length; + + useEffect(() => { + if (!focusedTab?.requestPaneTab) { + selectTab('body'); + } + }, []); + + return ( + +
+
selectTab('body')}> + Message +
+
selectTab('headers')}> + Headers + {activeHeadersLength > 0 && {activeHeadersLength}} +
+
selectTab('auth')}> + Auth + {auth.mode !== 'none' && } +
+
selectTab('settings')}> + Settings +
+
selectTab('docs')}> + Docs + {docs && docs.length > 0 && } +
+
+
+ {getTabPanel(focusedTab.requestPaneTab)} +
+
+ ); +}; + +export default WSRequestPane; diff --git a/packages/bruno-app/src/components/RequestPane/WSSettingsPane/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WSSettingsPane/StyledWrapper.js new file mode 100644 index 000000000..e04c6d331 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSSettingsPane/StyledWrapper.js @@ -0,0 +1,29 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .single-line-editor-wrapper { + padding: 0.15rem 0.4rem; + border-radius: 3px; + border: solid 1px ${(props) => props.theme.input.border}; + background-color: ${(props) => props.theme.input.bg}; + + &.error{ + border-color: ${(props) => props.theme.colors.text.danger}; + } + } + + .tooltip-mod { + font-size: 11px !important; + width: 150px !important; + + & ul { + padding-left: 4px; + } + + & ul > li { + list-style: circle; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WSSettingsPane/index.js b/packages/bruno-app/src/components/RequestPane/WSSettingsPane/index.js new file mode 100644 index 000000000..96a576940 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSSettingsPane/index.js @@ -0,0 +1,135 @@ +import cn from 'classnames'; +import InfoTip from 'components/InfoTip/index'; +import SingleLineEditor from 'components/SingleLineEditor'; +import ToolHint from 'components/ToolHint/index'; +import { useFormik } from 'formik'; +import get from 'lodash/get'; +import { updateItemSettings } from 'providers/ReduxStore/slices/collections'; +import { useTheme } from 'providers/Theme'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import * as Yup from 'yup'; +import StyledWrapper from './StyledWrapper'; + +/** + * @param {string} propertyKey + * @param {{draft?:Record}} item + * @returns + */ +const getPropertyFromDraftOrRequest = (propertyKey, item) => + item.draft ? get(item, `draft.${propertyKey}`, {}) : get(item, propertyKey, {}); + +const ERRORS = { + timeout: { + invalid: `Timeout needs to be a valid number` + }, + keepAliveInterval: { + invalid: `Timeout needs to be a valid number` + } +}; + +const WSSettingsPane = ({ item, collection }) => { + const dispatch = useDispatch(); + const { storedTheme } = useTheme(); + const requestPreferences = useSelector((state) => state.app.preferences.request); + + const { timeout: _connectionTimeout, keepAliveInterval = 0 } = getPropertyFromDraftOrRequest('settings', item); + + const connectionTimeout = _connectionTimeout ?? requestPreferences.timeout; + + const updateSetting = (key, value) => { + dispatch(updateItemSettings({ + collectionUid: collection.uid, + itemUid: item.uid, + settings: { + [key]: value + } + })); + }; + + const formErrors = { + timeout: isNaN(Number(connectionTimeout)) && ERRORS.timeout.invalid, + keepAliveInterval: isNaN(Number(keepAliveInterval)) && ERRORS.keepAliveInterval.invalid + }; + + return ( + +
+
+ + +

+ Timeout in milliseconds +

+
+ )} + /> + +
+
+ + updateSetting('timeout', newValue)} + collection={collection} + /> + +
+
+ +
+ + +

+ + Keep the websocket alive by sending ping requests to the server at every interval (in millseconds) + +

+

0 (zero) = off

+
+ )} + /> + +
+
+ + updateSetting('keepAliveInterval', newValue)} + collection={collection} + /> + +
+
+
+
+ ); +}; + +export default WSSettingsPane; diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/BodyMode/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WsBody/BodyMode/StyledWrapper.js new file mode 100644 index 000000000..3d571b4bf --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WsBody/BodyMode/StyledWrapper.js @@ -0,0 +1,30 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + font-size: 0.8125rem; + + .body-mode-selector { + background: transparent; + border-radius: 3px; + + .dropdown-item { + padding: 0.2rem 0.6rem !important; + padding-left: 1.5rem !important; + } + + .label-item { + padding: 0.2rem 0.6rem !important; + } + + .selected-body-mode { + color: ${(props) => props.theme.colors.text.yellow}; + } + } + + .caret { + color: rgb(140, 140, 140); + fill: rgb(140 140 140); + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/BodyMode/index.js b/packages/bruno-app/src/components/RequestPane/WsBody/BodyMode/index.js new file mode 100644 index 000000000..36e759b41 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WsBody/BodyMode/index.js @@ -0,0 +1,58 @@ +import React, { useRef, forwardRef } from 'react'; +import { IconCaretDown } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; +import { humanizeRequestBodyMode } from 'utils/collections'; +import StyledWrapper from './StyledWrapper'; + +const RAW_MODES = [ + { + label: 'JSON', + key: 'json' + }, + { + label: 'XML', + key: 'xml' + }, + { + label: 'TEXT', + key: 'text' + } +]; + +const WSRequestBodyMode = ({ mode, onModeChange }) => { + const dropdownTippyRef = useRef(); + const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); + + const Icon = forwardRef((props, ref) => { + return ( +
+ {humanizeRequestBodyMode(mode)} + {' '} + +
+ ); + }); + + return ( + +
+ } placement="bottom-end"> +
Raw
+ {RAW_MODES.map((d) => ( +
{ + dropdownTippyRef.current.hide(); + onModeChange(d.key); + }} + > + {d.label} +
+ ))} +
+
+
+ ); +}; +export default WSRequestBodyMode; diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js new file mode 100644 index 000000000..cafdcf009 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js @@ -0,0 +1,203 @@ +import { IconChevronDown, IconChevronUp, IconTrash, IconWand } from '@tabler/icons'; +import CodeEditor from 'components/CodeEditor/index'; +import ToolHint from 'components/ToolHint/index'; +import { get } from 'lodash'; +import invert from 'lodash/invert'; +import { updateRequestBody } from 'providers/ReduxStore/slices/collections'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { useTheme } from 'providers/Theme'; +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { autoDetectLang } from 'utils/codemirror/lang-detect'; +import { toastError } from 'utils/common/error'; +import { prettifyJSON } from 'utils/common/index'; +import xmlFormat from 'xml-formatter'; +import WSRequestBodyMode from '../BodyMode/index'; + +export const TYPE_BY_DECODER = { + base64: 'binary', + json: 'json', + xml: 'xml' +}; + +export const DECODER_BY_TYPE = invert(TYPE_BY_DECODER); + +export const SingleWSMessage = ({ + message, + item, + collection, + index, + methodType, + isCollapsed, + onToggleCollapse, + handleRun, + canClientSendMultipleMessages +}) => { + const dispatch = useDispatch(); + const { displayedTheme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); + const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); + + const { name, content, type } = message; + const [messageFormat, setMessageFormat] = useState(autoDetectLang(content)); + + const onUpdateMessageType = (type) => { + setMessageFormat(type); + + const currentMessages = [...(body.ws || [])]; + + currentMessages[index] = { + ...currentMessages[index], + type: DECODER_BY_TYPE[type] + }; + + dispatch(updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + })); + }; + + const onEdit = (value) => { + const currentMessages = [...(body.ws || [])]; + + currentMessages[index] = { + name: name ? name : `message ${index + 1}`, + type: DECODER_BY_TYPE[messageFormat], + content: value + }; + + dispatch(updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + })); + }; + + const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); + + const onDeleteMessage = () => { + const currentMessages = [...(body.ws || [])]; + + currentMessages.splice(index, 1); + + dispatch(updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + })); + }; + + const getContainerHeight + = canClientSendMultipleMessages && body.ws.length > 1 ? `${isCollapsed ? '' : 'h-80'}` : 'h-full'; + + let codeType = messageFormat; + if (TYPE_BY_DECODER[type]) { + codeType = TYPE_BY_DECODER[type]; + } + + const codemirrorMode = { + text: 'application/text', + xml: 'application/xml', + json: 'application/ld+json' + }; + + const onPrettify = () => { + if (codeType === 'json') { + try { + const prettyBodyJson = prettifyJSON(content); + const currentMessages = [...(body.ws || [])]; + currentMessages[index] = { + ...currentMessages[index], + name: name ? name : `message ${index + 1}`, + content: prettyBodyJson + }; + dispatch(updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + })); + } catch (e) { + toastError(new Error('Unable to prettify. Invalid JSON format.')); + } + } + + if (codeType === 'xml') { + try { + const prettyBodyXML = xmlFormat(content, { collapseContent: true }); + + const currentMessages = [...(body.ws || [])]; + currentMessages[index] = { + ...currentMessages[index], + name: name ? name : `message ${index + 1}`, + content: prettyBodyXML + }; + + dispatch(updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + })); + } catch (e) { + toastError(new Error('Unable to prettify. Invalid XML format.')); + } + } + }; + + return ( +
+
+
+ {isCollapsed ? ( + + ) : ( + + )} +
+
e.stopPropagation()}> + + + + + + {index > 0 && ( + + + + )} +
+
+ {!isCollapsed && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WsBody/StyledWrapper.js new file mode 100644 index 000000000..49966056e --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WsBody/StyledWrapper.js @@ -0,0 +1,53 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + flex: 1; + position: relative; + + .ws-message-header { + .font-medium { + color: ${(props) => props.theme.text}; + } + + button { + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + } + } + + .add-message-btn-container { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding-top: 8px; + background: ${(props) => props.theme.bg || '#fff'}; + z-index: 15; + border-top: 1px solid ${(props) => props.theme.border || 'rgba(0, 0, 0, 0.1)'}; + + .add-message-btn { + width: 100%; + } + } + + .CodeMirror { + border-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/index.js b/packages/bruno-app/src/components/RequestPane/WsBody/index.js new file mode 100644 index 000000000..1938fe595 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WsBody/index.js @@ -0,0 +1,116 @@ +import { get } from 'lodash'; +import { updateRequestBody } from 'providers/ReduxStore/slices/collections'; +import { IconPlus } from '@tabler/icons'; +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import ToolHint from 'components/ToolHint/index'; +import StyledWrapper from './StyledWrapper'; +import { SingleWSMessage } from './SingleWSMessage/index'; + +const WSBody = ({ item, collection, handleRun }) => { + const preferences = useSelector((state) => state.app.preferences); + const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; + const dispatch = useDispatch(); + const [collapsedMessages, setCollapsedMessages] = useState([]); + const messagesContainerRef = useRef(null); + const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); + + const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType'); + const canClientSendMultipleMessages = false; + + // Auto-scroll to the latest message when messages are added + useEffect(() => { + if (messagesContainerRef.current && body?.ws?.length > 0) { + const container = messagesContainerRef.current; + container.scrollTop = container.scrollHeight; + } + }, [body?.ws?.length]); + + const toggleMessageCollapse = (index) => { + setCollapsedMessages((prev) => { + if (prev.includes(index)) { + return prev.filter((i) => i !== index); + } else { + return [...prev, index]; + } + }); + }; + + const addNewMessage = () => { + const currentMessages = Array.isArray(body.ws) ? [...body.ws] : []; + + currentMessages.push({ + name: `message ${currentMessages.length + 1}`, + content: '{}' + }); + + dispatch(updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + })); + }; + + if (!body?.ws || !Array.isArray(body.ws)) { + return ( + +
+

No WebSocket messages available

+ + + +
+
+ ); + } + + return ( + +
+ {body.ws + .filter((_, index) => canClientSendMultipleMessages || index === 0) + .map((message, index) => ( + toggleMessageCollapse(index)} + handleRun={handleRun} + canClientSendMultipleMessages={canClientSendMultipleMessages} + /> + ))} +
+ + {canClientSendMultipleMessages && ( +
+ + + +
+ )} +
+ ); +}; + +export default WSBody; diff --git a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/StyledWrapper.js new file mode 100644 index 000000000..f8f88a764 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/StyledWrapper.js @@ -0,0 +1,102 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + height: 2.3rem; + + .input-container { + background-color: ${(props) => props.theme.requestTabPanel.url.bg}; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + + input { + background-color: ${(props) => props.theme.requestTabPanel.url.bg}; + outline: none; + box-shadow: none; + + &:focus { + outline: none !important; + box-shadow: none !important; + } + } + } + + .method-ws { + color: ${(props) => props.theme.request.ws}; + } + + .connection-status-strip { + animation: pulse 1.5s ease-in-out infinite; + background-color: ${(props) => props.theme.colors.text.green}; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + } + + @keyframes pulse { + 0% { + opacity: 0.4; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.4; + } + } + + .infotip { + position: relative; + display: inline-block; + cursor: pointer; + } + + .infotip:hover .infotip-text { + visibility: visible; + opacity: 1; + } + + .infotip-text { + visibility: hidden; + width: auto; + background-color: ${(props) => props.theme.requestTabs.active.bg}; + color: ${(props) => props.theme.text}; + text-align: center; + border-radius: 4px; + padding: 4px 8px; + position: absolute; + z-index: 1; + bottom: 34px; + left: 50%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.3s; + white-space: nowrap; + } + + .infotip-text::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + margin-left: -4px; + border-width: 4px; + border-style: solid; + border-color: ${(props) => props.theme.requestTabs.active.bg} transparent transparent transparent; + } + + .shortcut { + font-size: 0.625rem; + } + + .connection-controls { + .infotip { + &:hover { + background-color: ${(props) => props.theme.requestTabPanel.url.errorHoverBg}; + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js new file mode 100644 index 000000000..0c5f65d7d --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js @@ -0,0 +1,170 @@ +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 } 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 { closeWsConnection, isWsConnectionActive } from 'utils/network/index'; +import StyledWrapper from './StyledWrapper'; +import get from 'lodash/get'; + +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 showConnectingPulse = isConnecting && response.status !== 'CLOSED'; + + // 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); + } + }; + + checkConnectionStatus(); + const interval = setInterval(checkConnectionStatus, 2000); + return () => clearInterval(interval); + }, [item.uid]); + + const onUrlChange = (value) => { + closeWsConnection(item.uid); + dispatch(requestUrlChanged({ + url: value, + itemUid: item.uid, + collectionUid: collection.uid + })); + }; + + const handleCloseConnection = (e) => { + e.stopPropagation(); + + closeWsConnection(item.uid) + .then(() => { + toast.success('WebSocket connection closed'); + setIsConnectionActive(false); + setIsConnecting(false); + }) + .catch((err) => { + console.error('Failed to close WebSocket connection:', err); + toast.error('Failed to close WebSocket connection'); + }); + }; + + const handleRunClick = async (e) => { + e.stopPropagation(); + if (!url) { + toast.error('Please enter a valid WebSocket URL'); + return; + } + handleRun(e); + }; + + const handleConnect = (e) => { + setIsConnecting(true); + dispatch(wsConnectOnly(item, collection.uid)); + }; + + const onSave = (finalValue) => { + dispatch(saveRequest(item.uid, collection.uid)); + }; + + return ( + +
+
+
+ WS +
+ onSave(finalValue)} + onChange={onUrlChange} + placeholder="ws://localhost:8080 or wss://example.com" + className="w-full" + theme={displayedTheme} + onRun={handleRun} + /> +
+
{ + e.stopPropagation(); + if (!item.draft) return; + onSave(); + }} + > + + + Save ({saveShortcut}) + +
+ + {isConnectionActive && ( +
+
+ + Close Connection +
+
+ )} + + {!isConnectionActive && ( +
+
+ + Connect +
+
+ )} + +
+ +
+
+
+
+ + {isConnectionActive &&
} +
+ ); +}; + +export default WsQueryUrl; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 72cd76701..ec3878a7f 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -29,6 +29,9 @@ import CollectionOverview from 'components/CollectionSettings/Overview'; import RequestNotLoaded from './RequestNotLoaded'; import RequestIsLoading from './RequestIsLoading'; import FolderNotFound from './FolderNotFound'; +import WsQueryUrl from 'components/RequestPane/WsQueryUrl'; +import WSRequestPane from 'components/RequestPane/WSRequestPane'; +import WSResponsePane from 'components/ResponsePane/WsResponsePane'; const MIN_LEFT_PANE_WIDTH = 300; const MIN_RIGHT_PANE_WIDTH = 350; @@ -118,7 +121,7 @@ const RequestTabPanel = () => { if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) { return; } - + setTopPaneHeight(newHeight); } else { const newWidth = e.clientX - mainRect.left - dragOffset.current.x; @@ -185,6 +188,7 @@ const RequestTabPanel = () => { const item = findItemInCollection(collection, activeTabUid); const isGrpcRequest = item?.type === 'grpc-request'; + const isWsRequest = item?.type === 'ws-request'; if (focusedTab.type === 'collection-runner') { return ; @@ -229,6 +233,7 @@ const RequestTabPanel = () => { const handleRun = async () => { const isGrpcRequest = item?.type === 'grpc-request'; + const isWsRequest = item?.type === 'ws-request'; const request = item.draft ? item.draft.request : item.request; if (isGrpcRequest && !request.url) { @@ -241,6 +246,11 @@ const RequestTabPanel = () => { return; } + if (isWsRequest && !request.url) { + toast.error('Please enter a valid WebSocket URL'); + return; + } + dispatch(sendRequest(item, collection.uid)).catch((err) => toast.custom((t) => toast.dismiss(t.id)} />, { duration: 5000 @@ -248,14 +258,21 @@ const RequestTabPanel = () => { ); }; + // TODO: reaper, improve selection of panes return ( - +
- {isGrpcRequest ? ( - - ) : ( - - )} + { + isGrpcRequest + ? + : isWsRequest + ? + : + }
@@ -265,6 +282,7 @@ const RequestTabPanel = () => { height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`, minHeight: `${MIN_TOP_PANE_HEIGHT}px`, width: '100%' + } : { width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px` }} @@ -286,6 +304,10 @@ const RequestTabPanel = () => { {isGrpcRequest ? ( ) : null} + + {isWsRequest ? ( + + ) : null}
@@ -298,7 +320,12 @@ const RequestTabPanel = () => { + ) : item.type === 'ws-request' ? ( + ) : ( diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index fe574fdaa..e9ff2712b 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -1,4 +1,4 @@ -import React, { useState, useRef, Fragment } from 'react'; +import React, { useCallback, useState, useRef, Fragment } from 'react'; import get from 'lodash/get'; import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; @@ -18,6 +18,7 @@ import NewRequest from 'components/Sidebar/NewRequest/index'; import CloseTabIcon from './CloseTabIcon'; import DraftTabIcon from './DraftTabIcon'; import { flattenItems } from 'utils/collections/index'; +import { closeWsConnection } from 'utils/network/index'; const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => { const dispatch = useDispatch(); @@ -66,10 +67,13 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi }; const getMethodColor = (method = '') => { - return theme.request.methods[method.toLocaleLowerCase()]; + const colorMap = { + ...theme.request.methods, + ...theme.request + }; + return colorMap[method.toLocaleLowerCase()]; }; - const folder = folderUid ? findItemInCollection(collection, folderUid) : null; if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) { return ( @@ -90,6 +94,19 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi const item = findItemInCollection(collection, tab.uid); + const getMethodText = useCallback((item) => { + if (!item) return; + const isGrpc = item.type === 'grpc-request'; + const isWS = item.type === 'ws-request'; + if (!isWS && !isGrpc) { + return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method'); + } + if (isGrpc) { + return 'gRPC'; + } + return 'WS'; + }, [item]); + if (!item) { return ( @@ -118,6 +135,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi item={item} onCancel={() => setShowConfirmClose(false)} onCloseWithoutSave={() => { + isWS && closeWsConnection(item.uid); dispatch( deleteRequestDraft({ itemUid: item.uid, @@ -161,8 +179,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi } }} > - - {isGrpc ? 'gRPC' : method} + + {method} {item.name} @@ -180,7 +198,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
{ - if (!item.draft) return handleCloseClick(e); + if (!item.draft) { + isWS && closeWsConnection(item.uid); + return handleCloseClick(e); + }; e.stopPropagation(); e.preventDefault(); diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js index 79bf5725b..70cae8332 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js @@ -45,7 +45,7 @@ const getEffectiveAuthSource = (collection, item) => { const Timeline = ({ collection, item }) => { // Get the effective auth source if auth mode is inherit const authSource = getEffectiveAuthSource(collection, item); - const isGrpcRequest = item.type === 'grpc-request'; + const isGrpcRequest = item.type === 'grpc-request' || item.type === 'ws-request'; // Filter timeline entries based on new rules const combinedTimeline = ([...(collection?.timeline || [])]).filter(obj => { diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js new file mode 100644 index 000000000..e4e358af4 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js @@ -0,0 +1,58 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + height: 100%; + overflow: hidden; + background: ${(props) => props.theme.bg}; + border-radius: 4px; + + div.tabs { + div.tab { + padding: 6px 0px; + border: none; + border-bottom: solid 2px transparent; + margin-right: 1.25rem; + color: var(--color-tab-inactive); + cursor: pointer; + + &:focus, + &:active, + &:focus-within, + &:focus-visible, + &:target { + outline: none !important; + box-shadow: none !important; + } + + &.active { + color: ${(props) => props.theme.tabs.active.color} !important; + border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important; + } + } + } + + .stream-status { + display: inline-flex; + align-items: center; + + &.complete { + color: ${(props) => props.theme.colors.text.green}; + } + + &.cancelled { + color: ${(props) => props.theme.colors.text.danger}; + } + + &.streaming { + color: ${(props) => props.theme.colors.text.blue}; + } + } + + .message-counter { + display: inline-flex; + align-items: center; + margin-left: 10px; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js new file mode 100644 index 000000000..da9947118 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js @@ -0,0 +1,44 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + overflow-y: auto; + + .ws-message.new { + background-color: ${({ theme }) => theme.table.striped}; + } + + .ws-message:not(:last-child) { + border-bottom: 1px solid ${({ theme }) => theme.table.border}; + } + + .ws-message:not(:last-child).open { + border-bottom-width: 0px; + } + + .ws-incoming { + background: ${(props) => props.theme.bg}; + border-color: ${(props) => props.theme.table.border}; + } + + .ws-outgoing { + background: ${(props) => props.theme.bg}; + border-color: ${(props) => props.theme.table.border}; + } + + .CodeMirror { + border-radius: 0.25rem; + } + + .CodeMirror-foldgutter, .CodeMirror-linenumbers, .CodeMirror-lint-markers { + background: ${({ theme }) => theme.bg}; + } + + div[role='tablist'] { + .active { + color: ${(props) => props.theme.colors.text.yellow}; + } + } + +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js new file mode 100644 index 000000000..8762863db --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js @@ -0,0 +1,204 @@ +import React from 'react'; +import classnames from 'classnames'; +import StyledWrapper from './StyledWrapper'; +import { IconExclamationCircle, IconChevronRight, IconInfoCircle, IconChevronDown, IconArrowUpRight, IconArrowDownLeft } from '@tabler/icons'; +import CodeEditor from 'components/CodeEditor/index'; +import { useTheme } from 'providers/Theme'; +import { useState } from 'react'; +import { useSelector } from 'react-redux'; +import _ from 'lodash'; +import { useRef } from 'react'; +import { useEffect } from 'react'; + +const getContentMeta = (content) => { + if (typeof content === 'object') { + return { + isJSON: true, + content: JSON.stringify(content, null, 0) + }; + } + try { + return { + isJSON: true, + content: JSON.stringify(JSON.parse(content), null, 0) + }; + } catch { + return { + isJSON: false, + content: content + }; + } +}; + +const parseContent = (content) => { + let contentMeta = getContentMeta(content); + return { + type: contentMeta.isJSON ? 'application/json' : 'text/plain', + content: contentMeta.isJSON ? JSON.stringify(JSON.parse(contentMeta.content), null, 2) : contentMeta.content + }; +}; + +const getDataTypeText = (type) => { + const textMap = { + 'text/plain': 'RAW', + 'application/json': 'JSON' + }; + return textMap[type] ?? 'RAW'; +}; + +/** + * + * @param {"incoming"|"outgoing"|"info"} type + */ +const TypeIcon = ({ type }) => { + const commonProps = { + size: 18 + }; + return { + incoming: , + outgoing: , + info: , + error: + }[type]; +}; + +const WSMessageItem = ({ message, inFocus }) => { + const [isOpen, setIsOpen] = useState(false); + const [showHex, setShowHex] = useState(false); + const preferences = useSelector((state) => state.app.preferences); + const { displayedTheme } = useTheme(); + const [isNew, setIsNew] = useState(false); + const notified = useRef(false); + + const isIncoming = message.type === 'incoming'; + const isInfo = message.type === 'info'; + const isError = message.type === 'error'; + const isOutgoing = message.type === 'outgoing'; + let contentHexdump = message.messageHexdump; + let parsedContent = parseContent(message.message); + const dataType = getDataTypeText(parsedContent.type); + + useEffect(() => { + if (notified.current === true) return; + const dateDiff = Date.now() - new Date(message.timestamp).getTime(); + if (dateDiff < 1000 * 10) { + setIsNew(true); + setTimeout(() => { + notified.current = true; + setIsNew(false); + }, 2500); + } + }, [message]); + + const canOpenMessage = !isInfo && !isError; + + return ( +
{ + if (!node) return; + if (inFocus) node.scrollIntoView(); + }} + className={classnames('ws-message flex flex-col p-2', { + 'ws-incoming': isIncoming, + 'ws-outgoing': !isIncoming, + 'open': isOpen, + 'new': isNew + })} + > +
{ + if (!canOpenMessage) return; + setIsOpen(!isOpen); + }} + > +
+ + + + {parsedContent.content} +
+
+ {message.timestamp && ( + {new Date(message.timestamp).toISOString()} + )} + {canOpenMessage + ? ( + + {isOpen ? ( + + ) : ( + + )} + + ) + : } +
+
+ {isOpen && ( + <> +
+
setShowHex(true)} + > + hexdump +
+
setShowHex(false)} + > + {dataType.toLowerCase()} +
+
+
+ +
+ + )} +
+ ); +}; + +const WSMessagesList = ({ order = -1, messages = [] }) => { + if (!messages.length) { + return
No messages yet.
; + } + const ordered = order === -1 ? messages : messages.slice().reverse(); + return ( + + {ordered.map((msg, idx, src) => { + const inFocus = order === -1 ? src.length - 1 === idx : idx === 0; + return ; + })} + + ); +}; + +export default WSMessagesList; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseHeaders/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseHeaders/StyledWrapper.js new file mode 100644 index 000000000..d42e77f7f --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseHeaders/StyledWrapper.js @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + table { + width: 100%; + border-collapse: collapse; + + thead { + color: #777777; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + } + + td { + padding: 6px 10px; + + &.value { + word-break: break-all; + } + } + + tbody { + tr:nth-child(odd) { + background-color: ${(props) => props.theme.table.striped}; + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseHeaders/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseHeaders/index.js new file mode 100644 index 000000000..01157d226 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseHeaders/index.js @@ -0,0 +1,43 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; + +const WSResponseHeaders = ({ response }) => { + const formatHeaders = (headers) => { + if (!headers) return []; + if (Array.isArray(headers)) return headers; + return Object.entries(headers).map(([key, value]) => ({ name: key, value })); + }; + + const headersArray = formatHeaders(response.headers); + + return ( + + + + + + + + + + {headersArray && headersArray.length ? ( + headersArray.map((header, index) => ( + + + + + )) + ) : ( + + + + )} + +
NameValue
{header.name}{header.value}
+ No headers received +
+
+ ); +}; + +export default WSResponseHeaders; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/StyledWrapper.js new file mode 100644 index 000000000..8c32a8bab --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/StyledWrapper.js @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + font-size: 0.8125rem; + color: ${(props) => props.theme.requestTabPanel.responseStatus}; +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/index.js new file mode 100644 index 000000000..ad272cd3a --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/index.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { IconSortDescending2, IconSortAscending2 } from '@tabler/icons'; +import { useDispatch } from 'react-redux'; +import StyledWrapper from './StyledWrapper'; +import { wsUpdateResponseSortOrder } from 'providers/ReduxStore/slices/collections/index'; + +const WSResponseSortOrder = ({ collection, item }) => { + const dispatch = useDispatch(); + + const order = item.response?.sortOrder ?? -1; + + const toggleSortOrder = () => { + dispatch(wsUpdateResponseSortOrder({ + itemUid: item.uid, + collectionUid: collection.uid + })); + }; + + return ( + + + + ); +}; + +export default WSResponseSortOrder; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/StyledWrapper.js new file mode 100644 index 000000000..510a40f01 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/StyledWrapper.js @@ -0,0 +1,22 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + font-size: 0.75rem; + font-weight: 600; + display: flex; + align-items: center; + + &.text-ok { + color: ${(props) => props.theme.requestTabPanel.responseOk}; + } + + &.text-pending { + color: ${(props) => props.theme.requestTabPanel.responsePending}; + } + + &.text-error { + color: ${(props) => props.theme.requestTabPanel.responseError}; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/get-ws-status-code-phrase.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/get-ws-status-code-phrase.js new file mode 100644 index 000000000..ca729ff7e --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/get-ws-status-code-phrase.js @@ -0,0 +1,20 @@ +const wsStatusCodePhraseMap = { + 1000: 'NORMAL_CLOSURE', + 1001: 'GOING_AWAY', + 1002: 'PROTOCOL_ERROR', + 1003: 'UNSUPPORTED_DATA', + 1004: 'RESERVED', + 1005: 'NO_STATUS_RECEIVED', + 1006: 'ABNORMAL_CLOSURE', + 1007: 'INVALID_FRAME_PAYLOAD_DATA', + 1008: 'POLICY_VIOLATION', + 1009: 'MESSAGE_TOO_BIG', + 1010: 'MANDATORY_EXTENSION', + 1011: 'INTERNAL_ERROR', + 1012: 'SERVICE_RESTART', + 1013: 'TRY_AGAIN_LATER', + 1014: 'BAD_GATEWAY', + 1015: 'TLS_HANDSHAKE' +}; + +export default wsStatusCodePhraseMap; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/index.js new file mode 100644 index 000000000..027ffb865 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/index.js @@ -0,0 +1,25 @@ +import React from 'react'; +import classnames from 'classnames'; +import wsStatusCodePhraseMap from './get-ws-status-code-phrase'; +import StyledWrapper from './StyledWrapper'; + +const WSStatusCode = ({ status, text }) => { + const getTabClassname = (status) => { + return classnames('ml-2', { + // ok if normal connect and normal closure + 'text-ok': parseInt(status) === 0 || parseInt(status) === 1000, + 'text-error': parseInt(status) !== 1000 && parseInt(status) !== 0 + }); + }; + + const statusText = text || wsStatusCodePhraseMap[status]; + + return ( + + {Number.isInteger(status) && status != 0 ?
{status}
: null} + {statusText &&
{statusText}
} +
+ ); +}; + +export default WSStatusCode; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js new file mode 100644 index 000000000..473f2387b --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js @@ -0,0 +1,153 @@ +import React from 'react'; +import find from 'lodash/find'; +import { useDispatch, useSelector } from 'react-redux'; +import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; +import Overlay from '../Overlay'; +import Placeholder from '../Placeholder'; +import WSStatusCode from './WSStatusCode'; +import ResponseTime from '../ResponseTime/index'; +import Timeline from '../Timeline'; +import ClearTimeline from '../ClearTimeline'; +import ResponseClear from '../ResponseClear'; +import StyledWrapper from './StyledWrapper'; +import ResponseLayoutToggle from '../ResponseLayoutToggle'; +import Tab from 'components/Tab'; +import WSMessagesList from './WSMessagesList'; +import WSResponseSortOrder from './WSResponseSortOrder'; +import WSResponseHeaders from './WSResponseHeaders'; + +const WSResult = ({ response }) => { + return ; +}; + +const WSResponsePane = ({ item, collection }) => { + const dispatch = useDispatch(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const isLoading = ['queued', 'sending'].includes(item.requestState); + + const requestTimeline = [...(collection?.timeline || [])].filter((obj) => { + if (obj.itemUid === item.uid) return true; + }); + + const selectTab = (tab) => { + dispatch(updateResponsePaneTab({ + uid: item.uid, + responsePaneTab: tab + })); + }; + + const response = item.response || {}; + + const getTabPanel = (tab) => { + switch (tab) { + case 'response': { + return ; + } + case 'headers': { + return ; + } + case 'timeline': { + return ; + } + default: { + return
404 | Not found
; + } + } + }; + + if (isLoading && !item.response) { + return ( + + + + ); + } + + if (!item.response && !requestTimeline?.length) { + return ( + + + + ); + } + + if (!activeTabUid) { + return
Something went wrong
; + } + + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + if (!focusedTab || !focusedTab.uid || !focusedTab.responsePaneTab) { + return
An error occurred!
; + } + + const tabConfig = [ + { + name: 'response', + label: 'Messages', + count: Array.isArray(response.responses) ? response.responses.length : 0 + }, + { + name: 'headers', + label: 'Headers', + count: response.headers ? Object.keys(response.headers).length : 0 + }, + { + name: 'timeline', + label: 'Timeline' + } + ]; + + return ( + +
+ {tabConfig.map((tab) => ( + + ))} + {!isLoading ? ( +
+ {focusedTab?.responsePaneTab === 'timeline' ? ( + <> + + + + ) : item?.response ? ( + <> + + + + + + + ) : null} +
+ ) : null} +
+
+ {isLoading ? : null} + {!item?.response ? ( + focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? ( + + ) : null + ) : ( + <>{getTabPanel(focusedTab.responsePaneTab)} + )} +
+
+ ); +}; + +export default WSResponsePane; diff --git a/packages/bruno-app/src/components/ShareCollection/index.js b/packages/bruno-app/src/components/ShareCollection/index.js index 7fb5fd523..f5bab25e7 100644 --- a/packages/bruno-app/src/components/ShareCollection/index.js +++ b/packages/bruno-app/src/components/ShareCollection/index.js @@ -14,9 +14,15 @@ const ShareCollection = ({ onClose, collectionUid }) => { const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); const isCollectionLoading = areItemsLoading(collection); - const hasGrpcRequests = useMemo(() => { + const hasNonExportableRequestTypes = useMemo(() => { + let types = new Set(); const checkItem = (item) => { if (item.type === 'grpc-request') { + types.add('gRPC'); + return true; + } + if (item.type === 'ws-request') { + types.add('WebSocket'); return true; } if (item.items) { @@ -24,7 +30,10 @@ const ShareCollection = ({ onClose, collectionUid }) => { } return false; }; - return collection?.items?.some(checkItem) || false; + return { + has: collection?.items?.filter(checkItem).length || false, + types: [...types] + }; }, [collection]); const handleExportBrunoCollection = () => { @@ -75,10 +84,15 @@ const ShareCollection = ({ onClose, collectionUid }) => { }`} onClick={isCollectionLoading ? undefined : handleExportPostmanCollection} > - {hasGrpcRequests && ( + {hasNonExportableRequestTypes.has && (
- Note: gRPC requests in this collection will not be exported + + Note: + {hasNonExportableRequestTypes.types.join(', ')} + {' '} + requests in this collection will not be exported +
)}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js index 04d338d5e..02aa2cf01 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js @@ -37,6 +37,9 @@ const Wrapper = styled.div` .method-grpc { color: ${(props) => props.theme.request.grpc}; } + .method-ws { + color: ${(props) => props.theme.request.ws}; + } `; export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js index 73cfc50ed..b4ca62a4e 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js @@ -1,33 +1,49 @@ -import React from 'react'; import classnames from 'classnames'; +import React from 'react'; import StyledWrapper from './StyledWrapper'; +const getMethodFlags = (item) => ({ + isGrpc: item.type === 'grpc-request', + isWS: item.type === 'ws-request' +}); + +const getMethodText = (item, { isGrpc, isWS }) => { + if (isGrpc) return 'grpc'; + if (isWS) return 'ws'; + return item.request.method.length > 5 + ? item.request.method.substring(0, 3) + : item.request.method; +}; + +const getClassname = (method = '', { isGrpc, isWS }) => { + method = method.toLocaleLowerCase(); + return classnames('mr-1', { + 'method-get': method === 'get', + 'method-post': method === 'post', + 'method-put': method === 'put', + 'method-delete': method === 'delete', + 'method-patch': method === 'patch', + 'method-head': method === 'head', + 'method-options': method === 'options', + 'method-grpc': isGrpc, + 'method-ws': isWS + }); +}; + const RequestMethod = ({ item }) => { - if (!['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) { + if (!['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) { return null; } - const isGrpc = item.type === 'grpc-request'; - - const getClassname = (method = '') => { - method = method.toLocaleLowerCase(); - return classnames('mr-1', { - 'method-get': method === 'get', - 'method-post': method === 'post', - 'method-put': method === 'put', - 'method-delete': method === 'delete', - 'method-patch': method === 'patch', - 'method-head': method === 'head', - 'method-options': method === 'options', - 'method-grpc': isGrpc, - }); - }; + const flags = getMethodFlags(item); + const methodText = getMethodText(item, flags); + const className = getClassname(item.request.method, flags); return ( -
+
- {isGrpc ? 'grpc' : item.request.method.length > 5 ? item.request.method.substring(0, 3) : item.request.method} + {methodText}
diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js index 6fbb39370..6f1b9091b 100644 --- a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js +++ b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js @@ -7,7 +7,7 @@ import { uuid } from 'utils/common'; import Modal from 'components/Modal'; import { useDispatch, useSelector } from 'react-redux'; import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections'; -import { newHttpRequest, newGrpcRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions'; import { addTab } from 'providers/ReduxStore/slices/tabs'; import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector'; import { getDefaultRequestPaneTab } from 'utils/collections'; @@ -93,6 +93,10 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { return 'grpc-request'; } + if (collectionPresets.requestType === 'ws') { + return 'ws-request'; + } + return 'http-request'; }; @@ -140,6 +144,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { }), onSubmit: (values) => { const isGrpcRequest = values.requestType === 'grpc-request'; + const isWsRequest = values.requestType === 'ws-request'; if (isGrpcRequest) { dispatch( @@ -159,6 +164,21 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request')); // will need to handle import from grpcurl command when we support it, now it is just for creating new requests + } else if (isWsRequest) { + dispatch(newWsRequest({ + requestName: values.requestName, + requestMethod: values.requestMethod, + filename: values.filename, + requestType: values.requestType, + requestUrl: values.requestUrl, + collectionUid: collection.uid, + itemUid: item ? item.uid : null + })) + .then(() => { + toast.success('New request created!'); + onClose(); + }) + .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request')); } else if (isEphemeral) { const uid = uuid(); dispatch( @@ -303,65 +323,81 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { Type -
- - +
+
+
+ + +
+
+ + +
+
- { - formik.setFieldValue('requestMethod', 'POST'); - formik.handleChange(event); - }} - value="graphql-request" - checked={formik.values.requestType === 'graphql-request'} - /> - +
+
+ + +
- { - formik.setFieldValue('requestMethod', 'POST'); - formik.handleChange(event); - }} - value="grpc-request" - checked={formik.values.requestType === 'grpc-request'} - /> - +
+ + +
+
- - - +
+
+ + +
+
@@ -452,7 +488,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { URL
- {formik.values.requestType !== 'grpc-request' ? ( + {!['grpc-request', 'ws-request'].includes(formik.values.requestType) ? (
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index fa2c6fd3e..e124f43bf 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -27,9 +27,6 @@ const initialState = { }, general: { defaultCollectionLocation: '' - }, - beta: { - grpc: false } }, generateCode: { 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 c341cf4f5..8ea8ee8cd 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -20,7 +20,7 @@ import { transformRequestToSaveToFilesystem } from 'utils/collections'; import { uuid, waitForNextTick } from 'utils/common'; -import { cancelNetworkRequest, sendGrpcRequest, sendNetworkRequest } from 'utils/network/index'; +import { cancelNetworkRequest, connectWS, sendGrpcRequest, sendNetworkRequest, sendWsRequest } from 'utils/network/index'; import { callIpc } from 'utils/common/ipc'; import { @@ -242,6 +242,39 @@ export const sendCollectionOauth2Request = (collectionUid, itemUid) => (dispatch }); }; +export const wsConnectOnly = (item, collectionUid) => (dispatch, getState) => { + const state = getState(); + const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments; + const collection = findCollectionByUid(state.collections.collections, collectionUid); + + return new Promise(async (resolve, reject) => { + if (!collection) { + return reject(new Error('Collection not found')); + } + + let collectionCopy = cloneDeep(collection); + + const itemCopy = cloneDeep(item); + + const requestUid = uuid(); + itemCopy.requestUid = requestUid; + + const globalEnvironmentVariables = getGlobalEnvironmentVariables({ + globalEnvironments, + activeGlobalEnvironmentUid + }); + collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables; + + const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid); + + connectWS(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables, { connectOnly: true }) + .then(resolve) + .catch((err) => { + toast.error(err.message); + }); + }); +}; + export const sendRequest = (item, collectionUid) => (dispatch, getState) => { const state = getState(); const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments; @@ -284,12 +317,19 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid); const isGrpcRequest = itemCopy.type === 'grpc-request'; + const isWsRequest = itemCopy.type === 'ws-request'; if (isGrpcRequest) { sendGrpcRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables) .then(resolve) .catch((err) => { toast.error(err.message); }); + } else if (isWsRequest) { + sendWsRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables) + .then(resolve) + .catch((err) => { + toast.error(err.message); + }); } else { sendNetworkRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables) .then((response) => { @@ -1045,6 +1085,65 @@ export const newGrpcRequest = (params) => (dispatch, getState) => { }); }; +export const newWsRequest = (params) => (dispatch, getState) => { + const { requestName, requestMethod, filename, requestUrl, collectionUid, body, auth, headers, itemUid } = params; + + return new Promise((resolve, reject) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + if (!collection) { + return reject(new Error('Collection not found')); + } + + const item = { + uid: uuid(), + name: requestName, + filename, + type: 'ws-request', + headers: headers ?? [], + request: { + url: requestUrl, + method: requestMethod, + body: body ?? { + mode: 'ws', + ws: [ + { + name: 'message 1', + type: 'json', + content: '{}' + } + ] + }, + auth: auth ?? { + mode: 'inherit' + } + } + }; + + const resolvedFilename = resolveRequestFilename(filename); + const fullName = path.join(collection.pathname, resolvedFilename); + const { ipcRenderer } = window; + + // Set the seq field for WebSocket requests + const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i)); + item.seq = items.length + 1; + + ipcRenderer + .invoke('renderer:new-request', fullName, item) + .then(() => { + // task middleware will track this and open the new request in a new tab once request is created + dispatch(insertTaskIntoQueue({ + uid: uuid(), + type: 'OPEN_REQUEST', + collectionUid, + itemPathname: fullName + })); + resolve(); + }) + .catch(reject); + }); +}; + export const loadGrpcMethodsFromReflection = (item, collectionUid, url) => async (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); 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 241d4bd02..14d63a555 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -44,6 +44,26 @@ const grpcStatusCodes = { 16: 'UNAUTHENTICATED' }; +// WebSocket status code meanings +const wsStatusCodes = { + 1000: 'NORMAL_CLOSURE', + 1001: 'GOING_AWAY', + 1002: 'PROTOCOL_ERROR', + 1003: 'UNSUPPORTED_DATA', + 1004: 'RESERVED', + 1005: 'NO_STATUS_RECEIVED', + 1006: 'ABNORMAL_CLOSURE', + 1007: 'INVALID_FRAME_PAYLOAD_DATA', + 1008: 'POLICY_VIOLATION', + 1009: 'MESSAGE_TOO_BIG', + 1010: 'MANDATORY_EXTENSION', + 1011: 'INTERNAL_ERROR', + 1012: 'SERVICE_RESTART', + 1013: 'TRY_AGAIN_LATER', + 1014: 'BAD_GATEWAY', + 1015: 'TLS_HANDSHAKE' +}; + const initialState = { collections: [], collectionSortOrder: 'default', @@ -65,6 +85,23 @@ const initiatedGrpcResponse = { timestamp: Date.now(), } +const initiatedWsResponse = { + status: 'PENDING', + statusText: 'PENDING', + statusCode: 0, + headers: [], + body: '', + size: 0, + duration: 0, + sortOrder: -1, + responses: [], + isError: false, + error: null, + errorDetails: null, + metadata: [], + trailers: [] +}; + export const collectionsSlice = createSlice({ name: 'collections', initialState, @@ -1436,6 +1473,10 @@ export const collectionsSlice = createSlice({ item.draft.request.body.grpc = action.payload.content; break; } + case 'ws': { + item.draft.request.body.ws = action.payload.content; + break; + } } } } @@ -1981,6 +2022,9 @@ export const collectionsSlice = createSlice({ case 'wsse': set(folder, 'root.request.auth.wsse', action.payload.content); break; + case 'ws': + set(folder, 'root.request.auth.ws', action.payload.content); + break; } } }, @@ -2729,6 +2773,146 @@ export const collectionsSlice = createSlice({ updateActiveConnections: (state, action) => { state.activeConnections = [...action.payload.activeConnectionIds]; }, + runWsRequestEvent: (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; + const request = item.draft ? item.draft.request : item.request; + + if (eventType === 'request') { + item.requestSent = eventData; + item.requestSent.timestamp = Date.now(); + item.response = { + ...initiatedWsResponse, + initiatedWsResponse, + statusText: 'CONNECTING' + }; + } + + if (!collection.timeline) { + collection.timeline = []; + } + + collection.timeline.push({ + type: 'request', + eventType: eventType, + collectionUid: collection.uid, + folderUid: null, + itemUid: item.uid, + timestamp: Date.now(), + data: { + request: eventData || item.requestSent || item.request, + timestamp: Date.now(), + eventData: eventData + } + }); + }, + wsResponseReceived: (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 || initiatedWsResponse; + const timestamp = item?.requestSent?.timestamp; + let updatedResponse = { + ...currentResponse, + isError: false, + error: '', + duration: Date.now() - (timestamp || Date.now()) + }; + + // Process based on event type + switch (eventType) { + case 'message': + // Add message to responses list + updatedResponse.responses = (currentResponse?.responses || []).concat(eventData); + break; + + case 'redirect': + updatedResponse.requestHeaders = eventData.headers; + updatedResponse.responses ||= []; + updatedResponse.responses.push({ + message: eventData.message, + type: eventData.type, + timestamp: eventData.timestamp + }); + break; + + case 'upgrade': + updatedResponse.headers = eventData.headers; + break; + + case 'open': + updatedResponse.status = 'CONNECTED'; + updatedResponse.statusText = 'CONNECTED'; + updatedResponse.statusCode = 0; + updatedResponse.responses ||= []; + updatedResponse.responses.push({ + message: `Connected to ${eventData.url}`, + type: 'info', + timestamp: eventData.timestamp + }); + break; + + case 'close': + const { code, reason } = eventData; + updatedResponse.isError = false; + updatedResponse.error = ''; + updatedResponse.status = 'CLOSED'; + updatedResponse.statusCode = code; + updatedResponse.statusText = wsStatusCodes[code] || 'CLOSED'; + updatedResponse.statusDescription = reason; + + updatedResponse.responses.push({ + type: code !== 1000 ? 'info' : 'error', + message: reason.trim().length ? ['Closed:', reason.trim()].join(' ') : 'Closed', + timestamp + }); + break; + + case 'error': + const errorDetails = eventData.error || eventData.message; + updatedResponse.isError = true; + updatedResponse.error = errorDetails || 'WebSocket error occurred'; + updatedResponse.status = 'ERROR'; + updatedResponse.statusCode = wsStatusCodes[1011]; + updatedResponse.statusText = 'ERROR'; + + updatedResponse.responses.push({ + type: 'error', + message: errorDetails || 'WebSocket error occurred', + timestamp + }); + + break; + + case 'connecting': + updatedResponse.status = 'CONNECTING'; + updatedResponse.statusText = 'CONNECTING'; + break; + } + + item.response = updatedResponse; + }, + wsUpdateResponseSortOrder: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + + if (collection) { + const item = findItemInCollection(collection, action.payload.itemUid); + if (item) { + item.response.sortOrder = item.response.sortOrder ? -item.response.sortOrder : -1; + } + } + } } }); @@ -2857,7 +3041,10 @@ export const { addRequestTag, deleteRequestTag, updateCollectionTagsList, - updateActiveConnections + updateActiveConnections, + runWsRequestEvent, + wsResponseReceived, + wsUpdateResponseSortOrder } = collectionsSlice.actions; export default collectionsSlice.reducer; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 9ed845611..e987d8e5b 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -42,7 +42,7 @@ export const tabsSlice = createSlice({ // Determine the default requestPaneTab based on request type let defaultRequestPaneTab = 'params'; - if (type === 'grpc-request') { + if (type === 'grpc-request' || type === 'ws-request') { defaultRequestPaneTab = 'body'; } else if (type === 'graphql-request') { defaultRequestPaneTab = 'query'; diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 482b0a896..1b574b405 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -102,13 +102,15 @@ const darkTheme = { options: '#d69956', head: '#d69956' }, - grpc: '#6366f1' + grpc: '#6366f1', + ws: '#f59e0b' }, requestTabPanel: { url: { bg: '#3D3D3D', - icon: 'rgb(204, 204, 204)' + icon: 'rgb(204, 204, 204)', + errorHoverBg: '#4a2a2a' }, dragbar: { border: '#444', @@ -262,7 +264,7 @@ const darkTheme = { border: '#373737', placeholder: { color: '#a2a2a2', - opacity: 0.50 + opacity: 0.5 }, gutter: { bg: '#262626' @@ -303,6 +305,12 @@ const darkTheme = { hoverBg: 'rgba(102, 102, 102, 0.08)', transition: 'all 0.1s ease' }, + tooltip: { + bg: '#1f1f1f', + color: '#ffffff', + shortcutColor: '#f59e0b' + }, + infoTip: { bg: '#1f1f1f', border: '#333333', @@ -313,6 +321,7 @@ const darkTheme = { border: '#323233', color: 'rgb(169, 169, 169)' }, + console: { bg: '#1e1e1e', headerBg: '#2d2d30', diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index 0b0d5e4b0..98e23bb0b 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -102,13 +102,15 @@ const lightTheme = { options: '#ca7811', head: '#ca7811' }, - grpc: '#6366f1' + grpc: '#6366f1', + ws: '#f59e0b' }, requestTabPanel: { url: { bg: '#f3f3f3', - icon: '#515151' + icon: '#515151', + errorHoverBg: '#fef2f2' }, dragbar: { border: '#efefef', @@ -304,6 +306,13 @@ const lightTheme = { hoverBg: 'rgba(139, 139, 139, 0.05)', // Matching the border color with reduced opacity transition: 'all 0.1s ease' }, + + tooltip: { + bg: '#374151', + color: '#ffffff', + shortcutColor: '#f59e0b' + }, + infoTip: { bg: 'white', border: '#e0e0e0', diff --git a/packages/bruno-app/src/utils/codemirror/lang-detect.js b/packages/bruno-app/src/utils/codemirror/lang-detect.js new file mode 100644 index 000000000..28f4d4aa8 --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/lang-detect.js @@ -0,0 +1,34 @@ +/** + * @param {string} snippet + * @returns {boolean} + */ +export function isXML(snippet) { + return /<\/?[a-z][\s\S]*>/i.test(snippet); +} + +/** + * @param {string} snippet + * @returns {boolean} + */ +export function isJSON(snippet) { + try { + JSON.parse(snippet); + return true; + } catch (err) { + return false; + } +} + +/** + * @param {string} snippet + * @returns {string} + */ +export function autoDetectLang(snippet) { + if (isJSON(snippet)) { + return 'json'; + } + if (isXML(snippet)) { + return 'xml'; + } + return 'text'; +} diff --git a/packages/bruno-app/src/utils/collections/export.js b/packages/bruno-app/src/utils/collections/export.js index 185c31acc..14b15b7d5 100644 --- a/packages/bruno-app/src/utils/collections/export.js +++ b/packages/bruno-app/src/utils/collections/export.js @@ -29,7 +29,7 @@ export const deleteUidsInItems = (items) => { */ export const transformItem = (items = []) => { each(items, (item) => { - if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) { + if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) { if (item.type === 'graphql-request') { item.type = 'graphql'; } @@ -41,6 +41,10 @@ export const transformItem = (items = []) => { if (item.type === 'grpc-request') { item.type = 'grpc'; } + + if (item.type === 'ws-request') { + item.type = 'ws'; + } } if (item.items && item.items.length) { diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 63ff14c4f..3b8440ba1 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -261,7 +261,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded), multipartForm: copyMultipartFormParams(si.request.body.multipartForm), file: copyFileParams(si.request.body.file), - grpc: si.request.body.grpc + grpc: si.request.body.grpc, + ws: si.request.body.ws }, script: si.request.script, vars: si.request.vars, @@ -424,6 +425,14 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} content: replaceTabsWithSpaces(content) })) } + + if (di.request.body.mode === 'ws') { + di.request.body.ws = di.request.body.ws.map(({ name, content, type }, index) => ({ + name: name ? name : `message ${index + 1}`, + type: type ?? 'json', + content: replaceTabsWithSpaces(content) + })); + } } if (si.type == 'folder' && si?.root) { @@ -623,8 +632,14 @@ export const transformRequestToSaveToFilesystem = (item) => { delete itemToSave.request.params } + if (_item.type === 'ws-request') { + delete itemToSave.request.method; + delete itemToSave.request.methodType; + delete itemToSave.request.params; + } + // Only process params for non-gRPC requests - if (_item.type !== 'grpc-request') { + if (!['grpc-request', 'ws-request'].includes(_item.type)) { each(_item.request.params, (param) => { itemToSave.request.params.push({ uid: param.uid, @@ -664,6 +679,17 @@ export const transformRequestToSaveToFilesystem = (item) => { }; } + if (itemToSave.request.body.mode === 'ws') { + itemToSave.request.body = { + ...itemToSave.request.body, + ws: itemToSave.request.body.ws.map(({ name, content, type }, index) => ({ + name: name ? name : `message ${index + 1}`, + type, + content: replaceTabsWithSpaces(content) + })) + }; + } + return itemToSave; }; @@ -691,7 +717,7 @@ export const deleteItemInCollectionByPathname = (pathname, collection) => { }; export const isItemARequest = (item) => { - return item.hasOwnProperty('request') && ['http-request', 'graphql-request', 'grpc-request'].includes(item.type) && !item.items; + return item.hasOwnProperty('request') && ['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type) && !item.items; }; export const isItemAFolder = (item) => { @@ -874,7 +900,7 @@ export const getDefaultRequestPaneTab = (item) => { return 'query'; } - if (item.type === 'grpc-request') { + if (['ws-request', 'grpc-request'].includes(item.type)) { return 'body'; } }; diff --git a/packages/bruno-app/src/utils/importers/common.js b/packages/bruno-app/src/utils/importers/common.js index 5d3b21d72..ec774eeaa 100644 --- a/packages/bruno-app/src/utils/importers/common.js +++ b/packages/bruno-app/src/utils/importers/common.js @@ -62,9 +62,10 @@ export const updateUidsInCollection = (_collection) => { export const transformItemsInCollection = (collection) => { const transformItems = (items = []) => { each(items, (item) => { - if (['http', 'graphql', 'grpc'].includes(item.type)) { + if (['http', 'graphql', 'grpc', 'ws'].includes(item.type)) { item.type = `${item.type}-request`; const isGrpcRequest = item.type === 'grpc-request'; + const isWSRequest = item.type === 'ws-request'; if (item.request.query) { item.request.params = item.request.query.map((queryItem) => ({ @@ -78,6 +79,11 @@ export const transformItemsInCollection = (collection) => { delete item.request.params; } + if (isWSRequest) { + delete item.request.params; + delete item.request.method; + } + delete item.request.query; // from 5 feb 2024, multipartFormData needs to have a type @@ -100,7 +106,6 @@ export const transformItemsInCollection = (collection) => { }; transformItems(collection.items); - return collection; }; diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index 1d2f665bd..7c1e3b74a 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -225,3 +225,115 @@ export const generateGrpcSampleMessage = async (methodPath, existingMessage = nu .catch(reject); }); }; + +export const connectWS = async (item, collection, environment, runtimeVariables, options) => { + return new Promise((resolve, reject) => { + startWsConnection(item, collection, environment, runtimeVariables, options) + .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)); + }); +}; + +export const sendWsRequest = (item, collection, environment, runtimeVariables) => { + return new Promise(async (resolve, reject) => { + const ensureConnection = async () => { + const connectionStatus = await isWsConnectionActive(item.uid); + if (!connectionStatus.isActive) { + await connectWS(item, collection, environment, runtimeVariables, { connectOnly: true }); + } + }; + const { request } = item.draft ? item.draft : item; + queueWsMessage(item, collection.uid, request.body.ws[0].content) + .then((initialState) => { + // Return an initial state object to update the UI + // The real response data will be handled by event listeners + resolve({ + ...initialState + }); + }) + .catch((err) => reject(err)); + await ensureConnection(); + }); +}; + +export const startWsConnection = async (item, collection, environment, runtimeVariables, options) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + const request = item.draft ? item.draft : item; + const settings = item.draft ? item.draft.settings : item.settings; + + ipcRenderer + .invoke('renderer:ws:start-connection', { + request, + collection, + environment, + runtimeVariables, + settings, + options + }) + .then(() => { + resolve(); + }) + .catch((err) => { + reject(err); + }); + }); +}; + +/** + * Sends a message to an existing WebSocket connection + * @param {string} requestId - The request ID to send a message to + * @param {string} collectionUid - The collection ID the message is for + * @param {*} message - The message + * @returns {Promise} - The result of the send operation + */ +export const queueWsMessage = async (item, collectionUid, message) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:ws:queue-message', item.uid, collectionUid, message).then(resolve).catch(reject); + }); +}; + +/** + * Sends a message to an existing WebSocket connection + * @param {string} requestId - The request ID to send a message to + * @param {Object} message - The message to send + * @returns {Promise} - The result of the send operation + */ +export const sendWsMessage = async (item, collectionUid, message) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:ws:send-message', item.uid, collectionUid, message).then(resolve).catch(reject); + }); +}; + +/** + * Closes a WebSocket connection + * @param {string} requestId - The request ID to close + * @returns {Promise} - The result of the close operation + */ +export const closeWsConnection = async (requestId) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:ws:close-connection', requestId).then(resolve).catch(reject); + }); +}; + +/** + * Checks if a WebSocket connection is active + * @param {string} requestId - The request ID to check + * @returns {Promise} - Whether the connection is active + */ +export const isWsConnectionActive = async (requestId) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:ws:is-connection-active', requestId).then(resolve).catch(reject); + }); +}; diff --git a/packages/bruno-app/src/utils/network/ws-event-listeners.js b/packages/bruno-app/src/utils/network/ws-event-listeners.js new file mode 100644 index 000000000..c98ec55ad --- /dev/null +++ b/packages/bruno-app/src/utils/network/ws-event-listeners.js @@ -0,0 +1,115 @@ +import { useEffect } from 'react'; +import { wsResponseReceived, runWsRequestEvent } from 'providers/ReduxStore/slices/collections/index'; +import { useDispatch } from 'react-redux'; +import { isElectron } from 'utils/common/platform'; +import { updateActiveConnectionsInStore } from 'providers/ReduxStore/slices/collections/actions'; + +const useWsEventListeners = () => { + const { ipcRenderer } = window; + const dispatch = useDispatch(); + + useEffect(() => { + if (!isElectron()) { + return () => {}; + } + + ipcRenderer.invoke('renderer:ready'); + + // Handle WebSocket requestSent event + const removeWsRequestSentListener = ipcRenderer.on('main:ws:request', (requestId, collectionUid, eventData) => { + dispatch(runWsRequestEvent({ + eventType: 'request', + itemUid: requestId, + collectionUid: collectionUid, + requestUid: requestId, + eventData + })); + }); + + const removeWsUpgradeListener = ipcRenderer.on('main:ws:upgrade', (requestId, collectionUid, eventData) => { + dispatch(wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'upgrade', + eventData: eventData + })); + }); + + const removeWsRedirectListener = ipcRenderer.on('main:ws:redirect', (requestId, collectionUid, eventData) => { + dispatch(wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'redirect', + eventData: eventData + })); + }); + + // Handle WebSocket message event + const removeWsMessageListener = ipcRenderer.on('main:ws:message', (requestId, collectionUid, eventData) => { + dispatch(wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'message', + eventData: eventData + })); + }); + + // Handle WebSocket open event + const removeWsOpenListener = ipcRenderer.on('main:ws:open', (requestId, collectionUid, eventData) => { + dispatch(wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'open', + eventData: eventData + })); + }); + + // Handle WebSocket close event + const removeWsCloseListener = ipcRenderer.on('main:ws:close', (requestId, collectionUid, eventData) => { + dispatch(wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'close', + eventData: eventData + })); + }); + + // Handle WebSocket error event + const removeWsErrorListener = ipcRenderer.on('main:ws:error', (requestId, collectionUid, eventData) => { + dispatch(wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'error', + eventData: eventData + })); + }); + + // Handle WebSocket connecting event + const removeWsConnectingListener = ipcRenderer.on('main:ws:connecting', (requestId, collectionUid, eventData) => { + dispatch(wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'connecting', + eventData: eventData + })); + }); + + const removeWsConnectionsChangedListener = ipcRenderer.on('main:ws:connections-changed', (data) => { + dispatch(updateActiveConnectionsInStore(data)); + }); + + return () => { + removeWsRequestSentListener(); + removeWsUpgradeListener(); + removeWsRedirectListener(); + removeWsMessageListener(); + removeWsOpenListener(); + removeWsCloseListener(); + removeWsErrorListener(); + removeWsConnectingListener(); + removeWsConnectionsChangedListener(); + }; + }, [isElectron]); +}; + +export default useWsEventListeners; diff --git a/packages/bruno-app/src/utils/tabs/index.js b/packages/bruno-app/src/utils/tabs/index.js index f99670623..392b0835b 100644 --- a/packages/bruno-app/src/utils/tabs/index.js +++ b/packages/bruno-app/src/utils/tabs/index.js @@ -1,7 +1,7 @@ import find from 'lodash/find'; export const isItemARequest = (item) => { - return item.hasOwnProperty('request') && ['http-request', 'graphql-request', 'grpc-request'].includes(item.type); + return item.hasOwnProperty('request') && ['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type); }; export const isItemAFolder = (item) => { diff --git a/packages/bruno-cli/src/utils/bru.js b/packages/bruno-cli/src/utils/bru.js index 6ddcc6de6..aef2cfd18 100644 --- a/packages/bruno-cli/src/utils/bru.js +++ b/packages/bruno-cli/src/utils/bru.js @@ -63,6 +63,9 @@ const bruToJson = (bru) => { case 'grpc': requestType = 'grpc-request'; break; + case 'ws': + requestType = 'ws-request'; + break; default: requestType = 'http-request'; } @@ -103,6 +106,13 @@ const bruToJson = (bru) => { content: '{}' }] }); + } else if (requestType === 'ws-request') { + transformedJson.request.auth.mode = _.get(json, 'ws.auth', 'none'); + const bodyFromBru = _.get(json, 'body') || {}; + transformedJson.request.body = { + mode: 'ws', + ws: [bodyFromBru] + }; } else { transformedJson.request.method = _.upperCase(_.get(json, 'http.method')); transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none'); diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index c8a0e118e..884f5dc14 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -57,6 +57,7 @@ "form-data": "^4.0.0", "fs-extra": "^10.1.0", "graphql": "^16.6.0", + "hexy": "^0.3.5", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "iconv-lite": "^0.6.3", diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 199adfbbb..8aaa20f2d 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -21,6 +21,7 @@ const brunoConverters = require('@usebruno/converters'); const { postmanToBruno } = brunoConverters; const { cookiesStore } = require('../store/cookies'); const { parseLargeRequestWithRedaction } = require('../utils/parse'); +const { wsClient } = require('../ipc/network/ws-event-handlers'); const { writeFile, @@ -556,7 +557,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } fs.rmSync(pathname, { recursive: true, force: true }); - } else if (['http-request', 'graphql-request', 'grpc-request'].includes(type)) { + } else if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(type)) { if (!fs.existsSync(pathname)) { return Promise.reject(new Error('The file does not exist')); } @@ -583,6 +584,12 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection console.log(`watcher stopWatching: ${collectionPath}`); watcher.removeWatcher(collectionPath, mainWindow, collectionUid); lastOpenedCollections.remove(collectionPath); + + // If wsclient was initialised for any collections that are opened + // then close for the current collection + if (wsClient) { + wsClient.closeForCollection(collectionUid); + } } }); @@ -602,7 +609,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection // Recursive function to parse the collection items and create files/folders const parseCollectionItems = (items = [], currentPath) => { items.forEach(async (item) => { - if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) { + if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) { let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`); const content = await stringifyRequestViaWorker(item); const filePath = path.join(currentPath, sanitizedFilename); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 655edea68..26e2e2863 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -32,6 +32,7 @@ const Oauth2Store = require('../../store/oauth2'); const { isRequestTagsIncluded } = require('@usebruno/common'); const { cookiesStore } = require('../../store/cookies'); const registerGrpcEventHandlers = require('./grpc-event-handlers'); +const { registerWsEventHandlers } = require('./ws-event-handlers'); const { getCertsAndProxyConfig } = require('./cert-utils'); const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!'; @@ -1508,6 +1509,7 @@ const executeRequestOnFailHandler = async (request, error) => { const registerAllNetworkIpc = (mainWindow) => { registerNetworkIpc(mainWindow); registerGrpcEventHandlers(mainWindow); + registerWsEventHandlers(mainWindow); } module.exports = registerAllNetworkIpc diff --git a/packages/bruno-electron/src/ipc/network/ws-event-handlers.js b/packages/bruno-electron/src/ipc/network/ws-event-handlers.js new file mode 100644 index 000000000..c811d99e2 --- /dev/null +++ b/packages/bruno-electron/src/ipc/network/ws-event-handlers.js @@ -0,0 +1,307 @@ +const { ipcMain, app } = require('electron'); +const { WsClient } = require('@usebruno/requests'); +const { safeParseJSON, safeStringifyJSON } = require('../../utils/common'); +const { cloneDeep, each, get } = require('lodash'); +const interpolateVars = require('./interpolate-vars'); +const { preferencesUtil } = require('../../store/preferences'); +const { getCertsAndProxyConfig } = require('./cert-utils'); +const { + getEnvVars, + getTreePathFromCollectionToItem, + mergeHeaders, + mergeScripts, + mergeVars, + mergeAuth, + getFormattedCollectionOauth2Credentials +} = require('../../utils/collection'); +const { getProcessEnvVars } = require('../../store/process-env'); +const { + getOAuth2TokenUsingPasswordCredentials, + getOAuth2TokenUsingClientCredentials, + getOAuth2TokenUsingAuthorizationCode +} = require('../../utils/oauth2'); +const { interpolateString } = require('./interpolate-string'); +const path = require('node:path'); +const { setAuthHeaders } = require('./prepare-request'); + +const prepareWsRequest = async (item, collection, environment, runtimeVariables, certsAndProxyConfig = {}) => { + const request = item.draft ? item.draft.request : item.request; + const collectionRoot = collection?.draft ? get(collection, 'draft', {}) : get(collection, 'root', {}); + const headers = {}; + + each(get(collectionRoot, 'request.headers', []), (h) => { + if (h.enabled && h.name?.toLowerCase() === 'content-type') { + return false; + } + }); + + each(get(request, 'headers', []), (h) => { + if (h.enabled) { + headers[h.name] = h.value; + } + }); + + const envVars = getEnvVars(environment); + const processEnvVars = getProcessEnvVars(collection.uid); + + let wsRequest = { + uid: item.uid, + url: request.url, + headers, + processEnvVars, + envVars, + runtimeVariables, + body: request.body, + // Add variable properties for interpolation + vars: request.vars, + collectionVariables: request.collectionVariables, + folderVariables: request.folderVariables, + requestVariables: request.requestVariables, + globalEnvironmentVariables: request.globalEnvironmentVariables, + oauth2CredentialVariables: request.oauth2CredentialVariables + }; + + wsRequest = setAuthHeaders(wsRequest, request, collection); + + if (wsRequest.oauth2) { + let requestCopy = cloneDeep(wsRequest); + const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {}; + let credentials, credentialsId, oauth2Url, debugInfo; + + switch (grantType) { + case 'authorization_code': + interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); + ({ + credentials, + url: oauth2Url, + credentialsId, + debugInfo + } = await getOAuth2TokenUsingAuthorizationCode({ + request: requestCopy, + collectionUid: collection.uid, + certsAndProxyConfig + })); + wsRequest.oauth2Credentials = { + credentials, + url: oauth2Url, + collectionUid: collection.uid, + credentialsId, + debugInfo, + folderUid: request.oauth2Credentials?.folderUid + }; + if (tokenPlacement == 'header') { + wsRequest.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; + } else { + try { + const url = new URL(request.url); + url?.searchParams?.set(tokenQueryKey, credentials?.access_token); + request.url = url?.toString(); + } catch (error) { } + } + break; + case 'client_credentials': + interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); + ({ + credentials, + url: oauth2Url, + credentialsId, + debugInfo + } = await getOAuth2TokenUsingClientCredentials({ + request: requestCopy, + collectionUid: collection.uid, + certsAndProxyConfig + })); + wsRequest.oauth2Credentials = { + credentials, + url: oauth2Url, + collectionUid: collection.uid, + credentialsId, + debugInfo, + folderUid: request.oauth2Credentials?.folderUid + }; + if (tokenPlacement == 'header') { + wsRequest.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; + } else { + try { + const url = new URL(request.url); + url?.searchParams?.set(tokenQueryKey, credentials?.access_token); + request.url = url?.toString(); + } catch (error) { } + } + break; + case 'password': + interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); + ({ + credentials, + url: oauth2Url, + credentialsId, + debugInfo + } = await getOAuth2TokenUsingPasswordCredentials({ + request: requestCopy, + collectionUid: collection.uid, + certsAndProxyConfig + })); + wsRequest.oauth2Credentials = { + credentials, + url: oauth2Url, + collectionUid: collection.uid, + credentialsId, + debugInfo, + folderUid: request.oauth2Credentials?.folderUid + }; + if (tokenPlacement == 'header') { + wsRequest.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; + } else { + try { + const url = new URL(request.url); + url?.searchParams?.set(tokenQueryKey, credentials?.access_token); + request.url = url?.toString(); + } catch (error) { } + } + break; + } + } + + interpolateVars(wsRequest, envVars, runtimeVariables, processEnvVars); + + return wsRequest; +}; + +// Creating wsClient at module level so it can be accessed from window-all-closed event +let wsClient; + +/** + * Register IPC handlers for WebSocket + */ +const registerWsEventHandlers = (window) => { + const sendEvent = (eventName, ...args) => { + if (window && window.webContents) { + window.webContents.send(eventName, ...args); + } else { + console.warn(`Unable to send message "${eventName}": Window not available`); + } + }; + + wsClient = new WsClient(sendEvent); + + // Start a new WebSocket connection + ipcMain.handle('renderer:ws:start-connection', + async (event, { request, collection, environment, runtimeVariables, settings, options = {} }) => { + try { + const requestCopy = cloneDeep(request); + const preparedRequest = await prepareWsRequest(requestCopy, collection, environment, runtimeVariables, {}); + const connectOnly = options?.connectOnly ?? false; + const requestSent = { + type: 'request', + url: preparedRequest.url, + headers: preparedRequest.headers, + body: preparedRequest.body, + timestamp: Date.now() + }; + + if (!connectOnly) { + const hasMessages = preparedRequest.body.ws.some((msg) => msg.content.length); + if (hasMessages) { + preparedRequest.body.ws.forEach((message) => { + wsClient.queueMessage(preparedRequest.uid, collection.uid, message.content); + }); + } + } + + // Start WebSocket connection + await wsClient.startConnection({ + request: preparedRequest, + collection, + options: { + timeout: settings.timeout, + keepAlive: settings.keepAliveInterval > 0 ? true : false, + keepAliveInterval: settings.keepAliveInterval + } + }); + + sendEvent('main:ws:request', preparedRequest.uid, collection.uid, requestSent); + + // Send OAuth credentials update if available + if (preparedRequest?.oauth2Credentials) { + window.webContents.send('main:credentials-update', { + credentials: preparedRequest.oauth2Credentials?.credentials, + url: preparedRequest.oauth2Credentials?.url, + collectionUid: collection.uid, + credentialsId: preparedRequest.oauth2Credentials?.credentialsId, + ...(preparedRequest.oauth2Credentials?.folderUid + ? { folderUid: preparedRequest.oauth2Credentials.folderUid } + : { itemUid: preparedRequest.uid }), + debugInfo: preparedRequest.oauth2Credentials.debugInfo + }); + } + + return { success: true }; + } catch (error) { + console.error('Error starting WebSocket connection:', error); + if (error instanceof Error) { + throw error; + } + sendEvent('main:ws:error', request.uid, collection.uid, { error: error.message }); + return { success: false, error: error.message }; + } + }); + + // Get all active connection IDs + ipcMain.handle('renderer:ws:get-active-connections', (event) => { + try { + const activeConnectionIds = wsClient.getActiveConnectionIds(); + return { success: true, activeConnectionIds }; + } catch (error) { + console.error('Error getting active connections:', error); + return { success: false, error: error.message, activeConnectionIds: [] }; + } + }); + + ipcMain.handle('renderer:ws:queue-message', (event, requestId, collectionUid, message) => { + try { + wsClient.queueMessage(requestId, collectionUid, message); + return { success: true }; + } catch (error) { + console.error('Error queuing WebSocket message:', error); + return { success: false, error: error.message }; + } + }); + + // Send a message to an existing WebSocket connection + ipcMain.handle('renderer:ws:send-message', (event, requestId, collectionUid, message) => { + try { + wsClient.sendMessage(requestId, collectionUid, message); + return { success: true }; + } catch (error) { + console.error('Error sending WebSocket message:', error); + return { success: false, error: error.message }; + } + }); + + // Close a WebSocket connection + ipcMain.handle('renderer:ws:close-connection', (event, requestId, code, reason) => { + try { + wsClient.close(requestId, code, reason); + return { success: true }; + } catch (error) { + console.error('Error closing WebSocket connection:', error); + return { success: false, error: error.message }; + } + }); + + // Check if a WebSocket connection is active + ipcMain.handle('renderer:ws:is-connection-active', (event, requestId) => { + try { + const isActive = wsClient.isConnectionActive(requestId); + return { success: true, isActive }; + } catch (error) { + console.error('Error checking WebSocket connection status:', error); + return { success: false, error: error.message, isActive: false }; + } + }); +}; + +module.exports = { + registerWsEventHandlers, + wsClient +}; diff --git a/packages/bruno-filestore/src/formats/bru/index.ts b/packages/bruno-filestore/src/formats/bru/index.ts index daba7a78d..61002db64 100644 --- a/packages/bruno-filestore/src/formats/bru/index.ts +++ b/packages/bruno-filestore/src/formats/bru/index.ts @@ -24,11 +24,19 @@ export const bruRequestToJson = (data: string | any, parsed: boolean = false): a case 'grpc': requestType = 'grpc-request'; break; + case 'ws': + requestType = 'ws-request'; + break; default: requestType = 'http-request'; } const sequence = _.get(json, 'meta.seq'); + const urlPath: Record = { + 'grpc-request': 'grpc.url', + 'ws-request': 'ws.url', + 'default': 'http.url' + }; const transformedJson = { type: requestType, name: _.get(json, 'meta.name'), @@ -38,8 +46,10 @@ export const bruRequestToJson = (data: string | any, parsed: boolean = false): a request: { // Preserving special characters in custom methods. Using _.upperCase strips special characters. method: - requestType === 'grpc-request' ? _.get(json, 'grpc.method', '') : String(_.get(json, 'http.method') ?? '').toUpperCase(), - url: _.get(json, requestType === 'grpc-request' ? 'grpc.url' : 'http.url'), + requestType === 'grpc-request' + ? _.get(json, 'grpc.method', '') + : String(_.get(json, 'http.method') ?? '').toUpperCase(), + url: _.get(json, urlPath[requestType], _.get(json, urlPath.default)), headers: requestType === 'grpc-request' ? _.get(json, 'metadata', []) : _.get(json, 'headers', []), auth: _.get(json, 'auth', {}), body: _.get(json, 'body', {}), @@ -67,6 +77,17 @@ export const bruRequestToJson = (data: string | any, parsed: boolean = false): a } ]) }); + } else if (requestType === 'ws-request') { + transformedJson.request.auth.mode = _.get(json, 'ws.auth', 'none'); + transformedJson.request.body = _.get(json, 'body', { + mode: 'ws', + ws: _.get(json, 'body.ws', [ + { + name: 'message 1', + content: '{}' + } + ]) + }); } else { // For HTTP and GraphQL (transformedJson.request as any).params = _.get(json, 'params', []); @@ -103,6 +124,9 @@ export const jsonRequestToBru = (json: any): string => { case 'grpc-request': type = 'grpc'; break; + case 'ws-request': + type = 'ws'; + break; default: type = 'http'; } @@ -156,6 +180,22 @@ export const jsonRequestToBru = (json: any): string => { } ]) }); + } else if (type === 'ws') { + bruJson.ws = { + url: _.get(json, 'request.url'), + auth: _.get(json, 'request.auth.mode', 'none'), + body: _.get(json, 'request.body.mode', 'ws') + }; + + bruJson.body = _.get(json, 'request.body', { + mode: 'ws', + ws: _.get(json, 'request.body.ws', [ + { + name: 'message 1', + content: '{}' + } + ]) + }); } // Common fields for all request types diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index b6b72edc3..6355fb9a3 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -29,9 +29,9 @@ const { safeParseJson, outdentString } = require('./utils'); * */ const grammar = ohm.grammar(`Bru { - BruFile = (meta | http | grpc | query | params | headers | metadata | auths | bodies | varsandassert | script | tests | settings | docs)* + BruFile = (meta | http | grpc | ws | query | params | headers | metadata | auths | bodies | varsandassert | script | tests | settings | docs)* auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth2 | authwsse | authapikey | authOauth2Configs - bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body | bodygrpc + bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body | bodygrpc | bodyws bodyforms = bodyformurlencoded | bodymultipart | bodyfile params = paramspath | paramsquery @@ -88,6 +88,7 @@ const grammar = ohm.grammar(`Bru { http = get | post | put | delete | patch | options | head | connect | trace | httpcustom grpc = "grpc" dictionary + ws = "ws" dictionary get = "get" dictionary post = "post" dictionary put = "put" dictionary @@ -138,6 +139,7 @@ const grammar = ohm.grammar(`Bru { bodygraphql = "body:graphql" st* "{" nl* textblock tagend bodygraphqlvars = "body:graphql:vars" st* "{" nl* textblock tagend bodygrpc = "body:grpc" dictionary + bodyws = "body:ws" dictionary bodyformurlencoded = "body:form-urlencoded" dictionary bodymultipart = "body:multipart-form" dictionary @@ -278,6 +280,19 @@ const mapPairListToKeyValPair = (pairList = []) => { return _.merge({}, ...pairList[0]); }; +/** + * @param {Record} obj + * @returns {(key:string, opts?:{fallback: number })=>number|undefined} + */ +const createGetNumFromRecord = (obj) => (key, { fallback } = {}) => { + if (!(key in obj)) return fallback; + const asNumber = typeof obj[key] === 'number' ? obj[key] : Number(obj[key]); + if (isNaN(asNumber)) { + return fallback; + } + return asNumber; +}; + const sem = grammar.createSemantics().addAttribute('ast', { BruFile(tags) { if (!tags || !tags.ast || !tags.ast.length) { @@ -408,10 +423,19 @@ const sem = grammar.createSemantics().addAttribute('ast', { }, settings(_1, dictionary) { let settings = mapPairListToKeyValPair(dictionary.ast); + const getNumFromRecord = createGetNumFromRecord(settings); + + const keepAliveInterval = getNumFromRecord('keepAliveInterval', { + fallback: 0 + }); + + const timeout = getNumFromRecord('timeout'); return { settings: { - encodeUrl: typeof settings.encodeUrl === 'boolean' ? settings.encodeUrl : settings.encodeUrl === 'true' + encodeUrl: typeof settings.encodeUrl === 'boolean' ? settings.encodeUrl : settings.encodeUrl === 'true', + keepAliveInterval, + timeout } }; }, @@ -420,6 +444,11 @@ const sem = grammar.createSemantics().addAttribute('ast', { grpc: mapPairListToKeyValPair(dictionary.ast) }; }, + ws(_1, dictionary) { + return { + ws: mapPairListToKeyValPair(dictionary.ast) + }; + }, get(_1, dictionary) { return { http: { @@ -968,6 +997,29 @@ const sem = grammar.createSemantics().addAttribute('ast', { }] } }; + }, + bodyws(_1, dictionary) { + const pairs = mapPairListToKeyValPairs(dictionary.ast, false); + const namePair = _.find(pairs, { name: 'name' }); + const contentPair = _.find(pairs, { name: 'content' }); + const typePair = _.find(pairs, { name: 'type' }); + + const messageName = namePair ? namePair.value : ''; + const messageContent = contentPair ? contentPair.value : ''; + const messageTypeContent = typePair ? typePair.value : ''; + + return { + body: { + mode: 'ws', + ws: [ + { + name: messageName, + type: messageTypeContent, + content: messageContent + } + ] + } + }; } }); diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index 7531eb26b..9606324ff 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -17,7 +17,7 @@ const stripLastLine = (text) => { }; const jsonToBru = (json) => { - const { meta, http, grpc, params, headers, metadata, auth, body, script, tests, vars, assertions, settings, docs } = json; + const { meta, http, grpc, ws, params, headers, metadata, auth, body, script, tests, vars, assertions, settings, docs } = json; let bru = ''; @@ -95,6 +95,31 @@ const jsonToBru = (json) => { bru += ` } +`; + } + + if (ws && ws.url) { + bru += `ws { + url: ${ws.url}`; + + if (ws.body && ws.body.length) { + bru += ` + body: ${ws.body}`; + } + + if (ws.auth && ws.auth.length) { + bru += ` + auth: ${ws.auth}`; + } + + if (ws.methodType && ws.methodType.length) { + bru += ` + methodType: ${ws.methodType}`; + } + + bru += ` +} + `; } @@ -585,6 +610,29 @@ ${indentString(body.sparql)} } } + if (body && body.ws) { + // Convert each ws message to a separate body:ws block + if (Array.isArray(body.ws)) { + body.ws.forEach((message) => { + const { name, content, type = '' } = message; + + bru += `body:ws {\n`; + + bru += `${indentString(`name: ${getValueString(name)}`)}\n`; + if (type.length) { + bru += `${indentString(`type: ${getValueString(type)}`)}\n`; + } + + // Convert content to JSON string if it's an object + let contentValue = typeof content === 'object' ? JSON.stringify(content, null, 2) : content || '{}'; + + // Wrap content with triple quotes for multiline support, without extra indentation + bru += `${indentString(`content: '''\n${indentString(contentValue)}\n'''`)}\n`; + bru += '}\n\n'; + }); + } + } + let reqvars = _.get(vars, 'req'); let resvars = _.get(vars, 'res'); if (reqvars && reqvars.length) { diff --git a/packages/bruno-lang/v2/tests/bruToJson.spec.js b/packages/bruno-lang/v2/tests/bruToJson.spec.js new file mode 100644 index 000000000..a6199abb6 --- /dev/null +++ b/packages/bruno-lang/v2/tests/bruToJson.spec.js @@ -0,0 +1,48 @@ +const parser = require('../src/bruToJson'); + +describe('bruToJson parser', () => { + describe('body:ws', () => { + it('infers message and settings | smoke', () => { + const input = ` +body:ws { + type: json + name: message 1 + content: ''' + {"foo":"bar"} + ''' +} + +settings { + timeout: 30 +} +`; + + const expected = { + body: { + mode: 'ws', + ws: [ + { + content: '{"foo":"bar"}', + name: 'message 1', + type: 'json' + } + ] + }, + settings: { + encodeUrl: false, + keepAliveInterval: 0, + timeout: 30 + } + }; + + const output = parser(input); + + // Stub value if it doesn't exist in the input + expect(output).toHaveProperty('settings.keepAliveInterval'); + + // value needs to be a number + expect(output.settings.keepAliveInterval).toBe(0); + expect(output).toEqual(expected); + }); + }); +}); diff --git a/packages/bruno-lang/v2/tests/jsonToBru.spec.js b/packages/bruno-lang/v2/tests/jsonToBru.spec.js new file mode 100644 index 000000000..56e8ea059 --- /dev/null +++ b/packages/bruno-lang/v2/tests/jsonToBru.spec.js @@ -0,0 +1,56 @@ +const stringify = require('../src/jsonToBru'); + +describe('jsonToBru stringify', () => { + describe('body:ws', () => { + it('stringifies a valid bruno request | smoke', () => { + const input = { + ws: { + url: 'ws://localhost:3000', + body: 'ws' + }, + body: { + mode: 'ws', + ws: [ + { + content: '{"foo":"bar"}', + name: 'message 1', + type: 'json' + } + ] + }, + settings: { + keepAliveInterval: 30, + timeout: 250 + } + }; + + const output = stringify(input); + + // generic structure snapshot + expect(output).toMatchInlineSnapshot(` + "ws { + url: ws://localhost:3000 + body: ws + } + + body:ws { + name: message 1 + type: json + content: ''' + {"foo":"bar"} + ''' + } + + settings { + keepAliveInterval: 30 + timeout: 250 + } + " + `); + + // Hard check if the input settings were stored as is + expect(output).toMatch(new RegExp(`keepAliveInterval: ${input.settings.keepAliveInterval}`)); + expect(output).toMatch(new RegExp(`timeout: ${input.settings.timeout}`)); + }); + }); +}); diff --git a/packages/bruno-requests/package.json b/packages/bruno-requests/package.json index 266b3b472..0ba5ff49b 100644 --- a/packages/bruno-requests/package.json +++ b/packages/bruno-requests/package.json @@ -27,6 +27,8 @@ "axios": "^1.9.0", "grpc-reflection-js": "^0.3.0", "is-ip": "^5.0.1", + "ws": "^8.18.3", + "system-ca": "^2.0.1", "tough-cookie": "^6.0.0" }, "devDependencies": { diff --git a/packages/bruno-requests/rollup.config.js b/packages/bruno-requests/rollup.config.js index 9d8f9513e..377f2e23d 100644 --- a/packages/bruno-requests/rollup.config.js +++ b/packages/bruno-requests/rollup.config.js @@ -39,6 +39,6 @@ module.exports = [ typescript({ tsconfig: './tsconfig.json' }), terser(), ], - external: ['axios', 'qs'] + external: ['axios', 'qs', 'ws'] } ]; diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts index ffddfeffb..c30e41772 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -1,5 +1,6 @@ -export { addDigestInterceptor, getOAuth2Token } from './auth'; +export { addDigestInterceptor, getOAuth2Token } from './auth'; export { GrpcClient, generateGrpcSampleMessage } from './grpc'; +export { WsClient } from './ws/ws-client'; export { default as cookies } from './cookies'; export { getCACertificates } from './utils/ca-cert'; diff --git a/packages/bruno-requests/src/ws/ws-client.js b/packages/bruno-requests/src/ws/ws-client.js new file mode 100644 index 000000000..90bb8a0bc --- /dev/null +++ b/packages/bruno-requests/src/ws/ws-client.js @@ -0,0 +1,394 @@ +import ws from 'ws'; +import { hexy as hexdump } from 'hexy'; + +/** + * Safely parse JSON string with error handling + * @param {string} jsonString - The JSON string to parse + * @param {string} context - Context for error messages + * @returns {Object} Parsed object or throws error with context + * @throws {Error} If JSON parsing fails + */ +const safeParseJSON = (jsonString, context = 'JSON string') => { + try { + return JSON.parse(jsonString); + } catch (error) { + const errorMessage = `Failed to parse ${context}: ${error.message}`; + console.error(errorMessage, { + originalString: jsonString, + parseError: error + }); + throw new Error(errorMessage); + } +}; + +/** + * Get parsed WebSocket URL object + * @param {string} url - The WebSocket URL + * @returns {Object} Parsed URL object with protocol, host, path + */ +const getParsedWsUrlObject = (url) => { + const addProtocolIfMissing = (str) => { + if (str.includes('://')) return str; + + // For localhost, default to insecure (grpc://) for local development + if (str.includes('localhost') || str.includes('127.0.0.1')) { + return `ws://${str}`; + } + + // For other hosts, default to secure + return `wss://${str}`; + }; + + const removeTrailingSlash = (str) => (str.endsWith('/') ? str.slice(0, -1) : str); + + if (!url) return { host: '', path: '' }; + + try { + const urlObj = new URL(addProtocolIfMissing(url.toLowerCase())); + return { + protocol: urlObj.protocol, + host: urlObj.host, + path: removeTrailingSlash(urlObj.pathname), + search: urlObj.search, + fullUrl: urlObj.href + }; + } catch (err) { + console.error({ err }); + return { + host: '', + path: '' + }; + } +}; + +class WsClient { + messageQueues = {}; + activeConnections = new Map(); + connectionKeepAlive = new Map(); + + constructor(eventCallback) { + this.eventCallback = eventCallback; + } + + /** + * Start a WebSocket connection + * @param {Object} params - Connection parameters + * @param {Object} params.request - The WebSocket request object + * @param {Object} params.collection - The collection object + * @param {Object} params.options - Additional connection options + */ + async startConnection({ request, collection, options = {} }) { + const { url, headers } = request; + const { timeout = 30000, keepAlive = false, keepAliveInterval = 10_000 } = options; + + const parsedUrl = getParsedWsUrlObject(url); + const timeoutAsNumber = Number(timeout); + const validTimeout = isNaN(timeoutAsNumber) ? 30000 : timeoutAsNumber; + + const requestId = request.uid; + const collectionUid = collection.uid; + + try { + // Create WebSocket connection + const protocols = [].concat([headers['Sec-WebSocket-Protocol'], headers['sec-websocket-protocol']]).filter(Boolean); + const protocolVersion = headers['Sec-WebSocket-Version'] || headers['sec-websocket-version']; + + const wsOptions = { + headers, + handshakeTimeout: validTimeout, + followRedirects: true + }; + + if (protocolVersion) { + wsOptions.protocolVersion = protocolVersion; + } + + const wsConnection = new ws.WebSocket(parsedUrl.fullUrl, protocols, wsOptions); + + // Set up event handlers + this.#setupWsEventHandlers(wsConnection, requestId, collectionUid, { keepAlive, keepAliveInterval }); + + // Store the connection + this.#addConnection(requestId, collectionUid, wsConnection); + + // Emit connecting event + this.eventCallback('main:ws:connecting', requestId, collectionUid); + + return wsConnection; + } catch (error) { + console.error('Error creating WebSocket connection:', error); + this.eventCallback('main:ws:error', requestId, collectionUid, { + error: error.message + }); + throw error; + } + } + + #getMessageQueueId(requestId) { + return `${requestId}`; + } + + queueMessage(requestId, collectionUid, message) { + const connectionMeta = this.activeConnections.get(requestId); + + const mqKey = this.#getMessageQueueId(requestId); + this.messageQueues[mqKey] ||= []; + this.messageQueues[mqKey].push(message); + + if (connectionMeta && connectionMeta.connection && connectionMeta.connection.readyState === WebSocket.OPEN) { + this.#flushQueue(requestId, collectionUid); + return; + } + } + + #flushQueue(requestId, collectionUid) { + const mqKey = this.#getMessageQueueId(requestId); + if (!(mqKey in this.messageQueues)) return; + while (this.messageQueues[mqKey].length > 0) { + this.sendMessage(requestId, collectionUid, this.messageQueues[mqKey].shift()); + } + } + + /** + * Send a message to an active WebSocket connection + * @param {string} requestId - The request ID of the active connection + * @param {string} collectionUid - The collection UID for the request + * @param {Object|string} message - The message to send + */ + sendMessage(requestId, collectionUid, message) { + const connectionMeta = this.activeConnections.get(requestId); + + if (connectionMeta.connection && connectionMeta.connection.readyState === WebSocket.OPEN) { + let messageToSend; + + // Parse the message if it's a string + if (typeof message === 'string') { + try { + messageToSend = safeParseJSON(message, 'message content'); + } catch (parseError) { + // If parsing fails, send as string + messageToSend = message; + } + } else { + messageToSend = message; + } + + // Send the message + connectionMeta.connection.send(JSON.stringify(messageToSend), (error) => { + if (error) { + this.eventCallback('main:ws:error', requestId, collectionUid, { error }); + } else { + // Emit message sent event + this.eventCallback('main:ws:message', requestId, collectionUid, { + message: messageToSend, + messageHexdump: hexdump(JSON.stringify(messageToSend)), + type: 'outgoing', + timestamp: Date.now() + }); + } + }); + } else { + const error = new Error('WebSocket connection not available or not open'); + this.eventCallback('main:ws:error', requestId, collectionUid, { + error: error.message + }); + } + } + + /** + * Close a WebSocket connection + * @param {string} requestId - The request ID to close + * @param {number} code - Close code (optional) + * @param {string} reason - Close reason (optional) + */ + close(requestId, code = 1000, reason = 'Client initiated close') { + const connectionMeta = this.activeConnections.get(requestId); + if (connectionMeta?.connection) { + connectionMeta.connection.close(code, reason); + this.#removeConnection(requestId); + } + } + + /** + * Check if a connection is active + * @param {string} requestId - The request ID to check + * @returns {boolean} - Whether the connection is active + */ + isConnectionActive(requestId) { + const connectionMeta = this.activeConnections.get(requestId); + return connectionMeta && connectionMeta.connection.readyState === ws.WebSocket.OPEN; + } + + /** + * Get all active connection IDs + * @returns {string[]} Array of active connection IDs + */ + getActiveConnectionIds() { + return Array.from(this.activeConnections.keys()); + } + + closeForCollection(collectionUid) { + [...this.activeConnections.keys()].forEach((k) => { + const meta = this.activeConnections.get(k); + if (meta.collectionUid === collectionUid) { + meta.connection.close(); + this.activeConnections.delete(k); + } + }); + } + + /** + * Clear all active connections + */ + clearAllConnections() { + const connectionIds = this.getActiveConnectionIds(); + + this.activeConnections.forEach((connection) => { + if (connection.readyState === WebSocket.OPEN) { + connection.close(1000, 'Client clearing all connections'); + } + }); + + this.activeConnections.clear(); + + // Emit an event with empty active connection IDs + if (connectionIds.length > 0) { + this.eventCallback('main:ws:connections-changed', { + type: 'cleared', + activeConnectionIds: [] + }); + } + } + + /** + * Set up WebSocket event handlers + * @param {WebSocket} ws - The WebSocket instance + * @param {string} requestId - The request ID + * @param {string} collectionUid - The collection UID + * @param {object} options + * @param {boolean} options.keepAlive - keep the connection alive + * @param {number} options.keepAliveInterval - What the interval for keeping interval + * @private + */ + #setupWsEventHandlers(ws, requestId, collectionUid, options) { + ws.on('open', () => { + this.#flushQueue(requestId, collectionUid); + + if (options.keepAlive) { + const handle = setInterval(() => { + ws.isAlive = false; + ws.ping(); + }, options.keepAliveInterval); + + this.connectionKeepAlive.set(requestId, handle); + } + + this.eventCallback('main:ws:open', requestId, collectionUid, { + timestamp: Date.now(), + url: ws.url + }); + }); + + ws.on('redirect', (url, req) => { + const headerNames = req.getHeaderNames(); + const headers = Object.fromEntries(headerNames.map((d) => [d, req.getHeader(d)])); + this.eventCallback('main:ws:redirect', requestId, collectionUid, { + message: `Redirected to ${url}`, + type: 'info', + timestamp: Date.now(), + headers: headers + }); + }); + + ws.on('upgrade', (response) => { + this.eventCallback('main:ws:upgrade', requestId, collectionUid, { + type: 'info', + timestamp: Date.now(), + headers: { ...response.headers } + }); + }); + + ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + this.eventCallback('main:ws:message', requestId, collectionUid, { + message, + messageHexdump: hexdump(Buffer.from(data)), + type: 'incoming', + timestamp: Date.now() + }); + } catch (error) { + // If parsing fails, send as raw data + this.eventCallback('main:ws:message', requestId, collectionUid, { + message: data.toString(), + messageHexdump: hexdump(data), + type: 'incoming', + timestamp: Date.now() + }); + } + }); + + ws.on('close', (code, reason) => { + this.eventCallback('main:ws:close', requestId, collectionUid, { + code, + reason: Buffer.from(reason).toString(), + timestamp: Date.now() + }); + this.#removeConnection(requestId); + }); + + ws.on('error', (error) => { + this.eventCallback('main:ws:error', requestId, collectionUid, { + error: error.message, + timestamp: Date.now() + }); + }); + } + + /** + * Add a connection to the active connections map and emit an event + * @param {string} requestId - The request ID + * @param {WebSocket} connection - The WebSocket connection + * @private + */ + #addConnection(requestId, collectionUid, connection) { + this.activeConnections.set(requestId, { collectionUid, connection }); + + // Emit an event with all active connection IDs + this.eventCallback('main:ws:connections-changed', { + type: 'added', + requestId, + activeConnectionIds: this.getActiveConnectionIds() + }); + } + + /** + * Remove a connection from the active connections map and emit an event + * @param {string} requestId - The request ID + * @private + */ + #removeConnection(requestId) { + if (this.connectionKeepAlive.has(requestId)) { + clearInterval(this.connectionKeepAlive.get(requestId)); + this.connectionKeepAlive.delete(requestId); + } + + const mqId = this.#getMessageQueueId(requestId); + if (mqId in this.messageQueues) { + this.messageQueues[mqId] = []; + } + + if (this.activeConnections.has(requestId)) { + this.activeConnections.delete(requestId); + + // Emit an event with all active connection IDs + this.eventCallback('main:ws:connections-changed', { + type: 'removed', + requestId, + activeConnectionIds: this.getActiveConnectionIds() + }); + } + } +} + +export { WsClient }; diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 790454b52..1b0b82fd0 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -383,6 +383,55 @@ const grpcRequestSchema = Yup.object({ .noUnknown(true) .strict(); +const wsRequestSchema = Yup.object({ + url: requestUrlSchema, + headers: Yup.array().of(keyValueSchema).required('headers are required'), + auth: authSchema, + body: Yup.object({ + mode: Yup.string().oneOf(['ws']).required('mode is required'), + ws: Yup.array() + .of( + Yup.object({ + name: Yup.string().nullable(), + type: Yup.string().nullable(), + content: Yup.string().nullable() + }) + ) + .nullable() + }) + .strict() + .required('body is required'), + script: Yup.object({ + req: Yup.string().nullable(), + res: Yup.string().nullable() + }) + .noUnknown(true) + .strict(), + vars: Yup.object({ + req: Yup.array().of(varsSchema).nullable(), + res: Yup.array().of(varsSchema).nullable() + }) + .noUnknown(true) + .strict() + .nullable(), + assertions: Yup.array().of(keyValueSchema).nullable(), + tests: Yup.string().nullable(), + docs: Yup.string().nullable() +}) + .noUnknown(true) + .strict(); + +const wsSettingsSchema = Yup.object({ + settings: Yup.object({ + timeout: Yup.number() + .default(500), + keepAliveInterval: Yup.number() + .default(0) + }).noUnknown(true) + .strict() + .nullable() +}); + const folderRootSchema = Yup.object({ request: Yup.object({ headers: Yup.array().of(keyValueSchema).nullable(), @@ -420,24 +469,32 @@ const folderRootSchema = Yup.object({ const itemSchema = Yup.object({ uid: uidSchema, - type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder', 'js', 'grpc-request']).required('type is required'), + type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder', 'js', 'grpc-request', 'ws-request']).required('type is required'), seq: Yup.number().min(1), name: Yup.string().min(1, 'name must be at least 1 character').required('name is required'), tags: Yup.array().of(Yup.string().matches(/^[\w-]+$/, 'tag must be alphanumeric')), request: Yup.mixed().when('type', { is: (type) => type === 'grpc-request', then: grpcRequestSchema.required('request is required when item-type is grpc-request'), - otherwise: requestSchema.when('type', { - is: (type) => ['http-request', 'graphql-request'].includes(type), - then: (schema) => schema.required('request is required when item-type is request') + otherwise: Yup.mixed().when('type', { + is: (type) => type === 'ws-request', + then: wsRequestSchema.required('request is required when item-type is ws-request'), + otherwise: requestSchema.when('type', { + is: (type) => ['http-request', 'graphql-request'].includes(type), + then: (schema) => schema.required('request is required when item-type is request') + }) }) }), - settings: Yup.object({ - encodeUrl: Yup.boolean().nullable() - }) - .noUnknown(true) + settings: Yup.mixed() + .when('type', { + is: (type) => type === 'ws-request', + then: wsSettingsSchema, + otherwise: Yup.object({ + encodeUrl: Yup.boolean().nullable() + }).noUnknown(true) .strict() - .nullable(), + .nullable() + }), fileContent: Yup.string().when('type', { // If the type is 'js', the fileContent field is expected to be a string. // This can include an empty string, indicating that the JS file may not have any content. diff --git a/packages/bruno-tests/package.json b/packages/bruno-tests/package.json index 75cfde1ae..45b2ac18d 100644 --- a/packages/bruno-tests/package.json +++ b/packages/bruno-tests/package.json @@ -29,6 +29,7 @@ "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "ws": "^8.18.3" } } diff --git a/packages/bruno-tests/src/index.js b/packages/bruno-tests/src/index.js index 2e39f7a9c..569642541 100644 --- a/packages/bruno-tests/src/index.js +++ b/packages/bruno-tests/src/index.js @@ -7,6 +7,7 @@ const echoRouter = require('./echo'); const xmlParser = require('./utils/xmlParser'); const multipartRouter = require('./multipart'); const redirectRouter = require('./redirect'); +const wsRouter = require('./ws'); const app = new express(); const port = process.env.PORT || 8081; @@ -47,6 +48,10 @@ app.get('/redirect-to-ping', function (req, res) { return res.redirect('/ping'); }); -app.listen(port, function () { +const server = require('http').createServer(app); + +server.on('upgrade', wsRouter); + +server.listen(port, function () { console.log(`Testbench started on port: ${port}`); -}); +}); \ No newline at end of file diff --git a/packages/bruno-tests/src/ws/index.js b/packages/bruno-tests/src/ws/index.js new file mode 100644 index 000000000..c86f70354 --- /dev/null +++ b/packages/bruno-tests/src/ws/index.js @@ -0,0 +1,74 @@ +const ws = require('ws'); + +const onSocketError = (err) => { + console.error(err); +}; + +const wss = new ws.Server({ + noServer: true, + handleProtocols: (protocols, request) => { + if (request.url == '/ws/sub-proto') { + if (protocols.has("soap")) { + return 'soap' + } + return false + } + return false + } +}); + +wss.on('connection', function connection(ws, request) { + ws.on('message', function message(data) { + const msg = Buffer.from(data).toString().trim(); + const obj = JSON.parse(msg); + if ('func' in obj && obj.func === 'headers') { + ws.send( + JSON.stringify({ + headers: request.headers + }) + ); + } else { + ws.send( + JSON.stringify({ + data: JSON.parse(Buffer.from(data).toString()) + }) + ); + } + }); +}); + +const wsRouter = (request, socket, head) => { + socket.on('error', onSocketError); + + if (!request.url.startsWith('/ws')) { + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + socket.destroy(); + + socket.removeListener('error', onSocketError); + return; + } + + if (request.url == '/ws/sub-proto') { + const subproto = request.headers["sec-websocket-protocol"] || request.headers["Sec-WebSocket-Protocol"] + if (subproto != "soap") { + const message = "Unsupported WebSocket subprotocol" + socket.write( + 'HTTP/1.1 400 Bad Request\r\n' + + 'Content-Type: text/plain\r\n' + + `Content-Length: ${Buffer.byteLength(message)}\r\n` + + 'Connection: close\r\n' + + '\r\n' + + message + ); + socket.destroy(); + socket.removeListener('error', onSocketError); + return + } + } + + wss.handleUpgrade(request, socket, head, function done(ws) { + wss.emit('connection', ws, request); + }); +}; + +module.exports = wsRouter; diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts new file mode 100644 index 000000000..47d674d4b --- /dev/null +++ b/tests/utils/page/locators.ts @@ -0,0 +1,26 @@ +import { Page } from '../../../playwright'; + +export const buildWebsocketCommonLocators = (page: Page) => ({ + runner: () => page.getByTestId('run-button'), + saveButton: () => page + .locator('.infotip') + .filter({ hasText: /^Save/ }), + connectionControls: { + connect: () => + page + .locator('div.connection-controls') + .locator('.infotip') + .filter({ hasText: /^Connect$/ }), + disconnect: () => + page + .locator('div.connection-controls') + .locator('.infotip') + .filter({ hasText: /^Close Connection$/ }) + }, + messages: () => page.locator('.ws-message'), + toolbar: { + latestFirst: () => page.getByRole('button', { name: 'Latest First' }), + latestLast: () => page.getByRole('button', { name: 'Latest Last' }), + clearResponse: () => page.getByRole('button', { name: 'Clear Response' }) + } +}); diff --git a/tests/utils/wait.ts b/tests/utils/wait.ts new file mode 100644 index 000000000..e05aef18c --- /dev/null +++ b/tests/utils/wait.ts @@ -0,0 +1,13 @@ +import { setTimeout } from 'timers/promises'; + +// TODO: reaper Might not be necessary, figure out a better way later +export const waitForPredicate = async (predicate: () => Promise, { tries = 10, interval = 100 } = {}) => { + let result; + let retries = tries; + do { + result = await predicate(); + retries -= 1; + await setTimeout(interval); + } while (!result && retries > 0); + return result; +}; diff --git a/tests/websockets/connection.spec.ts b/tests/websockets/connection.spec.ts new file mode 100644 index 000000000..88909addd --- /dev/null +++ b/tests/websockets/connection.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from '../../playwright'; +import { buildWebsocketCommonLocators } from '../utils/page/locators'; + +const MAX_CONNECTION_TIME = 3000; +const BRU_REQ_NAME = /^ws-test-request$/; + +test.describe.serial('websockets', () => { + test('websocket requests are visible', async ({ pageWithUserData: page, restartApp }) => { + await page.locator('#sidebar-collection-name').click(); + + expect(page.locator('span.item-name').filter({ hasText: BRU_REQ_NAME })).toBeVisible(); + }); + + test('websocket connects', async ({ pageWithUserData: page, restartApp }) => { + const locators = buildWebsocketCommonLocators(page); + + // Attempt a connection for the specified request + await page.getByTitle(BRU_REQ_NAME).click(); + await locators.connectionControls.connect().click(); + + // See if the socket connected by monitoring the opposite state + await expect(locators.connectionControls.disconnect()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + }); + + test('websocket closes', async ({ pageWithUserData: page, restartApp }) => { + const locators = buildWebsocketCommonLocators(page); + await locators.connectionControls.disconnect().click(); + + // See if the socket disconnected by monitoring the opposite state + await expect(locators.connectionControls.connect()).toBeVisible(); + }); + + test('websocket messages were recorded', async ({ pageWithUserData: page, restartApp }) => { + const locators = buildWebsocketCommonLocators(page); + + // Hard validate the recieved messages to confirm the connection state + await expect(locators.messages().first().getByText('Connected to ws://')).toBeAttached(); + await expect(locators.messages().nth(1).getByText('Closed')).toBeAttached(); + }); + + test('websocket messages sorting can be changed', async ({ pageWithUserData: page, restartApp }) => { + const locators = buildWebsocketCommonLocators(page); + + await locators.toolbar.latestLast().click(); + + await expect(locators.messages().first().getByText('Closed')).toBeAttached(); + await expect(locators.messages().nth(1).getByText('Connected to ws://')).toBeAttached(); + + await locators.toolbar.latestFirst().click(); + + await expect(locators.messages().first().getByText('Connected to ws://')).toBeAttached(); + await expect(locators.messages().nth(1).getByText('Closed')).toBeAttached(); + }); + + test('websocket request can send messages', async ({ pageWithUserData: page, restartApp }) => { + const locators = buildWebsocketCommonLocators(page); + + await locators.toolbar.clearResponse().click(); + await locators.runner().click(); + + // Check if the messages from the request are actually displayed on the messages container + await expect(locators.messages().nth(1).locator('.text-ellipsis')).toHaveText('{ "foo": "bar" }'); + await expect(locators.messages().nth(2).locator('.text-ellipsis')).toHaveText('{ "data": { "foo": "bar" } }'); + }); +}); diff --git a/tests/websockets/fixtures/collection/base.bru b/tests/websockets/fixtures/collection/base.bru new file mode 100644 index 000000000..810e05c64 --- /dev/null +++ b/tests/websockets/fixtures/collection/base.bru @@ -0,0 +1,18 @@ +meta { + name: base + type: ws + seq: 3 +} + +ws { + url: ws://localhost:8082 + body: ws + auth: inherit +} + +body:ws { + name: message 1 + content: ''' + {} + ''' +} diff --git a/tests/websockets/fixtures/collection/bruno.json b/tests/websockets/fixtures/collection/bruno.json new file mode 100644 index 000000000..fa729847c --- /dev/null +++ b/tests/websockets/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "collection", + "type": "collection" +} \ No newline at end of file diff --git a/tests/websockets/fixtures/collection/ws-test-request-with-headers.bru b/tests/websockets/fixtures/collection/ws-test-request-with-headers.bru new file mode 100644 index 000000000..f181d6479 --- /dev/null +++ b/tests/websockets/fixtures/collection/ws-test-request-with-headers.bru @@ -0,0 +1,23 @@ +meta { + name: ws-test-request-with-headers + type: ws + seq: 2 +} + +ws { + url: ws://localhost:8081/ws + auth: inherit +} + +headers { + Authorization: Dummy +} + +body:ws { + name: message 1 + content: ''' + { + "func":"headers" + } + ''' +} diff --git a/tests/websockets/fixtures/collection/ws-test-request-with-subproto.bru b/tests/websockets/fixtures/collection/ws-test-request-with-subproto.bru new file mode 100644 index 000000000..2af244732 --- /dev/null +++ b/tests/websockets/fixtures/collection/ws-test-request-with-subproto.bru @@ -0,0 +1,22 @@ +meta { + name: ws-test-request-with-subproto + type: ws + seq: 3 +} + +ws { + url: ws://localhost:8081/ws/sub-proto + body: ws + auth: inherit +} + +headers { + Sec-WebSocket-Protocol: soap +} + +body:ws { + name: message 1 + content: ''' + {} + ''' +} diff --git a/tests/websockets/fixtures/collection/ws-test-request.bru b/tests/websockets/fixtures/collection/ws-test-request.bru new file mode 100644 index 000000000..50a6481d0 --- /dev/null +++ b/tests/websockets/fixtures/collection/ws-test-request.bru @@ -0,0 +1,19 @@ +meta { + name: ws-test-request + type: ws + seq: 2 +} + +ws { + url: ws://localhost:8081/ws + auth: inherit +} + +body:ws { + name: message 1 + content: ''' + { + "foo":"bar" + } + ''' +} diff --git a/tests/websockets/headers.spec.ts b/tests/websockets/headers.spec.ts new file mode 100644 index 000000000..81f89dcd0 --- /dev/null +++ b/tests/websockets/headers.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '../../playwright'; +import { buildWebsocketCommonLocators } from '../utils/page/locators'; + +const BRU_REQ_NAME = /^ws-test-request-with-headers$/; + +test.describe.serial('headers', () => { + test('headers are returned if passed', async ({ pageWithUserData: page, restartApp }) => { + const locators = buildWebsocketCommonLocators(page); + + // Open the most recent collection + await page.locator('#sidebar-collection-name').click(); + + // Click on the required request + await page.getByTitle(BRU_REQ_NAME).click(); + await locators.runner().click(); + + // Check if the message has the authorisation header + await expect(locators.messages().nth(2).locator('.text-ellipsis')).toHaveText(/\"(authorization)\"\:\s+\"Dummy\"/); + }); +}); diff --git a/tests/websockets/init-user-data/collection-security.json b/tests/websockets/init-user-data/collection-security.json new file mode 100644 index 000000000..f29506cc8 --- /dev/null +++ b/tests/websockets/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/websockets/fixtures/collection", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} \ No newline at end of file diff --git a/tests/websockets/init-user-data/preferences.json b/tests/websockets/init-user-data/preferences.json new file mode 100644 index 000000000..bdba7ec77 --- /dev/null +++ b/tests/websockets/init-user-data/preferences.json @@ -0,0 +1,9 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/websockets/fixtures/collection" + ], + "beta": { + "websocket": true + } +} \ No newline at end of file diff --git a/tests/websockets/persistence.spec.ts b/tests/websockets/persistence.spec.ts new file mode 100644 index 000000000..e32cf9ba8 --- /dev/null +++ b/tests/websockets/persistence.spec.ts @@ -0,0 +1,68 @@ +import { expect, Locator, test } from '../../playwright'; +import { buildWebsocketCommonLocators } from '../utils/page/locators'; +import { readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { waitForPredicate } from '../utils/wait'; + +const BRU_REQ_NAME = /^base$/; + +// TODO: reaper move to someplace common +const isRequestSaved = async (saveButton: Locator) => { + const savedColor = '#9f9f9f'; + return (await saveButton.evaluate((d) => d.querySelector('svg')?.getAttribute('stroke') ?? '#invalid')) === savedColor; +}; + +test.describe.serial('persistence', () => { + let originalUrl = ''; + let originalContext = { + path: join(__dirname, 'fixtures/collection/base.bru'), + data: '' + }; + + test.beforeAll(async () => { + // Store original request data to simplify test consistency + originalContext.data = await readFile(originalContext.path, 'utf8'); + const originalUrlMatch = originalContext.data.match(`(url)\s*\:\s*(.+)`); + if (!originalUrlMatch) { + throw new Error('url not found in bru file for websocket'); + } + originalUrl = originalUrlMatch[0].replace(/url\:/, ''); + }); + + test.afterAll(async () => { + // Write back the original request information + await writeFile(originalContext.path, originalContext.data, 'utf8'); + }); + + test('save new websocket url', async ({ pageWithUserData: page }) => { + const replacementUrl = 'ws://localhost:8082'; + const locators = buildWebsocketCommonLocators(page); + + const clearText = async (text: string) => { + for (let i = text.length; i > 0; i--) { + await page.keyboard.press('Backspace'); + } + }; + + await page.locator('#sidebar-collection-name').click(); + await page.getByTitle(BRU_REQ_NAME).click(); + + // remove the original url from the request + await page.locator('.input-container').filter({ hasText: originalUrl }).first().click(); + await clearText(originalUrl); + + // replace it with an arbritrary url + await page.keyboard.insertText(replacementUrl); + + // check if the request is now unsaved + expect(await isRequestSaved(locators.saveButton())).toBe(false); + + await locators.saveButton().click(); + + const result = await waitForPredicate(() => isRequestSaved(locators.saveButton())); + expect(result).toBe(true); + + // check if the replacementUrl is now visually available + expect(page.locator('.input-container').filter({ hasText: replacementUrl }).first()).toBeAttached(); + }); +}); diff --git a/tests/websockets/subproto.spec.ts b/tests/websockets/subproto.spec.ts new file mode 100644 index 000000000..287dfbaf8 --- /dev/null +++ b/tests/websockets/subproto.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '../../playwright'; +import { buildWebsocketCommonLocators } from '../utils/page/locators'; + +const BRU_REQ_NAME = /^ws-test-request-with-subproto$/; + +test.describe.serial('subprotocol tests', () => { + test('Only connect if a valid subprotocol is sent with the request', async ({ pageWithUserData: page, restartApp }) => { + const locators = buildWebsocketCommonLocators(page); + const clearText = async (text: string) => { + for (let i = text.length; i > 0; i--) { + await page.keyboard.press('Backspace'); + } + }; + + const originalProtocol = 'soap'; + const wrongProtocol = 'wap'; + + // Open the needed request and keep the headers tab in focus for modifications + await page.locator('#sidebar-collection-name').click(); + await page.getByTitle(BRU_REQ_NAME).click(); + await page.getByRole('tab', { name: 'Headers1' }).click(); + + // Check if the original / correct protocol is in place and then send a request + await expect(page.locator('pre').filter({ hasText: originalProtocol })).toBeAttached(); + await locators.runner().click(); + + // Check the messages to confirm we ended up connecting + await expect(locators.messages().first().locator('.text-ellipsis')).toHaveText(/^(Connected to)/); + + // Disconnect the request + await locators.connectionControls.disconnect().click(); + + // Make changes to the header and add in an invalid sub protocol + await page.locator('pre').filter({ hasText: originalProtocol }).click(); + await clearText(originalProtocol); + await page.keyboard.insertText(wrongProtocol); + + // clear before making another request + await locators.toolbar.clearResponse().click(); + + // Make another request and check the new set of messages to confirm that we did + // get an error on connection + await locators.runner().click(); + + await expect(locators.messages().nth(0).locator('.text-ellipsis')).toHaveText(/^(Unexpected server response)/); + + // Reset state back to the original + await page.locator('pre').filter({ hasText: wrongProtocol }).click(); + await clearText(wrongProtocol); + await page.keyboard.insertText(originalProtocol); + await locators.saveButton().click(); + }); +});