├── .editorconfig ├── .github └── workflows │ └── run-test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── benchmark.ts ├── eslint.config.js ├── make-docs.sh ├── package-lock.json ├── package.json ├── release.sh ├── src ├── Channel.ts ├── Connection.ts ├── Consumer.ts ├── RPCClient.ts ├── SortedMap.ts ├── codec.ts ├── exception.ts ├── index.ts ├── normalize.ts ├── overrides.css ├── typedoc-plugin-versions.ts └── util.ts ├── test ├── SortedMap.ts ├── behavior.ts ├── consumer.ts ├── options.ts ├── rpc.ts ├── stream-helpers.ts └── util.ts ├── tsconfig.build.json ├── tsconfig.json └── typedoc.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = spaces 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/run-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, 2 | # cache/restore them, build the source code and run tests across different 3 | # versions of node For more information see: 4 | # https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 5 | 6 | name: Node.js CI 7 | 8 | on: 9 | push: 10 | branches: ["master"] 11 | pull_request: 12 | branches: ["master"] 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 15 18 | 19 | strategy: 20 | matrix: 21 | # See supported Node.js release schedule at: 22 | # https://nodejs.org/en/about/previous-releases 23 | node-version: [18, 20, 22] 24 | 25 | services: 26 | rabbitmq: 27 | image: rabbitmq:4-alpine 28 | ports: 29 | - 5672:5672 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Use Node.js ${{matrix.node-version}} 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: ${{matrix.node-version}} 37 | cache: 'npm' 38 | - run: npm ci 39 | - run: npm test 40 | timeout-minutes: 5 41 | env: 42 | RABBITMQ_URL: amqp://guest:guest@localhost:5672 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | /lib 4 | /node_modules 5 | /docs 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v5.0.5 2 | ### Performance 3 | Thanks to Ievgen Makukh (@Samuron), unconfirmed publishers should be a little bit faster. 4 | 5 | ## v5.0.4 6 | ### Bug Fixes 7 | Some types of async errors can now be emitted from the Channel object, 8 | rather than the Connection object. When acquiring a basic Channel, you 9 | can enable this: 10 | ```javascript 11 | const ch = connection.acquire({emitErrorsFromChannel: true}) 12 | ch.on('error', (err) => ...) 13 | ``` 14 | 15 | This option is false by default, to maintain backwards compatibility. 16 | However this is used internally by the Consumer & RPCClient classes. 17 | Consumers will now emit these errors to help with debugging in some 18 | situations. Similiarly, RPCClient requests may also get a "cause" 19 | attached to some errors. 20 | 21 | ## v5.0.3 22 | ### Bug Fixes 23 | RabbitMQ server v4.1 changed the minimum frameMax value, breaking clients which used the old default of 4096. The new default is 8192 (8 KiB). You can also use the `{frameMax: 8192}` connection option instead of upgrading this library. Thanks to @scottaj 24 | 25 | ## v5.0.2 26 | ### Bug Fixes 27 | - fix channel setup race 28 | 29 | ## v5.0.1 30 | ### Bug Fixes 31 | - 38c61f5 lazy channel should not get perma-stuck 32 | 33 | ## v5.0.0 34 | ### BREAKING CHANGES: 35 | #### 6766969 drop support for node 16 36 | Node 16 hit end-of-life on 2023-09-11. Typescript compiler target has been 37 | updated accordingly. 38 | 39 | #### 8000e78 rm publisher.publish() 40 | This method was just an alias for `send()` and has been deprecated for quite a 41 | while. To migrate just find & replace. 42 | 43 | #### 3bbfc87 consumer reply forces exchange="" 44 | See discussion in https://github.com/cody-greene/node-rabbitmq-client/issues/42 45 | 46 | The reply function provided to consumer handlers now forces the 47 | `exchange` argument to an empty string. 48 | 49 | If you want to publish to a different exchange, then use a dedicated publisher. 50 | This is already the default behavior so unless you're using 51 | `reply(msg, {exchange: 'whatever'})` you don't have to change anything. 52 | 53 | ```javascript 54 | const pub = rabbit.createPublisher() 55 | 56 | const sub = rabbit.createConsumer({queue: 'myqueue'}, async (msg, reply) => { 57 | reply('my response') // GOOD 58 | reply('my response', {exchange: 'other'}) // BAD 59 | await pub.send({exchange: 'other'}, 'my response') // GOOD 60 | }) 61 | ``` 62 | 63 | ### Features 64 | - a866874 (#58) `connection.onConnect()` Returns a promise which is resolved when the 65 | connection is established 66 | 67 | ## v4.6.0 68 | 69 | ### Features 70 | - Add lazy consumer options (#51): Start or restart a consumer with `sub.start()` 71 | 72 | ## v4.5.4 73 | 74 | ### Bug Fixes 75 | - silence MaxListenersExceededWarning with >10 channels 76 | 77 | ## v4.5.3 78 | 79 | ### Bug Fixes 80 | - experimental bun support 81 | 82 | ## v4.5.2 83 | 84 | ### Bug Fixes 85 | - aws-lambda, send() won't hang after reconnect (#46) 86 | 87 | ## v4.5.1 88 | 89 | ### Bug Fixes 90 | - Check frame size before socket write (#41) 91 | 92 | # v4.5.0 93 | Features: 94 | - You can now override the exchange and correlationId when replying to a 95 | consumed message with `reply()` (for messages with a replyTo header) 96 | 97 | # v4.4.0 98 | Features: 99 | - added a boolean getter, `connection.ready`: True if the connection is 100 | established and unblocked. Useful for healthchecks and the like. 101 | 102 | # v4.3.0 103 | Features: 104 | - Consumers now track some simple statistics: total messages 105 | acknowledged/requeued/dropped, and the current number of prefetched messages. 106 | Find these values in the `consumer.stats` object. 107 | 108 | # v4.2.1 109 | fix: closing a channel (in a certain order) no longer causes a lockup 110 | 111 | # v4.2.0 112 | [#29](https://github.com/cody-greene/node-rabbitmq-client/pull/29) Added some 113 | management methods to the top-level Connection interface. You can create/delete 114 | queues, exchanges, and bindings. A special channel is implicitly created and 115 | maintained internally, so you don't have to worry about it. New methods: 116 | - basicGet 117 | - exchangeBind 118 | - exchangeDeclare 119 | - exchangeDelete 120 | - exchangeUnbind 121 | - queueBind 122 | - queueDeclare 123 | - queueDelete 124 | - queuePurge 125 | - queueUnbind 126 | 127 | # v4.1.0 128 | - feat: Consumer return code [#21](https://github.com/cody-greene/node-rabbitmq-client/pull/21) 129 | 130 | # v4.0.0 131 | BREAKING CHANGES: 132 | - dropped node 14 (EOL 2023-04-30) 133 | - `MethodParams` is indexed by an enum rather than a string, e.g. 134 | `MethodParams['basic.ack'] -> MethodParams[Cmd.BasicAck]` 135 | - removed `nowait` from type definitions 136 | - fixed typo on exported type def: RPCCLient -> RPCClient 137 | 138 | Features: 139 | - Rename `Publisher.publish() -> .send()` and `RPCClient.publish() -> .send()` 140 | the old method still exists as an alias, for now (deprecated) 141 | - Added a few method overloads for: basicPublish, basicCancel, queueDeclare, 142 | queueDelete, queuePurge 143 | - Documentation improvements: more examples, clarifications 144 | - RPCClient supports retries `new RPCClient({maxAttempts: 2})` 145 | - Better stack traces. When `Publisher#send()` fails, due to an undeclared 146 | exchange for example, the stack trace now includes your application code, 147 | rather than the async internals of this library. 148 | 149 | # v3.3.2 150 | - fix: better heartbeats [#13](https://github.com/cody-greene/node-rabbitmq-client/pull/13) 151 | 152 | # v3.3.1 153 | - fix: out-of-order RPCs [#10](https://github.com/cody-greene/node-rabbitmq-client/pull/10) 154 | 155 | # v3.3.0 156 | - feat: expose Consumer.queue & Consumer.consumerTag fields 157 | - feat: add consumer concurrency limits [#6](https://github.com/cody-greene/node-rabbitmq-client/pull/6) 158 | 159 | # v3.2.0 160 | - feat: add Publisher maxAttempts [#5](https://github.com/cody-greene/node-rabbitmq-client/pull/5) 161 | 162 | # v3.1.3 163 | - improved error message when user/passwd is incorrect, i.e. ACCESS_REFUSED 164 | 165 | # v3.1.1 166 | - Consumer setup waits for in-progress jobs to complete before retrying 167 | - add "x-cancel-on-ha-failover" typedef 168 | 169 | # v3.1.0 170 | - add Connection.createRPClient() for easy RPC/request-response setup 171 | - fix header array encode/decode e.g. CC/BCC "Sender-selected Distribution" 172 | - adjust some HeaderFields typedefs 173 | - allow numbers in certain cases where a string is expected by the AMQP protocol, e.g. the "expiration" header prop; the value is trivially converted 174 | - fix array encode/decode for message headers in the rare cases it's actually used 175 | - reconnection backoff is a little more random 176 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018, Cody Greene 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/rabbitmq-client.svg)](https://badge.fury.io/js/rabbitmq-client) 2 | 3 | ## RabbitMQ Client 4 | Node.js client library for [RabbitMQ](https://www.rabbitmq.com/documentation.html). Publish 5 | messages, declare rules for routing those messages into queues, consume messages from queues. 6 | 7 | Why not amqplib? 8 | - No dependencies 9 | - Automatically re-connect, re-subscribe, or retry publishing 10 | - Optional higher-level Consumer/Publisher API for even more robustness 11 | - Written in typescript and published with heavily commented type definitions 12 | - [See here for full API documentation](http://cody-greene.github.io/node-rabbitmq-client) 13 | - Intuitive API with named parameters instead of positional 14 | - "x-arguments" like "x-message-ttl" don't have camelCase aliases 15 | 16 | ## RabbitMQ Compatibility 17 | 18 | - To connect to RabbitMQ version 4.1.x or higher, you must use version 5.0.3 or higher of this library. See [#75](https://github.com/cody-greene/node-rabbitmq-client/pull/75) and the [CHANGELOG](https://github.com/cody-greene/node-rabbitmq-client/blob/master/CHANGELOG.md#v503) for details 19 | 20 | ## Performance 21 | Performance is comparable to amqplib (see ./benchmark.ts). 22 | 23 | | Task Name | ops/sec | Average Time (ns) | Margin | Samples | 24 | |---------------------------------------------------|---------|-------------------|----------|---------| 25 | | rabbitmq-client publish-confirm (null route) | 2,611 | 382919 | ±3.69% | 1306 | 26 | | amqplib publish-confirm (null route) | 2,315 | 431880 | ±4.89% | 1158 | 27 | | rabbitmq-client publish-confirm (transient queue) | 961 | 1039884 | ±1.07% | 481 | 28 | | amqplib publish-confirm (transient queue) | 1,059 | 943706 | ±1.34% | 530 | 29 | 30 | ## Quick start 31 | In addition to the lower-level RabbitMQ methods, this library exposes two main 32 | interfaces, a `Consumer` and a `Publisher` (which should cover 90% of uses 33 | cases), as well as a third `RPCClient` for request-response communication. 34 | ```javascript 35 | import {Connection} from 'rabbitmq-client' 36 | 37 | // Initialize: 38 | const rabbit = new Connection('amqp://guest:guest@localhost:5672') 39 | rabbit.on('error', (err) => { 40 | console.log('RabbitMQ connection error', err) 41 | }) 42 | rabbit.on('connection', () => { 43 | console.log('Connection successfully (re)established') 44 | }) 45 | 46 | // Consume messages from a queue: 47 | // See API docs for all options 48 | const sub = rabbit.createConsumer({ 49 | queue: 'user-events', 50 | queueOptions: {durable: true}, 51 | // handle 2 messages at a time 52 | qos: {prefetchCount: 2}, 53 | // Optionally ensure an exchange exists 54 | exchanges: [{exchange: 'my-events', type: 'topic'}], 55 | // With a "topic" exchange, messages matching this pattern are routed to the queue 56 | queueBindings: [{exchange: 'my-events', routingKey: 'users.*'}], 57 | }, async (msg) => { 58 | console.log('received message (user-events)', msg) 59 | // The message is automatically acknowledged (BasicAck) when this function ends. 60 | // If this function throws an error, then msg is rejected (BasicNack) and 61 | // possibly requeued or sent to a dead-letter exchange. You can also return a 62 | // status code from this callback to control the ack/nack behavior 63 | // per-message. 64 | }) 65 | 66 | sub.on('error', (err) => { 67 | // Maybe the consumer was cancelled, or the connection was reset before a 68 | // message could be acknowledged. 69 | console.log('consumer error (user-events)', err) 70 | }) 71 | 72 | // Declare a publisher 73 | // See API docs for all options 74 | const pub = rabbit.createPublisher({ 75 | // Enable publish confirmations, similar to consumer acknowledgements 76 | confirm: true, 77 | // Enable retries 78 | maxAttempts: 2, 79 | // Optionally ensure the existence of an exchange before we use it 80 | exchanges: [{exchange: 'my-events', type: 'topic'}] 81 | }) 82 | 83 | // Publish a message to a custom exchange 84 | await pub.send( 85 | {exchange: 'my-events', routingKey: 'users.visit'}, // metadata 86 | {id: 1, name: 'Alan Turing'}) // message content 87 | 88 | // Or publish directly to a queue 89 | await pub.send('user-events', {id: 1, name: 'Alan Turing'}) 90 | 91 | // Clean up when you receive a shutdown signal 92 | async function onShutdown() { 93 | // Waits for pending confirmations and closes the underlying Channel 94 | await pub.close() 95 | // Stop consuming. Wait for any pending message handlers to settle. 96 | await sub.close() 97 | await rabbit.close() 98 | } 99 | process.on('SIGINT', onShutdown) 100 | process.on('SIGTERM', onShutdown) 101 | ``` 102 | 103 | ## Connection.createConsumer() vs Channel.basicConsume() 104 | The above `Consumer` & `Publisher` interfaces are recommended for most cases. 105 | These combine a few of the lower level RabbitMQ methods (exposed on the 106 | `Channel` interface) and and are much safer to use since they can recover after 107 | connection loss, or after a number of other edge-cases you may not have 108 | imagined. Consider the following list of scenarios (not exhaustive): 109 | - Connection lost due to a server restart, missed heartbeats (timeout), forced 110 | by the management UI, etc. 111 | - Channel closed as a result of publishing to an exchange which does not exist 112 | (or was deleted), or attempting to acknowledge an invalid deliveryTag 113 | - Consumer closed from the management UI, or because the queue was deleted, or 114 | because basicCancel() was called 115 | 116 | In all of these cases you would need to create a new channel and re-declare any 117 | queues/exchanges/bindings before you can start publishing/consuming messages 118 | again. And you're probably publishing many messages, concurrently, so you'd 119 | want to make sure this setup only runs once per connection. If a consumer is 120 | cancelled then you may be able to reuse the channel but you still need to check 121 | the queue and so on... 122 | 123 | The `Consumer` & `Publisher` interfaces abstract all of that away by running 124 | the necessary setup as needed and handling all the edge-cases for you. 125 | 126 | ## Managing queues & exchanges 127 | A number of management methods are available on the `Connection` interface; you 128 | can create/delete queues, exchanges, or bindings between them. It's generally 129 | safer to do this declaratively with a Consumer or Publisher. But maybe you 130 | just want to do something once. 131 | 132 | ```javascript 133 | const rabbit = new Connection() 134 | 135 | await rabbit.queueDeclare({queue: 'my-queue', exclusive: true}) 136 | 137 | await rabbit.exchangeDeclare({queue: 'my-queue', exchange: 'my-exchange', type: 'topic'}) 138 | 139 | await rabbit.queueBind({queue: 'my-queue', exchange: 'my-exchange'}) 140 | 141 | const {messageCount} = await rabbit.queueDeclare({queue: 'my-queue', passive: true}) 142 | ``` 143 | 144 | And if you really want to, you can acquire a raw AMQP Channel but this should 145 | be a last resort. 146 | 147 | ```javascript 148 | // Will wait for the connection to establish and then create a Channel 149 | const ch = await rabbit.acquire() 150 | 151 | // Channels can emit some events too (see documentation) 152 | ch.on('close', () => { 153 | console.log('channel was closed') 154 | }) 155 | 156 | const msg = ch.basicGet('my-queue') 157 | console.log(msg) 158 | 159 | // It's your responsibility to close any acquired channels 160 | await ch.close() 161 | ``` 162 | 163 | ## RPCClient: request-response communication between services 164 | This will create a single "client" `Channel` on which you may publish messages 165 | and listen for direct responses. This can allow, for example, two 166 | micro-services to communicate with each other using RabbitMQ as the middleman 167 | instead of directly via HTTP. 168 | 169 | ```javascript 170 | // rpc-server.js 171 | const rabbit = new Connection() 172 | 173 | const rpcServer = rabbit.createConsumer({ 174 | queue: 'my-rpc-queue' 175 | }, async (req, reply) => { 176 | console.log('request:', req.body) 177 | await reply('pong') 178 | }) 179 | 180 | process.on('SIGINT', async () => { 181 | await rpcServer.close() 182 | await rabbit.close() 183 | }) 184 | ``` 185 | 186 | ```javascript 187 | // rpc-client.js 188 | const rabbit = new Connection() 189 | 190 | const rpcClient = rabbit.createRPCClient({confirm: true}) 191 | 192 | const res = await rpcClient.send('my-rpc-queue', 'ping') 193 | console.log('response:', res.body) // pong 194 | 195 | await rpcClient.close() 196 | await rabbit.close() 197 | ``` 198 | -------------------------------------------------------------------------------- /benchmark.ts: -------------------------------------------------------------------------------- 1 | // node -r ts-node/register/transpile-only benchmark.ts 2 | /* eslint no-console: off */ 3 | import {randomBytes} from 'node:crypto' 4 | 5 | // @ts-ignore 6 | import amqplib from 'amqplib' // npm install --no-save amqplib 7 | import {Bench} from 'tinybench' 8 | 9 | import {Connection} from './lib' 10 | 11 | declare global { 12 | namespace NodeJS { 13 | export interface ProcessEnv { 14 | MODULE?: 'rabbitmq-client' | 'amqplib' 15 | RABBITMQ_URL?: string | undefined 16 | /** Disable Nagle's algorithm (send tcp packets immediately) */ 17 | NO_DELAY?: 'true'|'false' 18 | } 19 | } 20 | } 21 | 22 | const RABBITMQ_URL = process.env.RABBITMQ_URL || '' 23 | if (!RABBITMQ_URL) 24 | throw new TypeError('RABBITMQ_URL is unset') 25 | const NO_DELAY = process.env.RABBITMQ_NO_DELAY === 'true' 26 | const TRANSIENT_QUEUE = process.env.TRANSIENT_QUEUE || 'perf_' + randomBytes(8).toString('hex') 27 | const NULL_ROUTE = process.env.TRANSIENT_QUEUE || 'perf_' + randomBytes(8).toString('hex') 28 | 29 | const PAYLOAD = Buffer.from('prometheus') 30 | 31 | console.log(` 32 | NO_DELAY=${NO_DELAY} 33 | TRANSIENT_QUEUE=${TRANSIENT_QUEUE} 34 | NULL_ROUTE=${NULL_ROUTE} 35 | `) 36 | 37 | async function addRMQC(bench: Bench, rabbit: Connection, routingKey: string) { 38 | const pub = await rabbit.acquire() 39 | await pub.confirmSelect() 40 | bench.add(`rabbitmq-client publish-confirm (${routingKey})`, () => { 41 | return pub.basicPublish(routingKey, PAYLOAD) 42 | }, { 43 | afterAll() { 44 | pub.close() 45 | } 46 | }) 47 | } 48 | 49 | async function benchAMQPLIB(bench: Bench, conn: any, routingKey: string) { 50 | const ch = await conn.createConfirmChannel() 51 | bench.add(`amqplib publish-confirm (${routingKey})`, async () => { 52 | ch.publish('', routingKey, PAYLOAD, {}) 53 | return ch.waitForConfirms() 54 | }, { 55 | afterAll() { 56 | ch.close() 57 | } 58 | }) 59 | } 60 | 61 | async function main() { 62 | const bench = new Bench() 63 | 64 | const rabbit = new Connection({ 65 | url: RABBITMQ_URL, 66 | noDelay: NO_DELAY 67 | }) 68 | rabbit.on('error', err => { console.error('connection error', err) }) 69 | rabbit.on('connection.blocked', () => { console.error('connection.blocked') }) 70 | rabbit.on('connection.unblocked', () => { console.error('connection.unblocked') }) 71 | 72 | const conn = await amqplib.connect(RABBITMQ_URL, { 73 | noDelay: NO_DELAY 74 | }) 75 | // @ts-ignore 76 | conn.on('error', err => { console.error('connection error', err) }) 77 | conn.on('blocked', () => { console.log('connection.blocked') }) 78 | conn.on('unblocked', () => { console.log('connection.blocked') }) 79 | 80 | await addRMQC(bench, rabbit, NULL_ROUTE) 81 | await benchAMQPLIB(bench, conn, NULL_ROUTE) 82 | 83 | await rabbit.queueDeclare(TRANSIENT_QUEUE) 84 | await addRMQC(bench, rabbit, TRANSIENT_QUEUE) 85 | await benchAMQPLIB(bench, conn, TRANSIENT_QUEUE) 86 | await rabbit.queueDelete(TRANSIENT_QUEUE) 87 | 88 | await bench.run() 89 | 90 | await rabbit.close() 91 | await conn.close() 92 | 93 | console.table(bench.table()) 94 | } 95 | 96 | main() 97 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const ts = require('typescript-eslint') 4 | const js = require('@eslint/js') 5 | 6 | module.exports = ts.config({ 7 | files: ['src/**/*.ts', '*.ts'], 8 | extends: [ 9 | js.configs.recommended, 10 | ...ts.configs.recommended, 11 | ], 12 | rules: { 13 | '@typescript-eslint/no-explicit-any': 'off', 14 | '@typescript-eslint/no-namespace': 'off', 15 | '@typescript-eslint/no-unsafe-declaration-merging': 'off', 16 | '@typescript-eslint/no-unused-vars': 'warn', 17 | 'prefer-const': 'warn', 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /make-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Generate HTML documentation and commit to the gh-pages branch 3 | # View with: python -m http.server -d docs 8000 4 | # 5 | ## Get current docs: 6 | # git worktree add docs/ gh-pages 7 | # 8 | ## Release Steps 9 | # update CHANGELOG.md 10 | # git add CHANGELOG.md 11 | # ./release.sh 12 | # ./make-docs.sh (optional) 13 | # git push origin master gh-pages 14 | # npm publish 15 | # 16 | set -e 17 | 18 | # typedoc.json 19 | node -r ts-node/register/transpile-only node_modules/.bin/typedoc --customCss src/overrides.css 20 | 21 | lver=$(git describe --long --tags --dirty) 22 | read -p "Commit to gh-pages as $lver? Press key to continue.. " -n1 -s 23 | cd docs && git add --all && git commit --message="$lver" 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rabbitmq-client", 3 | "version": "5.0.5", 4 | "description": "Robust, typed, RabbitMQ (0-9-1) client library", 5 | "engines": { 6 | "node": ">=16" 7 | }, 8 | "homepage": "https://github.com/cody-greene/node-rabbitmq-client", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/cody-greene/node-rabbitmq-client.git" 12 | }, 13 | "keywords": [ 14 | "amqp", 15 | "rabbitmq", 16 | "reconnect", 17 | "0-9-1" 18 | ], 19 | "license": "MIT", 20 | "main": "./lib/index.js", 21 | "files": [ 22 | "lib/" 23 | ], 24 | "scripts": { 25 | "prepublishOnly": "rm -rf lib && tsc -p tsconfig.build.json", 26 | "test": "node -r ts-node/register --test test/*.ts" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^18.19.43", 30 | "eslint": "^9.8.0", 31 | "tinybench": "^2.5.1", 32 | "ts-node": "^10.4.0", 33 | "typedoc": "^0.26.5", 34 | "typescript": "^5.5.4", 35 | "typescript-eslint": "^8.0.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | VERSION_TAG="$1" 6 | 7 | sed -i -r "s/(\"version\":.*\")(.*)(\")/\1$VERSION_TAG\3/" package.json 8 | sed -i -r "s/^( version: ')(.*)(',)/\1$VERSION_TAG\3/" src/Connection.ts 9 | git add package.json src/Connection.ts 10 | git commit -m "release $VERSION_TAG" 11 | git tag v$VERSION_TAG 12 | -------------------------------------------------------------------------------- /src/Channel.ts: -------------------------------------------------------------------------------- 1 | import {AMQPError, AMQPChannelError, AMQPConnectionError} from './exception' 2 | import {createDeferred, Deferred, EncoderStream, recaptureAndThrow} from './util' 3 | import type {Connection} from './Connection' 4 | import EventEmitter from 'node:events' 5 | import { 6 | AsyncMessage, 7 | BodyFrame, 8 | Cmd, 9 | Envelope, 10 | FrameType, 11 | HeaderFrame, 12 | MessageBody, 13 | MethodFrame, 14 | MethodParams, 15 | ReplyCode, 16 | ReturnedMessage, 17 | SyncMessage, 18 | genContentFrames, 19 | genFrame, 20 | } from './codec' 21 | 22 | enum CH_MODE {NORMAL, TRANSACTION, CONFIRM} 23 | 24 | /** 25 | * AMQP messages consist of a MethodFrame followed by a HeaderFrame and a 26 | * number of BodyFrames. The body is chunked, limited by a max-frame-size 27 | * negotiated with the server. 28 | */ 29 | interface AMQPMessage { 30 | methodFrame?: MethodFrame, 31 | headerFrame?: HeaderFrame, 32 | /** Count of BodyFrames received */ 33 | received: number, 34 | chunks?: Buffer[] 35 | } 36 | 37 | export declare interface Channel { 38 | /** The specified consumer was stopped by the server. The error param 39 | * describes the reason for the cancellation. */ 40 | on(name: 'basic.cancel', cb: (consumerTag: string, err: any) => void): this; 41 | /** An undeliverable message was published with the "immediate" flag set, or 42 | * an unroutable message published with the "mandatory" flag set. The reply 43 | * code and text provide information about the reason that the message was 44 | * undeliverable. 45 | * {@label BASIC_RETURN} */ 46 | on(name: 'basic.return', cb: (msg: ReturnedMessage) => void): this; 47 | /** The channel was closed, because you closed it, or due to some error */ 48 | on(name: 'close', cb: () => void): this; 49 | /** Certain errors are emitted if the channel was acquired with 50 | * emitErrorsFromChannel=true, otherwise these are emitted from the 51 | * Connection object. */ 52 | on(name: 'error', cb: (err: any) => void): this; 53 | } 54 | 55 | /** 56 | * @see {@link Connection#acquire | Connection#acquire()} 57 | * @see {@link Connection#createConsumer | Connection#createConsumer()} 58 | * @see {@link Connection#createPublisher | Connection#createPublisher()} 59 | * @see {@link Connection#createRPCClient | Connection#createRPCClient()} 60 | * 61 | * A raw Channel can be acquired from your Connection, but please consider 62 | * using a higher level abstraction like a {@link Consumer} or 63 | * {@link Publisher} for most cases. 64 | * 65 | * AMQP is a multi-channelled protocol. Channels provide a way to multiplex a 66 | * heavyweight TCP/IP connection into several light weight connections. This 67 | * makes the protocol more “firewall friendly” since port usage is predictable. 68 | * It also means that traffic shaping and other network QoS features can be 69 | * easily employed. Channels are independent of each other and can perform 70 | * different functions simultaneously with other channels, the available 71 | * bandwidth being shared between the concurrent activities. 72 | * 73 | * @example 74 | * ``` 75 | * const rabbit = new Connection() 76 | * 77 | * // Will wait for the connection to establish and then create a Channel 78 | * const ch = await rabbit.acquire() 79 | * 80 | * // Channels can emit some events too (see documentation) 81 | * ch.on('close', () => { 82 | * console.log('channel was closed') 83 | * }) 84 | * 85 | * // Create a queue for the duration of this connection 86 | * await ch.queueDeclare({queue: 'my-queue'}) 87 | * 88 | * // Enable publisher acknowledgements 89 | * await ch.confirmSelect() 90 | * 91 | * const data = {title: 'just some object'} 92 | * 93 | * // Resolves when the data has been flushed through the socket or if 94 | * // ch.confirmSelect() was called: will wait for an acknowledgement 95 | * await ch.basicPublish({routingKey: 'my-queue'}, data) 96 | * 97 | * const msg = ch.basicGet('my-queue') 98 | * console.log(msg) 99 | * 100 | * await ch.queueDelete('my-queue') 101 | * 102 | * // It's your responsibility to close any acquired channels 103 | * await ch.close() 104 | * ``` 105 | */ 106 | export class Channel extends EventEmitter { 107 | /** @internal */ 108 | private _conn: Connection 109 | readonly id: number 110 | 111 | /** False if the channel is closed */ 112 | active: boolean 113 | 114 | /** @internal */ 115 | private _state: { 116 | unconfirmed: Map> 117 | mode: CH_MODE 118 | maxFrameSize: number 119 | rpc?: [dfd: Deferred, req: Cmd, res: Cmd] 120 | rpcBuffer: Array, req: Cmd, res: Cmd, it: Generator]> 121 | cleared: boolean, 122 | /** For tracking consumers created with basic.consume */ 123 | consumers: Map void> 124 | incoming?: AMQPMessage 125 | deliveryCount: number 126 | /** If true, emit errors from itself rather than from the Connection. I 127 | * should have done this from the beginning but now this is a back-compat flag. */ 128 | emitErrors: boolean 129 | /** 130 | * Ensures a channel can only publish one message at a time. 131 | * Multiple channels may interleave their DataFrames, but for any given channel 132 | * the header/body frames MUST follow a basic.publish 133 | */ 134 | stream: EncoderStream 135 | } 136 | 137 | /** @internal */ 138 | constructor(id: number, conn: Connection, emitErrors = false) { 139 | super() 140 | this._conn = conn 141 | this.id = id 142 | this.active = true 143 | this._state = { 144 | emitErrors: emitErrors, 145 | maxFrameSize: conn._opt.frameMax, 146 | deliveryCount: 1, 147 | mode: CH_MODE.NORMAL, 148 | unconfirmed: new Map(), 149 | rpcBuffer: [], 150 | cleared: false, 151 | consumers: new Map(), 152 | stream: new EncoderStream(conn._socket) 153 | } 154 | this._state.stream.on('error', () => { 155 | // don't need to propagate error here: 156 | // - if connection ended: already handled by the Connection class 157 | // - if encoding error: error recieved by write callback 158 | this.close() 159 | }) 160 | } 161 | 162 | /** Close the channel */ 163 | async close() { 164 | if (!this.active) { 165 | return 166 | } 167 | this.active = false 168 | try { 169 | // wait for encoder stream to end 170 | if (this._state.stream.writable) { 171 | if (!this._state.rpc) 172 | this._state.stream.end() 173 | await new Promise(resolve => this._state.stream.on('close', resolve)) 174 | } else { 175 | // if an rpc failed to encode then wait for it to clear 176 | await new Promise(setImmediate) 177 | } 178 | // wait for final rpc, if it was already sent 179 | if (this._state.rpc) { 180 | const [dfd] = this._state.rpc 181 | this._state.rpc = undefined 182 | await dfd.promise 183 | } 184 | // send channel.close 185 | const dfd = createDeferred() 186 | this._state.rpc = [dfd, Cmd.ChannelClose, Cmd.ChannelCloseOK] 187 | this._conn._writeMethod({ 188 | type: FrameType.METHOD, 189 | channelId: this.id, 190 | methodId: Cmd.ChannelClose, 191 | params: {replyCode: 200, replyText: '', methodId: 0}}) 192 | await dfd.promise 193 | } catch (err) { 194 | // ignored; if write fails because the connection closed then this is 195 | // technically a success. Can't have a channel without a connection! 196 | } finally { 197 | this._clear() 198 | } 199 | } 200 | 201 | /** @internal */ 202 | _handleRPC(methodId: Cmd, data: any) { 203 | if (methodId === Cmd.ChannelClose) { 204 | const params: MethodParams[Cmd.ChannelClose] = data 205 | this.active = false 206 | this._conn._writeMethod({ 207 | type: FrameType.METHOD, 208 | channelId: this.id, 209 | methodId: Cmd.ChannelCloseOK, 210 | params: undefined}) 211 | const strcode = ReplyCode[params.replyCode] || String(params.replyCode) 212 | const msg = Cmd[params.methodId] + ': ' + params.replyText 213 | const err = new AMQPChannelError(strcode, msg) 214 | //const badName = SPEC.getFullName(params.classId, params.methodId) 215 | if (params.methodId === Cmd.BasicPublish && this._state.unconfirmed.size > 0) { 216 | // reject first unconfirmed message 217 | const [tag, dfd] = this._state.unconfirmed.entries().next().value 218 | this._state.unconfirmed.delete(tag) 219 | dfd.reject(err) 220 | } else if (this._state.rpc && params.methodId === this._state.rpc[1]) { 221 | // or reject the rpc 222 | const [dfd] = this._state.rpc 223 | this._state.rpc = undefined 224 | dfd.reject(err) 225 | } else { 226 | // last resort 227 | if (this._state.emitErrors) { 228 | this.emit('error', err) 229 | } else { 230 | this._conn.emit('error', err) 231 | } 232 | } 233 | this._clear() 234 | return 235 | } 236 | if (!this._state.rpc) { 237 | throw new AMQPConnectionError('UNEXPECTED_FRAME', 238 | `client received unexpected method ch${this.id}:${Cmd[methodId]} ${JSON.stringify(data)}`) 239 | } 240 | const [dfd, , expectedId] = this._state.rpc 241 | this._state.rpc = undefined 242 | if (expectedId !== methodId) { 243 | throw new AMQPConnectionError('UNEXPECTED_FRAME', 244 | `client received unexpected method ch${this.id}:${Cmd[methodId]} ${JSON.stringify(data)}`) 245 | } 246 | dfd.resolve(data) 247 | if (this._state.stream.writable) { 248 | if (!this.active) 249 | this._state.stream.end() 250 | else if (this._state.rpcBuffer.length > 0) 251 | this._rpcNext(this._state.rpcBuffer.shift()!) 252 | } 253 | } 254 | 255 | /** 256 | * Invoke all pending response handlers with an error 257 | * @internal 258 | */ 259 | _clear(err?: Error) { 260 | if (this._state.cleared) 261 | return 262 | this._state.cleared = true 263 | if (err == null) 264 | err = new AMQPChannelError('CH_CLOSE', 'channel is closed') 265 | this.active = false 266 | if (this._state.rpc) { 267 | const [dfd] = this._state.rpc 268 | this._state.rpc = undefined 269 | dfd.reject(err) 270 | } 271 | for (const [dfd] of this._state.rpcBuffer) { 272 | dfd.reject(err) 273 | } 274 | this._state.rpcBuffer = [] 275 | for (const dfd of this._state.unconfirmed.values()) { 276 | dfd.reject(err) 277 | } 278 | this._state.unconfirmed.clear() 279 | this._state.consumers.clear() 280 | this._state.stream.destroy(err) 281 | this.emit('close') 282 | } 283 | 284 | /** @internal */ 285 | _onMethod(methodFrame: MethodFrame): void { 286 | if (this._state.incoming != null) { 287 | throw new AMQPConnectionError('UNEXPECTED_FRAME', 288 | 'unexpected method frame, already awaiting header/body; this is a bug') 289 | } 290 | const methodId = methodFrame.methodId 291 | if (methodId === Cmd.BasicDeliver || methodId === Cmd.BasicReturn || methodId === Cmd.BasicGetOK) { 292 | this._state.incoming = {methodFrame, headerFrame: undefined, chunks: undefined, received: 0} 293 | } else if (methodId === Cmd.BasicGetEmpty) { 294 | this._handleRPC(Cmd.BasicGetOK, undefined) 295 | } else if (this._state.mode === CH_MODE.CONFIRM && methodId === Cmd.BasicAck) { 296 | const params = methodFrame.params as Required 297 | if (params.multiple) { 298 | for (const [tag, dfd] of this._state.unconfirmed.entries()) { 299 | if (tag > params.deliveryTag) 300 | break 301 | dfd.resolve() 302 | this._state.unconfirmed.delete(tag) 303 | } 304 | } else { 305 | const dfd = this._state.unconfirmed.get(params.deliveryTag) 306 | if (dfd) { 307 | dfd.resolve() 308 | this._state.unconfirmed.delete(params.deliveryTag) 309 | } else { 310 | //TODO channel error; PRECONDITION_FAILED, unexpected ack 311 | } 312 | } 313 | } else if (this._state.mode === CH_MODE.CONFIRM && methodId === Cmd.BasicNack) { 314 | const params = methodFrame.params as Required 315 | if (params.multiple) { 316 | for (const [tag, dfd] of this._state.unconfirmed.entries()) { 317 | if (tag > params.deliveryTag) 318 | break 319 | dfd.reject(new AMQPError('NACK', 'message rejected by server')) 320 | this._state.unconfirmed.delete(tag) 321 | } 322 | } else { 323 | const dfd = this._state.unconfirmed.get(params.deliveryTag) 324 | if (dfd) { 325 | dfd.reject(new AMQPError('NACK', 'message rejected by server')) 326 | this._state.unconfirmed.delete(params.deliveryTag) 327 | } else { 328 | //TODO channel error; PRECONDITION_FAILED, unexpected nack 329 | } 330 | } 331 | } else if (methodId === Cmd.BasicCancel) { 332 | const params = methodFrame.params as Required 333 | this._state.consumers.delete(params.consumerTag) 334 | setImmediate(() => { 335 | this.emit('basic.cancel', params.consumerTag, new AMQPError('CANCEL_FORCED', 'cancelled by server')) 336 | }) 337 | //} else if (methodFrame.fullName === 'channel.flow') unsupported; https://blog.rabbitmq.com/posts/2014/04/breaking-things-with-rabbitmq-3-3 338 | } else { 339 | this._handleRPC(methodId, methodFrame.params) 340 | } 341 | } 342 | 343 | /** @internal */ 344 | _onHeader(headerFrame: HeaderFrame): void { 345 | if (!this._state.incoming || this._state.incoming.headerFrame || this._state.incoming.received > 0) 346 | throw new AMQPConnectionError('UNEXPECTED_FRAME', 'unexpected header frame; this is a bug') 347 | const expectedContentFrameCount = Math.ceil(headerFrame.bodySize / (this._state.maxFrameSize - 8)) 348 | this._state.incoming.headerFrame = headerFrame 349 | this._state.incoming.chunks = new Array(expectedContentFrameCount) 350 | if (expectedContentFrameCount === 0) 351 | this._onBody() 352 | } 353 | 354 | /** @internal */ 355 | _onBody(bodyFrame?: BodyFrame): void { 356 | if (this._state.incoming?.chunks == null || this._state.incoming.headerFrame == null || this._state.incoming.methodFrame == null) 357 | throw new AMQPConnectionError('UNEXPECTED_FRAME', 'unexpected AMQP body frame; this is a bug') 358 | if (bodyFrame) 359 | this._state.incoming.chunks[this._state.incoming.received++] = bodyFrame.payload 360 | if (this._state.incoming.received === this._state.incoming.chunks.length) { 361 | const {methodFrame, headerFrame, chunks} = this._state.incoming 362 | this._state.incoming = undefined 363 | 364 | let body: MessageBody = Buffer.concat(chunks) 365 | if (headerFrame.fields.contentType === 'text/plain' && !headerFrame.fields.contentEncoding) { 366 | body = body.toString() 367 | } else if (headerFrame.fields.contentType === 'application/json' && !headerFrame.fields.contentEncoding) { 368 | try { 369 | body = JSON.parse(body.toString()) 370 | } catch (_) { 371 | // do nothing; this is a user problem 372 | } 373 | } 374 | 375 | const uncastMessage = { 376 | ...methodFrame.params, 377 | ...headerFrame.fields, 378 | durable: headerFrame.fields.deliveryMode === 2, 379 | body 380 | } 381 | 382 | if (methodFrame.methodId === Cmd.BasicDeliver) { 383 | const msg = uncastMessage as AsyncMessage 384 | const handler = this._state.consumers.get(msg.consumerTag) 385 | if (!handler) { 386 | // this is a bug; missing handler for consumerTag 387 | // TODO should never happen but maybe close the channel here 388 | } else { 389 | // setImmediate allows basicConsume to resolve first if 390 | // basic.consume-ok & basic.deliver are received in the same chunk. 391 | // Also this resets the stack trace for handler() 392 | // no try-catch; users must handle their own errors 393 | setImmediate(handler, msg) 394 | } 395 | 396 | } else if (methodFrame.methodId === Cmd.BasicReturn) { 397 | setImmediate(() => { 398 | this.emit('basic.return', uncastMessage) // ReturnedMessage 399 | }) 400 | } else if (methodFrame.methodId === Cmd.BasicGetOK) { 401 | this._handleRPC(Cmd.BasicGetOK, uncastMessage) // SyncMessage 402 | } 403 | } 404 | } 405 | 406 | /** @internal 407 | * AMQP does not support RPC pipelining! 408 | * C = client 409 | * S = server 410 | * 411 | * C:basic.consume 412 | * C:queue.declare 413 | * ... 414 | * S:queue.declare <- response may arrive out of order 415 | * S:basic.consume 416 | * 417 | * So we can only have one RPC in-flight at a time: 418 | * C:basic.consume 419 | * S:basic.consume 420 | * C:queue.declare 421 | * S:queue.declare 422 | **/ 423 | _invoke

(req: P, res: Cmd, params: MethodParams[P]): Promise { 424 | if (!this.active) return Promise.reject(new AMQPChannelError('CH_CLOSE', 'channel is closed')) 425 | const dfd = createDeferred() 426 | const it = genFrame({ 427 | type: FrameType.METHOD, 428 | channelId: this.id, 429 | methodId: req, 430 | params: params} as MethodFrame, this._state.maxFrameSize) 431 | const rpc = [dfd, req, res, it] as const 432 | if (this._state.rpc) 433 | this._state.rpcBuffer.push(rpc) 434 | else 435 | this._rpcNext(rpc) 436 | return dfd.promise.catch(recaptureAndThrow) 437 | } 438 | 439 | /** @internal 440 | * Start the next RPC */ 441 | _rpcNext([dfd, req, res, it]: typeof this._state.rpcBuffer[number]) { 442 | this._state.rpc = [dfd, req, res] 443 | this._state.stream.write(it, (err) => { 444 | if (err) { 445 | this._state.rpc = undefined 446 | dfd.reject(err) 447 | } 448 | }) 449 | } 450 | 451 | /** @internal */ 452 | _invokeNowait(methodId: T, params: MethodParams[T]): void { 453 | if (!this.active) 454 | throw new AMQPChannelError('CH_CLOSE', 'channel is closed') 455 | const frame = { 456 | type: FrameType.METHOD, 457 | channelId: this.id, 458 | methodId: methodId, 459 | params: params 460 | } 461 | this._state.stream.write(genFrame(frame as MethodFrame, this._state.maxFrameSize), (err) => { 462 | if (err) { 463 | err.message += '; ' + Cmd[methodId] 464 | if (this._state.emitErrors) { 465 | this.emit('error', err) 466 | } else { 467 | this._conn.emit('error', err) 468 | } 469 | } 470 | }) 471 | } 472 | 473 | /** 474 | * This method publishes a message to a specific exchange. The message will 475 | * be routed to queues as defined by the exchange configuration. 476 | * 477 | * If the body is a string then it will be serialized with 478 | * contentType='text/plain'. If body is an object then it will be serialized 479 | * with contentType='application/json'. Buffer objects are unchanged. 480 | * 481 | * If publisher-confirms are enabled, then this will resolve when the 482 | * acknowledgement is received. Otherwise this will resolve after writing to 483 | * the TCP socket, which is usually immediate. Note that if you keep 484 | * publishing while the connection is blocked (see 485 | * {@link Connection#on:BLOCKED | Connection#on('connection.blocked')}) then 486 | * the TCP socket buffer will eventually fill and this method will no longer 487 | * resolve immediately. */ 488 | basicPublish(envelope: Envelope, body: MessageBody): Promise 489 | /** Send directly to a queue. Same as `basicPublish({routingKey: queue}, body)` */ 490 | basicPublish(queue: string, body: MessageBody): Promise 491 | /** @ignore */ 492 | basicPublish(envelope: string|Envelope, body: MessageBody): Promise 493 | basicPublish(params: string|Envelope, body: MessageBody): Promise { 494 | if (!this.active) return Promise.reject(new AMQPChannelError('CH_CLOSE', 'channel is closed')) 495 | if (typeof params == 'string') { 496 | params = {routingKey: params} 497 | } 498 | params = Object.assign({timestamp: Math.floor(Date.now() / 1000)}, params) 499 | params.deliveryMode = (params.durable || params.deliveryMode === 2) ? 2 : 1 500 | params.rsvp1 = 0 501 | if (typeof body == 'string') { 502 | body = Buffer.from(body, 'utf8') 503 | params.contentType = 'text/plain' 504 | params.contentEncoding = undefined 505 | } else if (!Buffer.isBuffer(body)) { 506 | body = Buffer.from(JSON.stringify(body), 'utf8') 507 | params.contentType = 'application/json' 508 | params.contentEncoding = undefined 509 | } 510 | const publish = this._state.stream.writeAsync(genContentFrames(this.id, params, body, this._state.maxFrameSize)) 511 | if (this._state.mode === CH_MODE.CONFIRM) { 512 | return publish.then(() => { 513 | // wait for basic.ack or basic.nack 514 | // note: Unroutable mandatory messages are acknowledged right 515 | // after the basic.return method. May be ack'd out-of-order. 516 | const dfd = createDeferred() 517 | this._state.unconfirmed.set(this._state.deliveryCount++, dfd) 518 | return dfd.promise 519 | }) 520 | } 521 | return publish 522 | } 523 | 524 | /** 525 | * This is a low-level method; consider using {@link Connection#createConsumer | Connection#createConsumer()} instead. 526 | * 527 | * Begin consuming messages from a queue. Consumers last as long as the 528 | * channel they were declared on, or until the client cancels them. The 529 | * callback `cb(msg)` is called for each incoming message. You must call 530 | * {@link Channel#basicAck} to complete the delivery, usually after you've 531 | * finished some task. */ 532 | async basicConsume(params: MethodParams[Cmd.BasicConsume], cb: (msg: AsyncMessage) => void): Promise { 533 | const data = await this._invoke(Cmd.BasicConsume, Cmd.BasicConsumeOK, {...params, rsvp1: 0, nowait: false}) 534 | const consumerTag = data.consumerTag 535 | this._state.consumers.set(consumerTag, cb) 536 | return {consumerTag} 537 | } 538 | 539 | /** Stop a consumer. */ 540 | basicCancel(consumerTag: string): Promise 541 | basicCancel(params: MethodParams[Cmd.BasicCancel]): Promise 542 | /** @ignore */ 543 | basicCancel(params: string|MethodParams[Cmd.BasicCancel]): Promise 544 | async basicCancel(params: string|MethodParams[Cmd.BasicCancel]): Promise { 545 | if (typeof params == 'string') { 546 | params = {consumerTag: params} 547 | } 548 | if (params.consumerTag == null) 549 | throw new TypeError('consumerTag is undefined; expected a string') 550 | // note: server may send a few messages before basic.cancel-ok is returned 551 | const res = await this._invoke(Cmd.BasicCancel, Cmd.BasicCancelOK, {...params, nowait: false}) 552 | this._state.consumers.delete(params.consumerTag) 553 | return res 554 | } 555 | 556 | /** 557 | * This method sets the channel to use publisher acknowledgements. 558 | * https://www.rabbitmq.com/confirms.html#publisher-confirms 559 | */ 560 | async confirmSelect(): Promise { 561 | await this._invoke(Cmd.ConfirmSelect, Cmd.ConfirmSelectOK, {nowait: false}) 562 | this._state.mode = CH_MODE.CONFIRM 563 | } 564 | 565 | /** 566 | * Don't use this unless you know what you're doing. This method is provided 567 | * for the sake of completeness, but you should use `confirmSelect()` instead. 568 | * 569 | * Sets the channel to use standard transactions. The client must use this 570 | * method at least once on a channel before using the Commit or Rollback 571 | * methods. Mutually exclusive with confirm mode. 572 | */ 573 | async txSelect(): Promise { 574 | await this._invoke(Cmd.TxSelect, Cmd.TxSelectOK, undefined) 575 | this._state.mode = CH_MODE.TRANSACTION 576 | } 577 | 578 | /** Declare queue, create if needed. If `queue` empty or undefined then a 579 | * random queue name is generated (see the return value). */ 580 | queueDeclare(params: MethodParams[Cmd.QueueDeclare]): Promise 581 | queueDeclare(queue?: string): Promise 582 | /** @ignore */ 583 | queueDeclare(params?: string|MethodParams[Cmd.QueueDeclare]): Promise 584 | queueDeclare(params: string|MethodParams[Cmd.QueueDeclare] = ''): Promise { 585 | if (typeof params == 'string') { 586 | params = {queue: params} 587 | } 588 | return this._invoke(Cmd.QueueDeclare, Cmd.QueueDeclareOK, {...params, rsvp1: 0, nowait: false}) 589 | } 590 | /** Acknowledge one or more messages. */ 591 | basicAck(params: MethodParams[Cmd.BasicAck]): void { 592 | return this._invokeNowait(Cmd.BasicAck, params) 593 | } 594 | /** Request a single message from a queue. Useful for testing. */ 595 | basicGet(params: MethodParams[Cmd.BasicGet]): Promise 596 | basicGet(queue?: string): Promise 597 | /** @ignore */ 598 | basicGet(params?: string|MethodParams[Cmd.BasicGet]): Promise 599 | basicGet(params: string|MethodParams[Cmd.BasicGet] = ''): Promise { 600 | if (typeof params == 'string') { 601 | params = {queue: params} 602 | } 603 | return this._invoke(Cmd.BasicGet, Cmd.BasicGetOK, {...params, rsvp1: 0}) 604 | } 605 | /** Reject one or more incoming messages. */ 606 | basicNack(params: MethodParams[Cmd.BasicNack]): void { 607 | this._invokeNowait(Cmd.BasicNack, {...params, requeue: typeof params.requeue == 'undefined' ? true : params.requeue}) 608 | } 609 | /** Specify quality of service. */ 610 | async basicQos(params: MethodParams[Cmd.BasicQos]): Promise { 611 | await this._invoke(Cmd.BasicQos, Cmd.BasicQosOK, params) 612 | } 613 | /** 614 | * This method asks the server to redeliver all unacknowledged messages on a 615 | * specified channel. Zero or more messages may be redelivered. 616 | */ 617 | async basicRecover(params: MethodParams[Cmd.BasicRecover]): Promise { 618 | await this._invoke(Cmd.BasicRecover, Cmd.BasicRecoverOK, params) 619 | } 620 | /** Bind exchange to an exchange. */ 621 | async exchangeBind(params: MethodParams[Cmd.ExchangeBind]): Promise { 622 | if (params.destination == null) 623 | throw new TypeError('destination is undefined; expected a string') 624 | if (params.source == null) 625 | throw new TypeError('source is undefined; expected a string') 626 | await this._invoke(Cmd.ExchangeBind, Cmd.ExchangeBindOK, {...params, rsvp1: 0, nowait: false}) 627 | } 628 | /** Verify exchange exists, create if needed. */ 629 | async exchangeDeclare(params: MethodParams[Cmd.ExchangeDeclare]): Promise { 630 | if (params.exchange == null) 631 | throw new TypeError('exchange is undefined; expected a string') 632 | await this._invoke(Cmd.ExchangeDeclare, Cmd.ExchangeDeclareOK, {...params, type: params.type || 'direct', rsvp1: 0, nowait: false}) 633 | } 634 | /** Delete an exchange. */ 635 | async exchangeDelete(params: MethodParams[Cmd.ExchangeDelete]): Promise { 636 | if (params.exchange == null) 637 | throw new TypeError('exchange is undefined; expected a string') 638 | await this._invoke(Cmd.ExchangeDelete, Cmd.ExchangeDeleteOK, {...params, rsvp1: 0, nowait: false}) 639 | } 640 | /** Unbind an exchange from an exchange. */ 641 | async exchangeUnbind(params: MethodParams[Cmd.ExchangeUnbind]): Promise { 642 | if (params.destination == null) 643 | throw new TypeError('destination is undefined; expected a string') 644 | if (params.source == null) 645 | throw new TypeError('source is undefined; expected a string') 646 | await this._invoke(Cmd.ExchangeUnbind, Cmd.ExchangeUnbindOK, {...params, rsvp1: 0, nowait: false}) 647 | } 648 | /** 649 | * This method binds a queue to an exchange. Until a queue is bound it will 650 | * not receive any messages. In a classic messaging model, store-and-forward 651 | * queues are bound to a direct exchange and subscription queues are bound to 652 | * a topic exchange. 653 | */ 654 | async queueBind(params: MethodParams[Cmd.QueueBind]): Promise { 655 | if (params.exchange == null) 656 | throw new TypeError('exchange is undefined; expected a string') 657 | await this._invoke(Cmd.QueueBind, Cmd.QueueBindOK, {...params, nowait: false}) 658 | } 659 | /** This method deletes a queue. When a queue is deleted any pending messages 660 | * are sent to a dead-letter queue if this is defined in the server 661 | * configuration, and all consumers on the queue are cancelled. If `queue` is 662 | * empty or undefined then the last declared queue on the channel is used. */ 663 | queueDelete(params: MethodParams[Cmd.QueueDelete]): Promise 664 | queueDelete(queue?: string): Promise 665 | /** @ignore */ 666 | queueDelete(params?: string|MethodParams[Cmd.QueueDelete]): Promise 667 | queueDelete(params: string|MethodParams[Cmd.QueueDelete] = ''): Promise { 668 | if (typeof params == 'string') { 669 | params = {queue: params} 670 | } 671 | return this._invoke(Cmd.QueueDelete, Cmd.QueueDeleteOK, {...params, rsvp1: 0, nowait: false}) 672 | } 673 | /** Remove all messages from a queue which are not awaiting acknowledgment. 674 | * If `queue` is empty or undefined then the last declared queue on the 675 | * channel is used. */ 676 | queuePurge(queue?: string): Promise 677 | queuePurge(params: MethodParams[Cmd.QueuePurge]): Promise 678 | /** @ignore */ 679 | queuePurge(params?: string|MethodParams[Cmd.QueuePurge]): Promise 680 | queuePurge(params: string|MethodParams[Cmd.QueuePurge] = ''): Promise { 681 | if (typeof params == 'string') { 682 | params = {queue: params} 683 | } 684 | return this._invoke(Cmd.QueuePurge, Cmd.QueuePurgeOK, {queue: params.queue, rsvp1: 0, nowait: false}) 685 | } 686 | /** Unbind a queue from an exchange. */ 687 | async queueUnbind(params: MethodParams[Cmd.QueueUnbind]): Promise { 688 | if (params.exchange == null) 689 | throw new TypeError('exchange is undefined; expected a string') 690 | await this._invoke(Cmd.QueueUnbind, Cmd.QueueUnbindOK, {...params, rsvp1: 0}) 691 | } 692 | /** 693 | * This method commits all message publications and acknowledgments performed 694 | * in the current transaction. A new transaction starts immediately after a 695 | * commit. 696 | */ 697 | async txCommit(): Promise { 698 | await this._invoke(Cmd.TxCommit, Cmd.TxCommitOK, undefined) 699 | } 700 | /** 701 | * This method abandons all message publications and acknowledgments 702 | * performed in the current transaction. A new transaction starts immediately 703 | * after a rollback. Note that unacked messages will not be automatically 704 | * redelivered by rollback; if that is required an explicit recover call 705 | * should be issued. 706 | */ 707 | async txRollback(): Promise { 708 | await this._invoke(Cmd.TxRollback, Cmd.TxRollbackOK, undefined) 709 | } 710 | } 711 | -------------------------------------------------------------------------------- /src/Connection.ts: -------------------------------------------------------------------------------- 1 | import net, {Socket} from 'node:net' 2 | import tls from 'node:tls' 3 | import EventEmitter from 'node:events' 4 | import {AMQPConnectionError, AMQPChannelError, AMQPError} from './exception' 5 | import {READY_STATE, createAsyncReader, expBackoff, createDeferred, Deferred, recaptureAndThrow} from './util' 6 | import { 7 | Cmd, 8 | DataFrame, 9 | FrameType, 10 | HEARTBEAT_FRAME, 11 | MethodFrame, 12 | MethodParams, 13 | PROTOCOL_HEADER, 14 | decodeFrame, 15 | encodeFrame, 16 | Envelope, 17 | MessageBody, 18 | ReturnedMessage, 19 | ReplyCode, 20 | SyncMessage 21 | } from './codec' 22 | import {Channel} from './Channel' 23 | import normalizeOptions, {ConnectionOptions} from './normalize' 24 | import SortedMap from './SortedMap' 25 | import {Consumer, ConsumerProps, ConsumerHandler} from './Consumer' 26 | import {RPCClient, RPCProps} from './RPCClient' 27 | 28 | /** @internal */ 29 | function raceWithTimeout(promise: Promise, ms: number, msg: string): Promise { 30 | let timer: NodeJS.Timeout 31 | return Promise.race([ 32 | promise, 33 | new Promise((resolve, reject) => 34 | timer = setTimeout(() => reject(new AMQPError('TIMEOUT', msg)), ms)) 35 | ]).finally(() => { 36 | clearTimeout(timer) 37 | }) 38 | } 39 | 40 | const CLIENT_PROPERTIES = { 41 | product: 'rabbitmq-client', 42 | version: '5.0.5', 43 | platform: `node.js-${process.version}`, 44 | capabilities: { 45 | 'basic.nack': true, 46 | 'connection.blocked': true, 47 | publisher_confirms: true, 48 | exchange_exchange_bindings: true, 49 | // https://www.rabbitmq.com/consumer-cancel.html 50 | consumer_cancel_notify: true, 51 | // https://www.rabbitmq.com/auth-notification.html 52 | authentication_failure_close: true, 53 | } 54 | } 55 | 56 | export declare interface Connection { 57 | /** The connection is successfully (re)established */ 58 | on(name: 'connection', cb: () => void): this; 59 | /** The rabbitmq server is low on resources. Message publishers should pause. 60 | * The outbound side of the TCP socket is blocked until 61 | * "connection.unblocked" is received, meaning messages will be held in 62 | * memory. 63 | * {@link https://www.rabbitmq.com/connection-blocked.html} 64 | * {@label BLOCKED} */ 65 | on(name: 'connection.blocked', cb: (reason: string) => void): this; 66 | /** The rabbitmq server is accepting new messages. */ 67 | on(name: 'connection.unblocked', cb: () => void): this; 68 | on(name: 'error', cb: (err: any) => void): this; 69 | } 70 | 71 | /** 72 | * This represents a single connection to a RabbitMQ server (or cluster). Once 73 | * created, it will immediately attempt to establish a connection. When the 74 | * connection is lost, for whatever reason, it will reconnect. This implements 75 | * the EventEmitter interface and may emit `error` events. Close it with 76 | * {@link Connection#close | Connection#close()} 77 | * 78 | * @example 79 | * ``` 80 | * const rabbit = new Connection('amqp://guest:guest@localhost:5672') 81 | * rabbit.on('error', (err) => { 82 | * console.log('RabbitMQ connection error', err) 83 | * }) 84 | * rabbit.on('connection', () => { 85 | * console.log('RabbitMQ (re)connected') 86 | * }) 87 | * process.on('SIGINT', () => { 88 | * rabbit.close() 89 | * }) 90 | * ``` 91 | */ 92 | export class Connection extends EventEmitter { 93 | /** @internal */ 94 | _opt: ReturnType 95 | /** @internal */ 96 | _socket: Socket 97 | 98 | /** @internal */ 99 | _state: { 100 | channelMax: number, 101 | frameMax: number, 102 | heartbeatTimer?: NodeJS.Timeout, 103 | /** Received data since last heartbeat */ 104 | hasRead?: boolean, 105 | /** Sent data since last heartbeat */ 106 | hasWrite?: boolean, 107 | hostIndex: number, 108 | lazyChannel?: Channel|Promise, 109 | retryCount: number, 110 | retryTimer?: NodeJS.Timeout, 111 | connectionTimer?: NodeJS.Timeout, 112 | readyState: READY_STATE, 113 | leased: SortedMap, 114 | /** Resolved when connection is (re)established. Rejected when the connection is closed. */ 115 | onConnect: Deferred, 116 | /** Resolved when all Channels are closed */ 117 | onEmpty: Deferred 118 | } 119 | 120 | constructor(propsOrUrl?: string|ConnectionOptions) { 121 | super() 122 | this._connect = this._connect.bind(this) 123 | this._opt = normalizeOptions(propsOrUrl) 124 | this._state = { 125 | channelMax: this._opt.maxChannels, 126 | frameMax: this._opt.frameMax, 127 | onEmpty: createDeferred(), 128 | // ignore unhandled rejection e.g. no one is waiting for a channel 129 | onConnect: createDeferred(true), 130 | connectionTimer: undefined, 131 | hostIndex: 0, 132 | leased: new SortedMap(), 133 | readyState: READY_STATE.CONNECTING, 134 | retryCount: 1, 135 | retryTimer: undefined 136 | } 137 | 138 | this._socket = this._connect() 139 | } 140 | 141 | /** 142 | * Allocate and return a new AMQP Channel. You MUST close the channel 143 | * yourself. Will wait for connect/reconnect when necessary. 144 | */ 145 | async acquire(opt?: {emitErrorsFromChannel?: boolean}): Promise { 146 | if (this._state.readyState >= READY_STATE.CLOSING) 147 | throw new AMQPConnectionError('CLOSING', 'channel creation failed; connection is closing') 148 | if (this._state.readyState === READY_STATE.CONNECTING) { 149 | // TODO also wait for connection.unblocked 150 | await raceWithTimeout( 151 | this._state.onConnect.promise, 152 | this._opt.acquireTimeout, 153 | 'channel aquisition timed out' 154 | ).catch(recaptureAndThrow) 155 | } 156 | 157 | // choosing an available channel id from this SortedMap is certainly slower 158 | // than incrementing a counter from 1 to MAX_CHANNEL_ID. However 159 | // this method allows for safely reclaiming old IDs once MAX_CHANNEL_ID+1 160 | // channels have been created. Also this function runs in O(log n) time 161 | // where n <= 0xffff. Which means ~16 tree nodes in the worst case. So it 162 | // shouldn't be noticable. And who needs that many Channels anyway!? 163 | const id = this._state.leased.pick() 164 | if (id > this._state.channelMax) 165 | throw new Error(`maximum number of AMQP Channels already opened (${this._state.channelMax})`) 166 | const ch = new Channel(id, this, opt?.emitErrorsFromChannel) 167 | this._state.leased.set(id, ch) 168 | ch.once('close', () => { 169 | this._state.leased.delete(id) 170 | this._checkEmpty() 171 | }) 172 | await ch._invoke(Cmd.ChannelOpen, Cmd.ChannelOpenOK, {rsvp1: ''}) 173 | return ch 174 | } 175 | 176 | /** 177 | * Wait for channels to close and then end the connection. Will not 178 | * automatically close any channels, giving you the chance to ack/nack any 179 | * outstanding messages while preventing new channels. 180 | */ 181 | async close(): Promise { 182 | if (this._state.readyState === READY_STATE.CLOSED) 183 | return 184 | if (this._state.readyState === READY_STATE.CLOSING) 185 | return new Promise(resolve => this._socket.once('close', resolve)) 186 | 187 | if (this._state.readyState === READY_STATE.CONNECTING) { 188 | this._state.readyState = READY_STATE.CLOSING 189 | if (this._state.retryTimer) 190 | clearTimeout(this._state.retryTimer) 191 | this._state.retryTimer = undefined 192 | this._state.onConnect.reject( 193 | new AMQPConnectionError('CLOSING', 'channel creation failed; connection is closing')) 194 | this._socket.destroy() 195 | return 196 | } 197 | 198 | this._state.readyState = READY_STATE.CLOSING 199 | if (this._state.lazyChannel instanceof Promise) { 200 | this._state.lazyChannel.then(ch => ch.close()) 201 | } else if (this._state.lazyChannel) { 202 | this._state.lazyChannel.close() 203 | } 204 | this._checkEmpty() 205 | // wait for all channels to close 206 | await this._state.onEmpty.promise 207 | 208 | clearInterval(this._state.heartbeatTimer) 209 | this._state.heartbeatTimer = undefined 210 | 211 | // might have transitioned to CLOSED while waiting for channels 212 | if (this._socket.writable) { 213 | this._writeMethod({ 214 | type: FrameType.METHOD, 215 | channelId: 0, 216 | methodId: Cmd.ConnectionClose, 217 | params: {replyCode: 200, methodId: 0, replyText: ''}}) 218 | this._socket.end() 219 | await new Promise(resolve => this._socket.once('close', resolve)) 220 | } 221 | } 222 | 223 | /** Immediately destroy the connection. All channels are closed. All pending 224 | * actions are rejected. */ 225 | unsafeDestroy(): void { 226 | if (this._state.readyState === READY_STATE.CLOSED) 227 | return 228 | // CLOSING, CONNECTING, OPEN 229 | this._state.readyState = READY_STATE.CLOSING 230 | if (this._state.retryTimer) 231 | clearTimeout(this._state.retryTimer) 232 | this._state.retryTimer = undefined 233 | this._state.onConnect.reject( 234 | new AMQPConnectionError('CLOSING', 'channel creation failed; connection is closing')) 235 | this._socket.destroy() 236 | } 237 | 238 | /** Create a message consumer that can recover from dropped connections. 239 | * @param cb Process an incoming message. */ 240 | createConsumer(props: ConsumerProps, cb: ConsumerHandler): Consumer { 241 | return new Consumer(this, props, cb) 242 | } 243 | 244 | /** This will create a single "client" `Channel` on which you may publish 245 | * messages and listen for direct responses. This can allow, for example, two 246 | * micro-services to communicate with each other using RabbitMQ as the 247 | * middleman instead of directly via HTTP. */ 248 | createRPCClient(props?: RPCProps): RPCClient { 249 | return new RPCClient(this, props || {}) 250 | } 251 | 252 | /** 253 | * Create a message publisher that can recover from dropped connections. 254 | * This will create a dedicated Channel, declare queues, declare exchanges, 255 | * and declare bindings. If the connection is reset, then all of this setup 256 | * will rerun on a new Channel. This also supports retries. 257 | */ 258 | createPublisher(props: PublisherProps = {}): Publisher { 259 | let _ch: Channel|undefined 260 | let pendingSetup: Promise|undefined 261 | let isClosed = false 262 | const maxAttempts = props.maxAttempts || 1 263 | const emitter = new EventEmitter() 264 | 265 | const setup = async () => { 266 | const ch = await this.acquire() 267 | ch.on('basic.return', (msg) => emitter.emit('basic.return', msg)) 268 | if (props.queues) for (const params of props.queues) { 269 | await ch.queueDeclare(params) 270 | } 271 | if (props.exchanges) for (const params of props.exchanges) { 272 | await ch.exchangeDeclare(params) 273 | } 274 | if (props.queueBindings) for (const params of props.queueBindings) { 275 | await ch.queueBind(params) 276 | } 277 | if (props.exchangeBindings) for (const params of props.exchangeBindings) { 278 | await ch.exchangeBind(params) 279 | } 280 | if (props.confirm) 281 | await ch.confirmSelect() 282 | _ch = ch 283 | return ch 284 | } 285 | 286 | const send = async (envelope: string|Envelope, body: MessageBody) => { 287 | let attempts = 0 288 | while (true) try { 289 | if (isClosed) 290 | throw new AMQPChannelError('CLOSED', 'publisher is closed') 291 | if (!_ch?.active) { 292 | if (!pendingSetup) 293 | pendingSetup = setup().finally(() =>{ pendingSetup = undefined }) 294 | _ch = await pendingSetup 295 | } 296 | return await _ch.basicPublish(envelope, body) 297 | } catch (err) { 298 | Error.captureStackTrace(err) // original async trace is likely not useful to users 299 | if (++attempts >= maxAttempts) { 300 | throw err 301 | } else { // notify & loop 302 | emitter.emit('retry', err, envelope, body) 303 | } 304 | } 305 | } 306 | 307 | return Object.assign(emitter, { 308 | send: send, 309 | close() { 310 | isClosed = true 311 | if (pendingSetup) 312 | return pendingSetup.then(ch => ch.close()) 313 | return _ch ? _ch.close() : Promise.resolve() 314 | } 315 | }) 316 | } 317 | 318 | /** @internal */ 319 | private _connect(): Socket { 320 | this._state.retryTimer = undefined 321 | 322 | // get next host, round-robin 323 | const host = this._opt.hosts[this._state.hostIndex] 324 | this._state.hostIndex = (this._state.hostIndex + 1) % this._opt.hosts.length 325 | 326 | // assume any previously opened socket is already fully closed 327 | let socket: Socket 328 | if (this._opt.tls) { 329 | socket = tls.connect({ 330 | port: host.port, 331 | host: host.hostname, 332 | ...this._opt.socket, 333 | ...this._opt.tls 334 | }) 335 | } else { 336 | socket = net.connect({ 337 | port: host.port, 338 | host: host.hostname, 339 | ...this._opt.socket 340 | }) 341 | } 342 | this._socket = socket 343 | 344 | socket.setNoDelay(!!this._opt.noDelay) 345 | 346 | let connectionError: Error|undefined 347 | 348 | // create connection timeout 349 | if (this._opt.connectionTimeout > 0) { 350 | this._state.connectionTimer = setTimeout(() => { 351 | socket.destroy(new AMQPConnectionError('CONNECTION_TIMEOUT', 'connection timed out')) 352 | }, this._opt.connectionTimeout) 353 | } 354 | 355 | socket.on('error', err => { 356 | connectionError = connectionError || err 357 | }) 358 | socket.on('close', () => { 359 | if (this._state.readyState === READY_STATE.CLOSING) { 360 | this._state.readyState = READY_STATE.CLOSED 361 | this._reset(connectionError || new AMQPConnectionError('CLOSING', 'connection is closed')) 362 | } else { 363 | connectionError = connectionError || new AMQPConnectionError('CONN_CLOSE', 364 | 'socket closed unexpectedly by server') 365 | if (this._state.readyState === READY_STATE.OPEN) 366 | this._state.onConnect = createDeferred(true) 367 | this._state.readyState = READY_STATE.CONNECTING 368 | this._reset(connectionError) 369 | const retryCount = this._state.retryCount++ 370 | const delay = expBackoff(this._opt.retryLow, this._opt.retryHigh, retryCount) 371 | this._state.retryTimer = setTimeout(this._connect, delay) 372 | // emit & cede control to user only as final step 373 | // suppress spam during reconnect 374 | if (retryCount <= 1) 375 | this.emit('error', connectionError) 376 | } 377 | }) 378 | 379 | const ogwrite = socket.write 380 | socket.write = (...args) => { 381 | this._state.hasWrite = true 382 | return ogwrite.apply(socket, args as any) 383 | } 384 | 385 | const readerLoop = async () => { 386 | try { 387 | const read = createAsyncReader(socket) 388 | await this._negotiate(read) 389 | // consume AMQP DataFrames until the socket is closed 390 | while (true) this._handleChunk(await decodeFrame(read)) 391 | } catch (err) { 392 | // TODO if err instanceof AMQPConnectionError then invoke connection.close + socket.end() + socket.resume() 393 | // all bets are off when we get a codec error; just kill the socket 394 | if (err.code !== 'READ_END') socket.destroy(err) 395 | } 396 | } 397 | 398 | socket.write(PROTOCOL_HEADER) 399 | readerLoop() 400 | 401 | return socket 402 | } 403 | 404 | /** @internal Establish connection parameters with the server. */ 405 | private async _negotiate(read: (bytes: number) => Promise): Promise { 406 | const readFrame = async (methodId: T): Promise => { 407 | const frame = await decodeFrame(read) 408 | if (frame.channelId === 0 && frame.type === FrameType.METHOD && frame.methodId === methodId) 409 | return frame.params as MethodParams[T] 410 | if (frame.type === FrameType.METHOD && frame.methodId === Cmd.ConnectionClose) { 411 | const strcode = ReplyCode[frame.params.replyCode] || String(frame.params.replyCode) 412 | const msg = Cmd[frame.params.methodId] + ': ' + frame.params.replyText 413 | throw new AMQPConnectionError(strcode, msg) 414 | } 415 | throw new AMQPConnectionError('COMMAND_INVALID', 416 | 'received unexpected frame during negotiation: ' + JSON.stringify(frame)) 417 | } 418 | 419 | // check for version mismatch (only on first chunk) 420 | const chunk = await read(8) 421 | if (chunk.toString('utf-8', 0, 4) === 'AMQP') { 422 | const version = chunk.slice(4).join('-') 423 | const message = `this version of AMQP is not supported; the server suggests ${version}` 424 | throw new AMQPConnectionError('VERSION_MISMATCH', message) 425 | } 426 | this._socket.unshift(chunk) 427 | 428 | /*const serverParams = */await readFrame(Cmd.ConnectionStart) 429 | // TODO support EXTERNAL mechanism, i.e. x509 peer verification 430 | // https://github.com/rabbitmq/rabbitmq-auth-mechanism-ssl 431 | // serverParams.mechanisms === 'EXTERNAL PLAIN AMQPLAIN' 432 | this._writeMethod({ 433 | type: FrameType.METHOD, 434 | channelId: 0, 435 | methodId: Cmd.ConnectionStartOK, 436 | params: { 437 | locale: 'en_US', 438 | mechanism: 'PLAIN', 439 | response: [null, this._opt.username, this._opt.password].join(String.fromCharCode(0)), 440 | clientProperties: this._opt.connectionName 441 | ? {...CLIENT_PROPERTIES, connection_name: this._opt.connectionName} 442 | : CLIENT_PROPERTIES 443 | }}) 444 | const params = await readFrame(Cmd.ConnectionTune) 445 | const channelMax = params.channelMax > 0 446 | ? Math.min(this._opt.maxChannels, params.channelMax) 447 | : this._opt.maxChannels 448 | this._state.channelMax = channelMax 449 | this._socket.setMaxListeners(0) // prevent MaxListenersExceededWarning with >10 channels 450 | const frameMax = params.frameMax > 0 451 | ? Math.min(this._opt.frameMax, params.frameMax) 452 | : this._opt.frameMax 453 | this._state.frameMax = frameMax 454 | const heartbeat = determineHeartbeat(params.heartbeat, this._opt.heartbeat) 455 | this._writeMethod({ 456 | type: FrameType.METHOD, 457 | channelId: 0, 458 | methodId: Cmd.ConnectionTuneOK, 459 | params: {channelMax, frameMax, heartbeat}}) 460 | this._writeMethod({ 461 | type: FrameType.METHOD, 462 | channelId: 0, 463 | methodId: Cmd.ConnectionOpen, 464 | params: {virtualHost: this._opt.vhost || '/', rsvp1: ''}}) 465 | await readFrame(Cmd.ConnectionOpenOK) 466 | 467 | // create heartbeat timeout, or disable when 0 468 | if (heartbeat > 0) { 469 | let miss = 0 470 | this._state.hasWrite = this._state.hasRead = false 471 | this._state.heartbeatTimer = setInterval(() => { 472 | if (!this._state.hasRead) { 473 | if (++miss >= 4) 474 | this._socket.destroy(new AMQPConnectionError('SOCKET_TIMEOUT', 'socket timed out (no heartbeat)')) 475 | } else { 476 | this._state.hasRead = false 477 | miss = 0 478 | } 479 | if (!this._state.hasWrite) { 480 | // if connection.blocked then heartbeat monitoring is disabled 481 | if (this._socket.writable && !this._socket.writableCorked) 482 | this._socket.write(HEARTBEAT_FRAME) 483 | } 484 | this._state.hasWrite = false 485 | }, Math.ceil(heartbeat * 1000 / 2)) 486 | } 487 | 488 | this._state.readyState = READY_STATE.OPEN 489 | this._state.retryCount = 1 490 | this._state.onConnect.resolve() 491 | if (this._state.connectionTimer) 492 | clearTimeout(this._state.connectionTimer) 493 | this._state.connectionTimer = undefined 494 | this.emit('connection') 495 | } 496 | 497 | /** @internal */ 498 | _writeMethod(params: MethodFrame): void { 499 | const frame = encodeFrame(params, this._state.frameMax) 500 | this._socket.write(frame) 501 | } 502 | 503 | /** @internal */ 504 | private _handleChunk(frame: DataFrame): void { 505 | this._state.hasRead = true 506 | let ch: Channel|undefined 507 | if (frame) { 508 | if (frame.type === FrameType.HEARTBEAT) { 509 | // still alive 510 | } else if (frame.type === FrameType.METHOD) { 511 | switch (frame.methodId) { 512 | case Cmd.ConnectionClose: { 513 | if (this._socket.writable) { 514 | this._writeMethod({ 515 | type: FrameType.METHOD, 516 | channelId: 0, 517 | methodId: Cmd.ConnectionCloseOK, 518 | params: undefined 519 | }) 520 | this._socket.end() 521 | this._socket.uncork() 522 | } 523 | const strcode = ReplyCode[frame.params.replyCode] || String(frame.params.replyCode) 524 | const souceMethod = frame.params.methodId ? Cmd[frame.params.methodId] + ': ' : '' 525 | const msg = souceMethod + frame.params.replyText 526 | this._socket.emit('error', new AMQPConnectionError(strcode, msg)) 527 | break 528 | } case Cmd.ConnectionCloseOK: 529 | // just wait for the socket to fully close 530 | break 531 | case Cmd.ConnectionBlocked: 532 | this._socket.cork() 533 | this.emit('connection.blocked', frame.params.reason) 534 | break 535 | case Cmd.ConnectionUnblocked: 536 | this._socket.uncork() 537 | this.emit('connection.unblocked') 538 | break 539 | default: 540 | ch = this._state.leased.get(frame.channelId) 541 | if (ch == null) { 542 | throw new AMQPConnectionError('UNEXPECTED_FRAME', 543 | 'client received a method frame for an unexpected channel') 544 | } 545 | ch._onMethod(frame) 546 | } 547 | } else if (frame.type === FrameType.HEADER) { 548 | const ch = this._state.leased.get(frame.channelId) 549 | if (ch == null) { 550 | // TODO test me 551 | throw new AMQPConnectionError('UNEXPECTED_FRAME', 552 | 'client received a header frame for an unexpected channel') 553 | } 554 | ch._onHeader(frame) 555 | } else if (frame.type === FrameType.BODY) { 556 | const ch = this._state.leased.get(frame.channelId) 557 | if (ch == null) { 558 | // TODO test me 559 | throw new AMQPConnectionError('UNEXPECTED_FRAME', 560 | 'client received a body frame for an unexpected channel') 561 | } 562 | ch._onBody(frame) 563 | } 564 | } 565 | } 566 | 567 | /** @internal */ 568 | private _reset(err: Error): void { 569 | for (const ch of this._state.leased.values()) 570 | ch._clear(err) 571 | this._state.leased.clear() 572 | this._checkEmpty() 573 | if (this._state.connectionTimer) 574 | clearTimeout(this._state.connectionTimer) 575 | this._state.connectionTimer = undefined 576 | clearInterval(this._state.heartbeatTimer) 577 | this._state.heartbeatTimer = undefined 578 | } 579 | 580 | /** @internal */ 581 | _checkEmpty(): void { 582 | if (!this._state.leased.size && this._state.readyState === READY_STATE.CLOSING) 583 | this._state.onEmpty.resolve() 584 | } 585 | 586 | /** @internal */ 587 | async _lazy(): Promise { 588 | const ch = this._state.lazyChannel 589 | if (ch instanceof Promise) { 590 | return ch 591 | } 592 | if (ch == null || !ch.active) try { 593 | return this._state.lazyChannel = await (this._state.lazyChannel = this.acquire()) 594 | } catch (err) { 595 | this._state.lazyChannel = void 0 596 | throw err 597 | } 598 | return ch 599 | } 600 | 601 | /** {@inheritDoc Channel#basicGet} */ 602 | basicGet(params: MethodParams[Cmd.BasicGet]): Promise 603 | basicGet(queue?: string): Promise 604 | /** @ignore */ 605 | basicGet(params?: string|MethodParams[Cmd.BasicGet]): Promise 606 | basicGet(params?: string|MethodParams[Cmd.BasicGet]): Promise { 607 | return this._lazy().then(ch => ch.basicGet(params)) 608 | } 609 | 610 | /** {@inheritDoc Channel#queueDeclare} */ 611 | queueDeclare(params: MethodParams[Cmd.QueueDeclare]): Promise 612 | queueDeclare(queue?: string): Promise 613 | /** @ignore */ 614 | queueDeclare(params?: string|MethodParams[Cmd.QueueDeclare]): Promise 615 | queueDeclare(params?: string|MethodParams[Cmd.QueueDeclare]): Promise { 616 | return this._lazy().then(ch => ch.queueDeclare(params)) 617 | } 618 | 619 | /** {@inheritDoc Channel#exchangeBind} */ 620 | exchangeBind(params: MethodParams[Cmd.ExchangeBind]): Promise { 621 | return this._lazy().then(ch => ch.exchangeBind(params)) 622 | } 623 | 624 | /** {@inheritDoc Channel#exchangeDeclare} */ 625 | exchangeDeclare(params: MethodParams[Cmd.ExchangeDeclare]): Promise { 626 | return this._lazy().then(ch => ch.exchangeDeclare(params)) 627 | } 628 | 629 | /** {@inheritDoc Channel#exchangeDelete} */ 630 | exchangeDelete(params: MethodParams[Cmd.ExchangeDelete]): Promise { 631 | return this._lazy().then(ch => ch.exchangeDelete(params)) 632 | } 633 | 634 | /** {@inheritDoc Channel#exchangeUnbind} */ 635 | exchangeUnbind(params: MethodParams[Cmd.ExchangeUnbind]): Promise { 636 | return this._lazy().then(ch => ch.exchangeUnbind(params)) 637 | } 638 | 639 | /** {@inheritDoc Channel#queueBind} */ 640 | queueBind(params: MethodParams[Cmd.QueueBind]): Promise { 641 | return this._lazy().then(ch => ch.queueBind(params)) 642 | } 643 | 644 | /** {@inheritDoc Channel#queueDelete} */ 645 | queueDelete(params: MethodParams[Cmd.QueueDelete]): Promise 646 | queueDelete(queue?: string): Promise 647 | /** @ignore */ 648 | queueDelete(params?: string|MethodParams[Cmd.QueueDelete]): Promise 649 | queueDelete(params?: string|MethodParams[Cmd.QueueDelete]): Promise { 650 | return this._lazy().then(ch => ch.queueDelete(params)) 651 | } 652 | 653 | /** {@inheritDoc Channel#queuePurge} */ 654 | queuePurge(queue?: string): Promise 655 | queuePurge(params: MethodParams[Cmd.QueuePurge]): Promise 656 | /** @ignore */ 657 | queuePurge(params?: string|MethodParams[Cmd.QueuePurge]): Promise 658 | queuePurge(params?: string|MethodParams[Cmd.QueuePurge]): Promise { 659 | return this._lazy().then(ch => ch.queuePurge(params)) 660 | } 661 | 662 | /** {@inheritDoc Channel#queueUnbind} */ 663 | queueUnbind(params: MethodParams[Cmd.QueueUnbind]): Promise { 664 | return this._lazy().then(ch => ch.queueUnbind(params)) 665 | } 666 | 667 | /** True if the connection is established and unblocked. See also {@link Connection#on:BLOCKED | Connection#on('connection.blocked')}) */ 668 | get ready(): boolean { 669 | return this._state.readyState === READY_STATE.OPEN && !this._socket.writableCorked 670 | } 671 | 672 | /** 673 | * Returns a promise which is resolved when the connection is established. 674 | * WARNING: if timeout=0 and you call close() before the client can connect, 675 | * then this promise may never resolve! 676 | * @param timeout Milliseconds to wait before giving up and rejecting the 677 | * promise. Use 0 for no timeout. 678 | * @param disableAutoClose By default this will automatically call 679 | * `connection.close()` when the timeout is reached. If 680 | * disableAutoClose=true, then connection will instead continue to retry 681 | * after this promise is rejected. You can call `close()` manually. 682 | **/ 683 | onConnect(timeout = 10_000, disableAutoClose = false) :Promise { 684 | if (this.ready) { 685 | return Promise.resolve() 686 | } 687 | if (this._state.readyState >= READY_STATE.CLOSING) { 688 | return Promise.reject(new Error('RabbitMQ failed to connect in time; Connection closed by client')) 689 | } 690 | // create this early for a useful stack trace 691 | const pessimisticError = new Error('RabbitMQ failed to connect in time') 692 | return new Promise((resolve, reject) => { 693 | let timer :NodeJS.Timeout|undefined 694 | // capture the most recent connection Error so it can be included in the 695 | // final rejection 696 | let lastError: unknown 697 | const onError = (err: unknown) => { 698 | lastError = err 699 | } 700 | const onConnection = () => { 701 | this.removeListener('connection', onConnection) 702 | this.removeListener('error', onError) 703 | if (timer != null) { 704 | clearTimeout(timer) 705 | } 706 | resolve() 707 | } 708 | if (timeout > 0) { 709 | timer = setTimeout(() => { 710 | this.removeListener('connection', onConnection) 711 | this.removeListener('error', onError) 712 | if (!disableAutoClose) { 713 | /* close should never throw but catch and ignore just in case */ 714 | this.close().catch(() => {}) 715 | } 716 | if (lastError) { 717 | pessimisticError.cause = lastError 718 | } 719 | reject(pessimisticError) 720 | }, timeout) 721 | } 722 | this.on('error', onError) 723 | this.on('connection', onConnection) 724 | }) 725 | } 726 | } 727 | 728 | function determineHeartbeat(x: number, y: number): number { 729 | if (x && y) return Math.min(x, y) 730 | // according to the AMQP spec, BOTH the client and server must set heartbeat to 0 731 | if (!x && !y) return 0 732 | // otherwise the higher number is used 733 | return Math.max(x, y) 734 | } 735 | 736 | export interface PublisherProps { 737 | /** Enable publish-confirm mode. See {@link Channel#confirmSelect} */ 738 | confirm?: boolean, 739 | /** Maximum publish attempts. Retries are disabled by default. 740 | * Increase this number to retry when a publish fails. The Connection options 741 | * acquireTimeout, retryLow, and retryHigh will affect time between retries. 742 | * Each failed attempt will also emit a "retry" event. 743 | * @default 1 744 | * */ 745 | maxAttempts?: number, 746 | /** see {@link Channel.on}('basic.return') */ 747 | onReturn?: (msg: ReturnedMessage) => void, 748 | /** 749 | * Define any queues to be declared before the first publish and whenever 750 | * the connection is reset. Same as {@link Channel#queueDeclare | Channel#queueDeclare()} 751 | */ 752 | queues?: Array 753 | /** 754 | * Define any exchanges to be declared before the first publish and 755 | * whenever the connection is reset. Same as {@link Channel#exchangeDeclare | Channel#exchangeDeclare()} 756 | */ 757 | exchanges?: Array 758 | /** 759 | * Define any queue-exchange bindings to be declared before the first publish and 760 | * whenever the connection is reset. Same as {@link Channel#queueBind | Channel#queueBind()} 761 | */ 762 | queueBindings?: Array 763 | /** 764 | * Define any exchange-exchange bindings to be declared before the first publish and 765 | * whenever the connection is reset. Same as {@link Channel#exchangeBind | Channel#exchangeBind()} 766 | */ 767 | exchangeBindings?: Array 768 | } 769 | 770 | /** 771 | * @see {@link Connection#createPublisher | Connection#createPublisher()} 772 | * 773 | * The underlying Channel is lazily created the first time a message is 774 | * published. 775 | * 776 | * @example 777 | * ``` 778 | * const pub = rabbit.createPublisher({ 779 | * confirm: true, 780 | * exchanges: [{exchange: 'user', type: 'topic'}] 781 | * }) 782 | * 783 | * await pub.send({exchange: 'user', routingKey: 'user.create'}, userInfo) 784 | * 785 | * await pub.close() 786 | * ``` 787 | */ 788 | export interface Publisher extends EventEmitter { 789 | /** {@inheritDoc Channel#basicPublish} */ 790 | send(envelope: Envelope, body: MessageBody): Promise; 791 | /** Send directly to a queue. Same as `send({routingKey: queue}, body)` */ 792 | send(queue: string, body: MessageBody): Promise; 793 | /** @ignore */ 794 | send(envelope: string|Envelope, body: MessageBody): Promise; 795 | /** {@inheritDoc Channel#on:BASIC_RETURN} */ 796 | on(name: 'basic.return', cb: (msg: ReturnedMessage) => void): this; 797 | /** See maxAttempts. Emitted each time a failed publish will be retried. */ 798 | on(name: 'retry', cb: (err: any, envelope: Envelope, body: MessageBody) => void): this; 799 | /** Close the underlying channel */ 800 | close(): Promise; 801 | } 802 | -------------------------------------------------------------------------------- /src/Consumer.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events' 2 | import type {AsyncMessage, MethodParams, MessageBody, Envelope, Cmd} from './codec' 3 | import type {Channel} from './Channel' 4 | import type {Connection} from './Connection' 5 | import {READY_STATE, expBackoff, expectEvent} from './util' 6 | 7 | /** @internal */ 8 | export const IDLE_EVENT = Symbol('idle') 9 | /** @internal */ 10 | export const PREFETCH_EVENT = Symbol('prefetch') 11 | 12 | type BasicConsumeParams = MethodParams[Cmd.BasicConsume] 13 | export interface ConsumerProps extends BasicConsumeParams { 14 | /** Non-zero positive integer. Maximum number of messages to process at once. 15 | * Should be less than or equal to "qos.prefetchCount". Any prefetched, but 16 | * unworked, messages will be requeued when the underlying channel is closed 17 | * (unless noAck=true, when every received message will be processed). 18 | * @default Infinity */ 19 | concurrency?: number, 20 | /** Requeue message when the handler throws an Error 21 | * (as with {@link Channel.basicNack}) 22 | * @default true */ 23 | requeue?: boolean, 24 | /** Additional options when declaring the queue just before creating the 25 | * consumer and whenever the connection is reset. */ 26 | queueOptions?: MethodParams[Cmd.QueueDeclare], 27 | /** If defined, basicQos() is invoked just before creating the consumer and 28 | * whenever the connection is reset */ 29 | qos?: MethodParams[Cmd.BasicQos], 30 | /** Any exchanges to be declared before the consumer and whenever the 31 | * connection is reset. See {@link Channel.exchangeDeclare} */ 32 | exchanges?: Array 33 | /** Any queue-exchange bindings to be declared before the consumer and 34 | * whenever the connection is reset. See {@link Channel.queueBind} */ 35 | queueBindings?: Array 36 | /** Any exchange-exchange bindings to be declared before the consumer and 37 | * whenever the connection is reset. See {@link Channel.exchangeBind} */ 38 | exchangeBindings?: Array 39 | /** If true, the consumer will not automatically start. You must call 40 | * consumer.start() yourself. 41 | * @default false */ 42 | lazy?: boolean 43 | } 44 | 45 | /** 46 | * @param msg The incoming message 47 | * @param reply Reply to an RPC-type message. Like {@link Channel#basicPublish | Channel#basicPublish()} 48 | * but the message body comes first. Some fields are also set automaticaly: 49 | * - routingKey = msg.replyTo 50 | * - exchange = "" 51 | * - correlationId = msg.correlationId 52 | */ 53 | export interface ConsumerHandler { 54 | (msg: AsyncMessage, reply: (body: MessageBody, envelope?: Envelope) => Promise): Promise|ConsumerStatus|void 55 | } 56 | 57 | export enum ConsumerStatus { 58 | /** BasicAck */ 59 | ACK = 0, 60 | /** BasicNack(requeue=true). The message is returned to the queue. */ 61 | REQUEUE = 1, 62 | /** BasicNack(requeue=false). The message is sent to the 63 | * configured dead-letter exchange, if any, or discarded. */ 64 | DROP = 2, 65 | } 66 | 67 | export declare interface Consumer { 68 | /** The consumer is successfully (re)created */ 69 | on(name: 'ready', cb: () => void): this; 70 | /** Errors are emitted if a message handler fails, or if channel setup fails, 71 | * or if the consumer is cancelled by the server (like when the queue is deleted). */ 72 | on(name: 'error', cb: (err: any) => void): this; 73 | } 74 | 75 | /** 76 | * @see {@link Connection#createConsumer | Connection#createConsumer()} 77 | * @see {@link ConsumerProps} 78 | * @see {@link ConsumerHandler} 79 | * 80 | * This will create a dedicated Channel, declare a queue, declare exchanges, 81 | * declare bindings, establish QoS, and finally start consuming messages. If 82 | * the connection is reset, then all of this setup will re-run on a new 83 | * Channel. This uses the same retry-delay logic as the Connection. 84 | * 85 | * The callback is called for each incoming message. If it throws an error then 86 | * the message is rejected (BasicNack) and possibly requeued, or sent to a 87 | * dead-letter exchange. The error is then emitted as an event. The callback 88 | * can also return a numeric status code to control the ACK/NACK behavior. The 89 | * {@link ConsumerStatus} enum is provided for convenience. 90 | * 91 | * ACK/NACK behavior when the callback: 92 | * - throws an error - BasicNack(requeue=ConsumerProps.requeue) 93 | * - returns 0 or undefined - BasicAck 94 | * - returns 1 - BasicNack(requeue=true) 95 | * - returns 2 - BasicNack(requeue=false) 96 | * 97 | * About concurency: For best performance, you'll likely want to start with 98 | * concurrency=X and qos.prefetchCount=2X. In other words, up to 2X messages 99 | * are loaded into memory, but only X ConsumerHandlers are running 100 | * concurrently. The consumer won't need to wait for a new message if one has 101 | * alredy been prefetched, minimizing idle time. With more worker processes, 102 | * you will want a lower prefetchCount to avoid worker-starvation. 103 | * 104 | * The 2nd argument of `handler(msg, reply)` can be used to reply to RPC 105 | * requests. e.g. `await reply('my-response-body')`. This acts like 106 | * basicPublish() except the message body comes first. Some fields are also set 107 | * automaticaly. See ConsumerHandler for more detail. 108 | * 109 | * This is an EventEmitter that may emit errors. Also, since this wraps a 110 | * Channel, this must be closed before closing the Connection. 111 | * 112 | * @example 113 | * ``` 114 | * const sub = rabbit.createConsumer({queue: 'my-queue'}, async (msg, reply) => { 115 | * console.log(msg) 116 | * // ... do some work ... 117 | * 118 | * // optionally reply to an RPC-type message 119 | * await reply('my-response-data') 120 | * 121 | * // optionally return a status code 122 | * if (somethingBad) { 123 | * return ConsumerStatus.DROP 124 | * } 125 | * }) 126 | * 127 | * sub.on('error', (err) => { 128 | * console.log('consumer error (my-queue)', err) 129 | * }) 130 | * 131 | * // when closing the application 132 | * await sub.close() 133 | * ``` 134 | */ 135 | export class Consumer extends EventEmitter { 136 | /** Maximum number of messages to process at once. Non-zero positive integer. 137 | * Writeable. */ 138 | concurrency: number 139 | /** Get current queue name. If the queue name was left blank in 140 | * ConsumerProps, then this will change whenever the channel is reset, as the 141 | * name is randomly generated. */ 142 | get queue() { return this._queue } 143 | /** Get the current consumer ID. If generated by the broker, then this will 144 | * change each time the consumer is ready. */ 145 | get consumerTag() { return this._consumerTag } 146 | /** Some statistics about this Consumer */ 147 | readonly stats = { 148 | /** Total acknowledged messages */ 149 | acknowledged: 0, 150 | /** Total messages rejected BasicNack(requeue=false) */ 151 | dropped: 0, 152 | /** Size of the queue when this consumer started */ 153 | initialMessageCount: 0, 154 | /** How many messages are in memory, waiting to be processed */ 155 | prefetched: 0, 156 | /** Total messages rejected with BasicNack(requeue=true) */ 157 | requeued: 0, 158 | } 159 | 160 | /** @internal */ 161 | _conn: Connection 162 | /** @internal */ 163 | _ch?: Channel 164 | /** @internal */ 165 | _handler: ConsumerHandler 166 | /** @internal */ 167 | _props: ConsumerProps 168 | /** @internal */ 169 | _queue = '' 170 | /** @internal */ 171 | _consumerTag = '' 172 | /** @internal */ 173 | _prefetched: Array = [] 174 | /** @internal */ 175 | _processing = new Set>() 176 | /** @internal */ 177 | _readyState = READY_STATE.CONNECTING 178 | /** @internal */ 179 | _retryCount = 1 180 | /** @internal */ 181 | _retryTimer?: NodeJS.Timeout 182 | /** @internal */ 183 | _pendingSetup?: Promise 184 | 185 | /** @internal */ 186 | constructor(conn: Connection, props: ConsumerProps, handler: ConsumerHandler) { 187 | super() 188 | this._conn = conn 189 | this._handler = handler 190 | this._props = props 191 | this._connect = this._connect.bind(this) 192 | this.concurrency = props.concurrency && Number.isInteger(props.concurrency) 193 | ? Math.max(1, props.concurrency) : Infinity 194 | Object.defineProperty(this.stats, 'prefetched', {get: () => this._prefetched.length}) 195 | if (props.lazy) { 196 | this._readyState = READY_STATE.CLOSED 197 | } else { 198 | this._connect() 199 | } 200 | } 201 | 202 | /** @internal */ 203 | private _makeReplyfn(req: AsyncMessage) { 204 | return (body: MessageBody, envelope?: Envelope) => { 205 | if (!req.replyTo) 206 | throw new Error('attempted to reply to a non-RPC message') 207 | return this._ch!.basicPublish({ 208 | correlationId: req.correlationId, 209 | ...envelope, 210 | exchange: '', 211 | routingKey: req.replyTo, 212 | }, body) 213 | } 214 | } 215 | 216 | /** @internal */ 217 | private async _execHandler(msg: AsyncMessage) { 218 | // n.b. message MUST ack/nack on the same channel to which it is delivered 219 | const {_ch: ch} = this 220 | if (!ch) return // satisfy the type checker but this should never happen 221 | try { 222 | let retval 223 | try { 224 | retval = await this._handler(msg, this._makeReplyfn(msg)) 225 | } catch (err) { 226 | if (!this._props.noAck) { 227 | ch.basicNack({deliveryTag: msg.deliveryTag, requeue: this._props.requeue}) 228 | if (this._props.requeue) { ++this.stats.requeued } else { ++this.stats.dropped } 229 | } 230 | this.emit('error', err) 231 | return 232 | } 233 | if (!this._props.noAck) { 234 | if (retval === ConsumerStatus.DROP) { 235 | ch.basicNack({deliveryTag: msg.deliveryTag, requeue: false}) 236 | ++this.stats.dropped 237 | } else if (retval === ConsumerStatus.REQUEUE) { 238 | ch.basicNack({deliveryTag: msg.deliveryTag, requeue: true}) 239 | ++this.stats.requeued 240 | } else { 241 | ch.basicAck({deliveryTag: msg.deliveryTag}) 242 | ++this.stats.acknowledged 243 | } 244 | } 245 | } catch (err) { 246 | // ack/nack can fail if the connection dropped 247 | err.message = 'failed to ack/nack message; ' + err.message 248 | this.emit('error', err) 249 | } 250 | } 251 | 252 | /** @internal*/ 253 | private _prepareMessage(msg: AsyncMessage): void { 254 | const prom = this._execHandler(msg) 255 | this._processing.add(prom) 256 | prom.finally(() => { 257 | this._processing.delete(prom) 258 | if (this._processing.size < this.concurrency && this._prefetched.length) { 259 | this._prepareMessage(this._prefetched.shift()!) 260 | } else if (!this._processing.size) { 261 | this.emit(IDLE_EVENT) 262 | } 263 | }) 264 | } 265 | 266 | /** @internal */ 267 | private async _setup() { 268 | // wait for in-progress jobs to complete before retrying 269 | await Promise.allSettled(this._processing) 270 | if (this._readyState === READY_STATE.CLOSING) { 271 | return // abort setup 272 | } 273 | 274 | let {_ch: ch, _props: props} = this 275 | if (!ch || !ch.active) { 276 | ch = this._ch = await this._conn.acquire({emitErrorsFromChannel: true}) 277 | ch.on('error', (err) => { 278 | this.emit('error', err) 279 | }) 280 | ch.once('close', () => { 281 | if (!this._props.noAck) { 282 | // clear any buffered messages since they can't be ACKd on a new channel 283 | this._prefetched = [] 284 | } 285 | if (this._readyState >= READY_STATE.CLOSING) { 286 | return 287 | } 288 | this._readyState = READY_STATE.CONNECTING 289 | this._reconnect() 290 | // the channel may close unexpectedly when: 291 | // - setup failed (error already emitted) 292 | // - connection lost (error already emitted) 293 | // - tried to ack/nack with invalid deliveryTag (error already emitted) 294 | // - channel forced to close by server action (NOT emitted) 295 | //this.emit('error', err) 296 | }) 297 | } 298 | const {queue, messageCount} = await ch.queueDeclare({...props.queueOptions, queue: props.queue}) 299 | this.stats.initialMessageCount = messageCount 300 | this._queue = queue 301 | if (props.exchanges) for (const params of props.exchanges) { 302 | await ch.exchangeDeclare(params) 303 | } 304 | if (props.queueBindings) for (const params of props.queueBindings) { 305 | await ch.queueBind(params) 306 | } 307 | if (props.exchangeBindings) for (const params of props.exchangeBindings) { 308 | await ch.exchangeBind(params) 309 | } 310 | if (props.qos) 311 | await ch.basicQos(props.qos) 312 | const {consumerTag} = await ch.basicConsume(props, (msg) => { 313 | const shouldBuffer = (this._prefetched.length) // don't skip the queue 314 | || (this._processing.size >= this.concurrency) // honor the concurrency limit 315 | || (!this._props.noAck && this._readyState === READY_STATE.CLOSING) // prevent new work while closing 316 | if (shouldBuffer && Number.isFinite(this.concurrency)) { 317 | this._prefetched.push(msg) 318 | this.emit(PREFETCH_EVENT) 319 | } else { 320 | this._prepareMessage(msg) 321 | } 322 | }) 323 | this._consumerTag = consumerTag 324 | // n.b. a "basic.cancel" event means the Channel is still usable 325 | // e.g. for ack/nack 326 | // server will send this if the queue is deleted 327 | ch.once('basic.cancel', (tag, err) => { 328 | if (this._readyState === READY_STATE.CLOSING) 329 | return 330 | this._readyState = READY_STATE.CONNECTING 331 | this._reconnect() 332 | this.emit('error', err) 333 | }) 334 | 335 | this._retryCount = 1 336 | // close() may have been called while setup() is running 337 | if (this._readyState === READY_STATE.CONNECTING) 338 | this._readyState = READY_STATE.OPEN 339 | // user errors in attached event handlers should not cause setup to fail 340 | process.nextTick(() => this.emit('ready')) 341 | } 342 | 343 | /** @internal */ 344 | private _connect() { 345 | this._retryTimer = undefined 346 | this._pendingSetup = this._setup().finally(() => { 347 | this._pendingSetup = undefined 348 | }).catch(err => { 349 | if (this._readyState >= READY_STATE.CLOSING) 350 | return 351 | this._readyState = READY_STATE.CONNECTING 352 | err.message = 'consumer setup failed; ' + err.message 353 | // suppress spam if, for example, passive queue declaration is failing 354 | if (this._retryCount <= 1) 355 | process.nextTick(() =>{ this.emit('error', err) }) 356 | this._reconnect() 357 | }) 358 | } 359 | 360 | /** @internal 361 | * reconnect when: 362 | * - setup fails 363 | * - basic.cancel 364 | * - channel closed 365 | * - connection closed (triggers channel close) 366 | */ 367 | private _reconnect() { 368 | this._consumerTag = '' 369 | this._queue = '' 370 | if (this._conn._state.readyState >= READY_STATE.CLOSING || this._retryTimer || this._pendingSetup) 371 | return 372 | const {retryLow, retryHigh} = this._conn._opt 373 | const delay = expBackoff(retryLow, retryHigh, this._retryCount++) 374 | this._retryTimer = setTimeout(this._connect, delay) 375 | } 376 | 377 | /** 378 | * Starts the consumer if it is currently stopped. 379 | * When created with lazy=true, begin consuming. 380 | */ 381 | start(): void { 382 | if (this._readyState !== READY_STATE.CLOSED) { 383 | return 384 | } 385 | 386 | this._readyState = READY_STATE.CONNECTING 387 | this._connect() 388 | } 389 | 390 | /** Stop consuming messages. Close the channel once all pending message 391 | * handlers have settled. If called while the Connection is reconnecting, 392 | * then this may be delayed by {@link ConnectionOptions.acquireTimeout} */ 393 | async close(): Promise { 394 | if (this._readyState === READY_STATE.CLOSED) 395 | return 396 | 397 | this._readyState = READY_STATE.CLOSING 398 | if (this._retryTimer) 399 | clearTimeout(this._retryTimer) 400 | this._retryTimer = undefined 401 | await this._pendingSetup 402 | const {_ch: ch} = this 403 | if (ch?.active && this._consumerTag) { 404 | // n.b. Some messages may arrive before basic.cancel-ok is received 405 | const consumerTag = this._consumerTag 406 | this._consumerTag = '' 407 | await ch.basicCancel({consumerTag}) 408 | } 409 | if (!this._props.noAck && this._prefetched.length) { 410 | // any buffered/unacknowledged messages will be redelivered by the broker 411 | // after the channel is closed 412 | this._prefetched = [] 413 | } else if (this._props.noAck && this._prefetched.length) { 414 | // in this case, buffered messages will not be requeued so we must wait 415 | // for them to process 416 | await expectEvent(this, IDLE_EVENT) 417 | } 418 | await Promise.allSettled(this._processing) 419 | await ch?.close() 420 | this._readyState = READY_STATE.CLOSED 421 | } 422 | } 423 | -------------------------------------------------------------------------------- /src/RPCClient.ts: -------------------------------------------------------------------------------- 1 | import type {AsyncMessage, MethodParams, Envelope, Cmd} from './codec' 2 | import type {Channel} from './Channel' 3 | import type {Connection} from './Connection' 4 | import {createDeferred, Deferred} from './util' 5 | import {AMQPChannelError, AMQPError} from './exception' 6 | 7 | export interface RPCProps { 8 | /** Enable publish-confirm mode. See {@link Channel.confirmSelect} */ 9 | confirm?: boolean 10 | /** Any exchange-exchange bindings to be declared before the consumer and 11 | * whenever the connection is reset. */ 12 | exchangeBindings?: Array 13 | /** Any exchanges to be declared before the consumer and whenever the 14 | * connection is reset */ 15 | exchanges?: Array 16 | /** Retries are disabled by default. 17 | * Increase this number to retry when a request fails due to timeout or 18 | * connection loss. The Connection options acquireTimeout, retryLow, and 19 | * retryHigh will affect time between retries. 20 | * @default 1 */ 21 | maxAttempts?: number 22 | /** Any queue-exchange bindings to be declared before the consumer and 23 | * whenever the connection is reset. */ 24 | queueBindings?: Array 25 | /** Define any queues to be declared before the first publish and whenever 26 | * the connection is reset. Same as {@link Channel.queueDeclare} */ 27 | queues?: Array 28 | /** Max time to wait for a response, in milliseconds. 29 | * Must be > 0. Note that the acquireTimeout will also affect requests. 30 | * @default 30_000 31 | * */ 32 | timeout?: number 33 | } 34 | 35 | const DEFAULT_TIMEOUT = 30_000 36 | 37 | /** 38 | * @see {@link Connection#createRPCClient | Connection#createRPCClient()} 39 | * @see {@link RPCProps} 40 | * @see {@link https://www.rabbitmq.com/direct-reply-to.html} 41 | * 42 | * This will create a single "client" `Channel` on which you may publish 43 | * messages and listen for direct responses. This can allow, for example, two 44 | * micro-services to communicate with each other using RabbitMQ as the 45 | * middleman instead of directly via HTTP. 46 | * 47 | * If you're using the createConsumer() helper, then you can reply to RPC 48 | * requests simply by using the `reply()` argument of 49 | * the {@link ConsumerHandler}. 50 | * 51 | * Also, since this wraps a Channel, this must be closed before closing the 52 | * Connection: `RPCClient.close()` 53 | * 54 | * @example 55 | * ``` 56 | * // rpc-client.js 57 | * const rabbit = new Connection() 58 | * 59 | * const rpcClient = rabbit.createRPCClient({confirm: true}) 60 | * 61 | * const res = await rpcClient.send('my-rpc-queue', 'ping') 62 | * console.log('response:', res.body) // pong 63 | * 64 | * await rpcClient.close() 65 | * await rabbit.close() 66 | * ``` 67 | * 68 | * ``` 69 | * // rpc-server.js 70 | * const rabbit = new Connection() 71 | * 72 | * const rpcServer = rabbit.createConsumer({ 73 | * queue: 'my-rpc-queue' 74 | * }, async (req, reply) => { 75 | * console.log('request:', req.body) 76 | * await reply('pong') 77 | * }) 78 | * 79 | * process.on('SIGINT', async () => { 80 | * await rpcServer.close() 81 | * await rabbit.close() 82 | * }) 83 | * ``` 84 | * 85 | * If you're communicating with a different rabbitmq client implementation 86 | * (maybe in a different language) then the consumer should send responses 87 | * like this: 88 | * ``` 89 | * ch.basicPublish({ 90 | * routingKey: req.replyTo, 91 | * correlationId: req.correlationId, 92 | * exchange: "" 93 | * }, responseBody) 94 | * ``` 95 | */ 96 | export class RPCClient { 97 | /** @internal */ 98 | _conn: Connection 99 | /** @internal */ 100 | _ch?: Channel 101 | /** @internal */ 102 | _props: RPCProps 103 | /** @internal */ 104 | _requests = new Map>() 105 | /** @internal */ 106 | _pendingSetup?: Promise 107 | /** @internal CorrelationId counter */ 108 | _id = 0 109 | /** True while the client has not been explicitly closed */ 110 | active = true 111 | 112 | /** @internal */ 113 | constructor(conn: Connection, props: RPCProps) { 114 | this._conn = conn 115 | this._props = props 116 | } 117 | 118 | /** @internal */ 119 | private async _setup() { 120 | let {_ch: ch, _props: props} = this 121 | if (!ch || !ch.active) { 122 | ch = this._ch = await this._conn.acquire({emitErrorsFromChannel: true}) 123 | let caught: unknown 124 | ch.once('error', (err) => { 125 | caught = err 126 | }) 127 | ch.once('close', () => { 128 | // request-response MUST be on the same channel, so if the channel dies 129 | // so does all pending requests 130 | const err = new AMQPChannelError('RPC_CLOSED', 'RPC channel closed unexpectedly', caught) 131 | for (const dfd of this._requests.values()) 132 | dfd.reject(err) 133 | this._requests.clear() 134 | }) 135 | } 136 | if (props.queues) for (const params of props.queues) { 137 | await ch.queueDeclare(params) 138 | } 139 | if (props.exchanges) for (const params of props.exchanges) { 140 | await ch.exchangeDeclare(params) 141 | } 142 | if (props.queueBindings) for (const params of props.queueBindings) { 143 | await ch.queueBind(params) 144 | } 145 | if (props.exchangeBindings) for (const params of props.exchangeBindings) { 146 | await ch.exchangeBind(params) 147 | } 148 | if (props.confirm) { 149 | await ch.confirmSelect() 150 | } 151 | // n.b. This is not a real queue & this consumer will not appear in the management UI 152 | await ch.basicConsume({ 153 | noAck: true, 154 | queue: 'amq.rabbitmq.reply-to' 155 | }, (res) => { 156 | if (res.correlationId) { 157 | // resolve an exact request 158 | const dfd = this._requests.get(res.correlationId) 159 | if (dfd != null) { 160 | this._requests.delete(res.correlationId) 161 | dfd.resolve(res) 162 | } 163 | } 164 | // otherwise the response is discarded 165 | }) 166 | // ch.once('basic.cancel') shouldn't happen 167 | } 168 | 169 | /** Like {@link Channel#basicPublish}, but it resolves with a response 170 | * message, or rejects with a timeout. 171 | * Additionally, some fields are automatically set: 172 | * - {@link Envelope.replyTo} 173 | * - {@link Envelope.correlationId} 174 | * - {@link Envelope.expiration} 175 | */ 176 | send(envelope: Envelope, body: any): Promise 177 | /** Send directly to a queue. Same as `send({routingKey: queue}, body)` */ 178 | send(queue: string, body: any): Promise 179 | /** @ignore */ 180 | send(envelope: string|Envelope, body: any): Promise 181 | async send(envelope: string|Envelope, body: any): Promise { 182 | const maxAttempts = this._props.maxAttempts || 1 183 | let attempts = 0 184 | while (true) try { 185 | if (!this.active) 186 | throw new AMQPChannelError('RPC_CLOSED', 'RPC client is closed') 187 | if (!this._ch?.active) { 188 | if (!this._pendingSetup) 189 | this._pendingSetup = this._setup().finally(() =>{ this._pendingSetup = undefined }) 190 | await this._pendingSetup 191 | } 192 | 193 | const id = String(++this._id) 194 | const timeout = this._props.timeout == null ? DEFAULT_TIMEOUT : this._props.timeout 195 | await this._ch!.basicPublish({ 196 | ...(typeof envelope === 'string' ? {routingKey: envelope} : envelope), 197 | replyTo: 'amq.rabbitmq.reply-to', 198 | correlationId: id, 199 | expiration: String(timeout) 200 | }, body) 201 | 202 | const dfd = createDeferred() 203 | const timer = setTimeout(() => { 204 | dfd.reject(new AMQPError('RPC_TIMEOUT', 'RPC response timed out')) 205 | this._requests.delete(id) 206 | }, timeout) 207 | this._requests.set(id, dfd) 208 | 209 | // remember to stop the timer if we get a response or if there is some other failure 210 | return await dfd.promise.finally(() =>{ clearTimeout(timer) }) 211 | } catch (err) { 212 | if (++attempts >= maxAttempts) { 213 | Error.captureStackTrace(err) // original async trace is likely not useful to users 214 | throw err 215 | } 216 | // else loop; notify with event? 217 | } 218 | } 219 | 220 | /** @deprecated Alias for {@link RPCClient#send} */ 221 | publish(envelope: string|Envelope, body: any): Promise { 222 | return this.send(envelope, body) 223 | } 224 | 225 | /** Stop consuming messages. Close the channel once all pending message 226 | * handlers have settled. If called while the Connection is reconnecting, 227 | * then this may be delayed by {@link ConnectionOptions.acquireTimeout} */ 228 | async close(): Promise { 229 | this.active = false 230 | try { 231 | await this._pendingSetup 232 | await Promise.allSettled(Array.from(this._requests.values()).map(dfd => dfd.promise)) 233 | } catch (err) { 234 | // do nothing; task failed successfully 235 | } 236 | // Explicitly not cancelling the consumer; it's not necessary. 237 | await this._ch?.close() 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/SortedMap.ts: -------------------------------------------------------------------------------- 1 | class Node { 2 | key: K 3 | value: V 4 | parent: Node 5 | left: Node 6 | right: Node 7 | size: number 8 | 9 | /** The nil node (see the Null Object Pattern), used for leaf nodes or for 10 | * the parent of the root node. NIL nodes are immutable. This simplifies the 11 | * rebalancing algorithms. This will throw a TypeError if modifications are 12 | * attempted. */ 13 | static NIL: Node = Object.freeze( 14 | new class extends Node { 15 | toString() { return '·' } 16 | constructor() { 17 | super(Symbol('nil'), Symbol('nil')) 18 | this.size = 0 19 | this.parent = this.left = this.right = this 20 | } 21 | }() 22 | ) 23 | 24 | constructor(key: K, value: V) { 25 | this.key = key 26 | this.value = value 27 | this.parent = this.left = this.right = Node.NIL 28 | this.size = 1 29 | } 30 | } 31 | 32 | /** @internal Returns a negative number if a < b, a positive number if a > b, or 0 if a == b */ 33 | export type Comparator = (a: V, b: V) => number 34 | 35 | /** 36 | * Hirai, Y.; Yamamoto, K. (2011). "Balancing weight-balanced trees" (PDF). 37 | * Journal of Functional Programming. 21 (3): 287. 38 | * doi:10.1017/S0956796811000104. 39 | * https://yoichihirai.com/bst.pdf 40 | * 41 | * Delta decides whether any rotation is made at all 42 | */ 43 | const Δ = 3 44 | /** Gamma chooses a single rotation or a double rotation. */ 45 | const Γ = 2 46 | 47 | /** 48 | * Weight-Balanced Tree. 49 | * Insertion, deletion and iteration have O(log n) time complexity where n is 50 | * the number of entries in the tree. Items must have an absolute ordering. 51 | * 52 | * @internal 53 | */ 54 | export default class SortedMap implements Map { 55 | private _root: Node 56 | private _compare: Comparator 57 | 58 | /** 59 | * @param source An inital list of values 60 | * @param compare Sorting criterum: Should run in O(1) time 61 | */ 62 | constructor(source?: Iterable<[K, V]>|null, compare: Comparator = (a, b) => a === b ? 0 : a < b ? -1 : 1) { 63 | this._compare = compare 64 | this._root = Node.NIL 65 | if (source) for (const [key, val] of source) 66 | this._insertNode(new Node(key, val)) 67 | } 68 | 69 | get size(): number { return this._root.size } 70 | 71 | get [Symbol.toStringTag]() { return 'SortedMap' } 72 | 73 | /** Traverse keys, breadth-first */ 74 | *bfs() { 75 | if (this._root === Node.NIL) return 76 | const queue = [this._root] 77 | while (queue.length) { 78 | const next = queue.shift()! 79 | yield next.key 80 | if (next.left !== Node.NIL) queue.push(next.left) 81 | if (next.right !== Node.NIL) queue.push(next.right) 82 | } 83 | } 84 | 85 | toString(): string { 86 | return `[${this[Symbol.toStringTag]} size:${this.size}]` 87 | } 88 | 89 | has(key: K): boolean { 90 | return this._findNode(key) !== Node.NIL 91 | } 92 | 93 | get(key: K): V | undefined { 94 | const node = this._findNode(key) 95 | return node === Node.NIL ? undefined : node.value 96 | } 97 | 98 | set(key: K, value: V): this { 99 | this._insertNode(new Node(key, value)) 100 | return this 101 | } 102 | 103 | delete(key: K): boolean { 104 | const node = this._findNode(key) 105 | const result = this._deleteNode(node) 106 | return result 107 | } 108 | 109 | clear(): void { 110 | this._root = Node.NIL 111 | } 112 | 113 | forEach(cb: (value1: V, value2: K, tree: Map) => void, self?: any): void { 114 | for (const [key, val] of this.entries()) 115 | cb.call(self, val, key, this) 116 | } 117 | 118 | [Symbol.iterator](): IterableIterator<[K, V]> { 119 | return this.entries() 120 | } 121 | 122 | values(): IterableIterator { 123 | return this._iterator(node => node.value) 124 | } 125 | 126 | keys(): IterableIterator { 127 | return this._iterator(node => node.key) 128 | } 129 | 130 | entries(): IterableIterator<[K, V]> { 131 | return this._iterator(node => [node.key, node.value]) 132 | } 133 | 134 | /** Return a missing value from the set. If {4} then result = 3 so it's not 135 | * always the LOWEST missing number but that's not necessary here. */ 136 | pick(min = 1): number { 137 | // n.b. 138 | // to find the LOWEST missing number in [min ... STORED_VALUES] (inclusive) 139 | // requires checking the value at rank 0 at the cost of an extra 140 | // ~O(log n) steps, or extra book-keeping to track the stored min value. 141 | if (this._root === Node.NIL) return min 142 | let dir = 0 143 | // FIXME Kind of a rude typecast, but this only works when K is a number. 144 | // Maybe a subclass makes more sense. 145 | let node: Node = this._root as Node 146 | let parent = node 147 | do { 148 | dir = (node.left.size + min) - node.key 149 | parent = node 150 | if (dir < 0) { 151 | node = node.left 152 | } else { 153 | min += node.left.size + 1 154 | node = node.right 155 | } 156 | } while (node !== Node.NIL) 157 | return dir < 0 ? parent.key - 1 : parent.key + 1 158 | } 159 | 160 | private _iterator(get: (node: Node) => R): IterableIterator { 161 | // eslint-disable-next-line @typescript-eslint/no-this-alias 162 | const tree = this 163 | let node = Node.NIL 164 | let started = false 165 | return { 166 | [Symbol.iterator]() { return this }, 167 | next() { 168 | if (node === Node.NIL) node = tree._firstNode() 169 | if (started) node = tree._nextNode(node) 170 | started = true 171 | 172 | const done = node === Node.NIL 173 | const value = done ? undefined : get(node) 174 | return {done, value} as IteratorResult 175 | // ^^^^^^^^^^^^^^^^^^^^ 176 | // See https://github.com/Microsoft/TypeScript/issues/11375 177 | } 178 | } 179 | } 180 | 181 | private _firstNode(node: Node = this._root): Node { 182 | while (node.left !== Node.NIL) node = node.left 183 | return node 184 | } 185 | 186 | private _nextNode(node: Node): Node { 187 | if (node === Node.NIL) return node 188 | if (node.right !== Node.NIL) return this._firstNode(node.right) 189 | let parent = node.parent 190 | while (parent !== Node.NIL && node === parent.right) { 191 | node = parent 192 | parent = parent.parent 193 | } 194 | return parent 195 | } 196 | 197 | private _findNode(key: K, node: Node = this._root): Node { 198 | let dir: number 199 | while (node !== Node.NIL && (dir = this._compare(key, node.key))) { 200 | node = dir < 0 ? node.left : node.right 201 | } 202 | return node 203 | } 204 | 205 | private _leftRotate(node: Node): Node { 206 | // assert node !== Node.NIL 207 | // assert node.right !== Node.NIL 208 | const child = node.right 209 | node.right = child.left 210 | // don't attempt to modify NIL 211 | if (child.left !== Node.NIL) child.left.parent = node 212 | child.parent = node.parent 213 | if (node === this._root) this._root = child 214 | else if (node === node.parent.left) node.parent.left = child 215 | else node.parent.right = child 216 | node.parent = child 217 | child.left = node 218 | child.size = node.size 219 | node.size = node.left.size + node.right.size + 1 220 | return child 221 | } 222 | 223 | private _rightRotate(node: Node): Node { 224 | // assert node !== Node.NIL 225 | // assert node.left !== Node.NIL 226 | const child = node.left 227 | node.left = child.right 228 | // don't attempt to modify NIL 229 | if (child.right !== Node.NIL) child.right.parent = node 230 | child.parent = node.parent 231 | if (node === this._root) this._root = child 232 | else if (node === node.parent.left) node.parent.left = child 233 | else node.parent.right = child 234 | node.parent = child 235 | child.right = node 236 | child.size = node.size 237 | node.size = node.left.size + node.right.size + 1 238 | return child 239 | } 240 | 241 | private _isUnbalanced(a: Node, b: Node): boolean { 242 | return Δ * (a.size + 1) < b.size + 1 243 | } 244 | 245 | private _isSingle(a: Node, b: Node): boolean { 246 | return a.size + 1 < Γ * (b.size + 1) 247 | } 248 | 249 | private _insertNode(node: Node) { 250 | // assert node !== Node.NIL 251 | if (this._root === Node.NIL) { 252 | this._root = node 253 | return 254 | } 255 | 256 | let parent = Node.NIL 257 | let root = this._root 258 | let dir: number 259 | 260 | // find insertion point 261 | do { 262 | dir = this._compare(node.key, root.key) 263 | if (dir === 0) { 264 | return // element is already in the tree! 265 | } 266 | parent = root 267 | root = dir < 0 ? root.left : root.right 268 | } while (root !== Node.NIL) 269 | 270 | // replace leaf 271 | node.parent = parent 272 | if (dir < 0) parent.left = node 273 | else parent.right = node 274 | 275 | // walk back down the tree and rebalance 276 | do { 277 | parent.size += 1 278 | if (parent.right === node) { 279 | if (this._isUnbalanced(parent.left, parent.right)) { 280 | if (this._isSingle(parent.right.left, parent.right.right)) { 281 | parent = this._leftRotate(parent) 282 | } else { 283 | this._rightRotate(parent.right) 284 | parent = this._leftRotate(parent) 285 | } 286 | } 287 | } else { 288 | if (this._isUnbalanced(parent.right, parent.left)) { 289 | if (this._isSingle(parent.left.right, parent.left.left)) { 290 | parent = this._rightRotate(parent) 291 | } else { 292 | this._leftRotate(parent.left) 293 | parent = this._rightRotate(parent) 294 | } 295 | } 296 | } 297 | 298 | node = parent 299 | parent = parent.parent 300 | } while (parent !== Node.NIL) 301 | 302 | // in case the root node was rotated away 303 | this._root = node 304 | } 305 | 306 | private _deleteNode(node: Node): boolean { 307 | if (node === Node.NIL) return false 308 | 309 | let child: Node 310 | let parent: Node 311 | if (node.left !== Node.NIL && node.right !== Node.NIL) { 312 | // find the first child with a NIL left-pointer (in the right-hand tree) 313 | // this will replace the deleted node 314 | let next = node.right 315 | while (next.left !== Node.NIL) next = next.left 316 | 317 | next.left = node.left 318 | next.left.parent = next 319 | if (node === this._root) this._root = next 320 | else if (node === node.parent.left) node.parent.left = next 321 | else node.parent.right = next 322 | child = next.right // may be NIL 323 | parent = next.parent 324 | if (node === parent) parent = next 325 | else { 326 | if (child !== Node.NIL) child.parent = parent 327 | parent.left = child 328 | next.right = node.right 329 | node.right.parent = next 330 | } 331 | next.parent = node.parent 332 | } else { 333 | child = node.left === Node.NIL ? node.right : node.left // may be NIL 334 | parent = node.parent // may be NIL 335 | if (child !== Node.NIL) child.parent = parent 336 | if (node === this._root) this._root = child 337 | else if (parent.left === node) parent.left = child 338 | else parent.right = child 339 | } 340 | 341 | node = child 342 | while (parent !== Node.NIL) { 343 | parent.size = parent.left.size + parent.right.size + 1 344 | // left rotation is performed when an element is deleted from the left subtree 345 | if (parent.left === node) { 346 | if (this._isUnbalanced(parent.left, parent.right)) { 347 | if (this._isSingle(parent.right.left, parent.right.right)) { 348 | parent = this._leftRotate(parent) 349 | } else { 350 | this._rightRotate(parent.right) 351 | parent = this._leftRotate(parent) 352 | } 353 | } 354 | } else { 355 | if (this._isUnbalanced(parent.right, parent.left)) { 356 | if (this._isSingle(parent.left.right, parent.left.left)) { 357 | parent = this._rightRotate(parent) 358 | } else { 359 | this._leftRotate(parent.left) 360 | parent = this._rightRotate(parent) 361 | } 362 | } 363 | } 364 | 365 | node = parent 366 | parent = parent.parent 367 | } 368 | 369 | // in case the root node was rotated away 370 | this._root = node 371 | 372 | return true 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /src/exception.ts: -------------------------------------------------------------------------------- 1 | /** Low severity, e.g. nack'd message */ 2 | class AMQPError extends Error { 3 | code: string 4 | /** @internal */ 5 | constructor(code: string, message: string, cause?: unknown) { 6 | super(message, {cause}) 7 | this.name = 'AMQPError' 8 | this.code = code 9 | } 10 | } 11 | 12 | /** Medium severity. The channel is closed. */ 13 | class AMQPChannelError extends AMQPError { 14 | /** @internal */ 15 | name = 'AMQPChannelError' 16 | } 17 | 18 | /** High severity. All pending actions are rejected and all channels are closed. The connection is reset. */ 19 | class AMQPConnectionError extends AMQPChannelError { 20 | /** @internal */ 21 | name = 'AMQPConnectionError' 22 | } 23 | 24 | export {AMQPError, AMQPConnectionError, AMQPChannelError} 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {Connection, PublisherProps, Publisher} from './Connection' 2 | 3 | export default Connection 4 | export {Connection} 5 | export {AMQPConnectionError, AMQPChannelError, AMQPError} from './exception' 6 | export {ConsumerStatus} from './Consumer' 7 | 8 | export type {PublisherProps, Publisher} 9 | export type {RPCClient, RPCProps} from './RPCClient' 10 | export type {Consumer, ConsumerProps, ConsumerHandler} from './Consumer' 11 | export type {Channel} from './Channel' 12 | export type {ConnectionOptions} from './normalize' 13 | export type {Cmd, Decimal, ReturnedMessage, SyncMessage, AsyncMessage, Envelope, MessageBody, HeaderFields, MethodParams} from './codec' 14 | -------------------------------------------------------------------------------- /src/normalize.ts: -------------------------------------------------------------------------------- 1 | import {TcpSocketConnectOpts} from 'node:net' 2 | import type {ConnectionOptions as TLSOptions} from 'node:tls' 3 | 4 | const DEFAULT_CONNECTION = 'amqp://guest:guest@localhost:5672' 5 | const TLS_PORT = '5671' 6 | const TCP_PORT = '5672' 7 | const DEFAULT_OPTS = { 8 | acquireTimeout: 20000, 9 | connectionTimeout: 10000, 10 | frameMax: 8192, 11 | heartbeat: 60, 12 | maxChannels: 0x07ff, // (16bit number so the protocol max is 0xffff) 13 | retryLow: 1000, 14 | retryHigh: 30000, 15 | } 16 | 17 | export interface ConnectionOptions { 18 | /** Milliseconds to wait before aborting a Channel creation attempt, i.e. acquire() 19 | * @default 20_000*/ 20 | acquireTimeout?: number, 21 | /** Custom name for the connection, visible in the server's management UI */ 22 | connectionName?: string, 23 | /** Max wait time, in milliseconds, for a connection attempt 24 | * @default 10_000*/ 25 | connectionTimeout?: number, 26 | /** Max size, in bytes, of AMQP data frames. Protocol max is 27 | * 2^32-1. Actual value is negotiated with the server. 28 | * @default 8192 */ 29 | frameMax?: number, 30 | /** Period of time, in seconds, after which the TCP connection 31 | * should be considered unreachable. Server may have its own max value, in 32 | * which case the lowest of the two is used. A value of 0 will disable this 33 | * feature. Heartbeats are sent every `heartbeat / 2` seconds, so two missed 34 | * heartbeats means the connection is dead. 35 | * @default 60 */ 36 | heartbeat?: number, 37 | /** Maximum active AMQP channels. 65535 is the protocol max. 38 | * The server may also have a max value, in which case the lowest of the two 39 | * is used. 40 | * @default 2047 */ 41 | maxChannels?: number, 42 | /** Max delay, in milliseconds, for exponential-backoff when reconnecting 43 | * @default 30_000 */ 44 | retryHigh?: number, 45 | /** Step size, in milliseconds, for exponential-backoff when reconnecting 46 | * @default 1000*/ 47 | retryLow?: number, 48 | 49 | /** May also include params: heartbeat, connection_timeout, channel_max 50 | * @default "amqp://guest:guest@localhost:5672" 51 | */ 52 | url?: string, 53 | 54 | hostname?: string, 55 | port?: string|number, 56 | vhost?: string, 57 | password?: string, 58 | username?: string, 59 | 60 | /** Enable TLS, or set TLS specific options like overriding the CA for 61 | * self-signed certificates. Automatically enabled if url starts with 62 | * "amqps:" */ 63 | tls?: boolean|TLSOptions, 64 | 65 | /** Additional options when creating the TCP socket with net.connect(). */ 66 | socket?: TcpSocketConnectOpts, 67 | 68 | /** Disable {@link https://en.wikipedia.org/wiki/Nagle's_algorithm | Nagle's 69 | * algorithm} for reduced latency. Disabling Nagle’s algorithm will enable the 70 | * application to have many small packets in flight on the network at once, 71 | * instead of a smaller number of large packets, which may increase load on 72 | * the network, and may or may not benefit the application performance. */ 73 | noDelay?: boolean, 74 | 75 | /** "hostname:port" of multiple nodes in a cluster */ 76 | hosts?: string[], 77 | } 78 | 79 | type ValidatedKeys = 80 | | 'acquireTimeout' 81 | | 'connectionName' 82 | | 'connectionTimeout' 83 | | 'frameMax' 84 | | 'heartbeat' 85 | | 'maxChannels' 86 | | 'password' 87 | | 'retryHigh' 88 | | 'retryLow' 89 | | 'username' 90 | | 'vhost' 91 | interface ValidatedOptions extends Pick, ValidatedKeys> { 92 | noDelay?: boolean, 93 | socket?: TcpSocketConnectOpts, 94 | tls?: TLSOptions, 95 | hosts: Array<{hostname: string, port: number}> 96 | } 97 | 98 | /** @internal */ 99 | export default function normalizeOptions(raw?: string|ConnectionOptions): ValidatedOptions { 100 | if (typeof raw === 'string') { 101 | raw = {url: raw} 102 | } 103 | const props: any = {...DEFAULT_OPTS, ...raw} 104 | let url 105 | if (typeof props.url == 'string') { 106 | url = new URL(props.url) 107 | props.username = decodeURIComponent(url.username) 108 | props.password = decodeURIComponent(url.password) 109 | props.vhost = decodeURIComponent(url.pathname.split('/')[1] || '/') 110 | props.hostname = url.hostname 111 | if (url.protocol === 'amqp:') { 112 | props.port = url.port || TCP_PORT 113 | } else if (url.protocol === 'amqps:') { 114 | props.port = url.port || TLS_PORT 115 | props.tls = props.tls || true 116 | } else { 117 | throw new Error('unsupported protocol in connectionString; expected amqp: or amqps:') 118 | } 119 | 120 | const heartbeat = parseInt(url.searchParams.get('heartbeat')!) 121 | if (!isNaN(heartbeat)) { 122 | props.heartbeat = Math.max(0, heartbeat) 123 | } 124 | 125 | const connectionTimeout = parseInt(url.searchParams.get('connection_timeout')!) 126 | if (!isNaN(connectionTimeout)) { 127 | props.connectionTimeout = Math.max(0, connectionTimeout) 128 | } 129 | 130 | const maxChannels = parseInt(url.searchParams.get('channel_max')!) 131 | if (!isNaN(maxChannels)) { 132 | props.maxChannels = Math.min(Math.max(1, maxChannels), props.maxChannels) 133 | } 134 | } else { 135 | url = new URL(DEFAULT_CONNECTION) 136 | if (props.hostname == null) props.hostname = url.hostname 137 | if (props.port == null) props.port = url.port 138 | if (props.username == null) props.username = url.username 139 | if (props.password == null) props.password = url.password 140 | if (props.vhost == null) props.vhost = '/' 141 | } 142 | 143 | if (props.tls === true) 144 | props.tls = {} 145 | 146 | if (Array.isArray(props.hosts)) { 147 | props.hosts = props.hosts.map((host: string) => { 148 | let [hostname, port] = host.split(':') 149 | if (!port) { 150 | port = props.tls ? TLS_PORT : TCP_PORT 151 | } 152 | return {hostname, port} 153 | }) 154 | } else { 155 | props.hosts = [{hostname: props.hostname, port: props.port}] 156 | } 157 | 158 | assertNumber(props, 'acquireTimeout', 0) 159 | assertNumber(props, 'connectionTimeout', 0) 160 | assertNumber(props, 'frameMax', 8, 2**32-1) 161 | assertNumber(props, 'heartbeat', 0) 162 | assertNumber(props, 'maxChannels', 1, 2**16-1) 163 | assertNumber(props, 'retryLow', 1) 164 | assertNumber(props, 'retryHigh', 1) 165 | 166 | return props 167 | } 168 | 169 | function assertNumber(props: Record, name: string, min: number, max?: number) { 170 | const val = props[name] 171 | if (isNaN(val) || !Number.isFinite(val) || val < min || (max != null && val > max)) { 172 | throw new TypeError(max != null 173 | ? `${name} must be a number (${min}, ${max})` 174 | : `${name} must be a number >= ${min}`) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/overrides.css: -------------------------------------------------------------------------------- 1 | pre { 2 | white-space: nowrap; 3 | overflow-x: auto; 4 | } 5 | -------------------------------------------------------------------------------- /src/typedoc-plugin-versions.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import libpath from 'node:path' 3 | 4 | import {Application, JSX} from 'typedoc' 5 | 6 | export function load(app: Application) { 7 | const pkgver = JSON.parse(fs.readFileSync('package.json', 'utf-8')).version 8 | let rootpath = app.options.getValue('out') 9 | app.options.addReader({ 10 | name: 'plugin-versions', 11 | order: Infinity, // run last 12 | supportsPackages: false, 13 | read(opt): void { 14 | rootpath = opt.getValue('out') 15 | opt.setValue('out', `${rootpath}/${pkgver}`) 16 | } 17 | }) 18 | 19 | app.renderer.hooks.on('head.end', (ctx) => JSX.createElement('script', { 20 | src: ctx.relativeURL('../version-selector.js'), 21 | type: 'module'})) 22 | 23 | app.renderer.on('endRender', () => { 24 | const dirs = fs.readdirSync(rootpath, {withFileTypes: true}) 25 | .filter(item => item.isDirectory() && /^\d.*/.test(item.name)) 26 | .map(item => item.name) 27 | .sort((a, b) => a < b ? 1 : -1) // TODO more sophisticated semver compare 28 | dirs.unshift('latest') 29 | 30 | app.logger.info(`writing versions.js: ${dirs.join(' ')}`) 31 | fs.writeFileSync(`${rootpath}/versions.js`, 'export default ' + JSON.stringify(dirs)) 32 | 33 | const src = app.options.getValue('out') 34 | const dest = `${rootpath}/latest` 35 | fs.rmSync(dest, {recursive: true, force: true}) 36 | // gh-pages doesn't like symlinks 37 | // TODO use url rewrite rules 38 | fs.cpSync(src, dest, {recursive: true}) 39 | app.logger.info(`Documentation generated at ${libpath.relative('.', dest)}`) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import {AMQPConnectionError} from './exception' 2 | import {Readable, Writable} from 'node:stream' 3 | import {promisify} from 'node:util' 4 | import EventEmitter from 'node:events' 5 | 6 | /** @internal */ 7 | export enum READY_STATE {CONNECTING, OPEN, CLOSING, CLOSED} 8 | 9 | /** @internal */ 10 | export interface Deferred { 11 | resolve: (value: T | PromiseLike) => void 12 | reject: (reason?: any) => void 13 | promise: Promise 14 | } 15 | 16 | /** @internal */ 17 | export function createDeferred(noUncaught?: boolean): Deferred { 18 | const dfd: any = {} 19 | dfd.promise = new Promise((resolve, reject) => { 20 | dfd.resolve = resolve 21 | dfd.reject = reject 22 | }) 23 | if (noUncaught) 24 | dfd.promise.catch(() => {}) 25 | return dfd 26 | } 27 | 28 | /** 29 | * Calculate exponential backoff/retry delay. 30 | * Where attempts >= 1, exp > 1 31 | * @example expBackoff(1000, 30000, attempts) 32 | * --------------------------------- 33 | * attempts | possible delay 34 | * ----------+---------------------- 35 | * 1 | 1000 to 2000 36 | * 2 | 1000 to 4000 37 | * 3 | 1000 to 8000 38 | * 4 | 1000 to 16000 39 | * 5 | 1000 to 30000 40 | * --------------------------------- 41 | * Attempts required before max delay is possible = Math.ceil(Math.log(high/step) / Math.log(exp)) 42 | * @internal 43 | */ 44 | export function expBackoff(step: number, high: number, attempts: number, exp=2): number { 45 | const slots = Math.ceil(Math.min(high/step, Math.pow(exp, attempts))) 46 | const max = Math.min(slots * step, high) 47 | return Math.floor(Math.random() * (max - step) + step) 48 | } 49 | 50 | /** @internal */ 51 | export function pick(src: any, keys: string[]): any { 52 | const dest: any = {} 53 | for (const key of keys) { 54 | dest[key] = src[key] 55 | } 56 | return dest 57 | } 58 | 59 | /** @internal */ 60 | export function camelCase(string: string): string { 61 | const parts = string.match(/[^.|-]+/g) 62 | if (!parts) 63 | return string 64 | return parts.reduce((acc, word, index) => { 65 | return acc + (index ? word.charAt(0).toUpperCase() + word.slice(1) : word) 66 | }) 67 | } 68 | 69 | type ReadCB = (err: any, buf: Buffer) => void 70 | 71 | /** 72 | * Wrap Readable.read() to ensure that it either resolves with a Buffer of the 73 | * requested length, waiting for more data when necessary, or is rejected. 74 | * Assumes only a single pending read at a time. 75 | * See also: https://nodejs.org/api/stream.html#readablereadsize 76 | * @internal 77 | */ 78 | export function createAsyncReader(socket: Readable): (bytes: number) => Promise { 79 | let bytes: number 80 | let cb: ReadCB|undefined 81 | 82 | function _read() { 83 | if (!cb) return 84 | const buf: Buffer|null = socket.read(bytes) 85 | if (!buf && socket.readable) 86 | return // wait for readable OR close 87 | if (buf?.byteLength !== bytes) { 88 | cb(new AMQPConnectionError('READ_END', 89 | 'stream ended before all bytes received'), buf!) 90 | } else { 91 | cb(null, buf) 92 | } 93 | cb = undefined 94 | } 95 | 96 | socket.on('close', _read) 97 | socket.on('readable', _read) 98 | 99 | return promisify((_bytes: number, _cb: ReadCB) => { 100 | bytes = _bytes 101 | cb = _cb 102 | _read() 103 | }) 104 | } 105 | 106 | /** 107 | * Consumes Iterators (like from a generator-fn). 108 | * Writes Buffers (or whatever the iterators produce) to the output stream 109 | * @internal 110 | */ 111 | export class EncoderStream extends Writable { 112 | private _cur?: [Iterator, (error?: Error | null) => void] 113 | private _out: Writable 114 | constructor(out: Writable) { 115 | super({objectMode: true}) 116 | this._out = out 117 | this._loop = this._loop.bind(this) 118 | out.on('drain', this._loop) 119 | } 120 | writeAsync: (it: Iterator) => Promise = promisify(this.write) 121 | _destroy(err: Error | null, cb: (err?: Error | null) => void): void { 122 | this._out.removeListener('drain', this._loop) 123 | if (this._cur) { 124 | this._cur[1](err) 125 | this._cur = undefined 126 | } 127 | cb(err) 128 | } 129 | _write(it: Iterator, enc: unknown, cb: (error?: Error | null) => void): void { 130 | this._cur = [it, cb] 131 | this._loop() 132 | } 133 | /** Squeeze the current iterator until it's empty, but respect back-pressure. */ 134 | _loop(): void { 135 | if (!this._cur) return 136 | const [it, cb] = this._cur 137 | let res 138 | let ok = !this._out.writableNeedDrain 139 | try { 140 | // if Nagle's algorithm is enabled, this will reduce latency 141 | this._out.cork() 142 | while (ok && (res = it.next()) && !res.done) 143 | ok = this._out.write(res.value) 144 | } catch (err) { 145 | return cb(err) 146 | } finally { 147 | this._out.uncork() 148 | // TODO consider this: 149 | //process.nextTick(() => this._out.uncork()) 150 | } 151 | if (res?.done) { 152 | this._cur = undefined 153 | cb() 154 | } 155 | } 156 | } 157 | 158 | /** @internal */ 159 | export function expectEvent(emitter: EventEmitter, name: string|symbol): Promise { 160 | return new Promise((resolve) => { emitter.once(name, resolve) }) 161 | } 162 | 163 | /** @internal */ 164 | export function recaptureAndThrow(err: Error): any { 165 | Error.captureStackTrace(err, recaptureAndThrow) 166 | throw err 167 | } 168 | -------------------------------------------------------------------------------- /test/SortedMap.ts: -------------------------------------------------------------------------------- 1 | import test from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import SortedMap from '../src/SortedMap' 4 | 5 | function toSortedMap(keys: number[]): SortedMap { 6 | return new SortedMap(keys.map(k => [k, null])) 7 | } 8 | function toString(sm: SortedMap): string { 9 | return Array.from(sm.bfs()).join() 10 | } 11 | 12 | test('SortedMap delete easy', () => { 13 | const sm = toSortedMap([3,2,1]) 14 | sm.delete(3) 15 | assert.equal(toString(sm), '2,1') 16 | }) 17 | 18 | test('SortedMap delete pull-right-left', () => { 19 | const sm = toSortedMap([2,1,4,3]) 20 | sm.delete(2) 21 | assert.equal(toString(sm), '3,1,4') 22 | }) 23 | 24 | test('SortedMap delete pull-right-left-deep', () => { 25 | const sm = toSortedMap([2,1,5,4,3]) 26 | sm.delete(2) 27 | assert.equal(toString(sm), '3,1,5,4') 28 | }) 29 | 30 | test('SortedMap delete pull-right-rot-right', () => { 31 | const sm = toSortedMap([5,3,6,2,4]) 32 | sm.delete(5) 33 | // 6,3,2,4 +rebalance (rotate right) 34 | assert.equal(toString(sm), '3,2,6,4') 35 | }) 36 | -------------------------------------------------------------------------------- /test/behavior.ts: -------------------------------------------------------------------------------- 1 | import test from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import Connection, {AsyncMessage} from '../src' 4 | import {createServer} from 'node:net' 5 | import {expectEvent, createDeferred, Deferred, READY_STATE} from '../src/util' 6 | import {MethodFrame, DataFrame, Cmd, FrameType} from '../src/codec' 7 | import {autoTeardown, useFakeServer} from './util' 8 | 9 | const RABBITMQ_URL = process.env.RABBITMQ_URL 10 | 11 | test('can publish and get messages', async () => { 12 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 13 | //rabbit.on('error', (err) => { t.error(err) }) 14 | 15 | const ch = autoTeardown(await rabbit.acquire()) 16 | const {queue} = await ch.queueDeclare({exclusive: true}) 17 | 18 | const m0 = await ch.basicGet({queue}) 19 | assert.equal(m0, undefined, 'basicGet returns empty when the queue is empty') 20 | 21 | const body = {color: 'green'} 22 | await ch.basicPublish({routingKey: queue}, body) 23 | 24 | const m1 = await ch.basicGet({queue}) 25 | if (m1 == null) return assert.fail('expected a message but got none') 26 | assert.equal(m1.contentType, 'application/json') 27 | assert.deepEqual(m1.body, body) 28 | ch.basicAck(m1) 29 | 30 | await ch.basicPublish({routingKey: queue}, 'my data') 31 | 32 | const m2 = await ch.basicGet({queue}) 33 | if (m2 == null) return assert.fail('expected a message but got none') 34 | assert.equal(m2.contentType, 'text/plain') 35 | assert.equal(m2.body, 'my data') 36 | ch.basicAck(m2) 37 | }) 38 | 39 | test('can publish confirmed messages', async () => { 40 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 41 | const ch = autoTeardown(await rabbit.acquire()) 42 | 43 | await ch.confirmSelect() 44 | assert.ok(true, 'enabled confirm mode') 45 | 46 | const {queue} = await ch.queueDeclare({exclusive: true}) 47 | 48 | await ch.basicPublish({routingKey: queue}, 'my data') 49 | assert.ok(true, 'published message') 50 | }) 51 | 52 | test('Connection#close() will gracefully end the connection', async () => { 53 | const rabbit = new Connection(RABBITMQ_URL) 54 | //rabbit.on('error', (err) => t.error(err)) 55 | 56 | await expectEvent(rabbit, 'connection') 57 | assert.ok(true, 'established connection') 58 | rabbit.once('connection', () => assert.fail('should not reconnect')) 59 | 60 | await rabbit.close() 61 | assert.ok(true, 'closed connection') 62 | }) 63 | 64 | test('Connection#close() should wait for channels to close (after reconnections)', async () => { 65 | const rabbit = autoTeardown(new Connection({ 66 | url: RABBITMQ_URL, 67 | retryLow: 25 68 | })) 69 | 70 | await expectEvent(rabbit, 'connection') 71 | assert.ok(true, 'established connection') 72 | 73 | rabbit._socket.destroy() 74 | const err = await expectEvent(rabbit, 'error') 75 | assert.equal(err.code, 'CONN_CLOSE', 76 | 'connection reset') 77 | 78 | await expectEvent(rabbit, 'connection') 79 | assert.ok(true, 're-established connection') 80 | 81 | const ch = autoTeardown(await rabbit.acquire()) 82 | rabbit.close().catch(err => assert.ifError(err)) 83 | await ch.confirmSelect() 84 | await ch.basicQos({prefetchCount: 1}) 85 | }) 86 | 87 | test('checks for a protocol version mismatch', async () => { 88 | // create stub server 89 | const server = createServer() 90 | server.listen() 91 | await expectEvent(server, 'listening') 92 | const addr = server.address() 93 | if (addr == null || typeof addr == 'string') 94 | return assert.fail('expected server addr obj') 95 | server.on('connection', (socket) => { 96 | // server should respond with the expected protocol version and close the connection 97 | assert.ok(true, 'connection attempt') 98 | socket.end('AMQP\x00\x00\x00\x00') 99 | server.close() 100 | }) 101 | 102 | const rabbit = new Connection({port: addr.port, retryLow: 25}) 103 | 104 | const err1 = await expectEvent(rabbit, 'error') 105 | assert.equal(err1.code, 'VERSION_MISMATCH', 106 | 'should get a version error') 107 | 108 | // TODO repeated errors are currently suppressed; maybe that should be a optional flag 109 | //const err2 = await expectEvent(rabbit, 'error') 110 | //assert.equal(err2.code, 'VERSION_MISMATCH', 111 | // 'should attempt reconnect') 112 | 113 | await rabbit.close() 114 | assert.ok(true, 'closed connection') 115 | }) 116 | 117 | test('[opt.connectionTimeout] is a time limit on each connection attempt', async () => { 118 | // create a stub server 119 | const server = autoTeardown(createServer()) 120 | server.listen() 121 | await expectEvent(server, 'listening') 122 | const addr = server.address() 123 | if (addr == null || typeof addr == 'string') 124 | return assert.fail('expected server addr obj') 125 | server.on('connection', () => { 126 | // never respond 127 | }) 128 | 129 | const rabbit = autoTeardown(new Connection({ 130 | port: addr.port, 131 | connectionTimeout: 25 132 | })) 133 | 134 | const err = await expectEvent(rabbit, 'error') 135 | assert.equal(err.code, 'CONNECTION_TIMEOUT', 136 | 'should get timeout error') 137 | }) 138 | 139 | test('will reconnect when connection.close is received from the server', async () => { 140 | const [port, server] = await useFakeServer([ 141 | async (socket, next) => { 142 | let frame 143 | 144 | // S:connection.start 145 | socket.write('AQAAAAAAHAAKAAoACQAAAAAAAAAFUExBSU4AAAAFZW5fVVPO', 'base64') 146 | frame = await next() as MethodFrame 147 | assert.equal(frame.methodId, Cmd.ConnectionStartOK) 148 | 149 | // S:connection.tune 150 | socket.write('AQAAAAAADAAKAB4H/wACAAAAPM4', 'base64') 151 | frame = await next() as MethodFrame 152 | assert.equal(frame.methodId, Cmd.ConnectionTuneOK) 153 | 154 | frame = await next() as MethodFrame 155 | assert.equal(frame.methodId, Cmd.ConnectionOpen) 156 | // S:connection.open-ok 157 | socket.write('AQAAAAAABQAKACkAzg', 'base64') 158 | 159 | // S:connection.close 160 | socket.end('AQAAAAAAIwAKADIBQBhDT05ORUNUSU9OX0ZPUkNFRCAtIHRlc3QAAAAAzg', 'base64') 161 | frame = await next() as MethodFrame 162 | assert.equal(frame.methodId, Cmd.ConnectionCloseOK) 163 | }, 164 | async (socket, next) => { 165 | let frame 166 | 167 | // S:connection.start 168 | socket.write('AQAAAAAAHAAKAAoACQAAAAAAAAAFUExBSU4AAAAFZW5fVVPO', 'base64') 169 | frame = await next() as MethodFrame 170 | assert.equal(frame.methodId, Cmd.ConnectionStartOK) 171 | 172 | // S:connection.tune 173 | socket.write('AQAAAAAADAAKAB4H/wACAAAAPM4', 'base64') 174 | frame = await next() as MethodFrame 175 | assert.equal(frame.methodId, Cmd.ConnectionTuneOK) 176 | 177 | frame = await next() as MethodFrame 178 | assert.equal(frame.methodId, Cmd.ConnectionOpen) 179 | // S:connection.open-ok 180 | socket.write('AQAAAAAABQAKACkAzg', 'base64') 181 | 182 | frame = await next() as MethodFrame 183 | assert.equal(frame.methodId, Cmd.ConnectionClose) 184 | // S:connection.close-ok 185 | socket.end('AQAAAAAABAAKADPO', 'base64') 186 | server.close() 187 | } 188 | ]) 189 | 190 | const rabbit = autoTeardown(new Connection({ 191 | //url: RABBITMQ_URL, 192 | port: port, 193 | retryLow: 25, // fast reconnect 194 | connectionName: '__connection.close_test__' 195 | })) 196 | 197 | await expectEvent(rabbit, 'connection') 198 | assert.ok(true, 'established connection') 199 | 200 | // can also use this cmd when testing with real server 201 | // rabbitmqctl close_connection $(rabbitmqctl list_connections pid client_properties | grep -F '__connection.close_test__' | head -n1 | awk '{print $1}') test 202 | 203 | const err = await expectEvent(rabbit, 'error') 204 | assert.equal(err.code, 'CONNECTION_FORCED', 205 | 'connection lost') 206 | 207 | await expectEvent(rabbit, 'connection') 208 | assert.ok(true, 'reconnected') 209 | }) 210 | 211 | test('will reconnect when receiving a frame for an unexpected channel', async () => { 212 | const [port, server] = await useFakeServer([ 213 | async (socket, next) => { 214 | let frame 215 | 216 | // S:connection.start 217 | socket.write('AQAAAAAAHAAKAAoACQAAAAAAAAAFUExBSU4AAAAFZW5fVVPO', 'base64') 218 | frame = await next() as MethodFrame 219 | assert.equal(frame.methodId, Cmd.ConnectionStartOK) 220 | 221 | // S:connection.tune 222 | socket.write('AQAAAAAADAAKAB4H/wACAAAAPM4', 'base64') 223 | frame = await next() as MethodFrame 224 | assert.equal(frame.methodId, Cmd.ConnectionTuneOK) 225 | 226 | frame = await next() as MethodFrame 227 | assert.equal(frame.methodId, Cmd.ConnectionOpen) 228 | // S:connection.open-ok 229 | socket.write('AQAAAAAABQAKACkAzg', 'base64') 230 | 231 | // S:queue.declare on channel 5 should cause an error 232 | socket.end('AQAFAAAAFAAyAAoAAAh3aGF0ZXZlcgAAAAAAzg', 'base64') 233 | }, 234 | async (socket, next) => { 235 | let frame 236 | 237 | // S:connection.start 238 | socket.write('AQAAAAAAHAAKAAoACQAAAAAAAAAFUExBSU4AAAAFZW5fVVPO', 'base64') 239 | frame = await next() as MethodFrame 240 | assert.equal(frame.methodId, Cmd.ConnectionStartOK) 241 | 242 | // S:connection.tune 243 | socket.write('AQAAAAAADAAKAB4H/wACAAAAPM4', 'base64') 244 | frame = await next() as MethodFrame 245 | assert.equal(frame.methodId, Cmd.ConnectionTuneOK) 246 | 247 | frame = await next() as MethodFrame 248 | assert.equal(frame.methodId, Cmd.ConnectionOpen) 249 | // S:connection.open-ok 250 | socket.write('AQAAAAAABQAKACkAzg', 'base64') 251 | 252 | frame = await next() as MethodFrame 253 | assert.equal(frame.methodId, Cmd.ConnectionClose) 254 | // S:connection.close-ok 255 | socket.end('AQAAAAAABAAKADPO', 'base64') 256 | server.close() 257 | } 258 | ]) 259 | 260 | const rabbit = autoTeardown(new Connection({ 261 | //url: RABBITMQ_URL, 262 | port: port, 263 | retryLow: 25, // fast reconnect 264 | connectionName: '__connection.close_test__' 265 | })) 266 | 267 | await expectEvent(rabbit, 'connection') 268 | assert.ok(true, 'established connection') 269 | 270 | const err = await expectEvent(rabbit, 'error') 271 | assert.equal(err.code, 'UNEXPECTED_FRAME', 272 | 'bad frame caused an error') 273 | 274 | await expectEvent(rabbit, 'connection') 275 | assert.ok(true, 'reconnected') 276 | }) 277 | 278 | test('handles channel errors', async () => { 279 | const queue = '__test_e8f4f1d045bc0097__' // this random queue should not exist 280 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 281 | 282 | const ch = await rabbit.acquire() 283 | assert.ok(true, 'got channel') 284 | const err = await ch.queueDeclare({passive: true, queue}).catch(err => err) 285 | assert.equal(err.code, 'NOT_FOUND', 286 | 'caught channel error') 287 | assert.equal(ch.active, false, 288 | 'channel is closed') 289 | }) 290 | 291 | test('publish with confirms are rejected with channel error', async () => { 292 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 293 | const ch = autoTeardown(await rabbit.acquire()) 294 | await ch.confirmSelect() 295 | const pending = [ 296 | ch.basicPublish({routingKey: '__not_found_9a1e1bba7f62571d__'}, ''), 297 | ch.basicPublish({routingKey: '__not_found_9a1e1bba7f62571d__', exchange: '__not_found_9a1e1bba7f62571d__'}, ''), 298 | ch.basicPublish({routingKey: '__not_found_9a1e1bba7f62571d__'}, ''), 299 | ] 300 | const [r0, r1, r2] = await Promise.allSettled(pending) 301 | 302 | assert.equal(r0.status, 'fulfilled', '1st publish ok') 303 | // should be rejected because the exchange does not exist 304 | if (r1.status === 'rejected') 305 | assert.equal(r1.reason.code, 'NOT_FOUND', '2nd publish rejected') 306 | else assert.fail('2nd publish should fail') 307 | // remaining unconfirmed publishes should just get CH_CLOSE 308 | if (r2.status === 'rejected') 309 | assert.equal(r2.reason.code, 'CH_CLOSE', '3rd publish rejected') 310 | else assert.fail('3rd publish should fail') 311 | assert.equal(ch.active, false, 'channel is dead') 312 | }) 313 | 314 | test('publish without confirms should emit channel errors on the Connection', async () => { 315 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 316 | const ch = await rabbit.acquire() 317 | await ch.basicPublish({routingKey: '__not_found_9a1e1bba7f62571d__', exchange: '__not_found_9a1e1bba7f62571d__'}, '') 318 | const err = await expectEvent(rabbit, 'error') 319 | assert.equal(err.code, 'NOT_FOUND', 'emitted error') 320 | }) 321 | 322 | test('basic.ack can emit channel errors', async () => { 323 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 324 | const ch = autoTeardown(await rabbit.acquire()) 325 | ch.basicAck({deliveryTag: 1}) // does not return a Promise 326 | const err = await expectEvent(rabbit, 'error') 327 | assert.equal(err.code, 'PRECONDITION_FAILED', 'unknown delivery tag') 328 | }) 329 | 330 | test('basic.ack can emit codec errors', async () => { 331 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 332 | const ch = autoTeardown(await rabbit.acquire()) 333 | // @ts-ignore intentional error 334 | ch.basicAck({deliveryTag: 'not a number'}) // does not return a Promise 335 | const err = await expectEvent(rabbit, 'error') 336 | assert.ok(err instanceof SyntaxError, 'got encoding error') 337 | }) 338 | 339 | test('handles encoder errors', async () => { 340 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 341 | const ch = autoTeardown(await rabbit.acquire()) 342 | 343 | const err = await ch.basicCancel({consumerTag: null as any}).catch(err => err) 344 | assert.equal(err.message, 'consumerTag is undefined; expected a string', 345 | 'should check required params before encoding') 346 | 347 | // will explode when encoding 348 | const bomb: string = {toString() { throw new TypeError('boom') }} as any 349 | 350 | assert.equal(ch.id, 1, 'got a channel') 351 | const errs = await Promise.all([ 352 | ch.basicCancel({consumerTag: bomb}).catch(err => err), // the bad one 353 | ch.queueDeclare({exclusive: true}).catch(err => err), // this will never be sent 354 | expectEvent(ch, 'close'), 355 | ]) 356 | assert.equal(errs[0].message, 'boom') 357 | assert.equal(errs[1].code, 'CH_CLOSE', 'buffered RPC is rejected') 358 | 359 | const other = autoTeardown(await rabbit.acquire()) 360 | assert.equal(other.id, ch.id, 361 | 'created a new channel with the same id (old channel was properly closed)') 362 | }) 363 | 364 | test('handles encoder errors (while closing)', async () => { 365 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 366 | const ch = autoTeardown(await rabbit.acquire()) 367 | 368 | // will explode when encoding 369 | const bomb: string = {toString() { throw new TypeError('boom') }} as any 370 | 371 | assert.equal(ch.id, 1, 'got a channel') 372 | const errs = await Promise.all([ 373 | ch.basicCancel({consumerTag: bomb}).catch(err => err), // the bad one 374 | ch.queueDeclare({exclusive: true}).catch(err => err), // this will never be sent 375 | ch.close(), // <-- new 376 | expectEvent(ch, 'close'), 377 | ]) 378 | assert.equal(errs[0].message, 'boom') 379 | assert.equal(errs[1].code, 'CH_CLOSE', 'buffered RPC is rejected') 380 | 381 | const other = autoTeardown(await rabbit.acquire()) 382 | assert.equal(other.id, ch.id, 383 | 'created a new channel with the same id (old channel was properly closed)') 384 | }) 385 | 386 | test('[opt.heartbeat] creates a timeout to detect a dead socket', async () => { 387 | let s1complete = false 388 | 389 | const [port, server] = await useFakeServer(async (socket, next) => { 390 | let frame 391 | 392 | // S:connection.start 393 | socket.write('AQAAAAAAHAAKAAoACQAAAAAAAAAFUExBSU4AAAAFZW5fVVPO', 'base64') 394 | frame = await next() as MethodFrame 395 | assert.equal(frame.methodId, Cmd.ConnectionStartOK) 396 | 397 | // S:connection.tune 398 | socket.write('AQAAAAAADAAKAB4H/wACAAAAPM4', 'base64') 399 | frame = await next() as MethodFrame 400 | assert.equal(frame.methodId, Cmd.ConnectionTuneOK) 401 | 402 | frame = await next() as MethodFrame 403 | assert.equal(frame.methodId, Cmd.ConnectionOpen) 404 | // S:connection.open-ok 405 | socket.write('AQAAAAAABQAKACkAzg', 'base64') 406 | 407 | // don't send hearbeats; wait for timeout 408 | s1complete = true 409 | }) 410 | 411 | const rabbit = autoTeardown(new Connection({ 412 | port: port, 413 | heartbeat: 1 414 | })) 415 | 416 | const err = await expectEvent(rabbit, 'error') 417 | assert.equal(err.code, 'SOCKET_TIMEOUT', 418 | 'got socket timeout error') 419 | 420 | server.close() 421 | await rabbit.close() 422 | assert.equal(s1complete, true) 423 | }) 424 | 425 | test('[opt.heartbeat] regularly sends a heartbeat frame on its own', async () => { 426 | let s1complete = false 427 | 428 | const [port, server] = await useFakeServer(async (socket, next) => { 429 | server.close() 430 | let frame 431 | 432 | // S:connection.start 433 | socket.write('AQAAAAAAHAAKAAoACQAAAAAAAAAFUExBSU4AAAAFZW5fVVPO', 'base64') 434 | frame = await next() as MethodFrame 435 | assert.equal(frame.methodId, Cmd.ConnectionStartOK) 436 | 437 | // S:connection.tune 438 | socket.write('AQAAAAAADAAKAB4H/wACAAAAPM4', 'base64') 439 | frame = await next() as MethodFrame 440 | assert.equal(frame.methodId, Cmd.ConnectionTuneOK) 441 | 442 | frame = await next() as MethodFrame 443 | assert.equal(frame.methodId, Cmd.ConnectionOpen) 444 | // S:connection.open-ok 445 | socket.write('AQAAAAAABQAKACkAzg', 'base64') 446 | 447 | frame = await next() as DataFrame 448 | assert.equal(frame.type, FrameType.HEARTBEAT, 'got heartbeat from client') 449 | 450 | // S:connection.close 451 | socket.end('AQAAAAAAIwAKADIBQBhDT05ORUNUSU9OX0ZPUkNFRCAtIHRlc3QAAAAAzg', 'base64') 452 | frame = await next() as MethodFrame 453 | assert.equal(frame.methodId, Cmd.ConnectionCloseOK, 'client confirmed forced close') 454 | s1complete = true 455 | }) 456 | 457 | const rabbit = autoTeardown(new Connection({ 458 | port: port, 459 | heartbeat: 1 460 | })) 461 | 462 | const err = await expectEvent(rabbit, 'error') 463 | assert.equal(err.code, 'CONNECTION_FORCED') 464 | 465 | await rabbit.close() 466 | assert.equal(s1complete, true) 467 | }) 468 | 469 | test('can create a basic consumer', async () => { 470 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 471 | const ch = autoTeardown(await rabbit.acquire()) 472 | const {queue} = await ch.queueDeclare({exclusive: true}) 473 | 474 | const expectedMessages: Deferred[] = [createDeferred(), createDeferred()] 475 | await ch.basicPublish({routingKey: queue}, 'red data') 476 | await ch.basicPublish({routingKey: queue}, 'black data') 477 | assert.ok(true, 'published messages') 478 | 479 | const {consumerTag} = await ch.basicConsume({queue}, (msg) => { 480 | expectedMessages[msg.deliveryTag - 1].resolve(msg) 481 | }) 482 | assert.ok(true, 'created basic consumer') 483 | 484 | const [m1, m2] = await Promise.all(expectedMessages.map(dfd => dfd.promise)) 485 | assert.equal(m1.body, 'red data') 486 | assert.equal(m2.body, 'black data') 487 | 488 | await ch.basicCancel({consumerTag}) 489 | assert.ok(true, 'cancelled consumer') 490 | }) 491 | 492 | test('emits basic.return with rejected messages', async () => { 493 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 494 | const ch = autoTeardown(await rabbit.acquire()) 495 | await ch.basicPublish({ 496 | routingKey: '__basic.return test__', 497 | mandatory: true 498 | }, 'my data') 499 | const msg = await expectEvent(ch, 'basic.return') 500 | assert.equal(msg.body, 'my data', 501 | 'msg returned') 502 | assert.equal(msg.replyText, 'NO_ROUTE', 503 | 'msg is unroutable') 504 | }) 505 | 506 | test('handles zero-length returned messages', async () => { 507 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 508 | const ch = autoTeardown(await rabbit.acquire()) 509 | const payload = Buffer.alloc(0) 510 | await ch.basicPublish({mandatory: true, routingKey: '__not_found_7953ec8de0da686e__'}, payload) 511 | const msg = await expectEvent(ch, 'basic.return') 512 | assert.equal(msg.replyText, 'NO_ROUTE', 'message returned') 513 | assert.equal(msg.body.byteLength, 0, 'body is empty') 514 | }) 515 | 516 | test('[opt.maxChannels] can cause acquire() to fail', async () => { 517 | const rabbit = autoTeardown(new Connection({ 518 | url: RABBITMQ_URL, 519 | maxChannels: 1 520 | })) 521 | 522 | const [r0, r1] = await Promise.allSettled([ 523 | rabbit.acquire(), 524 | rabbit.acquire() 525 | ]) 526 | 527 | assert.equal(r0.status, 'fulfilled', 'ch 1 acquired') 528 | r0.value.close() 529 | assert.equal(r1.status, 'rejected', 'ch 2 unavailable') 530 | }) 531 | 532 | test('Connection#acquire() can time out', async () => { 533 | // create a stub server 534 | const server = autoTeardown(createServer()) 535 | server.listen() 536 | await expectEvent(server, 'listening') 537 | const addr = server.address() 538 | if (addr == null || typeof addr == 'string') 539 | return assert.fail('expected server addr obj') 540 | server.on('connection', () => { 541 | // never respond 542 | }) 543 | 544 | const rabbit = autoTeardown(new Connection({port: addr.port, acquireTimeout: 25})) 545 | 546 | await assert.rejects(rabbit.acquire(), {code: 'TIMEOUT'}) 547 | }) 548 | 549 | test('Connection#createPublisher()', async () => { 550 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 551 | const queue = '__test_c77466a747761f46__' 552 | 553 | const pro = autoTeardown(rabbit.createPublisher({ 554 | confirm: true, 555 | queues: [{queue, exclusive: true}], 556 | })) 557 | assert.ok(true, 'created publisher') 558 | 559 | // should emit 'basic.return' events 560 | await pro.send({routingKey: '__not_found_7315197c1ab0e5f6__', mandatory: true}, 561 | 'my returned message') 562 | assert.ok(true, 'publish acknowledged') 563 | const msg0 = await expectEvent(pro, 'basic.return') 564 | assert.equal(msg0.body, 'my returned message', 565 | 'got returned message') 566 | 567 | // should establish queues, bindings 568 | await pro.send({routingKey: queue}, 569 | 'my good message') 570 | assert.ok(true, 'publish acknowledged') 571 | const msg1 = await rabbit.basicGet({queue}) 572 | assert.equal(msg1?.body, 'my good message', 573 | 'got published message from temporary queue') 574 | 575 | // should recover after channel error 576 | let err0 577 | try { 578 | await pro.send({exchange: '__not_found_7315197c1ab0e5f6__'}, '') 579 | } catch (err) { 580 | err0 = err 581 | } 582 | assert.equal(err0?.code, 'NOT_FOUND', 583 | 'caused a channel error') 584 | await pro.send({routingKey: queue}, '') 585 | assert.ok(true, 'published on new channel') 586 | 587 | // should recover after connection loss 588 | rabbit._socket.destroy() 589 | await expectEvent(rabbit, 'error') 590 | assert.ok(true, 'connection reset') 591 | await pro.send({routingKey: queue}, '') 592 | assert.ok(true, 'published after connection error') 593 | 594 | // should not publish after close() 595 | await pro.close() 596 | let err1 597 | try { 598 | await pro.send({routingKey: queue}, '') 599 | } catch (err) { 600 | err1 = err 601 | } 602 | assert.equal(err1.code, 'CLOSED', 603 | 'failed to publish after close()') 604 | }) 605 | 606 | test('Connection#createPublisher() concurrent publishes should trigger one setup', async () => { 607 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 608 | const pro = autoTeardown(rabbit.createPublisher()) 609 | 610 | await Promise.all([ 611 | pro.send({routingKey: '__not_found_7953ec8de0da686e__'}, ''), 612 | pro.send({routingKey: '__not_found_7953ec8de0da686e__'}, '') 613 | ]) 614 | 615 | assert.equal(rabbit._state.leased.size, 1, 616 | 'one channel created') 617 | }) 618 | 619 | test('Publisher should retry failed setup', async () => { 620 | const queue = '__not_found_7953ec8de0da686e__' 621 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 622 | const pro = autoTeardown(rabbit.createPublisher({ 623 | confirm: true, 624 | // setup should fail when the queue does not exist 625 | queues: [{passive: true, queue}] 626 | })) 627 | 628 | const [res] = await Promise.allSettled([ 629 | pro.send({routingKey: queue}, 'hello') 630 | ]) 631 | 632 | assert.equal(res.status, 'rejected', 'setup failed 1st time') 633 | 634 | await rabbit.queueDeclare({queue, exclusive: true}) // auto-delete after test 635 | 636 | await pro.send({routingKey: queue}, 'hello') 637 | assert.ok(true, 'setup completed and message published') 638 | }) 639 | 640 | test('Connection#createPublisher() should complete setup before allowing (multiple) publishes', async () => { 641 | const queue = '__test_99c78753f1a782ed' 642 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 643 | const pub = autoTeardown(rabbit.createPublisher({ 644 | confirm: true, 645 | queues: [{queue, exclusive: true}] 646 | })) 647 | 648 | // observe rabbit.acquire() 649 | const dfd = createDeferred() 650 | const ogacquire = rabbit.acquire 651 | rabbit.acquire = function (...args) { 652 | return ogacquire.apply(rabbit, args).then(res => { 653 | process.nextTick(dfd.resolve) 654 | return res 655 | }, err => { 656 | dfd.reject(err) 657 | throw err 658 | }) 659 | } 660 | 661 | await Promise.all([ 662 | pub.send(queue, 'green'), // trigger setup 663 | // publish again, after acquiring a channel, but before enabling confirms 664 | // should also wait for setup to complete 665 | dfd.promise.then(() => pub.send(queue, 'red')), 666 | ]) 667 | 668 | const m1 = await rabbit.basicGet(queue) 669 | const m2 = await rabbit.basicGet(queue) 670 | 671 | // If red comes before green, then the publisher was allowed to send before 672 | // setup completed 673 | assert.equal(m1!.body, 'green') 674 | assert.equal(m2!.body, 'red') 675 | }) 676 | 677 | test('Publisher (maxAttempts) should retry failed publish', async () => { 678 | const exchange = '__test_ce7cea6070c084fe' 679 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 680 | const pro = autoTeardown(rabbit.createPublisher({ 681 | confirm: true, 682 | maxAttempts: 2, 683 | exchanges: [{exchange}] 684 | })) 685 | // establish the internal Channel and exchange (lazy setup) 686 | await pro.send({exchange}, 'test1') 687 | // deleting the exchange should cause the next publish to fail 688 | await rabbit.exchangeDelete({exchange}) 689 | 690 | const [err] = await Promise.all([ 691 | expectEvent(pro, 'retry'), 692 | pro.send({exchange}, 'test2') 693 | ]) 694 | assert.equal(err.code, 'NOT_FOUND', 'got retry event') 695 | assert.ok(true, 'publish succeeded eventually') 696 | }) 697 | 698 | test('Connection should retry with next cluster node', async () => { 699 | let s1complete = false 700 | let s2complete = false 701 | 702 | const [port1, server1] = await useFakeServer(async (socket, next) => { 703 | server1.close() 704 | let frame 705 | 706 | // S:connection.start 707 | socket.write('AQAAAAAAHAAKAAoACQAAAAAAAAAFUExBSU4AAAAFZW5fVVPO', 'base64') 708 | frame = await next() as MethodFrame 709 | assert.equal(frame.methodId, Cmd.ConnectionStartOK) 710 | 711 | // S:connection.tune 712 | socket.write('AQAAAAAADAAKAB4H/wACAAAAPM4', 'base64') 713 | frame = await next() as MethodFrame 714 | assert.equal(frame.methodId, Cmd.ConnectionTuneOK) 715 | 716 | frame = await next() as MethodFrame 717 | assert.equal(frame.methodId, Cmd.ConnectionOpen) 718 | // S:connection.open-ok 719 | socket.write('AQAAAAAABQAKACkAzg', 'base64') 720 | 721 | // S:connection.close 722 | socket.end('AQAAAAAAIwAKADIBQBhDT05ORUNUSU9OX0ZPUkNFRCAtIHRlc3QAAAAAzg', 'base64') 723 | frame = await next() as MethodFrame 724 | assert.equal(frame.methodId, Cmd.ConnectionCloseOK, 'client confirmed forced close') 725 | s1complete = true 726 | }) 727 | const [port2, server2] = await useFakeServer(async (socket, next) => { 728 | server2.close() 729 | let frame 730 | 731 | assert.ok(true, 'connected to 2nd host') 732 | 733 | // S:connection.start 734 | socket.write('AQAAAAAAHAAKAAoACQAAAAAAAAAFUExBSU4AAAAFZW5fVVPO', 'base64') 735 | frame = await next() as MethodFrame 736 | assert.equal(frame.methodId, Cmd.ConnectionStartOK) 737 | 738 | // S:connection.tune 739 | socket.write('AQAAAAAADAAKAB4H/wACAAAAPM4', 'base64') 740 | frame = await next() as MethodFrame 741 | assert.equal(frame.methodId, Cmd.ConnectionTuneOK) 742 | 743 | frame = await next() as MethodFrame 744 | assert.equal(frame.methodId, Cmd.ConnectionOpen) 745 | // S:connection.open-ok 746 | socket.write('AQAAAAAABQAKACkAzg', 'base64') 747 | 748 | frame = await next() as MethodFrame 749 | assert.equal(frame.methodId, Cmd.ConnectionClose, 'client initiated close') 750 | // S:connection.close-ok 751 | socket.end('AQAAAAAABAAKADPO', 'base64') 752 | s2complete = true 753 | }) 754 | 755 | const rabbit = autoTeardown(new Connection({ 756 | retryLow: 25, // fast reconnect 757 | hosts: [`localhost:${port1}`, `localhost:${port2}`] 758 | })) 759 | 760 | await expectEvent(rabbit, 'connection') 761 | assert.ok(true, 'established first connection') 762 | const err = await expectEvent(rabbit, 'error') 763 | assert.equal(err.code, 'CONNECTION_FORCED', '1st conn errored') 764 | 765 | await expectEvent(rabbit, 'connection') 766 | 767 | await rabbit.close() 768 | assert.equal(s1complete, true, 769 | 'server 1 assertions complete') 770 | assert.equal(s2complete, true, 771 | 'server 2 assertions complete') 772 | }) 773 | 774 | test('should encode/decode array values in message headers', async () => { 775 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 776 | const ch = autoTeardown(await rabbit.acquire()) 777 | const {queue} = await ch.queueDeclare({exclusive: true}) 778 | assert.ok(queue, 'created temporary queue') 779 | await ch.basicPublish({routingKey: queue, headers: {'x-test-arr': ['red', 'blue']}}, '') 780 | assert.ok(true, 'publish successful') 781 | const msg = await ch.basicGet({queue}) 782 | assert.ok(msg, 'recieved message') 783 | const arr = msg?.headers?.['x-test-arr'] 784 | assert.equal(arr.join(), 'red,blue', 'got the array') 785 | }) 786 | 787 | test('handles (un)auth error', async () => { 788 | const wrongurl = new URL(RABBITMQ_URL || 'amqp://guest:guest@localhost:5672') 789 | wrongurl.password = 'badpassword' 790 | const rabbit = autoTeardown(new Connection(wrongurl.toString())) 791 | const err = await expectEvent(rabbit, 'error') 792 | assert.equal(err.code, 'ACCESS_REFUSED') 793 | }) 794 | 795 | test('out-of-order RPC', async () => { 796 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 797 | const ch = autoTeardown(await rabbit.acquire()) 798 | const queues = ['1a9370fa04be7108', '5be264772fdd703b'] 799 | 800 | await Promise.all([ 801 | ch.queueDeclare({queue: queues[0], exclusive: true}), 802 | ch.basicConsume({queue: queues[0], consumerTag: 'red'}, () => {}), 803 | ch.queueDeclare({queue: queues[1], exclusive: true}), 804 | ]) 805 | 806 | // rabbit will emit an UNEXPECTED_FRAME err if a response arrives out of order 807 | }) 808 | 809 | test('lazy channel', {timeout: 250}, async () => { 810 | const rabbit = new Connection(RABBITMQ_URL) 811 | 812 | // 'can manage queues without explicitly creating a channel' 813 | const {queue} = await rabbit.queueDeclare({exclusive: true}) 814 | 815 | // 'lazy channel can recover from errors' 816 | const [res] = await Promise.allSettled([ 817 | rabbit.queueDeclare({queue: '6b5a171726e573d5', passive: true}) 818 | ]) 819 | assert.equal(res.status === 'rejected' && res.reason.code, 'NOT_FOUND') 820 | await rabbit.queueDeclare({queue, passive: true}) 821 | 822 | // 'lazy channel auto-closes' 823 | await rabbit.close() 824 | }) 825 | 826 | test('lazy channel recovers from acquisition errors', async () => { 827 | const rabbit = autoTeardown(new Connection({ 828 | url: RABBITMQ_URL, 829 | maxChannels: 1 830 | })) 831 | const ch = autoTeardown(await rabbit.acquire()) 832 | 833 | await assert.rejects(rabbit.queueDeclare({queue: '__5a87c00901794671__', exclusive: true}) 834 | , /maximum number of AMQP Channels already opened/) 835 | 836 | // Should succeed after closing the 1st channel 837 | await ch.close() 838 | await rabbit.queueDeclare({queue: '__5a87c00901794671__', exclusive: true}) 839 | }) 840 | 841 | test('client-side frame size checks', async () => { 842 | const rabbit = autoTeardown(new Connection({ 843 | url: RABBITMQ_URL, 844 | frameMax: 8192, 845 | })) 846 | 847 | const queue = '__test_797e71d3d9153ace' 848 | 849 | // simple method with oversized frame should fail 850 | const [res] = await Promise.allSettled([rabbit.queueBind({ 851 | queue: queue, 852 | routingKey: 'test', 853 | exchange: queue, 854 | arguments: {bigstring: '0'.repeat(8114)} 855 | })]) 856 | assert.equal(res.status, 'rejected') 857 | assert.match(res.reason.message, /^frame size of 8193/) 858 | 859 | // publish with oversized header should fail 860 | const pub = autoTeardown(rabbit.createPublisher({confirm: true})) 861 | const [res2] = await Promise.allSettled([ 862 | pub.send({routingKey: queue, headers: {bigstring: '0'.repeat(8134)}}, null) 863 | ]) 864 | assert.equal(res2.status, 'rejected') 865 | assert.match(res2.reason.message, /^frame size of 8193/) 866 | }) 867 | 868 | test('connection.onConnect: reject', async (t) => { 869 | const rabbit = autoTeardown(new Connection('amqp://wrong:password@127.0.0.1:5672')) 870 | await assert.rejects(rabbit.onConnect(10), (err: any) => { 871 | assert.match(err.message, /failed to connect in time/) 872 | // error.cause has recent connection error 873 | assert(err.cause instanceof Error) 874 | return true 875 | }) 876 | assert.equal(rabbit.ready, false) 877 | // reject immediately when already closed 878 | await assert.rejects(rabbit.onConnect(10), /closed by client/) 879 | }) 880 | 881 | test('connection.onConnect: resolve', async (t) => { 882 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 883 | await rabbit.onConnect(500) 884 | assert(rabbit.ready, 'connection ready') 885 | // should still resolve when already connected 886 | await rabbit.onConnect(500) 887 | assert(true, 'connection still ready') 888 | }) 889 | -------------------------------------------------------------------------------- /test/consumer.ts: -------------------------------------------------------------------------------- 1 | import test from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import Connection, {AsyncMessage, ConsumerStatus} from '../src' 4 | import {PREFETCH_EVENT} from '../src/Consumer' 5 | import {expectEvent, createDeferred, Deferred, READY_STATE} from '../src/util' 6 | import {sleep, createIterableConsumer, autoTeardown} from './util' 7 | 8 | type TMParams = [Deferred, AsyncMessage] 9 | 10 | const RABBITMQ_URL = process.env.RABBITMQ_URL 11 | 12 | test('Consumer#close() waits for setup to complete', async () => { 13 | const rabbit = autoTeardown(new Connection({url: RABBITMQ_URL, retryLow: 25})) 14 | const queue = '__test_b1d57ae9eb91df85__' 15 | 16 | const consumer = autoTeardown(rabbit.createConsumer({ 17 | queue: queue, 18 | queueOptions: {exclusive: true}, 19 | }, async () => { 20 | 21 | })) 22 | 23 | // wait for setup to actually start 24 | await sleep(1) 25 | 26 | await Promise.all([ 27 | expectEvent(consumer, 'ready'), 28 | consumer.close(), 29 | ]) 30 | assert.ok(true, 'got "ready" event while closing') 31 | }) 32 | 33 | test('Connection#createConsumer()', async () => { 34 | const rabbit = autoTeardown(new Connection({url: RABBITMQ_URL, retryLow: 25})) 35 | 36 | const queue = '__test_df1865ed59a6b6272b14206b85863099__' 37 | const exchange = '__test_6d074e50d427e75a__' 38 | 39 | //const dfd = createDeferred() 40 | const consumer = autoTeardown(createIterableConsumer(rabbit, { 41 | requeue: true, 42 | queue: queue, 43 | queueOptions: {exclusive: true}, 44 | exchanges: [{autoDelete: true, exchange, type: 'topic'}], 45 | queueBindings: [{queue, exchange, routingKey: 'audit.*'}] 46 | })) 47 | 48 | await expectEvent(consumer, 'ready') 49 | assert.ok(true, 'consumer is ready') 50 | 51 | const ch = autoTeardown(await rabbit.acquire()) 52 | 53 | // ack nack 54 | await ch.basicPublish({exchange, routingKey: 'audit.test'}, 'red fish') 55 | const msg = await consumer.read() 56 | assert.equal(msg.body, 'red fish', 'got the message') 57 | assert.equal(msg.redelivered, false, '1st delivery of this msg') 58 | msg.reject(new Error('forced basic.nack')) 59 | const err0 = await expectEvent(consumer, 'error') 60 | assert.equal(err0.message, 'forced basic.nack', 61 | 'emitted error from failed handler') 62 | const msg2 = await consumer.read() 63 | assert.equal(msg2.redelivered, true, 64 | 'basic.ack for redelivered msg') 65 | msg2.resolve() 66 | 67 | // can recover after queue deleted (basic.cancel) 68 | consumer.once('error', err => { assert.equal(err.code, 'CANCEL_FORCED', 'consumer cancelled') }) 69 | const {messageCount} = await ch.queueDelete({queue}) 70 | assert.equal(messageCount, 0, 'queue is empty') 71 | await expectEvent(consumer, 'ready') 72 | assert.ok(true, 'consumer is ready again') 73 | 74 | // can recover after connection loss 75 | rabbit._socket.destroy() 76 | await expectEvent(rabbit, 'error') 77 | assert.ok(true, 'connection reset') 78 | await expectEvent(consumer, 'ready') 79 | assert.ok(true, 'consumer is ready yet again') 80 | 81 | // can recover after channel error (channel.close) 82 | const [res] = await Promise.allSettled([ 83 | consumer._ch?.queueDeclare({passive: true, queue: '__should_not_exist_7c8367661d62d8cc__'}) 84 | ]) 85 | assert.equal(res.status, 'rejected') 86 | assert.equal(res.reason.code, 'NOT_FOUND', 'caused a channel error') 87 | await expectEvent(consumer, 'ready') 88 | assert.ok(true, 'consumer is ready for the last time') 89 | }) 90 | 91 | test('Consumer does not create duplicates when setup temporarily fails', async () => { 92 | const rabbit = autoTeardown(new Connection({ 93 | url: RABBITMQ_URL, 94 | retryHigh: 25 95 | })) 96 | const queue = '__test1524e4b733696e9c__' 97 | const consumer = autoTeardown(rabbit.createConsumer({ 98 | queue: queue, 99 | // setup will fail until queue is created 100 | queueOptions: {passive: true} 101 | }, () => { /* do nothing */ })) 102 | 103 | const err = await expectEvent(consumer, 'error') 104 | assert.equal(err.code, 'NOT_FOUND', 'setup should fail at first') 105 | 106 | await rabbit.queueDeclare({queue, exclusive: true}) // auto-deleted 107 | await expectEvent(consumer, 'ready') 108 | assert.ok(true, 'consumer setup successful') 109 | consumer.once('ready', () => { 110 | assert.fail('expected only ONE ready event') 111 | }) 112 | }) 113 | 114 | test('Consumer waits for in-progress jobs to complete before reconnecting', async () => { 115 | const rabbit = autoTeardown(new Connection({ 116 | url: RABBITMQ_URL, 117 | retryLow: 1, retryHigh: 1 118 | })) 119 | const queue = '__test1c23944c0f14a28f__' 120 | const consumer = autoTeardown(rabbit.createConsumer({ 121 | queue: queue, 122 | queueOptions: {exclusive: true}, 123 | qos: {prefetchCount: 1} 124 | }, (msg) => { 125 | const dfd = createDeferred() 126 | consumer.emit('test.message', [dfd, msg]) 127 | // complete the job at some later time with dfd.resolve() 128 | return dfd.promise 129 | })) 130 | 131 | await expectEvent(consumer, 'ready') 132 | const ch = autoTeardown(await rabbit.acquire()) 133 | await ch.basicPublish(queue, 'red') 134 | const [job1, msg1] = await expectEvent(consumer, 'test.message') 135 | assert.equal(msg1.body, 'red', 'consumed a message') 136 | 137 | // intentionally cause a channel error so setup has to rerun 138 | await consumer._ch!.basicAck({deliveryTag: 404}) 139 | await expectEvent(consumer, 'error') 140 | assert.ok(true, 'channel killed') 141 | 142 | await sleep(25) 143 | job1.resolve() 144 | const err = await expectEvent(consumer, 'error') 145 | assert.equal(err.code, 'CH_CLOSE', 'old channel is closed') 146 | const [job2, msg2] = await expectEvent(consumer, 'test.message') 147 | job2.resolve() 148 | assert.equal(msg2.body, 'red') 149 | assert.equal(msg2.redelivered, true, 'consumed redelivered message after delay') 150 | }) 151 | 152 | test('Consumer should limit handler concurrency', async () => { 153 | const rabbit = autoTeardown(new Connection({ 154 | url: RABBITMQ_URL, 155 | retryHigh: 25, 156 | })) 157 | const queue = '__test1c23944c0f14a28a__' 158 | const consumer = autoTeardown(rabbit.createConsumer({ 159 | queue: queue, 160 | queueOptions: {exclusive: true}, 161 | concurrency: 1, 162 | }, (msg) => { 163 | const dfd = createDeferred() 164 | consumer.emit('test.message', [dfd, msg]) 165 | // complete the job at some later time with dfd.resolve() 166 | return dfd.promise 167 | })) 168 | 169 | type TMParams = [Deferred, AsyncMessage] 170 | 171 | await expectEvent(consumer, 'ready') 172 | const ch = autoTeardown(await rabbit.acquire()) 173 | await Promise.all([ 174 | ch.basicPublish(queue, 'red'), 175 | ch.basicPublish(queue, 'blue'), 176 | ch.basicPublish(queue, 'green'), 177 | ]) 178 | const [job1, msg1] = await expectEvent(consumer, 'test.message') 179 | await expectEvent(consumer, PREFETCH_EVENT) 180 | await expectEvent(consumer, PREFETCH_EVENT) 181 | assert.equal(msg1.body, 'red', 'consumed a message') 182 | assert.equal(msg1.redelivered, false, 'redelivered=false') 183 | assert.equal(consumer._prefetched.length, 2, '2nd message got buffered') 184 | job1.resolve() 185 | 186 | const [job2a, msg2a] = await expectEvent(consumer, 'test.message') 187 | assert.equal(msg2a.body, 'blue', 'consumed 2nd message') 188 | assert.equal(msg2a.redelivered, false, 'redelivered=false') 189 | assert.equal(consumer._prefetched.length, 1, '3rd message still buffered') 190 | 191 | // intentionally cause a channel error so setup has to rerun 192 | await consumer._ch!.basicAck({deliveryTag: 404}) 193 | await expectEvent(consumer, 'error') 194 | assert.equal(consumer._prefetched.length, 0, 'buffered message was dropped') 195 | 196 | job2a.resolve() 197 | const err = await expectEvent(consumer, 'error') 198 | assert.equal(err.code, 'CH_CLOSE', 'old channel is closed') 199 | 200 | // messages should be redelivered after channel error 201 | 202 | const [job2b, msg2b] = await expectEvent(consumer, 'test.message') 203 | assert.equal(msg2b.body, 'blue', 'consumed 2nd message again') 204 | assert.equal(msg2b.redelivered, true, 'redelivered=true') 205 | job2b.resolve() 206 | 207 | const [job3, msg3] = await expectEvent(consumer, 'test.message') 208 | assert.equal(msg3.body, 'green', 'consumed 3rd message') 209 | assert.equal(msg3.redelivered, true, 'redelivered=true') 210 | job3.resolve() 211 | }) 212 | 213 | test('Consumer concurrency with noAck=true', async () => { 214 | const rabbit = autoTeardown(new Connection({ 215 | url: RABBITMQ_URL, 216 | retryHigh: 25, 217 | })) 218 | const queue = '__test1c23944c0f14a28f__' 219 | const consumer = autoTeardown(rabbit.createConsumer({ 220 | queue: queue, 221 | noAck: true, 222 | queueOptions: {exclusive: true}, 223 | concurrency: 1, 224 | }, (msg) => { 225 | const dfd = createDeferred() 226 | consumer.emit('test.message', [dfd, msg]) 227 | // complete the job at some later time with dfd.resolve() 228 | return dfd.promise 229 | })) 230 | 231 | await expectEvent(consumer, 'ready') 232 | const ch = autoTeardown(await rabbit.acquire()) 233 | 234 | await Promise.all([ 235 | ch.basicPublish(queue, 'red'), 236 | ch.basicPublish(queue, 'blue'), 237 | ]) 238 | 239 | const [job1, msg1] = await expectEvent(consumer, 'test.message') 240 | assert.equal(msg1.body, 'red', 'consumed 1st message') 241 | await expectEvent(consumer, PREFETCH_EVENT) 242 | assert.equal(consumer._prefetched.length, 1, '2nd message got buffered') 243 | 244 | // intentionally cause a channel error 245 | await consumer._ch!.basicAck({deliveryTag: 404}) 246 | await expectEvent(consumer, 'error') 247 | assert.equal(consumer._prefetched.length, 1, 'buffered message remains') 248 | 249 | // with noAck=true, close() should wait for remaining messages to process 250 | const consumerClosed = consumer.close() 251 | 252 | // should not emit an error after the channel reset 253 | job1.resolve() 254 | 255 | const [job2, msg2] = await expectEvent(consumer, 'test.message') 256 | assert.equal(msg2.body, 'blue', 'consumed 2nd message') 257 | assert.equal(msg2.redelivered, false, 'redelivered=false') 258 | 259 | job2.resolve() 260 | 261 | await consumerClosed 262 | }) 263 | 264 | test('Consumer return codes', async () => { 265 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 266 | const queue = '__test_03f0726440598228' 267 | const deadqueue = '__test_fadb49f36193d615' 268 | const exchange = '__test_db6d7203284a44c2' 269 | const sub = autoTeardown(createIterableConsumer(rabbit, { 270 | queue, 271 | requeue: false, 272 | queueOptions: { 273 | exclusive: true, 274 | arguments: {'x-dead-letter-exchange': exchange} 275 | }, 276 | })) 277 | const dead = autoTeardown(createIterableConsumer(rabbit, { 278 | queue: deadqueue, 279 | queueOptions: {exclusive: true}, 280 | queueBindings: [{exchange, routingKey: queue}], 281 | exchanges: [{exchange, type: 'fanout', autoDelete: true}] 282 | })) 283 | 284 | await expectEvent(sub, 'ready') 285 | await expectEvent(dead, 'ready') 286 | const ch = autoTeardown(await rabbit.acquire()) 287 | 288 | // can drop by default 289 | await ch.basicPublish(queue, 'red') 290 | const msg1 = await sub.read() 291 | assert.equal(msg1.redelivered, false) 292 | msg1.reject(new Error('expected')) 293 | const err = await expectEvent(sub, 'error') 294 | assert.equal(err.message, 'expected') 295 | const msg2 = await dead.read() 296 | assert.equal(msg2.body, 'red', 'got dead-letter') 297 | msg2.resolve() 298 | 299 | // can selectively requeue 300 | await ch.basicPublish(queue, 'blue') 301 | const msg3 = await sub.read() 302 | assert.equal(msg3.redelivered, false) 303 | msg3.resolve(ConsumerStatus.REQUEUE) 304 | const msg4 = await sub.read() 305 | assert.equal(msg4.redelivered, true, 'msg redelivered') 306 | 307 | // can explicitly drop 308 | msg4.resolve(ConsumerStatus.DROP) 309 | const msg5 = await dead.read() 310 | assert.equal(msg5.body, 'blue', 'message dropped') 311 | msg5.resolve() 312 | }) 313 | 314 | test('Consumer stats', async () => { 315 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 316 | const pub = autoTeardown(rabbit.createPublisher({confirm: true})) 317 | const sub = autoTeardown(createIterableConsumer(rabbit, { 318 | queueOptions: {exclusive: true} 319 | })) 320 | 321 | await expectEvent(sub, 'ready') 322 | 323 | await Promise.all([ 324 | pub.send(sub.queue, null), 325 | pub.send(sub.queue, null), 326 | ]) 327 | 328 | const msg1 = await sub.read() 329 | msg1.resolve(ConsumerStatus.ACK) 330 | 331 | const msg2 = await sub.read() 332 | msg2.resolve(ConsumerStatus.REQUEUE) 333 | 334 | const msg3 = await sub.read() 335 | msg3.resolve(ConsumerStatus.DROP) 336 | 337 | await Promise.all([ 338 | pub.close(), 339 | sub.close(), 340 | rabbit.close(), 341 | ]) 342 | 343 | assert.equal(sub.stats.acknowledged, 1) 344 | assert.equal(sub.stats.requeued, 1) 345 | assert.equal(sub.stats.dropped, 1) 346 | }) 347 | 348 | test('Lazy consumer', async () => { 349 | const rabbit = new Connection(RABBITMQ_URL) 350 | const sub = createIterableConsumer(rabbit, { 351 | queueOptions: {exclusive: true}, 352 | lazy: true 353 | }) 354 | 355 | assert.equal(sub._readyState, READY_STATE.CLOSED) 356 | 357 | sub.start() 358 | await expectEvent(sub, 'ready') 359 | 360 | assert.equal(sub._readyState, READY_STATE.OPEN) 361 | 362 | await sub.close() 363 | assert.equal(sub._readyState, READY_STATE.CLOSED) 364 | 365 | // Should allow restarting after close 366 | sub.start() 367 | await expectEvent(sub, 'ready') 368 | assert.equal(sub._readyState, READY_STATE.OPEN) 369 | 370 | await Promise.all([ 371 | sub.close(), 372 | rabbit.close(), 373 | ]) 374 | 375 | assert.equal(sub._readyState, READY_STATE.CLOSED) 376 | }) 377 | -------------------------------------------------------------------------------- /test/options.ts: -------------------------------------------------------------------------------- 1 | import test from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import normalizeOptions from '../src/normalize' 4 | 5 | test('should enable TLS and change the default port for "amqps" urls', async () => { 6 | const a1 = normalizeOptions('amqps://guest:guest@127.0.0.1') 7 | assert.ok(a1.tls) 8 | assert.equal(a1.hosts[0].port, '5671') 9 | 10 | const a2 = normalizeOptions('amqps://guest:guest@127.0.0.1:1234') 11 | assert.ok(a2.tls) 12 | assert.equal(a2.hosts[0].port, '1234') 13 | 14 | const a3 = normalizeOptions('amqp://guest:guest@127.0.0.1') 15 | assert.ok(!a3.tls) 16 | assert.equal(a3.hosts[0].port, '5672') 17 | 18 | const a4 = normalizeOptions('amqp://guest:guest@127.0.0.1:1234') 19 | assert.ok(!a4.tls) 20 | assert.equal(a4.hosts[0].port, '1234') 21 | }) 22 | -------------------------------------------------------------------------------- /test/rpc.ts: -------------------------------------------------------------------------------- 1 | import test, { after } from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import Connection, {ConsumerHandler} from '../src' 4 | import {expectEvent} from '../src/util' 5 | import {autoTeardown, createIterableConsumer} from './util' 6 | 7 | const RABBITMQ_URL = process.env.RABBITMQ_URL 8 | 9 | test('basic rpc setup', async () => { 10 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 11 | const queue = '__test_284f68e427a918b2__' 12 | const client = autoTeardown(rabbit.createRPCClient()) 13 | const server = autoTeardown(rabbit.createConsumer({ 14 | queue: queue, 15 | queueOptions: {exclusive: true} 16 | }, async (msg, reply) => { 17 | await reply('PONG') 18 | })) 19 | 20 | await expectEvent(server, 'ready') 21 | const res = await client.send(queue, 'PING') 22 | assert.equal(res.body, 'PONG', 'got the response') 23 | }) 24 | 25 | test('rpc can timeout', async () => { 26 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 27 | const queue = '__test_52ef4e140113eb58__' 28 | const client = autoTeardown(rabbit.createRPCClient({ 29 | confirm: true, 30 | timeout: 10, 31 | queues: [{queue, exclusive: true}] 32 | })) 33 | 34 | await assert.rejects(client.send({routingKey: queue}, ''), { 35 | code: 'RPC_TIMEOUT' 36 | }) 37 | }) 38 | 39 | test('rpc failure modes', async () => { 40 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 41 | const queue = '__test_52ef4e140113eb53__' 42 | const client = autoTeardown(rabbit.createRPCClient({ 43 | confirm: true, 44 | // fail until queue is created 45 | queues: [{queue, passive: true}] 46 | })) 47 | 48 | // 'setup can fail' 49 | let err 50 | try { 51 | await client.send({routingKey: queue}, '') 52 | } catch (_err) { 53 | err = _err 54 | } 55 | assert.equal(err.code, 'NOT_FOUND', 'setup failed successfully') 56 | 57 | await rabbit.queueDeclare({queue, exclusive: true}) 58 | 59 | // 'can encounter a ChannelError' 60 | const [r1, r2] = await Promise.allSettled([ 61 | client.send({routingKey: queue}, ''), 62 | // should fail since the exchange does not exist 63 | client.send({exchange: '__bc84b490a8dab5a0__', routingKey: queue}, ''), 64 | ]) 65 | assert.equal(r1.status, 'rejected') 66 | assert.equal(r1.reason.code, 'RPC_CLOSED', 'channel closed before timeout') 67 | assert.equal(r2.status, 'rejected') 68 | assert.equal(r2.reason.code, 'NOT_FOUND', 'caused channel error') 69 | 70 | // 'should still work after a few failures' 71 | const server = autoTeardown(rabbit.createConsumer({ 72 | queue: queue, 73 | queueOptions: {exclusive: true} 74 | }, async (msg, reply) => { 75 | await reply('PONG') 76 | })) 77 | const res = await client.send(queue, 'PING') 78 | assert.equal(res.body, 'PONG') 79 | }) 80 | 81 | test('rpc retry (maxAttempts)', async () => { 82 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 83 | 84 | const request = Symbol('request') 85 | const queue = '__test_f6ad3342820fb0c7' 86 | const server = autoTeardown(rabbit.createConsumer({queue, queueOptions: {exclusive: true}}, (msg, reply) => { 87 | server.emit(request, [msg, reply]) 88 | })) 89 | await expectEvent(server, 'ready') 90 | 91 | const client = autoTeardown(rabbit.createRPCClient({confirm: true, maxAttempts: 2, timeout: 50})) 92 | 93 | // 1 success 94 | const [res1] = await Promise.all([ 95 | client.send(queue, 'ping'), 96 | expectEvent>(server, request) 97 | .then(([msg, reply]) => reply('pong')) 98 | ]) 99 | assert.equal(res1.body, 'pong') 100 | assert.equal(res1.correlationId, '1') 101 | 102 | // 1 fail, 1 success 103 | const [res2] = await Promise.all([ 104 | client.send(queue, 'pong'), 105 | (async () => { 106 | await expectEvent>(server, request) 107 | // ignore msg2 108 | const [, reply3] = await expectEvent>(server, request) 109 | await reply3('pong') 110 | })() 111 | ]) 112 | assert.equal(res2.body, 'pong') 113 | assert.equal(res2.correlationId, '3', 'success on 2nd try') 114 | 115 | // 2 fail 116 | const [res3] = await Promise.allSettled([ 117 | client.send(queue, 'pong'), 118 | (async () => { 119 | const [msg4] = await expectEvent>(server, request) 120 | assert.equal(msg4.correlationId, '4') 121 | const [msg5] = await expectEvent>(server, request) 122 | assert.equal(msg5.correlationId, '5') 123 | })() 124 | ]) 125 | assert.equal(res3.status, 'rejected') 126 | assert.equal(res3.reason.code, 'RPC_TIMEOUT', 'timeout on 2nd try') 127 | }) 128 | 129 | test('rpc discard late responses', async () => { 130 | const rabbit = autoTeardown(new Connection(RABBITMQ_URL)) 131 | const queue = 'f5f8cf8b737f6cdb' 132 | const server = autoTeardown(createIterableConsumer(rabbit, { 133 | queue, 134 | queueOptions: {exclusive: true} 135 | })) 136 | 137 | const client = autoTeardown(rabbit.createRPCClient({confirm: true, timeout: 50})) 138 | 139 | await Promise.allSettled([ 140 | client.send(queue, 'ping'), 141 | // this kills the channel 142 | client.send({exchange: '__bc84b490a8dab5a0__'}, null), 143 | ]) 144 | 145 | const sending = client.send(queue, 'bing') 146 | 147 | const m1 = await server.read() 148 | assert.equal(m1.body, 'ping') 149 | await m1.reply('pong') 150 | m1.resolve() 151 | 152 | const m2 = await server.read() 153 | assert.equal(m2.body, 'bing') 154 | await m2.reply('bong') 155 | m2.resolve() 156 | 157 | const r1 = await sending 158 | assert.equal(r1.body, 'bong') 159 | }) 160 | -------------------------------------------------------------------------------- /test/stream-helpers.ts: -------------------------------------------------------------------------------- 1 | import test from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import {sleep} from './util' 4 | import {expectEvent, createAsyncReader, EncoderStream} from '../src/util' 5 | import {Readable, Writable} from 'node:stream' 6 | 7 | /** Create a Readable stream with configurable speed */ 8 | function stubReadable(data: Buffer, chunkSize: number, delay=1): Readable { 9 | let offset = 0 10 | return new Readable({ 11 | read() { 12 | if (offset >= data.byteLength) 13 | this.push(null) 14 | else if (delay > 0) 15 | sleep(delay).then(() => this.push(data.slice(offset, offset += chunkSize))) 16 | else 17 | this.push(data.slice(offset, offset += chunkSize)) 18 | } 19 | }) 20 | } 21 | 22 | test('createAsyncReader()', async () => { 23 | const data = Buffer.from('ec825a689c073e6da3bf34862a1513e0774767a21943bc25', 'hex') 24 | const stream = stubReadable(data, 4) // 4 bytes per ms (24 bytes total) 25 | const read = createAsyncReader(stream) 26 | 27 | // can read from a stream 28 | const b1 = await read(4) // should wait for 1 chunk 29 | assert.equal(b1.toString('hex'), data.slice(0, 4).toString('hex'), 30 | 'read first chunk') 31 | // should wait for enough data 32 | const b3 = await read(12) // should wait for 3 chunks 33 | assert.equal(b3.toString('hex'), data.slice(4, 16).toString('hex'), 34 | 'read second chunk') 35 | const b2 = await read(8) // should wait for 2 chunks 36 | assert.equal(b2.toString('hex'), data.slice(16, 24).toString('hex'), 37 | 'read second chunk') 38 | 39 | const [res] = await Promise.allSettled([read(1)]) 40 | assert.equal(res.status, 'rejected') 41 | assert.equal(res.reason.code, 'READ_END', 'final read should error') 42 | assert.equal(stream.readable, false, 'stream is closed') 43 | }) 44 | 45 | test('createAsyncReader() handles a stream closed with partial data', async () => { 46 | const data = Buffer.from('010203', 'hex') 47 | const stream = stubReadable(data, 1) 48 | const read = createAsyncReader(stream) 49 | 50 | const [res] = await Promise.allSettled([read(4)]) 51 | assert.equal(res.status, 'rejected') 52 | assert.equal(res.reason.code, 'READ_END', 'read rejected') 53 | assert.equal(stream.readable, false, 'stream is closed') 54 | }) 55 | 56 | test('createAsyncReader() handles a destroyed/errored stream', async () => { 57 | const data = Buffer.from('0102030405', 'hex') 58 | const stream = stubReadable(data, 1) 59 | const read = createAsyncReader(stream) 60 | 61 | setTimeout(() => stream.destroy(), 2) 62 | 63 | const [res] = await Promise.allSettled([read(4)]) 64 | assert.equal(res.status, 'rejected') 65 | assert.equal(res.reason.code, 'READ_END', 'read rejected') 66 | assert.equal(stream.readable, false, 'stream is closed') 67 | }) 68 | 69 | class StubSocket extends Writable { 70 | _cur?: [any, (error?: Error | null) => void] 71 | _write(chunk: any, enc: unknown, cb: (error?: Error | null) => void) { 72 | this._cur = [chunk, cb] 73 | } 74 | read(): any { 75 | if (!this._cur) return null 76 | const [chunk, cb] = this._cur 77 | this._cur = undefined 78 | cb() 79 | return chunk 80 | } 81 | } 82 | 83 | test('EncodeStream', async () => { 84 | const socket = new StubSocket({objectMode: true, highWaterMark: 2}) 85 | const stream = new EncoderStream(socket) 86 | 87 | // 'should write everything if allowed' 88 | stream.write(['red', 'orange'].values()) 89 | assert.equal(socket.writableLength, 2) 90 | assert.equal(socket.read(), 'red') 91 | assert.equal(socket.writableLength, 1) 92 | assert.equal(socket.read(), 'orange') 93 | assert.equal(socket.writableLength, 0) 94 | assert.equal(stream.writableLength, 0) 95 | 96 | // 'should complete more than one iterator' 97 | stream.write(['green'].values()) 98 | assert.equal(socket.read(), 'green') 99 | 100 | // 'should pause when encountering back-pressure' 101 | stream.write(['blue', 'indigo', 'violet'].values()) 102 | assert.equal(socket.writableLength, 2) 103 | assert.equal(socket.read(), 'blue') 104 | assert.equal(socket.writableLength, 1, 'no writes until fully drained') 105 | assert.equal(socket.read(), 'indigo') 106 | assert.equal(socket.read(), 'violet') 107 | assert.equal(socket.writableLength, 0) 108 | 109 | // 'should not write at all if the destination is already full' 110 | stream.write(['thalia', 'calliope'].values()) 111 | assert.equal(socket.writableLength, 2) 112 | stream.write(['erato'].values()) 113 | assert.equal(socket.writableLength, 2) 114 | assert.equal(socket.read(), 'thalia') 115 | assert.equal(socket.read(), 'calliope') 116 | assert.equal(socket.read(), 'erato') 117 | 118 | await stream.writeAsync(['orpheus'].values()) 119 | assert.equal(socket.read(), 'orpheus', 'writeAsync() works') 120 | 121 | // 'should catch iterator errors' 122 | stream.write(function*(){ 123 | throw new Error('bad news') 124 | }()) 125 | const err = await expectEvent(stream, 'error') 126 | assert.equal(err.message, 'bad news', 'emitted error') 127 | assert.equal(stream.writable, false, 'stream is dead') 128 | }) 129 | 130 | test('EncoderStream should stop writing when destroyed', async () => { 131 | const socket = new StubSocket({objectMode: true, highWaterMark: 2}) 132 | const stream = new EncoderStream(socket) 133 | 134 | stream.write(['red', 'blue', 'green'].values()) 135 | assert.equal(socket.writableLength, 2) 136 | stream.destroy() 137 | assert.equal(socket.read(), 'red') 138 | assert.equal(socket.read(), 'blue') 139 | assert.equal(socket.read(), null, 'green should not be written') 140 | }) 141 | 142 | test('EncoderStream invokes write callback when destroyed', async () => { 143 | const it = ['red', 'blue', 'green'].values() 144 | const socket = new StubSocket({objectMode: true, highWaterMark: 1}) 145 | const stream = new EncoderStream(socket) 146 | stream.on('error', () => { /* ignored */ }) 147 | 148 | const promise = stream.writeAsync(it) 149 | stream.destroy(new Error('bad news')) 150 | await assert.rejects(() => promise, {message: 'bad news'}) 151 | }) 152 | -------------------------------------------------------------------------------- /test/util.ts: -------------------------------------------------------------------------------- 1 | import {Socket, createServer} from 'node:net' 2 | import {DataFrame, decodeFrame} from '../src/codec' 3 | import {expectEvent, createAsyncReader, createDeferred} from '../src/util' 4 | import Connection, {ConsumerProps, AsyncMessage, ConsumerStatus} from '../src' 5 | import {PassThrough} from 'node:stream' 6 | import { after } from 'node:test' 7 | 8 | function sleep(ms: number) { 9 | return new Promise(resolve => setTimeout(resolve, ms)) 10 | } 11 | 12 | async function* produceFrames(socket: Socket) { 13 | const versionHeader = Buffer.from('AMQP\x00\x00\x09\x01') 14 | const read = createAsyncReader(socket) 15 | const chunk = await read(8) 16 | if (chunk.compare(versionHeader)) 17 | throw new Error('expected version header') 18 | try { 19 | while (true) yield await decodeFrame(read) 20 | } catch (err) { 21 | if (err.code !== 'READ_END') socket.destroy(err) 22 | } 23 | } 24 | 25 | type RabbitNextCB = () => Promise 26 | type ConnectionCallback = (socket: Socket, next: RabbitNextCB) => Promise 27 | 28 | async function useFakeServer(cb: ConnectionCallback|Array) { 29 | const callbacks = Array.isArray(cb) ? cb : [cb] 30 | const server = createServer() 31 | server.listen() 32 | await expectEvent(server, 'listening') 33 | after(() => server.close()) 34 | const addr = server.address() 35 | if (addr == null || typeof addr == 'string') 36 | throw new Error('expected server addr obj') 37 | let idx = 0 38 | server.on('connection', (socket) => { 39 | const frames = produceFrames(socket) 40 | let res: Awaited> 41 | const next = async () => { 42 | res = await frames.next() 43 | return res.value 44 | } 45 | callbacks[idx](socket, next).catch(err => { 46 | server.close() 47 | socket.destroy() 48 | throw err 49 | }) 50 | idx = Math.min(idx + 1, callbacks.length - 1) 51 | }) 52 | return [addr.port, server] as const 53 | } 54 | 55 | interface DeferredMessage extends AsyncMessage { 56 | resolve(status?: ConsumerStatus): void 57 | reject(reason: any): void 58 | reply(body: any): Promise 59 | } 60 | 61 | function createIterableConsumer(rabbit: Connection, opt: ConsumerProps) { 62 | const stream = new PassThrough({objectMode: true}) 63 | const sub = rabbit.createConsumer(opt, (msg, reply) => { 64 | const dfd = createDeferred() 65 | stream.write(Object.assign(msg, { 66 | resolve: dfd.resolve, 67 | reject: dfd.reject, 68 | reply: reply 69 | })) 70 | return dfd.promise 71 | }) 72 | 73 | const it: AsyncIterator = stream[Symbol.asyncIterator]() 74 | 75 | const close = sub.close.bind(sub) 76 | return Object.assign(sub, { 77 | [Symbol.asyncIterator]() { 78 | return it 79 | }, 80 | async read() { 81 | const res = await it.next() 82 | if (res.done) 83 | throw new Error('iterable consumer is closed') 84 | return res.value 85 | }, 86 | async close() { 87 | await close() 88 | stream.end() 89 | } 90 | }) 91 | } 92 | 93 | interface SomeResource { 94 | close() :void 95 | } 96 | function autoTeardown(obj: T) :T { 97 | after(() => { obj.close() }) 98 | return obj 99 | } 100 | 101 | export {useFakeServer, sleep, createIterableConsumer, autoTeardown} 102 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["src/typedoc-plugin-*.ts"], 4 | "include": ["src/*.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "lib": ["es2023"], 5 | "target": "ES2022", 6 | 7 | "declaration": true, 8 | "downlevelIteration": true, 9 | "esModuleInterop": true, 10 | "isolatedModules": true, 11 | "outDir": "lib", 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "strict": true, 15 | "stripInternal": true, 16 | "types": ["node"], 17 | "useUnknownInCatchVariables": false 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src/index.ts"], 3 | "excludeExternals": true, 4 | "includeVersion": false, 5 | "githubPages": false, 6 | "sidebarLinks": { 7 | "View source on GitHub": "https://github.com/cody-greene/node-rabbitmq-client" 8 | }, 9 | "out": "./docs", 10 | "plugin": ["./src/typedoc-plugin-versions.ts"], 11 | "tsconfig": "tsconfig.build.json" 12 | } 13 | --------------------------------------------------------------------------------