├── .github ├── renovate.json5 └── workflows │ └── checks.yml ├── .gitignore ├── .yarnrc.yml ├── LICENSE.md ├── README.md ├── bin └── test.ts ├── config └── mosquitto.conf ├── eslint.config.js ├── index.ts ├── package.json ├── src ├── bus.ts ├── bus_manager.ts ├── debug.ts ├── define_config.ts ├── encoders │ └── json_encoder.ts ├── message_hasher.ts ├── retry_queue.ts ├── retry_queue_with_duplicates.ts ├── retry_queue_without_duplicates.ts ├── test_helpers.ts ├── transports │ ├── memory.ts │ ├── mqtt.ts │ └── redis.ts └── types │ └── main.ts ├── test_helpers ├── chaos_injector.ts └── chaos_transport.ts ├── tests ├── bus.spec.ts ├── bus_manager.spec.ts ├── drivers │ ├── memory_transport.spec.ts │ ├── mqtt_transport.spec.ts │ └── redis_transport.spec.ts ├── encoders │ └── json_encoder.spec.ts ├── message_hasher.spec.ts └── retry_queue.spec.ts ├── tsconfig.json ├── tsup.config.ts └── yarn.lock /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | extends: ["config:recommended", "schedule:weekly", "group:allNonMajor"], 4 | labels: ["dependencies"], 5 | rangeStrategy: "bump", 6 | packageRules: [ 7 | { 8 | matchDepTypes: ["peerDependencies"], 9 | enabled: false, 10 | }, 11 | ], 12 | ignoreDeps: [ 13 | // manually bumping 14 | "node", 15 | "@types/node" 16 | ], 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | - push 4 | - pull_request 5 | 6 | jobs: 7 | test: 8 | uses: boringnode/.github/.github/workflows/test.yml@main 9 | with: 10 | disable-windows: true 11 | 12 | lint: 13 | uses: boringnode/.github/.github/workflows/lint.yml@main 14 | 15 | typecheck: 16 | uses: boringnode/.github/.github/workflows/typecheck.yml@main 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build 2 | build 3 | 4 | # Node & Dependencies 5 | node_modules 6 | coverage 7 | 8 | # Build tools specific 9 | .yarn/* 10 | !.yarn/patches 11 | !.yarn/plugins 12 | !.yarn/releases 13 | !.yarn/sdks 14 | !.yarn/versions 15 | npm-debug.log 16 | yarn-error.log 17 | 18 | # Editors specific 19 | .fleet 20 | .idea 21 | .vscode 22 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2023 Romain Lanz, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | @boringnode/bus 3 |
4 | 5 |
6 | 7 | [![typescript-image]][typescript-url] 8 | [![gh-workflow-image]][gh-workflow-url] 9 | [![npm-image]][npm-url] 10 | [![npm-download-image]][npm-download-url] 11 | [![license-image]][license-url] 12 | 13 |
14 | 15 |
16 | 17 | `@boringnode/bus` is a service bus implementation for Node.js. It is designed to be simple and easy to use. 18 | 19 | Currently, it supports the following transports: 20 | 21 |

22 | 👉 Memory: A simple in-memory transport for testing purposes.
23 | 👉 Redis: A Redis transport for production usage.
24 | 👉 Mqtt: A Mqtt transport for production usage. 25 |

26 | 27 | ## Table of Contents 28 | 29 | 30 | 31 | 32 | - [Installation](#installation) 33 | - [Usage](#usage) 34 | - [Retry Queue](#retry-queue) 35 | 36 | 37 | 38 | ## Installation 39 | 40 | ```bash 41 | npm install @boringnode/bus 42 | ``` 43 | 44 | ## Usage 45 | 46 | The module exposes a manager that can be used to register buses. 47 | 48 | ```typescript 49 | import { BusManager } from '@boringnode/bus' 50 | import { redis } from '@boringnode/bus/transports/redis' 51 | import { mqtt } from '@boringnode/bus/transports/mqtt' 52 | import { memory } from '@boringnode/bus/transports/memory' 53 | 54 | const manager = new BusManager({ 55 | default: 'main', 56 | transports: { 57 | main: { 58 | transport: memory(), 59 | }, 60 | redis: { 61 | transport: redis({ 62 | host: 'localhost', 63 | port: 6379, 64 | }), 65 | }, 66 | mqtt: { 67 | transport: mqtt({ 68 | host: 'localhost', 69 | port: 1883, 70 | }), 71 | }, 72 | } 73 | }) 74 | ``` 75 | 76 | Once the manager is created, you can subscribe to channels and publish messages. 77 | 78 | ```typescript 79 | manager.subscribe('channel', (message) => { 80 | console.log('Received message', message) 81 | }) 82 | 83 | manager.publish('channel', 'Hello world') 84 | ``` 85 | 86 | By default, the bus will use the `default` transport. You can specify different transport by using the `use` method. 87 | 88 | ```typescript 89 | manager.use('redis').publish('channel', 'Hello world') 90 | manager.use('mqtt').publish('channel', 'Hello world') 91 | ``` 92 | 93 | ### Without the manager 94 | 95 | If you don't need multiple buses, you can create a single bus directly by importing the transports and the Bus class. 96 | 97 | ```typescript 98 | import { Bus } from '@boringnode/bus' 99 | import { RedisTransport } from '@boringnode/bus/transports/redis' 100 | 101 | const transport = new RedisTransport({ 102 | host: 'localhost', 103 | port: 6379, 104 | }) 105 | 106 | const bus = new Bus(transport, { 107 | retryQueue: { 108 | retryInterval: '100ms' 109 | } 110 | }) 111 | ``` 112 | 113 | ## Retry Queue 114 | 115 | The bus also supports a retry queue. When a message fails to be published, it will be moved to the retry queue. 116 | 117 | For example, your Redis server is down. 118 | 119 | ```typescript 120 | const manager = new BusManager({ 121 | default: 'main', 122 | transports: { 123 | main: { 124 | transport: redis({ 125 | host: 'localhost', 126 | port: 6379, 127 | }), 128 | retryQueue: { 129 | retryInterval: '100ms' 130 | } 131 | }, 132 | } 133 | }) 134 | 135 | manager.use('redis').publish('channel', 'Hello World') 136 | ``` 137 | 138 | The message will be moved to the retry queue and will be retried every 100ms. 139 | 140 | You have multiple options to configure the retry queue. 141 | 142 | ```typescript 143 | export interface RetryQueueOptions { 144 | // Enable the retry queue (default: true) 145 | enabled?: boolean 146 | 147 | // Defines if we allow duplicates messages in the retry queue (default: true) 148 | removeDuplicates?: boolean 149 | 150 | // The maximum size of the retry queue (default: null) 151 | maxSize?: number | null 152 | 153 | // The interval between each retry (default: false) 154 | retryInterval?: Duration | false 155 | } 156 | ``` 157 | 158 | ## Test helpers 159 | 160 | The module also provides some test helpers to make it easier to test the code that relies on the bus. First, you can use the `MemoryTransport` to create a bus that uses an in-memory transport. 161 | 162 | You can also use the `ChaosTransport` to simulate a transport that fails randomly, in order to test the resilience of your code. 163 | 164 | ```ts 165 | import { Bus } from '@boringnode/bus' 166 | import { ChaosTransport } from '@boringnode/bus/test_helpers' 167 | 168 | const buggyTransport = new ChaosTransport(new MemoryTransport()) 169 | const bus = new Bus(buggyTransport) 170 | 171 | /** 172 | * Now, every time you will try to publish a message, the transport 173 | * will throw an error. 174 | */ 175 | buggyTransport.alwaysThrow() 176 | ``` 177 | 178 | [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/boringnode/bus/checks.yml?branch=0.x&style=for-the-badge 179 | [gh-workflow-url]: https://github.com/boringnode/bus/actions/workflows/checks.yml 180 | [npm-image]: https://img.shields.io/npm/v/@boringnode/bus.svg?style=for-the-badge&logo=npm 181 | [npm-url]: https://www.npmjs.com/package/@boringnode/bus 182 | [npm-download-image]: https://img.shields.io/npm/dm/@boringnode/bus?style=for-the-badge 183 | [npm-download-url]: https://www.npmjs.com/package/@boringnode/bus 184 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 185 | [typescript-url]: https://www.typescriptlang.org 186 | [license-image]: https://img.shields.io/npm/l/@boringnode/bus?color=blueviolet&style=for-the-badge 187 | [license-url]: LICENSE.md 188 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { configure, processCLIArgs, run } from '@japa/runner' 9 | import { assert } from '@japa/assert' 10 | import { expectTypeOf } from '@japa/expect-type' 11 | 12 | processCLIArgs(process.argv.splice(2)) 13 | configure({ 14 | files: ['tests/**/*.spec.ts'], 15 | plugins: [assert(), expectTypeOf()], 16 | }) 17 | 18 | void run() 19 | -------------------------------------------------------------------------------- /config/mosquitto.conf: -------------------------------------------------------------------------------- 1 | listener 1883 0.0.0.0 2 | allow_anonymous true 3 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | 3 | export default configPkg({}) 4 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | export { Bus } from './src/bus.js' 9 | export { BusManager } from './src/bus_manager.js' 10 | export { defineConfig } from './src/define_config.js' 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boringnode/bus", 3 | "description": "A simple and lean driver-based service bus implementation", 4 | "version": "0.7.1", 5 | "engines": { 6 | "node": ">=20.6" 7 | }, 8 | "main": "build/index.js", 9 | "type": "module", 10 | "files": [ 11 | "build" 12 | ], 13 | "exports": { 14 | ".": "./build/index.js", 15 | "./transports/*": "./build/src/transports/*.js", 16 | "./test_helpers": "./build/src/test_helpers/index.js", 17 | "./types/*": "./build/src/types/*.js" 18 | }, 19 | "scripts": { 20 | "build": "yarn clean && tsup-node", 21 | "clean": "del-cli build", 22 | "format": "prettier --write .", 23 | "lint": "eslint .", 24 | "prepublishOnly": "yarn build", 25 | "release": "yarn dlx release-it", 26 | "update:toc": "yarn dlx doctoc README.md", 27 | "test": "c8 yarn quick:test", 28 | "quick:test": "yarn node --import ts-node-maintained/register/esm --enable-source-maps bin/test.ts", 29 | "typecheck": "tsc --noEmit" 30 | }, 31 | "devDependencies": { 32 | "@adonisjs/eslint-config": "^2.0.0", 33 | "@adonisjs/prettier-config": "^1.4.4", 34 | "@adonisjs/tsconfig": "^1.4.0", 35 | "@japa/assert": "^4.0.1", 36 | "@japa/expect-type": "^2.0.3", 37 | "@japa/runner": "^4.2.0", 38 | "@swc/core": "^1.11.24", 39 | "@testcontainers/hivemq": "^10.26.0", 40 | "@testcontainers/redis": "^10.26.0", 41 | "@types/node": "^20.17.19", 42 | "@types/object-hash": "^3.0.6", 43 | "c8": "^10.1.3", 44 | "del-cli": "^6.0.0", 45 | "eslint": "^9.27.0", 46 | "ioredis": "^5.6.1", 47 | "mqtt": "^5.13.0", 48 | "prettier": "^3.5.3", 49 | "release-it": "^18.1.2", 50 | "testcontainers": "^10.26.0", 51 | "ts-node-maintained": "^10.9.5", 52 | "tsup": "^8.5.0", 53 | "typescript": "^5.8.3" 54 | }, 55 | "dependencies": { 56 | "@paralleldrive/cuid2": "^2.2.2", 57 | "@poppinss/utils": "^6.9.3", 58 | "object-hash": "^3.0.0" 59 | }, 60 | "peerDependencies": { 61 | "ioredis": "^5.0.0" 62 | }, 63 | "peerDependenciesMeta": { 64 | "ioredis": { 65 | "optional": true 66 | } 67 | }, 68 | "author": "Romain Lanz ", 69 | "contributors": [ 70 | "Julien Ripouteau " 71 | ], 72 | "license": "MIT", 73 | "keywords": [ 74 | "bus", 75 | "transport", 76 | "service bus" 77 | ], 78 | "prettier": "@adonisjs/prettier-config", 79 | "publishConfig": { 80 | "access": "public", 81 | "tag": "latest" 82 | }, 83 | "release-it": { 84 | "git": { 85 | "commitMessage": "chore(release): ${version}", 86 | "tagAnnotation": "v${version}", 87 | "tagName": "v${version}" 88 | }, 89 | "github": { 90 | "release": true, 91 | "releaseName": "v${version}", 92 | "web": true 93 | } 94 | }, 95 | "packageManager": "yarn@4.9.1" 96 | } 97 | -------------------------------------------------------------------------------- /src/bus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import string from '@poppinss/utils/string' 9 | import { createId } from '@paralleldrive/cuid2' 10 | import { RetryQueue } from './retry_queue.js' 11 | import debug from './debug.js' 12 | import type { RetryQueueOptions, Serializable, SubscribeHandler, Transport } from './types/main.js' 13 | 14 | export class Bus { 15 | readonly #transport: Transport 16 | readonly #busId: string 17 | readonly #errorRetryQueue: RetryQueue 18 | readonly #retryQueueInterval: NodeJS.Timeout | undefined 19 | 20 | constructor(transport: Transport, options?: { retryQueue?: RetryQueueOptions }) { 21 | this.#transport = transport 22 | this.#busId = createId() 23 | this.#errorRetryQueue = new RetryQueue(options?.retryQueue) 24 | 25 | if (options?.retryQueue?.retryInterval) { 26 | const intervalValue = 27 | typeof options?.retryQueue?.retryInterval === 'number' 28 | ? options?.retryQueue?.retryInterval 29 | : string.milliseconds.parse(options?.retryQueue?.retryInterval) 30 | 31 | this.#retryQueueInterval = setInterval(() => { 32 | void this.processErrorRetryQueue() 33 | }, intervalValue) 34 | } 35 | 36 | transport.setId(this.#busId).onReconnect(() => this.#onReconnect()) 37 | } 38 | 39 | getRetryQueue() { 40 | return this.#errorRetryQueue 41 | } 42 | 43 | processErrorRetryQueue() { 44 | debug(`start error retry queue processing with ${this.#errorRetryQueue.size()} messages`) 45 | 46 | return this.#errorRetryQueue.process(async (channel, message) => { 47 | return await this.publish(channel, message.payload) 48 | }) 49 | } 50 | 51 | async #onReconnect() { 52 | debug(`bus transport ${this.#transport.constructor.name} reconnected`) 53 | 54 | await this.processErrorRetryQueue() 55 | } 56 | 57 | subscribe(channel: string, handler: SubscribeHandler) { 58 | debug(`subscribing to channel ${channel}`) 59 | 60 | return this.#transport.subscribe(channel, async (message) => { 61 | debug('received message %j from bus', message) 62 | // @ts-expect-error - TODO: Weird typing issue 63 | handler(message) 64 | }) 65 | } 66 | 67 | async publish(channel: string, message: Serializable) { 68 | try { 69 | debug('publishing message "%j" to channel "%s"', message, channel) 70 | 71 | await this.#transport.publish(channel, message) 72 | 73 | return true 74 | } catch (error) { 75 | debug('error publishing message "%j" to channel "%s". Retrying later', message, channel) 76 | 77 | const wasAdded = this.#errorRetryQueue.enqueue(channel, { 78 | payload: message, 79 | busId: this.#busId, 80 | }) 81 | 82 | if (!wasAdded) return false 83 | 84 | debug(`added message %j to error retry queue`, message) 85 | return false 86 | } 87 | } 88 | 89 | disconnect() { 90 | if (this.#retryQueueInterval) { 91 | clearInterval(this.#retryQueueInterval) 92 | } 93 | 94 | return this.#transport.disconnect() 95 | } 96 | 97 | unsubscribe(channel: string) { 98 | return this.#transport.unsubscribe(channel) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/bus_manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { RuntimeException } from '@poppinss/utils/exceptions' 9 | import { Bus } from './bus.js' 10 | import debug from './debug.js' 11 | import type { 12 | ManagerConfig, 13 | Serializable, 14 | SubscribeHandler, 15 | TransportConfig, 16 | } from './types/main.js' 17 | 18 | export class BusManager> { 19 | readonly #defaultTransportName: keyof KnownTransports | undefined 20 | readonly #transports: KnownTransports 21 | 22 | #transportsCache: Partial> = {} 23 | 24 | constructor(config: ManagerConfig) { 25 | debug('creating bus manager. config: %O', config) 26 | 27 | this.#transports = config.transports 28 | this.#defaultTransportName = config.default 29 | } 30 | 31 | use(transports?: KnownTransport): Bus { 32 | let transportToUse: keyof KnownTransports | undefined = transports || this.#defaultTransportName 33 | 34 | if (!transportToUse) { 35 | throw new RuntimeException( 36 | 'Cannot create bus instance. No default transport is defined in the config' 37 | ) 38 | } 39 | 40 | const cachedTransport = this.#transportsCache[transportToUse] 41 | if (cachedTransport) { 42 | debug('returning cached transport instance for %s', transportToUse) 43 | return cachedTransport 44 | } 45 | 46 | const transportConfig = this.#transports[transportToUse] 47 | 48 | debug('creating new transport instance for %s', transportToUse) 49 | const transportInstance = new Bus(transportConfig.transport(), { 50 | retryQueue: transportConfig.retryQueue, 51 | }) 52 | this.#transportsCache[transportToUse] = transportInstance 53 | 54 | return transportInstance 55 | } 56 | 57 | async publish(channel: string, message: Serializable) { 58 | return this.use().publish(channel, message) 59 | } 60 | 61 | subscribe(channel: string, handler: SubscribeHandler) { 62 | return this.use().subscribe(channel, handler) 63 | } 64 | 65 | unsubscribe(channel: string) { 66 | return this.use().unsubscribe(channel) 67 | } 68 | 69 | disconnect() { 70 | return this.use().disconnect() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { debuglog } from 'node:util' 9 | 10 | export default debuglog('boringnode:bus') 11 | -------------------------------------------------------------------------------- /src/define_config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import type { ManagerConfig, TransportConfig } from './types/main.js' 9 | 10 | export function defineConfig>( 11 | config: ManagerConfig 12 | ): ManagerConfig { 13 | return config 14 | } 15 | -------------------------------------------------------------------------------- /src/encoders/json_encoder.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import type { TransportEncoder, TransportMessage } from '../types/main.js' 9 | 10 | export class JsonEncoder implements TransportEncoder { 11 | encode(message: TransportMessage) { 12 | return JSON.stringify(message) 13 | } 14 | 15 | decode(data: string | Buffer) { 16 | return JSON.parse(data.toString()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/message_hasher.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import hash from 'object-hash' 9 | import type { Serializable } from './types/main.js' 10 | 11 | export class MessageHasher { 12 | hash(value: Serializable): string { 13 | return hash(value, { algorithm: 'sha1', encoding: 'base64' }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/retry_queue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { RetryQueueWithDuplicates } from './retry_queue_with_duplicates.js' 9 | import { RetryQueueWithoutDuplicates } from './retry_queue_without_duplicates.js' 10 | import type { TransportMessage, RetryQueueOptions } from './types/main.js' 11 | 12 | export class RetryQueue { 13 | readonly #options: RetryQueueOptions 14 | readonly #queue: RetryQueueWithDuplicates | RetryQueueWithoutDuplicates 15 | 16 | constructor(params: RetryQueueOptions = {}) { 17 | const { enabled = true, maxSize = null, removeDuplicates = true } = params 18 | 19 | this.#options = { enabled, maxSize, removeDuplicates } 20 | 21 | if (removeDuplicates) { 22 | this.#queue = new RetryQueueWithoutDuplicates({ enabled, maxSize }) 23 | return 24 | } 25 | 26 | this.#queue = new RetryQueueWithDuplicates({ enabled, maxSize }) 27 | } 28 | 29 | getOptions() { 30 | return this.#options 31 | } 32 | 33 | getInternalQueue() { 34 | return this.#queue 35 | } 36 | 37 | size() { 38 | return this.#queue.size() 39 | } 40 | 41 | async process(handler: (channel: string, message: TransportMessage) => Promise) { 42 | return this.#queue.process(handler) 43 | } 44 | 45 | enqueue(channel: string, message: TransportMessage) { 46 | return this.#queue.enqueue(channel, message) 47 | } 48 | 49 | dequeue() { 50 | this.#queue.dequeue() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/retry_queue_with_duplicates.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import type { TransportMessage, RetryQueueOptions } from './types/main.js' 9 | 10 | export class RetryQueueWithDuplicates { 11 | #queue = new Set<{ channel: string; message: TransportMessage }>() 12 | 13 | readonly #enabled: boolean 14 | readonly #maxSize: number | null 15 | 16 | constructor(params: RetryQueueOptions = {}) { 17 | const { enabled = true, maxSize = null } = params 18 | 19 | this.#enabled = enabled 20 | this.#maxSize = maxSize 21 | } 22 | 23 | size() { 24 | return this.#queue.size 25 | } 26 | 27 | async process(handler: (channel: string, message: TransportMessage) => Promise) { 28 | if (!this.#enabled) return 29 | 30 | for (const { channel, message } of this.#queue) { 31 | const result = await handler(channel, message).catch(() => false) 32 | 33 | if (!result) { 34 | break 35 | } 36 | 37 | this.dequeue() 38 | } 39 | } 40 | 41 | enqueue(channel: string, message: TransportMessage) { 42 | if (!this.#enabled) return false 43 | 44 | if (this.#maxSize && this.#queue.size >= this.#maxSize) { 45 | this.dequeue() 46 | } 47 | 48 | this.#queue.add({ channel, message }) 49 | 50 | return true 51 | } 52 | 53 | dequeue() { 54 | if (!this.#enabled) return 55 | 56 | const [first] = this.#queue 57 | 58 | if (first) { 59 | this.#queue.delete(first) 60 | 61 | return first.message 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/retry_queue_without_duplicates.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { MessageHasher } from './message_hasher.js' 9 | import type { TransportMessage, RetryQueueOptions } from './types/main.js' 10 | 11 | export class RetryQueueWithoutDuplicates { 12 | #queue = new Map() 13 | #messageHasher: MessageHasher 14 | 15 | readonly #enabled: boolean 16 | readonly #maxSize: number | null 17 | 18 | constructor(params: RetryQueueOptions = {}) { 19 | const { enabled = true, maxSize = null } = params 20 | 21 | this.#enabled = enabled 22 | this.#maxSize = maxSize 23 | this.#messageHasher = new MessageHasher() 24 | } 25 | 26 | #generateMessageHash(message: TransportMessage) { 27 | return this.#messageHasher.hash(message.payload) 28 | } 29 | 30 | size() { 31 | return this.#queue.size 32 | } 33 | 34 | async process(handler: (channel: string, message: TransportMessage) => Promise) { 35 | if (!this.#enabled) return 36 | 37 | for (const { channel, message } of this.#queue.values()) { 38 | const result = await handler(channel, message).catch(() => false) 39 | 40 | if (!result) { 41 | break 42 | } 43 | 44 | this.dequeue() 45 | } 46 | } 47 | 48 | enqueue(channel: string, message: TransportMessage) { 49 | if (!this.#enabled) return false 50 | 51 | if (this.#maxSize && this.#queue.size >= this.#maxSize) { 52 | this.dequeue() 53 | } 54 | 55 | const hash = this.#generateMessageHash(message) 56 | 57 | if (this.#queue.has(hash)) { 58 | return false 59 | } 60 | 61 | this.#queue.set(hash, { channel, message }) 62 | 63 | return true 64 | } 65 | 66 | dequeue() { 67 | if (!this.#enabled) return 68 | 69 | const { message } = this.#queue.values().next().value 70 | 71 | if (message) { 72 | this.#queue.delete(this.#generateMessageHash(message)) 73 | 74 | return message 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/test_helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | export { ChaosTransport } from '../test_helpers/chaos_transport.js' 9 | -------------------------------------------------------------------------------- /src/transports/memory.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import type { Transport, Serializable, SubscribeHandler } from '../types/main.js' 9 | 10 | export function memory() { 11 | return () => new MemoryTransport() 12 | } 13 | 14 | export class MemoryTransport implements Transport { 15 | #id!: string 16 | 17 | /** 18 | * A Map that stores the subscriptions for each channel. 19 | */ 20 | static #subscriptions: Map< 21 | string, 22 | Array<{ 23 | handler: SubscribeHandler 24 | busId: string 25 | }> 26 | > = new Map() 27 | 28 | setId(id: string) { 29 | this.#id = id 30 | 31 | return this 32 | } 33 | 34 | /** 35 | * List of messages received by this bus 36 | */ 37 | receivedMessages: any[] = [] 38 | 39 | async publish(channel: string, message: Serializable) { 40 | const handlers = MemoryTransport.#subscriptions.get(channel) 41 | 42 | if (!handlers) { 43 | return 44 | } 45 | 46 | for (const { handler, busId } of handlers) { 47 | if (busId === this.#id) continue 48 | 49 | handler(message) 50 | } 51 | } 52 | 53 | async subscribe(channel: string, handler: SubscribeHandler) { 54 | const handlers = MemoryTransport.#subscriptions.get(channel) || [] 55 | 56 | handlers.push({ handler: this.#wrapHandler(handler), busId: this.#id }) 57 | 58 | MemoryTransport.#subscriptions.set(channel, handlers) 59 | } 60 | 61 | async unsubscribe(channel: string) { 62 | const handlers = MemoryTransport.#subscriptions.get(channel) || [] 63 | 64 | MemoryTransport.#subscriptions.set( 65 | channel, 66 | handlers.filter((h) => h.busId !== this.#id) 67 | ) 68 | } 69 | 70 | async disconnect() { 71 | MemoryTransport.#subscriptions.clear() 72 | } 73 | 74 | onReconnect(_callback: () => void) {} 75 | 76 | #wrapHandler(handler: SubscribeHandler) { 77 | return (message: any) => { 78 | this.receivedMessages.push(message) 79 | handler(message) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/transports/mqtt.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { connect, MqttClient } from 'mqtt' 9 | import { assert } from '@poppinss/utils/assert' 10 | 11 | import debug from '../debug.js' 12 | import { 13 | Transport, 14 | TransportEncoder, 15 | TransportMessage, 16 | Serializable, 17 | SubscribeHandler, 18 | MqttProtocol, 19 | MqttTransportConfig, 20 | } from '../types/main.js' 21 | import { JsonEncoder } from '../encoders/json_encoder.js' 22 | 23 | export function mqtt(config: MqttTransportConfig, encoder?: TransportEncoder) { 24 | return () => new MqttTransport(config, encoder) 25 | } 26 | 27 | export class MqttTransport implements Transport { 28 | #id: string | undefined 29 | #client: MqttClient 30 | #url: string 31 | readonly #encoder: TransportEncoder 32 | 33 | constructor(config: MqttTransportConfig, encoder?: TransportEncoder) { 34 | this.#encoder = encoder ?? new JsonEncoder() 35 | this.#url = `${config.protocol || MqttProtocol.MQTT}://${config.host}${config.port ? `:${config.port}` : ''}` 36 | 37 | this.#client = connect(this.#url, config.options ?? {}) 38 | } 39 | 40 | setId(id: string): Transport { 41 | this.#id = id 42 | 43 | return this 44 | } 45 | 46 | async disconnect(): Promise { 47 | await this.#client.endAsync() 48 | } 49 | 50 | async publish(channel: string, message: any): Promise { 51 | assert(this.#id, 'You must set an id before publishing a message') 52 | 53 | const encoded = this.#encoder.encode({ payload: message, busId: this.#id }) 54 | 55 | await this.#client.publishAsync(channel, encoded) 56 | } 57 | 58 | async subscribe( 59 | channel: string, 60 | handler: SubscribeHandler 61 | ): Promise { 62 | this.#client.subscribe(channel, (err) => { 63 | if (err) { 64 | throw err 65 | } 66 | }) 67 | 68 | this.#client.on('message', (receivedChannel: string, message: Buffer | string) => { 69 | if (channel !== receivedChannel) return 70 | 71 | debug('received message for channel "%s"', channel) 72 | 73 | const data = this.#encoder.decode>(message) 74 | 75 | /** 76 | * Ignore messages published by this bus instance 77 | */ 78 | if (data.busId === this.#id) { 79 | debug('ignoring message published by the same bus instance') 80 | return 81 | } 82 | 83 | // @ts-expect-error - TODO: Weird typing issue 84 | handler(data.payload) 85 | }) 86 | } 87 | 88 | onReconnect(): void { 89 | this.#client.reconnect() 90 | } 91 | 92 | async unsubscribe(channel: string): Promise { 93 | await this.#client.unsubscribeAsync(channel) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/transports/redis.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { Redis } from 'ioredis' 9 | import { assert } from '@poppinss/utils/assert' 10 | 11 | import debug from '../debug.js' 12 | import { JsonEncoder } from '../encoders/json_encoder.js' 13 | import type { 14 | Transport, 15 | TransportEncoder, 16 | TransportMessage, 17 | Serializable, 18 | SubscribeHandler, 19 | RedisTransportConfig, 20 | } from '../types/main.js' 21 | 22 | export function redis(config: RedisTransportConfig, encoder?: TransportEncoder) { 23 | return () => new RedisTransport(config, encoder) 24 | } 25 | 26 | export class RedisTransport implements Transport { 27 | readonly #publisher: Redis 28 | readonly #subscriber: Redis 29 | readonly #encoder: TransportEncoder 30 | readonly #useMessageBuffer: boolean = false 31 | 32 | #id: string | undefined 33 | 34 | constructor(path: string, encoder?: TransportEncoder) 35 | constructor(options: RedisTransportConfig, encoder?: TransportEncoder) 36 | constructor(options: RedisTransportConfig | string, encoder?: TransportEncoder) { 37 | // @ts-expect-error - merged definitions of overloaded constructor is not public 38 | this.#publisher = new Redis(options) 39 | // @ts-expect-error - merged definitions of overloaded constructor is not public 40 | this.#subscriber = new Redis(options) 41 | this.#encoder = encoder ?? new JsonEncoder() 42 | 43 | if (typeof options === 'object') { 44 | this.#useMessageBuffer = options.useMessageBuffer ?? false 45 | } 46 | } 47 | 48 | setId(id: string): Transport { 49 | this.#id = id 50 | 51 | return this 52 | } 53 | 54 | async disconnect(): Promise { 55 | await Promise.all([this.#publisher.quit(), this.#subscriber.quit()]) 56 | } 57 | 58 | async publish(channel: string, message: Serializable): Promise { 59 | assert(this.#id, 'You must set an id before publishing a message') 60 | 61 | const encoded = this.#encoder.encode({ payload: message, busId: this.#id }) 62 | 63 | await this.#publisher.publish(channel, encoded) 64 | } 65 | 66 | async subscribe( 67 | channel: string, 68 | handler: SubscribeHandler 69 | ): Promise { 70 | this.#subscriber.subscribe(channel, (err) => { 71 | if (err) { 72 | throw err 73 | } 74 | }) 75 | 76 | const event = this.#useMessageBuffer ? 'messageBuffer' : 'message' 77 | this.#subscriber.on(event, (receivedChannel: Buffer | string, message: Buffer | string) => { 78 | receivedChannel = receivedChannel.toString() 79 | 80 | if (channel !== receivedChannel) return 81 | 82 | debug('received message for channel "%s"', channel) 83 | 84 | const data = this.#encoder.decode>(message) 85 | 86 | /** 87 | * Ignore messages published by this bus instance 88 | */ 89 | if (data.busId === this.#id) { 90 | debug('ignoring message published by the same bus instance') 91 | return 92 | } 93 | 94 | // @ts-expect-error - TODO: Weird typing issue 95 | handler(data.payload) 96 | }) 97 | } 98 | 99 | onReconnect(callback: () => void): void { 100 | this.#subscriber.on('reconnecting', callback) 101 | } 102 | 103 | async unsubscribe(channel: string): Promise { 104 | await this.#subscriber.unsubscribe(channel) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/types/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import type { RedisOptions } from 'ioredis' 9 | import type { IClientOptions } from 'mqtt' 10 | export type TransportFactory = () => Transport 11 | 12 | /** 13 | * A Duration can be a number in milliseconds or a string formatted as a duration 14 | * 15 | * Formats accepted are : 16 | * - Simple number in milliseconds 17 | * - String formatted as a duration. Uses https://github.com/lukeed/ms under the hood 18 | */ 19 | export type Duration = number | string 20 | 21 | export interface ManagerConfig> { 22 | default?: keyof KnownTransports 23 | transports: KnownTransports 24 | } 25 | 26 | export interface TransportConfig { 27 | transport: TransportFactory 28 | retryQueue?: RetryQueueOptions 29 | } 30 | 31 | export interface RedisTransportConfig extends RedisOptions { 32 | /** 33 | * If true, we will use `messageBuffer` event instead of `message` event 34 | * that is emitted by ioredis. `messageBuffer` will returns a buffer instead 35 | * of a string and this is useful when you are dealing with binary data. 36 | */ 37 | useMessageBuffer?: boolean 38 | } 39 | 40 | export enum MqttProtocol { 41 | MQTT = 'mqtt', 42 | MQTTS = 'mqtts', 43 | TCP = 'tcp', 44 | TLS = 'tls', 45 | WS = 'ws', 46 | WSS = 'wss', 47 | WXS = 'wxs', 48 | ALIS = 'alis', 49 | } 50 | 51 | export interface MqttTransportConfig { 52 | host: string 53 | port?: number 54 | protocol?: MqttProtocol 55 | options?: IClientOptions 56 | } 57 | 58 | export interface Transport { 59 | setId: (id: string) => Transport 60 | onReconnect: (callback: () => void) => void 61 | publish: (channel: string, message: Serializable) => Promise 62 | subscribe: ( 63 | channel: string, 64 | handler: SubscribeHandler 65 | ) => Promise 66 | unsubscribe: (channel: string) => Promise 67 | disconnect: () => Promise 68 | } 69 | 70 | export interface TransportMessage { 71 | busId: string 72 | payload: T 73 | } 74 | 75 | export interface TransportEncoder { 76 | encode: (message: TransportMessage) => string | Buffer 77 | decode: (data: string | Buffer) => { busId: string; payload: T } 78 | } 79 | 80 | export interface RetryQueueOptions { 81 | enabled?: boolean 82 | removeDuplicates?: boolean 83 | maxSize?: number | null 84 | retryInterval?: Duration | false 85 | } 86 | 87 | export type SubscribeHandler = (payload: T) => void | Promise 88 | 89 | export type Serializable = 90 | | string 91 | | number 92 | | boolean 93 | | null 94 | | Serializable[] 95 | | { [key: string]: Serializable } 96 | -------------------------------------------------------------------------------- /test_helpers/chaos_injector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { setTimeout } from 'node:timers/promises' 9 | 10 | export class ChaosInjector { 11 | /** 12 | * Probability of throwing an error 13 | */ 14 | #throwProbability = 0 15 | 16 | /** 17 | * Minimum delay in milliseconds 18 | */ 19 | #minDelay = 0 20 | 21 | /** 22 | * Maximum delay in milliseconds 23 | */ 24 | #maxDelay = 0 25 | 26 | /** 27 | * Randomly throw an error with the given probability 28 | */ 29 | injectExceptions() { 30 | if (Math.random() < this.#throwProbability) { 31 | throw new Error('Chaos: Random error') 32 | } 33 | } 34 | 35 | /** 36 | * Apply a random delay between minDelay and maxDelay 37 | */ 38 | async injectDelay() { 39 | const delay = this.#minDelay + Math.random() * (this.#maxDelay - this.#minDelay) 40 | await setTimeout(delay) 41 | } 42 | 43 | /** 44 | * Apply some chaos : delay and/or throw an error 45 | */ 46 | async injectChaos() { 47 | await this.injectDelay() 48 | this.injectExceptions() 49 | } 50 | 51 | /** 52 | * Make the cache always throw an error 53 | */ 54 | alwaysThrow() { 55 | this.#throwProbability = 1 56 | return this 57 | } 58 | 59 | /** 60 | * Reset the throw probability to 0 61 | */ 62 | neverThrow() { 63 | this.#throwProbability = 0 64 | return this 65 | } 66 | 67 | /** 68 | * Always apply the given delay 69 | */ 70 | alwaysDelay(minDelay: number, maxDelay: number) { 71 | this.#minDelay = minDelay 72 | this.#maxDelay = maxDelay 73 | return this 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test_helpers/chaos_transport.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { ChaosInjector } from './chaos_injector.js' 9 | import type { Transport, Serializable, SubscribeHandler } from '../src/types/main.js' 10 | 11 | export class ChaosTransport implements Transport { 12 | /** 13 | * The inner transport that is wrapped 14 | */ 15 | readonly #innerTransport: Transport 16 | 17 | /** 18 | * Reference to the chaos injector 19 | */ 20 | #chaosInjector: ChaosInjector 21 | 22 | constructor(innerTransport: Transport) { 23 | this.#innerTransport = innerTransport 24 | this.#chaosInjector = new ChaosInjector() 25 | } 26 | 27 | setId(id: string) { 28 | this.#innerTransport.setId(id) 29 | 30 | return this.#innerTransport 31 | } 32 | 33 | getInnerTransport(): T { 34 | return this.#innerTransport as T 35 | } 36 | 37 | /** 38 | * Make the cache always throw an error 39 | */ 40 | alwaysThrow() { 41 | this.#chaosInjector.alwaysThrow() 42 | return this 43 | } 44 | 45 | /** 46 | * Reset the cache to never throw an error 47 | */ 48 | neverThrow() { 49 | this.#chaosInjector.neverThrow() 50 | return this 51 | } 52 | 53 | async publish(channel: string, message: Serializable) { 54 | await this.#chaosInjector.injectChaos() 55 | return this.#innerTransport.publish(channel, message) 56 | } 57 | 58 | async subscribe(channel: string, handler: SubscribeHandler) { 59 | return this.#innerTransport.subscribe(channel, handler) 60 | } 61 | 62 | unsubscribe(channel: string) { 63 | return this.#innerTransport.unsubscribe(channel) 64 | } 65 | 66 | disconnect() { 67 | return this.#innerTransport.disconnect() 68 | } 69 | 70 | onReconnect(_callback: () => void): void {} 71 | } 72 | -------------------------------------------------------------------------------- /tests/bus.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { setTimeout } from 'node:timers/promises' 9 | import { test } from '@japa/runner' 10 | import { Bus } from '../src/bus.js' 11 | import { MemoryTransport } from '../src/transports/memory.js' 12 | import { ChaosTransport } from '../test_helpers/chaos_transport.js' 13 | 14 | const kTestingChannel = 'testing-channel' 15 | 16 | test.group('Bus', () => { 17 | test('should retry queue processing with an interval', async ({ assert, cleanup }) => { 18 | const transport1 = new ChaosTransport(new MemoryTransport()) 19 | const transport2 = new ChaosTransport(new MemoryTransport()) 20 | 21 | const bus1 = new Bus(transport1, { retryQueue: { retryInterval: '100ms' } }) 22 | const bus2 = new Bus(transport2, { retryQueue: { retryInterval: '100ms' } }) 23 | 24 | cleanup(async () => { 25 | await bus1.disconnect() 26 | await bus2.disconnect() 27 | }) 28 | 29 | transport1.alwaysThrow() 30 | transport2.alwaysThrow() 31 | 32 | let count = 0 33 | 34 | await bus1.subscribe(kTestingChannel, () => { 35 | count++ 36 | }) 37 | 38 | await bus2.publish(kTestingChannel, 'test') 39 | 40 | assert.equal(count, 0) 41 | 42 | transport1.neverThrow() 43 | transport2.neverThrow() 44 | 45 | await setTimeout(200) 46 | 47 | assert.equal(count, 1) 48 | }) 49 | 50 | test('should retry queue processing when asked', async ({ assert, cleanup }) => { 51 | const transport1 = new ChaosTransport(new MemoryTransport()) 52 | const transport2 = new ChaosTransport(new MemoryTransport()) 53 | 54 | const bus1 = new Bus(transport1) 55 | const bus2 = new Bus(transport2) 56 | 57 | cleanup(async () => { 58 | await bus1.disconnect() 59 | await bus2.disconnect() 60 | }) 61 | 62 | transport1.alwaysThrow() 63 | transport2.alwaysThrow() 64 | 65 | let count = 0 66 | 67 | await bus1.subscribe(kTestingChannel, () => { 68 | count++ 69 | }) 70 | 71 | await bus2.publish(kTestingChannel, 'test') 72 | 73 | assert.equal(count, 0) 74 | 75 | transport1.neverThrow() 76 | transport2.neverThrow() 77 | 78 | await bus2.processErrorRetryQueue() 79 | 80 | assert.equal(count, 1) 81 | }) 82 | 83 | test('should not retry when retry queue is disabled', async ({ assert, cleanup }) => { 84 | const transport1 = new ChaosTransport(new MemoryTransport()) 85 | const transport2 = new ChaosTransport(new MemoryTransport()) 86 | 87 | const bus1 = new Bus(transport1, { retryQueue: { enabled: false } }) 88 | const bus2 = new Bus(transport2, { retryQueue: { enabled: false } }) 89 | 90 | cleanup(async () => { 91 | await bus1.disconnect() 92 | await bus2.disconnect() 93 | }) 94 | 95 | transport1.alwaysThrow() 96 | transport2.alwaysThrow() 97 | 98 | let count = 0 99 | 100 | await bus1.subscribe(kTestingChannel, () => { 101 | count++ 102 | }) 103 | 104 | await bus2.publish(kTestingChannel, 'test') 105 | 106 | assert.equal(count, 0) 107 | 108 | transport1.neverThrow() 109 | transport2.neverThrow() 110 | 111 | await setTimeout(200) 112 | 113 | assert.equal(count, 0) 114 | }) 115 | 116 | test('should not remove item from queue if publish failed', async ({ assert, cleanup }) => { 117 | const transport = new ChaosTransport(new MemoryTransport()) 118 | const bus = new Bus(transport, { retryQueue: { enabled: true } }) 119 | 120 | cleanup(async () => { 121 | await bus.disconnect() 122 | }) 123 | 124 | transport.alwaysThrow() 125 | 126 | await bus.publish(kTestingChannel, 'test') 127 | 128 | assert.deepEqual(bus.getRetryQueue().size(), 1) 129 | 130 | await bus.processErrorRetryQueue() 131 | 132 | assert.deepEqual(bus.getRetryQueue().size(), 1) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /tests/bus_manager.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { test } from '@japa/runner' 9 | import { Bus } from '../src/bus.js' 10 | import { BusManager } from '../src/bus_manager.js' 11 | import { MemoryTransport } from '../src/transports/memory.js' 12 | 13 | test.group('Bus Manager', () => { 14 | test('create bus instance from the manager', ({ assert, expectTypeOf }) => { 15 | const manager = new BusManager({ 16 | default: 'memory', 17 | transports: { 18 | memory: { 19 | transport: () => new MemoryTransport(), 20 | }, 21 | }, 22 | }) 23 | 24 | expectTypeOf(manager.use).parameter(0).toEqualTypeOf<'memory' | undefined>() 25 | expectTypeOf(manager.use('memory')).toEqualTypeOf() 26 | 27 | assert.instanceOf(manager.use('memory'), Bus) 28 | }) 29 | 30 | test('cache bus instance', ({ assert, expectTypeOf }) => { 31 | const manager = new BusManager({ 32 | default: 'memory', 33 | transports: { 34 | memory: { 35 | transport: () => new MemoryTransport(), 36 | }, 37 | memory1: { 38 | transport: () => new MemoryTransport(), 39 | }, 40 | }, 41 | }) 42 | 43 | expectTypeOf(manager.use).parameter(0).toEqualTypeOf<'memory' | 'memory1' | undefined>() 44 | expectTypeOf(manager.use('memory')).toEqualTypeOf() 45 | expectTypeOf(manager.use('memory1')).toEqualTypeOf() 46 | 47 | assert.strictEqual(manager.use('memory'), manager.use('memory')) 48 | assert.notStrictEqual(manager.use('memory'), manager.use('memory1')) 49 | }) 50 | 51 | test('use default bus', ({ assert }) => { 52 | const manager = new BusManager({ 53 | default: 'memory', 54 | transports: { 55 | memory: { 56 | transport: () => new MemoryTransport(), 57 | }, 58 | }, 59 | }) 60 | 61 | assert.strictEqual(manager.use(), manager.use('memory')) 62 | }) 63 | 64 | test('fail when default transport is missing', ({ assert }) => { 65 | const manager = new BusManager({ 66 | transports: { 67 | memory: { 68 | transport: () => new MemoryTransport(), 69 | }, 70 | }, 71 | }) 72 | 73 | assert.throws( 74 | () => manager.use(), 75 | 'Cannot create bus instance. No default transport is defined in the config' 76 | ) 77 | }) 78 | 79 | test('pass retry queue options to the bus instance', ({ assert }) => { 80 | const manager = new BusManager({ 81 | default: 'memory', 82 | transports: { 83 | memory: { 84 | transport: () => new MemoryTransport(), 85 | retryQueue: { 86 | enabled: false, 87 | maxSize: 100, 88 | }, 89 | }, 90 | }, 91 | }) 92 | 93 | const bus = manager.use('memory') 94 | 95 | assert.deepEqual(bus.getRetryQueue().getOptions(), { 96 | enabled: false, 97 | maxSize: 100, 98 | removeDuplicates: true, 99 | }) 100 | }) 101 | 102 | test('publish message using default transport', async ({ assert }) => { 103 | const manager = new BusManager({ 104 | default: 'memory1', 105 | transports: { 106 | memory1: { 107 | transport: () => new MemoryTransport(), 108 | }, 109 | memory2: { 110 | transport: () => new MemoryTransport(), 111 | }, 112 | }, 113 | }) 114 | 115 | let count = 0 116 | 117 | await manager.use('memory2').subscribe('testing-channel', () => { 118 | count++ 119 | }) 120 | 121 | await manager.publish('testing-channel', 'test') 122 | 123 | assert.equal(count, 1) 124 | }) 125 | 126 | test('subscribe message using default transport', async ({ assert }) => { 127 | const manager = new BusManager({ 128 | default: 'memory1', 129 | transports: { 130 | memory1: { 131 | transport: () => new MemoryTransport(), 132 | }, 133 | memory2: { 134 | transport: () => new MemoryTransport(), 135 | }, 136 | }, 137 | }) 138 | 139 | let count = 0 140 | 141 | await manager.subscribe('testing-channel', () => { 142 | count++ 143 | }) 144 | 145 | await manager.use('memory2').publish('testing-channel', 'test') 146 | 147 | assert.equal(count, 1) 148 | }) 149 | 150 | test('unsubscribe message using default transport', async ({ assert }) => { 151 | const manager = new BusManager({ 152 | default: 'memory1', 153 | transports: { 154 | memory1: { 155 | transport: () => new MemoryTransport(), 156 | }, 157 | memory2: { 158 | transport: () => new MemoryTransport(), 159 | }, 160 | }, 161 | }) 162 | 163 | let count = 0 164 | 165 | await manager.subscribe('testing-channel', () => { 166 | count++ 167 | }) 168 | 169 | await manager.unsubscribe('testing-channel') 170 | 171 | await manager.use('memory2').publish('testing-channel', 'test') 172 | 173 | assert.equal(count, 0) 174 | }) 175 | }) 176 | -------------------------------------------------------------------------------- /tests/drivers/memory_transport.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { setTimeout } from 'node:timers/promises' 9 | import { test } from '@japa/runner' 10 | import { MemoryTransport } from '../../src/transports/memory.js' 11 | 12 | test.group('Memory Transport', () => { 13 | test('transport should not receive message emitted by itself', async ({ assert, cleanup }) => { 14 | const transport = new MemoryTransport().setId('transport') 15 | cleanup(() => transport.disconnect()) 16 | 17 | await transport.subscribe('testing-channel', () => { 18 | assert.fail('Bus should not receive message emitted by itself') 19 | }) 20 | 21 | await transport.publish('testing-channel', 'test') 22 | await setTimeout(1000) 23 | }).disableTimeout() 24 | 25 | test('transport should receive message emitted by another bus', async ({ 26 | assert, 27 | cleanup, 28 | }, done) => { 29 | const transport1 = new MemoryTransport().setId('transport1') 30 | const transport2 = new MemoryTransport().setId('transport2') 31 | 32 | cleanup(async () => { 33 | await transport1.disconnect() 34 | await transport2.disconnect() 35 | }) 36 | 37 | await transport1.subscribe('testing-channel', (payload) => { 38 | assert.equal(payload, 'test') 39 | done() 40 | }) 41 | 42 | await transport2.publish('testing-channel', 'test') 43 | }).waitForDone() 44 | }) 45 | -------------------------------------------------------------------------------- /tests/drivers/mqtt_transport.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { setTimeout } from 'node:timers/promises' 9 | import { test } from '@japa/runner' 10 | import { HiveMQContainer, StartedHiveMQContainer } from '@testcontainers/hivemq' 11 | import { GenericContainer, StartedTestContainer } from 'testcontainers' 12 | import { MqttTransport } from '../../src/transports/mqtt.js' 13 | import { JsonEncoder } from '../../src/encoders/json_encoder.js' 14 | import { TransportEncoder, TransportMessage } from '../../src/types/main.js' 15 | 16 | test.group('Mqtt Transport', (group) => { 17 | let hiveMqContainer: StartedHiveMQContainer 18 | let emqxContainer: StartedTestContainer 19 | let mosquittoContainer: StartedTestContainer 20 | 21 | group.setup(async () => { 22 | hiveMqContainer = await new HiveMQContainer() 23 | .withExposedPorts({ 24 | container: 1883, 25 | host: 1884, 26 | }) 27 | .start() 28 | emqxContainer = await new GenericContainer('emqx/emqx').withExposedPorts(1883).start() 29 | mosquittoContainer = await new GenericContainer('eclipse-mosquitto') 30 | .withExposedPorts({ 31 | container: 1883, 32 | host: 1885, 33 | }) 34 | .withCopyFilesToContainer([ 35 | { 36 | source: './config/mosquitto.conf', 37 | target: '/mosquitto/config/mosquitto.conf', 38 | }, 39 | ]) 40 | .start() 41 | 42 | return async () => { 43 | await hiveMqContainer.stop() 44 | await emqxContainer.stop() 45 | await mosquittoContainer.stop() 46 | } 47 | }) 48 | 49 | test('HiveMQ transport should not receive message emitted by itself', async ({ 50 | assert, 51 | cleanup, 52 | }) => { 53 | const transport = new MqttTransport({ 54 | host: hiveMqContainer.getHost(), 55 | port: hiveMqContainer.getPort(), 56 | }).setId('bus') 57 | cleanup(() => transport.disconnect()) 58 | 59 | await transport.subscribe('testing-channel', () => { 60 | assert.fail('Bus should not receive message emitted by itself') 61 | }) 62 | 63 | await transport.publish('testing-channel', 'test') 64 | await setTimeout(1000) 65 | }).disableTimeout() 66 | 67 | test('HiveMQ transport should receive message emitted by another bus', async ({ 68 | assert, 69 | cleanup, 70 | }, done) => { 71 | assert.plan(1) 72 | 73 | const transport1 = new MqttTransport({ 74 | host: hiveMqContainer.getHost(), 75 | port: hiveMqContainer.getPort(), 76 | }).setId('bus1') 77 | const transport2 = new MqttTransport({ 78 | host: hiveMqContainer.getHost(), 79 | port: hiveMqContainer.getPort(), 80 | }).setId('bus2') 81 | 82 | cleanup(async () => { 83 | await transport1.disconnect() 84 | await transport2.disconnect() 85 | }) 86 | 87 | await transport1.subscribe('testing-channel', (payload) => { 88 | assert.equal(payload, 'test') 89 | done() 90 | }) 91 | 92 | await setTimeout(200) 93 | 94 | await transport2.publish('testing-channel', 'test') 95 | }).waitForDone() 96 | 97 | test('HiveMQ message should be encoded and decoded correctly when using JSON encoder', async ({ 98 | assert, 99 | cleanup, 100 | }, done) => { 101 | assert.plan(1) 102 | const transport1 = new MqttTransport( 103 | { 104 | host: hiveMqContainer.getHost(), 105 | port: hiveMqContainer.getPort(), 106 | }, 107 | new JsonEncoder() 108 | ).setId('bus1') 109 | const transport2 = new MqttTransport( 110 | { 111 | host: hiveMqContainer.getHost(), 112 | port: hiveMqContainer.getPort(), 113 | }, 114 | new JsonEncoder() 115 | ).setId('bus2') 116 | 117 | cleanup(async () => { 118 | await transport1.disconnect() 119 | await transport2.disconnect() 120 | }) 121 | 122 | const data = { test: 'test' } 123 | 124 | await transport1.subscribe('testing-channel', (payload) => { 125 | assert.deepEqual(payload, data) 126 | done() 127 | }) 128 | 129 | await setTimeout(200) 130 | 131 | await transport2.publish('testing-channel', data) 132 | }).waitForDone() 133 | 134 | test('HiveMQ send binary data', async ({ assert, cleanup }, done) => { 135 | assert.plan(1) 136 | 137 | class BinaryEncoder implements TransportEncoder { 138 | encode(message: TransportMessage) { 139 | return Buffer.from(JSON.stringify(message)) 140 | } 141 | 142 | decode(data: string | Buffer) { 143 | const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'binary') 144 | return JSON.parse(buffer.toString()) 145 | } 146 | } 147 | 148 | const transport1 = new MqttTransport( 149 | { host: hiveMqContainer.getHost(), port: hiveMqContainer.getPort() }, 150 | new BinaryEncoder() 151 | ).setId('bus1') 152 | 153 | const transport2 = new MqttTransport( 154 | { host: hiveMqContainer.getHost(), port: hiveMqContainer.getPort() }, 155 | new BinaryEncoder() 156 | ).setId('bus2') 157 | 158 | cleanup(() => { 159 | transport1.disconnect() 160 | transport2.disconnect() 161 | }) 162 | 163 | const data = ['foo', '👍'] 164 | 165 | await transport1.subscribe('testing-channel', (payload) => { 166 | assert.deepEqual(payload, data) 167 | done() 168 | }) 169 | 170 | await setTimeout(200) 171 | await transport2.publish('testing-channel', data) 172 | }).waitForDone() 173 | 174 | test('EMQX transport should not receive message emitted by itself', async ({ 175 | assert, 176 | cleanup, 177 | }) => { 178 | const transport = new MqttTransport({ 179 | host: emqxContainer.getHost(), 180 | port: emqxContainer.getMappedPort(1883), 181 | }).setId('bus') 182 | cleanup(() => transport.disconnect()) 183 | 184 | await transport.subscribe('testing-channel', () => { 185 | assert.fail('Bus should not receive message emitted by itself') 186 | }) 187 | 188 | await transport.publish('testing-channel', 'test') 189 | await setTimeout(1000) 190 | }).disableTimeout() 191 | 192 | test('EMQX transport should receive message emitted by another bus', async ({ 193 | assert, 194 | cleanup, 195 | }, done) => { 196 | assert.plan(1) 197 | 198 | const transport1 = new MqttTransport({ 199 | host: emqxContainer.getHost(), 200 | port: emqxContainer.getMappedPort(1883), 201 | }).setId('bus1') 202 | const transport2 = new MqttTransport({ 203 | host: emqxContainer.getHost(), 204 | port: emqxContainer.getMappedPort(1883), 205 | }).setId('bus2') 206 | 207 | cleanup(async () => { 208 | await transport1.disconnect() 209 | await transport2.disconnect() 210 | }) 211 | 212 | await transport1.subscribe('testing-channel', (payload) => { 213 | assert.equal(payload, 'test') 214 | done() 215 | }) 216 | 217 | await setTimeout(200) 218 | 219 | await transport2.publish('testing-channel', 'test') 220 | }).waitForDone() 221 | 222 | test('EMQX message should be encoded and decoded correctly when using JSON encoder', async ({ 223 | assert, 224 | cleanup, 225 | }, done) => { 226 | assert.plan(1) 227 | const transport1 = new MqttTransport( 228 | { 229 | host: emqxContainer.getHost(), 230 | port: emqxContainer.getMappedPort(1883), 231 | }, 232 | new JsonEncoder() 233 | ).setId('bus1') 234 | const transport2 = new MqttTransport( 235 | { 236 | host: emqxContainer.getHost(), 237 | port: emqxContainer.getMappedPort(1883), 238 | }, 239 | new JsonEncoder() 240 | ).setId('bus2') 241 | cleanup(async () => { 242 | await transport1.disconnect() 243 | await transport2.disconnect() 244 | }) 245 | 246 | const data = { test: 'test' } 247 | 248 | await transport1.subscribe('testing-channel', (payload) => { 249 | assert.deepEqual(payload, data) 250 | done() 251 | }) 252 | 253 | await setTimeout(200) 254 | 255 | await transport2.publish('testing-channel', data) 256 | }).waitForDone() 257 | 258 | test('EMQX send binary data', async ({ assert, cleanup }, done) => { 259 | assert.plan(1) 260 | 261 | class BinaryEncoder implements TransportEncoder { 262 | encode(message: TransportMessage) { 263 | return Buffer.from(JSON.stringify(message)) 264 | } 265 | 266 | decode(data: string | Buffer) { 267 | const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'binary') 268 | return JSON.parse(buffer.toString()) 269 | } 270 | } 271 | 272 | const transport1 = new MqttTransport( 273 | { host: emqxContainer.getHost(), port: emqxContainer.getMappedPort(1883) }, 274 | new BinaryEncoder() 275 | ).setId('bus1') 276 | 277 | const transport2 = new MqttTransport( 278 | { host: emqxContainer.getHost(), port: emqxContainer.getMappedPort(1883) }, 279 | new BinaryEncoder() 280 | ).setId('bus2') 281 | 282 | cleanup(() => { 283 | transport1.disconnect() 284 | transport2.disconnect() 285 | }) 286 | 287 | const data = ['foo', '👍'] 288 | 289 | await transport1.subscribe('testing-channel', (payload) => { 290 | assert.deepEqual(payload, data) 291 | done() 292 | }) 293 | 294 | await setTimeout(200) 295 | await transport2.publish('testing-channel', data) 296 | }).waitForDone() 297 | 298 | test('Mosquitto transport should not receive message emitted by itself', async ({ 299 | assert, 300 | cleanup, 301 | }) => { 302 | const transport = new MqttTransport({ 303 | host: mosquittoContainer.getHost(), 304 | port: mosquittoContainer.getMappedPort(1883), 305 | }).setId('bus') 306 | cleanup(() => transport.disconnect()) 307 | 308 | await transport.subscribe('testing-channel', () => { 309 | assert.fail('Bus should not receive message emitted by itself') 310 | }) 311 | 312 | await transport.publish('testing-channel', 'test') 313 | await setTimeout(1000) 314 | }).disableTimeout() 315 | 316 | test('Mosquitto transport should receive message emitted by another bus', async ({ 317 | assert, 318 | cleanup, 319 | }, done) => { 320 | assert.plan(1) 321 | 322 | const transport1 = new MqttTransport({ 323 | host: mosquittoContainer.getHost(), 324 | port: mosquittoContainer.getMappedPort(1883), 325 | }).setId('bus1') 326 | const transport2 = new MqttTransport({ 327 | host: mosquittoContainer.getHost(), 328 | port: mosquittoContainer.getMappedPort(1883), 329 | }).setId('bus2') 330 | 331 | cleanup(async () => { 332 | await transport1.disconnect() 333 | await transport2.disconnect() 334 | }) 335 | 336 | await transport1.subscribe('testing-channel', (payload) => { 337 | assert.equal(payload, 'test') 338 | done() 339 | }) 340 | 341 | await setTimeout(200) 342 | 343 | await transport2.publish('testing-channel', 'test') 344 | }).waitForDone() 345 | 346 | test('Mosquitto message should be encoded and decoded correctly when using JSON encoder', async ({ 347 | assert, 348 | cleanup, 349 | }, done) => { 350 | assert.plan(1) 351 | const transport1 = new MqttTransport( 352 | { 353 | host: mosquittoContainer.getHost(), 354 | port: mosquittoContainer.getMappedPort(1883), 355 | }, 356 | new JsonEncoder() 357 | ).setId('bus1') 358 | const transport2 = new MqttTransport( 359 | { 360 | host: mosquittoContainer.getHost(), 361 | port: mosquittoContainer.getMappedPort(1883), 362 | }, 363 | new JsonEncoder() 364 | ).setId('bus2') 365 | cleanup(async () => { 366 | await transport1.disconnect() 367 | await transport2.disconnect() 368 | }) 369 | 370 | const data = { test: 'test' } 371 | 372 | await transport1.subscribe('testing-channel', (payload) => { 373 | assert.deepEqual(payload, data) 374 | done() 375 | }) 376 | 377 | await setTimeout(200) 378 | 379 | await transport2.publish('testing-channel', data) 380 | }).waitForDone() 381 | 382 | test('Mosquitto send binary data', async ({ assert, cleanup }, done) => { 383 | assert.plan(1) 384 | 385 | class BinaryEncoder implements TransportEncoder { 386 | encode(message: TransportMessage) { 387 | return Buffer.from(JSON.stringify(message)) 388 | } 389 | 390 | decode(data: string | Buffer) { 391 | const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'binary') 392 | return JSON.parse(buffer.toString()) 393 | } 394 | } 395 | 396 | const transport1 = new MqttTransport( 397 | { host: mosquittoContainer.getHost(), port: mosquittoContainer.getMappedPort(1883) }, 398 | new BinaryEncoder() 399 | ).setId('bus1') 400 | 401 | const transport2 = new MqttTransport( 402 | { host: mosquittoContainer.getHost(), port: mosquittoContainer.getMappedPort(1883) }, 403 | new BinaryEncoder() 404 | ).setId('bus2') 405 | 406 | cleanup(() => { 407 | transport1.disconnect() 408 | transport2.disconnect() 409 | }) 410 | 411 | const data = ['foo', '👍'] 412 | 413 | await transport1.subscribe('testing-channel', (payload) => { 414 | assert.deepEqual(payload, data) 415 | done() 416 | }) 417 | 418 | await setTimeout(200) 419 | await transport2.publish('testing-channel', data) 420 | }).waitForDone() 421 | }) 422 | -------------------------------------------------------------------------------- /tests/drivers/redis_transport.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { setTimeout } from 'node:timers/promises' 9 | import { test } from '@japa/runner' 10 | import { RedisContainer, StartedRedisContainer } from '@testcontainers/redis' 11 | import { RedisTransport } from '../../src/transports/redis.js' 12 | import { JsonEncoder } from '../../src/encoders/json_encoder.js' 13 | import { TransportEncoder, TransportMessage } from '../../src/types/main.js' 14 | 15 | test.group('Redis Transport', (group) => { 16 | let container: StartedRedisContainer 17 | 18 | group.setup(async () => { 19 | container = await new RedisContainer().start() 20 | 21 | return async () => { 22 | await container.stop() 23 | } 24 | }) 25 | 26 | test('transport should not receive message emitted by itself', async ({ assert, cleanup }) => { 27 | const transport = new RedisTransport(container.getConnectionUrl()).setId('bus') 28 | cleanup(() => transport.disconnect()) 29 | 30 | await transport.subscribe('testing-channel', () => { 31 | assert.fail('Bus should not receive message emitted by itself') 32 | }) 33 | 34 | await transport.publish('testing-channel', 'test') 35 | await setTimeout(1000) 36 | }).disableTimeout() 37 | 38 | test('transport should receive message emitted by another bus', async ({ 39 | assert, 40 | cleanup, 41 | }, done) => { 42 | assert.plan(1) 43 | 44 | const transport1 = new RedisTransport(container.getConnectionUrl()).setId('bus1') 45 | const transport2 = new RedisTransport(container.getConnectionUrl()).setId('bus2') 46 | 47 | cleanup(async () => { 48 | await transport1.disconnect() 49 | await transport2.disconnect() 50 | }) 51 | 52 | await transport1.subscribe('testing-channel', (payload) => { 53 | assert.equal(payload, 'test') 54 | done() 55 | }) 56 | 57 | await setTimeout(200) 58 | 59 | await transport2.publish('testing-channel', 'test') 60 | }).waitForDone() 61 | 62 | test('transport should trigger onReconnect when the client reconnects', async ({ 63 | assert, 64 | cleanup, 65 | }) => { 66 | const transport = new RedisTransport(container.getConnectionUrl()).setId('bus') 67 | cleanup(() => transport.disconnect()) 68 | 69 | let onReconnectTriggered = false 70 | transport.onReconnect(() => { 71 | onReconnectTriggered = true 72 | }) 73 | 74 | await container.restart() 75 | await setTimeout(200) 76 | 77 | assert.isTrue(onReconnectTriggered) 78 | }) 79 | 80 | test('message should be encoded and decoded correctly when using JSON encoder', async ({ 81 | assert, 82 | cleanup, 83 | }, done) => { 84 | assert.plan(1) 85 | 86 | const transport1 = new RedisTransport(container.getConnectionUrl(), new JsonEncoder()).setId( 87 | 'bus1' 88 | ) 89 | const transport2 = new RedisTransport(container.getConnectionUrl(), new JsonEncoder()).setId( 90 | 'bus2' 91 | ) 92 | 93 | cleanup(async () => { 94 | await transport1.disconnect() 95 | await transport2.disconnect() 96 | }) 97 | 98 | const data = { test: 'test' } 99 | 100 | await transport1.subscribe('testing-channel', (payload) => { 101 | assert.deepEqual(payload, data) 102 | done() 103 | }) 104 | 105 | await setTimeout(200) 106 | 107 | await transport2.publish('testing-channel', data) 108 | }).waitForDone() 109 | 110 | test('send binary data using useMessageBuffer', async ({ assert, cleanup }, done) => { 111 | assert.plan(1) 112 | 113 | class BinaryEncoder implements TransportEncoder { 114 | encode(message: TransportMessage) { 115 | return Buffer.from(JSON.stringify(message)) 116 | } 117 | 118 | decode(data: string | Buffer) { 119 | const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'binary') 120 | return JSON.parse(buffer.toString()) 121 | } 122 | } 123 | 124 | const transport1 = new RedisTransport( 125 | { host: container.getHost(), port: container.getMappedPort(6379), useMessageBuffer: true }, 126 | new BinaryEncoder() 127 | ).setId('bus1') 128 | 129 | const transport2 = new RedisTransport( 130 | { host: container.getHost(), port: container.getMappedPort(6379), useMessageBuffer: true }, 131 | new BinaryEncoder() 132 | ).setId('bus2') 133 | 134 | cleanup(() => { 135 | transport1.disconnect() 136 | transport2.disconnect() 137 | }) 138 | 139 | const data = ['foo', '👍'] 140 | 141 | await transport1.subscribe('testing-channel', (payload) => { 142 | assert.deepEqual(payload, data) 143 | done() 144 | }) 145 | 146 | await setTimeout(200) 147 | await transport2.publish('testing-channel', data) 148 | }).waitForDone() 149 | }) 150 | -------------------------------------------------------------------------------- /tests/encoders/json_encoder.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { test } from '@japa/runner' 9 | import { JsonEncoder } from '../../src/encoders/json_encoder.js' 10 | 11 | test.group('JSON Encoder', () => { 12 | test('json encoder should encode and decode correctly', async ({ assert }) => { 13 | const data = { busId: 'bus', payload: 'test' } 14 | const encoder = new JsonEncoder() 15 | 16 | const encodedData = encoder.encode(data) 17 | const decodedData = encoder.decode(encodedData) 18 | 19 | assert.deepEqual(data, decodedData) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/message_hasher.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { test } from '@japa/runner' 9 | import { MessageHasher } from '../src/message_hasher.js' 10 | 11 | test.group('Message hasher', () => { 12 | test('should hash message', ({ assert }) => { 13 | const hasher = new MessageHasher() 14 | 15 | assert.equal(hasher.hash({ foo: 'bar' }), hasher.hash({ foo: 'bar' })) 16 | assert.notEqual(hasher.hash({ foo: 'bar' }), hasher.hash({ foo: 'baz' })) 17 | }) 18 | 19 | test('should hash message with different order', ({ assert }) => { 20 | const hasher = new MessageHasher() 21 | const hash1 = hasher.hash({ foo: 'bar', baz: 'qux' }) 22 | const hash2 = hasher.hash({ baz: 'qux', foo: 'bar' }) 23 | 24 | assert.equal(hash1, hash2) 25 | }) 26 | 27 | test('should hash message with different order (nested)', ({ assert }) => { 28 | const hasher = new MessageHasher() 29 | const hash1 = hasher.hash({ foo: 'bar', baz: ['qux', 'quux'] }) 30 | const hash2 = hasher.hash({ baz: ['qux', 'quux'], foo: 'bar' }) 31 | 32 | assert.equal(hash1, hash2) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /tests/retry_queue.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @boringnode/bus 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { test } from '@japa/runner' 9 | import { RetryQueue } from '../src/retry_queue.js' 10 | import { RetryQueueWithoutDuplicates } from '../src/retry_queue_without_duplicates.js' 11 | import { RetryQueueWithDuplicates } from '../src/retry_queue_with_duplicates.js' 12 | 13 | const channel = 'testing' 14 | 15 | test.group('RetryQueue', () => { 16 | test('should create a queue without duplicates', ({ assert }) => { 17 | const queue = new RetryQueue({ removeDuplicates: true }) 18 | 19 | assert.instanceOf(queue.getInternalQueue(), RetryQueueWithoutDuplicates) 20 | }) 21 | 22 | test('should create a queue with duplicates', ({ assert }) => { 23 | const queue = new RetryQueue({ removeDuplicates: false }) 24 | 25 | assert.instanceOf(queue.getInternalQueue(), RetryQueueWithDuplicates) 26 | }) 27 | }) 28 | 29 | test.group('RetryQueueWithDuplicates', () => { 30 | test('does insert duplicates', ({ assert }) => { 31 | const queue = new RetryQueueWithDuplicates() 32 | 33 | const firstEnqueueResult = queue.enqueue(channel, { busId: 'testing', payload: 'foo' }) 34 | const firstQueueSizeSnapshot = queue.size() 35 | assert.equal(firstEnqueueResult, true) 36 | 37 | const secondEnqueueResult = queue.enqueue(channel, { busId: 'testing', payload: 'foo' }) 38 | const secondQueueSizeSnapshot = queue.size() 39 | assert.equal(secondEnqueueResult, true) 40 | 41 | assert.equal(firstQueueSizeSnapshot, 1) 42 | assert.equal(secondQueueSizeSnapshot, 2) 43 | }) 44 | 45 | test('should enqueue multiple messages', ({ assert }) => { 46 | const queue = new RetryQueueWithDuplicates() 47 | 48 | queue.enqueue(channel, { busId: 'testing', payload: 'foo' }) 49 | const firstQueueSizeSnapshot = queue.size() 50 | 51 | queue.enqueue(channel, { busId: 'testing', payload: 'bar' }) 52 | const secondQueueSizeSnapshot = queue.size() 53 | 54 | assert.equal(firstQueueSizeSnapshot, 1) 55 | assert.equal(secondQueueSizeSnapshot, 2) 56 | }) 57 | 58 | test('should remove first inserted message if max size is reached', ({ assert }) => { 59 | const queue = new RetryQueueWithDuplicates({ maxSize: 5 }) 60 | 61 | for (let i = 0; i < 5; i++) { 62 | queue.enqueue(channel, { busId: 'testing', payload: i }) 63 | } 64 | 65 | const firstQueueSizeSnapshot = queue.size() 66 | 67 | queue.enqueue(channel, { busId: 'testing', payload: 5 }) 68 | const secondQueueSizeSnapshot = queue.size() 69 | 70 | assert.equal(firstQueueSizeSnapshot, 5) 71 | assert.equal(secondQueueSizeSnapshot, 5) 72 | 73 | const queuedItems = [] 74 | while (queue.size() > 0) queuedItems.push(queue.dequeue()) 75 | 76 | assert.deepEqual( 77 | queuedItems.map((i) => i!.payload), 78 | [1, 2, 3, 4, 5] 79 | ) 80 | }) 81 | 82 | test('should call handler for each message', async ({ assert }) => { 83 | const queue = new RetryQueueWithDuplicates() 84 | 85 | for (let i = 0; i < 5; i++) { 86 | queue.enqueue(channel, { busId: 'testing', payload: i }) 87 | } 88 | 89 | let count = 0 90 | await queue.process(async (_channel, message) => { 91 | assert.equal(message.payload, count++) 92 | return true 93 | }) 94 | 95 | assert.equal(count, 5) 96 | }) 97 | 98 | test('should stop processing and re-add message to the queue if handler returns false', async ({ 99 | assert, 100 | }) => { 101 | const queue = new RetryQueueWithDuplicates() 102 | 103 | for (let i = 0; i < 5; i++) { 104 | queue.enqueue(channel, { busId: 'testing', payload: i }) 105 | } 106 | 107 | let count = 0 108 | await queue.process(async () => { 109 | return ++count !== 3 110 | }) 111 | 112 | assert.equal(count, 3) 113 | assert.equal(queue.size(), 3) 114 | }) 115 | 116 | test('should stop processing and re-add message to the queue if handler throws an error', async ({ 117 | assert, 118 | }) => { 119 | const queue = new RetryQueueWithDuplicates() 120 | 121 | for (let i = 0; i < 5; i++) { 122 | queue.enqueue(channel, { busId: 'testing', payload: i }) 123 | } 124 | 125 | let count = 0 126 | await queue.process(async () => { 127 | if (++count === 3) throw new Error('test') 128 | return true 129 | }) 130 | 131 | assert.equal(count, 3) 132 | assert.equal(queue.size(), 3) 133 | }) 134 | }) 135 | 136 | test.group('RetryQueueWithoutDuplicates', () => { 137 | test('does not insert duplicates', ({ assert }) => { 138 | const queue = new RetryQueueWithoutDuplicates() 139 | 140 | const firstEnqueueResult = queue.enqueue(channel, { busId: 'testing', payload: 'foo' }) 141 | const firstQueueSizeSnapshot = queue.size() 142 | assert.equal(firstEnqueueResult, true) 143 | 144 | const secondEnqueueResult = queue.enqueue(channel, { busId: 'testing', payload: 'foo' }) 145 | const secondQueueSizeSnapshot = queue.size() 146 | assert.equal(secondEnqueueResult, false) 147 | 148 | assert.equal(firstQueueSizeSnapshot, 1) 149 | assert.equal(secondQueueSizeSnapshot, 1) 150 | }) 151 | 152 | test('does not insert duplicates with same payload but different order', ({ assert }) => { 153 | const queue = new RetryQueueWithoutDuplicates() 154 | 155 | const firstEnqueueResult = queue.enqueue(channel, { 156 | busId: 'testing', 157 | payload: { test: 'foo', test2: 'bar' }, 158 | }) 159 | const firstQueueSizeSnapshot = queue.size() 160 | assert.equal(firstEnqueueResult, true) 161 | 162 | const secondEnqueueResult = queue.enqueue(channel, { 163 | busId: 'testing', 164 | payload: { test2: 'bar', test: 'foo' }, 165 | }) 166 | const secondQueueSizeSnapshot = queue.size() 167 | assert.equal(secondEnqueueResult, false) 168 | 169 | assert.equal(firstQueueSizeSnapshot, 1) 170 | assert.equal(secondQueueSizeSnapshot, 1) 171 | }) 172 | 173 | test('should enqueue multiple messages', ({ assert }) => { 174 | const queue = new RetryQueueWithoutDuplicates() 175 | 176 | queue.enqueue(channel, { busId: 'testing', payload: 'foo' }) 177 | const firstQueueSizeSnapshot = queue.size() 178 | 179 | queue.enqueue(channel, { busId: 'testing', payload: 'bar' }) 180 | const secondQueueSizeSnapshot = queue.size() 181 | 182 | assert.equal(firstQueueSizeSnapshot, 1) 183 | assert.equal(secondQueueSizeSnapshot, 2) 184 | }) 185 | 186 | test('should remove first inserted message if max size is reached', ({ assert }) => { 187 | const queue = new RetryQueueWithoutDuplicates({ maxSize: 5 }) 188 | 189 | for (let i = 0; i < 5; i++) { 190 | queue.enqueue(channel, { busId: 'testing', payload: i }) 191 | } 192 | 193 | const firstQueueSizeSnapshot = queue.size() 194 | 195 | queue.enqueue(channel, { busId: 'testing', payload: 5 }) 196 | const secondQueueSizeSnapshot = queue.size() 197 | 198 | assert.equal(firstQueueSizeSnapshot, 5) 199 | assert.equal(secondQueueSizeSnapshot, 5) 200 | 201 | const queuedItems = [] 202 | while (queue.size() > 0) queuedItems.push(queue.dequeue()) 203 | 204 | assert.deepEqual( 205 | queuedItems.map((i) => i.payload), 206 | [1, 2, 3, 4, 5] 207 | ) 208 | }) 209 | 210 | test('should call handler for each message', async ({ assert }) => { 211 | const queue = new RetryQueueWithoutDuplicates() 212 | 213 | for (let i = 0; i < 5; i++) { 214 | queue.enqueue(channel, { busId: 'testing', payload: i }) 215 | } 216 | 217 | let count = 0 218 | await queue.process(async (_channel, message) => { 219 | assert.equal(message.payload, count++) 220 | return true 221 | }) 222 | 223 | assert.equal(count, 5) 224 | }) 225 | 226 | test('should stop processing and re-add message to the queue if handler returns false', async ({ 227 | assert, 228 | }) => { 229 | const queue = new RetryQueueWithoutDuplicates() 230 | 231 | for (let i = 0; i < 5; i++) { 232 | queue.enqueue(channel, { busId: 'testing', payload: i }) 233 | } 234 | 235 | let count = 0 236 | await queue.process(async () => { 237 | return ++count !== 3 238 | }) 239 | 240 | assert.equal(count, 3) 241 | assert.equal(queue.size(), 3) 242 | }) 243 | 244 | test('should stop processing and re-add message to the queue if handler throws an error', async ({ 245 | assert, 246 | }) => { 247 | const queue = new RetryQueueWithoutDuplicates() 248 | 249 | for (let i = 0; i < 5; i++) { 250 | queue.enqueue(channel, { busId: 'testing', payload: i }) 251 | } 252 | 253 | let count = 0 254 | await queue.process(async () => { 255 | if (++count === 3) throw new Error('test') 256 | return true 257 | }) 258 | 259 | assert.equal(count, 3) 260 | assert.equal(queue.size(), 3) 261 | }) 262 | }) 263 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['./index.ts', './src/types/*.ts', './src/transports/*.ts', './src/test_helpers.ts'], 5 | outDir: './build', 6 | clean: true, 7 | format: 'esm', 8 | dts: true, 9 | sourcemap: true, 10 | target: 'esnext', 11 | }) 12 | --------------------------------------------------------------------------------