pax_global_header00006660000000000000000000000064152677405600014526gustar00rootroot0000000000000052 comment=a8c84b7a7dccbd0863c03416446dbf0320275d8d linagora-rabbitmq-client-a8c84b7/000077500000000000000000000000001526774056000170175ustar00rootroot00000000000000linagora-rabbitmq-client-a8c84b7/.github/000077500000000000000000000000001526774056000203575ustar00rootroot00000000000000linagora-rabbitmq-client-a8c84b7/.github/workflows/000077500000000000000000000000001526774056000224145ustar00rootroot00000000000000linagora-rabbitmq-client-a8c84b7/.github/workflows/ci.yml000066400000000000000000000050201526774056000235270ustar00rootroot00000000000000name: CI on: push: branches: - main pull_request: branches: - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: typecheck: name: Type Check runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - name: Run type check run: npm run typecheck test: name: Test (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: node-version: [18, 20, 22] steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run tests run: npm test build: name: Build runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - name: Build package run: npm run build - name: Check build output run: | for f in dist/index.js dist/index.cjs dist/index.d.ts \ dist/testing/index.js dist/testing/index.cjs dist/testing/index.d.ts; do if [ ! -f "$f" ]; then echo "Error: $f not found" exit 1 fi done echo "Build outputs verified" status-check: name: Status Check runs-on: ubuntu-latest if: always() needs: [typecheck, test, build] steps: - name: Check all jobs status run: | if [ "${{ needs.typecheck.result }}" != "success" ]; then echo "Type check job failed" exit 1 fi if [ "${{ needs.test.result }}" != "success" ]; then echo "Test job failed" exit 1 fi if [ "${{ needs.build.result }}" != "success" ]; then echo "Build job failed" exit 1 fi echo "All jobs passed successfully" linagora-rabbitmq-client-a8c84b7/.github/workflows/cleanup-merged-branch.yml000066400000000000000000000021231526774056000272600ustar00rootroot00000000000000name: Cleanup Merged Branch on: pull_request: types: [closed] branches: [main] jobs: delete-branch: name: Delete Merged Branch runs-on: ubuntu-latest if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository permissions: contents: write pull-requests: write steps: - name: Delete branch run: | echo "Deleting branch: ${{ github.event.pull_request.head.ref }}" gh api \ --method DELETE \ "repos/${{ github.repository }}/git/refs/heads/${{ github.event.pull_request.head.ref }}" \ || echo "Branch already deleted or does not exist" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Comment on PR run: | gh pr comment ${{ github.event.pull_request.number }} \ --repo ${{ github.repository }} \ --body "Branch \`${{ github.event.pull_request.head.ref }}\` has been automatically deleted after merge." env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} linagora-rabbitmq-client-a8c84b7/.github/workflows/publish.yml000066400000000000000000000022661526774056000246130ustar00rootroot00000000000000name: Publish package on: push: tags: - 'v*' permissions: contents: read id-token: write jobs: publish: name: Publish Package to NPM runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: npm ci - name: Run type check run: npm run typecheck - name: Run tests run: npm test - name: Build package run: npm run build - name: Verify build output run: | for f in dist/index.js dist/index.cjs dist/index.d.ts \ dist/testing/index.js dist/testing/index.cjs dist/testing/index.d.ts; do if [ ! -f "$f" ]; then echo "Error: $f not found" exit 1 fi done echo "Build outputs verified" - name: Publish to NPM run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN_IN_VAULT }} linagora-rabbitmq-client-a8c84b7/.github/workflows/release.yml000066400000000000000000000012311526774056000245540ustar00rootroot00000000000000name: Release on: push: tags: - 'v*' permissions: contents: write jobs: release: name: Create GitHub Release runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - name: Build package run: npm run build - name: Create Release uses: softprops/action-gh-release@v2 with: generate_release_notes: true files: | dist/* linagora-rabbitmq-client-a8c84b7/.gitignore000066400000000000000000000000531526774056000210050ustar00rootroot00000000000000node_modules dist *.tgz .claude/ CLAUDE.md linagora-rabbitmq-client-a8c84b7/LICENSE000066400000000000000000000020511526774056000200220ustar00rootroot00000000000000MIT License Copyright (c) 2026 Linagora 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. linagora-rabbitmq-client-a8c84b7/README.md000066400000000000000000000250571526774056000203070ustar00rootroot00000000000000# @linagora/rabbitmq-client An opinionated RabbitMQ client for Node.js that handles the boilerplate you'd otherwise copy-paste between services: confirm channels, dead letter queues, exponential backoff on publish, and automatic reconnection that restores your subscriptions. ## Install ```bash npm install @linagora/rabbitmq-client ``` ## Quick start ```typescript import { RabbitMQClient } from '@linagora/rabbitmq-client' const client = new RabbitMQClient({ url: 'amqp://localhost' }) await client.init() await client.publish('auth', 'user.created', { userId: '123' }) await client.subscribe('auth', 'user.created', 'my-service.user-created', async (msg) => { // your logic here }) await client.close() ``` ## Configuration Only `url` is required. Everything else has defaults. | Option | Default | Description | |--------|---------|-------------| | `url` | -- | AMQP connection string | | `maxRetries` | `3` | How many times to retry a failed handler before sending the message to DLQ | | `retryDelay` | `1000` | Milliseconds between handler retries | | `connectionRetryDelay` | `5000` | Milliseconds between reconnection attempts | | `initMaxAttempts` | `5` | Connection attempts on startup before giving up | | `publishMaxAttempts` | `5` | Publish retries (exponential backoff, capped at 60s) | | `prefetch` | `10` | Channel prefetch: how many messages the broker delivers into consumer memory before waiting for acks | | `concurrency` | = `prefetch` | How many handlers run at once per subscription. Prefetch buffers messages in memory; concurrency bounds how many are processed simultaneously. Only throttles when set below `prefetch` (the broker never delivers more than `prefetch` unacked at a time); `0` means no limit. Override per queue via `subscribe(..., { concurrency })` | | `closeTimeout` | `5000` | Milliseconds to wait for in-flight messages when closing | | `logger` | console (warn/error) | Any `ILogger` (`console`, Winston, pino, Bunyan). Omitted: logs warn/error to the console, stays quiet otherwise. Pass `silentLogger` for no output. | | `hooks` | -- | Observability callbacks (see [Hooks](#hooks)) | ## How it works ### Publishing Publishes go through a confirm channel, so you know the broker accepted the message. If something goes wrong, the client retries with exponential backoff and forces a new channel on each attempt to avoid retrying on a silently dead connection. Exchange assertions are cached per connection to avoid unnecessary AMQP round-trips on the hot path. You can pass headers, correlation IDs, and other AMQP properties: ```typescript await client.publish('auth', 'user.created', { userId: '123' }, { headers: { 'x-trace-id': traceId }, correlationId: requestId, messageId: uuid(), expiration: '60000', // TTL in ms }) ``` To reach one queue directly, publish to the default exchange (`''`) with the queue name as the routing key. The client does not declare the default exchange, since it always exists. ```typescript await client.publish('', 'orders.queue', { orderId: '42' }) ``` A confirm only says the broker accepted the message, not that any queue got it. Pass `mandatory: true` to reject with `UnroutableMessageError` when no queue is bound to receive it. That error is not retried, since the same routing would fail the same way. ```typescript import { UnroutableMessageError } from '@linagora/rabbitmq-client' try { await client.publish('', 'orders.queue', { orderId: '42' }, { mandatory: true }) } catch (error) { if (error instanceof UnroutableMessageError) { // orders.queue does not exist } } ``` ### Subscribing Each call to `subscribe` wires up the DLQ plumbing for you: - A dead letter exchange (`.dlx`) - A dead letter queue (`.dlq`) - A dead letter routing key (`.dead`) The main queue is created as a quorum queue with `at-least-once` delivery and `reject-publish` overflow. Messages are manually acknowledged. You can override the default queue arguments by passing a fifth argument: ```typescript await client.subscribe('events', 'order.placed', 'order-queue', handler, { queueArguments: { 'x-queue-type': 'classic', 'x-max-length': 10_000 }, }) ``` Custom arguments are merged with the DLQ wiring defaults, so you can swap the queue type or add a max-length without losing dead-letter routing. To limit how many messages this queue processes at once (for example, to protect a downstream database from a burst), set `concurrency` on the subscription. It defaults to the client-level `concurrency`, which in turn defaults to `prefetch`: ```typescript // Buffer up to 50 messages in memory, but process at most 4 at a time. await client.subscribe('events', 'order.placed', 'order-queue', handler, { concurrency: 4, }) ``` One queue can take messages from several exchanges. Pass the other bindings in `bindings`; they share the queue, its consumer and its DLQ, and are restored on reconnection. Name the dead letter exchange after the queue with `deadLetterExchange`, so it does not live under another service's exchange. For exchanges another service owns, `passiveExchanges` checks they exist instead of declaring them, and `subscribe` fails when one is missing. Pass `true` for every exchange, or a list of names to check those and declare the rest: ```typescript await client.subscribe('space', 'twake.space.#', 'twake-space', handler, { bindings: [ { exchange: 'b2b', routingKey: 'domain.user.deleted' }, { exchange: 'auth', routingKey: 'user.deleted' }, ], deadLetterExchange: 'twake-space.dlx', passiveExchanges: true, }) ``` To deliver every message to every instance of a service, subscribe with `exclusive`. Each instance gets its own queue, named `.`, which the broker deletes when the connection closes or the consumer is cancelled. The client declares one under a new name on reconnection, and `unsubscribe` takes the `queue` you passed. A permission pattern that matches `.` followed by anything covers it, such as `^live(\..*)?$`; an exact `^live$` does not. It has no DLQ: a message the handler fails is dropped, and so are messages published or in flight while the instance is disconnected. Nothing bounds it unless you set `x-max-length`. ```typescript await client.subscribe('live', '#', 'live', handler, { exclusive: true, queueArguments: { 'x-max-length': 10_000 }, }) ``` ### Unsubscribing Cancel a consumer and remove it from the auto-restoration list: ```typescript await client.unsubscribe('order-queue') ``` After unsubscribing, the queue will not be re-subscribed on reconnection. ### Message handling Incoming messages are JSON-parsed first. If that fails, the message goes straight to the DLQ (no point retrying garbage). Otherwise, your handler runs up to `maxRetries` times. Success means ack, final failure means nack to the DLQ. A handler that knows retrying will not help throws `DeadLetterError`, and the message goes to the DLQ at once. A subscription can set its own `maxRetries`, `Infinity` included, and `maxRetryDelay`, which doubles the delay between attempts from `retryDelay` up to that cap. With `maxRetries: Infinity` a message waits unacked while, say, the database is down, and keeps its place in the queue. `close()` and a reconnect stop the retries and leave the message unacked, so the broker redelivers it. ```typescript await client.subscribe('events', 'order.placed', 'order-queue', handler, { maxRetries: Infinity, maxRetryDelay: 60_000, }) ``` The handler also receives where the message came from, `exchange` and `routingKey`, and what the publisher and the broker attached to the delivery: `headers`, `timestamp`, `messageId` and `correlationId`. A handler that only needs the body can ignore it. ```typescript await client.subscribe('events', 'order.#', 'order-queue', async (message, { routingKey, headers }) => { // `x-death` is set by the broker on a message that went through a DLQ const replayed = headers['x-death'] !== undefined }) ``` ### Reconnection If the connection or channel drops, the client reconnects and re-subscribes to everything automatically. Multiple reconnection triggers (e.g. connection close + channel close firing at the same time) are collapsed into a single attempt. A subscription that fails to restore is retried on its own, starting at `connectionRetryDelay` and doubling up to 60 seconds, until it succeeds, is unsubscribed, or the client closes. A consumer the broker cancels, for example because its queue was deleted, is re-subscribed the same way. ### Graceful shutdown `close()` waits for in-flight message handlers to finish before tearing down the channel, up to `closeTimeout` milliseconds. If handlers don't drain in time, the client closes anyway and logs a warning. ```typescript // wait for handlers to finish, then close await client.close() // close but keep subscriptions for a later init() await client.close(false) ``` ### Health check `checkHealth()` creates and immediately deletes a temporary queue. Useful for Kubernetes readiness probes. ```typescript const healthy = await client.checkHealth() ``` ## Hooks Optional callbacks for wiring metrics, tracing, or alerting. Hook errors are swallowed so they never break message flow. ```typescript const client = new RabbitMQClient({ url: 'amqp://localhost', hooks: { onPublish({ exchange, routingKey, attempts }) { metrics.increment('rabbitmq.publish', { exchange }) }, onMessageProcessed({ exchange, routingKey, duration, attempts }) { metrics.histogram('rabbitmq.handler.duration', duration) }, onMessageDlq({ exchange, routingKey, duration, reason }) { alerting.warn(`Message sent to DLQ: ${reason}`) }, onReconnect({ subscriptionsRestored, subscriptionsFailed }) { metrics.increment('rabbitmq.reconnect') }, }, }) ``` | Hook | Fires when | |------|-----------| | `onPublish` | A message is confirmed by the broker | | `onMessageProcessed` | A handler completes successfully (includes duration and retry count) | | `onMessageDlq` | A message is nacked to the DLQ, with reason `'invalid_json'`, `'max_retries_exhausted'` or `'dead_letter_error'` | | `onReconnect` | The client reconnects and re-establishes subscriptions | ## Test helpers The `@linagora/rabbitmq-client/testing` entrypoint provides mocks that don't depend on any test framework. ```typescript import { createMockAmqplib } from '@linagora/rabbitmq-client/testing' const { mockConnection, amqpMock } = createMockAmqplib() // check what was published mockConnection.channel.getPublishedMessages() // feed a message into a consumer mockConnection.channel.simulateMessage({ userId: '123' }) // simulate a broker going down mockConnection.simulateClose() ``` ## License MIT linagora-rabbitmq-client-a8c84b7/package-lock.json000066400000000000000000002210631526774056000222370ustar00rootroot00000000000000{ "name": "@linagora/rabbitmq-client", "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@linagora/rabbitmq-client", "version": "0.7.2", "license": "MIT", "dependencies": { "amqplib": "^0.10.7" }, "devDependencies": { "@types/amqplib": "^0.10.7", "pino": "^9.14.0", "tsup": "^8.4.0", "typescript": "^5.7.3", "vitest": "^3.1.1" }, "engines": { "node": ">=18" } }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "aix" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-x64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { "node": ">=18" } }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@types/amqplib": { "version": "0.10.8", "resolved": "https://registry.npmjs.org/@types/amqplib/-/amqplib-0.10.8.tgz", "integrity": "sha512-vtDp8Pk1wsE/AuQ8/Rgtm6KUZYqcnTgNvEHwzCkX8rL7AGsC6zqAfKAAJhUZXFhM/Pp++tbnUHiam/8vVpPztA==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { "version": "25.5.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.1.tgz", "integrity": "sha512-lgrR3HRNQdTEeeXBnLURFO4JIIbpcVcMlLM9IG0jsNRTRNSbMkm9S2hyhxhnokke1NM25Dr9QghgeB5PQKolrw==", "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { "optional": true }, "vite": { "optional": true } } }, "node_modules/@vitest/pretty-format": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", "dependencies": { "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", "dependencies": { "tinyspy": "^4.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" }, "engines": { "node": ">=0.4.0" } }, "node_modules/amqplib": { "version": "0.10.9", "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.9.tgz", "integrity": "sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==", "license": "MIT", "dependencies": { "buffer-more-ints": "~1.0.0", "url-parse": "~1.5.10" }, "engines": { "node": ">=10" } }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", "dev": true, "license": "MIT" }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { "node": ">=12" } }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "dev": true, "license": "MIT", "engines": { "node": ">=8.0.0" } }, "node_modules/buffer-more-ints": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==", "license": "MIT" }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", "dev": true, "license": "MIT", "dependencies": { "load-tsconfig": "^0.2.3" }, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "peerDependencies": { "esbuild": ">=0.18" } }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" }, "engines": { "node": ">=18" } }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { "node": ">= 16" } }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" }, "engines": { "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", "dev": true, "license": "MIT", "engines": { "node": ">= 6" } }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "dev": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" }, "engines": { "node": ">=6.0" }, "peerDependenciesMeta": { "supports-color": { "optional": true } } }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { "node": ">=18" }, "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" } }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" } }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" }, "peerDependencies": { "picomatch": "^3 || ^4" }, "peerDependenciesMeta": { "picomatch": { "optional": true } } }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", "dev": true, "license": "MIT", "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", "dev": true, "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true, "license": "MIT" }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, "license": "MIT", "engines": { "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/antonk52" } }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true, "license": "MIT" }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, "license": "MIT" }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, "engines": { "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/pathval": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 14.16" } }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", "dev": true, "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "node_modules/pino-abstract-transport": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", "dev": true, "license": "MIT", "dependencies": { "split2": "^4.0.0" } }, "node_modules/pino-std-serializers": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "dev": true, "license": "MIT" }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "dev": true, "license": "MIT", "engines": { "node": ">= 6" } }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { "type": "opencollective", "url": "https://opencollective.com/postcss/" }, { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" } }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "dev": true, "funding": [ { "type": "opencollective", "url": "https://opencollective.com/postcss/" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], "license": "MIT", "dependencies": { "lilconfig": "^3.1.1" }, "engines": { "node": ">= 18" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "peerDependenciesMeta": { "jiti": { "optional": true }, "postcss": { "optional": true }, "tsx": { "optional": true }, "yaml": { "optional": true } } }, "node_modules/process-warning": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/fastify" }, { "type": "opencollective", "url": "https://opencollective.com/fastify" } ], "license": "MIT" }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "license": "MIT" }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "dev": true, "license": "MIT" }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" }, "funding": { "type": "individual", "url": "https://paulmillr.com/funding/" } }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", "dev": true, "license": "MIT", "engines": { "node": ">= 12.13.0" } }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" }, "engines": { "node": ">=18.0.0", "npm": ">=8.0.0" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "dev": true, "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "dev": true, "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" } }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">= 12" } }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true, "license": "ISC", "engines": { "node": ">= 10.x" } }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^9.0.1" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" }, "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" } }, "node_modules/thenify-all": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" }, "engines": { "node": ">=0.8" } }, "node_modules/thread-stream": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", "dev": true, "license": "MIT", "dependencies": { "real-require": "^0.2.0" } }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" }, "funding": { "url": "https://github.com/sponsors/SuperchupuDev" } }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" } }, "node_modules/tinyrainbow": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/tinyspy": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true, "license": "MIT", "bin": { "tree-kill": "cli.js" } }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true, "license": "Apache-2.0" }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", "dev": true, "license": "MIT", "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" }, "engines": { "node": ">=18" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "peerDependenciesMeta": { "@microsoft/api-extractor": { "optional": true }, "@swc/core": { "optional": true }, "postcss": { "optional": true }, "typescript": { "optional": true } } }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { "node": ">=14.17" } }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "dev": true, "license": "MIT" }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", "license": "MIT", "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, "jiti": { "optional": true }, "less": { "optional": true }, "lightningcss": { "optional": true }, "sass": { "optional": true }, "sass-embedded": { "optional": true }, "stylus": { "optional": true }, "sugarss": { "optional": true }, "terser": { "optional": true }, "tsx": { "optional": true }, "yaml": { "optional": true } } }, "node_modules/vite-node": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" }, "engines": { "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/vitest": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, "@types/debug": { "optional": true }, "@types/node": { "optional": true }, "@vitest/browser": { "optional": true }, "@vitest/ui": { "optional": true }, "happy-dom": { "optional": true }, "jsdom": { "optional": true } } }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" }, "engines": { "node": ">=8" } } } } linagora-rabbitmq-client-a8c84b7/package.json000066400000000000000000000027441526774056000213140ustar00rootroot00000000000000{ "name": "@linagora/rabbitmq-client", "version": "0.7.2", "description": "Production-grade RabbitMQ client with confirm channels, DLQ infrastructure, auto-reconnection, exponential backoff, graceful shutdown, and observability hooks", "type": "module", "exports": { ".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } }, "./testing": { "import": { "types": "./dist/testing/index.d.ts", "default": "./dist/testing/index.js" }, "require": { "types": "./dist/testing/index.d.cts", "default": "./dist/testing/index.cjs" } } }, "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "tsup", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build" }, "dependencies": { "amqplib": "^0.10.7" }, "devDependencies": { "@types/amqplib": "^0.10.7", "pino": "^9.14.0", "tsup": "^8.4.0", "typescript": "^5.7.3", "vitest": "^3.1.1" }, "engines": { "node": ">=18" }, "license": "MIT", "repository": { "type": "git", "url": "https://github.com/linagora/rabbitmq-client.git" }, "keywords": [ "rabbitmq", "amqp", "message-queue", "pino" ] } linagora-rabbitmq-client-a8c84b7/src/000077500000000000000000000000001526774056000176065ustar00rootroot00000000000000linagora-rabbitmq-client-a8c84b7/src/client.ts000066400000000000000000001043541526774056000214430ustar00rootroot00000000000000import { randomUUID } from 'node:crypto' import amqp from 'amqplib' import type { ILogger, PublishOptions, RabbitMQClientOptions, RabbitMQHooks, RabbitMQMessage, RabbitMQMessageHandler, RabbitMQMessageProperties, RabbitMQSubscription, SubscribeOptions, } from './types.js' import { defaultLogger, forLibraryCalls } from './logger.js' import { Semaphore } from './semaphore.js' const DEFAULT_PREFETCH = 10 const MAX_BACKOFF_MS = 60_000 const DEFAULTS = { maxRetries: 3, retryDelay: 1000, connectionRetryDelay: 5000, initMaxAttempts: 5, publishMaxAttempts: 5, prefetch: DEFAULT_PREFETCH, closeTimeout: 5000, } as const function rejecting(options: SubscribeOptions | undefined): string { return options?.exclusive ? 'dropping it' : 'sending to DLQ' } function backoff(baseDelay: number, attempt: number, maxDelay: number): number { return Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay) } /** A `mandatory` publish the broker could route to no queue. */ export class UnroutableMessageError extends Error { constructor( readonly exchange: string, readonly routingKey: string, ) { super(`No queue is bound to receive ${exchange || '(default exchange)'}/${routingKey}`) this.name = 'UnroutableMessageError' } } /** Thrown by a handler to send its message to the DLQ at once, without retrying. */ export class DeadLetterError extends Error { constructor(message: string, options?: { cause?: unknown }) { super(message, options) this.name = 'DeadLetterError' } } /** * Production-grade RabbitMQ client with confirm channels, automatic DLQ * infrastructure, reconnection with subscription restoration, and * exponential-backoff publishing. * * @example * ```ts * const client = new RabbitMQClient({ url: 'amqp://localhost' }) * await client.init() * await client.publish('events', 'user.created', { userId: '123' }) * await client.subscribe('events', 'user.created', 'user-service', handler) * ``` */ export class RabbitMQClient { private connection: amqp.ChannelModel | null = null private channel: amqp.ConfirmChannel | null = null private connected = false private closing = false private subscriptions: RabbitMQSubscription[] = [] private readonly options: Required> private readonly logger: ILogger private readonly hooks: RabbitMQHooks private initializationPromise: Promise | null = null private reconnectionPromise: Promise | null = null private assertedExchanges = new Set() // One per channel, so a late return on a replaced channel reaches no waiter. private returnWaiters = new Set<(msg: amqp.Message) => void>() private consumerTags = new Map() // Per-queue concurrency limiter, keyed by queue name. Persisted across // reconnects (unlike consumerTags) so handlers still running when a // connection drops keep holding their permits, and the new consumer cannot // exceed the ceiling while the old pipeline drains. `null` = no limit. private subscriptionSemaphores = new Map() private retryWakers = new Set<() => void>() private inflightCount = 0 private drainResolve: (() => void) | null = null constructor(options: RabbitMQClientOptions) { this.options = { url: options.url, maxRetries: options.maxRetries ?? DEFAULTS.maxRetries, retryDelay: options.retryDelay ?? DEFAULTS.retryDelay, connectionRetryDelay: options.connectionRetryDelay ?? DEFAULTS.connectionRetryDelay, initMaxAttempts: options.initMaxAttempts ?? DEFAULTS.initMaxAttempts, publishMaxAttempts: options.publishMaxAttempts ?? DEFAULTS.publishMaxAttempts, prefetch: options.prefetch ?? DEFAULTS.prefetch, // Default concurrency tracks prefetch, preserving the previous behaviour // (handlers ran fire-and-forget, so up to `prefetch` ran at once) while // making the ceiling explicit and independently tunable. A value <= 0 // (including an unlimited `prefetch: 0`) means "no concurrency limit". concurrency: options.concurrency ?? options.prefetch ?? DEFAULTS.prefetch, closeTimeout: options.closeTimeout ?? DEFAULTS.closeTimeout, } this.logger = forLibraryCalls(options.logger ?? defaultLogger) this.hooks = options.hooks ?? {} } /** * Opens a connection and creates a confirm channel. Retries up to * `initMaxAttempts` times, then throws. Idempotent and safe to call * concurrently — duplicate calls share the same in-flight promise. */ async init(): Promise { if (this.connection && this.connected) { return } if (this.initializationPromise) { return this.initializationPromise } // Attach a no-op catch to prevent Node from briefly treating this as an // unhandled rejection before the await below registers its own handler. this.initializationPromise = this.doConnect(this.options.initMaxAttempts) this.initializationPromise.catch(() => undefined) try { await this.initializationPromise } finally { this.initializationPromise = null } } private async doConnect(maxAttempts?: number): Promise { this.assertedExchanges.clear() this.consumerTags.clear() // A channel failure leaves its connection open; drop it so it does not // outlive the client and keep the process alive. this.connection?.close().catch(() => undefined) this.connection = null let attempts = 0 while (!this.connected && !this.closing) { try { const connection = await amqp.connect(this.options.url) this.connection = connection this.logger.info('Connected to server') const channel = await connection.createConfirmChannel() this.channel = channel // Retries sleeping on the replaced channel give up; the broker redelivers their messages. this.wakeRetries() const returnWaiters = new Set<(msg: amqp.Message) => void>() this.returnWaiters = returnWaiters channel.on('return', (msg: amqp.Message) => { for (const waiter of returnWaiters) waiter(msg) }) this.logger.info('Confirm channel created') await channel.prefetch(this.options.prefetch) this.logger.info('Channel prefetch set', { prefetch: this.options.prefetch }) // Events from a connection or channel a reconnect has replaced must not // tear down the current one. connection.on('error', (error: Error) => { if (connection !== this.connection) return this.connected = false this.logger.error('Connection error', { error }) }) connection.on('close', () => { if (connection !== this.connection) return this.connected = false this.logger.warn('Connection closed') this.reconnectWithRetry() }) channel.on('error', (error: Error) => { if (channel !== this.channel) return this.logger.error('Channel error', { error }) this.handleChannelFailure() }) channel.on('close', () => { if (channel !== this.channel) return this.logger.warn('Channel closed') this.handleChannelFailure() }) this.connected = true this.logger.info('Client initialized successfully') } catch (error) { attempts++ this.connected = false if (maxAttempts !== undefined && attempts >= maxAttempts) { this.logger.error('Connection failed after maximum attempts', { error, attempts, maxAttempts }) throw new Error( `Failed to connect to RabbitMQ after ${attempts} attempts. ` + 'Check RABBITMQ_URL configuration and RabbitMQ server availability.', ) } this.logger.warn('Connection attempt failed, retrying...', { error, attempt: attempts, maxAttempts: maxAttempts ?? 'unlimited', retryDelayMs: this.options.connectionRetryDelay, }) await this.sleep(this.options.connectionRetryDelay) } } if (!this.connected) { throw new Error('RabbitMQ client closed while connecting') } } private handleChannelFailure(): void { if (this.connected) { this.connected = false this.reconnectWithRetry() } } /** * Returns whether the client currently has an active connection and channel. */ isConnected(): boolean { return this.connected } /** * Publishes a JSON message to a topic exchange with publisher confirms, or * straight to a queue through the default exchange (`''`, routing key = queue name). * Retries with exponential backoff (capped at 60 s), forcing a reconnect * on each failure. Throws after `publishMaxAttempts` exhausted. */ async publish( exchange: string, routingKey: string, message: RabbitMQMessage, options?: PublishOptions, ): Promise { let attempts = 0 const maxAttempts = options?.maxAttempts ?? this.options.publishMaxAttempts const baseDelay = this.options.connectionRetryDelay const content = Buffer.from(JSON.stringify(message)) // Taken once, not per attempt: a retried publish must not look newer than // a message published after it succeeded. const timestamp = Math.floor(Date.now() / 1000) while (attempts < maxAttempts) { try { if (!this.connected) { await this.reconnectWithRetry() } if (!this.channel) { throw new Error('Channel not available') } // The default exchange ('') always exists and the broker refuses to // declare it, so publishing straight to a queue skips the assertion. if (exchange !== '' && !this.assertedExchanges.has(exchange)) { await this.channel.assertExchange(exchange, 'topic', { durable: true }) this.assertedExchanges.add(exchange) } const channel = this.channel const returnWaiters = this.returnWaiters // The broker sends basic.return before the confirm of the same message, // so the flag is settled by the time waitForConfirms resolves. A return // carries no delivery tag, so it is matched on where it was sent plus the // message id, or the content when there is none. let returned = false const messageId = options?.messageId const onReturn = (msg: amqp.Message) => { if (msg.fields.exchange !== exchange || msg.fields.routingKey !== routingKey) return if (messageId !== undefined ? msg.properties.messageId === messageId : msg.content.equals(content)) { returned = true } } if (options?.mandatory) returnWaiters.add(onReturn) try { channel.publish(exchange, routingKey, content, { persistent: true, timestamp, headers: options?.headers, correlationId: options?.correlationId, messageId: options?.messageId, expiration: options?.expiration, mandatory: options?.mandatory, }) await channel.waitForConfirms() } finally { returnWaiters.delete(onReturn) } if (returned) { throw new UnroutableMessageError(exchange, routingKey) } if (attempts > 0) { this.logger.info('Published message after retries', { exchange, routingKey, messageSize: content.length, attempts }) } else { this.logger.info('Published message', { exchange, routingKey, messageSize: content.length }) } this.logger.debug('Published message payload', { exchange, routingKey, payload: message }) this.callHook(this.hooks.onPublish, { exchange, routingKey, attempts: attempts + 1 }) return } catch (error) { // The broker answered, and the same routing would fail the same way. if (error instanceof UnroutableMessageError) { this.logger.warn('Published message was unroutable', { exchange, routingKey }) throw error } attempts++ this.connected = false if (attempts >= maxAttempts) { this.logger.error('Publish failed after max attempts', { error, exchange, routingKey, attempts, maxAttempts }) throw new Error( `Failed to publish to ${exchange || '(default exchange)'}/${routingKey} after ${attempts} attempts: ${error instanceof Error ? error.message : String(error)}`, ) } const retryDelay = backoff(baseDelay, attempts, MAX_BACKOFF_MS) this.logger.warn('Publish attempt failed, retrying', { error, exchange, routingKey, attempt: attempts, maxAttempts, retryDelayMs: retryDelay }) await this.sleep(retryDelay) } } } /** * Gracefully shuts down the client. Waits up to `closeTimeout` ms for * in-flight message handlers to finish before closing the channel and * connection. Pass `false` to preserve subscriptions for a later * `init()` / reconnect cycle. */ async close(clearSubscriptions = true): Promise { // Closing the channel fires its 'close' handler, which would otherwise // reconnect and leave a connection behind that keeps the process alive. this.closing = true try { // Woken before the wait below, so a reconnect sleeping between attempts // does not hold close() for its whole backoff. this.wakeRetries() // A connect already running gives up after its current attempt; whatever // it opened is closed below. await Promise.allSettled([this.initializationPromise, this.reconnectionPromise]) if (this.inflightCount > 0) { this.logger.info('Waiting for in-flight messages to drain', { inflightCount: this.inflightCount }) await this.waitForDrain(this.options.closeTimeout) } try { // A channel lost without a completed reconnect is already closed, and // amqplib throws when closing it again. await this.channel?.close().catch((error: unknown) => { if (this.connected) throw error }) } finally { await this.connection?.close() } this.connection = null this.channel = null this.connected = false this.initializationPromise = null this.reconnectionPromise = null this.assertedExchanges.clear() this.consumerTags.clear() if (clearSubscriptions) { this.subscriptions = [] this.subscriptionSemaphores.clear() } this.logger.info('Connection closed') } catch (error) { this.logger.error('Error closing connection', { error }) throw error } finally { this.closing = false } } private async reconnectWithRetry(): Promise { if (this.closing) return if (this.reconnectionPromise) { return this.reconnectionPromise.catch((error) => { if (!this.closing) throw error }) } this.connected = false this.logger.info('Starting reconnection...') this.reconnectionPromise = this.doReconnect() this.reconnectionPromise.catch(() => undefined) try { await this.reconnectionPromise } catch (error) { // The event handlers call this without awaiting it, so a reconnect that // close() interrupted must not surface as an unhandled rejection. if (!this.closing) throw error } finally { this.reconnectionPromise = null } } private async doReconnect(): Promise { for (let attempt = 1; ; attempt++) { await this.doConnect() await this.resubscribeAll() // A channel that failed during resubscribeAll joined this reconnect // instead of starting its own, so nothing else will replace it. if (this.connected || this.closing) return const retryDelay = backoff(this.options.connectionRetryDelay, attempt, MAX_BACKOFF_MS) this.logger.warn('Channel lost while restoring subscriptions, reconnecting again', { attempt, retryDelayMs: retryDelay }) await this.sleepUntilClose(retryDelay) } } private async resubscribeAll(): Promise { const channel = this.channel if (this.subscriptions.length === 0 || !channel) { return } this.logger.info('Re-establishing subscriptions', { count: this.subscriptions.length }) const subs = [...this.subscriptions] const results = await Promise.allSettled( subs.map(async (sub) => { await this.setupSubscription(sub) return sub.queue }), ) const succeeded: string[] = [] const failed: string[] = [] results.forEach((result, index) => { if (result.status === 'fulfilled') { succeeded.push(result.value) } else { const queue = subs[index].queue failed.push(queue) this.logger.error('Failed to re-subscribe to queue', { error: result.reason, queue }) this.retrySubscription(subs[index], channel).catch(() => undefined) } }) if (succeeded.length > 0) { this.logger.info('Successfully re-subscribed to queues', { count: succeeded.length, queues: succeeded }) } if (failed.length > 0) { this.logger.warn('Some subscriptions failed to restore', { count: failed.length, queues: failed }) } // A restore whose channel died is retried by doReconnect, so it is not a reconnect yet. if (this.connected) { this.callHook(this.hooks.onReconnect, { subscriptionsRestored: succeeded.length, subscriptionsFailed: failed.length }) } } // A reconnect restores every subscription on its new channel, so a retry // bound to the old channel stops rather than racing it. private async retrySubscription(sub: RabbitMQSubscription, channel: amqp.ConfirmChannel): Promise { const stale = () => this.closing || channel !== this.channel || !this.subscriptions.includes(sub) for (let attempt = 1; !stale(); attempt++) { const retryDelay = backoff(this.options.connectionRetryDelay, attempt, MAX_BACKOFF_MS) this.logger.warn('Retrying subscription', { queue: sub.queue, attempt, retryDelayMs: retryDelay }) await this.sleepUntilClose(retryDelay) if (stale()) return try { await this.setupSubscription(sub) this.logger.info('Restored subscription after retries', { queue: sub.queue, attempts: attempt }) return } catch (error) { this.logger.error('Failed to re-subscribe to queue', { error, queue: sub.queue, attempt }) } } } /** * Subscribes to a queue with automatic DLQ infrastructure setup. * * Pass `options.queueArguments` to override the default quorum-queue * arguments (merged with the DLQ wiring defaults). */ async subscribe( exchange: string, routingKey: string, queue: string, handler: RabbitMQMessageHandler, options?: SubscribeOptions, ): Promise { if (!this.connected || !this.channel) { throw new Error('RabbitMQ client not connected. Call init() first.') } const sub: RabbitMQSubscription = { exchange, routingKey, queue, handler, options } const existingIndex = this.subscriptions.findIndex((s) => s.queue === queue) const previous = existingIndex === -1 ? undefined : this.subscriptions[existingIndex] if (existingIndex === -1) { this.subscriptions.push(sub) } else { this.subscriptions[existingIndex] = sub // Re-subscribing may change concurrency; drop the old limiter so // setupSubscription rebuilds it from the new options. (A reconnect goes // through setupSubscription directly and keeps the existing limiter.) this.subscriptionSemaphores.delete(queue) } try { await this.setupSubscription(sub) } catch (error) { // A subscription that cannot be set up must not be retried on every reconnect. this.subscriptions = previous ? this.subscriptions.map((s) => (s === sub ? previous : s)) : this.subscriptions.filter((s) => s !== sub) throw error } } /** * Cancels a queue subscription and removes it from the restoration list. */ async unsubscribe(queue: string): Promise { const tag = this.consumerTags.get(queue) if (tag && this.channel) { await this.channel.cancel(tag) } this.consumerTags.delete(queue) this.subscriptionSemaphores.delete(queue) this.subscriptions = this.subscriptions.filter((s) => s.queue !== queue) this.logger.info('Unsubscribed from queue', { queue }) } private async setupSubscription(sub: RabbitMQSubscription): Promise { if (!this.channel) { throw new Error('Channel not available') } // Bind this consumer to the exact channel that will deliver its messages, // so ack/nack always target that channel even after a reconnect swaps // `this.channel`. const channel = this.channel const { exchange, routingKey, queue, handler, options } = sub const dlxExchange = options?.deadLetterExchange ?? `${exchange}.dlx` const dlqQueue = `${queue}.dlq` // Set once on the queue, so every binding's dead letters reach the DLQ. const dlqRoutingKey = `${routingKey}.dead` const bindings = [{ exchange, routingKey }, ...(options?.bindings ?? [])] if (!options?.exclusive) { await channel.assertExchange(dlxExchange, 'topic', { durable: true }) await channel.assertQueue(dlqQueue, { durable: true }) await channel.bindQueue(dlqQueue, dlxExchange, dlqRoutingKey) } const passive = options?.passiveExchanges const isPassive = (name: string) => passive === true || (Array.isArray(passive) && passive.includes(name)) const missing = [...new Set(bindings.map((b) => b.exchange))].filter((name) => !this.assertedExchanges.has(name)) const toCheck = missing.filter(isPassive) if (toCheck.length > 0) { await this.checkExchanges(toCheck) } for (const name of missing.filter((name) => !isPassive(name))) { await channel.assertExchange(name, 'topic', { durable: true }) } for (const name of missing) this.assertedExchanges.add(name) let declared = queue if (options?.exclusive) { // A new name per connection: after a network drop the broker may still // hold the old queue and refuse its name. Prefixed with `queue`, not // broker-named, so permissions scoped to the service's names cover it. // A quorum queue cannot be exclusive. declared = `${queue}.${randomUUID()}` await channel.assertQueue(declared, { exclusive: true, durable: false, autoDelete: true, arguments: { ...options.queueArguments, 'x-queue-type': 'classic' }, }) } else { const queueType = options?.queueArguments?.['x-queue-type'] ?? 'quorum' const queueArgs: Record = { 'x-queue-type': queueType, 'x-overflow': 'reject-publish', } // at-least-once DLQ strategy is only supported by quorum queues if (queueType === 'quorum') { queueArgs['x-dead-letter-strategy'] = 'at-least-once' } await channel.assertQueue(queue, { durable: true, deadLetterExchange: dlxExchange, deadLetterRoutingKey: dlqRoutingKey, arguments: { ...queueArgs, ...options?.queueArguments }, }) } for (const binding of bindings) { await channel.bindQueue(declared, binding.exchange, binding.routingKey) } // Reuse the queue's existing limiter across reconnects; only build a new // one the first time (or after subscribe()/unsubscribe() cleared it). // A concurrency <= 0 means "no limit" (null), matching an unlimited prefetch. if (!this.subscriptionSemaphores.has(queue)) { const concurrency = options?.concurrency ?? this.options.concurrency this.subscriptionSemaphores.set(queue, concurrency > 0 ? new Semaphore(concurrency) : null) } const semaphore = this.subscriptionSemaphores.get(queue) ?? null const { consumerTag } = await channel.consume( declared, (message) => { if (message) { this.dispatch(message, handler, semaphore, channel, options) return } // amqplib passes null when the broker cancels the consumer, e.g. when the queue is deleted. this.logger.warn('Broker cancelled the consumer', { queue: declared }) if (this.consumerTags.get(queue) === consumerTag) this.consumerTags.delete(queue) this.retrySubscription(sub, channel).catch(() => undefined) }, { noAck: false }, ) // unsubscribe() or a newer subscribe() ran while this consumer was being set up. if (!this.subscriptions.includes(sub)) { // The limiter this call built would outlive the subscription and be // reused by a later subscribe() to the same queue. if (!this.subscriptions.some((s) => s.queue === queue)) { this.subscriptionSemaphores.delete(queue) } await channel.cancel(consumerTag).catch(() => undefined) return } this.consumerTags.set(queue, consumerTag) this.logger.info('Subscribed to queue', { queue: declared, bindings }) } // A failed check closes the channel it ran on, so it runs on a throwaway one // rather than take down the shared channel and every consumer on it. private async checkExchanges(exchanges: string[]): Promise { if (!this.connection) { throw new Error('Connection not available') } const probe = await this.connection.createChannel() probe.on('error', () => undefined) try { for (const name of exchanges) { await probe.checkExchange(name) } } finally { await probe.close().catch(() => undefined) } } /** * Gates a delivered message on the subscription's concurrency semaphore, then * processes it. A message counts as in-flight from delivery until its handler * settles (including time spent waiting for a permit), so graceful `close()` * drains queued messages as well as actively-processing ones. */ private dispatch( message: amqp.ConsumeMessage, handler: RabbitMQMessageHandler, semaphore: Semaphore | null, channel: amqp.ConfirmChannel, options: SubscribeOptions | undefined, ): void { this.inflightCount++ const acquire = semaphore ? semaphore.acquire() : Promise.resolve() acquire .then(() => this.handleWithRetry(message, handler, channel, options)) .catch((error) => { this.logger.error('Unhandled error in message handler', { error }) }) .finally(() => { semaphore?.release() this.inflightCount-- if (this.inflightCount === 0 && this.drainResolve) { this.drainResolve() } }) } private async handleWithRetry( message: amqp.ConsumeMessage, handler: RabbitMQMessageHandler, channel: amqp.ConfirmChannel, options: SubscribeOptions | undefined, ): Promise { // `channel` is the one that delivered this message. If a reconnect has // since replaced it, this message was never acked and the broker will // redeliver it on the new channel, so drop this stale attempt rather than // run the handler again or ack a tag the new channel does not know. if (channel !== this.channel) { this.logger.warn('Skipping message from a superseded channel; it will be redelivered') return } const startTime = Date.now() let attempts = 0 const routingKey = message.fields.routingKey const exchange = message.fields.exchange let content: RabbitMQMessage try { content = JSON.parse(message.content.toString()) } catch (parseError) { const rawContent = message.content.toString() const rawPreview = rawContent.substring(0, 100) this.logger.error(`Failed to parse message JSON, ${rejecting(options)}`, { error: parseError, exchange, routingKey, rawContentPreview: rawPreview + (rawContent.length > 100 ? '...' : ''), }) this.reject(channel, message, options, { exchange, routingKey, duration: 0, reason: 'invalid_json' }) return } this.logger.debug('Message received, processing', { exchange, routingKey, payload: content }) const properties: RabbitMQMessageProperties = { exchange, routingKey, headers: message.properties.headers ?? {}, timestamp: message.properties.timestamp, messageId: message.properties.messageId, correlationId: message.properties.correlationId, } const maxRetries = options?.maxRetries ?? this.options.maxRetries while (attempts < maxRetries) { // Only the handler call belongs in this try. Acking inside it would make // a dead channel look like a failed handler and re-run its side effects. try { await handler(content, properties) } catch (error) { attempts++ // By name, not instanceof: the ESM and CJS builds each define their own class. if (error instanceof Error && error.name === 'DeadLetterError') { const duration = Date.now() - startTime this.logger.warn(`Handler dead-lettered the message, ${rejecting(options)}`, { error: error.message, exchange, routingKey }) this.reject(channel, message, options, { exchange, routingKey, duration, reason: 'dead_letter_error' }) return } this.logger.error('Handler failed', { error: error instanceof Error ? error.message : error, stack: error instanceof Error ? error.stack : undefined, exchange, routingKey, attempt: attempts, maxRetries, }) if (attempts < maxRetries) { await this.sleepUntilClose(this.retryDelayFor(attempts, options)) if (this.closing || channel !== this.channel) { this.logger.warn( `Stopped retrying a message on close or reconnect; ${options?.exclusive ? 'it is lost with its queue' : 'it will be redelivered'}`, { exchange, routingKey }, ) return } } continue } const duration = Date.now() - startTime this.logger.info('Message processed successfully', { exchange, routingKey, duration, attempts: attempts + 1 }) if (this.settle(channel, message, 'ack')) { this.callHook(this.hooks.onMessageProcessed, { exchange, routingKey, duration, attempts: attempts + 1 }) } return } const duration = Date.now() - startTime this.logger.error(`Message failed after max retries, ${rejecting(options)}`, { exchange, routingKey, maxRetries, duration }) this.reject(channel, message, options, { exchange, routingKey, duration, reason: 'max_retries_exhausted' }) } // An exclusive queue has no DLQ: the nack drops the message. private reject( channel: amqp.ConfirmChannel, message: amqp.ConsumeMessage, options: SubscribeOptions | undefined, info: Parameters>[0], ): void { if (this.settle(channel, message, 'nack') && !options?.exclusive) { this.callHook(this.hooks.onMessageDlq, info) } } /** * Acks or nacks a delivery, reporting whether the broker accepted it. * * A settle failure is a transport error, never a handler failure: by this * point the handler has already run, so it must not be retried just because * the channel went away. The delivery stays unacked and the broker redelivers * it once the channel closes. */ private settle( channel: amqp.ConfirmChannel, message: amqp.ConsumeMessage, action: 'ack' | 'nack', ): boolean { // A handler can run for minutes, so the channel that delivered this // message may have been replaced by a reconnect since the entry check. // Settling on it would throw, and replaying the delivery tag on the // current channel would settle an unrelated message, since tags are scoped // to the channel that issued them. The broker requeues a closed channel's // unacked deliveries, so let it be redelivered instead. if (channel !== this.channel) { this.logger.warn('Skipping message from a superseded channel; it will be redelivered') return false } try { if (action === 'ack') { channel.ack(message) } else { channel.nack(message, false, false) } return true } catch (error) { this.logger.error('Failed to settle message; leaving it unacked for redelivery', { error, action, exchange: message.fields.exchange, routingKey: message.fields.routingKey, }) return false } } /** * Lightweight liveness probe: creates and immediately deletes a temporary * exclusive queue. Returns `false` when disconnected or on broker error. */ async checkHealth(): Promise { if (!this.connected || !this.channel) { return false } try { const { queue } = await this.channel.assertQueue('', { exclusive: true, autoDelete: true, }) await this.channel.deleteQueue(queue) return true } catch (error) { this.logger.warn('Health check failed', { error }) return false } } private waitForDrain(timeout: number): Promise { if (this.inflightCount === 0) return Promise.resolve() return new Promise((resolve) => { const timer = setTimeout(() => { this.logger.warn('Close timeout reached with messages still in flight', { inflightCount: this.inflightCount }) this.drainResolve = null resolve() }, timeout) this.drainResolve = () => { clearTimeout(timer) this.drainResolve = null resolve() } }) } /** Invokes a hook callback, swallowing errors so hooks never break message flow. */ private callHook(hook: ((info: T) => void) | undefined, info: T): void { try { hook?.(info) } catch (error) { this.logger.debug('Observability hook error', { error }) } } private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } private retryDelayFor(attempts: number, options: SubscribeOptions | undefined): number { const { retryDelay } = this.options if (options?.maxRetryDelay === undefined) return retryDelay return backoff(retryDelay, attempts, options.maxRetryDelay) } private wakeRetries(): void { for (const wake of [...this.retryWakers]) wake() } /** Also cut short by a reconnect, via wakeRetries(). */ private sleepUntilClose(ms: number): Promise { if (this.closing) return Promise.resolve() return new Promise((resolve) => { const wake = () => { clearTimeout(timer) this.retryWakers.delete(wake) resolve() } const timer = setTimeout(wake, ms) this.retryWakers.add(wake) }) } } linagora-rabbitmq-client-a8c84b7/src/index.ts000066400000000000000000000006021526774056000212630ustar00rootroot00000000000000export { DeadLetterError, RabbitMQClient, UnroutableMessageError } from './client.js' export { defaultLogger, silentLogger } from './logger.js' export type { ILogger, PublishOptions, RabbitMQBinding, RabbitMQClientOptions, RabbitMQHooks, RabbitMQMessage, RabbitMQMessageHandler, RabbitMQMessageProperties, RabbitMQSubscription, SubscribeOptions, } from './types.js' linagora-rabbitmq-client-a8c84b7/src/logger.ts000066400000000000000000000036371526774056000214460ustar00rootroot00000000000000import type { ILogger } from './types.js' /** * Default logger used when the consumer does not inject one. * * Forwards `warn` and `error` to the console so connection drops, channel * errors, and failed handlers stay visible out of the box, while staying * silent for `info`/`debug` so routine operation never pollutes the * consumer's output. Pass `silentLogger` for zero output, or any `ILogger` * (console, Winston, pino, Bunyan) to take full control. */ export const defaultLogger: ILogger = { debug: () => {}, info: () => {}, warn: (message, ...args) => console.warn('[rabbitmq-client]', message, ...args), error: (message, ...args) => console.error('[rabbitmq-client]', message, ...args), } type Level = keyof ILogger type ObjectFirst = (context: object, message: string) => void /** * pino takes `(context, message)` and drops a context passed after the * message, and only serializes an Error under `err`. It is the one logger * with a `bindings()` method, so it is recognised by that and called its way. */ export const forLibraryCalls = (logger: ILogger): ILogger => { if (typeof (logger as { bindings?: unknown }).bindings !== 'function') return logger const call = (level: Level) => (message: string, context?: unknown): void => { const log = (logger[level] as unknown as ObjectFirst).bind(logger) if (typeof context !== 'object' || context === null) return log({}, message) const { error, ...rest } = context as { error?: unknown } log(error === undefined ? rest : { ...rest, err: error }, message) } return { debug: call('debug'), info: call('info'), warn: call('warn'), error: call('error') } } /** * No-op logger that discards every message. Use this to silence the client * entirely, including the `warn`/`error` output the default logger emits. */ export const silentLogger: ILogger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, } linagora-rabbitmq-client-a8c84b7/src/semaphore.ts000066400000000000000000000032761526774056000221510ustar00rootroot00000000000000/** * Minimal FIFO counting semaphore used to cap how many message handlers run * concurrently, independently of channel prefetch. * * Prefetch bounds how many messages the broker delivers into consumer memory; * the semaphore bounds how many of those are actively being processed at once. * Keeping the two separate lets a consumer buffer a large prefetch window while * still protecting a slow downstream (a database, an HTTP API) from a burst. */ export class Semaphore { private permits: number private readonly waiters: Array<() => void> = [] constructor(permits: number) { if (!Number.isInteger(permits) || permits < 1) { throw new Error(`Semaphore requires a positive integer of permits, got ${permits}`) } this.permits = permits } /** * Acquires a permit, resolving immediately when one is free or queueing * (FIFO) until another holder releases. Always pair with `release()` in a * `finally` so a throwing handler never leaks a permit. */ acquire(): Promise { if (this.permits > 0) { this.permits-- return Promise.resolve() } return new Promise((resolve) => { this.waiters.push(resolve) }) } /** * Releases a permit. Hands it directly to the next waiter if any are queued, * otherwise returns it to the pool. */ release(): void { const next = this.waiters.shift() if (next) { next() } else { this.permits++ } } /** Permits currently available. Exposed for tests and introspection. */ get available(): number { return this.permits } /** Number of callers currently queued waiting for a permit. */ get waiting(): number { return this.waiters.length } } linagora-rabbitmq-client-a8c84b7/src/testing/000077500000000000000000000000001526774056000212635ustar00rootroot00000000000000linagora-rabbitmq-client-a8c84b7/src/testing/index.ts000066400000000000000000000167611526774056000227550ustar00rootroot00000000000000import { EventEmitter } from 'events' import type { RabbitMQMessageProperties } from '../types.js' // --------------------------------------------------------------------------- // Framework-agnostic mock function factory // --------------------------------------------------------------------------- interface MockFn { (...args: unknown[]): unknown mock: { calls: unknown[][] } mockResolvedValue: (val: unknown) => MockFn mockImplementation: (fn: (...args: unknown[]) => unknown) => MockFn } function mockFn(): MockFn { let impl: ((...args: unknown[]) => unknown) | null = null const calls: unknown[][] = [] const fn = ((...args: unknown[]) => { calls.push(args) return impl ? impl(...args) : undefined }) as MockFn fn.mock = { calls } fn.mockResolvedValue = (val: unknown) => { impl = () => Promise.resolve(val) return fn } fn.mockImplementation = (newImpl: (...args: unknown[]) => unknown) => { impl = newImpl return fn } return fn } // --------------------------------------------------------------------------- // Published message record // --------------------------------------------------------------------------- export interface PublishedMessage { exchange: string routingKey: string message: unknown options: unknown } // --------------------------------------------------------------------------- // MockRabbitMQChannel // --------------------------------------------------------------------------- export class MockRabbitMQChannel extends EventEmitter { private _publishedMessages: PublishedMessage[] = [] private _consumeCallback: ((msg: unknown) => void) | null = null isClosed = false // Stubs readonly assertExchange = mockFn().mockResolvedValue({}) readonly checkExchange = mockFn().mockResolvedValue({}) readonly assertQueue = mockFn().mockResolvedValue({ queue: '' }) readonly bindQueue = mockFn().mockResolvedValue({}) readonly ack = mockFn() readonly nack = mockFn() readonly close = mockFn().mockResolvedValue(undefined) readonly waitForConfirms = mockFn().mockResolvedValue(undefined) readonly prefetch = mockFn().mockResolvedValue(undefined) readonly deleteQueue = mockFn().mockResolvedValue({}) readonly cancel = mockFn().mockResolvedValue(undefined) /** * Tracks all published messages and stores them for later inspection. */ publish(exchange: string, routingKey: string, content: Buffer, options?: unknown): boolean { let message: unknown try { message = JSON.parse(content.toString()) } catch { message = content.toString() } this._publishedMessages.push({ exchange, routingKey, message, options }) return true } private _consumerCounter = 0 /** * Stores the consumer callback for use with simulateMessage. * Returns a unique consumer tag per call. */ consume(_queue: string, callback: (msg: unknown) => void): Promise<{ consumerTag: string }> { this._consumeCallback = callback const consumerTag = `mock-consumer-${++this._consumerCounter}` return Promise.resolve({ consumerTag }) } // --------------------------------------------------------------------------- // Simulation helpers // --------------------------------------------------------------------------- /** * Delivers a valid JSON message to the registered consume callback, with * optional AMQP properties such as `headers` or `timestamp`. */ simulateMessage(content: unknown, properties: Partial = {}): void { this._deliver(Buffer.from(JSON.stringify(content)), properties) } /** * Delivers a message with content that is not valid JSON. */ simulateInvalidJsonMessage(): void { this._deliver(Buffer.from('not valid json {')) } private _deliver(content: Buffer, properties: Partial = {}): void { if (!this._consumeCallback) return const { exchange = '', routingKey = '', ...amqpProperties } = properties this._consumeCallback({ content, fields: { deliveryTag: 1, redelivered: false, exchange, routingKey, consumerTag: 'mock-consumer' }, properties: { headers: {}, ...amqpProperties }, }) } /** Emits a close event and marks the channel as closed. */ simulateClose(): void { this.isClosed = true this.emit('close') } /** Emits an error event. */ simulateError(error: Error): void { this.emit('error', error) } // --------------------------------------------------------------------------- // Inspection helpers // --------------------------------------------------------------------------- getPublishedMessages(): PublishedMessage[] { return [...this._publishedMessages] } clearMessages(): void { this._publishedMessages = [] } /** Resets all state back to initial values. */ reset(): void { this._publishedMessages = [] this._consumeCallback = null this._consumerCounter = 0 this.isClosed = false this.removeAllListeners() } } // --------------------------------------------------------------------------- // MockRabbitMQConnection // --------------------------------------------------------------------------- export class MockRabbitMQConnection extends EventEmitter { private _channels: MockRabbitMQChannel[] = [] isConnected = true constructor() { super() // Create the initial channel eagerly so `conn.channel` is defined // before the first createConfirmChannel call. this._channels.push(new MockRabbitMQChannel()) } /** * Returns the most recently created channel. */ get channel(): MockRabbitMQChannel { return this._channels[this._channels.length - 1] } /** * Returns all channels created so far (including the initial one). */ get channels(): MockRabbitMQChannel[] { return [...this._channels] } get channelCount(): number { return this._channels.length } /** * Creates a new channel (simulates a reconnection / new confirm-channel). */ createConfirmChannel(): Promise { const ch = new MockRabbitMQChannel() this._channels.push(ch) return Promise.resolve(ch) } /** Creates an untracked plain channel, as used for passive exchange checks. */ createChannel(): Promise { return Promise.resolve(new MockRabbitMQChannel()) } close(): Promise { this.isConnected = false return Promise.resolve() } /** Emits a close event and marks the connection as disconnected. */ simulateClose(): void { this.isConnected = false this.emit('close') } /** Emits an error event. */ simulateError(error: Error): void { this.emit('error', error) } /** Resets to the initial connected state with a single fresh channel. */ reset(): void { this._channels = [new MockRabbitMQChannel()] this.isConnected = true this.removeAllListeners() } } // --------------------------------------------------------------------------- // createMockAmqplib // --------------------------------------------------------------------------- export interface AmqpMock { connect: MockFn } export interface MockAmqplibResult { mockConnection: MockRabbitMQConnection amqpMock: AmqpMock } /** * Returns a pre-wired { mockConnection, amqpMock } pair that can be used as * a drop-in replacement for amqplib in tests (e.g. via vi.mock or jest.mock). */ export function createMockAmqplib(): MockAmqplibResult { const mockConnection = new MockRabbitMQConnection() const amqpMock: AmqpMock = { connect: mockFn().mockResolvedValue(mockConnection), } return { mockConnection, amqpMock } } linagora-rabbitmq-client-a8c84b7/src/types.ts000066400000000000000000000156151526774056000213320ustar00rootroot00000000000000/** * Minimal logging contract required by this library. * * Calls are message-first (`logger.error('message', { context })`), matching * `console`, Winston, and Bunyan. A pino logger is recognised and called * pino's way (`(context, message)`, errors under `err`), so any of them can be * passed directly without writing a wrapper. Only these four levels are used. */ export interface ILogger { /** Detailed diagnostic information for developers. */ debug(message: string, ...args: unknown[]): void /** Routine informational messages about library operations. */ info(message: string, ...args: unknown[]): void /** Warnings about non-fatal issues. */ warn(message: string, ...args: unknown[]): void /** Errors indicating an operation failed. */ error(message: string, ...args: unknown[]): void } /** * Hooks for observability — optional callbacks invoked on key client events. * Use these to wire metrics, tracing, or custom logging. */ export interface RabbitMQHooks { /** Called after the broker confirms a published message */ onPublish?: (info: { exchange: string; routingKey: string; attempts: number }) => void /** Called after a message handler returns successfully (duration includes retries) */ onMessageProcessed?: (info: { exchange: string; routingKey: string; duration: number; attempts: number }) => void /** Called when a message is nacked to the dead-letter queue */ onMessageDlq?: (info: { exchange: string; routingKey: string; duration: number; reason: 'invalid_json' | 'max_retries_exhausted' | 'dead_letter_error' }) => void /** Called after reconnection completes and subscriptions are re-established */ onReconnect?: (info: { subscriptionsRestored: number; subscriptionsFailed: number }) => void } /** * Options for queue subscription. */ export interface SubscribeOptions { /** Override default AMQP queue arguments (merged with DLQ wiring defaults) */ queueArguments?: Record /** * Max message handlers to run concurrently for this subscription. * Overrides the client-level `concurrency` for this queue only. Prefetch * bounds messages held in memory; concurrency bounds how many are processed * at once, and only throttles when set below `prefetch`. `0` means no limit. */ concurrency?: number /** More exchanges and routing keys to bind the same queue to, consumed by the same handler */ bindings?: RabbitMQBinding[] /** Dead-letter exchange name (default: `.dlx`) */ deadLetterExchange?: string /** * Check that the source exchanges exist instead of declaring them, for * exchanges another service owns: `true` for all of them, or a list of * names. Subscribing fails when one is missing. */ passiveExchanges?: boolean | string[] /** Max handler attempts before the DLQ for this queue (overrides the client default); `Infinity` retries until the handler succeeds */ maxRetries?: number /** * Double the delay between handler attempts, from `retryDelay` up to this * many ms. Without it the delay stays at `retryDelay`. */ maxRetryDelay?: number /** * A queue for this connection only, for fanning messages out to every * consumer: classic, not durable, named `.`, deleted when the * connection closes or the consumer is cancelled, and declared under a new * name on reconnection. `queue` also names the subscription. It has no * DLQ, so `deadLetterExchange` is ignored and a message its handler fails is * dropped. Set `x-max-length` in `queueArguments` to bound it. */ exclusive?: boolean } /** An exchange and routing key a queue is bound to */ export interface RabbitMQBinding { exchange: string routingKey: string } /** * Configuration options for the RabbitMQ client. */ export interface RabbitMQClientOptions { /** AMQP connection URL (e.g., 'amqp://user:pass@host:5672') */ url: string /** Max handler retries before sending to DLQ (default: 3) */ maxRetries?: number /** Delay in ms between handler retries (default: 1000) */ retryDelay?: number /** Delay in ms between reconnection attempts (default: 5000) */ connectionRetryDelay?: number /** Max connection attempts during init; undefined = unlimited (default: 5) */ initMaxAttempts?: number /** Max publish attempts with exponential backoff (default: 5) */ publishMaxAttempts?: number /** Channel prefetch count (default: 10) */ prefetch?: number /** * Max message handlers to run concurrently per subscription (default: equal * to `prefetch`). Bounds how many `handler` callbacks execute at once, on top * of how many messages prefetch keeps buffered in memory. Only throttles when * set below `prefetch`, since the broker never delivers more than `prefetch` * unacked messages at a time; `0` (or a negative value) means no limit. * Override per queue via `SubscribeOptions.concurrency`. */ concurrency?: number /** Logger satisfying the ILogger contract; defaults to console warn/error only if omitted */ logger?: ILogger /** Timeout in ms to wait for in-flight messages during close (default: 5000) */ closeTimeout?: number /** Observability hooks for metrics and monitoring */ hooks?: RabbitMQHooks } /** * Options for the `publish()` method. */ export interface PublishOptions { /** Max publish attempts with backoff (overrides client default) */ maxAttempts?: number /** Custom message headers for tracing and metadata */ headers?: Record /** Correlation ID for request-reply patterns */ correlationId?: string /** Unique message identifier */ messageId?: string /** Per-message TTL in milliseconds (as string per AMQP spec) */ expiration?: string /** * Reject with `UnroutableMessageError` when the broker routes the message to * no queue, instead of resolving on the confirm. Not retried. */ mandatory?: boolean } /** JSON-serializable message payload */ export type RabbitMQMessage = Record /** What the publisher and the broker attached to a consumed message */ export interface RabbitMQMessageProperties { /** Exchange the message was published to */ exchange: string /** Routing key the message was published with */ routingKey: string /** AMQP headers, including broker-added ones such as `x-death` on a dead-lettered message */ headers: Record /** AMQP timestamp in seconds, if the publisher set one */ timestamp?: number /** Unique message identifier, if the publisher set one */ messageId?: string /** Correlation ID, if the publisher set one */ correlationId?: string } /** Async handler function for consumed messages */ export type RabbitMQMessageHandler = ( message: RabbitMQMessage, properties: RabbitMQMessageProperties, ) => Promise /** Stored subscription metadata for restoration after reconnection */ export interface RabbitMQSubscription { exchange: string routingKey: string queue: string handler: RabbitMQMessageHandler options?: SubscribeOptions } linagora-rabbitmq-client-a8c84b7/test/000077500000000000000000000000001526774056000177765ustar00rootroot00000000000000linagora-rabbitmq-client-a8c84b7/test/client.test.ts000066400000000000000000001612021526774056000226040ustar00rootroot00000000000000import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { Connection, ConfirmChannel } from 'amqplib' // Hoist mock objects so they are available inside the vi.mock factory const { mockChannel, mockProbeChannel, mockConnection } = vi.hoisted(() => { const mockChannel = { prefetch: vi.fn().mockResolvedValue(undefined), on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined), assertExchange: vi.fn().mockResolvedValue({}), assertQueue: vi.fn().mockResolvedValue({ queue: 'test-queue' }), bindQueue: vi.fn().mockResolvedValue({}), publish: vi.fn().mockReturnValue(true), waitForConfirms: vi.fn().mockResolvedValue(undefined), consume: vi.fn().mockResolvedValue({ consumerTag: 'test' }), ack: vi.fn(), nack: vi.fn(), deleteQueue: vi.fn().mockResolvedValue({}), cancel: vi.fn().mockResolvedValue(undefined), } as unknown as ConfirmChannel & Record> const mockProbeChannel = { on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined), checkExchange: vi.fn().mockResolvedValue({}), } as unknown as ConfirmChannel & Record> const mockConnection = { createConfirmChannel: vi.fn().mockResolvedValue(mockChannel), createChannel: vi.fn().mockResolvedValue(mockProbeChannel), on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined), } as unknown as Connection & Record> return { mockChannel, mockProbeChannel, mockConnection } }) vi.mock('amqplib', () => ({ default: { connect: vi.fn().mockResolvedValue(mockConnection), }, })) import amqp from 'amqplib' import { DeadLetterError, RabbitMQClient, UnroutableMessageError } from '../src/client.js' import { silentLogger } from '../src/logger.js' const baseOptions = { url: 'amqp://localhost', maxRetries: 3, retryDelay: 10, connectionRetryDelay: 10, initMaxAttempts: 5, publishMaxAttempts: 5, prefetch: 10, logger: silentLogger, } const createMessage = (content: unknown) => ({ content: Buffer.from(typeof content === 'string' ? content : JSON.stringify(content)), fields: { deliveryTag: 1, redelivered: false, exchange: 'ex', routingKey: 'key', consumerTag: 'test' }, properties: { headers: {} }, }) function setupConsumeCapture() { let cb: ((msg: unknown) => void) | null = null mockChannel.consume.mockImplementation(((_queue: string, fn: (msg: unknown) => void) => { cb = fn return Promise.resolve({ consumerTag: 'test' }) }) as typeof mockChannel.consume) return (msg: unknown) => cb!(msg) } describe('RabbitMQClient', () => { let client: RabbitMQClient beforeEach(() => { vi.clearAllMocks() // Restore default implementations cleared by clearAllMocks mockChannel.prefetch.mockResolvedValue(undefined) mockChannel.on.mockReturnThis() mockChannel.close.mockResolvedValue(undefined) mockChannel.assertExchange.mockResolvedValue({}) mockProbeChannel.on.mockReturnThis() mockProbeChannel.close.mockResolvedValue(undefined) mockProbeChannel.checkExchange.mockResolvedValue({}) mockChannel.assertQueue.mockResolvedValue({ queue: 'test-queue' }) mockChannel.bindQueue.mockResolvedValue({}) mockChannel.publish.mockReturnValue(true) mockChannel.waitForConfirms.mockResolvedValue(undefined) mockChannel.consume.mockResolvedValue({ consumerTag: 'test' }) mockChannel.deleteQueue.mockResolvedValue({}) mockChannel.cancel.mockResolvedValue(undefined) mockConnection.createConfirmChannel.mockResolvedValue(mockChannel) mockConnection.createChannel.mockResolvedValue(mockProbeChannel) mockConnection.on.mockReturnThis() mockConnection.close.mockResolvedValue(undefined) vi.mocked(amqp.connect).mockResolvedValue(mockConnection as unknown as Connection) vi.useFakeTimers() client = new RabbitMQClient(baseOptions) }) afterEach(async () => { vi.useRealTimers() }) describe('init()', () => { it('should connect and create a confirm channel', async () => { await client.init() expect(amqp.connect).toHaveBeenCalledWith('amqp://localhost') expect(mockConnection.createConfirmChannel).toHaveBeenCalledOnce() expect(mockChannel.prefetch).toHaveBeenCalledWith(10) expect(client.isConnected()).toBe(true) }) it('should register connection and channel event handlers', async () => { await client.init() expect(mockConnection.on).toHaveBeenCalledWith('error', expect.any(Function)) expect(mockConnection.on).toHaveBeenCalledWith('close', expect.any(Function)) expect(mockChannel.on).toHaveBeenCalledWith('error', expect.any(Function)) expect(mockChannel.on).toHaveBeenCalledWith('close', expect.any(Function)) }) it('should be idempotent when already connected', async () => { await client.init() await client.init() expect(amqp.connect).toHaveBeenCalledOnce() }) it('should deduplicate concurrent init calls', async () => { const init1 = client.init() const init2 = client.init() await Promise.all([init1, init2]) expect(amqp.connect).toHaveBeenCalledOnce() }) it('should retry on connection failure and succeed', async () => { const connectMock = vi.mocked(amqp.connect) connectMock .mockRejectedValueOnce(new Error('refused')) .mockRejectedValueOnce(new Error('refused')) .mockResolvedValueOnce(mockConnection as unknown as Connection) const initPromise = client.init() await vi.advanceTimersByTimeAsync(100) await initPromise expect(connectMock).toHaveBeenCalledTimes(3) expect(client.isConnected()).toBe(true) }) it('should throw after exhausting initMaxAttempts', async () => { const connectMock = vi.mocked(amqp.connect) connectMock.mockRejectedValue(new Error('refused')) // Attach the rejection handler before advancing timers to avoid // a brief "unhandled rejection" window during fake timer advancement. const assertion = expect(client.init()).rejects.toThrow(/5 attempts/) await vi.advanceTimersByTimeAsync(500) await assertion }) }) describe('close()', () => { it('should close channel and connection', async () => { await client.init() await client.close() expect(mockChannel.close).toHaveBeenCalledOnce() expect(mockConnection.close).toHaveBeenCalledOnce() expect(client.isConnected()).toBe(false) }) it('should re-throw errors from channel close', async () => { await client.init() mockChannel.close.mockRejectedValueOnce(new Error('close failed')) await expect(client.close()).rejects.toThrow('close failed') }) it('should not reconnect when closing fires the channel and connection close events', async () => { await client.init() // amqplib emits 'close' on the channel and the connection when they are closed. const handler = (target: { on: { mock: { calls: unknown[][] } } }, event: string) => target.on.mock.calls.findLast(([name]) => name === event)?.[1] as () => void mockChannel.close.mockImplementationOnce(async () => handler(mockChannel, 'close')()) mockConnection.close.mockImplementationOnce(async () => handler(mockConnection, 'close')()) await client.close() await vi.advanceTimersByTimeAsync(60_000) expect(vi.mocked(amqp.connect)).toHaveBeenCalledOnce() expect(client.isConnected()).toBe(false) }) it('should stop a retrying init and resubscribe nothing when closed mid-connect', async () => { const onReconnect = vi.fn() const c = new RabbitMQClient({ ...baseOptions, hooks: { onReconnect } }) vi.mocked(amqp.connect).mockRejectedValueOnce(new Error('down')) const init = c.init() init.catch(() => undefined) const closed = c.close() await vi.advanceTimersByTimeAsync(baseOptions.connectionRetryDelay) await closed await expect(init).rejects.toThrow('closed while connecting') expect(vi.mocked(amqp.connect)).toHaveBeenCalledOnce() expect(onReconnect).not.toHaveBeenCalled() expect(c.isConnected()).toBe(false) }) it('should ignore close events from a connection a reconnect replaced', async () => { await client.init() const staleClose = mockConnection.on.mock.calls.find(([name]) => name === 'close')![1] as () => void const freshConnection = { ...mockConnection, on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined) } vi.mocked(amqp.connect).mockResolvedValueOnce(freshConnection as unknown as Connection) staleClose() await vi.advanceTimersByTimeAsync(100) vi.mocked(amqp.connect).mockClear() staleClose() await vi.advanceTimersByTimeAsync(100) expect(vi.mocked(amqp.connect)).not.toHaveBeenCalled() expect(client.isConnected()).toBe(true) }) }) describe('isConnected()', () => { it('should return false before init', () => { expect(client.isConnected()).toBe(false) }) it('should return true after init', async () => { await client.init() expect(client.isConnected()).toBe(true) }) it('should return false after close', async () => { await client.init() await client.close() expect(client.isConnected()).toBe(false) }) }) describe('publish()', () => { beforeEach(async () => { await client.init() }) it('should assert exchange, publish with persistent flag and timestamp, and confirm', async () => { vi.setSystemTime(1757000000500) await client.publish('test-exchange', 'test.key', { foo: 'bar' }) expect(mockChannel.assertExchange).toHaveBeenCalledWith('test-exchange', 'topic', { durable: true }) expect(mockChannel.publish).toHaveBeenCalledOnce() const [exchange, routingKey, content, options] = mockChannel.publish.mock.calls[0] expect(exchange).toBe('test-exchange') expect(routingKey).toBe('test.key') expect(JSON.parse(content.toString())).toEqual({ foo: 'bar' }) expect(options).toEqual({ persistent: true, timestamp: 1757000000 }) expect(mockChannel.waitForConfirms).toHaveBeenCalledOnce() }) it('should publish straight to a queue through the default exchange without declaring it', async () => { await client.publish('', 'orders.queue', { foo: 'bar' }) expect(mockChannel.assertExchange).not.toHaveBeenCalled() expect(mockChannel.publish).toHaveBeenCalledWith('', 'orders.queue', expect.any(Buffer), expect.any(Object)) expect(mockChannel.waitForConfirms).toHaveBeenCalledOnce() }) describe('mandatory', () => { /** Delivers a basic.return to the channel, as the broker does for an unroutable mandatory message. */ function emitReturn(msg: { fields: object; content: Buffer; properties: object }) { mockChannel.on.mock.calls.findLast(([event]) => event === 'return')?.[1](msg) } function returnEveryPublish() { mockChannel.publish.mockImplementation(((exchange: string, routingKey: string, content: Buffer) => { emitReturn({ fields: { exchange, routingKey, replyCode: 312 }, content, properties: {} }) return true }) as typeof mockChannel.publish) } it('should reject an unroutable message without retrying or reconnecting', async () => { returnEveryPublish() await expect(client.publish('', 'gone.queue', { foo: 'bar' }, { mandatory: true })).rejects.toBeInstanceOf( UnroutableMessageError, ) expect(mockChannel.publish).toHaveBeenCalledOnce() expect(mockChannel.publish.mock.calls[0][3]).toMatchObject({ mandatory: true }) expect(vi.mocked(amqp.connect)).toHaveBeenCalledOnce() }) it('should resolve when the message was routed', async () => { await client.publish('', 'orders.queue', { foo: 'bar' }, { mandatory: true }) expect(mockChannel.waitForConfirms).toHaveBeenCalledOnce() }) it('should ignore the return of the same message id sent to another queue', async () => { mockChannel.publish.mockImplementation((() => { emitReturn({ fields: { exchange: '', routingKey: 'other.queue' }, content: Buffer.from('{}'), properties: { messageId: 'a' } }) return true }) as typeof mockChannel.publish) await expect( client.publish('', 'orders.queue', { foo: 'bar' }, { mandatory: true, messageId: 'a' }), ).resolves.toBeUndefined() }) it('should tell two in-flight messages with the same content apart by message id', async () => { const confirms: (() => void)[] = [] mockChannel.waitForConfirms.mockImplementation(() => new Promise((resolve) => confirms.push(resolve))) const first = client.publish('', 'orders.queue', { foo: 'bar' }, { mandatory: true, messageId: 'a' }) const second = client.publish('', 'orders.queue', { foo: 'bar' }, { mandatory: true, messageId: 'b' }) await vi.advanceTimersByTimeAsync(0) emitReturn({ fields: { exchange: '', routingKey: 'orders.queue', replyCode: 312 }, content: Buffer.from(JSON.stringify({ foo: 'bar' })), properties: { messageId: 'a' }, }) for (const confirm of confirms) confirm() await expect(first).rejects.toBeInstanceOf(UnroutableMessageError) await expect(second).resolves.toBeUndefined() expect(mockChannel.on.mock.calls.filter(([event]) => event === 'return')).toHaveLength(1) }) it('should resolve an ordinary publish the broker returns', async () => { returnEveryPublish() await expect(client.publish('ex', 'key', { foo: 'bar' })).resolves.toBeUndefined() }) }) it('should retry with exponential backoff on failure', async () => { mockChannel.waitForConfirms .mockRejectedValueOnce(new Error('confirm failed')) .mockResolvedValueOnce(undefined) const publishPromise = client.publish('ex', 'key', { msg: 1 }) await vi.advanceTimersByTimeAsync(200) await publishPromise // First attempt failed, second succeeded after reconnect expect(vi.mocked(amqp.connect)).toHaveBeenCalledTimes(2) }) it('should keep the first attempt timestamp across retries', async () => { // One millisecond before a second boundary, so the retry lands in the next second vi.setSystemTime(1757000000999) mockChannel.waitForConfirms .mockRejectedValueOnce(new Error('confirm failed')) .mockResolvedValueOnce(undefined) const publishPromise = client.publish('ex', 'key', { msg: 1 }) await vi.advanceTimersByTimeAsync(5000) await publishPromise const timestamps = mockChannel.publish.mock.calls.map((call) => call[3].timestamp) expect(timestamps).toEqual([1757000000, 1757000000]) }) it('should throw after exhausting publishMaxAttempts', async () => { mockChannel.waitForConfirms.mockRejectedValue(new Error('confirm failed')) // Override to keep failing on new channels too mockConnection.createConfirmChannel.mockImplementation(() => { const failChannel = { ...mockChannel, waitForConfirms: vi.fn().mockRejectedValue(new Error('confirm failed')) } return Promise.resolve(failChannel) }) // Attach the rejection handler before advancing timers to avoid // a brief "unhandled rejection" window during fake timer advancement. const assertion = expect(client.publish('ex', 'key', { msg: 1 }, { maxAttempts: 2 })).rejects.toThrow(/2 attempts/) await vi.advanceTimersByTimeAsync(500) await assertion // Restore for other tests mockConnection.createConfirmChannel.mockResolvedValue(mockChannel) }) it('should force reconnect on publish error (dead channel detection)', async () => { mockChannel.waitForConfirms.mockRejectedValueOnce(new Error('dead')) const publishPromise = client.publish('ex', 'key', { msg: 1 }) await vi.advanceTimersByTimeAsync(200) await publishPromise // connected was set to false, forcing reconnection expect(vi.mocked(amqp.connect)).toHaveBeenCalledTimes(2) }) }) describe('subscribe()', () => { beforeEach(async () => { await client.init() }) it('should set up DLQ infrastructure', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('test-exchange', 'test.key', 'test-queue', handler) expect(mockChannel.assertExchange).toHaveBeenCalledWith('test-exchange.dlx', 'topic', { durable: true }) expect(mockChannel.assertQueue).toHaveBeenCalledWith('test-queue.dlq', { durable: true }) expect(mockChannel.bindQueue).toHaveBeenCalledWith('test-queue.dlq', 'test-exchange.dlx', 'test.key.dead') }) it('should create main queue with quorum type and DLQ config', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('test-exchange', 'test.key', 'test-queue', handler) expect(mockChannel.assertQueue).toHaveBeenCalledWith('test-queue', { durable: true, deadLetterExchange: 'test-exchange.dlx', deadLetterRoutingKey: 'test.key.dead', arguments: { 'x-dead-letter-strategy': 'at-least-once', 'x-queue-type': 'quorum', 'x-overflow': 'reject-publish', }, }) }) it('should bind queue and start consuming with manual ack', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('test-exchange', 'test.key', 'test-queue', handler) expect(mockChannel.bindQueue).toHaveBeenCalledWith('test-queue', 'test-exchange', 'test.key') expect(mockChannel.consume).toHaveBeenCalledOnce() expect(mockChannel.consume.mock.calls[0][0]).toBe('test-queue') expect(mockChannel.consume.mock.calls[0][2]).toEqual({ noAck: false }) }) it('should throw when not connected', async () => { await client.close() const handler = vi.fn().mockResolvedValue(undefined) await expect(client.subscribe('ex', 'key', 'queue', handler)).rejects.toThrow('not connected') }) it('should not duplicate subscriptions for the same queue', async () => { const handler1 = vi.fn().mockResolvedValue(undefined) const handler2 = vi.fn().mockResolvedValue(undefined) await client.subscribe('ex', 'key', 'queue', handler1) await client.subscribe('ex', 'key', 'queue', handler2) expect(mockChannel.consume).toHaveBeenCalledTimes(2) }) }) describe('handleWithRetry()', () => { let deliver: (msg: unknown) => void beforeEach(async () => { deliver = setupConsumeCapture() await client.init() }) it('should ack on successful processing', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('ex', 'key', 'queue', handler) const msg = createMessage({ foo: 'bar' }) deliver(msg) await vi.advanceTimersByTimeAsync(50) expect(handler).toHaveBeenCalledWith({ foo: 'bar' }, { exchange: 'ex', routingKey: 'key', headers: {} }) expect(mockChannel.ack).toHaveBeenCalledWith(msg) }) it('should pass the delivery properties to the handler', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('ex', 'key', 'queue', handler) const xDeath = [{ count: 1, reason: 'rejected', queue: 'queue', time: { '!': 'timestamp', value: 1757000000 } }] const msg = { ...createMessage({ foo: 'bar' }), properties: { headers: { 'x-death': xDeath }, timestamp: 1757000000, messageId: 'm-1', correlationId: 'c-1' }, } deliver(msg) await vi.advanceTimersByTimeAsync(50) expect(handler).toHaveBeenCalledWith({ foo: 'bar' }, { exchange: 'ex', routingKey: 'key', headers: { 'x-death': xDeath }, timestamp: 1757000000, messageId: 'm-1', correlationId: 'c-1', }) }) it('should pass empty headers when the delivery carries none', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('ex', 'key', 'queue', handler) deliver({ ...createMessage({ foo: 'bar' }), properties: {} }) await vi.advanceTimersByTimeAsync(50) expect(handler).toHaveBeenCalledWith({ foo: 'bar' }, { exchange: 'ex', routingKey: 'key', headers: {} }) }) it('should nack invalid JSON immediately to DLQ', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('ex', 'key', 'queue', handler) const msg = createMessage('not valid json {') deliver(msg) await vi.advanceTimersByTimeAsync(50) expect(handler).not.toHaveBeenCalled() expect(mockChannel.nack).toHaveBeenCalledWith(msg, false, false) }) it('should retry on handler failure and ack on eventual success', async () => { const handler = vi.fn() .mockRejectedValueOnce(new Error('fail')) .mockResolvedValueOnce(undefined) await client.subscribe('ex', 'key', 'queue', handler) const msg = createMessage({ foo: 'bar' }) deliver(msg) await vi.advanceTimersByTimeAsync(200) expect(handler).toHaveBeenCalledTimes(2) expect(mockChannel.ack).toHaveBeenCalledWith(msg) expect(mockChannel.nack).not.toHaveBeenCalled() }) it('should nack to DLQ after exhausting maxRetries', async () => { const handler = vi.fn().mockRejectedValue(new Error('always fails')) await client.subscribe('ex', 'key', 'queue', handler) const msg = createMessage({ foo: 'bar' }) deliver(msg) await vi.advanceTimersByTimeAsync(500) expect(handler).toHaveBeenCalledTimes(3) // maxRetries = 3 expect(mockChannel.ack).not.toHaveBeenCalled() expect(mockChannel.nack).toHaveBeenCalledWith(msg, false, false) }) it('should nack to DLQ at once on DeadLetterError', async () => { const onMessageDlq = vi.fn() client = new RabbitMQClient({ ...baseOptions, hooks: { onMessageDlq } }) await client.init() const handler = vi.fn().mockRejectedValue(new DeadLetterError('bad event')) await client.subscribe('ex', 'key', 'queue', handler) const msg = createMessage({ foo: 'bar' }) deliver(msg) await vi.advanceTimersByTimeAsync(500) expect(handler).toHaveBeenCalledOnce() expect(mockChannel.nack).toHaveBeenCalledWith(msg, false, false) expect(onMessageDlq).toHaveBeenCalledWith(expect.objectContaining({ reason: 'dead_letter_error' })) }) it('should use the subscription maxRetries', async () => { const handler = vi.fn().mockRejectedValue(new Error('always fails')) await client.subscribe('ex', 'key', 'queue', handler, { maxRetries: 5 }) deliver(createMessage({ foo: 'bar' })) await vi.advanceTimersByTimeAsync(500) expect(handler).toHaveBeenCalledTimes(5) expect(mockChannel.nack).toHaveBeenCalledOnce() }) it('should double the retry delay up to maxRetryDelay', async () => { const handler = vi.fn().mockRejectedValue(new Error('db down')) await client.subscribe('ex', 'key', 'queue', handler, { maxRetries: Infinity, maxRetryDelay: 40 }) deliver(createMessage({ foo: 'bar' })) // retryDelay is 10: attempts at 0, 10, 30, 70, 110 (capped at 40 from then on) await vi.advanceTimersByTimeAsync(0) expect(handler).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(10) expect(handler).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(20) expect(handler).toHaveBeenCalledTimes(3) await vi.advanceTimersByTimeAsync(40) expect(handler).toHaveBeenCalledTimes(4) await vi.advanceTimersByTimeAsync(40) expect(handler).toHaveBeenCalledTimes(5) expect(mockChannel.nack).not.toHaveBeenCalled() }) it('should stop an unbounded retry on close and leave the message unacked', async () => { const handler = vi.fn().mockRejectedValue(new Error('db down')) await client.subscribe('ex', 'key', 'queue', handler, { maxRetries: Infinity, maxRetryDelay: 60_000 }) deliver(createMessage({ foo: 'bar' })) await vi.advanceTimersByTimeAsync(100) const calls = handler.mock.calls.length await client.close() await vi.advanceTimersByTimeAsync(120_000) expect(handler).toHaveBeenCalledTimes(calls) expect(mockChannel.ack).not.toHaveBeenCalled() expect(mockChannel.nack).not.toHaveBeenCalled() }) }) describe('checkHealth()', () => { it('should return false when not connected', async () => { expect(await client.checkHealth()).toBe(false) }) it('should return true when connection is healthy', async () => { await client.init() expect(await client.checkHealth()).toBe(true) expect(mockChannel.assertQueue).toHaveBeenCalled() expect(mockChannel.deleteQueue).toHaveBeenCalled() }) it('should return false when queue operation fails', async () => { await client.init() mockChannel.assertQueue.mockRejectedValueOnce(new Error('queue op failed')) expect(await client.checkHealth()).toBe(false) }) }) describe('reconnection', () => { it('should reconnect and restore subscriptions on connection close', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.init() await client.subscribe('ex', 'key', 'queue', handler) // Capture the connection close handler const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void // Reset mocks to track reconnection calls vi.mocked(amqp.connect).mockClear() mockChannel.consume.mockClear() // Trigger connection close closeHandler() await vi.advanceTimersByTimeAsync(200) // Should have reconnected expect(amqp.connect).toHaveBeenCalledOnce() // Should have restored the subscription expect(mockChannel.consume).toHaveBeenCalledOnce() expect(mockChannel.consume.mock.calls[0][0]).toBe('queue') expect(client.isConnected()).toBe(true) }) it('should retry a subscription that fails to restore until it succeeds', async () => { await client.init() await client.subscribe('ex', 'key', 'queue', vi.fn()) const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void mockChannel.consume.mockClear() mockChannel.consume .mockRejectedValueOnce(new Error('NOT_FOUND')) .mockRejectedValueOnce(new Error('NOT_FOUND')) closeHandler() await vi.advanceTimersByTimeAsync(0) expect(mockChannel.consume).toHaveBeenCalledTimes(1) // connectionRetryDelay is 10 ms, doubled on each attempt. await vi.advanceTimersByTimeAsync(10) expect(mockChannel.consume).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(20) expect(mockChannel.consume).toHaveBeenCalledTimes(3) await vi.advanceTimersByTimeAsync(1000) expect(mockChannel.consume).toHaveBeenCalledTimes(3) }) it('should stop retrying a subscription once it is unsubscribed', async () => { await client.init() await client.subscribe('ex', 'key', 'queue', vi.fn()) const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void mockChannel.consume.mockClear() mockChannel.consume.mockRejectedValue(new Error('NOT_FOUND')) closeHandler() await vi.advanceTimersByTimeAsync(0) await client.unsubscribe('queue') await vi.advanceTimersByTimeAsync(1000) expect(mockChannel.consume).toHaveBeenCalledTimes(1) }) it('should stop retrying a subscription on close', async () => { await client.init() await client.subscribe('ex', 'key', 'queue', vi.fn()) const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void mockChannel.consume.mockClear() mockChannel.consume.mockRejectedValue(new Error('NOT_FOUND')) closeHandler() await vi.advanceTimersByTimeAsync(0) await client.close(false) await vi.advanceTimersByTimeAsync(1000) expect(mockChannel.consume).toHaveBeenCalledTimes(1) }) it('should stop retrying a subscription once a reconnect replaces its channel', async () => { await client.init() await client.subscribe('ex', 'key', 'queue', vi.fn()) const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void mockChannel.consume.mockClear() mockChannel.consume.mockRejectedValueOnce(new Error('NOT_FOUND')) closeHandler() await vi.advanceTimersByTimeAsync(0) expect(mockChannel.consume).toHaveBeenCalledTimes(1) mockConnection.createConfirmChannel.mockResolvedValueOnce({ ...mockChannel }) closeHandler() await vi.advanceTimersByTimeAsync(1000) expect(mockChannel.consume).toHaveBeenCalledTimes(2) }) it('should cancel a consumer restored for a queue unsubscribed meanwhile', async () => { await client.init() await client.subscribe('ex', 'key', 'queue', vi.fn()) const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void mockChannel.consume.mockImplementationOnce((async () => { await client.unsubscribe('queue') return { consumerTag: 'late' } }) as typeof mockChannel.consume) closeHandler() await vi.advanceTimersByTimeAsync(1000) expect(mockChannel.cancel).toHaveBeenCalledWith('late') }) it('should reconnect on channel error', async () => { await client.init() const channelErrorHandler = mockChannel.on.mock.calls.find( (call: unknown[]) => call[0] === 'error', )![1] as (err: Error) => void vi.mocked(amqp.connect).mockClear() channelErrorHandler(new Error('channel died')) await vi.advanceTimersByTimeAsync(200) expect(amqp.connect).toHaveBeenCalledOnce() expect(client.isConnected()).toBe(true) }) it('should reconnect on channel close', async () => { await client.init() const channelCloseHandler = mockChannel.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void vi.mocked(amqp.connect).mockClear() channelCloseHandler() await vi.advanceTimersByTimeAsync(200) expect(amqp.connect).toHaveBeenCalledOnce() expect(client.isConnected()).toBe(true) }) it('should reconnect again when the channel fails while subscriptions are being restored', async () => { await client.init() await client.subscribe('ex', 'key', 'queue', vi.fn()) const connectionCloseHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void const channelCloseHandler = mockChannel.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void vi.mocked(amqp.connect).mockClear() mockChannel.consume.mockClear() mockChannel.consume.mockImplementationOnce((() => { channelCloseHandler() return Promise.reject(new Error('Channel ended, no reply will be forthcoming')) }) as typeof mockChannel.consume) connectionCloseHandler() await vi.advanceTimersByTimeAsync(0) expect(client.isConnected()).toBe(false) await vi.advanceTimersByTimeAsync(10) expect(amqp.connect).toHaveBeenCalledTimes(2) expect(client.isConnected()).toBe(true) }) it('should back off between reconnects while the channel keeps failing during restore', async () => { await client.init() await client.subscribe('ex', 'key', 'queue', vi.fn()) const connectionCloseHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void const channelCloseHandler = mockChannel.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void vi.mocked(amqp.connect).mockClear() mockChannel.consume.mockImplementation((() => { channelCloseHandler() return Promise.reject(new Error('Channel ended, no reply will be forthcoming')) }) as typeof mockChannel.consume) connectionCloseHandler() await vi.advanceTimersByTimeAsync(0) expect(amqp.connect).toHaveBeenCalledTimes(1) // connectionRetryDelay is 10 ms, doubled on each attempt. await vi.advanceTimersByTimeAsync(10) expect(amqp.connect).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(20) expect(amqp.connect).toHaveBeenCalledTimes(3) await vi.advanceTimersByTimeAsync(39) expect(amqp.connect).toHaveBeenCalledTimes(3) await vi.advanceTimersByTimeAsync(1) expect(amqp.connect).toHaveBeenCalledTimes(4) await client.close() }) it('should stop reconnecting at once when closed between attempts', async () => { await client.init() await client.subscribe('ex', 'key', 'queue', vi.fn()) const connectionCloseHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void const channelCloseHandler = mockChannel.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void vi.mocked(amqp.connect).mockClear() mockChannel.consume.mockImplementationOnce((() => { channelCloseHandler() return Promise.reject(new Error('Channel ended, no reply will be forthcoming')) }) as typeof mockChannel.consume) connectionCloseHandler() await vi.advanceTimersByTimeAsync(0) mockChannel.close.mockRejectedValueOnce(new Error('Channel closed')) // Resolves without advancing the timers, so it did not wait for the backoff. await client.close() await vi.advanceTimersByTimeAsync(1000) expect(amqp.connect).toHaveBeenCalledOnce() }) it('should preserve subscriptions across close(false) and re-init', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.init() await client.subscribe('ex', 'key', 'queue', handler) await client.close(false) mockChannel.consume.mockClear() await client.init() // Trigger reconnection to test subscription restoration const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void mockChannel.consume.mockClear() closeHandler() await vi.advanceTimersByTimeAsync(200) expect(mockChannel.consume).toHaveBeenCalledOnce() expect(mockChannel.consume.mock.calls[0][0]).toBe('queue') }) it('should clear subscriptions on close() by default', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.init() await client.subscribe('ex', 'key', 'queue', handler) await client.close() // clears subscriptions await client.init() mockChannel.consume.mockClear() // Trigger reconnection const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void closeHandler() await vi.advanceTimersByTimeAsync(200) // No subscriptions to restore expect(mockChannel.consume).not.toHaveBeenCalled() }) it('should re-subscribe when the broker cancels the consumer', async () => { const deliver = setupConsumeCapture() await client.init() await client.subscribe('ex', 'key', 'queue', vi.fn()) mockChannel.consume.mockClear() deliver(null) await vi.advanceTimersByTimeAsync(0) expect(mockChannel.consume).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(10) expect(mockChannel.consume).toHaveBeenCalledOnce() expect(mockChannel.consume.mock.calls[0][0]).toBe('queue') }) it('should not cancel a consumer the broker already cancelled', async () => { const deliver = setupConsumeCapture() await client.init() await client.subscribe('ex', 'key', 'queue', vi.fn()) deliver(null) await client.unsubscribe('queue') await vi.advanceTimersByTimeAsync(1000) expect(mockChannel.cancel).not.toHaveBeenCalled() expect(mockChannel.consume).toHaveBeenCalledOnce() }) }) describe('unsubscribe()', () => { beforeEach(async () => { await client.init() }) it('should cancel consumer and remove subscription', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('ex', 'key', 'queue', handler) await client.unsubscribe('queue') expect(mockChannel.cancel).toHaveBeenCalledWith('test') }) it('should cancel the consumer of an exclusive queue by the queue passed', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('live', '#', 'live', handler, { exclusive: true }) await client.unsubscribe('live') expect(mockChannel.cancel).toHaveBeenCalledWith('test') }) it('should not restore unsubscribed queue on reconnect', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('ex', 'key', 'queue', handler) await client.unsubscribe('queue') const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void mockChannel.consume.mockClear() closeHandler() await vi.advanceTimersByTimeAsync(200) expect(mockChannel.consume).not.toHaveBeenCalled() }) it('should not throw for unknown queue', async () => { await expect(client.unsubscribe('nonexistent')).resolves.toBeUndefined() }) }) describe('subscribe() with options', () => { beforeEach(async () => { await client.init() }) it('should merge custom queue arguments with defaults', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('ex', 'key', 'queue', handler, { queueArguments: { 'x-queue-type': 'classic', 'x-max-length': 1000 }, }) expect(mockChannel.assertQueue).toHaveBeenCalledWith('queue', { durable: true, deadLetterExchange: 'ex.dlx', deadLetterRoutingKey: 'key.dead', arguments: { 'x-queue-type': 'classic', 'x-overflow': 'reject-publish', 'x-max-length': 1000, }, }) }) it('should bind one queue to several exchanges', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('space', 'twake.space.#', 'queue', handler, { bindings: [ { exchange: 'b2b', routingKey: 'domain.user.deleted' }, { exchange: 'auth', routingKey: 'user.deleted' }, ], }) expect(mockChannel.assertExchange).toHaveBeenCalledWith('b2b', 'topic', { durable: true }) expect(mockChannel.assertExchange).toHaveBeenCalledWith('auth', 'topic', { durable: true }) expect(mockChannel.bindQueue).toHaveBeenCalledWith('queue', 'space', 'twake.space.#') expect(mockChannel.bindQueue).toHaveBeenCalledWith('queue', 'b2b', 'domain.user.deleted') expect(mockChannel.bindQueue).toHaveBeenCalledWith('queue', 'auth', 'user.deleted') expect(mockChannel.consume).toHaveBeenCalledOnce() }) it('should use the given dead letter exchange', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('space', 'twake.space.#', 'queue', handler, { deadLetterExchange: 'queue.dlx', }) expect(mockChannel.assertExchange).toHaveBeenCalledWith('queue.dlx', 'topic', { durable: true }) expect(mockChannel.assertExchange).not.toHaveBeenCalledWith('space.dlx', 'topic', { durable: true }) expect(mockChannel.bindQueue).toHaveBeenCalledWith('queue.dlq', 'queue.dlx', 'twake.space.#.dead') expect(mockChannel.assertQueue).toHaveBeenCalledWith('queue', expect.objectContaining({ deadLetterExchange: 'queue.dlx', })) }) it('should check source exchanges instead of declaring them when passive', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('space', 'twake.space.#', 'queue', handler, { bindings: [{ exchange: 'b2b', routingKey: 'domain.user.deleted' }], passiveExchanges: true, }) expect(mockProbeChannel.checkExchange).toHaveBeenCalledWith('space') expect(mockProbeChannel.checkExchange).toHaveBeenCalledWith('b2b') expect(mockProbeChannel.close).toHaveBeenCalledOnce() expect(mockChannel.assertExchange).not.toHaveBeenCalledWith('space', 'topic', { durable: true }) expect(mockChannel.assertExchange).not.toHaveBeenCalledWith('b2b', 'topic', { durable: true }) }) it('should check only the listed exchanges and declare the others', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('space', 'twake.space.#', 'queue', handler, { bindings: [{ exchange: 'activity', routingKey: '#' }], passiveExchanges: ['space'], }) expect(mockProbeChannel.checkExchange).toHaveBeenCalledWith('space') expect(mockProbeChannel.checkExchange).not.toHaveBeenCalledWith('activity') expect(mockChannel.assertExchange).toHaveBeenCalledWith('activity', 'topic', { durable: true }) expect(mockChannel.assertExchange).not.toHaveBeenCalledWith('space', 'topic', { durable: true }) }) it('should fail to subscribe when a passive exchange is missing, and not restore it on reconnection', async () => { mockProbeChannel.checkExchange.mockRejectedValueOnce(new Error('NOT_FOUND - no exchange')) const handler = vi.fn().mockResolvedValue(undefined) await expect(client.subscribe('admin-panel', 'dns.validated', 'queue', handler, { passiveExchanges: true, })).rejects.toThrow('NOT_FOUND') expect(mockChannel.consume).not.toHaveBeenCalled() expect(mockChannel.close).not.toHaveBeenCalled() const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void closeHandler() await vi.advanceTimersByTimeAsync(200) expect(mockProbeChannel.checkExchange).toHaveBeenCalledOnce() expect(mockChannel.consume).not.toHaveBeenCalled() }) it('should restore every binding after reconnection', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('space', 'twake.space.#', 'queue', handler, { bindings: [{ exchange: 'b2b', routingKey: 'domain.user.deleted' }], }) const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void mockChannel.bindQueue.mockClear() closeHandler() await vi.advanceTimersByTimeAsync(200) expect(mockChannel.bindQueue).toHaveBeenCalledWith('queue', 'space', 'twake.space.#') expect(mockChannel.bindQueue).toHaveBeenCalledWith('queue', 'b2b', 'domain.user.deleted') }) it('should declare an exclusive queue under the queue name without dead letter wiring', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('live', '#', 'live', handler, { exclusive: true, queueArguments: { 'x-queue-type': 'quorum', 'x-max-length': 1000 }, }) expect(mockChannel.assertQueue).toHaveBeenCalledOnce() const [declared, options] = mockChannel.assertQueue.mock.calls[0] expect(declared).toMatch(/^live\.[0-9a-f-]{36}$/) expect(options).toEqual({ exclusive: true, durable: false, autoDelete: true, arguments: { 'x-queue-type': 'classic', 'x-max-length': 1000 }, }) expect(mockChannel.assertExchange).not.toHaveBeenCalledWith('live.dlx', 'topic', { durable: true }) expect(mockChannel.bindQueue).toHaveBeenCalledOnce() expect(mockChannel.bindQueue).toHaveBeenCalledWith(declared, 'live', '#') expect(mockChannel.consume).toHaveBeenCalledWith(declared, expect.any(Function), { noAck: false }) }) it('should declare an exclusive queue under a new name after reconnection', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('live', '#', 'live', handler, { exclusive: true }) const [first] = mockChannel.assertQueue.mock.calls[0] const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void mockChannel.assertQueue.mockClear() mockChannel.bindQueue.mockClear() closeHandler() await vi.advanceTimersByTimeAsync(200) const [second, options] = mockChannel.assertQueue.mock.calls[0] expect(second).toMatch(/^live\.[0-9a-f-]{36}$/) expect(second).not.toBe(first) expect(options).toMatchObject({ exclusive: true, durable: false, autoDelete: true, arguments: { 'x-queue-type': 'classic' }, }) expect(mockChannel.bindQueue).toHaveBeenCalledWith(second, 'live', '#') }) it('should drop a failed message of an exclusive queue without the DLQ hook', async () => { const onMessageDlq = vi.fn() client = new RabbitMQClient({ ...baseOptions, hooks: { onMessageDlq } }) await client.init() const deliver = setupConsumeCapture() const handler = vi.fn().mockRejectedValue(new DeadLetterError('bad')) await client.subscribe('live', '#', 'live', handler, { exclusive: true }) deliver(createMessage({ users: [] })) await vi.advanceTimersByTimeAsync(0) expect(mockChannel.nack).toHaveBeenCalledWith(expect.anything(), false, false) expect(onMessageDlq).not.toHaveBeenCalled() }) it('should preserve custom options across reconnection', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.subscribe('ex', 'key', 'queue', handler, { queueArguments: { 'x-queue-type': 'classic' }, }) const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void mockChannel.assertQueue.mockClear() closeHandler() await vi.advanceTimersByTimeAsync(200) expect(mockChannel.assertQueue).toHaveBeenCalledWith('queue', expect.objectContaining({ arguments: expect.objectContaining({ 'x-queue-type': 'classic' }), })) }) }) describe('exchange assertion caching', () => { beforeEach(async () => { await client.init() }) it('should only assert exchange once across multiple publishes', async () => { mockChannel.assertExchange.mockClear() await client.publish('ex', 'key', { msg: 1 }) await client.publish('ex', 'key', { msg: 2 }) await client.publish('ex', 'key', { msg: 3 }) const assertCalls = mockChannel.assertExchange.mock.calls.filter( (call: unknown[]) => call[0] === 'ex', ) expect(assertCalls).toHaveLength(1) }) it('should re-assert exchange after reconnection', async () => { await client.publish('ex', 'key', { msg: 1 }) const closeHandler = mockConnection.on.mock.calls.find( (call: unknown[]) => call[0] === 'close', )![1] as () => void mockChannel.assertExchange.mockClear() closeHandler() await vi.advanceTimersByTimeAsync(200) await client.publish('ex', 'key', { msg: 2 }) const assertCalls = mockChannel.assertExchange.mock.calls.filter( (call: unknown[]) => call[0] === 'ex', ) expect(assertCalls).toHaveLength(1) }) }) describe('graceful shutdown', () => { it('should wait for in-flight messages before closing', async () => { let resolveHandler!: () => void const handler = vi.fn().mockImplementation( () => new Promise((resolve) => { resolveHandler = resolve }), ) const deliver = setupConsumeCapture() await client.init() await client.subscribe('ex', 'key', 'queue', handler) deliver(createMessage({ foo: 'bar' })) await vi.advanceTimersByTimeAsync(0) let closeResolved = false const closePromise = client.close().then(() => { closeResolved = true }) await vi.advanceTimersByTimeAsync(100) expect(closeResolved).toBe(false) resolveHandler() await vi.advanceTimersByTimeAsync(0) await closePromise expect(closeResolved).toBe(true) }) it('should close after timeout if messages are still in-flight', async () => { const handler = vi.fn().mockImplementation( () => new Promise(() => { /* never resolves */ }), ) const deliver = setupConsumeCapture() const shortTimeoutClient = new RabbitMQClient({ ...baseOptions, closeTimeout: 500 }) await shortTimeoutClient.init() await shortTimeoutClient.subscribe('ex', 'key', 'queue', handler) deliver(createMessage({ foo: 'bar' })) await vi.advanceTimersByTimeAsync(0) let closeResolved = false const closePromise = shortTimeoutClient.close().then(() => { closeResolved = true }) await vi.advanceTimersByTimeAsync(499) expect(closeResolved).toBe(false) await vi.advanceTimersByTimeAsync(1) await closePromise expect(closeResolved).toBe(true) }) }) describe('hooks', () => { it('should call onPublish after successful publish', async () => { const onPublish = vi.fn() const hookClient = new RabbitMQClient({ ...baseOptions, hooks: { onPublish } }) await hookClient.init() await hookClient.publish('ex', 'key', { msg: 1 }) expect(onPublish).toHaveBeenCalledWith({ exchange: 'ex', routingKey: 'key', attempts: 1 }) }) it('should call onMessageProcessed after handler success', async () => { const onMessageProcessed = vi.fn() const hookClient = new RabbitMQClient({ ...baseOptions, hooks: { onMessageProcessed } }) const deliver = setupConsumeCapture() await hookClient.init() const handler = vi.fn().mockResolvedValue(undefined) await hookClient.subscribe('ex', 'key', 'queue', handler) deliver(createMessage({ foo: 'bar' })) await vi.advanceTimersByTimeAsync(50) expect(onMessageProcessed).toHaveBeenCalledWith( expect.objectContaining({ exchange: 'ex', routingKey: 'key', attempts: 1 }), ) }) it('should call onMessageDlq when message sent to DLQ', async () => { const onMessageDlq = vi.fn() const hookClient = new RabbitMQClient({ ...baseOptions, hooks: { onMessageDlq } }) const deliver = setupConsumeCapture() await hookClient.init() const handler = vi.fn().mockRejectedValue(new Error('always fails')) await hookClient.subscribe('ex', 'key', 'queue', handler) deliver(createMessage({ foo: 'bar' })) await vi.advanceTimersByTimeAsync(500) expect(onMessageDlq).toHaveBeenCalledWith( expect.objectContaining({ exchange: 'ex', routingKey: 'key', reason: 'max_retries_exhausted' }), ) }) it('should call onMessageDlq for invalid JSON', async () => { const onMessageDlq = vi.fn() const hookClient = new RabbitMQClient({ ...baseOptions, hooks: { onMessageDlq } }) const deliver = setupConsumeCapture() await hookClient.init() const handler = vi.fn().mockResolvedValue(undefined) await hookClient.subscribe('ex', 'key', 'queue', handler) deliver(createMessage('not valid json')) await vi.advanceTimersByTimeAsync(50) expect(onMessageDlq).toHaveBeenCalledWith( expect.objectContaining({ reason: 'invalid_json', duration: 0 }), ) }) it('should not break when hook throws', async () => { const onPublish = vi.fn().mockImplementation(() => { throw new Error('hook error') }) const hookClient = new RabbitMQClient({ ...baseOptions, hooks: { onPublish } }) await hookClient.init() await expect(hookClient.publish('ex', 'key', { msg: 1 })).resolves.toBeUndefined() }) }) describe('processing concurrency', () => { // A handler that blocks until explicitly released, tracking how many are // running at once so we can assert the concurrency ceiling is respected. function blockingHandler() { let active = 0 let peak = 0 const releases: Array<() => void> = [] const handler = vi.fn().mockImplementation( () => new Promise((resolve) => { active++ peak = Math.max(peak, active) releases.push(() => { active-- resolve() }) }), ) return { handler, get active() { return active }, get peak() { return peak }, releaseOne() { releases.shift()?.() }, // Drain any still-blocked handlers so a test never leaves permanently // pending promises behind. releaseAll() { while (releases.length) releases.shift()!() }, } } it('caps concurrent handler execution at the configured concurrency', async () => { const c = new RabbitMQClient({ ...baseOptions, concurrency: 2 }) const deliver = setupConsumeCapture() await c.init() const h = blockingHandler() await c.subscribe('ex', 'key', 'queue', h.handler) for (let i = 0; i < 5; i++) deliver(createMessage({ n: i })) await vi.advanceTimersByTimeAsync(0) // Only two run; the other three wait on a permit. expect(h.handler).toHaveBeenCalledTimes(2) expect(h.active).toBe(2) // Releasing one frees a permit, so exactly one queued message starts. h.releaseOne() await vi.advanceTimersByTimeAsync(0) expect(h.handler).toHaveBeenCalledTimes(3) expect(h.active).toBe(2) h.releaseOne(); h.releaseOne(); h.releaseOne(); h.releaseOne() await vi.advanceTimersByTimeAsync(0) expect(h.handler).toHaveBeenCalledTimes(5) expect(h.peak).toBe(2) }) it('defaults concurrency to the prefetch value', async () => { // baseOptions sets prefetch 10 and no explicit concurrency. const deliver = setupConsumeCapture() await client.init() const h = blockingHandler() await client.subscribe('ex', 'key', 'queue', h.handler) for (let i = 0; i < 12; i++) deliver(createMessage({ n: i })) await vi.advanceTimersByTimeAsync(0) expect(h.handler).toHaveBeenCalledTimes(10) expect(h.peak).toBe(10) h.releaseAll() }) it('lets a subscription override the client concurrency', async () => { const c = new RabbitMQClient({ ...baseOptions, concurrency: 5 }) const deliver = setupConsumeCapture() await c.init() const h = blockingHandler() await c.subscribe('ex', 'key', 'queue', h.handler, { concurrency: 1 }) for (let i = 0; i < 3; i++) deliver(createMessage({ n: i })) await vi.advanceTimersByTimeAsync(0) expect(h.handler).toHaveBeenCalledTimes(1) expect(h.active).toBe(1) h.releaseAll() }) it('drains messages queued behind a full semaphore on graceful close()', async () => { // concurrency 1 forces messages 2 and 3 to wait on a permit; a long // closeTimeout ensures the test controls completion, not the timer. const c = new RabbitMQClient({ ...baseOptions, concurrency: 1, closeTimeout: 60_000 }) const deliver = setupConsumeCapture() await c.init() const h = blockingHandler() await c.subscribe('ex', 'key', 'queue', h.handler) for (let i = 0; i < 3; i++) deliver(createMessage({ n: i })) await vi.advanceTimersByTimeAsync(0) expect(h.handler).toHaveBeenCalledTimes(1) let closed = false const closePromise = c.close().then(() => { closed = true }) // close() must not resolve until the permit-queued messages have run too. h.releaseOne() await vi.advanceTimersByTimeAsync(0) expect(h.handler).toHaveBeenCalledTimes(2) expect(closed).toBe(false) h.releaseOne() await vi.advanceTimersByTimeAsync(0) expect(h.handler).toHaveBeenCalledTimes(3) expect(closed).toBe(false) h.releaseOne() await vi.advanceTimersByTimeAsync(0) await closePromise expect(closed).toBe(true) }) it('treats concurrency <= 0 (and an unlimited prefetch: 0) as no limit', async () => { for (const opts of [{ concurrency: 0 }, { prefetch: 0 }]) { const c = new RabbitMQClient({ ...baseOptions, ...opts }) const deliver = setupConsumeCapture() await c.init() const h = blockingHandler() // Must not throw building an absent limiter (regression guard for // new Semaphore(0)). await expect(c.subscribe('ex', 'key', 'queue', h.handler)).resolves.toBeUndefined() for (let i = 0; i < 15; i++) deliver(createMessage({ n: i })) await vi.advanceTimersByTimeAsync(0) expect(h.handler).toHaveBeenCalledTimes(15) h.releaseAll() } }) }) }) linagora-rabbitmq-client-a8c84b7/test/logger.test.ts000066400000000000000000000070131526774056000226040ustar00rootroot00000000000000import { describe, it, expect, vi, afterEach } from 'vitest' import { Writable } from 'stream' import pino from 'pino' import { defaultLogger, forLibraryCalls, silentLogger } from '../src/logger.js' import type { ILogger } from '../src/types.js' describe('defaultLogger', () => { afterEach(() => vi.restoreAllMocks()) it('writes warn and error to the console, prefixed', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const error = vi.spyOn(console, 'error').mockImplementation(() => {}) defaultLogger.warn('something off', { a: 1 }) defaultLogger.error('it failed', { b: 2 }) expect(warn).toHaveBeenCalledWith('[rabbitmq-client]', 'something off', { a: 1 }) expect(error).toHaveBeenCalledWith('[rabbitmq-client]', 'it failed', { b: 2 }) }) it('stays silent for info and debug', () => { const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const info = vi.spyOn(console, 'info').mockImplementation(() => {}) const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}) defaultLogger.info('routine', { x: 1 }) defaultLogger.debug('verbose', { y: 2 }) expect(log).not.toHaveBeenCalled() expect(info).not.toHaveBeenCalled() expect(debug).not.toHaveBeenCalled() }) }) describe('silentLogger', () => { afterEach(() => vi.restoreAllMocks()) it('emits nothing on any level', () => { const spies = (['log', 'info', 'debug', 'warn', 'error'] as const).map((m) => vi.spyOn(console, m).mockImplementation(() => {}), ) silentLogger.debug('a') silentLogger.info('b') silentLogger.warn('c') silentLogger.error('d') spies.forEach((spy) => expect(spy).not.toHaveBeenCalled()) }) }) describe('ILogger compatibility', () => { it('console satisfies the contract', () => { // Type-level assertion: passes only because console matches ILogger. const logger: ILogger = console expect(typeof logger.error).toBe('function') }) it('a message-first custom logger receives args in order', () => { const calls: unknown[][] = [] const custom: ILogger = { debug: (...a) => calls.push(['debug', ...a]), info: (...a) => calls.push(['info', ...a]), warn: (...a) => calls.push(['warn', ...a]), error: (...a) => calls.push(['error', ...a]), } custom.error('Connection error', { error: 'boom' }) expect(calls).toEqual([['error', 'Connection error', { error: 'boom' }]]) }) }) describe('forLibraryCalls', () => { const capture = () => { const lines: Record[] = [] const stream = new Writable({ write(chunk, _enc, done) { lines.push(JSON.parse(chunk.toString())) done() }, }) return { lines, logger: pino({ level: 'debug' }, stream) } } it('keeps the context of a pino logger, with errors under err', () => { const { lines, logger } = capture() forLibraryCalls(logger).error('Handler failed', { routingKey: 'user.deleted', error: new Error('boom') }) expect(lines[0]).toMatchObject({ msg: 'Handler failed', routingKey: 'user.deleted', err: { type: 'Error', message: 'boom' }, }) }) it('logs a pino message that carries no context', () => { const { lines, logger } = capture() forLibraryCalls(logger).info('Connected to server') expect(lines[0]).toMatchObject({ msg: 'Connected to server' }) }) it('leaves a message-first logger untouched', () => { expect(forLibraryCalls(console)).toBe(console) expect(forLibraryCalls(silentLogger)).toBe(silentLogger) }) }) linagora-rabbitmq-client-a8c84b7/test/semaphore.test.ts000066400000000000000000000041031526774056000233050ustar00rootroot00000000000000import { describe, it, expect } from 'vitest' import { Semaphore } from '../src/semaphore.js' describe('Semaphore', () => { it('rejects a non-positive or non-integer permit count', () => { expect(() => new Semaphore(0)).toThrow() expect(() => new Semaphore(-1)).toThrow() expect(() => new Semaphore(1.5)).toThrow() }) it('grants permits immediately while capacity is available', async () => { const sem = new Semaphore(2) await sem.acquire() await sem.acquire() expect(sem.available).toBe(0) expect(sem.waiting).toBe(0) }) it('queues acquirers beyond capacity until a permit is released', async () => { const sem = new Semaphore(1) await sem.acquire() let secondAcquired = false const second = sem.acquire().then(() => { secondAcquired = true }) // Still blocked: the only permit is held. await Promise.resolve() expect(secondAcquired).toBe(false) expect(sem.waiting).toBe(1) sem.release() await second expect(secondAcquired).toBe(true) expect(sem.waiting).toBe(0) }) it('hands a released permit to waiters in FIFO order', async () => { const sem = new Semaphore(1) await sem.acquire() const order: number[] = [] const first = sem.acquire().then(() => order.push(1)) const second = sem.acquire().then(() => order.push(2)) sem.release() await first sem.release() await second expect(order).toEqual([1, 2]) }) it('never exceeds its permit ceiling under a burst', async () => { const CONCURRENCY = 3 const sem = new Semaphore(CONCURRENCY) let active = 0 let peak = 0 const task = async () => { await sem.acquire() try { active++ peak = Math.max(peak, active) await Promise.resolve() } finally { active-- sem.release() } } await Promise.all(Array.from({ length: 20 }, task)) // Exactly the ceiling: never over (correctness) and actually reached, so an // over-serializing regression (peak < ceiling) is caught too. expect(peak).toBe(CONCURRENCY) }) }) linagora-rabbitmq-client-a8c84b7/test/settle-failure.test.ts000066400000000000000000000137471526774056000242650ustar00rootroot00000000000000import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { Connection, ConfirmChannel } from 'amqplib' // A channel factory, unlike the shared singleton in client.test.ts, so a // reconnect can hand the client a genuinely different channel object. function createChannel(name: string) { const listeners = new Map void>() const ch = { name, isClosed: false, prefetch: vi.fn().mockResolvedValue(undefined), on: vi.fn((event: string, fn: (...args: unknown[]) => void) => { listeners.set(event, fn) return ch }), close: vi.fn().mockResolvedValue(undefined), assertExchange: vi.fn().mockResolvedValue({}), assertQueue: vi.fn().mockResolvedValue({ queue: 'test-queue' }), bindQueue: vi.fn().mockResolvedValue({}), publish: vi.fn().mockReturnValue(true), waitForConfirms: vi.fn().mockResolvedValue(undefined), consume: vi.fn(), deleteQueue: vi.fn().mockResolvedValue({}), cancel: vi.fn().mockResolvedValue(undefined), // amqplib's ConfirmChannel throws IllegalOperationError on a closed // channel; the mock in src/testing tracks isClosed but does not enforce it. ack: vi.fn(() => { if (ch.isClosed) throw new IllegalOperationError('Channel closed') }), nack: vi.fn(() => { if (ch.isClosed) throw new IllegalOperationError('Channel closed') }), // test-only helpers deliver: null as null | ((msg: unknown) => void), fire: (event: string, ...args: unknown[]) => listeners.get(event)?.(...args), } ch.consume.mockImplementation((_queue: string, fn: (msg: unknown) => void) => { ch.deliver = fn return Promise.resolve({ consumerTag: `tag-${name}` }) }) return ch } class IllegalOperationError extends Error { constructor(message: string) { super(message) this.name = 'IllegalOperationError' } } const { channels, mockConnection } = vi.hoisted(() => ({ channels: [] as ReturnType[], mockConnection: { createConfirmChannel: vi.fn(), on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined), } as unknown as Connection & Record>, })) vi.mock('amqplib', () => ({ default: { connect: vi.fn().mockResolvedValue(mockConnection) }, })) import amqp from 'amqplib' import { RabbitMQClient } from '../src/client.js' import { silentLogger } from '../src/logger.js' const baseOptions = { url: 'amqp://localhost', maxRetries: 3, retryDelay: 10, connectionRetryDelay: 10, prefetch: 1, logger: silentLogger, } const createMessage = (content: unknown) => ({ content: Buffer.from(JSON.stringify(content)), fields: { deliveryTag: 1, redelivered: false, exchange: 'ex', routingKey: 'key', consumerTag: 'test' }, properties: { headers: {} }, }) function deferred() { let resolve!: (v: T) => void let reject!: (e: unknown) => void const promise = new Promise((res, rej) => { resolve = res; reject = rej }) return { promise, resolve, reject } } describe('settle failures', () => { let client: RabbitMQClient beforeEach(() => { vi.clearAllMocks() channels.length = 0 mockConnection.createConfirmChannel.mockImplementation(() => { const ch = createChannel(`ch${channels.length + 1}`) channels.push(ch) return Promise.resolve(ch as unknown as ConfirmChannel) }) mockConnection.on.mockReturnThis() mockConnection.close.mockResolvedValue(undefined) vi.mocked(amqp.connect).mockResolvedValue(mockConnection as unknown as Connection) vi.useFakeTimers() client = new RabbitMQClient(baseOptions) }) afterEach(() => { vi.useRealTimers() }) // ------------------------------------------------------------------------- // A throwing ack must not be treated as a handler failure. // ------------------------------------------------------------------------- it('does not re-invoke the handler when only the ack fails', async () => { const handler = vi.fn().mockResolvedValue(undefined) await client.init() await client.subscribe('ex', 'key', 'queue', handler) const ch1 = channels[0] // Handler succeeds; the transport is what is broken. ch1.isClosed = true ch1.deliver!(createMessage({ foo: 'bar' })) await vi.advanceTimersByTimeAsync(500) // The handler ran once and succeeded. A dead socket is not a reason to // run its side effects again. expect(handler).toHaveBeenCalledTimes(1) }) // ------------------------------------------------------------------------- // The terminal nack must not leave the message silently unsettled. // ------------------------------------------------------------------------- it('reports a failed nack instead of claiming the message was dead-lettered', async () => { // Slow enough that the channel dies partway through the retry loop. const handler = vi.fn(async () => { await new Promise((r) => setTimeout(r, 100)) throw new Error('handler always fails') }) const onMessageDlq = vi.fn() const errors: string[] = [] const logger = { ...silentLogger, error: (msg: string) => { errors.push(msg) } } client = new RabbitMQClient({ ...baseOptions, logger, hooks: { onMessageDlq } }) await client.init() await client.subscribe('ex', 'key', 'queue', handler) const ch1 = channels[0] ch1.deliver!(createMessage({ foo: 'bar' })) await vi.advanceTimersByTimeAsync(120) // Channel dies before the retries are exhausted. ch1.isClosed = true await vi.advanceTimersByTimeAsync(1000) expect(handler).toHaveBeenCalledTimes(3) // The nack could not be delivered, so the message was NOT dead-lettered // and the hook must not claim otherwise. expect(onMessageDlq).not.toHaveBeenCalled() // The failure is reported as what it is, not swallowed as an unhandled // error escaping the handler. expect(errors).toContain('Failed to settle message; leaving it unacked for redelivery') expect(errors).not.toContain('Unhandled error in message handler') }) }) linagora-rabbitmq-client-a8c84b7/test/superseded-channel.test.ts000066400000000000000000000121221526774056000250730ustar00rootroot00000000000000import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { Connection, ConfirmChannel } from 'amqplib' class IllegalOperationError extends Error { constructor(message: string) { super(message) this.name = 'IllegalOperationError' } } // A channel factory, unlike the shared singleton in client.test.ts, so a // reconnect can hand the client a genuinely different channel object. function createChannel(name: string) { const listeners = new Map void>() const ch = { name, isClosed: false, prefetch: vi.fn().mockResolvedValue(undefined), on: vi.fn((event: string, fn: (...args: unknown[]) => void) => { listeners.set(event, fn) return ch }), close: vi.fn().mockResolvedValue(undefined), assertExchange: vi.fn().mockResolvedValue({}), assertQueue: vi.fn().mockResolvedValue({ queue: 'test-queue' }), bindQueue: vi.fn().mockResolvedValue({}), publish: vi.fn().mockReturnValue(true), waitForConfirms: vi.fn().mockResolvedValue(undefined), consume: vi.fn(), deleteQueue: vi.fn().mockResolvedValue({}), cancel: vi.fn().mockResolvedValue(undefined), // amqplib's ConfirmChannel throws IllegalOperationError on a closed // channel; the mock in src/testing tracks isClosed but does not enforce it. ack: vi.fn(() => { if (ch.isClosed) throw new IllegalOperationError('Channel closed') }), nack: vi.fn(() => { if (ch.isClosed) throw new IllegalOperationError('Channel closed') }), // test-only helpers deliver: null as null | ((msg: unknown) => void), fire: (event: string, ...args: unknown[]) => listeners.get(event)?.(...args), } ch.consume.mockImplementation((_queue: string, fn: (msg: unknown) => void) => { ch.deliver = fn return Promise.resolve({ consumerTag: `tag-${name}` }) }) return ch } const { channels, mockConnection } = vi.hoisted(() => ({ channels: [] as ReturnType[], mockConnection: { createConfirmChannel: vi.fn(), on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined), } as unknown as Connection & Record>, })) vi.mock('amqplib', () => ({ default: { connect: vi.fn().mockResolvedValue(mockConnection) }, })) import amqp from 'amqplib' import { RabbitMQClient } from '../src/client.js' import { silentLogger } from '../src/logger.js' const baseOptions = { url: 'amqp://localhost', maxRetries: 3, retryDelay: 10, connectionRetryDelay: 10, prefetch: 1, logger: silentLogger, } const createMessage = (content: unknown) => ({ content: Buffer.from(JSON.stringify(content)), fields: { deliveryTag: 1, redelivered: false, exchange: 'ex', routingKey: 'key', consumerTag: 'test' }, properties: { headers: {} }, }) function deferred() { let resolve!: (v: T) => void const promise = new Promise((res) => { resolve = res }) return { promise, resolve } } describe('superseded channel', () => { let client: RabbitMQClient beforeEach(() => { vi.clearAllMocks() channels.length = 0 mockConnection.createConfirmChannel.mockImplementation(() => { const ch = createChannel(`ch${channels.length + 1}`) channels.push(ch) return Promise.resolve(ch as unknown as ConfirmChannel) }) mockConnection.on.mockReturnThis() mockConnection.close.mockResolvedValue(undefined) vi.mocked(amqp.connect).mockResolvedValue(mockConnection as unknown as Connection) vi.useFakeTimers() client = new RabbitMQClient(baseOptions) }) afterEach(() => { vi.useRealTimers() }) it('does not settle a delivery whose channel was replaced while the handler ran', async () => { const gate = deferred() const handler = vi.fn(() => gate.promise) const onMessageProcessed = vi.fn() client = new RabbitMQClient({ ...baseOptions, hooks: { onMessageProcessed } }) await client.init() await client.subscribe('ex', 'key', 'queue', handler) const ch1 = channels[0] ch1.deliver!(createMessage({ foo: 'bar' })) await vi.advanceTimersByTimeAsync(1) expect(handler).toHaveBeenCalledTimes(1) // Broker kills the channel mid-handler (consumer_timeout), exactly as in // the incident: channel error, then reconnect. ch1.isClosed = true ch1.fire('error', new Error('PRECONDITION_FAILED - delivery acknowledgement timed out')) await vi.advanceTimersByTimeAsync(200) // The reconnect gave the client a new channel and resubscribed on it. expect(channels.length).toBe(2) const ch2 = channels[1] expect(ch2.consume).toHaveBeenCalled() // Handler now finishes, long after its channel died. gate.resolve() await vi.advanceTimersByTimeAsync(200) // The dead channel must not be acked, and the delivery tag must not be // replayed on ch2 where it refers to nothing. expect(ch1.ack).not.toHaveBeenCalled() expect(ch2.ack).not.toHaveBeenCalled() // Nothing was acknowledged, so nothing was processed as far as the broker // is concerned. The hook must not claim otherwise. expect(onMessageProcessed).not.toHaveBeenCalled() }) }) linagora-rabbitmq-client-a8c84b7/test/testing.test.ts000066400000000000000000000102351526774056000230020ustar00rootroot00000000000000import { describe, it, expect, vi } from 'vitest' import { MockRabbitMQChannel, MockRabbitMQConnection, createMockAmqplib, } from '../src/testing/index.js' describe('MockRabbitMQChannel', () => { it('should track published messages', () => { const channel = new MockRabbitMQChannel() channel.publish('ex', 'key', Buffer.from(JSON.stringify({ foo: 'bar' })), { persistent: true }) const messages = channel.getPublishedMessages() expect(messages).toHaveLength(1) expect(messages[0].exchange).toBe('ex') expect(messages[0].routingKey).toBe('key') expect(messages[0].message).toEqual({ foo: 'bar' }) }) it('should simulate message consumption', () => { const channel = new MockRabbitMQChannel() const handler = vi.fn() channel.consume('queue', handler) channel.simulateMessage({ test: 'data' }) expect(handler).toHaveBeenCalledOnce() const msg = handler.mock.calls[0][0] expect(JSON.parse(msg.content.toString())).toEqual({ test: 'data' }) }) it('should simulate a message with properties', () => { const channel = new MockRabbitMQChannel() const handler = vi.fn() channel.consume('queue', handler) channel.simulateMessage({ test: 'data' }, { headers: { 'x-death': [] }, timestamp: 1757000000 }) const msg = handler.mock.calls[0][0] expect(msg.properties).toEqual({ headers: { 'x-death': [] }, timestamp: 1757000000 }) }) it('should simulate invalid JSON message', () => { const channel = new MockRabbitMQChannel() const handler = vi.fn() channel.consume('queue', handler) channel.simulateInvalidJsonMessage() expect(handler).toHaveBeenCalledOnce() const msg = handler.mock.calls[0][0] expect(msg.content.toString()).toBe('not valid json {') }) it('should simulate channel close and error events', () => { const channel = new MockRabbitMQChannel() const closeHandler = vi.fn() const errorHandler = vi.fn() channel.on('close', closeHandler) channel.on('error', errorHandler) channel.simulateClose() expect(closeHandler).toHaveBeenCalledOnce() channel.simulateError(new Error('test')) expect(errorHandler).toHaveBeenCalledOnce() }) it('should clear messages', () => { const channel = new MockRabbitMQChannel() channel.publish('ex', 'key', Buffer.from('{}')) expect(channel.getPublishedMessages()).toHaveLength(1) channel.clearMessages() expect(channel.getPublishedMessages()).toHaveLength(0) }) it('should reset all state', () => { const channel = new MockRabbitMQChannel() channel.publish('ex', 'key', Buffer.from('{}')) channel.simulateClose() channel.reset() expect(channel.getPublishedMessages()).toHaveLength(0) expect(channel.isClosed).toBe(false) }) }) describe('MockRabbitMQConnection', () => { it('should create a new channel on each createConfirmChannel call', async () => { const conn = new MockRabbitMQConnection() const ch1 = conn.channel await conn.createConfirmChannel() await conn.createConfirmChannel() expect(conn.channelCount).toBe(3) // initial + 2 reconnections expect(conn.channel).not.toBe(ch1) }) it('should simulate connection close and error events', () => { const conn = new MockRabbitMQConnection() const closeHandler = vi.fn() const errorHandler = vi.fn() conn.on('close', closeHandler) conn.on('error', errorHandler) conn.simulateClose() expect(closeHandler).toHaveBeenCalledOnce() expect(conn.isConnected).toBe(false) conn.simulateError(new Error('test')) expect(errorHandler).toHaveBeenCalledOnce() }) it('should reset to initial state', async () => { const conn = new MockRabbitMQConnection() await conn.createConfirmChannel() await conn.createConfirmChannel() conn.simulateClose() conn.reset() expect(conn.isConnected).toBe(true) expect(conn.channelCount).toBe(1) }) }) describe('createMockAmqplib', () => { it('should return mockConnection and amqpMock', async () => { const { mockConnection, amqpMock } = createMockAmqplib() const conn = await amqpMock.connect('amqp://localhost') expect(conn).toBe(mockConnection) expect(mockConnection.channel).toBeDefined() }) }) linagora-rabbitmq-client-a8c84b7/test/types.test.ts000066400000000000000000000033361526774056000224750ustar00rootroot00000000000000import { describe, it, expectTypeOf } from 'vitest' import type { RabbitMQClientOptions, RabbitMQMessage, RabbitMQMessageHandler, RabbitMQSubscription, } from '../src/types.js' describe('types', () => { it('RabbitMQClientOptions requires url and has optional fields with defaults', () => { expectTypeOf().toHaveProperty('url') expectTypeOf().toBeString() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() }) it('RabbitMQMessage accepts any JSON-serializable object', () => { expectTypeOf<{ foo: string }>().toMatchTypeOf() }) it('RabbitMQMessageHandler takes a message and returns a promise', () => { const handler: RabbitMQMessageHandler = async (_msg) => {} expectTypeOf(handler).toBeFunction() expectTypeOf(handler).returns.toEqualTypeOf>() }) it('RabbitMQSubscription has exchange, routingKey, queue, handler', () => { expectTypeOf().toHaveProperty('exchange') expectTypeOf().toHaveProperty('routingKey') expectTypeOf().toHaveProperty('queue') expectTypeOf().toHaveProperty('handler') }) }) linagora-rabbitmq-client-a8c84b7/tsconfig.json000066400000000000000000000007441526774056000215330ustar00rootroot00000000000000{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022"], "outDir": "dist", "declaration": true, "declarationMap": true, "sourceMap": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true }, "include": ["src"], "exclude": ["node_modules", "dist", "test"] } linagora-rabbitmq-client-a8c84b7/tsup.config.ts000066400000000000000000000003731526774056000216310ustar00rootroot00000000000000import { defineConfig } from 'tsup' export default defineConfig({ entry: { index: 'src/index.ts', 'testing/index': 'src/testing/index.ts', }, format: ['esm', 'cjs'], dts: true, clean: true, sourcemap: true, splitting: false, })