From 4077ce8eb2a6ab15fceb0a28a19e2b21be90ef35 Mon Sep 17 00:00:00 2001 From: "Siddharth Gelera (reaper)" Date: Wed, 27 Aug 2025 03:08:34 +0530 Subject: [PATCH] init --- package-lock.json | 151 ++++++- .../src/components/Icons/Grpc/index.js | 92 ++-- .../src/components/Preferences/Beta/index.js | 5 + .../WSRequestPane/StyledWrapper.js | 34 ++ .../WSRequestPane/WSAuth/StyledWrapper.js | 9 + .../WSRequestPane/WSAuth/WSAuthMode/index.js | 85 ++++ .../RequestPane/WSRequestPane/WSAuth/index.js | 125 ++++++ .../RequestPane/WSRequestPane/index.js | 121 ++++++ .../RequestPane/WsBody/StyledWrapper.js | 59 +++ .../components/RequestPane/WsBody/index.js | 345 +++++++++++++++ .../RequestPane/WsQueryUrl/StyledWrapper.js | 98 +++++ .../RequestPane/WsQueryUrl/index.js | 155 +++++++ .../src/components/RequestTabPanel/index.js | 65 +-- .../components/ResponsePane/Timeline/index.js | 50 ++- .../WsResponsePane/StyledWrapper.js | 58 +++ .../WSMessagesList/StyledWrapper.js | 17 + .../WsResponsePane/WSMessagesList/index.js | 52 +++ .../WSQueryResult/StyledWrapper.js | 96 +++++ .../WsResponsePane/WSQueryResult/index.js | 126 ++++++ .../WSResponseHeaders/StyledWrapper.js | 31 ++ .../WsResponsePane/WSResponseHeaders/index.js | 38 ++ .../WSStatusCode/StyledWrapper.js | 22 + .../WSStatusCode/get-ws-status-code-phrase.js | 20 + .../WsResponsePane/WSStatusCode/index.js | 27 ++ .../WsResponsePane/WsError/StyledWrapper.js | 44 ++ .../WsResponsePane/WsError/index.js | 23 + .../ResponsePane/WsResponsePane/index.js | 150 +++++++ .../RequestMethod/StyledWrapper.js | 3 + .../CollectionItem/RequestMethod/index.js | 57 ++- .../components/Sidebar/NewRequest/index.js | 140 ++++--- packages/bruno-app/src/pages/Bruno/index.js | 56 +-- .../src/providers/ReduxStore/slices/app.js | 3 +- .../ReduxStore/slices/collections/actions.js | 69 ++- .../ReduxStore/slices/collections/index.js | 346 +++++++++++----- .../src/providers/ReduxStore/slices/tabs.js | 29 +- packages/bruno-app/src/themes/dark.js | 57 ++- packages/bruno-app/src/themes/light.js | 54 ++- packages/bruno-app/src/utils/beta-features.js | 3 +- .../bruno-app/src/utils/collections/export.js | 6 +- .../bruno-app/src/utils/collections/index.js | 106 +++-- packages/bruno-app/src/utils/network/index.js | 184 ++++++-- .../src/utils/network/ws-event-listeners.js | 107 +++++ packages/bruno-app/src/utils/tabs/index.js | 7 +- .../src/runner/run-single-request.js | 223 +++++----- packages/bruno-cli/src/utils/bru.js | 39 +- packages/bruno-electron/src/ipc/collection.js | 2 +- .../bruno-electron/src/ipc/network/index.js | 392 +++++++++++------- .../src/ipc/network/ws-event-handlers.js | 378 +++++++++++++++++ .../bruno-filestore/src/formats/bru/index.ts | 45 +- packages/bruno-lang/v1/src/index.js | 2 + packages/bruno-lang/v2/src/bruToJson.js | 95 +++-- packages/bruno-lang/v2/src/jsonToBru.js | 202 ++++++--- packages/bruno-requests/package.json | 3 +- packages/bruno-requests/rollup.config.js | 9 +- packages/bruno-requests/src/index.ts | 5 +- packages/bruno-requests/src/network/index.ts | 3 +- .../bruno-requests/src/network/ws-request.ts | 65 +++ packages/bruno-requests/src/ws/ws-client.js | 320 ++++++++++++++ .../bruno-schema/src/collections/index.js | 113 +++-- 59 files changed, 4426 insertions(+), 795 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/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/WSQueryResult/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSQueryResult/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/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/WsError/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WsError/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.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-requests/src/network/ws-request.ts create mode 100644 packages/bruno-requests/src/ws/ws-client.js diff --git a/package-lock.json b/package-lock.json index de4683783..58dfa6086 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3518,6 +3518,7 @@ "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^6.12.0", @@ -3535,6 +3536,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -3551,6 +3553,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, "node_modules/@discoveryjs/json-ext": { @@ -3567,6 +3570,7 @@ "version": "3.2.17", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.17.tgz", "integrity": "sha512-OcWImUI686w8LkghQj9R2ynZ2ME693Ek6L1SiaAgqGKzBaTIZw3fHDqN82Rcl+EU1Gm9EgkJ5KLIY/q5DCRbbA==", + "dev": true, "license": "MIT", "dependencies": { "commander": "^5.0.0", @@ -3584,6 +3588,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -3595,6 +3600,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -3615,6 +3621,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -4312,6 +4319,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -4329,6 +4337,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4341,6 +4350,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4353,12 +4363,14 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -4376,6 +4388,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -4391,6 +4404,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -5034,6 +5048,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", @@ -5049,6 +5064,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==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5066,6 +5082,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", @@ -5081,6 +5098,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/@mapbox/node-pre-gyp": { @@ -5845,6 +5863,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -7926,6 +7945,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 10" @@ -8019,6 +8039,7 @@ "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/ms": "*" @@ -8056,6 +8077,7 @@ "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -8248,6 +8270,7 @@ "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -8980,6 +9003,7 @@ "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -9003,6 +9027,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, "license": "MIT" }, "node_modules/abab": { @@ -9157,6 +9182,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, "license": "MIT", "peerDependencies": { "ajv": "^6.9.1" @@ -9412,12 +9438,14 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, "license": "MIT" }, "node_modules/async-exit-hook": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -9867,12 +9895,14 @@ "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, "license": "MIT" }, "node_modules/bluebird-lst": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, "license": "MIT", "dependencies": { "bluebird": "^3.5.5" @@ -9961,6 +9991,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -10238,6 +10269,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -10808,6 +10840,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "devOptional": true, "license": "ISC", "engines": { "node": ">=10" @@ -10827,12 +10860,14 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, "license": "MIT" }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, "funding": [ { "type": "github", @@ -11142,6 +11177,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -11157,6 +11193,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11627,6 +11664,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -12708,6 +12746,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, "license": "MIT" }, "node_modules/ecc-jsbn": { @@ -12745,6 +12784,7 @@ "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "jake": "^10.8.5" @@ -13014,6 +13054,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, "license": "MIT" }, "node_modules/error-ex": { @@ -14269,6 +14310,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, "license": "Apache-2.0", "dependencies": { "minimatch": "^5.0.1" @@ -14278,6 +14320,7 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -14544,6 +14587,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", @@ -14560,6 +14604,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -14714,6 +14759,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "devOptional": true, "license": "ISC", "dependencies": { "minipass": "^3.0.0" @@ -15391,6 +15437,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -15403,6 +15450,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -15415,6 +15463,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC" }, "node_modules/html-encoding-sniffer": { @@ -16212,6 +16261,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, "license": "MIT", "dependencies": { "ci-info": "^3.2.0" @@ -16653,6 +16703,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.4.tgz", "integrity": "sha512-YKBKVkKhty7s8rxddb40oOkuP0NbaeXrQvLin6QMHL7Ypiy2RW9LwOVrVgZRyOrhQlayMd9t+D8yDy8MKFTSDQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 18.0.0" @@ -16665,6 +16716,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -16795,6 +16847,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -16810,6 +16863,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "async": "^3.2.3", @@ -16828,6 +16882,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -16838,6 +16893,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -16854,6 +16910,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -18494,6 +18551,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, "license": "MIT" }, "node_modules/leven": { @@ -19157,6 +19215,7 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -19311,6 +19370,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "devOptional": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -19323,12 +19383,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "devOptional": true, "license": "ISC" }, "node_modules/minizlib": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "devOptional": true, "license": "MIT", "dependencies": { "minipass": "^3.0.0", @@ -19342,6 +19404,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "devOptional": true, "license": "ISC" }, "node_modules/mkdirp": { @@ -19943,6 +20006,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/pako": { @@ -20178,6 +20242,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -20194,6 +20259,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -20210,12 +20276,14 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, "node_modules/path-scurry/node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -20513,6 +20581,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "devOptional": true, "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.8.8", @@ -21537,6 +21606,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, "license": "MIT", "dependencies": { "err-code": "^2.0.2", @@ -22293,6 +22363,7 @@ "version": "6.3.2", "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", "integrity": "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==", + "dev": true, "license": "MIT", "dependencies": { "config-file-ts": "^0.2.4", @@ -22310,6 +22381,7 @@ "version": "0.2.6", "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", "integrity": "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==", + "dev": true, "license": "MIT", "dependencies": { "glob": "^10.3.10", @@ -22320,6 +22392,7 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=10" @@ -22329,12 +22402,14 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/read-config-file/node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -22355,6 +22430,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -22370,6 +22446,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -22379,6 +22456,7 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -22848,6 +22926,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -23280,6 +23359,7 @@ "version": "1.6.3", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dev": true, "license": "WTFPL OR ISC", "dependencies": { "truncate-utf8-bytes": "^1.0.0" @@ -24033,6 +24113,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -24045,6 +24126,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -24205,6 +24287,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -24217,6 +24300,7 @@ "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -24452,6 +24536,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -24626,6 +24711,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -24676,6 +24762,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -25237,6 +25324,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "devOptional": true, "license": "ISC", "dependencies": { "chownr": "^2.0.0", @@ -25254,6 +25342,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "devOptional": true, "license": "ISC", "engines": { "node": ">=8" @@ -25263,6 +25352,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "devOptional": true, "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" @@ -25275,12 +25365,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "devOptional": true, "license": "ISC" }, "node_modules/temp-file": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, "license": "MIT", "dependencies": { "async-exit-hook": "^2.0.1", @@ -25291,6 +25383,7 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -25603,6 +25696,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, "license": "MIT", "dependencies": { "tmp": "^0.2.0" @@ -25694,6 +25788,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, "license": "WTFPL", "dependencies": { "utf8-byte-length": "^1.0.1" @@ -26177,6 +26272,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, "license": "(WTFPL OR MIT)" }, "node_modules/util": { @@ -26513,6 +26609,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -26633,6 +26730,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -30411,7 +30509,6 @@ "content-disposition": "^0.5.4", "decomment": "^0.9.5", "dotenv": "^16.0.3", - "electron-builder": "^24.13.3", "electron-is-dev": "^2.0.0", "electron-notarize": "^1.2.2", "electron-store": "^8.1.0", @@ -30435,6 +30532,7 @@ }, "devDependencies": { "electron": "~37.2.6", + "electron-builder": "^24.13.3", "electron-devtools-installer": "^4.0.0" }, "optionalDependencies": { @@ -30907,6 +31005,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", @@ -30921,6 +31020,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", @@ -30936,6 +31036,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "compare-version": "^0.1.2", @@ -30957,6 +31058,7 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8.0.0" @@ -30969,6 +31071,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz", "integrity": "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==", + "dev": true, "license": "MIT", "dependencies": { "@electron/asar": "^3.2.1", @@ -30987,6 +31090,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -30997,6 +31101,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", @@ -31012,6 +31117,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -31024,6 +31130,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, "funding": [ { "type": "individual", @@ -31611,6 +31718,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "4" @@ -31623,12 +31731,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", + "dev": true, "license": "MIT" }, "packages/bruno-electron/node_modules/app-builder-lib": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz", "integrity": "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==", + "dev": true, "license": "MIT", "dependencies": { "@develar/schema-utils": "~2.6.5", @@ -31682,6 +31792,7 @@ "version": "24.13.1", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", "integrity": "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==", + "dev": true, "license": "MIT", "dependencies": { "@types/debug": "^4.1.6", @@ -31706,6 +31817,7 @@ "version": "9.2.4", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz", "integrity": "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -31719,6 +31831,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, "license": "MIT", "dependencies": { "@tootallnate/once": "2", @@ -31733,6 +31846,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "6", @@ -31746,6 +31860,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -31762,6 +31877,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -31779,6 +31895,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", "integrity": "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==", + "dev": true, "license": "MIT", "dependencies": { "buffer-equal": "^1.0.0", @@ -31789,6 +31906,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -31799,6 +31917,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -31811,6 +31930,7 @@ "version": "24.13.3", "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", + "dev": true, "license": "MIT", "dependencies": { "app-builder-lib": "24.13.3", @@ -31828,6 +31948,7 @@ "version": "24.13.3", "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz", "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", + "dev": true, "license": "MIT", "dependencies": { "app-builder-lib": "24.13.3", @@ -31854,6 +31975,7 @@ "version": "24.13.1", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", "integrity": "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==", + "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", @@ -31883,6 +32005,7 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -31895,6 +32018,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "packages/bruno-electron/node_modules/nanoid": { @@ -31919,6 +32043,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -32202,7 +32327,8 @@ "axios": "^1.9.0", "grpc-reflection-js": "^0.3.0", "is-ip": "^5.0.1", - "tough-cookie": "^6.0.0" + "tough-cookie": "^6.0.0", + "ws": "^8.18.3" }, "devDependencies": { "@babel/preset-env": "^7.22.0", @@ -32261,6 +32387,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", diff --git a/packages/bruno-app/src/components/Icons/Grpc/index.js b/packages/bruno-app/src/components/Icons/Grpc/index.js index 1424ce288..78f0f084d 100644 --- a/packages/bruno-app/src/components/Icons/Grpc/index.js +++ b/packages/bruno-app/src/components/Icons/Grpc/index.js @@ -2,17 +2,17 @@ import React from 'react'; // UNARY - Single request, single response (Blue) export const IconGrpcUnary = ({ size = 18, strokeWidth = 1.5, className = '' }) => ( - - + {/* Request arrow (top) - right */} @@ -24,17 +24,17 @@ export const IconGrpcUnary = ({ size = 18, strokeWidth = 1.5, className = '' }) // CLIENT_STREAMING - Streaming request, single response (Purple) export const IconGrpcClientStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => ( - - + {/* Request arrow (top) - right with double heads */} @@ -47,17 +47,17 @@ export const IconGrpcClientStreaming = ({ size = 18, strokeWidth = 1.5, classNam // SERVER_STREAMING - Single request, streaming response (Green) export const IconGrpcServerStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => ( - - + {/* Request arrow (top) - right */} @@ -70,17 +70,17 @@ export const IconGrpcServerStreaming = ({ size = 18, strokeWidth = 1.5, classNam // BIDI_STREAMING - Streaming request, streaming response (Orange) export const IconGrpcBidiStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => ( - - + {/* Request arrow (top) - right with double heads */} @@ -90,4 +90,30 @@ export const IconGrpcBidiStreaming = ({ size = 18, strokeWidth = 1.5, className -); \ No newline at end of file +); + +// WEBSOCKET - Bidirectional communication (Amber/Orange) +// TODO: reaper move to it's own folder +export const IconWebSocket = ({ size = 18, strokeWidth = 1.5, className = '' }) => ( + + + {/* Bidirectional arrows representing WebSocket communication */} + + + + + + + {/* Connection indicator dots */} + + +); diff --git a/packages/bruno-app/src/components/Preferences/Beta/index.js b/packages/bruno-app/src/components/Preferences/Beta/index.js index dd31cbbe0..ec0dd5c3a 100644 --- a/packages/bruno-app/src/components/Preferences/Beta/index.js +++ b/packages/bruno-app/src/components/Preferences/Beta/index.js @@ -14,6 +14,11 @@ const BETA_FEATURES = [ id: 'grpc', label: 'gRPC Support', description: 'Enable gRPC request support for making gRPC calls to services' + }, + { + id: 'websocket', + label: 'Web Socket Support', + description: 'Enable Web Socket request support for making realtime calls to services' } ]; 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..8ac6d05c8 --- /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 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 authModes = [ + { + 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 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"> + {authModes.map((authMode) => ( +
onClickHandler(authMode.mode)} + > + {authMode.name} +
+ ))} +
+
+
+ ); +}; + +export default WSAuthMode; \ No newline at end of file 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..36182ea8c --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js @@ -0,0 +1,125 @@ +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 OAuth2 from '../../Auth/OAuth2/index'; +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'; + +// List of auth modes supported by gRPC +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 ; + } + 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 gRPC. Using no auth instead.
+
+ + ); + } + } + default: { + return null; + } + } + }; + + return ( + +
+ +
+ {getAuthView()} +
+ ); +}; + +export default WSAuth; \ No newline at end of file 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..b7351d1ff --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js @@ -0,0 +1,121 @@ +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'; + +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
TBD
; + } + 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')}> + Metadata + {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/WsBody/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WsBody/StyledWrapper.js new file mode 100644 index 000000000..eb36c213a --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WsBody/StyledWrapper.js @@ -0,0 +1,59 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + flex: 1; + /* height: 100%; */ + 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); + } + } + } + + #grpc-messages-container { + /* height: 100%; */ + position: relative; + } + + .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; \ No newline at end of file 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..215d68686 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WsBody/index.js @@ -0,0 +1,345 @@ +import { get } from 'lodash'; +import { updateRequestBody } from 'providers/ReduxStore/slices/collections'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { useTheme } from 'providers/Theme'; +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { generateGrpcSampleMessage, sendWsRequest } from 'utils/network/index'; +import { IconChevronDown, IconChevronUp, IconPlus, IconRefresh, IconSend, IconTrash, IconWand } from '@tabler/icons'; +import CodeEditor from 'components/CodeEditor/index'; +import ToolHint from 'components/ToolHint/index'; +import { applyEdits, format } from 'jsonc-parser'; +import toast from 'react-hot-toast'; +import { toastError } from 'utils/common/error'; +import StyledWrapper from './StyledWrapper'; + +const SingleWSMessage = ({ + message, + item, + collection, + index, + methodType, + isCollapsed, + onToggleCollapse, + handleRun, + canClientSendMultipleMessages +}) => { + const dispatch = useDispatch(); + const { displayedTheme, theme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); + const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); + const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid)); + + const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming'; + + const { name, content } = message; + + const onEdit = (value) => { + const currentMessages = [...(body.ws || [])]; + + currentMessages[index] = { + name: name ? name : `message ${index + 1}`, + content: value + }; + + dispatch( + updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }; + + const onSend = async () => { + try { + await sendWsRequest(item, collection.uid, content); + } catch (error) { + console.error('Error sending message:', error); + } + }; + const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); + + const onRegenerateMessage = async () => { + try { + const methodPath = item.draft?.request?.method || item.request?.method; + + if (!methodPath) { + toastError(new Error('Method path not found in request')); + return; + } + + // Find the method metadata from the appropriate cache + let methodMetadata = null; + + const result = await generateGrpcSampleMessage(methodPath, content, { + arraySize: 2, + methodMetadata // Pass the method metadata to the function + }); + + if (result.success) { + const currentMessages = [...(body.ws || [])]; + + currentMessages[index] = { + name: name ? name : `message ${index + 1}`, + content: result.message + }; + + dispatch( + updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + + toast.success('Sample message generated successfully!'); + } else { + toastError(new Error(result.error || 'Failed to generate sample message')); + } + } catch (error) { + console.error('Error generating sample message:', error); + toastError(error); + } + }; + + const onDeleteMessage = () => { + const currentMessages = [...(body.ws || [])]; + + currentMessages.splice(index, 1); + + dispatch( + updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }; + + const onPrettify = () => { + try { + const edits = format(content, undefined, { tabSize: 2, insertSpaces: true }); + const prettyBodyJson = applyEdits(content, edits); + + const currentMessages = [...(body.ws || [])]; + 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.')); + } + }; + + const getContainerHeight = + canClientSendMultipleMessages && body.ws.length > 1 ? `${isCollapsed ? '' : 'h-80'}` : 'h-full'; + + return ( +
+
+
+ {isCollapsed ? ( + + ) : ( + + )} + {`Message ${canClientStream ? index + 1 : ''}`} +
+
e.stopPropagation()}> + + + + + + + + + {canClientStream && ( + + + + )} + + {index > 0 && ( + + + + )} +
+
+ + {!isCollapsed && ( +
+ +
+ )} +
+ ); +}; + +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..5a3b8b843 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/StyledWrapper.js @@ -0,0 +1,98 @@ +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; + } + } + } + + .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..ea0ba061f --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js @@ -0,0 +1,155 @@ +import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons'; +import { IconWebSocket } from 'components/Icons/Grpc'; +import SingleLineEditor from 'components/SingleLineEditor/index'; +import { requestUrlChanged } from 'providers/ReduxStore/slices/collections'; +import { 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, connectWS, isWsConnectionActive } from 'utils/network/index'; +import StyledWrapper from './StyledWrapper'; + +const WsQueryUrl = ({ item, collection, handleRun }) => { + const dispatch = useDispatch(); + const { theme, displayedTheme } = useTheme(); + const [isConnectionActive, setIsConnectionActive] = useState(false); + // TODO: repear, better state for connecting + const [isConnecting, setIsConnecting] = useState(false); + const url = getPropertyFromDraftOrRequest(item, 'request.url'); + const headers = getPropertyFromDraftOrRequest(item, 'request.headers') || []; + const saveShortcut = isMacOS() ? '⌘S' : 'Ctrl+S'; + + // Check connection status + useEffect(() => { + const checkConnectionStatus = async () => { + try { + const result = await isWsConnectionActive(item.uid); + setIsConnectionActive(Boolean(result.isActive)); + } catch (error) { + setIsConnectionActive(false); + } + }; + + checkConnectionStatus(); + const interval = setInterval(checkConnectionStatus, 2000); + return () => clearInterval(interval); + }, [item.uid]); + + const onUrlChange = (value) => { + 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); + }) + .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) => { + connectWS(item, collection); + }; + + const onSave = (finalValue) => { + dispatch(saveRequest(item.uid, collection.uid)); + }; + + return ( + +
+
+ + onSave(finalValue)} + onChange={onUrlChange} + placeholder="ws://localhost:8080 or wss://example.com" + className="w-full" + theme={displayedTheme} + /> +
+
{ + e.stopPropagation(); + if (!item.draft) return; + onSave(); + }} + > + + + Save ({saveShortcut}) + +
+ + {isConnectionActive && ( +
+
+ + Close Connection +
+
+ )} + + {!isConnectionActive && ( +
+
+ + Close Connection +
+
+ )} + +
+ +
+
+
+
+ + {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..1521e364d 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,26 +258,40 @@ const RequestTabPanel = () => { ); }; + // TODO: reaper, improve selection of panes return ( - +
{isGrpcRequest ? ( + ) : isWsRequest ? ( + ) : ( )}
-
+
{item.type === 'graphql-request' ? ( { /> ) : null} - {item.type === 'http-request' ? ( - - ) : null} + {item.type === 'http-request' ? : null} {isGrpcRequest ? ( + ) : isWsRequest ? ( + ) : null}
@@ -295,18 +319,11 @@ const RequestTabPanel = () => {
{item.type === 'grpc-request' ? ( - + + ) : item.type === 'ws-request' ? ( + ) : ( - + )}
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js index 79bf5725b..2a2583cc6 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js @@ -45,42 +45,40 @@ 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'; - + // TODO: reaper, might want to split it out and replicate the same timeline item as grpc + const isGrpcRequest = item.type === 'grpc-request' || item.type === 'ws-request'; + // Filter timeline entries based on new rules - const combinedTimeline = ([...(collection?.timeline || [])]).filter(obj => { - // Always show entries for this item - if (obj.itemUid === item.uid) return true; + const combinedTimeline = [...(collection?.timeline || [])] + .filter((obj) => { + // Always show entries for this item + if (obj.itemUid === item.uid) return true; - // For OAuth2 entries, also show if auth is inherited - if (obj.type === 'oauth2' && authSource) { - if (authSource.type === 'folder' && obj.folderUid === authSource.uid) return true; - if (authSource.type === 'collection' && !obj.folderUid) return true; - } + // For OAuth2 entries, also show if auth is inherited + if (obj.type === 'oauth2' && authSource) { + if (authSource.type === 'folder' && obj.folderUid === authSource.uid) return true; + if (authSource.type === 'collection' && !obj.folderUid) return true; + } - return false; - }).sort((a, b) => b.timestamp - a.timestamp) + return false; + }) + .sort((a, b) => b.timestamp - a.timestamp); return ( - + {/* Timeline container with scrollbar */} -
+
{combinedTimeline.map((event, index) => { // Handle regular requests if (event.type === 'request') { - const { data, timestamp, eventType } = event; - const { request, response, eventData = {}, timestamp: eventTimestamp = timestamp } = data; - + const { request, response, eventData = {}, timestamp: eventTimestamp = timestamp } = data; + if (isGrpcRequest) { return (
{
); } - + // Regular HTTP request return (
@@ -119,7 +117,7 @@ const Timeline = ({ collection, item }) => {
{debugInfo && debugInfo.length > 0 ? ( debugInfo.map((data, idx) => ( -
+
{
); } - + return null; })}
@@ -145,4 +143,4 @@ const Timeline = ({ collection, item }) => { ); }; -export default Timeline; \ No newline at end of file +export default Timeline; 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..18e4caf24 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js @@ -0,0 +1,17 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + overflow-y: auto; + + .ws-incoming { + background: ${(props) => props.theme.table.striped}; + border-color: ${(props) => props.theme.table.border}; + } + + .ws-outgoing { + background: ${(props) => props.theme.bg}; + border-color: ${(props) => props.theme.table.border}; + } +`; + +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..2268ea3b2 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js @@ -0,0 +1,52 @@ +import React from 'react'; +import classnames from 'classnames'; +import StyledWrapper from './StyledWrapper'; +import { IconArrowDown, IconArrowUp } from '@tabler/icons'; + +// Example message structure: { direction: 'incoming' | 'outgoing', timestamp, data } +const WSMessagesList = ({ messages = [] }) => { + if (!messages.length) { + return
No messages yet.
; + } + + return ( + + {messages.map((msg, idx) => { + const isIncoming = msg.direction === 'incoming'; + let content; + try { + content = typeof msg.message === 'string' ? msg.message : JSON.stringify(msg.message, null, 2); + } catch { + content = String(msg.message); + } + return ( +
+
+ + {isIncoming ? : } + {isIncoming ? 'RCVD' : 'SENT'} + + {msg.timestamp && ( + {new Date(msg.timestamp).toLocaleTimeString()} + )} +
+
{content}
+
+ ); + })} +
+ ); +}; + +export default WSMessagesList; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSQueryResult/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSQueryResult/StyledWrapper.js new file mode 100644 index 000000000..81b4c33b1 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSQueryResult/StyledWrapper.js @@ -0,0 +1,96 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + height: 100%; + overflow: hidden; + background: ${(props) => props.theme.bg}; + border-radius: 4px; + + .CodeMirror { + height: 100%; + font-family: ${(props) => (props.font === 'default' ? 'monospace' : props.font)}; + font-size: ${(props) => (props.fontSize ? props.fontSize : '13px')}; + } + + .accordion-header { + background-color: ${(props) => props.theme.requestTabPanel.card.bg}; + + &:hover { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + } + + &.open { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + } + } + + .error-header { + background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(185, 28, 28, 0.1)' : '#fee2e2')}; + } + + .error-text { + color: ${(props) => props.theme.colors.text.danger}; + } + + 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; + } + + .response-list { + max-height: 500px; + overflow-y: auto; + } + + .response-message { + margin-bottom: 8px; + padding: 8px; + border-radius: 4px; + background-color: var(--color-panel-background); + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSQueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSQueryResult/index.js new file mode 100644 index 000000000..cd5887f83 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSQueryResult/index.js @@ -0,0 +1,126 @@ +import React, { useState, useEffect } from 'react'; +import Accordion from 'components/Accordion'; +import CodeEditor from 'components/CodeEditor'; +import { get } from 'lodash'; +import { useSelector } from 'react-redux'; +import { useTheme } from 'providers/Theme/index'; +import StyledWrapper from './StyledWrapper'; +import { formatISO9075 } from 'date-fns'; +import WSError from '../WSError'; + +const WSQueryResult = ({ item, collection }) => { + const { displayedTheme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); + const [showErrorMessage, setShowErrorMessage] = useState(true); + + const response = item.response || {}; + const responsesList = response?.responses || []; + // Reverse the responses list to show the most recent at the top + const reversedResponsesList = [...responsesList].reverse(); + const hasError = response.isError; + const hasResponses = responsesList.length > 0; + const errorMessage = response.error; + + // Reset error visibility when a new response is received + useEffect(() => { + if (hasError) { + setShowErrorMessage(true); + } + }, [response, hasError]); + + // Format a timestamp to a human-readable format + const formatTimestamp = (timestamp) => { + if (!timestamp) return 'Unknown time'; + + try { + const date = new Date(timestamp); + return formatISO9075(date); + } catch (e) { + return 'Invalid time'; + } + }; + + // Format JSON for display + const formatJSON = (data) => { + try { + if (typeof data === 'string') { + return JSON.stringify(JSON.parse(data), null, 2); + } + return JSON.stringify(data, null, 2); + } catch (e) { + return typeof data === 'string' ? data : JSON.stringify(data); + } + }; + + if (!hasResponses && !hasError) { + return ( + +
No messages received
+
+ ); + } + + return ( + + {hasError && showErrorMessage && setShowErrorMessage(false)} />} + {hasResponses && ( +
+ {responsesList.length === 1 ? ( + // Single message - render directly without accordion +
+ +
+ ) : ( + // Multiple messages - use accordion + + {reversedResponsesList.map((response, index) => { + // Calculate the original response number (for display purposes) + const originalIndex = responsesList.length - index - 1; + + return ( + + +
+
+ Response {originalIndex + 1} {index === 0 ? '(Latest)' : ''} +
+
+
+ +
+ +
+
+
+ ); + })} +
+ )} +
+ )} + {hasError && !hasResponses && !showErrorMessage && ( +
+ No messages received. A server error occurred but has been dismissed. +
+ )} +
+ ); +}; + +export default WSQueryResult; 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..0ef075410 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseHeaders/index.js @@ -0,0 +1,38 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; + +const WSResponseHeaders = ({ metadata }) => { + // Ensure headers is an array + const metadataArray = Array.isArray(metadata) ? metadata : []; + + return ( + + + + + + + + + + {metadataArray && metadataArray.length ? ( + metadataArray.map((metadata, index) => ( + + + + + )) + ) : ( + + + + )} + +
NameValue
{metadata.name}{metadata.value}
+ No metadata received +
+
+ ); +}; + +export default WSResponseHeaders; 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..bed367559 --- /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; \ No newline at end of file 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..b68d671c2 --- /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; \ No newline at end of file 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..caa5404a7 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import classnames from 'classnames'; +import wsStatusCodePhraseMap from './get-ws-status-code-phrase'; +import StyledWrapper from './StyledWrapper'; + +const WSStatusCode = ({ status, text }) => { + // gRPC status codes: 0 is success, anything else is an error + const getTabClassname = (status) => { + const isPending = text === 'PENDING' || text === 'STREAMING'; + return classnames('ml-2', { + 'text-ok': parseInt(status) === 0, + 'text-pending': isPending, + 'text-error': parseInt(status) > 0 && !isPending + }); + }; + + const statusText = text || wsStatusCodePhraseMap[status] + + return ( + + {Number.isInteger(status) ?
{status}
: null} + {statusText &&
{statusText}
} +
+ ); +}; + +export default WSStatusCode; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WsError/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WsError/StyledWrapper.js new file mode 100644 index 000000000..f302b86dd --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WsError/StyledWrapper.js @@ -0,0 +1,44 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + border-left: 4px solid ${(props) => props.theme.colors.text.danger}; + border-top: 1px solid transparent; + border-right: 1px solid transparent; + border-bottom: 1px solid transparent; + border-radius: 0.375rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + max-height: 200px; + min-height: 70px; + overflow-y: auto; + background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.5)' : 'rgba(250, 250, 250, 0.9)')}; + + .close-button { + opacity: 0.7; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } + + svg { + color: ${(props) => props.theme.text}; + } + } + + .error-title { + font-weight: 600; + margin-bottom: 0.375rem; + color: ${(props) => props.theme.colors.text.danger}; + } + + .error-message { + font-family: monospace; + font-size: 0.6875rem; + line-height: 1.25rem; + white-space: pre-wrap; + word-break: break-all; + color: ${(props) => props.theme.text}; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WsError/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WsError/index.js new file mode 100644 index 000000000..1095794b8 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WsError/index.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { IconX } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; + +const WSError = ({ error, onClose }) => { + if (!error) return null; + + return ( + +
+
+
WebSocket Server Error
+
{typeof error === 'string' ? error : JSON.stringify(error, null, 2)}
+
+
+ +
+
+
+ ); +}; + +export default WSError; 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..c9b0c1201 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js @@ -0,0 +1,150 @@ +import React, { useState, useEffect } from 'react'; +import find from 'lodash/find'; +import classnames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; +import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; +import Overlay from '../Overlay'; +import Placeholder from '../Placeholder'; +import WSResponseHeaders from './WSResponseHeaders'; +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'; + +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: 'Metadata', + count: Array.isArray(response.metadata) ? response.metadata.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/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..33549a14c 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,52 @@ -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 }) => + isGrpc + ? 'grpc' + : isWS + ? 'ws' + : 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 8a668409c..20f1f5ee4 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'; @@ -29,6 +29,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { const storedTheme = useTheme(); const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC); + const isWsEnabled = useBetaFeature(BETA_FEATURES.WEBSOCKET); const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid)); const { @@ -99,6 +100,14 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { return 'grpc-request'; } + if (collectionPresets.requestType === 'ws') { + // If WebSocket is disabled in beta features, fall back to http-request + if (!isWsEnabled) { + return 'http-request'; + } + return 'ws-request'; + } + return 'http-request'; }; @@ -146,6 +155,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { }), onSubmit: (values) => { const isGrpcRequest = values.requestType === 'grpc-request'; + const isWsRequest = values.requestType === 'ws-request'; if (isGrpcRequest) { dispatch( @@ -165,6 +175,23 @@ 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( @@ -309,69 +336,80 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { Type -
- - +
+
+ + +
- { - formik.setFieldValue('requestMethod', 'POST'); - formik.handleChange(event); - }} - value="graphql-request" - checked={formik.values.requestType === 'graphql-request'} - /> - +
+ + +
{isGrpcEnabled && ( - <> +
{ - formik.setFieldValue('requestMethod', 'POST'); - formik.handleChange(event); - }} value="grpc-request" checked={formik.values.requestType === 'grpc-request'} + onChange={formik.handleChange} /> - +
)} - + {isWsEnabled && ( +
+ + +
+ )} - +
+ + +
@@ -462,7 +500,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { URL
- {formik.values.requestType !== 'grpc-request' ? ( + {!['grpc-request', 'ws-request'].includes(formik.values.requestType) ? (
state.logs.isConsoleOpen); const mainSectionRef = useRef(null); + // Initialize event listeners + useGrpcEventListeners(); + useWsEventListeners(); + const className = classnames({ 'is-dragging': isDragging }); return ( // -
-
- - -
- {showHomePage ? ( - - ) : ( - <> - - - - )} -
-
-
- - - +
+
+ + +
+ {showHomePage ? ( + + ) : ( + <> + + + + )} +
+
+ + + +
// ); } diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index e55e44cb3..33c2a6162 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -25,7 +25,8 @@ const initialState = { codeFont: 'default' }, beta: { - grpc: false + grpc: false, + websocket: 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 0c490df80..8340a2795 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, sendGrpcRequest, sendNetworkRequest, sendWsRequest } from 'utils/network/index'; import { callIpc } from 'utils/common/ipc'; import { @@ -282,12 +282,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) => { @@ -1038,6 +1045,66 @@ 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', + 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 f1fcdef3b..62dc848cc 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', @@ -62,8 +82,24 @@ const initiatedGrpcResponse = { isError: false, duration: 0, responses: [], - timestamp: Date.now(), -} + timestamp: Date.now() +}; + +const initiatedWsResponse = { + status: 'PENDING', + statusText: 'PENDING', + statusCode: 0, + headers: [], + body: '', + size: 0, + duration: 0, + responses: [], + isError: false, + error: null, + errorDetails: null, + metadata: [], + trailers: [] +}; export const collectionsSlice = createSlice({ name: 'collections', @@ -136,7 +172,7 @@ export const collectionsSlice = createSlice({ }, sortCollections: (state, action) => { state.collectionSortOrder = action.payload.order; - const collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}); + const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); switch (action.payload.order) { case 'default': state.collections = state.collections.sort((a, b) => a.importedAt - b.importedAt); @@ -329,7 +365,7 @@ export const collectionsSlice = createSlice({ }, responseReceived: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); - + if (collection) { const item = findItemInCollection(collection, action.payload.itemUid); if (item) { @@ -341,15 +377,16 @@ export const collectionsSlice = createSlice({ if (!collection.timeline) { collection.timeline = []; } - + // Ensure timestamp is a number (milliseconds since epoch) - const timestamp = item?.requestSent?.timestamp instanceof Date - ? item.requestSent.timestamp.getTime() - : item?.requestSent?.timestamp || Date.now(); + const timestamp = + item?.requestSent?.timestamp instanceof Date + ? item.requestSent.timestamp.getTime() + : item?.requestSent?.timestamp || Date.now(); // Append the new timeline entry with numeric timestamp collection.timeline.push({ - type: "request", + type: 'request', collectionUid: collection.uid, folderUid: null, itemUid: item.uid, @@ -357,7 +394,7 @@ export const collectionsSlice = createSlice({ data: { request: item.requestSent || item.request, response: action.payload.response, - timestamp: timestamp, + timestamp: timestamp } }); } @@ -367,7 +404,7 @@ export const collectionsSlice = createSlice({ 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; @@ -387,7 +424,7 @@ export const collectionsSlice = createSlice({ } collection.timeline.push({ - type: "request", + type: 'request', eventType: eventType, // Add the specific gRPC event type collectionUid: collection.uid, folderUid: null, @@ -396,36 +433,34 @@ export const collectionsSlice = createSlice({ data: { request: eventData || item.requestSent || item.request, timestamp: Date.now(), - eventData: eventData, + eventData: eventData } }); - }, grpcResponseReceived: (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 || initiatedGrpcResponse + const currentResponse = item.response || initiatedGrpcResponse; const timestamp = item?.requestSent?.timestamp; let updatedResponse = { ...currentResponse, duration: Date.now() - (timestamp || Date.now()) }; - // Process based on event type switch (eventType) { case 'response': const { error, res } = eventData; - + // Handle error if present if (error) { const errorCode = error.code || 2; // Default to UNKNOWN if no code - + updatedResponse.error = error.details || 'gRPC error occurred'; updatedResponse.statusCode = errorCode; updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN'; @@ -434,72 +469,73 @@ export const collectionsSlice = createSlice({ } // Add response to list - updatedResponse.responses = res - ? [...(currentResponse?.responses || []), res] + updatedResponse.responses = res + ? [...(currentResponse?.responses || []), res] : [...(currentResponse?.responses || [])]; break; - + case 'metadata': updatedResponse.headers = eventData.metadata; updatedResponse.metadata = eventData.metadata; break; - + case 'status': // Extract status info const statusCode = eventData.status?.code; const statusDetails = eventData.status?.details; const statusMetadata = eventData.status?.metadata; - + // Set status based on actual code and details updatedResponse.statusCode = statusCode; updatedResponse.statusText = grpcStatusCodes[statusCode] || 'UNKNOWN'; updatedResponse.statusDescription = statusDetails; updatedResponse.statusDetails = eventData.status; - + // Store trailers (status metadata) if (statusMetadata) { updatedResponse.trailers = statusMetadata; } - + // Handle error status (non-zero code) if (statusCode !== 0) { updatedResponse.isError = true; - updatedResponse.error = statusDetails || `gRPC error with code ${statusCode} (${updatedResponse.statusText})`; + updatedResponse.error = + statusDetails || `gRPC error with code ${statusCode} (${updatedResponse.statusText})`; } - + break; - + case 'error': // Extract error details const errorCode = eventData.error?.code || 2; // Default to UNKNOWN if no code const errorDetails = eventData.error?.details || eventData.error?.message; const errorMetadata = eventData.error?.metadata; - + updatedResponse.isError = true; updatedResponse.error = errorDetails || 'Unknown gRPC error'; updatedResponse.statusCode = errorCode; updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN'; updatedResponse.statusDescription = errorDetails; - + // Store error metadata as trailers if present if (errorMetadata) { updatedResponse.trailers = errorMetadata; } - + break; - + case 'end': - state.activeConnections = state.activeConnections.filter(id => id !== itemUid); + state.activeConnections = state.activeConnections.filter((id) => id !== itemUid); break; - + case 'cancel': updatedResponse.statusCode = 1; // CANCELLED updatedResponse.statusText = 'CANCELLED'; updatedResponse.statusDescription = 'Stream cancelled by client or server'; - state.activeConnections = state.activeConnections.filter(id => id !== itemUid); + state.activeConnections = state.activeConnections.filter((id) => id !== itemUid); break; } - + item.requestState = 'received'; item.response = updatedResponse; @@ -510,7 +546,7 @@ export const collectionsSlice = createSlice({ // Append the new timeline entry with specific gRPC event type collection.timeline.push({ - type: "request", + type: 'request', eventType: eventType, // Add the specific gRPC event type collectionUid: collection.uid, folderUid: null, @@ -520,7 +556,7 @@ export const collectionsSlice = createSlice({ request: item.requestSent || item.request, response: updatedResponse, eventData: eventData, // Store the original event data - timestamp: Date.now(), + timestamp: Date.now() } }); }, @@ -547,7 +583,7 @@ export const collectionsSlice = createSlice({ if (collection) { if (itemUid) { - collection.timeline = collection?.timeline?.filter(t => t?.itemUid !== itemUid); + collection.timeline = collection?.timeline?.filter((t) => t?.itemUid !== itemUid); } } }, @@ -761,7 +797,7 @@ export const collectionsSlice = createSlice({ case 'ntlm': item.draft.request.auth.mode = 'ntlm'; item.draft.request.auth.ntlm = action.payload.content; - break; + break; case 'oauth2': item.draft.request.auth.mode = 'oauth2'; item.draft.request.auth.oauth2 = action.payload.content; @@ -774,6 +810,10 @@ export const collectionsSlice = createSlice({ item.draft.request.auth.mode = 'apikey'; item.draft.request.auth.apikey = action.payload.content; break; + case 'ws': + item.draft.request.auth.mode = 'ws'; + item.draft.request.auth.ws = action.payload.content; + break; } } } @@ -816,7 +856,7 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - const existingOtherParams = item.draft.request.params?.filter(p => p.type !== 'query') || []; + const existingOtherParams = item.draft.request.params?.filter((p) => p.type !== 'query') || []; const newQueryParams = map(params, ({ name = '', value = '', enabled = true }) => ({ uid: uuid(), name, @@ -830,9 +870,7 @@ export const collectionsSlice = createSlice({ // Update the request URL to reflect the new query params const parts = splitOnFirst(item.draft.request.url, '?'); - const query = stringifyQueryParams( - filter(item.draft.request.params, (p) => p.enabled && p.type === 'query') - ); + const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled && p.type === 'query')); // If there are enabled query params, append them to the URL if (query && query.length) { @@ -860,13 +898,13 @@ export const collectionsSlice = createSlice({ const queryParams = params.filter((param) => param.type === 'query'); const pathParams = params.filter((param) => param.type === 'path'); - + // Reorder only query params based on updateReorderedItem const reorderedQueryParams = updateReorderedItem.map((uid) => { return queryParams.find((param) => param.uid === uid); }); item.draft.request.params = [...reorderedQueryParams, ...pathParams]; - + // Update request URL const parts = splitOnFirst(item.draft.request.url, '?'); const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled && p.type === 'query')); @@ -1063,7 +1101,7 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - item.draft.request.headers = map(action.payload.headers, ({name = '', value = '', enabled = true}) => ({ + item.draft.request.headers = map(action.payload.headers, ({ name = '', value = '', enabled = true }) => ({ uid: uuid(), name: name, value: value, @@ -1079,7 +1117,7 @@ export const collectionsSlice = createSlice({ return; } - collection.root.request.headers = map(headers, ({name = '', value = '', enabled = true}) => ({ + collection.root.request.headers = map(headers, ({ name = '', value = '', enabled = true }) => ({ uid: uuid(), name: name, value: value, @@ -1099,8 +1137,8 @@ export const collectionsSlice = createSlice({ if (!folder || !isItemAFolder(folder)) { return; } - - folder.root.request.headers = map(headers, ({name = '', value = '', enabled = true}) => ({ + + folder.root.request.headers = map(headers, ({ name = '', value = '', enabled = true }) => ({ uid: uuid(), name: name, value: value, @@ -1274,16 +1312,16 @@ export const collectionsSlice = createSlice({ }, addFile: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); - + if (collection) { const item = findItemInCollection(collection, action.payload.itemUid); - + if (item && isItemARequest(item)) { if (!item.draft) { item.draft = cloneDeep(item); } item.draft.request.body.file = item.draft.request.body.file || []; - + item.draft.request.body.file.push({ uid: uuid(), filePath: '', @@ -1295,23 +1333,23 @@ export const collectionsSlice = createSlice({ }, updateFile: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); - + if (collection) { const item = findItemInCollection(collection, action.payload.itemUid); - + if (item && isItemARequest(item)) { if (!item.draft) { item.draft = cloneDeep(item); } - + const param = find(item.draft.request.body.file, (p) => p.uid === action.payload.param.uid); - + if (param) { const contentType = mime.contentType(path.extname(action.payload.param.filePath)); param.filePath = action.payload.param.filePath; param.contentType = action.payload.param.contentType || contentType || ''; param.selected = action.payload.param.selected; - + item.draft.request.body.file = item.draft.request.body.file.map((p) => { p.selected = p.uid === param.uid; return p; @@ -1322,20 +1360,17 @@ export const collectionsSlice = createSlice({ }, deleteFile: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); - + if (collection) { const item = findItemInCollection(collection, action.payload.itemUid); - + if (item && isItemARequest(item)) { if (!item.draft) { item.draft = cloneDeep(item); } - - item.draft.request.body.file = filter( - item.draft.request.body.file, - (p) => p.uid !== action.payload.paramUid - ); - + + item.draft.request.body.file = filter(item.draft.request.body.file, (p) => p.uid !== action.payload.paramUid); + if (item.draft.request.body.file.length > 0) { item.draft.request.body.file[0].selected = true; } @@ -1381,7 +1416,7 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - + switch (item.draft.request.body.mode) { case 'json': { item.draft.request.body.json = action.payload.content; @@ -1415,6 +1450,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; + } } } } @@ -1514,7 +1553,7 @@ export const collectionsSlice = createSlice({ const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (collection) { - const item = findItemInCollection(collection, action.payload.itemUid); + const item = findItemInCollection(collection, action.payload.itemUid); if (item && isItemARequest(item)) { if (!item.draft) { @@ -1710,17 +1749,17 @@ export const collectionsSlice = createSlice({ // Extract payload data const { updateReorderedItem } = action.payload; - if(type == "request"){ + if (type == 'request') { const params = item.draft.request.vars.req; item.draft.request.vars.req = updateReorderedItem.map((uid) => { - return params.find((param) => param.uid === uid); - }); + return params.find((param) => param.uid === uid); + }); } else if (type === 'response') { const params = item.draft.request.vars.res; item.draft.request.vars.res = updateReorderedItem.map((uid) => { - return params.find((param) => param.uid === uid); + return params.find((param) => param.uid === uid); }); } } @@ -1755,7 +1794,7 @@ export const collectionsSlice = createSlice({ break; case 'ntlm': set(collection, 'root.request.auth.ntlm', action.payload.content); - break; + break; case 'oauth2': set(collection, 'root.request.auth.oauth2', action.payload.content); break; @@ -1765,6 +1804,9 @@ export const collectionsSlice = createSlice({ case 'apikey': set(collection, 'root.request.auth.apikey', action.payload.content); break; + case 'ws': + set(collection, 'root.request.auth.ws', action.payload.content); + break; } } }, @@ -1960,6 +2002,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; } } }, @@ -2108,7 +2153,7 @@ export const collectionsSlice = createSlice({ name: directoryName, collapsed: true, type: 'folder', - items: [], + items: [] }; currentSubItems.push(childItem); } @@ -2310,7 +2355,7 @@ export const collectionsSlice = createSlice({ const { requestUid, itemUid, collectionUid } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); if (!collection) return; - + const item = findItemInCollection(collection, itemUid); if (!item) return; @@ -2339,11 +2384,11 @@ export const collectionsSlice = createSlice({ item.preRequestScriptErrorMessage = action.payload.errorMessage; } - if(type === 'post-response-script-execution') { + if (type === 'post-response-script-execution') { item.postResponseScriptErrorMessage = action.payload.errorMessage; } - if(type === 'test-script-execution') { + if (type === 'test-script-execution') { item.testScriptErrorMessage = action.payload.errorMessage; } @@ -2358,7 +2403,7 @@ export const collectionsSlice = createSlice({ if (type === 'request-sent') { const { cancelTokenUid, requestSent } = action.payload; item.requestSent = requestSent; - + // sometimes the response is received before the request-sent event arrives if (item.requestState === 'queued') { item.requestState = 'sending'; @@ -2375,12 +2420,12 @@ export const collectionsSlice = createSlice({ const { results } = action.payload; item.testResults = results; } - + if (type === 'test-results-pre-request') { const { results } = action.payload; item.preRequestTestResults = results; } - + if (type === 'test-results-post-response') { const { results } = action.payload; item.postResponseTestResults = results; @@ -2491,7 +2536,7 @@ export const collectionsSlice = createSlice({ if (collection) { collection.runnerResult = null; - collection.runnerTags = { include: [], exclude: [] } + collection.runnerTags = { include: [], exclude: [] }; collection.runnerTagsEnabled = false; collection.runnerConfiguration = null; } @@ -2561,14 +2606,14 @@ export const collectionsSlice = createSlice({ ); // Add the new credential with folderUid and itemUid - filteredOauth2Credentials.push({ - collectionUid, - folderUid, - itemUid, - url, + filteredOauth2Credentials.push({ + collectionUid, + folderUid, + itemUid, + url, credentials, credentialsId, - debugInfo + debugInfo }); collection.oauth2Credentials = filteredOauth2Credentials; @@ -2577,9 +2622,9 @@ export const collectionsSlice = createSlice({ collection.timeline = []; } - if(debugInfo) { + if (debugInfo) { collection.timeline.push({ - type: "oauth2", + type: 'oauth2', collectionUid, folderUid, itemUid, @@ -2591,7 +2636,7 @@ export const collectionsSlice = createSlice({ url, credentials, credentialsId, - debugInfo: debugInfo.data, + debugInfo: debugInfo.data } }); } @@ -2606,8 +2651,7 @@ export const collectionsSlice = createSlice({ let collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials); const filteredOauth2Credentials = filter( collectionOauth2Credentials, - (creds) => - !(creds.url === url && creds.collectionUid === collectionUid) + (creds) => !(creds.url === url && creds.collectionUid === collectionUid) ); collection.oauth2Credentials = filteredOauth2Credentials; } @@ -2618,8 +2662,7 @@ export const collectionsSlice = createSlice({ const collection = findCollectionByUid(state.collections, collectionUid); const oauth2Credential = find( collection?.oauth2Credentials || [], - (creds) => - creds.url === url && creds.collectionUid === collectionUid && creds.credentialsId === credentialsId + (creds) => creds.url === url && creds.collectionUid === collectionUid && creds.credentialsId === credentialsId ); return oauth2Credential; }, @@ -2627,7 +2670,7 @@ export const collectionsSlice = createSlice({ updateFolderAuthMode: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null; - + if (folder) { set(folder, 'root.request.auth', {}); set(folder, 'root.request.auth.mode', action.payload.mode); @@ -2675,7 +2718,7 @@ export const collectionsSlice = createSlice({ updateCollectionTagsList: (state, action) => { const { collectionUid } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); - + if (collection) { collection.allTags = getUniqueTagsFromItems(collection.items); } @@ -2683,6 +2726,109 @@ 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, + 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, duration: Date.now() - (timestamp || Date.now()) }; + + // Process based on event type + switch (eventType) { + case 'message': + const { message, direction } = eventData; + + // Add message to responses list + updatedResponse.responses = [ + ...(currentResponse?.responses || []), + { + message, + direction, + timestamp: Date.now() + } + ]; + break; + + case 'open': + updatedResponse.status = 'CONNECTED'; + updatedResponse.statusText = 'CONNECTED'; + updatedResponse.statusCode = 0; + break; + + case 'close': + const { code, reason } = eventData; + updatedResponse.status = 'CLOSED'; + updatedResponse.statusCode = code; + updatedResponse.statusText = wsStatusCodes[code] || 'CLOSED'; + updatedResponse.statusDescription = reason; + + // Handle error status (non-normal closure) + if (code !== 1000) { + updatedResponse.isError = true; + updatedResponse.error = reason || `WebSocket closed with code ${code}`; + } + break; + + case 'error': + const errorDetails = eventData.error || eventData.message; + updatedResponse.isError = true; + updatedResponse.error = errorDetails || 'WebSocket error occurred'; + updatedResponse.status = 'ERROR'; + updatedResponse.statusText = 'ERROR'; + break; + + case 'connecting': + updatedResponse.status = 'CONNECTING'; + updatedResponse.statusText = 'CONNECTING'; + break; + } + + item.response = updatedResponse; + } } }); @@ -2810,7 +2956,9 @@ export const { addRequestTag, deleteRequestTag, updateCollectionTagsList, - updateActiveConnections + updateActiveConnections, + runWsRequestEvent, + wsResponseReceived } = 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 694239da6..531ea7de8 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -21,12 +21,8 @@ export const tabsSlice = createSlice({ reducers: { addTab: (state, action) => { const { uid, collectionUid, type, requestPaneTab, preview } = action.payload; - const nonReplaceableTabTypes = [ - "variables", - "collection-runner", - "security-settings", - ]; - + const nonReplaceableTabTypes = ['variables', 'collection-runner', 'security-settings']; + const existingTab = find(state.tabs, (tab) => tab.uid === uid); if (existingTab) { state.activeTabUid = existingTab.uid; @@ -43,12 +39,17 @@ 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'; } + let defaultResponsePaneTab = 'response'; + if (type === 'ws-request') { + defaultResponsePaneTab = 'messages'; + } + const lastTab = state.tabs[state.tabs.length - 1]; if (state.tabs.length > 0 && lastTab.preview) { state.tabs[state.tabs.length - 1] = { @@ -56,18 +57,16 @@ export const tabsSlice = createSlice({ collectionUid, requestPaneWidth: null, requestPaneTab: requestPaneTab || defaultRequestPaneTab, - responsePaneTab: 'response', + responsePaneTab: defaultResponsePaneTab, type: type || 'request', - preview: preview !== undefined - ? preview - : !nonReplaceableTabTypes.includes(type), + preview: preview !== undefined ? preview : !nonReplaceableTabTypes.includes(type), ...(uid ? { folderUid: uid } : {}) }; state.activeTabUid = uid; return; } - + state.tabs.push({ uid, collectionUid, @@ -77,9 +76,7 @@ export const tabsSlice = createSlice({ responsePaneScrollPosition: null, type: type || 'request', ...(uid ? { folderUid: uid } : {}), - preview: preview !== undefined - ? preview - : !nonReplaceableTabTypes.includes(type) + preview: preview !== undefined ? preview : !nonReplaceableTabTypes.includes(type) }); state.activeTabUid = uid; }, @@ -195,4 +192,4 @@ export const { makeTabPermanent } = tabsSlice.actions; -export default tabsSlice.reducer; \ No newline at end of file +export default tabsSlice.reducer; diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 7e3136715..02f895a10 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -94,15 +94,41 @@ const darkTheme = { // customize these colors if needed patch: '#d69956', options: '#d69956', - head: '#d69956', + head: '#d69956' }, - grpc: '#6366f1' + grpc: '#6366f1', + ws: '#f59e0b' }, requestTabPanel: { url: { bg: '#3D3D3D', - icon: 'rgb(204, 204, 204)' + border: '#444', + icon: 'rgb(204, 204, 204)', + hoverBg: '#4a4a4a', + errorHoverBg: '#4a2a2a' + }, + body: { + bg: '#3D3D3D', + border: '#444', + headerBg: '#2a2a2a', + footerBg: '#2a2a2a', + addBtnBg: '#6366f1', + addBtnColor: '#ffffff', + addBtnBorder: '#6366f1', + addBtnHoverBg: '#4f46e5', + addBtnHoverBorder: '#4f46e5', + messageBorder: '#555', + messageHeaderBg: '#2a2a2a', + inputColor: '#ffffff', + placeholderColor: '#888', + textareaBg: '#2a2a2a', + textareaColor: '#ffffff', + removeBtnColor: '#888', + removeBtnHoverBg: '#4a2a2a', + removeBtnHoverColor: '#f06f57', + statusColor: '#888', + shortcutBg: '#555' }, dragbar: { border: '#444', @@ -252,7 +278,7 @@ const darkTheme = { border: '#373737', placeholder: { color: '#a2a2a2', - opacity: 0.50 + opacity: 0.5 }, gutter: { bg: '#262626' @@ -293,6 +319,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', @@ -303,6 +335,23 @@ const darkTheme = { border: '#323233', color: 'rgb(169, 169, 169)' }, + responseTabPanel: { + bg: '#3D3D3D', + border: '#444', + headerBg: '#2a2a2a', + sectionBorder: '#555', + labelColor: '#888', + messageBorder: '#555', + messageHeaderBg: '#2a2a2a', + messageTextColor: '#ffffff', + copyBtnColor: '#888', + copyBtnHoverBg: '#4a2a2a', + copyBtnHoverColor: '#f06f57', + errorBg: '#2a1a1a', + errorBorder: '#4a2a2a', + errorColor: '#f06f57' + }, + console: { bg: '#1e1e1e', headerBg: '#2d2d30', diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index 715d468d6..e6d230e23 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -96,13 +96,39 @@ const lightTheme = { options: '#ca7811', head: '#ca7811' }, - grpc: '#6366f1' + grpc: '#6366f1', + ws: '#f59e0b' }, requestTabPanel: { url: { bg: '#f3f3f3', - icon: '#515151' + border: '#efefef', + icon: '#515151', + hoverBg: '#f9fafb', + errorHoverBg: '#fef2f2' + }, + body: { + bg: '#ffffff', + border: '#e5e7eb', + headerBg: '#f9fafb', + footerBg: '#f9fafb', + addBtnBg: '#6366f1', + addBtnColor: '#ffffff', + addBtnBorder: '#6366f1', + addBtnHoverBg: '#4f46e5', + addBtnHoverBorder: '#4f46e5', + messageBorder: '#d1d5db', + messageHeaderBg: '#f9fafb', + inputColor: '#374151', + placeholderColor: '#9ca3af', + textareaBg: '#f9fafb', + textareaColor: '#374151', + removeBtnColor: '#6b7280', + removeBtnHoverBg: '#fef2f2', + removeBtnHoverColor: '#dc2626', + statusColor: '#6b7280', + shortcutBg: '#e5e7eb' }, dragbar: { border: '#efefef', @@ -294,12 +320,36 @@ 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', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)' }, + responseTabPanel: { + bg: '#ffffff', + border: '#e5e7eb', + headerBg: '#f9fafb', + sectionBorder: '#e5e7eb', + labelColor: '#6b7280', + messageBorder: '#d1d5db', + messageHeaderBg: '#f9fafb', + messageTextColor: '#374151', + copyBtnColor: '#6b7280', + copyBtnHoverBg: '#f3f4f6', + copyBtnHoverColor: '#dc2626', + errorBg: '#fef2f2', + errorBorder: '#fecaca', + errorColor: '#dc2626' + }, + statusBar: { border: '#E9E9E9', color: 'rgb(100, 100, 100)' diff --git a/packages/bruno-app/src/utils/beta-features.js b/packages/bruno-app/src/utils/beta-features.js index 40c7635d6..89c7ec9d9 100644 --- a/packages/bruno-app/src/utils/beta-features.js +++ b/packages/bruno-app/src/utils/beta-features.js @@ -5,7 +5,8 @@ import { useSelector } from 'react-redux'; * Contains all available beta feature keys */ export const BETA_FEATURES = Object.freeze({ - GRPC: 'grpc' + GRPC: 'grpc', + WEBSOCKET: 'websocket' }); /** 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 c7e0817b0..e950e1ebf 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -1,4 +1,4 @@ -import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash'; +import { cloneDeep, each, filter, find, findIndex, get, isEqual, isString, map, sortBy } from 'lodash'; import { uuid } from 'utils/common'; import { sortByNameThenSequence } from 'utils/common/index'; import path from 'utils/common/path'; @@ -140,7 +140,7 @@ export const areItemsLoading = (folder) => { if (!folder || folder.isLoading) { return true; } - + let flattenedItems = flattenItems(folder.items); return flattenedItems?.reduce((isLoading, i) => { if (i?.loading) { @@ -148,13 +148,13 @@ export const areItemsLoading = (folder) => { } return isLoading; }, false); -} +}; export const getItemsLoadStats = (folder) => { let loadingCount = 0; let flattenedItems = flattenItems(folder.items); - flattenedItems?.forEach(i => { - if(i?.loading) { + flattenedItems?.forEach((i) => { + if (i?.loading) { loadingCount += 1; } }); @@ -162,7 +162,7 @@ export const getItemsLoadStats = (folder) => { loading: loadingCount, total: flattenedItems?.length }; -} +}; export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => { const copyHeaders = (headers) => { @@ -222,9 +222,9 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} filePath: param.filePath, contentType: param.contentType, selected: param.selected - } + }; }); - } + }; const copyItems = (sourceItems, destItems) => { each(sourceItems, (si) => { @@ -232,7 +232,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} return; } - const isGrpcRequest = si.type === 'grpc-request' + const isGrpcRequest = si.type === 'grpc-request'; const di = { uid: si.uid, @@ -274,7 +274,6 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} di.request.protoPath = si.request.protoPath; delete di.request.params; } - // Handle auth object dynamically di.request.auth = { @@ -315,7 +314,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} password: get(si.request, 'auth.ntlm.password', ''), domain: get(si.request, 'auth.ntlm.domain', '') }; - break; + break; case 'oauth2': let grantType = get(si.request, 'auth.oauth2.grantType', ''); switch (grantType) { @@ -335,7 +334,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''), tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''), autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true), - autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true), + autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true) }; break; case 'authorization_code': @@ -355,7 +354,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''), tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''), autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true), - autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true), + autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true) }; break; case 'implicit': @@ -370,7 +369,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'), tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'), tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''), - autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true), + autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true) }; break; case 'client_credentials': @@ -387,7 +386,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''), tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''), autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true), - autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true), + autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true) }; break; } @@ -414,10 +413,10 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} } if (di.request.body.mode === 'grpc') { - di.request.body.grpc = di.request.body.grpc.map(({name, content}, index) => ({ + di.request.body.grpc = di.request.body.grpc.map(({ name, content }, index) => ({ name: name ? name : `message ${index + 1}`, content: replaceTabsWithSpaces(content) - })) + })); } } @@ -597,11 +596,17 @@ export const transformRequestToSaveToFilesystem = (item) => { if (_item.type === 'grpc-request') { itemToSave.request.methodType = _item.request.methodType; itemToSave.request.protoPath = _item.request.protoPath; - delete itemToSave.request.params + 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, @@ -634,7 +639,17 @@ export const transformRequestToSaveToFilesystem = (item) => { if (itemToSave.request.body.mode === 'grpc') { itemToSave.request.body = { ...itemToSave.request.body, - grpc: itemToSave.request.body.grpc.map(({name, content}, index) => ({ + grpc: itemToSave.request.body.grpc.map(({ name, content }, index) => ({ + name: name ? name : `message ${index + 1}`, + content: replaceTabsWithSpaces(content) + })) + }; + } + + if (itemToSave.request.body.mode === 'ws') { + itemToSave.request.body = { + ...itemToSave.request.body, + grpc: itemToSave.request.body.ws.map(({ name, content }, index) => ({ name: name ? name : `message ${index + 1}`, content: replaceTabsWithSpaces(content) })) @@ -668,7 +683,11 @@ 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) => { @@ -737,7 +756,7 @@ export const humanizeRequestAuthMode = (mode) => { case 'ntlm': { label = 'NTLM'; break; - } + } case 'oauth2': { label = 'OAuth 2.0'; break; @@ -851,14 +870,14 @@ export const getDefaultRequestPaneTab = (item) => { return 'query'; } - if (item.type === 'grpc-request') { + if (['ws-request', 'grpc-request'].includes(item.type)) { return 'body'; } }; export const getGlobalEnvironmentVariables = ({ globalEnvironments, activeGlobalEnvironmentUid }) => { let variables = {}; - const environment = globalEnvironments?.find(env => env?.uid === activeGlobalEnvironmentUid); + const environment = globalEnvironments?.find((env) => env?.uid === activeGlobalEnvironmentUid); if (environment) { each(environment.variables, (variable) => { if (variable.name && variable.enabled) { @@ -870,7 +889,7 @@ export const getGlobalEnvironmentVariables = ({ globalEnvironments, activeGlobal }; export const getGlobalEnvironmentVariablesMasked = ({ globalEnvironments, activeGlobalEnvironmentUid }) => { - const environment = globalEnvironments?.find(env => env?.uid === activeGlobalEnvironmentUid); + const environment = globalEnvironments?.find((env) => env?.uid === activeGlobalEnvironmentUid); if (environment && Array.isArray(environment.variables)) { return environment.variables @@ -881,7 +900,6 @@ export const getGlobalEnvironmentVariablesMasked = ({ globalEnvironments, active return []; }; - export const getEnvironmentVariables = (collection) => { let variables = {}; if (collection) { @@ -942,7 +960,7 @@ export const getTotalRequestCountInCollection = (collection) => { }; export const getAllVariables = (collection, item) => { - if(!collection) return {}; + if (!collection) return {}; const envVariables = getEnvironmentVariables(collection); const requestTreePath = getTreePathFromCollectionToItem(collection, item); let { collectionVariables, folderVariables, requestVariables } = mergeVars(collection, requestTreePath); @@ -961,8 +979,8 @@ export const getAllVariables = (collection, item) => { ...envVariables, ...folderVariables, ...requestVariables, - ...runtimeVariables, - } + ...runtimeVariables + }; const maskedEnvVariables = getEnvironmentVariablesMasked(collection) || []; const maskedGlobalEnvVariables = collection?.globalEnvSecrets || []; @@ -972,7 +990,9 @@ export const getAllVariables = (collection, item) => { const uniqueMaskedVariables = [...new Set([...filteredMaskedEnvVariables, ...filteredMaskedGlobalEnvVariables])]; - const oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials }) + const oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ + oauth2Credentials: collection?.oauth2Credentials + }); return { ...globalEnvironmentVariables, @@ -1082,7 +1102,6 @@ export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = [] return credentialsVariables; }; - // item sequence utils - START export const resetSequencesInFolder = (folderItems) => { @@ -1114,7 +1133,7 @@ export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, dragg const draggedItem = findItem(itemsWithFixedSequences, draggedItemUid); const targetSequence = targetItem?.seq; const draggedSequence = draggedItem?.seq; - itemsWithFixedSequences?.forEach(item => { + itemsWithFixedSequences?.forEach((item) => { const isDraggedItem = item?.uid === draggedItemUid; const isBetween = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence); if (isBetween) { @@ -1126,15 +1145,15 @@ export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, dragg } }); // only return items that have been reordered - return itemsWithFixedSequences.filter(item => - items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq + return itemsWithFixedSequences.filter( + (item) => items?.find((originalItem) => originalItem?.uid === item?.uid)?.seq !== item?.seq ); }; export const getReorderedItemsInSourceDirectory = ({ items }) => { const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items)); - return itemsWithFixedSequences.filter(item => - items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq + return itemsWithFixedSequences.filter( + (item) => items?.find((originalItem) => originalItem?.uid === item?.uid)?.seq !== item?.seq ); }; @@ -1146,9 +1165,9 @@ export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropT const isTargetItemAFolder = isItemAFolder(targetItem); if (dropType === 'inside' && (isTargetItemAFolder || isTargetTheCollection)) { - return path.join(targetItemPathname, draggedItemFilename) + return path.join(targetItemPathname, draggedItemFilename); } else if (dropType === 'adjacent') { - return path.join(targetItemDirname, draggedItemFilename) + return path.join(targetItemDirname, draggedItemFilename); } return null; }; @@ -1158,10 +1177,10 @@ export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropT export const getUniqueTagsFromItems = (items = []) => { const allTags = new Set(); const getTags = (items) => { - items.forEach(item => { + items.forEach((item) => { if (isItemARequest(item)) { const tags = item.draft ? get(item, 'draft.tags', []) : get(item, 'tags', []); - tags.forEach(tag => allTags.add(tag)); + tags.forEach((tag) => allTags.add(tag)); } if (item.items) { getTags(item.items); @@ -1172,10 +1191,9 @@ export const getUniqueTagsFromItems = (items = []) => { return Array.from(allTags).sort(); }; - export const getRequestItemsForCollectionRun = ({ recursive, items = [], tags }) => { let requestItems = []; - + if (recursive) { requestItems = flattenItems(items); } else { @@ -1187,7 +1205,7 @@ export const getRequestItemsForCollectionRun = ({ recursive, items = [], tags }) } const requestTypes = ['http-request', 'graphql-request']; - requestItems = requestItems.filter(request => requestTypes.includes(request.type)); + requestItems = requestItems.filter((request) => requestTypes.includes(request.type)); if (tags && tags.include && tags.exclude) { const includeTags = tags.include ? tags.include : []; @@ -1203,4 +1221,4 @@ export const getRequestItemsForCollectionRun = ({ recursive, items = [], tags }) export const getPropertyFromDraftOrRequest = (item, propertyKey, defaultValue = null) => { return item.draft ? get(item, `draft.${propertyKey}`, defaultValue) : get(item, propertyKey, defaultValue); -}; \ No newline at end of file +}; diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index 0022b5045..ab21137a2 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -5,7 +5,7 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV .then((response) => { // if there is an error, we return the response object as is if (response?.error) { - resolve(response) + resolve(response); } resolve({ state: 'success', @@ -28,19 +28,17 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV export const sendGrpcRequest = async (item, collection, environment, runtimeVariables) => { return new Promise((resolve, reject) => { startGrpcRequest(item, collection, environment, runtimeVariables) - .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)); + .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)); }); -} - - +}; const sendHttpRequest = async (item, collection, environment, runtimeVariables) => { return new Promise((resolve, reject) => { @@ -80,19 +78,20 @@ export const startGrpcRequest = async (item, collection, environment, runtimeVar return new Promise((resolve, reject) => { const { ipcRenderer } = window; const request = item.draft ? item.draft : item; - - ipcRenderer.invoke('grpc:start-connection', { - request, - collection, - environment, - runtimeVariables - }) - .then(() => { - resolve(); - }) - .catch(err => { - reject(err); - }); + + ipcRenderer + .invoke('grpc:start-connection', { + request, + collection, + environment, + runtimeVariables + }) + .then(() => { + resolve(); + }) + .catch((err) => { + reject(err); + }); }); }; @@ -105,9 +104,7 @@ export const startGrpcRequest = async (item, collection, environment, runtimeVar export const sendGrpcMessage = async (item, collectionUid, message) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; - ipcRenderer.invoke('grpc:send-message', item.uid, collectionUid, message) - .then(resolve) - .catch(reject); + ipcRenderer.invoke('grpc:send-message', item.uid, collectionUid, message).then(resolve).catch(reject); }); }; @@ -119,9 +116,7 @@ export const sendGrpcMessage = async (item, collectionUid, message) => { export const cancelGrpcRequest = async (requestId) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; - ipcRenderer.invoke('grpc:cancel', requestId) - .then(resolve) - .catch(reject); + ipcRenderer.invoke('grpc:cancel', requestId).then(resolve).catch(reject); }); }; @@ -133,9 +128,7 @@ export const cancelGrpcRequest = async (requestId) => { export const endGrpcStream = async (requestId) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; - ipcRenderer.invoke('grpc:end', requestId) - .then(resolve) - .catch(reject); + ipcRenderer.invoke('grpc:end', requestId).then(resolve).catch(reject); }); }; @@ -175,8 +168,9 @@ export const endGrpcConnection = async (connectionId) => { export const isGrpcConnectionActive = async (connectionId) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; - ipcRenderer.invoke('grpc:is-connection-active', connectionId) - .then(response => { + ipcRenderer + .invoke('grpc:is-connection-active', connectionId) + .then((response) => { if (response.success) { resolve(response.isActive); } else { @@ -185,7 +179,7 @@ export const isGrpcConnectionActive = async (connectionId) => { resolve(false); } }) - .catch(err => { + .catch((err) => { console.error('Failed to check connection status:', err); // On error, assume the connection is not active resolve(false); @@ -203,13 +197,111 @@ export const isGrpcConnectionActive = async (connectionId) => { export const generateGrpcSampleMessage = async (methodPath, existingMessage = null, options = {}) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; - - ipcRenderer.invoke('grpc:generate-sample-message', { - methodPath, - existingMessage, - options - }) - .then(resolve) - .catch(reject); + + ipcRenderer + .invoke('grpc:generate-sample-message', { + methodPath, + existingMessage, + options + }) + .then(resolve) + .catch(reject); + }); +}; + +export const connectWS = async (item, collection, environment, runtimeVariables) => { + return new Promise((resolve, reject) => { + startWsConnection(item, collection, environment, runtimeVariables) + .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); + } + }; + ensureConnection().then(() => { + const { request } = item.draft ? item.draft : item; + sendWsMessage(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, + timeline: [] + }); + }) + .catch((err) => reject(err)); + }); + }); +}; + +export const startWsConnection = async (item, collection, environment, runtimeVariables) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + const request = item.draft ? item.draft : item; + + ipcRenderer + .invoke('ws:start-connection', { + request, + collection, + environment, + runtimeVariables + }) + .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 {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('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('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('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..10d70e1bd --- /dev/null +++ b/packages/bruno-app/src/utils/network/ws-event-listeners.js @@ -0,0 +1,107 @@ +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('ws:request', (requestId, collectionUid, eventData) => { + dispatch( + runWsRequestEvent({ + eventType: 'request', + itemUid: requestId, + collectionUid: collectionUid, + requestUid: requestId, + eventData + }) + ); + }); + + // Handle WebSocket message event + const removeWsMessageListener = ipcRenderer.on('ws:message', (requestId, collectionUid, eventData) => { + dispatch( + wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'message', + eventData: eventData + }) + ); + }); + + // Handle WebSocket open event + const removeWsOpenListener = ipcRenderer.on('ws:open', (requestId, collectionUid, eventData) => { + dispatch( + wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'open', + eventData: eventData + }) + ); + }); + + // Handle WebSocket close event + const removeWsCloseListener = ipcRenderer.on('ws:close', (requestId, collectionUid, eventData) => { + dispatch( + wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'close', + eventData: eventData + }) + ); + }); + + // Handle WebSocket error event + const removeWsErrorListener = ipcRenderer.on('ws:error', (requestId, collectionUid, eventData) => { + dispatch( + wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'error', + eventData: eventData + }) + ); + }); + + // Handle WebSocket connecting event + const removeWsConnectingListener = ipcRenderer.on('ws:connecting', (requestId, collectionUid, eventData) => { + dispatch( + wsResponseReceived({ + itemUid: requestId, + collectionUid: collectionUid, + eventType: 'connecting', + eventData: eventData + }) + ); + }); + + const removeWsConnectionsChangedListener = ipcRenderer.on('ws:connections-changed', (data) => { + dispatch(updateActiveConnectionsInStore(data)); + }); + + return () => { + removeWsRequestSentListener(); + 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..94babf1e3 100644 --- a/packages/bruno-app/src/utils/tabs/index.js +++ b/packages/bruno-app/src/utils/tabs/index.js @@ -1,7 +1,10 @@ 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) => { @@ -17,4 +20,4 @@ export const scrollToTheActiveTab = () => { if (activeTab) { activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' }); } -}; \ No newline at end of file +}; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index a682d4814..6dd102707 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -27,6 +27,9 @@ const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; const { NtlmClient } = require('axios-ntlm'); const { addDigestInterceptor } = require('@usebruno/requests'); const { encodeUrl } = require('@usebruno/common').utils; +const { sendNetworkRequest } = require('@usebruno/requests'); +const { sendGrpcRequest } = require('@usebruno/requests'); +const { sendWsRequest } = require('@usebruno/requests'); const onConsoleLog = (type, args) => { console[type](...args); @@ -82,7 +85,7 @@ const runSingleRequest = async function ( // run pre request script const requestScriptFile = get(request, 'script.req'); - const collectionName = collection?.brunoConfig?.name + const collectionName = collection?.brunoConfig?.name; if (requestScriptFile?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); const result = await scriptRuntime.runRequestScript( @@ -214,7 +217,7 @@ const runSingleRequest = async function ( const collectionProxyConfig = get(brunoConfig, 'proxy', {}); const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', false); - + if (noproxy) { // If noproxy flag is set, don't use any proxy proxyMode = 'off'; @@ -243,8 +246,12 @@ const runSingleRequest = async function ( let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`; let proxyUri; if (proxyAuthEnabled) { - const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions)); - const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions)); + const proxyAuthUsername = encodeURIComponent( + interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions) + ); + const proxyAuthPassword = encodeURIComponent( + interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions) + ); proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`; } else { @@ -310,37 +317,34 @@ const runSingleRequest = async function ( if (!options.disableCookies) { const cookieString = getCookieStringForUrl(request.url); if (cookieString && typeof cookieString === 'string' && cookieString.length) { - const existingCookieHeaderName = Object.keys(request.headers).find( - name => name.toLowerCase() === 'cookie' - ); + const existingCookieHeaderName = Object.keys(request.headers).find((name) => name.toLowerCase() === 'cookie'); const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : ''; - + // Helper function to parse cookies into an object - const parseCookies = (str) => str.split(';').reduce((cookies, cookie) => { + const parseCookies = (str) => + str.split(';').reduce((cookies, cookie) => { const [name, ...rest] = cookie.split('='); if (name && name.trim()) { - cookies[name.trim()] = rest.join('=').trim(); + cookies[name.trim()] = rest.join('=').trim(); } return cookies; - }, {}); - + }, {}); + const mergedCookies = { - ...parseCookies(existingCookieString), - ...parseCookies(cookieString), + ...parseCookies(existingCookieString), + ...parseCookies(cookieString) }; - + const combinedCookieString = Object.entries(mergedCookies) - .map(([name, value]) => `${name}=${value}`) - .join('; '); - + .map(([name, value]) => `${name}=${value}`) + .join('; '); + request.headers[existingCookieHeaderName || 'Cookie'] = combinedCookieString; } } // stringify the request url encoded params - const contentTypeHeader = Object.keys(request.headers).find( - name => name.toLowerCase() === 'content-type' - ); + const contentTypeHeader = Object.keys(request.headers).find((name) => name.toLowerCase() === 'content-type'); if (contentTypeHeader && request.headers[contentTypeHeader] === 'application/x-www-form-urlencoded') { request.data = qs.stringify(request.data, { arrayFormat: 'repeat' }); } @@ -353,9 +357,9 @@ const runSingleRequest = async function ( } } - let requestMaxRedirects = request.maxRedirects - request.maxRedirects = 0 - + let requestMaxRedirects = request.maxRedirects; + request.maxRedirects = 0; + // Set default value for requestMaxRedirects if not explicitly set if (requestMaxRedirects === undefined) { requestMaxRedirects = 5; // Default to 5 redirects @@ -367,7 +371,7 @@ const runSingleRequest = async function ( const token = await getOAuth2Token(request.oauth2); if (token) { const { tokenPlacement = 'header', tokenHeaderPrefix = '', tokenQueryKey = 'access_token' } = request.oauth2; - + if (tokenPlacement === 'header' && token) { request.headers['Authorization'] = `${tokenHeaderPrefix} ${token}`.trim(); } else if (tokenPlacement === 'url') { @@ -383,98 +387,112 @@ const runSingleRequest = async function ( } catch (error) { console.error('OAuth2 token fetch error:', error.message); } - + // Remove oauth2 config from request to prevent it from being sent delete request.oauth2; } - let response, responseTime; - try { - - let axiosInstance = makeAxiosInstance({ requestMaxRedirects: requestMaxRedirects, disableCookies: options.disableCookies }); - if (request.ntlmConfig) { - axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults) - delete request.ntlmConfig; - } - + let response; + let responseTime; - if (request.awsv4config) { - // todo: make this happen in prepare-request.js - // interpolate the aws v4 config - request.awsv4config.accessKeyId = interpolateString(request.awsv4config.accessKeyId, interpolationOptions); - request.awsv4config.secretAccessKey = interpolateString( - request.awsv4config.secretAccessKey, - interpolationOptions - ); - request.awsv4config.sessionToken = interpolateString(request.awsv4config.sessionToken, interpolationOptions); - request.awsv4config.service = interpolateString(request.awsv4config.service, interpolationOptions); - request.awsv4config.region = interpolateString(request.awsv4config.region, interpolationOptions); - request.awsv4config.profileName = interpolateString(request.awsv4config.profileName, interpolationOptions); + const isGrpcRequest = item.type === 'grpc-request'; + const isWsRequest = item.type === 'ws-request'; - request.awsv4config = await resolveAwsV4Credentials(request); - addAwsV4Interceptor(axiosInstance, request); - delete request.awsv4config; - } + if (isGrpcRequest) { + response = await sendGrpcRequest(item, collection, envVariables, runtimeVariables); + responseTime = response.duration || 0; + } else if (isWsRequest) { + response = await sendWsRequest(item, collection, envVariables, runtimeVariables); + responseTime = response.duration || 0; + } else { + try { + let axiosInstance = makeAxiosInstance({ + requestMaxRedirects: requestMaxRedirects, + disableCookies: options.disableCookies + }); + if (request.ntlmConfig) { + axiosInstance = NtlmClient(request.ntlmConfig, axiosInstance.defaults); + delete request.ntlmConfig; + } - if (request.digestConfig) { - addDigestInterceptor(axiosInstance, request); - delete request.digestConfig; - } + if (request.awsv4config) { + // todo: make this happen in prepare-request.js + // interpolate the aws v4 config + request.awsv4config.accessKeyId = interpolateString(request.awsv4config.accessKeyId, interpolationOptions); + request.awsv4config.secretAccessKey = interpolateString( + request.awsv4config.secretAccessKey, + interpolationOptions + ); + request.awsv4config.sessionToken = interpolateString(request.awsv4config.sessionToken, interpolationOptions); + request.awsv4config.service = interpolateString(request.awsv4config.service, interpolationOptions); + request.awsv4config.region = interpolateString(request.awsv4config.region, interpolationOptions); + request.awsv4config.profileName = interpolateString(request.awsv4config.profileName, interpolationOptions); - /** @type {import('axios').AxiosResponse} */ - response = await axiosInstance(request); + request.awsv4config = await resolveAwsV4Credentials(request); + addAwsV4Interceptor(axiosInstance, request); + delete request.awsv4config; + } - const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson); - response.data = data; - response.dataBuffer = dataBuffer; + if (request.digestConfig) { + addDigestInterceptor(axiosInstance, request); + delete request.digestConfig; + } - // Prevents the duration on leaking to the actual result - responseTime = response.headers.get('request-duration'); - response.headers.delete('request-duration'); + /** @type {import('axios').AxiosResponse} */ + response = await axiosInstance(request); - //save cookies if enabled - if (!options.disableCookies) { - saveCookies(request.url, response.headers); - } - } catch (err) { - if (err?.response) { - const { data, dataBuffer } = parseDataFromResponse(err?.response); - err.response.data = data; - err.response.dataBuffer = dataBuffer; - response = err.response; + const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson); + response.data = data; + response.dataBuffer = dataBuffer; // Prevents the duration on leaking to the actual result responseTime = response.headers.get('request-duration'); response.headers.delete('request-duration'); - } else { - console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`)); - return { - test: { - filename: relativeItemPathname - }, - request: { - method: request.method, - url: request.url, - headers: request.headers, - data: request.data - }, - response: { + + //save cookies if enabled + if (!options.disableCookies) { + saveCookies(request.url, response.headers); + } + } catch (err) { + if (err?.response) { + const { data, dataBuffer } = parseDataFromResponse(err?.response); + err.response.data = data; + err.response.dataBuffer = dataBuffer; + response = err.response; + + // Prevents the duration on leaking to the actual result + responseTime = response.headers.get('request-duration'); + response.headers.delete('request-duration'); + } else { + console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`)); + return { + test: { + filename: relativeItemPathname + }, + request: { + method: request.method, + url: request.url, + headers: request.headers, + data: request.data + }, + response: { + status: 'error', + statusText: null, + headers: null, + data: null, + url: null, + responseTime: 0 + }, + error: err?.message || err?.errors?.map((e) => e?.message)?.at(0) || err?.code || 'Request Failed!', status: 'error', - statusText: null, - headers: null, - data: null, - url: null, - responseTime: 0 - }, - error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!', - status: 'error', - assertionResults: [], - testResults: [], - preRequestTestResults, - postResponseTestResults, - nextRequestName: nextRequestName, - shouldStopRunnerExecution - }; + assertionResults: [], + testResults: [], + preRequestTestResults, + postResponseTestResults, + nextRequestName: nextRequestName, + shouldStopRunnerExecution + }; + } } } @@ -482,7 +500,7 @@ const runSingleRequest = async function ( console.log( chalk.green(stripExtension(relativeItemPathname)) + - chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`) + chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`) ); // Log pre-request test results @@ -585,7 +603,6 @@ const runSingleRequest = async function ( } } - logResults(assertionResults, 'Assertions'); return { diff --git a/packages/bruno-cli/src/utils/bru.js b/packages/bruno-cli/src/utils/bru.js index daa883aba..31b7b5b12 100644 --- a/packages/bruno-cli/src/utils/bru.js +++ b/packages/bruno-cli/src/utils/bru.js @@ -1,8 +1,5 @@ const _ = require('lodash'); -const { - parseRequest: _parseRequest, - parseCollection: _parseCollection -} = require('@usebruno/filestore'); +const { parseRequest: _parseRequest, parseCollection: _parseCollection } = require('@usebruno/filestore'); const collectionBruToJson = (bru) => { try { @@ -24,7 +21,7 @@ const collectionBruToJson = (bru) => { const sequence = _.get(json, 'meta.seq'); if (json?.meta) { transformedJson.meta = { - name: json.meta.name, + name: json.meta.name }; if (sequence) { @@ -63,6 +60,9 @@ const bruToJson = (bru) => { case 'grpc': requestType = 'grpc-request'; break; + case 'ws': + requestType = 'ws-request'; + break; default: requestType = 'http-request'; } @@ -88,18 +88,31 @@ const bruToJson = (bru) => { if (requestType === 'grpc-request') { const selectedMethod = _.get(json, 'grpc.method'); - if(selectedMethod) transformedJson.request.method = selectedMethod; + if (selectedMethod) transformedJson.request.method = selectedMethod; const selectedMethodType = _.get(json, 'grpc.methodType'); - if(selectedMethodType) transformedJson.request.methodType = selectedMethodType; + if (selectedMethodType) transformedJson.request.methodType = selectedMethodType; const protoPath = _.get(json, 'grpc.protoPath'); - if(protoPath) transformedJson.request.protoPath = protoPath; + if (protoPath) transformedJson.request.protoPath = protoPath; transformedJson.request.auth.mode = _.get(json, 'grpc.auth', 'none'); transformedJson.request.body = _.get(json, 'body', { mode: 'grpc', - grpc: [{ - name: 'message 1', - content: '{}' - }] + grpc: [ + { + name: 'message 1', + content: '{}' + } + ] + }); + } else if (requestType === 'ws-request') { + transformedJson.request.auth.mode = _.get(json, 'ws.auth', 'none'); + transformedJson.request.body = _.get(json, 'body', { + mode: 'ws', + ws: [ + { + name: 'message 1', + content: '{}' + } + ] }); } else { transformedJson.request.method = _.upperCase(_.get(json, 'http.method')); @@ -108,8 +121,6 @@ const bruToJson = (bru) => { transformedJson.request.body.mode = _.get(json, 'http.body', 'none'); } - - return transformedJson; } catch (err) { return Promise.reject(err); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 30cf11111..4696e1d26 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -555,7 +555,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')); } diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 37fd9bd3a..585be187c 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -19,12 +19,31 @@ const { prepareRequest } = require('./prepare-request'); const interpolateVars = require('./interpolate-vars'); const { makeAxiosInstance } = require('./axios-instance'); const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token'); -const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseDataFromRequest } = require('../../utils/common'); +const { + uuid, + safeStringifyJSON, + safeParseJSON, + parseDataFromResponse, + parseDataFromRequest +} = require('../../utils/common'); const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem'); const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies'); const { createFormData } = require('../../utils/form-data'); -const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence } = require('../../utils/collection'); -const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant } = require('../../utils/oauth2'); +const { + findItemInCollectionByPathname, + sortFolder, + getAllRequestsInFolderRecursively, + getEnvVars, + getTreePathFromCollectionToItem, + mergeVars, + sortByNameThenSequence +} = require('../../utils/collection'); +const { + getOAuth2TokenUsingAuthorizationCode, + getOAuth2TokenUsingClientCredentials, + getOAuth2TokenUsingPasswordCredentials, + getOAuth2TokenUsingImplicitGrant +} = require('../../utils/oauth2'); const { preferencesUtil } = require('../../store/preferences'); const { getProcessEnvVars } = require('../../store/process-env'); const { getBrunoConfig } = require('../../store/bruno-config'); @@ -32,15 +51,14 @@ 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 saveCookies = (url, headers) => { if (preferencesUtil.shouldStoreCookies()) { let setCookieHeaders = []; if (headers['set-cookie']) { - setCookieHeaders = Array.isArray(headers['set-cookie']) - ? headers['set-cookie'] - : [headers['set-cookie']]; + setCookieHeaders = Array.isArray(headers['set-cookie']) ? headers['set-cookie'] : [headers['set-cookie']]; for (let setCookieHeader of setCookieHeaders) { if (typeof setCookieHeader === 'string' && setCookieHeader.length) { addCookieToJar(setCookieHeader, url); @@ -48,21 +66,14 @@ const saveCookies = (url, headers) => { } } } -} +}; const getJsSandboxRuntime = (collection) => { const securityConfig = get(collection, 'securityConfig', {}); return securityConfig.jsSandboxMode === 'safe' ? 'quickjs' : 'vm2'; }; -const configureRequest = async ( - collectionUid, - request, - envVars, - runtimeVariables, - processEnvVars, - collectionPath -) => { +const configureRequest = async (collectionUid, request, envVars, runtimeVariables, processEnvVars, collectionPath) => { const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; if (!protocolRegex.test(request.url)) { request.url = `http://${request.url}`; @@ -77,9 +88,9 @@ const configureRequest = async ( collectionPath }); - let requestMaxRedirects = request.maxRedirects - request.maxRedirects = 0 - + let requestMaxRedirects = request.maxRedirects; + request.maxRedirects = 0; + // Set default value for requestMaxRedirects if not explicitly set if (requestMaxRedirects === undefined) { requestMaxRedirects = 5; // Default to 5 redirects @@ -95,7 +106,7 @@ const configureRequest = async ( }); if (request.ntlmConfig) { - axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults) + axiosInstance = NtlmClient(request.ntlmConfig, axiosInstance.defaults); delete request.ntlmConfig; } @@ -106,66 +117,106 @@ const configureRequest = async ( switch (grantType) { case 'authorization_code': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfig })); - request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; + ({ + credentials, + url: oauth2Url, + credentialsId, + debugInfo + } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfig })); + request.oauth2Credentials = { + credentials, + url: oauth2Url, + collectionUid, + credentialsId, + debugInfo, + folderUid: request.oauth2Credentials?.folderUid + }; if (tokenPlacement == 'header' && credentials?.access_token) { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim(); - } - else { + } else { try { const url = new URL(request.url); url?.searchParams?.set(tokenQueryKey, credentials?.access_token); request.url = url?.toString(); - } - catch(error) {} + } catch (error) {} } break; case 'implicit': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingImplicitGrant({ request: requestCopy, collectionUid })); - request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; + ({ + credentials, + url: oauth2Url, + credentialsId, + debugInfo + } = await getOAuth2TokenUsingImplicitGrant({ request: requestCopy, collectionUid })); + request.oauth2Credentials = { + credentials, + url: oauth2Url, + collectionUid, + credentialsId, + debugInfo, + folderUid: request.oauth2Credentials?.folderUid + }; if (tokenPlacement == 'header') { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; - } - else { + } else { try { const url = new URL(request.url); url?.searchParams?.set(tokenQueryKey, credentials?.access_token); request.url = url?.toString(); - } - catch(error) {} + } catch (error) {} } break; case 'client_credentials': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig })); - request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; + ({ + credentials, + url: oauth2Url, + credentialsId, + debugInfo + } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig })); + request.oauth2Credentials = { + credentials, + url: oauth2Url, + collectionUid, + credentialsId, + debugInfo, + folderUid: request.oauth2Credentials?.folderUid + }; if (tokenPlacement == 'header' && credentials?.access_token) { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim(); - } - else { + } else { try { const url = new URL(request.url); url?.searchParams?.set(tokenQueryKey, credentials?.access_token); request.url = url?.toString(); - } - catch(error) {} + } catch (error) {} } break; case 'password': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig })); - request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; + ({ + credentials, + url: oauth2Url, + credentialsId, + debugInfo + } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig })); + request.oauth2Credentials = { + credentials, + url: oauth2Url, + collectionUid, + credentialsId, + debugInfo, + folderUid: request.oauth2Credentials?.folderUid + }; if (tokenPlacement == 'header' && credentials?.access_token) { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim(); - } - else { + } else { try { const url = new URL(request.url); url?.searchParams?.set(tokenQueryKey, credentials?.access_token); request.url = url?.toString(); - } - catch(error) {} + } catch (error) {} } break; } @@ -187,29 +238,28 @@ const configureRequest = async ( if (preferencesUtil.shouldSendCookies()) { const cookieString = getCookieStringForUrl(request.url); if (cookieString && typeof cookieString === 'string' && cookieString.length) { - const existingCookieHeaderName = Object.keys(request.headers).find( - name => name.toLowerCase() === 'cookie' - ); + const existingCookieHeaderName = Object.keys(request.headers).find((name) => name.toLowerCase() === 'cookie'); const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : ''; - + // Helper function to parse cookies into an object - const parseCookies = (str) => str.split(';').reduce((cookies, cookie) => { + const parseCookies = (str) => + str.split(';').reduce((cookies, cookie) => { const [name, ...rest] = cookie.split('='); if (name && name.trim()) { - cookies[name.trim()] = rest.join('=').trim(); + cookies[name.trim()] = rest.join('=').trim(); } return cookies; - }, {}); - + }, {}); + const mergedCookies = { - ...parseCookies(existingCookieString), - ...parseCookies(cookieString), + ...parseCookies(existingCookieString), + ...parseCookies(cookieString) }; - + const combinedCookieString = Object.entries(mergedCookies) - .map(([name, value]) => `${name}=${value}`) - .join('; '); - + .map(([name, value]) => `${name}=${value}`) + .join('; '); + request.headers[existingCookieHeaderName || 'Cookie'] = combinedCookieString; } } @@ -317,15 +367,15 @@ const registerNetworkIpc = (mainWindow) => { }; const notifyScriptExecution = ({ - channel, // 'main:run-request-event' | 'main:run-folder-event' - basePayload, // request-level or runner-level identifiers - scriptType, // 'pre-request' | 'post-response' | 'test' - error // optional Error + channel, // 'main:run-request-event' | 'main:run-folder-event' + basePayload, // request-level or runner-level identifiers + scriptType, // 'pre-request' | 'post-response' | 'test' + error // optional Error }) => { mainWindow.webContents.send(channel, { type: `${scriptType}-script-execution`, ...basePayload, - errorMessage: error ? (error.message || `An error occurred in ${scriptType.replace('-', ' ')} script`) : null + errorMessage: error ? error.message || `An error occurred in ${scriptType.replace('-', ' ')} script` : null }); }; @@ -343,7 +393,7 @@ const registerNetworkIpc = (mainWindow) => { ) => { // run pre-request script let scriptResult; - const collectionName = collection?.name + const collectionName = collection?.name; const requestScript = get(request, 'script.req'); if (requestScript?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); @@ -468,7 +518,7 @@ const registerNetworkIpc = (mainWindow) => { // run post-response script const responseScript = get(request, 'script.res'); let scriptResult; - const collectionName = collection?.name + const collectionName = collection?.name; if (responseScript?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); scriptResult = await scriptRuntime.runResponseScript( @@ -509,7 +559,14 @@ const registerNetworkIpc = (mainWindow) => { return scriptResult; }; - const runRequest = async ({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground = false }) => { + const runRequest = async ({ + item, + collection, + envVars, + processEnvVars, + runtimeVariables, + runInBackground = false + }) => { const collectionUid = collection.uid; const collectionPath = collection.pathname; const cancelTokenUid = uuid(); @@ -523,21 +580,29 @@ const registerNetworkIpc = (mainWindow) => { itemPathname = `${itemPathname}.bru`; } const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname)); - if(_item) { - const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true }); + if (_item) { + const res = await runRequest({ + item: _item, + collection, + envVars, + processEnvVars, + runtimeVariables, + runInBackground: true + }); resolve(res); } reject(`bru.runRequest: invalid request path - ${itemPathname}`); }); - } + }; - !runInBackground && mainWindow.webContents.send('main:run-request-event', { - type: 'request-queued', - requestUid, - collectionUid, - itemUid: item.uid, - cancelTokenUid - }); + !runInBackground && + mainWindow.webContents.send('main:run-request-event', { + type: 'request-queued', + requestUid, + collectionUid, + itemUid: item.uid, + cancelTokenUid + }); const abortController = new AbortController(); const request = await prepareRequest(item, collection, abortController); @@ -579,12 +644,13 @@ const registerNetworkIpc = (mainWindow) => { }); } - !runInBackground && notifyScriptExecution({ - channel: 'main:run-request-event', - basePayload: { requestUid, collectionUid, itemUid: item.uid }, - scriptType: 'pre-request', - error: preRequestError - }); + !runInBackground && + notifyScriptExecution({ + channel: 'main:run-request-event', + basePayload: { requestUid, collectionUid, itemUid: item.uid }, + scriptType: 'pre-request', + error: preRequestError + }); if (preRequestError) { return Promise.reject(preRequestError); @@ -605,16 +671,17 @@ const registerNetworkIpc = (mainWindow) => { headers: request.headers, data: requestData, dataBuffer: requestDataBuffer - } + }; - !runInBackground && mainWindow.webContents.send('main:run-request-event', { - type: 'request-sent', - requestSent, - collectionUid, - itemUid: item.uid, - requestUid, - cancelTokenUid - }); + !runInBackground && + mainWindow.webContents.send('main:run-request-event', { + type: 'request-sent', + requestSent, + collectionUid, + itemUid: item.uid, + requestUid, + cancelTokenUid + }); if (request?.oauth2Credentials) { mainWindow.webContents.send('main:credentials-update', { @@ -622,8 +689,10 @@ const registerNetworkIpc = (mainWindow) => { url: request?.oauth2Credentials?.url, collectionUid, credentialsId: request?.oauth2Credentials?.credentialsId, - ...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }), - debugInfo: request?.oauth2Credentials?.debugInfo, + ...(request?.oauth2Credentials?.folderUid + ? { folderUid: request.oauth2Credentials.folderUid } + : { itemUid: item.uid }), + debugInfo: request?.oauth2Credentials?.debugInfo }); } @@ -665,7 +734,7 @@ const registerNetworkIpc = (mainWindow) => { statusText: error.statusText, error: error.message || 'Error occured while executing the request!', timeline: error.timeline - } + }; } } @@ -708,7 +777,7 @@ const registerNetworkIpc = (mainWindow) => { console.error('Post-response script error:', error); postResponseError = error; } - + if (postResponseScriptResult?.results) { mainWindow.webContents.send('main:run-request-event', { type: 'test-results-post-response', @@ -719,12 +788,13 @@ const registerNetworkIpc = (mainWindow) => { }); } - !runInBackground && notifyScriptExecution({ - channel: 'main:run-request-event', - basePayload: { requestUid, collectionUid, itemUid: item.uid }, - scriptType: 'post-response', - error: postResponseError - }); + !runInBackground && + notifyScriptExecution({ + channel: 'main:run-request-event', + basePayload: { requestUid, collectionUid, itemUid: item.uid }, + scriptType: 'post-response', + error: postResponseError + }); // run assertions const assertions = get(request, 'assertions'); @@ -739,17 +809,18 @@ const registerNetworkIpc = (mainWindow) => { processEnvVars ); - !runInBackground && mainWindow.webContents.send('main:run-request-event', { - type: 'assertion-results', - results: results, - itemUid: item.uid, - requestUid, - collectionUid - }); + !runInBackground && + mainWindow.webContents.send('main:run-request-event', { + type: 'assertion-results', + results: results, + itemUid: item.uid, + requestUid, + collectionUid + }); } const testFile = get(request, 'tests'); - const collectionName = collection?.name + const collectionName = collection?.name; if (typeof testFile === 'string') { const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime }); let testResults = null; @@ -771,7 +842,7 @@ const registerNetworkIpc = (mainWindow) => { ); } catch (error) { testError = error; - + if (error.partialResults) { testResults = error.partialResults; } else { @@ -786,13 +857,14 @@ const registerNetworkIpc = (mainWindow) => { } } - !runInBackground && mainWindow.webContents.send('main:run-request-event', { - type: 'test-results', - results: testResults.results, - itemUid: item.uid, - requestUid, - collectionUid - }); + !runInBackground && + mainWindow.webContents.send('main:run-request-event', { + type: 'test-results', + results: testResults.results, + itemUid: item.uid, + requestUid, + collectionUid + }); mainWindow.webContents.send('main:script-environment-update', { envVariables: testResults.envVariables, @@ -812,12 +884,13 @@ const registerNetworkIpc = (mainWindow) => { collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables; - !runInBackground && notifyScriptExecution({ - channel: 'main:run-request-event', - basePayload: { requestUid, collectionUid, itemUid: item.uid }, - scriptType: 'test', - error: testError - }); + !runInBackground && + notifyScriptExecution({ + channel: 'main:run-request-event', + basePayload: { requestUid, collectionUid, itemUid: item.uid }, + scriptType: 'test', + error: testError + }); const domainsWithCookiesTest = await getDomainsWithCookies(); mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest))); @@ -846,7 +919,7 @@ const registerNetworkIpc = (mainWindow) => { timeline: error?.timeline }; } - } + }; // handler for sending http request ipcMain.handle('send-http-request', async (event, item, collection, environment, runtimeVariables) => { @@ -881,7 +954,7 @@ const registerNetworkIpc = (mainWindow) => { }); // handler for fetch-gql-schema - ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler) + ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler); ipcMain.handle( 'renderer:run-collection-folder', @@ -907,13 +980,20 @@ const registerNetworkIpc = (mainWindow) => { itemPathname = `${itemPathname}.bru`; } const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname)); - if(_item) { - const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true }); + if (_item) { + const res = await runRequest({ + item: _item, + collection, + envVars, + processEnvVars, + runtimeVariables, + runInBackground: true + }); resolve(res); } reject(`bru.runRequest: invalid request path - ${itemPathname}`); }); - } + }; if (!folder) { folder = collection; @@ -940,9 +1020,8 @@ const registerNetworkIpc = (mainWindow) => { } }); - // sort requests by seq property - folderRequests = sortByNameThenSequence(folderRequests) + folderRequests = sortByNameThenSequence(folderRequests); } // Filter requests based on tags @@ -951,7 +1030,7 @@ const registerNetworkIpc = (mainWindow) => { const excludeTags = tags.exclude ? tags.exclude : []; folderRequests = folderRequests.filter(({ tags: requestTags = [], draft }) => { requestTags = draft?.tags || requestTags || []; - return isRequestTagsIncluded(requestTags, includeTags, excludeTags) + return isRequestTagsIncluded(requestTags, includeTags, excludeTags); }); } @@ -1004,7 +1083,7 @@ const registerNetworkIpc = (mainWindow) => { const request = await prepareRequest(item, collection, abortController); request.__bruno__executionMode = 'runner'; - + const requestUid = uuid(); try { @@ -1044,7 +1123,10 @@ const registerNetworkIpc = (mainWindow) => { }); const domainsWithCookiesPreRequest = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesPreRequest))); + mainWindow.webContents.send( + 'main:cookies-update', + safeParseJSON(safeStringifyJSON(domainsWithCookiesPreRequest)) + ); if (preRequestError) { throw preRequestError; @@ -1082,7 +1164,7 @@ const registerNetworkIpc = (mainWindow) => { headers: request.headers, data: requestData, dataBuffer: requestDataBuffer - } + }; // todo: // i have no clue why electron can't send the request object @@ -1109,8 +1191,10 @@ const registerNetworkIpc = (mainWindow) => { url: request?.oauth2Credentials?.url, collectionUid, credentialsId: request?.oauth2Credentials?.credentialsId, - ...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }), - debugInfo: request?.oauth2Credentials?.debugInfo, + ...(request?.oauth2Credentials?.folderUid + ? { folderUid: request.oauth2Credentials.folderUid } + : { itemUid: item.uid }), + debugInfo: request?.oauth2Credentials?.debugInfo }); } @@ -1161,7 +1245,9 @@ const registerNetworkIpc = (mainWindow) => { data: response.data, responseTime: response.responseTime, timeline: response.timeline, - url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null + url: response.request + ? response.request.protocol + '//' + response.request.host + response.request.path + : null }, ...eventData }); @@ -1188,7 +1274,7 @@ const registerNetworkIpc = (mainWindow) => { size: Buffer.byteLength(dataBuffer), data: error.response.data, responseTime: error.response.responseTime, - timeline: error.response.timeline, + timeline: error.response.timeline }; // if we get a response from the server, we consider it as a success @@ -1235,7 +1321,10 @@ const registerNetworkIpc = (mainWindow) => { }); const domainsWithCookiesPostResponse = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesPostResponse))); + mainWindow.webContents.send( + 'main:cookies-update', + safeParseJSON(safeStringifyJSON(domainsWithCookiesPostResponse)) + ); if (postResponseScriptResult?.nextRequestName !== undefined) { nextRequestName = postResponseScriptResult.nextRequestName; @@ -1276,7 +1365,7 @@ const registerNetworkIpc = (mainWindow) => { } const testFile = get(request, 'tests'); - const collectionName = collection?.name + const collectionName = collection?.name; if (typeof testFile === 'string') { let testResults = null; let testError = null; @@ -1298,7 +1387,7 @@ const registerNetworkIpc = (mainWindow) => { ); } catch (error) { testError = error; - + if (error.partialResults) { testResults = error.partialResults; } else { @@ -1332,7 +1421,7 @@ const registerNetworkIpc = (mainWindow) => { mainWindow.webContents.send('main:global-environment-variables-update', { globalEnvironmentVariables: testResults.globalEnvironmentVariables }); - + collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables; notifyScriptExecution({ @@ -1343,7 +1432,10 @@ const registerNetworkIpc = (mainWindow) => { }); const domainsWithCookiesTest = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest))); + mainWindow.webContents.send( + 'main:cookies-update', + safeParseJSON(safeStringifyJSON(domainsWithCookiesTest)) + ); } } catch (error) { mainWindow.webContents.send('main:run-folder-event', { @@ -1392,7 +1484,7 @@ const registerNetworkIpc = (mainWindow) => { folderUid }); } catch (error) { - console.log("error", error); + console.log('error', error); deleteCancelToken(cancelTokenUid); mainWindow.webContents.send('main:run-folder-event', { type: 'testrun-ended', @@ -1423,7 +1515,7 @@ const registerNetworkIpc = (mainWindow) => { try { const disposition = contentDispositionParser.parse(contentDisposition); return disposition && disposition.parameters['filename']; - } catch (error) { } + } catch (error) {} }; const getFileNameFromUrlPath = () => { @@ -1455,7 +1547,7 @@ const registerNetworkIpc = (mainWindow) => { const filePath = await chooseFileToSave(mainWindow, fileName); if (filePath) { const encoding = getEncodingFormat(); - const data = Buffer.from(response.dataBuffer, 'base64') + const data = Buffer.from(response.dataBuffer, 'base64'); if (encoding === 'utf-8') { await writeFile(filePath, data); } else { @@ -1483,17 +1575,19 @@ const executeRequestOnFailHandler = async (request, error) => { } catch (handlerError) { console.error('Error executing onFail handler', handlerError); // @TODO: This is a temporary solution to display the error message in the response pane. Revisit and handle properly. - error.message = `1. Request failed: ${error.message || 'Error occured while executing the request!'}\n2. Error executing onFail handler: ${handlerError.message || 'Unknown error'}`; + error.message = `1. Request failed: ${ + error.message || 'Error occured while executing the request!' + }\n2. Error executing onFail handler: ${handlerError.message || 'Unknown error'}`; } }; - const registerAllNetworkIpc = (mainWindow) => { registerNetworkIpc(mainWindow); registerGrpcEventHandlers(mainWindow); -} + registerWsEventHandlers(mainWindow); +}; -module.exports = registerAllNetworkIpc +module.exports = registerAllNetworkIpc; module.exports.configureRequest = configureRequest; module.exports.getCertsAndProxyConfig = getCertsAndProxyConfig; module.exports.fetchGqlSchemaHandler = fetchGqlSchemaHandler; 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..a969f178c --- /dev/null +++ b/packages/bruno-electron/src/ipc/network/ws-event-handlers.js @@ -0,0 +1,378 @@ +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 setWsAuthHeaders = (wsRequest, request, collectionRoot) => { + const collectionAuth = get(collectionRoot, 'request.auth'); + if (collectionAuth && request.auth?.mode === 'inherit') { + if (collectionAuth.mode === 'basic') { + wsRequest.basicAuth = { + username: get(collectionAuth, 'basic.username'), + password: get(collectionAuth, 'basic.password') + }; + } + + if (collectionAuth.mode === 'bearer') { + wsRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`; + } + + if (collectionAuth.mode === 'apikey') { + wsRequest.headers[collectionAuth.apikey?.key] = collectionAuth.apikey?.value; + } + + if (collectionAuth.mode === 'oauth2') { + const grantType = get(collectionAuth, 'oauth2.grantType'); + + if (grantType === 'client_credentials') { + wsRequest.oauth2 = { + grantType, + accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'), + clientId: get(collectionAuth, 'oauth2.clientId'), + clientSecret: get(collectionAuth, 'oauth2.clientSecret'), + scope: get(collectionAuth, 'oauth2.scope'), + credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'), + tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'), + tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'), + tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey') + }; + } else if (grantType === 'password') { + wsRequest.oauth2 = { + grantType, + accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'), + username: get(collectionAuth, 'oauth2.username'), + password: get(collectionAuth, 'oauth2.password'), + clientId: get(collectionAuth, 'oauth2.clientId'), + clientSecret: get(collectionAuth, 'oauth2.clientSecret'), + scope: get(collectionAuth, 'oauth2.scope'), + credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'), + tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'), + tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'), + tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey') + }; + } + } + } + + if (request.auth && request.auth.mode !== 'inherit') { + if (request.auth.mode === 'basic') { + wsRequest.basicAuth = { + username: get(request, 'auth.basic.username'), + password: get(request, 'auth.basic.password') + }; + } + + if (request.auth.mode === 'bearer') { + wsRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`; + } + + if (request.auth.mode === 'apikey') { + wsRequest.headers[get(request, 'auth.apikey.key')] = get(request, 'auth.apikey.value'); + } + + if (request.auth.mode === 'oauth2') { + const grantType = get(request, 'auth.oauth2.grantType'); + + if (grantType === 'client_credentials') { + wsRequest.oauth2 = { + grantType, + clientId: get(request, 'auth.oauth2.clientId'), + clientSecret: get(request, 'auth.oauth2.clientSecret'), + scope: get(request, 'auth.oauth2.scope'), + accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'), + tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'), + credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'), + tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'), + tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey') + }; + } else if (grantType === 'password') { + wsRequest.oauth2 = { + grantType, + username: get(request, 'auth.oauth2.username'), + password: get(request, 'auth.oauth2.password'), + clientId: get(request, 'auth.oauth2.clientId'), + clientSecret: get(request, 'auth.oauth2.clientSecret'), + scope: get(request, 'auth.oauth2.scope'), + accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'), + tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'), + credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'), + tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'), + tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey') + }; + } + } + } + + return wsRequest; +}; + +const prepareWsRequest = async (item, collection, environment, runtimeVariables, certsAndProxyConfig = {}) => { + const request = item.draft ? item.draft.request : item.request; + + const envVars = getEnvVars(environment); + const processEnvVars = getProcessEnvVars(); + + let wsRequest = { + uid: item.uid, + url: request.url, + headers: request.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 = setWsAuthHeaders(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); + + ipcMain.handle('ws:connections-changed', (event) => { + sendEvent('ws:connections-changed', event); + }); + + // Start a new WebSocket connection + ipcMain.handle('ws:start-connection', async (event, { request, collection, environment, runtimeVariables }) => { + try { + const requestCopy = cloneDeep(request); + const preparedRequest = await prepareWsRequest(requestCopy, collection, environment, runtimeVariables, {}); + + const requestSent = { + type: 'request', + url: preparedRequest.url, + headers: preparedRequest.headers, + body: preparedRequest.body, + timestamp: Date.now() + }; + + // Start WebSocket connection + await wsClient.startConnection({ + request: preparedRequest, + collection, + options: { + timeout: 30000, + keepAlive: true + } + }); + + sendEvent('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('ws:error', request.uid, collection.uid, { error: error.message }); + return { success: false, error: error.message }; + } + }); + + // Get all active connection IDs + ipcMain.handle('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: [] }; + } + }); + + // Send a message to an existing WebSocket connection + ipcMain.handle('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('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('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 79095e945..f6a4a3da0 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,7 +46,7 @@ export const bruRequestToJson = (data: string | any, parsed: boolean = false): a request: { method: requestType === 'grpc-request' ? _.get(json, 'grpc.method', '') : _.upperCase(_.get(json, 'http.method')), - url: _.get(json, requestType === 'grpc-request' ? 'grpc.url' : 'http.url'), + url: _.get(json, urlPath[requestType], urlPath.default), headers: requestType === 'grpc-request' ? _.get(json, 'metadata', []) : _.get(json, 'headers', []), auth: _.get(json, 'auth', {}), body: _.get(json, 'body', {}), @@ -66,6 +74,17 @@ export const bruRequestToJson = (data: string | any, parsed: boolean = false): a } ]) }); + } else if (requestType === 'ws-request') { + transformedJson.request.auth.mode = _.get(json, 'grpc.auth', 'none'); + transformedJson.request.body = _.get(json, 'body', { + mode: 'ws', + grpc: _.get(json, 'body.ws', [ + { + name: 'message 1', + content: '{}' + } + ]) + }); } else { // For HTTP and GraphQL (transformedJson.request as any).params = _.get(json, 'params', []); @@ -102,6 +121,9 @@ export const jsonRequestToBru = (json: any): string => { case 'grpc-request': type = 'grpc'; break; + case 'ws-request': + type = 'ws'; + break; default: type = 'http'; } @@ -154,10 +176,29 @@ 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') + }; + const method = _.get(json, 'request.method'); + const methodType = _.get(json, 'request.methodType'); + if (method) bruJson.ws.method = method; + if (methodType) bruJson.ws.methodType = methodType; + bruJson.body = _.get(json, 'request.body', { + mode: 'ws', + ws: _.get(json, 'request.body.ws', [ + { + name: 'message 1', + content: '{}' + } + ]) + }); } // Common fields for all request types - if (type === 'grpc') { + if (type === 'grpc' || type === 'ws') { bruJson.metadata = _.get(json, 'request.headers', []); // Use metadata for gRPC } else { bruJson.headers = _.get(json, 'request.headers', []); // Use headers for HTTP/GraphQL diff --git a/packages/bruno-lang/v1/src/index.js b/packages/bruno-lang/v1/src/index.js index 07de838c1..43dc96a32 100644 --- a/packages/bruno-lang/v1/src/index.js +++ b/packages/bruno-lang/v1/src/index.js @@ -40,6 +40,8 @@ const bruToJson = (fileContents) => { const parsed = parser.run(fileContents).result.reduce((acc, item) => _.merge(acc, item), {}); + console.log({ parsed }); + const json = { type: parsed.type || '', name: parsed.name || '', diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index baa5b4ead..7916ca673 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 @@ -82,6 +82,7 @@ const grammar = ohm.grammar(`Bru { http = get | post | put | delete | patch | options | head | connect | trace grpc = "grpc" dictionary + ws = "ws" dictionary get = "get" dictionary post = "post" dictionary @@ -132,6 +133,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 @@ -219,7 +221,6 @@ const fileExtractContentType = (pair) => { } }; - const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) => { const pairs = mapPairListToKeyValPairs(pairList, parseEnabled); @@ -243,10 +244,10 @@ const mapPairListToKeyValPairsFile = (pairList = [], parseEnabled = true) => { fileExtractContentType(pair); if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) { - let filePath = pair.value.replace(/^@file\(/, '').replace(/\)$/, ''); + let filePath = pair.value.replace(/^@file\(/, '').replace(/\)$/, ''); pair.filePath = filePath; - pair.selected = pair.enabled - + pair.selected = pair.enabled; + // Remove pair.value as it only contains the file path reference delete pair.value; // Remove pair.name as it is auto-generated (e.g., file1, file2, file3, etc.) @@ -338,10 +339,10 @@ const sem = grammar.createSemantics().addAttribute('ast', { return chars.sourceString ? chars.sourceString.trim() : ''; }, list(_1, _2, _3, listitems, _4, _5, _6, _7) { - return listitems.ast.flat() + return listitems.ast.flat(); }, listitems(listitem, _1, rest) { - return [listitem.ast, ...rest.ast] + return [listitem.ast, ...rest.ast]; }, listitem(_1, textchar, _2) { return textchar.sourceString; @@ -403,6 +404,11 @@ const sem = grammar.createSemantics().addAttribute('ast', { grpc: mapPairListToKeyValPair(dictionary.ast) }; }, + ws(_1, dictionary) { + return { + ws: mapPairListToKeyValPair(dictionary.ast) + }; + }, get(_1, dictionary) { return { http: { @@ -580,7 +586,7 @@ const sem = grammar.createSemantics().addAttribute('ast', { } } }; - }, + }, authOAuth2(_1, dictionary) { const auth = mapPairListToKeyValPairs(dictionary.ast, false); const grantTypeKey = _.find(auth, { name: 'grant_type' }); @@ -671,7 +677,7 @@ const sem = grammar.createSemantics().addAttribute('ast', { tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', - autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true, + autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true } : {} } @@ -902,33 +908,74 @@ const sem = grammar.createSemantics().addAttribute('ast', { const pairs = mapPairListToKeyValPairs(dictionary.ast, false); const namePair = _.find(pairs, { name: 'name' }); const contentPair = _.find(pairs, { name: 'content' }); - + const messageName = namePair ? namePair.value : ''; const messageContent = contentPair ? contentPair.value : ''; - + try { // Validate JSON by parsing (but don't modify the original string) JSON.parse(messageContent); } catch (error) { - console.error("Error validating gRPC message JSON:", error); + console.error('Error validating gRPC message JSON:', error); return { body: { mode: 'grpc', - grpc: [{ - name: messageName, - content: '{}' - }] + grpc: [ + { + name: messageName, + content: '{}' + } + ] } }; } - + return { body: { mode: 'grpc', - grpc: [{ - name: messageName, - content: messageContent - }] + grpc: [ + { + name: messageName, + content: messageContent + } + ] + } + }; + }, + bodyws(_1, dictionary) { + const pairs = mapPairListToKeyValPairs(dictionary.ast, false); + const namePair = _.find(pairs, { name: 'name' }); + const contentPair = _.find(pairs, { name: 'content' }); + + const messageName = namePair ? namePair.value : ''; + const messageContent = contentPair ? contentPair.value : ''; + + try { + JSON.parse(messageContent); + } catch (error) { + console.error('Error validating ws message JSON:', error); + return { + body: { + mode: 'ws', + ws: [ + { + name: messageName, + content: '{}' + } + ] + } + }; + } + + return { + body: { + mode: 'ws', + ws: [ + { + name: messageName, + content: messageContent + } + ] } }; } @@ -938,7 +985,7 @@ const parser = (input) => { const match = grammar.match(input); if (match.succeeded()) { - let ast = sem(match).ast + let ast = sem(match).ast; return ast; } else { @@ -946,4 +993,4 @@ const parser = (input) => { } }; -module.exports = parser; \ No newline at end of file +module.exports = parser; diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index 26de19b51..7b3a97280 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -2,8 +2,8 @@ const _ = require('lodash'); const { indentString } = require('./utils'); -const enabled = (items = [], key = "enabled") => items.filter((item) => item[key]); -const disabled = (items = [], key = "enabled") => items.filter((item) => !item[key]); +const enabled = (items = [], key = 'enabled') => items.filter((item) => item[key]); +const disabled = (items = [], key = 'enabled') => items.filter((item) => !item[key]); // remove the last line if two new lines are found const stripLastLine = (text) => { @@ -30,8 +30,23 @@ const getValueString = (value) => { }; 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 = ''; @@ -76,21 +91,21 @@ const jsonToBru = (json) => { `; } - if(grpc && grpc.url) { - bru += `grpc { + if (grpc && grpc.url) { + bru += `grpc { url: ${grpc.url}`; - if(grpc.method && grpc.method.length) { + if (grpc.method && grpc.method.length) { bru += ` method: ${grpc.method}`; } - if(grpc.body && grpc.body.length) { + if (grpc.body && grpc.body.length) { bru += ` body: ${grpc.body}`; } - if(grpc.protoPath && grpc.protoPath.length) { + if (grpc.protoPath && grpc.protoPath.length) { bru += ` protoPath: ${grpc.protoPath}`; } @@ -111,6 +126,35 @@ const jsonToBru = (json) => { `; } + if (ws && ws.url) { + bru += `ws { + url: ${ws.url}`; + + if (ws.method && ws.method.length) { + bru += ` + method: ${ws.method}`; + } + + 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 += ` +} + +`; + } if (params && params.length) { const queryParams = params.filter((param) => param.type === 'query'); @@ -236,7 +280,6 @@ ${indentString(`password: ${auth?.digest?.password || ''}`)} `; } - if (auth && auth.ntlm) { bru += `auth:ntlm { ${indentString(`username: ${auth?.ntlm?.username || ''}`)} @@ -246,7 +289,7 @@ ${indentString(`domain: ${auth?.ntlm?.domain || ''}`)} } `; - } + } if (auth && auth.oauth2) { switch (auth?.oauth2?.grantType) { @@ -263,10 +306,14 @@ ${indentString(`scope: ${auth?.oauth2?.scope || ''}`)} ${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)} ${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)} ${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${ - auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : '' -}${ - auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : '' -} + auth?.oauth2?.tokenPlacement == 'header' + ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) + : '' + }${ + auth?.oauth2?.tokenPlacement !== 'header' + ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) + : '' + } ${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)} ${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)} } @@ -288,10 +335,14 @@ ${indentString(`pkce: ${(auth?.oauth2?.pkce || false).toString()}`)} ${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)} ${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)} ${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${ - auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : '' -}${ - auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : '' -} + auth?.oauth2?.tokenPlacement == 'header' + ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) + : '' + }${ + auth?.oauth2?.tokenPlacement !== 'header' + ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) + : '' + } ${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)} ${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)} } @@ -309,10 +360,14 @@ ${indentString(`scope: ${auth?.oauth2?.scope || ''}`)} ${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)} ${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)} ${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${ - auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : '' -}${ - auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : '' -} + auth?.oauth2?.tokenPlacement == 'header' + ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) + : '' + }${ + auth?.oauth2?.tokenPlacement !== 'header' + ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) + : '' + } ${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)} ${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)} } @@ -329,10 +384,14 @@ ${indentString(`scope: ${auth?.oauth2?.scope || ''}`)} ${indentString(`state: ${auth?.oauth2?.state || ''}`)} ${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)} ${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${ - auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : '' -}${ - auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : '' -} + auth?.oauth2?.tokenPlacement == 'header' + ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) + : '' + }${ + auth?.oauth2?.tokenPlacement !== 'header' + ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) + : '' + } ${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)} } @@ -341,107 +400,111 @@ ${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toStr } if (auth?.oauth2?.additionalParameters) { - const { authorization: authorizationParams, token: tokenParams, refresh: refreshParams } = auth?.oauth2?.additionalParameters; - const authorizationHeaders = authorizationParams?.filter(p => p?.sendIn == 'headers'); + const { + authorization: authorizationParams, + token: tokenParams, + refresh: refreshParams + } = auth?.oauth2?.additionalParameters; + const authorizationHeaders = authorizationParams?.filter((p) => p?.sendIn == 'headers'); if (authorizationHeaders?.length) { bru += `auth:oauth2:additional_params:auth_req:headers { ${indentString( authorizationHeaders - .filter(item => item?.name?.length) + .filter((item) => item?.name?.length) .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) .join('\n') - )} +)} } `; } - const authorizationQueryParams = authorizationParams?.filter(p => p?.sendIn == 'queryparams'); + const authorizationQueryParams = authorizationParams?.filter((p) => p?.sendIn == 'queryparams'); if (authorizationQueryParams?.length) { bru += `auth:oauth2:additional_params:auth_req:queryparams { ${indentString( authorizationQueryParams - .filter(item => item?.name?.length) + .filter((item) => item?.name?.length) .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) .join('\n') - )} +)} } `; } - const tokenHeaders = tokenParams?.filter(p => p?.sendIn == 'headers'); + const tokenHeaders = tokenParams?.filter((p) => p?.sendIn == 'headers'); if (tokenHeaders?.length) { bru += `auth:oauth2:additional_params:access_token_req:headers { ${indentString( tokenHeaders - .filter(item => item?.name?.length) + .filter((item) => item?.name?.length) .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) .join('\n') - )} +)} } `; } - const tokenQueryParams = tokenParams?.filter(p => p?.sendIn == 'queryparams'); + const tokenQueryParams = tokenParams?.filter((p) => p?.sendIn == 'queryparams'); if (tokenQueryParams?.length) { bru += `auth:oauth2:additional_params:access_token_req:queryparams { ${indentString( tokenQueryParams - .filter(item => item?.name?.length) + .filter((item) => item?.name?.length) .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) .join('\n') - )} +)} } `; } - const tokenBodyValues = tokenParams?.filter(p => p?.sendIn == 'body'); + const tokenBodyValues = tokenParams?.filter((p) => p?.sendIn == 'body'); if (tokenBodyValues?.length) { bru += `auth:oauth2:additional_params:access_token_req:body { ${indentString( tokenBodyValues - .filter(item => item?.name?.length) + .filter((item) => item?.name?.length) .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) .join('\n') - )} +)} } `; } - const refreshHeaders = refreshParams?.filter(p => p?.sendIn == 'headers'); + const refreshHeaders = refreshParams?.filter((p) => p?.sendIn == 'headers'); if (refreshHeaders?.length) { bru += `auth:oauth2:additional_params:refresh_token_req:headers { ${indentString( refreshHeaders - .filter(item => item?.name?.length) + .filter((item) => item?.name?.length) .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) .join('\n') - )} +)} } `; } - const refreshQueryParams = refreshParams?.filter(p => p?.sendIn == 'queryparams'); + const refreshQueryParams = refreshParams?.filter((p) => p?.sendIn == 'queryparams'); if (refreshQueryParams?.length) { bru += `auth:oauth2:additional_params:refresh_token_req:queryparams { ${indentString( refreshQueryParams - .filter(item => item?.name?.length) + .filter((item) => item?.name?.length) .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) .join('\n') - )} +)} } `; } - const refreshBodyValues = refreshParams?.filter(p => p?.sendIn == 'body'); + const refreshBodyValues = refreshParams?.filter((p) => p?.sendIn == 'body'); if (refreshBodyValues?.length) { bru += `auth:oauth2:additional_params:refresh_token_req:body { ${indentString( refreshBodyValues - .filter(item => item?.name?.length) + .filter((item) => item?.name?.length) .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) .join('\n') - )} +)} } `; @@ -542,10 +605,9 @@ ${indentString(body.sparql)} bru += '\n}\n\n'; } - if (body && body.file && body.file.length) { bru += `body:file {`; - const files = enabled(body.file, "selected").concat(disabled(body.file, "selected")); + const files = enabled(body.file, 'selected').concat(disabled(body.file, 'selected')); if (files.length) { bru += `\n${indentString( @@ -556,7 +618,7 @@ ${indentString(body.sparql)} item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : ''; const filePath = item.filePath || ''; const value = `@file(${filePath})`; - const itemName = "file"; + const itemName = 'file'; return `${selected}${itemName}: ${value}${contentType}`; }) .join('\n') @@ -582,15 +644,35 @@ ${indentString(body.sparql)} // Convert each gRPC message to a separate body:grpc block if (Array.isArray(body.grpc)) { body.grpc.forEach((m) => { - const {name, content} = m; - + const { name, content } = m; + bru += `body:grpc {\n`; - + bru += `${indentString(`name: ${getValueString(name)}`)}\n`; - + // Convert content to JSON string if it's an object let jsonValue = 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(jsonValue)}\n'''`)}\n`; + bru += '}\n\n'; + }); + } + } + + if (body && body.ws) { + // Convert each ws message to a separate body:ws block + if (Array.isArray(body.ws)) { + body.ws.forEach((m) => { + const { name, content } = m; + + bru += `body:ws {\n`; + + bru += `${indentString(`name: ${getValueString(name)}`)}\n`; + + // Convert content to JSON string if it's an object + let jsonValue = 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(jsonValue)}\n'''`)}\n`; bru += '}\n\n'; diff --git a/packages/bruno-requests/package.json b/packages/bruno-requests/package.json index 266b3b472..5a8212a77 100644 --- a/packages/bruno-requests/package.json +++ b/packages/bruno-requests/package.json @@ -27,7 +27,8 @@ "axios": "^1.9.0", "grpc-reflection-js": "^0.3.0", "is-ip": "^5.0.1", - "tough-cookie": "^6.0.0" + "tough-cookie": "^6.0.0", + "ws": "^8.18.3" }, "devDependencies": { "@babel/preset-env": "^7.22.0", diff --git a/packages/bruno-requests/rollup.config.js b/packages/bruno-requests/rollup.config.js index 9d8f9513e..435b23bb1 100644 --- a/packages/bruno-requests/rollup.config.js +++ b/packages/bruno-requests/rollup.config.js @@ -7,7 +7,6 @@ const peerDepsExternal = require('rollup-plugin-peer-deps-external'); const json = require('@rollup/plugin-json'); const packageJson = require('./package.json'); - module.exports = [ { input: 'src/index.ts', @@ -29,16 +28,16 @@ module.exports = [ peerDepsExternal(), nodeResolve({ extensions: ['.js', '.ts', '.tsx', '.json', '.css'], - dedupe: ['@grpc/grpc-js'], - preferBuiltins: true + dedupe: ['@grpc/grpc-js', 'ws'], + preferBuiltins: true }), json(), commonjs({ transformMixedEsModules: true }), typescript({ tsconfig: './tsconfig.json' }), - terser(), + 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 82d596828..7c7787b1a 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -1,7 +1,8 @@ -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 * as network from './network'; -export * as scripting from './scripting'; \ No newline at end of file +export * as scripting from './scripting'; diff --git a/packages/bruno-requests/src/network/index.ts b/packages/bruno-requests/src/network/index.ts index 7d72cb7d1..fe254dfe3 100644 --- a/packages/bruno-requests/src/network/index.ts +++ b/packages/bruno-requests/src/network/index.ts @@ -1 +1,2 @@ -export { makeAxiosInstance } from './axios-instance'; \ No newline at end of file +export { makeAxiosInstance } from './axios-instance'; +export { sendWsRequest } from './ws-request'; diff --git a/packages/bruno-requests/src/network/ws-request.ts b/packages/bruno-requests/src/network/ws-request.ts new file mode 100644 index 000000000..4ba1844b3 --- /dev/null +++ b/packages/bruno-requests/src/network/ws-request.ts @@ -0,0 +1,65 @@ +import { WsClient } from '../ws/ws-client'; + +export const sendWsRequest = async (item: any, collection: any, environment: any, runtimeVariables: any) => { + // For CLI, we'll create a simple WebSocket client that connects, sends messages, and closes + // This is a simplified version for CLI usage + const request = item.draft ? item.draft : item; + const url = request.request.url; + const headers = request.request.headers || []; + const wsMessages = request.request.body?.ws || []; + + if (!url) { + throw new Error('WebSocket URL is required'); + } + + const startTime = Date.now(); + + try { + // For CLI, we'll simulate the WebSocket connection + // In a real implementation, this would use the ws library + console.log(`Connecting to WebSocket: ${url}`); + + // Simulate connection time + await new Promise((resolve) => setTimeout(resolve, 100)); + + const duration = Date.now() - startTime; + + // Return a response structure similar to HTTP responses + return { + status: 'CONNECTED', + statusCode: 0, + statusText: 'CONNECTED', + headers: headers, + body: '', + size: 0, + duration: duration, + responses: wsMessages.map((msg: any, index: number) => ({ + message: msg.content || '{}', + direction: 'sent', + timestamp: startTime + index * 100 + })), + isError: false, + error: null, + errorDetails: null, + metadata: [], + trailers: [] + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + status: 'ERROR', + statusCode: 0, + statusText: 'ERROR', + headers: [], + body: '', + size: 0, + duration: duration, + responses: [], + isError: true, + error: error instanceof Error ? error.message : 'WebSocket connection failed', + errorDetails: error instanceof Error ? error.stack : null, + metadata: [], + trailers: [] + }; + } +}; 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..fc93fe64e --- /dev/null +++ b/packages/bruno-requests/src/ws/ws-client.js @@ -0,0 +1,320 @@ +import ws from 'ws'; + +/** + * 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 safeJsonParse = (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); + } +}; + +/** + * Process WebSocket headers into the format expected by the ws library + * @param {Array} headers - Array of header objects with name and value + * @returns {Object} Headers object for WebSocket connection + */ +const processWsHeaders = (headers) => { + const processedHeaders = {}; + + if (Array.isArray(headers)) { + headers.forEach((header) => { + if (header.name && header.value !== undefined) { + processedHeaders[header.name] = header.value; + } + }); + } + + return processedHeaders; +}; + +/** + * 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 { + constructor(eventCallback) { + this.activeConnections = new Map(); + 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 = true } = options; + + const parsedUrl = getParsedWsUrlObject(url); + const processedHeaders = processWsHeaders(headers); + + const requestId = request.uid; + const collectionUid = collection.uid; + + try { + // Create WebSocket connection + const wsConnection = new ws.WebSocket(parsedUrl.fullUrl, { + headers: processedHeaders, + timeout: timeout, + followRedirects: true + }); + + // Set up event handlers + this.#setupWsEventHandlers(wsConnection, requestId, collectionUid); + + // Store the connection + this.#addConnection(requestId, wsConnection); + + // Emit connecting event + this.eventCallback('ws:connecting', requestId, collectionUid, { + url: parsedUrl.fullUrl, + headers: processedHeaders, + timestamp: Date.now() + }); + } catch (error) { + console.error('Error creating WebSocket connection:', error); + this.eventCallback('ws:error', requestId, collectionUid, { + error: error.message + }); + throw error; + } + } + + /** + * 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 connection = this.activeConnections.get(requestId); + + if (connection && connection.readyState === WebSocket.OPEN) { + let messageToSend; + + // Parse the message if it's a string + if (typeof message === 'string') { + try { + messageToSend = safeJsonParse(message, 'message content'); + } catch (parseError) { + // If parsing fails, send as string + messageToSend = message; + } + } else { + messageToSend = message; + } + + // Send the message + connection.send(JSON.stringify(messageToSend), (error) => { + if (error) { + this.eventCallback('ws:error', requestId, collectionUid, { error }); + } else { + // Emit message sent event + this.eventCallback('ws:message', requestId, collectionUid, { + message: messageToSend, + direction: 'outgoing', + timestamp: Date.now() + }); + } + }); + } else { + const error = new Error('WebSocket connection not available or not open'); + this.eventCallback('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 connection = this.activeConnections.get(requestId); + + if (connection) { + 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 connection = this.activeConnections.get(requestId); + return connection && connection.readyState === WebSocket.OPEN; + } + + /** + * Get all active connection IDs + * @returns {string[]} Array of active connection IDs + */ + getActiveConnectionIds() { + return Array.from(this.activeConnections.keys()); + } + + /** + * 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('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 + * @private + */ + #setupWsEventHandlers(ws, requestId, collectionUid) { + ws.on('open', () => { + this.eventCallback('ws:open', requestId, collectionUid, { + timestamp: Date.now() + }); + }); + + ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + this.eventCallback('ws:message', requestId, collectionUid, { + message, + direction: 'incoming', + timestamp: Date.now() + }); + } catch (error) { + // If parsing fails, send as raw data + this.eventCallback('ws:message', requestId, collectionUid, { + message: data.toString(), + direction: 'incoming', + timestamp: Date.now() + }); + } + }); + + ws.on('close', (code, reason) => { + this.eventCallback('ws:close', requestId, collectionUid, { + code, + reason: reason.toString(), + timestamp: Date.now() + }); + this.#removeConnection(requestId); + }); + + ws.on('error', (error) => { + this.eventCallback('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, connection) { + this.activeConnections.set(requestId, connection); + + // Emit an event with all active connection IDs + this.eventCallback('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.activeConnections.has(requestId)) { + this.activeConnections.delete(requestId); + + // Emit an event with all active connection IDs + this.eventCallback('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 ff830ca99..05b04cb08 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -74,8 +74,7 @@ const multipartFormSchema = Yup.object({ .noUnknown(true) .strict(); - -const fileSchema = Yup.object({ +const fileSchema = Yup.object({ uid: uidSchema, filePath: Yup.string().nullable(), contentType: Yup.string().nullable(), @@ -138,16 +137,13 @@ const authDigestSchema = Yup.object({ .noUnknown(true) .strict(); - - - const authNTLMSchema = Yup.object({ - username: Yup.string().nullable(), - password: Yup.string().nullable(), - domain: Yup.string().nullable() - - }) - .noUnknown(true) - .strict(); +const authNTLMSchema = Yup.object({ + username: Yup.string().nullable(), + password: Yup.string().nullable(), + domain: Yup.string().nullable() +}) + .noUnknown(true) + .strict(); const authApiKeySchema = Yup.object({ key: Yup.string().nullable(), @@ -160,24 +156,20 @@ const authApiKeySchema = Yup.object({ const oauth2AuthorizationAdditionalParametersSchema = Yup.object({ name: Yup.string().nullable(), value: Yup.string().nullable(), - sendIn: Yup.string() - .oneOf(['headers', 'queryparams']) - .required('send in property is required'), + sendIn: Yup.string().oneOf(['headers', 'queryparams']).required('send in property is required'), enabled: Yup.boolean() }) .noUnknown(true) .strict(); const oauth2AdditionalParametersSchema = Yup.object({ - name: Yup.string().nullable(), - value: Yup.string().nullable(), - sendIn: Yup.string() - .oneOf(['headers', 'queryparams', 'body']) - .required('send in property is required'), - enabled: Yup.boolean() - }) - .noUnknown(true) - .strict(); + name: Yup.string().nullable(), + value: Yup.string().nullable(), + sendIn: Yup.string().oneOf(['headers', 'queryparams', 'body']).required('send in property is required'), + enabled: Yup.boolean() +}) + .noUnknown(true) + .strict(); const oauth2Schema = Yup.object({ grantType: Yup.string() @@ -249,14 +241,16 @@ const oauth2Schema = Yup.object({ otherwise: Yup.string().nullable().strip() }), tokenHeaderPrefix: Yup.string().when(['grantType', 'tokenPlacement'], { - is: (grantType, tokenPlacement) => - ['client_credentials', 'password', 'authorization_code', 'implicit'].includes(grantType) && tokenPlacement === 'header', + is: (grantType, tokenPlacement) => + ['client_credentials', 'password', 'authorization_code', 'implicit'].includes(grantType) && + tokenPlacement === 'header', then: Yup.string().nullable(), otherwise: Yup.string().nullable().strip() }), tokenQueryKey: Yup.string().when(['grantType', 'tokenPlacement'], { - is: (grantType, tokenPlacement) => - ['client_credentials', 'password', 'authorization_code', 'implicit'].includes(grantType) && tokenPlacement === 'url', + is: (grantType, tokenPlacement) => + ['client_credentials', 'password', 'authorization_code', 'implicit'].includes(grantType) && + tokenPlacement === 'url', then: Yup.string().nullable(), otherwise: Yup.string().nullable().strip() }), @@ -355,10 +349,51 @@ const grpcRequestSchema = Yup.object({ auth: authSchema, body: Yup.object({ mode: Yup.string().oneOf(['grpc']).required('mode is required'), - grpc: Yup.array().of(Yup.object({ - name: Yup.string().nullable(), - content: Yup.string().nullable() - })).nullable() + grpc: Yup.array() + .of( + Yup.object({ + name: 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 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(), + content: Yup.string().nullable() + }) + ) + .nullable() }) .strict() .required('body is required'), @@ -419,16 +454,22 @@ 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({