From aa12bfa48fff4294a0a09e20cff0844d2c901e37 Mon Sep 17 00:00:00 2001 From: dostrikov Date: Wed, 19 Jun 2024 19:21:51 +0700 Subject: [PATCH] feat(repeater): refrain from utilizing non standard ports (#197) closes #196 --- package-lock.json | 777 +++++++++++++++--- package.json | 16 +- packages/repeater/README.md | 6 +- .../src/api/DefaultRepeatersManager.ts | 1 - .../api/ExecuteRequestEventHandler.spec.ts | 23 +- .../src/api/ExecuteRequestEventHandler.ts | 4 +- .../api/commands/RegisterRepeaterCommand.ts | 29 - packages/repeater/src/api/commands/index.ts | 6 - .../src/api/events/RepeaterStatusEvent.ts | 13 - packages/repeater/src/api/events/index.ts | 1 - packages/repeater/src/api/index.ts | 8 - packages/repeater/src/index.ts | 8 +- .../src/lib/DefaultRepeaterCommands.spec.ts | 62 ++ .../src/lib/DefaultRepeaterCommands.ts | 25 + .../src/lib/DefaultRepeaterServer.spec.ts | 216 +++++ .../repeater/src/lib/DefaultRepeaterServer.ts | 374 +++++++++ packages/repeater/src/lib/Repeater.spec.ts | 428 ++++++---- packages/repeater/src/lib/Repeater.ts | 246 +++--- packages/repeater/src/lib/RepeaterCommands.ts | 7 + .../repeater/src/lib/RepeaterFactory.spec.ts | 173 +--- packages/repeater/src/lib/RepeaterFactory.ts | 88 +- packages/repeater/src/lib/RepeaterServer.ts | 125 +++ packages/repeater/src/lib/index.ts | 13 +- packages/repeater/src/models/Protocol.ts | 3 +- packages/repeater/src/register.ts | 41 +- .../src/request-runner/Request.spec.ts | 11 - .../repeater/src/request-runner/Request.ts | 57 +- .../src/request-runner/Response.spec.ts | 4 +- .../repeater/src/request-runner/Response.ts | 10 +- .../protocols/HttpRequestRunner.spec.ts | 276 ++++--- .../protocols/HttpRequestRunner.ts | 239 +++--- .../protocols/WsRequestRunner.spec.ts | 133 --- .../protocols/WsRequestRunner.ts | 207 ----- .../src/request-runner/protocols/index.ts | 1 - .../src/utils/DefaultProxyFactory.spec.ts | 105 +++ .../repeater/src/utils/DefaultProxyFactory.ts | 83 ++ ...ormalizeZlibDeflateTransformStream.spec.ts | 47 ++ .../NormalizeZlibDeflateTransformStream.ts | 23 + .../src/utils/PatchedHttpsProxyAgent.ts | 28 + packages/repeater/src/utils/ProxyFactory.ts | 22 + packages/repeater/src/utils/index.ts | 3 + 41 files changed, 2661 insertions(+), 1281 deletions(-) delete mode 100644 packages/repeater/src/api/commands/RegisterRepeaterCommand.ts delete mode 100644 packages/repeater/src/api/events/RepeaterStatusEvent.ts delete mode 100644 packages/repeater/src/api/events/index.ts create mode 100644 packages/repeater/src/lib/DefaultRepeaterCommands.spec.ts create mode 100644 packages/repeater/src/lib/DefaultRepeaterCommands.ts create mode 100644 packages/repeater/src/lib/DefaultRepeaterServer.spec.ts create mode 100644 packages/repeater/src/lib/DefaultRepeaterServer.ts create mode 100644 packages/repeater/src/lib/RepeaterCommands.ts create mode 100644 packages/repeater/src/lib/RepeaterServer.ts delete mode 100644 packages/repeater/src/request-runner/protocols/WsRequestRunner.spec.ts delete mode 100644 packages/repeater/src/request-runner/protocols/WsRequestRunner.ts create mode 100644 packages/repeater/src/utils/DefaultProxyFactory.spec.ts create mode 100644 packages/repeater/src/utils/DefaultProxyFactory.ts create mode 100644 packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts create mode 100644 packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.ts create mode 100644 packages/repeater/src/utils/PatchedHttpsProxyAgent.ts create mode 100644 packages/repeater/src/utils/ProxyFactory.ts create mode 100644 packages/repeater/src/utils/index.ts diff --git a/package-lock.json b/package-lock.json index cf0a4d80..ebac391d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,15 +20,20 @@ "chalk": "^4.1.2", "ci-info": "^3.3.0", "content-type": "^1.0.4", + "fast-content-type-parse": "^1.1.0", "form-data": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.4", + "iconv-lite": "^0.6.3", "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", "tty-table": "^4.1.5", - "uuid": "^8.3.2", - "ws": "^8.17.1" + "uuid": "^8.3.2" }, "devDependencies": { "@commitlint/cli": "^17.0.3", @@ -50,7 +55,6 @@ "@types/request-promise": "^4.1.48", "@types/semver": "^7.3.9", "@types/uuid": "^8.3.4", - "@types/ws": "^8.5.3", "@typescript-eslint/eslint-plugin": "5.33.1", "@typescript-eslint/parser": "5.33.1", "eslint": "8.15.0", @@ -66,6 +70,7 @@ "nx": "14.5.6", "prettier": "2.7.1", "semantic-release": "~19.0.3", + "socket.io": "^4.7.5", "ts-jest": "27.1.4", "ts-mockito": "^2.6.1", "typescript": "4.7.4" @@ -2287,6 +2292,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 +2374,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", @@ -2457,6 +2480,21 @@ "integrity": "sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==", "dev": true }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "8.4.5", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.5.tgz", @@ -2634,15 +2672,6 @@ "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", "dev": true }, - "node_modules/@types/ws": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", - "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/yargs": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", @@ -3006,6 +3035,19 @@ "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==", "dev": true }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", @@ -3484,6 +3526,15 @@ } ] }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", @@ -3550,12 +3601,12 @@ } }, "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "dev": true, "dependencies": { - "fill-range": "^7.1.1" + "fill-range": "^7.0.1" }, "engines": { "node": ">=8" @@ -4055,6 +4106,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", @@ -4182,6 +4241,15 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/copy-webpack-plugin": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", @@ -4267,6 +4335,19 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cosmiconfig": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", @@ -4754,6 +4835,88 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", + "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", + "dev": true, + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0" + }, + "engines": { + "node": ">=10.2.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-client/node_modules/ws": { + "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" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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/engine.io/node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "dev": true, + "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 + } + } + }, "node_modules/enhanced-resolve": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", @@ -5571,6 +5734,11 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5690,9 +5858,9 @@ } }, "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -6360,30 +6528,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": { @@ -6411,12 +6598,11 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" @@ -7820,6 +8006,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", @@ -8775,6 +8988,15 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -8924,6 +9146,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/notepack.io": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", + "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==" + }, "node_modules/npm": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/npm/-/npm-8.12.0.tgz", @@ -11552,6 +11779,15 @@ "node": ">=12" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -12638,8 +12874,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/saxes": { "version": "5.0.1", @@ -13220,6 +13455,90 @@ "node": ">=6" } }, + "node_modules/socket.io": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", + "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "dev": true, + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz", + "integrity": "sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==", + "dev": true, + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.11.0" + } + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "dev": true, + "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 + } + } + }, + "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", @@ -14452,6 +14771,15 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -14617,6 +14945,18 @@ "iconv-lite": "0.4.24" } }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", @@ -14753,26 +15093,6 @@ "typedarray-to-buffer": "^3.1.5" } }, - "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "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 - } - } - }, "node_modules/xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", @@ -14785,6 +15105,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", @@ -16726,6 +17054,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 +17124,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", @@ -16884,6 +17227,21 @@ "integrity": "sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==", "dev": true }, + "@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/eslint": { "version": "8.4.5", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.5.tgz", @@ -17060,15 +17418,6 @@ "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", "dev": true }, - "@types/ws": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", - "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/yargs": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", @@ -17343,6 +17692,16 @@ "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==", "dev": true }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, "acorn": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", @@ -17693,6 +18052,12 @@ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true }, + "base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true + }, "before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", @@ -17755,12 +18120,12 @@ } }, "braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "dev": true, "requires": { - "fill-range": "^7.1.1" + "fill-range": "^7.0.1" } }, "breakword": { @@ -18116,6 +18481,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", @@ -18220,6 +18590,12 @@ } } }, + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true + }, "copy-webpack-plugin": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", @@ -18276,6 +18652,16 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "cosmiconfig": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", @@ -18661,6 +19047,58 @@ "once": "^1.4.0" } }, + "engine.io": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", + "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", + "dev": true, + "requires": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0" + }, + "dependencies": { + "ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "dev": true, + "requires": {} + } + } + }, + "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" + }, + "dependencies": { + "ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "requires": {} + } + } + }, + "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", @@ -19267,6 +19705,11 @@ "jest-message-util": "^27.5.1" } }, + "fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==" + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -19372,9 +19815,9 @@ } }, "fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "dev": true, "requires": { "to-regex-range": "^5.0.1" @@ -19870,24 +20313,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": { @@ -19903,12 +20363,11 @@ "dev": true }, "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "requires": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "identity-obj-proxy": { @@ -20940,6 +21399,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", @@ -21627,6 +22107,12 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true + }, "neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -21747,6 +22233,11 @@ "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "dev": true }, + "notepack.io": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", + "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==" + }, "npm": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/npm/-/npm-8.12.0.tgz", @@ -23594,6 +24085,12 @@ } } }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true + }, "object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -24367,8 +24864,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "saxes": { "version": "5.0.1", @@ -24820,6 +25316,69 @@ } } }, + "socket.io": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", + "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "dev": true, + "requires": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + } + }, + "socket.io-adapter": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz", + "integrity": "sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==", + "dev": true, + "requires": { + "debug": "~4.3.4", + "ws": "~8.11.0" + }, + "dependencies": { + "ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "dev": true, + "requires": {} + } + } + }, + "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": "~3.0.1" + } + }, + "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", @@ -25775,6 +26334,12 @@ "spdx-expression-parse": "^3.0.0" } }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -25900,6 +26465,17 @@ "dev": true, "requires": { "iconv-lite": "0.4.24" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } } }, "whatwg-mimetype": { @@ -26013,12 +26589,6 @@ "typedarray-to-buffer": "^3.1.5" } }, - "ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "requires": {} - }, "xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", @@ -26031,6 +26601,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", diff --git a/package.json b/package.json index b74f12c1..af166df5 100644 --- a/package.json +++ b/package.json @@ -83,15 +83,20 @@ "chalk": "^4.1.2", "ci-info": "^3.3.0", "content-type": "^1.0.4", + "fast-content-type-parse": "^1.1.0", "form-data": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.4", + "iconv-lite": "^0.6.3", "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", "tty-table": "^4.1.5", - "uuid": "^8.3.2", - "ws": "^8.17.1" + "uuid": "^8.3.2" }, "devDependencies": { "@commitlint/cli": "^17.0.3", @@ -113,7 +118,6 @@ "@types/request-promise": "^4.1.48", "@types/semver": "^7.3.9", "@types/uuid": "^8.3.4", - "@types/ws": "^8.5.3", "@typescript-eslint/eslint-plugin": "5.33.1", "@typescript-eslint/parser": "5.33.1", "eslint": "8.15.0", @@ -129,8 +133,14 @@ "nx": "14.5.6", "prettier": "2.7.1", "semantic-release": "~19.0.3", + "socket.io": "^4.7.5", "ts-jest": "27.1.4", "ts-mockito": "^2.6.1", "typescript": "4.7.4" + }, + "overrides": { + "socket.io-msgpack-parser": { + "notepack.io": "~3.0.1" + } } } diff --git a/packages/repeater/README.md b/packages/repeater/README.md index cd529543..79cf4e27 100644 --- a/packages/repeater/README.md +++ b/packages/repeater/README.md @@ -169,8 +169,8 @@ describe('Scan', () => { ### Implementation details -Under the hood `Repeater` register `ExecuteRequestEventHandler` in bus, -which in turn uses the `RequestRunner` to proceed with request: +Under the hood, `Repeater` connects to the Bright engine using the WebSocket protocol and then listens for incoming commands from the engine. +These commands are executed by the `RequestRunner` to process the requests coming from the engine: ```ts export interface RequestRunner { @@ -179,7 +179,7 @@ export interface RequestRunner { } ``` -Package contains `RequestRunner` implementations for both HTTP and WS protocols. +Package contains `RequestRunner` implementations for HTTP protocol only. To support other protocol new class implementation of `RequestRunner` should be registered in global IoC container: ```ts diff --git a/packages/repeater/src/api/DefaultRepeatersManager.ts b/packages/repeater/src/api/DefaultRepeatersManager.ts index a2da95b3..7b199b36 100644 --- a/packages/repeater/src/api/DefaultRepeatersManager.ts +++ b/packages/repeater/src/api/DefaultRepeatersManager.ts @@ -42,7 +42,6 @@ export class DefaultRepeatersManager implements RepeatersManager { ...(projectId ? { projectIds: [projectId] } : {}) }) ); - if (!repeater?.id) { throw new Error('Cannot create a new repeater.'); } diff --git a/packages/repeater/src/api/ExecuteRequestEventHandler.spec.ts b/packages/repeater/src/api/ExecuteRequestEventHandler.spec.ts index 0807442d..6897eb9a 100644 --- a/packages/repeater/src/api/ExecuteRequestEventHandler.spec.ts +++ b/packages/repeater/src/api/ExecuteRequestEventHandler.spec.ts @@ -1,8 +1,8 @@ import 'reflect-metadata'; import { ExecuteRequestEventHandler } from './ExecuteRequestEventHandler'; import { Protocol } from '../models'; -import { Request, RequestRunner } from '../request-runner'; -import { anything, capture, instance, mock, reset, when } from 'ts-mockito'; +import { RequestRunner } from '../request-runner'; +import { anything, instance, mock, reset, when } from 'ts-mockito'; describe('ExecuteRequestEventHandler', () => { const requestRunnerResponse = { @@ -65,24 +65,5 @@ describe('ExecuteRequestEventHandler', () => { await expect(res).rejects.toThrow(`Unsupported protocol "http"`); }); - - it('`correlation_id_regex` should become `correlationIdRegex` in runner input', async () => { - const payload = { - protocol: Protocol.HTTP, - url: 'http://foo.bar/', - headers: {}, - correlation_id_regex: 'baz' - }; - const handler = new ExecuteRequestEventHandler([ - instance(mockedRequestRunner) - ]); - - await handler.handle(payload); - - const [request]: [Request] = capture( - mockedRequestRunner.run - ).first(); - expect(request.correlationIdRegex).toBeInstanceOf(RegExp); - }); }); }); diff --git a/packages/repeater/src/api/ExecuteRequestEventHandler.ts b/packages/repeater/src/api/ExecuteRequestEventHandler.ts index 36d129be..f8dd43d1 100644 --- a/packages/repeater/src/api/ExecuteRequestEventHandler.ts +++ b/packages/repeater/src/api/ExecuteRequestEventHandler.ts @@ -42,9 +42,7 @@ export class ExecuteRequestEventHandler throw new Error(`Unsupported protocol "${protocol}"`); } - const response: Response = await runner.run( - new Request({ ...event, correlationIdRegex: event.correlation_id_regex }) - ); + const response: Response = await runner.run(new Request({ ...event })); const { statusCode, message, errorCode, body, headers } = response; diff --git a/packages/repeater/src/api/commands/RegisterRepeaterCommand.ts b/packages/repeater/src/api/commands/RegisterRepeaterCommand.ts deleted file mode 100644 index 75bc759c..00000000 --- a/packages/repeater/src/api/commands/RegisterRepeaterCommand.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Command } from '@sectester/core'; - -export interface RegisterRepeaterCommandPayload { - version: string; - repeaterId: string; -} - -export type RegisterRepeaterResult = - | { - version: string; - script: string | Record; - } - | { error: RepeaterRegisteringError }; - -export enum RepeaterRegisteringError { - NOT_ACTIVE = 'not_active', - BUSY = 'busy', - REQUIRES_TO_BE_UPDATED = 'requires_to_be_updated', - NOT_FOUND = 'not_found' -} - -export class RegisterRepeaterCommand extends Command< - RegisterRepeaterCommandPayload, - { payload: RegisterRepeaterResult } -> { - constructor(payload: RegisterRepeaterCommandPayload) { - super(payload, { type: 'RepeaterRegistering' }); - } -} diff --git a/packages/repeater/src/api/commands/index.ts b/packages/repeater/src/api/commands/index.ts index aea128ac..e1bd9204 100644 --- a/packages/repeater/src/api/commands/index.ts +++ b/packages/repeater/src/api/commands/index.ts @@ -1,9 +1,3 @@ export { CreateRepeaterRequest } from './CreateRepeaterRequest'; export { DeleteRepeaterRequest } from './DeleteRepeaterRequest'; export { GetRepeaterRequest } from './GetRepeaterRequest'; -export { - RegisterRepeaterCommand, - RegisterRepeaterCommandPayload, - RegisterRepeaterResult, - RepeaterRegisteringError -} from './RegisterRepeaterCommand'; diff --git a/packages/repeater/src/api/events/RepeaterStatusEvent.ts b/packages/repeater/src/api/events/RepeaterStatusEvent.ts deleted file mode 100644 index 956eeb49..00000000 --- a/packages/repeater/src/api/events/RepeaterStatusEvent.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { RepeaterStatus } from '../../lib'; -import { Event } from '@sectester/core'; - -interface RepeaterStatusEventPayload { - repeaterId: string; - status: RepeaterStatus; -} - -export class RepeaterStatusEvent extends Event { - constructor(payload: RepeaterStatusEventPayload) { - super(payload, 'RepeaterStatusUpdated'); - } -} diff --git a/packages/repeater/src/api/events/index.ts b/packages/repeater/src/api/events/index.ts deleted file mode 100644 index a16866ed..00000000 --- a/packages/repeater/src/api/events/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './RepeaterStatusEvent'; diff --git a/packages/repeater/src/api/index.ts b/packages/repeater/src/api/index.ts index fdd8cb76..af424c58 100644 --- a/packages/repeater/src/api/index.ts +++ b/packages/repeater/src/api/index.ts @@ -1,10 +1,2 @@ -export { - RepeaterRegisteringError, - RegisterRepeaterResult, - RegisterRepeaterCommandPayload, - RegisterRepeaterCommand -} from './commands'; -export { RepeaterStatusEvent } from './events'; export * from './RepeatersManager'; export * from './DefaultRepeatersManager'; -export * from './ExecuteRequestEventHandler'; diff --git a/packages/repeater/src/index.ts b/packages/repeater/src/index.ts index fbce4bbd..dc35f21f 100644 --- a/packages/repeater/src/index.ts +++ b/packages/repeater/src/index.ts @@ -1,11 +1,5 @@ import './register'; - -export { - RepeatersManager, - ExecuteRequestEventHandler, - ExecuteRequestPayload, - ExecuteRequestResult -} from './api'; +export { RepeatersManager } from './api'; export * from './lib'; export * from './models'; export * from './request-runner'; diff --git a/packages/repeater/src/lib/DefaultRepeaterCommands.spec.ts b/packages/repeater/src/lib/DefaultRepeaterCommands.spec.ts new file mode 100644 index 00000000..e575cc4b --- /dev/null +++ b/packages/repeater/src/lib/DefaultRepeaterCommands.spec.ts @@ -0,0 +1,62 @@ +import { DefaultRepeaterCommands } from './DefaultRepeaterCommands'; +import { Protocol } from '../models/Protocol'; +import { RequestRunner, Request, Response } from '../request-runner'; +import { instance, mock, reset, when } from 'ts-mockito'; + +describe('DefaultRepeaterCommands', () => { + let sut!: DefaultRepeaterCommands; + + const mockedRequestRunner = mock(); + + beforeEach(() => { + sut = new DefaultRepeaterCommands([instance(mockedRequestRunner)]); + }); + + afterEach(() => reset(mockedRequestRunner)); + + describe('sendRequest', () => { + it('should send', async () => { + // arrange + const request = new Request({ + protocol: Protocol.HTTP, + url: 'http://foo.bar', + method: 'GET' + }); + + const response = new Response({ + protocol: Protocol.HTTP, + statusCode: 200 + }); + + when(mockedRequestRunner.protocol).thenReturn(Protocol.HTTP); + when(mockedRequestRunner.run(request)).thenResolve(response); + + // act + const result = await sut.sendRequest(request); + + // assert + expect(result).toEqual(response); + }); + + it('should throw when there are no suitable protocol handler', async () => { + // arrange + const request = new Request({ + protocol: Protocol.HTTP, + url: 'http://foo.bar', + method: 'GET' + }); + + when(mockedRequestRunner.protocol).thenReturn( + 'someOtherProtocol' as Protocol + ); + + // act + const act = () => sut.sendRequest(request); + + // assert + await expect(act).rejects.toThrow( + `Unsupported protocol "${Protocol.HTTP}"` + ); + }); + }); +}); diff --git a/packages/repeater/src/lib/DefaultRepeaterCommands.ts b/packages/repeater/src/lib/DefaultRepeaterCommands.ts new file mode 100644 index 00000000..d899058d --- /dev/null +++ b/packages/repeater/src/lib/DefaultRepeaterCommands.ts @@ -0,0 +1,25 @@ +import { RepeaterCommands } from './RepeaterCommands'; +import { Request, Response, RequestRunner } from '../request-runner'; +import { injectable, injectAll } from 'tsyringe'; + +@injectable() +export class DefaultRepeaterCommands implements RepeaterCommands { + constructor( + @injectAll(RequestRunner) + private readonly requestRunners: RequestRunner[] + ) {} + + public async 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/lib/DefaultRepeaterServer.spec.ts b/packages/repeater/src/lib/DefaultRepeaterServer.spec.ts new file mode 100644 index 00000000..069dafb6 --- /dev/null +++ b/packages/repeater/src/lib/DefaultRepeaterServer.spec.ts @@ -0,0 +1,216 @@ +import { + DefaultRepeaterServer, + DefaultRepeaterServerOptions, + SocketEvents +} from './DefaultRepeaterServer'; +import { + RepeaterErrorCodes, + RepeaterServerEventHandler, + RepeaterServerEvents +} from './RepeaterServer'; +import { Protocol } from '../models/Protocol'; +import { RepeaterCommands } from './RepeaterCommands'; +import { delay, Logger } from '@sectester/core'; +import { anything, instance, mock, reset, verify, when } from 'ts-mockito'; +import { Server } from 'socket.io'; +import msgpack from 'socket.io-msgpack-parser'; +import { createServer, Server as HttpServer } from 'http'; + +class MockSocketServer { + private readonly httpServer: HttpServer; + private readonly io: Server; + + get address() { + const address = this.httpServer.address(); + if (typeof address === 'string') { + return address; + } + + return `http://localhost:${address?.port}`; + } + + constructor() { + this.httpServer = createServer(); + + this.httpServer.listen(0); + + this.io = new Server(this.httpServer, { + path: '/api/ws/v1', + parser: msgpack + }); + } + + public onConnection(callback: (socket: any) => void) { + this.io.on('connection', callback); + } + + public close() { + this.io.close(); + } + + public emit(event: string, data: any) { + this.io.sockets.emit(event, data); + } +} + +describe('DefaultRepeaterServer', () => { + const RepeaterId = 'fooId'; + + let sut!: DefaultRepeaterServer; + let mockSocketServer!: MockSocketServer; + + const mockedLogger = mock(); + const mockedDefaultRepeaterServerOptions = + mock(); + + beforeEach(() => { + mockSocketServer = new MockSocketServer(); + + sut = new DefaultRepeaterServer( + instance(mockedLogger), + instance(mockedDefaultRepeaterServerOptions) + ); + + const address = mockSocketServer.address; + + when(mockedDefaultRepeaterServerOptions.uri).thenReturn(address); + when(mockedDefaultRepeaterServerOptions.token).thenReturn('token'); + when(mockedDefaultRepeaterServerOptions.connectTimeout).thenReturn(10_00); + }); + + afterEach(() => { + sut.disconnect(); + + mockSocketServer.close(); + + reset( + mockedLogger, + mockedDefaultRepeaterServerOptions + ); + }); + + describe('connect', () => { + it('should connect', async () => { + // act + await sut.connect(RepeaterId); + + // assert + verify(mockedLogger.debug('Repeater connected to %s', anything())).once(); + }); + }); + + describe('deploy', () => { + it('should deploy', async () => { + // arrange + const event = { repeaterId: RepeaterId }; + + mockSocketServer.onConnection(socket => { + socket.on('deploy', () => { + socket.emit('deployed', event); + }); + }); + + const handler: RepeaterServerEventHandler = jest.fn(); + + sut.on(RepeaterServerEvents.DEPLOY, handler); + + await sut.connect(RepeaterId); + + // act + await sut.deploy({ repeaterId: RepeaterId }); + + // assert + expect(handler).toHaveBeenCalledWith(event); + }); + }); + + describe('disconnect', () => { + it('should disconnect', async () => { + // arrange + const handler: RepeaterServerEventHandler = jest.fn(); + + sut.on(RepeaterServerEvents.DEPLOY, handler); + + await sut.connect(RepeaterId); + + // act + const act = () => sut.disconnect(); + + // assert + expect(act).not.toThrow(); + }); + }); + + describe('on', () => { + it.each([ + { + input: { + event: SocketEvents.UPDATE_AVAILABLE, + data: { version: '1.0.0' } + }, + expected: { + event: RepeaterServerEvents.UPDATE_AVAILABLE, + data: [{ version: '1.0.0' }] + } + }, + { + input: { + event: SocketEvents.ERROR, + data: { code: RepeaterErrorCodes.UNKNOWN_ERROR, message: 'msg' } + }, + expected: { + event: RepeaterServerEvents.ERROR, + data: [{ code: RepeaterErrorCodes.UNKNOWN_ERROR, message: 'msg' }] + } + }, + { + input: { + event: SocketEvents.REQUEST, + data: { protocol: Protocol.HTTP, url: 'https://foo.com' } + }, + expected: { + event: RepeaterServerEvents.REQUEST, + data: [{ protocol: Protocol.HTTP, url: 'https://foo.com' }, undefined] + } + } + ])( + 'should propagate $input.event data to $expected.event', + async ({ input, expected }) => { + // arrange + const handler: RepeaterServerEventHandler = jest.fn(); + + sut.on(expected.event, handler); + + await sut.connect(RepeaterId); + + // act + mockSocketServer.emit(input.event, input.data); + + // assert + await delay(200); + expect(handler).toHaveBeenCalledWith(...expected.data); + } + ); + }); + + describe('off', () => { + it('should not invoke handler when it switched off', async () => { + // arrange + const event = { code: RepeaterErrorCodes.UNKNOWN_ERROR, message: 'msg' }; + + const handler: RepeaterServerEventHandler = jest.fn(); + + sut.on(RepeaterServerEvents.ERROR, handler); + + await sut.connect(RepeaterId); + + sut.off(RepeaterServerEvents.ERROR, handler); + + // act + mockSocketServer.emit(SocketEvents.ERROR, event); + + // assert + expect(handler).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/repeater/src/lib/DefaultRepeaterServer.ts b/packages/repeater/src/lib/DefaultRepeaterServer.ts new file mode 100644 index 00000000..3a62c764 --- /dev/null +++ b/packages/repeater/src/lib/DefaultRepeaterServer.ts @@ -0,0 +1,374 @@ +import 'reflect-metadata'; +import { + RepeaterServer, + RepeaterServerEventHandler, + RepeaterServerEventsMap, + CallbackFunction, + DeployCommandOptions, + RepeaterErrorCodes, + RepeaterServerDeployedEvent, + RepeaterServerErrorEvent, + RepeaterServerEvents, + RepeaterServerReconnectionAttemptedEvent, + RepeaterServerReconnectionFailedEvent, + RepeaterServerRequestEvent, + RepeaterServerRequestResponse, + RepeaterUpgradeAvailableEvent, + HandlerFunction +} from './RepeaterServer'; +import { Logger } from '@sectester/core'; +import { inject, injectable, Lifecycle, scoped } from 'tsyringe'; +import io, { Socket } from 'socket.io-client'; +import parser from 'socket.io-msgpack-parser'; +import { EventEmitter, once } from 'events'; +import { hostname } from 'os'; +import Timer = NodeJS.Timer; + +export interface DefaultRepeaterServerOptions { + readonly uri: string; + readonly token: string; + readonly connectTimeout?: number; + readonly proxyUrl?: string; +} + +export const DefaultRepeaterServerOptions: unique symbol = Symbol( + 'DefaultRepeaterServerOptions' +); + +export 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) => void; + [SocketEvents.UNDEPLOY]: () => void; + [SocketEvents.PING]: () => void; +} + +@scoped(Lifecycle.ContainerScoped) +@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 latestReconnectionError?: Error; + private pingTimer?: Timer; + private connectionTimer?: Timer; + private _socket?: Socket; + private connectionAttempts = 0; + private events = new EventEmitter(); + + private readonly handlerMap = new WeakMap< + RepeaterServerEventHandler, + HandlerFunction + >(); + + 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(namePrefix: string = hostname()) { + this._socket = io(this.options.uri, { + parser, + path: '/api/ws/v1', + transports: ['websocket'], + reconnectionAttempts: this.MAX_RECONNECTION_ATTEMPTS, + auth: { + domain: namePrefix + '123', + token: this.options.token + } + }); + + 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 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 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?: { + error?: Error; + }; + cause?: Error; + }; + const nestedError = description?.error ?? cause; + + if (nestedError) { + this.logger.debug('The error cause: %s', nestedError.message); + } + } + + 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('An error occurred', 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/lib/Repeater.spec.ts b/packages/repeater/src/lib/Repeater.spec.ts index a26f774b..0b61b963 100644 --- a/packages/repeater/src/lib/Repeater.spec.ts +++ b/packages/repeater/src/lib/Repeater.spec.ts @@ -1,15 +1,16 @@ -import 'reflect-metadata'; import { Repeater, RunningStatus } from './Repeater'; +import { Protocol } from '../models/Protocol'; +import { Request, Response } from '../request-runner'; import { - RegisterRepeaterCommand, - RepeaterRegisteringError, - RepeaterStatusEvent -} from '../api'; -import { Configuration, EventBus, Logger } from '@sectester/core'; + RepeaterErrorCodes, + RepeaterServer, + RepeaterServerEvents, + RepeaterServerRequestEvent +} from './RepeaterServer'; +import { RepeaterCommands } from './RepeaterCommands'; +import { delay, Logger } from '@sectester/core'; import { - anyOfClass, anything, - capture, instance, mock, objectContaining, @@ -17,230 +18,329 @@ import { verify, when } from 'ts-mockito'; -import { DependencyContainer } from 'tsyringe'; describe('Repeater', () => { - const version = '42.0.1'; - const repeaterId = 'fooId'; + const RepeaterId = 'fooId'; - let repeater!: Repeater; - const mockedConfiguration = mock(); - const mockedEventBus = mock(); - const mockedLogger = mock(); - const mockedContainer = mock(); + let sut!: Repeater; - const createRepater = () => - new Repeater({ - repeaterId, - bus: instance(mockedEventBus), - configuration: instance(mockedConfiguration) - }); + const mockedRepeaterServer = mock(); + const repeaterCommands = mock(); + const mockedLogger = mock(); 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(); - }); + when(mockedRepeaterServer.deploy(anything())).thenResolve({ + repeaterId: RepeaterId + }); - afterEach(() => { - reset( - mockedConfiguration, - mockedEventBus, - mockedLogger, - mockedContainer + sut = new Repeater( + RepeaterId, + instance(mockedLogger), + instance(mockedRepeaterServer), + instance(repeaterCommands) ); - - jest.useRealTimers(); }); + afterEach(() => + reset( + mockedRepeaterServer, + repeaterCommands, + mockedLogger + ) + ); + describe('start', () => { it('should start', async () => { - await repeater.start(); - - verify( - mockedEventBus.execute( - objectContaining({ - type: 'RepeaterRegistering', - payload: { - repeaterId, - version - } - }) - ) - ).once(); + // act + await sut.start(); + // assert + verify(mockedRepeaterServer.connect()).once(); verify( - mockedEventBus.publish( - objectContaining({ - type: 'RepeaterStatusUpdated', - payload: { - repeaterId, - status: 'connected' - } - }) + mockedRepeaterServer.deploy( + objectContaining({ repeaterId: RepeaterId }) ) ).once(); }); - it('should throw an error on failed registration', async () => { - when( - mockedEventBus.execute(anyOfClass(RegisterRepeaterCommand)) - ).thenResolve(); + it('should throw when underlying connect throws', async () => { + // arrange + when(mockedRepeaterServer.connect()).thenReject(new Error('foo')); - await expect(repeater.start()).rejects.toThrow( - 'Error registering repeater.' - ); + // act + const act = () => sut.start(); + + // assert + await expect(act).rejects.toThrowError('foo'); }); - it('should send ping periodically', async () => { - await repeater.start(); - jest.advanceTimersByTime(15000); - jest.runOnlyPendingTimers(); + it('should throw when underlying deploy throws', async () => { + // arrange + when(mockedRepeaterServer.deploy(anything())).thenReject( + new Error('foo') + ); - verify( - mockedEventBus.publish( - objectContaining({ - type: 'RepeaterStatusUpdated', - payload: { - repeaterId, - status: 'connected' - } - }) - ) - ).thrice(); + // act + const act = () => sut.start(); + + // assert + await expect(act).rejects.toThrowError('foo'); }); it('should have RunningStatus.STARTING just after start() call', () => { - void repeater.start(); - expect(repeater.runningStatus).toBe(RunningStatus.STARTING); + // act + void sut.start(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.STARTING); }); it('should have RunningStatus.RUNNING after successful start()', async () => { - await repeater.start(); - expect(repeater.runningStatus).toBe(RunningStatus.RUNNING); + // act + await sut.start(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.RUNNING); }); it('should throw an error on start() twice', async () => { - await repeater.start(); + // arrange + await sut.start(); - const res = repeater.start(); + // act + const res = sut.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 } }); - - 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); - } - ); + // act + when(mockedRepeaterServer.connect()).thenReject().thenResolve(); + + // assert + await expect(sut.start()).rejects.toThrow(); + await expect(sut.start()).resolves.not.toThrow(); + }); + + it(`should subscribe to ${RepeaterServerEvents.UPDATE_AVAILABLE} and proceed on event`, async () => { + // arrange + const event = { version: '1.0.0' }; - 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 } + mockedRepeaterServer.on( + RepeaterServerEvents.UPDATE_AVAILABLE, + anything() + ) + ).thenCall((_, handler) => handler(event)); + + // act + await sut.start(); + + // assert + verify( + mockedLogger.warn( + '%s: A new Repeater version (%s) is available, for update instruction visit https://docs.brightsec.com/docs/installation-options', + anything(), + '1.0.0' + ) + ).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.REQUEST} and proceed on event`, async () => { + // arrange + const event: RepeaterServerRequestEvent = { + protocol: Protocol.HTTP, + url: 'http://foo.bar', + method: 'GET' + }; + + const request = new Request(event); + + const response = new Response({ + protocol: Protocol.HTTP, + statusCode: 200 }); - await repeater.start(); + when( + mockedRepeaterServer.on(RepeaterServerEvents.REQUEST, anything()) + ).thenCall((_, handler) => setImmediate(() => handler(event))); + + when(repeaterCommands.sendRequest(objectContaining(request))).thenResolve( + response + ); + + // act + await sut.start(); - const [arg]: string[] = capture(mockedLogger.warn).first(); - expect(arg).toContain('A new Repeater version (%s) is available'); + // assert + await delay(200); + verify(repeaterCommands.sendRequest(objectContaining(request))).once(); }); - }); - describe('stop', () => { - it('should stop', async () => { - await repeater.start(); - await repeater.stop(); + it(`should subscribe to ${RepeaterServerEvents.RECONNECT_ATTEMPT} and proceed on event`, async () => { + // arrange + const event = { + attempt: 1, + maxAttempts: 3 + }; + when( + mockedRepeaterServer.on( + RepeaterServerEvents.RECONNECT_ATTEMPT, + anything() + ) + ).thenCall((_, handler) => handler(event)); + + // act + await sut.start(); + + // assert verify( - mockedEventBus.publish( - objectContaining({ - type: 'RepeaterStatusUpdated', - payload: { - repeaterId, - status: 'disconnected' - } - }) + mockedLogger.warn( + 'Failed to connect to Bright cloud (attempt %d/%d)', + anything(), + anything() ) ).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on error`, async () => { + // arrange + const event = { + code: RepeaterErrorCodes.UNKNOWN_ERROR, + message: 'error' + }; + + when( + mockedRepeaterServer.on(RepeaterServerEvents.ERROR, anything()) + ).thenCall((_, handler) => handler(event)); + + // act + await sut.start(); + + // assert + verify(mockedLogger.error('error')).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.ERROR} and proceed on critical error`, async () => { + // arrange + const event = { + code: RepeaterErrorCodes.UNEXPECTED_ERROR, + message: 'unexpected error', + remediation: 'remediation' + }; - jest.advanceTimersByTime(25000); - jest.runOnlyPendingTimers(); + when( + mockedRepeaterServer.on(RepeaterServerEvents.ERROR, anything()) + ).thenCall((_, handler) => setImmediate(() => handler(event))); + // act + await sut.start(); + + // assert + await delay(200); verify( - mockedEventBus.publish( - objectContaining({ payload: { status: 'connected' } }) + mockedLogger.error( + '%s: %s. %s', + anything(), + 'unexpected error', + 'remediation' + ) + ).once(); + verify(mockedRepeaterServer.disconnect()).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_FAILED} and proceed on event`, async () => { + // arrange + const error = new Error('test error'); + const event = { + error + }; + + when( + mockedRepeaterServer.on( + RepeaterServerEvents.RECONNECTION_FAILED, + anything() ) + ).thenCall((_, handler) => setImmediate(() => handler(event))); + + // act + await sut.start(); + + // assert + await delay(200); + verify(mockedLogger.error(error.message)).once(); + verify(mockedRepeaterServer.disconnect()).once(); + }); + + it(`should subscribe to ${RepeaterServerEvents.RECONNECTION_SUCCEEDED} and proceed on event`, async () => { + // arrange + when( + mockedRepeaterServer.on( + RepeaterServerEvents.RECONNECTION_SUCCEEDED, + anything() + ) + ).thenCall((_, handler) => handler()); + + // act + await sut.start(); + + // assert + verify( + mockedLogger.log('The Repeater (%s) connected', RepeaterId) ).once(); }); + }); + + describe('stop', () => { + it('should stop', async () => { + // arrange + await sut.start(); + + // act + await sut.stop(); + + // assert + verify(mockedRepeaterServer.disconnect()).once(); + }); it('should have RunningStatus.OFF after start() and stop()', async () => { - await repeater.start(); - await repeater.stop(); - expect(repeater.runningStatus).toBe(RunningStatus.OFF); + // arrange + await sut.start(); + + // act + await sut.stop(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); }); it('should do nothing on stop() without start()', async () => { - await repeater.stop(); - expect(repeater.runningStatus).toBe(RunningStatus.OFF); + // act + await sut.stop(); + + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); }); it('should do nothing on second stop() call', async () => { - await repeater.start(); - await repeater.stop(); - await repeater.stop(); + // arrange + await sut.start(); + await sut.stop(); + + // assert + await sut.stop(); - expect(repeater.runningStatus).toBe(RunningStatus.OFF); + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); }); }); describe('runningStatus', () => { it('should have RunningStatus.OFF initially', () => { - expect(repeater.runningStatus).toBe(RunningStatus.OFF); + // assert + expect(sut.runningStatus).toBe(RunningStatus.OFF); }); }); }); diff --git a/packages/repeater/src/lib/Repeater.ts b/packages/repeater/src/lib/Repeater.ts index 8c50a88f..e59475c2 100644 --- a/packages/repeater/src/lib/Repeater.ts +++ b/packages/repeater/src/lib/Repeater.ts @@ -1,15 +1,18 @@ import { - ExecuteRequestEventHandler, - RegisterRepeaterCommand, - RegisterRepeaterResult, - RepeaterRegisteringError, - RepeaterStatusEvent -} from '../api'; -import { RepeaterStatus } from '../models'; -import { Configuration, EventBus, Logger } from '@sectester/core'; -import { gt } from 'semver'; + RepeaterServer, + RepeaterErrorCodes, + RepeaterServerErrorEvent, + RepeaterServerEvents, + RepeaterServerReconnectionAttemptedEvent, + RepeaterServerReconnectionFailedEvent, + RepeaterServerRequestEvent, + RepeaterUpgradeAvailableEvent +} from './RepeaterServer'; +import { RepeaterCommands } from './RepeaterCommands'; +import { Request } from '../request-runner/Request'; +import { Logger } from '@sectester/core'; import chalk from 'chalk'; -import Timer = NodeJS.Timer; +import { inject, injectable, Lifecycle, scoped } from 'tsyringe'; export enum RunningStatus { OFF, @@ -20,37 +23,24 @@ export enum RunningStatus { export type RepeaterId = string; export const RepeaterId = Symbol('RepeaterId'); +@scoped(Lifecycle.ContainerScoped) +@injectable() export class Repeater { - public readonly repeaterId: RepeaterId; - - private readonly bus: EventBus; - private readonly configuration: Configuration; - private readonly logger: Logger; - - private timer?: Timer; - private _runningStatus = RunningStatus.OFF; get runningStatus(): RunningStatus { return this._runningStatus; } - constructor({ - repeaterId, - bus, - configuration - }: { - repeaterId: RepeaterId; - bus: EventBus; - configuration: Configuration; - }) { - this.repeaterId = repeaterId; - this.bus = bus; - this.configuration = configuration; - - const { container } = this.configuration; - this.logger = container.resolve(Logger); - } + constructor( + @inject(RepeaterId) + public readonly repeaterId: RepeaterId, + private readonly logger: Logger, + @inject(RepeaterServer) + private readonly repeaterServer: RepeaterServer, + @inject(RepeaterCommands) + private readonly repeaterCommands: RepeaterCommands + ) {} public async start(): Promise { if (this.runningStatus !== RunningStatus.OFF) { @@ -60,9 +50,7 @@ export class Repeater { this._runningStatus = RunningStatus.STARTING; try { - await this.register(); - await this.subscribeToEvents(); - await this.schedulePing(); + await this.connect(); this._runningStatus = RunningStatus.RUNNING; } catch (e) { @@ -78,85 +66,147 @@ export class Repeater { this._runningStatus = RunningStatus.OFF; - if (this.timer) { - clearInterval(this.timer); - } + this.repeaterServer.disconnect(); - await this.sendStatus('disconnected'); - await this.bus.destroy?.(); + return Promise.resolve(); } - private async register(): Promise { - const res = await this.bus.execute( - new RegisterRepeaterCommand({ - version: this.configuration.repeaterVersion, - repeaterId: this.repeaterId - }) - ); + private async connect(): Promise { + this.logger.log('Connecting the Bridges'); - if (!res) { - throw new Error('Error registering repeater.'); - } + this.subscribeDiagnosticEvents(); - this.handleRegisterResult(res); + await this.repeaterServer.connect(); + + this.logger.log('Deploying the repeater'); + + await this.deploy(); + + this.logger.log('The Repeater (%s) started', this.repeaterId); + + this.subscribeConnectedEvent(); } - private async subscribeToEvents(): Promise { - await Promise.all( - [ - ExecuteRequestEventHandler - // TODO repeater scripts - ].map(type => this.bus.register(type)) - ); + private async deploy() { + await this.repeaterServer.deploy({ + repeaterId: this.repeaterId + }); } - private async schedulePing(): Promise { - await this.sendStatus('connected'); - this.timer = setInterval(() => this.sendStatus('connected'), 10000); - this.timer.unref(); + private subscribeConnectedEvent() { + this.repeaterServer.on(RepeaterServerEvents.CONNECTED, this.deploy); } - private async sendStatus(status: RepeaterStatus): Promise { - await this.bus.publish( - new RepeaterStatusEvent({ - status, - repeaterId: this.repeaterId - }) + private subscribeDiagnosticEvents() { + 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, + this.upgradeAvailable + ); + this.repeaterServer.on( + RepeaterServerEvents.RECONNECT_ATTEMPT, + this.reconnectAttempt + ); + this.repeaterServer.on(RepeaterServerEvents.RECONNECTION_SUCCEEDED, () => + this.logger.log('The Repeater (%s) connected', this.repeaterId) ); } - private handleRegisterResult(res: { payload: RegisterRepeaterResult }): void { - const { payload } = res; + private handleError = ({ + code, + message, + remediation + }: RepeaterServerErrorEvent) => { + const normalizedMessage = this.normalizeMessage(message); + const normalizedRemediation = this.normalizeMessage(remediation ?? ''); - if ('error' in payload) { - this.handleRegisterError(payload.error); + if (this.isCriticalError(code)) { + this.handleCriticalError(normalizedMessage, normalizedRemediation); } 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 - ); - } + this.logger.error(normalizedMessage); } + }; + + private normalizeMessage(message: string): string { + return message.replace(/\.$/, ''); } - 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.` - ); - } + 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.stop().catch(this.logger.error); + } + + private upgradeAvailable = (event: RepeaterUpgradeAvailableEvent) => { + 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'), + event.version + ); + }; + + private reconnectAttempt = ({ + attempt, + maxAttempts + }: RepeaterServerReconnectionAttemptedEvent) => { + this.logger.warn( + 'Failed to connect to Bright cloud (attempt %d/%d)', + attempt, + maxAttempts + ); + }; + + private reconnectionFailed = ({ + error + }: RepeaterServerReconnectionFailedEvent) => { + this.logger.error(error.message); + this.stop().catch(this.logger.error); + }; + + private requestReceived = async (event: RepeaterServerRequestEvent) => { + const response = await this.repeaterCommands.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/lib/RepeaterCommands.ts b/packages/repeater/src/lib/RepeaterCommands.ts new file mode 100644 index 00000000..adf1e7ac --- /dev/null +++ b/packages/repeater/src/lib/RepeaterCommands.ts @@ -0,0 +1,7 @@ +import { Request, Response } from '../request-runner'; + +export interface RepeaterCommands { + sendRequest(request: Request): Promise; +} + +export const RepeaterCommands: unique symbol = Symbol('RepeaterCommands'); diff --git a/packages/repeater/src/lib/RepeaterFactory.spec.ts b/packages/repeater/src/lib/RepeaterFactory.spec.ts index cc7281ed..626794b7 100644 --- a/packages/repeater/src/lib/RepeaterFactory.spec.ts +++ b/packages/repeater/src/lib/RepeaterFactory.spec.ts @@ -1,17 +1,14 @@ -import 'reflect-metadata'; import { RepeaterFactory } from './RepeaterFactory'; import { HttpRequestRunner, RequestRunner, - RequestRunnerOptions, - WsRequestRunner + RequestRunnerOptions } 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, deepEqual, instance, mock, @@ -22,23 +19,9 @@ 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 = { timeout: 30000, maxContentLength: 100, @@ -64,27 +47,22 @@ describe('RepeaterFactory', () => { const mockedContainer = mock(); const mockedChildContainer = mock(); const mockedConfiguration = mock(); - const mockedEventBus = mock(); const mockedRepeaterManager = 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 @@ -92,80 +70,27 @@ describe('RepeaterFactory', () => { }); afterEach(() => { - reset( + reset( mockedContainer, mockedChildContainer, - mockedConfiguration, - mockedEventBus, - mockedRepeaterManager + mockedConfiguration ); }); describe('createRepeater', () => { it('should create repeater', async () => { + // arrange const factory = new RepeaterFactory(configuration); - const res = await factory.createRepeater(); + // act + await factory.createRepeater(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - verify(mockedEventBus.init!()).once(); - expect(res).toBeInstanceOf(Repeater); - expect(res).toMatchObject({ - repeaterId - }); - }); - - it('should create repeater with given name prefix and description', async () => { - const factory = new RepeaterFactory(configuration); - - const res = await factory.createRepeater({ - namePrefix: 'foo', - description: 'description' - }); - - const [arg]: [ - { - name: string; - description?: string; - } - ] = capture<{ - name: string; - description?: string; - }>(mockedRepeaterManager.createRepeater).first(); - - 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 () => { - const factory = new RepeaterFactory(configuration); - - const res = await factory.createRepeater({ - namePrefix: 'foo', - disableRandomNameGeneration: true - }); - - verify( - mockedRepeaterManager.createRepeater(objectContaining({ name: 'foo' })) - ); - expect(res).toBeInstanceOf(Repeater); - }); - - it('should create repeater with given project', async () => { - const factory = new RepeaterFactory(configuration); - const projectId = '321'; - const res = await factory.createRepeater({ - projectId - }); - - verify( - mockedRepeaterManager.createRepeater(objectContaining({ projectId })) - ); - expect(res).toBeInstanceOf(Repeater); + // assert + verify(mockedChildContainer.resolve(Repeater)).once(); }); it('should register custom request runner options', async () => { + // arrange const factory = new RepeaterFactory(configuration); when( mockedChildContainer.register(RequestRunnerOptions, anything()) @@ -177,12 +102,14 @@ describe('RepeaterFactory', () => { allowedMimes: ['text/html'] }; + // act await factory.createRepeater({ namePrefix: 'foo', description: 'description', requestRunnerOptions }); + // assert verify( mockedChildContainer.register( RequestRunnerOptions, @@ -194,13 +121,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, @@ -212,12 +142,15 @@ describe('RepeaterFactory', () => { }); it('should register request runners', async () => { + // arrange const factory = new RepeaterFactory(configuration); + // act await factory.createRepeater({ - requestRunners: [HttpRequestRunner, WsRequestRunner] + requestRunners: [HttpRequestRunner] }); + // assert verify( mockedChildContainer.register( RequestRunner, @@ -229,42 +162,6 @@ describe('RepeaterFactory', () => { }) ) ).once(); - verify( - mockedChildContainer.register( - RequestRunner, - deepEqual({ - useClass: WsRequestRunner - }), - deepEqual({ - lifecycle: Lifecycle.ContainerScoped - }) - ) - ).once(); - }); - - it('should throw an error if name prefix is too long', async () => { - const factory = new RepeaterFactory(configuration); - - const res = factory.createRepeater({ - namePrefix: 'foo'.repeat(50) - }); - - 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 () => { - const factory = new RepeaterFactory(configuration); - - const res = factory.createRepeater({ - namePrefix: 'foo'.repeat(80), - disableRandomNameGeneration: true - }); - - await expect(res).rejects.toThrow( - 'Name prefix must be less than or equal to 80 characters.' - ); }); }); @@ -273,12 +170,9 @@ describe('RepeaterFactory', () => { const factory = new RepeaterFactory(configuration); const existingRepeaterId = '123'; - const res = await factory.createRepeaterFromExisting(existingRepeaterId); + await factory.createRepeaterFromExisting(existingRepeaterId); - expect(res).toBeInstanceOf(Repeater); - expect(res).toMatchObject({ - repeaterId: existingRepeaterId - }); + verify(mockedChildContainer.resolve(Repeater)).once(); }); it('should register custom request runner options', async () => { @@ -331,7 +225,7 @@ describe('RepeaterFactory', () => { const existingRepeaterId = '123'; await factory.createRepeaterFromExisting(existingRepeaterId, { - requestRunners: [HttpRequestRunner, WsRequestRunner] + requestRunners: [HttpRequestRunner] }); verify( @@ -345,17 +239,6 @@ describe('RepeaterFactory', () => { }) ) ).once(); - verify( - mockedChildContainer.register( - RequestRunner, - deepEqual({ - useClass: WsRequestRunner - }), - deepEqual({ - lifecycle: Lifecycle.ContainerScoped - }) - ) - ).once(); }); }); }); diff --git a/packages/repeater/src/lib/RepeaterFactory.ts b/packages/repeater/src/lib/RepeaterFactory.ts index 9168c48a..06fe7288 100644 --- a/packages/repeater/src/lib/RepeaterFactory.ts +++ b/packages/repeater/src/lib/RepeaterFactory.ts @@ -1,9 +1,9 @@ import { Repeater, RepeaterId } from './Repeater'; import { RequestRunner, RequestRunnerOptions } from '../request-runner'; -import { RepeaterOptions } from './RepeaterOptions'; -import { RepeatersManager } from '../api'; import { RepeaterRequestRunnerOptions } from './RepeaterRequestRunnerOptions'; -import { Configuration, EventBus } from '@sectester/core'; +import { RepeatersManager } from '../api'; +import { RepeaterOptions } from './RepeaterOptions'; +import { Configuration } from '@sectester/core'; import { v4 as uuidv4 } from 'uuid'; import { DependencyContainer, injectable, Lifecycle } from 'tsyringe'; @@ -28,17 +28,17 @@ export class RepeaterFactory { description, disableRandomNameGeneration, namePrefix = 'sectester', - ...requestRunnerOptions + ...options }: RepeaterOptions = {}): Promise { - const name = this.generateName(namePrefix, disableRandomNameGeneration); + await this.configuration.loadCredentials(); const { repeaterId } = await this.repeatersManager.createRepeater({ description, projectId, - name + name: this.generateName(namePrefix, disableRandomNameGeneration) }); - return this.createRepeaterInstance(repeaterId, requestRunnerOptions); + return this.createRepeaterInstance(repeaterId, options); } public async createRepeaterFromExisting( @@ -50,66 +50,21 @@ export class RepeaterFactory { return this.createRepeaterInstance(repeaterId, options); } - private async createRepeaterInstance( + private createRepeaterInstance( repeaterId: string, { requestRunnerOptions, requestRunners = [] }: RepeaterRequestRunnerOptions = {} - ) { + ): Repeater { const container = this.configuration.container.createChildContainer(); - container.register(RepeaterId, { - useValue: repeaterId - }); + container.register(RepeaterId, { useValue: repeaterId }); this.registerRequestRunnerOptions(container, requestRunnerOptions); - this.registerRequestRunners(container, requestRunners); - - const bus = await this.createEventBus(container); - - return new Repeater({ - bus, - repeaterId, - configuration: this.configuration - }); - } - - private async createEventBus( - container: DependencyContainer - ): Promise { - await this.configuration.loadCredentials(); - - if (!this.configuration.credentials) { - throw new Error( - 'Please provide credentials to establish a connection with the bus.' - ); - } - - const bus = container.resolve(EventBus); - - await bus.init?.(); - - return bus; - } - - private generateName( - namePrefix: string, - disableRandomNameGeneration: boolean = false - ) { - const normalizedPrefix = namePrefix?.trim(); - const randomPostfix = disableRandomNameGeneration ? '' : `-${uuidv4()}`; - const name = `${normalizedPrefix}${randomPostfix}`; - - if (name.length > this.MAX_NAME_LENGTH) { - const maxPrefixLength = this.MAX_NAME_LENGTH - randomPostfix.length; + this.registerRequestRunners(container, requestRunners ?? []); - throw new Error( - `Name prefix must be less than or equal to ${maxPrefixLength} characters.` - ); - } - - return name; + return container.resolve(Repeater); } private registerRequestRunners( @@ -149,4 +104,23 @@ export class RepeaterFactory { } }); } + + private generateName( + namePrefix: string, + disableRandomNameGeneration: boolean = false + ) { + const normalizedPrefix = namePrefix?.trim(); + const randomPostfix = disableRandomNameGeneration ? '' : `-${uuidv4()}`; + const name = `${normalizedPrefix}${randomPostfix}`; + + if (name.length > this.MAX_NAME_LENGTH) { + const maxPrefixLength = this.MAX_NAME_LENGTH - randomPostfix.length; + + throw new Error( + `Name prefix must be less than or equal to ${maxPrefixLength} characters.` + ); + } + + return name; + } } diff --git a/packages/repeater/src/lib/RepeaterServer.ts b/packages/repeater/src/lib/RepeaterServer.ts new file mode 100644 index 00000000..02a4fb97 --- /dev/null +++ b/packages/repeater/src/lib/RepeaterServer.ts @@ -0,0 +1,125 @@ +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 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]; + [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 type CallbackFunction = (arg: T) => unknown; +export type HandlerFunction = (args: unknown[]) => unknown; + +export interface RepeaterServer { + connect(domain?: string): Promise; + + disconnect(): void; + + 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/lib/index.ts b/packages/repeater/src/lib/index.ts index 4d4794bb..5fe0055e 100644 --- a/packages/repeater/src/lib/index.ts +++ b/packages/repeater/src/lib/index.ts @@ -1,5 +1,8 @@ -export { Repeater, RunningStatus, RepeaterId } from './Repeater'; -export { RepeaterFactory } from './RepeaterFactory'; -export { RepeaterOptions } from './RepeaterOptions'; -export { RepeaterRequestRunnerOptions } from './RepeaterRequestRunnerOptions'; -export { RepeaterStatus } from '../models/RepeaterStatus'; +export * from './Repeater'; +export * from './RepeaterFactory'; +export * from './RepeaterOptions'; +export * from './RepeaterRequestRunnerOptions'; +export * from './DefaultRepeaterCommands'; +export * from './DefaultRepeaterServer'; +export * from './RepeaterCommands'; +export * from './RepeaterServer'; diff --git a/packages/repeater/src/models/Protocol.ts b/packages/repeater/src/models/Protocol.ts index 2fa5e095..e513e337 100644 --- a/packages/repeater/src/models/Protocol.ts +++ b/packages/repeater/src/models/Protocol.ts @@ -1,4 +1,3 @@ export enum Protocol { - HTTP = 'http', - WS = 'ws' + HTTP = 'http' } diff --git a/packages/repeater/src/register.ts b/packages/repeater/src/register.ts index d42c6b8e..84752d1f 100644 --- a/packages/repeater/src/register.ts +++ b/packages/repeater/src/register.ts @@ -1,11 +1,19 @@ -import { RepeaterFactory, RepeaterId } from './lib'; -import { DefaultRepeatersManager, RepeatersManager } from './api'; +import { + RepeaterFactory, + RepeaterId, + DefaultRepeaterCommands, + DefaultRepeaterServer, + DefaultRepeaterServerOptions, + RepeaterCommands, + RepeaterServer +} from './lib'; import { HttpRequestRunner, RequestRunner, - RequestRunnerOptions, - WsRequestRunner + RequestRunnerOptions } from './request-runner'; +import { DefaultRepeatersManager, RepeatersManager } from './api'; +import { DefaultProxyFactory, ProxyFactory } from './utils'; import { container, DependencyContainer, @@ -27,10 +35,6 @@ container.register(RequestRunner, { useClass: HttpRequestRunner }); -container.register(RequestRunner, { - useClass: WsRequestRunner -}); - container.register(RequestRunnerOptions, { useValue: { timeout: 30000, @@ -90,4 +94,25 @@ container.register(EventBus, { } }); +container.register(DefaultRepeaterServerOptions, { + useFactory: (childContainer: DependencyContainer) => { + const configuration = childContainer.resolve(Configuration); + + if (!configuration.credentials) { + throw new Error( + 'Please provide credentials to establish a connection with the bridges.' + ); + } + + return { + uri: `${configuration.api}/workstations`, + token: configuration.credentials.token, + connectTimeout: 10000 + }; + } +}); + +container.register(ProxyFactory, { useClass: DefaultProxyFactory }); +container.register(RepeaterServer, { useClass: DefaultRepeaterServer }); +container.register(RepeaterCommands, { useClass: DefaultRepeaterCommands }); container.register(RepeatersManager, { useClass: DefaultRepeatersManager }); diff --git a/packages/repeater/src/request-runner/Request.spec.ts b/packages/repeater/src/request-runner/Request.spec.ts index 61df7918..6774cafa 100644 --- a/packages/repeater/src/request-runner/Request.spec.ts +++ b/packages/repeater/src/request-runner/Request.spec.ts @@ -34,17 +34,6 @@ describe('Request', () => { ).toThrow('Body must be string.'); }); - it('should throw Error on invalid correlationIdRegex', () => { - expect( - () => - new Request({ - url: 'http://foo.bar', - correlationIdRegex: '(', - protocol: Protocol.HTTP - }) - ).toThrow('Correlation id must be regular expression.'); - }); - it('should create an instance', () => { expect( () => diff --git a/packages/repeater/src/request-runner/Request.ts b/packages/repeater/src/request-runner/Request.ts index 176ba768..73395963 100644 --- a/packages/repeater/src/request-runner/Request.ts +++ b/packages/repeater/src/request-runner/Request.ts @@ -4,10 +4,13 @@ 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 { @@ -27,20 +30,19 @@ export class Request { 'referer', 'user-agent' ]); + public readonly protocol: Protocol; public readonly url: string; + public readonly method: string; public readonly body?: string; - public readonly correlationIdRegex?: RegExp; - - private readonly _method?: string; + public readonly encoding?: 'base64'; + public readonly maxContentSize?: number; + public readonly decompress?: boolean; + public readonly timeout?: number; - get method(): string | undefined { - return this._method; - } - - private _headers?: Record; + private _headers: Record = {}; - get headers(): Readonly> | undefined { + get headers(): Readonly> { return this._headers; } @@ -53,18 +55,27 @@ export class Request { method, url, body, - correlationIdRegex, + timeout, + maxContentSize, + encoding, + decompress = true, headers = {} }: RequestOptions) { this.protocol = protocol; - this._method = method?.toUpperCase() ?? 'GET'; + this.method = method?.toUpperCase() ?? 'GET'; + this.validateUrl(url); - this.url = url; - this.correlationIdRegex = - this.normalizeCorrelationIdRegex(correlationIdRegex); - this.setHeaders(headers); + this.url = url.trim(); + this.precheckBody(body); this.body = body; + + this.setHeaders(headers); + + this.encoding = encoding; + this.timeout = timeout; + this.maxContentSize = maxContentSize; + this.decompress = !!decompress; } public setHeaders(headers: Record): void { @@ -99,16 +110,4 @@ 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.spec.ts b/packages/repeater/src/request-runner/Response.spec.ts index 04d99047..509f5c07 100644 --- a/packages/repeater/src/request-runner/Response.spec.ts +++ b/packages/repeater/src/request-runner/Response.spec.ts @@ -5,7 +5,7 @@ describe('Response', () => { describe('constructor', () => { it('should create an instance having only protocol', () => { const responseOptions = { - protocol: Protocol.WS + protocol: Protocol.HTTP }; const response = new Response(responseOptions); @@ -15,7 +15,7 @@ describe('Response', () => { it('should create an instance with full fieldset', () => { const responseOptions = { - protocol: Protocol.WS, + protocol: Protocol.HTTP, statusCode: 200, headers: { 'x-key': ['x-value'] }, body: '{}', 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.spec.ts b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.spec.ts index a1d194b6..b1bdf5b1 100644 --- a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.spec.ts +++ b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.spec.ts @@ -1,20 +1,26 @@ +import 'reflect-metadata'; import { HttpRequestRunner } from './HttpRequestRunner'; +import { Protocol } from '../../models/Protocol'; import { Request, RequestOptions } from '../Request'; import { RequestRunnerOptions } from '../RequestRunnerOptions'; -import { Protocol } from '../../models'; +import { ProxyFactory } from '../../utils'; +import { Logger } from '@sectester/core'; import nock from 'nock'; -import 'reflect-metadata'; -import { anything, spy, verify, when } from 'ts-mockito'; -import { Logger, LogLevel } from '@sectester/core'; -import { SocksProxyAgent } from 'socks-proxy-agent'; -import { brotliCompress, constants, gzip } from 'zlib'; -import { promisify } from 'util'; - -const createRequest = (options: Partial = {}) => { +import { anything, instance, mock, reset, spy, verify, when } from 'ts-mockito'; +import { promisify } from 'node:util'; +import { + brotliCompress, + constants, + gzip, + deflate, + deflateRaw +} from 'node:zlib'; + +const createRequest = (options?: Partial) => { const requestOptions = { - protocol: Protocol.HTTP, url: 'https://foo.bar', - method: 'GET', + headers: {}, + protocol: Protocol.HTTP, ...options }; const request = new Request(requestOptions); @@ -25,74 +31,102 @@ const createRequest = (options: Partial = {}) => { }; describe('HttpRequestRunner', () => { - const setupRunner = ( - options: RequestRunnerOptions = {}, - logger: Logger = new Logger() - ): HttpRequestRunner => new HttpRequestRunner(options, logger); + const loggerMock = mock(); + const proxyFactoryMock = mock(); + let spiedRunnerOptions!: RequestRunnerOptions; - beforeAll(() => nock.disableNetConnect()); - afterAll(() => nock.enableNetConnect()); + let sut!: HttpRequestRunner; - afterEach(() => { - nock.cleanAll(); - nock.restore(); + beforeAll(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); }); + afterAll(() => nock.enableNetConnect()); beforeEach(() => { if (!nock.isActive()) { nock.activate(); } + + const RunnerOptions: RequestRunnerOptions = {}; + spiedRunnerOptions = spy(RunnerOptions); + + sut = new HttpRequestRunner( + instance(loggerMock), + instance(proxyFactoryMock), + RunnerOptions + ); + }); + + afterEach(() => { + nock.cleanAll(); + nock.restore(); + + reset( + loggerMock, + spiedRunnerOptions, + proxyFactoryMock + ); }); describe('protocol', () => { - const runner = setupRunner(); - it('should return HTTP', () => expect(runner.protocol).toBe(Protocol.HTTP)); + it('should return HTTP', () => { + const protocol = sut.protocol; + expect(protocol).toBe(Protocol.HTTP); + }); }); describe('run', () => { it('should call setHeaders on the provided request if additional headers were configured globally', async () => { const headers = { testHeader: 'test-header-value' }; - const runner = setupRunner({ headers }); + when(spiedRunnerOptions.headers).thenReturn(headers); const { request, spiedRequest } = createRequest(); - nock('https://foo.bar').get('/').reply(200, {}); - await runner.run(request); + await sut.run(request); verify(spiedRequest.setHeaders(headers)).once(); }); it('should not call setHeaders on the provided request if there were no additional headers configured', async () => { - const runner = setupRunner(); const { request, spiedRequest } = createRequest(); - nock('https://foo.bar').get('/').reply(200, {}); - await runner.run(request); + await sut.run(request); verify(spiedRequest.setHeaders(anything())).never(); }); it('should perform an external http request', async () => { - const runner = setupRunner(); const { request, requestOptions } = createRequest(); nock(requestOptions.url).get('/').reply(200, {}); - const response = await runner.run(request); + const response = await sut.run(request); expect(response).toMatchObject({ statusCode: 200, - body: {} + body: '{}' + }); + }); + + it('should handle HTTP errors', async () => { + const { request, requestOptions } = createRequest(); + nock(requestOptions.url).get('/').reply(500, {}); + + const response = await sut.run(request); + + expect(response).toMatchObject({ + statusCode: 500, + body: '{}' }); }); it('should preserve directory traversal', async () => { - const runner = setupRunner(); const path = 'public/../../../../../../etc/passwd'; const { request } = createRequest({ url: `http://localhost:8080/${path}` }); nock('http://localhost:8080').get(`/${path}`).reply(200, {}); - const response = await runner.run(request); + const response = await sut.run(request); expect(response).toMatchObject({ statusCode: 200, @@ -100,77 +134,101 @@ describe('HttpRequestRunner', () => { }); }); - it('should handle HTTP errors', async () => { - const runner = setupRunner(); - const { request, requestOptions } = createRequest(); - nock(requestOptions.url).get('/').reply(500, {}); - - const response = await runner.run(request); - - expect(response).toMatchObject({ - statusCode: 500, - body: {} - }); - }); - it('should handle timeout', async () => { + when(spiedRunnerOptions.timeout).thenReturn(1); const { request, requestOptions } = createRequest(); nock(requestOptions.url).get('/').delayBody(2).reply(204); - const runner = setupRunner({ timeout: 1 }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response).toMatchObject({ errorCode: 'Error', - message: 'This operation was aborted' + message: 'Waiting response has timed out' }); }); it('should handle non-HTTP errors', async () => { - const runner = setupRunner({}, new Logger(LogLevel.SILENT)); const { request } = createRequest(); - const response = await runner.run(request); + const response = await sut.run(request); - expect(response.statusCode).toBeUndefined(); + expect(response).toMatchObject({ + statusCode: undefined + }); }); it('should truncate response body with not white-listed mime type', async () => { - const runner = setupRunner({ - maxContentLength: 1 - }); + when(spiedRunnerOptions.maxContentLength).thenReturn(1); const { request, requestOptions } = createRequest(); const bigBody = 'x'.repeat(1025); nock(requestOptions.url) .get('/') .reply(200, bigBody, { 'content-type': 'application/x-custom' }); - const response = await runner.run(request); + const response = await sut.run(request); + expect(response.body?.length).toEqual(1024); expect(response.body).toEqual(bigBody.slice(0, 1024)); }); it('should not truncate response body if it is in allowed mime types', async () => { - const runner = setupRunner({ - maxContentLength: 1, - allowedMimes: ['application/x-custom'] - }); + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn([ + 'application/x-custom' + ]); const { request, requestOptions } = createRequest(); const bigBody = 'x'.repeat(1025); nock(requestOptions.url).get('/').reply(200, bigBody, { 'content-type': 'application/x-custom' }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(bigBody); }); - it('should decode response body if content-encoding is gzip', async () => { - const runner = setupRunner({ - maxContentLength: 1, - allowedMimes: ['text/plain'] + it('should decode response body if content-encoding is brotli', async () => { + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); + const { request, requestOptions } = createRequest(); + const expected = 'x'.repeat(1025); + const bigBody = await promisify(brotliCompress)(expected); + nock(requestOptions.url).get('/').reply(200, bigBody, { + 'content-type': 'text/plain', + 'content-encoding': 'br' + }); + + const response = await sut.run(request); + + expect(response.body).toEqual(expected); + }); + + it('should prevent decoding response body if decompress option is disabled', async () => { + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); + const { request, requestOptions } = createRequest({ + decompress: false, + encoding: 'base64' + }); + const expected = 'x'.repeat(100); + const body = await promisify(gzip)(expected, { + flush: constants.Z_SYNC_FLUSH, + finishFlush: constants.Z_SYNC_FLUSH + }); + nock(requestOptions.url).get('/').reply(200, body, { + 'content-type': 'text/plain', + 'content-encoding': 'gzip' }); + + const response = await sut.run(request); + + expect(response.body).toEqual(body.toString('base64')); + expect(response.headers).toMatchObject({ 'content-encoding': 'gzip' }); + }); + + it('should decode response body if content-encoding is gzip', async () => { + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); const { request, requestOptions } = createRequest(); const expected = 'x'.repeat(1025); const bigBody = await promisify(gzip)(expected, { @@ -182,34 +240,52 @@ describe('HttpRequestRunner', () => { 'content-encoding': 'gzip' }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(expected); }); - it('should decode response body if content-encoding is brotli', async () => { - const runner = setupRunner({ - maxContentLength: 1, - allowedMimes: ['text/plain'] + it('should decode response body if content-encoding is deflate', async () => { + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); + const { request, requestOptions } = createRequest(); + const expected = 'x'.repeat(1025); + const bigBody = await promisify(deflate)(expected, { + flush: constants.Z_SYNC_FLUSH, + finishFlush: constants.Z_SYNC_FLUSH + }); + nock(requestOptions.url).get('/').reply(200, bigBody, { + 'content-type': 'text/plain', + 'content-encoding': 'deflate' }); + + const response = await sut.run(request); + + expect(response.body).toEqual(expected); + }); + + it('should decode response body if content-encoding is deflate and content does not have zlib headers', async () => { + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); const { request, requestOptions } = createRequest(); const expected = 'x'.repeat(1025); - const bigBody = await promisify(brotliCompress)(expected); + const bigBody = await promisify(deflateRaw)(expected, { + flush: constants.Z_SYNC_FLUSH, + finishFlush: constants.Z_SYNC_FLUSH + }); nock(requestOptions.url).get('/').reply(200, bigBody, { 'content-type': 'text/plain', - 'content-encoding': 'br' + 'content-encoding': 'deflate' }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(expected); }); it('should decode and truncate gzipped response body if content-type is not in allowed list', async () => { - const runner = setupRunner({ - maxContentLength: 1, - allowedMimes: ['text/plain'] - }); + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn(['text/plain']); const { request, requestOptions } = createRequest(); const bigBody = 'x'.repeat(1025); const expected = bigBody.slice(0, 1024); @@ -222,64 +298,34 @@ describe('HttpRequestRunner', () => { 'content-encoding': 'gzip' }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(expected); }); it('should not truncate response body if allowed mime type starts with actual one', async () => { - const runner = setupRunner({ - maxContentLength: 1, - allowedMimes: ['application/x-custom'] - }); + when(spiedRunnerOptions.maxContentLength).thenReturn(1); + when(spiedRunnerOptions.allowedMimes).thenReturn([ + 'application/x-custom' + ]); const { request, requestOptions } = createRequest(); const bigBody = 'x'.repeat(1025); nock(requestOptions.url).get('/').reply(200, bigBody, { 'content-type': 'application/x-custom-with-suffix' }); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(bigBody); }); it('should skip truncate on 204 response status', async () => { - const runner = setupRunner({ - maxContentLength: 1 - }); + when(spiedRunnerOptions.maxContentLength).thenReturn(1); const { request, requestOptions } = createRequest(); nock(requestOptions.url).get('/').reply(204); - const response = await runner.run(request); + const response = await sut.run(request); expect(response.body).toEqual(''); }); - - it('should use SocksProxyAgent if socks proxyUrl provided', async () => { - const runner = setupRunner({ - proxyUrl: 'socks://proxy.baz' - }); - const { request, requestOptions } = createRequest(); - const scope = nock(requestOptions.url).get('/').reply(200, 'Dummy'); - - scope.on('request', req => - expect(req.options.agent).toBeInstanceOf(SocksProxyAgent) - ); - - await runner.run(request); - }); - - it('should use keepAlive agent on if reuseConnection enabled', async () => { - const runner = setupRunner({ - reuseConnection: true - }); - const { request, requestOptions } = createRequest(); - const scope = nock(requestOptions.url).get('/').reply(200, 'Dummy'); - - scope.on('request', req => { - expect(req.options.agent.options.keepAlive).toBeTruthy(); - }); - - await runner.run(request); - }); }); }); diff --git a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts index 00762e75..e9722838 100644 --- a/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts +++ b/packages/repeater/src/request-runner/protocols/HttpRequestRunner.ts @@ -1,58 +1,55 @@ import { RequestRunner } from '../RequestRunner'; +import { Response } from '../Response'; +import { Request } from '../Request'; import { Protocol } from '../../models'; import { RequestRunnerOptions } from '../RequestRunnerOptions'; -import { Request } from '../Request'; -import { Response } from '../Response'; -import { parse as parseMimetype } from 'content-type'; +import { ProxyFactory, NormalizeZlibDeflateTransformStream } from '../../utils'; import { Logger } from '@sectester/core'; -import { SocksProxyAgent } from 'socks-proxy-agent'; import { inject, injectable } from 'tsyringe'; -import { parse as parseUrl } from 'url'; -import { once } from 'events'; -import https, { RequestOptions } from 'https'; +import iconv from 'iconv-lite'; +import { safeParse } from 'fast-content-type-parse'; +import { parse as parseUrl } from 'node:url'; import http, { - AgentOptions, ClientRequest, IncomingMessage, OutgoingMessage -} from 'http'; +} from 'node:http'; +import https, { + AgentOptions, + RequestOptions as ClientRequestOptions +} from 'node:https'; +import { once } from 'node:events'; +import { Readable } from 'node:stream'; import { constants, createBrotliDecompress, createGunzip, createInflate -} from 'zlib'; -import { Readable } from 'stream'; - -type IncomingResponse = IncomingMessage & { body?: string }; +} from 'node:zlib'; +import { IncomingHttpHeaders } from 'http'; @injectable() export class HttpRequestRunner implements RequestRunner { - private readonly proxy?: SocksProxyAgent; + private readonly httpProxyAgent?: http.Agent; + private readonly httpsProxyAgent?: https.Agent; private readonly httpAgent?: http.Agent; private readonly httpsAgent?: https.Agent; - private readonly maxContentLength: number; get protocol(): Protocol { return Protocol.HTTP; } constructor( + private readonly logger: Logger, + @inject(ProxyFactory) private readonly proxyFactory: ProxyFactory, @inject(RequestRunnerOptions) - private readonly options: RequestRunnerOptions, - private readonly logger: Logger + private readonly options: RequestRunnerOptions ) { if (this.options.proxyUrl) { - this.proxy = new SocksProxyAgent({ - ...parseUrl(this.options.proxyUrl) - }); + ({ httpsAgent: this.httpsProxyAgent, httpAgent: this.httpProxyAgent } = + this.proxyFactory.createProxy({ proxyUrl: this.options.proxyUrl })); } - this.maxContentLength = - typeof this.options.maxContentLength === 'number' - ? this.options.maxContentLength - : -1; - if (this.options.reuseConnection) { const agentOptions: AgentOptions = { keepAlive: true, @@ -76,71 +73,76 @@ export class HttpRequestRunner implements RequestRunner { options ); - const response = await this.request(options); + const { res, body } = await this.request(options); return new Response({ + body, protocol: this.protocol, - statusCode: response.statusCode, - headers: response.headers, - body: response.body + statusCode: res.statusCode, + headers: this.convertHeaders(res.headers), + encoding: options.encoding }); } catch (err) { - return this.handleRequestError(err, options); - } - } - - private handleRequestError(err: any, options: Request): Response { - const { cause } = err; - const { message, code, syscall, name } = cause ?? err; - let errorCode = code ?? syscall ?? name; + const { cause } = err; + const { message, code, syscall, name } = cause ?? err; + const errorCode = code ?? syscall ?? name; + + this.logger.error( + 'Error executing request: "%s %s HTTP/1.1"', + options.method, + options.url + ); + this.logger.error('Cause: %s', message); - if (typeof errorCode !== 'string') { - errorCode = Error.name; + return new Response({ + message, + errorCode, + protocol: this.protocol + }); } + } - this.logger.error( - 'Error executing request: "%s %s HTTP/1.1"', - options.method, - options.url + private convertHeaders( + headers: IncomingHttpHeaders + ): Record { + return Object.fromEntries( + Object.entries(headers).map( + ([name, value]: [string, string | string[] | undefined]) => [ + name, + value ?? '' + ] + ) ); - this.logger.error('Cause: %s', message); - - return new Response({ - message, - errorCode, - protocol: this.protocol - }); } - private async request(options: Request): Promise { - const ac = new AbortController(); - const { signal } = ac; + private async request(options: Request) { let timer: NodeJS.Timeout | undefined; let res!: IncomingMessage; try { - const req = this.createRequest(options, { signal }); + const req = this.createRequest(options); - timer = this.setTimeout(ac); - process.nextTick(() => req.end(options.body)); + process.nextTick(() => + req.end( + options.encoding && options.body + ? iconv.encode(options.body, options.encoding) + : options.body + ) + ); + timer = this.setTimeout(req, options.timeout); - [res] = (await once(req, 'response', { - signal - })) as [IncomingMessage]; + [res] = (await once(req, 'response')) as [IncomingMessage]; } finally { clearTimeout(timer); } - return this.truncateResponse(res); + return this.truncateResponse(options, res); } - private createRequest( - request: Request, - options?: { signal?: AbortSignal } - ): ClientRequest { + private createRequest(request: Request): ClientRequest { const protocol = request.secureEndpoint ? https : http; const outgoingMessage = protocol.request( - this.createRequestOptions(request, options) + this.createRequestOptions(request) ); this.setHeaders(outgoingMessage, request); @@ -151,19 +153,20 @@ export class HttpRequestRunner implements RequestRunner { return outgoingMessage; } - private setTimeout(ac: AbortController): NodeJS.Timeout | undefined { - if (typeof this.options.timeout === 'number') { + private setTimeout( + req: ClientRequest, + timeout?: number + ): NodeJS.Timeout | undefined { + timeout ??= this.options.timeout; + if (typeof timeout === 'number') { return setTimeout( - () => ac.abort(/*'Waiting response has timed out'*/), - this.options.timeout + () => req.destroy(new Error('Waiting response has timed out')), + timeout ); } } - private createRequestOptions( - request: Request, - options?: { signal?: AbortSignal } - ): RequestOptions { + private createRequestOptions(request: Request): ClientRequestOptions { const { auth, hostname, @@ -174,61 +177,82 @@ export class HttpRequestRunner implements RequestRunner { } = parseUrl(request.url); const path = `${pathname ?? '/'}${search ?? ''}${hash ?? ''}`; const agent = this.getRequestAgent(request); + const timeout = request.timeout ?? this.options.timeout; return { - ...options, hostname, port, path, auth, agent, + timeout, method: request.method, - timeout: this.options.timeout, rejectUnauthorized: false }; } private getRequestAgent(options: Request) { - return ( - this.proxy ?? (options.secureEndpoint ? this.httpsAgent : this.httpAgent) - ); + return options.secureEndpoint + ? this.httpsProxyAgent ?? this.httpsAgent + : this.httpProxyAgent ?? this.httpAgent; } private async truncateResponse( - res: IncomingResponse - ): Promise { + { decompress, encoding, maxContentSize }: Request, + res: IncomingMessage + ) { if (this.responseHasNoBody(res)) { this.logger.debug('The response does not contain any body.'); - res.body = ''; - - return res; + return { res, body: '' }; } - const type = this.parseContentType(res); - const maxBodySize = this.maxContentLength * 1024; - const requiresTruncating = !this.options.allowedMimes?.some( - (mime: string) => type.startsWith(mime) - ); + const contentType = this.parseContentType(res); + const { type } = contentType; - const body = await this.parseBody(res, { maxBodySize, requiresTruncating }); + const requiresTruncating = + this.options.maxContentLength !== -1 && + !this.options.allowedMimes?.some((mime: string) => type.startsWith(mime)); - res.body = body.toString(); - res.headers['content-length'] = String(body.byteLength); + const maxBodySize = + typeof maxContentSize === 'number' + ? maxContentSize * 1024 + : this.options.maxContentLength + ? Math.abs(this.options.maxContentLength) * 1024 + : undefined; - return res; + const body = await this.parseBody(res, { + decompress, + maxBodySize: requiresTruncating ? maxBodySize : undefined + }); + + res.headers['content-length'] = body.byteLength.toFixed(); + + if (decompress) { + delete res.headers['content-encoding']; + } + + return { res, body: iconv.decode(body, encoding ?? contentType.encoding) }; } - private parseContentType(res: IncomingMessage): string { - let type = res.headers['content-type'] || 'text/plain'; + private parseContentType(res: IncomingMessage): { + type: string; + encoding: string; + } { + const contentType = + res.headers['content-type'] || 'application/octet-stream'; + const { + type, + parameters: { charset } + } = safeParse(contentType); - try { - ({ type } = parseMimetype(type)); - } catch { - // noop + let encoding: string | undefined = charset; + + if (!encoding || !iconv.encodingExists(encoding)) { + encoding = 'utf-8'; } - return type; + return { type, encoding }; } private unzipBody(response: IncomingMessage): Readable { @@ -249,7 +273,9 @@ export class HttpRequestRunner implements RequestRunner { body = response.pipe(createGunzip(zlibOptions)); break; case 'deflate': - body = response.pipe(createInflate(zlibOptions)); + body = response + .pipe(new NormalizeZlibDeflateTransformStream()) + .pipe(createInflate(zlibOptions)); break; case 'br': body = response.pipe(createBrotliDecompress()); @@ -272,20 +298,23 @@ export class HttpRequestRunner implements RequestRunner { private async parseBody( res: IncomingMessage, - options: { maxBodySize: number; requiresTruncating: boolean } + options: { + maxBodySize?: number; + decompress?: boolean; + } ): Promise { const chunks: Buffer[] = []; + const stream = options.decompress ? this.unzipBody(res) : res; - for await (const chuck of this.unzipBody(res)) { + for await (const chuck of stream) { chunks.push(chuck); } let body = Buffer.concat(chunks); const truncated = - this.maxContentLength !== -1 && - body.byteLength > options.maxBodySize && - options.requiresTruncating; + typeof options.maxBodySize === 'number' && + body.byteLength > options.maxBodySize; if (truncated) { this.logger.debug( diff --git a/packages/repeater/src/request-runner/protocols/WsRequestRunner.spec.ts b/packages/repeater/src/request-runner/protocols/WsRequestRunner.spec.ts deleted file mode 100644 index f8ee1a01..00000000 --- a/packages/repeater/src/request-runner/protocols/WsRequestRunner.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { WsRequestRunner } from './WsRequestRunner'; -import { Request } from '../Request'; -import { RequestRunnerOptions } from '../RequestRunnerOptions'; -import { Protocol } from '../../models'; -import 'reflect-metadata'; -import { reset, spy, when } from 'ts-mockito'; -import { Server } from 'ws'; -import { Logger, LogLevel } from '@sectester/core'; -import { once } from 'events'; - -describe('WsRequestRunner', () => { - const executorOptions: RequestRunnerOptions = { timeout: 2000 }; - const spiedExecutorOptions = spy(executorOptions); - - let runner!: WsRequestRunner; - - beforeEach(() => { - // ADHOC: ts-mockito resets object's property descriptor as well - Object.assign(executorOptions, { timeout: 2000 }); - runner = new WsRequestRunner(executorOptions, new Logger(LogLevel.SILENT)); - }); - - afterEach(() => reset(spiedExecutorOptions)); - - describe('protocol', () => { - it('should use WS protocol', () => - expect(runner.protocol).toBe(Protocol.WS)); - }); - - describe('execute', () => { - let server: Server; - let wsPort: number; - - beforeEach(async () => { - server = new Server({ port: 0 }); - await once(server, 'listening'); - - const address = server.address(); - if (typeof address === 'string') { - throw new Error('Unsupported server address type'); - } - - wsPort = address.port; - }); - - afterEach( - () => - new Promise(done => { - wsPort = NaN; - server.close(done); - }) - ); - - it('should send request body to a web-socket server', () => { - const url = `ws://localhost:${wsPort}`; - const headers = {}; - const body = 'test request body'; - const request = new Request({ - url, - headers, - body, - protocol: Protocol.WS - }); - - server.on('connection', socket => { - socket.on('message', data => { - expect(data).toBeInstanceOf(Buffer); - expect(data.toString()).toBe(body); - socket.send('test reply'); - }); - }); - - return runner.run(request); - }); - - it('should fail sending request by timeout', async () => { - when(spiedExecutorOptions.timeout).thenReturn(100); - - const url = `ws://localhost:${wsPort}`; - const request = new Request({ url, protocol: Protocol.WS, headers: {} }); - - const response = await runner.run(request); - - expect(response).toEqual({ - body: undefined, - errorCode: 'ETIMEDOUT', - headers: undefined, - message: 'Waiting frame has timed out', - protocol: 'ws', - statusCode: undefined - }); - }); - - it('should not allow setting forbidden headers', () => { - const url = `ws://localhost:${wsPort}`; - const headers = { 'test-header': 'test-header-value' }; - WsRequestRunner.FORBIDDEN_HEADERS.forEach( - headerName => (headers[headerName] = 'forbidden-header-value') - ); - const request = new Request({ url, headers, protocol: Protocol.WS }); - - server.on('connection', (socket, req) => { - WsRequestRunner.FORBIDDEN_HEADERS.forEach(headerName => { - expect(req.headers[headerName]).not.toBe('forbidden-header-value'); - }); - - expect(req.headers['test-header']).toBe('test-header-value'); - - socket.on('message', () => { - socket.send('test reply'); - }); - }); - - return runner.run(request); - }); - - it('should get the response from server', async () => { - const url = `ws://localhost:${wsPort}`; - const data = 'test reply'; - const request = new Request({ url, headers: {}, protocol: Protocol.WS }); - - server.on('connection', socket => { - socket.on('message', () => { - socket.send(data, { binary: false, compress: false }); - }); - }); - - const response = await runner.run(request); - - expect(response.body).toBe(data); - }); - }); -}); diff --git a/packages/repeater/src/request-runner/protocols/WsRequestRunner.ts b/packages/repeater/src/request-runner/protocols/WsRequestRunner.ts deleted file mode 100644 index f60d632c..00000000 --- a/packages/repeater/src/request-runner/protocols/WsRequestRunner.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { Request } from '../Request'; -import { RequestRunner } from '../RequestRunner'; -import { RequestRunnerOptions } from '../RequestRunnerOptions'; -import { Response } from '../Response'; -import { Protocol } from '../../models'; -import { Logger } from '@sectester/core'; -import WebSocket from 'ws'; -import { inject, injectable } from 'tsyringe'; -import { SocksProxyAgent } from 'socks-proxy-agent'; -import { once } from 'events'; -import { IncomingMessage } from 'http'; -import { parse } from 'url'; -import { promisify } from 'util'; - -interface WSMessage { - body: string | undefined; - code?: number; -} - -@injectable() -export class WsRequestRunner implements RequestRunner { - public static readonly FORBIDDEN_HEADERS: ReadonlySet = new Set([ - 'sec-websocket-version', - 'sec-websocket-key' - ]); - - private readonly agent?: SocksProxyAgent; - - constructor( - @inject(RequestRunnerOptions) - private readonly options: RequestRunnerOptions, - private readonly logger: Logger - ) { - this.agent = this.options.proxyUrl - ? new SocksProxyAgent({ - ...parse(this.options.proxyUrl) - }) - : undefined; - } - - get protocol(): Protocol { - return Protocol.WS; - } - - public async run(options: Request): Promise { - let timeout: NodeJS.Timeout | undefined; - let client: WebSocket | undefined; - - try { - this.logger.debug( - 'Executing WS request with following params: %j', - options - ); - - client = this.createWebSocketClient(options); - const connectRes: IncomingMessage = await this.connect(client); - - timeout = this.setTimeout(client); - const msg = await this.sendMessage(client, options); - - return this.createWsResponse(msg, connectRes); - } catch (err) { - return this.handleRequestError(err, options); - } finally { - if (timeout) { - clearTimeout(timeout); - } - - if (client?.readyState === WebSocket.OPEN) { - client.close(1000); - } - } - } - - private createWebSocketClient(options: Request): WebSocket { - return new WebSocket(options.url, { - agent: this.agent, - rejectUnauthorized: false, - handshakeTimeout: this.options.timeout, - headers: this.normalizeHeaders(options.headers ?? {}) - }); - } - - private async sendMessage( - client: WebSocket, - options: Request - ): Promise { - // @ts-expect-error TS infers a wrong type here - await promisify(client.send.bind(client))(options.body); - - const message = await this.consume(client, options.correlationIdRegex); - - return message; - } - - private createWsResponse( - msg: WSMessage | undefined, - connectRes: IncomingMessage - ): Response { - return new Response({ - protocol: this.protocol, - statusCode: msg?.code ?? connectRes.statusCode, - headers: connectRes.headers, - body: msg?.body - }); - } - - private handleRequestError(err: any, options: Request): Response { - const message = err.info ?? err.message; - const errorCode = err.code ?? err.syscall; - - this.logger.error('Error executing request: %s', options.url); - this.logger.error('Cause: %s', message); - - return new Response({ - message, - errorCode, - protocol: this.protocol - }); - } - - private setTimeout(client: WebSocket): NodeJS.Timeout { - const timeout = setTimeout( - () => - client.emit( - 'error', - Object.assign(new Error('Waiting frame has timed out'), { - code: 'ETIMEDOUT' - }) - ), - this.options.timeout - ); - - timeout.unref(); - - return timeout; - } - - private async consume( - client: WebSocket, - matcher?: RegExp - ): Promise { - const result = (await Promise.race([ - this.waitForResponse(client, matcher), - once(client, 'close') - ])) as [string | number, string | undefined]; - - let msg: WSMessage | undefined; - - if (result.length) { - const [data, reason]: [string | number, string | undefined] = result; - const body = typeof data === 'string' ? data : reason; - const code = typeof data === 'number' ? data : undefined; - - msg = { - body, - code - }; - } - - return msg; - } - - private waitForResponse( - client: WebSocket, - matcher: RegExp | undefined - ): Promise<[string]> { - return new Promise(resolve => { - client.on('message', (data: WebSocket.Data) => { - const dataString = String(data); - !matcher || matcher.test(dataString) - ? resolve([dataString]) - : undefined; - }); - }); - } - - private async connect(client: WebSocket): Promise { - const opening = once(client, 'open'); - const upgrading = once(client, 'upgrade') as Promise<[IncomingMessage]>; - - await opening; - - const [res]: [IncomingMessage] = await upgrading; - - return res; - } - - private normalizeHeaders( - headers: Record - ): Record { - return Object.entries(headers).reduce( - ( - result: Record, - [key, value]: [string, string | string[]] - ) => { - const headerName = key.trim().toLowerCase(); - if (!WsRequestRunner.FORBIDDEN_HEADERS.has(headerName)) { - result[key] = value; - } - - return result; - }, - {} - ); - } -} diff --git a/packages/repeater/src/request-runner/protocols/index.ts b/packages/repeater/src/request-runner/protocols/index.ts index 39b4ce2e..e274526b 100644 --- a/packages/repeater/src/request-runner/protocols/index.ts +++ b/packages/repeater/src/request-runner/protocols/index.ts @@ -1,2 +1 @@ export * from './HttpRequestRunner'; -export * from './WsRequestRunner'; diff --git a/packages/repeater/src/utils/DefaultProxyFactory.spec.ts b/packages/repeater/src/utils/DefaultProxyFactory.spec.ts new file mode 100644 index 00000000..4d68240d --- /dev/null +++ b/packages/repeater/src/utils/DefaultProxyFactory.spec.ts @@ -0,0 +1,105 @@ +import { DefaultProxyFactory } from './DefaultProxyFactory'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { SocksProxyAgent } from 'socks-proxy-agent'; + +describe('DefaultProxyFactory', () => { + let defaultProxyFactory: DefaultProxyFactory; + + beforeEach(() => { + defaultProxyFactory = new DefaultProxyFactory(); + }); + + describe('createProxy', () => { + it('should create http and https proxy agents for http protocol', () => { + // arrange + const proxyOptions = { + proxyUrl: 'http://proxy.example.com', + rejectUnauthorized: false + }; + + // act + const result = defaultProxyFactory.createProxy(proxyOptions); + + // assert + expect(result.httpAgent).toBeInstanceOf(HttpProxyAgent); + expect(result.httpsAgent).toBeInstanceOf(HttpsProxyAgent); + }); + + it('should create socks proxy agents for socks protocol', () => { + // arrange + const proxyOptions = { + proxyUrl: 'socks://proxy.example.com', + rejectUnauthorized: false + }; + + // act + const result = defaultProxyFactory.createProxy(proxyOptions); + + // assert + expect(result.httpAgent).toBeInstanceOf(SocksProxyAgent); + expect(result.httpsAgent).toBeInstanceOf(SocksProxyAgent); + }); + + it('should throw error for unsupported protocol', () => { + // arrange + const proxyOptions = { + proxyUrl: 'unsupported://proxy.example.com', + rejectUnauthorized: false + }; + + // act & assert + expect(() => defaultProxyFactory.createProxy(proxyOptions)).toThrowError( + 'Unsupported proxy protocol' + ); + }); + }); + + describe('createProxyForClient', () => { + it('should return http agent for http protocol', () => { + // arrange + const targetProxyOptions = { + targetUrl: 'http://target.example.com', + proxyUrl: 'http://proxy.example.com', + rejectUnauthorized: false + }; + + // act + const result = + defaultProxyFactory.createProxyForClient(targetProxyOptions); + + // assert + expect(result).toBeInstanceOf(HttpProxyAgent); + }); + + it('should return https agent for https protocol', () => { + // arrange + const targetProxyOptions = { + targetUrl: 'https://target.example.com', + proxyUrl: 'http://proxy.example.com', + rejectUnauthorized: false + }; + + // act + const result = + defaultProxyFactory.createProxyForClient(targetProxyOptions); + + // assert + expect(result).toBeInstanceOf(HttpsProxyAgent); + }); + + it('should throw error for unsupported protocol', () => { + // arrange + const targetProxyOptions = { + targetUrl: 'unsupported://target.example.com', + proxyUrl: 'http://proxy.example.com', + rejectUnauthorized: false + }; + + // act & assert + expect(() => + defaultProxyFactory.createProxyForClient(targetProxyOptions) + ).toThrowError('Proxy not supported for protocol'); + }); + }); +}); diff --git a/packages/repeater/src/utils/DefaultProxyFactory.ts b/packages/repeater/src/utils/DefaultProxyFactory.ts new file mode 100644 index 00000000..597477a6 --- /dev/null +++ b/packages/repeater/src/utils/DefaultProxyFactory.ts @@ -0,0 +1,83 @@ +import { ProxyFactory, ProxyOptions, TargetProxyOptions } from './ProxyFactory'; +import { PatchedHttpsProxyAgent } from './PatchedHttpsProxyAgent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { SocksProxyAgent } from 'socks-proxy-agent'; +import https from 'node:https'; +import http from 'node:http'; + +export class DefaultProxyFactory implements ProxyFactory { + public createProxy({ proxyUrl, rejectUnauthorized = false }: ProxyOptions) { + let protocol: string; + try { + ({ protocol } = new URL(proxyUrl)); + } catch (error) { + throw new Error( + `Invalid Proxy URL: '${proxyUrl}'. Please provide a valid URL.` + ); + } + + switch (protocol) { + case 'http:': + case 'https:': + return this.createHttpProxy(proxyUrl, rejectUnauthorized); + case 'socks:': + case 'socks4:': + case 'socks4a:': + case 'socks5:': + case 'socks5h:': + return this.createSocksProxy(proxyUrl); + default: + throw new Error( + `Unsupported proxy protocol: '${protocol.replace( + ':', + '' + )}'. Please use a supported protocol (HTTP(S), SOCKS4, or SOCKS5).` + ); + } + } + + public createProxyForClient({ + targetUrl, + ...options + }: TargetProxyOptions): https.Agent | http.Agent { + const proxies = this.createProxy(options); + let protocol: string; + try { + ({ protocol } = new URL(targetUrl)); + } catch (error) { + throw new Error( + `Invalid Target URL: '${targetUrl}'. Please contact support at support@brightsec.com` + ); + } + + switch (protocol) { + case 'http:': + case 'ws:': + return proxies.httpAgent; + case 'https:': + case 'wss:': + return proxies.httpsAgent; + default: + throw new Error( + `Proxy not supported for protocol '${protocol}'. Please contact support at support@brightsec.com` + ); + } + } + + private createHttpProxy(proxyUrl: string, rejectUnauthorized?: boolean) { + return { + httpsAgent: new PatchedHttpsProxyAgent(proxyUrl, { + rejectUnauthorized + }), + httpAgent: new HttpProxyAgent(proxyUrl, { + rejectUnauthorized + }) + }; + } + + private createSocksProxy(proxyUrl: string) { + const common = new SocksProxyAgent(proxyUrl); + + return { httpAgent: common, httpsAgent: common }; + } +} diff --git a/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts b/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts new file mode 100644 index 00000000..20a379f5 --- /dev/null +++ b/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.spec.ts @@ -0,0 +1,47 @@ +import { NormalizeZlibDeflateTransformStream } from './NormalizeZlibDeflateTransformStream'; +import { promisify } from 'node:util'; +import { constants, createInflate, deflate, deflateRaw } from 'node:zlib'; +import { Readable } from 'node:stream'; + +const zOpts = { + flush: constants.Z_SYNC_FLUSH, + finishFlush: constants.Z_SYNC_FLUSH +}; + +describe('NormalizeZlibDeflateTransformStream', () => { + describe('pipe', () => { + it('should add zlib headers to raw deflate', async () => { + // arrange + const data = 'xyz'.repeat(200); + + const stream = Readable.from(await promisify(deflateRaw)(data, zOpts)); + // act + const inflated = stream + .pipe(new NormalizeZlibDeflateTransformStream()) + .pipe(createInflate(zOpts)); + // assert + const result = []; + for await (const chunk of inflated) { + result.push(chunk); + } + expect(result.join('')).toBe(data); + }); + + it('should not affect deflate with zlib headers', async () => { + // arrange + const data = 'xyz'.repeat(200); + + const stream = Readable.from(await promisify(deflate)(data, zOpts)); + // act + const inflated = stream + .pipe(new NormalizeZlibDeflateTransformStream()) + .pipe(createInflate(zOpts)); + // assert + const result = []; + for await (const chunk of inflated) { + result.push(chunk); + } + expect(result.join('')).toBe(data); + }); + }); +}); diff --git a/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.ts b/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.ts new file mode 100644 index 00000000..064e87ba --- /dev/null +++ b/packages/repeater/src/utils/NormalizeZlibDeflateTransformStream.ts @@ -0,0 +1,23 @@ +import { Transform, TransformCallback } from 'node:stream'; + +export class NormalizeZlibDeflateTransformStream extends Transform { + private hasCheckedHead = false; + private readonly header = Buffer.from([0x78, 0x9c]); + // eslint-disable-next-line @typescript-eslint/naming-convention + public _transform( + chunk: any, + encoding: BufferEncoding, + callback: TransformCallback + ) { + if (!this.hasCheckedHead && chunk.length !== 0) { + // ADHOC: detects raw deflate: https://stackoverflow.com/a/37528114 + if (chunk.compare(this.header, 0, 1, 0, 1) !== 0) { + this.push(this.header, encoding); + } + this.hasCheckedHead = true; + } + + this.push(chunk, encoding); + callback(); + } +} diff --git a/packages/repeater/src/utils/PatchedHttpsProxyAgent.ts b/packages/repeater/src/utils/PatchedHttpsProxyAgent.ts new file mode 100644 index 00000000..8a38efa9 --- /dev/null +++ b/packages/repeater/src/utils/PatchedHttpsProxyAgent.ts @@ -0,0 +1,28 @@ +import { + HttpsProxyAgent, + type HttpsProxyAgentOptions +} from 'https-proxy-agent'; +import { type URL } from 'node:url'; +import type http from 'node:http'; +import type net from 'node:net'; + +const kTlsUpgradeOptions = Symbol('tlsUpgradeOptions'); + +// ADHOC: This is a workaround for this issue: https://github.com/TooTallNate/node-https-proxy-agent/issues/89 +export class PatchedHttpsProxyAgent< + T extends string +> extends HttpsProxyAgent { + private readonly [kTlsUpgradeOptions]?: HttpsProxyAgentOptions; + + constructor(proxy: T | URL, opts?: HttpsProxyAgentOptions) { + super(proxy, opts); + this[kTlsUpgradeOptions] = opts; + } + + public override connect( + req: http.ClientRequest, + opts: Parameters['connect']>[1] + ): Promise { + return super.connect(req, { ...this[kTlsUpgradeOptions], ...opts }); + } +} diff --git a/packages/repeater/src/utils/ProxyFactory.ts b/packages/repeater/src/utils/ProxyFactory.ts new file mode 100644 index 00000000..cfbda2be --- /dev/null +++ b/packages/repeater/src/utils/ProxyFactory.ts @@ -0,0 +1,22 @@ +import https from 'node:https'; +import http from 'node:http'; + +export interface ProxyOptions { + proxyUrl: string; + rejectUnauthorized?: boolean; +} + +export interface TargetProxyOptions extends ProxyOptions { + targetUrl: string; +} + +export interface ProxyFactory { + createProxy(options: ProxyOptions): { + httpsAgent: https.Agent; + httpAgent: http.Agent; + }; + + createProxyForClient(options: TargetProxyOptions): https.Agent | http.Agent; +} + +export const ProxyFactory: unique symbol = Symbol('ProxyFactory'); diff --git a/packages/repeater/src/utils/index.ts b/packages/repeater/src/utils/index.ts new file mode 100644 index 00000000..c47972ae --- /dev/null +++ b/packages/repeater/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './ProxyFactory'; +export * from './NormalizeZlibDeflateTransformStream'; +export * from './DefaultProxyFactory';