From 589c90e214a5976d7e3953efc80b42627666c8fb Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Mon, 20 May 2024 19:08:28 +0400 Subject: [PATCH] feat(repeater): replace rmq with web-sockets closes #196 --- package-lock.json | 360 +++++++++++++++-- package.json | 6 + packages/core/src/logger/Logger.ts | 8 +- .../src/api/DefaultRepeatersManager.spec.ts | 32 +- .../src/api/DefaultRepeatersManager.ts | 20 +- packages/repeater/src/api/RepeatersManager.ts | 2 - .../src/api/commands/GetRepeaterRequest.ts | 20 - packages/repeater/src/api/commands/index.ts | 1 - .../repeater/src/bus/DefaultRepeaterBus.ts | 157 ++++++++ .../src/bus/DefaultRepeaterBusFactory.ts | 33 ++ .../src/bus/DefaultRepeaterCommandHub.ts | 25 ++ .../repeater/src/bus/DefaultRepeaterServer.ts | 378 ++++++++++++++++++ packages/repeater/src/bus/RepeaterBus.ts | 6 + .../repeater/src/bus/RepeaterBusFactory.ts | 7 + .../repeater/src/bus/RepeaterCommandHub.ts | 7 + packages/repeater/src/bus/RepeaterServer.ts | 132 ++++++ packages/repeater/src/bus/index.ts | 7 + packages/repeater/src/lib/Repeater.spec.ts | 207 ++-------- packages/repeater/src/lib/Repeater.ts | 114 +----- .../repeater/src/lib/RepeaterFactory.spec.ts | 163 +++----- packages/repeater/src/lib/RepeaterFactory.ts | 41 +- packages/repeater/src/register.ts | 11 + .../repeater/src/request-runner/Request.ts | 94 ++++- .../repeater/src/request-runner/Response.ts | 10 +- .../protocols/HttpRequestRunner.ts | 5 +- 25 files changed, 1302 insertions(+), 544 deletions(-) delete mode 100644 packages/repeater/src/api/commands/GetRepeaterRequest.ts create mode 100644 packages/repeater/src/bus/DefaultRepeaterBus.ts create mode 100644 packages/repeater/src/bus/DefaultRepeaterBusFactory.ts create mode 100644 packages/repeater/src/bus/DefaultRepeaterCommandHub.ts create mode 100644 packages/repeater/src/bus/DefaultRepeaterServer.ts create mode 100644 packages/repeater/src/bus/RepeaterBus.ts create mode 100644 packages/repeater/src/bus/RepeaterBusFactory.ts create mode 100644 packages/repeater/src/bus/RepeaterCommandHub.ts create mode 100644 packages/repeater/src/bus/RepeaterServer.ts create mode 100644 packages/repeater/src/bus/index.ts diff --git a/package-lock.json b/package-lock.json index 1ac8d75a..469dc98f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,14 +15,20 @@ "@har-sdk/core": "^1.4.3", "amqp-connection-manager": "^4.1.13", "amqplib": "^0.10.3", + "arch": "^3.0.0", "axios": "^0.26.1", "axios-rate-limit": "^1.3.0", "chalk": "^4.1.2", "ci-info": "^3.3.0", "content-type": "^1.0.4", + "find-up": "^5.0.0", "form-data": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.4", "reflect-metadata": "^0.1.13", "semver": "^7.5.2", + "socket.io-client": "^4.7.5", + "socket.io-msgpack-parser": "^3.0.2", "socks-proxy-agent": "^6.2.0-beta.0", "tslib": "~2.3.1", "tsyringe": "^4.6.0", @@ -2287,6 +2293,19 @@ "node": ">= 6" } }, + "node_modules/@semantic-release/github/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@semantic-release/npm": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-9.0.1.tgz", @@ -2356,6 +2375,11 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -3249,6 +3273,25 @@ "node": ">= 8" } }, + "node_modules/arch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-3.0.0.tgz", + "integrity": "sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -4055,6 +4098,14 @@ "dot-prop": "^5.1.0" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4754,6 +4805,26 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", @@ -5705,7 +5776,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -6360,30 +6430,49 @@ "dev": true }, "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" } }, "node_modules/https-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", - "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", - "dev": true, + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", "dependencies": { - "agent-base": "6", + "agent-base": "^7.0.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" } }, "node_modules/human-signals": { @@ -7820,6 +7909,33 @@ "node": ">= 6" } }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/jsdom/node_modules/ws": { "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", @@ -8304,7 +8420,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -8924,6 +9039,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/notepack.io": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", + "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==" + }, "node_modules/npm": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/npm/-/npm-8.12.0.tgz", @@ -11711,7 +11831,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -11726,7 +11845,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -13220,6 +13338,41 @@ "node": ">=6" } }, + "node_modules/socket.io-client": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", + "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-msgpack-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/socket.io-msgpack-parser/-/socket.io-msgpack-parser-3.0.2.tgz", + "integrity": "sha512-1e76bJ1PCKi9H+JiYk+S29PBJvknHjQWM7Mtj0hjF2KxDA6b6rQxv3rTsnwBoz/haZOhlCDIMQvPATbqYeuMxg==", + "dependencies": { + "component-emitter": "~1.3.0", + "notepack.io": "~2.2.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socks": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz", @@ -14754,9 +14907,9 @@ } }, "node_modules/ws": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", - "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", "engines": { "node": ">=10.0.0" }, @@ -14785,6 +14938,14 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -14889,7 +15050,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "engines": { "node": ">=10" }, @@ -16726,6 +16886,16 @@ "agent-base": "6", "debug": "4" } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } } } }, @@ -16786,6 +16956,11 @@ "@sinonjs/commons": "^1.7.0" } }, + "@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -17516,6 +17691,11 @@ "picomatch": "^2.0.4" } }, + "arch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-3.0.0.tgz", + "integrity": "sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==" + }, "arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -18116,6 +18296,11 @@ "dot-prop": "^5.1.0" } }, + "component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -18661,6 +18846,23 @@ "once": "^1.4.0" } }, + "engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==" + }, "enhanced-resolve": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", @@ -19384,7 +19586,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "requires": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -19870,24 +20071,41 @@ "dev": true }, "http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "requires": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "dependencies": { + "agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "requires": { + "debug": "^4.3.4" + } + } } }, "https-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", - "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", - "dev": true, + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", "requires": { - "agent-base": "6", + "agent-base": "^7.0.2", "debug": "4" + }, + "dependencies": { + "agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "requires": { + "debug": "^4.3.4" + } + } } }, "human-signals": { @@ -20940,6 +21158,27 @@ "mime-types": "^2.1.12" } }, + "http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "requires": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, "ws": { "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", @@ -21277,7 +21516,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "requires": { "p-locate": "^5.0.0" } @@ -21747,6 +21985,11 @@ "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "dev": true }, + "notepack.io": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", + "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==" + }, "npm": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/npm/-/npm-8.12.0.tgz", @@ -23707,7 +23950,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "requires": { "yocto-queue": "^0.1.0" } @@ -23716,7 +23958,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "requires": { "p-limit": "^3.0.2" } @@ -24820,6 +25061,35 @@ } } }, + "socket.io-client": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", + "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + } + }, + "socket.io-msgpack-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/socket.io-msgpack-parser/-/socket.io-msgpack-parser-3.0.2.tgz", + "integrity": "sha512-1e76bJ1PCKi9H+JiYk+S29PBJvknHjQWM7Mtj0hjF2KxDA6b6rQxv3rTsnwBoz/haZOhlCDIMQvPATbqYeuMxg==", + "requires": { + "component-emitter": "~1.3.0", + "notepack.io": "~2.2.0" + } + }, + "socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + } + }, "socks": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz", @@ -26014,9 +26284,9 @@ } }, "ws": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", - "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", "requires": {} }, "xml-name-validator": { @@ -26031,6 +26301,11 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -26109,8 +26384,7 @@ "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" } } } diff --git a/package.json b/package.json index 51e2dfbf..4f16f37e 100644 --- a/package.json +++ b/package.json @@ -78,14 +78,20 @@ "@har-sdk/core": "^1.4.3", "amqp-connection-manager": "^4.1.13", "amqplib": "^0.10.3", + "arch": "^3.0.0", "axios": "^0.26.1", "axios-rate-limit": "^1.3.0", "chalk": "^4.1.2", "ci-info": "^3.3.0", "content-type": "^1.0.4", + "find-up": "^5.0.0", "form-data": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.4", "reflect-metadata": "^0.1.13", "semver": "^7.5.2", + "socket.io-client": "^4.7.5", + "socket.io-msgpack-parser": "^3.0.2", "socks-proxy-agent": "^6.2.0-beta.0", "tslib": "~2.3.1", "tsyringe": "^4.6.0", diff --git a/packages/core/src/logger/Logger.ts b/packages/core/src/logger/Logger.ts index cf0fb13b..af9349eb 100644 --- a/packages/core/src/logger/Logger.ts +++ b/packages/core/src/logger/Logger.ts @@ -28,8 +28,12 @@ export class Logger { this._logLevel = logLevel; } - public error(message: string, ...args: any[]): void { - this.write(message, LogLevel.ERROR, ...args); + public error(errorOrMessage: string | Error, ...args: any[]): void { + if (typeof errorOrMessage === 'string') { + this.write(errorOrMessage, LogLevel.ERROR, ...args); + } else { + this.write(errorOrMessage.message, LogLevel.ERROR, ...args); + } } public warn(message: string, ...args: any[]): void { diff --git a/packages/repeater/src/api/DefaultRepeatersManager.spec.ts b/packages/repeater/src/api/DefaultRepeatersManager.spec.ts index c2c449e6..6b2dcd0e 100644 --- a/packages/repeater/src/api/DefaultRepeatersManager.spec.ts +++ b/packages/repeater/src/api/DefaultRepeatersManager.spec.ts @@ -1,9 +1,5 @@ import 'reflect-metadata'; -import { - CreateRepeaterRequest, - DeleteRepeaterRequest, - GetRepeaterRequest -} from './commands'; +import { CreateRepeaterRequest, DeleteRepeaterRequest } from './commands'; import { DefaultRepeatersManager } from './DefaultRepeatersManager'; import { RepeatersManager } from './RepeatersManager'; import { CommandDispatcher } from '@sectester/core'; @@ -67,32 +63,6 @@ describe('DefaultRepeatersManager', () => { }); }); - describe('getRepeater', () => { - it('should return repeater', async () => { - const repeaterId = '142'; - when( - mockedCommandDispatcher.execute(anyOfClass(GetRepeaterRequest)) - ).thenResolve({ id: repeaterId }); - - const result = await manager.getRepeater(repeaterId); - - verify( - mockedCommandDispatcher.execute(anyOfClass(GetRepeaterRequest)) - ).once(); - expect(result).toMatchObject({ repeaterId }); - }); - - it('should throw an error if cannot find repeater', async () => { - when( - mockedCommandDispatcher.execute(anyOfClass(GetRepeaterRequest)) - ).thenResolve(); - - const act = manager.getRepeater('123'); - - await expect(act).rejects.toThrow('Cannot find repeater'); - }); - }); - describe('deleteRepeater', () => { it('should remove repeater', async () => { when( diff --git a/packages/repeater/src/api/DefaultRepeatersManager.ts b/packages/repeater/src/api/DefaultRepeatersManager.ts index a2da95b3..630ec035 100644 --- a/packages/repeater/src/api/DefaultRepeatersManager.ts +++ b/packages/repeater/src/api/DefaultRepeatersManager.ts @@ -1,9 +1,5 @@ import { RepeatersManager } from './RepeatersManager'; -import { - CreateRepeaterRequest, - DeleteRepeaterRequest, - GetRepeaterRequest -} from './commands'; +import { CreateRepeaterRequest, DeleteRepeaterRequest } from './commands'; import { inject, injectable } from 'tsyringe'; import { CommandDispatcher } from '@sectester/core'; @@ -14,20 +10,6 @@ export class DefaultRepeatersManager implements RepeatersManager { private readonly commandDispatcher: CommandDispatcher ) {} - public async getRepeater( - repeaterId: string - ): Promise<{ repeaterId: string }> { - const repeater = await this.commandDispatcher.execute( - new GetRepeaterRequest(repeaterId) - ); - - if (!repeater?.id) { - throw new Error('Cannot find repeater'); - } - - return { repeaterId: repeater.id }; - } - public async createRepeater({ projectId, ...options diff --git a/packages/repeater/src/api/RepeatersManager.ts b/packages/repeater/src/api/RepeatersManager.ts index 845c5bc0..c53bdd3e 100644 --- a/packages/repeater/src/api/RepeatersManager.ts +++ b/packages/repeater/src/api/RepeatersManager.ts @@ -1,6 +1,4 @@ export interface RepeatersManager { - getRepeater(repeaterId: string): Promise<{ repeaterId: string }>; - createRepeater(options: { name: string; projectId?: string; diff --git a/packages/repeater/src/api/commands/GetRepeaterRequest.ts b/packages/repeater/src/api/commands/GetRepeaterRequest.ts deleted file mode 100644 index 05882ff5..00000000 --- a/packages/repeater/src/api/commands/GetRepeaterRequest.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { HttpRequest } from '@sectester/bus'; - -export interface GetRepeaterResponsePayload { - id: string; - name: string; - projectIds: string[]; -} - -export class GetRepeaterRequest extends HttpRequest< - undefined, - GetRepeaterResponsePayload -> { - constructor(repeaterId: string) { - super({ - url: `/api/v1/repeaters/${repeaterId}`, - method: 'GET', - payload: undefined - }); - } -} diff --git a/packages/repeater/src/api/commands/index.ts b/packages/repeater/src/api/commands/index.ts index aea128ac..08f6202f 100644 --- a/packages/repeater/src/api/commands/index.ts +++ b/packages/repeater/src/api/commands/index.ts @@ -1,6 +1,5 @@ export { CreateRepeaterRequest } from './CreateRepeaterRequest'; export { DeleteRepeaterRequest } from './DeleteRepeaterRequest'; -export { GetRepeaterRequest } from './GetRepeaterRequest'; export { RegisterRepeaterCommand, RegisterRepeaterCommandPayload, diff --git a/packages/repeater/src/bus/DefaultRepeaterBus.ts b/packages/repeater/src/bus/DefaultRepeaterBus.ts new file mode 100644 index 00000000..f17b1209 --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterBus.ts @@ -0,0 +1,157 @@ +import { RepeaterBus } from './RepeaterBus'; +import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { + RepeaterErrorCodes, + RepeaterServer, + RepeaterServerErrorEvent, + RepeaterServerEvents, + RepeaterServerReconnectionFailedEvent, + RepeaterServerRequestEvent +} from './RepeaterServer'; +import { Request } from '../request-runner/Request'; +import { Logger } from '@sectester/core'; +import chalk from 'chalk'; + +export class DefaultRepeaterBus implements RepeaterBus { + private repeaterRunning: boolean = false; + + constructor( + private readonly repeaterId: string, + private readonly logger: Logger, + private readonly repeaterServer: RepeaterServer, + private readonly commandHub: RepeaterCommandHub + ) {} + + public close() { + this.repeaterRunning = false; + + this.repeaterServer.disconnect(); + + return Promise.resolve(); + } + + public async connect(): Promise { + if (this.repeaterRunning) { + return; + } + + this.repeaterRunning = true; + + this.logger.log('Connecting the Repeater (%s)...', this.repeaterId); + + this.subscribeToEvents(); + + await this.repeaterServer.connect(this.repeaterId); + + this.logger.log('Deploying the Repeater (%s)...', this.repeaterId); + + await this.repeaterServer.deploy({ + repeaterId: this.repeaterId + }); + + this.logger.log('The Repeater (%s) started', this.repeaterId); + } + + private subscribeToEvents() { + this.repeaterServer.on(RepeaterServerEvents.ERROR, this.handleError); + this.repeaterServer.on( + RepeaterServerEvents.RECONNECTION_FAILED, + this.reconnectionFailed + ); + this.repeaterServer.on(RepeaterServerEvents.REQUEST, this.requestReceived); + this.repeaterServer.on(RepeaterServerEvents.UPDATE_AVAILABLE, payload => + this.logger.warn( + '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', + chalk.yellow('(!) IMPORTANT'), + payload.version + ) + ); + this.repeaterServer.on( + RepeaterServerEvents.RECONNECT_ATTEMPT, + ({ attempt, maxAttempts }) => + this.logger.warn( + 'Failed to connect to Bright cloud (attempt %d/%d)', + attempt, + maxAttempts + ) + ); + this.repeaterServer.on(RepeaterServerEvents.RECONNECTION_SUCCEEDED, () => + this.logger.log('The Repeater (%s) connected', this.repeaterId) + ); + } + + private handleError = ({ + code, + message, + remediation + }: RepeaterServerErrorEvent) => { + const normalizedMessage = this.normalizeMessage(message); + const normalizedRemediation = this.normalizeMessage(remediation ?? ''); + + if (this.isCriticalError(code)) { + this.handleCriticalError(normalizedMessage, normalizedRemediation); + } else { + this.logger.error(normalizedMessage); + } + }; + + private normalizeMessage(message: string): string { + return message.replace(/\.$/, ''); + } + + private isCriticalError(code: RepeaterErrorCodes): boolean { + return [ + RepeaterErrorCodes.REPEATER_DEACTIVATED, + RepeaterErrorCodes.REPEATER_NO_LONGER_SUPPORTED, + RepeaterErrorCodes.REPEATER_UNAUTHORIZED, + RepeaterErrorCodes.REPEATER_ALREADY_STARTED, + RepeaterErrorCodes.REPEATER_NOT_PERMITTED, + RepeaterErrorCodes.UNEXPECTED_ERROR + ].includes(code); + } + + private handleCriticalError(message: string, remediation: string): void { + this.logger.error( + '%s: %s. %s', + chalk.red('(!) CRITICAL'), + message, + remediation + ); + this.close().catch(this.logger.error); + process.exitCode = 1; + } + + private reconnectionFailed = ({ + error + }: RepeaterServerReconnectionFailedEvent) => { + this.logger.error(error); + this.close().catch(this.logger.error); + process.exitCode = 1; + }; + + private requestReceived = async (event: RepeaterServerRequestEvent) => { + const response = await this.commandHub.sendRequest( + new Request({ ...event }) + ); + + const { + statusCode, + message, + errorCode, + body, + headers, + protocol, + encoding + } = response; + + return { + protocol, + body, + headers, + statusCode, + errorCode, + message, + encoding + }; + }; +} diff --git a/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts b/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts new file mode 100644 index 00000000..7c9f3537 --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterBusFactory.ts @@ -0,0 +1,33 @@ +import { RepeaterBus } from './RepeaterBus'; +import { DefaultRepeaterBus } from './DefaultRepeaterBus'; +import { RepeaterBusFactory } from './RepeaterBusFactory'; +import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { RepeaterServer } from './RepeaterServer'; +import { Configuration, Logger } from '@sectester/core'; +import { inject, injectable } from 'tsyringe'; + +@injectable() +export class DefaultRepeaterBusFactory implements RepeaterBusFactory { + constructor( + private readonly logger: Logger, + private readonly configuration: Configuration, + @inject(RepeaterServer) private readonly repeaterServer: RepeaterServer, + @inject(RepeaterCommandHub) + private readonly commandHub: RepeaterCommandHub + ) {} + + public create(repeaterId: string): RepeaterBus { + this.logger.log( + 'Creating the repeater (%s, %s)...', + repeaterId, + this.configuration.version + ); + + return new DefaultRepeaterBus( + repeaterId, + this.logger, + this.repeaterServer, + this.commandHub + ); + } +} diff --git a/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts b/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts new file mode 100644 index 00000000..5f147207 --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterCommandHub.ts @@ -0,0 +1,25 @@ +import { RepeaterCommandHub } from './RepeaterCommandHub'; +import { Request, Response, RequestRunner } from '../request-runner'; +import { injectable, injectAll } from 'tsyringe'; + +@injectable() +export class DefaultRepeaterCommandHub implements RepeaterCommandHub { + constructor( + @injectAll(RequestRunner) + private readonly requestRunners: RequestRunner[] + ) {} + + public sendRequest(request: Request): Promise { + const { protocol } = request; + + const requestRunner = this.requestRunners.find( + x => x.protocol === protocol + ); + + if (!requestRunner) { + throw new Error(`Unsupported protocol "${protocol}"`); + } + + return requestRunner.run(request); + } +} diff --git a/packages/repeater/src/bus/DefaultRepeaterServer.ts b/packages/repeater/src/bus/DefaultRepeaterServer.ts new file mode 100644 index 00000000..a62accb6 --- /dev/null +++ b/packages/repeater/src/bus/DefaultRepeaterServer.ts @@ -0,0 +1,378 @@ +import { + DeployCommandOptions, + DeploymentRuntime, + RepeaterErrorCodes, + RepeaterServer, + RepeaterServerDeployedEvent, + RepeaterServerErrorEvent, + RepeaterServerEventHandler, + RepeaterServerEvents, + RepeaterServerEventsMap, + RepeaterServerReconnectionAttemptedEvent, + RepeaterServerReconnectionFailedEvent, + RepeaterServerRequestEvent, + RepeaterServerRequestResponse, + RepeaterUpgradeAvailableEvent +} from './RepeaterServer'; +import { Logger } from '@sectester/core'; +import { inject, injectable } from 'tsyringe'; +import io, { Socket } from 'socket.io-client'; +import parser from 'socket.io-msgpack-parser'; +import { ErrorEvent } from 'ws'; +import { EventEmitter, once } from 'events'; +import Timer = NodeJS.Timer; + +export interface DefaultRepeaterServerOptions { + readonly uri: string; + readonly token: string; + readonly connectTimeout?: number; + readonly proxyUrl?: string; + readonly insecure?: boolean; +} + +export const DefaultRepeaterServerOptions: unique symbol = Symbol( + 'DefaultRepeaterServerOptions' +); + +type CallbackFunction = (arg: T) => unknown; +type HandlerFunction = (args: unknown[]) => unknown; + +const enum SocketEvents { + DEPLOYED = 'deployed', + DEPLOY = 'deploy', + UNDEPLOY = 'undeploy', + UNDEPLOYED = 'undeployed', + ERROR = 'error', + UPDATE_AVAILABLE = 'update-available', + PING = 'ping', + REQUEST = 'request' +} + +interface SocketListeningEventMap { + [SocketEvents.DEPLOYED]: (event: RepeaterServerDeployedEvent) => void; + [SocketEvents.UNDEPLOYED]: () => void; + [SocketEvents.ERROR]: (event: RepeaterServerErrorEvent) => void; + [SocketEvents.UPDATE_AVAILABLE]: ( + event: RepeaterUpgradeAvailableEvent + ) => void; + [SocketEvents.REQUEST]: ( + request: RepeaterServerRequestEvent, + callback: CallbackFunction + ) => void; +} + +interface SocketEmitEventMap { + [SocketEvents.DEPLOY]: ( + options: DeployCommandOptions, + runtime?: DeploymentRuntime + ) => void; + [SocketEvents.UNDEPLOY]: () => void; + [SocketEvents.PING]: () => void; +} + +@injectable() +export class DefaultRepeaterServer implements RepeaterServer { + private readonly MAX_DEPLOYMENT_TIMEOUT = 60_000; + private readonly MAX_PING_INTERVAL = 10_000; + private readonly MAX_RECONNECTION_ATTEMPTS = 20; + private readonly MIN_RECONNECTION_DELAY = 1000; + private readonly MAX_RECONNECTION_DELAY = 86_400_000; + private readonly events = new EventEmitter(); + private readonly handlerMap = new WeakMap< + RepeaterServerEventHandler, + HandlerFunction + >(); + private latestReconnectionError?: Error; + private pingTimer?: Timer; + private connectionTimer?: Timer; + private _socket?: Socket; + private connectionAttempts = 0; + + private get socket() { + if (!this._socket) { + throw new Error( + 'Please make sure that repeater established a connection with host.' + ); + } + + return this._socket; + } + + constructor( + private readonly logger: Logger, + @inject(DefaultRepeaterServerOptions) + private readonly options: DefaultRepeaterServerOptions + ) {} + + public disconnect() { + this.events.removeAllListeners(); + this.clearPingTimer(); + this.clearConnectionTimer(); + + this._socket?.disconnect(); + this._socket?.removeAllListeners(); + this._socket = undefined; + } + + public async deploy( + options: DeployCommandOptions + ): Promise { + process.nextTick(() => this.socket.emit(SocketEvents.DEPLOY, options)); + + const [result]: RepeaterServerDeployedEvent[] = await Promise.race([ + once(this.socket, SocketEvents.DEPLOYED), + new Promise((_, reject) => + setTimeout( + reject, + this.MAX_DEPLOYMENT_TIMEOUT, + new Error('No response.') + ).unref() + ) + ]); + + this.createPingTimer(); + + return result; + } + + public async connect(hostname: string) { + this._socket = io(this.options.uri, { + parser, + path: '/api/ws/v1', + transports: ['websocket'], + reconnectionDelayMax: this.MAX_RECONNECTION_DELAY, + reconnectionDelay: this.MIN_RECONNECTION_DELAY, + timeout: this.options?.connectTimeout, + rejectUnauthorized: !this.options.insecure, + reconnectionAttempts: this.MAX_RECONNECTION_ATTEMPTS, + auth: { + token: this.options.token, + domain: hostname + } + }); + + this.listenToReservedEvents(); + this.listenToApplicationEvents(); + + await once(this.socket, 'connect'); + + this.logger.debug('Repeater connected to %s', this.options.uri); + } + + public off( + event: K, + handler: RepeaterServerEventHandler + ): void { + const wrappedHandler = this.handlerMap.get(handler); + if (wrappedHandler) { + this.events.off(event, wrappedHandler); + this.handlerMap.delete(handler); + } + } + + public on( + event: K, + handler: RepeaterServerEventHandler + ): void { + const wrappedHandler = (...args: unknown[]) => + this.wrapEventListener(event, handler, ...args); + this.handlerMap.set(handler, wrappedHandler); + this.events.on(event, wrappedHandler); + } + + private listenToApplicationEvents() { + this.socket.on(SocketEvents.DEPLOYED, event => + this.events.emit(RepeaterServerEvents.DEPLOY, event) + ); + this.socket.on(SocketEvents.REQUEST, (event, callback) => + this.events.emit(RepeaterServerEvents.REQUEST, event, callback) + ); + this.socket.on(SocketEvents.ERROR, event => { + this.events.emit(RepeaterServerEvents.ERROR, event); + }); + this.socket.on(SocketEvents.UPDATE_AVAILABLE, event => + this.events.emit(RepeaterServerEvents.UPDATE_AVAILABLE, event) + ); + } + + private listenToReservedEvents() { + this.socket.on('connect', this.handleConnect); + this.socket.on('connect_error', this.handleConnectionError); + this.socket.on('disconnect', this.handleDisconnect); + this.socket.io.on('reconnect', () => { + this.latestReconnectionError = undefined; + }); + this.socket.io.on( + 'reconnect_error', + error => (this.latestReconnectionError = error) + ); + this.socket.io.on('reconnect_failed', () => + this.events.emit(RepeaterServerEvents.RECONNECTION_FAILED, { + error: this.latestReconnectionError + } as RepeaterServerReconnectionFailedEvent) + ); + this.socket.io.on('reconnect_attempt', attempt => + this.events.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { + attempt, + maxAttempts: this.MAX_RECONNECTION_ATTEMPTS + } as RepeaterServerReconnectionAttemptedEvent) + ); + this.socket.io.on('reconnect', () => + this.events.emit(RepeaterServerEvents.RECONNECTION_SUCCEEDED) + ); + } + + private handleConnectionError = (err: Error) => { + const { data } = err as unknown as { + data?: Omit; + }; + + // If the error is not related to the repeater, we should ignore it + if (!data?.code) { + this.logConnectionError(err); + + return; + } + + if (this.suppressConnectionError(data)) { + this.events.emit(RepeaterServerEvents.ERROR, { + ...data, + message: err.message + }); + + return; + } + + if (this.connectionAttempts >= this.MAX_RECONNECTION_ATTEMPTS) { + this.events.emit(RepeaterServerEvents.RECONNECTION_FAILED, { + error: err + } as RepeaterServerReconnectionFailedEvent); + + return; + } + + // If the error is not related to the authentication, we should manually reconnect + this.scheduleReconnection(); + }; + + private suppressConnectionError( + data: Omit + ) { + return [ + RepeaterErrorCodes.REPEATER_UNAUTHORIZED, + RepeaterErrorCodes.REPEATER_NOT_PERMITTED + ].includes(data.code); + } + + private scheduleReconnection() { + let delay = Math.max( + this.MIN_RECONNECTION_DELAY * 2 ** this.connectionAttempts, + this.MIN_RECONNECTION_DELAY + ); + delay += delay * 0.3 * Math.random(); + delay = Math.min(delay, this.MAX_RECONNECTION_DELAY); + + this.connectionAttempts++; + + this.events.emit(RepeaterServerEvents.RECONNECT_ATTEMPT, { + attempt: this.connectionAttempts, + maxAttempts: this.MAX_RECONNECTION_ATTEMPTS + } as RepeaterServerReconnectionAttemptedEvent); + this.connectionTimer = setTimeout(() => this.socket.connect(), delay); + } + + private logConnectionError(err: Error) { + this.logger.debug( + 'An error occurred while connecting to the repeater: %s', + err.message + ); + + const { description, cause } = err as { + description?: ErrorEvent; + cause?: Error; + }; + const nestedError = description?.error ?? cause; + + if (nestedError) { + this.logger.debug('The error cause: %s', nestedError.message); + } + } + + private async wrapEventListener( + event: string, + handler: (...payload: TArgs) => unknown, + ...args: unknown[] + ) { + try { + const callback = this.extractLastArgument(args); + + // eslint-disable-next-line @typescript-eslint/return-await + const response = await handler(...(args as TArgs)); + + callback?.(response); + } catch (err) { + this.handleEventError(err, event, args); + } + } + + private extractLastArgument(args: unknown[]): CallbackFunction | undefined { + const lastArg = args.pop(); + if (typeof lastArg === 'function') { + return lastArg as CallbackFunction; + } else { + // If the last argument is not a function, add it back to the args array + args.push(lastArg); + + return undefined; + } + } + + private clearConnectionTimer() { + if (this.connectionTimer) { + clearTimeout(this.connectionTimer); + } + } + + private handleConnect = () => { + this.connectionAttempts = 0; + this.clearConnectionTimer(); + this.events.emit(RepeaterServerEvents.CONNECTED); + }; + + private handleDisconnect = (reason: string): void => { + this.clearPingTimer(); + + if (reason !== 'io client disconnect') { + this.events.emit(RepeaterServerEvents.DISCONNECTED); + } + + // the disconnection was initiated by the server, you need to reconnect manually + if (reason === 'io server disconnect') { + this.socket.connect(); + } + }; + + private handleEventError(error: Error, event: string, args: unknown[]): void { + this.logger.debug( + 'An error occurred while processing the %s event with the following payload: %j', + event, + args + ); + this.logger.error(error); + } + + private createPingTimer() { + this.clearPingTimer(); + + this.pingTimer = setInterval( + () => this.socket.volatile.emit(SocketEvents.PING), + this.MAX_PING_INTERVAL + ).unref(); + } + + private clearPingTimer() { + if (this.pingTimer) { + clearInterval(this.pingTimer); + } + } +} diff --git a/packages/repeater/src/bus/RepeaterBus.ts b/packages/repeater/src/bus/RepeaterBus.ts new file mode 100644 index 00000000..a3fd758f --- /dev/null +++ b/packages/repeater/src/bus/RepeaterBus.ts @@ -0,0 +1,6 @@ +export interface RepeaterBus { + connect(): Promise; + close(): Promise; +} + +export const RepeaterBus: unique symbol = Symbol('RepeaterBus'); diff --git a/packages/repeater/src/bus/RepeaterBusFactory.ts b/packages/repeater/src/bus/RepeaterBusFactory.ts new file mode 100644 index 00000000..cc65e800 --- /dev/null +++ b/packages/repeater/src/bus/RepeaterBusFactory.ts @@ -0,0 +1,7 @@ +import { RepeaterBus } from './RepeaterBus'; + +export interface RepeaterBusFactory { + create(repeaterId: string): RepeaterBus; +} + +export const RepeaterBusFactory: unique symbol = Symbol('RepeaterBusFactory'); diff --git a/packages/repeater/src/bus/RepeaterCommandHub.ts b/packages/repeater/src/bus/RepeaterCommandHub.ts new file mode 100644 index 00000000..7391bcc6 --- /dev/null +++ b/packages/repeater/src/bus/RepeaterCommandHub.ts @@ -0,0 +1,7 @@ +import { Request, Response } from '../request-runner'; + +export interface RepeaterCommandHub { + sendRequest(request: Request): Promise; +} + +export const RepeaterCommandHub: unique symbol = Symbol('RepeaterCommandHub'); diff --git a/packages/repeater/src/bus/RepeaterServer.ts b/packages/repeater/src/bus/RepeaterServer.ts new file mode 100644 index 00000000..20e1553c --- /dev/null +++ b/packages/repeater/src/bus/RepeaterServer.ts @@ -0,0 +1,132 @@ +import { Protocol } from '../models/Protocol'; + +export interface RepeaterServerDeployedEvent { + repeaterId: string; +} + +export interface RepeaterServerRequestEvent { + protocol: Protocol; + url: string; + method?: string; + headers?: Record; + correlationIdRegex?: string; + body?: string; + encoding?: 'base64'; + maxContentSize?: number; + timeout?: number; +} + +export type RepeaterServerRequestResponse = + | { + protocol: Protocol; + statusCode?: number; + message?: string; + errorCode?: string; + headers?: Record; + body?: string; + } + | { + protocol: Protocol; + message?: string; + errorCode?: string; + }; + +export interface RepeaterServerReconnectionFailedEvent { + error: Error; +} + +export interface RepeaterServerReconnectionAttemptedEvent { + attempt: number; + maxAttempts: number; +} + +export enum RepeaterErrorCodes { + REPEATER_NOT_PERMITTED = 'repeater_not_permitted', + REPEATER_ALREADY_STARTED = 'repeater_already_started', + REPEATER_DEACTIVATED = 'repeater_deactivated', + REPEATER_UNAUTHORIZED = 'repeater_unauthorized', + REPEATER_NO_LONGER_SUPPORTED = 'repeater_no_longer_supported', + UNKNOWN_ERROR = 'unknown_error', + UNEXPECTED_ERROR = 'unexpected_error' +} + +export interface RepeaterServerErrorEvent { + message: string; + code: RepeaterErrorCodes; + transaction?: string; + remediation?: string; +} + +export interface RepeaterUpgradeAvailableEvent { + version: string; +} + +export interface DeployCommandOptions { + repeaterId?: string; +} + +export interface DeploymentRuntime { + version: string; + ci?: string; + os?: string; + arch?: string; + docker?: boolean; + distribution?: string; + nodeVersion?: string; +} + +export const enum RepeaterServerEvents { + DEPLOYED = 'deployed', + DEPLOY = 'deploy', + CONNECTED = 'connected', + DISCONNECTED = 'disconnected', + REQUEST = 'request', + UPDATE_AVAILABLE = 'update_available', + RECONNECTION_FAILED = 'reconnection_failed', + RECONNECT_ATTEMPT = 'reconnect_attempt', + RECONNECTION_SUCCEEDED = 'reconnection_succeeded', + ERROR = 'error', + PING = 'ping' +} + +export interface RepeaterServerEventsMap { + [RepeaterServerEvents.DEPLOY]: [DeployCommandOptions, DeploymentRuntime?]; + [RepeaterServerEvents.DEPLOYED]: RepeaterServerDeployedEvent; + [RepeaterServerEvents.CONNECTED]: void; + [RepeaterServerEvents.DISCONNECTED]: void; + [RepeaterServerEvents.REQUEST]: RepeaterServerRequestEvent; + [RepeaterServerEvents.UPDATE_AVAILABLE]: RepeaterUpgradeAvailableEvent; + [RepeaterServerEvents.RECONNECTION_FAILED]: RepeaterServerReconnectionFailedEvent; + [RepeaterServerEvents.RECONNECT_ATTEMPT]: RepeaterServerReconnectionAttemptedEvent; + [RepeaterServerEvents.RECONNECTION_SUCCEEDED]: void; + [RepeaterServerEvents.ERROR]: RepeaterServerErrorEvent; + [RepeaterServerEvents.PING]: void; +} + +export type RepeaterServerEventHandler< + K extends keyof RepeaterServerEventsMap +> = ( + ...args: RepeaterServerEventsMap[K] extends (infer U)[] + ? U[] + : [RepeaterServerEventsMap[K]] +) => unknown; + +export interface RepeaterServer { + disconnect(): void; + + connect(hostname: string): Promise; + + deploy(options: DeployCommandOptions): Promise; + + on( + event: K, + handler: RepeaterServerEventHandler + ): void; + + off( + event: K, + handler?: RepeaterServerEventHandler + ): void; +} + +export const RepeaterServer: unique symbol = Symbol('RepeaterServer'); diff --git a/packages/repeater/src/bus/index.ts b/packages/repeater/src/bus/index.ts new file mode 100644 index 00000000..7ba307a6 --- /dev/null +++ b/packages/repeater/src/bus/index.ts @@ -0,0 +1,7 @@ +export * from './DefaultRepeaterBusFactory'; +export * from './DefaultRepeaterCommandHub'; +export * from './DefaultRepeaterServer'; +export * from './RepeaterBus'; +export * from './RepeaterBusFactory'; +export * from './RepeaterCommandHub'; +export * from './RepeaterServer'; diff --git a/packages/repeater/src/lib/Repeater.spec.ts b/packages/repeater/src/lib/Repeater.spec.ts index a26f774b..7a9d14ce 100644 --- a/packages/repeater/src/lib/Repeater.spec.ts +++ b/packages/repeater/src/lib/Repeater.spec.ts @@ -1,245 +1,118 @@ -import 'reflect-metadata'; import { Repeater, RunningStatus } from './Repeater'; -import { - RegisterRepeaterCommand, - RepeaterRegisteringError, - RepeaterStatusEvent -} from '../api'; -import { Configuration, EventBus, Logger } from '@sectester/core'; -import { - anyOfClass, - anything, - capture, - instance, - mock, - objectContaining, - reset, - verify, - when -} from 'ts-mockito'; -import { DependencyContainer } from 'tsyringe'; +import { RepeaterBus } from '../bus'; +import { instance, mock, reset, verify, when } from 'ts-mockito'; describe('Repeater', () => { - const version = '42.0.1'; const repeaterId = 'fooId'; let repeater!: Repeater; - const mockedConfiguration = mock(); - const mockedEventBus = mock(); - const mockedLogger = mock(); - const mockedContainer = mock(); + const mockedRepeaterBus = mock(); - const createRepater = () => + const createRepeater = () => new Repeater({ repeaterId, - bus: instance(mockedEventBus), - configuration: instance(mockedConfiguration) + bus: instance(mockedRepeaterBus) }); beforeEach(() => { - when(mockedContainer.resolve(Logger)).thenReturn(instance(mockedLogger)); - when(mockedContainer.isRegistered(Logger, anything())).thenReturn(true); - when(mockedConfiguration.repeaterVersion).thenReturn(version); - when(mockedConfiguration.container).thenReturn(instance(mockedContainer)); - when( - mockedEventBus.execute(anyOfClass(RegisterRepeaterCommand)) - ).thenResolve({ payload: { version } }); - when(mockedEventBus.publish(anyOfClass(RepeaterStatusEvent))).thenResolve(); - - jest.useFakeTimers(); - - repeater = createRepater(); + repeater = createRepeater(); }); - afterEach(() => { - reset( - mockedConfiguration, - mockedEventBus, - mockedLogger, - mockedContainer - ); - - jest.useRealTimers(); - }); + afterEach(() => reset(mockedRepeaterBus)); describe('start', () => { it('should start', async () => { + // act await repeater.start(); - verify( - mockedEventBus.execute( - objectContaining({ - type: 'RepeaterRegistering', - payload: { - repeaterId, - version - } - }) - ) - ).once(); - - verify( - mockedEventBus.publish( - objectContaining({ - type: 'RepeaterStatusUpdated', - payload: { - repeaterId, - status: 'connected' - } - }) - ) - ).once(); - }); - - it('should throw an error on failed registration', async () => { - when( - mockedEventBus.execute(anyOfClass(RegisterRepeaterCommand)) - ).thenResolve(); - - await expect(repeater.start()).rejects.toThrow( - 'Error registering repeater.' - ); - }); - - it('should send ping periodically', async () => { - await repeater.start(); - jest.advanceTimersByTime(15000); - jest.runOnlyPendingTimers(); - - verify( - mockedEventBus.publish( - objectContaining({ - type: 'RepeaterStatusUpdated', - payload: { - repeaterId, - status: 'connected' - } - }) - ) - ).thrice(); + // assert + verify(mockedRepeaterBus.connect()).once(); }); it('should have RunningStatus.STARTING just after start() call', () => { + // act void repeater.start(); + + // assert expect(repeater.runningStatus).toBe(RunningStatus.STARTING); }); it('should have RunningStatus.RUNNING after successful start()', async () => { + // act await repeater.start(); + + // assert expect(repeater.runningStatus).toBe(RunningStatus.RUNNING); }); it('should throw an error on start() twice', async () => { + // arrange await repeater.start(); + // act const res = repeater.start(); + // assert await expect(res).rejects.toThrow('Repeater is already active.'); }); it('should be possible to start() after start() error', async () => { - when(mockedEventBus.execute(anyOfClass(RegisterRepeaterCommand))) - .thenReject() - .thenResolve({ payload: { version } }); + // act + when(mockedRepeaterBus.connect()).thenReject().thenResolve(); + // assert await expect(repeater.start()).rejects.toThrow(); await expect(repeater.start()).resolves.not.toThrow(); }); - - it.each([ - { - error: RepeaterRegisteringError.REQUIRES_TO_BE_UPDATED, - expected: 'The current running version is no longer supported' - }, - { - error: RepeaterRegisteringError.BUSY, - expected: `There is an already running Repeater with ID ${repeaterId}` - }, - { - error: RepeaterRegisteringError.NOT_FOUND, - expected: 'Unauthorized access' - }, - { - error: RepeaterRegisteringError.NOT_ACTIVE, - expected: 'The current Repeater is not active' - } - ])( - 'should throw an error on registration error ${error}', - async ({ expected, error }) => { - when( - mockedEventBus.execute(anyOfClass(RegisterRepeaterCommand)) - ).thenResolve({ - payload: { error } - }); - - await expect(repeater.start()).rejects.toThrow(expected); - } - ); - - it('should log a warning if a new version is available', async () => { - const newVersion = version.replace(/(\d+)/, (_, x) => `${+x + 1}`); - when( - mockedEventBus.execute(anyOfClass(RegisterRepeaterCommand)) - ).thenResolve({ - payload: { version: newVersion } - }); - - await repeater.start(); - - const [arg]: string[] = capture(mockedLogger.warn).first(); - expect(arg).toContain('A new Repeater version (%s) is available'); - }); }); describe('stop', () => { it('should stop', async () => { + // arrange await repeater.start(); + + // act await repeater.stop(); - verify( - mockedEventBus.publish( - objectContaining({ - type: 'RepeaterStatusUpdated', - payload: { - repeaterId, - status: 'disconnected' - } - }) - ) - ).once(); - - jest.advanceTimersByTime(25000); - jest.runOnlyPendingTimers(); - - verify( - mockedEventBus.publish( - objectContaining({ payload: { status: 'connected' } }) - ) - ).once(); + // assert + verify(mockedRepeaterBus.close()).once(); }); it('should have RunningStatus.OFF after start() and stop()', async () => { + // arrange await repeater.start(); + + // act await repeater.stop(); + + // assert expect(repeater.runningStatus).toBe(RunningStatus.OFF); }); it('should do nothing on stop() without start()', async () => { + // act await repeater.stop(); + + // assert expect(repeater.runningStatus).toBe(RunningStatus.OFF); }); it('should do nothing on second stop() call', async () => { + // arrange await repeater.start(); await repeater.stop(); + + // assert await repeater.stop(); + // assert expect(repeater.runningStatus).toBe(RunningStatus.OFF); }); }); describe('runningStatus', () => { it('should have RunningStatus.OFF initially', () => { + // assert expect(repeater.runningStatus).toBe(RunningStatus.OFF); }); }); diff --git a/packages/repeater/src/lib/Repeater.ts b/packages/repeater/src/lib/Repeater.ts index 8c50a88f..cb9ce5ae 100644 --- a/packages/repeater/src/lib/Repeater.ts +++ b/packages/repeater/src/lib/Repeater.ts @@ -1,15 +1,4 @@ -import { - ExecuteRequestEventHandler, - RegisterRepeaterCommand, - RegisterRepeaterResult, - RepeaterRegisteringError, - RepeaterStatusEvent -} from '../api'; -import { RepeaterStatus } from '../models'; -import { Configuration, EventBus, Logger } from '@sectester/core'; -import { gt } from 'semver'; -import chalk from 'chalk'; -import Timer = NodeJS.Timer; +import { RepeaterBus } from '../bus'; export enum RunningStatus { OFF, @@ -23,11 +12,7 @@ export const RepeaterId = Symbol('RepeaterId'); export class Repeater { public readonly repeaterId: RepeaterId; - private readonly bus: EventBus; - private readonly configuration: Configuration; - private readonly logger: Logger; - - private timer?: Timer; + private readonly bus: RepeaterBus; private _runningStatus = RunningStatus.OFF; @@ -37,19 +22,13 @@ export class Repeater { constructor({ repeaterId, - bus, - configuration + bus }: { repeaterId: RepeaterId; - bus: EventBus; - configuration: Configuration; + bus: RepeaterBus; }) { this.repeaterId = repeaterId; this.bus = bus; - this.configuration = configuration; - - const { container } = this.configuration; - this.logger = container.resolve(Logger); } public async start(): Promise { @@ -60,9 +39,7 @@ export class Repeater { this._runningStatus = RunningStatus.STARTING; try { - await this.register(); - await this.subscribeToEvents(); - await this.schedulePing(); + await this.bus.connect(); this._runningStatus = RunningStatus.RUNNING; } catch (e) { @@ -78,85 +55,6 @@ export class Repeater { this._runningStatus = RunningStatus.OFF; - if (this.timer) { - clearInterval(this.timer); - } - - await this.sendStatus('disconnected'); - await this.bus.destroy?.(); - } - - private async register(): Promise { - const res = await this.bus.execute( - new RegisterRepeaterCommand({ - version: this.configuration.repeaterVersion, - repeaterId: this.repeaterId - }) - ); - - if (!res) { - throw new Error('Error registering repeater.'); - } - - this.handleRegisterResult(res); - } - - private async subscribeToEvents(): Promise { - await Promise.all( - [ - ExecuteRequestEventHandler - // TODO repeater scripts - ].map(type => this.bus.register(type)) - ); - } - - private async schedulePing(): Promise { - await this.sendStatus('connected'); - this.timer = setInterval(() => this.sendStatus('connected'), 10000); - this.timer.unref(); - } - - private async sendStatus(status: RepeaterStatus): Promise { - await this.bus.publish( - new RepeaterStatusEvent({ - status, - repeaterId: this.repeaterId - }) - ); - } - - private handleRegisterResult(res: { payload: RegisterRepeaterResult }): void { - const { payload } = res; - - if ('error' in payload) { - this.handleRegisterError(payload.error); - } else { - if (gt(payload.version, this.configuration.repeaterVersion)) { - this.logger.warn( - '%s: A new Repeater version (%s) is available, please update @sectester.', - chalk.yellow('(!) IMPORTANT'), - payload.version - ); - } - } - } - - private handleRegisterError(error: RepeaterRegisteringError): never { - switch (error) { - case RepeaterRegisteringError.NOT_ACTIVE: - throw new Error(`Access Refused: The current Repeater is not active.`); - case RepeaterRegisteringError.NOT_FOUND: - throw new Error(`Unauthorized access. Please check your credentials.`); - case RepeaterRegisteringError.BUSY: - throw new Error( - `Access Refused: There is an already running Repeater with ID ${this.repeaterId}` - ); - case RepeaterRegisteringError.REQUIRES_TO_BE_UPDATED: - throw new Error( - `${chalk.red( - '(!) CRITICAL' - )}: The current running version is no longer supported, please update @sectester.` - ); - } + await this.bus.close(); } } diff --git a/packages/repeater/src/lib/RepeaterFactory.spec.ts b/packages/repeater/src/lib/RepeaterFactory.spec.ts index d9c3d6b7..99c1403e 100644 --- a/packages/repeater/src/lib/RepeaterFactory.spec.ts +++ b/packages/repeater/src/lib/RepeaterFactory.spec.ts @@ -1,5 +1,6 @@ -import 'reflect-metadata'; import { RepeaterFactory } from './RepeaterFactory'; +import { RepeaterBus } from '../bus/RepeaterBus'; +import { RepeaterBusFactory } from '../bus/RepeaterBusFactory'; import { HttpRequestRunner, RequestRunner, @@ -7,7 +8,7 @@ import { } from '../request-runner'; import { Repeater } from './Repeater'; import { RepeatersManager } from '../api'; -import { Configuration, EventBus } from '@sectester/core'; +import { Configuration } from '@sectester/core'; import { anything, capture, @@ -21,21 +22,6 @@ import { } from 'ts-mockito'; import { DependencyContainer, Lifecycle } from 'tsyringe'; -const resolvableInstance = (m: T): T => - new Proxy(instance(m), { - get(target, prop, receiver) { - if ( - ['Symbol(Symbol.toPrimitive)', 'then', 'catch'].includes( - prop.toString() - ) - ) { - return undefined; - } - - return Reflect.get(target, prop, receiver); - } - }); - describe('RepeaterFactory', () => { const repeaterId = 'fooId'; const defaultOptions = { @@ -63,51 +49,64 @@ describe('RepeaterFactory', () => { const mockedContainer = mock(); const mockedChildContainer = mock(); const mockedConfiguration = mock(); - const mockedEventBus = mock(); + const mockedRepeaterBus = mock(); const mockedRepeaterManager = mock(); + const mockedRepeaterBusFactory = mock(); const configuration = instance(mockedConfiguration); beforeEach(() => { - when(mockedChildContainer.resolve(EventBus)).thenReturn( - resolvableInstance(mockedEventBus) - ); - when( - mockedContainer.resolve(RepeatersManager) - ).thenReturn(instance(mockedRepeaterManager)); - when(mockedConfiguration.container).thenReturn(instance(mockedContainer)); + when(mockedConfiguration.loadCredentials()).thenResolve(); + when(mockedContainer.createChildContainer()).thenReturn( instance(mockedChildContainer) ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - when(mockedEventBus.init!()).thenResolve(); + when( + mockedContainer.resolve(RepeatersManager) + ).thenReturn(instance(mockedRepeaterManager)); when(mockedRepeaterManager.createRepeater(anything())).thenResolve({ repeaterId }); + + when( + mockedChildContainer.resolve(RepeaterBusFactory) + ).thenReturn(instance(mockedRepeaterBusFactory)); + + when(mockedRepeaterBusFactory.create(repeaterId)).thenReturn( + instance(mockedRepeaterBus) + ); }); afterEach(() => { - reset( + reset< + | DependencyContainer + | Configuration + | RepeaterBus + | RepeatersManager + | RepeaterBusFactory + >( mockedContainer, mockedChildContainer, mockedConfiguration, - mockedEventBus, + mockedRepeaterBus, + mockedRepeaterBusFactory, mockedRepeaterManager ); }); describe('createRepeater', () => { it('should create repeater', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act const res = await factory.createRepeater(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - verify(mockedEventBus.init!()).once(); + // assert expect(res).toBeInstanceOf(Repeater); expect(res).toMatchObject({ repeaterId @@ -115,8 +114,10 @@ describe('RepeaterFactory', () => { }); it('should create repeater with given name prefix and description', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act const res = await factory.createRepeater({ namePrefix: 'foo', description: 'description' @@ -132,19 +133,23 @@ describe('RepeaterFactory', () => { description?: string; }>(mockedRepeaterManager.createRepeater).first(); + // assert expect(arg?.name).toMatch(/^foo/); expect(arg?.description).toBe('description'); expect(res).toBeInstanceOf(Repeater); }); it('should create repeater with given name without the random postfix', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act const res = await factory.createRepeater({ namePrefix: 'foo', disableRandomNameGeneration: true }); + // assert verify( mockedRepeaterManager.createRepeater(objectContaining({ name: 'foo' })) ); @@ -152,6 +157,7 @@ describe('RepeaterFactory', () => { }); it('should create repeater with given project', async () => { + // arrange const factory = new RepeaterFactory(configuration); const projectId = '321'; const res = await factory.createRepeater({ @@ -165,6 +171,7 @@ describe('RepeaterFactory', () => { }); it('should register custom request runner options', async () => { + // arrange const factory = new RepeaterFactory(configuration); when( mockedChildContainer.register(RequestRunnerOptions, anything()) @@ -176,12 +183,14 @@ describe('RepeaterFactory', () => { allowedMimes: ['text/html'] }; + // act await factory.createRepeater({ namePrefix: 'foo', description: 'description', requestRunnerOptions }); + // assert verify( mockedChildContainer.register( RequestRunnerOptions, @@ -193,13 +202,16 @@ describe('RepeaterFactory', () => { }); it('should register request runner options', async () => { + // arrange const factory = new RepeaterFactory(configuration); when( mockedChildContainer.register(RequestRunnerOptions, anything()) ).thenReturn(); + // act await factory.createRepeater({ requestRunnerOptions: defaultOptions }); + // assert verify( mockedChildContainer.register( RequestRunnerOptions, @@ -211,12 +223,15 @@ describe('RepeaterFactory', () => { }); it('should register request runners', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act await factory.createRepeater({ requestRunners: [HttpRequestRunner] }); + // assert verify( mockedChildContainer.register( RequestRunner, @@ -231,108 +246,34 @@ describe('RepeaterFactory', () => { }); it('should throw an error if name prefix is too long', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act const res = factory.createRepeater({ namePrefix: 'foo'.repeat(50) }); + // assert await expect(res).rejects.toThrow( 'Name prefix must be less than or equal to 43 characters.' ); }); it('should throw an error when name prefix is too long and random postfix is disabled', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act const res = factory.createRepeater({ namePrefix: 'foo'.repeat(80), disableRandomNameGeneration: true }); + // assert await expect(res).rejects.toThrow( 'Name prefix must be less than or equal to 80 characters.' ); }); }); - - describe('createRepeaterFromExisting', () => { - it('should create repeater from existing repeater ID', async () => { - const factory = new RepeaterFactory(configuration); - const existingRepeaterId = '123'; - - const res = await factory.createRepeaterFromExisting(existingRepeaterId); - - expect(res).toBeInstanceOf(Repeater); - expect(res).toMatchObject({ - repeaterId: existingRepeaterId - }); - }); - - it('should register custom request runner options', async () => { - const factory = new RepeaterFactory(configuration); - const existingRepeaterId = '123'; - when( - mockedChildContainer.register(RequestRunnerOptions, anything()) - ).thenReturn(); - - const requestRunnerOptions = { - timeout: 10000, - maxContentLength: 200, - allowedMimes: ['text/html'] - }; - - await factory.createRepeaterFromExisting(existingRepeaterId, { - requestRunnerOptions - }); - - verify( - mockedChildContainer.register( - RequestRunnerOptions, - objectContaining({ - useValue: requestRunnerOptions - }) - ) - ).once(); - }); - - it('should register request runner options', async () => { - const factory = new RepeaterFactory(configuration); - const existingRepeaterId = '123'; - - await factory.createRepeaterFromExisting(existingRepeaterId, { - requestRunnerOptions: defaultOptions - }); - - verify( - mockedChildContainer.register( - RequestRunnerOptions, - deepEqual({ - useValue: defaultOptions - }) - ) - ).once(); - }); - - it('should register request runners', async () => { - const factory = new RepeaterFactory(configuration); - const existingRepeaterId = '123'; - - await factory.createRepeaterFromExisting(existingRepeaterId, { - requestRunners: [HttpRequestRunner] - }); - - verify( - mockedChildContainer.register( - RequestRunner, - deepEqual({ - useClass: HttpRequestRunner - }), - deepEqual({ - lifecycle: Lifecycle.ContainerScoped - }) - ) - ).once(); - }); - }); }); diff --git a/packages/repeater/src/lib/RepeaterFactory.ts b/packages/repeater/src/lib/RepeaterFactory.ts index 9168c48a..26abde14 100644 --- a/packages/repeater/src/lib/RepeaterFactory.ts +++ b/packages/repeater/src/lib/RepeaterFactory.ts @@ -2,8 +2,10 @@ import { Repeater, RepeaterId } from './Repeater'; import { RequestRunner, RequestRunnerOptions } from '../request-runner'; import { RepeaterOptions } from './RepeaterOptions'; import { RepeatersManager } from '../api'; +import { RepeaterBusFactory } from '../bus/RepeaterBusFactory'; +import { DefaultRepeaterServerOptions } from '../bus/DefaultRepeaterServer'; import { RepeaterRequestRunnerOptions } from './RepeaterRequestRunnerOptions'; -import { Configuration, EventBus } from '@sectester/core'; +import { Configuration } from '@sectester/core'; import { v4 as uuidv4 } from 'uuid'; import { DependencyContainer, injectable, Lifecycle } from 'tsyringe'; @@ -41,43 +43,35 @@ export class RepeaterFactory { return this.createRepeaterInstance(repeaterId, requestRunnerOptions); } - public async createRepeaterFromExisting( - repeaterId: string, - options?: RepeaterRequestRunnerOptions - ): Promise { - await this.repeatersManager.getRepeater(repeaterId); - - return this.createRepeaterInstance(repeaterId, options); - } - private async createRepeaterInstance( repeaterId: string, { requestRunnerOptions, requestRunners = [] }: RepeaterRequestRunnerOptions = {} - ) { + ): Promise { const container = this.configuration.container.createChildContainer(); container.register(RepeaterId, { useValue: repeaterId }); + await this.registerRepeaterServerOptions(container); this.registerRequestRunnerOptions(container, requestRunnerOptions); this.registerRequestRunners(container, requestRunners); - const bus = await this.createEventBus(container); + const busFactory = + container.resolve(RepeaterBusFactory); return new Repeater({ - bus, repeaterId, - configuration: this.configuration + bus: busFactory.create(repeaterId) }); } - private async createEventBus( + private async registerRepeaterServerOptions( container: DependencyContainer - ): Promise { + ): Promise { await this.configuration.loadCredentials(); if (!this.configuration.credentials) { @@ -86,11 +80,16 @@ export class RepeaterFactory { ); } - const bus = container.resolve(EventBus); - - await bus.init?.(); - - return bus; + container.register( + DefaultRepeaterServerOptions, + { + useValue: { + uri: `${this.configuration.api}/workstations`, + token: this.configuration.credentials?.token as string, + connectTimeout: 10000 + } + } + ); } private generateName( diff --git a/packages/repeater/src/register.ts b/packages/repeater/src/register.ts index a1d8f55b..bb0ec1b6 100644 --- a/packages/repeater/src/register.ts +++ b/packages/repeater/src/register.ts @@ -1,5 +1,13 @@ import { RepeaterFactory, RepeaterId } from './lib'; import { DefaultRepeatersManager, RepeatersManager } from './api'; +import { + DefaultRepeaterBusFactory, + DefaultRepeaterCommandHub, + DefaultRepeaterServer, + RepeaterBusFactory, + RepeaterCommandHub, + RepeaterServer +} from './bus'; import { HttpRequestRunner, RequestRunner, @@ -86,3 +94,6 @@ container.register(EventBus, { }); container.register(RepeatersManager, { useClass: DefaultRepeatersManager }); +container.register(RepeaterServer, { useClass: DefaultRepeaterServer }); +container.register(RepeaterCommandHub, { useClass: DefaultRepeaterCommandHub }); +container.register(RepeaterBusFactory, { useClass: DefaultRepeaterBusFactory }); diff --git a/packages/repeater/src/request-runner/Request.ts b/packages/repeater/src/request-runner/Request.ts index ad57de5b..4ab420c9 100644 --- a/packages/repeater/src/request-runner/Request.ts +++ b/packages/repeater/src/request-runner/Request.ts @@ -4,9 +4,14 @@ import { URL } from 'url'; export interface RequestOptions { protocol: Protocol; url: string; - method?: string; headers?: Record; + method?: string; body?: string; + correlationIdRegex?: string | RegExp; + encoding?: 'base64'; + maxContentSize?: number; + timeout?: number; + decompress?: boolean; } export class Request { @@ -26,34 +31,80 @@ export class Request { 'referer', 'user-agent' ]); + public readonly protocol: Protocol; public readonly url: string; public readonly body?: string; + public readonly correlationIdRegex?: RegExp; + public readonly encoding?: 'base64'; + public readonly maxContentSize?: number; + public readonly decompress?: boolean; + public readonly timeout?: number; - private readonly _method?: string; + private _method: string; - get method(): string | undefined { + get method(): string { return this._method; } - private _headers?: Record; + private _headers: Record = {}; - get headers(): Readonly> | undefined { + get headers(): Readonly> { return this._headers; } + private _ca?: Buffer; + + get ca() { + return this._ca; + } + + private _pfx?: Buffer; + + get pfx() { + return this._pfx; + } + + private _passphrase?: string; + + get passphrase() { + return this._passphrase; + } + get secureEndpoint(): boolean { return this.url.startsWith('https'); } - constructor({ protocol, method, url, body, headers = {} }: RequestOptions) { + constructor({ + protocol, + method, + url, + body, + timeout, + correlationIdRegex, + maxContentSize, + encoding, + decompress = true, + headers = {} + }: RequestOptions) { this.protocol = protocol; this._method = method?.toUpperCase() ?? 'GET'; + this.validateUrl(url); - this.url = url; - this.setHeaders(headers); + this.url = url.trim(); + this.precheckBody(body); this.body = body; + + this.correlationIdRegex = + this.normalizeCorrelationIdRegex(correlationIdRegex); + + this.setHeaders(headers); + + this.encoding = encoding; + this.timeout = timeout; + this.maxContentSize = maxContentSize; + this.decompress = !!decompress; } public setHeaders(headers: Record): void { @@ -62,16 +113,17 @@ export class Request { ...headers }; - this._headers = Object.fromEntries( - Object.entries(mergedHeaders).map( - ([field, value]: [string, string | string[]]) => [ - field, + this._headers = Object.entries(mergedHeaders).reduce( + (result, [field, value]: [string, string | string[]]) => { + result[field] = Array.isArray(value) && Request.SINGLE_VALUE_HEADERS.has(field.toLowerCase()) ? value.join(', ') - : value - ] - ) + : value; + + return result; + }, + {} ); } @@ -88,4 +140,16 @@ export class Request { throw new Error('Body must be string.'); } } + + private normalizeCorrelationIdRegex( + correlationIdRegex: RegExp | string | undefined + ): RegExp | undefined { + if (correlationIdRegex) { + try { + return new RegExp(correlationIdRegex, 'i'); + } catch { + throw new Error('Correlation id must be regular expression.'); + } + } + } } diff --git a/packages/repeater/src/request-runner/Response.ts b/packages/repeater/src/request-runner/Response.ts index b522aa2d..f05b010c 100644 --- a/packages/repeater/src/request-runner/Response.ts +++ b/packages/repeater/src/request-runner/Response.ts @@ -3,8 +3,9 @@ import { Protocol } from '../models'; export class Response { public readonly protocol: Protocol; public readonly statusCode?: number; - public readonly headers?: Record; + public readonly headers?: Record; public readonly body?: string; + public readonly encoding?: 'base64'; public readonly message?: string; public readonly errorCode?: string; @@ -14,14 +15,16 @@ export class Response { headers, body, message, - errorCode + errorCode, + encoding }: { protocol: Protocol; statusCode?: number; message?: string; errorCode?: string; - headers?: Record; + headers?: Record; body?: string; + encoding?: 'base64'; }) { this.protocol = protocol; this.statusCode = statusCode; @@ -29,5 +32,6 @@ export class Response { this.body = body; this.errorCode = errorCode; this.message = message; + this.encoding = encoding; } } diff --git a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts index 00762e75..623b332d 100644 --- a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts +++ b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts @@ -81,7 +81,10 @@ export class HttpRequestRunner implements RequestRunner { return new Response({ protocol: this.protocol, statusCode: response.statusCode, - headers: response.headers, + headers: (response.headers ?? {}) as unknown as Record< + string, + string | string[] + >, body: response.body }); } catch (err) {