diff --git a/eslint.config.js b/eslint.config.js index 671b6f363..cdeb10f29 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -60,9 +60,9 @@ module.exports = runESMImports().then(() => defineConfig([ '@stylistic/function-call-spacing': ['error', 'never'], '@stylistic/multiline-ternary': ['off'], '@stylistic/padding-line-between-statements': ['off'], - '@stylistic/jsx-one-expression-per-line': ['off'], '@stylistic/semi-style': ['error', 'last'], '@stylistic/max-len': ['off'], + '@stylistic/jsx-one-expression-per-line': ['off'] }, }, { diff --git a/package-lock.json b/package-lock.json index 52fd51301..5218ddf05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1610,7 +1610,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz", "integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -1628,7 +1628,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -1645,7 +1645,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1663,7 +1663,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@babel/helper-member-expression-to-functions": { @@ -1734,7 +1734,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -1809,7 +1809,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.25.9", @@ -1852,7 +1852,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -1869,7 +1869,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -1885,7 +1885,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -1901,7 +1901,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -1919,7 +1919,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -1954,7 +1954,7 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2053,7 +2053,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2069,7 +2069,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2251,7 +2251,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", @@ -2268,7 +2268,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2284,7 +2284,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2302,7 +2302,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", @@ -2320,7 +2320,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2336,7 +2336,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2368,7 +2368,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", @@ -2385,7 +2385,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -2406,7 +2406,7 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -2416,7 +2416,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2433,7 +2433,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2449,7 +2449,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2466,7 +2466,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2482,7 +2482,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2499,7 +2499,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2515,7 +2515,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2531,7 +2531,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2563,7 +2563,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2580,7 +2580,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", @@ -2598,7 +2598,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2614,7 +2614,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2630,7 +2630,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2646,7 +2646,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2662,7 +2662,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -2695,7 +2695,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -2714,7 +2714,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -2731,7 +2731,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2748,7 +2748,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2779,7 +2779,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2795,7 +2795,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", @@ -2813,7 +2813,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2830,7 +2830,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2862,7 +2862,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2894,7 +2894,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -2912,7 +2912,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2997,7 +2997,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -3014,7 +3014,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3031,7 +3031,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3047,7 +3047,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3063,7 +3063,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -3080,7 +3080,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3096,7 +3096,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3112,7 +3112,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3147,7 +3147,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3163,7 +3163,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3180,7 +3180,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3197,7 +3197,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3214,7 +3214,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.26.0", @@ -3315,7 +3315,7 @@ "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -7985,6 +7985,7 @@ "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", @@ -8004,6 +8005,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -8016,6 +8018,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1", @@ -8030,6 +8033,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, "license": "MIT" }, "node_modules/@testing-library/jest-dom": { @@ -8110,6 +8114,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, "license": "MIT" }, "node_modules/@types/babel__core": { @@ -8364,6 +8369,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, "license": "MIT" }, "node_modules/@types/lodash": { @@ -8395,6 +8401,7 @@ "version": "12.2.3", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/linkify-it": "*", @@ -8405,6 +8412,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -9452,6 +9460,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "dequal": "^2.0.3" @@ -9819,7 +9828,7 @@ "version": "0.4.12", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.22.6", @@ -9834,7 +9843,7 @@ "version": "0.10.6", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.2", @@ -9848,7 +9857,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.3" @@ -10008,6 +10017,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -11598,7 +11617,7 @@ "version": "3.39.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "browserslist": "^4.24.2" @@ -12648,6 +12667,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, "license": "MIT" }, "node_modules/dom-converter": { @@ -13652,7 +13672,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -14367,6 +14387,13 @@ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", "license": "MIT" }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -15430,6 +15457,18 @@ "he": "bin/he" } }, + "node_modules/hexy": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.3.5.tgz", + "integrity": "sha512-UCP7TIZPXz5kxYJnNOym+9xaenxCLor/JyhKieo8y8/bJWunGh9xbhy3YrgYJUQ87WwfXGm05X330DszOfINZw==", + "license": "MIT", + "bin": { + "hexy": "bin/hexy_cmd.js" + }, + "engines": { + "node": ">=10.4" + } + }, "node_modules/hey-listen": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", @@ -16248,7 +16287,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -18499,7 +18538,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/lodash.flow": { @@ -18637,11 +18676,34 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, "license": "MIT", "bin": { "lz-string": "bin/bin.js" } }, + "node_modules/macos-export-certificate-and-key": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/macos-export-certificate-and-key/-/macos-export-certificate-and-key-1.2.4.tgz", + "integrity": "sha512-y5QZEywlBNKd+EhPZ1Hz1FmDbbeQKtuVHJaTlawdl7vXw9bi/4tJB2xSMwX4sMVcddy3gbQ8K0IqXAi2TpDo2g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^4.3.0" + } + }, + "node_modules/macos-export-certificate-and-key/node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "license": "MIT", + "optional": true + }, "node_modules/magic-string": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", @@ -20081,7 +20143,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -22433,14 +22495,14 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/regenerate-unicode-properties": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -22459,7 +22521,7 @@ "version": "0.15.2", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.4" @@ -22469,7 +22531,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2", @@ -22487,14 +22549,14 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/regjsparser": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.0.2" @@ -22507,7 +22569,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -22663,7 +22725,7 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -24875,7 +24937,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -24955,6 +25017,16 @@ "jscat": "bundle.js" } }, + "node_modules/system-ca": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/system-ca/-/system-ca-2.0.1.tgz", + "integrity": "sha512-9ZDV9yl8ph6Op67wDGPr4LykX86usE9x3le+XZSHfVMiiVJ5IRgmCWjLgxyz35ju9H3GDIJJZm4ogAeIfN5cQQ==", + "license": "Apache-2.0", + "optionalDependencies": { + "macos-export-certificate-and-key": "^1.2.0", + "win-export-certificate-and-key": "^2.1.0" + } + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -25742,7 +25814,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -25803,7 +25875,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -25813,7 +25885,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", @@ -25827,7 +25899,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -25837,7 +25909,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -26431,6 +26503,28 @@ "dev": true, "license": "MIT" }, + "node_modules/win-export-certificate-and-key": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/win-export-certificate-and-key/-/win-export-certificate-and-key-2.1.0.tgz", + "integrity": "sha512-WeMLa/2uNZcS/HWGKU2G1Gzeh3vHpV/UFvwLhJLKxPHYFAbubxxVcJbqmPXaqySWK1Ymymh16zKK5WYIJ3zgzA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^3.1.0" + } + }, + "node_modules/win-export-certificate-and-key/node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT", + "optional": true + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -30168,6 +30262,7 @@ "form-data": "^4.0.0", "fs-extra": "^10.1.0", "graphql": "^16.6.0", + "hexy": "^0.3.5", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "iconv-lite": "^0.6.3", @@ -31979,7 +32074,9 @@ "axios": "^1.9.0", "grpc-reflection-js": "^0.3.0", "is-ip": "^5.0.1", - "tough-cookie": "^6.0.0" + "system-ca": "^2.0.1", + "tough-cookie": "^6.0.0", + "ws": "^8.18.3" }, "devDependencies": { "@babel/preset-env": "^7.22.0", @@ -32038,6 +32135,27 @@ "node": ">=16" } }, + "packages/bruno-requests/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/bruno-schema": { "name": "@usebruno/schema", "version": "0.7.0", @@ -32082,7 +32200,8 @@ "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "ws": "^8.18.3" } }, "packages/bruno-tests/node_modules/axios": { @@ -32202,6 +32321,27 @@ ], "license": "MIT" }, + "packages/bruno-tests/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/bruno-toml": { "name": "@usebruno/toml", "version": "0.1.0", diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 4a8f0591a..d5609395d 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -45,7 +45,7 @@ export default class CodeEditor extends React.Component { const editor = (this.editor = CodeMirror(this._node, { value: this.props.value || '', lineNumbers: true, - lineWrapping: true, + lineWrapping: this.props.enableLineWrapping ?? true, tabSize: TAB_SIZE, mode: this.props.mode || 'application/ld+json', brunoVarInfo: { @@ -237,6 +237,14 @@ export default class CodeEditor extends React.Component { this.editor.scrollTo(null, this.props.initialScroll); } + if (this.props.enableLineWrapping !== prevProps.enableLineWrapping) { + this.editor.setOption('lineWrapping', this.props.enableLineWrapping); + } + + if (this.props.mode !== prevProps.mode) { + this.editor.setOption('mode', this.props.mode); + } + this.ignoreChangeEvent = false; } diff --git a/packages/bruno-app/src/components/CollectionSettings/Protobuf/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Protobuf/StyledWrapper.js index 21adc36e1..8ba4be3dc 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Protobuf/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Protobuf/StyledWrapper.js @@ -10,4 +10,4 @@ const StyledWrapper = styled.div` } `; -export default StyledWrapper; \ No newline at end of file +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/StyledWrapper.js new file mode 100644 index 000000000..e6a766672 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/StyledWrapper.js @@ -0,0 +1,34 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + div.tabs { + div.tab { + padding: 6px 0px; + border: none; + border-bottom: solid 2px transparent; + margin-right: 1.25rem; + color: var(--color-tab-inactive); + cursor: pointer; + + &:focus, + &:active, + &:focus-within, + &:focus-visible, + &:target { + outline: none !important; + box-shadow: none !important; + } + + &.active { + color: ${(props) => props.theme.tabs.active.color} !important; + border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important; + } + + .content-indicator { + color: ${(props) => props.theme.text} + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/StyledWrapper.js new file mode 100644 index 000000000..f76b0d9a4 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/StyledWrapper.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + .inherit-mode-text { + color: ${(props) => props.theme.colors.text.yellow}; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/WSAuthMode/index.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/WSAuthMode/index.js new file mode 100644 index 000000000..0c4202ef1 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/WSAuthMode/index.js @@ -0,0 +1,85 @@ +import React, { useRef, forwardRef } from 'react'; +import get from 'lodash/get'; +import { IconCaretDown } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; +import { useDispatch } from 'react-redux'; +import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections'; +import { humanizeRequestAuthMode } from 'utils/collections'; +import StyledWrapper from '../../../Auth/AuthMode/StyledWrapper'; + +const AUTH_MODES = [ + { + name: 'Basic Auth', + mode: 'basic' + }, + { + name: 'Bearer Token', + mode: 'bearer' + }, + { + name: 'API Key', + mode: 'apikey' + }, + { + name: 'OAuth2', + mode: 'oauth2' + }, + { + name: 'Inherit', + mode: 'inherit' + }, + { + name: 'No Auth', + mode: 'none' + } +]; + +const WSAuthMode = ({ item, collection }) => { + const dispatch = useDispatch(); + const dropdownTippyRef = useRef(); + const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); + const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode'); + + const Icon = forwardRef((props, ref) => { + return ( +
+ {humanizeRequestAuthMode(authMode)} + {' '} + +
+ ); + }); + + const onModeChange = (value) => { + dispatch(updateRequestAuthMode({ + itemUid: item.uid, + collectionUid: collection.uid, + mode: value + })); + }; + + const onClickHandler = (mode) => { + dropdownTippyRef?.current?.hide(); + onModeChange(mode); + }; + + return ( + +
+ } placement="bottom-end"> + {AUTH_MODES.map((authMode) => ( +
onClickHandler(authMode.mode)} + > + {authMode.name} +
+ ))} +
+
+
+ ); +}; + +export default WSAuthMode; diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js new file mode 100644 index 000000000..59d1bae32 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js @@ -0,0 +1,129 @@ +import React, { useEffect } from 'react'; +import get from 'lodash/get'; +import { useDispatch } from 'react-redux'; +import WSAuthMode from './WSAuthMode'; +import BearerAuth from '../../Auth/BearerAuth'; +import BasicAuth from '../../Auth/BasicAuth'; +import ApiKeyAuth from '../../Auth/ApiKeyAuth'; +import StyledWrapper from './StyledWrapper'; +import { humanizeRequestAuthMode } from 'utils/collections'; +import { getTreePathFromCollectionToItem } from 'utils/collections/index'; +import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; + +const supportedAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit']; + +const WSAuth = ({ item, collection }) => { + const dispatch = useDispatch(); + const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode'); + const requestTreePath = getTreePathFromCollectionToItem(collection, item); + + const request = item.draft + ? get(item, 'draft.request', {}) + : get(item, 'request', {}); + + const save = () => { + return saveRequest(item.uid, collection.uid); + }; + + // Reset to 'none' if current auth mode is not supported + useEffect(() => { + if (authMode && !supportedAuthModes.includes(authMode)) { + dispatch(updateRequestAuthMode({ + itemUid: item.uid, + collectionUid: collection.uid, + mode: 'none' + })); + } + }, [authMode, collection.uid, dispatch, item.uid]); + + const getEffectiveAuthSource = () => { + if (authMode !== 'inherit') return null; + + const collectionAuth = get(collection, 'root.request.auth'); + let effectiveSource = { + type: 'collection', + name: 'Collection', + auth: collectionAuth + }; + + // Check folders in reverse to find the closest auth configuration + for (let i of [...requestTreePath].reverse()) { + if (i.type === 'folder') { + const folderAuth = get(i, 'root.request.auth'); + if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') { + effectiveSource = { + type: 'folder', + name: i.name, + auth: folderAuth + }; + break; + } + } + } + + return effectiveSource; + }; + + const getAuthView = () => { + switch (authMode) { + case 'basic': { + return ; + } + case 'bearer': { + return ; + } + case 'apikey': { + return ; + } + case 'oauth2': { + return ( + <> +
+
+ OAuth 2 not yet supported by WebSockets. Using no auth instead. +
+
+ + ); + } + case 'inherit': { + const source = getEffectiveAuthSource(); + + // Only show inherited auth if it's one of the supported types + if (source && supportedAuthModes.includes(source.auth?.mode)) { + return ( + <> +
+
Auth inherited from {source.name}:
+
{humanizeRequestAuthMode(source.auth?.mode)}
+
+ + ); + } else { + return ( + <> +
+
Inherited auth not supported by WebSockets. Using no auth instead.
+
+ + ); + } + } + default: { + return null; + } + } + }; + + return ( + +
+ +
+ {getAuthView()} +
+ ); +}; + +export default WSAuth; diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js new file mode 100644 index 000000000..068b1e7e4 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js @@ -0,0 +1,120 @@ +import classnames from 'classnames'; +import Documentation from 'components/Documentation/index'; +import RequestHeaders from 'components/RequestPane/RequestHeaders'; +import StatusDot from 'components/StatusDot/index'; +import { find } from 'lodash'; +import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import HeightBoundContainer from 'ui/HeightBoundContainer'; +import { getPropertyFromDraftOrRequest } from 'utils/collections/index'; +import WsBody from '../WsBody/index'; +import StyledWrapper from './StyledWrapper'; +import WSAuth from './WSAuth'; +import WSSettingsPane from '../WSSettingsPane/index'; + +const WSRequestPane = ({ item, collection, handleRun }) => { + const dispatch = useDispatch(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + + const selectTab = (tab) => { + dispatch(updateRequestPaneTab({ + uid: item.uid, + requestPaneTab: tab + })); + }; + + const getTabPanel = (tab) => { + switch (tab) { + case 'body': { + return ( + + ); + } + case 'headers': { + return ; + } + case 'settings': { + return ; + } + case 'auth': { + return ; + } + case 'docs': { + return ; + } + default: { + return
404 | Not found
; + } + } + }; + + if (!activeTabUid) { + return
Something went wrong
; + } + + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) { + return
An error occurred!
; + } + + const getTabClassname = (tabName) => { + return classnames(`tab select-none ${tabName}`, { + active: tabName === focusedTab.requestPaneTab + }); + }; + + const isMultipleContentTab = ['script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab); + const headers = getPropertyFromDraftOrRequest(item, 'request.headers'); + const docs = getPropertyFromDraftOrRequest(item, 'request.docs'); + const auth = getPropertyFromDraftOrRequest(item, 'request.auth'); + + const activeHeadersLength = headers.filter((header) => header.enabled).length; + + useEffect(() => { + if (!focusedTab?.requestPaneTab) { + selectTab('body'); + } + }, []); + + return ( + +
+
selectTab('body')}> + Message +
+
selectTab('headers')}> + Headers + {activeHeadersLength > 0 && {activeHeadersLength}} +
+
selectTab('auth')}> + Auth + {auth.mode !== 'none' && } +
+
selectTab('settings')}> + Settings +
+
selectTab('docs')}> + Docs + {docs && docs.length > 0 && } +
+
+
+ {getTabPanel(focusedTab.requestPaneTab)} +
+
+ ); +}; + +export default WSRequestPane; diff --git a/packages/bruno-app/src/components/RequestPane/WSSettingsPane/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WSSettingsPane/StyledWrapper.js new file mode 100644 index 000000000..e04c6d331 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSSettingsPane/StyledWrapper.js @@ -0,0 +1,29 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .single-line-editor-wrapper { + padding: 0.15rem 0.4rem; + border-radius: 3px; + border: solid 1px ${(props) => props.theme.input.border}; + background-color: ${(props) => props.theme.input.bg}; + + &.error{ + border-color: ${(props) => props.theme.colors.text.danger}; + } + } + + .tooltip-mod { + font-size: 11px !important; + width: 150px !important; + + & ul { + padding-left: 4px; + } + + & ul > li { + list-style: circle; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WSSettingsPane/index.js b/packages/bruno-app/src/components/RequestPane/WSSettingsPane/index.js new file mode 100644 index 000000000..96a576940 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/WSSettingsPane/index.js @@ -0,0 +1,135 @@ +import cn from 'classnames'; +import InfoTip from 'components/InfoTip/index'; +import SingleLineEditor from 'components/SingleLineEditor'; +import ToolHint from 'components/ToolHint/index'; +import { useFormik } from 'formik'; +import get from 'lodash/get'; +import { updateItemSettings } from 'providers/ReduxStore/slices/collections'; +import { useTheme } from 'providers/Theme'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import * as Yup from 'yup'; +import StyledWrapper from './StyledWrapper'; + +/** + * @param {string} propertyKey + * @param {{draft?:Record}} item + * @returns + */ +const getPropertyFromDraftOrRequest = (propertyKey, item) => + item.draft ? get(item, `draft.${propertyKey}`, {}) : get(item, propertyKey, {}); + +const ERRORS = { + timeout: { + invalid: `Timeout needs to be a valid number` + }, + keepAliveInterval: { + invalid: `Timeout needs to be a valid number` + } +}; + +const WSSettingsPane = ({ item, collection }) => { + const dispatch = useDispatch(); + const { storedTheme } = useTheme(); + const requestPreferences = useSelector((state) => state.app.preferences.request); + + const { timeout: _connectionTimeout, keepAliveInterval = 0 } = getPropertyFromDraftOrRequest('settings', item); + + const connectionTimeout = _connectionTimeout ?? requestPreferences.timeout; + + const updateSetting = (key, value) => { + dispatch(updateItemSettings({ + collectionUid: collection.uid, + itemUid: item.uid, + settings: { + [key]: value + } + })); + }; + + const formErrors = { + timeout: isNaN(Number(connectionTimeout)) && ERRORS.timeout.invalid, + keepAliveInterval: isNaN(Number(keepAliveInterval)) && ERRORS.keepAliveInterval.invalid + }; + + return ( + +
+
+ + +

+ Timeout in milliseconds +

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

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

+

0 (zero) = off

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

No WebSocket messages available

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