├── .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 |

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 |
--------------------------------------------------------------------------------