├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── jest.config.ts ├── package.json ├── packages ├── bus-class-serializer │ ├── README.md │ ├── package.json │ ├── src │ │ ├── class-serializer.spec.ts │ │ ├── class-serializer.ts │ │ └── index.ts │ └── tsconfig.json ├── bus-core │ ├── README.md │ ├── package.json │ ├── src │ │ ├── container │ │ │ ├── container-adapter.spec.ts │ │ │ ├── container-adapter.ts │ │ │ └── index.ts │ │ ├── error │ │ │ ├── class-handler-not-resolved.ts │ │ │ ├── container-not-registered.ts │ │ │ ├── fail-message-outside-handling-context.ts │ │ │ ├── index.ts │ │ │ └── return-message-outside-handling-context.ts │ │ ├── handler │ │ │ ├── README.md │ │ │ ├── custom-handler.ts │ │ │ ├── default-handler-registry.spec.ts │ │ │ ├── default-handler-registry.ts │ │ │ ├── error │ │ │ │ ├── handler-already-registered.ts │ │ │ │ ├── handler-dispatch-rejected.ts │ │ │ │ ├── index.ts │ │ │ │ └── system-message-missing-resolver.ts │ │ │ ├── handler-for.ts │ │ │ ├── handler-registry.ts │ │ │ ├── handler.integration.ts │ │ │ ├── handler.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── logger │ │ │ ├── README.md │ │ │ ├── debug-logger.spec.ts │ │ │ ├── debug-logger.ts │ │ │ ├── index.ts │ │ │ ├── logger-factory.ts │ │ │ └── logger.ts │ │ ├── message-handling-context │ │ │ ├── index.ts │ │ │ ├── message-handling-context.spec.ts │ │ │ └── message-handling-context.ts │ │ ├── message-lifecycle-context │ │ │ ├── index.ts │ │ │ └── message-lifecycle-context.ts │ │ ├── receiver │ │ │ ├── index.ts │ │ │ └── receiver.ts │ │ ├── retry-strategy │ │ │ ├── default-retry-strategy.spec.ts │ │ │ ├── default-retry-strategy.ts │ │ │ ├── index.ts │ │ │ └── retry-strategy.ts │ │ ├── serialization │ │ │ ├── index.ts │ │ │ ├── json-serializer.spec.ts │ │ │ ├── json-serializer.ts │ │ │ ├── message-serializer.spec.ts │ │ │ ├── message-serializer.ts │ │ │ └── serializer.ts │ │ ├── service-bus │ │ │ ├── bus-configuration.ts │ │ │ ├── bus-instance-concurrency.integration.ts │ │ │ ├── bus-instance-outboxing.integration.ts │ │ │ ├── bus-instance-receiver.integration.ts │ │ │ ├── bus-instance-return-message.integration.ts │ │ │ ├── bus-instance.integration.ts │ │ │ ├── bus-instance.ts │ │ │ ├── bus-state.ts │ │ │ ├── bus.spec.ts │ │ │ ├── bus.ts │ │ │ ├── error │ │ │ │ ├── bus-already-initialized.ts │ │ │ │ ├── index.ts │ │ │ │ ├── invalid-bus-state.ts │ │ │ │ └── invalid-operation.ts │ │ │ └── index.ts │ │ ├── test │ │ │ ├── index.ts │ │ │ ├── test-command-2.ts │ │ │ ├── test-command-3.ts │ │ │ ├── test-command-handler.ts │ │ │ ├── test-command.ts │ │ │ ├── test-event-2.ts │ │ │ ├── test-event-class-handler.ts │ │ │ ├── test-event-handler.ts │ │ │ ├── test-event.ts │ │ │ ├── test-fail-message.ts │ │ │ ├── test-sticky-attributes.ts │ │ │ └── test-system-message.ts │ │ ├── transport │ │ │ ├── README.md │ │ │ ├── default-in-memory-queue-configuration.ts │ │ │ ├── in-memory-queue-configuration.ts │ │ │ ├── in-memory-queue.spec.ts │ │ │ ├── in-memory-queue.ts │ │ │ ├── index.ts │ │ │ ├── transport-configuration.ts │ │ │ ├── transport-message.ts │ │ │ └── transport.ts │ │ ├── util │ │ │ ├── assert-unreachable.ts │ │ │ ├── class-constructor.ts │ │ │ ├── core-dependencies.ts │ │ │ ├── index.ts │ │ │ ├── middleware.ts │ │ │ ├── sleep.spec.ts │ │ │ ├── sleep.ts │ │ │ ├── typed-emitter.spec.ts │ │ │ └── typed-emitter.ts │ │ └── workflow │ │ │ ├── README.md │ │ │ ├── assets │ │ │ └── workflow.gif │ │ │ ├── error │ │ │ ├── index.ts │ │ │ ├── workflow-already-handles-message.ts │ │ │ ├── workflow-already-initialized.ts │ │ │ └── workflow-already-started-by-message.ts │ │ │ ├── index.ts │ │ │ ├── message-workflow-mapping.ts │ │ │ ├── persistence │ │ │ ├── README.md │ │ │ ├── error │ │ │ │ ├── index.ts │ │ │ │ ├── persistence-not-configure.ts │ │ │ │ └── workflow-state-not-initialized.ts │ │ │ ├── in-memory-persistence.spec.ts │ │ │ ├── in-memory-persistence.ts │ │ │ ├── index.ts │ │ │ └── persistence.ts │ │ │ ├── registry │ │ │ ├── index.ts │ │ │ ├── workflow-handler-fn.ts │ │ │ ├── workflow-registry.spec.ts │ │ │ └── workflow-registry.ts │ │ │ ├── started.spec.ts │ │ │ ├── test │ │ │ ├── final-task.ts │ │ │ ├── index.ts │ │ │ ├── run-task-handler.ts │ │ │ ├── run-task.ts │ │ │ ├── task-ran.ts │ │ │ ├── test-command.ts │ │ │ ├── test-discarded-workflow.ts │ │ │ ├── test-void-startedby-workflow.ts │ │ │ ├── test-workflow-startedby-completes.ts │ │ │ ├── test-workflow-startedby-discard.ts │ │ │ ├── test-workflow-state.ts │ │ │ └── test-workflow.ts │ │ │ ├── workflow-concurrency.integration.ts │ │ │ ├── workflow-startedby.integration.ts │ │ │ ├── workflow-state.ts │ │ │ ├── workflow.integration.ts │ │ │ └── workflow.ts │ └── tsconfig.json ├── bus-messages │ ├── README.md │ ├── package.json │ ├── src │ │ ├── command.ts │ │ ├── event.ts │ │ ├── index.ts │ │ ├── message-attributes.ts │ │ └── message.ts │ └── tsconfig.json ├── bus-mongodb │ ├── README.md │ ├── package.json │ ├── src │ │ ├── error │ │ │ ├── index.ts │ │ │ └── workflow-state-not-found.ts │ │ ├── index.ts │ │ ├── mongodb-configuration.ts │ │ ├── mongodb-persistence.integration.ts │ │ └── mongodb-persistence.ts │ ├── test │ │ ├── index.ts │ │ ├── run-task.ts │ │ ├── task-ran.ts │ │ ├── test-command.ts │ │ ├── test-workflow-state.ts │ │ └── test-workflow.ts │ └── tsconfig.json ├── bus-postgres │ ├── README.md │ ├── package.json │ ├── src │ │ ├── error │ │ │ ├── index.ts │ │ │ └── workflow-state-not-found.ts │ │ ├── index.ts │ │ ├── postgres-configuration.ts │ │ ├── postgres-persistence.integration.ts │ │ └── postgres-persistence.ts │ ├── test │ │ ├── index.ts │ │ ├── run-task.ts │ │ ├── task-ran.ts │ │ ├── test-command.ts │ │ ├── test-workflow-state.ts │ │ └── test-workflow.ts │ └── tsconfig.json ├── bus-rabbitmq │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── rabbitmq-transport-configuration.ts │ │ ├── rabbitmq-transport.integration.ts │ │ └── rabbitmq-transport.ts │ └── tsconfig.json ├── bus-sqs-lambda │ ├── README.md │ ├── package.json │ ├── src │ │ ├── bus-sqs-lambda-receiver.spec.ts │ │ ├── bus-sqs-lambda-receiver.ts │ │ └── index.ts │ └── tsconfig.json ├── bus-sqs │ ├── README.md │ ├── package.json │ ├── src │ │ ├── generate-policy.ts │ │ ├── index.ts │ │ ├── queue-resolvers.ts │ │ ├── sqs-transport-configuration.ts │ │ ├── sqs-transport.integration.ts │ │ ├── sqs-transport.spec.ts │ │ └── sqs-transport.ts │ └── tsconfig.json └── bus-test │ ├── README.md │ ├── package.json │ ├── src │ ├── helpers │ │ ├── handle-checker.ts │ │ ├── index.ts │ │ ├── test-command-handler.ts │ │ ├── test-command.ts │ │ ├── test-event.ts │ │ ├── test-fail-message.ts │ │ ├── test-poisoned-message.ts │ │ ├── test-system-message-handler.ts │ │ └── test-system-message.ts │ ├── index.ts │ └── transport.integration.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── test.env ├── test └── setup.ts ├── tsconfig.json ├── tsconfig.test.json └── workflow.png /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | defaults: &defaults 4 | working_directory: ~/repo 5 | build: # this can be any name you choose 6 | docker: 7 | - image: node:20 8 | 9 | 10 | jobs: 11 | build: 12 | <<: *defaults 13 | docker: 14 | - image: node:20 15 | resource_class: large 16 | parallelism: 10 17 | steps: 18 | - checkout 19 | - restore_cache: 20 | name: Restore pnpm Package Cache 21 | keys: 22 | - pnpm-packages-{{ checksum "pnpm-lock.yaml" }} 23 | - run: 24 | name: Install pnpm package manager 25 | command: | 26 | curl -L https://pnpm.js.org/pnpm.js | node - add --global pnpm@9.6.0 27 | - run: 28 | name: Install dependencies 29 | command: pnpm i 30 | - run: 31 | name: Build 32 | command: pnpm run build 33 | - run: 34 | name: Lint 35 | command: pnpm run format:check 36 | - run: 37 | name: Unit Test 38 | command: pnpm run test:unit --maxConcurrency=1 # CircleCI runs out of mem 39 | - run: 40 | name: Integration Test 41 | command: pnpm run test:integration 42 | # - setup_remote_docker 43 | # - run: 44 | # name: Integration Test 45 | # command: | 46 | # docker run --name rabbit -d -p 8080:15672 -p 5672:5672 rabbitmq:3-management 47 | # docker create -v /repo --name repo alpine:3.4 /bin/true 48 | # docker cp . repo:/repo 49 | # docker run -it --volumes-from repo --workdir /repo/bus node:8.15.0 pnpm test:integration 50 | # Save workspace for subsequent jobs (i.e. test) 51 | - persist_to_workspace: 52 | root: . 53 | paths: 54 | - . 55 | 56 | deploy: 57 | <<: *defaults 58 | docker: 59 | - image: node:20 60 | steps: 61 | # Reuse the workspace from the build job 62 | - attach_workspace: 63 | at: . 64 | - restore_cache: 65 | name: Restore pnpm Package Cache 66 | keys: 67 | - pnpm-packages-{{ checksum "pnpm-lock.yaml" }} 68 | - run: 69 | name: Install pnpm package manager 70 | command: | 71 | curl -L https://pnpm.js.org/pnpm.js | node - add --global pnpm@9.6.0 72 | - run: 73 | name: Authenticate with registry 74 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc 75 | - run: 76 | name: Publish 77 | # .npmrc is modified with the publish token 78 | command: pnpm publish --recursive --no-git-checks 79 | 80 | workflows: 81 | version: 2 82 | build-deploy: 83 | jobs: 84 | - build 85 | - deploy: 86 | requires: 87 | - build 88 | filters: 89 | branches: 90 | only: 91 | - master 92 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false 16 | 17 | [{Makefile,*.mk}] 18 | intent_style = tab 19 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -e 3 | 4 | . "$(dirname "$0")/_/husky.sh" 5 | 6 | pnpm run format:precommit 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .* 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.15.1 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # This file is just for editor integration, the CLI tool uses .gitignore. Unfortunately 2 | # Prettier doesn't support multiple ignore files so any extra ignore patterns/paths 3 | # need to be added via the CLI glob. 4 | 5 | # Prettier was converting gitlab-ci.yml double-quotes to single-quotes, breaking 6 | # variable interpolation. 7 | *.yml 8 | *.yaml 9 | # Prettier was inserting blank lines, which was breaking MDX compilation. 10 | *.mdx 11 | 12 | # Exists only during builds 13 | .pnpm-store 14 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'none', 3 | tabWidth: 2, 4 | semi: false, 5 | singleQuote: true, 6 | jsxSingleQuote: true, 7 | arrowParens: 'avoid', 8 | printWidth: 80 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | /* 5 | Runs the test in the active window in watch mode, allowing for debug breakpoints to be hit. 6 | Breakpoints can be added anywhere up and down the stack, including in node_modules. 7 | */ 8 | "name": "Debug test", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeExecutable": "pnpm", 12 | "args": ["run", "test:watch", "${file}", "--", "--runInBand"], 13 | "cwd": "${workspaceRoot}", 14 | "protocol": "inspector", 15 | "console": "integratedTerminal", 16 | "internalConsoleOptions": "neverOpen" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.activeBackground": "#93e6fc", 4 | "activityBar.activeBorder": "#fa45d4", 5 | "activityBar.background": "#93e6fc", 6 | "activityBar.foreground": "#15202b", 7 | "activityBar.inactiveForeground": "#15202b99", 8 | "activityBarBadge.background": "#fa45d4", 9 | "activityBarBadge.foreground": "#15202b", 10 | "statusBar.background": "#61dafb", 11 | "statusBar.foreground": "#15202b", 12 | "statusBarItem.hoverBackground": "#2fcefa", 13 | "titleBar.activeBackground": "#61dafb", 14 | "titleBar.activeForeground": "#15202b", 15 | "titleBar.inactiveBackground": "#61dafb99", 16 | "titleBar.inactiveForeground": "#15202b99", 17 | "sash.hoverBorder": "#93e6fc", 18 | "statusBarItem.remoteBackground": "#61dafb", 19 | "statusBarItem.remoteForeground": "#15202b", 20 | "commandCenter.border": "#15202b99" 21 | }, 22 | "peacock.color": "#61dafb" 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 node-ts/bus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @node-ts/bus 2 | 3 | @node-ts/bus is a node-based library that aims to simplify the development of resilient message-based applications. By handling the technical aspects of the underlying bus transport, it enables developers to focus on creating loosely coupled systems with less boilerplate. 4 | 5 | @node-ts/bus allows developers to specify messages and message handlers. It then manages the message transport, subscriptions, and retries behind the scenes. In case of failure, messages are returned to the queue for retry, promoting application resilience. 6 | 7 | Additionally, the library provides message workflows, or sagas, to help developers coordinate multiple messages and handlers in longer running processes. As a result, applications built with @node-ts/bus can be more robust, self-healing, and resistant to data loss or corruption. 8 | 9 | ## Further info 10 | 11 | 🔥 View our docs at [https://bus.node-ts.com](https://bus.node-ts.com) 🔥 12 | 13 | 🤔 Have a question? [Join the Discussion](https://github.com/node-ts/bus/discussions) 🤔 14 | 15 | ## Components 16 | 17 | - [@node-ts/bus-core](https://github.com/node-ts/bus/tree/master/packages/bus-core) - Core bus library for sending and receiving messages and managing workflows 18 | - [@node-ts/bus-messages](https://github.com/node-ts/bus/tree/master/packages/bus-messages) - A set of message type definitions used to define your own messages, events and commands 19 | - [@node-ts/bus-class-serializer](https://github.com/node-ts/bus/tree/master/packages/bus-class-serializer) - A json serializer that converts to class instances 20 | - [@node-ts/bus-postgres](https://github.com/node-ts/bus/tree/master/packages/bus-postgres) - A Postgres persistence adapter for @node-ts/bus 21 | - [@node-ts/bus-mongodb](https://github.com/node-ts/bus/tree/master/packages/bus-mongodb) - A MongoDB persistence adapter for @node-ts/bus 22 | - [@node-ts/bus-rabbitmq](https://github.com/node-ts/bus/tree/master/packages/bus-rabbitmq) - A Rabbit MQ transport adapter for @node-ts/bus 23 | - [@node-ts/bus-sqs](https://github.com/node-ts/bus/tree/master/packages/bus-sqs) - An Amazon SQS transport adapter for @node-ts/bus 24 | 25 | ## Development 26 | 27 | This guide is for developers and contributors to the library itself. For consumers, please see our consumer docs at [https://bus.node-ts.com](https://bus.node-ts.com). 28 | 29 | ### Installation 30 | 31 | This package uses `pnpm` for monorepo support and workspaces. 32 | 33 | Install dependencies 34 | 35 | ```sh 36 | pnpm i 37 | ``` 38 | 39 | ### Scripts 40 | 41 | - `bootstrap` - install dependencies in all packages and hoist to root 42 | - `build` - build all packages 43 | - `build:watch` - build all packages and watch for changes with incremental builds 44 | - `clean` - remove all _dist_ and _node_modules_ folders 45 | - `lint` - lint inspect 46 | - `test` - run unit and integration tests 47 | - `test:watch` - run tests in watch mode, rerun on changes 48 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest' 2 | 3 | const config: Config = { 4 | preset: 'ts-jest', 5 | testTimeout: 5000, 6 | setupFilesAfterEnv: ['/test/setup.ts'], 7 | collectCoverageFrom: [ 8 | '**/*.ts', 9 | '!**/node_modules/**', 10 | '!**/vendor/**', 11 | '!**/dist/**', 12 | '!**/bus-messages/**', 13 | '!**/error/*' 14 | ], 15 | testRegex: '(src\\/.+\\.|/)(integration|spec)\\.ts$', 16 | testEnvironment: 'node', 17 | testPathIgnorePatterns: ['node_modules/', 'dist/', 'bus-test/'], 18 | transform: { 19 | '^.+\\.tsx?$': [ 20 | 'ts-jest', 21 | { 22 | tsconfig: 'tsconfig.test.json' 23 | } 24 | ] 25 | } 26 | } 27 | 28 | export default config 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@node-ts/bus", 3 | "version": "0.0.3", 4 | "main": "index.js", 5 | "description": "A service bus for message-based, distributed node applications", 6 | "repository": "github:node-ts/bus.git", 7 | "license": "MIT", 8 | "private": true, 9 | "scripts": { 10 | "build": "pnpm run --recursive build", 11 | "build:watch": "pnpm run --recursive build:watch", 12 | "clean": "pnpm run --recursive clean && rm -r node_modules", 13 | "format": "prettier --ignore-path .gitignore --write --config .prettierrc.js . \"!**/*.(yml|yaml|mdx)\"", 14 | "format:check": "prettier --ignore-path .gitignore --check --config .prettierrc.js . \"!**/*.(yml|yaml|mdx)\"", 15 | "format:precommit": "lint-staged", 16 | "preinstall": "npx only-allow pnpm", 17 | "prepare": "husky install", 18 | "test": "dotenv -e test.env -- jest --collect-coverage", 19 | "test:unit": "dotenv -e test.env -- jest \"(src\\/.+\\.|/)spec\\.ts$\"", 20 | "test:integration": "dotenv -e test.env -- jest --runInBand \"bus-(core).*(src\\/.+\\.|/)integration\\.ts$\"", 21 | "test:watch": "dotenv -e test.env -- jest --watch" 22 | }, 23 | "keywords": [ 24 | "typescript", 25 | "enterprise service bus", 26 | "distributed system", 27 | "message bus", 28 | "message queue", 29 | "node" 30 | ], 31 | "engines": { 32 | "pnpm": "^9.6.0", 33 | "node": ">=20.12.2" 34 | }, 35 | "workspaces": [ 36 | "packages/*" 37 | ], 38 | "devDependencies": { 39 | "@tsconfig/node18": "^18.2.2", 40 | "@types/jest": "^29.5.11", 41 | "dotenv-cli": "^4.0.0", 42 | "esbuild": "^0.20.0", 43 | "husky": "^8.0.3", 44 | "jest": "^29.7.0", 45 | "lint-staged": "^13.2.2", 46 | "prettier": "^2.8.8", 47 | "prettier-plugin-organize-imports": "^3.2.2", 48 | "reflect-metadata": "^0.1.13", 49 | "supports-color": "^9.0.1", 50 | "ts-jest": "^29.1.2", 51 | "ts-node": "^10.9.2", 52 | "tslib": "^2.6.3", 53 | "typescript": "^5.5.4" 54 | }, 55 | "lint-staged": { 56 | "*.(ts|tsx|js|md|css|html|json)": "prettier --ignore-path .gitignore --write --config .prettierrc.js" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/bus-class-serializer/README.md: -------------------------------------------------------------------------------- 1 | # `@node-ts/bus-class-serializer` 2 | 3 | A JSON-based serializer for [@node-ts/bus](https://bus.node-ts.com) that deserializes into strong types. This is a preferred alternative to the default serializer that does not support this. 4 | 5 | ## Installation 6 | 7 | Install this package and its dependencies 8 | 9 | ```sh 10 | npm i reflect-metadata class-transformer @node-ts/bus-class-serializer 11 | ``` 12 | 13 | Configure your bus to use the serializer: 14 | 15 | ```typescript 16 | import { ClassSerializer } from '@node-ts/bus-class-serializer' 17 | 18 | await Bus.configure().withSerializer(new ClassSerializer()).initialize() 19 | ``` 20 | 21 | **Note** This package relies on [class transformer](https://www.npmjs.com/package/class-transformer) that requires [reflect-metadata](https://www.npmjs.com/package/reflect-metadata) to be installed and called at the start of your application before any other imports. Please follow their guides on how to configure your app and contracts to serialize correctly. 22 | 23 | ## Why? 24 | 25 | Consider the following message: 26 | 27 | ```typescript 28 | class Update extends Command { 29 | constructor(readonly date: Date) {} 30 | } 31 | ``` 32 | 33 | When serialized/deserialized this will become a plain object with no date functions on the property: 34 | 35 | ```typescript 36 | const receivedMessage = JSON.parse(JSON.stringify(new Update(new Date()))) 37 | receivedMessage.getDate() // Error - getDate is undefined 38 | ``` 39 | 40 | Although we could manually fix and assign the date in the handle, this is tedious and adds unecessary code. Instead messages should be holistically deserialized to strong type at the boundaries of the system. 41 | 42 | Using this serializer, a minor change to the contract is made: 43 | 44 | ```typescript 45 | class Update extends Command { 46 | @Type(() => Date) readonly date: Date 47 | constructor(date: Date) { 48 | this.date = date 49 | } 50 | } 51 | ``` 52 | 53 | When this is deserialized, the date should be an actual `Date`: 54 | 55 | ```typescript 56 | const serializer = new ClassSerializer() 57 | const receivedMessage = serializer.deserialize( 58 | serializer.serialize(new Update(new Date())), 59 | Update 60 | ) 61 | receivedMessage.getDate() // Success - a date value 62 | ``` 63 | -------------------------------------------------------------------------------- /packages/bus-class-serializer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@node-ts/bus-class-serializer", 3 | "version": "1.1.0", 4 | "description": "A JSON serializer for @node-ts/bus based on class-transformer for strong marshalling of messages", 5 | "homepage": "https://github.com/node-ts/bus#readme", 6 | "license": "MIT", 7 | "main": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "repository": "github:node-ts/bus.git", 10 | "scripts": { 11 | "clean": "rm -rf dist", 12 | "build": "tsc", 13 | "build:watch": "pnpm run build --incremental --watch --preserveWatchOutput" 14 | }, 15 | "publishConfig": { 16 | "access": "public" 17 | }, 18 | "devDependencies": { 19 | "@node-ts/bus-core": "workspace:^", 20 | "class-transformer": "^0.5.1", 21 | "reflect-metadata": "^0.2.1" 22 | }, 23 | "peerDependencies": { 24 | "@node-ts/bus-core": "workspace:^", 25 | "class-transformer": "^0.5.1" 26 | }, 27 | "keywords": [ 28 | "serializer", 29 | "esb", 30 | "redis", 31 | "typescript", 32 | "enterprise integration patterns", 33 | "bus", 34 | "messaging", 35 | "microservices", 36 | "distributed systems", 37 | "framework", 38 | "enterprise framework", 39 | "CQRS", 40 | "ES", 41 | "NServiceBus", 42 | "Mule ESB" 43 | ], 44 | "gitHead": "265ea7e16c614971d4b01c3642682d6c93feb52f" 45 | } 46 | -------------------------------------------------------------------------------- /packages/bus-class-serializer/src/class-serializer.spec.ts: -------------------------------------------------------------------------------- 1 | import { ClassSerializer } from './class-serializer' 2 | import { Type } from 'class-transformer' 3 | 4 | class Contract { 5 | @Type(() => Date) readonly c: Date 6 | 7 | testFn: () => void 8 | 9 | constructor(readonly a: string, readonly b: number, c: Date) { 10 | this.c = c 11 | } 12 | } 13 | 14 | describe('ClassSerializer', () => { 15 | let sut: ClassSerializer 16 | const date = new Date(2000, 2, 1, 10, 0, 0, 0) 17 | const contract = new Contract('a', 1, date) 18 | 19 | beforeEach(() => { 20 | sut = new ClassSerializer() 21 | }) 22 | 23 | describe('when serializing', () => { 24 | let result: string 25 | beforeEach(() => { 26 | result = sut.serialize({ 27 | a: 'a', 28 | b: 1, 29 | c: date 30 | }) 31 | }) 32 | 33 | it('should convert an object to a string', () => { 34 | expect(result).toEqual(`{"a":"a","b":1,"c":"${date.toISOString()}"}`) 35 | }) 36 | }) 37 | 38 | describe('when deserializing', () => { 39 | let result: Contract 40 | const date = new Date(200) 41 | beforeEach(() => { 42 | result = sut.deserialize( 43 | `{"a":"a","b":1,"c":"${date.toISOString()}"}`, 44 | Contract 45 | ) 46 | }) 47 | 48 | it('should deserialize to a plain object', () => { 49 | expect(result).toMatchObject({ a: 'a', b: 1 }) 50 | expect(result.c).toBeDefined() 51 | expect(result.c.toUTCString).toBeDefined() 52 | expect(result.c.getDate()).toEqual(date.getDate()) 53 | }) 54 | }) 55 | 56 | describe('when converting typed object to plain', () => { 57 | let result: object 58 | beforeEach(() => { 59 | result = sut.toPlain(contract) 60 | }) 61 | 62 | it('should strip out additional fields', () => { 63 | expect(Object.keys(result)).not.toContain('testFn') 64 | }) 65 | }) 66 | 67 | describe('when converting plain object to typed', () => { 68 | let result: Contract 69 | beforeEach(() => { 70 | const plain = sut.toPlain(contract) 71 | result = sut.toClass(plain, Contract) 72 | }) 73 | 74 | it('should strip out additional fields', () => { 75 | expect(result).toBeInstanceOf(Contract) 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /packages/bus-class-serializer/src/class-serializer.ts: -------------------------------------------------------------------------------- 1 | import { ClassConstructor, Serializer } from '@node-ts/bus-core' 2 | import { instanceToPlain, plainToInstance } from 'class-transformer' 3 | 4 | /** 5 | * A JSON-based serializer that uses `class-transformer` to transform to and from 6 | * class instances of an object rather than just their plain types. As a result, 7 | * object types can use all of the serialization decorator hints provided by 8 | * that library. 9 | */ 10 | export class ClassSerializer implements Serializer { 11 | serialize(obj: ObjectType): string { 12 | return JSON.stringify(instanceToPlain(obj)) 13 | } 14 | 15 | deserialize( 16 | serialized: string, 17 | classConstructor: ClassConstructor 18 | ): ObjectType { 19 | return plainToInstance(classConstructor, JSON.parse(serialized) as object) 20 | } 21 | 22 | toPlain(obj: T): object { 23 | return instanceToPlain(obj) 24 | } 25 | 26 | toClass( 27 | obj: object, 28 | classConstructor: ClassConstructor 29 | ): T { 30 | return plainToInstance(classConstructor, obj) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/bus-class-serializer/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './class-serializer' 2 | -------------------------------------------------------------------------------- /packages/bus-class-serializer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.spec.ts", "src/**/*.integration.ts", "src/**/test"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "baseUrl": "./", 8 | 9 | "target": "ES6", 10 | 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/bus-core/README.md: -------------------------------------------------------------------------------- 1 | # @node-ts/bus-core 2 | 3 | The core messaging framework. This package provides an in-memory queue and persistence by default, but is designed to be used with other @node-ts/bus-\* packages that provide compatibility with other transports (SQS, RabbitMQ, Azure Queues) and persistence technologies (PostgreSQL, SQL Server, Oracle). 4 | 5 | 🔥 View our docs at [https://bus.node-ts.com](https://bus.node-ts.com) 🔥 6 | 7 | 🤔 Have a question? [Join the Discussion](https://github.com/node-ts/bus/discussions) 🤔 8 | 9 | ## Installation 10 | 11 | Download and install the packages: 12 | 13 | ```bash 14 | npm i @node-ts/bus-core @node-ts/bus-messages --save 15 | ``` 16 | 17 | Configure and initialize the bus when your application starts up. 18 | 19 | ```typescript 20 | import { Bus } from '@node-ts/bus-core' 21 | async function run() { 22 | const bus = await Bus.configure().initialize() 23 | 24 | // Start listening for messages and dispatch them to handlers when read 25 | await bus.start() 26 | } 27 | ``` 28 | 29 | For more information, visit our docs at [https://bus.node-ts.com](https://bus.node-ts.com) 30 | -------------------------------------------------------------------------------- /packages/bus-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@node-ts/bus-core", 3 | "version": "1.3.1", 4 | "description": "A service bus for message-based, distributed node applications", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "repository": "github:node-ts/bus.git", 8 | "license": "MIT", 9 | "scripts": { 10 | "build": "tsc", 11 | "build:watch": "pnpm run build --incremental --watch --preserveWatchOutput" 12 | }, 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "dependencies": { 17 | "@node-ts/bus-messages": "workspace:^", 18 | "alscontext": "^0.0.3", 19 | "debug": "^4.3.4", 20 | "reflect-metadata": "^0.1.13", 21 | "serialize-error": "8", 22 | "supports-color": "^9.0.1", 23 | "throat": "^6.0.2", 24 | "tslib": "^2.6.2", 25 | "uuid": "^8.3.2" 26 | }, 27 | "devDependencies": { 28 | "@node-ts/code-standards": "^0.0.10", 29 | "@types/debug": "^4.1.5", 30 | "@types/faker": "^4.1.5", 31 | "@types/node": "^18.19.10", 32 | "@types/serialize-error": "^4.0.1", 33 | "@types/uuid": "^8.3.1", 34 | "faker": "^4.1.0", 35 | "typemoq": "^2.1.0", 36 | "typescript": "^5.3.3" 37 | }, 38 | "keywords": [ 39 | "esb", 40 | "bus", 41 | "messaging", 42 | "microservices", 43 | "distributed systems", 44 | "CQRS", 45 | "ES", 46 | "NServiceBus", 47 | "Mule ESB", 48 | "typescript" 49 | ], 50 | "gitHead": "265ea7e16c614971d4b01c3642682d6c93feb52f" 51 | } 52 | -------------------------------------------------------------------------------- /packages/bus-core/src/container/container-adapter.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 2 | import { ClassConstructor } from '../util' 3 | 4 | /** 5 | * An adapter so that resolvers can use a local DI/IoC container 6 | * to resolve class based handlers and workflows 7 | */ 8 | export interface ContainerAdapter { 9 | /** 10 | * Fetch a class instance from the container 11 | * @param type Type of the class to fetch an instance for 12 | * @param context Optional context to pass to the container. This is used in order to allow different resolving containers to be used for different messages. 13 | * @example get(MessageHandler) 14 | */ 15 | get( 16 | type: ClassConstructor, 17 | context?: { message?: Message; messageAttributes?: MessageAttributes } 18 | ): T | Promise 19 | } 20 | -------------------------------------------------------------------------------- /packages/bus-core/src/container/index.ts: -------------------------------------------------------------------------------- 1 | export * from './container-adapter' 2 | -------------------------------------------------------------------------------- /packages/bus-core/src/error/class-handler-not-resolved.ts: -------------------------------------------------------------------------------- 1 | export class ClassHandlerNotResolved extends Error { 2 | constructor(readonly reason?: string) { 3 | super( 4 | `Unable to retrieve a class instance from the DI container.` + 5 | ` Ensure that the class has been registered with your container and that` + 6 | ` the containerAdapter from .withContainer(...) is correct.` 7 | ) 8 | Object.setPrototypeOf(this, new.target.prototype) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/bus-core/src/error/container-not-registered.ts: -------------------------------------------------------------------------------- 1 | export class ContainerNotRegistered extends Error { 2 | constructor(readonly classHandlerName: string) { 3 | super( 4 | `A class-based handler is registered for this message, however no IoC container has been provided.` + 5 | ` Ensure that Bus.configure().withContainer(...) has been called with a valid adapter to your IoC container.` 6 | ) 7 | Object.setPrototypeOf(this, new.target.prototype) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-core/src/error/fail-message-outside-handling-context.ts: -------------------------------------------------------------------------------- 1 | export class FailMessageOutsideHandlingContext extends Error { 2 | constructor( 3 | readonly help = `Calling .failMessage() with a message indicates that the message received from the 4 | queue can not be processed even with retries and should immediately be sent 5 | to the dead letter queue. 6 | 7 | This error occurs when .failMessage() has been called outside of a message handling context, 8 | or more specifically - outside the stack of a Handler() operation` 9 | ) { 10 | super(`Attempted to fail message outside of a message handling context`) 11 | Object.setPrototypeOf(this, new.target.prototype) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/bus-core/src/error/index.ts: -------------------------------------------------------------------------------- 1 | export * from './fail-message-outside-handling-context' 2 | export * from './class-handler-not-resolved' 3 | export * from './container-not-registered' 4 | export * from './return-message-outside-handling-context' 5 | -------------------------------------------------------------------------------- /packages/bus-core/src/error/return-message-outside-handling-context.ts: -------------------------------------------------------------------------------- 1 | export class ReturnMessageOutsideHandlingContext extends Error { 2 | constructor( 3 | readonly help = `Calling .returnMessage() with a message indicates that the message received from the 4 | queue should be returned so it can be retried. 5 | 6 | This error occurs when .returnMessage() has been called outside of a message handling context, 7 | or more specifically - outside the stack of a Handler() operation` 8 | ) { 9 | super(`Attempted to return message outside of a message handling context`) 10 | Object.setPrototypeOf(this, new.target.prototype) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/bus-core/src/handler/custom-handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A handler that handles messages defined externally to the system, that don't extend from the Message base 3 | */ 4 | export interface CustomHandler { 5 | handle(message: TMessage): void | Promise 6 | } 7 | -------------------------------------------------------------------------------- /packages/bus-core/src/handler/error/handler-already-registered.ts: -------------------------------------------------------------------------------- 1 | export class HandlerAlreadyRegistered extends Error { 2 | readonly help: string 3 | 4 | constructor(readonly handlerName: string) { 5 | super( 6 | `Attempted to register a handler, when a handler with the same name has already been registered.` 7 | ) 8 | this.help = `Handlers must be registered with a unique $name property` 9 | 10 | Object.setPrototypeOf(this, new.target.prototype) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/bus-core/src/handler/error/handler-dispatch-rejected.ts: -------------------------------------------------------------------------------- 1 | export class HandlerDispatchRejected extends Error { 2 | /** 3 | * @param rejections All errors thrown by handlers for the message 4 | */ 5 | constructor(readonly rejections: Error[]) { 6 | super( 7 | `Processing of a message has failed in at least one handler and will be sent back to the queue for retry.` 8 | ) 9 | 10 | Object.setPrototypeOf(this, new.target.prototype) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/bus-core/src/handler/error/index.ts: -------------------------------------------------------------------------------- 1 | export * from './handler-already-registered' 2 | export * from './system-message-missing-resolver' 3 | export * from './handler-dispatch-rejected' 4 | -------------------------------------------------------------------------------- /packages/bus-core/src/handler/error/system-message-missing-resolver.ts: -------------------------------------------------------------------------------- 1 | import { MessageBase } from '../handler' 2 | 3 | export class SystemMessageMissingResolver extends Error { 4 | readonly help: string 5 | 6 | constructor(readonly messageType: MessageBase) { 7 | super( 8 | `A system message has been registered without providing a custom resolver.` 9 | ) 10 | this.help = `Ensure your .withHandler includes a customResolver with resolveWith supplied.` 11 | 12 | Object.setPrototypeOf(this, new.target.prototype) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/bus-core/src/handler/handler-for.ts: -------------------------------------------------------------------------------- 1 | import { ClassConstructor } from '../util' 2 | import { HandlerDefinition, MessageBase } from './handler' 3 | 4 | /** 5 | * Declares a message handling function 6 | * @param messageType Type of message that the function handles 7 | * @param messageHandler Function that is executed each time the type of message is read from the bus 8 | * @example 9 | * const testEventHandler = handlerFor(TestEvent, (message: TestEvent, attributes: MessageAttributes) => {}) 10 | */ 11 | export const handlerFor = ( 12 | messageType: ClassConstructor, 13 | messageHandler: HandlerDefinition 14 | ) => { 15 | return { 16 | messageType, 17 | messageHandler 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/bus-core/src/handler/handler-registry.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@node-ts/bus-messages' 2 | import { LoggerFactory } from '../logger' 3 | import { ClassConstructor } from '../util' 4 | import { Handler, HandlerDefinition, MessageBase } from './handler' 5 | 6 | interface RegisteredHandlers { 7 | messageType: ClassConstructor 8 | handlers: HandlerDefinition[] 9 | } 10 | 11 | export interface HandlerRegistrations { 12 | [key: string]: RegisteredHandlers 13 | } 14 | 15 | /** 16 | * Provide a way for externally managed messages to be handled 17 | * by the Bus 18 | */ 19 | export interface CustomResolver { 20 | /** 21 | * A resolver function that will be executed for each read message 22 | * to determine if it's to be handled by the handler declaring this resolver 23 | */ 24 | resolveWith: (message: MessageType) => boolean 25 | 26 | /** 27 | * If provided, will attempt to subscribe the queue to this topic. 28 | * If not provided, assumes that the queue will be subscribed to the topic manually. 29 | */ 30 | topicIdentifier?: string 31 | } 32 | 33 | export interface HandlerResolver { 34 | handler: HandlerDefinition 35 | resolver(message: unknown): boolean 36 | messageType: ClassConstructor | undefined 37 | topicIdentifier: string | undefined 38 | } 39 | 40 | export type MessageName = string 41 | 42 | /** 43 | * An internal singleton that contains all registrations of messages to functions that handle 44 | * those messages. 45 | */ 46 | export interface HandlerRegistry { 47 | /** 48 | * Registers that a function handles a particular message type 49 | * @param messageType The class type of message to handle 50 | * @param handler The function handler to dispatch messages to as they arrive 51 | * @param customResolver An optional custom resolver that will be used instead 52 | * of the default @node-ts/bus-messages/Message behaviour in terms of matching 53 | * incoming messages to handlers. 54 | */ 55 | register( 56 | messageType: ClassConstructor, 57 | handler: HandlerDefinition 58 | ): void 59 | 60 | registerCustom( 61 | handler: HandlerDefinition, 62 | customResolver: CustomResolver 63 | ): void 64 | 65 | /** 66 | * Gets all registered message handlers for a given message name 67 | * @param message A message that has been received from the bus 68 | */ 69 | get( 70 | loggerFactory: LoggerFactory, 71 | message: object 72 | ): HandlerDefinition[] 73 | 74 | /** 75 | * Retrieves a list of all messages that have handler registrations 76 | */ 77 | getMessageNames(): string[] 78 | 79 | /** 80 | * Returns the class constructor for a message that has a handler registration 81 | * @param messageName Message to get a class constructor for 82 | */ 83 | getMessageConstructor( 84 | messageName: string 85 | ): ClassConstructor | undefined 86 | 87 | /** 88 | * Retrieves an array of all topic arns that are managed externally but require subscribing to as there are 89 | * custom handlers that handle those messages. 90 | */ 91 | getExternallyManagedTopicIdentifiers(): string[] 92 | 93 | /** 94 | * Gets all registered message handler resolvers 95 | */ 96 | getResolvers(): HandlerResolver[] 97 | 98 | /** 99 | * Gets a list of all class based handlers that have been registered 100 | */ 101 | getClassHandlers(): Handler[] 102 | 103 | /** 104 | * Removes all handlers from the registry 105 | */ 106 | reset(): void 107 | } 108 | -------------------------------------------------------------------------------- /packages/bus-core/src/handler/handler.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 2 | import { ClassConstructor } from '../util' 3 | import { CustomHandler } from './custom-handler' 4 | 5 | /** 6 | * Defines the types of messages that the bus can handle 7 | */ 8 | export type MessageBase = 9 | | Message // For messages that originate inside the app and conform to @node-ts/bus-messages 10 | | object // For messages that originate from external services where the structure can't be modified 11 | 12 | /** 13 | * Implemented by a class to indicate it acts as a handler for a given message 14 | */ 15 | export interface Handler< 16 | TMessage extends MessageBase = MessageBase, 17 | TMessageAttributes extends MessageAttributes = MessageAttributes 18 | > { 19 | /** 20 | * The type of message the class handles 21 | */ 22 | messageType: ClassConstructor 23 | 24 | /** 25 | * A function that is called each time a message of `messageType` is received 26 | * @param message The message read from the bus 27 | * @param attributes Attributes of the message read from the bus 28 | */ 29 | handle( 30 | message: TMessage, 31 | attributes: TMessageAttributes 32 | ): void | Promise 33 | } 34 | 35 | export type FunctionHandler< 36 | TMessage, 37 | TMessageAttributes extends MessageAttributes = MessageAttributes 38 | > = (message: TMessage, attributes: TMessageAttributes) => void | Promise 39 | 40 | export type HandlerDefinition< 41 | TMessage = any, 42 | TMessageAttributes extends MessageAttributes = MessageAttributes 43 | > = 44 | | FunctionHandler 45 | | ClassConstructor> 46 | | ClassConstructor> 47 | 48 | /** 49 | * A naive but best guess effort into if a handler is class based and should be resolved from a container 50 | */ 51 | export const isClassHandler = (handler: HandlerDefinition) => 52 | handler.prototype?.handle && handler.prototype?.constructor?.name 53 | -------------------------------------------------------------------------------- /packages/bus-core/src/handler/index.ts: -------------------------------------------------------------------------------- 1 | export * from './handler-registry' 2 | export * from './handler' 3 | export * from './error' 4 | export * from './default-handler-registry' 5 | export * from './handler-for' 6 | export * from './custom-handler' 7 | -------------------------------------------------------------------------------- /packages/bus-core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Transport, 3 | TransportMessage, 4 | TransportConfiguration, 5 | TransportConnectionOptions, 6 | TransportInitializationOptions, 7 | DEFAULT_DEAD_LETTER_QUEUE_NAME 8 | } from './transport' 9 | export * from './handler' 10 | export * from './serialization' 11 | export * from './util' 12 | export * from './workflow' 13 | export * from './error' 14 | export * from './container' 15 | export * from './logger' 16 | export * from './service-bus' 17 | export * from './retry-strategy' 18 | export * from './message-handling-context' 19 | export * from './receiver' 20 | -------------------------------------------------------------------------------- /packages/bus-core/src/logger/README.md: -------------------------------------------------------------------------------- 1 | ## @node-ts/bus-core/logger 2 | 3 | Logging is fully configurable and by default uses [debug](https://www.npmjs.com/package/debug). 4 | 5 | ## Controlling the log level of the default logger 6 | 7 | By default all log levels are invoked when running. It is up to the logger to determine what to output depending on the log level. 8 | 9 | For the default logger (ie: [debug](https://www.npmjs.com/package/debug)), this can be controlled by setting the value of `DEBUG`. 10 | 11 | For example, to get the full debug output of Bus: 12 | 13 | ```sh 14 | DEBUG=@node-ts/bus-* npm run index.js 15 | ``` 16 | 17 | ## Providing a new logger 18 | 19 | A third party or custom logger can be provided by using the `.withLogger()` function when configuring the bus. 20 | 21 | For example: 22 | 23 | ```typescript 24 | import { Bus, Logger } from '@node-ts/bus-core' 25 | 26 | const consoleLogger: Logger = { 27 | debug: console.log, 28 | trace: console.log 29 | info: console.log 30 | warn: console.log 31 | error: console.log 32 | fatal: console.log 33 | } 34 | 35 | Bus.configure() 36 | .withLogger((target: string) => consoleLogger) 37 | ``` 38 | -------------------------------------------------------------------------------- /packages/bus-core/src/logger/debug-logger.spec.ts: -------------------------------------------------------------------------------- 1 | import { DebugLogger } from './debug-logger' 2 | 3 | describe('DebugLogger', () => { 4 | const sut = new DebugLogger('abc') 5 | 6 | it.each(['debug', 'trace', 'info', 'warn', 'error', 'fatal'])( 7 | `should log at error level %s`, 8 | errorLevel => { 9 | sut[errorLevel as keyof DebugLogger]('hello world', { meta: 'example' }) 10 | } 11 | ) 12 | }) 13 | -------------------------------------------------------------------------------- /packages/bus-core/src/logger/debug-logger.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './logger' 2 | import debug, { Debugger } from 'debug' 3 | 4 | /** 5 | * The default logger based on the `debug` package. To see log output, run 6 | * the application with `DEBUG=@node-ts/bus-*` set as an environment variable. 7 | */ 8 | export class DebugLogger implements Logger { 9 | private logger: Debugger 10 | 11 | constructor(name: string) { 12 | this.logger = debug(name) 13 | } 14 | 15 | private log(message: string, meta?: object): void { 16 | meta ? this.logger(message, meta) : this.logger(message) 17 | } 18 | 19 | debug(message: string, meta?: object): void { 20 | this.log(message, meta) 21 | } 22 | trace(message: string, meta?: object): void { 23 | this.log(message, meta) 24 | } 25 | info(message: string, meta?: object): void { 26 | this.log(message, meta) 27 | } 28 | warn(message: string, meta?: object): void { 29 | this.log(message, meta) 30 | } 31 | error(message: string, meta?: object): void { 32 | this.log(message, meta) 33 | } 34 | fatal(message: string, meta?: object): void { 35 | this.log(message, meta) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/bus-core/src/logger/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger' 2 | export * from './debug-logger' 3 | export * from './logger-factory' 4 | -------------------------------------------------------------------------------- /packages/bus-core/src/logger/logger-factory.ts: -------------------------------------------------------------------------------- 1 | import { DebugLogger } from './debug-logger' 2 | import { Logger } from './logger' 3 | 4 | export type LoggerFactory = (target: string) => Logger 5 | 6 | /* 7 | Keep a lookup of existing loggers so that loggers are reused between invocations for 8 | the same target. 9 | */ 10 | const defaultLoggers: { [key: string]: DebugLogger } = {} 11 | 12 | export const defaultLoggerFactory: LoggerFactory = (target: string) => { 13 | if (!defaultLoggers[target]) { 14 | defaultLoggers[target] = new DebugLogger(target) 15 | } 16 | 17 | return defaultLoggers[target] 18 | } 19 | -------------------------------------------------------------------------------- /packages/bus-core/src/logger/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A logging adapter that will be used through the bus library 3 | * in cases where it should use a provided logger rather than 4 | * the in-built one. 5 | */ 6 | export interface Logger { 7 | debug(message: string, meta?: object): void 8 | trace(message: string, meta?: object): void 9 | info(message: string, meta?: object): void 10 | warn(message: string, meta?: object): void 11 | error(message: string, meta?: object): void 12 | fatal(message: string, meta?: object): void 13 | } 14 | -------------------------------------------------------------------------------- /packages/bus-core/src/message-handling-context/index.ts: -------------------------------------------------------------------------------- 1 | export * from './message-handling-context' 2 | -------------------------------------------------------------------------------- /packages/bus-core/src/message-handling-context/message-handling-context.spec.ts: -------------------------------------------------------------------------------- 1 | import { TransportMessage } from '../transport' 2 | import { messageHandlingContext } from './message-handling-context' 3 | 4 | const buildTransportMessage = (): TransportMessage => ({ 5 | id: 'a', 6 | raw: {}, 7 | attributes: { attributes: {}, stickyAttributes: {} }, 8 | domainMessage: { $name: 'a', $version: 1 } 9 | }) 10 | 11 | describe('messageHandlingContext', () => { 12 | describe('when a message is added', () => { 13 | it('should default to not being in a handler context', () => { 14 | const message = buildTransportMessage() 15 | messageHandlingContext.run(message, () => { 16 | expect(messageHandlingContext.isInHandlerContext).toEqual(false) 17 | }) 18 | }) 19 | 20 | it('should override being in a handler context', () => { 21 | const message = buildTransportMessage() 22 | messageHandlingContext.run( 23 | message, 24 | () => { 25 | expect(messageHandlingContext.isInHandlerContext).toEqual(true) 26 | }, 27 | true 28 | ) 29 | }) 30 | 31 | it('should retrieve the message from within the same context', () => { 32 | const message = buildTransportMessage() 33 | messageHandlingContext.run(message, () => { 34 | const retrievedMessage = messageHandlingContext.get() 35 | expect(retrievedMessage).toEqual(message) 36 | }) 37 | }) 38 | 39 | it('should not retrieve a message from a different context', async () => { 40 | const context1 = new Promise(resolve => { 41 | const message = buildTransportMessage() 42 | messageHandlingContext.run(message, async () => { 43 | const retrievedMessage = messageHandlingContext.get()! 44 | expect(retrievedMessage).toEqual(message) 45 | resolve() 46 | }) 47 | }) 48 | const context2 = new Promise(resolve => { 49 | const message = buildTransportMessage() 50 | messageHandlingContext.run(message, async () => { 51 | const retrievedMessage = messageHandlingContext.get()! 52 | expect(retrievedMessage).toEqual(message) 53 | resolve() 54 | }) 55 | }) 56 | await Promise.all([context1, context2]) 57 | }) 58 | 59 | it('should retrieve a message from a nested async chain', async () => { 60 | const message = buildTransportMessage() 61 | await messageHandlingContext.run(message, async () => { 62 | await new Promise(resolve => { 63 | const retrievedMessage = messageHandlingContext.get()! 64 | expect(retrievedMessage).toEqual(message) 65 | resolve() 66 | }) 67 | }) 68 | }) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /packages/bus-core/src/message-handling-context/message-handling-context.ts: -------------------------------------------------------------------------------- 1 | import { TransportMessage } from '../transport' 2 | import ALS from 'alscontext' 3 | 4 | type Context = TransportMessage & { isInHandlerContext?: boolean } 5 | 6 | /** 7 | * A context that stores the transport message when it is received from the bus. Any calls in deeper stacks can 8 | * access the context by calling `messageHandlingContext.get()`. 9 | */ 10 | class MessageHandlingContext extends ALS { 11 | /** 12 | * Fetch the message context for the current async stack 13 | */ 14 | get(): Context { 15 | return super.get('message') 16 | } 17 | 18 | /** 19 | * Set the message context for the current async stack 20 | */ 21 | set(message: Context) { 22 | return super.set('message', message) 23 | } 24 | 25 | /** 26 | * Start and run a new async context 27 | */ 28 | run( 29 | context: Context, 30 | fn: () => T | Promise, 31 | isInHandlerContext = false 32 | ): T | Promise { 33 | return super.run({ message: context, isInHandlerContext }, fn) 34 | } 35 | 36 | /** 37 | * Check if the call stack is within a handler or workflow handler context 38 | */ 39 | get isInHandlerContext(): boolean { 40 | const isInHandlerContext = super.get('isInHandlerContext') 41 | return isInHandlerContext === true 42 | } 43 | } 44 | 45 | export const messageHandlingContext = new MessageHandlingContext() 46 | -------------------------------------------------------------------------------- /packages/bus-core/src/message-lifecycle-context/index.ts: -------------------------------------------------------------------------------- 1 | export * from './message-lifecycle-context' 2 | -------------------------------------------------------------------------------- /packages/bus-core/src/message-lifecycle-context/message-lifecycle-context.ts: -------------------------------------------------------------------------------- 1 | import ALS from 'alscontext' 2 | 3 | type Context = { 4 | /** 5 | * Flags that the application has requested that the current message be 6 | * returned to the queue for retry. 7 | */ 8 | messageReturnedToQueue: boolean 9 | } 10 | 11 | /** 12 | * An internal context that tracks calls within handlers to .returnMessage() 13 | */ 14 | class MessageLifecycleContext extends ALS { 15 | /** 16 | * Fetch the message context for the current async stack 17 | */ 18 | get(): Context { 19 | return super.get('message') 20 | } 21 | 22 | /** 23 | * Set the message context for the current async stack 24 | */ 25 | set(message: Context) { 26 | return super.set('message', message) 27 | } 28 | 29 | /** 30 | * Start and run a new async context 31 | */ 32 | run( 33 | context: Context, 34 | fn: () => T | Promise, 35 | isInHandlerContext = false 36 | ): T | Promise { 37 | return super.run({ message: context, isInHandlerContext }, fn) 38 | } 39 | 40 | /** 41 | * Check if the call stack is within a handler or workflow handler context 42 | */ 43 | get isInHandlerContext(): boolean { 44 | const isInHandlerContext = super.get('isInHandlerContext') 45 | return isInHandlerContext === true 46 | } 47 | } 48 | 49 | export const messageLifecycleContext = new MessageLifecycleContext() 50 | -------------------------------------------------------------------------------- /packages/bus-core/src/receiver/index.ts: -------------------------------------------------------------------------------- 1 | export * from './receiver' 2 | -------------------------------------------------------------------------------- /packages/bus-core/src/receiver/receiver.ts: -------------------------------------------------------------------------------- 1 | import { TransportMessage } from '../transport' 2 | import { MessageSerializer } from '../serialization' 3 | 4 | /** 5 | * When used, the app will be responsible for receiving its own messages rather than subscribing directly 6 | * to the transport. This can be useful for local testing or in serverless environments, where the cloud 7 | * will manage receiving a message from the transport and passing it directly to the serverless container. 8 | */ 9 | export interface Receiver< 10 | TReceivedMessage = unknown, 11 | TTransportMessage extends TransportMessage = TransportMessage 12 | > { 13 | /** 14 | * Invoked when a message is received by the application and needs to be converted into a transport message 15 | * so that it can be passed to the dispatcher and send to handlers. 16 | * 17 | * @param receivedMessage The message received by the app 18 | * @param messageSerializer The configured serializer, which can be used to deserialize the incoming message 19 | */ 20 | receive( 21 | receivedMessage: TReceivedMessage, 22 | messageSerializer: MessageSerializer 23 | ): Promise 24 | } 25 | -------------------------------------------------------------------------------- /packages/bus-core/src/retry-strategy/default-retry-strategy.spec.ts: -------------------------------------------------------------------------------- 1 | import { DefaultRetryStrategy } from './default-retry-strategy' 2 | 3 | describe('DefaultRetryStrategy', () => { 4 | const sut = new DefaultRetryStrategy() 5 | 6 | it.each([ 7 | [0, 5, 6], 8 | [1, 23, 28], 9 | [2, 113, 138], 10 | [3, 563, 688], 11 | [4, 2813, 3438], 12 | [5, 14063, 17188], 13 | [6, 70313, 85938], 14 | [7, 351563, 429688], 15 | [8, 1757813, 2148438], 16 | [9, 8789063, 9000000] 17 | ])( 18 | 'attempt %s should delay between %sms and %sms', 19 | (attempt: number, expectedMinDelay: number, expectedMaxDelay: number) => { 20 | const actualDelay = sut.calculateRetryDelay(attempt) 21 | expect(actualDelay).toBeGreaterThanOrEqual(expectedMinDelay) 22 | expect(actualDelay).toBeLessThanOrEqual(expectedMaxDelay) 23 | } 24 | ) 25 | }) 26 | -------------------------------------------------------------------------------- /packages/bus-core/src/retry-strategy/default-retry-strategy.ts: -------------------------------------------------------------------------------- 1 | import { Milliseconds, RetryStrategy } from './retry-strategy' 2 | 3 | const MAX_DELAY_MS = 2.5 * 60 * 60 * 1000 // 2.5 hours 4 | const JITTER_PERCENT = 0.1 5 | 6 | /** 7 | * A default message retry strategy that exponentially increases the delay between retries 8 | * from 5ms to 2.5 hrs for the first 10 attempts. Each retry delay includes a jitter of 9 | * up to 10% to avoid deadlock-related errors from continually blocking. 10 | */ 11 | export class DefaultRetryStrategy implements RetryStrategy { 12 | calculateRetryDelay(currentAttempt: number): Milliseconds { 13 | const numberOfFailures = currentAttempt + 1 14 | const constantDelay: Milliseconds = Math.pow(5, numberOfFailures) 15 | const jitterAmount = Math.random() * JITTER_PERCENT * constantDelay 16 | const jitterDirection = Math.random() > 0.5 ? 1 : -1 17 | const jitter = jitterAmount * jitterDirection 18 | const delay = Math.round(constantDelay + jitter) 19 | return Math.min(delay, MAX_DELAY_MS) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/bus-core/src/retry-strategy/index.ts: -------------------------------------------------------------------------------- 1 | export * from './retry-strategy' 2 | export * from './default-retry-strategy' 3 | -------------------------------------------------------------------------------- /packages/bus-core/src/retry-strategy/retry-strategy.ts: -------------------------------------------------------------------------------- 1 | export type Milliseconds = number 2 | 3 | /** 4 | * Defines how a message retry strategy is to be implemented that calculates the delay between subsequent 5 | * retries of a message. 6 | */ 7 | export interface RetryStrategy { 8 | /** 9 | * Calculate the delay between retrying a failed message 10 | * @param currentAttempt How many attempts at handling the message have failed 11 | * @returns The number of milliseconds to delay retrying a failed message attempt 12 | */ 13 | calculateRetryDelay(currentAttempt: number): Milliseconds 14 | } 15 | -------------------------------------------------------------------------------- /packages/bus-core/src/serialization/index.ts: -------------------------------------------------------------------------------- 1 | export * from './serializer' 2 | export * from './json-serializer' 3 | export * from './message-serializer' 4 | -------------------------------------------------------------------------------- /packages/bus-core/src/serialization/json-serializer.spec.ts: -------------------------------------------------------------------------------- 1 | import { JsonSerializer } from './json-serializer' 2 | 3 | class Contract { 4 | readonly c: Date 5 | 6 | testFn: () => void 7 | 8 | constructor(readonly a: string, readonly b: number, c: Date) { 9 | this.c = c 10 | } 11 | } 12 | 13 | describe('JsonSerializer', () => { 14 | let sut: JsonSerializer 15 | const date = new Date(2000, 2, 1, 10, 0, 0, 0) 16 | const contract = new Contract('a', 1, date) 17 | 18 | beforeEach(() => { 19 | sut = new JsonSerializer() 20 | }) 21 | 22 | describe('when serializing', () => { 23 | let result: string 24 | beforeEach(() => { 25 | result = sut.serialize({ 26 | a: 'a', 27 | b: 1, 28 | c: date 29 | }) 30 | }) 31 | 32 | it('should convert an object to a string', () => { 33 | expect(result).toEqual(`{"a":"a","b":1,"c":"${date.toISOString()}"}`) 34 | }) 35 | }) 36 | 37 | describe('when deserializing', () => { 38 | let result: Contract 39 | const date = new Date(200) 40 | beforeEach(() => { 41 | result = sut.deserialize( 42 | `{"a":"a","b":1,"c":"${date.toISOString()}"}`, 43 | Contract 44 | ) 45 | }) 46 | 47 | it('should deserialize to a plain object', () => { 48 | expect(result).toMatchObject({ a: 'a', b: 1 }) 49 | expect(result.c).toBeDefined() 50 | }) 51 | 52 | it('should be unable to deserilize strong types', () => { 53 | expect(result.c.toUTCString).toBeUndefined() 54 | }) 55 | }) 56 | 57 | describe('when converting typed object to plain', () => { 58 | let result: object 59 | beforeEach(() => { 60 | result = sut.toPlain(contract) 61 | }) 62 | 63 | it('should strip out additional fields', () => { 64 | expect(Object.keys(result)).not.toContain('testFn') 65 | }) 66 | }) 67 | 68 | describe('when converting plain object to typed', () => { 69 | let result: Contract 70 | beforeEach(() => { 71 | const plain = sut.toPlain(contract) 72 | result = sut.toClass(plain, Contract) 73 | }) 74 | 75 | it('should strip out additional fields', () => { 76 | expect(result).toBeInstanceOf(Contract) 77 | }) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /packages/bus-core/src/serialization/json-serializer.ts: -------------------------------------------------------------------------------- 1 | import { Serializer } from './serializer' 2 | import { ClassConstructor } from '../util' 3 | 4 | /** 5 | * A naive and generally unsafe default JSON serializer. This relies on 6 | * the native JSON.stringify/parse functions that do a basic job of serialization. 7 | * Deserialized forms will be plain objects with class properties (eg Date) not 8 | * being a strong type. 9 | * 10 | * It's recommended that an external serializer like `@node-ts/bus-class-serializer` 11 | * be used instead that will preserve types when serialized/deserialized. 12 | */ 13 | export class JsonSerializer implements Serializer { 14 | serialize(obj: ObjectType): string { 15 | return JSON.stringify(obj) 16 | } 17 | 18 | deserialize( 19 | serialized: string, 20 | classConstructor: ClassConstructor 21 | ): ObjectType { 22 | const plain = JSON.parse(serialized) 23 | return this.toClass(plain, classConstructor) 24 | } 25 | 26 | toPlain(obj: T): object { 27 | return JSON.parse(JSON.stringify(obj)) 28 | } 29 | 30 | toClass( 31 | obj: object, 32 | classConstructor: ClassConstructor 33 | ): T { 34 | const instance = new classConstructor() 35 | Object.assign(instance, obj) 36 | return instance 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/bus-core/src/serialization/message-serializer.spec.ts: -------------------------------------------------------------------------------- 1 | import { Serializer } from './serializer' 2 | import { ClassConstructor } from '../util' 3 | import * as faker from 'faker' 4 | import { Message } from '@node-ts/bus-messages' 5 | import { DefaultHandlerRegistry } from '../handler' 6 | import { MessageSerializer } from './message-serializer' 7 | 8 | class DummyMessage { 9 | $name = 'bluh' 10 | $version = 1 11 | value: string 12 | 13 | constructor(s: string) { 14 | this.value = s 15 | } 16 | } 17 | 18 | class ToxicSerializer implements Serializer { 19 | serialize(obj: ObjectType): string { 20 | return (obj as Message).$name 21 | } 22 | 23 | deserialize( 24 | serialized: string, 25 | classType: ClassConstructor 26 | ): ObjectType { 27 | return new classType(serialized) 28 | } 29 | 30 | toPlain(_: T): object { 31 | return {} 32 | } 33 | 34 | toClass(_: object, __: ClassConstructor): T { 35 | return {} as T 36 | } 37 | } 38 | 39 | describe('MessageSerializer', () => { 40 | const serializer = new ToxicSerializer() 41 | const messageSerializer = new MessageSerializer( 42 | serializer, 43 | new DefaultHandlerRegistry() 44 | ) 45 | 46 | it('should use underlying serializer to serialize', () => { 47 | const message = new DummyMessage('a') 48 | const result = messageSerializer.serialize(message) 49 | expect(result).toBe(message.$name) 50 | }) 51 | 52 | it('should use underlying deserializer to deserialize', () => { 53 | const msg = new DummyMessage(faker.random.words()) 54 | const raw = JSON.stringify(msg) 55 | 56 | const result = messageSerializer.deserialize(raw) 57 | expect(result.value).toBe(msg.value) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /packages/bus-core/src/serialization/message-serializer.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@node-ts/bus-messages' 2 | import { HandlerRegistry } from '../handler' 3 | import { Serializer } from './serializer' 4 | 5 | /** 6 | * This a wrapper around the real serializer. 7 | * Unlike JsonSerializer, whose sole job is parsing data, 8 | * this class will do some plumbing work to look up the Handler Registry for 9 | * the message constructor. 10 | * 11 | * Normally, transports will use this instead of the real serializer. 12 | */ 13 | export class MessageSerializer { 14 | constructor( 15 | private readonly serializer: Serializer, 16 | private readonly handlerRegistry: HandlerRegistry 17 | ) {} 18 | 19 | serialize(message: MessageType): string { 20 | return this.serializer.serialize(message) 21 | } 22 | 23 | deserialize( 24 | serializedMessage: string 25 | ): MessageType { 26 | const naiveDeserializedMessage = JSON.parse(serializedMessage) as Message 27 | const messageType = this.handlerRegistry.getMessageConstructor( 28 | naiveDeserializedMessage.$name 29 | ) 30 | 31 | return ( 32 | !!messageType 33 | ? this.serializer.deserialize(serializedMessage, messageType) 34 | : naiveDeserializedMessage 35 | ) as MessageType 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/bus-core/src/serialization/serializer.ts: -------------------------------------------------------------------------------- 1 | import { ClassConstructor } from '../util' 2 | 3 | /** 4 | * A serializer that's use to serialize/deserialize objects as they leave and enter the application boundary. 5 | */ 6 | export interface Serializer { 7 | serialize(obj: T): string 8 | deserialize(val: string, classType: ClassConstructor): T 9 | toPlain(obj: T): object 10 | toClass( 11 | obj: object, 12 | classConstructor: ClassConstructor 13 | ): T 14 | } 15 | -------------------------------------------------------------------------------- /packages/bus-core/src/service-bus/bus-instance-concurrency.integration.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryQueue } from '../transport' 2 | import { Bus } from './bus' 3 | import { TestEvent } from '../test/test-event' 4 | import { sleep } from '../util' 5 | import { Mock, IMock, Times } from 'typemoq' 6 | import { BusInstance } from './bus-instance' 7 | import { handlerFor } from '../handler' 8 | import { TestCommand } from '../test/test-command' 9 | 10 | const event = new TestEvent() 11 | type Callback = (correlationId: string) => void 12 | 13 | describe('BusInstance - Concurrency', () => { 14 | let queue: InMemoryQueue 15 | let callback: IMock 16 | let handleCount = 0 17 | const resolutions: ((_: unknown) => void)[] = [] 18 | const CONCURRENCY = 2 19 | let bus: BusInstance 20 | 21 | const eventHandler = handlerFor(TestEvent, async () => { 22 | handleCount++ 23 | 24 | await new Promise(resolve => { 25 | resolutions.push(resolve) 26 | }) 27 | await bus.send(new TestCommand()) 28 | }) 29 | 30 | const commandHandler = handlerFor(TestCommand, async (_, attributes) => { 31 | callback.object(attributes.correlationId!) 32 | }) 33 | 34 | beforeAll(async () => { 35 | queue = new InMemoryQueue() 36 | callback = Mock.ofType() 37 | 38 | bus = Bus.configure() 39 | .withTransport(queue) 40 | .withHandler(eventHandler) 41 | .withHandler(commandHandler) 42 | .withConcurrency(CONCURRENCY) 43 | .build() 44 | 45 | await bus.initialize() 46 | await bus.start() 47 | }) 48 | 49 | afterAll(async () => bus.stop()) 50 | 51 | describe('when starting the bus with concurrent handlers', () => { 52 | beforeAll(async () => { 53 | await Promise.all([ 54 | // These should be handled immediately 55 | bus.publish(event, { correlationId: 'first' }), 56 | bus.publish(event, { correlationId: 'second' }), 57 | // This should be handled when the next worker becomes available 58 | bus.publish(event, { correlationId: 'third' }) 59 | ]) 60 | await sleep(100) 61 | }) 62 | 63 | it('should handle messages in parallel up to the concurrency limit', async () => { 64 | expect(handleCount).toEqual(CONCURRENCY) 65 | 66 | // Let the first handler complete 67 | resolutions[0](undefined) 68 | await sleep(10) 69 | 70 | expect(handleCount).toEqual(CONCURRENCY + 1) 71 | // Resolve subsequent handlers 72 | resolutions[1](undefined) 73 | resolutions[2](undefined) 74 | }) 75 | 76 | describe('when the command handlers are run', () => { 77 | it('the message handling context should have propagated all sticky attributes', () => { 78 | callback.verify(x => x('first'), Times.once()) 79 | callback.verify(x => x('second'), Times.once()) 80 | callback.verify(x => x('third'), Times.once()) 81 | }) 82 | }) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /packages/bus-core/src/service-bus/bus-instance-receiver.integration.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 2 | import { Receiver } from '../receiver' 3 | import { MessageSerializer } from '../serialization' 4 | import { InMemoryQueue, TransportMessage } from '../transport' 5 | import { Bus } from './bus' 6 | import { BusInstance } from './bus-instance' 7 | import { InvalidOperation } from './error' 8 | import { handlerFor } from '../handler' 9 | import { TestCommand, TestEvent } from '../test' 10 | import { It, Mock, Times } from 'typemoq' 11 | 12 | const emptyAttributes: MessageAttributes = { 13 | attributes: {}, 14 | stickyAttributes: {} 15 | } 16 | 17 | class PassthroughReceiver 18 | implements Receiver> 19 | { 20 | async receive( 21 | receivedMessage: Message | Message[], 22 | _messageSerializer: MessageSerializer 23 | ): Promise | TransportMessage[]> { 24 | const toSend = Array.isArray(receivedMessage) 25 | ? receivedMessage 26 | : [receivedMessage] 27 | return toSend.map(domainMessage => ({ 28 | id: Date.now().toString(), 29 | attributes: emptyAttributes, 30 | domainMessage, 31 | raw: receivedMessage 32 | })) 33 | } 34 | } 35 | 36 | const receiver = new PassthroughReceiver() 37 | 38 | describe('BusInstance Receiver', () => { 39 | describe('when configuring Bus with a Receiver', () => { 40 | let bus: BusInstance 41 | let commandHandler = jest.fn() 42 | let testCommandHandler = handlerFor(TestCommand, commandHandler) 43 | const handlerThatThrows = handlerFor(TestEvent, () => { 44 | throw new Error() 45 | }) 46 | const queue = Mock.ofType() 47 | 48 | beforeAll(async () => { 49 | bus = Bus.configure() 50 | .withReceiver(receiver) 51 | .withHandler(testCommandHandler) 52 | .withHandler(handlerThatThrows) 53 | .withTransport(queue.object) 54 | .build() 55 | await bus.initialize() 56 | }) 57 | 58 | afterAll(async () => { 59 | await bus.dispose() 60 | }) 61 | 62 | describe('when bus.start() is called', () => { 63 | it('should throw an InvalidOperationError', async () => { 64 | await expect(bus.start()).rejects.toBeInstanceOf(InvalidOperation) 65 | }) 66 | }) 67 | 68 | describe('when a message is passed through to bus.receive()', () => { 69 | const command = new TestCommand() 70 | beforeAll(async () => { 71 | commandHandler.mockReset() 72 | await bus.receive(command) 73 | }) 74 | 75 | it('should dispatch to handlers', () => { 76 | expect(commandHandler).toHaveBeenCalledWith(command, emptyAttributes) 77 | }) 78 | 79 | it('should not call delete message, as the receiver implementation should handle it', () => { 80 | queue.verify(q => q.deleteMessage(It.isAny()), Times.never()) 81 | }) 82 | }) 83 | 84 | describe('when an error is thrown when receiving a message', () => { 85 | it('the error should be re-thrown so the receiver host can retry the message', async () => { 86 | const event = new TestEvent() 87 | await expect(bus.receive(event)).rejects.toThrow() 88 | // Receiver host should return the message, not the application 89 | queue.verify(q => q.returnMessage(It.isAny()), Times.never()) 90 | }) 91 | }) 92 | 93 | describe('when a batch of messages are passed through to bus.receive()', () => { 94 | const commands = Array(10) 95 | .fill(undefined) 96 | .map(() => new TestCommand()) 97 | 98 | beforeAll(async () => { 99 | commandHandler.mockReset() 100 | await bus.receive(commands) 101 | }) 102 | 103 | it('should dispatch all commands to handlers', () => { 104 | commands.forEach(command => { 105 | expect(commandHandler).toHaveBeenCalledWith(command, emptyAttributes) 106 | }) 107 | }) 108 | }) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /packages/bus-core/src/service-bus/bus-instance-return-message.integration.ts: -------------------------------------------------------------------------------- 1 | import { Mock, Times } from 'typemoq' 2 | import { ReturnMessageOutsideHandlingContext } from '../error' 3 | import { handlerFor } from '../handler' 4 | import { TestCommand } from '../test/test-command' 5 | import { Bus } from './bus' 6 | import { BusInstance } from './bus-instance' 7 | import { sleep } from '../util' 8 | 9 | describe('BusInstance - Return Message', () => { 10 | let bus: BusInstance 11 | let invocationCount = 0 12 | const callback = Mock.ofType<() => undefined>() 13 | 14 | beforeAll(async () => { 15 | bus = Bus.configure() 16 | .withHandler( 17 | handlerFor(TestCommand, async (_: TestCommand) => { 18 | invocationCount++ 19 | callback.object() 20 | 21 | if (invocationCount < 3) { 22 | await bus.returnMessage() 23 | } 24 | }) 25 | ) 26 | .build() 27 | 28 | await bus.initialize() 29 | await bus.start() 30 | }) 31 | 32 | afterAll(async () => { 33 | await bus.dispose() 34 | }) 35 | 36 | describe('when a message is returned to the queue during handling', () => { 37 | beforeAll(async () => { 38 | await bus.send(new TestCommand()) 39 | while (invocationCount < 3) { 40 | await sleep(10) 41 | } 42 | }) 43 | 44 | it('should be returned to the queue and retry', async () => { 45 | callback.verify(c => c(), Times.exactly(3)) 46 | }) 47 | }) 48 | 49 | describe('when a message is returned to the queue outside of a handler', () => { 50 | it('should throw an error', async () => { 51 | await expect(bus.returnMessage()).rejects.toBeInstanceOf( 52 | ReturnMessageOutsideHandlingContext 53 | ) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /packages/bus-core/src/service-bus/bus-state.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents the lifecycle state of a message-handling bus instance 3 | */ 4 | export enum BusState { 5 | Starting = 'starting', 6 | Started = 'started', 7 | Stopping = 'stopping', 8 | Stopped = 'stopped' 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-core/src/service-bus/bus.spec.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../logger' 2 | import { Serializer } from '../serialization' 3 | import { TestEventClassHandler } from '../test/test-event-class-handler' 4 | import { Transport } from '../transport' 5 | import { Persistence } from '../workflow' 6 | import { Bus } from './bus' 7 | import { BusAlreadyInitialized } from './error' 8 | import { BusState } from './bus-state' 9 | 10 | describe('Bus', () => { 11 | describe('when configuring Bus after initialization', () => { 12 | it('should reject', async () => { 13 | const config = Bus.configure() 14 | const bus = config.build() 15 | expect(() => config.withHandler(TestEventClassHandler)).toThrowError( 16 | BusAlreadyInitialized 17 | ) 18 | expect(() => config.withLogger(() => ({} as Logger))).toThrowError( 19 | BusAlreadyInitialized 20 | ) 21 | expect(() => config.withPersistence({} as Persistence)).toThrowError( 22 | BusAlreadyInitialized 23 | ) 24 | expect(() => config.withSerializer({} as Serializer)).toThrowError( 25 | BusAlreadyInitialized 26 | ) 27 | expect(() => config.withTransport({} as Transport)).toThrowError( 28 | BusAlreadyInitialized 29 | ) 30 | expect(() => config.withWorkflow({} as any)).toThrowError( 31 | BusAlreadyInitialized 32 | ) 33 | await bus.dispose() 34 | }) 35 | }) 36 | 37 | describe('when configuring bus concurrency', () => { 38 | it('should accept a concurrency of 1', () => { 39 | Bus.configure().withConcurrency(1) 40 | }) 41 | 42 | it('should accept a concurrency > 1', () => { 43 | Bus.configure().withConcurrency(10) 44 | }) 45 | 46 | it('should throw an error when concurrency < 1', () => { 47 | expect(() => Bus.configure().withConcurrency(0)).toThrowError() 48 | }) 49 | }) 50 | 51 | describe('when interrupt signals are sent', () => { 52 | it('should stop the bus on SIGINT', async () => { 53 | const bus = Bus.configure().build() 54 | await bus.initialize() 55 | await bus.start() 56 | process.emit('SIGINT') 57 | expect(bus.state).toBe(BusState.Stopped) 58 | }) 59 | 60 | it('should stop the bus on SIGTERM', async () => { 61 | const bus = Bus.configure().build() 62 | await bus.initialize() 63 | await bus.start() 64 | process.emit('SIGTERM') 65 | expect(bus.state).toBe(BusState.Stopped) 66 | }) 67 | 68 | it('should stop the bus on user provided interrupts', async () => { 69 | const additionalInterrupts: NodeJS.Signals[] = ['SIGUSR2'] 70 | const bus = Bus.configure() 71 | .withAdditionalInterruptSignal(...additionalInterrupts) 72 | .build() 73 | await bus.initialize() 74 | await bus.start() 75 | process.emit('SIGUSR2') 76 | expect(bus.state).toBe(BusState.Stopped) 77 | }) 78 | }) 79 | 80 | describe('when disposing the bus', () => { 81 | describe('after its been initialized', () => { 82 | it('should dispose', async () => { 83 | const bus = Bus.configure().build() 84 | await bus.initialize() 85 | await bus.dispose() 86 | }) 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /packages/bus-core/src/service-bus/bus.ts: -------------------------------------------------------------------------------- 1 | import { BusConfiguration } from './bus-configuration' 2 | 3 | export class Bus { 4 | private constructor() {} 5 | 6 | /** 7 | * Configures the Bus prior to use 8 | */ 9 | static configure(): BusConfiguration { 10 | return new BusConfiguration() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/bus-core/src/service-bus/error/bus-already-initialized.ts: -------------------------------------------------------------------------------- 1 | export class BusAlreadyInitialized extends Error { 2 | readonly help: string 3 | 4 | constructor() { 5 | super(`Attempted to configure Bus after its been initialized`) 6 | this.help = `Ensure all configuration operations happen once at startup of your app` 7 | 8 | Object.setPrototypeOf(this, new.target.prototype) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/bus-core/src/service-bus/error/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bus-already-initialized' 2 | export * from './invalid-bus-state' 3 | export * from './invalid-operation' 4 | -------------------------------------------------------------------------------- /packages/bus-core/src/service-bus/error/invalid-bus-state.ts: -------------------------------------------------------------------------------- 1 | import { BusState } from '../bus-state' 2 | 3 | export class InvalidBusState extends Error { 4 | constructor( 5 | message: string, 6 | readonly actualState: BusState, 7 | readonly expectedState: BusState[] 8 | ) { 9 | super(message) 10 | 11 | Object.setPrototypeOf(this, new.target.prototype) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/bus-core/src/service-bus/error/invalid-operation.ts: -------------------------------------------------------------------------------- 1 | export class InvalidOperation extends Error { 2 | constructor(message: string) { 3 | super(message) 4 | 5 | Object.setPrototypeOf(this, new.target.prototype) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/bus-core/src/service-bus/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bus' 2 | export * from './bus-instance' 3 | export * from './bus-state' 4 | export * from './bus-configuration' 5 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './test-command' 2 | export * from './test-event' 3 | export * from './test-event-handler' 4 | export * from './test-command-handler' 5 | export * from './test-command-2' 6 | export * from './test-system-message' 7 | export * from './test-event-2' 8 | export * from './test-fail-message' 9 | export * from './test-sticky-attributes' 10 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/test-command-2.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@node-ts/bus-messages' 2 | 3 | export class TestCommand2 extends Command { 4 | static NAME = '@node-ts/bus-core/test-command-2' 5 | $name = TestCommand2.NAME 6 | $version = 1 7 | } 8 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/test-command-3.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@node-ts/bus-messages' 2 | 3 | export class TestCommand3 extends Command { 4 | static NAME = '@node-ts/bus-core/test-command-3' 5 | $name = TestCommand3.NAME 6 | $version = 1 7 | } 8 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/test-command-handler.ts: -------------------------------------------------------------------------------- 1 | import { TestCommand } from './test-command' 2 | 3 | export const testCommandHandler = (_: TestCommand) => undefined 4 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/test-command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@node-ts/bus-messages' 2 | 3 | export class TestCommand extends Command { 4 | static NAME = '@node-ts/bus-core/test-command' 5 | $name = TestCommand.NAME 6 | $version = 1 7 | } 8 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/test-event-2.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '@node-ts/bus-messages' 2 | 3 | export class TestEvent2 extends Event { 4 | static NAME = '@node-ts/bus-core/test-event-2' 5 | $name = TestEvent2.NAME 6 | $version = 1 7 | } 8 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/test-event-class-handler.ts: -------------------------------------------------------------------------------- 1 | import { MessageAttributes } from '@node-ts/bus-messages' 2 | import { Handler } from '../handler' 3 | import { TestEvent } from './test-event' 4 | import { MessageLogger } from './test-event-handler' 5 | 6 | export class TestEventClassHandler implements Handler { 7 | messageType = TestEvent 8 | 9 | constructor(private readonly messageLogger: MessageLogger) {} 10 | 11 | async handle( 12 | message: TestEvent, 13 | attributes: MessageAttributes 14 | ): Promise { 15 | this.messageLogger.log(message) 16 | this.messageLogger.log(attributes) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/test-event-handler.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 2 | import { HandlerDefinition } from '../handler' 3 | import { ClassConstructor } from '../util' 4 | import { TestEvent } from './test-event' 5 | 6 | const handlerFor = ( 7 | messageType: ClassConstructor, 8 | messageHandler: HandlerDefinition 9 | ) => { 10 | return { 11 | messageType, 12 | messageHandler 13 | } 14 | } 15 | export interface MessageLogger { 16 | log(message: unknown): void 17 | } 18 | 19 | export const testEventHandler = (messageLogger: MessageLogger) => 20 | handlerFor(TestEvent, (message: TestEvent, attributes: MessageAttributes) => { 21 | messageLogger.log(message) 22 | messageLogger.log(attributes) 23 | }) 24 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/test-event.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '@node-ts/bus-messages' 2 | 3 | export class TestEvent extends Event { 4 | static NAME = '@node-ts/bus-core/test-event' 5 | $name = TestEvent.NAME 6 | $version = 1 7 | 8 | property1: string | undefined 9 | property2: string 10 | 11 | constructor(property1?: string) { 12 | super() 13 | this.property1 = property1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/test-fail-message.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@node-ts/bus-messages' 2 | 3 | export class TestFailMessage extends Message { 4 | static NAME = '@node-ts/bus-core/test-fail-message' 5 | $name = TestFailMessage.NAME 6 | $version = 1 7 | 8 | constructor(readonly id: string) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/test-sticky-attributes.ts: -------------------------------------------------------------------------------- 1 | import { BusInstance } from '../service-bus' 2 | import { TestEvent } from './test-event' 3 | 4 | export const testStickAttributesTestCommand = async (bus: BusInstance) => { 5 | await bus.send(new TestEvent()) 6 | } 7 | -------------------------------------------------------------------------------- /packages/bus-core/src/test/test-system-message.ts: -------------------------------------------------------------------------------- 1 | import * as faker from 'faker' 2 | export class TestSystemMessage { 3 | static NAME = faker.random.uuid() 4 | constructor(readonly name = TestSystemMessage.NAME) {} 5 | } 6 | -------------------------------------------------------------------------------- /packages/bus-core/src/transport/README.md: -------------------------------------------------------------------------------- 1 | # Transports 2 | 3 | Transports are the underlying message broker that `@node-ts/bus-core` uses to communicate. By default this package includes an in-memory queue (`InMemoryQueue`), but can (and should) be replaced with a durable transport. 4 | 5 | Currently adapters for two technologies are implemented and available for use: 6 | 7 | - [@node-ts/bus-sqs](/packages/bus-sqs/) 8 | - [@node-ts/bus-rabbitmq](/packages/bus-rabbitmq/) 9 | 10 | ## Implementing a Transport 11 | 12 | Implementing a new transport is relatively simple (and encouraged!). This can be done by implementing the [Transport<>](https://github.com/node-ts/bus/blob/master/packages/bus-core/src/transport/transport.ts) interface from `@node-ts/bus-core`. If you'd like to contribute your transport adapter back to `@node-ts/bus` then please fork this repo, add a new package at `/packages/bus-` and create a PR back to this repository. 13 | 14 | Transport adapters should be created and then registered in a new inversify module so that they consumers can use the transport just by loading the module. 15 | 16 | For an example of a transport implementation, see the code for the [@node-ts/bus-sqs](https://github.com/node-ts/bus/blob/master/packages/bus-sqs/) transport. 17 | -------------------------------------------------------------------------------- /packages/bus-core/src/transport/default-in-memory-queue-configuration.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryQueueConfiguration } from './in-memory-queue-configuration' 2 | 3 | export class DefaultInMemoryQueueConfiguration 4 | implements InMemoryQueueConfiguration 5 | { 6 | maxRetries = 10 7 | 8 | receiveTimeoutMs = 1000 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-core/src/transport/in-memory-queue-configuration.ts: -------------------------------------------------------------------------------- 1 | export interface InMemoryQueueConfiguration { 2 | /** 3 | * Maximum number of attempts to retry a failed message before routing it to the DLQ 4 | */ 5 | maxRetries: number 6 | 7 | /** 8 | * The number of milliseconds to wait whilst attempting to read the next message 9 | */ 10 | receiveTimeoutMs: number 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-core/src/transport/index.ts: -------------------------------------------------------------------------------- 1 | export * from './transport' 2 | export { InMemoryQueue, InMemoryMessage } from './in-memory-queue' 3 | export * from './transport-message' 4 | export * from './transport-configuration' 5 | export * from './in-memory-queue-configuration' 6 | -------------------------------------------------------------------------------- /packages/bus-core/src/transport/transport-configuration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A common set of transport configuration options. This should be extended 3 | * by a provider-specific configuration that augments it with further options. 4 | */ 5 | export interface TransportConfiguration { 6 | /** 7 | * The name of the queue that receives incoming messages 8 | * @example order-booking-service 9 | */ 10 | queueName: string 11 | 12 | /** 13 | * An optional name of the dead letter queue to fail messages to 14 | * @default dead-letter 15 | * @example order-booking-service-dlq 16 | */ 17 | deadLetterQueueName?: string 18 | } 19 | 20 | export const DEFAULT_DEAD_LETTER_QUEUE_NAME = 'dead-letter' 21 | -------------------------------------------------------------------------------- /packages/bus-core/src/transport/transport-message.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 2 | 3 | /** 4 | * A message from the transport provider that encapsulates the raw message 5 | * plus the domain message payload 6 | */ 7 | export interface TransportMessage { 8 | /** 9 | * Uniquely identify the message 10 | */ 11 | id: string | undefined 12 | 13 | /** 14 | * The domain message payload transmitted in the payload 15 | */ 16 | domainMessage: Message 17 | 18 | /** 19 | * The raw message as it was received from the transport 20 | */ 21 | raw: TransportMessageType 22 | 23 | /** 24 | * Additional attributes and metadata that was sent along with the message 25 | */ 26 | attributes: MessageAttributes 27 | } 28 | -------------------------------------------------------------------------------- /packages/bus-core/src/transport/transport.ts: -------------------------------------------------------------------------------- 1 | import { Event, Command, MessageAttributes } from '@node-ts/bus-messages' 2 | import { CoreDependencies } from '../util' 3 | import { HandlerRegistry } from '../handler' 4 | import { TransportMessage } from './transport-message' 5 | 6 | export interface TransportInitializationOptions { 7 | /** 8 | * The handler registry that contains all of the message handlers that the transport needs to 9 | * subscribe to. 10 | */ 11 | handlerRegistry: HandlerRegistry 12 | 13 | /** 14 | * If the transport is being initialized in send-only mode 15 | */ 16 | sendOnly: boolean 17 | } 18 | 19 | export interface TransportConnectionOptions { 20 | concurrency: number 21 | } 22 | 23 | /** 24 | * A transport adapter interface that enables the service bus to use a messaging technology. 25 | */ 26 | export interface Transport { 27 | /** 28 | * Publishes an event to the underlying transport. This is generally done to a topic or some other 29 | * mechanism that consumers can subscribe themselves to 30 | * @param event A domain event to be published 31 | * @param messageOptions Options that control the behaviour around how the message is sent and 32 | * additional information that travels with it. 33 | */ 34 | publish( 35 | event: TEvent, 36 | messageOptions?: MessageAttributes 37 | ): Promise 38 | 39 | /** 40 | * Sends a command to the underlying transport. This is generally done to a topic or some other 41 | * mechanism that consumers can subscribe themselves to 42 | * @param command A domain command to be sent 43 | * @param messageOptions Options that control the behaviour around how the message is sent and 44 | * additional information that travels with it. 45 | */ 46 | send( 47 | command: TCommand, 48 | messageOptions?: MessageAttributes 49 | ): Promise 50 | 51 | /** 52 | * Forwards @param transportMessage to the dead letter queue. The message must have been read in from the 53 | * queue and have a receipt handle. 54 | */ 55 | fail(transportMessage: TransportMessage): Promise 56 | 57 | /** 58 | * Forwards @param transportMessage to the dead letter queue. The message must have been read in from the 59 | * queue and have a receipt handle. 60 | */ 61 | fail(transportMessage: TransportMessage): Promise 62 | 63 | /** 64 | * Fetch the next message from the underlying queue. If there are no messages, then `undefined` 65 | * should be returned. 66 | * 67 | * @returns The message construct from the underlying transport, that includes both the raw message envelope 68 | * plus the contents or body that contains the `@node-ts/bus-messages` message. 69 | */ 70 | readNextMessage(): Promise | undefined> 71 | 72 | /** 73 | * Removes a message from the underlying transport. This will be called once a message has been 74 | * successfully handled by any of the message handling functions. 75 | * @param message The message to be removed from the transport 76 | */ 77 | deleteMessage(message: TransportMessage): Promise 78 | 79 | /** 80 | * Returns a message to the queue for retry. This will be called if an error was thrown when 81 | * trying to process a message. 82 | * @param message The message to be returned to the queue for reprocessing 83 | */ 84 | returnMessage(message: TransportMessage): Promise 85 | 86 | /** 87 | * An optional function that is called before startup that will provide core dependencies 88 | * to the transport. This can be used to fetch loggers, registries etc that are used 89 | * in initialization steps 90 | * @param coreDependencies 91 | */ 92 | prepare(coreDependencies: CoreDependencies): void 93 | 94 | /** 95 | * An optional function that will be called on startup. This gives a chance for the transport 96 | * to establish any connections to the underlying infrastructure. 97 | */ 98 | connect?(options: TransportConnectionOptions): Promise 99 | 100 | /** 101 | * An optional function that will be called on shutdown. This gives a chance for the transport 102 | * to close any connections to the underlying infrastructure. 103 | */ 104 | disconnect?(): Promise 105 | 106 | /** 107 | * An optional method called on the transport when it should start consuming messages. 108 | */ 109 | start?(): Promise 110 | 111 | /** 112 | * An optional method called on the transport when it should no longer consume messages. 113 | */ 114 | stop?(): Promise 115 | 116 | /** 117 | * An optional function that will be called when the service bus is starting. This is an 118 | * opportunity for the transport to see what messages need to be handled so that subscriptions 119 | * to the topics can be created. 120 | * @param handlerRegistry The list of messages being handled by the bus that the transport needs to subscribe to. 121 | */ 122 | initialize?(options: TransportInitializationOptions): Promise 123 | 124 | /** 125 | * An optional function that will be called when the service bus is shutting down. This is an 126 | * opportunity for the transport to close out any open requests to fetch messages etc. 127 | */ 128 | dispose?(): Promise 129 | } 130 | -------------------------------------------------------------------------------- /packages/bus-core/src/util/assert-unreachable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This raises transpile time errors any time the tokenizer hits this function in the code 3 | */ 4 | export function assertUnreachable(unexpectedValue: never): never { 5 | throw new Error(`Unexepected code path - ${unexpectedValue}`) 6 | } 7 | -------------------------------------------------------------------------------- /packages/bus-core/src/util/class-constructor.ts: -------------------------------------------------------------------------------- 1 | export type ClassConstructor = new (...args: any[]) => TReturn 2 | -------------------------------------------------------------------------------- /packages/bus-core/src/util/core-dependencies.ts: -------------------------------------------------------------------------------- 1 | import { MessageSerializer, Serializer } from '../serialization' 2 | import { HandlerRegistry } from '../handler' 3 | import { LoggerFactory } from '../logger' 4 | import { ContainerAdapter } from '../container' 5 | import { RetryStrategy } from '../retry-strategy' 6 | 7 | /** 8 | * A core set of dependencies that are shared around the service. 9 | * This is used to provide dependencies to internal and external 10 | * implementations (eg: transports, persistences) without having 11 | * them to provide what they need. 12 | */ 13 | export interface CoreDependencies { 14 | handlerRegistry: HandlerRegistry 15 | serializer: Serializer 16 | messageSerializer: MessageSerializer 17 | loggerFactory: LoggerFactory 18 | container: ContainerAdapter | undefined 19 | retryStrategy: RetryStrategy 20 | interruptSignals: NodeJS.Signals[] 21 | } 22 | -------------------------------------------------------------------------------- /packages/bus-core/src/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sleep' 2 | export * from './class-constructor' 3 | export * from './assert-unreachable' 4 | export * from './typed-emitter' 5 | export * from './core-dependencies' 6 | export * from './middleware' 7 | -------------------------------------------------------------------------------- /packages/bus-core/src/util/middleware.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This middleware pattern has been grabbed from here: 3 | * https://evertpot.com/generic-middleware/ 4 | */ 5 | 6 | /** 7 | * 'next' function, passed to a middleware 8 | */ 9 | export type Next = () => void | Promise 10 | 11 | /** 12 | * A middleware 13 | */ 14 | export type Middleware = (context: T, next: Next) => Promise | void 15 | 16 | /** 17 | * A middleware container and invoker 18 | */ 19 | export class MiddlewareDispatcher { 20 | middlewares: Middleware[] = [] 21 | 22 | constructor(readonly finalMiddlewares: Middleware[] = []) {} 23 | 24 | /** 25 | * Add a middleware function. 26 | */ 27 | use(...middlewares: Middleware[]): void { 28 | this.middlewares.push(...middlewares) 29 | } 30 | 31 | /** 32 | * Add 'final' middlewares that will be added to the end of the 33 | * regular middlewares. This allows for finer control when exposing 34 | * the @see use functionality to consumers but wanting to ensure that your 35 | * final middleware is last to run 36 | */ 37 | useFinal(...middlewares: Middleware[]): void { 38 | this.finalMiddlewares.push(...middlewares) 39 | } 40 | 41 | /** 42 | * Execute the chain of middlewares, in the order they were added on a 43 | * given Context. 44 | */ 45 | dispatch(context: T): Promise { 46 | return invokeMiddlewares( 47 | context, 48 | this.middlewares.concat(this.finalMiddlewares) 49 | ) 50 | } 51 | } 52 | 53 | async function invokeMiddlewares( 54 | context: T, 55 | middlewares: Middleware[] 56 | ): Promise { 57 | if (!middlewares.length) { 58 | return 59 | } 60 | 61 | const middleware = middlewares[0] 62 | 63 | return middleware(context, async () => { 64 | await invokeMiddlewares(context, middlewares.slice(1)) 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /packages/bus-core/src/util/sleep.spec.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from './sleep' 2 | 3 | describe('sleep', () => { 4 | beforeEach(() => { 5 | jest.setTimeout(500) 6 | jest.useFakeTimers() 7 | }) 8 | 9 | afterEach(() => { 10 | jest.useRealTimers() 11 | }) 12 | 13 | it('should sleep the duration of timeoutMs', async () => { 14 | const promise = sleep(1000) 15 | jest.advanceTimersByTime(1000) 16 | await promise 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /packages/bus-core/src/util/sleep.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a promise that resolves when the timeout expires 3 | * @param timeoutMs How long to wait until the promise resolves 4 | */ 5 | export const sleep = async (timeoutMs: number): Promise => 6 | new Promise(resolve => setTimeout(resolve, timeoutMs)) 7 | -------------------------------------------------------------------------------- /packages/bus-core/src/util/typed-emitter.spec.ts: -------------------------------------------------------------------------------- 1 | import { TypedEmitter, Unsubscribe } from './typed-emitter' 2 | import { Mock, IMock, Times } from 'typemoq' 3 | 4 | describe('TypedEmitter', () => { 5 | let sut: TypedEmitter 6 | 7 | beforeEach(() => { 8 | sut = new TypedEmitter() 9 | }) 10 | 11 | describe('when subscribing via .once()', () => { 12 | const callback = Mock.ofType<(event: string) => void>() 13 | beforeEach(() => { 14 | sut.once(callback.object) 15 | sut.emit('one') 16 | sut.emit('one') 17 | }) 18 | 19 | it('should only receive the first event', () => { 20 | callback.verify(invocation => invocation('one'), Times.once()) 21 | }) 22 | }) 23 | 24 | describe('when piping', () => { 25 | const destinationTypedEmitter = new TypedEmitter() 26 | const callback = Mock.ofType<(event: string) => void>() 27 | 28 | beforeEach(() => { 29 | sut.pipe(destinationTypedEmitter) 30 | destinationTypedEmitter.once(callback.object) 31 | sut.emit('one') 32 | }) 33 | 34 | it('should pipe events through', () => { 35 | callback.verify(invocation => invocation('one'), Times.once()) 36 | }) 37 | }) 38 | 39 | describe('when unsubscribing via .off()', () => { 40 | const callback = Mock.ofType<(event: string) => void>() 41 | beforeEach(() => { 42 | sut.on(callback.object) 43 | sut.off(callback.object) 44 | sut.emit('one') 45 | }) 46 | 47 | it('should not receive any events', () => { 48 | callback.verify(invocation => invocation('one'), Times.never()) 49 | }) 50 | }) 51 | 52 | describe('when subscribing via .on()', () => { 53 | let callback: IMock<(event: string) => void> 54 | let unsubscribe: Unsubscribe 55 | 56 | beforeEach(() => { 57 | callback = Mock.ofType<(event: string) => void>() 58 | unsubscribe = sut.on(callback.object) 59 | 60 | sut.emit('one') 61 | sut.emit('one') 62 | }) 63 | 64 | it('should receive all emitted events', () => { 65 | callback.verify(invocation => invocation('one'), Times.exactly(2)) 66 | }) 67 | 68 | describe('when unsubscribing', () => { 69 | beforeEach(() => { 70 | unsubscribe() 71 | callback.reset() 72 | sut.emit('one') 73 | }) 74 | 75 | it('should not receive subsequent events', () => { 76 | callback.verify(invocation => invocation('one'), Times.never()) 77 | }) 78 | }) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /packages/bus-core/src/util/typed-emitter.ts: -------------------------------------------------------------------------------- 1 | export interface Listener { 2 | (event: T): any 3 | } 4 | 5 | export type Unsubscribe = () => void 6 | 7 | /** 8 | * An EventEmitter that emits a strongly typed event 9 | * @see https://basarat.gitbook.io/typescript/main-1/typed-event 10 | */ 11 | export class TypedEmitter { 12 | private listeners: Listener[] = [] 13 | private listenersOncer: Listener[] = [] 14 | 15 | on = (listener: Listener): Unsubscribe => { 16 | this.listeners.push(listener) 17 | return () => this.off(listener) 18 | } 19 | 20 | once = (listener: Listener): void => { 21 | this.listenersOncer.push(listener) 22 | } 23 | 24 | off = (listener: Listener) => { 25 | var callbackIndex = this.listeners.indexOf(listener) 26 | if (callbackIndex > -1) this.listeners.splice(callbackIndex, 1) 27 | } 28 | 29 | emit = (event: T) => { 30 | // Update any general listeners 31 | this.listeners.forEach(listener => listener(event)) 32 | 33 | // Clear the `once` queue 34 | if (this.listenersOncer.length > 0) { 35 | const toCall = this.listenersOncer 36 | this.listenersOncer = [] 37 | toCall.forEach(listener => listener(event)) 38 | } 39 | } 40 | 41 | pipe = (typedEmitter: TypedEmitter): Unsubscribe => { 42 | return this.on(e => typedEmitter.emit(e)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/assets/workflow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-ts/bus/d8a3ce6e2bba3c3469d715257334f0fdaf7d28bc/packages/bus-core/src/workflow/assets/workflow.gif -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/error/index.ts: -------------------------------------------------------------------------------- 1 | export * from './workflow-already-started-by-message' 2 | export * from './workflow-already-handles-message' 3 | export * from './workflow-already-initialized' 4 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/error/workflow-already-handles-message.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@node-ts/bus-messages' 2 | import { ClassConstructor } from '../../util' 3 | 4 | export class WorkflowAlreadyHandlesMessage extends Error { 5 | constructor( 6 | readonly workflowName: string, 7 | readonly messageType: ClassConstructor 8 | ) { 9 | super(`Attempted to re-register the same message handler for a workflow`) 10 | 11 | Object.setPrototypeOf(this, new.target.prototype) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/error/workflow-already-initialized.ts: -------------------------------------------------------------------------------- 1 | export class WorkflowAlreadyInitialized extends Error { 2 | constructor() { 3 | super( 4 | `Attempted to initialize workflow registry after it has already been initialized` 5 | ) 6 | 7 | Object.setPrototypeOf(this, new.target.prototype) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/error/workflow-already-started-by-message.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@node-ts/bus-messages' 2 | import { ClassConstructor } from '../../util' 3 | 4 | export class WorkflowAlreadyStartedByMessage extends Error { 5 | constructor( 6 | readonly workflowName: string, 7 | readonly messageType: ClassConstructor 8 | ) { 9 | super(`Attempted to re-register the same message as starting a workflow`) 10 | 11 | Object.setPrototypeOf(this, new.target.prototype) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error' 2 | export * from './workflow' 3 | export * from './workflow-state' 4 | export * from './message-workflow-mapping' 5 | export { Persistence, InMemoryPersistence } from './persistence' 6 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/message-workflow-mapping.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 2 | import { WorkflowState } from './workflow-state' 3 | 4 | /** 5 | * A mapping definition between an incoming message and 0..* workflow state instances in persistence. 6 | */ 7 | export interface MessageWorkflowMapping< 8 | MessageType extends Message = Message, 9 | WorkflowStateType extends WorkflowState = WorkflowState 10 | > { 11 | /** 12 | * A lookup function that resolves a value used to lookup workflow state 13 | */ 14 | lookup: ( 15 | message: MessageType, 16 | attributes: MessageAttributes 17 | ) => string | undefined 18 | 19 | /** 20 | * The field in workflow state where the lookup value is matched against 21 | */ 22 | mapsTo: keyof WorkflowStateType & string 23 | } 24 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/persistence/README.md: -------------------------------------------------------------------------------- 1 | # Persistence 2 | 3 | Persistence allows [@node-ts/bus-workflow](/packages/bus-workflow/) to maintain durable state in an underlying provider, and therefore run as a stateless service. This is important for a number of reasons: 4 | 5 | 1. The workflow service can be treated as an ephemeral service that can be scaled in and out at will 6 | 2. Execution of each workflow can be handled by a pool of distributed workflow services 7 | 3. Workflow state will persist regardless of how many services are running, if any 8 | 9 | By default, [@node-ts/bus-workflow](/packages/bus-workflow/) uses an in-memory persistence provider. This is fine to use when playing with the package and doing casual development, however it's not intended for production use as the state won't survive a process restart. 10 | 11 | The following persistence providers are currently available: 12 | 13 | - [@node-ts/bus-postgres](/packages/bus-postgres/) 14 | - [@node-ts/bus-mongodb](/packages/bus-mongodb/) 15 | 16 | ## Implementing a Persistence 17 | 18 | Implementing a new transport is relatively simple (and encouraged!). This can be done by implementing the [Persistence<>](https://github.com/node-ts/bus/blob/master/packages/bus-workflow/src/workflow/persistence/persistence.ts) interface from `@node-ts/bus-workflow`. If you'd like to contribute your persistence adapter back to `@node-ts/bus` then please fork this repo, add a new package at `/packages/bus-` and create a PR back to this repository. 19 | 20 | Persistence adapters should be created and then registered in a new inversify module so that they consumers can use the persistence just by loading the module. 21 | 22 | For an example of a persistence implementation, see the code for the [@node-ts/bus-postgres](https://github.com/node-ts/bus/blob/master/packages/bus-postgres/) persistence adapter. 23 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/persistence/error/index.ts: -------------------------------------------------------------------------------- 1 | export * from './workflow-state-not-initialized' 2 | export * from './persistence-not-configure' 3 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/persistence/error/persistence-not-configure.ts: -------------------------------------------------------------------------------- 1 | export class PersistenceNotConfigured extends Error { 2 | readonly help: string 3 | 4 | constructor() { 5 | super(`Persistence not configured`) 6 | this.help = 7 | 'Ensure that Bus.configure().withPersistence() has been called prior to initialization' 8 | 9 | Object.setPrototypeOf(this, new.target.prototype) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/persistence/error/workflow-state-not-initialized.ts: -------------------------------------------------------------------------------- 1 | export class WorkflowStateNotInitialized extends Error { 2 | readonly help: string 3 | 4 | constructor(readonly workflowStateName: string) { 5 | super(`Workflow state not initialized`) 6 | this.help = 7 | 'Ensure that the workflow has been registered with `Bus.configure().withWorkflow()' 8 | 9 | Object.setPrototypeOf(this, new.target.prototype) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/persistence/in-memory-persistence.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryPersistence } from './in-memory-persistence' 2 | import { TestWorkflowState, TestCommand } from '../test' 3 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 4 | import { MessageWorkflowMapping } from '../message-workflow-mapping' 5 | import { WorkflowState, WorkflowStatus } from '../workflow-state' 6 | 7 | describe('InMemoryPersistence', () => { 8 | let sut: InMemoryPersistence 9 | const propertyMapping: MessageWorkflowMapping< 10 | TestCommand, 11 | TestWorkflowState 12 | > = { 13 | lookup: message => message.property1, 14 | mapsTo: 'property1' 15 | } 16 | 17 | beforeEach(() => { 18 | sut = new InMemoryPersistence() 19 | }) 20 | 21 | describe('when getting workflow state', () => { 22 | const messageOptions: MessageAttributes = { 23 | attributes: {}, 24 | stickyAttributes: {} 25 | } 26 | 27 | beforeEach(async () => { 28 | const mapping: MessageWorkflowMapping = { 29 | lookup: message => message.property1, 30 | mapsTo: 'property1' 31 | } 32 | await sut.initializeWorkflow(TestWorkflowState, [ 33 | mapping as MessageWorkflowMapping 34 | ]) 35 | }) 36 | 37 | describe("when the mapper doesn't resolve", () => { 38 | let result: TestWorkflowState[] 39 | 40 | beforeEach(async () => { 41 | const message = new TestCommand(undefined) 42 | result = await sut.getWorkflowState( 43 | TestWorkflowState, 44 | propertyMapping, 45 | message, 46 | messageOptions 47 | ) 48 | }) 49 | 50 | it('should return an empty result', () => { 51 | expect(result).toHaveLength(0) 52 | }) 53 | }) 54 | 55 | describe("that doesn't exist", () => { 56 | let result: TestWorkflowState[] 57 | const unmatchedMapping: MessageWorkflowMapping< 58 | TestCommand, 59 | TestWorkflowState 60 | > = { 61 | lookup: message => message.$name, 62 | mapsTo: '$workflowId' 63 | } 64 | 65 | beforeEach(async () => { 66 | result = await sut.getWorkflowState( 67 | TestWorkflowState, 68 | unmatchedMapping, 69 | new TestCommand('abc'), 70 | messageOptions 71 | ) 72 | }) 73 | 74 | it('should return an empty result', () => { 75 | expect(result).toHaveLength(0) 76 | }) 77 | }) 78 | }) 79 | 80 | describe('when saving workflow state', () => { 81 | beforeEach(async () => { 82 | await sut.initializeWorkflow(TestWorkflowState, [ 83 | propertyMapping as MessageWorkflowMapping 84 | ]) 85 | }) 86 | 87 | describe('for a new workflow', () => { 88 | beforeEach(async () => { 89 | await sut.saveWorkflowState(new TestWorkflowState()) 90 | }) 91 | 92 | it('should add the item to memory', () => { 93 | expect(sut.length(TestWorkflowState)).toEqual(1) 94 | }) 95 | }) 96 | 97 | describe('for an existing workflow', () => { 98 | const testCommand = new TestCommand('a') 99 | const workflowId = 'abc' 100 | const messageOptions: MessageAttributes = { 101 | attributes: {}, 102 | stickyAttributes: {} 103 | } 104 | 105 | beforeEach(async () => { 106 | const workflowState = new TestWorkflowState() 107 | workflowState.$workflowId = workflowId 108 | workflowState.$status = WorkflowStatus.Running 109 | await sut.saveWorkflowState(workflowState) 110 | 111 | workflowState.property1 = testCommand.property1! 112 | await sut.saveWorkflowState(workflowState) 113 | }) 114 | 115 | it('should save in place', () => { 116 | expect(sut.length(TestWorkflowState)).toEqual(1) 117 | }) 118 | 119 | it('should save the changes', async () => { 120 | const workflowState = await sut.getWorkflowState( 121 | TestWorkflowState, 122 | propertyMapping, 123 | testCommand, 124 | messageOptions 125 | ) 126 | 127 | expect(workflowState).toHaveLength(1) 128 | expect(workflowState[0].$workflowId).toEqual(workflowId) 129 | expect(workflowState[0].property1).toEqual(testCommand.property1) 130 | }) 131 | }) 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/persistence/in-memory-persistence.ts: -------------------------------------------------------------------------------- 1 | import { Persistence } from './persistence' 2 | import { WorkflowState, WorkflowStatus } from '../workflow-state' 3 | import { MessageWorkflowMapping } from '../message-workflow-mapping' 4 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 5 | import { ClassConstructor, CoreDependencies } from '../../util' 6 | import { WorkflowStateNotInitialized } from './error' 7 | import { Logger } from '../../logger' 8 | 9 | interface WorkflowStorage { 10 | [workflowStateName: string]: WorkflowState[] 11 | } 12 | 13 | /** 14 | * A non-durable in-memory persistence for storage and retrieval of workflow state. Before using this, 15 | * be warned that all workflow state will not survive a process restart or application shut down. As 16 | * such this should only be used for testing, prototyping or handling unimportant workflows. 17 | */ 18 | export class InMemoryPersistence implements Persistence { 19 | private workflowState: WorkflowStorage = {} 20 | private logger: Logger 21 | 22 | prepare(coreDependencies: CoreDependencies): void { 23 | this.logger = coreDependencies.loggerFactory( 24 | '@node-ts/bus-core:in-memory-persistence' 25 | ) 26 | } 27 | 28 | async initializeWorkflow( 29 | workflowStateConstructor: ClassConstructor, 30 | _: MessageWorkflowMapping[] 31 | ): Promise { 32 | const name = new workflowStateConstructor().$name 33 | this.workflowState[name] = [] 34 | } 35 | 36 | async getWorkflowState< 37 | WorkflowStateType extends WorkflowState, 38 | MessageType extends Message 39 | >( 40 | workflowStateConstructor: ClassConstructor, 41 | messageMap: MessageWorkflowMapping, 42 | message: MessageType, 43 | attributes: MessageAttributes, 44 | includeCompleted?: boolean | undefined 45 | ): Promise { 46 | const filterValue = messageMap.lookup(message, attributes) 47 | if (!filterValue) { 48 | return [] 49 | } 50 | 51 | const workflowStateName = new workflowStateConstructor().$name 52 | const workflowState = this.workflowState[ 53 | workflowStateName 54 | ] as WorkflowStateType[] 55 | if (!workflowState) { 56 | throw new WorkflowStateNotInitialized('Workflow state not initialized') 57 | } 58 | return workflowState.filter( 59 | data => 60 | (includeCompleted || data.$status === WorkflowStatus.Running) && 61 | (data[messageMap.mapsTo] as {} as string) === filterValue 62 | ) 63 | } 64 | 65 | async saveWorkflowState( 66 | workflowState: WorkflowStateType 67 | ): Promise { 68 | const workflowStateName = workflowState.$name 69 | const existingWorkflowState = this.workflowState[ 70 | workflowStateName 71 | ] as WorkflowStateType[] 72 | const existingItem = existingWorkflowState.find( 73 | d => d.$workflowId === workflowState.$workflowId 74 | ) 75 | if (existingItem) { 76 | try { 77 | Object.assign(existingItem, workflowState) 78 | } catch (err) { 79 | this.logger.error('Unable to update data', { err }) 80 | throw err 81 | } 82 | } else { 83 | existingWorkflowState.push(workflowState) 84 | } 85 | } 86 | 87 | length(workflowStateConstructor: ClassConstructor): number { 88 | return this.workflowState[ 89 | workflowStateConstructor.prototype.constructor.NAME 90 | ].length 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/persistence/index.ts: -------------------------------------------------------------------------------- 1 | export { Persistence } from './persistence' 2 | export * from './in-memory-persistence' 3 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/persistence/persistence.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 2 | import { WorkflowState } from '../workflow-state' 3 | import { MessageWorkflowMapping } from '../message-workflow-mapping' 4 | import { ClassConstructor, CoreDependencies } from '../../util' 5 | 6 | /** 7 | * Infrastructure that provides the ability to persist workflow state for long running processes 8 | */ 9 | export interface Persistence { 10 | /** 11 | * An optional function that is called before startup that will provide core dependencies 12 | * to the persistence. This can be used to fetch loggers etc that are used 13 | * in initialization steps 14 | * @param coreDependencies 15 | */ 16 | prepare(coreDependencies: CoreDependencies): void 17 | 18 | /** 19 | * If provided, initializes the persistence implementation. This is where database connections are 20 | * started. 21 | */ 22 | initialize?(): Promise 23 | 24 | /** 25 | * If provided, will dispose any resources related to the persistence. This is where things like 26 | * closing database connections should occur. 27 | */ 28 | dispose?(): Promise 29 | 30 | /** 31 | * Allows the persistence implementation to set up its internal structure to support the workflow state 32 | * that it will be persisting. Typically for a database this could mean setting up the internal table 33 | * schema to support persisting of each of the workflow state models. 34 | */ 35 | initializeWorkflow( 36 | workflowStateConstructor: ClassConstructor, 37 | messageWorkflowMappings: MessageWorkflowMapping[] 38 | ): Promise 39 | 40 | /** 41 | * Retrieves all workflow state models that match the given `messageMap` criteria 42 | * @param workflowStateConstructor The workflow model type to retrieve 43 | * @param messageMap How the message is mapped to workflow state models 44 | * @param message The message to map to workflow state 45 | * @param includeCompleted If completed workflow state items should also be returned. False by default 46 | */ 47 | getWorkflowState< 48 | WorkflowStateType extends WorkflowState, 49 | MessageType extends Message 50 | >( 51 | workflowStateConstructor: ClassConstructor, 52 | messageMap: MessageWorkflowMapping, 53 | message: MessageType, 54 | messageOptions: MessageAttributes, 55 | includeCompleted?: boolean 56 | ): Promise 57 | 58 | /** 59 | * Saves a new workflow state model or updates an existing one. Persistence implementations should take care 60 | * to observe the change in `$version` of the workflow state model when persisting to ensure race conditions 61 | * don't occur. 62 | */ 63 | saveWorkflowState( 64 | workflowState: WorkflowStateType 65 | ): Promise 66 | } 67 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/registry/index.ts: -------------------------------------------------------------------------------- 1 | export * from './workflow-registry' 2 | export * from './workflow-handler-fn' 3 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/registry/workflow-handler-fn.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 2 | import { WorkflowState } from '../workflow-state' 3 | 4 | export type WorkflowHandlerFn< 5 | TMessage extends Message, 6 | TWorkflowState extends WorkflowState 7 | > = ( 8 | message: TMessage, 9 | data: Readonly, 10 | messageOptions: MessageAttributes 11 | ) => 12 | | Promise> 13 | | Promise 14 | | Partial 15 | | void 16 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/final-task.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '@node-ts/bus-messages' 2 | 3 | export class FinalTask extends Event { 4 | static NAME = '@node-ts/bus-core/final-task' 5 | $name = FinalTask.NAME 6 | $version = 0 7 | } 8 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './test-workflow-state' 2 | export * from './test-command' 3 | export * from './test-workflow' 4 | export * from './task-ran' 5 | export * from './run-task' 6 | export * from './final-task' 7 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/run-task-handler.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from '../../handler' 2 | import { BusInstance } from '../../service-bus' 3 | import { RunTask } from './run-task' 4 | import { TaskRan } from './task-ran' 5 | 6 | export class RunTaskHandler implements Handler { 7 | messageType = RunTask 8 | 9 | constructor(private readonly bus: BusInstance) {} 10 | 11 | async handle(command: RunTask): Promise { 12 | const event = new TaskRan(command.value) 13 | await this.bus.publish(event) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/run-task.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@node-ts/bus-messages' 2 | 3 | export class RunTask extends Command { 4 | static NAME = '@node-ts/bus-core/run-task' 5 | $name = RunTask.NAME 6 | $version = 0 7 | 8 | constructor(readonly value: string) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/task-ran.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '@node-ts/bus-messages' 2 | 3 | export class TaskRan extends Event { 4 | static NAME = '@node-ts/bus-core/task-ran' 5 | $name = TaskRan.NAME 6 | $version = 0 7 | 8 | constructor(readonly value: string) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/test-command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@node-ts/bus-messages' 2 | 3 | export class TestCommand extends Command { 4 | static NAME = '@node-ts/bus-core/test-command' 5 | $name = TestCommand.NAME 6 | $version = 0 7 | 8 | constructor(readonly property1: string | undefined) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/test-discarded-workflow.ts: -------------------------------------------------------------------------------- 1 | import { Workflow, WorkflowMapper } from '../workflow' 2 | import { WorkflowState } from '../workflow-state' 3 | import { TestCommand } from './test-command' 4 | 5 | export class TestDiscardedWorkflowState extends WorkflowState { 6 | static NAME = 'TestDiscardedWorkflowState' 7 | $name = TestDiscardedWorkflowState.NAME 8 | } 9 | 10 | export class TestDiscardedWorkflow extends Workflow { 11 | configureWorkflow( 12 | mapper: WorkflowMapper 13 | ): void { 14 | mapper.withState(TestDiscardedWorkflowState).startedBy(TestCommand, 'step1') 15 | } 16 | 17 | async step1() { 18 | return this.discardWorkflow() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/test-void-startedby-workflow.ts: -------------------------------------------------------------------------------- 1 | import { Workflow, WorkflowMapper } from '../workflow' 2 | import { WorkflowState } from '../workflow-state' 3 | import { TestCommand } from './test-command' 4 | 5 | export class TestVoidStartedByWorkflowState extends WorkflowState { 6 | static NAME = 'TestVoidStartedByWorkflowState' 7 | $name = TestVoidStartedByWorkflowState.NAME 8 | } 9 | 10 | export class TestVoidStartedByWorkflow extends Workflow { 11 | configureWorkflow( 12 | mapper: WorkflowMapper 13 | ): void { 14 | mapper 15 | .withState(TestVoidStartedByWorkflowState) 16 | .startedBy(TestCommand, 'step1') 17 | } 18 | 19 | async step1() { 20 | // ... 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/test-workflow-startedby-completes.ts: -------------------------------------------------------------------------------- 1 | import { Workflow, WorkflowMapper } from '../workflow' 2 | import { WorkflowState } from '../workflow-state' 3 | import { TestCommand } from './test-command' 4 | 5 | export class TestWorkflowStartedByCompletesData extends WorkflowState { 6 | $name = 'node-ts/bus/workflow/test-workflow-started-by-completes' 7 | property1: string 8 | } 9 | 10 | /** 11 | * A test case where the workflow is completed in the StartedBy handler 12 | */ 13 | export class TestWorkflowStartedByCompletes extends Workflow { 14 | configureWorkflow( 15 | mapper: WorkflowMapper< 16 | TestWorkflowStartedByCompletesData, 17 | TestWorkflowStartedByCompletes 18 | > 19 | ): void { 20 | mapper 21 | .withState(TestWorkflowStartedByCompletesData) 22 | .startedBy(TestCommand, 'complete') 23 | } 24 | 25 | /** 26 | * Completes the workflow immediately and save a final state 27 | */ 28 | complete(message: TestCommand) { 29 | return this.completeWorkflow({ 30 | property1: message.property1 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/test-workflow-startedby-discard.ts: -------------------------------------------------------------------------------- 1 | import { Workflow, WorkflowMapper } from '../workflow' 2 | import { WorkflowState } from '../workflow-state' 3 | import { TestCommand } from './test-command' 4 | 5 | export class TestWorkflowStartedByDiscardData extends WorkflowState { 6 | $name = 'node-ts/bus/workflow/test-workflow-started-by-discard' 7 | property1: string 8 | } 9 | 10 | /** 11 | * A test case where the workflow is completes during startup without persisting state 12 | */ 13 | export class TestWorkflowStartedByDiscard extends Workflow { 14 | configureWorkflow( 15 | mapper: WorkflowMapper< 16 | TestWorkflowStartedByDiscardData, 17 | TestWorkflowStartedByDiscard 18 | > 19 | ): void { 20 | mapper 21 | .withState(TestWorkflowStartedByDiscardData) 22 | .startedBy(TestCommand, 'discard') 23 | } 24 | 25 | discard() { 26 | return this.discardWorkflow() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/test-workflow-state.ts: -------------------------------------------------------------------------------- 1 | import { WorkflowState } from '../workflow-state' 2 | 3 | export class TestWorkflowState extends WorkflowState { 4 | static NAME = 'TestWorkflowState' 5 | $name = TestWorkflowState.NAME 6 | 7 | property1: string 8 | eventValue: string 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/test/test-workflow.ts: -------------------------------------------------------------------------------- 1 | import { Workflow, WorkflowMapper } from '../' 2 | import { TestWorkflowState } from './test-workflow-state' 3 | import { TestCommand } from './test-command' 4 | import { RunTask } from './run-task' 5 | import { TaskRan } from './task-ran' 6 | import { FinalTask } from './final-task' 7 | import { BusInstance } from '../../service-bus' 8 | 9 | export class TestWorkflow extends Workflow { 10 | constructor( 11 | private bus: BusInstance, 12 | private completionCallback?: () => void 13 | ) { 14 | super() 15 | } 16 | 17 | configureWorkflow( 18 | mapper: WorkflowMapper 19 | ): void { 20 | mapper 21 | .withState(TestWorkflowState) 22 | .startedBy(TestCommand, 'step1') 23 | .when(TaskRan, 'step2', { 24 | lookup: message => message.value, 25 | mapsTo: 'property1' 26 | }) 27 | .when(FinalTask, 'step3') // Maps on workflow id 28 | } 29 | 30 | async step1({ property1 }: TestCommand) { 31 | await this.bus.send(new RunTask(property1!)) 32 | return { property1 } 33 | } 34 | 35 | async step2({ value }: TaskRan, state: TestWorkflowState) { 36 | await this.bus.send(new FinalTask()) 37 | return { ...state, property1: value } 38 | } 39 | 40 | async step3() { 41 | if (this.completionCallback) { 42 | // Optional chaining (?.) doesn't work with typemoq 43 | this.completionCallback() 44 | } 45 | return this.completeWorkflow() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/workflow-concurrency.integration.ts: -------------------------------------------------------------------------------- 1 | import { It, Mock, Times } from 'typemoq' 2 | import { Handler, Workflow, WorkflowMapper, WorkflowState } from '../' 3 | import { Bus, BusInstance } from '../service-bus' 4 | import { ClassConstructor, sleep } from '../util' 5 | import { InMemoryPersistence } from './persistence' 6 | import { 7 | FinalTask, 8 | RunTask, 9 | TaskRan, 10 | TestCommand, 11 | TestWorkflowState 12 | } from './test' 13 | import { MessageAttributes } from '@node-ts/bus-messages' 14 | import * as uuid from 'uuid' 15 | 16 | jest.setTimeout(10_000) 17 | 18 | describe('Workflow Concurrency', () => { 19 | const completeCallback = 20 | Mock.ofType<(workflowId: string, correlationId: string) => void>() 21 | 22 | class TestWorkflow extends Workflow { 23 | constructor(private bus: BusInstance) { 24 | super() 25 | } 26 | 27 | configureWorkflow( 28 | mapper: WorkflowMapper 29 | ): void { 30 | mapper 31 | .withState(TestWorkflowState) 32 | .startedBy(TestCommand, 'step1') 33 | .when(TaskRan, 'step2', { 34 | lookup: message => message.value, 35 | mapsTo: 'property1' 36 | }) 37 | .when(FinalTask, 'step3') // Maps on workflow id 38 | } 39 | 40 | async step1({ property1 }: TestCommand, _: any) { 41 | await this.bus.send(new RunTask(property1!)) 42 | return { property1 } 43 | } 44 | 45 | async step2({ value }: TaskRan, state: TestWorkflowState) { 46 | await this.bus.send(new FinalTask()) 47 | return { ...state, property1: value } 48 | } 49 | 50 | async step3( 51 | _: FinalTask, 52 | __: WorkflowState, 53 | { 54 | correlationId, 55 | stickyAttributes: { workflowId } 56 | }: MessageAttributes<{}, { workflowId: string }> 57 | ) { 58 | completeCallback.object(workflowId, correlationId!) 59 | return this.completeWorkflow() 60 | } 61 | } 62 | 63 | class RunTaskHandler implements Handler { 64 | messageType = RunTask 65 | 66 | constructor(private bus: BusInstance) {} 67 | 68 | async handle(message: RunTask) { 69 | await this.bus.publish(new TaskRan(message.value)) 70 | } 71 | } 72 | 73 | const CONSUME_TIMEOUT = 5_000 74 | let bus: BusInstance 75 | const inMemoryPersistence = new InMemoryPersistence() 76 | const workflowsToInvoke = 100 77 | const correlationIds = new Array(workflowsToInvoke) 78 | .fill(undefined) 79 | .map(() => uuid.v4()) 80 | 81 | beforeAll(async () => { 82 | bus = Bus.configure() 83 | .withPersistence(inMemoryPersistence) 84 | .withContainer({ 85 | get(ctor: ClassConstructor) { 86 | return new ctor(bus) 87 | } 88 | }) 89 | .withWorkflow(TestWorkflow) 90 | .withHandler(RunTaskHandler) 91 | .withConcurrency(10) 92 | .build() 93 | 94 | await bus.initialize() 95 | await bus.start() 96 | 97 | // Introduce sufficient parallelism to test for message handling context leakage 98 | const sendMessages = correlationIds.map( 99 | async (correlationId: string) => 100 | await bus.send(new TestCommand(uuid.v4()), { correlationId }) 101 | ) 102 | await Promise.all(sendMessages) 103 | await sleep(CONSUME_TIMEOUT) 104 | }) 105 | 106 | afterAll(async () => { 107 | await bus.dispose() 108 | }) 109 | 110 | describe('when a message that starts a workflow is received', () => { 111 | it('should complete the workflow', () => { 112 | correlationIds.forEach(correlationId => 113 | completeCallback.verify( 114 | c => 115 | c( 116 | It.is(workflowId => !!workflowId), 117 | correlationId 118 | ), 119 | Times.once() 120 | ) 121 | ) 122 | }) 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/workflow-startedby.integration.ts: -------------------------------------------------------------------------------- 1 | import { Bus, BusInstance } from '../service-bus' 2 | import { sleep } from '../util' 3 | import { InMemoryPersistence } from './persistence' 4 | import { TestCommand } from './test' 5 | import { 6 | TestDiscardedWorkflow, 7 | TestDiscardedWorkflowState 8 | } from './test/test-discarded-workflow' 9 | import { It, Mock, Times } from 'typemoq' 10 | import { 11 | TestVoidStartedByWorkflow, 12 | TestVoidStartedByWorkflowState 13 | } from './test/test-void-startedby-workflow' 14 | 15 | describe('Workflow Started By', () => { 16 | const inMemoryPersistence = Mock.ofType() 17 | let bus: BusInstance 18 | 19 | beforeAll(async () => { 20 | bus = Bus.configure() 21 | .withPersistence(inMemoryPersistence.object) 22 | .withWorkflow(TestDiscardedWorkflow) 23 | .withWorkflow(TestVoidStartedByWorkflow) 24 | .build() 25 | 26 | await bus.initialize() 27 | await bus.start() 28 | }) 29 | 30 | afterAll(async () => { 31 | bus.dispose() 32 | }) 33 | 34 | describe('when a workflow that discards during startedBy is executed', () => { 35 | beforeEach(async () => { 36 | inMemoryPersistence.reset() 37 | await bus.send(new TestCommand('abc')) 38 | await sleep(2_000) 39 | }) 40 | 41 | it('should not persist any workflow state', async () => { 42 | inMemoryPersistence.verify( 43 | p => 44 | p.saveWorkflowState( 45 | It.isObjectWith({ 46 | $name: TestDiscardedWorkflowState.NAME 47 | }) 48 | ), 49 | Times.never() 50 | ) 51 | }) 52 | }) 53 | 54 | describe('when a workflow that returns void during startedBy is executed', () => { 55 | beforeEach(async () => { 56 | inMemoryPersistence.reset() 57 | await bus.send(new TestCommand('abc')) 58 | await sleep(2_000) 59 | }) 60 | 61 | it('should persist workflow state', async () => { 62 | inMemoryPersistence.verify( 63 | p => 64 | p.saveWorkflowState( 65 | It.isObjectWith({ 66 | $name: TestVoidStartedByWorkflowState.NAME 67 | }) 68 | ), 69 | Times.once() 70 | ) 71 | }) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/workflow-state.ts: -------------------------------------------------------------------------------- 1 | export enum WorkflowStatus { 2 | /** 3 | * The workflow is still active and has not yet finished 4 | */ 5 | Running = 'running', 6 | 7 | /** 8 | * The workflow has completed and will not receive further messages 9 | */ 10 | Complete = 'complete', 11 | 12 | /** 13 | * A pseudo status to indicate that changes to the current state should not be persisted 14 | */ 15 | Discard = 'discard' 16 | } 17 | 18 | /** 19 | * A base workflow state definition to model and persist the state of a workflow throughout 20 | * its lifespan. 21 | */ 22 | export abstract class WorkflowState { 23 | /** 24 | * Unique identifier of the workflow state 25 | */ 26 | $workflowId: string 27 | 28 | /** 29 | * Used to manage concurrency when storing the workflow state. This value is incremented 30 | * each time the data is persisted which keeps locking of data low. 31 | */ 32 | $version = 0 33 | 34 | /** 35 | * Marks if the workflow is currently running or has ended 36 | */ 37 | $status: WorkflowStatus 38 | 39 | /** 40 | * A unique name for the workflow state. This should be formatted in a namespace style, 41 | * ie: 'company/application/workflow-name' 42 | * eg: $name = 'node-ts/bus-core/my-workflow' 43 | */ 44 | abstract readonly $name: string 45 | } 46 | 47 | export type WorkflowStateConstructor< 48 | TWorkflowState extends WorkflowState = WorkflowState 49 | > = new () => TWorkflowState 50 | -------------------------------------------------------------------------------- /packages/bus-core/src/workflow/workflow.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 2 | import { ClassConstructor } from '../util' 3 | import { 4 | WorkflowAlreadyHandlesMessage, 5 | WorkflowAlreadyStartedByMessage 6 | } from './error' 7 | import { MessageWorkflowMapping } from './message-workflow-mapping' 8 | import { WorkflowState, WorkflowStatus } from './workflow-state' 9 | 10 | export type WorkflowHandler< 11 | TMessage extends Message, 12 | TMessageAttributes extends MessageAttributes, 13 | WorkflowStateType extends WorkflowState 14 | > = ( 15 | message?: TMessage, 16 | attributes?: TMessageAttributes, 17 | workflowState?: WorkflowStateType 18 | ) => 19 | | void 20 | | Partial 21 | | Promise> 22 | 23 | export type WhenHandler< 24 | WorkflowStateType extends WorkflowState, 25 | WorkflowType extends Workflow 26 | > = ( 27 | workflow: WorkflowType 28 | ) => WorkflowHandler 29 | 30 | type KeyOfType = { [P in keyof T]: T[P] extends U ? P : never }[keyof T] 31 | 32 | export type OnWhenHandler< 33 | WorkflowStateType extends WorkflowState = WorkflowState, 34 | WorkflowType extends Workflow = Workflow 35 | > = { 36 | workflowCtor: ClassConstructor> 37 | workflowHandler: KeyOfType 38 | customLookup: MessageWorkflowMapping | undefined 39 | } 40 | 41 | /** 42 | * A workflow configuration that describes how to map incoming messages to handlers within the workflow. 43 | */ 44 | export class WorkflowMapper< 45 | WorkflowStateType extends WorkflowState, 46 | WorkflowType extends Workflow 47 | > { 48 | readonly onStartedBy = new Map< 49 | ClassConstructor, 50 | { 51 | workflowCtor: ClassConstructor> 52 | workflowHandler: KeyOfType 53 | } 54 | >() 55 | readonly onWhen = new Map< 56 | ClassConstructor, 57 | OnWhenHandler 58 | >() 59 | private workflowStateType: ClassConstructor | undefined 60 | 61 | constructor( 62 | private readonly workflow: ClassConstructor> 63 | ) {} 64 | 65 | get workflowStateCtor(): ClassConstructor | undefined { 66 | return this.workflowStateType 67 | } 68 | 69 | withState(workflowStateType: ClassConstructor): this { 70 | this.workflowStateType = workflowStateType 71 | return this 72 | } 73 | 74 | startedBy( 75 | message: ClassConstructor, 76 | workflowHandler: KeyOfType 77 | // workflowHandler: (workflow: WorkflowType) => WorkflowHandler 78 | ): this { 79 | if (this.onStartedBy.has(message)) { 80 | throw new WorkflowAlreadyStartedByMessage(this.workflow.name, message) 81 | } 82 | this.onStartedBy.set(message, { 83 | workflowHandler, 84 | workflowCtor: this.workflow 85 | }) 86 | return this 87 | } 88 | 89 | when( 90 | message: ClassConstructor, 91 | workflowHandler: KeyOfType, 92 | customLookup?: MessageWorkflowMapping 93 | ): this { 94 | if (this.onWhen.has(message)) { 95 | throw new WorkflowAlreadyHandlesMessage(this.workflow.name, message) 96 | } 97 | this.onWhen.set(message, { 98 | workflowHandler, 99 | workflowCtor: this.workflow, 100 | customLookup: customLookup as MessageWorkflowMapping< 101 | Message, 102 | WorkflowState 103 | > 104 | }) 105 | return this 106 | } 107 | } 108 | 109 | export abstract class Workflow { 110 | abstract configureWorkflow( 111 | mapper: WorkflowMapper 112 | ): void 113 | 114 | /** 115 | * Ends the workflow and optionally sets any final state. After this is returned, 116 | * the workflow instance will no longer be activated for subsequent messages. 117 | */ 118 | protected completeWorkflow(workflowState?: Partial) { 119 | return { 120 | ...workflowState, 121 | $status: WorkflowStatus.Complete 122 | } 123 | } 124 | 125 | /** 126 | * Prevents a new workflow from starting, and prevents the persistence of 127 | * the workflow state. This should only be used in `startedBy` workflow handlers. 128 | */ 129 | protected discardWorkflow() { 130 | return { $status: WorkflowStatus.Discard } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /packages/bus-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.spec.ts", "src/**/*.integration.ts", "src/**/test"], 5 | "compilerOptions": { 6 | "baseUrl": ".", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-messages/README.md: -------------------------------------------------------------------------------- 1 | # @node-ts/bus-messages 2 | 3 | This package should be consumed wherever your application defines message contracts. Messages are small pieces of data that get passed around between services. They can define an instruction to perform an action, or report that something has just occurred. 4 | 5 | 🔥 View our docs at [https://bus.node-ts.com](https://bus.node-ts.com) 🔥 6 | 7 | 🤔 Have a question? [Join the Discussion](https://github.com/node-ts/bus/discussions) 🤔 8 | 9 | ## Installation 10 | 11 | Install the **@node-ts/bus-messages** package via npm: 12 | 13 | ```sh 14 | npm install @node-ts/bus-messages 15 | ``` 16 | -------------------------------------------------------------------------------- /packages/bus-messages/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@node-ts/bus-messages", 3 | "description": "A core set of message definitions for distributed applications.", 4 | "version": "1.1.0", 5 | "license": "MIT", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "repository": "github:node-ts/bus.git", 9 | "scripts": { 10 | "clean": "rm -rf dist", 11 | "build": "tsc", 12 | "build:watch": "pnpm run build --incremental --watch --preserveWatchOutput" 13 | }, 14 | "publishConfig": { 15 | "access": "public" 16 | }, 17 | "dependencies": { 18 | "tslib": "^2.6.2" 19 | }, 20 | "devDependencies": { 21 | "@node-ts/code-standards": "^0.0.10", 22 | "typescript": "^5.3.3" 23 | }, 24 | "keywords": [ 25 | "esb", 26 | "bus", 27 | "messaging", 28 | "microservices", 29 | "distributed systems", 30 | "CQRS", 31 | "ES", 32 | "NServiceBus", 33 | "Mule ESB", 34 | "typescript" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /packages/bus-messages/src/command.ts: -------------------------------------------------------------------------------- 1 | import { Message } from './message' 2 | 3 | export abstract class Command extends Message { 4 | abstract readonly $name: string 5 | abstract readonly $version: number 6 | } 7 | -------------------------------------------------------------------------------- /packages/bus-messages/src/event.ts: -------------------------------------------------------------------------------- 1 | import { Message } from './message' 2 | 3 | export abstract class Event extends Message { 4 | abstract readonly $name: string 5 | abstract readonly $version: number 6 | } 7 | -------------------------------------------------------------------------------- /packages/bus-messages/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './message' 2 | export * from './command' 3 | export * from './event' 4 | export * from './message-attributes' 5 | -------------------------------------------------------------------------------- /packages/bus-messages/src/message-attributes.ts: -------------------------------------------------------------------------------- 1 | type Uuid = string 2 | 3 | export interface MessageAttributeMap { 4 | [key: string]: string | number | boolean | undefined 5 | } 6 | 7 | export type Attributes = 8 | AttributesType 9 | export type StickyAttributes = 10 | StickyAttributesType 11 | 12 | /** 13 | * Options that control the behaviour around how the message is sent and 14 | * additional information that travels with it. 15 | */ 16 | export interface MessageAttributes< 17 | AttributesType extends MessageAttributeMap = MessageAttributeMap, 18 | StickyAttributesType extends MessageAttributeMap = MessageAttributeMap 19 | > { 20 | /** 21 | * An identifier that can be used to relate or group messages together. 22 | * This value is sticky, in that any messages that are sent as a result 23 | * of receiving one message will be sent out with this same correlationId. 24 | */ 25 | correlationId?: Uuid 26 | 27 | /** 28 | * Additional metadata that will be sent alongside the message payload. 29 | * This is useful for sending information like: 30 | * - the id of a user where the message originated from 31 | * - the originating system hostname or IP for auditing information 32 | * - when the message was first sent 33 | * 34 | * These attributes will be attached to the outgoing message, but will not 35 | * propagate beyond the first receipt 36 | */ 37 | attributes: AttributesType 38 | 39 | /** 40 | * Additional metadata that will be sent alongside the message payload. 41 | * This is useful for sending information like: 42 | * - The id of the user who originally sent the message that triggered this message 43 | * 44 | * These values are sticky, in that they will propagate for any message that 45 | * is sent as a result of receiving the message with sticky attributes. 46 | */ 47 | stickyAttributes: StickyAttributesType 48 | } 49 | -------------------------------------------------------------------------------- /packages/bus-messages/src/message.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A base message type that is transmitted over a transport. Declaring messages with this base class enables 3 | * efficient routing and dispatch to handlers. 4 | */ 5 | export abstract class Message { 6 | abstract readonly $name: string 7 | abstract readonly $version: number 8 | } 9 | -------------------------------------------------------------------------------- /packages/bus-messages/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.spec.ts", "src/**/*.integration.ts"], 5 | "compilerOptions": { 6 | "baseUrl": ".", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-mongodb/README.md: -------------------------------------------------------------------------------- 1 | # @node-ts/bus-mongodb 2 | 3 | A Mongodb based persistence for workflow storage in [@node-ts/bus](https://bus.node-ts.com) 4 | 5 | 🔥 View our docs at [https://bus.node-ts.com](https://bus.node-ts.com) 🔥 6 | 7 | 🤔 Have a question? [Join the Discussion](https://github.com/node-ts/bus/discussions) 🤔 8 | 9 | ## Installation 10 | 11 | Install all packages and their dependencies 12 | 13 | ```bash 14 | npm install @node-ts/bus-mongodb 15 | ``` 16 | 17 | Configure a new Mongodb persistence and register it with `Bus`: 18 | 19 | ```typescript 20 | import { Bus } from '@node-ts/bus-core' 21 | import { MongodbPersistence, MongodbConfiguration } from '@node-ts/bus-mongodb' 22 | 23 | const configuration: MongodbConfiguration = { 24 | connection: 'mongodb://localhost:27017', 25 | databaseName: 'workflows' 26 | } 27 | const mongodbPersistence = new MongodbPersistence(configuration) 28 | 29 | // Configure bus to use mongodb as a persistence 30 | const run = async () => { 31 | const bus = Bus 32 | .configure() 33 | .withPersistence(mongodbPersistence) 34 | .build() 35 | await bus.initialize() 36 | await bus.start() 37 | } 38 | run.then(() => void) 39 | ``` 40 | 41 | ## Configuration Options 42 | 43 | The Mongodb persistence has the following configuration: 44 | 45 | - **connection** _(required)_ The mongodb connection string to use. This can be a single server, a replica set, or a mongodb+srv connection. 46 | - **schemaName** _(required)_ The database name to create workflow collections inside. 47 | 48 | ## Development 49 | 50 | Local development can be done with the aid of docker to run the required infrastructure. To do so, run: 51 | 52 | ```bash 53 | docker run --name bus-mongodb -p 27017:27017 -d mongo 54 | ``` 55 | -------------------------------------------------------------------------------- /packages/bus-mongodb/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@node-ts/bus-mongodb", 3 | "description": "A Mongodb persistence adapter for workflow storage in @node-ts/bus-workflow.", 4 | "version": "1.1.0", 5 | "license": "MIT", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "repository": "github:node-ts/bus.git", 9 | "scripts": { 10 | "clean": "rm -rf dist", 11 | "build": "tsc", 12 | "build:watch": "pnpm run build --incremental --watch --preserveWatchOutput" 13 | }, 14 | "publishConfig": { 15 | "access": "public" 16 | }, 17 | "dependencies": { 18 | "@node-ts/bus-messages": "workspace:^", 19 | "mongodb": "^5.3.0", 20 | "tslib": "^2.6.2", 21 | "uuid": "^3.3.2" 22 | }, 23 | "devDependencies": { 24 | "@node-ts/bus-core": "workspace:^", 25 | "@node-ts/code-standards": "^0.0.10", 26 | "@types/amqplib": "^0.5.11", 27 | "@types/uuid": "^3.4.4", 28 | "reflect-metadata": "^0.1.13", 29 | "typemoq": "^2.1.0", 30 | "typescript": "^5.3.3" 31 | }, 32 | "peerDependencies": { 33 | "@node-ts/bus-core": "^1.0.0-alpha.0" 34 | }, 35 | "keywords": [ 36 | "esb", 37 | "mongodb", 38 | "typescript", 39 | "enterprise integration patterns", 40 | "bus", 41 | "messaging", 42 | "microservices", 43 | "distributed systems", 44 | "framework", 45 | "enterprise framework", 46 | "CQRS", 47 | "ES", 48 | "NServiceBus", 49 | "Mule ESB" 50 | ], 51 | "gitHead": "265ea7e16c614971d4b01c3642682d6c93feb52f" 52 | } 53 | -------------------------------------------------------------------------------- /packages/bus-mongodb/src/error/index.ts: -------------------------------------------------------------------------------- 1 | export * from './workflow-state-not-found' 2 | -------------------------------------------------------------------------------- /packages/bus-mongodb/src/error/workflow-state-not-found.ts: -------------------------------------------------------------------------------- 1 | export class WorkflowStateNotFound extends Error { 2 | constructor( 3 | readonly workflowId: string, 4 | readonly tableName: string, 5 | readonly version: number 6 | ) { 7 | super(`Could not find workflow state`) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-mongodb/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mongodb-configuration' 2 | export * from './mongodb-persistence' 3 | -------------------------------------------------------------------------------- /packages/bus-mongodb/src/mongodb-configuration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Provides configuration for creating and configuring a Mongodb persistence provider. 3 | */ 4 | export interface MongodbConfiguration { 5 | /** 6 | * The mongodb connection string to use. This can be a single server, a replica set, or a mongodb+srv connection. 7 | */ 8 | connection: string 9 | 10 | /** 11 | * The database name to create workflow collections inside. 12 | */ 13 | databaseName: string 14 | } 15 | -------------------------------------------------------------------------------- /packages/bus-mongodb/src/mongodb-persistence.integration.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Bus, 3 | WorkflowStatus, 4 | MessageWorkflowMapping, 5 | Logger, 6 | BusInstance 7 | } from '@node-ts/bus-core' 8 | import { MessageAttributes } from '@node-ts/bus-messages' 9 | import { MongodbPersistence } from './mongodb-persistence' 10 | import { MongodbConfiguration } from './mongodb-configuration' 11 | import { Mock } from 'typemoq' 12 | import { TestWorkflowState, TestCommand, TestWorkflow } from '../test' 13 | import * as uuid from 'uuid' 14 | import { Collection, Db, MongoClient } from 'mongodb' 15 | import { WorkflowStateNotFound } from './error' 16 | 17 | const configuration: MongodbConfiguration = { 18 | connection: 'mongodb://localhost:27017/workflows', 19 | databaseName: 'workflows' 20 | } 21 | 22 | describe('MongodbPersistence', () => { 23 | let sut: MongodbPersistence 24 | let client: MongoClient 25 | let database: Db 26 | let collection: Collection 27 | let bus: BusInstance 28 | 29 | beforeAll(async () => { 30 | client = new MongoClient(configuration.connection) 31 | client = await client.connect() 32 | database = client.db(configuration.databaseName) as Db 33 | collection = database.collection('testworkflowstate') as Collection 34 | sut = new MongodbPersistence(configuration) 35 | bus = Bus.configure() 36 | .withLogger(() => Mock.ofType().object) 37 | .withPersistence(sut) 38 | .withWorkflow(TestWorkflow) 39 | .build() 40 | 41 | await bus.initialize() 42 | await bus.start() 43 | }) 44 | 45 | afterAll(async () => { 46 | await client.db(configuration.databaseName).dropDatabase() 47 | await bus.dispose() 48 | }) 49 | 50 | describe('when initializing the persistence', () => { 51 | it('should create a workflow table', async () => { 52 | const count = await collection.countDocuments() 53 | expect(count).toEqual(0) 54 | }) 55 | }) 56 | 57 | describe('when saving new workflow state', () => { 58 | const workflowState = new TestWorkflowState() 59 | workflowState.$workflowId = uuid.v4() 60 | workflowState.$status = WorkflowStatus.Running 61 | workflowState.$version = 0 62 | workflowState.eventValue = 'abc' 63 | workflowState.property1 = 'something' 64 | 65 | beforeAll(async () => { 66 | await sut.saveWorkflowState(workflowState) 67 | }) 68 | 69 | it('should add the row into the table', async () => { 70 | const result = await collection.find().toArray() 71 | expect(result.length).toEqual(1) 72 | expect(result[0]).toMatchObject({ 73 | id: workflowState.$workflowId, 74 | version: 1, 75 | data: { 76 | __workflowId: workflowState.$workflowId, 77 | __status: WorkflowStatus.Running, 78 | __version: 1, 79 | __name: 'TestWorkflowState', 80 | eventValue: 'abc', 81 | property1: 'something' 82 | } 83 | }) 84 | }) 85 | 86 | describe('when getting the workflow state by property', () => { 87 | const testCommand = new TestCommand(workflowState.property1) 88 | const messageOptions: MessageAttributes = { 89 | attributes: {}, 90 | stickyAttributes: {} 91 | } 92 | let dataV1: TestWorkflowState 93 | let mapping: MessageWorkflowMapping 94 | 95 | it('should retrieve the item', async () => { 96 | mapping = { 97 | lookup: message => message.property1, 98 | mapsTo: 'property1' 99 | } 100 | const results = await sut.getWorkflowState( 101 | TestWorkflowState, 102 | mapping, 103 | testCommand, 104 | messageOptions 105 | ) 106 | expect(results).toHaveLength(1) 107 | dataV1 = results[0] 108 | expect(dataV1).toMatchObject({ ...workflowState, $version: 1 }) 109 | }) 110 | 111 | describe('when updating the workflow state', () => { 112 | let updates: TestWorkflowState 113 | let dataV2: TestWorkflowState 114 | 115 | beforeAll(async () => { 116 | updates = { 117 | ...dataV1, 118 | eventValue: 'something else' 119 | } 120 | await sut.saveWorkflowState(updates) 121 | 122 | const results = await sut.getWorkflowState( 123 | TestWorkflowState, 124 | mapping, 125 | testCommand, 126 | messageOptions 127 | ) 128 | dataV2 = results[0] 129 | }) 130 | 131 | it('should return the updates', () => { 132 | expect(dataV2).toMatchObject({ 133 | ...updates, 134 | $version: 2 135 | }) 136 | }) 137 | }) 138 | describe('when updating the workflow state with invalid version', () => { 139 | let updates: TestWorkflowState 140 | let error: Error 141 | 142 | beforeAll(async () => { 143 | updates = { 144 | ...dataV1, 145 | eventValue: 'something else' 146 | } 147 | try { 148 | await sut.saveWorkflowState(updates) 149 | } catch (err) { 150 | error = err as Error 151 | } 152 | }) 153 | 154 | it('should throw WorkflowStateNotFound', async () => { 155 | expect(error).toBeInstanceOf(WorkflowStateNotFound) 156 | }) 157 | }) 158 | }) 159 | }) 160 | }) 161 | -------------------------------------------------------------------------------- /packages/bus-mongodb/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './test-workflow-state' 2 | export * from './test-command' 3 | export * from './test-workflow' 4 | export * from './task-ran' 5 | export * from './run-task' 6 | -------------------------------------------------------------------------------- /packages/bus-mongodb/test/run-task.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@node-ts/bus-messages' 2 | 3 | export class RunTask extends Command { 4 | static NAME = '@node-ts/bus-core/run-task' 5 | $name = RunTask.NAME 6 | $version = 0 7 | 8 | constructor(readonly value: string) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-mongodb/test/task-ran.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '@node-ts/bus-messages' 2 | 3 | export class TaskRan extends Event { 4 | static NAME = '@node-ts/bus-core/task-ran' 5 | $name = TaskRan.NAME 6 | $version = 0 7 | 8 | constructor(readonly value: string) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-mongodb/test/test-command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@node-ts/bus-messages' 2 | 3 | export class TestCommand extends Command { 4 | static NAME = '@node-ts/bus-core/test-command' 5 | $name = TestCommand.NAME 6 | $version = 0 7 | 8 | constructor(readonly property1: string | undefined) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-mongodb/test/test-workflow-state.ts: -------------------------------------------------------------------------------- 1 | import { WorkflowState } from '@node-ts/bus-core' 2 | 3 | export class TestWorkflowState extends WorkflowState { 4 | static NAME = 'TestWorkflowState' 5 | $name = TestWorkflowState.NAME 6 | 7 | property1: string 8 | eventValue: string 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-mongodb/test/test-workflow.ts: -------------------------------------------------------------------------------- 1 | import { BusInstance, Workflow, WorkflowMapper } from '@node-ts/bus-core' 2 | import { RunTask } from './run-task' 3 | import { TaskRan } from './task-ran' 4 | import { TestCommand } from './test-command' 5 | import { TestWorkflowState } from './test-workflow-state' 6 | 7 | export class TestWorkflow extends Workflow { 8 | constructor(private readonly bus: BusInstance) { 9 | super() 10 | } 11 | 12 | configureWorkflow( 13 | mapper: WorkflowMapper 14 | ): void { 15 | mapper 16 | .withState(TestWorkflowState) 17 | .startedBy(TestCommand, 'sendRunTask') 18 | .when(TaskRan, 'complete', { 19 | lookup: message => message.value, 20 | mapsTo: 'property1' 21 | }) 22 | } 23 | 24 | async sendRunTask({ 25 | property1 26 | }: TestCommand): Promise> { 27 | await this.bus.send(new RunTask(property1!)) 28 | return { 29 | property1 30 | } 31 | } 32 | 33 | complete({ value }: TaskRan): Partial { 34 | return this.completeWorkflow({ 35 | eventValue: value 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/bus-mongodb/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.spec.ts", "src/**/*.integration.ts", "src/**/test"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "baseUrl": "./" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-postgres/README.md: -------------------------------------------------------------------------------- 1 | # @node-ts/bus-postgres 2 | 3 | A Postgres based persistence for workflow storage in [@node-ts/bus](https://bus.node-ts.com) 4 | 5 | 🔥 View our docs at [https://bus.node-ts.com](https://bus.node-ts.com) 🔥 6 | 7 | 🤔 Have a question? [Join the Discussion](https://github.com/node-ts/bus/discussions) 🤔 8 | 9 | ## Installation 10 | 11 | Install all packages and their dependencies 12 | 13 | ```bash 14 | npm install @node-ts/bus-postgres 15 | ``` 16 | 17 | Configure a new Postgres persistence and register it with `Bus`: 18 | 19 | ```typescript 20 | import { Bus } from '@node-ts/bus-core' 21 | import { PostgresPersistence, PostgresConfiguration } from '@node-ts/bus-postgres' 22 | 23 | const configuration: PostgresConfiguration = { 24 | connection: { 25 | connectionString: 'postgres://postgres:password@localhost:5432/postgres' 26 | }, 27 | schemaName: 'workflows' 28 | } 29 | const postgresPersistence = new PostgresPersistence(configuration) 30 | 31 | // Configure bus to use postgres as a persistence 32 | const run = async () => { 33 | const bus = Bus 34 | .configure() 35 | .withPersistence(postgresPersistence) 36 | .build() 37 | await bus.initialize() 38 | await bus.start() 39 | } 40 | run.then(() => void) 41 | ``` 42 | 43 | ## Configuration Options 44 | 45 | The Postgres persistence has the following configuration: 46 | 47 | - **connection** _(required)_ Connection pool settings for the application to connect to the postgres instance 48 | - **schemaName** _(required)_ The schema name to create workflow tables under. This can be the 'public' default from postgres, but it's recommended to use 'workflows' or something similar to group all workflow concerns in the one place. This schema will be created if it doesn't already exist. 49 | 50 | ## Development 51 | 52 | Local development can be done with the aid of docker to run the required infrastructure. To do so, run: 53 | 54 | ```bash 55 | docker run --name bus-postgres -e POSTGRES_PASSWORD=password -p 6432:5432 -d postgres 56 | ``` 57 | -------------------------------------------------------------------------------- /packages/bus-postgres/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@node-ts/bus-postgres", 3 | "description": "A Postgres persistence adapter for workflow storage in @node-ts/bus-workflow.", 4 | "version": "1.1.0", 5 | "license": "MIT", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "repository": "github:node-ts/bus.git", 9 | "scripts": { 10 | "clean": "rm -rf dist", 11 | "build": "tsc", 12 | "build:watch": "pnpm run build --incremental --watch --preserveWatchOutput" 13 | }, 14 | "publishConfig": { 15 | "access": "public" 16 | }, 17 | "dependencies": { 18 | "@node-ts/bus-messages": "workspace:^", 19 | "pg": "^8.2.1", 20 | "tslib": "^2.6.2", 21 | "uuid": "^3.3.2" 22 | }, 23 | "devDependencies": { 24 | "@node-ts/bus-core": "workspace:^", 25 | "@node-ts/code-standards": "^0.0.10", 26 | "@types/amqplib": "^0.5.11", 27 | "@types/pg": "^7.14.3", 28 | "@types/uuid": "^3.4.4", 29 | "reflect-metadata": "^0.1.13", 30 | "typemoq": "^2.1.0", 31 | "typescript": "^5.3.3" 32 | }, 33 | "peerDependencies": { 34 | "@node-ts/bus-core": "^1.0.0-alpha.0" 35 | }, 36 | "keywords": [ 37 | "esb", 38 | "postgres", 39 | "typescript", 40 | "enterprise integration patterns", 41 | "bus", 42 | "messaging", 43 | "microservices", 44 | "distributed systems", 45 | "framework", 46 | "enterprise framework", 47 | "CQRS", 48 | "ES", 49 | "NServiceBus", 50 | "Mule ESB" 51 | ], 52 | "gitHead": "265ea7e16c614971d4b01c3642682d6c93feb52f" 53 | } 54 | -------------------------------------------------------------------------------- /packages/bus-postgres/src/error/index.ts: -------------------------------------------------------------------------------- 1 | export * from './workflow-state-not-found' 2 | -------------------------------------------------------------------------------- /packages/bus-postgres/src/error/workflow-state-not-found.ts: -------------------------------------------------------------------------------- 1 | export class WorkflowStateNotFound extends Error { 2 | constructor( 3 | readonly workflowId: string, 4 | readonly tableName: string, 5 | readonly version: number 6 | ) { 7 | super(`Could not find workflow state`) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-postgres/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './postgres-configuration' 2 | export * from './postgres-persistence' 3 | -------------------------------------------------------------------------------- /packages/bus-postgres/src/postgres-configuration.ts: -------------------------------------------------------------------------------- 1 | import { PoolConfig } from 'pg' 2 | 3 | /** 4 | * Provides configuration for creating and configuring a postgres pool 5 | */ 6 | export interface PostgresConfiguration { 7 | /** 8 | * Connection pool settings for the application to connect to the postgres instance 9 | */ 10 | connection: PoolConfig 11 | 12 | /** 13 | * The schema name to create workflow tables under. This can be the 'public' default from postgres, 14 | * but it's recommended to use 'workflows' or something similar to group all workflow concerns in 15 | * the one place. This schema will be created if it doesn't already exist. 16 | */ 17 | schemaName: string 18 | } 19 | -------------------------------------------------------------------------------- /packages/bus-postgres/src/postgres-persistence.integration.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Bus, 3 | WorkflowStatus, 4 | MessageWorkflowMapping, 5 | Logger, 6 | BusInstance 7 | } from '@node-ts/bus-core' 8 | import { MessageAttributes } from '@node-ts/bus-messages' 9 | import { PostgresPersistence } from './postgres-persistence' 10 | import { PostgresConfiguration } from './postgres-configuration' 11 | import { Mock } from 'typemoq' 12 | import { TestWorkflowState, TestCommand, TestWorkflow } from '../test' 13 | import { Pool } from 'pg' 14 | import * as uuid from 'uuid' 15 | 16 | const configuration: PostgresConfiguration = { 17 | connection: { 18 | connectionString: 'postgres://postgres:password@localhost:6432/postgres' 19 | }, 20 | schemaName: 'workflows' 21 | } 22 | 23 | describe('PostgresPersistence', () => { 24 | let sut: PostgresPersistence 25 | let postgres: Pool 26 | let bus: BusInstance 27 | 28 | beforeAll(async () => { 29 | postgres = new Pool(configuration.connection) 30 | await postgres.query( 31 | 'create schema if not exists ' + configuration.schemaName 32 | ) 33 | sut = new PostgresPersistence(configuration, postgres) 34 | bus = Bus.configure() 35 | .withLogger(() => Mock.ofType().object) 36 | .withPersistence(sut) 37 | .withWorkflow(TestWorkflow) 38 | .build() 39 | 40 | await bus.initialize() 41 | await bus.start() 42 | }) 43 | 44 | afterAll(async () => { 45 | await postgres.query('drop table if exists "workflows"."testworkflowstate"') 46 | await postgres.query('drop schema if exists ' + configuration.schemaName) 47 | await bus.dispose() 48 | }) 49 | 50 | describe('when initializing the transport', () => { 51 | it('should create a workflow table', async () => { 52 | const result = await postgres.query( 53 | 'select count(*) from "workflows"."testworkflowstate"' 54 | ) 55 | const { count } = result.rows[0] as { count: string } 56 | expect(count).toEqual('0') 57 | }) 58 | }) 59 | 60 | describe('when saving new workflow state', () => { 61 | const workflowState = new TestWorkflowState() 62 | workflowState.$workflowId = uuid.v4() 63 | workflowState.$status = WorkflowStatus.Running 64 | workflowState.$version = 0 65 | workflowState.eventValue = 'abc' 66 | workflowState.property1 = 'something' 67 | 68 | beforeAll(async () => { 69 | await sut.saveWorkflowState(workflowState) 70 | }) 71 | 72 | it('should add the row into the table', async () => { 73 | const result = await postgres.query( 74 | 'select count(*) from "workflows"."testworkflowstate"' 75 | ) 76 | const { count } = result.rows[0] as { count: string } 77 | expect(count).toEqual('1') 78 | }) 79 | 80 | describe('when getting the workflow state by property', () => { 81 | const testCommand = new TestCommand(workflowState.property1) 82 | const messageOptions: MessageAttributes = { 83 | attributes: {}, 84 | stickyAttributes: {} 85 | } 86 | let dataV1: TestWorkflowState 87 | let mapping: MessageWorkflowMapping 88 | 89 | it('should retrieve the item', async () => { 90 | mapping = { 91 | lookup: message => message.property1, 92 | mapsTo: 'property1' 93 | } 94 | const results = await sut.getWorkflowState( 95 | TestWorkflowState, 96 | mapping, 97 | testCommand, 98 | messageOptions 99 | ) 100 | expect(results).toHaveLength(1) 101 | dataV1 = results[0] 102 | expect(dataV1).toMatchObject({ ...workflowState, $version: 1 }) 103 | }) 104 | 105 | describe('when updating the workflow state', () => { 106 | let updates: TestWorkflowState 107 | let dataV2: TestWorkflowState 108 | 109 | beforeAll(async () => { 110 | updates = { 111 | ...dataV1, 112 | eventValue: 'something else' 113 | } 114 | await sut.saveWorkflowState(updates) 115 | 116 | const results = await sut.getWorkflowState( 117 | TestWorkflowState, 118 | mapping, 119 | testCommand, 120 | messageOptions 121 | ) 122 | dataV2 = results[0] 123 | }) 124 | 125 | it('should return the updates', () => { 126 | expect(dataV2).toMatchObject({ 127 | ...updates, 128 | $version: 2 129 | }) 130 | }) 131 | }) 132 | }) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /packages/bus-postgres/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './test-workflow-state' 2 | export * from './test-command' 3 | export * from './test-workflow' 4 | export * from './task-ran' 5 | export * from './run-task' 6 | -------------------------------------------------------------------------------- /packages/bus-postgres/test/run-task.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@node-ts/bus-messages' 2 | 3 | export class RunTask extends Command { 4 | static NAME = '@node-ts/bus-core/run-task' 5 | $name = RunTask.NAME 6 | $version = 0 7 | 8 | constructor(readonly value: string) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-postgres/test/task-ran.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '@node-ts/bus-messages' 2 | 3 | export class TaskRan extends Event { 4 | static NAME = '@node-ts/bus-core/task-ran' 5 | $name = TaskRan.NAME 6 | $version = 0 7 | 8 | constructor(readonly value: string) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-postgres/test/test-command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@node-ts/bus-messages' 2 | 3 | export class TestCommand extends Command { 4 | static NAME = '@node-ts/bus-core/test-command' 5 | $name = TestCommand.NAME 6 | $version = 0 7 | 8 | constructor(readonly property1: string | undefined) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-postgres/test/test-workflow-state.ts: -------------------------------------------------------------------------------- 1 | import { WorkflowState } from '@node-ts/bus-core' 2 | 3 | export class TestWorkflowState extends WorkflowState { 4 | static NAME = 'TestWorkflowState' 5 | $name = TestWorkflowState.NAME 6 | 7 | property1: string 8 | eventValue: string 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-postgres/test/test-workflow.ts: -------------------------------------------------------------------------------- 1 | import { TestWorkflowState } from './test-workflow-state' 2 | import { 3 | Bus, 4 | HandlerContext, 5 | Workflow, 6 | WorkflowMapper 7 | } from '@node-ts/bus-core' 8 | import { TestCommand } from './test-command' 9 | import { RunTask } from './run-task' 10 | import { TaskRan } from './task-ran' 11 | 12 | export class TestWorkflow extends Workflow { 13 | configureWorkflow( 14 | mapper: WorkflowMapper 15 | ): void { 16 | mapper 17 | .withState(TestWorkflowState) 18 | .startedBy(TestCommand, 'sendRunTask') 19 | .when(TaskRan, 'complete', { 20 | lookup: ({ message }) => message.value, 21 | mapsTo: 'property1' 22 | }) 23 | } 24 | 25 | async sendRunTask({ 26 | message: { property1 } 27 | }: HandlerContext): Promise> { 28 | await Bus.send(new RunTask(property1!)) 29 | return { 30 | property1 31 | } 32 | } 33 | 34 | complete({ 35 | message: { value } 36 | }: HandlerContext): Partial { 37 | return this.completeWorkflow({ 38 | eventValue: value 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/bus-postgres/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.spec.ts", "src/**/*.integration.ts", "src/**/test"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "baseUrl": "./" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-rabbitmq/README.md: -------------------------------------------------------------------------------- 1 | # @node-ts/bus-rabbitmq 2 | 3 | A Rabbit MQ transport adapter for [@node-ts/bus](https://bus.node-ts.com) 4 | 5 | 🔥 View our docs at [https://bus.node-ts.com](https://bus.node-ts.com) 🔥 6 | 7 | 🤔 Have a question? [Join the Discussion](https://github.com/node-ts/bus/discussions) 🤔 8 | 9 | ## Installation 10 | 11 | Install all packages and their dependencies 12 | 13 | ```bash 14 | npm install @node-ts/bus-rabbitmq 15 | ``` 16 | 17 | Once installed, configure a new `RabbitMqTransport` and register it for use with `Bus`: 18 | 19 | ```typescript 20 | import { Bus } from '@node-ts/bus-core' 21 | import { 22 | RabbitMqTransport, 23 | RabbitMqTransportConfiguration 24 | } from '@node-ts/bus-rabbitmq' 25 | 26 | const rabbitConfiguration: RabbitMqTransportConfiguration = { 27 | queueName: 'accounts-application-queue', 28 | connectionString: 'amqp://guest:guest@localhost', 29 | maxRetries: 5 30 | } 31 | const rabbitMqTransport = new RabbitMqTransport(rabbitConfiguration) 32 | 33 | // Configure Bus to use RabbitMQ as a transport 34 | const run = async () => { 35 | const bus = Bus.configure().withTransport(rabbitMqTransport).build() 36 | await bus.initialize() 37 | } 38 | run.catch(console.error) 39 | ``` 40 | 41 | ## Configuration Options 42 | 43 | The RabbitMQ transport has the following configuration: 44 | 45 | - **queueName** _(required)_ The name of the service queue to create and read messages from. 46 | - **connectionString** _(required)_ An amqp formatted connection string that's used to connect to the RabbitMQ instance 47 | - **maxRetries** _(optional)_ The number of attempts to retry failed messages before they're routed to the dead letter queue. _Default: 10_ 48 | 49 | ## Development 50 | 51 | Local development can be done with the aid of docker to run the required infrastructure. To do so, run: 52 | 53 | ```bash 54 | docker run -d -p 8080:15672 -p 5672:5672 rabbitmq:3-management 55 | ``` 56 | -------------------------------------------------------------------------------- /packages/bus-rabbitmq/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@node-ts/bus-rabbitmq", 3 | "description": "A RabbitMQ transport adapter for @node-ts/bus-core.", 4 | "version": "1.1.0", 5 | "license": "MIT", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "repository": "github:node-ts/bus.git", 9 | "scripts": { 10 | "clean": "rm -rf dist", 11 | "build": "tsc", 12 | "build:watch": "pnpm run build --incremental --watch --preserveWatchOutput" 13 | }, 14 | "publishConfig": { 15 | "access": "public" 16 | }, 17 | "dependencies": { 18 | "@node-ts/bus-messages": "workspace:^", 19 | "@types/amqplib": "^0.10.1", 20 | "amqplib": "^0.10.3", 21 | "tslib": "^2.6.2", 22 | "uuid": "^3.3.2" 23 | }, 24 | "devDependencies": { 25 | "@node-ts/bus-core": "workspace:^", 26 | "@node-ts/bus-test": "workspace:^", 27 | "@node-ts/code-standards": "^0.0.10", 28 | "@types/faker": "^4.1.5", 29 | "@types/uuid": "^3.4.4", 30 | "faker": "^4.1.0", 31 | "reflect-metadata": "^0.1.13", 32 | "typemoq": "^2.1.0", 33 | "typescript": "^5.3.3" 34 | }, 35 | "peerDependencies": { 36 | "@node-ts/bus-core": "^1.0.0-alpha.0" 37 | }, 38 | "keywords": [ 39 | "esb", 40 | "rabbitmq", 41 | "typescript", 42 | "enterprise integration patterns", 43 | "bus", 44 | "messaging", 45 | "microservices", 46 | "distributed systems", 47 | "framework", 48 | "enterprise framework", 49 | "CQRS", 50 | "ES", 51 | "NServiceBus", 52 | "Mule ESB" 53 | ], 54 | "gitHead": "265ea7e16c614971d4b01c3642682d6c93feb52f" 55 | } 56 | -------------------------------------------------------------------------------- /packages/bus-rabbitmq/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './rabbitmq-transport-configuration' 2 | export * from './rabbitmq-transport' 3 | -------------------------------------------------------------------------------- /packages/bus-rabbitmq/src/rabbitmq-transport-configuration.ts: -------------------------------------------------------------------------------- 1 | import { TransportConfiguration } from '@node-ts/bus-core' 2 | 3 | export interface RabbitMqTransportConfiguration extends TransportConfiguration { 4 | /** 5 | * The amqp connection string to use to connect to the rabbit mq instance 6 | * @example amqp://guest:guest@localhost 7 | */ 8 | connectionString: string 9 | 10 | /** 11 | * The maximum number of attempts to retry a failed message before routing it to the dead letter queue. 12 | * @default 10 13 | */ 14 | maxRetries?: number 15 | 16 | /** 17 | * Whether the messages in RabbitMQ are persistent or not (survive a broker restart). By default, false. 18 | */ 19 | persistentMessages?: boolean 20 | } 21 | -------------------------------------------------------------------------------- /packages/bus-rabbitmq/src/rabbitmq-transport.integration.ts: -------------------------------------------------------------------------------- 1 | import { RabbitMqTransport } from './rabbitmq-transport' 2 | import { Connection, Channel, connect, ConsumeMessage } from 'amqplib' 3 | import { 4 | DefaultHandlerRegistry, 5 | JsonSerializer, 6 | MessageSerializer 7 | } from '@node-ts/bus-core' 8 | import { RabbitMqTransportConfiguration } from './rabbitmq-transport-configuration' 9 | import { 10 | Message, 11 | MessageAttributeMap, 12 | MessageAttributes 13 | } from '@node-ts/bus-messages' 14 | import * as uuid from 'uuid' 15 | import { transportTests, TestSystemMessage } from '@node-ts/bus-test' 16 | 17 | const configuration: RabbitMqTransportConfiguration = { 18 | queueName: '@node-ts/bus-rabbitmq-test', 19 | deadLetterQueueName: '@node-ts/bus-rabbitmq-test-dead-letter', 20 | connectionString: 'amqp://guest:guest@0.0.0.0', 21 | maxRetries: 10 22 | } 23 | 24 | describe('RabbitMqTransport', () => { 25 | jest.setTimeout(10000) 26 | 27 | let rabbitMqTransport = new RabbitMqTransport(configuration) 28 | let connection: Connection 29 | let channel: Channel 30 | const messageSerializer = new MessageSerializer( 31 | new JsonSerializer(), 32 | new DefaultHandlerRegistry() 33 | ) 34 | 35 | const systemMessageTopicIdentifier = TestSystemMessage.NAME 36 | const message = new TestSystemMessage() 37 | const publishSystemMessage = async (systemMessageAttribute: string) => { 38 | const attributes = { systemMessage: systemMessageAttribute } 39 | channel.publish( 40 | systemMessageTopicIdentifier, 41 | '', 42 | Buffer.from(JSON.stringify(message)), 43 | { 44 | messageId: uuid.v4(), 45 | headers: { 46 | attributes: JSON.stringify(attributes) 47 | } 48 | } 49 | ) 50 | } 51 | 52 | const readAllFromDeadLetterQueue = async () => { 53 | // Wait for message to arrive to give the handler time to fail it 54 | const rabbitMessage = await new Promise(async resolve => { 55 | const consumerTag = uuid.v4() 56 | channel.consume( 57 | configuration.deadLetterQueueName!, 58 | message => { 59 | channel.ack(message!) 60 | channel.cancel(consumerTag) 61 | resolve(message!) 62 | }, 63 | { 64 | consumerTag 65 | } 66 | ) 67 | }) 68 | await channel.purgeQueue(configuration.deadLetterQueueName!) 69 | 70 | const payload = rabbitMessage.content.toString('utf8') 71 | const message = messageSerializer.deserialize(payload) as Message 72 | 73 | const attributes: MessageAttributes = { 74 | correlationId: rabbitMessage.properties.correlationId as string, 75 | attributes: 76 | rabbitMessage.properties.headers && 77 | rabbitMessage.properties.headers.attributes 78 | ? (JSON.parse( 79 | rabbitMessage.properties.headers.attributes as string 80 | ) as MessageAttributeMap) 81 | : {}, 82 | stickyAttributes: 83 | rabbitMessage.properties.headers && 84 | rabbitMessage.properties.headers.stickyAttributes 85 | ? (JSON.parse( 86 | rabbitMessage.properties.headers.stickyAttributes as string 87 | ) as MessageAttributeMap) 88 | : {} 89 | } 90 | 91 | return [{ message, attributes }] 92 | } 93 | 94 | beforeAll(async () => { 95 | connection = await connect(configuration.connectionString) 96 | channel = await connection.createChannel() 97 | // Ignore failures due to queues that don't yet exist 98 | await Promise.allSettled([ 99 | channel.purgeQueue(configuration.queueName), 100 | channel.purgeQueue(configuration.deadLetterQueueName!) 101 | ]) 102 | }) 103 | 104 | transportTests( 105 | rabbitMqTransport, 106 | publishSystemMessage, 107 | systemMessageTopicIdentifier, 108 | readAllFromDeadLetterQueue 109 | ) 110 | 111 | afterAll(async () => { 112 | await channel.deleteExchange(systemMessageTopicIdentifier) 113 | await channel.close() 114 | await connection.close() 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /packages/bus-rabbitmq/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.spec.ts", "src/**/*.integration.ts", "src/**/test"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "baseUrl": "./" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-sqs-lambda/README.md: -------------------------------------------------------------------------------- 1 | # @node-ts/bus-sqs-lambda 2 | 3 | An Amazon SQS and Lambda receiver for [@node-ts/bus](https://bus.node-ts.com). 4 | 5 | This package allows the host application to receive SQS messages via a Lambda handler directly, rather than subscribing to the SQS transport. 6 | 7 | 🔥 View our docs at [https://bus.node-ts.com](https://bus.node-ts.com) 🔥 8 | 9 | 🤔 Have a question? [Join the Discussion](https://github.com/node-ts/bus/discussions) 🤔 10 | 11 | ## Installation 12 | 13 | Install packages and their dependencies 14 | 15 | ```bash 16 | npm i @node-ts/bus-sqs-lambda @node-ts/bus-sqs @node-ts/bus-core 17 | ``` 18 | 19 | Once installed, configure Bus to use this receiver during initialization: 20 | 21 | ```typescript 22 | import { Bus } from '@node-ts/bus-core' 23 | import { SqsTransport, SqsTransportConfiguration } from '@node-ts/bus-sqs' 24 | import { BusSqsLambdaReceiver } from '@node-ts/bus-sqs-lambda' 25 | 26 | const sqsConfiguration: SqsTransportConfiguration = { 27 | awsRegion: process.env.AWS_REGION, 28 | awsAccountId: process.env.AWS_ACCOUNT_ID, 29 | queueName: `my-service`, 30 | deadLetterQueueName: `my-service-dead-letter` 31 | } 32 | const sqsTransport = new SqsTransport(sqsConfiguration) 33 | 34 | // Configure Bus to run in a Lambda 35 | const bus = Bus.configure() 36 | .withTransport(sqsTransport) 37 | .withReceiver(new BusSqsLambdaReceiver()) 38 | .build() 39 | 40 | await bus.initialize() 41 | ``` 42 | 43 | ## Usage 44 | 45 | Once configured and initialized, any Lambda that is triggered by SQS messages can send these messages to Bus for processing and dispatch using the `bus.receive` method: 46 | 47 | ```typescript 48 | // Your lambda code 49 | 50 | module.exports.handler = bus.receive 51 | ``` 52 | -------------------------------------------------------------------------------- /packages/bus-sqs-lambda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@node-ts/bus-sqs-lambda", 3 | "description": "A retrieval mechanism that receives messages from SQS-subscribed lambda.", 4 | "version": "1.0.0-beta.1", 5 | "license": "MIT", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "repository": "github:node-ts/bus.git", 9 | "scripts": { 10 | "clean": "rm -rf dist", 11 | "build": "tsc", 12 | "build:watch": "pnpm run build --incremental --watch --preserveWatchOutput" 13 | }, 14 | "publishConfig": { 15 | "access": "public" 16 | }, 17 | "dependencies": { 18 | "@aws-sdk/client-sqs": "3.428.0", 19 | "@node-ts/bus-messages": "workspace:^", 20 | "@node-ts/bus-sqs": "workspace:^", 21 | "aws-lambda": "^1.0.7", 22 | "tslib": "^2.6.3", 23 | "uuid": "^3.3.2" 24 | }, 25 | "devDependencies": { 26 | "@node-ts/bus-core": "workspace:^", 27 | "@node-ts/bus-sqs": "workspace:^", 28 | "@node-ts/bus-test": "workspace:^", 29 | "@node-ts/code-standards": "^0.0.10", 30 | "@types/aws-lambda": "^8.10.143", 31 | "@types/faker": "^4.1.5", 32 | "@types/uuid": "^3.4.4", 33 | "class-transformer": "^0.5.1", 34 | "faker": "^4.1.0", 35 | "reflect-metadata": "^0.1.13", 36 | "typemoq": "^2.1.0", 37 | "typescript": "^5.5.4" 38 | }, 39 | "peerDependencies": { 40 | "@node-ts/bus-core": "^1.0.15" 41 | }, 42 | "keywords": [ 43 | "esb", 44 | "SQS", 45 | "typescript", 46 | "enterprise integration patterns", 47 | "bus", 48 | "messaging", 49 | "microservices", 50 | "distributed systems", 51 | "framework", 52 | "enterprise framework", 53 | "CQRS", 54 | "ES", 55 | "NServiceBus", 56 | "Mule ESB", 57 | "Serverless", 58 | "Lambda" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /packages/bus-sqs-lambda/src/bus-sqs-lambda-receiver.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JsonSerializer, 3 | MessageSerializer, 4 | TransportMessage 5 | } from '@node-ts/bus-core' 6 | import { BusSqsLambdaReceiver } from './bus-sqs-lambda-receiver' 7 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 8 | import { SQSRecord } from 'aws-lambda' 9 | 10 | const attributePayload = { 11 | Records: [ 12 | { 13 | messageId: '4b9a650d-00fa-4f86-a4f2-d57661155b94', 14 | receiptHandle: 15 | 'AQEByTon76mjc9w6jPFA+C1P3bOC5B6ayx1ap0VVvTbCy9w0Patfwnc0y8qHPFFPCxkiszTHkRhHBiLgrkeQ32rRCRz0ZJr1mL7ekYnKS/GndtwKAw/hBZW2vKFiHc9bMxG6DmlBkeNc9QmIGdJRplYhIEmDUd3ckuU7FegGvODiwfTpjxe5bz2Q7T7aS85iKJ6ZTtAIZiHhLKOQYwxkAqXiCB4nOsFYRul+rSLfw3oDhUgG9mqEJZnPqdkifUFFEwaGzx6q9HIOLB/J6S8ZDxoLTR2yOJESnBywZJzzHUuADBOn1IBImOxV3vfpHSAuxgibZSuz1vlFV50x3l6i14t/RQZsujnLn/w+1w0XpW2//0AmGrZvib+YfHDZQ2UjgzDbVPED+zJtpH1wYw5HzP/4jg==', 16 | body: 17 | '{\n' + 18 | ' "Type" : "Notification",\n' + 19 | ' "MessageId" : "31fe335f-39c7-5124-bcf7-6c634835eb49",\n' + 20 | ' "TopicArn" : "arn:aws:sns:us-west-2:339712791595:zerodual-staging-audit-create-audit-log",\n' + 21 | ' "Subject" : "audit/create-audit-log",\n' + 22 | ' "Message" : "{\\"id\\":\\"8c6bc223-5437-4de8-b999-8a742d65784b\\",\\"userId\\":\\"e04010bd-e2fa-492f-b680-54e586680da2\\",\\"ip\\":\\"dc71:b6ca:6974:bff9:c17c:2fe3:5de1:deb3\\",\\"operations\\":[],\\"metadata\\":{},\\"$name\\":\\"audit/create-audit-log\\",\\"$version\\":0,\\"createdAt\\":\\"2024-08-18T22:03:27.780Z\\"}",\n' + 23 | ' "Timestamp" : "2024-08-18T22:03:30.355Z",\n' + 24 | ' "SignatureVersion" : "1",\n' + 25 | ' "Signature" : "...",\n' + 26 | ' "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-...",\n' + 27 | ' "UnsubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=...",\n' + 28 | ' "MessageAttributes" : {\n' + 29 | ' "correlationId" : {"Type":"String","Value":"e3808fce-9b66-4596-b528-479b72e598fe"},\n' + 30 | ' "stickyAttributes.x-sticky-attribute" : {"Type":"String","Value":"baz"},\n' + 31 | ' "attributes.x-foo-attribute" : {"Type":"String","Value":"bar"}\n' + 32 | ' }\n' + 33 | '}', 34 | attributes: { 35 | ApproximateReceiveCount: '1', 36 | SentTimestamp: '1545082650636', 37 | SenderId: 'AIDAIENQZJOLO23YVJ4VO', 38 | ApproximateFirstReceiveTimestamp: '1545082650649' 39 | }, 40 | messageAttributes: {}, 41 | md5OfBody: 'f54bafbb90f2526eeecbed33fe71d668', 42 | eventSource: 'aws:sqs', 43 | eventSourceARN: 'arn:aws:sqs:us-west-2:339712791595:temp-sqs', 44 | awsRegion: 'us-west-2' 45 | } 46 | ] 47 | } 48 | 49 | class TestMessageSerializer extends MessageSerializer { 50 | serialize(message: MessageType): string { 51 | return JSON.stringify(message) 52 | } 53 | deserialize( 54 | serializedMessage: string 55 | ): MessageType { 56 | return JSON.parse(serializedMessage) 57 | } 58 | } 59 | 60 | describe('BusSqsLambdaReceiver', () => { 61 | const receiver = new BusSqsLambdaReceiver() 62 | const serializer = new TestMessageSerializer(new JsonSerializer(), {} as any) 63 | 64 | describe('when lambda receives a message with attributes', () => { 65 | let attributes: MessageAttributes 66 | let messages: TransportMessage[] 67 | 68 | beforeAll(async () => { 69 | messages = await receiver.receive(attributePayload, serializer) 70 | attributes = messages[0].attributes 71 | }) 72 | 73 | it('should parse out the body', () => { 74 | expect(messages).toHaveLength(1) 75 | expect(messages[0].domainMessage).toMatchObject({ 76 | id: '8c6bc223-5437-4de8-b999-8a742d65784b', 77 | userId: 'e04010bd-e2fa-492f-b680-54e586680da2', 78 | ip: 'dc71:b6ca:6974:bff9:c17c:2fe3:5de1:deb3', 79 | operations: [], 80 | metadata: {}, 81 | $name: 'audit/create-audit-log', 82 | $version: 0, 83 | createdAt: '2024-08-18T22:03:27.780Z' 84 | }) 85 | }) 86 | 87 | it('should parse out the correlationId', () => { 88 | expect(attributes.correlationId).toEqual( 89 | 'e3808fce-9b66-4596-b528-479b72e598fe' 90 | ) 91 | }) 92 | 93 | it('should parse out attributes', () => { 94 | expect(attributes.attributes).toMatchObject({ 'x-foo-attribute': 'bar' }) 95 | }) 96 | 97 | it('should parse out sticky attributes', () => { 98 | expect(attributes.stickyAttributes).toMatchObject({ 99 | 'x-sticky-attribute': 'baz' 100 | }) 101 | }) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /packages/bus-sqs-lambda/src/bus-sqs-lambda-receiver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MessageSerializer, 3 | Receiver, 4 | TransportMessage 5 | } from '@node-ts/bus-core' 6 | import { fromMessageAttributeMap } from '@node-ts/bus-sqs' 7 | import type { SQSEvent, SQSRecord } from 'aws-lambda' 8 | 9 | /** 10 | * Receives messages from an SQS event that's triggered a lambda function, and converts them into a TransportMessage 11 | * ready to be dispatched. 12 | */ 13 | export class BusSqsLambdaReceiver 14 | implements Receiver> 15 | { 16 | async receive( 17 | receivedMessage: SQSEvent, 18 | messageSerializer: MessageSerializer 19 | ): Promise[]> { 20 | return receivedMessage.Records.map(record => { 21 | const body = JSON.parse(record.body) 22 | const domainMessage = messageSerializer.deserialize(body.Message) 23 | const attributes = fromMessageAttributeMap(body.MessageAttributes) 24 | 25 | return { 26 | id: record.messageId, 27 | domainMessage, 28 | raw: record, 29 | attributes 30 | } 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/bus-sqs-lambda/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bus-sqs-lambda-receiver' 2 | -------------------------------------------------------------------------------- /packages/bus-sqs-lambda/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.spec.ts", "src/**/*.integration.ts", "src/**/test"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "baseUrl": "./" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-sqs/README.md: -------------------------------------------------------------------------------- 1 | # @node-ts/bus-sqs 2 | 3 | An Amazon SQS transport adapter for [@node-ts/bus](https://bus.node-ts.com) 4 | 5 | 🔥 View our docs at [https://bus.node-ts.com](https://bus.node-ts.com) 🔥 6 | 7 | 🤔 Have a question? [Join the Discussion](https://github.com/node-ts/bus/discussions) 🤔 8 | 9 | ## Installation 10 | 11 | Install packages and their dependencies 12 | 13 | ```bash 14 | npm i @node-ts/bus-sqs @node-ts/bus-core 15 | ``` 16 | 17 | Once installed, configure Bus to use this transport during initialization: 18 | 19 | ```typescript 20 | import { Bus } from '@node-ts/bus-core' 21 | import { SqsTransport, SqsTransportConfiguration } from '@node-ts/bus-sqs' 22 | 23 | const sqsConfiguration: SqsTransportConfiguration = { 24 | awsRegion: process.env.AWS_REGION, 25 | awsAccountId: process.env.AWS_ACCOUNT_ID, 26 | queueName: `my-service`, 27 | deadLetterQueueName: `my-service-dead-letter` 28 | } 29 | const sqsTransport = new SqsTransport(sqsConfiguration) 30 | 31 | // Configure Bus to use SQS as a transport 32 | const bus = Bus.configure().withTransport(sqsTransport).build() 33 | await bus.initialize() 34 | ``` 35 | 36 | ## Development 37 | 38 | Local development can be done with the aid of docker to run the required infrastructure. To do so, run: 39 | 40 | ```bash 41 | docker run -e SERVICES=sqs,sns -e DEFAULT_REGION=us-east-1 -p 4566-4583:4566-4583 localstack/localstack 42 | ``` 43 | 44 | This will create a localstack instance running and exposing a mock sqs/sns that's compatible with the AWS-SDK. This same environment is used when running integration tests for the `SqsTransport`. 45 | -------------------------------------------------------------------------------- /packages/bus-sqs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@node-ts/bus-sqs", 3 | "description": "An AWS SQS transport adapter for @node-ts/bus-core.", 4 | "version": "1.1.3", 5 | "license": "MIT", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "repository": "github:node-ts/bus.git", 9 | "scripts": { 10 | "clean": "rm -rf dist", 11 | "build": "tsc", 12 | "build:watch": "pnpm run build --incremental --watch --preserveWatchOutput" 13 | }, 14 | "publishConfig": { 15 | "access": "public" 16 | }, 17 | "dependencies": { 18 | "@aws-sdk/client-sns": "3.428.0", 19 | "@aws-sdk/client-sqs": "3.428.0", 20 | "@aws-sdk/util-arn-parser": "^3.495.0", 21 | "@node-ts/bus-messages": "workspace:^", 22 | "tslib": "^2.6.2", 23 | "uuid": "^3.3.2" 24 | }, 25 | "devDependencies": { 26 | "@node-ts/bus-core": "workspace:^", 27 | "@node-ts/bus-test": "workspace:^", 28 | "@node-ts/code-standards": "^0.0.10", 29 | "@types/amqplib": "^0.5.11", 30 | "@types/faker": "^4.1.5", 31 | "@types/uuid": "^3.4.4", 32 | "class-transformer": "^0.5.1", 33 | "faker": "^4.1.0", 34 | "reflect-metadata": "^0.1.13", 35 | "typemoq": "^2.1.0", 36 | "typescript": "^5.3.3" 37 | }, 38 | "peerDependencies": { 39 | "@node-ts/bus-core": "^1.0.15" 40 | }, 41 | "keywords": [ 42 | "esb", 43 | "SQS", 44 | "typescript", 45 | "enterprise integration patterns", 46 | "bus", 47 | "messaging", 48 | "microservices", 49 | "distributed systems", 50 | "framework", 51 | "enterprise framework", 52 | "CQRS", 53 | "ES", 54 | "NServiceBus", 55 | "Mule ESB" 56 | ], 57 | "gitHead": "265ea7e16c614971d4b01c3642682d6c93feb52f" 58 | } 59 | -------------------------------------------------------------------------------- /packages/bus-sqs/src/generate-policy.ts: -------------------------------------------------------------------------------- 1 | export const generatePolicy = (awsAccountId: string, awsRegion: string) => ` 2 | { 3 | "Version": "2012-10-17", 4 | "Statement": [ 5 | { 6 | "Sid": "node-ts-bus-topic-subscriptions", 7 | "Principal": { 8 | "Service": "sns.amazonaws.com" 9 | }, 10 | "Effect": "Allow", 11 | "Action":"sqs:SendMessage", 12 | "Resource": [ 13 | "arn:aws:sqs:${awsRegion}:${awsAccountId}:*" 14 | ], 15 | "Condition":{ 16 | "StringLike":{ 17 | "aws:SourceArn":"arn:aws:sns:${awsRegion}:${awsAccountId}:*" 18 | } 19 | } 20 | } 21 | ] 22 | } 23 | ` 24 | -------------------------------------------------------------------------------- /packages/bus-sqs/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sqs-transport-configuration' 2 | export { SqsTransport, fromMessageAttributeMap } from './sqs-transport' 3 | export { Message as SqsMessage } from '@aws-sdk/client-sqs' 4 | -------------------------------------------------------------------------------- /packages/bus-sqs/src/queue-resolvers.ts: -------------------------------------------------------------------------------- 1 | import { SqsTransportConfiguration } from './sqs-transport-configuration' 2 | 3 | const invalidSqsSnsCharacters = new RegExp('[^a-zA-Z0-9_-]', 'g') 4 | 5 | export const normalizeMessageName = (messageName: string) => 6 | messageName.replace(invalidSqsSnsCharacters, '-') 7 | 8 | /** 9 | * Resolves the name of the topic a message is sent to 10 | * @param messageName Name of the message being sent to the topic 11 | * @returns a valid SNS topic name 12 | */ 13 | export const resolveTopicName = (messageName: string) => 14 | normalizeMessageName(messageName) 15 | 16 | /** 17 | * Resolves the ARN of a topic a message is sent to 18 | * @param awsAccountId id of the account the topic is created in 19 | * @param awsRegion region the topic is created in 20 | * @param topicName name of the topic being created 21 | * @returns an arn of a topic 22 | */ 23 | export const resolveTopicArn = ( 24 | awsAccountId: string, 25 | awsRegion: string, 26 | topicName: string 27 | ) => `arn:aws:sns:${awsRegion}:${awsAccountId}:${topicName}` 28 | 29 | export const resolveQueueUrl = ( 30 | { awsAccountId, awsRegion }: SqsTransportConfiguration, 31 | queueName: string 32 | ) => `https://sqs.${awsRegion}.amazonaws.com/${awsAccountId}/${queueName}` 33 | 34 | export const resolveQueueArn = ( 35 | awsAccountId: string, 36 | awsRegion: string, 37 | queueName: string 38 | ) => `arn:aws:sqs:${awsRegion}:${awsAccountId}:${queueName}` 39 | 40 | export const resolveDeadLetterQueueName = () => `dlq` 41 | -------------------------------------------------------------------------------- /packages/bus-sqs/src/sqs-transport-configuration.ts: -------------------------------------------------------------------------------- 1 | import { TransportConfiguration } from '@node-ts/bus-core' 2 | import { 3 | resolveTopicArn as defaultResolveTopicArn, 4 | resolveTopicName as defaultResolveTopicName 5 | } from './queue-resolvers' 6 | 7 | export interface SqsTransportConfiguration 8 | extends Omit { 9 | /** 10 | * The AWS Account Id of the account where queues and topics will be created 11 | */ 12 | awsAccountId?: string 13 | 14 | /** 15 | * The AWS region to create queues and topics in 16 | */ 17 | awsRegion?: string 18 | 19 | /** 20 | * An optional AWS ARN of the dead letter queue to fail messages to 21 | * @default undefined 22 | */ 23 | deadLetterQueueArn?: string 24 | 25 | /** 26 | * The number of seconds to retain messages in the service and dead letter queues 27 | * @default 1209600 (14 days) 28 | */ 29 | messageRetentionPeriod?: number 30 | 31 | /** 32 | * The AWS ARN for the target SQS Queue 33 | */ 34 | queueArn?: string 35 | 36 | /** 37 | * The name of the queue that receives incoming messages 38 | * @example order-booking-service 39 | */ 40 | queueName?: string 41 | 42 | /** 43 | * An optional custom queue policy to apply to any created SQS queues. 44 | * By default a generic policy will be added that grants send permissions to SNS 45 | * topics within the same AWS account. This can be further restricted or relaxed by 46 | * providing a custom policy. 47 | * @example 48 | * { 49 | * "Version": "2012-10-17", 50 | * "Statement": [ 51 | * { 52 | * "Principal": "*", 53 | * "Effect": "Allow", 54 | * "Action": [ 55 | * "sqs:SendMessage" 56 | * ], 57 | * "Resource": [ 58 | * "arn:aws:sqs:us-west-2:12345678:production-*" 59 | * ], 60 | * "Condition": { 61 | * "ArnLike": { 62 | * "aws:SourceArn": "arn:aws:sns:us-west-2:12345678:production-*" 63 | * } 64 | * } 65 | * } 66 | * ] 67 | * } 68 | */ 69 | queuePolicy?: string 70 | 71 | /** 72 | * The visibility timeout for the queue, in seconds. Valid values: An integer from 0 to 43,200 (12 hours) 73 | * @default 30 74 | */ 75 | visibilityTimeout?: number 76 | 77 | /** 78 | * The number of times a message is delivered to the source queue before being moved to the dead-letter queue 79 | * @default 10 80 | */ 81 | maxReceiveCount?: number 82 | 83 | /** 84 | * The wait time on sqs.receiveMessage, setting it to 0 will essentially turn it to short polling. 85 | * 86 | * It also has a impact on shutdown duration because sqs,receiveMessage is a non interruptible action. 87 | * 88 | * @default 10 89 | */ 90 | waitTimeSeconds?: number 91 | 92 | /** 93 | * A resolver function that maps a message name to an SNS topic. 94 | * @param messageName Name of the message to map 95 | * @returns An SNS topic name where messages of @param messageName are sent. Must be compatible with SNS topic naming 96 | * @example 97 | * resolveTopicName (messageName: string) => `production-${messageName}` 98 | */ 99 | resolveTopicName?: typeof defaultResolveTopicName 100 | 101 | /** 102 | * A resolver function that maps an SNS topic name to an SNS topic arn 103 | * @returns An SNS topic url where messages are sent 104 | * @example 105 | * resolveTopicArn (awsAccountId: string, awsRegion: string, topicName: string) => 106 | * `arn:aws:sns:${awsRegion}:${awsAccountId}:${topicName}` 107 | */ 108 | resolveTopicArn?: typeof defaultResolveTopicArn 109 | } 110 | -------------------------------------------------------------------------------- /packages/bus-sqs/src/sqs-transport.integration.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateTopicCommand, 3 | PublishCommand, 4 | SNSClient 5 | } from '@aws-sdk/client-sns' 6 | import { 7 | DeleteMessageCommand, 8 | DeleteQueueCommand, 9 | PurgeQueueCommand, 10 | ReceiveMessageCommand, 11 | SQSClient 12 | } from '@aws-sdk/client-sqs' 13 | import { Message } from '@node-ts/bus-messages' 14 | import { TestSystemMessage, transportTests } from '@node-ts/bus-test' 15 | import { 16 | SQSMessageBody, 17 | SqsTransport, 18 | fromMessageAttributeMap 19 | } from './sqs-transport' 20 | import { SqsTransportConfiguration } from './sqs-transport-configuration' 21 | 22 | function getEnvVar(key: string): string { 23 | const value = process.env[key] 24 | if (!value) { 25 | throw new Error(`Env var not set - ${key}`) 26 | } 27 | return value 28 | } 29 | 30 | // Use a randomize number otherwise aws will disallow recreate just deleted queue 31 | // const resourcePrefix = `integration-bus-sqs-${faker.random.number()}` 32 | const resourcePrefix = `integration-bus-sqs-1` 33 | const AWS_REGION = getEnvVar('AWS_REGION') 34 | const AWS_ACCOUNT_ID = getEnvVar('AWS_ACCOUNT_ID') 35 | 36 | const sqsConfiguration: SqsTransportConfiguration = { 37 | awsRegion: AWS_REGION, 38 | awsAccountId: AWS_ACCOUNT_ID, 39 | queueName: `${resourcePrefix}-test`, 40 | deadLetterQueueName: `${resourcePrefix}-dead-letter` 41 | } 42 | 43 | const manualTopicName = `${resourcePrefix}-test-system-message` 44 | const manualTopicIdentifier = `arn:aws:sns:${process.env.AWS_REGION}:${process.env.AWS_ACCOUNT_ID}:${manualTopicName}` 45 | 46 | jest.setTimeout(15000) 47 | 48 | describe('SqsTransport', () => { 49 | const sqs = new SQSClient({ 50 | endpoint: 'http://localhost:4566', 51 | region: AWS_REGION 52 | }) 53 | const sns = new SNSClient({ 54 | endpoint: 'http://localhost:4566', 55 | region: AWS_REGION 56 | }) 57 | const sqsTransport = new SqsTransport(sqsConfiguration, sqs, sns) 58 | const deadLetterQueueUrl = sqsTransport.deadLetterQueueUrl 59 | 60 | beforeAll(async () => { 61 | const createTopic = new CreateTopicCommand({ 62 | Name: manualTopicName 63 | }) 64 | await sns.send(createTopic) 65 | }) 66 | 67 | afterAll(async () => { 68 | const appQueueUrl = sqsTransport.queueUrl 69 | await sqs.send(new PurgeQueueCommand({ QueueUrl: appQueueUrl })) 70 | await sqs.send(new DeleteQueueCommand({ QueueUrl: appQueueUrl })) 71 | await sqs.send(new DeleteQueueCommand({ QueueUrl: deadLetterQueueUrl })) 72 | }) 73 | 74 | const message = new TestSystemMessage() 75 | const publishSystemMessage = async (systemMessageAttribute: string) => { 76 | await sns.send( 77 | new PublishCommand({ 78 | Message: JSON.stringify(message), 79 | TopicArn: manualTopicIdentifier, 80 | MessageAttributes: { 81 | 'attributes.systemMessage': { 82 | DataType: 'String', 83 | StringValue: systemMessageAttribute 84 | } 85 | } 86 | }) 87 | ) 88 | } 89 | 90 | const readAllFromDeadLetterQueue = async () => { 91 | const result = await sqs.send( 92 | new ReceiveMessageCommand({ 93 | QueueUrl: deadLetterQueueUrl, 94 | WaitTimeSeconds: 5, 95 | MaxNumberOfMessages: 10, 96 | AttributeNames: ['All'] 97 | }) 98 | ) 99 | 100 | const transportMessages = result.Messages || [] 101 | 102 | await Promise.all( 103 | transportMessages.map(message => 104 | sqs.send( 105 | new DeleteMessageCommand({ 106 | QueueUrl: deadLetterQueueUrl, 107 | ReceiptHandle: message.ReceiptHandle! 108 | }) 109 | ) 110 | ) 111 | ) 112 | 113 | return (result.Messages || []).map(transportMessage => { 114 | const rawMessage = JSON.parse(transportMessage.Body!) as SQSMessageBody 115 | const message = JSON.parse(rawMessage.Message) as Message 116 | const attributes = fromMessageAttributeMap(rawMessage.MessageAttributes) 117 | return { message, attributes } 118 | }) 119 | } 120 | 121 | transportTests( 122 | sqsTransport, 123 | publishSystemMessage, 124 | manualTopicIdentifier, 125 | readAllFromDeadLetterQueue 126 | ) 127 | }) 128 | -------------------------------------------------------------------------------- /packages/bus-sqs/src/sqs-transport.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | toMessageAttributeMap, 3 | SqsMessageAttributes, 4 | fromMessageAttributeMap, 5 | SqsTransport, 6 | SnsMessageAttributeMap 7 | } from './sqs-transport' 8 | import { MessageAttributes } from '@node-ts/bus-messages' 9 | import * as faker from 'faker' 10 | import { SqsTransportConfiguration } from './sqs-transport-configuration' 11 | import { 12 | CoreDependencies, 13 | TransportMessage, 14 | RetryStrategy, 15 | DebugLogger 16 | } from '@node-ts/bus-core' 17 | import { Mock, It, Times } from 'typemoq' 18 | import { 19 | ChangeMessageVisibilityCommand, 20 | Message, 21 | SQSClient 22 | } from '@aws-sdk/client-sqs' 23 | 24 | describe('sqs-transport', () => { 25 | describe('when converting SNS attribute values to message attributes', () => { 26 | const correlationId = faker.random.uuid() 27 | 28 | const sqsAttributes: SqsMessageAttributes = { 29 | 'stickyAttributes.attribute1': { Type: 'String', Value: 'b' }, 30 | 'stickyAttributes.attribute2': { Type: 'Number', Value: '2' }, 31 | correlationId: { Type: 'String', Value: correlationId }, 32 | 'attributes.attribute2': { Type: 'Number', Value: '1' }, 33 | 'attributes.attribute1': { Type: 'String', Value: 'a' } 34 | } 35 | 36 | let messageAttributes: MessageAttributes 37 | 38 | beforeEach(() => { 39 | messageAttributes = fromMessageAttributeMap(sqsAttributes) 40 | }) 41 | 42 | it('should parse the correlation id', () => { 43 | expect(messageAttributes.correlationId).toEqual(correlationId) 44 | }) 45 | 46 | it('should parse the attributes', () => { 47 | expect(messageAttributes.attributes).toMatchObject({ 48 | attribute1: 'a', 49 | attribute2: 1 50 | }) 51 | }) 52 | 53 | it('should parse the sticky attributes', () => { 54 | expect(messageAttributes.stickyAttributes).toMatchObject({ 55 | attribute1: 'b', 56 | attribute2: 2 57 | }) 58 | }) 59 | }) 60 | 61 | describe('when converting message attributes to SNS attribute values', () => { 62 | const messageOptions: MessageAttributes = { 63 | correlationId: faker.random.uuid(), 64 | attributes: { 65 | attribute1: 'a', 66 | attribute2: 1 67 | }, 68 | stickyAttributes: { 69 | attribute1: 'b', 70 | attribute2: 2 71 | } 72 | } 73 | 74 | let messageAttributes: SnsMessageAttributeMap 75 | 76 | beforeEach(() => { 77 | messageAttributes = toMessageAttributeMap(messageOptions) 78 | }) 79 | 80 | it('should convert the correlationId', () => { 81 | expect(messageAttributes.correlationId).toBeDefined() 82 | expect(messageAttributes.correlationId.DataType).toEqual('String') 83 | expect(messageAttributes.correlationId.StringValue).toEqual( 84 | messageOptions.correlationId 85 | ) 86 | }) 87 | 88 | it('should convert attributesValues', () => { 89 | expect(messageAttributes['attributes.attribute1']).toBeDefined() 90 | const attribute1 = messageAttributes['attributes.attribute1'] 91 | expect(attribute1.DataType).toEqual('String') 92 | expect(attribute1.StringValue).toEqual('a') 93 | 94 | expect(messageAttributes['attributes.attribute2']).toBeDefined() 95 | const attribute2 = messageAttributes['attributes.attribute2'] 96 | expect(attribute2.DataType).toEqual('Number') 97 | expect(attribute2.StringValue).toEqual('1') 98 | }) 99 | 100 | it('should convert stickyAttributeValues', () => { 101 | expect(messageAttributes['stickyAttributes.attribute1']).toBeDefined() 102 | const attribute1 = messageAttributes['stickyAttributes.attribute1'] 103 | expect(attribute1.DataType).toEqual('String') 104 | expect(attribute1.StringValue).toEqual('b') 105 | 106 | expect(messageAttributes['stickyAttributes.attribute2']).toBeDefined() 107 | const attribute2 = messageAttributes['stickyAttributes.attribute2'] 108 | expect(attribute2.DataType).toEqual('Number') 109 | expect(attribute2.StringValue).toEqual('2') 110 | }) 111 | }) 112 | 113 | describe('when returning a message to the queue', () => { 114 | it('should use the retry strategy delay', async () => { 115 | const sqs = Mock.ofType() 116 | const sut = new SqsTransport( 117 | { 118 | queueArn: 'arn:aws:sqs:us-west-2:12345678:test' 119 | } as SqsTransportConfiguration, 120 | sqs.object 121 | ) 122 | 123 | const retryStrategy: RetryStrategy = { 124 | calculateRetryDelay() { 125 | return 3000 126 | } 127 | } 128 | 129 | sut.prepare({ 130 | retryStrategy, 131 | loggerFactory: (name: string) => new DebugLogger(name) 132 | } as any as CoreDependencies) 133 | 134 | sqs 135 | .setup(s => 136 | s.send( 137 | It.is( 138 | (command: ChangeMessageVisibilityCommand) => 139 | command.input.VisibilityTimeout === 3 140 | ) 141 | ) 142 | ) 143 | .returns(() => ({ promise: async () => undefined } as any)) 144 | .verifiable(Times.once()) 145 | 146 | await sut.returnMessage({ raw: {} } as TransportMessage) 147 | sqs.verifyAll() 148 | }) 149 | }) 150 | }) 151 | -------------------------------------------------------------------------------- /packages/bus-sqs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.spec.ts", "src/**/*.integration.ts", "src/**/test"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "baseUrl": "./" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/bus-test/README.md: -------------------------------------------------------------------------------- 1 | # @node-ts/bus-test 2 | 3 | This package provides a common suite of tests for [@node-ts/bus](https://bus.node-ts.com) that help to ensure consistency across packages that extend the library by adding a new [transport](https://bus.node-ts.com/guide/transports) adapter. 4 | 5 | The package expects that [jest](https://www.npmjs.com/package/jest) is used as the test runner. 6 | 7 | ## Installation 8 | 9 | Add this to your transport or persistence package: 10 | 11 | ```sh 12 | npm i @node-ts/bus-test --save-dev 13 | ``` 14 | 15 | ## Implementation 16 | 17 | Transport tests are run by creating a `describe()` block and calling `transportTests()` with the following parameters: 18 | 19 | - **transport** - A fully configured transport that's the subject under test 20 | - **publishSystemMessage** - A callback that will publish a `@node-ts/bus-test:TestSystemMessage` with a `systemMessage` attribute set to the value of the `testSystemAttributeValue` parameter 21 | - **systemMessageTopicIdentifier** - An optional system message topic identifier that identifies the source topic of the system message 22 | - **readAllFromDeadLetterQueue** - A callback that will read and delete all messages on the dead letter queue 23 | 24 | ### Example 25 | 26 | Examples of how to implement the standard transport tests can be viewed by looking at existing implementations such as: 27 | 28 | - [RabbitMqTransport](https://github.com/node-ts/bus/blob/master/packages/bus-rabbitmq/src/rabbitmq-transport.integration.ts) 29 | - [SqsTransport](https://github.com/node-ts/bus/blob/master/packages/bus-sqs/src/sqs-transport.integration.ts) 30 | -------------------------------------------------------------------------------- /packages/bus-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@node-ts/bus-test", 3 | "version": "0.0.22", 4 | "description": "A dev-dependency package used to test transport implementations.", 5 | "homepage": "https://github.com/node-ts/bus#readme", 6 | "main": "./src/index.ts", 7 | "license": "MIT", 8 | "repository": "github:node-ts/bus.git", 9 | "publishConfig": { 10 | "access": "public" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/node-ts/bus/issues" 14 | }, 15 | "dependencies": { 16 | "@node-ts/bus-core": "workspace:^", 17 | "@node-ts/bus-messages": "workspace:^", 18 | "@node-ts/code-standards": "^0.0.10", 19 | "@types/faker": "^5.5.7", 20 | "@types/node": "^14.14.31", 21 | "class-transformer": "^0.5.1", 22 | "faker": "^5.5.3", 23 | "reflect-metadata": "^0.1.13", 24 | "tslib": "^2.6.2", 25 | "typemoq": "^2.1.0", 26 | "typescript": "^5.3.3", 27 | "uuid": "^8.3.2" 28 | }, 29 | "devDependencies": { 30 | "@types/uuid": "^8.3.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/bus-test/src/helpers/handle-checker.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 2 | 3 | export interface HandleChecker { 4 | check(message: Message, attributes: MessageAttributes): void 5 | } 6 | -------------------------------------------------------------------------------- /packages/bus-test/src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './handle-checker' 2 | export * from './test-event' 3 | export * from './test-command' 4 | export * from './test-command-handler' 5 | export * from './test-fail-message' 6 | export * from './test-system-message' 7 | export * from './test-poisoned-message' 8 | -------------------------------------------------------------------------------- /packages/bus-test/src/helpers/test-command-handler.ts: -------------------------------------------------------------------------------- 1 | import { handlerFor } from '@node-ts/bus-core' 2 | import { HandleChecker } from './handle-checker' 3 | import { TestCommand } from './test-command' 4 | 5 | export const testCommandHandler = (handleChecker: HandleChecker) => 6 | handlerFor(TestCommand, (message, attributes) => 7 | handleChecker.check(message, attributes) 8 | ) 9 | -------------------------------------------------------------------------------- /packages/bus-test/src/helpers/test-command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@node-ts/bus-messages' 2 | import { Type } from 'class-transformer' 3 | 4 | export class TestCommand extends Command { 5 | static NAME = '@node-ts/bus-core/test-command' 6 | $name = TestCommand.NAME 7 | $version = 1 8 | 9 | @Type(() => Date) 10 | readonly date: Date 11 | 12 | constructor(readonly value: string, date: Date) { 13 | super() 14 | 15 | this.date = date 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/bus-test/src/helpers/test-event.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '@node-ts/bus-messages' 2 | 3 | export class TestEvent extends Event { 4 | static NAME = '@node-ts/bus-core/test-event' 5 | $name = TestEvent.NAME 6 | $version = 1 7 | } 8 | -------------------------------------------------------------------------------- /packages/bus-test/src/helpers/test-fail-message.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@node-ts/bus-messages' 2 | 3 | export class TestFailMessage extends Message { 4 | static NAME = '@node-ts/bus-sqs/test-fail-message' 5 | $name = TestFailMessage.NAME 6 | $version = 1 7 | 8 | constructor(readonly id: string) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-test/src/helpers/test-poisoned-message.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@node-ts/bus-messages' 2 | 3 | export class TestPoisonedMessage extends Message { 4 | static NAME = '@node-ts/bus-test/test-poisoned-message' 5 | $name = TestPoisonedMessage.NAME 6 | $version = 1 7 | 8 | constructor(readonly id: string) { 9 | super() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/bus-test/src/helpers/test-system-message-handler.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageAttributes } from '@node-ts/bus-messages' 2 | import { HandleChecker } from './handle-checker' 3 | 4 | export const testSystemMessageHandler = 5 | (handleChecker: HandleChecker) => 6 | async (message: Message, attributes: MessageAttributes) => 7 | handleChecker.check(message, attributes) 8 | -------------------------------------------------------------------------------- /packages/bus-test/src/helpers/test-system-message.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'uuid' 2 | 3 | export class TestSystemMessage { 4 | static NAME = `integration-${uuid.v4()}` 5 | readonly $name = TestSystemMessage.NAME 6 | readonly $version: number = 0 7 | } 8 | -------------------------------------------------------------------------------- /packages/bus-test/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './transport.integration' 2 | export * from './helpers' 3 | -------------------------------------------------------------------------------- /packages/bus-test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.spec.ts", "src/**/*.integration.ts", "src/**/test"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "baseUrl": "./" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/** 3 | -------------------------------------------------------------------------------- /test.env: -------------------------------------------------------------------------------- 1 | AWS_REGION=us-east-1 2 | AWS_ACCOUNT_ID=000000000000 3 | AWS_ACCESS_KEY_ID=dummy_key_id 4 | AWS_SECRET_ACCESS_KEY=dummy_access_key 5 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["ES2020"], 5 | "target": "ES6", 6 | "skipLibCheck": true, 7 | "declaration": true, 8 | "strictPropertyInitialization": false, 9 | 10 | // Handled in linting, useful for testing 11 | "allowUnreachableCode": true, 12 | "noUnusedParameters": false, 13 | "noUnusedLocals": false, 14 | "importHelpers": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "experimentalDecorators": true, 5 | "emitDecoratorMetadata": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-ts/bus/d8a3ce6e2bba3c3469d715257334f0fdaf7d28bc/workflow.png --------------------------------------------------------------------------------