This commit is contained in:
Siddharth Gelera (reaper)
2025-08-27 03:08:34 +05:30
committed by Siddharth Gelera
parent 5e9cec38f0
commit 4077ce8eb2
59 changed files with 4426 additions and 795 deletions

151
package-lock.json generated
View File

@@ -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",

View File

@@ -2,17 +2,17 @@ import React from 'react';
// UNARY - Single request, single response (Blue)
export const IconGrpcUnary = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
{/* Request arrow (top) - right */}
<path d="M3 8h18" stroke="#3B82F6" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#3B82F6" strokeWidth={strokeWidth} />
@@ -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 = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
{/* Request arrow (top) - right with double heads */}
<path d="M3 8h18" stroke="#8B5CF6" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#8B5CF6" strokeWidth={strokeWidth} />
@@ -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 = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
{/* Request arrow (top) - right */}
<path d="M3 8h18" stroke="#10B981" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#10B981" strokeWidth={strokeWidth} />
@@ -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 = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
{/* Request arrow (top) - right with double heads */}
<path d="M3 8h18" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#F97316" strokeWidth={strokeWidth} />
@@ -90,4 +90,30 @@ export const IconGrpcBidiStreaming = ({ size = 18, strokeWidth = 1.5, className
<path d="M6 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M10 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
</svg>
);
);
// WEBSOCKET - Bidirectional communication (Amber/Orange)
// TODO: reaper move to it's own folder
export const IconWebSocket = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
{/* Bidirectional arrows representing WebSocket communication */}
<path d="M3 8h18" stroke="#f59e0b" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#f59e0b" strokeWidth={strokeWidth} />
<path d="M14 5l3 3l-3 3" stroke="#f59e0b" strokeWidth={strokeWidth} />
<path d="M21 16h-18" stroke="#f59e0b" strokeWidth={strokeWidth} />
<path d="M6 13l-3 3l3 3" stroke="#f59e0b" strokeWidth={strokeWidth} />
<path d="M10 13l-3 3l3 3" stroke="#f59e0b" strokeWidth={strokeWidth} />
{/* Connection indicator dots */}
<circle cx="12" cy="12" r="2" fill="#f59e0b" />
</svg>
);

View File

@@ -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'
}
];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: value
})
);
};
const onClickHandler = (mode) => {
dropdownTippyRef?.current?.hide();
onModeChange(mode);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
{authModes.map((authMode) => (
<div
key={authMode.mode}
className="dropdown-item"
onClick={() => onClickHandler(authMode.mode)}
>
{authMode.name}
</div>
))}
</Dropdown>
</div>
</StyledWrapper>
);
};
export default WSAuthMode;

View File

@@ -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 <BasicAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'bearer': {
return <BearerAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
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 (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
</>
);
} else {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Inherited auth not supported by gRPC. Using no auth instead.</div>
</div>
</>
);
}
}
default: {
return null;
}
}
};
return (
<StyledWrapper className="w-full mt-1 overflow-y-scroll">
<div className="flex flex-grow justify-start items-center">
<WSAuthMode item={item} collection={collection} />
</div>
{getAuthView()}
</StyledWrapper>
);
};
export default WSAuth;

View File

@@ -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 (
<WsBody
item={item}
collection={collection}
hideModeSelector={true}
hidePrettifyButton={true}
handleRun={handleRun}
/>
);
}
case 'headers': {
return <RequestHeaders item={item} collection={collection} addHeaderText="Add Metadata" />;
}
case 'settings': {
return <div className="flex w-full mt-10 justify-center text-neutral-400">TBD</div>;
}
case 'auth': {
return <WSAuth item={item} collection={collection} />;
}
case 'docs': {
return <Documentation item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
}
};
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
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 (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Message
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Metadata
{activeHeadersLength > 0 && <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
{auth.mode !== 'none' && <StatusDot type="default" />}
</div>
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
Settings
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
{docs && docs.length > 0 && <StatusDot type="default" />}
</div>
</div>
<section
className={classnames('flex w-full flex-1 h-full', {
'mt-2': !isMultipleContentTab
})}
>
<HeightBoundContainer>{getTabPanel(focusedTab.requestPaneTab)}</HeightBoundContainer>
</section>
</StyledWrapper>
);
};
export default WSRequestPane;

View File

@@ -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;

View File

@@ -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 (
<div
className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}
>
<div
className="ws-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed ? (
<IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
) : (
<IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
)}
<span className="font-medium text-sm">{`Message ${canClientStream ? index + 1 : ''}`}</span>
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<ToolHint text="Format JSON with proper indentation and spacing" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
<ToolHint text="Generate a new sample message based on schema" toolhintId={`regenerate-msg-${index}`}>
<button
onClick={onRegenerateMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconRefresh size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
{canClientStream && (
<ToolHint
text={isConnectionActive ? 'Send WS message' : 'Connection not active'}
toolhintId={`send-msg-${index}`}
>
<button
onClick={onSend}
disabled={!isConnectionActive}
className={`p-1 rounded ${
isConnectionActive ? 'hover:bg-zinc-200 dark:hover:bg-zinc-600' : 'opacity-50 cursor-not-allowed'
} transition-colors`}
>
<IconSend
size={16}
strokeWidth={1.5}
className={`${
isConnectionActive ? 'text-zinc-700 dark:text-zinc-300' : 'text-zinc-400 dark:text-zinc-500'
}`}
/>
</button>
</ToolHint>
)}
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
)}
</div>
</div>
{!isCollapsed && (
<div className={`flex ${body.ws.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'h-80'} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode="application/ld+json"
enableVariableHighlighting={true}
/>
</div>
)}
</div>
);
};
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 (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div className="flex flex-col items-center justify-center py-8">
<p className="text-zinc-500 dark:text-zinc-400 mb-4">No WebSocket messages available</p>
<ToolHint text="Add the first message to your WebSocket request" toolhintId="add-first-msg">
<button
onClick={addNewMessage}
className="flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-sm text-neutral-700 dark:text-neutral-300">Add First Message</span>
</button>
</ToolHint>
</div>
</StyledWrapper>
);
}
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div
ref={messagesContainerRef}
id="grpc-messages-container"
className={`flex-1 ${body.ws.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'overflow-y-auto'} ${
canClientSendMultipleMessages && 'pb-16'
}`}
>
{body.ws
.filter((_, index) => canClientSendMultipleMessages || index === 0)
.map((message, index) => (
<SingleWSMessage
key={index}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
isCollapsed={collapsedMessages.includes(index)}
onToggleCollapse={() => toggleMessageCollapse(index)}
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
/>
))}
</div>
{canClientSendMultipleMessages && (
<div className="add-message-btn-container">
<ToolHint text="Add a new WebSocket message to the request" toolhintId="add-msg-fixed">
<button
onClick={addNewMessage}
className="add-message-btn flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors shadow-md"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-sm text-neutral-700 dark:text-neutral-300">Add Message</span>
</button>
</ToolHint>
</div>
)}
</StyledWrapper>
);
};
export default WSBody;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper>
<div className="flex items-center h-full">
<div className="flex items-center input-container flex-1 w-full input-container h-full relative">
<IconWebSocket size={20} strokeWidth={2} className="ml-2 mr-4" />
<SingleLineEditor
value={url}
onSave={(finalValue) => onSave(finalValue)}
onChange={onUrlChange}
placeholder="ws://localhost:8080 or wss://example.com"
className="w-full"
theme={displayedTheme}
/>
<div className="flex items-center h-full mr-2 cursor-pointer">
<div
className="infotip mr-3"
onClick={(e) => {
e.stopPropagation();
if (!item.draft) return;
onSave();
}}
>
<IconDeviceFloppy
color={item.draft ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotip-text text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isConnectionActive && (
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
<div className="infotip" onClick={handleCloseConnection}>
<IconPlugConnectedX
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className="cursor-pointer"
/>
<span className="infotip-text text-xs">Close Connection</span>
</div>
</div>
)}
{!isConnectionActive && (
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
<div className="infotip" onClick={handleConnect}>
<IconPlugConnected
className="cursor-pointer"
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={22}
/>
<span className="infotip-text text-xs">Close Connection</span>
</div>
</div>
)}
<div className="cursor-pointer" onClick={handleRunClick}>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
</div>
</div>
</div>
</div>
{isConnectionActive && <div className="connection-status-strip"></div>}
</StyledWrapper>
);
};
export default WsQueryUrl;

View File

@@ -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 <RunnerResults collection={collection} />;
@@ -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) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
@@ -248,26 +258,40 @@ const RequestTabPanel = () => {
);
};
// TODO: reaper, improve selection of panes
return (
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''}`}>
<StyledWrapper
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
isVerticalLayout ? 'vertical-layout' : ''
}`}
>
<div className="pt-4 pb-3 px-4">
{isGrpcRequest ? (
<GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />
) : isWsRequest ? (
<WsQueryUrl item={item} collection={collection} handleRun={handleRun} />
) : (
<QueryUrl item={item} collection={collection} handleRun={handleRun} />
)}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section
ref={mainSectionRef}
className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}
>
<section className="request-pane">
<div
className="px-4 h-full"
style={isVerticalLayout ? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
} : {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
}}
style={
isVerticalLayout
? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
}
: {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
}
}
>
{item.type === 'graphql-request' ? (
<GraphQLRequestPane
@@ -279,12 +303,12 @@ const RequestTabPanel = () => {
/>
) : null}
{item.type === 'http-request' ? (
<HttpRequestPane item={item} collection={collection} />
) : null}
{item.type === 'http-request' ? <HttpRequestPane item={item} collection={collection} /> : null}
{isGrpcRequest ? (
<GrpcRequestPane item={item} collection={collection} handleRun={handleRun} />
) : isWsRequest ? (
<WSRequestPane item={item} collection={collection} handleRun={handleRun} />
) : null}
</div>
</section>
@@ -295,18 +319,11 @@ const RequestTabPanel = () => {
<section className="response-pane flex-grow overflow-x-auto">
{item.type === 'grpc-request' ? (
<GrpcResponsePane
item={item}
collection={collection}
response={item.response}
/>
<GrpcResponsePane item={item} collection={collection} response={item.response} />
) : item.type === 'ws-request' ? (
<WSResponsePane item={item} collection={collection} response={item.response} />
) : (
<ResponsePane
item={item}
collection={collection}
response={item.response}
/>
<ResponsePane item={item} collection={collection} response={item.response} />
)}
</section>
</section>

View File

@@ -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 (
<StyledWrapper
className="pb-4 w-full flex flex-grow flex-col"
>
<StyledWrapper className="pb-4 w-full flex flex-grow flex-col">
{/* Timeline container with scrollbar */}
<div
className="timeline-container"
>
<div className="timeline-container">
{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 (
<div key={index} className="timeline-event mb-2">
<GrpcTimelineItem
timestamp={eventTimestamp}
timestamp={eventTimestamp}
request={request}
response={response}
eventType={eventType}
@@ -91,7 +89,7 @@ const Timeline = ({ collection, item }) => {
</div>
);
}
// Regular HTTP request
return (
<div key={index} className="timeline-event mb-2">
@@ -119,7 +117,7 @@ const Timeline = ({ collection, item }) => {
<div className="mt-2">
{debugInfo && debugInfo.length > 0 ? (
debugInfo.map((data, idx) => (
<div className='ml-4' key={idx}>
<div className="ml-4" key={idx}>
<TimelineItem
timestamp={timestamp}
request={data?.request}
@@ -137,7 +135,7 @@ const Timeline = ({ collection, item }) => {
</div>
);
}
return null;
})}
</div>
@@ -145,4 +143,4 @@ const Timeline = ({ collection, item }) => {
);
};
export default Timeline;
export default Timeline;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 <div className="p-4 text-gray-500">No messages yet.</div>;
}
return (
<StyledWrapper className="ws-messages-list flex flex-col gap-2 mt-4">
{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 (
<div
key={idx}
className={classnames('ws-message flex flex-col rounded border p-2', {
'ws-incoming': isIncoming,
'ws-outgoing': !isIncoming
})}
>
<div className="flex items-center justify-between mb-1">
<span
className={classnames(
'font-semibold flex items-center gap-1',
isIncoming ? 'text-blue-700' : 'text-green-700'
)}
>
{isIncoming ? <IconArrowDown size={18} /> : <IconArrowUp size={18} />}
{isIncoming ? 'RCVD' : 'SENT'}
</span>
{msg.timestamp && (
<span className="text-xs text-gray-400">{new Date(msg.timestamp).toLocaleTimeString()}</span>
)}
</div>
<pre className="whitespace-pre-wrap break-all text-sm">{content}</pre>
</div>
);
})}
</StyledWrapper>
);
};
export default WSMessagesList;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper className="w-full h-full relative flex flex-col">
<div className="text-gray-500 dark:text-gray-400 p-4">No messages received</div>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full h-full relative flex flex-col mt-2">
{hasError && showErrorMessage && <WSError error={errorMessage} onClose={() => setShowErrorMessage(false)} />}
{hasResponses && (
<div className={`overflow-y-auto ${responsesList.length === 1 ? 'flex-1' : ''}`}>
{responsesList.length === 1 ? (
// Single message - render directly without accordion
<div className="h-full">
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
value={formatJSON(reversedResponsesList[0])}
mode="application/json"
readOnly={true}
/>
</div>
) : (
// Multiple messages - use accordion
<Accordion defaultIndex={0}>
{reversedResponsesList.map((response, index) => {
// Calculate the original response number (for display purposes)
const originalIndex = responsesList.length - index - 1;
return (
<Accordion.Item key={originalIndex} index={index}>
<Accordion.Header index={index} style={{ padding: '8px 12px', minHeight: '40px' }}>
<div className="flex justify-between w-full">
<div className="font-medium">
Response {originalIndex + 1} {index === 0 ? '(Latest)' : ''}
</div>
</div>
</Accordion.Header>
<Accordion.Content index={index} style={{ padding: '0px' }}>
<div className="h-60">
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
value={formatJSON(response)}
mode="application/json"
readOnly={true}
/>
</div>
</Accordion.Content>
</Accordion.Item>
);
})}
</Accordion>
)}
</div>
)}
{hasError && !hasResponses && !showErrorMessage && (
<div className="text-gray-500 dark:text-gray-400 p-4">
No messages received. A server error occurred but has been dismissed.
</div>
)}
</StyledWrapper>
);
};
export default WSQueryResult;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper className="pb-4 w-full">
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
</tr>
</thead>
<tbody>
{metadataArray && metadataArray.length ? (
metadataArray.map((metadata, index) => (
<tr key={index}>
<td className="key">{metadata.name}</td>
<td className="value">{metadata.value}</td>
</tr>
))
) : (
<tr>
<td colSpan="2" className="text-center py-4 text-gray-500">
No metadata received
</td>
</tr>
)}
</tbody>
</table>
</StyledWrapper>
);
};
export default WSResponseHeaders;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper className={getTabClassname(status)}>
{Number.isInteger(status) ? <div className="mr-1">{status}</div> : null}
{statusText && <div>{statusText}</div>}
</StyledWrapper>
);
};
export default WSStatusCode;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper className="mt-4 mb-2">
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="error-title">WebSocket Server Error</div>
<div className="error-message">{typeof error === 'string' ? error : JSON.stringify(error, null, 2)}</div>
</div>
<div className="close-button flex-shrink-0 cursor-pointer" onClick={onClose}>
<IconX size={16} strokeWidth={1.5} />
</div>
</div>
</StyledWrapper>
);
};
export default WSError;

View File

@@ -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 <WSMessagesList messages={response.responses || []} />;
}
case 'headers': {
return <WSResponseHeaders metadata={response.metadata} />;
}
case 'timeline': {
return <Timeline collection={collection} item={item} />;
}
default: {
return <div>404 | Not found</div>;
}
}
};
if (isLoading && !item.response) {
return (
<StyledWrapper className="flex flex-col h-full relative">
<Overlay item={item} collection={collection} />
</StyledWrapper>
);
}
if (!item.response && !requestTimeline?.length) {
return (
<StyledWrapper className="flex h-full relative">
<Placeholder />
</StyledWrapper>
);
}
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.responsePaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
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 (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center pl-3 pr-4 tabs" role="tablist">
{tabConfig.map((tab) => (
<Tab
key={tab.name}
name={tab.name}
label={tab.label}
isActive={focusedTab.responsePaneTab === tab.name}
onClick={selectTab}
count={tab.count}
/>
))}
{!isLoading ? (
<div className="flex flex-grow justify-end items-center">
{focusedTab?.responsePaneTab === 'timeline' ? (
<>
<ResponseLayoutToggle />
<ClearTimeline item={item} collection={collection} />
</>
) : item?.response ? (
<>
<ResponseLayoutToggle />
<ResponseClear item={item} collection={collection} />
<WSStatusCode
status={response.statusCode}
text={response.statusText}
details={response.statusDescription}
/>
<ResponseTime duration={response.duration} />
</>
) : null}
</div>
) : null}
</div>
<section
className={`flex flex-col flex-grow pl-3 pr-4 h-0 ${focusedTab.responsePaneTab === 'response' ? '' : 'mt-4'}`}
>
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
<Timeline collection={collection} item={item} />
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>
)}
</section>
</StyledWrapper>
);
};
export default WSResponsePane;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper>
<div className={getClassname(item.request.method)}>
<div className={className}>
<span className="uppercase">
{isGrpc ? 'grpc' : item.request.method.length > 5 ? item.request.method.substring(0, 3) : item.request.method}
{methodText}
</span>
</div>
</StyledWrapper>

View File

@@ -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
</label>
<div className="flex items-center mt-2">
<input
id="http-request"
className="cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="http-request"
checked={formik.values.requestType === 'http-request'}
/>
<label htmlFor="http-request" className="ml-1 cursor-pointer select-none">
HTTP
</label>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<input
type="radio"
id="http-request"
name="requestType"
value="http-request"
checked={formik.values.requestType === 'http-request'}
onChange={formik.handleChange}
/>
<label htmlFor="http-request" className="ml-1 cursor-pointer select-none">
HTTP Request
</label>
</div>
<input
id="graphql-request"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={(event) => {
formik.setFieldValue('requestMethod', 'POST');
formik.handleChange(event);
}}
value="graphql-request"
checked={formik.values.requestType === 'graphql-request'}
/>
<label htmlFor="graphql-request" className="ml-1 cursor-pointer select-none">
GraphQL
</label>
<div className="flex items-center gap-2">
<input
type="radio"
id="graphql-request"
name="requestType"
value="graphql-request"
checked={formik.values.requestType === 'graphql-request'}
onChange={formik.handleChange}
/>
<label htmlFor="graphql-request" className="ml-1 cursor-pointer select-none">
GraphQL Request
</label>
</div>
{isGrpcEnabled && (
<>
<div className="flex items-center gap-2">
<input
id="grpc-request"
className="ml-4 cursor-pointer"
type="radio"
id="grpc-request"
name="requestType"
onChange={(event) => {
formik.setFieldValue('requestMethod', 'POST');
formik.handleChange(event);
}}
value="grpc-request"
checked={formik.values.requestType === 'grpc-request'}
onChange={formik.handleChange}
/>
<label htmlFor="grpc-request" className="ml-1 cursor-pointer select-none">
gRPC
gRPC Request
</label>
</>
</div>
)}
<input
id="from-curl"
className="cursor-pointer ml-auto"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="from-curl"
checked={formik.values.requestType === 'from-curl'}
/>
{isWsEnabled && (
<div className="flex items-center gap-2">
<input
type="radio"
id="ws-request"
name="requestType"
value="ws-request"
checked={formik.values.requestType === 'ws-request'}
onChange={formik.handleChange}
/>
<label htmlFor="ws-request" className="ml-1 cursor-pointer select-none">
WebSocket Request
</label>
</div>
)}
<label htmlFor="from-curl" className="ml-1 cursor-pointer select-none">
From cURL
</label>
<div className="flex items-center gap-2">
<input
type="radio"
id="from-curl"
name="requestType"
value="from-curl"
checked={formik.values.requestType === 'from-curl'}
onChange={formik.handleChange}
/>
<label htmlFor="from-curl" className="ml-1 cursor-pointer select-none">
From cURL
</label>
</div>
</div>
</div>
<div className="mt-4">
@@ -462,7 +500,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
URL
</label>
<div className="flex items-center mt-2 ">
{formik.values.requestType !== 'grpc-request' ? (
{!['grpc-request', 'ws-request'].includes(formik.values.requestType) ? (
<div className="flex items-center h-full method-selector-container">
<HttpMethodSelector
method={formik.values.requestMethod}

View File

@@ -12,6 +12,8 @@ import 'codemirror/theme/material.css';
import 'codemirror/theme/monokai.css';
import 'codemirror/addon/scroll/simplescrollbars.css';
import Devtools from 'components/Devtools';
import useGrpcEventListeners from 'utils/network/grpc-event-listeners';
import useWsEventListeners from 'utils/network/ws-event-listeners';
require('codemirror/mode/javascript/javascript');
require('codemirror/mode/xml/xml');
@@ -52,38 +54,42 @@ export default function Main() {
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
const mainSectionRef = useRef(null);
// Initialize event listeners
useGrpcEventListeners();
useWsEventListeners();
const className = classnames({
'is-dragging': isDragging
});
return (
// <ErrorCapture>
<div className="flex flex-col h-screen max-h-screen overflow-hidden">
<div
ref={mainSectionRef}
className="flex-1 min-h-0 flex"
style={{
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
}}
>
<StyledWrapper className={className} style={{ height: '100%', zIndex: 1 }}>
<Sidebar />
<section className="flex flex-grow flex-col overflow-hidden">
{showHomePage ? (
<Welcome />
) : (
<>
<RequestTabs />
<RequestTabPanel key={activeTabUid} />
</>
)}
</section>
</StyledWrapper>
</div>
<Devtools mainSectionRef={mainSectionRef} />
<StatusBar />
<div className="flex flex-col h-screen max-h-screen overflow-hidden">
<div
ref={mainSectionRef}
className="flex-1 min-h-0 flex"
style={{
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
}}
>
<StyledWrapper className={className} style={{ height: '100%', zIndex: 1 }}>
<Sidebar />
<section className="flex flex-grow flex-col overflow-hidden">
{showHomePage ? (
<Welcome />
) : (
<>
<RequestTabs />
<RequestTabPanel key={activeTabUid} />
</>
)}
</section>
</StyledWrapper>
</div>
<Devtools mainSectionRef={mainSectionRef} />
<StatusBar />
</div>
// </ErrorCapture>
);
}

View File

@@ -25,7 +25,8 @@ const initialState = {
codeFont: 'default'
},
beta: {
grpc: false
grpc: false,
websocket: false
}
},
generateCode: {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
export default tabsSlice.reducer;

View File

@@ -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',

View File

@@ -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)'

View File

@@ -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'
});
/**

View File

@@ -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) {

View File

@@ -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);
};
};

View File

@@ -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<Object>} - 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<Object>} - 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<boolean>} - 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);
});
};

View File

@@ -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;

View File

@@ -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' });
}
};
};

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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'));
}

View File

@@ -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;

View File

@@ -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
};

View File

@@ -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<typeof requestType, string> = {
'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

View File

@@ -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 || '',

View File

@@ -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;
module.exports = parser;

View File

@@ -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';

View File

@@ -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",

View File

@@ -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']
}
];

View File

@@ -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';
export * as scripting from './scripting';

View File

@@ -1 +1,2 @@
export { makeAxiosInstance } from './axios-instance';
export { makeAxiosInstance } from './axios-instance';
export { sendWsRequest } from './ws-request';

View File

@@ -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: []
};
}
};

View File

@@ -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 };

View File

@@ -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({