├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ └── github-ci.yaml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierrc.js ├── CHANGELOG.md ├── CONTRIBUTING.md ├── README.md ├── bin └── build-types.sh ├── docker-compose.yml ├── examples ├── pubsub-publisher.js ├── pubsub-subscriber.js ├── receiver.js └── sender.js ├── jest.config.js ├── package.json ├── release.config.js ├── src ├── AmqpConnectionManager.ts ├── ChannelWrapper.ts ├── helpers.ts └── index.ts ├── test ├── .eslintrc.js ├── AmqpConnectionManagerTest.ts ├── ChannelWrapperTest.ts ├── fixtures.ts ├── importTest.ts ├── integrationTest.ts └── tsconfig.json ├── tsconfig.cjs.json ├── tsconfig.json └── tsconfig.types.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: ['./tsconfig.json', './test/tsconfig.json'], 5 | }, 6 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 7 | rules: { 8 | '@typescript-eslint/explicit-function-return-type': 'off', 9 | '@typescript-eslint/no-explicit-any': 'off', 10 | '@typescript-eslint/explicit-member-accessibility': 'off', 11 | '@typescript-eslint/no-use-before-define': 'off', 12 | // typescript compiler has better unused variable checking. 13 | '@typescript-eslint/no-unused-vars': 'off', 14 | }, 15 | overrides: [ 16 | { 17 | files: ['src/**/*.ts', 'src/**/*.tsx'], 18 | }, 19 | { 20 | files: ['test/**/*.ts', 'test/**/*.tsx'], 21 | rules: { 22 | '@typescript-eslint/no-non-null-assertion': 'off', 23 | '@typescript-eslint/no-object-literal-type-assertion': 'off', 24 | }, 25 | }, 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/github-ci.yaml: -------------------------------------------------------------------------------- 1 | name: GitHub CI 2 | on: 3 | push: 4 | branches: [master] 5 | tags: 6 | - '*' 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [14.x, 16.x, 'lts/*'] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: docker-compose up -d 22 | - run: npm install 23 | - run: npm test 24 | # - name: Coveralls 25 | # uses: coverallsapp/github-action@master 26 | # with: 27 | # github-token: ${{ SECRETS.GITHUB_TOKEN }} 28 | # flag-name: run-${{ matrix.node-version }} 29 | # parallel: true 30 | # coveralls: 31 | # needs: test 32 | # runs-on: ubuntu-latest 33 | # steps: 34 | # - name: Coveralls Finished 35 | # uses: coverallsapp/github-action@master 36 | # with: 37 | # github-token: ${{ SECRETS.GITHUB_TOKEN }} 38 | # parallel-finished: true 39 | release: 40 | # Only release on push to master 41 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' 42 | runs-on: ubuntu-latest 43 | # Waits for test jobs for each Node.js version to complete 44 | needs: [test] 45 | steps: 46 | - uses: actions/checkout@v2 47 | - name: Use Node.js 48 | uses: actions/setup-node@v2 49 | with: 50 | node-version: 'lts/*' 51 | - run: npm install 52 | - name: semantic-release 53 | run: npm run semantic-release 54 | env: 55 | GH_TOKEN: ${{ secrets.DEPLOY_GH_TOKEN }} 56 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /lib 3 | /npm-debug.log 4 | /coverage 5 | /package-lock.json 6 | /.nyc_output 7 | /.vscode 8 | /dist -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no-install pretty-quick --staged 2 | npx --no-install lint-staged -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | printWidth: 100, 4 | tabWidth: 4, 5 | semi: true, 6 | singleQuote: true, 7 | overrides: [ 8 | { 9 | files: '*.md', 10 | options: { 11 | tabWidth: 2, 12 | }, 13 | }, 14 | { 15 | files: '*.yml', 16 | options: { 17 | tabWidth: 2, 18 | }, 19 | }, 20 | { 21 | files: '*.yaml', 22 | options: { 23 | tabWidth: 2, 24 | }, 25 | }, 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [4.1.14](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.13...v4.1.14) (2023-07-27) 2 | 3 | ### Bug Fixes 4 | 5 | - added type build step ([0cc9859](https://github.com/jwalton/node-amqp-connection-manager/commit/0cc9859c47bd54f1d449568f665426755064cba4)) 6 | - export types in separate directory ([5d6cdbf](https://github.com/jwalton/node-amqp-connection-manager/commit/5d6cdbfc9340c2b8a2b93d40277656ccaa937cae)) 7 | 8 | ## [4.1.13](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.12...v4.1.13) (2023-05-02) 9 | 10 | ### Bug Fixes 11 | 12 | - **types:** move `types` condition to the front ([a1eb206](https://github.com/jwalton/node-amqp-connection-manager/commit/a1eb206a86aff49164c766575ddb7717c0847c44)) 13 | 14 | ## [4.1.12](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.11...v4.1.12) (2023-04-03) 15 | 16 | ### Bug Fixes 17 | 18 | - **types:** Export types for ESM. ([7d3755a](https://github.com/jwalton/node-amqp-connection-manager/commit/7d3755a0a9bdef6a55ee2bd61d450071727beb52)), closes [#329](https://github.com/jwalton/node-amqp-connection-manager/issues/329) 19 | 20 | ## [4.1.11](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.10...v4.1.11) (2023-02-24) 21 | 22 | ### Bug Fixes 23 | 24 | - Add unbindQueue to ChannelWrapper. ([55ce8d3](https://github.com/jwalton/node-amqp-connection-manager/commit/55ce8d37d64fc946e95f238ea3ee3e034e6dd456)) 25 | 26 | ## [4.1.10](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.9...v4.1.10) (2022-12-31) 27 | 28 | ### Bug Fixes 29 | 30 | - exporting ChannelWrapper as a type without it getting emitted as metadata ([a6f7b5c](https://github.com/jwalton/node-amqp-connection-manager/commit/a6f7b5c7332b41229f90717f5c4650a04fe1dbba)) 31 | 32 | ## [4.1.9](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.8...v4.1.9) (2022-10-24) 33 | 34 | ### Bug Fixes 35 | 36 | - Fail immediately for a bad password on latest amqplib. ([412ed92](https://github.com/jwalton/node-amqp-connection-manager/commit/412ed921be20494e87f5f6b5c9a65ad2f207d304)) 37 | 38 | ## [4.1.8](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.7...v4.1.8) (2022-10-24) 39 | 40 | ### Bug Fixes 41 | 42 | - error thrown when queue deleted in amqplib 0.10.0 ([60700ee](https://github.com/jwalton/node-amqp-connection-manager/commit/60700eebcac6f1ce985a34da59f216481473780a)), closes [#301](https://github.com/jwalton/node-amqp-connection-manager/issues/301) 43 | 44 | ## [4.1.7](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.6...v4.1.7) (2022-09-30) 45 | 46 | ### Bug Fixes 47 | 48 | - consumer registered twice during setup ([1ca216a](https://github.com/jwalton/node-amqp-connection-manager/commit/1ca216a47c2abdbb1f4a1e04b9032cb03db17aa2)), closes [#297](https://github.com/jwalton/node-amqp-connection-manager/issues/297) 49 | 50 | ## [4.1.6](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.5...v4.1.6) (2022-08-11) 51 | 52 | ### Bug Fixes 53 | 54 | - Upgrade promise-breaker to 6.0.0 to fix typescript imports. ([c9aff08](https://github.com/jwalton/node-amqp-connection-manager/commit/c9aff0893336dab3e440825f56c3e94aa39d9ecc)), closes [#234](https://github.com/jwalton/node-amqp-connection-manager/issues/234) 55 | 56 | ## [4.1.5](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.4...v4.1.5) (2022-08-09) 57 | 58 | ### Reverts 59 | 60 | - Revert "fix: import of promise breaker" ([aaeae1e](https://github.com/jwalton/node-amqp-connection-manager/commit/aaeae1e3e29ce5ef9598e6e8be1f377d9559ee2e)) 61 | 62 | ## [4.1.4](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.3...v4.1.4) (2022-08-05) 63 | 64 | ### Bug Fixes 65 | 66 | - import of promise breaker ([d873885](https://github.com/jwalton/node-amqp-connection-manager/commit/d87388550d85e8326f6d91b6f86819809e1401ae)), closes [#234](https://github.com/jwalton/node-amqp-connection-manager/issues/234) 67 | 68 | ## [4.1.3](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.2...v4.1.3) (2022-05-04) 69 | 70 | ### Bug Fixes 71 | 72 | - accept 0 for heartbeatIntervalInSeconds ([208af68](https://github.com/jwalton/node-amqp-connection-manager/commit/208af6875bacda01b5a75be52f631bd71b4eafcd)) 73 | 74 | ## [4.1.2](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.1...v4.1.2) (2022-04-13) 75 | 76 | ### Bug Fixes 77 | 78 | - **types:** Export PublishOptions type. ([6d20252](https://github.com/jwalton/node-amqp-connection-manager/commit/6d2025204d3adc050f916c2c116c9ac8db36114c)) 79 | 80 | ## [4.1.1](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.1.0...v4.1.1) (2022-02-05) 81 | 82 | ### Bug Fixes 83 | 84 | - process unable to exit after connect ([8d572b1](https://github.com/jwalton/node-amqp-connection-manager/commit/8d572b1899f3739bb887104dd992aff6722f137a)) 85 | 86 | # [4.1.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.0.1...v4.1.0) (2022-02-01) 87 | 88 | ### Features 89 | 90 | - cancel specific consumer ([5f3b2eb](https://github.com/jwalton/node-amqp-connection-manager/commit/5f3b2eb1ab20d2fc63054192632a80d78a7934a2)) 91 | 92 | ## [4.0.1](https://github.com/jwalton/node-amqp-connection-manager/compare/v4.0.0...v4.0.1) (2022-01-21) 93 | 94 | ### Bug Fixes 95 | 96 | - accept type of amqplib.credentials.external() ([1db3b2d](https://github.com/jwalton/node-amqp-connection-manager/commit/1db3b2d5339387124c9b7b43192b7d93db7b1702)) 97 | 98 | # [4.0.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.9.0...v4.0.0) (2022-01-07) 99 | 100 | ### Bug Fixes 101 | 102 | - Emit `connectFailed` on connection failure. ([0f05987](https://github.com/jwalton/node-amqp-connection-manager/commit/0f05987af16db25954fb83de4e0bed05e71121cf)), closes [#222](https://github.com/jwalton/node-amqp-connection-manager/issues/222) 103 | 104 | ### Continuous Integration 105 | 106 | - Stop testing on node 10 and 12. ([5da9cb0](https://github.com/jwalton/node-amqp-connection-manager/commit/5da9cb034ec1f311677fd0db3931176ee2797dc2)) 107 | 108 | ### BREAKING CHANGES 109 | 110 | - No longer running unit tests on node 10 and 12, although this package may continue to work on these. 111 | - We will no longer emit a `disconnect` event on an 112 | initial connection failure - instead we now emit `connectFailed` on each 113 | connection failure, and only emit `disconnect` when we transition from 114 | connected to disconnected. 115 | 116 | # [3.9.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.8.1...v3.9.0) (2022-01-04) 117 | 118 | ### Features 119 | 120 | - proxying every exchange function of amqplib ([bca347c](https://github.com/jwalton/node-amqp-connection-manager/commit/bca347c437ec116249a671b4b903d7668e097cf8)) 121 | 122 | ## [3.8.1](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.8.0...v3.8.1) (2021-12-29) 123 | 124 | ### Bug Fixes 125 | 126 | - batch sending stops ([6a5b589](https://github.com/jwalton/node-amqp-connection-manager/commit/6a5b5890de3e2d543e27f4c078d27dc32b8b23d9)), closes [#196](https://github.com/jwalton/node-amqp-connection-manager/issues/196) 127 | 128 | # [3.8.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.7.0...v3.8.0) (2021-12-29) 129 | 130 | ### Features 131 | 132 | - plain channel ([328d31d](https://github.com/jwalton/node-amqp-connection-manager/commit/328d31d758ad32df49c8fe03d89b1f6d06a9465b)) 133 | 134 | # [3.7.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.6.0...v3.7.0) (2021-09-21) 135 | 136 | ### Bug Fixes 137 | 138 | - **AmqpConnectionManager:** IAmqpConnectionManager interface definition ([dedec7e](https://github.com/jwalton/node-amqp-connection-manager/commit/dedec7e24f12395e4ff2424d5f4026a5551ba064)) 139 | 140 | ### Features 141 | 142 | - add default publish timeout ([6826be2](https://github.com/jwalton/node-amqp-connection-manager/commit/6826be26ab6ed786832251ee06ff7bfc303d775c)) 143 | - expose AmqpConnectionManagerClass ([835a81f](https://github.com/jwalton/node-amqp-connection-manager/commit/835a81f0c953d5ab2a01611d277478d5b78aa8b0)) 144 | - timeout option for publish ([dee380d](https://github.com/jwalton/node-amqp-connection-manager/commit/dee380d7ed70cc801166e8859fff5e66bd6b9ece)) 145 | 146 | # [3.6.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.5.2...v3.6.0) (2021-08-27) 147 | 148 | ### Features 149 | 150 | - reconnect and cancelAll consumers ([fb0c00b](https://github.com/jwalton/node-amqp-connection-manager/commit/fb0c00becc224ffedd28e810cbb314187d21efdb)) 151 | 152 | ## [3.5.2](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.5.1...v3.5.2) (2021-08-26) 153 | 154 | ### Bug Fixes 155 | 156 | - Fix handling of resending messages during a disconnect. ([e1457a5](https://github.com/jwalton/node-amqp-connection-manager/commit/e1457a598c6ecffca9c864036f1875f546ad5017)), closes [#152](https://github.com/jwalton/node-amqp-connection-manager/issues/152) 157 | 158 | ### Performance Improvements 159 | 160 | - Send messages to underlying channel in synchronous batches. ([b866ef2](https://github.com/jwalton/node-amqp-connection-manager/commit/b866ef25ebe97c1cf4fe421835291584cb738f41)) 161 | 162 | ## [3.5.1](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.5.0...v3.5.1) (2021-08-26) 163 | 164 | ### Bug Fixes 165 | 166 | - **types:** Make private things private. ([8b1338b](https://github.com/jwalton/node-amqp-connection-manager/commit/8b1338ba2f46267b4aedcbeeaccfdc6cb24680ec)) 167 | 168 | # [3.5.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.4.5...v3.5.0) (2021-08-26) 169 | 170 | ### Features 171 | 172 | - manual reconnect ([798b45f](https://github.com/jwalton/node-amqp-connection-manager/commit/798b45f52c437f35a0f89b431b872354a7a3eb0e)) 173 | 174 | ## [3.4.5](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.4.4...v3.4.5) (2021-08-26) 175 | 176 | ### Performance Improvements 177 | 178 | - resolve sent messages immediately ([2349da2](https://github.com/jwalton/node-amqp-connection-manager/commit/2349da2db8d34934c6d0225983b6411509730560)) 179 | 180 | ## [3.4.4](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.4.3...v3.4.4) (2021-08-26) 181 | 182 | ### Bug Fixes 183 | 184 | - **types:** Allow passing object to `connect()` in addition to strings. ([516fd9f](https://github.com/jwalton/node-amqp-connection-manager/commit/516fd9fa19a12ce6aa585f97503dfb4fa336352f)) 185 | 186 | ## [3.4.3](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.4.2...v3.4.3) (2021-08-25) 187 | 188 | ### Bug Fixes 189 | 190 | - **types:** 'options' should be optional in `connect()`. ([4619149](https://github.com/jwalton/node-amqp-connection-manager/commit/4619149e702418b9e4bba6e135c675754589a8ed)) 191 | - Fix bluebird warning. ([cb2f124](https://github.com/jwalton/node-amqp-connection-manager/commit/cb2f124e10d193f6c9a3cb255636e112be2c4e53)), closes [#171](https://github.com/jwalton/node-amqp-connection-manager/issues/171) 192 | 193 | ## [3.4.3](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.4.2...v3.4.3) (2021-08-25) 194 | 195 | ### Bug Fixes 196 | 197 | - Fix bluebird warning. ([cb2f124](https://github.com/jwalton/node-amqp-connection-manager/commit/cb2f124e10d193f6c9a3cb255636e112be2c4e53)), closes [#171](https://github.com/jwalton/node-amqp-connection-manager/issues/171) 198 | 199 | ## [3.4.2](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.4.1...v3.4.2) (2021-08-25) 200 | 201 | ### Bug Fixes 202 | 203 | - **types:** Minor type fixes. ([6865613](https://github.com/jwalton/node-amqp-connection-manager/commit/68656134a13786af2b751527e5d03eff07dd72b9)) 204 | 205 | ## [3.4.1](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.4.0...v3.4.1) (2021-08-25) 206 | 207 | ### Bug Fixes 208 | 209 | - Only send disconnect event on first error. ([efde3b9](https://github.com/jwalton/node-amqp-connection-manager/commit/efde3b919252f031a3edf2a06d7cfe280c06044f)), closes [#145](https://github.com/jwalton/node-amqp-connection-manager/issues/145) 210 | 211 | # [3.4.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.3.0...v3.4.0) (2021-08-25) 212 | 213 | ### Features 214 | 215 | - Convert to typescript, add module exports. ([5f442b1](https://github.com/jwalton/node-amqp-connection-manager/commit/5f442b139dfe15468fef32022b87f409cd781a78)) 216 | 217 | # [3.3.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.2.4...v3.3.0) (2021-08-24) 218 | 219 | ### Bug Fixes 220 | 221 | - emit setup errors not caused by closed channel ([7c5fe10](https://github.com/jwalton/node-amqp-connection-manager/commit/7c5fe104c5333086a8b06bc28f451b4f22cc489d)), closes [#95](https://github.com/jwalton/node-amqp-connection-manager/issues/95) 222 | - setup on channel/connection closing/closed ([b21bd01](https://github.com/jwalton/node-amqp-connection-manager/commit/b21bd0173dc60712cedfd398161e52b6f621bf2a)) 223 | 224 | ### Features 225 | 226 | - immediately reconnect on amqplib connect timeout ([ad06108](https://github.com/jwalton/node-amqp-connection-manager/commit/ad0610878f0aba27cc5078a6d1e61420a77b7965)) 227 | 228 | ## [3.2.4](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.2.3...v3.2.4) (2021-08-23) 229 | 230 | ### Bug Fixes 231 | 232 | - connection close not awaited ([8955fe7](https://github.com/jwalton/node-amqp-connection-manager/commit/8955fe7ee8f52505b629ee091f3658dfeb425c37)) 233 | 234 | ## [3.2.3](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.2.2...v3.2.3) (2021-08-21) 235 | 236 | ### Bug Fixes 237 | 238 | - fixed issue with publish ignoring 'drain' event ([e195d9b](https://github.com/jwalton/node-amqp-connection-manager/commit/e195d9bc29907de49eec5e09aea3bba0b894d965)), closes [#129](https://github.com/jwalton/node-amqp-connection-manager/issues/129) 239 | 240 | ## [3.2.2](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.2.1...v3.2.2) (2021-02-09) 241 | 242 | ### Bug Fixes 243 | 244 | - When messages are acked/nacked, make sure we remove the correct message from the sent messages queue. ([c662026](https://github.com/jwalton/node-amqp-connection-manager/commit/c662026bc287e684a0f43ce2de7a44b80a88e8ff)), closes [#142](https://github.com/jwalton/node-amqp-connection-manager/issues/142) 245 | 246 | ## [3.2.1](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.2.0...v3.2.1) (2020-09-12) 247 | 248 | ### Bug Fixes 249 | 250 | - Push never resolves if error occured (courtesy @SSANSH). ([48a78f8](https://github.com/jwalton/node-amqp-connection-manager/commit/48a78f8de5d39002035b37f27fc3e0ce5015490c)) 251 | - **package:** resolve hanging retry connection timeout by introducing cancelable timeout ([e37dd1a](https://github.com/jwalton/node-amqp-connection-manager/commit/e37dd1a4e423012910d31ae8bcebf781cac6f3b5)) 252 | 253 | # [3.2.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.1.1...v3.2.0) (2020-01-20) 254 | 255 | ### Features 256 | 257 | - add bindQueue and assertExchange on ChannelWrapper ([879e522](https://github.com/jwalton/node-amqp-connection-manager/commit/879e522)) 258 | 259 | ## [3.1.1](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.1.0...v3.1.1) (2020-01-06) 260 | 261 | ### Bug Fixes 262 | 263 | - typo ([6055b02](https://github.com/jwalton/node-amqp-connection-manager/commit/6055b02)) 264 | 265 | # [3.1.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v3.0.0...v3.1.0) (2019-12-06) 266 | 267 | ### Features 268 | 269 | - Allow using URL object to connect, same format as amqplib accepts. ([f046680](https://github.com/jwalton/node-amqp-connection-manager/commit/f046680)) 270 | 271 | # [3.0.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v2.3.3...v3.0.0) (2019-07-04) 272 | 273 | ### Continuous Integration 274 | 275 | - Stop running tests for node 6 and node 8. ([164b882](https://github.com/jwalton/node-amqp-connection-manager/commit/164b882)) 276 | 277 | ### BREAKING CHANGES 278 | 279 | - Officially drop support for node 6 and node 8 (although they will probably still 280 | work). 281 | 282 | ## [2.3.3](https://github.com/jwalton/node-amqp-connection-manager/compare/v2.3.2...v2.3.3) (2019-06-25) 283 | 284 | ### Bug Fixes 285 | 286 | - **package:** update promise-breaker to version 5.0.0 ([ed91042](https://github.com/jwalton/node-amqp-connection-manager/commit/ed91042)) 287 | 288 | ## [2.3.2](https://github.com/jwalton/node-amqp-connection-manager/compare/v2.3.1...v2.3.2) (2019-05-21) 289 | 290 | ### Bug Fixes 291 | 292 | - Null delta to get semantic-release to pick up [#65](https://github.com/jwalton/node-amqp-connection-manager/issues/65). Fix [#84](https://github.com/jwalton/node-amqp-connection-manager/issues/84). ([9737135](https://github.com/jwalton/node-amqp-connection-manager/commit/9737135)) 293 | 294 | ## [2.3.1](https://github.com/jwalton/node-amqp-connection-manager/compare/v2.3.0...v2.3.1) (2019-04-01) 295 | 296 | ### Bug Fixes 297 | 298 | - prevent too many connection attempts on error ([2760ce5](https://github.com/jwalton/node-amqp-connection-manager/commit/2760ce5)), closes [#77](https://github.com/jwalton/node-amqp-connection-manager/issues/77) 299 | 300 | # [2.3.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v2.2.0...v2.3.0) (2018-11-20) 301 | 302 | ### Features 303 | 304 | - Add ChannelWrapper.ackAll() and ChannelWrapper.nackAll(). ([0246695](https://github.com/jwalton/node-amqp-connection-manager/commit/0246695)), closes [#60](https://github.com/jwalton/node-amqp-connection-manager/issues/60) 305 | 306 | # [2.2.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v2.1.2...v2.2.0) (2018-09-25) 307 | 308 | ### Features 309 | 310 | - Set 'this' to be the channel wrapper in the setup function. ([551200f](https://github.com/jwalton/node-amqp-connection-manager/commit/551200f)) 311 | 312 | ## [2.1.2](https://github.com/jwalton/node-amqp-connection-manager/compare/v2.1.1...v2.1.2) (2018-09-13) 313 | 314 | ### Bug Fixes 315 | 316 | - Export a default object from root module. ([78893c9](https://github.com/jwalton/node-amqp-connection-manager/commit/78893c9)), closes [#51](https://github.com/jwalton/node-amqp-connection-manager/issues/51) 317 | 318 | ## [2.1.1](https://github.com/jwalton/node-amqp-connection-manager/compare/v2.1.0...v2.1.1) (2018-09-05) 319 | 320 | ### Bug Fixes 321 | 322 | - Remove reconnection listener when closing the connection manager. ([eeb6e2b](https://github.com/jwalton/node-amqp-connection-manager/commit/eeb6e2b)) 323 | 324 | # [2.1.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v2.0.0...v2.1.0) (2018-08-09) 325 | 326 | ### Features 327 | 328 | - Support for per URL connection options ([ec2d484](https://github.com/jwalton/node-amqp-connection-manager/commit/ec2d484)), closes [#29](https://github.com/jwalton/node-amqp-connection-manager/issues/29) [#34](https://github.com/jwalton/node-amqp-connection-manager/issues/34) [#44](https://github.com/jwalton/node-amqp-connection-manager/issues/44) 329 | 330 | 331 | 332 | # [2.0.0](https://github.com/jwalton/node-amqp-connection-manager/compare/v1.4.2...v2.0.0) (2018-05-05) 333 | 334 | ### Code Refactoring 335 | 336 | - Rewrite all source in javascript. ([377d01d](https://github.com/jwalton/node-amqp-connection-manager/commit/377d01d)) 337 | 338 | ### BREAKING CHANGES 339 | 340 | - Officially dropping support for node v4.x.x. 341 | 342 | # 1.4.0 343 | 344 | - Add 'blocked' and 'unblocked' events (#25). 345 | 346 | # 1.3.7 347 | 348 | - Fix bug where we would stop sending messages if remote gracefully closes connection. 349 | 350 | # 1.3.6 351 | 352 | - Fix bug where ChannelWrapper would expect setup function to return a Promise 353 | and not accept a callback if channel was already connected. 354 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project uses [semantic-release](https://github.com/semantic-release/semantic-release) 4 | so commit messages should follow [Angular commit message conventions](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines). 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # amqp-connection-manager 2 | 3 | [![NPM version](https://badge.fury.io/js/amqp-connection-manager.svg)](https://npmjs.org/package/amqp-connection-manager) 4 | ![Build Status](https://github.com/jwalton/node-amqp-connection-manager/workflows/GitHub%20CI/badge.svg) 5 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 6 | 7 | Connection management for amqplib. This is a wrapper around [amqplib](http://www.squaremobius.net/amqp.node/) which provides automatic reconnects. 8 | 9 | ## Features 10 | 11 | - Automatically reconnect when your [amqplib](http://www.squaremobius.net/amqp.node/) broker dies in a fire. 12 | - Round-robin connections between multiple brokers in a cluster. 13 | - If messages are sent while the broker is unavailable, queues messages in memory until we reconnect. 14 | - Supports both promises and callbacks (using [promise-breaker](https://github.com/jwalton/node-promise-breaker)) 15 | - Very un-opinionated library - a thin wrapper around [amqplib](http://www.squaremobius.net/amqp.node/). 16 | 17 | ## Installation 18 | 19 | ```sh 20 | npm install --save amqplib amqp-connection-manager 21 | ``` 22 | 23 | ## Basics 24 | 25 | The basic idea here is that, usually, when you create a new channel, you do some 26 | setup work at the beginning (like asserting that various queues or exchanges 27 | exist, or binding to queues), and then you send and receive messages and you 28 | never touch that stuff again. 29 | 30 | amqp-connection-manager will reconnect to a new broker whenever the broker it is 31 | currently connected to dies. When you ask amqp-connection-manager for a 32 | channel, you specify one or more `setup` functions to run; the setup functions 33 | will be run every time amqp-connection-manager reconnects, to make sure your 34 | channel and broker are in a sane state. 35 | 36 | Before we get into an example, note this example is written using Promises, 37 | however much like amqplib, any function which returns a Promise will also accept 38 | a callback as an optional parameter. 39 | 40 | Here's the example: 41 | 42 | ```js 43 | var amqp = require('amqp-connection-manager'); 44 | 45 | // Create a new connection manager 46 | var connection = amqp.connect(['amqp://localhost']); 47 | 48 | // Ask the connection manager for a ChannelWrapper. Specify a setup function to 49 | // run every time we reconnect to the broker. 50 | var channelWrapper = connection.createChannel({ 51 | json: true, 52 | setup: function (channel) { 53 | // `channel` here is a regular amqplib `ConfirmChannel`. 54 | // Note that `this` here is the channelWrapper instance. 55 | return channel.assertQueue('rxQueueName', { durable: true }); 56 | }, 57 | }); 58 | 59 | // Send some messages to the queue. If we're not currently connected, these will be queued up in memory 60 | // until we connect. Note that `sendToQueue()` and `publish()` return a Promise which is fulfilled or rejected 61 | // when the message is actually sent (or not sent.) 62 | channelWrapper 63 | .sendToQueue('rxQueueName', { hello: 'world' }) 64 | .then(function () { 65 | return console.log('Message was sent! Hooray!'); 66 | }) 67 | .catch(function (err) { 68 | return console.log('Message was rejected... Boo!'); 69 | }); 70 | ``` 71 | 72 | Sometimes it's handy to modify a channel at run time. For example, suppose you 73 | have a channel that's listening to one kind of message, and you decide you now 74 | also want to listen to some other kind of message. This can be done by adding a 75 | new setup function to an existing ChannelWrapper: 76 | 77 | ```js 78 | channelWrapper.addSetup(function (channel) { 79 | return Promise.all([ 80 | channel.assertQueue('my-queue', { exclusive: true, autoDelete: true }), 81 | channel.bindQueue('my-queue', 'my-exchange', 'create'), 82 | channel.consume('my-queue', handleMessage), 83 | ]); 84 | }); 85 | ``` 86 | 87 | `addSetup()` returns a Promise which resolves when the setup function is 88 | finished (or immediately, if the underlying connection is not currently 89 | connected to a broker.) There is also a `removeSetup(setup, teardown)` which 90 | will run `teardown(channel)` if the channel is currently connected to a broker 91 | (and will not run `teardown` at all otherwise.) Note that `setup` and `teardown` 92 | _must_ either accept a callback or return a Promise. 93 | 94 | See a complete example in the [examples](./examples) folder. 95 | 96 | ## API 97 | 98 | ### connect(urls, options) 99 | 100 | Creates a new AmqpConnectionManager, which will connect to one of the URLs provided in `urls`. If a broker is 101 | unreachable or dies, then AmqpConnectionManager will try the next available broker, round-robin. 102 | 103 | Options: 104 | 105 | - `options.heartbeatIntervalInSeconds` - Interval to send heartbeats to broker. Defaults to 5 seconds. 106 | - `options.reconnectTimeInSeconds` - The time to wait before trying to reconnect. If not specified, 107 | defaults to `heartbeatIntervalInSeconds`. 108 | - `options.findServers(callback)` is a function which returns one or more servers to connect to. This should 109 | return either a single URL or an array of URLs. This is handy when you're using a service discovery mechanism. 110 | such as Consul or etcd. Instead of taking a `callback`, this can also return a Promise. Note that if this 111 | is supplied, then `urls` is ignored. 112 | - `options.connectionOptions` is passed as options to the amqplib connect method. 113 | 114 | ### AmqpConnectionManager events 115 | 116 | - `connect({connection, url})` - Emitted whenever we successfully connect to a broker. 117 | - `connectFailed({err, url})` - Emitted whenever we attempt to connect to a broker, but fail. 118 | - `disconnect({err})` - Emitted whenever we disconnect from a broker. 119 | - `blocked({reason})` - Emitted whenever a connection is blocked by a broker 120 | - `unblocked` - Emitted whenever a connection is unblocked by a broker 121 | 122 | ### AmqpConnectionManager#createChannel(options) 123 | 124 | Create a new ChannelWrapper. This is a proxy for the actual channel (which may or may not exist at any moment, 125 | depending on whether or not we are currently connected.) 126 | 127 | Options: 128 | 129 | - `options.name` - Name for this channel. Used for debugging. 130 | - `options.setup(channel, [cb])` - A function to call whenever we reconnect to the 131 | broker (and therefore create a new underlying channel.) This function should 132 | either accept a callback, or return a Promise. See `addSetup` below. 133 | Note that `this` inside the setup function will the returned ChannelWrapper. 134 | The ChannelWrapper has a special `context` member you can use to store 135 | arbitrary data in. 136 | - `options.json` - if true, then ChannelWrapper assumes all messages passed to `publish()` and `sendToQueue()` 137 | are plain JSON objects. These will be encoded automatically before being sent. 138 | - `options.confirm` - if true (default), the created channel will be a ConfirmChannel 139 | - `options.publishTimeout` - a default timeout for messages published to this channel. 140 | 141 | ### AmqpConnectionManager#isConnected() 142 | 143 | Returns true if the AmqpConnectionManager is connected to a broker, false otherwise. 144 | 145 | ### AmqpConnectionManager#close() 146 | 147 | Close this AmqpConnectionManager and free all associated resources. 148 | 149 | ### ChannelWrapper events 150 | 151 | - `connect` - emitted every time this channel connects or reconnects. 152 | - `error(err, {name})` - emitted if an error occurs setting up the channel. 153 | - `close` - emitted when this channel closes via a call to `close()` 154 | 155 | ### ChannelWrapper#addSetup(setup) 156 | 157 | Adds a new 'setup handler'. 158 | 159 | `setup(channel, [cb])` is a function to call when a new underlying channel is created - handy for asserting 160 | exchanges and queues exists, and whatnot. The `channel` object here is a ConfirmChannel from amqplib. 161 | The `setup` function should return a Promise (or optionally take a callback) - no messages will be sent until 162 | this Promise resolves. 163 | 164 | If there is a connection, `setup()` will be run immediately, and the addSetup Promise/callback won't resolve 165 | until `setup` is complete. Note that in this case, if the setup throws an error, no 'error' event will 166 | be emitted, since you can just handle the error here (although the `setup` will still be added for future 167 | reconnects, even if it throws an error.) 168 | 169 | Setup functions should, ideally, not throw errors, but if they do then the ChannelWrapper will emit an 'error' 170 | event. 171 | 172 | ### ChannelWrapper#removeSetup(setup, teardown) 173 | 174 | Removes a setup handler. If the channel is currently connected, will call `teardown(channel)`, passing in the 175 | underlying amqplib ConfirmChannel. `teardown` should either take a callback or return a Promise. 176 | 177 | ### ChannelWrapper#publish and ChannelWrapper#sendToQueue 178 | 179 | These work exactly like their counterparts in amqplib's Channel, except that they return a Promise (or accept a 180 | callback) which resolves when the message is confirmed to have been delivered to the broker. The promise rejects if 181 | either the broker refuses the message, or if `close()` is called on the ChannelWrapper before the message can be 182 | delivered. 183 | 184 | Both of these functions take an additional option when passing options: 185 | 186 | - `timeout` - If specified, if a messages is not acked by the amqp broker within the specified number of milliseconds, 187 | the message will be rejected. Note that the message _may_ still end up getting delivered after the timeout, as we 188 | have no way to cancel the in-flight request. 189 | 190 | ### ChannelWrapper#ack and ChannelWrapper#nack 191 | 192 | These are just aliases for calling `ack()` and `nack()` on the underlying channel. They do nothing if the underlying 193 | channel is not connected. 194 | 195 | ### ChannelWrapper#queueLength() 196 | 197 | Returns a count of messages currently waiting to be sent to the underlying channel. 198 | 199 | ### ChannelWrapper#close() 200 | 201 | Close a channel, clean up resources associated with it. 202 | -------------------------------------------------------------------------------- /bin/build-types.sh: -------------------------------------------------------------------------------- 1 | cat >dist/cjs/package.json <dist/esm/package.json < console.log('Connected!')); 9 | connection.on('disconnect', err => console.log('Disconnected.', err.stack)); 10 | 11 | // Create a channel wrapper 12 | const channelWrapper = connection.createChannel({ 13 | json: true, 14 | setup: channel => channel.assertExchange(EXCHANGE_NAME, 'topic') 15 | }); 16 | 17 | // Send messages until someone hits CTRL-C or something goes wrong... 18 | function sendMessage() { 19 | channelWrapper.publish(EXCHANGE_NAME, "test", {time: Date.now()}, { contentType: 'application/json', persistent: true }) 20 | .then(function() { 21 | console.log("Message sent"); 22 | return wait(1000); 23 | }) 24 | .then(() => sendMessage()) 25 | .catch(err => { 26 | console.log("Message was rejected:", err.stack); 27 | channelWrapper.close(); 28 | connection.close(); 29 | }); 30 | }; 31 | 32 | console.log("Sending messages..."); 33 | sendMessage(); 34 | -------------------------------------------------------------------------------- /examples/pubsub-subscriber.js: -------------------------------------------------------------------------------- 1 | const amqp = require('..'); 2 | 3 | const QUEUE_NAME = 'amqp-connection-manager-sample2' 4 | const EXCHANGE_NAME = 'amqp-connection-manager-sample2-ex'; 5 | 6 | // Handle an incomming message. 7 | const onMessage = data => { 8 | var message = JSON.parse(data.content.toString()); 9 | console.log("subscriber: got message", message); 10 | channelWrapper.ack(data); 11 | } 12 | 13 | // Create a connetion manager 14 | const connection = amqp.connect(['amqp://localhost']); 15 | connection.on('connect', () => console.log('Connected!')); 16 | connection.on('disconnect', err => console.log('Disconnected.', err.stack)); 17 | 18 | // Set up a channel listening for messages in the queue. 19 | var channelWrapper = connection.createChannel({ 20 | setup: channel => 21 | // `channel` here is a regular amqplib `ConfirmChannel`. 22 | return Promise.all([ 23 | channel.assertQueue(QUEUE_NAME, { exclusive: true, autoDelete: true }), 24 | channel.assertExchange(EXCHANGE_NAME, 'topic'), 25 | channel.prefetch(1), 26 | channel.bindQueue(QUEUE_NAME, EXCHANGE_NAME, '#'), 27 | channel.consume(QUEUE_NAME, onMessage) 28 | ]) 29 | }); 30 | 31 | channelWrapper.waitForConnect() 32 | .then(function() { 33 | console.log("Listening for messages"); 34 | }); 35 | -------------------------------------------------------------------------------- /examples/receiver.js: -------------------------------------------------------------------------------- 1 | var amqp = require('..'); 2 | 3 | var QUEUE_NAME = 'amqp-connection-manager-sample1' 4 | 5 | // Handle an incomming message. 6 | var onMessage = function(data) { 7 | var message = JSON.parse(data.content.toString()); 8 | console.log("receiver: got message", message); 9 | channelWrapper.ack(data); 10 | } 11 | 12 | // Create a connetion manager 13 | var connection = amqp.connect(['amqp://localhost']); 14 | connection.on('connect', function() { 15 | console.log('Connected!'); 16 | }); 17 | connection.on('disconnect', function(err) { 18 | console.log('Disconnected.', err.stack); 19 | }); 20 | 21 | // Set up a channel listening for messages in the queue. 22 | var channelWrapper = connection.createChannel({ 23 | setup: function(channel) { 24 | // `channel` here is a regular amqplib `ConfirmChannel`. 25 | return Promise.all([ 26 | channel.assertQueue(QUEUE_NAME, {durable: true}), 27 | channel.prefetch(1), 28 | channel.consume(QUEUE_NAME, onMessage) 29 | ]); 30 | } 31 | }); 32 | 33 | channelWrapper.waitForConnect() 34 | .then(function() { 35 | console.log("Listening for messages"); 36 | }); 37 | -------------------------------------------------------------------------------- /examples/sender.js: -------------------------------------------------------------------------------- 1 | var amqp = require('..'); 2 | var wait = require('../lib/helpers').wait; 3 | 4 | var QUEUE_NAME = 'amqp-connection-manager-sample1' 5 | 6 | // Create a connetion manager 7 | var connection = amqp.connect(['amqp://localhost']); 8 | connection.on('connect', function() { 9 | console.log('Connected!'); 10 | }); 11 | connection.on('disconnect', function(err) { 12 | console.log('Disconnected.', err.stack); 13 | }); 14 | 15 | // Create a channel wrapper 16 | var channelWrapper = connection.createChannel({ 17 | json: true, 18 | setup: function(channel) { 19 | // `channel` here is a regular amqplib `ConfirmChannel`. 20 | return channel.assertQueue(QUEUE_NAME, {durable: true}); 21 | } 22 | }); 23 | 24 | // Send messages until someone hits CTRL-C or something goes wrong... 25 | var sendMessage = function() { 26 | channelWrapper.sendToQueue(QUEUE_NAME, {time: Date.now()}) 27 | .then(function() { 28 | console.log("Message sent"); 29 | return wait(1000); 30 | }) 31 | .then(function() { 32 | return sendMessage(); 33 | }).catch(function(err) { 34 | console.log("Message was rejected:", err.stack); 35 | channelWrapper.close(); 36 | connection.close(); 37 | }); 38 | }; 39 | 40 | console.log("Sending messages..."); 41 | sendMessage(); 42 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Transforms tell jest how to process our non-javascript files. 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | // Tells Jest what folders to ignore for tests 7 | testPathIgnorePatterns: [`node_modules`, `\\.cache`], 8 | testMatch: ['**/test/**/*Test.ts'], 9 | collectCoverageFrom: ['**/src/**.ts', '!**/node_modules/**', '!**/vendor/**'], 10 | resolver: 'jest-ts-webcompat-resolver', 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amqp-connection-manager", 3 | "version": "4.1.14", 4 | "description": "Auto-reconnect and round robin support for amqplib.", 5 | "module": "./dist/esm/index.js", 6 | "main": "./dist/cjs/index.js", 7 | "types": "./dist/types/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/types/index.d.ts", 11 | "import": "./dist/esm/index.js", 12 | "require": "./dist/cjs/index.js", 13 | "default": "./dist/cjs/index.js" 14 | } 15 | }, 16 | "files": [ 17 | "dist/**/*" 18 | ], 19 | "dependencies": { 20 | "promise-breaker": "^6.0.0" 21 | }, 22 | "peerDependencies": { 23 | "amqplib": "*" 24 | }, 25 | "devDependencies": { 26 | "@jwalton/semantic-release-config": "^1.0.0", 27 | "@semantic-release/changelog": "^6.0.1", 28 | "@semantic-release/git": "^10.0.1", 29 | "@types/amqplib": "^0.10.0", 30 | "@types/chai": "^4.2.21", 31 | "@types/chai-as-promised": "^7.1.4", 32 | "@types/chai-string": "^1.4.2", 33 | "@types/jest": "^29.2.0", 34 | "@types/node": "^20.0.0", 35 | "@types/whatwg-url": "^11.0.0", 36 | "@typescript-eslint/eslint-plugin": "^5.9.0", 37 | "@typescript-eslint/parser": "^5.9.0", 38 | "amqplib": "^0.10.3", 39 | "chai": "^4.1.2", 40 | "chai-as-promised": "^7.1.1", 41 | "chai-jest": "^1.0.2", 42 | "chai-string": "^1.1.2", 43 | "coveralls": "^3.1.0", 44 | "cross-env": "^7.0.2", 45 | "eslint": "^8.6.0", 46 | "eslint-config-prettier": "^8.3.0", 47 | "eslint-plugin-promise": "^6.0.0", 48 | "greenkeeper-lockfile": "^1.14.0", 49 | "husky": "^8.0.1", 50 | "istanbul": "^0.4.0", 51 | "jest": "^29.2.1", 52 | "jest-ts-webcompat-resolver": "^1.0.0", 53 | "lint-staged": "^13.0.3", 54 | "prettier": "^2.3.2", 55 | "pretty-quick": "^3.1.1", 56 | "promise-tools": "^2.1.0", 57 | "semantic-release": "^21.0.2", 58 | "ts-jest": "^29.0.3", 59 | "ts-node": "^10.2.1", 60 | "typescript": "^5.0.3" 61 | }, 62 | "engines": { 63 | "node": ">=10.0.0", 64 | "npm": ">5.0.0" 65 | }, 66 | "scripts": { 67 | "prepare": "husky install && npm run build", 68 | "prepublishOnly": "npm run build", 69 | "build": "tsc && tsc -p tsconfig.cjs.json && tsc -p tsconfig.types.json && ./bin/build-types.sh", 70 | "clean": "rm -rf dist types coverage", 71 | "test": "npm run test:lint && npm run test:unittest", 72 | "test:unittest": "tsc -p test && jest --coverage", 73 | "test:lint": "eslint --ext .ts src test", 74 | "semantic-release": "semantic-release" 75 | }, 76 | "repository": { 77 | "type": "git", 78 | "url": "https://github.com/jwalton/node-amqp-connection-manager" 79 | }, 80 | "lint-staged": { 81 | "**/*.ts": [ 82 | "eslint --ext .ts" 83 | ] 84 | }, 85 | "keywords": [ 86 | "amqp", 87 | "rabbitmq", 88 | "cluster", 89 | "amqplib" 90 | ], 91 | "author": "Jason Walton (https://github.com/jwalton)", 92 | "license": "MIT", 93 | "bugs": { 94 | "url": "https://github.com/jwalton/node-amqp-connection-manager/issues" 95 | }, 96 | "homepage": "https://github.com/jwalton/node-amqp-connection-manager" 97 | } 98 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "@jwalton/semantic-release-config", 3 | "verifyConditions": [ 4 | "@semantic-release/changelog", 5 | "@semantic-release/npm", 6 | "@semantic-release/git" 7 | ], 8 | "prepare": [ 9 | "@semantic-release/changelog", 10 | "@semantic-release/npm", 11 | "@semantic-release/git" 12 | ] 13 | }; 14 | -------------------------------------------------------------------------------- /src/AmqpConnectionManager.ts: -------------------------------------------------------------------------------- 1 | import * as amqp from 'amqplib'; 2 | import { EventEmitter, once } from 'events'; 3 | import { TcpSocketConnectOpts } from 'net'; 4 | import pb from 'promise-breaker'; 5 | import { ConnectionOptions } from 'tls'; 6 | import { URL } from 'url'; 7 | import ChannelWrapper, { CreateChannelOpts } from './ChannelWrapper.js'; 8 | import { wait } from './helpers.js'; 9 | 10 | // Default heartbeat time. 11 | const HEARTBEAT_IN_SECONDS = 5; 12 | 13 | export type ConnectionUrl = 14 | | string 15 | | amqp.Options.Connect 16 | | { url: string; connectionOptions?: AmqpConnectionOptions }; 17 | 18 | export interface ConnectListener { 19 | (arg: { connection: amqp.Connection; url: string | amqp.Options.Connect }): void; 20 | } 21 | 22 | export interface ConnectFailedListener { 23 | (arg: { err: Error; url: string | amqp.Options.Connect | undefined }): void; 24 | } 25 | 26 | export type AmqpConnectionOptions = (ConnectionOptions | TcpSocketConnectOpts) & { 27 | noDelay?: boolean; 28 | timeout?: number; 29 | keepAlive?: boolean; 30 | keepAliveDelay?: number; 31 | clientProperties?: any; 32 | credentials?: 33 | | { 34 | mechanism: string; 35 | username: string; 36 | password: string; 37 | response: () => Buffer; 38 | } 39 | | { 40 | mechanism: string; 41 | response: () => Buffer; 42 | } 43 | | undefined; 44 | }; 45 | 46 | export interface AmqpConnectionManagerOptions { 47 | /** Interval to send heartbeats to broker. Defaults to 5 seconds. */ 48 | heartbeatIntervalInSeconds?: number; 49 | 50 | /** 51 | * The time to wait before trying to reconnect. If not specified, defaults 52 | * to `heartbeatIntervalInSeconds`. 53 | */ 54 | reconnectTimeInSeconds?: number | undefined; 55 | 56 | /** 57 | * `findServers` is a function that which returns one or more servers to 58 | * connect to. This should return either a single URL or an array of URLs. 59 | * This is handy when you're using a service discovery mechanism such as 60 | * Consul or etcd. Instead of taking a callback, this can also return a 61 | * Promise. Note that if this is supplied, then `urls` is ignored. 62 | */ 63 | findServers?: 64 | | ((callback: (urls: ConnectionUrl | ConnectionUrl[]) => void) => void) 65 | | (() => Promise) 66 | | undefined; 67 | 68 | /** Connection options, passed as options to the amqplib.connect() method. */ 69 | connectionOptions?: AmqpConnectionOptions; 70 | } 71 | 72 | /* istanbul ignore next */ 73 | function neverThrows() { 74 | return (err: Error) => 75 | setImmediate(() => { 76 | throw new Error( 77 | `AmqpConnectionManager - should never get here: ${err.message}\n` + err.stack 78 | ); 79 | }); 80 | } 81 | 82 | export interface IAmqpConnectionManager { 83 | connectionOptions?: AmqpConnectionOptions; 84 | heartbeatIntervalInSeconds: number; 85 | reconnectTimeInSeconds: number; 86 | 87 | addListener(event: string, listener: (...args: any[]) => void): this; 88 | addListener(event: 'connect', listener: ConnectListener): this; 89 | addListener(event: 'connectFailed', listener: ConnectFailedListener): this; 90 | addListener(event: 'blocked', listener: (arg: { reason: string }) => void): this; 91 | addListener(event: 'unblocked', listener: () => void): this; 92 | addListener(event: 'disconnect', listener: (arg: { err: Error }) => void): this; 93 | 94 | // eslint-disable-next-line @typescript-eslint/ban-types 95 | listeners(eventName: string | symbol): Function[]; 96 | 97 | on(event: string, listener: (...args: any[]) => void): this; 98 | on(event: 'connect', listener: ConnectListener): this; 99 | on(event: 'connectFailed', listener: ConnectFailedListener): this; 100 | on(event: 'blocked', listener: (arg: { reason: string }) => void): this; 101 | on(event: 'unblocked', listener: () => void): this; 102 | on(event: 'disconnect', listener: (arg: { err: Error }) => void): this; 103 | 104 | once(event: string, listener: (...args: any[]) => void): this; 105 | once(event: 'connect', listener: ConnectListener): this; 106 | once(event: 'connectFailed', listener: ConnectFailedListener): this; 107 | once(event: 'blocked', listener: (arg: { reason: string }) => void): this; 108 | once(event: 'unblocked', listener: () => void): this; 109 | once(event: 'disconnect', listener: (arg: { err: Error }) => void): this; 110 | 111 | prependListener(event: string, listener: (...args: any[]) => void): this; 112 | prependListener(event: 'connect', listener: ConnectListener): this; 113 | prependListener(event: 'connectFailed', listener: ConnectFailedListener): this; 114 | prependListener(event: 'blocked', listener: (arg: { reason: string }) => void): this; 115 | prependListener(event: 'unblocked', listener: () => void): this; 116 | prependListener(event: 'disconnect', listener: (arg: { err: Error }) => void): this; 117 | 118 | prependOnceListener(event: string, listener: (...args: any[]) => void): this; 119 | prependOnceListener(event: 'connect', listener: ConnectListener): this; 120 | prependOnceListener(event: 'connectFailed', listener: ConnectFailedListener): this; 121 | prependOnceListener(event: 'blocked', listener: (arg: { reason: string }) => void): this; 122 | prependOnceListener(event: 'unblocked', listener: () => void): this; 123 | prependOnceListener(event: 'disconnect', listener: (arg: { err: Error }) => void): this; 124 | 125 | removeListener(event: string, listener: (...args: any[]) => void): this; 126 | 127 | connect(options?: { timeout?: number }): Promise; 128 | reconnect(): void; 129 | createChannel(options?: CreateChannelOpts): ChannelWrapper; 130 | close(): Promise; 131 | isConnected(): boolean; 132 | 133 | /** The current connection. */ 134 | readonly connection: amqp.Connection | undefined; 135 | 136 | /** Returns the number of registered channels. */ 137 | readonly channelCount: number; 138 | } 139 | 140 | // 141 | // Events: 142 | // * `connect({connection, url})` - Emitted whenever we connect to a broker. 143 | // * `connectFailed({err, url})` - Emitted whenever we fail to connect to a broker. 144 | // * `disconnect({err})` - Emitted whenever we disconnect from a broker. 145 | // * `blocked({reason})` - Emitted whenever connection is blocked by a broker. 146 | // * `unblocked()` - Emitted whenever connection is unblocked by a broker. 147 | // 148 | export default class AmqpConnectionManager extends EventEmitter implements IAmqpConnectionManager { 149 | private _channels: ChannelWrapper[]; 150 | private _currentUrl: number; 151 | private _closed = false; 152 | private _cancelRetriesHandler?: () => void; 153 | private _connectPromise?: Promise; 154 | private _currentConnection?: amqp.Connection; 155 | private _findServers: 156 | | ((callback: (urls: ConnectionUrl | ConnectionUrl[]) => void) => void) 157 | | (() => Promise); 158 | private _urls?: ConnectionUrl[]; 159 | 160 | public connectionOptions: AmqpConnectionOptions | undefined; 161 | public heartbeatIntervalInSeconds: number; 162 | public reconnectTimeInSeconds: number; 163 | 164 | /** 165 | * Create a new AmqplibConnectionManager. 166 | * 167 | * @param urls - An array of brokers to connect to. 168 | * Takes url strings or objects {url: string, connectionOptions?: object} 169 | * If present, a broker's [connectionOptions] will be used instead 170 | * of [options.connectionOptions] when passed to the amqplib connect method. 171 | * AmqplibConnectionManager will round-robin between them whenever it 172 | * needs to create a new connection. 173 | * @param [options={}] - 174 | * @param [options.heartbeatIntervalInSeconds=5] - The interval, 175 | * in seconds, to send heartbeats. 176 | * @param [options.reconnectTimeInSeconds] - The time to wait 177 | * before trying to reconnect. If not specified, defaults to 178 | * `heartbeatIntervalInSeconds`. 179 | * @param [options.connectionOptions] - Passed to the amqplib 180 | * connect method. 181 | * @param [options.findServers] - A `fn(callback)` or a `fn()` 182 | * which returns a Promise. This should resolve to one or more servers 183 | * to connect to, either a single URL or an array of URLs. This is handy 184 | * when you're using a service discovery mechanism such as Consul or etcd. 185 | * Note that if this is supplied, then `urls` is ignored. 186 | */ 187 | constructor( 188 | urls: ConnectionUrl | ConnectionUrl[] | undefined | null, 189 | options: AmqpConnectionManagerOptions = {} 190 | ) { 191 | super(); 192 | if (!urls && !options.findServers) { 193 | throw new Error('Must supply either `urls` or `findServers`'); 194 | } 195 | this._channels = []; 196 | 197 | this._currentUrl = 0; 198 | this.connectionOptions = options.connectionOptions; 199 | 200 | this.heartbeatIntervalInSeconds = 201 | options.heartbeatIntervalInSeconds || options.heartbeatIntervalInSeconds === 0 202 | ? options.heartbeatIntervalInSeconds 203 | : HEARTBEAT_IN_SECONDS; 204 | this.reconnectTimeInSeconds = 205 | options.reconnectTimeInSeconds || this.heartbeatIntervalInSeconds; 206 | 207 | // There will be one listener per channel, and there could be a lot of channels, so disable warnings from node. 208 | this.setMaxListeners(0); 209 | 210 | this._findServers = options.findServers || (() => Promise.resolve(urls)); 211 | } 212 | 213 | /** 214 | * Start the connect retries and await the first connect result. Even if the initial connect fails or timeouts, the 215 | * reconnect attempts will continue in the background. 216 | * @param [options={}] - 217 | * @param [options.timeout] - Time to wait for initial connect 218 | */ 219 | async connect({ timeout }: { timeout?: number } = {}): Promise { 220 | this._connect(); 221 | 222 | let reject: (reason?: any) => void; 223 | const onConnectFailed = ({ err }: { err: Error }) => { 224 | // Ignore disconnects caused bad credentials. 225 | if (err.message.includes('ACCESS-REFUSED') || err.message.includes('403')) { 226 | reject(err); 227 | } 228 | }; 229 | 230 | let waitTimeout; 231 | if (timeout) { 232 | waitTimeout = wait(timeout); 233 | } 234 | try { 235 | await Promise.race([ 236 | once(this, 'connect'), 237 | new Promise((_resolve, innerReject) => { 238 | reject = innerReject; 239 | this.on('connectFailed', onConnectFailed); 240 | }), 241 | ...(waitTimeout 242 | ? [ 243 | waitTimeout.promise.then(() => { 244 | throw new Error('amqp-connection-manager: connect timeout'); 245 | }), 246 | ] 247 | : []), 248 | ]); 249 | } finally { 250 | waitTimeout?.cancel(); 251 | this.removeListener('connectFailed', onConnectFailed); 252 | } 253 | } 254 | 255 | // `options` here are any options that can be passed to ChannelWrapper. 256 | createChannel(options: CreateChannelOpts = {}): ChannelWrapper { 257 | const channel = new ChannelWrapper(this, options); 258 | this._channels.push(channel); 259 | channel.once('close', () => { 260 | this._channels = this._channels.filter((c) => c !== channel); 261 | }); 262 | return channel; 263 | } 264 | 265 | close(): Promise { 266 | if (this._closed) { 267 | return Promise.resolve(); 268 | } 269 | this._closed = true; 270 | 271 | if (this._cancelRetriesHandler) { 272 | this._cancelRetriesHandler(); 273 | this._cancelRetriesHandler = undefined; 274 | } 275 | 276 | return Promise.resolve(this._connectPromise).then(() => { 277 | return Promise.all(this._channels.map((channel) => channel.close())) 278 | .catch(function () { 279 | // Ignore errors closing channels. 280 | }) 281 | .then(() => { 282 | this._channels = []; 283 | if (this._currentConnection) { 284 | this._currentConnection.removeAllListeners('close'); 285 | return this._currentConnection.close(); 286 | } else { 287 | return null; 288 | } 289 | }) 290 | .then(() => { 291 | this._currentConnection = undefined; 292 | }); 293 | }); 294 | } 295 | 296 | isConnected(): boolean { 297 | return !!this._currentConnection; 298 | } 299 | 300 | /** Force reconnect - noop unless connected */ 301 | reconnect(): void { 302 | if (this._closed) { 303 | throw new Error('cannot reconnect after close'); 304 | } 305 | 306 | // If we have a connection, close it and immediately connect again. 307 | // Wait for ordinary reconnect otherwise. 308 | if (this._currentConnection) { 309 | this._currentConnection.removeAllListeners(); 310 | this._currentConnection 311 | .close() 312 | .catch(() => { 313 | // noop 314 | }) 315 | .then(() => { 316 | this._currentConnection = undefined; 317 | this.emit('disconnect', { err: new Error('forced reconnect') }); 318 | return this._connect(); 319 | }) 320 | .catch(neverThrows); 321 | } 322 | } 323 | 324 | /** The current connection. */ 325 | get connection(): amqp.Connection | undefined { 326 | return this._currentConnection; 327 | } 328 | 329 | /** Returns the number of registered channels. */ 330 | get channelCount(): number { 331 | return this._channels.length; 332 | } 333 | 334 | private _connect(): Promise { 335 | if (this._connectPromise) { 336 | return this._connectPromise; 337 | } 338 | 339 | if (this._closed || this.isConnected()) { 340 | return Promise.resolve(null); 341 | } 342 | 343 | let attemptedUrl: string | amqp.Options.Connect | undefined; 344 | 345 | const result = (this._connectPromise = Promise.resolve() 346 | .then(() => { 347 | if (!this._urls || this._currentUrl >= this._urls.length) { 348 | this._currentUrl = 0; 349 | return pb.call(this._findServers, 0, null); 350 | } else { 351 | return this._urls; 352 | } 353 | }) 354 | .then((urls: ConnectionUrl | ConnectionUrl[] | undefined) => { 355 | if (Array.isArray(urls)) { 356 | this._urls = urls; 357 | } else if (urls) { 358 | this._urls = [urls]; 359 | } 360 | 361 | if (!this._urls || this._urls.length === 0) { 362 | throw new Error('amqp-connection-manager: No servers found'); 363 | } 364 | 365 | // Round robin between brokers 366 | const url = this._urls[this._currentUrl]; 367 | this._currentUrl++; 368 | 369 | // Set connectionOptions to the setting in the class instance (which came via the constructor) 370 | let connectionOptions: ConnectionOptions | undefined = this.connectionOptions; 371 | let originalUrl: string | amqp.Options.Connect; 372 | let connect: string | amqp.Options.Connect; 373 | 374 | if (typeof url === 'object' && 'url' in url) { 375 | originalUrl = connect = url.url; 376 | // If URL is an object, pull out any specific URL connectionOptions for it or use the 377 | // instance connectionOptions if none were provided for this specific URL. 378 | connectionOptions = url.connectionOptions || this.connectionOptions; 379 | } else if (typeof url === 'string') { 380 | originalUrl = connect = url; 381 | } else { 382 | originalUrl = url; 383 | connect = { 384 | ...url, 385 | heartbeat: url.heartbeat ?? this.heartbeatIntervalInSeconds, 386 | }; 387 | } 388 | attemptedUrl = originalUrl; 389 | 390 | // Add the `heartbeastIntervalInSeconds` to the connection options. 391 | if (typeof connect === 'string') { 392 | const u = new URL(connect); 393 | if (!u.searchParams.get('heartbeat')) { 394 | u.searchParams.set('heartbeat', `${this.heartbeatIntervalInSeconds}`); 395 | } 396 | connect = u.toString(); 397 | } 398 | 399 | return amqp.connect(connect, connectionOptions).then((connection) => { 400 | this._currentConnection = connection; 401 | 402 | //emit 'blocked' when RabbitMQ server decides to block the connection (resources running low) 403 | connection.on('blocked', (reason) => this.emit('blocked', { reason })); 404 | 405 | connection.on('unblocked', () => this.emit('unblocked')); 406 | 407 | connection.on('error', (/* err */) => { 408 | // if this event was emitted, then the connection was already closed, 409 | // so no need to call #close here 410 | // also, 'close' is emitted after 'error', 411 | // so no need for work already done in 'close' handler 412 | }); 413 | 414 | // Reconnect if the connection closes 415 | connection.on('close', (err) => { 416 | this._currentConnection = undefined; 417 | this.emit('disconnect', { err }); 418 | 419 | const handle = wait(this.reconnectTimeInSeconds * 1000); 420 | this._cancelRetriesHandler = handle.cancel; 421 | 422 | handle.promise 423 | .then(() => this._connect()) 424 | // `_connect()` should never throw. 425 | .catch(neverThrows); 426 | }); 427 | 428 | this._connectPromise = undefined; 429 | this.emit('connect', { connection, url: originalUrl }); 430 | 431 | // Need to return null here, or Bluebird will complain - #171. 432 | return null; 433 | }); 434 | }) 435 | .catch((err) => { 436 | this.emit('connectFailed', { err, url: attemptedUrl }); 437 | 438 | // Connection failed... 439 | this._currentConnection = undefined; 440 | this._connectPromise = undefined; 441 | 442 | let handle; 443 | if (err.name === 'OperationalError' && err.message === 'connect ETIMEDOUT') { 444 | handle = wait(0); 445 | } else { 446 | handle = wait(this.reconnectTimeInSeconds * 1000); 447 | } 448 | this._cancelRetriesHandler = handle.cancel; 449 | 450 | return handle.promise.then(() => this._connect()); 451 | })); 452 | 453 | return result; 454 | } 455 | } 456 | -------------------------------------------------------------------------------- /src/ChannelWrapper.ts: -------------------------------------------------------------------------------- 1 | import type * as amqplib from 'amqplib'; 2 | import { Options } from 'amqplib'; 3 | import * as crypto from 'crypto'; 4 | import { EventEmitter } from 'events'; 5 | import pb from 'promise-breaker'; 6 | import { promisify } from 'util'; 7 | import { IAmqpConnectionManager } from './AmqpConnectionManager.js'; 8 | 9 | const MAX_MESSAGES_PER_BATCH = 1000; 10 | 11 | const randomBytes = promisify(crypto.randomBytes); 12 | 13 | export type Channel = amqplib.ConfirmChannel | amqplib.Channel; 14 | 15 | export type SetupFunc = 16 | | ((channel: Channel, callback: (error?: Error) => void) => void) 17 | | ((channel: Channel) => Promise) 18 | | ((channel: amqplib.ConfirmChannel, callback: (error?: Error) => void) => void) 19 | | ((channel: amqplib.ConfirmChannel) => Promise); 20 | 21 | export interface CreateChannelOpts { 22 | /** Name for this channel. Used for debugging. */ 23 | name?: string; 24 | /** 25 | * A function to call whenever we reconnect to the broker (and therefore create a new underlying channel.) 26 | * This function should either accept a callback, or return a Promise. See addSetup below 27 | */ 28 | setup?: SetupFunc; 29 | /** 30 | * True to create a ConfirmChannel (default). False to create a regular Channel. 31 | */ 32 | confirm?: boolean; 33 | /** 34 | * if true, then ChannelWrapper assumes all messages passed to publish() and sendToQueue() are plain JSON objects. 35 | * These will be encoded automatically before being sent. 36 | */ 37 | json?: boolean; 38 | /** 39 | * Default publish timeout in ms. Messages not published within the given time are rejected with a timeout error. 40 | */ 41 | publishTimeout?: number; 42 | } 43 | 44 | interface PublishMessage { 45 | type: 'publish'; 46 | exchange: string; 47 | routingKey: string; 48 | content: Buffer; 49 | options?: amqplib.Options.Publish; 50 | resolve: (result: boolean) => void; 51 | reject: (err: Error) => void; 52 | timeout?: NodeJS.Timeout; 53 | isTimedout: boolean; 54 | } 55 | 56 | interface SendToQueueMessage { 57 | type: 'sendToQueue'; 58 | queue: string; 59 | content: Buffer; 60 | options?: amqplib.Options.Publish; 61 | resolve: (result: boolean) => void; 62 | reject: (err: Error) => void; 63 | timeout?: NodeJS.Timeout; 64 | isTimedout: boolean; 65 | } 66 | 67 | export interface PublishOptions extends Options.Publish { 68 | /** Message will be rejected after timeout ms */ 69 | timeout?: number; 70 | } 71 | 72 | export interface ConsumerOptions extends amqplib.Options.Consume { 73 | prefetch?: number; 74 | } 75 | 76 | export interface Consumer { 77 | consumerTag: string | null; 78 | queue: string; 79 | onMessage: (msg: amqplib.ConsumeMessage) => void; 80 | options: ConsumerOptions; 81 | } 82 | 83 | type Message = PublishMessage | SendToQueueMessage; 84 | 85 | const IRRECOVERABLE_ERRORS = [ 86 | 403, // AMQP Access Refused Error. 87 | 404, // AMQP Not Found Error. 88 | 406, // AMQP Precondition Failed Error. 89 | 501, // AMQP Frame Error. 90 | 502, // AMQP Frame Syntax Error. 91 | 503, // AMQP Invalid Command Error. 92 | 504, // AMQP Channel Not Open Error. 93 | 505, // AMQP Unexpected Frame. 94 | 530, // AMQP Not Allowed Error. 95 | 540, // AMQP Not Implemented Error. 96 | 541, // AMQP Internal Error. 97 | ]; 98 | 99 | /** 100 | * Calls to `publish()` or `sendToQueue()` work just like in amqplib, but messages are queued internally and 101 | * are guaranteed to be delivered. If the underlying connection drops, ChannelWrapper will wait for a new 102 | * connection and continue. 103 | * 104 | * Events: 105 | * * `connect` - emitted every time this channel connects or reconnects. 106 | * * `error(err, {name})` - emitted if an error occurs setting up the channel. 107 | * * `drop({message, err})` - called when a JSON message was dropped because it could not be encoded. 108 | * * `close` - emitted when this channel closes via a call to `close()` 109 | * 110 | */ 111 | export default class ChannelWrapper extends EventEmitter { 112 | private _connectionManager: IAmqpConnectionManager; 113 | private _json: boolean; 114 | 115 | /** If we're in the process of creating a channel, this is a Promise which 116 | * will resolve when the channel is set up. Otherwise, this is `null`. 117 | */ 118 | private _settingUp: Promise | undefined = undefined; 119 | private _setups: SetupFunc[]; 120 | /** Queued messages, not yet sent. */ 121 | private _messages: Message[] = []; 122 | /** Oublished, but not yet confirmed messages. */ 123 | private _unconfirmedMessages: Message[] = []; 124 | /** Reason code during publish or sendtoqueue messages. */ 125 | private _irrecoverableCode: number | undefined; 126 | /** Consumers which will be reconnected on channel errors etc. */ 127 | private _consumers: Consumer[] = []; 128 | 129 | /** 130 | * The currently connected channel. Note that not all setup functions 131 | * have been run on this channel until `@_settingUp` is either null or 132 | * resolved. 133 | */ 134 | private _channel?: Channel; 135 | 136 | /** 137 | * True to create a ConfirmChannel. False to create a regular Channel. 138 | */ 139 | private _confirm = true; 140 | /** 141 | * True if the "worker" is busy sending messages. False if we need to 142 | * start the worker to get stuff done. 143 | */ 144 | private _working = false; 145 | 146 | /** 147 | * We kill off workers when we disconnect. Whenever we start a new 148 | * worker, we bump up the `_workerNumber` - this makes it so if stale 149 | * workers ever do wake up, they'll know to stop working. 150 | */ 151 | private _workerNumber = 0; 152 | 153 | /** 154 | * True if the underlying channel has room for more messages. 155 | */ 156 | private _channelHasRoom = true; 157 | 158 | /** 159 | * Default publish timeout 160 | */ 161 | private _publishTimeout?: number; 162 | 163 | public name?: string; 164 | 165 | addListener(event: string, listener: (...args: any[]) => void): this; 166 | addListener(event: 'connect', listener: () => void): this; 167 | addListener(event: 'error', listener: (err: Error, info: { name: string }) => void): this; 168 | addListener(event: 'close', listener: () => void): this; 169 | addListener(event: string, listener: (...args: any[]) => void): this { 170 | return super.addListener(event, listener); 171 | } 172 | 173 | on(event: string, listener: (...args: any[]) => void): this; 174 | on(event: 'connect', listener: () => void): this; 175 | on(event: 'error', listener: (err: Error, info: { name: string }) => void): this; 176 | on(event: 'close', listener: () => void): this; 177 | on(event: string, listener: (...args: any[]) => void): this { 178 | return super.on(event, listener); 179 | } 180 | 181 | once(event: string, listener: (...args: any[]) => void): this; 182 | once(event: 'connect', listener: () => void): this; 183 | once(event: 'error', listener: (err: Error, info: { name: string }) => void): this; 184 | once(event: 'close', listener: () => void): this; 185 | once(event: string, listener: (...args: any[]) => void): this { 186 | return super.once(event, listener); 187 | } 188 | 189 | prependListener(event: string, listener: (...args: any[]) => void): this; 190 | prependListener(event: 'connect', listener: () => void): this; 191 | prependListener(event: 'error', listener: (err: Error, info: { name: string }) => void): this; 192 | prependListener(event: 'close', listener: () => void): this; 193 | prependListener(event: string, listener: (...args: any[]) => void): this { 194 | return super.prependListener(event, listener); 195 | } 196 | 197 | prependOnceListener(event: string, listener: (...args: any[]) => void): this; 198 | prependOnceListener(event: 'connect', listener: () => void): this; 199 | prependOnceListener( 200 | event: 'error', 201 | listener: (err: Error, info: { name: string }) => void 202 | ): this; 203 | prependOnceListener(event: 'close', listener: () => void): this; 204 | prependOnceListener(event: string, listener: (...args: any[]) => void): this { 205 | return super.prependOnceListener(event, listener); 206 | } 207 | 208 | /** 209 | * Adds a new 'setup handler'. 210 | * 211 | * `setup(channel, [cb])` is a function to call when a new underlying channel is created - handy for asserting 212 | * exchanges and queues exists, and whatnot. The `channel` object here is a ConfigChannel from amqplib. 213 | * The `setup` function should return a Promise (or optionally take a callback) - no messages will be sent until 214 | * this Promise resolves. 215 | * 216 | * If there is a connection, `setup()` will be run immediately, and the addSetup Promise/callback won't resolve 217 | * until `setup` is complete. Note that in this case, if the setup throws an error, no 'error' event will 218 | * be emitted, since you can just handle the error here (although the `setup` will still be added for future 219 | * reconnects, even if it throws an error.) 220 | * 221 | * Setup functions should, ideally, not throw errors, but if they do then the ChannelWrapper will emit an 'error' 222 | * event. 223 | * 224 | * @param setup - setup function. 225 | * @param [done] - callback. 226 | * @returns - Resolves when complete. 227 | */ 228 | addSetup(setup: SetupFunc, done?: pb.Callback): Promise { 229 | return pb.addCallback( 230 | done, 231 | (this._settingUp || Promise.resolve()).then(() => { 232 | this._setups.push(setup); 233 | if (this._channel) { 234 | return pb.call(setup, this, this._channel); 235 | } else { 236 | return undefined; 237 | } 238 | }) 239 | ); 240 | } 241 | 242 | /** 243 | * Remove a setup function added with `addSetup`. If there is currently a 244 | * connection, `teardown(channel, [cb])` will be run immediately, and the 245 | * returned Promise will not resolve until it completes. 246 | * 247 | * @param {function} setup - the setup function to remove. 248 | * @param {function} [teardown] - `function(channel, [cb])` to run to tear 249 | * down the channel. 250 | * @param {function} [done] - Optional callback. 251 | * @returns {void | Promise} - Resolves when complete. 252 | */ 253 | removeSetup(setup: SetupFunc, teardown?: SetupFunc, done?: pb.Callback): Promise { 254 | return pb.addCallback(done, () => { 255 | this._setups = this._setups.filter((s) => s !== setup); 256 | 257 | return (this._settingUp || Promise.resolve()).then(() => 258 | this._channel && teardown ? pb.call(teardown, this, this._channel) : undefined 259 | ); 260 | }); 261 | } 262 | 263 | /** 264 | * Returns a Promise which resolves when this channel next connects. 265 | * (Mainly here for unit testing...) 266 | * 267 | * @param [done] - Optional callback. 268 | * @returns - Resolves when connected. 269 | */ 270 | waitForConnect(done?: pb.Callback): Promise { 271 | return pb.addCallback( 272 | done, 273 | this._channel && !this._settingUp 274 | ? Promise.resolve() 275 | : new Promise((resolve) => this.once('connect', resolve)) 276 | ); 277 | } 278 | 279 | /* 280 | * Publish a message to the channel. 281 | * 282 | * This works just like amqplib's `publish()`, except if the channel is not 283 | * connected, this will wait until the channel is connected. Returns a 284 | * Promise which will only resolve when the message has been succesfully sent. 285 | * The returned promise will be rejected if `close()` is called on this 286 | * channel before it can be sent, if `options.json` is set and the message 287 | * can't be encoded, or if the broker rejects the message for some reason. 288 | * 289 | */ 290 | publish( 291 | exchange: string, 292 | routingKey: string, 293 | content: Buffer | string | unknown, 294 | options?: PublishOptions, 295 | done?: pb.Callback 296 | ): Promise { 297 | return pb.addCallback( 298 | done, 299 | new Promise((resolve, reject) => { 300 | const { timeout, ...opts } = options || {}; 301 | this._enqueueMessage( 302 | { 303 | type: 'publish', 304 | exchange, 305 | routingKey, 306 | content: this._getEncodedMessage(content), 307 | resolve, 308 | reject, 309 | options: opts, 310 | isTimedout: false, 311 | }, 312 | timeout || this._publishTimeout 313 | ); 314 | this._startWorker(); 315 | }) 316 | ); 317 | } 318 | 319 | /* 320 | * Send a message to a queue. 321 | * 322 | * This works just like amqplib's `sendToQueue`, except if the channel is not connected, this will wait until the 323 | * channel is connected. Returns a Promise which will only resolve when the message has been succesfully sent. 324 | * The returned promise will be rejected only if `close()` is called on this channel before it can be sent. 325 | * 326 | * `message` here should be a JSON-able object. 327 | */ 328 | sendToQueue( 329 | queue: string, 330 | content: Buffer | string | unknown, 331 | options?: PublishOptions, 332 | done?: pb.Callback 333 | ): Promise { 334 | const encodedContent = this._getEncodedMessage(content); 335 | 336 | return pb.addCallback( 337 | done, 338 | new Promise((resolve, reject) => { 339 | const { timeout, ...opts } = options || {}; 340 | this._enqueueMessage( 341 | { 342 | type: 'sendToQueue', 343 | queue, 344 | content: encodedContent, 345 | resolve, 346 | reject, 347 | options: opts, 348 | isTimedout: false, 349 | }, 350 | timeout || this._publishTimeout 351 | ); 352 | this._startWorker(); 353 | }) 354 | ); 355 | } 356 | 357 | private _enqueueMessage(message: Message, timeout?: number) { 358 | if (timeout) { 359 | message.timeout = setTimeout(() => { 360 | let idx = this._messages.indexOf(message); 361 | if (idx !== -1) { 362 | this._messages.splice(idx, 1); 363 | } else { 364 | idx = this._unconfirmedMessages.indexOf(message); 365 | if (idx !== -1) { 366 | this._unconfirmedMessages.splice(idx, 1); 367 | } 368 | } 369 | message.isTimedout = true; 370 | message.reject(new Error('timeout')); 371 | }, timeout); 372 | } 373 | this._messages.push(message); 374 | } 375 | 376 | /** 377 | * Create a new ChannelWrapper. 378 | * 379 | * @param connectionManager - connection manager which 380 | * created this channel. 381 | * @param [options] - 382 | * @param [options.name] - A name for this channel. Handy for debugging. 383 | * @param [options.setup] - A default setup function to call. See 384 | * `addSetup` for details. 385 | * @param [options.json] - if true, then ChannelWrapper assumes all 386 | * messages passed to `publish()` and `sendToQueue()` are plain JSON objects. 387 | * These will be encoded automatically before being sent. 388 | * 389 | */ 390 | constructor(connectionManager: IAmqpConnectionManager, options: CreateChannelOpts = {}) { 391 | super(); 392 | this._onConnect = this._onConnect.bind(this); 393 | this._onDisconnect = this._onDisconnect.bind(this); 394 | this._connectionManager = connectionManager; 395 | this._confirm = options.confirm ?? true; 396 | this.name = options.name; 397 | 398 | this._publishTimeout = options.publishTimeout; 399 | this._json = options.json ?? false; 400 | 401 | // Array of setup functions to call. 402 | this._setups = []; 403 | this._consumers = []; 404 | 405 | if (options.setup) { 406 | this._setups.push(options.setup); 407 | } 408 | 409 | const connection = connectionManager.connection; 410 | if (connection) { 411 | this._onConnect({ connection }); 412 | } 413 | connectionManager.on('connect', this._onConnect); 414 | connectionManager.on('disconnect', this._onDisconnect); 415 | } 416 | 417 | // Called whenever we connect to the broker. 418 | private async _onConnect({ connection }: { connection: amqplib.Connection }): Promise { 419 | this._irrecoverableCode = undefined; 420 | 421 | try { 422 | let channel: Channel; 423 | if (this._confirm) { 424 | channel = await connection.createConfirmChannel(); 425 | } else { 426 | channel = await connection.createChannel(); 427 | } 428 | 429 | this._channel = channel; 430 | this._channelHasRoom = true; 431 | channel.on('close', () => this._onChannelClose(channel)); 432 | channel.on('drain', () => this._onChannelDrain()); 433 | 434 | this._settingUp = Promise.all( 435 | this._setups.map((setupFn) => 436 | // TODO: Use a timeout here to guard against setupFns that never resolve? 437 | pb.call(setupFn, this, channel).catch((err) => { 438 | if (err.name === 'IllegalOperationError') { 439 | // Don't emit an error if setups failed because the channel closed. 440 | return; 441 | } 442 | this.emit('error', err, { name: this.name }); 443 | }) 444 | ) 445 | ) 446 | .then(() => { 447 | return Promise.all(this._consumers.map((c) => this._reconnectConsumer(c))); 448 | }) 449 | .then(() => { 450 | this._settingUp = undefined; 451 | }); 452 | await this._settingUp; 453 | 454 | if (!this._channel) { 455 | // Can happen if channel closes while we're setting up. 456 | return; 457 | } 458 | 459 | // Since we just connected, publish any queued messages 460 | this._startWorker(); 461 | this.emit('connect'); 462 | } catch (err) { 463 | this.emit('error', err, { name: this.name }); 464 | this._settingUp = undefined; 465 | this._channel = undefined; 466 | } 467 | } 468 | 469 | // Called whenever the channel closes. 470 | private _onChannelClose(channel: Channel): void { 471 | if (this._channel === channel) { 472 | this._channel = undefined; 473 | } 474 | // Wait for another reconnect to create a new channel. 475 | } 476 | 477 | /** Called whenever the channel drains. */ 478 | private _onChannelDrain(): void { 479 | this._channelHasRoom = true; 480 | this._startWorker(); 481 | } 482 | 483 | // Called whenever we disconnect from the AMQP server. 484 | private _onDisconnect(ex: { err: Error & { code: number } }): void { 485 | this._irrecoverableCode = ex.err instanceof Error ? ex.err.code : undefined; 486 | this._channel = undefined; 487 | this._settingUp = undefined; 488 | 489 | // Kill off the current worker. We never get any kind of error for messages in flight - see 490 | // https://github.com/squaremo/amqp.node/issues/191. 491 | this._working = false; 492 | } 493 | 494 | // Returns the number of unsent messages queued on this channel. 495 | queueLength(): number { 496 | return this._messages.length; 497 | } 498 | 499 | // Destroy this channel. 500 | // 501 | // Any unsent messages will have their associated Promises rejected. 502 | // 503 | close(): Promise { 504 | return Promise.resolve().then(() => { 505 | this._working = false; 506 | if (this._messages.length !== 0) { 507 | // Reject any unsent messages. 508 | this._messages.forEach((message) => { 509 | if (message.timeout) { 510 | clearTimeout(message.timeout); 511 | } 512 | message.reject(new Error('Channel closed')); 513 | }); 514 | } 515 | if (this._unconfirmedMessages.length !== 0) { 516 | // Reject any unconfirmed messages. 517 | this._unconfirmedMessages.forEach((message) => { 518 | if (message.timeout) { 519 | clearTimeout(message.timeout); 520 | } 521 | message.reject(new Error('Channel closed')); 522 | }); 523 | } 524 | 525 | this._connectionManager.removeListener('connect', this._onConnect); 526 | this._connectionManager.removeListener('disconnect', this._onDisconnect); 527 | const answer = (this._channel && this._channel.close()) || undefined; 528 | this._channel = undefined; 529 | 530 | this.emit('close'); 531 | 532 | return answer; 533 | }); 534 | } 535 | 536 | private _shouldPublish(): boolean { 537 | return ( 538 | this._messages.length > 0 && !this._settingUp && !!this._channel && this._channelHasRoom 539 | ); 540 | } 541 | 542 | // Start publishing queued messages, if there isn't already a worker doing this. 543 | private _startWorker(): void { 544 | if (!this._working && this._shouldPublish()) { 545 | this._working = true; 546 | this._workerNumber++; 547 | this._publishQueuedMessages(this._workerNumber); 548 | } 549 | } 550 | 551 | // Define if a message can cause irrecoverable error 552 | private _canWaitReconnection(): boolean { 553 | return !this._irrecoverableCode || !IRRECOVERABLE_ERRORS.includes(this._irrecoverableCode); 554 | } 555 | 556 | private _messageResolved(message: Message, result: boolean) { 557 | removeUnconfirmedMessage(this._unconfirmedMessages, message); 558 | message.resolve(result); 559 | } 560 | 561 | private _messageRejected(message: Message, err: Error) { 562 | if (!this._channel && this._canWaitReconnection()) { 563 | // Tried to write to a closed channel. Leave the message in the queue and we'll try again when 564 | // we reconnect. 565 | removeUnconfirmedMessage(this._unconfirmedMessages, message); 566 | this._messages.push(message); 567 | } else { 568 | // Something went wrong trying to send this message - could be JSON.stringify failed, could be 569 | // the broker rejected the message. Either way, reject it back 570 | removeUnconfirmedMessage(this._unconfirmedMessages, message); 571 | message.reject(err); 572 | } 573 | } 574 | 575 | private _getEncodedMessage(content: Buffer | string | unknown): Buffer { 576 | let encodedMessage: Buffer; 577 | 578 | if (this._json) { 579 | encodedMessage = Buffer.from(JSON.stringify(content)); 580 | } else if (typeof content === 'string') { 581 | encodedMessage = Buffer.from(content); 582 | } else if (content instanceof Buffer) { 583 | encodedMessage = content; 584 | } else if (typeof content === 'object' && typeof (content as any).toString === 'function') { 585 | encodedMessage = Buffer.from((content as any).toString()); 586 | } else { 587 | console.warn( 588 | 'amqp-connection-manager: Sending JSON message, but json option not speicifed' 589 | ); 590 | encodedMessage = Buffer.from(JSON.stringify(content)); 591 | } 592 | 593 | return encodedMessage; 594 | } 595 | 596 | private _publishQueuedMessages(workerNumber: number): void { 597 | const channel = this._channel; 598 | if ( 599 | !channel || 600 | !this._shouldPublish() || 601 | !this._working || 602 | workerNumber !== this._workerNumber 603 | ) { 604 | // Can't publish anything right now... 605 | this._working = false; 606 | return; 607 | } 608 | 609 | try { 610 | // Send messages in batches of 1000 - don't want to starve the event loop. 611 | let sendsLeft = MAX_MESSAGES_PER_BATCH; 612 | while (this._channelHasRoom && this._messages.length > 0 && sendsLeft > 0) { 613 | sendsLeft--; 614 | 615 | const message = this._messages.shift(); 616 | if (!message) { 617 | break; 618 | } 619 | 620 | let thisCanSend = true; 621 | 622 | switch (message.type) { 623 | case 'publish': { 624 | if (this._confirm) { 625 | this._unconfirmedMessages.push(message); 626 | thisCanSend = this._channelHasRoom = channel.publish( 627 | message.exchange, 628 | message.routingKey, 629 | message.content, 630 | message.options, 631 | (err) => { 632 | if (message.isTimedout) { 633 | return; 634 | } 635 | 636 | if (message.timeout) { 637 | clearTimeout(message.timeout); 638 | } 639 | 640 | if (err) { 641 | this._messageRejected(message, err); 642 | } else { 643 | this._messageResolved(message, thisCanSend); 644 | } 645 | } 646 | ); 647 | } else { 648 | if (message.timeout) { 649 | clearTimeout(message.timeout); 650 | } 651 | thisCanSend = this._channelHasRoom = channel.publish( 652 | message.exchange, 653 | message.routingKey, 654 | message.content, 655 | message.options 656 | ); 657 | message.resolve(thisCanSend); 658 | } 659 | break; 660 | } 661 | case 'sendToQueue': { 662 | if (this._confirm) { 663 | this._unconfirmedMessages.push(message); 664 | thisCanSend = this._channelHasRoom = channel.sendToQueue( 665 | message.queue, 666 | message.content, 667 | message.options, 668 | (err) => { 669 | if (message.isTimedout) { 670 | return; 671 | } 672 | 673 | if (message.timeout) { 674 | clearTimeout(message.timeout); 675 | } 676 | 677 | if (err) { 678 | this._messageRejected(message, err); 679 | } else { 680 | this._messageResolved(message, thisCanSend); 681 | } 682 | } 683 | ); 684 | } else { 685 | if (message.timeout) { 686 | clearTimeout(message.timeout); 687 | } 688 | thisCanSend = this._channelHasRoom = channel.sendToQueue( 689 | message.queue, 690 | message.content, 691 | message.options 692 | ); 693 | message.resolve(thisCanSend); 694 | } 695 | break; 696 | } 697 | /* istanbul ignore next */ 698 | default: 699 | throw new Error(`Unhandled message type ${(message as any).type}`); 700 | } 701 | } 702 | 703 | // If we didn't send all the messages, send some more... 704 | if (this._channelHasRoom && this._messages.length > 0) { 705 | setImmediate(() => this._publishQueuedMessages(workerNumber)); 706 | } else { 707 | this._working = false; 708 | } 709 | 710 | /* istanbul ignore next */ 711 | } catch (err) { 712 | this._working = false; 713 | this.emit('error', err); 714 | } 715 | } 716 | 717 | /** 718 | * Setup a consumer 719 | * This consumer will be reconnected on cancellation and channel errors. 720 | */ 721 | async consume( 722 | queue: string, 723 | onMessage: Consumer['onMessage'], 724 | options: ConsumerOptions = {} 725 | ): Promise { 726 | const consumerTag = options.consumerTag || (await randomBytes(16)).toString('hex'); 727 | const consumer: Consumer = { 728 | consumerTag: null, 729 | queue, 730 | onMessage, 731 | options: { 732 | ...options, 733 | consumerTag, 734 | }, 735 | }; 736 | 737 | if (this._settingUp) { 738 | await this._settingUp; 739 | } 740 | 741 | this._consumers.push(consumer); 742 | await this._consume(consumer); 743 | return { consumerTag }; 744 | } 745 | 746 | private async _consume(consumer: Consumer): Promise { 747 | if (!this._channel) { 748 | return; 749 | } 750 | 751 | const { prefetch, ...options } = consumer.options; 752 | if (typeof prefetch === 'number') { 753 | this._channel.prefetch(prefetch, false); 754 | } 755 | 756 | const { consumerTag } = await this._channel.consume( 757 | consumer.queue, 758 | (msg) => { 759 | if (!msg) { 760 | consumer.consumerTag = null; 761 | this._reconnectConsumer(consumer).catch((err) => { 762 | if (err.code === 404) { 763 | // Ignore errors caused by queue not declared. In 764 | // those cases the connection will reconnect and 765 | // then consumers reestablished. The full reconnect 766 | // might be avoided if we assert the queue again 767 | // before starting to consume. 768 | return; 769 | } 770 | this.emit('error', err); 771 | }); 772 | return; 773 | } 774 | consumer.onMessage(msg); 775 | }, 776 | options 777 | ); 778 | consumer.consumerTag = consumerTag; 779 | } 780 | 781 | private async _reconnectConsumer(consumer: Consumer): Promise { 782 | if (!this._consumers.includes(consumer)) { 783 | // Intentionally canceled 784 | return; 785 | } 786 | await this._consume(consumer); 787 | } 788 | 789 | /** 790 | * Cancel all consumers 791 | */ 792 | async cancelAll(): Promise { 793 | const consumers = this._consumers; 794 | this._consumers = []; 795 | if (!this._channel) { 796 | return; 797 | } 798 | 799 | const channel = this._channel; 800 | await Promise.all( 801 | consumers.reduce((acc, consumer) => { 802 | if (consumer.consumerTag) { 803 | acc.push(channel.cancel(consumer.consumerTag)); 804 | } 805 | return acc; 806 | }, []) 807 | ); 808 | } 809 | 810 | async cancel(consumerTag: string): Promise { 811 | const idx = this._consumers.findIndex((x) => x.options.consumerTag === consumerTag); 812 | if (idx === -1) { 813 | return; 814 | } 815 | 816 | const consumer = this._consumers[idx]; 817 | this._consumers.splice(idx, 1); 818 | if (this._channel && consumer.consumerTag) { 819 | await this._channel.cancel(consumer.consumerTag); 820 | } 821 | } 822 | 823 | /** Send an `ack` to the underlying channel. */ 824 | ack(message: amqplib.Message, allUpTo?: boolean): void { 825 | this._channel && this._channel.ack(message, allUpTo); 826 | } 827 | 828 | /** Send an `ackAll` to the underlying channel. */ 829 | ackAll(): void { 830 | this._channel && this._channel.ackAll(); 831 | } 832 | 833 | /** Send a `nack` to the underlying channel. */ 834 | nack(message: amqplib.Message, allUpTo?: boolean, requeue?: boolean): void { 835 | this._channel && this._channel.nack(message, allUpTo, requeue); 836 | } 837 | 838 | /** Send a `nackAll` to the underlying channel. */ 839 | nackAll(requeue?: boolean): void { 840 | this._channel && this._channel.nackAll(requeue); 841 | } 842 | 843 | /** Send a `purgeQueue` to the underlying channel. */ 844 | async purgeQueue(queue: string): Promise { 845 | if (this._channel) { 846 | return await this._channel.purgeQueue(queue); 847 | } else { 848 | throw new Error(`Not connected.`); 849 | } 850 | } 851 | 852 | /** Send a `checkQueue` to the underlying channel. */ 853 | async checkQueue(queue: string): Promise { 854 | if (this._channel) { 855 | return await this._channel.checkQueue(queue); 856 | } else { 857 | throw new Error(`Not connected.`); 858 | } 859 | } 860 | 861 | /** Send a `assertQueue` to the underlying channel. */ 862 | async assertQueue( 863 | queue: string, 864 | options?: amqplib.Options.AssertQueue 865 | ): Promise { 866 | if (this._channel) { 867 | return await this._channel.assertQueue(queue, options); 868 | } else { 869 | return { queue, messageCount: 0, consumerCount: 0 }; 870 | } 871 | } 872 | 873 | /** Send a `bindQueue` to the underlying channel. */ 874 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 875 | async bindQueue(queue: string, source: string, pattern: string, args?: any): Promise { 876 | if (this._channel) { 877 | await this._channel.bindQueue(queue, source, pattern, args); 878 | } 879 | } 880 | 881 | /** Send a `unbindQueue` to the underlying channel. */ 882 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 883 | async unbindQueue(queue: string, source: string, pattern: string, args?: any): Promise { 884 | if (this._channel) { 885 | await this._channel.unbindQueue(queue, source, pattern, args); 886 | } 887 | } 888 | 889 | /** Send a `deleteQueue` to the underlying channel. */ 890 | async deleteQueue( 891 | queue: string, 892 | options?: Options.DeleteQueue 893 | ): Promise { 894 | if (this._channel) { 895 | return await this._channel.deleteQueue(queue, options); 896 | } else { 897 | throw new Error(`Not connected.`); 898 | } 899 | } 900 | 901 | /** Send a `assertExchange` to the underlying channel. */ 902 | async assertExchange( 903 | exchange: string, 904 | type: 'direct' | 'topic' | 'headers' | 'fanout' | 'match' | string, 905 | options?: Options.AssertExchange 906 | ): Promise { 907 | if (this._channel) { 908 | return await this._channel.assertExchange(exchange, type, options); 909 | } else { 910 | return { exchange }; 911 | } 912 | } 913 | 914 | /** Send a `bindExchange` to the underlying channel. */ 915 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 916 | async bindExchange( 917 | destination: string, 918 | source: string, 919 | pattern: string, 920 | args?: any 921 | ): Promise { 922 | if (this._channel) { 923 | return await this._channel.bindExchange(destination, source, pattern, args); 924 | } else { 925 | throw new Error(`Not connected.`); 926 | } 927 | } 928 | 929 | /** Send a `checkExchange` to the underlying channel. */ 930 | async checkExchange(exchange: string): Promise { 931 | if (this._channel) { 932 | return await this._channel.checkExchange(exchange); 933 | } else { 934 | throw new Error(`Not connected.`); 935 | } 936 | } 937 | 938 | /** Send a `deleteExchange` to the underlying channel. */ 939 | async deleteExchange( 940 | exchange: string, 941 | options?: Options.DeleteExchange 942 | ): Promise { 943 | if (this._channel) { 944 | return await this._channel.deleteExchange(exchange, options); 945 | } else { 946 | throw new Error(`Not connected.`); 947 | } 948 | } 949 | 950 | /** Send a `unbindExchange` to the underlying channel. */ 951 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 952 | async unbindExchange( 953 | destination: string, 954 | source: string, 955 | pattern: string, 956 | args?: any 957 | ): Promise { 958 | if (this._channel) { 959 | return await this._channel.unbindExchange(destination, source, pattern, args); 960 | } else { 961 | throw new Error(`Not connected.`); 962 | } 963 | } 964 | 965 | /** Send a `get` to the underlying channel. */ 966 | async get(queue: string, options?: Options.Get): Promise { 967 | if (this._channel) { 968 | return await this._channel.get(queue, options); 969 | } else { 970 | throw new Error(`Not connected.`); 971 | } 972 | } 973 | } 974 | 975 | function removeUnconfirmedMessage(arr: Message[], message: Message) { 976 | const toRemove = arr.indexOf(message); 977 | if (toRemove === -1) { 978 | throw new Error(`Message is not in _unconfirmedMessages!`); 979 | } 980 | const removed = arr.splice(toRemove, 1); 981 | return removed[0]; 982 | } 983 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export function wait(timeInMs: number): { promise: Promise; cancel: () => void } { 2 | let timeoutHandle: NodeJS.Timeout; 3 | 4 | return { 5 | promise: new Promise(function (resolve) { 6 | timeoutHandle = setTimeout(resolve, timeInMs); 7 | }), 8 | cancel: () => clearTimeout(timeoutHandle), 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | import AmqpConnectionManager, { 3 | AmqpConnectionManagerOptions, 4 | ConnectionUrl, 5 | IAmqpConnectionManager, 6 | } from './AmqpConnectionManager.js'; 7 | import CW, { PublishOptions } from './ChannelWrapper.js'; 8 | 9 | export type { 10 | AmqpConnectionManagerOptions, 11 | ConnectionUrl, 12 | IAmqpConnectionManager as AmqpConnectionManager, 13 | } from './AmqpConnectionManager.js'; 14 | export type { CreateChannelOpts, SetupFunc, Channel } from './ChannelWrapper.js'; 15 | export type ChannelWrapper = CW; 16 | 17 | import { Options as AmqpLibOptions } from 'amqplib'; 18 | 19 | export namespace Options { 20 | export type Connect = AmqpLibOptions.Connect; 21 | export type AssertQueue = AmqpLibOptions.AssertQueue; 22 | export type DeleteQueue = AmqpLibOptions.DeleteQueue; 23 | export type AssertExchange = AmqpLibOptions.AssertExchange; 24 | export type DeleteExchange = AmqpLibOptions.DeleteExchange; 25 | export type Publish = PublishOptions; 26 | export type Consume = AmqpLibOptions.Consume; 27 | export type Get = AmqpLibOptions.Get; 28 | } 29 | 30 | export function connect( 31 | urls: ConnectionUrl | ConnectionUrl[] | undefined | null, 32 | options?: AmqpConnectionManagerOptions 33 | ): IAmqpConnectionManager { 34 | const conn = new AmqpConnectionManager(urls, options); 35 | conn.connect().catch(() => { 36 | /* noop */ 37 | }); 38 | return conn; 39 | } 40 | 41 | export { AmqpConnectionManager as AmqpConnectionManagerClass }; 42 | 43 | const amqp = { connect }; 44 | 45 | export default amqp; 46 | -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'import/extensions': 'off', 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /test/AmqpConnectionManagerTest.ts: -------------------------------------------------------------------------------- 1 | import origAmqp from 'amqplib'; 2 | import chai from 'chai'; 3 | import chaiString from 'chai-string'; 4 | import { once } from 'events'; 5 | import * as promiseTools from 'promise-tools'; 6 | import AmqpConnectionManager from '../src/AmqpConnectionManager'; 7 | import { FakeAmqp, FakeConnection } from './fixtures'; 8 | 9 | chai.use(chaiString); 10 | const { expect } = chai; 11 | 12 | const amqplib = new FakeAmqp(); 13 | 14 | describe('AmqpConnectionManager', function () { 15 | let amqp: AmqpConnectionManager | undefined; 16 | 17 | beforeEach(() => { 18 | jest.spyOn(origAmqp, 'connect').mockImplementation(((url: string) => 19 | amqplib.connect(url)) as any); 20 | amqplib.reset(); 21 | }); 22 | 23 | afterEach(() => { 24 | amqp?.close(); 25 | jest.restoreAllMocks(); 26 | }); 27 | 28 | it('should establish a connection to a broker', async () => { 29 | amqp = new AmqpConnectionManager('amqp://localhost'); 30 | amqp.connect(); 31 | const [{ connection, url }] = await once(amqp, 'connect'); 32 | expect(url, 'url').to.equal('amqp://localhost'); 33 | expect(connection.url, 'connection.url').to.equal('amqp://localhost?heartbeat=5'); 34 | }); 35 | 36 | it('should establish a connection to a broker, using an object as the URL', async () => { 37 | amqp = new AmqpConnectionManager({ 38 | protocol: 'amqp', 39 | hostname: 'localhost', 40 | }); 41 | amqp.connect(); 42 | const [{ connection, url }] = await once(amqp, 'connect'); 43 | expect(url, 'url').to.eql({ 44 | protocol: 'amqp', 45 | hostname: 'localhost', 46 | }); 47 | expect((connection as any).url, 'connection.url').to.eql({ 48 | protocol: 'amqp', 49 | hostname: 'localhost', 50 | heartbeat: 5, 51 | }); 52 | }); 53 | 54 | it('should establish a url object based connection to a broker', async () => { 55 | amqp = new AmqpConnectionManager({ url: 'amqp://localhost' }); 56 | amqp.connect(); 57 | const [{ connection, url }] = await once(amqp, 'connect'); 58 | expect(url, 'url').to.equal('amqp://localhost'); 59 | expect(connection.url, 'connection.url').to.equal('amqp://localhost?heartbeat=5'); 60 | }); 61 | 62 | it('should establish a connection to a broker disabling heartbeat', async () => { 63 | amqp = new AmqpConnectionManager('amqp://localhost', { 64 | heartbeatIntervalInSeconds: 0, 65 | }); 66 | amqp.connect(); 67 | const [{ connection, url }] = await once(amqp, 'connect'); 68 | expect(url, 'url').to.equal('amqp://localhost'); 69 | expect(connection.url, 'connection.url').to.equal('amqp://localhost?heartbeat=0'); 70 | }); 71 | 72 | it('should close connection to a broker', async () => { 73 | amqp = new AmqpConnectionManager('amqp://localhost'); 74 | amqp.connect(); 75 | const [{ connection, url }] = await once(amqp, 'connect'); 76 | expect(url, 'url').to.equal('amqp://localhost'); 77 | expect((connection as any).url, 'connection.url').to.equal('amqp://localhost?heartbeat=5'); 78 | const conn = amqp.connection; 79 | await amqp?.close(); 80 | 81 | expect(amqp?.connection, 'current connection').to.be.undefined; 82 | expect((conn as any)._closed, 'connection closed').to.be.true; 83 | }); 84 | 85 | // /** 86 | // * When close() was called before _connect() finished, the connection was remaining established indefinitely 87 | // */ 88 | it('should close pending connection to a broker', async () => { 89 | let closed = false; 90 | let connected = false; 91 | 92 | amqp = new AmqpConnectionManager('amqp://localhost'); 93 | amqp.connect(); 94 | // Connection should not yet be established 95 | expect(amqp.connection, 'current connection').to.equal(undefined); 96 | // Connection should be pending though 97 | expect((amqp as any)._connectPromise).to.be.an.instanceof(Promise); 98 | 99 | // Call close before the connection is established 100 | const closePromise = amqp.close().then(() => { 101 | closed = true; 102 | 103 | // Connection should not present after close 104 | expect(amqp?.connection, 'current connection').to.be.undefined; 105 | // Connection promise should not be present anymore 106 | expect((amqp as any)._connectPromise).to.be.undefined; 107 | // Connect should resolve before close 108 | expect(connected).to.equal(true); 109 | }); 110 | 111 | // This prevents double call to close() 112 | expect((amqp as any)._closed).to.equal(true); 113 | 114 | // Wait for connect before checking amqp?.connection 115 | const connectPromise = new Promise((resolve, reject) => { 116 | // I tried to use once helper from events module but 117 | // does not work with babel for some reason 118 | amqp?.once('connect', resolve); 119 | amqp?.once('error', reject); 120 | }).then(() => { 121 | connected = true; 122 | 123 | // Connection should be present right after connect 124 | expect(amqp?.connection, 'current connection').to.be.an.instanceof(FakeConnection); 125 | // Connection promise should not be present anymore 126 | expect((amqp as any)._connectPromise).to.be.undefined; 127 | // Connect should resolve before close 128 | expect(closed).to.equal(false); 129 | }); 130 | 131 | await Promise.all([closePromise, connectPromise]); 132 | }); 133 | 134 | it('should establish a connection to a broker using findServers', async () => { 135 | amqp = new AmqpConnectionManager(null, { 136 | findServers() { 137 | return Promise.resolve('amqp://localhost'); 138 | }, 139 | }); 140 | amqp.connect(); 141 | const [{ connection, url }] = await once(amqp, 'connect'); 142 | expect(url, 'url').to.equal('amqp://localhost'); 143 | expect(connection.url, 'connection.url').to.equal('amqp://localhost?heartbeat=5'); 144 | }); 145 | 146 | it('should establish a url object based connection to a broker using findServers', async () => { 147 | amqp = new AmqpConnectionManager(null, { 148 | findServers() { 149 | return Promise.resolve({ url: 'amqp://localhost' }); 150 | }, 151 | }); 152 | amqp.connect(); 153 | const [{ connection, url }] = await once(amqp, 'connect'); 154 | expect(url, 'url').to.equal('amqp://localhost'); 155 | expect(connection.url, 'connection.url').to.equal('amqp://localhost?heartbeat=5'); 156 | }); 157 | 158 | it('should fail to connect if findServers returns no servers', async () => { 159 | amqp = new AmqpConnectionManager(null, { 160 | findServers() { 161 | return Promise.resolve(null); 162 | }, 163 | }); 164 | 165 | amqp.connect(); 166 | const [{ err }] = await once(amqp, 'connectFailed'); 167 | expect(err.message).to.contain('No servers found'); 168 | return amqp?.close(); 169 | }); 170 | 171 | it('should timeout connect', async () => { 172 | jest.spyOn(origAmqp, 'connect').mockImplementation((): any => { 173 | return promiseTools.delay(200); 174 | }); 175 | amqp = new AmqpConnectionManager('amqp://localhost'); 176 | let err; 177 | try { 178 | await amqp.connect({ timeout: 0.1 }); 179 | } catch (error: any) { 180 | err = error; 181 | } 182 | expect(err.message).to.equal('amqp-connection-manager: connect timeout'); 183 | }); 184 | 185 | it('should work with a URL with a query', async () => { 186 | amqp = new AmqpConnectionManager('amqp://localhost?frameMax=0x1000'); 187 | amqp.connect(); 188 | const [{ connection }] = await once(amqp, 'connect'); 189 | expect(connection.url, 'connection.url').to.equal( 190 | 'amqp://localhost?frameMax=0x1000&heartbeat=5' 191 | ); 192 | }); 193 | 194 | it('should throw an error if no url and no `findServers` option are provided', async () => { 195 | expect(() => new (AmqpConnectionManager as any)()).to.throw( 196 | 'Must supply either `urls` or `findServers`' 197 | ); 198 | }); 199 | 200 | it("should reconnect to the broker if it can't connect in the first place", async () => { 201 | amqplib.deadServers = ['amqp://rabbit1']; 202 | 203 | // Should try to connect to rabbit1 first and be refused, and then successfully connect to rabbit2. 204 | amqp = new AmqpConnectionManager(['amqp://rabbit1', 'amqp://rabbit2'], { 205 | heartbeatIntervalInSeconds: 0.01, 206 | }); 207 | amqp.connect(); 208 | 209 | let connectFailedSeen = 0; 210 | amqp.on('connectFailed', function () { 211 | connectFailedSeen++; 212 | amqplib.failConnections = false; 213 | }); 214 | 215 | const [{ connection, url }] = await once(amqp, 'connect'); 216 | expect(connectFailedSeen).to.equal(1); 217 | 218 | // Verify that we round-robined to the next server, since the first was unavailable. 219 | expect(url, 'url').to.equal('amqp://rabbit2'); 220 | if (typeof url !== 'string') { 221 | throw new Error('url is not a string'); 222 | } 223 | expect((connection as any).url, 'connection.url').to.startWith(url); 224 | }); 225 | 226 | it('should reconnect to the broker if the broker disconnects', async () => { 227 | amqp = new AmqpConnectionManager('amqp://localhost', { 228 | heartbeatIntervalInSeconds: 0.01, 229 | }); 230 | let disconnectsSeen = 0; 231 | amqp.on('disconnect', () => disconnectsSeen++); 232 | 233 | await amqp.connect(); 234 | amqplib.kill(); 235 | 236 | await amqp.connect(); 237 | expect(disconnectsSeen).to.equal(1); 238 | }); 239 | 240 | it('should reconnect to the broker if the broker closed connection', async () => { 241 | amqp = new AmqpConnectionManager('amqp://localhost', { 242 | heartbeatIntervalInSeconds: 0.01, 243 | }); 244 | 245 | let disconnectsSeen = 0; 246 | amqp.on('disconnect', () => disconnectsSeen++); 247 | 248 | await amqp.connect(); 249 | 250 | // Close the connection nicely 251 | amqplib.simulateRemoteClose(); 252 | 253 | await once(amqp, 'connect'); 254 | expect(disconnectsSeen).to.equal(1); 255 | }); 256 | 257 | it('should know if it is connected or not', async () => { 258 | amqp = new AmqpConnectionManager('amqp://localhost'); 259 | amqp.connect(); 260 | 261 | expect(amqp.isConnected()).to.be.false; 262 | 263 | await once(amqp, 'connect'); 264 | expect(amqp?.isConnected()).to.be.true; 265 | }); 266 | 267 | it('should be able to manually reconnect', async () => { 268 | amqp = new AmqpConnectionManager('amqp://localhost'); 269 | await amqp.connect(); 270 | 271 | amqp.reconnect(); 272 | await once(amqp, 'disconnect'); 273 | await once(amqp, 'connect'); 274 | }); 275 | 276 | it('should throw on manual reconnect after close', async () => { 277 | amqp = new AmqpConnectionManager('amqp://localhost'); 278 | await amqp.connect(); 279 | await amqp.close(); 280 | expect(amqp.reconnect).to.throw(); 281 | }); 282 | 283 | it('should create and clean up channel wrappers', async function () { 284 | amqp = new AmqpConnectionManager('amqp://localhost'); 285 | await amqp.connect(); 286 | const channel = amqp.createChannel({ name: 'test-chan' }); 287 | 288 | // Channel should register with connection manager 289 | expect(amqp.channelCount, 'registered channels').to.equal(1); 290 | expect(amqp.listeners('connect').length, 'connect listners').to.equal(1); 291 | expect(amqp.listeners('disconnect').length, 'disconnect listners').to.equal(1); 292 | 293 | // Closing the channel should remove all listeners and de-register the channel 294 | await channel.close(); 295 | 296 | expect(amqp.channelCount, 'registered channels after close').to.equal(0); 297 | expect(amqp.listeners('connect').length, 'connect listners after close').to.equal(0); 298 | expect(amqp.listeners('disconnect').length, 'disconnect listners after close').to.equal(0); 299 | }); 300 | 301 | it('should clean up channels on close', async function () { 302 | amqp = new AmqpConnectionManager('amqp://localhost'); 303 | await amqp.connect(); 304 | amqp.createChannel({ name: 'test-chan' }); 305 | 306 | // Channel should register with connection manager 307 | expect(amqp.channelCount, 'registered channels').to.equal(1); 308 | expect(amqp.listeners('connect').length, 'connect listners').to.equal(1); 309 | expect(amqp.listeners('disconnect').length, 'disconnect listners').to.equal(1); 310 | 311 | // Closing the connection should remove all listeners and de-register the channel 312 | await amqp.close(); 313 | 314 | expect(amqp.channelCount, 'registered channels after close').to.equal(0); 315 | expect(amqp.listeners('connect').length, 'connect listners after close').to.equal(0); 316 | expect(amqp.listeners('disconnect').length, 'disconnect listners after close').to.equal(0); 317 | }); 318 | 319 | it('should not reconnect after close', async () => { 320 | amqp = new AmqpConnectionManager('amqp://localhost', { 321 | heartbeatIntervalInSeconds: 0.01, 322 | }); 323 | 324 | let connectsSeen = 0; 325 | amqp.on('connect', () => connectsSeen++); 326 | await amqp.connect(); 327 | 328 | // Close the manager 329 | await amqp?.close(); 330 | 331 | // Murder the broker on the first connect 332 | amqplib.kill(); 333 | 334 | await promiseTools.delay(50); 335 | expect(connectsSeen).to.equal(1); 336 | }); 337 | 338 | it('should detect connection block/unblock', async () => { 339 | amqp = new AmqpConnectionManager('amqp://localhost'); 340 | 341 | let blockSeen = 0; 342 | let unblockSeen = 0; 343 | 344 | amqp.on('blocked', () => blockSeen++); 345 | 346 | amqp.on('unblocked', () => unblockSeen++); 347 | 348 | await amqp.connect(); 349 | // Close the connection nicely 350 | amqplib.simulateRemoteBlock(); 351 | amqplib.simulateRemoteUnblock(); 352 | 353 | expect(blockSeen).to.equal(1); 354 | expect(unblockSeen).to.equal(1); 355 | }); 356 | }); 357 | -------------------------------------------------------------------------------- /test/ChannelWrapperTest.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | 3 | import * as amqplib from 'amqplib'; 4 | import chai from 'chai'; 5 | import chaiAsPromised from 'chai-as-promised'; 6 | import chaiJest from 'chai-jest'; 7 | import chaiString from 'chai-string'; 8 | import * as promiseTools from 'promise-tools'; 9 | import ChannelWrapper, { SetupFunc } from '../src/ChannelWrapper'; 10 | import * as fixtures from './fixtures'; 11 | 12 | chai.use(chaiString); 13 | chai.use(chaiJest); 14 | chai.use(chaiAsPromised); 15 | const { expect } = chai; 16 | 17 | function makeMessage(content: string): amqplib.Message { 18 | return { 19 | content: Buffer.from(content), 20 | fields: { 21 | deliveryTag: 0, 22 | exchange: 'exchange', 23 | redelivered: false, 24 | routingKey: 'routingKey', 25 | }, 26 | properties: { 27 | headers: {}, 28 | } as any, 29 | }; 30 | } 31 | 32 | function getUnderlyingChannel( 33 | channelWrapper: ChannelWrapper 34 | ): fixtures.FakeConfirmChannel | fixtures.FakeChannel { 35 | const channel = (channelWrapper as any)._channel; 36 | if (!channel) { 37 | throw new Error('No underlying channel'); 38 | } 39 | return channel; 40 | } 41 | 42 | describe('ChannelWrapper', function () { 43 | let connectionManager: fixtures.FakeAmqpConnectionManager; 44 | 45 | beforeEach(function () { 46 | connectionManager = new fixtures.FakeAmqpConnectionManager() as any; 47 | }); 48 | 49 | it('should run all setup functions on connect', async function () { 50 | const setup1 = jest.fn().mockImplementation(() => promiseTools.delay(10)); 51 | const setup2 = jest.fn().mockImplementation(() => promiseTools.delay(10)); 52 | 53 | const channelWrapper = new ChannelWrapper(connectionManager, { setup: setup1 }); 54 | 55 | await channelWrapper.addSetup(setup2); 56 | 57 | expect(setup1).to.have.beenCalledTimes(0); 58 | expect(setup2).to.have.beenCalledTimes(0); 59 | 60 | connectionManager.simulateConnect(); 61 | 62 | await channelWrapper.waitForConnect(); 63 | 64 | expect(setup1).to.have.beenCalledTimes(1); 65 | expect(setup2).to.have.beenCalledTimes(1); 66 | }); 67 | 68 | it('should run all setup functions on reconnect', async function () { 69 | const setup1 = jest.fn().mockImplementation(() => Promise.resolve()); 70 | const setup2 = jest.fn().mockImplementation(() => Promise.resolve()); 71 | 72 | const channelWrapper = new ChannelWrapper(connectionManager, { setup: setup1 }); 73 | await channelWrapper.addSetup(setup2); 74 | 75 | connectionManager.simulateConnect(); 76 | await channelWrapper.waitForConnect(); 77 | 78 | expect(setup1).to.have.beenCalledTimes(1); 79 | expect(setup2).to.have.beenCalledTimes(1); 80 | 81 | connectionManager.simulateDisconnect(); 82 | connectionManager.simulateConnect(); 83 | await channelWrapper.waitForConnect(); 84 | 85 | expect(setup1).to.have.beenCalledTimes(2); 86 | expect(setup2).to.have.beenCalledTimes(2); 87 | }); 88 | 89 | it('should set `this` correctly in a setup function', async function () { 90 | let whatIsThis; 91 | 92 | const channelWrapper = new ChannelWrapper(connectionManager, { 93 | setup() { 94 | // eslint-disable-next-line @typescript-eslint/no-this-alias 95 | whatIsThis = this; 96 | }, 97 | }); 98 | 99 | connectionManager.simulateConnect(); 100 | await channelWrapper.waitForConnect(); 101 | 102 | expect(whatIsThis).to.equal(channelWrapper); 103 | }); 104 | 105 | it('should emit an error if a setup function throws', async function () { 106 | const setup1 = jest.fn().mockImplementation(() => Promise.resolve()); 107 | const setup2 = jest.fn().mockImplementation(() => Promise.reject(new Error('Boom!'))); 108 | const errors = []; 109 | 110 | const channelWrapper = new ChannelWrapper(connectionManager, { 111 | setup: setup1, 112 | }); 113 | 114 | channelWrapper.on('error', (err) => errors.push(err)); 115 | 116 | await channelWrapper.addSetup(setup2); 117 | 118 | connectionManager.simulateConnect(); 119 | 120 | await promiseTools.whilst( 121 | () => setup2.mock.calls.length === 0, 122 | () => promiseTools.delay(10) 123 | ); 124 | 125 | expect(setup1).to.have.beenCalledTimes(1); 126 | expect(setup2).to.have.beenCalledTimes(1); 127 | expect(errors.length).to.equal(1); 128 | }); 129 | 130 | it('should not emit an error if a setup function throws because the channel is closed', async function () { 131 | const setup1 = jest 132 | .fn() 133 | .mockImplementation((channel) => Promise.resolve().then(() => channel.close())); 134 | 135 | const setup2 = jest.fn().mockImplementation(() => 136 | promiseTools.delay(20).then(function () { 137 | const e = new Error('Channel closed'); 138 | e.name = 'IllegalOperationError'; 139 | throw e; 140 | }) 141 | ); 142 | 143 | const errors = []; 144 | 145 | const channelWrapper = new ChannelWrapper(connectionManager, { setup: setup1 }); 146 | channelWrapper.on('error', (err) => errors.push(err)); 147 | 148 | await channelWrapper.addSetup(setup2); 149 | connectionManager.simulateConnect(); 150 | 151 | await promiseTools.delay(50); 152 | 153 | expect(setup1).to.have.beenCalledTimes(1); 154 | expect(setup2).to.have.beenCalledTimes(1); 155 | expect(errors.length).to.equal(0); 156 | }); 157 | 158 | it('should return immediately from waitForConnect if we are already connected', function () { 159 | connectionManager.simulateConnect(); 160 | const channelWrapper = new ChannelWrapper(connectionManager); 161 | return channelWrapper.waitForConnect().then(() => channelWrapper.waitForConnect()); 162 | }); 163 | 164 | it('should run setup functions immediately if already connected', async function () { 165 | const setup1 = jest.fn().mockImplementation(() => promiseTools.delay(10)); 166 | const setup2 = jest.fn().mockImplementation(() => promiseTools.delay(10)); 167 | 168 | connectionManager.simulateConnect(); 169 | 170 | const channelWrapper = new ChannelWrapper(connectionManager, { 171 | setup: setup1, 172 | }); 173 | 174 | await channelWrapper.waitForConnect(); 175 | // Initial setup will be run in background - wait for connect event. 176 | expect(setup1).to.have.beenCalledTimes(1); 177 | 178 | await channelWrapper.addSetup(setup2); 179 | 180 | // Any setups we add after this should get run right away, though. 181 | expect(setup2).to.have.beenCalledTimes(1); 182 | }); 183 | 184 | it('should emit errors if setup functions fail to run at connect time', async function () { 185 | const setup = () => Promise.reject(new Error('Bad setup!')); 186 | const setup2 = () => Promise.reject(new Error('Bad setup2!')); 187 | const errorHandler = jest.fn().mockImplementation(function (_err: Error) {}); 188 | 189 | const channelWrapper = new ChannelWrapper(connectionManager, { setup }); 190 | channelWrapper.on('error', errorHandler); 191 | 192 | connectionManager.simulateConnect(); 193 | 194 | await channelWrapper.waitForConnect(); 195 | 196 | expect(errorHandler).to.have.beenCalledTimes(1); 197 | expect(lastArgs(errorHandler)?.[0]?.message).to.equal('Bad setup!'); 198 | 199 | await expect(channelWrapper.addSetup(setup2)).to.be.rejectedWith('Bad setup2!'); 200 | 201 | // Should not be an `error` event here, since we theoretically just handled the error. 202 | expect(errorHandler, 'no second error event').to.have.beenCalledTimes(1); 203 | }); 204 | 205 | it('should emit an error if amqplib refuses to create a channel for us', async function () { 206 | const errorHandler = jest.fn().mockImplementation(function (_err: Error) {}); 207 | 208 | const channelWrapper = new ChannelWrapper(connectionManager); 209 | channelWrapper.on('error', errorHandler); 210 | 211 | await (channelWrapper as any)._onConnect({ 212 | connection: { 213 | createConfirmChannel() { 214 | return Promise.reject(new Error('No channel for you!')); 215 | }, 216 | }, 217 | }); 218 | 219 | expect(errorHandler).to.have.beenCalledTimes(1); 220 | expect(lastArgs(errorHandler)?.[0]?.message).to.equal('No channel for you!'); 221 | }); 222 | 223 | it('should create plain channel', async function () { 224 | const setup = jest.fn().mockImplementation(() => promiseTools.delay(10)); 225 | 226 | connectionManager.simulateConnect(); 227 | const channelWrapper = new ChannelWrapper(connectionManager, { 228 | setup, 229 | confirm: false, 230 | }); 231 | await channelWrapper.waitForConnect(); 232 | 233 | expect(setup).to.have.beenCalledTimes(1); 234 | }); 235 | 236 | it('should work if there are no setup functions', async function () { 237 | connectionManager.simulateConnect(); 238 | const channelWrapper = new ChannelWrapper(connectionManager); 239 | await channelWrapper.waitForConnect(); 240 | // Yay! We didn't blow up! 241 | }); 242 | 243 | it('should publish messages to the underlying channel', function () { 244 | connectionManager.simulateConnect(); 245 | const channelWrapper = new ChannelWrapper(connectionManager); 246 | return channelWrapper 247 | .waitForConnect() 248 | .then(() => 249 | channelWrapper.publish('exchange', 'routingKey', 'argleblargle', { 250 | messageId: 'foo', 251 | }) 252 | ) 253 | .then(function (result) { 254 | expect(result, 'result').to.equal(true); 255 | 256 | // get the underlying channel 257 | const channel = getUnderlyingChannel(channelWrapper); 258 | expect(channel.publish).to.have.beenCalledTimes(1); 259 | expect(lastArgs(channel.publish).slice(0, 4)).to.eql([ 260 | 'exchange', 261 | 'routingKey', 262 | Buffer.from('argleblargle'), 263 | { messageId: 'foo' }, 264 | ]); 265 | 266 | // Try without options 267 | return channelWrapper.publish('exchange', 'routingKey', 'argleblargle'); 268 | }) 269 | .then(function (result) { 270 | expect(result, 'second result').to.equal(true); 271 | 272 | // get the underlying channel 273 | const channel = getUnderlyingChannel(channelWrapper); 274 | expect(channel.publish, 'second call to publish').to.have.beenCalledTimes(2); 275 | expect(lastArgs(channel.publish)?.slice(0, 4), 'second args').to.eql([ 276 | 'exchange', 277 | 'routingKey', 278 | Buffer.from('argleblargle'), 279 | {}, 280 | ]); 281 | expect(channelWrapper.queueLength(), 'queue length').to.equal(0); 282 | }); 283 | }); 284 | 285 | it('should publish messages to the underlying channel with callbacks', function (done) { 286 | connectionManager.simulateConnect(); 287 | const channelWrapper = new ChannelWrapper(connectionManager); 288 | channelWrapper.waitForConnect(function (err) { 289 | if (err) { 290 | return done(err); 291 | } 292 | return channelWrapper.publish( 293 | 'exchange', 294 | 'routingKey', 295 | 'argleblargle', 296 | { messageId: 'foo' }, 297 | function (err, result) { 298 | if (err) { 299 | return done(err); 300 | } 301 | try { 302 | expect(result, 'result').to.equal(true); 303 | 304 | // get the underlying channel 305 | const channel = getUnderlyingChannel(channelWrapper); 306 | if (!channel) { 307 | throw new Error('No channel'); 308 | } 309 | expect(channel.publish).to.have.beenCalledTimes(1); 310 | expect(lastArgs(channel.publish)?.slice(0, 4), 'publish args').to.eql([ 311 | 'exchange', 312 | 'routingKey', 313 | Buffer.from('argleblargle'), 314 | { messageId: 'foo' }, 315 | ]); 316 | return done(); 317 | } catch (error) { 318 | return done(error); 319 | } 320 | } 321 | ); 322 | }); 323 | }); 324 | 325 | it('should sendToQueue messages to the underlying channel', function () { 326 | connectionManager.simulateConnect(); 327 | const channelWrapper = new ChannelWrapper(connectionManager); 328 | return channelWrapper 329 | .waitForConnect() 330 | .then(() => channelWrapper.sendToQueue('queue', 'argleblargle', { messageId: 'foo' })) 331 | .then(function (result) { 332 | expect(result, 'result').to.equal(true); 333 | 334 | // get the underlying channel 335 | const channel = getUnderlyingChannel(channelWrapper); 336 | expect(channel.sendToQueue).to.have.beenCalledTimes(1); 337 | expect(lastArgs(channel.sendToQueue)?.slice(0, 3), 'args').to.eql([ 338 | 'queue', 339 | Buffer.from('argleblargle'), 340 | { messageId: 'foo' }, 341 | ]); 342 | return expect(channelWrapper.queueLength(), 'queue length').to.equal(0); 343 | }); 344 | }); 345 | 346 | it('should queue messages for the underlying channel when disconnected', function () { 347 | const channelWrapper = new ChannelWrapper(connectionManager); 348 | const p1 = channelWrapper.publish('exchange', 'routingKey', 'argleblargle', { 349 | messageId: 'foo', 350 | }); 351 | const p2 = channelWrapper.sendToQueue('queue', 'argleblargle', { 352 | messageId: 'foo', 353 | }); 354 | 355 | expect(channelWrapper.queueLength(), 'queue length').to.equal(2); 356 | connectionManager.simulateConnect(); 357 | return channelWrapper 358 | .waitForConnect() 359 | .then(() => Promise.all([p1, p2])) 360 | .then(function () { 361 | // get the underlying channel 362 | const channel = getUnderlyingChannel(channelWrapper); 363 | expect(channel.publish).to.have.beenCalledTimes(1); 364 | expect(channel.sendToQueue).to.have.beenCalledTimes(1); 365 | return expect( 366 | channelWrapper.queueLength(), 367 | 'queue length after sending everything' 368 | ).to.equal(0); 369 | }); 370 | }); 371 | 372 | it('should queue messages for the underlying channel if channel closes while we are trying to send', async function () { 373 | const channelWrapper = new ChannelWrapper(connectionManager); 374 | 375 | connectionManager.simulateConnect(); 376 | await channelWrapper.waitForConnect(); 377 | (channelWrapper as any)._channel.publish = function ( 378 | _exchange: string, 379 | _routingKey: string, 380 | _encodedMessage: Buffer, 381 | _options: amqplib.Options.Publish, 382 | cb: (err?: Error) => void 383 | ) { 384 | this.close(); 385 | return cb(new Error('Channel closed')); 386 | }; 387 | 388 | const p1 = channelWrapper.publish('exchange', 'routingKey', 'argleblargle', { 389 | messageId: 'foo', 390 | }); 391 | 392 | await promiseTools.delay(10); 393 | 394 | expect((channelWrapper as any)._channel).to.not.exist; 395 | 396 | connectionManager.simulateDisconnect(); 397 | connectionManager.simulateConnect(); 398 | channelWrapper.waitForConnect(); 399 | 400 | await p1; 401 | 402 | // get the underlying channel 403 | const channel = getUnderlyingChannel(channelWrapper); 404 | expect(channel.publish).to.have.beenCalledTimes(1); 405 | return expect( 406 | channelWrapper.queueLength(), 407 | 'queue length after sending everything' 408 | ).to.equal(0); 409 | }); 410 | 411 | it('should timeout published message', async function () { 412 | const channelWrapper = new ChannelWrapper(connectionManager); 413 | 414 | const startTime = Date.now(); 415 | const error = await channelWrapper 416 | .publish('exchange', 'routingKey', 'argleblargle', { 417 | timeout: 100, 418 | }) 419 | .catch((err) => err); 420 | const duration = Date.now() - startTime; 421 | expect(error.message).to.equal('timeout'); 422 | expect(duration).to.be.approximately(100, 10); 423 | }); 424 | 425 | it('should use default timeout for published messages', async function () { 426 | const channelWrapper = new ChannelWrapper(connectionManager, { publishTimeout: 100 }); 427 | 428 | const startTime = Date.now(); 429 | const error = await channelWrapper 430 | .publish('exchange', 'routingKey', 'argleblargle') 431 | .catch((err) => err); 432 | const duration = Date.now() - startTime; 433 | expect(error.message).to.equal('timeout'); 434 | expect(duration).to.be.approximately(100, 10); 435 | }); 436 | 437 | it('should run all setup messages prior to sending any queued messages', function () { 438 | const order: string[] = []; 439 | 440 | const setup: SetupFunc = function (channel: amqplib.ConfirmChannel) { 441 | order.push('setup'); 442 | 443 | // Since this should get run before anything gets sent, this is where we need to add listeners to the 444 | // underlying channel. 445 | channel.on('publish', () => order.push('publish')); 446 | channel.on('sendToQueue', () => order.push('sendToQueue')); 447 | 448 | return Promise.resolve(); 449 | }; 450 | 451 | const channelWrapper = new ChannelWrapper(connectionManager); 452 | 453 | const p1 = channelWrapper.publish('exchange', 'routingKey', 'argleblargle', { 454 | messageId: 'foo', 455 | }); 456 | const p2 = channelWrapper.sendToQueue('queue', 'argleblargle', { 457 | messageId: 'foo', 458 | }); 459 | 460 | return channelWrapper 461 | .addSetup(setup) 462 | .then(function () { 463 | connectionManager.simulateConnect(); 464 | return channelWrapper.waitForConnect(); 465 | }) 466 | .then(() => Promise.all([p1, p2])) 467 | .then(() => expect(order).to.eql(['setup', 'publish', 'sendToQueue'])); 468 | }); 469 | 470 | it('should remove setup messages', async () => { 471 | const setup = jest.fn().mockImplementation(() => Promise.resolve()); 472 | 473 | const channelWrapper = new ChannelWrapper(connectionManager); 474 | await channelWrapper.addSetup(setup); 475 | await channelWrapper.removeSetup(setup); 476 | 477 | connectionManager.simulateConnect(); 478 | await channelWrapper.waitForConnect(); 479 | 480 | expect(setup).to.have.not.beenCalled; 481 | }); 482 | 483 | it('should fail silently when removing a setup that was not added', async () => { 484 | const channelWrapper = new ChannelWrapper(connectionManager); 485 | await channelWrapper.removeSetup(() => undefined); 486 | }); 487 | 488 | it('should run teardown when removing a setup if we are connected', async function () { 489 | const setup = jest.fn().mockImplementation(() => Promise.resolve()); 490 | const teardown = jest.fn().mockImplementation(() => Promise.resolve()); 491 | 492 | const channelWrapper = new ChannelWrapper(connectionManager); 493 | await channelWrapper.addSetup(setup); 494 | 495 | expect(teardown, 'pre-teardown count').to.have.not.beenCalled; 496 | 497 | connectionManager.simulateConnect(); 498 | await channelWrapper.waitForConnect(); 499 | 500 | await channelWrapper.removeSetup(setup, teardown); 501 | expect(teardown).to.have.beenCalledTimes(1); 502 | }); 503 | 504 | it('should proxy acks and nacks to the underlying channel', function () { 505 | connectionManager.simulateConnect(); 506 | const channelWrapper = new ChannelWrapper(connectionManager); 507 | return channelWrapper.waitForConnect().then(function () { 508 | // get the underlying channel 509 | const channel = getUnderlyingChannel(channelWrapper); 510 | 511 | const message = makeMessage('a'); 512 | channelWrapper.ack(message, true); 513 | expect(channel.ack).to.have.beenCalledTimes(1); 514 | expect(channel.ack).to.have.beenCalledWith(message, true); 515 | 516 | channelWrapper.ack(message); 517 | expect(channel.ack).to.have.beenCalledTimes(2); 518 | expect(channel.ack).to.have.beenCalledWith(message, undefined); 519 | 520 | channelWrapper.ackAll(); 521 | expect(channel.ackAll).to.have.beenCalledTimes(1); 522 | 523 | channelWrapper.nack(message, false, true); 524 | expect(channel.nack).to.have.beenCalledTimes(1); 525 | expect(channel.nack).to.have.beenCalledWith(message, false, true); 526 | 527 | channelWrapper.nackAll(true); 528 | expect(channel.nackAll).to.have.beenCalledTimes(1); 529 | expect(channel.nackAll).to.have.beenCalledWith(true); 530 | }); 531 | }); 532 | 533 | it("should proxy acks and nacks to the underlying channel, even if we aren't done setting up", async () => { 534 | const channelWrapper = new ChannelWrapper(connectionManager); 535 | 536 | const a = makeMessage('a'); 537 | const b = makeMessage('b'); 538 | 539 | channelWrapper.addSetup(function () { 540 | channelWrapper.ack(a); 541 | channelWrapper.nack(b); 542 | return Promise.resolve(); 543 | }); 544 | 545 | connectionManager.simulateConnect(); 546 | 547 | await channelWrapper.waitForConnect(); 548 | const channel = getUnderlyingChannel(channelWrapper); 549 | 550 | expect(channel.ack).to.have.beenCalledTimes(1); 551 | expect(channel.ack).to.have.beenCalledWith(a, undefined); 552 | expect(channel.nack).to.have.beenCalledTimes(1); 553 | expect(channel.nack).to.have.beenCalledWith(b, undefined, undefined); 554 | }); 555 | 556 | it('should ignore acks and nacks if we are disconnected', function () { 557 | const channelWrapper = new ChannelWrapper(connectionManager); 558 | channelWrapper.ack(makeMessage('a'), true); 559 | return channelWrapper.nack(makeMessage('c'), false, true); 560 | }); 561 | 562 | it('should proxy assertQueue, checkQueue, bindQueue, assertExchange, checkExchange to the underlying channel', function () { 563 | connectionManager.simulateConnect(); 564 | const channelWrapper = new ChannelWrapper(connectionManager); 565 | return channelWrapper.waitForConnect().then(function () { 566 | // get the underlying channel 567 | const channel = getUnderlyingChannel(channelWrapper); 568 | 569 | channelWrapper.assertQueue('dog'); 570 | expect(channel.assertQueue).to.have.beenCalledTimes(1); 571 | expect(channel.assertQueue).to.have.beenCalledWith('dog', undefined); 572 | 573 | channelWrapper.checkQueue('cat'); 574 | expect(channel.checkQueue).to.have.beenCalledTimes(1); 575 | expect(channel.checkQueue).to.have.beenCalledWith('cat'); 576 | 577 | channelWrapper.bindQueue('dog', 'bone', '.*'); 578 | expect(channel.bindQueue).to.have.beenCalledTimes(1); 579 | expect(channel.bindQueue).to.have.beenCalledWith('dog', 'bone', '.*', undefined); 580 | 581 | channelWrapper.assertExchange('bone', 'topic'); 582 | expect(channel.assertExchange).to.have.beenCalledTimes(1); 583 | expect(channel.assertExchange).to.have.beenCalledWith('bone', 'topic', undefined); 584 | 585 | channelWrapper.checkExchange('fish'); 586 | expect(channel.checkExchange).to.have.beenCalledTimes(1); 587 | expect(channel.checkExchange).to.have.beenCalledWith('fish'); 588 | 589 | }); 590 | }); 591 | 592 | it('should proxy assertQueue, assertExchange, bindQueue and unbindQueue to the underlying channel', function () { 593 | connectionManager.simulateConnect(); 594 | const channelWrapper = new ChannelWrapper(connectionManager); 595 | return channelWrapper.waitForConnect().then(function () { 596 | // get the underlying channel 597 | const channel = getUnderlyingChannel(channelWrapper); 598 | 599 | channelWrapper.assertQueue('dog'); 600 | expect(channel.assertQueue).to.have.beenCalledTimes(1); 601 | expect(channel.assertQueue).to.have.beenCalledWith('dog', undefined); 602 | 603 | channelWrapper.assertExchange('bone', 'topic'); 604 | expect(channel.assertExchange).to.have.beenCalledTimes(1); 605 | expect(channel.assertExchange).to.have.beenCalledWith('bone', 'topic', undefined); 606 | 607 | channelWrapper.bindQueue('dog', 'bone', 'legs'); 608 | expect(channel.bindQueue).to.have.beenCalledTimes(1); 609 | expect(channel.bindQueue).to.have.beenCalledWith('dog', 'bone', 'legs', undefined); 610 | 611 | channelWrapper.unbindQueue('dog', 'bone', 'legs'); 612 | expect(channel.unbindQueue).to.have.beenCalledTimes(1); 613 | expect(channel.unbindQueue).to.have.beenCalledWith('dog', 'bone', 'legs', undefined); 614 | }); 615 | }); 616 | 617 | it(`should proxy assertQueue, bindQueue, assertExchange to the underlying channel, even if we aren't done setting up`, async () => { 618 | const channelWrapper = new ChannelWrapper(connectionManager); 619 | 620 | channelWrapper.addSetup(function () { 621 | channelWrapper.assertQueue('dog'); 622 | channelWrapper.bindQueue('dog', 'bone', '.*'); 623 | channelWrapper.assertExchange('bone', 'topic'); 624 | return Promise.resolve(); 625 | }); 626 | 627 | connectionManager.simulateConnect(); 628 | 629 | await channelWrapper.waitForConnect(); 630 | const channel = getUnderlyingChannel(channelWrapper); 631 | expect(channel.assertQueue).to.have.beenCalledTimes(1); 632 | expect(channel.assertQueue).to.have.beenCalledWith('dog', undefined); 633 | 634 | expect(channel.bindQueue).to.have.beenCalledTimes(1); 635 | expect(channel.bindQueue).to.have.beenCalledWith('dog', 'bone', '.*', undefined); 636 | 637 | expect(channel.assertExchange).to.have.beenCalledTimes(1); 638 | expect(channel.assertExchange).to.have.beenCalledWith('bone', 'topic', undefined); 639 | }); 640 | 641 | it('should ignore assertQueue, bindQueue, assertExchange if we are disconnected', function () { 642 | const channelWrapper = new ChannelWrapper(connectionManager); 643 | channelWrapper.assertQueue('dog', { durable: true }); 644 | channelWrapper.bindQueue('dog', 'bone', '.*'); 645 | channelWrapper.assertExchange('bone', 'topic'); 646 | }); 647 | 648 | it('should proxy bindExchange, unbindExchange and deleteExchange to the underlying channel', function () { 649 | connectionManager.simulateConnect(); 650 | const channelWrapper = new ChannelWrapper(connectionManager); 651 | return channelWrapper.waitForConnect().then(function () { 652 | // get the underlying channel 653 | const channel = getUnderlyingChannel(channelWrapper); 654 | 655 | channelWrapper.bindExchange('paris', 'london', '*'); 656 | expect(channel.bindExchange).to.have.beenCalledTimes(1); 657 | expect(channel.bindExchange).to.have.beenCalledWith('paris', 'london', '*', undefined); 658 | 659 | channelWrapper.unbindExchange('paris', 'london', '*'); 660 | expect(channel.unbindExchange).to.have.beenCalledTimes(1); 661 | expect(channel.unbindExchange).to.have.beenCalledWith( 662 | 'paris', 663 | 'london', 664 | '*', 665 | undefined 666 | ); 667 | 668 | channelWrapper.deleteExchange('chicago'); 669 | expect(channel.deleteExchange).to.have.beenCalledTimes(1); 670 | expect(channel.deleteExchange).to.have.beenCalledWith('chicago', undefined); 671 | }); 672 | }); 673 | 674 | // Not much to test here - just make sure we don't throw any exceptions or anything weird. :) 675 | 676 | it('clean up when closed', function () { 677 | let closeEvents = 0; 678 | 679 | connectionManager.simulateConnect(); 680 | const channelWrapper = new ChannelWrapper(connectionManager); 681 | channelWrapper.on('close', () => closeEvents++); 682 | return channelWrapper.waitForConnect().then(function () { 683 | const channel = getUnderlyingChannel(channelWrapper); 684 | return channelWrapper.close().then(function () { 685 | // Should close the channel. 686 | expect(channel.close).to.have.beenCalledTimes(1); 687 | 688 | // Channel should let the connectionManager know it's going away. 689 | return expect(closeEvents).to.equal(1); 690 | }); 691 | }); 692 | }); 693 | 694 | it('clean up when closed when not connected', function () { 695 | let closeEvents = 0; 696 | 697 | return Promise.resolve() 698 | .then(function () { 699 | const channelWrapper = new ChannelWrapper(connectionManager); 700 | channelWrapper.on('close', () => closeEvents++); 701 | return channelWrapper.close(); 702 | }) 703 | .then(() => 704 | // Channel should let the connectionManager know it's going away. 705 | expect(closeEvents).to.equal(1) 706 | ); 707 | }); 708 | 709 | it('reject outstanding messages when closed', function () { 710 | const channelWrapper = new ChannelWrapper(connectionManager); 711 | const p1 = channelWrapper.publish('exchange', 'routingKey', 'argleblargle', { 712 | messageId: 'foo', 713 | }); 714 | return Promise.all([channelWrapper.close(), expect(p1).to.be.rejected]); 715 | }); 716 | 717 | it('should encode JSON messages', function () { 718 | connectionManager.simulateConnect(); 719 | const channelWrapper = new ChannelWrapper(connectionManager, { 720 | json: true, 721 | }); 722 | return channelWrapper 723 | .waitForConnect() 724 | .then(() => 725 | channelWrapper.publish('exchange', 'routingKey', { 726 | message: 'woo', 727 | }) 728 | ) 729 | .then(function () { 730 | // get the underlying channel 731 | const channel = getUnderlyingChannel(channelWrapper); 732 | expect(channel.publish).to.have.beenCalledTimes(1); 733 | const content = lastArgs(channel.publish)?.[2]; 734 | return expect(content.write, 'content should be a buffer').to.exist; 735 | }); 736 | }); 737 | 738 | it('should reject messages when JSON encoding fails', function () { 739 | const badJsonMesage: { x: any } = { x: 7 }; 740 | badJsonMesage.x = badJsonMesage; 741 | 742 | connectionManager.simulateConnect(); 743 | const channelWrapper = new ChannelWrapper(connectionManager, { 744 | json: true, 745 | }); 746 | return channelWrapper.waitForConnect().then(function () { 747 | const p1 = channelWrapper.publish('exchange', 'routingKey', badJsonMesage); 748 | return expect(p1).to.be.rejected; 749 | }); 750 | }); 751 | 752 | it('should reject messages if they get rejected by the broker', async function () { 753 | connectionManager.simulateConnect(); 754 | const channelWrapper = new ChannelWrapper(connectionManager, { 755 | setup(channel: amqplib.ConfirmChannel) { 756 | channel.publish = ( 757 | _exchange: string, 758 | _routingKey: string, 759 | _encodedMessage: Buffer, 760 | _options: amqplib.Options.Publish, 761 | cb: (err?: Error) => void 762 | ) => { 763 | cb(new Error('no publish')); 764 | return true; 765 | }; 766 | channel.sendToQueue = (_a: any, _b: any, _c: any, cb: (err?: Error) => void) => { 767 | cb(new Error('no send')); 768 | return true; 769 | }; 770 | return Promise.resolve(); 771 | }, 772 | }); 773 | 774 | await channelWrapper.waitForConnect(); 775 | 776 | const p1 = channelWrapper.publish('exchange', 'routingKey', 'content'); 777 | const p2 = channelWrapper.sendToQueue('queue', 'content'); 778 | 779 | await expect(p1).to.be.rejectedWith('no publish'); 780 | await expect(p2).to.be.rejectedWith('no send'); 781 | }); 782 | 783 | it('should reject correct message if broker rejects out of order', async function () { 784 | connectionManager.simulateConnect(); 785 | 786 | const callbacks: { 787 | message: Buffer; 788 | cb: (err?: Error) => void; 789 | }[] = []; 790 | 791 | const channelWrapper = new ChannelWrapper(connectionManager, { 792 | setup(channel: amqplib.ConfirmChannel) { 793 | channel.publish = ( 794 | _exchange: string, 795 | _routingKey: string, 796 | message: Buffer, 797 | _options: amqplib.Options.Publish, 798 | cb: (err?: Error) => void 799 | ) => { 800 | callbacks.push({ message, cb }); 801 | return true; 802 | }; 803 | return Promise.resolve(); 804 | }, 805 | }); 806 | 807 | await channelWrapper.waitForConnect(); 808 | 809 | channelWrapper.publish('exchange', 'routingKey', 'content1'); 810 | const p2 = channelWrapper.publish('exchange', 'routingKey', 'content2'); 811 | await promiseTools.delay(10); 812 | expect(callbacks).to.have.length(2); 813 | 814 | // Nack the second message. 815 | callbacks.find((c) => c.message.toString() === 'content2')?.cb(new Error('boom')); 816 | await expect(p2).to.be.rejectedWith('boom'); 817 | 818 | // Simulate a disconnect and reconnect. 819 | connectionManager.simulateDisconnect(); 820 | await promiseTools.delay(10); 821 | callbacks.find((c) => c.message.toString() === 'content1')?.cb(new Error('disconnected')); 822 | connectionManager.simulateConnect(); 823 | await promiseTools.delay(10); 824 | expect(callbacks).to.have.length(3); 825 | 826 | // Make sure the first message is resent. 827 | const resent = callbacks[callbacks.length - 1]; 828 | expect(resent.message.toString()).to.equal('content1'); 829 | }); 830 | 831 | it('should keep sending messages, even if we disconnect in the middle of sending', async function () { 832 | const callbacks: ((err: Error | undefined) => void)[] = []; 833 | 834 | connectionManager.simulateConnect(); 835 | const channelWrapper = new ChannelWrapper(connectionManager, { 836 | setup(channel: amqplib.ConfirmChannel) { 837 | channel.publish = function ( 838 | _exchange: string, 839 | _routingKey: string, 840 | _message: Buffer, 841 | _options: amqplib.Options.Publish, 842 | cb: (err?: Error) => void 843 | ) { 844 | callbacks.push(cb); 845 | return true; 846 | }; 847 | 848 | return Promise.resolve(); 849 | }, 850 | }); 851 | 852 | await channelWrapper.waitForConnect(); 853 | const p1 = channelWrapper.publish('exchange', 'routingKey', 'content'); 854 | 855 | // Disconnect, and generate an error for the in-flight message. 856 | await promiseTools.delay(10); 857 | connectionManager.simulateDisconnect(); 858 | expect(callbacks).to.have.length(1); 859 | callbacks[0](new Error('disconnected')); 860 | 861 | // Reconnect. Should resend the message. 862 | await promiseTools.delay(10); 863 | connectionManager.simulateConnect(); 864 | await promiseTools.delay(10); 865 | expect(callbacks).to.have.length(2); 866 | callbacks[1](undefined); 867 | await p1; 868 | }); 869 | 870 | it('should handle getting a confirm out-of-order with a disconnect', async function () { 871 | const callbacks: ((err: Error | undefined) => void)[] = []; 872 | 873 | connectionManager.simulateConnect(); 874 | const channelWrapper = new ChannelWrapper(connectionManager, { 875 | setup(channel: amqplib.ConfirmChannel) { 876 | channel.publish = function ( 877 | _exchange: string, 878 | _routingKey: string, 879 | _message: Buffer, 880 | _options: amqplib.Options.Publish, 881 | cb: (err?: Error) => void 882 | ) { 883 | callbacks.push(cb); 884 | return true; 885 | }; 886 | 887 | return Promise.resolve(); 888 | }, 889 | }); 890 | 891 | await channelWrapper.waitForConnect(); 892 | const p1 = channelWrapper.publish('exchange', 'routingKey', 'content'); 893 | 894 | // Disconnect. 895 | await promiseTools.delay(10); 896 | connectionManager.simulateDisconnect(); 897 | expect(callbacks).to.have.length(1); 898 | 899 | // Message succeeds after disconnect. 900 | callbacks[0](undefined); 901 | 902 | // Reconnect. Should resend the message. 903 | await promiseTools.delay(10); 904 | connectionManager.simulateConnect(); 905 | await promiseTools.delay(10); 906 | expect(callbacks).to.have.length(1); 907 | await p1; 908 | }); 909 | 910 | it('should handle getting a confirm out-of-order with a disconnect and reconnect', async function () { 911 | const callbacks: ((err: Error | undefined) => void)[] = []; 912 | 913 | connectionManager.simulateConnect(); 914 | const channelWrapper = new ChannelWrapper(connectionManager, { 915 | setup(channel: amqplib.ConfirmChannel) { 916 | channel.publish = function ( 917 | _exchange: string, 918 | _routingKey: string, 919 | _message: Buffer, 920 | _options: amqplib.Options.Publish, 921 | cb: (err?: Error) => void 922 | ) { 923 | callbacks.push(cb); 924 | return true; 925 | }; 926 | 927 | return Promise.resolve(); 928 | }, 929 | }); 930 | 931 | await channelWrapper.waitForConnect(); 932 | const p1 = channelWrapper.publish('exchange', 'routingKey', 'content'); 933 | 934 | // Disconnect. 935 | await promiseTools.delay(10); 936 | connectionManager.simulateDisconnect(); 937 | expect(callbacks).to.have.length(1); 938 | 939 | // Reconnect. 940 | await promiseTools.delay(10); 941 | connectionManager.simulateConnect(); 942 | await promiseTools.delay(10); 943 | 944 | // Message from first connection succeeds after disconnect/reconnect. 945 | expect(callbacks).to.have.length(1); 946 | callbacks[0](undefined); 947 | await p1; 948 | }); 949 | 950 | it('should emit an error, we disconnect during publish with code 502 (AMQP Frame Syntax Error)', function () { 951 | connectionManager.simulateConnect(); 952 | const err = new Error('AMQP Frame Syntax Error'); 953 | (err as any).code = 502; 954 | const channelWrapper = new ChannelWrapper(connectionManager, { 955 | setup(channel: amqplib.ConfirmChannel) { 956 | channel.publish = function ( 957 | _exchange: string, 958 | _routingKey: string, 959 | _message: Buffer, 960 | _options: amqplib.Options.Publish, 961 | cb: (err?: Error) => void 962 | ) { 963 | connectionManager.simulateRemoteCloseEx(err); 964 | cb(); 965 | return true; 966 | }; 967 | return Promise.resolve(); 968 | }, 969 | }); 970 | 971 | return channelWrapper 972 | .waitForConnect() 973 | .then(() => channelWrapper.publish('exchange', 'routingKey', 'content')) 974 | .then(function () {}) 975 | .catch((e) => { 976 | expect(e).to.equal(err); 977 | }); 978 | }); 979 | 980 | it('should retry, we disconnect during publish with code 320 (AMQP Connection Forced Error)', async function () { 981 | const callbacks: ((err: Error | undefined) => void)[] = []; 982 | 983 | connectionManager.simulateConnect(); 984 | const err = new Error('AMQP Frame Syntax Error'); 985 | (err as any).code = 320; 986 | const channelWrapper = new ChannelWrapper(connectionManager, { 987 | setup(channel: amqplib.ConfirmChannel) { 988 | channel.publish = function ( 989 | _exchange: string, 990 | _routingKey: string, 991 | _message: Buffer, 992 | _options: amqplib.Options.Publish, 993 | cb: (err?: Error) => void 994 | ) { 995 | callbacks.push(cb); 996 | return true; 997 | }; 998 | return Promise.resolve(); 999 | }, 1000 | }); 1001 | 1002 | await channelWrapper.waitForConnect(); 1003 | const p1 = channelWrapper.publish('exchange', 'routingKey', 'content'); 1004 | await promiseTools.delay(10); 1005 | expect(callbacks).to.have.length(1); 1006 | 1007 | // Simulate disconnect during publish. 1008 | connectionManager.simulateRemoteCloseEx(err); 1009 | callbacks[0](new Error('disconnected')); 1010 | 1011 | // Reconnect. 1012 | connectionManager.simulateConnect(); 1013 | await promiseTools.delay(10); 1014 | 1015 | // Message should be republished. 1016 | expect(callbacks).to.have.length(2); 1017 | callbacks[1](undefined); 1018 | await p1; 1019 | }); 1020 | 1021 | it('should publish queued messages to the underlying channel without waiting for confirms', async function () { 1022 | connectionManager.simulateConnect(); 1023 | const channelWrapper = new ChannelWrapper(connectionManager, { 1024 | setup(channel: amqplib.ConfirmChannel) { 1025 | channel.publish = jest.fn().mockImplementation(() => true); 1026 | return Promise.resolve(); 1027 | }, 1028 | }); 1029 | 1030 | await channelWrapper.waitForConnect(); 1031 | const p1 = channelWrapper.publish('exchange', 'routingKey', 'msg:1'); 1032 | const p2 = channelWrapper.publish('exchange', 'routingKey', 'msg:2'); 1033 | await promiseTools.delay(10); 1034 | 1035 | const channel = getUnderlyingChannel(channelWrapper); 1036 | expect(channel.publish).to.have.beenCalledTimes(2); 1037 | expect(p1).to.not.be.fulfilled; 1038 | expect(p2).to.not.be.fulfilled; 1039 | }); 1040 | 1041 | it('should stop publishing messages to the queue when the queue is full', async function () { 1042 | const queue: (() => void)[] = []; 1043 | let innerChannel: amqplib.Channel = {} as any; 1044 | 1045 | connectionManager.simulateConnect(); 1046 | const channelWrapper = new ChannelWrapper(connectionManager, { 1047 | async setup(channel: amqplib.ConfirmChannel) { 1048 | innerChannel = channel; 1049 | channel.publish = jest 1050 | .fn() 1051 | .mockImplementation((_exchage, _routingKey, content, _options, callback) => { 1052 | channel.emit('publish', content); 1053 | queue.push(() => callback(null)); 1054 | return queue.length < 2; 1055 | }); 1056 | }, 1057 | }); 1058 | 1059 | await channelWrapper.waitForConnect(); 1060 | 1061 | channelWrapper.publish('exchange', 'routingKey', 'msg:1'); 1062 | channelWrapper.publish('exchange', 'routingKey', 'msg:2'); 1063 | channelWrapper.publish('exchange', 'routingKey', 'msg:3'); 1064 | await promiseTools.delay(10); 1065 | 1066 | // Only two messages should have been published to the underlying queue. 1067 | expect(queue.length).to.equal(2); 1068 | 1069 | // Simulate queue draining. 1070 | queue.pop()!(); 1071 | innerChannel.emit('drain'); 1072 | 1073 | await promiseTools.delay(10); 1074 | 1075 | // Final message should have been published to the underlying queue. 1076 | expect(queue.length).to.equal(2); 1077 | }); 1078 | 1079 | it('should consume messages', async function () { 1080 | let onMessage: any = null; 1081 | 1082 | connectionManager.simulateConnect(); 1083 | const channelWrapper = new ChannelWrapper(connectionManager, { 1084 | async setup(channel: amqplib.ConfirmChannel) { 1085 | channel.consume = jest.fn().mockImplementation((_queue, onMsg, _options) => { 1086 | onMessage = onMsg; 1087 | return Promise.resolve({ consumerTag: 'abc' }); 1088 | }); 1089 | }, 1090 | }); 1091 | await channelWrapper.waitForConnect(); 1092 | 1093 | const messages: any[] = []; 1094 | await channelWrapper.consume( 1095 | 'queue', 1096 | (msg) => { 1097 | messages.push(msg); 1098 | }, 1099 | { noAck: true } 1100 | ); 1101 | 1102 | onMessage(1); 1103 | onMessage(2); 1104 | onMessage(3); 1105 | expect(messages).to.deep.equal([1, 2, 3]); 1106 | }); 1107 | 1108 | it('should reconnect consumer on consumer cancellation', async function () { 1109 | let onMessage: any = null; 1110 | let consumerTag = 0; 1111 | 1112 | connectionManager.simulateConnect(); 1113 | const channelWrapper = new ChannelWrapper(connectionManager, { 1114 | async setup(channel: amqplib.ConfirmChannel) { 1115 | channel.consume = jest.fn().mockImplementation((_queue, onMsg, _options) => { 1116 | onMessage = onMsg; 1117 | return Promise.resolve({ consumerTag: `${consumerTag++}` }); 1118 | }); 1119 | }, 1120 | }); 1121 | await channelWrapper.waitForConnect(); 1122 | 1123 | const messages: any[] = []; 1124 | await channelWrapper.consume('queue', (msg) => { 1125 | messages.push(msg); 1126 | }); 1127 | 1128 | onMessage(1); 1129 | onMessage(null); // simulate consumer cancel 1130 | onMessage(2); 1131 | onMessage(null); // simulate second cancel 1132 | onMessage(3); 1133 | 1134 | expect(messages).to.deep.equal([1, 2, 3]); 1135 | expect(consumerTag).to.equal(3); 1136 | }); 1137 | 1138 | it('should reconnect consumers on channel error', async function () { 1139 | let onQueue1: any = null; 1140 | let onQueue2: any = null; 1141 | let consumerTag = 0; 1142 | 1143 | // Define a prefetch function here, because it will otherwise be 1144 | // unique for each new channel 1145 | const prefetchFn = jest 1146 | .fn() 1147 | .mockImplementation((_prefetch: number, _isGlobal: boolean) => {}); 1148 | 1149 | connectionManager.simulateConnect(); 1150 | const channelWrapper = new ChannelWrapper(connectionManager, { 1151 | async setup(channel: amqplib.ConfirmChannel) { 1152 | channel.prefetch = prefetchFn; 1153 | channel.consume = jest.fn().mockImplementation((queue, onMsg, _options) => { 1154 | if (queue === 'queue1') { 1155 | onQueue1 = onMsg; 1156 | } else { 1157 | onQueue2 = onMsg; 1158 | } 1159 | return Promise.resolve({ consumerTag: `${consumerTag++}` }); 1160 | }); 1161 | }, 1162 | }); 1163 | await channelWrapper.waitForConnect(); 1164 | 1165 | const queue1: any[] = []; 1166 | await channelWrapper.consume( 1167 | 'queue1', 1168 | (msg) => { 1169 | queue1.push(msg); 1170 | }, 1171 | { noAck: true, prefetch: 10 } 1172 | ); 1173 | 1174 | const queue2: any[] = []; 1175 | await channelWrapper.consume('queue2', (msg) => { 1176 | queue2.push(msg); 1177 | }); 1178 | 1179 | onQueue1(1); 1180 | onQueue2(1); 1181 | 1182 | connectionManager.simulateDisconnect(); 1183 | connectionManager.simulateConnect(); 1184 | await channelWrapper.waitForConnect(); 1185 | 1186 | onQueue1(2); 1187 | onQueue2(2); 1188 | 1189 | expect(queue1).to.deep.equal([1, 2]); 1190 | expect(queue2).to.deep.equal([1, 2]); 1191 | expect(consumerTag).to.equal(4); 1192 | expect(prefetchFn).to.have.beenCalledTimes(2); 1193 | expect(prefetchFn).to.have.beenNthCalledWith(1, 10, false); 1194 | expect(prefetchFn).to.have.beenNthCalledWith(2, 10, false); 1195 | }); 1196 | 1197 | it('should be able to cancel all consumers', async function () { 1198 | let onQueue1: any = null; 1199 | let onQueue2: any = null; 1200 | let consumerTag = 0; 1201 | const canceledTags: number[] = []; 1202 | 1203 | connectionManager.simulateConnect(); 1204 | const channelWrapper = new ChannelWrapper(connectionManager, { 1205 | async setup(channel: amqplib.ConfirmChannel) { 1206 | channel.consume = jest.fn().mockImplementation((queue, onMsg, _options) => { 1207 | if (queue === 'queue1') { 1208 | onQueue1 = onMsg; 1209 | } else { 1210 | onQueue2 = onMsg; 1211 | } 1212 | return Promise.resolve({ consumerTag: `${consumerTag++}` }); 1213 | }); 1214 | channel.cancel = jest.fn().mockImplementation((consumerTag) => { 1215 | canceledTags.push(consumerTag); 1216 | if (consumerTag === '0') { 1217 | onQueue1(null); 1218 | } else if (consumerTag === '1') { 1219 | onQueue2(null); 1220 | } 1221 | return Promise.resolve(); 1222 | }); 1223 | }, 1224 | }); 1225 | await channelWrapper.waitForConnect(); 1226 | 1227 | const queue1: any[] = []; 1228 | await channelWrapper.consume('queue1', (msg) => { 1229 | queue1.push(msg); 1230 | }); 1231 | 1232 | const queue2: any[] = []; 1233 | await channelWrapper.consume('queue2', (msg) => { 1234 | queue2.push(msg); 1235 | }); 1236 | 1237 | onQueue1(1); 1238 | onQueue2(1); 1239 | 1240 | await channelWrapper.cancelAll(); 1241 | 1242 | // Consumers shouldn't be resumed after reconnect when canceled 1243 | connectionManager.simulateDisconnect(); 1244 | connectionManager.simulateConnect(); 1245 | await channelWrapper.waitForConnect(); 1246 | 1247 | expect(queue1).to.deep.equal([1]); 1248 | expect(queue2).to.deep.equal([1]); 1249 | expect(consumerTag).to.equal(2); 1250 | expect(canceledTags).to.deep.equal(['0', '1']); 1251 | }); 1252 | 1253 | it('should be able to cancel specific consumers', async function () { 1254 | let onQueue1: any = null; 1255 | let onQueue2: any = null; 1256 | const canceledTags: number[] = []; 1257 | 1258 | connectionManager.simulateConnect(); 1259 | const channelWrapper = new ChannelWrapper(connectionManager, { 1260 | async setup(channel: amqplib.ConfirmChannel) { 1261 | channel.consume = jest.fn().mockImplementation((queue, onMsg, _options) => { 1262 | if (queue === 'queue1') { 1263 | onQueue1 = onMsg; 1264 | } else { 1265 | onQueue2 = onMsg; 1266 | } 1267 | return Promise.resolve({ 1268 | consumerTag: _options.consumerTag, 1269 | }); 1270 | }); 1271 | channel.cancel = jest.fn().mockImplementation((consumerTag) => { 1272 | canceledTags.push(consumerTag); 1273 | if (consumerTag === '0') { 1274 | onQueue1(null); 1275 | } else if (consumerTag === '1') { 1276 | onQueue2(null); 1277 | } 1278 | return Promise.resolve(); 1279 | }); 1280 | }, 1281 | }); 1282 | await channelWrapper.waitForConnect(); 1283 | 1284 | const queue1: any[] = []; 1285 | const { consumerTag: consumerTag1 } = await channelWrapper.consume( 1286 | 'queue1', 1287 | (msg) => { 1288 | queue1.push(msg); 1289 | }, 1290 | { consumerTag: '1' } 1291 | ); 1292 | 1293 | const queue2: any[] = []; 1294 | const { consumerTag: consumerTag2 } = await channelWrapper.consume( 1295 | 'queue2', 1296 | (msg) => { 1297 | queue2.push(msg); 1298 | }, 1299 | { consumerTag: '2' } 1300 | ); 1301 | 1302 | onQueue1(1); 1303 | onQueue2(1); 1304 | 1305 | await channelWrapper.cancel(consumerTag1); 1306 | await channelWrapper.cancel(consumerTag2); 1307 | 1308 | // Consumers shouldn't be resumed after reconnect when canceled 1309 | connectionManager.simulateDisconnect(); 1310 | connectionManager.simulateConnect(); 1311 | await channelWrapper.waitForConnect(); 1312 | 1313 | expect(queue1).to.deep.equal([1]); 1314 | expect(queue2).to.deep.equal([1]); 1315 | expect(canceledTags).to.deep.equal(['1', '2']); 1316 | }); 1317 | 1318 | it('should not register same consumer twice', async function () { 1319 | const setup = jest.fn().mockImplementation(() => promiseTools.delay(10)); 1320 | 1321 | const channelWrapper = new ChannelWrapper(connectionManager, { setup }); 1322 | connectionManager.simulateConnect(); 1323 | 1324 | await channelWrapper.consume('queue', () => {}); 1325 | 1326 | await channelWrapper.waitForConnect(); 1327 | 1328 | const channel = getUnderlyingChannel(channelWrapper); 1329 | expect(channel.consume).to.have.beenCalledTimes(1); 1330 | }); 1331 | }); 1332 | 1333 | /** Returns the arguments of the most recent call to this mock. */ 1334 | function lastArgs(mock: jest.Mock): Y | undefined { 1335 | if (mock.mock.calls.length === 0) { 1336 | return undefined; 1337 | } 1338 | return mock.mock.calls[mock.mock.calls.length - 1]; 1339 | } 1340 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 3 | 4 | import { Connection, Message, Options, Replies } from 'amqplib'; 5 | import { EventEmitter, once } from 'events'; 6 | import { IAmqpConnectionManager } from '../src/AmqpConnectionManager'; 7 | import ChannelWrapper, { CreateChannelOpts } from '../src/ChannelWrapper'; 8 | 9 | export class FakeAmqp { 10 | public connection: Connection | undefined; 11 | public url: string | undefined; 12 | public failConnections = false; 13 | public deadServers: string[] = []; 14 | public connect: (url: string) => Promise = async () => { 15 | throw new Error('Not setup'); 16 | }; 17 | 18 | constructor() { 19 | this.reset(); 20 | } 21 | 22 | kill() { 23 | const err = new Error('Died in a fire'); 24 | this.connection?.emit('error', err); 25 | this.connection?.emit('close', err); 26 | } 27 | 28 | simulateRemoteClose() { 29 | this.connection?.emit('close', new Error('Connection closed')); 30 | } 31 | 32 | simulateRemoteBlock() { 33 | this.connection?.emit('blocked', new Error('Connection blocked')); 34 | } 35 | 36 | simulateRemoteUnblock() { 37 | this.connection?.emit('unblocked'); 38 | } 39 | 40 | reset() { 41 | this.connection = undefined; 42 | this.url = undefined; 43 | this.failConnections = false; 44 | this.deadServers = []; 45 | this.connect = jest.fn().mockImplementation((url) => { 46 | if (this.failConnections) { 47 | return Promise.reject(new Error('No')); 48 | } 49 | 50 | let allowConnection = true; 51 | this.deadServers.forEach((deadUrl) => { 52 | if (url.startsWith(deadUrl)) { 53 | allowConnection = false; 54 | } 55 | }); 56 | if (!allowConnection) { 57 | return Promise.reject(new Error(`Dead server ${url}`)); 58 | } 59 | 60 | const connection = (this.connection = new exports.FakeConnection(url)); 61 | return Promise.resolve(connection); 62 | }); 63 | } 64 | } 65 | 66 | export class FakeChannel extends EventEmitter { 67 | publish = jest 68 | .fn() 69 | .mockImplementation( 70 | ( 71 | _exchange: string, 72 | _routingKey: string, 73 | content: Buffer, 74 | _options?: Options.Publish 75 | ): boolean => { 76 | this.emit('publish', content); 77 | return true; 78 | } 79 | ); 80 | 81 | sendToQueue = jest 82 | .fn() 83 | .mockImplementation( 84 | (_queue: string, content: Buffer, _options?: Options.Publish): boolean => { 85 | this.emit('sendToQueue', content); 86 | return true; 87 | } 88 | ); 89 | 90 | ack = jest.fn().mockImplementation(function (_message: Message, _allUpTo?: boolean): void {}); 91 | 92 | ackAll = jest.fn().mockImplementation(function (): void {}); 93 | 94 | nack = jest 95 | .fn() 96 | .mockImplementation(function ( 97 | _message: Message, 98 | _allUpTo?: boolean, 99 | _requeue?: boolean 100 | ): void {}); 101 | 102 | nackAll = jest.fn().mockImplementation(function (_requeue?: boolean): void {}); 103 | 104 | assertQueue = jest 105 | .fn() 106 | .mockImplementation(async function ( 107 | queue: string, 108 | _options?: Options.AssertQueue 109 | ): Promise { 110 | return { 111 | queue, 112 | messageCount: 0, 113 | consumerCount: 0, 114 | }; 115 | }); 116 | 117 | checkQueue = jest 118 | .fn() 119 | .mockImplementation(async function (_queue: string): Promise { 120 | return {}; 121 | }); 122 | 123 | bindQueue = jest 124 | .fn() 125 | .mockImplementation(async function ( 126 | _queue: string, 127 | _source: string, 128 | _pattern: string, 129 | _args?: any 130 | ): Promise { 131 | return {}; 132 | }); 133 | 134 | unbindQueue = jest 135 | .fn() 136 | .mockImplementation(async function ( 137 | _queue: string, 138 | _source: string, 139 | _pattern: string, 140 | _args?: any 141 | ): Promise { 142 | return {}; 143 | }); 144 | 145 | assertExchange = jest 146 | .fn() 147 | .mockImplementation(async function ( 148 | exchange: string, 149 | _type: 'direct' | 'topic' | 'headers' | 'fanout' | 'match' | string, 150 | _options?: Options.AssertExchange 151 | ): Promise { 152 | return { exchange }; 153 | }); 154 | 155 | bindExchange = jest 156 | .fn() 157 | .mockImplementation(async function ( 158 | _destination: string, 159 | _source: string, 160 | _pattern: string, 161 | _args?: any 162 | ): Promise { 163 | return {}; 164 | }); 165 | 166 | checkExchange = jest 167 | .fn() 168 | .mockImplementation(async function (_exchange: string): Promise { 169 | return {}; 170 | }); 171 | 172 | deleteExchange = jest 173 | .fn() 174 | .mockImplementation(async function ( 175 | _exchange: string, 176 | _options?: Options.DeleteExchange 177 | ): Promise { 178 | return {}; 179 | }); 180 | 181 | unbindExchange = jest 182 | .fn() 183 | .mockImplementation(async function ( 184 | _destination: string, 185 | _source: string, 186 | _pattern: string, 187 | _args?: any 188 | ): Promise { 189 | return {}; 190 | }); 191 | 192 | close = jest.fn().mockImplementation(async (): Promise => { 193 | this.emit('close'); 194 | }); 195 | 196 | consume = jest.fn().mockImplementation(async (): Promise => { 197 | return { consumerTag: 'abc' }; 198 | }); 199 | 200 | prefetch = jest.fn().mockImplementation((_prefetch: number, _isGlobal: boolean): void => {}); 201 | } 202 | 203 | export class FakeConfirmChannel extends FakeChannel { 204 | publish = jest 205 | .fn() 206 | .mockImplementation( 207 | ( 208 | _exchange: string, 209 | _routingKey: string, 210 | content: Buffer, 211 | _options?: Options.Publish, 212 | callback?: (err: any, ok: Replies.Empty) => void 213 | ): boolean => { 214 | this.emit('publish', content); 215 | callback?.(null, {}); 216 | return true; 217 | } 218 | ); 219 | 220 | sendToQueue = jest 221 | .fn() 222 | .mockImplementation( 223 | ( 224 | _queue: string, 225 | content: Buffer, 226 | _options?: Options.Publish, 227 | callback?: (err: any, ok: Replies.Empty) => void 228 | ): boolean => { 229 | this.emit('sendToQueue', content); 230 | callback?.(null, {}); 231 | return true; 232 | } 233 | ); 234 | } 235 | 236 | export class FakeConnection extends EventEmitter { 237 | url: string; 238 | _closed = false; 239 | 240 | constructor(url: string) { 241 | super(); 242 | this.url = url; 243 | this._closed = false; 244 | } 245 | 246 | createChannel() { 247 | return Promise.resolve(new exports.FakeChannel()); 248 | } 249 | 250 | createConfirmChannel() { 251 | return Promise.resolve(new exports.FakeConfirmChannel()); 252 | } 253 | 254 | close() { 255 | this._closed = true; 256 | return Promise.resolve(); 257 | } 258 | } 259 | 260 | export class FakeAmqpConnectionManager extends EventEmitter implements IAmqpConnectionManager { 261 | connected: boolean; 262 | private _connection: FakeConnection | undefined; 263 | 264 | heartbeatIntervalInSeconds = 5; 265 | reconnectTimeInSeconds = 10; 266 | 267 | constructor() { 268 | super(); 269 | this.connected = false; 270 | } 271 | 272 | get connection() { 273 | return this._connection as any as Connection | undefined; 274 | } 275 | 276 | get channelCount(): number { 277 | return 0; 278 | } 279 | 280 | async connect(): Promise { 281 | await Promise.all([once(this, 'connect'), this.simulateConnect()]); 282 | } 283 | 284 | reconnect(): void { 285 | this.simulateDisconnect(); 286 | this.simulateConnect(); 287 | } 288 | 289 | isConnected() { 290 | return this.connected; 291 | } 292 | 293 | createChannel(options?: CreateChannelOpts): ChannelWrapper { 294 | return new ChannelWrapper(this, options); 295 | } 296 | 297 | simulateConnect() { 298 | const url = 'amqp://localhost'; 299 | this._connection = new exports.FakeConnection(url); 300 | this.connected = true; 301 | this.emit('connect', { 302 | connection: this.connection, 303 | url, 304 | }); 305 | } 306 | 307 | simulateRemoteCloseEx(err: Error) { 308 | this.emit('disconnect', { err }); 309 | this.emit('close', err); 310 | } 311 | 312 | simulateDisconnect() { 313 | this._connection = undefined; 314 | this.connected = false; 315 | this.emit('disconnect', { 316 | err: new Error('Boom!'), 317 | }); 318 | } 319 | 320 | async close() {} 321 | } 322 | -------------------------------------------------------------------------------- /test/importTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import amqp, { AmqpConnectionManagerClass as AmqpConnectionManager } from '../src'; 3 | 4 | describe('import test', function () { 5 | it('should let you import as default (#51)', function () { 6 | expect(amqp).to.exist; 7 | expect(amqp.connect).to.exist; 8 | }); 9 | 10 | it('should let you import class', function () { 11 | new AmqpConnectionManager('url'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/integrationTest.ts: -------------------------------------------------------------------------------- 1 | import { Channel, ConfirmChannel, ConsumeMessage } from 'amqplib'; 2 | import chai from 'chai'; 3 | import chaiJest from 'chai-jest'; 4 | import { once } from 'events'; 5 | import { defer, timeout } from 'promise-tools'; 6 | import amqp, { AmqpConnectionManagerClass as AmqpConnectionManager } from '../src'; 7 | import { IAmqpConnectionManager } from '../src/AmqpConnectionManager'; 8 | 9 | chai.use(chaiJest); 10 | 11 | const { expect } = chai; 12 | 13 | /** 14 | * Tests in this file assume you have a RabbitMQ instance running on localhost. 15 | * You can start one with: 16 | * 17 | * docker-compose up -d 18 | * 19 | */ 20 | describe('Integration tests', () => { 21 | let connection: IAmqpConnectionManager; 22 | 23 | afterEach(async () => { 24 | await connection?.close(); 25 | }); 26 | 27 | it('should connect to the broker', async () => { 28 | // Create a new connection manager 29 | connection = amqp.connect(['amqp://localhost']); 30 | await timeout(once(connection, 'connect'), 3000); 31 | }); 32 | 33 | it('should connect to the broker with a username and password', async () => { 34 | // Create a new connection manager 35 | connection = amqp.connect(['amqp://guest:guest@localhost:5672']); 36 | await timeout(once(connection, 'connect'), 3000); 37 | }); 38 | 39 | it('should connect to the broker with a string', async () => { 40 | // Create a new connection manager 41 | connection = amqp.connect('amqp://guest:guest@localhost:5672'); 42 | await timeout(once(connection, 'connect'), 3000); 43 | }); 44 | 45 | it('should connect to the broker with a amqp.Connect object', async () => { 46 | // Create a new connection manager 47 | connection = amqp.connect({ 48 | protocol: 'amqp', 49 | hostname: 'localhost', 50 | port: 5672, 51 | vhost: '/', 52 | }); 53 | await timeout(once(connection, 'connect'), 3000); 54 | }); 55 | 56 | it('should connect to the broker with an url/options object', async () => { 57 | // Create a new connection manager 58 | connection = amqp.connect({ 59 | url: 'amqp://guest:guest@localhost:5672', 60 | }); 61 | await timeout(once(connection, 'connect'), 3000); 62 | }); 63 | 64 | it('should connect to the broker with a string with options', async () => { 65 | // Create a new connection manager 66 | connection = amqp.connect( 67 | 'amqp://guest:guest@localhost:5672/%2F?heartbeat=10&channelMax=100' 68 | ); 69 | await timeout(once(connection, 'connect'), 3000); 70 | }); 71 | 72 | // This test might cause jest to complain about leaked resources due to the bug described and fixed by: 73 | // https://github.com/squaremo/amqp.node/pull/584 74 | it('should throw on awaited connect with wrong password', async () => { 75 | connection = new AmqpConnectionManager('amqp://guest:wrong@localhost'); 76 | let err; 77 | try { 78 | await connection.connect(); 79 | } catch (error: any) { 80 | err = error; 81 | } 82 | expect(err.message).to.contain('ACCESS-REFUSED'); 83 | }); 84 | 85 | it('send and receive messages', async () => { 86 | const queueName = 'testQueue1'; 87 | const content = `hello world - ${Date.now()}`; 88 | 89 | // Create a new connection manager 90 | connection = amqp.connect(['amqp://localhost']); 91 | 92 | // Ask the connection manager for a ChannelWrapper. Specify a setup function to 93 | // run every time we reconnect to the broker. 94 | const sendChannel = connection.createChannel({ 95 | setup: async (channel: ConfirmChannel) => { 96 | await channel.assertQueue(queueName, { durable: false, autoDelete: true }); 97 | }, 98 | }); 99 | 100 | const rxPromise = defer(); 101 | 102 | const receiveWrapper = connection.createChannel({ 103 | setup: async (channel: ConfirmChannel) => { 104 | // `channel` here is a regular amqplib `ConfirmChannel`. 105 | // Note that `this` here is the channelWrapper instance. 106 | await channel.assertQueue(queueName, { durable: false, autoDelete: true }); 107 | await channel.consume( 108 | queueName, 109 | (message) => { 110 | if (!message) { 111 | // Ignore. 112 | } else if (message.content.toString() === content) { 113 | rxPromise.resolve(message); 114 | } else { 115 | console.log( 116 | `Received message ${message?.content.toString()} !== ${content}` 117 | ); 118 | } 119 | }, 120 | { 121 | noAck: true, 122 | } 123 | ); 124 | }, 125 | }); 126 | 127 | await timeout(once(connection, 'connect'), 3000); 128 | 129 | await sendChannel.sendToQueue(queueName, content); 130 | 131 | const result = await timeout(rxPromise.promise, 3000); 132 | expect(result.content.toString()).to.equal(content); 133 | 134 | await sendChannel.close(); 135 | await receiveWrapper.close(); 136 | }); 137 | 138 | it('send and receive messages with plain channel', async () => { 139 | const queueName = 'testQueue2'; 140 | const content = `hello world - ${Date.now()}`; 141 | 142 | connection = new AmqpConnectionManager('amqp://localhost'); 143 | const sendChannel = connection.createChannel({ 144 | confirm: false, 145 | setup: async (channel: Channel) => { 146 | await channel.assertQueue(queueName, { durable: false, autoDelete: true }); 147 | }, 148 | }); 149 | 150 | const receiveChannel = connection.createChannel({ 151 | confirm: false, 152 | setup: async (channel: Channel) => { 153 | await channel.assertQueue(queueName, { durable: false, autoDelete: true }); 154 | }, 155 | }); 156 | 157 | await connection.connect(); 158 | 159 | const rxPromise = defer(); 160 | await receiveChannel.consume(queueName, (message) => { 161 | rxPromise.resolve(message); 162 | }); 163 | 164 | await sendChannel.sendToQueue(queueName, content); 165 | 166 | const result = await timeout(rxPromise.promise, 3000); 167 | expect(result.content.toString()).to.equal(content); 168 | 169 | await sendChannel.close(); 170 | await receiveChannel.close(); 171 | }); 172 | 173 | it('RPC', async () => { 174 | const queueName = 'testQueueRpc'; 175 | 176 | // Create a new connection manager 177 | connection = amqp.connect(['amqp://localhost']); 178 | 179 | let rpcClientQueueName = ''; 180 | 181 | const result = defer(); 182 | 183 | // Ask the connection manager for a ChannelWrapper. Specify a setup function to 184 | // run every time we reconnect to the broker. 185 | const rpcClient = connection.createChannel({ 186 | setup: async (channel: ConfirmChannel) => { 187 | const qok = await channel.assertQueue('', { exclusive: true }); 188 | rpcClientQueueName = qok.queue; 189 | 190 | await channel.consume( 191 | rpcClientQueueName, 192 | (message) => { 193 | result.resolve(message?.content.toString()); 194 | }, 195 | { noAck: true } 196 | ); 197 | }, 198 | }); 199 | 200 | const rpcServer = connection.createChannel({ 201 | setup: async (channel: ConfirmChannel) => { 202 | await channel.assertQueue(queueName, { durable: false, autoDelete: true }); 203 | await channel.prefetch(1); 204 | await channel.consume( 205 | queueName, 206 | (message) => { 207 | if (message) { 208 | channel.sendToQueue(message.properties.replyTo, Buffer.from('world'), { 209 | correlationId: message.properties.correlationId, 210 | }); 211 | } 212 | }, 213 | { noAck: true } 214 | ); 215 | }, 216 | }); 217 | 218 | await timeout(once(connection, 'connect'), 3000); 219 | await timeout(rpcClient.waitForConnect(), 3000); 220 | await timeout(rpcServer.waitForConnect(), 3000); 221 | 222 | // Send message from client to server. 223 | await rpcClient.sendToQueue(queueName, 'hello', { 224 | correlationId: 'test', 225 | replyTo: rpcClientQueueName, 226 | messageId: 'asdkasldk', 227 | }); 228 | 229 | const reply = await result.promise; 230 | expect(reply).to.equal('world'); 231 | 232 | await rpcClient.close(); 233 | await rpcServer.close(); 234 | }); 235 | 236 | it('direct-reply-to', async () => { 237 | // See https://www.rabbitmq.com/direct-reply-to.html 238 | const rpcClientQueueName = 'amq.rabbitmq.reply-to'; 239 | const queueName = 'testQueueRpc'; 240 | 241 | // Create a new connection manager 242 | connection = amqp.connect(['amqp://localhost']); 243 | 244 | const result = defer(); 245 | 246 | connection.on('disconnect', ({ err }) => { 247 | if (err) { 248 | console.log(err); 249 | } 250 | }); 251 | 252 | // Ask the connection manager for a ChannelWrapper. Specify a setup function to 253 | // run every time we reconnect to the broker. 254 | const rpcClient = connection.createChannel({ 255 | setup: async (channel: ConfirmChannel) => { 256 | await channel.consume( 257 | rpcClientQueueName, 258 | (message) => { 259 | result.resolve(message?.content.toString()); 260 | }, 261 | { noAck: true } 262 | ); 263 | }, 264 | }); 265 | 266 | const rpcServer = connection.createChannel({ 267 | setup: async (channel: ConfirmChannel) => { 268 | await channel.assertQueue(queueName, { durable: false, autoDelete: true }); 269 | await channel.prefetch(1); 270 | await channel.consume( 271 | queueName, 272 | (message) => { 273 | if (message) { 274 | channel.sendToQueue(message.properties.replyTo, Buffer.from('world'), { 275 | correlationId: message.properties.correlationId, 276 | }); 277 | } 278 | }, 279 | { noAck: true } 280 | ); 281 | }, 282 | }); 283 | 284 | await timeout(once(connection, 'connect'), 3000); 285 | await timeout(rpcServer.waitForConnect(), 3000); 286 | await timeout(rpcClient.waitForConnect(), 3000); 287 | 288 | // Send message from client to server. 289 | await rpcClient.sendToQueue(queueName, 'hello', { 290 | correlationId: 'test', 291 | replyTo: rpcClientQueueName, 292 | messageId: 'asdkasldk', 293 | }); 294 | 295 | const reply = await result.promise; 296 | expect(reply).to.equal('world'); 297 | 298 | await rpcClient.close(); 299 | await rpcServer.close(); 300 | }); 301 | 302 | it('should reconnect consumer after queue deletion', async function () { 303 | const queueName = 'testQueue'; 304 | 305 | connection = new AmqpConnectionManager('amqp://localhost', { reconnectTimeInSeconds: 0.5 }); 306 | const channelWrapper = connection.createChannel({ 307 | confirm: true, 308 | setup: async (channel: Channel) => { 309 | await channel.assertQueue(queueName, { durable: false, autoDelete: true }); 310 | }, 311 | }); 312 | 313 | const result = defer(); 314 | await channelWrapper.consume(queueName, (msg) => { 315 | result.resolve(msg.content.toString()); 316 | }); 317 | 318 | await Promise.all([connection.connect(), once(channelWrapper, 'connect')]); 319 | 320 | // The deleted queue should cause a reconnect 321 | await channelWrapper.deleteQueue(queueName); 322 | 323 | // Await all setup functions to run before sending a message 324 | await once(channelWrapper, 'connect'); 325 | await channelWrapper.sendToQueue(queueName, 'hello'); 326 | 327 | const content = await result.promise; 328 | expect(content).to.equal('hello'); 329 | }); 330 | }); 331 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "typeRoots": ["../node_modules/@types", "../@types"] 6 | }, 7 | "include": ["./**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "sourceMap": false, 6 | "module": "commonjs", 7 | "outDir": "./dist/cjs" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2018", 5 | "module": "es6", 6 | "lib": ["es2018"], 7 | "allowJs": false, 8 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 9 | "declaration": false, 10 | "sourceMap": true, 11 | "outDir": "./dist/esm", 12 | "stripInternal": true, 13 | 14 | /* Strict Type-Checking Options */ 15 | "strict": true /* Enable all strict type-checking options. */, 16 | // "strictNullChecks": true, /* Enable strict null checks. */ 17 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 18 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 19 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 20 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 21 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 22 | 23 | /* Additional Checks */ 24 | "forceConsistentCasingInFileNames": true, 25 | "noUnusedLocals": true /* Report errors on unused locals. */, 26 | "noUnusedParameters": true /* Report errors on unused parameters. */, 27 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 28 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 29 | 30 | /* Module Resolution Options */ 31 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 32 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 33 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 34 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 35 | "typeRoots": [ 36 | /* List of folders to include type definitions from. */ "./node_modules/@types", 37 | "./@types" 38 | ], 39 | // "typeRoots": [], /* List of folders to include type definitions from. */ 40 | // "types": [], /* Type declaration files to be included in compilation. */ 41 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 42 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 43 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 44 | 45 | /* Source Map Options */ 46 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 47 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 48 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 49 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 50 | 51 | /* Experimental Options */ 52 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 53 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 54 | }, 55 | "include": ["./src/**/*"], 56 | "compileOnSave": true 57 | } 58 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "emitDeclarationOnly": true, 6 | "outDir": "./dist/types" 7 | } 8 | } 9 | --------------------------------------------------------------------------------