From ce39ecb62c18e9b5198c14816bca8798a2bb3ccb Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Tue, 5 Nov 2024 11:53:43 +0300 Subject: [PATCH] Feat/nestjs connectrpc (#328) * feat(nestjs-connectrpc): init * refactor(nestjs-connectrpc): utils * refactor(nestjs-connectrpc): decorators * style: fix lint * refactor: reduce nesting to improve code readability * refactor: add utility function AddMethodMetadata * refactor: method startServer return promise * perf: added a return to avoid calling the set twice * style: remove async from startServer method * chore: dependency versions are fixed --------- Co-authored-by: OsirisAnubis --- .pnp.cjs | 317 +++++++++++++++++- packages/nestjs-connectrpc/README.md | 1 + packages/nestjs-connectrpc/package.json | 53 +++ .../src/connectrpc.constants.ts | 5 + .../src/connectrpc.decorators.ts | 97 ++++++ .../src/connectrpc.interfaces.ts | 74 ++++ .../src/connectrpc.server.ts | 122 +++++++ .../src/connectrpc.strategy.ts | 72 ++++ .../src/custom-metadata.storage.ts | 25 ++ packages/nestjs-connectrpc/src/index.ts | 4 + .../src/utils/async.utils.ts | 153 +++++++++ .../src/utils/router.utils.ts | 120 +++++++ yarn.lock | 184 ++++++++++ 13 files changed, 1226 insertions(+), 1 deletion(-) create mode 100644 packages/nestjs-connectrpc/README.md create mode 100644 packages/nestjs-connectrpc/package.json create mode 100644 packages/nestjs-connectrpc/src/connectrpc.constants.ts create mode 100644 packages/nestjs-connectrpc/src/connectrpc.decorators.ts create mode 100644 packages/nestjs-connectrpc/src/connectrpc.interfaces.ts create mode 100644 packages/nestjs-connectrpc/src/connectrpc.server.ts create mode 100644 packages/nestjs-connectrpc/src/connectrpc.strategy.ts create mode 100644 packages/nestjs-connectrpc/src/custom-metadata.storage.ts create mode 100644 packages/nestjs-connectrpc/src/index.ts create mode 100644 packages/nestjs-connectrpc/src/utils/async.utils.ts create mode 100644 packages/nestjs-connectrpc/src/utils/router.utils.ts diff --git a/.pnp.cjs b/.pnp.cjs index b73c701b..710763df 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -30,6 +30,10 @@ const RAW_RUNTIME_STATE = "name": "@atls/nestjs-batch-queue",\ "reference": "workspace:packages/nestjs-batch-queue"\ },\ + {\ + "name": "@atls/nestjs-connectrpc",\ + "reference": "workspace:packages/nestjs-connectrpc"\ + },\ {\ "name": "@atls/nestjs-cqrs",\ "reference": "workspace:packages/nestjs-cqrs"\ @@ -133,6 +137,7 @@ const RAW_RUNTIME_STATE = ["@atlantis-lab/nestjs-signed-url", ["workspace:packages/nestjs-signed-url"]],\ ["@atls/grpc-keto", ["workspace:packages/nestjs-grpc-keto"]],\ ["@atls/nestjs-batch-queue", ["workspace:packages/nestjs-batch-queue"]],\ + ["@atls/nestjs-connectrpc", ["workspace:packages/nestjs-connectrpc"]],\ ["@atls/nestjs-cqrs", ["workspace:packages/nestjs-cqrs"]],\ ["@atls/nestjs-dataloader", ["workspace:packages/nestjs-dataloader"]],\ ["@atls/nestjs-external-renderer", ["workspace:packages/nestjs-external-renderer"]],\ @@ -548,6 +553,24 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@atls/nestjs-connectrpc", [\ + ["workspace:packages/nestjs-connectrpc", {\ + "packageLocation": "./packages/nestjs-connectrpc/",\ + "packageDependencies": [\ + ["@atls/nestjs-connectrpc", "workspace:packages/nestjs-connectrpc"],\ + ["@bufbuild/protobuf", "npm:1.10.0"],\ + ["@connectrpc/connect", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:1.6.1"],\ + ["@connectrpc/connect-node", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:1.6.1"],\ + ["@nestjs/common", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.0.5"],\ + ["@nestjs/core", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.0.5"],\ + ["@nestjs/microservices", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.2.4"],\ + ["@nestjs/platform-express", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.2.4"],\ + ["reflect-metadata", "npm:0.2.2"],\ + ["rxjs", "npm:7.8.1"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@atls/nestjs-cqrs", [\ ["workspace:packages/nestjs-cqrs", {\ "packageLocation": "./packages/nestjs-cqrs/",\ @@ -3280,6 +3303,64 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@bufbuild/protobuf", [\ + ["npm:1.10.0", {\ + "packageLocation": "../.yarn/berry/cache/@bufbuild-protobuf-npm-1.10.0-7f066cde74-10c0.zip/node_modules/@bufbuild/protobuf/",\ + "packageDependencies": [\ + ["@bufbuild/protobuf", "npm:1.10.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@connectrpc/connect", [\ + ["npm:1.6.1", {\ + "packageLocation": "../.yarn/berry/cache/@connectrpc-connect-npm-1.6.1-d31c4e5a29-10c0.zip/node_modules/@connectrpc/connect/",\ + "packageDependencies": [\ + ["@connectrpc/connect", "npm:1.6.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:1.6.1", {\ + "packageLocation": "./.yarn/__virtual__/@connectrpc-connect-virtual-5e9157bdee/2/.yarn/berry/cache/@connectrpc-connect-npm-1.6.1-d31c4e5a29-10c0.zip/node_modules/@connectrpc/connect/",\ + "packageDependencies": [\ + ["@connectrpc/connect", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:1.6.1"],\ + ["@bufbuild/protobuf", "npm:1.10.0"],\ + ["@types/bufbuild__protobuf", null]\ + ],\ + "packagePeers": [\ + "@bufbuild/protobuf",\ + "@types/bufbuild__protobuf"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@connectrpc/connect-node", [\ + ["npm:1.6.1", {\ + "packageLocation": "../.yarn/berry/cache/@connectrpc-connect-node-npm-1.6.1-c3083f9671-10c0.zip/node_modules/@connectrpc/connect-node/",\ + "packageDependencies": [\ + ["@connectrpc/connect-node", "npm:1.6.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:1.6.1", {\ + "packageLocation": "./.yarn/__virtual__/@connectrpc-connect-node-virtual-c17e9794fb/2/.yarn/berry/cache/@connectrpc-connect-node-npm-1.6.1-c3083f9671-10c0.zip/node_modules/@connectrpc/connect-node/",\ + "packageDependencies": [\ + ["@connectrpc/connect-node", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:1.6.1"],\ + ["@bufbuild/protobuf", "npm:1.10.0"],\ + ["@connectrpc/connect", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:1.6.1"],\ + ["@types/bufbuild__protobuf", null],\ + ["@types/connectrpc__connect", null],\ + ["undici", "npm:5.28.4"]\ + ],\ + "packagePeers": [\ + "@bufbuild/protobuf",\ + "@connectrpc/connect",\ + "@types/bufbuild__protobuf",\ + "@types/connectrpc__connect"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@emotion/css-prettifier", [\ ["npm:1.1.4", {\ "packageLocation": "../.yarn/berry/cache/@emotion-css-prettifier-npm-1.1.4-849a301a6c-10c0.zip/node_modules/@emotion/css-prettifier/",\ @@ -7185,6 +7266,34 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ + ["virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.0.5", {\ + "packageLocation": "./.yarn/__virtual__/@nestjs-common-virtual-80e1232acf/2/.yarn/berry/cache/@nestjs-common-npm-10.0.5-0a7f781d1a-10c0.zip/node_modules/@nestjs/common/",\ + "packageDependencies": [\ + ["@nestjs/common", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.0.5"],\ + ["@types/class-transformer", null],\ + ["@types/class-validator", null],\ + ["@types/reflect-metadata", null],\ + ["@types/rxjs", null],\ + ["class-transformer", null],\ + ["class-validator", null],\ + ["iterare", "npm:1.2.1"],\ + ["reflect-metadata", "npm:0.2.2"],\ + ["rxjs", "npm:7.8.1"],\ + ["tslib", "npm:2.6.0"],\ + ["uid", "npm:2.0.2"]\ + ],\ + "packagePeers": [\ + "@types/class-transformer",\ + "@types/class-validator",\ + "@types/reflect-metadata",\ + "@types/rxjs",\ + "class-transformer",\ + "class-validator",\ + "reflect-metadata",\ + "rxjs"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:77887786a24289fa840c9acd370d634accbe79bcf317ecf5401844ffff73b8a593879dd9cce463873637e6414a631dfdb1a2473704bf332d823bcfffac8c2469#npm:10.4.1", {\ "packageLocation": "./.yarn/__virtual__/@nestjs-common-virtual-d13c8c526b/2/.yarn/berry/cache/@nestjs-common-npm-10.4.1-940734b1b1-10c0.zip/node_modules/@nestjs/common/",\ "packageDependencies": [\ @@ -7272,7 +7381,7 @@ const RAW_RUNTIME_STATE = ]],\ ["@nestjs/core", [\ ["npm:10.0.5", {\ - "packageLocation": "./.yarn/unplugged/@nestjs-core-virtual-684e49535a/node_modules/@nestjs/core/",\ + "packageLocation": "./.yarn/unplugged/@nestjs-core-virtual-4ff5a55b98/node_modules/@nestjs/core/",\ "packageDependencies": [\ ["@nestjs/core", "npm:10.0.5"]\ ],\ @@ -7657,6 +7766,45 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ + ["virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.0.5", {\ + "packageLocation": "./.yarn/unplugged/@nestjs-core-virtual-4ff5a55b98/node_modules/@nestjs/core/",\ + "packageDependencies": [\ + ["@nestjs/core", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.0.5"],\ + ["@nestjs/common", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.0.5"],\ + ["@nestjs/microservices", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.2.4"],\ + ["@nestjs/platform-express", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.2.4"],\ + ["@nestjs/websockets", null],\ + ["@nuxtjs/opencollective", "npm:0.3.2"],\ + ["@types/nestjs__common", null],\ + ["@types/nestjs__microservices", null],\ + ["@types/nestjs__platform-express", null],\ + ["@types/nestjs__websockets", null],\ + ["@types/reflect-metadata", null],\ + ["@types/rxjs", null],\ + ["fast-safe-stringify", "npm:2.1.1"],\ + ["iterare", "npm:1.2.1"],\ + ["path-to-regexp", "npm:3.2.0"],\ + ["reflect-metadata", "npm:0.2.2"],\ + ["rxjs", "npm:7.8.1"],\ + ["tslib", "npm:2.6.0"],\ + ["uid", "npm:2.0.2"]\ + ],\ + "packagePeers": [\ + "@nestjs/common",\ + "@nestjs/microservices",\ + "@nestjs/platform-express",\ + "@nestjs/websockets",\ + "@types/nestjs__common",\ + "@types/nestjs__microservices",\ + "@types/nestjs__platform-express",\ + "@types/nestjs__websockets",\ + "@types/reflect-metadata",\ + "@types/rxjs",\ + "reflect-metadata",\ + "rxjs"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:77887786a24289fa840c9acd370d634accbe79bcf317ecf5401844ffff73b8a593879dd9cce463873637e6414a631dfdb1a2473704bf332d823bcfffac8c2469#npm:10.4.1", {\ "packageLocation": "./.yarn/unplugged/@nestjs-core-virtual-fd48581fb6/node_modules/@nestjs/core/",\ "packageDependencies": [\ @@ -8081,6 +8229,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@nestjs/microservices", [\ + ["npm:10.2.4", {\ + "packageLocation": "../.yarn/berry/cache/@nestjs-microservices-npm-10.2.4-36046d0a39-10c0.zip/node_modules/@nestjs/microservices/",\ + "packageDependencies": [\ + ["@nestjs/microservices", "npm:10.2.4"]\ + ],\ + "linkType": "SOFT"\ + }],\ ["npm:10.4.1", {\ "packageLocation": "../.yarn/berry/cache/@nestjs-microservices-npm-10.4.1-709407ada4-10c0.zip/node_modules/@nestjs/microservices/",\ "packageDependencies": [\ @@ -8473,6 +8628,69 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ + ["virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.2.4", {\ + "packageLocation": "./.yarn/__virtual__/@nestjs-microservices-virtual-40bd19dfd0/2/.yarn/berry/cache/@nestjs-microservices-npm-10.2.4-36046d0a39-10c0.zip/node_modules/@nestjs/microservices/",\ + "packageDependencies": [\ + ["@nestjs/microservices", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.2.4"],\ + ["@grpc/grpc-js", null],\ + ["@nestjs/common", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.0.5"],\ + ["@nestjs/core", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.0.5"],\ + ["@nestjs/websockets", null],\ + ["@types/amqp-connection-manager", null],\ + ["@types/amqplib", null],\ + ["@types/cache-manager", null],\ + ["@types/grpc__grpc-js", null],\ + ["@types/ioredis", null],\ + ["@types/kafkajs", null],\ + ["@types/mqtt", null],\ + ["@types/nats", null],\ + ["@types/nestjs__common", null],\ + ["@types/nestjs__core", null],\ + ["@types/nestjs__websockets", null],\ + ["@types/reflect-metadata", null],\ + ["@types/rxjs", null],\ + ["amqp-connection-manager", null],\ + ["amqplib", null],\ + ["cache-manager", null],\ + ["ioredis", null],\ + ["iterare", "npm:1.2.1"],\ + ["kafkajs", null],\ + ["mqtt", null],\ + ["nats", null],\ + ["reflect-metadata", "npm:0.2.2"],\ + ["rxjs", "npm:7.8.1"],\ + ["tslib", "npm:2.6.2"]\ + ],\ + "packagePeers": [\ + "@grpc/grpc-js",\ + "@nestjs/common",\ + "@nestjs/core",\ + "@nestjs/websockets",\ + "@types/amqp-connection-manager",\ + "@types/amqplib",\ + "@types/cache-manager",\ + "@types/grpc__grpc-js",\ + "@types/ioredis",\ + "@types/kafkajs",\ + "@types/mqtt",\ + "@types/nats",\ + "@types/nestjs__common",\ + "@types/nestjs__core",\ + "@types/nestjs__websockets",\ + "@types/reflect-metadata",\ + "@types/rxjs",\ + "amqp-connection-manager",\ + "amqplib",\ + "cache-manager",\ + "ioredis",\ + "kafkajs",\ + "mqtt",\ + "nats",\ + "reflect-metadata",\ + "rxjs"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:77887786a24289fa840c9acd370d634accbe79bcf317ecf5401844ffff73b8a593879dd9cce463873637e6414a631dfdb1a2473704bf332d823bcfffac8c2469#npm:10.4.1", {\ "packageLocation": "./.yarn/__virtual__/@nestjs-microservices-virtual-f7a1c4c51f/2/.yarn/berry/cache/@nestjs-microservices-npm-10.4.1-709407ada4-10c0.zip/node_modules/@nestjs/microservices/",\ "packageDependencies": [\ @@ -8727,6 +8945,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@nestjs/platform-express", [\ + ["npm:10.2.4", {\ + "packageLocation": "../.yarn/berry/cache/@nestjs-platform-express-npm-10.2.4-9288a94935-10c0.zip/node_modules/@nestjs/platform-express/",\ + "packageDependencies": [\ + ["@nestjs/platform-express", "npm:10.2.4"]\ + ],\ + "linkType": "SOFT"\ + }],\ ["npm:10.4.1", {\ "packageLocation": "../.yarn/berry/cache/@nestjs-platform-express-npm-10.4.1-76944971fd-10c0.zip/node_modules/@nestjs/platform-express/",\ "packageDependencies": [\ @@ -8888,6 +9113,28 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ + ["virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.2.4", {\ + "packageLocation": "./.yarn/__virtual__/@nestjs-platform-express-virtual-4ffd205e87/2/.yarn/berry/cache/@nestjs-platform-express-npm-10.2.4-9288a94935-10c0.zip/node_modules/@nestjs/platform-express/",\ + "packageDependencies": [\ + ["@nestjs/platform-express", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.2.4"],\ + ["@nestjs/common", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.0.5"],\ + ["@nestjs/core", "virtual:648c68c35811325b322f076db385dc59f68f30bef81445d78151df0a3ea0d4065e47b943cf5d70cd0f1373bda966ec3fc1243f9ee65c9dabb966db8a62ec7194#npm:10.0.5"],\ + ["@types/nestjs__common", null],\ + ["@types/nestjs__core", null],\ + ["body-parser", "npm:1.20.2"],\ + ["cors", "npm:2.8.5"],\ + ["express", "npm:4.18.2"],\ + ["multer", "npm:1.4.4-lts.1"],\ + ["tslib", "npm:2.6.2"]\ + ],\ + "packagePeers": [\ + "@nestjs/common",\ + "@nestjs/core",\ + "@types/nestjs__common",\ + "@types/nestjs__core"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:8bec40052f12d81e744cef5439ddeb1bd4ee060e942425ae026f8b5aed6129af0faec331a05cddfcc04441a2829beb6b34db019f2a7591ff3c9c1776de7944d5#npm:10.4.1", {\ "packageLocation": "./.yarn/__virtual__/@nestjs-platform-express-virtual-ccaf9dc4c1/2/.yarn/berry/cache/@nestjs-platform-express-npm-10.4.1-76944971fd-10c0.zip/node_modules/@nestjs/platform-express/",\ "packageDependencies": [\ @@ -13466,6 +13713,25 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["body-parser", [\ + ["npm:1.20.1", {\ + "packageLocation": "../.yarn/berry/cache/body-parser-npm-1.20.1-759fd14db9-10c0.zip/node_modules/body-parser/",\ + "packageDependencies": [\ + ["body-parser", "npm:1.20.1"],\ + ["bytes", "npm:3.1.2"],\ + ["content-type", "npm:1.0.5"],\ + ["debug", "virtual:c7b184cd14c02e3ce555ab1875e60cf5033c617e17d82c4c02ea822101d3c817f48bf25a766b4d4335742dc5c9c14c2e88a57ed955a56c4ad0613899f82f5618#npm:2.6.9"],\ + ["depd", "npm:2.0.0"],\ + ["destroy", "npm:1.2.0"],\ + ["http-errors", "npm:2.0.0"],\ + ["iconv-lite", "npm:0.4.24"],\ + ["on-finished", "npm:2.4.1"],\ + ["qs", "npm:6.11.0"],\ + ["raw-body", "npm:2.5.1"],\ + ["type-is", "npm:1.6.18"],\ + ["unpipe", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:1.20.2", {\ "packageLocation": "../.yarn/berry/cache/body-parser-npm-1.20.2-44738662cf-10c0.zip/node_modules/body-parser/",\ "packageDependencies": [\ @@ -15895,6 +16161,44 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["express", [\ + ["npm:4.18.2", {\ + "packageLocation": "../.yarn/berry/cache/express-npm-4.18.2-bb15ff679a-10c0.zip/node_modules/express/",\ + "packageDependencies": [\ + ["express", "npm:4.18.2"],\ + ["accepts", "npm:1.3.8"],\ + ["array-flatten", "npm:1.1.1"],\ + ["body-parser", "npm:1.20.1"],\ + ["content-disposition", "npm:0.5.4"],\ + ["content-type", "npm:1.0.5"],\ + ["cookie", "npm:0.5.0"],\ + ["cookie-signature", "npm:1.0.6"],\ + ["debug", "virtual:c7b184cd14c02e3ce555ab1875e60cf5033c617e17d82c4c02ea822101d3c817f48bf25a766b4d4335742dc5c9c14c2e88a57ed955a56c4ad0613899f82f5618#npm:2.6.9"],\ + ["depd", "npm:2.0.0"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["etag", "npm:1.8.1"],\ + ["finalhandler", "npm:1.2.0"],\ + ["fresh", "npm:0.5.2"],\ + ["http-errors", "npm:2.0.0"],\ + ["merge-descriptors", "npm:1.0.1"],\ + ["methods", "npm:1.1.2"],\ + ["on-finished", "npm:2.4.1"],\ + ["parseurl", "npm:1.3.3"],\ + ["path-to-regexp", "npm:0.1.7"],\ + ["proxy-addr", "npm:2.0.7"],\ + ["qs", "npm:6.11.0"],\ + ["range-parser", "npm:1.2.1"],\ + ["safe-buffer", "npm:5.2.1"],\ + ["send", "npm:0.18.0"],\ + ["serve-static", "npm:1.15.0"],\ + ["setprototypeof", "npm:1.2.0"],\ + ["statuses", "npm:2.0.1"],\ + ["type-is", "npm:1.6.18"],\ + ["utils-merge", "npm:1.0.1"],\ + ["vary", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:4.19.2", {\ "packageLocation": "../.yarn/berry/cache/express-npm-4.19.2-f81334a22a-10c0.zip/node_modules/express/",\ "packageDependencies": [\ @@ -20726,6 +21030,17 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["raw-body", [\ + ["npm:2.5.1", {\ + "packageLocation": "../.yarn/berry/cache/raw-body-npm-2.5.1-9dd1d9fff9-10c0.zip/node_modules/raw-body/",\ + "packageDependencies": [\ + ["raw-body", "npm:2.5.1"],\ + ["bytes", "npm:3.1.2"],\ + ["http-errors", "npm:2.0.0"],\ + ["iconv-lite", "npm:0.4.24"],\ + ["unpipe", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:2.5.2", {\ "packageLocation": "../.yarn/berry/cache/raw-body-npm-2.5.2-5cb9dfebc1-10c0.zip/node_modules/raw-body/",\ "packageDependencies": [\ diff --git a/packages/nestjs-connectrpc/README.md b/packages/nestjs-connectrpc/README.md new file mode 100644 index 00000000..9af49270 --- /dev/null +++ b/packages/nestjs-connectrpc/README.md @@ -0,0 +1 @@ +ConnectRpc migration @wolfcoded/nestjs-bufconnect diff --git a/packages/nestjs-connectrpc/package.json b/packages/nestjs-connectrpc/package.json new file mode 100644 index 00000000..aad01eaa --- /dev/null +++ b/packages/nestjs-connectrpc/package.json @@ -0,0 +1,53 @@ +{ + "name": "@atls/nestjs-connectrpc", + "version": "0.0.0", + "license": "BSD-3-Clause", + "type": "module", + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, + "main": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "yarn library build", + "prepack": "yarn run build", + "postpack": "rm -rf dist" + }, + "devDependencies": { + "@bufbuild/protobuf": "1.10.0", + "@connectrpc/connect": "1.6.1", + "@connectrpc/connect-node": "1.6.1", + "@nestjs/common": "10.0.5", + "@nestjs/core": "10.0.5", + "@nestjs/microservices": "10.2.4", + "@nestjs/platform-express": "10.2.4", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.1" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^1", + "@connectrpc/connect": "^1", + "@connectrpc/connect-node": "^1", + "@nestjs/common": "^10", + "@nestjs/core": "^10", + "@nestjs/microservices": "^10", + "@nestjs/platform-express": "^10", + "reflect-metadata": "^0.2", + "rxjs": "^7" + }, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "main": "dist/index.js", + "typings": "dist/index.d.ts" + } +} diff --git a/packages/nestjs-connectrpc/src/connectrpc.constants.ts b/packages/nestjs-connectrpc/src/connectrpc.constants.ts new file mode 100644 index 00000000..a393c8cb --- /dev/null +++ b/packages/nestjs-connectrpc/src/connectrpc.constants.ts @@ -0,0 +1,5 @@ +export const METHOD_DECORATOR_KEY = Symbol('METHOD_DECORATOR_KEY') + +export const STREAM_METHOD_DECORATOR_KEY = Symbol('STREAM_METHOD_DECORATOR_KEY') + +export const CONNECTRPC_TRANSPORT = Symbol('CONNECTRPC_TRANSPORT') diff --git a/packages/nestjs-connectrpc/src/connectrpc.decorators.ts b/packages/nestjs-connectrpc/src/connectrpc.decorators.ts new file mode 100644 index 00000000..6a189953 --- /dev/null +++ b/packages/nestjs-connectrpc/src/connectrpc.decorators.ts @@ -0,0 +1,97 @@ +import type { ServiceType } from '@bufbuild/protobuf' + +import type { ConstructorWithPrototype } from './connectrpc.interfaces.js' +import type { FunctionPropertyDescriptor } from './connectrpc.interfaces.js' +import type { MethodKey } from './connectrpc.interfaces.js' +import type { MethodKeys } from './connectrpc.interfaces.js' + +import { MessagePattern } from '@nestjs/microservices' + +import { CONNECTRPC_TRANSPORT } from './connectrpc.constants.js' +import { METHOD_DECORATOR_KEY } from './connectrpc.constants.js' +import { STREAM_METHOD_DECORATOR_KEY } from './connectrpc.constants.js' +import { MethodType } from './connectrpc.interfaces.js' +import { CustomMetadataStore } from './custom-metadata.storage.js' +import { createConnectRpcMethodMetadata } from './utils/router.utils.js' + +/** + * Type guard to check if a given descriptor is a function property descriptor. + * @param {PropertyDescriptor | undefined} descriptor - The descriptor to check. + * @returns {descriptor is FunctionPropertyDescriptor} - True if descriptor is for a function. + */ +function isFunctionPropertyDescriptor( + descriptor: PropertyDescriptor | undefined +): descriptor is FunctionPropertyDescriptor { + return descriptor !== undefined && typeof descriptor.value === 'function' +} + +/** + * ConnectRpcService decorator to register RPC services and their handlers. + * @param {ServiceType} serviceName - The service type from protobuf. + * @returns {ClassDecorator} - Class decorator function. + */ +export const ConnectRpcService = (serviceName: ServiceType): ClassDecorator => + (target: ConstructorWithPrototype): void => { + // Получаем все зарегистрированные методы и объединяем их + const unaryMethodKeys: MethodKeys = Reflect.getMetadata(METHOD_DECORATOR_KEY, target) || [] + const streamMethodKeys: MethodKeys = + Reflect.getMetadata(STREAM_METHOD_DECORATOR_KEY, target) || [] + const allMethodKeys = [...unaryMethodKeys, ...streamMethodKeys] as Array + + allMethodKeys.forEach((methodImpl) => { + const { key: functionName, methodType } = methodImpl + const descriptor = Object.getOwnPropertyDescriptor(target.prototype, functionName) + + if (!descriptor || !isFunctionPropertyDescriptor(descriptor)) return + + const metadata = createConnectRpcMethodMetadata( + descriptor.value, + functionName, + serviceName.typeName, + functionName, + methodType + ) + + CustomMetadataStore.getInstance().set(serviceName.typeName, serviceName) + MessagePattern(metadata, CONNECTRPC_TRANSPORT)(target.prototype, functionName, descriptor) + }) + } + +export const AddMethodMetadata = ( + target: object, + key: string | symbol, + methodType: MethodType, + metadataKey: string | symbol +): void => { + const metadata: MethodKey = { + key: key.toString(), + methodType, + } + + const existingMethods = + (Reflect.getMetadata(metadataKey, target.constructor) as Set) || new Set() + + if (existingMethods.has(metadata)) return + + existingMethods.add(metadata) + Reflect.defineMetadata(metadataKey, existingMethods, target.constructor) +} + +/** + * Decorator for unary RPC methods. + * Registers the method as a unary RPC with no streaming. + * @returns {MethodDecorator} - Method decorator function. + */ +export const ConnectRpcMethod = (): MethodDecorator => (target: object, key: string | symbol) => { + AddMethodMetadata(target, key, MethodType.NO_STREAMING, METHOD_DECORATOR_KEY) +} + +/** + * Decorator for streaming RPC methods. + * Registers the method as a streaming RPC with RX_STREAMING type. + * @returns {MethodDecorator} - Method decorator function. + */ +export const ConnectRpcStreamMethod = (): MethodDecorator => + (target: object, key: string | symbol) => { + AddMethodMetadata(target, key, MethodType.RX_STREAMING, STREAM_METHOD_DECORATOR_KEY) + } diff --git a/packages/nestjs-connectrpc/src/connectrpc.interfaces.ts b/packages/nestjs-connectrpc/src/connectrpc.interfaces.ts new file mode 100644 index 00000000..d1f56cb7 --- /dev/null +++ b/packages/nestjs-connectrpc/src/connectrpc.interfaces.ts @@ -0,0 +1,74 @@ +import type * as http from 'http' +import type * as http2 from 'http2' +import type * as https from 'https' +import type { ConnectRouterOptions } from '@connectrpc/connect' +import type { Observable } from 'rxjs' + +export interface ConnectRpcPattern { + service: string + rpc: string + streaming: MethodType +} + +export enum MethodType { + NO_STREAMING = 'no_stream', + RX_STREAMING = 'rx_stream', +} + +export enum ServerProtocol { + HTTP = 'http', + HTTPS = 'https', + HTTP2 = 'http2', + HTTP2_INSECURE = 'http2_insecure', +} + +export interface BaseServerOptions { + port: number + connectOptions?: ConnectRouterOptions + callback?: () => void +} + +export interface HttpOptions extends BaseServerOptions { + protocol: ServerProtocol.HTTP + serverOptions?: http.ServerOptions +} + +export interface HttpsOptions extends BaseServerOptions { + protocol: ServerProtocol.HTTPS + serverOptions: https.ServerOptions +} + +export interface Http2Options extends BaseServerOptions { + protocol: ServerProtocol.HTTP2 + serverOptions: http2.SecureServerOptions +} + +export interface Http2InsecureOptions extends BaseServerOptions { + protocol: ServerProtocol.HTTP2_INSECURE + serverOptions?: http2.ServerOptions +} + +export type ServerTypeOptions = Http2InsecureOptions | Http2Options | HttpOptions | HttpsOptions + +export type ServerInstance = http.Server | http2.Http2Server | https.Server | null + +export interface ConstructorWithPrototype { + prototype: Record +} + +export interface MethodKey { + key: string + methodType: MethodType +} + +export type MethodKeys = Array + +export interface FunctionPropertyDescriptor extends PropertyDescriptor { + value: (...arguments_: Array) => never +} + +export type ResultOrDeferred = + | Observable + | T + | { subscribe: () => void } + | { toPromise: () => Promise } diff --git a/packages/nestjs-connectrpc/src/connectrpc.server.ts b/packages/nestjs-connectrpc/src/connectrpc.server.ts new file mode 100644 index 00000000..c6e8e7c5 --- /dev/null +++ b/packages/nestjs-connectrpc/src/connectrpc.server.ts @@ -0,0 +1,122 @@ +import type { ConnectRouter } from '@connectrpc/connect' + +import type { Http2InsecureOptions } from './connectrpc.interfaces.js' +import type { Http2Options } from './connectrpc.interfaces.js' +import type { ServerTypeOptions } from './connectrpc.interfaces.js' +import type { HttpsOptions } from './connectrpc.interfaces.js' +import type { HttpOptions } from './connectrpc.interfaces.js' +import type { ServerInstance } from './connectrpc.interfaces.js' + +import { connectNodeAdapter } from '@connectrpc/connect-node' +import * as http from 'http' +import * as http2 from 'http2' +import * as https from 'https' + +import { ServerProtocol } from './connectrpc.interfaces.js' + +export class HTTPServer { + private serverPrivate: ServerInstance = null + + constructor( + private readonly options: ServerTypeOptions, + private readonly router: (router: ConnectRouter) => void + ) {} + + set server(value: http.Server | http2.Http2Server | https.Server | null) { + this.serverPrivate = value + } + + get server(): http.Server | http2.Http2Server | https.Server | null { + return this.serverPrivate + } + + async listen(): Promise { + await this.startServer() + } + + createHttpServer(): http.Server { + const { serverOptions = {}, connectOptions = {} } = this.options as HttpOptions + + return http.createServer( + serverOptions, + connectNodeAdapter({ + ...connectOptions, + routes: this.router, + }) + ) + } + + createHttpsServer(): https.Server { + const { serverOptions = {}, connectOptions = {} } = this.options as HttpsOptions + + return https.createServer( + serverOptions, + connectNodeAdapter({ ...connectOptions, routes: this.router }) + ) + } + + createHttp2Server(): http2.Http2Server { + const { serverOptions = {}, connectOptions = {} } = this.options as Http2Options + + return http2.createSecureServer( + serverOptions, + connectNodeAdapter({ ...connectOptions, routes: this.router }) + ) + } + + createHttp2InsecureServer(): http2.Http2Server { + const { serverOptions = {}, connectOptions = {} } = this.options as Http2InsecureOptions + + return http2.createServer( + serverOptions, + connectNodeAdapter({ ...connectOptions, routes: this.router }) + ) + } + + // eslint-disable-next-line @typescript-eslint/promise-function-async + startServer(): Promise { + return new Promise((resolve, reject) => { + switch (this.options.protocol) { + case ServerProtocol.HTTP: { + this.server = this.createHttpServer() + break + } + case ServerProtocol.HTTPS: { + this.server = this.createHttpsServer() + break + } + case ServerProtocol.HTTP2: { + this.server = this.createHttp2Server() + break + } + case ServerProtocol.HTTP2_INSECURE: { + this.server = this.createHttp2InsecureServer() + break + } + default: { + reject(new Error('Invalid protocol option')) + return + } + } + + this.server.listen(this.options.port, () => { + if (this.options.callback) this.options.callback() + resolve() + }) + }) + } + + async close(callback?: () => void): Promise { + return new Promise((resolve, reject) => { + if (this.server === null) { + reject(new Error('Server is not running')) + } else { + this.server.close(() => { + this.server = null + if (callback) callback() + resolve() + }) + } + }) + } +} diff --git a/packages/nestjs-connectrpc/src/connectrpc.strategy.ts b/packages/nestjs-connectrpc/src/connectrpc.strategy.ts new file mode 100644 index 00000000..a0c50944 --- /dev/null +++ b/packages/nestjs-connectrpc/src/connectrpc.strategy.ts @@ -0,0 +1,72 @@ +import type { ConnectRouter } from '@connectrpc/connect' +import type { CustomTransportStrategy } from '@nestjs/microservices' +import type { MessageHandler } from '@nestjs/microservices' + +import type { ServerTypeOptions } from './connectrpc.interfaces.js' + +import { Server } from '@nestjs/microservices' +import { isString } from '@nestjs/common/utils/shared.utils.js' + +import { HTTPServer } from './connectrpc.server.js' +import { CustomMetadataStore } from './custom-metadata.storage.js' +import { addServicesToRouter } from './utils/router.utils.js' +import { createServiceHandlersMap } from './utils/router.utils.js' + +export class ConnectRpcServer extends Server implements CustomTransportStrategy { + private readonly customMetadataStore: CustomMetadataStore | null = null + + private server: HTTPServer | null = null + + private readonly options: ServerTypeOptions + + constructor(options: ServerTypeOptions) { + super() + this.customMetadataStore = CustomMetadataStore.getInstance() + this.options = options + } + + async listen( + callback: (error?: unknown, ...optionalParameters: Array) => void + ): Promise { + try { + const router = this.buildRouter() + this.server = new HTTPServer(this.options, router) + + await this.server.listen() + + callback() + } catch (error) { + callback(error) + } + } + + public async close(): Promise { + await this.server?.close() + } + + public override addHandler( + pattern: unknown, + callback: MessageHandler, + isEventHandler = false + ): void { + const route = isString(pattern) ? pattern : JSON.stringify(pattern) + if (isEventHandler) { + const modifiedCallback = callback + modifiedCallback.isEventHandler = true + this.messageHandlers.set(route, modifiedCallback) + return + } + this.messageHandlers.set(route, callback) + } + + buildRouter() { + return (router: ConnectRouter): void => { + if (!this.customMetadataStore) return + const serviceHandlersMap = createServiceHandlersMap( + this.getHandlers(), + this.customMetadataStore + ) + addServicesToRouter(router, serviceHandlersMap, this.customMetadataStore) + } + } +} diff --git a/packages/nestjs-connectrpc/src/custom-metadata.storage.ts b/packages/nestjs-connectrpc/src/custom-metadata.storage.ts new file mode 100644 index 00000000..93b4ae9d --- /dev/null +++ b/packages/nestjs-connectrpc/src/custom-metadata.storage.ts @@ -0,0 +1,25 @@ +import type { ServiceType } from '@bufbuild/protobuf' + +export class CustomMetadataStore { + private static instance: CustomMetadataStore + + private customMetadata: Map = new Map() + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + public static getInstance(): CustomMetadataStore { + if (!CustomMetadataStore.instance) { + CustomMetadataStore.instance = new CustomMetadataStore() + } + return CustomMetadataStore.instance + } + + set(key: string, value: ServiceType): void { + this.customMetadata.set(key, value) + } + + get(key: string): ServiceType | undefined { + return this.customMetadata.get(key) ?? undefined + } +} diff --git a/packages/nestjs-connectrpc/src/index.ts b/packages/nestjs-connectrpc/src/index.ts new file mode 100644 index 00000000..395e5d22 --- /dev/null +++ b/packages/nestjs-connectrpc/src/index.ts @@ -0,0 +1,4 @@ +export * from './connectrpc.decorators.js' +export * from './connectrpc.interfaces.js' +export * from './connectrpc.strategy.js' +export * from './connectrpc.server.js' diff --git a/packages/nestjs-connectrpc/src/utils/async.utils.ts b/packages/nestjs-connectrpc/src/utils/async.utils.ts new file mode 100644 index 00000000..b20a560f --- /dev/null +++ b/packages/nestjs-connectrpc/src/utils/async.utils.ts @@ -0,0 +1,153 @@ +import type { ResultOrDeferred } from '../connectrpc.interfaces.js' + +import { Observable } from 'rxjs' +import { Subject } from 'rxjs' +import { from } from 'rxjs' +import { lastValueFrom } from 'rxjs' + +/** + * Type guard to check if a given input is an AsyncGenerator. + * @param {unknown} input - The input to check. + * @returns {boolean} - True if the input is an AsyncGenerator, otherwise false. + */ +export function isAsyncGenerator(input: unknown): input is AsyncGenerator { + return typeof input === 'object' && input !== null && Symbol.asyncIterator in input +} + +/** + * Utility function to create an async iterator for a Subject. + * @param {Subject} subject - The Subject to create an iterator from. + * @returns {AsyncIterableIterator} - The async iterator. + */ +async function* asyncIterator(subject: Subject): AsyncIterableIterator { + const nextValue = async (): Promise => + new Promise((resolve, reject) => { + subject.subscribe({ + next: (val) => { + resolve(val) + }, + error: (err) => { + reject(err) + }, + complete: () => { + resolve(null as PromiseLike | T) + }, + }) + }) + + while (true) { + // eslint-disable-next-line no-await-in-loop + const item = await nextValue() + if (item === null) return + yield item + } +} + +/** + * Converts an Observable to an AsyncGenerator, yielding items emitted by the Observable. + * @param {Observable} observable - The Observable to convert. + * @returns {AsyncGenerator} - An AsyncGenerator that yields each emitted value from the Observable. + */ +export async function* observableToAsyncGenerator(observable: Observable): AsyncGenerator { + const queue = new Subject() + + const subscriber = observable.subscribe({ + next: (value) => { + queue.next(value) + }, + error: (error) => { + queue.error(error) + }, + complete: () => { + queue.complete() + }, + }) + + try { + for await (const item of asyncIterator(queue)) { + yield item + } + } finally { + subscriber.unsubscribe() + } +} + +/** + * Type guard to check if a given object is an Observable. + * @param {unknown} object - The object to check. + * @returns {boolean} - True if the object is an Observable, otherwise false. + */ +export const isObservable = (object: unknown): object is Observable => + object instanceof Observable + +/** + * Type guard to check if a given object has a subscribe method. + * @param {unknown} object - The object to check. + * @returns {boolean} - True if the object has a subscribe method, otherwise false. + */ +export const hasSubscribe = (object: unknown): object is { subscribe: () => void } => + typeof object === 'object' && + object !== null && + typeof (object as { subscribe?: () => void }).subscribe === 'function' + +/** + * Type guard to check if a given object has a toPromise method. + * @param {unknown} object - The object to check. + * @returns {boolean} - True if the object has a toPromise method, otherwise false. + */ +export const hasToPromise = (object: unknown): object is { toPromise: () => Promise } => + typeof object === 'object' && + object !== null && + typeof (object as { toPromise?: () => Promise }).toPromise === 'function' + +/** + * Converts various types to an Observable, supporting objects that are Observables, have a subscribe or toPromise method. + * @param {ResultOrDeferred} resultOrDeferred - The result or deferred value to convert. + * @returns {Observable} - An Observable that emits the result or deferred value. + */ +export const transformToObservable = (resultOrDeferred: ResultOrDeferred): Observable => { + if (isObservable(resultOrDeferred)) { + return resultOrDeferred as Observable + } + if (hasSubscribe(resultOrDeferred)) { + return new Observable((subscriber) => { + // @ts-expect-error + resultOrDeferred.subscribe({ + next: (value: any) => { + subscriber.next(value as T) + }, + error: (error: any) => { + subscriber.error(error) + }, + complete: () => { + subscriber.complete() + }, + }) + }) + } + if (hasToPromise(resultOrDeferred)) { + return from(lastValueFrom(resultOrDeferred as Observable)) + } + return new Observable((subscriber) => { + subscriber.next(resultOrDeferred) + subscriber.complete() + }) +} + +/** + * Converts either an AsyncGenerator or an Observable to an AsyncGenerator. + * @param {AsyncGenerator | Observable} input - The input to convert. + * @returns {AsyncGenerator} - An AsyncGenerator that yields values from the input. + * @throws {TypeError} - If the input is not an AsyncGenerator or Observable. + */ +export async function* toAsyncGenerator( + input: AsyncGenerator | Observable +): AsyncGenerator { + if (isObservable(input)) { + yield* observableToAsyncGenerator(input) + } else if (isAsyncGenerator(input)) { + yield* input + } else { + throw new TypeError('Unsupported input type. Expected an Observable or an AsyncGenerator.') + } +} diff --git a/packages/nestjs-connectrpc/src/utils/router.utils.ts b/packages/nestjs-connectrpc/src/utils/router.utils.ts new file mode 100644 index 00000000..03e4373b --- /dev/null +++ b/packages/nestjs-connectrpc/src/utils/router.utils.ts @@ -0,0 +1,120 @@ +import type { ServiceType } from '@bufbuild/protobuf' +import type { ConnectRouter } from '@connectrpc/connect' +import type { ServiceImpl } from '@connectrpc/connect' +import type { MessageHandler } from '@nestjs/microservices' +import type { Observable } from 'rxjs' + +import type { ConnectRpcPattern } from '../connectrpc.interfaces.js' +import type { CustomMetadataStore } from '../custom-metadata.storage.js' + +import { lastValueFrom } from 'rxjs' + +import { MethodType } from '../connectrpc.interfaces.js' +import { toAsyncGenerator } from './async.utils.js' +import { transformToObservable } from './async.utils.js' + +/** + * Creates a JSON string pattern for a given service, method, and streaming type. + * @param {string} service - The name of the service. + * @param {string} methodName - The name of the method to call. + * @param {MethodType} streaming - The streaming type (NO_STREAMING or RX_STREAMING). + * @returns {string} - The JSON string pattern for the RPC method. + */ +export const createPattern = (service: string, methodName: string, streaming: MethodType): string => + JSON.stringify({ service, rpc: methodName, streaming } as ConnectRpcPattern) + +/** + * Registers services and their handlers to the provided ConnectRouter instance. + * @param {ConnectRouter} router - The ConnectRouter instance to configure. + * @param {Record>>} serviceHandlersMap - + * A map of services and their respective handler implementations. + * @param {CustomMetadataStore} customMetadataStore - The metadata store containing service configurations. + */ +export const addServicesToRouter = ( + router: ConnectRouter, + serviceHandlersMap: Record>>, + customMetadataStore: CustomMetadataStore +): void => { + for (const serviceName of Object.keys(serviceHandlersMap)) { + const service = customMetadataStore.get(serviceName) + // eslint-disable-next-line no-continue + if (!service) continue + router.service(service, serviceHandlersMap[serviceName]) + } +} + +/** + * Generates a map of service handlers with support for synchronous and asynchronous streaming. + * @param {Map} handlers - The map of message patterns to their respective handlers. + * @param {CustomMetadataStore} customMetadataStore - The metadata store with service configurations. + * @returns {Record>>} - A map of service names to their handlers. + */ +export const createServiceHandlersMap = ( + handlers: Map, + customMetadataStore: CustomMetadataStore +): Record>> => { + const serviceHandlersMap: Record>> = {} + + handlers.forEach((handlerMetadata, pattern) => { + const parsedPattern = JSON.parse(pattern) as ConnectRpcPattern + const { service, rpc, streaming } = parsedPattern + const serviceMetadata = customMetadataStore.get(service) + + if (!serviceMetadata) return + const methodProto = serviceMetadata.methods[rpc] + if (!methodProto) return + serviceHandlersMap[service] ??= {} + + switch (streaming) { + case MethodType.NO_STREAMING: + serviceHandlersMap[service][rpc] = async ( + request: unknown, + context: unknown + ): Promise => { + const resultOrDeferred = await handlerMetadata(request, context) + return lastValueFrom(transformToObservable(resultOrDeferred)) + } + break + + case MethodType.RX_STREAMING: + serviceHandlersMap[service][rpc] = async function* handleStream( + request: unknown, + context: unknown + ): AsyncGenerator { + const streamOrValue = await handlerMetadata(request, context) + yield* toAsyncGenerator(streamOrValue as AsyncGenerator | Observable) + } + break + + default: + throw new Error(`Unsupported streaming type: ${streaming as string}`) + } + }) + + return serviceHandlersMap +} + +/** + * Creates metadata for a Connect RPC method, setting defaults when necessary. + * @param {object} target - The target object (usually a service class) for metadata association. + * @param {string | symbol} key - The name of the method or property. + * @param {string} [service] - Optional service name, defaults to the class name of the target. + * @param {string} [method] - Optional method name; defaults to the capitalized key name. + * @param {MethodType} [streaming=MethodType.NO_STREAMING] - The streaming type, defaulting to NO_STREAMING. + * @returns {{ service: string; rpc: string; streaming: MethodType }} - Metadata with service, RPC method, and streaming type. + */ +export const createConnectRpcMethodMetadata = ( + target: object, + key: string | symbol, + service?: string, + method?: string, + streaming: MethodType = MethodType.NO_STREAMING +): { service: string; rpc: string; streaming: MethodType } => { + const capitalizeFirstLetter = (input: string): string => + input.charAt(0).toUpperCase() + input.slice(1) + + const serviceName = service ?? target.constructor.name + const rpcMethodName = method ?? capitalizeFirstLetter(String(key)) + + return { service: serviceName, rpc: rpcMethodName, streaming } +} diff --git a/yarn.lock b/yarn.lock index 34d7fd74..fad4c2b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -335,6 +335,32 @@ __metadata: languageName: unknown linkType: soft +"@atls/nestjs-connectrpc@workspace:packages/nestjs-connectrpc": + version: 0.0.0-use.local + resolution: "@atls/nestjs-connectrpc@workspace:packages/nestjs-connectrpc" + dependencies: + "@bufbuild/protobuf": "npm:1.10.0" + "@connectrpc/connect": "npm:1.6.1" + "@connectrpc/connect-node": "npm:1.6.1" + "@nestjs/common": "npm:10.0.5" + "@nestjs/core": "npm:10.0.5" + "@nestjs/microservices": "npm:10.2.4" + "@nestjs/platform-express": "npm:10.2.4" + reflect-metadata: "npm:0.2.2" + rxjs: "npm:7.8.1" + peerDependencies: + "@bufbuild/protobuf": ^1 + "@connectrpc/connect": ^1 + "@connectrpc/connect-node": ^1 + "@nestjs/common": ^10 + "@nestjs/core": ^10 + "@nestjs/microservices": ^10 + "@nestjs/platform-express": ^10 + reflect-metadata: ^0.2 + rxjs: ^7 + languageName: unknown + linkType: soft + "@atls/nestjs-cqrs@workspace:packages/nestjs-cqrs": version: 0.0.0-use.local resolution: "@atls/nestjs-cqrs@workspace:packages/nestjs-cqrs" @@ -2124,6 +2150,34 @@ __metadata: languageName: node linkType: hard +"@bufbuild/protobuf@npm:1.10.0": + version: 1.10.0 + resolution: "@bufbuild/protobuf@npm:1.10.0" + checksum: 10c0/5487b9c2e63846d0e3bde4d025cc77ae44a22166a5d6c184df0da5581e1ab6d66dd115af0ccad814576dcd011bb1b93989fb0ac1eb4ae452979bb8b186693ba0 + languageName: node + linkType: hard + +"@connectrpc/connect-node@npm:1.6.1": + version: 1.6.1 + resolution: "@connectrpc/connect-node@npm:1.6.1" + dependencies: + undici: "npm:^5.28.4" + peerDependencies: + "@bufbuild/protobuf": ^1.10.0 + "@connectrpc/connect": 1.6.1 + checksum: 10c0/9891bbbe5ec155d16141e378c120dd6d4c47e1517656d4676aca762d70426a9eb3d9ec92595a7cfc4f5cbe40ff5be572d0c3d9010058107854e7f62ee05fb46e + languageName: node + linkType: hard + +"@connectrpc/connect@npm:1.6.1": + version: 1.6.1 + resolution: "@connectrpc/connect@npm:1.6.1" + peerDependencies: + "@bufbuild/protobuf": ^1.10.0 + checksum: 10c0/35c6fd3e33c3a1ff9dce230b059ecd7991ef0dc60c16fb898e5c46b930a01077ac0b34d53d6742cc8ed079f20f8eacc7c77a8620aeec9efaf68950494f387011 + languageName: node + linkType: hard + "@emotion/css-prettifier@npm:^1.1.4": version: 1.1.4 resolution: "@emotion/css-prettifier@npm:1.1.4" @@ -4753,6 +4807,49 @@ __metadata: languageName: node linkType: hard +"@nestjs/microservices@npm:10.2.4": + version: 10.2.4 + resolution: "@nestjs/microservices@npm:10.2.4" + dependencies: + iterare: "npm:1.2.1" + tslib: "npm:2.6.2" + peerDependencies: + "@grpc/grpc-js": "*" + "@nestjs/common": ^10.0.0 + "@nestjs/core": ^10.0.0 + "@nestjs/websockets": ^10.0.0 + amqp-connection-manager: "*" + amqplib: "*" + cache-manager: "*" + ioredis: "*" + kafkajs: "*" + mqtt: "*" + nats: "*" + reflect-metadata: ^0.1.12 + rxjs: ^7.1.0 + peerDependenciesMeta: + "@grpc/grpc-js": + optional: true + "@nestjs/websockets": + optional: true + amqp-connection-manager: + optional: true + amqplib: + optional: true + cache-manager: + optional: true + ioredis: + optional: true + kafkajs: + optional: true + mqtt: + optional: true + nats: + optional: true + checksum: 10c0/ef25bace3239d5afa6e19976794aadb5f8f0ee9a3eb1ebd2ddf670b385bacd81d707465086e18d134d1dad9042baef157482efa5645d6cff202ec998a75793c3 + languageName: node + linkType: hard + "@nestjs/microservices@npm:10.4.1": version: 10.4.1 resolution: "@nestjs/microservices@npm:10.4.1" @@ -4855,6 +4952,22 @@ __metadata: languageName: node linkType: hard +"@nestjs/platform-express@npm:10.2.4": + version: 10.2.4 + resolution: "@nestjs/platform-express@npm:10.2.4" + dependencies: + body-parser: "npm:1.20.2" + cors: "npm:2.8.5" + express: "npm:4.18.2" + multer: "npm:1.4.4-lts.1" + tslib: "npm:2.6.2" + peerDependencies: + "@nestjs/common": ^10.0.0 + "@nestjs/core": ^10.0.0 + checksum: 10c0/b94a6e0899d80e506aa93d4ea9b80501a5888269d418308c9212efa65901257abec696b0c80cef941e77fff80a005843f28c6ec75d7a7bd3951811e91d7e91c8 + languageName: node + linkType: hard + "@nestjs/testing@npm:10.4.1": version: 10.4.1 resolution: "@nestjs/testing@npm:10.4.1" @@ -8194,6 +8307,26 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:1.20.1": + version: 1.20.1 + resolution: "body-parser@npm:1.20.1" + dependencies: + bytes: "npm:3.1.2" + content-type: "npm:~1.0.4" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:1.2.0" + http-errors: "npm:2.0.0" + iconv-lite: "npm:0.4.24" + on-finished: "npm:2.4.1" + qs: "npm:6.11.0" + raw-body: "npm:2.5.1" + type-is: "npm:~1.6.18" + unpipe: "npm:1.0.0" + checksum: 10c0/a202d493e2c10a33fb7413dac7d2f713be579c4b88343cd814b6df7a38e5af1901fc31044e04de176db56b16d9772aa25a7723f64478c20f4d91b1ac223bf3b8 + languageName: node + linkType: hard + "body-parser@npm:1.20.2": version: 1.20.2 resolution: "body-parser@npm:1.20.2" @@ -10230,6 +10363,45 @@ __metadata: languageName: node linkType: hard +"express@npm:4.18.2": + version: 4.18.2 + resolution: "express@npm:4.18.2" + dependencies: + accepts: "npm:~1.3.8" + array-flatten: "npm:1.1.1" + body-parser: "npm:1.20.1" + content-disposition: "npm:0.5.4" + content-type: "npm:~1.0.4" + cookie: "npm:0.5.0" + cookie-signature: "npm:1.0.6" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + encodeurl: "npm:~1.0.2" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + finalhandler: "npm:1.2.0" + fresh: "npm:0.5.2" + http-errors: "npm:2.0.0" + merge-descriptors: "npm:1.0.1" + methods: "npm:~1.1.2" + on-finished: "npm:2.4.1" + parseurl: "npm:~1.3.3" + path-to-regexp: "npm:0.1.7" + proxy-addr: "npm:~2.0.7" + qs: "npm:6.11.0" + range-parser: "npm:~1.2.1" + safe-buffer: "npm:5.2.1" + send: "npm:0.18.0" + serve-static: "npm:1.15.0" + setprototypeof: "npm:1.2.0" + statuses: "npm:2.0.1" + type-is: "npm:~1.6.18" + utils-merge: "npm:1.0.1" + vary: "npm:~1.1.2" + checksum: 10c0/75af556306b9241bc1d7bdd40c9744b516c38ce50ae3210658efcbf96e3aed4ab83b3432f06215eae5610c123bc4136957dc06e50dfc50b7d4d775af56c4c59c + languageName: node + linkType: hard + "express@npm:4.19.2": version: 4.19.2 resolution: "express@npm:4.19.2" @@ -14403,6 +14575,18 @@ __metadata: languageName: node linkType: hard +"raw-body@npm:2.5.1": + version: 2.5.1 + resolution: "raw-body@npm:2.5.1" + dependencies: + bytes: "npm:3.1.2" + http-errors: "npm:2.0.0" + iconv-lite: "npm:0.4.24" + unpipe: "npm:1.0.0" + checksum: 10c0/5dad5a3a64a023b894ad7ab4e5c7c1ce34d3497fc7138d02f8c88a3781e68d8a55aa7d4fd3a458616fa8647cc228be314a1c03fb430a07521de78b32c4dd09d2 + languageName: node + linkType: hard + "raw-body@npm:2.5.2": version: 2.5.2 resolution: "raw-body@npm:2.5.2"