diff --git a/.changeset/azure-service-bus-transport.md b/.changeset/azure-service-bus-transport.md new file mode 100644 index 00000000..21cbde0c --- /dev/null +++ b/.changeset/azure-service-bus-transport.md @@ -0,0 +1,12 @@ +--- +'@node-ts/bus-azure-service-bus': minor +--- + +Add `@node-ts/bus-azure-service-bus`, an Azure Service Bus transport (#346). + +- Each message is sent to a topic of its own, and each service receives from its own queue, which a subscription on every topic it handles forwards into. `resolveTopicName` maps message names to topics. +- Connect with a `connectionString`, or a `fullyQualifiedNamespace` and a `TokenCredential`, or pass your own `ServiceBusClient` and `ServiceBusAdministrationClient`. +- Messages are received `concurrency` at a time in peek-lock mode, with their locks renewed while they're handled (`lockDuration`, `maxAutoLockRenewalDurationInMs`). +- Retries schedule a copy of the message after the recoverability policy's delay, with its own message id. Dead-lettered messages use Service Bus' dead-lettering, with the failure metadata in a `bus-failure` application property, and are forwarded to the shared dead letter queue. +- `bus provision` creates the topics, queues and subscriptions with the Manage right, and plans the Azure role assignments the service needs at runtime (`azure-rbac`). At startup the transport checks its queue with a peek, which only needs Listen, and its subscriptions too with `verifySubscriptions`. +- Replies go straight to the requester's queue in the same namespace. `AzureServiceBusMessageTooLarge`, `AzureServiceBusTierNotSupported` and `AzureServiceBusConnectionNotConfigured` name the problem and the fix. diff --git a/.circleci/config.yml b/.circleci/config.yml index 51f85781..893857e3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -86,7 +86,9 @@ jobs: curl -sf http://127.0.0.1:4566/_localstack/health - run: name: Integration Test - command: pnpm run test:integration + # bus-azure-service-bus' tests need the Service Bus emulator, which + # the azure-service-bus job runs, so they're left out here + command: pnpm exec dotenv -e test.env -- jest --runInBand '^(?!(.*/)?packages/bus-azure-service-bus/).*(src/.+\.|/)integration\.ts$' - run: name: Integration Test (Redis transport on Valkey) command: REDIS_URL=redis://127.0.0.1:6381 pnpm exec dotenv -e test.env -- jest --runInBand --coverage=false packages/bus-redis/src/redis-transport.integration.ts @@ -96,6 +98,45 @@ jobs: paths: - . + azure-service-bus: + # The Service Bus emulator needs a mounted config file, SQL Server and + # about 2 GB of RAM, so it runs with docker compose on a machine executor + # rather than as secondary containers. Starting it accepts the licences of + # the emulator and SQL Server (ACCEPT_EULA in docker-compose.yml). + machine: + image: ubuntu-2404:current + resource_class: medium + working_directory: ~/repo + steps: + - checkout + - run: + name: Start the Service Bus emulator + command: docker compose --profile azure-service-bus up -d servicebus servicebus-sql + # Each step is a new shell, which starts on the image's own Node.js, so + # the steps that need ours switch to it with nvm first + - run: + name: Install Node.js and pnpm + command: | + nvm install "$(cat .nvmrc)" + npm install --global pnpm@12.4.1 + - run: + name: Install dependencies + command: nvm use "$(cat .nvmrc)" && pnpm i + - run: + name: Build + command: nvm use "$(cat .nvmrc)" && pnpm --filter '@node-ts/bus-azure-service-bus...' run build + - run: + name: Wait for the Service Bus emulator + command: | + for i in $(seq 1 90); do + curl -sf http://localhost:5300/health | grep -q '"healthy"' && break + sleep 2 + done + curl -sf http://localhost:5300/health || (docker compose --profile azure-service-bus logs servicebus && exit 1) + - run: + name: Integration Test + command: nvm use "$(cat .nvmrc)" && pnpm exec dotenv -e test.env -- jest --runInBand 'packages/bus-azure-service-bus/.+\.integration\.ts$' + docs: <<: *defaults steps: @@ -177,12 +218,14 @@ workflows: build-deploy: jobs: - build + - azure-service-bus - docs: requires: - build - deploy: requires: - build + - azure-service-bus filters: branches: only: diff --git a/CLAUDE.md b/CLAUDE.md index 82e65676..24d662f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ pnpm exec dotenv -e test.env -- jest packages/bus-sqs/src/sqs-transport.spec.ts - Jest is configured once at the root (`jest.config.ts`, ts-jest using `tsconfig.test.json`; a `moduleNameMapper` lets relative `.js` imports, as in generated message types, resolve to `.ts` source); `test/setup.ts` drops the default logger's `@node-ts/…` console warnings and errors (set `BUS_TEST_LOGS=true` to see them). Other console output still shows, so don't leave `console.log` in tests. - Tests: `*.spec.ts` = unit, `*.integration.ts` = integration. `packages/bus-test/src/in-memory.integration.ts` runs bus-test's own round trip suites over the in-memory queue and persistence. - **Build before testing across packages**: every package has `main: ./dist/index.js`, so e.g. bus-sqs tests import the built bus-core and bus-test, not their source. Changes in bus-core need `pnpm build` before they're visible to other packages. -- Integration tests for adapters need local infra; `docker compose up -d --wait` starts it all from the root `docker-compose.yml`, and waits until it's healthy (Mongo's healthcheck initiates its replica set). Endpoints default to the compose ports and can be overridden with env vars (listed in `test.env`): `LOCALSTACK_ENDPOINT` (default `http://localhost:4566`, dummy AWS creds in `test.env`), `RABBITMQ_URL` (`amqp://guest:guest@0.0.0.0`), `POSTGRES_URL` (`postgres://postgres:password@localhost:6432/postgres`), `MONGODB_URL` (`mongodb://localhost:27017/workflows?directConnection=true`, a single-node replica set, see `packages/bus-mongodb/CLAUDE.md`), `REDIS_URL` (`redis://localhost:6380`; compose also has Valkey on 6381). CI runs all integration tests against CircleCI secondary containers (`.circleci/config.yml`), and bus-redis' again against Valkey. +- Integration tests for adapters need local infra; `docker compose up -d --wait` starts it all from the root `docker-compose.yml`, and waits until it's healthy (Mongo's healthcheck initiates its replica set). Endpoints default to the compose ports and can be overridden with env vars (listed in `test.env`): `LOCALSTACK_ENDPOINT` (default `http://localhost:4566`, dummy AWS creds in `test.env`), `RABBITMQ_URL` (`amqp://guest:guest@0.0.0.0`), `POSTGRES_URL` (`postgres://postgres:password@localhost:6432/postgres`), `MONGODB_URL` (`mongodb://localhost:27017/workflows?directConnection=true`, a single-node replica set, see `packages/bus-mongodb/CLAUDE.md`), `REDIS_URL` (`redis://localhost:6380`; compose also has Valkey on 6381). The Service Bus emulator (with SQL Server, about 2 GB) only starts with its profile, `docker compose --profile azure-service-bus up -d`, on AMQP port 5673 and management port 5300 (`SERVICE_BUS_EMULATOR_HOST`, `SERVICE_BUS_EMULATOR_AMQP_PORT`, `SERVICE_BUS_EMULATOR_MANAGEMENT_PORT`). CI runs all other integration tests against CircleCI secondary containers (`.circleci/config.yml`), bus-redis' again against Valkey, and bus-azure-service-bus' in the `azure-service-bus` job, on a machine executor that runs the emulator with `docker compose`. - Formatting is automatic: a `.claude/settings.json` hook runs prettier on every file Claude edits, and husky + lint-staged format on commit. - Linting: `eslint.config.mjs` is `recommended` from ESLint and typescript-eslint, plus the type-aware `no-floating-promises`, `no-misused-promises` and `await-thenable`, with `eslint-config-prettier` so it never fights prettier. Type information comes from `tsconfig.eslint.json` (specs and `test/` fixtures included), and cross-package types come from each package's `dist`, so build first. lint-staged also runs ESLint on staged TS/JS files. Await or `.catch` every promise; use `void` only for deliberate fire-and-forget, with a comment saying why. Unused parameters are allowed when prefixed with `_`. - Dependency updates are grouped by Renovate (`renovate.json`); the maintainer installs the Renovate GitHub app. @@ -46,7 +46,7 @@ pnpm exec dotenv -e test.env -- jest packages/bus-sqs/src/sqs-transport.spec.ts - `bus-messages` — `Message`/`Command`/`Event` base types and `MessageAttributes`. Messages identify themselves by `$name` (usually a static `NAME`) and `$version`. - `bus-core` — the bus itself, the `Transport`/`Persistence`/`Serializer`/`Receiver` interfaces (`Transport.endpointName` is the service queue's name), and in-memory defaults (`InMemoryQueue`, `InMemoryPersistence`, `JsonSerializer`). -- Transports: `bus-sqs` (SNS topics fanned out to SQS queues, with policy generation), `bus-rabbitmq`, `PostgresTransport` in `bus-postgres` (queues in shared Postgres tables, no broker; see `packages/bus-postgres/CLAUDE.md`), and `bus-redis` (a Redis Stream per queue, read by a consumer group, on Redis 7+/Valkey 7.2+; see `packages/bus-redis/CLAUDE.md`). +- Transports: `bus-sqs` (SNS topics fanned out to SQS queues, with policy generation), `bus-rabbitmq`, `bus-azure-service-bus` (a topic per message forwarded into each service's queue; see `packages/bus-azure-service-bus/CLAUDE.md`), `PostgresTransport` in `bus-postgres` (queues in shared Postgres tables, no broker; see `packages/bus-postgres/CLAUDE.md`), and `bus-redis` (a Redis Stream per queue, read by a consumer group, on Redis 7+/Valkey 7.2+; see `packages/bus-redis/CLAUDE.md`). - Persistence (workflow state and delayed messages): `bus-postgres`, `bus-mongodb`. - `bus-sqs-lambda` — a `Receiver` that feeds Lambda SQS events into the bus instead of the bus polling the transport. - `bus-opentelemetry` — `openTelemetry()` middleware for spans, W3C trace context (carried in message attributes) and messaging metrics, with only `@opentelemetry/api` as a peer. See `packages/bus-opentelemetry/CLAUDE.md`. diff --git a/README.md b/README.md index 58500ef0..7de5af5d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Requires Node.js 24 or later. - [@node-ts/bus-messages](https://github.com/node-ts/bus/tree/master/packages/bus-messages) - The base types of commands, events and their attributes, used to declare your own messages - [@node-ts/bus-cli](https://github.com/node-ts/bus/tree/master/packages/bus-cli) - Command line tools, including `bus generate-message-types`, which lets the bus restore Dates and class instances in messages - [@node-ts/bus-rabbitmq](https://github.com/node-ts/bus/tree/master/packages/bus-rabbitmq) - A RabbitMQ transport +- [@node-ts/bus-azure-service-bus](https://github.com/node-ts/bus/tree/master/packages/bus-azure-service-bus) - An Azure Service Bus transport - [@node-ts/bus-sqs](https://github.com/node-ts/bus/tree/master/packages/bus-sqs) - An Amazon SQS transport - [@node-ts/bus-sqs-lambda](https://github.com/node-ts/bus/tree/master/packages/bus-sqs-lambda) - A receiver that handles SQS messages in AWS Lambda - [@node-ts/bus-postgres](https://github.com/node-ts/bus/tree/master/packages/bus-postgres) - A Postgres persistence for workflow state, and a Postgres transport that needs no broker diff --git a/docker-compose.yml b/docker-compose.yml index 28a76f69..9c6cfa1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,3 +61,32 @@ services: interval: 2s timeout: 10s retries: 30 + + # Microsoft's Service Bus emulator, for bus-azure-service-bus. It needs SQL + # Server and about 2 GB of RAM, so it only starts with its profile: + # docker compose --profile azure-service-bus up -d + # Starting it accepts the licences of both images (ACCEPT_EULA): + # https://github.com/Azure/azure-service-bus-emulator-installer/blob/main/EMULATOR_EULA.txt + # and https://go.microsoft.com/fwlink/?LinkId=746388. AMQP is on 5673, since + # RabbitMQ has 5672, and the management API and /health are on 5300. + servicebus: + profiles: ['azure-service-bus'] + image: mcr.microsoft.com/azure-messaging/servicebus-emulator:2.0.1 + volumes: + - ./packages/bus-azure-service-bus/emulator/config.json:/ServiceBus_Emulator/ConfigFiles/Config.json + environment: + SQL_SERVER: servicebus-sql + MSSQL_SA_PASSWORD: 'Emulator_Passw0rd' + ACCEPT_EULA: 'Y' + ports: + - '5673:5672' + - '5300:5300' + depends_on: + - servicebus-sql + + servicebus-sql: + profiles: ['azure-service-bus'] + image: mcr.microsoft.com/mssql/server:2022-CU27-ubuntu-22.04 + environment: + MSSQL_SA_PASSWORD: 'Emulator_Passw0rd' + ACCEPT_EULA: 'Y' diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 7abdf33a..97dccb78 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -125,6 +125,7 @@ const infrastructure: DefaultTheme.SidebarItem[] = [ items: [ { text: 'RabbitMQ', link: '/transports/rabbitmq' }, { text: 'Amazon SQS', link: '/transports/amazon-sqs' }, + { text: 'Azure Service Bus', link: '/transports/azure-service-bus' }, { text: 'SQS and Lambda', link: '/transports/sqs-lambda' }, { text: 'Postgres', link: '/transports/postgres' }, { text: 'Redis', link: '/transports/redis' }, diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index b23dac6f..c5384896 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -70,4 +70,4 @@ The generator needs TypeScript types. In a JavaScript project, write the message - [Handling messages](/getting-started/handling-messages), to receive your first message - [Serializers](/guide/serializers), for what the message types restore -- [Transports](/transports), to run on RabbitMQ or Amazon SQS +- [Transports](/transports), to run on RabbitMQ, Amazon SQS or Azure Service Bus diff --git a/docs/guide/middleware.md b/docs/guide/middleware.md index 8aeef586..99480f1e 100644 --- a/docs/guide/middleware.md +++ b/docs/guide/middleware.md @@ -97,11 +97,12 @@ Outgoing middleware can also set native headers for the transport in `context.he <<< @/snippets/middleware.ts#headers -| Transport | Writes each header as | Reserved names | -| ------------------------------------ | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| [RabbitMQ](/transports/rabbitmq) | an AMQP header, kept when the message retries | `attributes`, `stickyAttributes`, `sentAt`, `failedAttempts`, `bus-failure`, `x-death`, and names starting `x-first-death-` or `x-last-death-` | -| [Amazon SQS](/transports/amazon-sqs) | an SNS message attribute under its own name | `correlationId`, `messageId`, `sentAt`, `replyTo`, `bus-failure`, and names starting `attributes.` or `stickyAttributes.` | -| In-memory queue | an entry in the raw message's `headers` | `bus-failure` | +| Transport | Writes each header as | Reserved names | +| -------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [RabbitMQ](/transports/rabbitmq) | an AMQP header, kept when the message retries | `attributes`, `stickyAttributes`, `sentAt`, `failedAttempts`, `bus-failure`, `x-death`, and names starting `x-first-death-` or `x-last-death-` | +| [Amazon SQS](/transports/amazon-sqs) | an SNS message attribute under its own name | `correlationId`, `messageId`, `sentAt`, `replyTo`, `bus-failure`, and names starting `attributes.` or `stickyAttributes.` | +| [Azure Service Bus](/transports/azure-service-bus) | an application property under its own name, kept when the message retries | `messageId`, `sentAt`, `failedAttempts`, `bus-failure`, `DeadLetterReason`, `DeadLetterErrorDescription`, and names starting `attributes.` or `stickyAttributes.` | +| In-memory queue | an entry in the raw message's `headers` | `bus-failure` | Setting a reserved name makes the `send()`, `publish()` or `reply()` throw `TransportHeaderReserved`, before the message is buffered or sent. AWS's limit of 10 message attributes on SQS only applies with SNS raw message delivery, which the SQS transport doesn't use. diff --git a/docs/guide/provisioning.md b/docs/guide/provisioning.md index c10da059..b2d87d8f 100644 --- a/docs/guide/provisioning.md +++ b/docs/guide/provisioning.md @@ -11,14 +11,15 @@ A bus needs infrastructure before it can run: a queue, a topic or exchange for e Everything is worked out from the bus' configuration: its transport, persistence, handlers, workflows, custom handlers and message types. -| Adapter | Provisions | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| [Amazon SQS](/transports/amazon-sqs#provisioning) | An SNS topic per message, the service queue and dead letter queue, a subscription per handled message, and the queue's access policy | -| [RabbitMQ](/transports/rabbitmq#provisioning) | A fanout exchange per message, the service queue with its retry and dead letter queues, and the bindings | -| [Postgres transport](/transports/postgres#provisioning) | The schema, the messages, queues, subscriptions and dead letters tables, the service's queue, and a subscription per handled message | -| [Redis](/transports/redis#provisioning) | The service queue's stream and its consumer group, and the queue in the subscription set of each handled message | -| [Postgres](/persistence/postgres#provisioning) | The schema, a table per workflow state with indexes on its lookups, and the outgoing messages table | -| [MongoDB](/persistence/mongodb#provisioning) | A collection per workflow state with indexes on its lookups, and the outgoing messages collection | +| Adapter | Provisions | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| [Amazon SQS](/transports/amazon-sqs#provisioning) | An SNS topic per message, the service queue and dead letter queue, a subscription per handled message, and the queue's access policy | +| [RabbitMQ](/transports/rabbitmq#provisioning) | A fanout exchange per message, the service queue with its retry and dead letter queues, and the bindings | +| [Azure Service Bus](/transports/azure-service-bus#provisioning) | A topic per message, the service queue and dead letter queue, and a subscription per handled message that forwards into the queue | +| [Postgres transport](/transports/postgres#provisioning) | The schema, the messages, queues, subscriptions and dead letters tables, the service's queue, and a subscription per handled message | +| [Redis](/transports/redis#provisioning) | The service queue's stream and its consumer group, and the queue in the subscription set of each handled message | +| [Postgres](/persistence/postgres#provisioning) | The schema, a table per workflow state with indexes on its lookups, and the outgoing messages table | +| [MongoDB](/persistence/mongodb#provisioning) | A collection per workflow state with indexes on its lookups, and the outgoing messages collection | A topic or exchange is provisioned for every message the bus handles, and every message in the [message types](/guide/serializers/message-types) passed to `withMessageTypes()`, other than workflow state. That way a service that sends a message doesn't depend on the service that handles it being deployed first. A send-only bus provisions only those topics or exchanges, so **pass a send-only bus the message types of everything it sends**: without them it provisions nothing, its runtime permissions allow sending nothing, and `bus provision` logs a warning. The in-memory queue and persistence have nothing to provision. @@ -119,7 +120,7 @@ SqsTransport (7 resources) - `adapters` has an entry for each transport or persistence that provisions anything, in the order they ran: the persistence first, then the transport. - Each resource has a `type`, its `name` (an ARN for AWS resources) and, for some, the `properties` it's created with. The types are listed on each adapter's page. -- `runtimePermissions` is only included with `--permissions`. Its `format` says what `document` is: `iam-policy` (an IAM policy document), `rabbitmq-permissions` (the `configure`, `write` and `read` expressions for the vhost), `sql` (a list of grant statements), `redis-acl` (the ACL rules of a user) or `mongodb-privileges` (the privileges of a role). The `ProvisionReport` type, exported by `@node-ts/bus-cli`, describes it. +- `runtimePermissions` is only included with `--permissions`. Its `format` says what `document` is: `iam-policy` (an IAM policy document), `rabbitmq-permissions` (the `configure`, `write` and `read` expressions for the vhost), `azure-rbac` (Azure role assignments, scoped within the Service Bus namespace), `sql` (a list of grant statements), `redis-acl` (the ACL rules of a user) or `mongodb-privileges` (the privileges of a role). The `ProvisionReport` type, exported by `@node-ts/bus-cli`, describes it. ## Checking resources at startup @@ -141,7 +142,7 @@ With it, the bus also creates what it finds it needs later, such as the topic of ## Schedulers and shared stores -A [scheduler](/guide/delayed-delivery#running-a-dedicated-scheduler), configured with `asScheduler()`, sends the scheduled messages of every service that shares its persistence, so it can publish any message. Its runtime permissions allow publishing to any topic or exchange: every SNS topic in the account and region, or every exchange in the RabbitMQ vhost. When the SQS transport's `resolveTopicName` adds a fixed prefix, such as an environment name, the grant is narrowed to the topics that start with it. The transport finds that prefix by calling `resolveTopicName` with made-up message names, so a resolver that throws for names it doesn't know gets the grant for every topic, with a warning. Narrow them further to your services' topics by hand if you need to. +A [scheduler](/guide/delayed-delivery#running-a-dedicated-scheduler), configured with `asScheduler()`, sends the scheduled messages of every service that shares its persistence, so it can publish any message. Its runtime permissions allow publishing to any topic or exchange: every SNS topic in the account and region, every exchange in the RabbitMQ vhost, or the whole Service Bus namespace. When the SQS transport's `resolveTopicName` adds a fixed prefix, such as an environment name, the grant is narrowed to the topics that start with it. The transport finds that prefix by calling `resolveTopicName` with made-up message names, so a resolver that throws for names it doesn't know gets the grant for every topic, with a warning. Narrow them further to your services' topics by hand if you need to. Any other started bus also sends the due messages in its persistence, whichever bus stored them. When several services share a persistence, give each one permission to publish the others' scheduled messages too, or turn their sending off with `withDelayedDelivery({ dispatch: false })` and run a scheduler. @@ -159,5 +160,5 @@ A [custom transport](/transports/custom) or [persistence](/persistence/custom) i ## See also -- [Amazon SQS](/transports/amazon-sqs#provisioning), [RabbitMQ](/transports/rabbitmq#provisioning), the [Postgres transport](/transports/postgres#provisioning), [Redis](/transports/redis#provisioning), [Postgres](/persistence/postgres#provisioning) and [MongoDB](/persistence/mongodb#provisioning), for what each provisions and the permissions it needs +- [Amazon SQS](/transports/amazon-sqs#provisioning), [RabbitMQ](/transports/rabbitmq#provisioning), [Azure Service Bus](/transports/azure-service-bus#provisioning), the [Postgres transport](/transports/postgres#provisioning), [Redis](/transports/redis#provisioning), [Postgres](/persistence/postgres#provisioning) and [MongoDB](/persistence/mongodb#provisioning), for what each provisions and the permissions it needs - [`BusInstance.provision`](/api/bus-core/classes/BusInstance#provision), [`ProvisioningPlan`](/api/bus-core/interfaces/ProvisioningPlan) and [`ResourcesNotProvisioned`](/api/bus-core/classes/ResourcesNotProvisioned) in the API reference diff --git a/docs/guide/recoverability.md b/docs/guide/recoverability.md index a9eb48e0..68f528c8 100644 --- a/docs/guide/recoverability.md +++ b/docs/guide/recoverability.md @@ -79,18 +79,19 @@ A message that was failed with `failMessage()` without anything throwing has a ` <<< @/snippets/recoverability.ts#read-failure -| Transport | Where the header is | -| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | -| [RabbitMQ](/transports/rabbitmq) | an AMQP header on the dead-lettered message | -| [Amazon SQS](/transports/amazon-sqs) | an SQS message attribute on the dead-lettered message. The SNS envelope in its body, with the message's attributes, is kept | -| [Redis](/transports/redis) | a `bus-failure` field of the entry in the queue's dead letter stream | -| In-memory queue | the dead-lettered message's `raw.headers` | +| Transport | Where the header is | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [RabbitMQ](/transports/rabbitmq) | an AMQP header on the dead-lettered message | +| [Amazon SQS](/transports/amazon-sqs) | an SQS message attribute on the dead-lettered message. The SNS envelope in its body, with the message's attributes, is kept | +| [Azure Service Bus](/transports/azure-service-bus) | an application property on the dead-lettered message, which the service queue forwards to the dead letter queue with its other properties | +| [Redis](/transports/redis) | a `bus-failure` field of the entry in the queue's dead letter stream | +| In-memory queue | the dead-lettered message's `raw.headers` | Transports also add it to messages they dead-letter themselves because they can't be parsed. `bus-failure` is reserved, so outgoing middleware can't set it. ## Replaying dead-lettered messages -Once the cause is fixed, move the messages back to the service queue with the broker's own tools: a [dead letter queue redrive](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configure-dead-letter-queue-redrive.html) on Amazon SQS, a [shovel](https://www.rabbitmq.com/docs/shovel) on RabbitMQ, or the [redrive script](/transports/redis#retries-and-dead-letters) on Redis. The RabbitMQ transport leaves its attempt count off dead-lettered messages, so a replayed message gets all its attempts again. +Once the cause is fixed, move the messages back to the service queue with the broker's own tools: a [dead letter queue redrive](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configure-dead-letter-queue-redrive.html) on Amazon SQS, a [shovel](https://www.rabbitmq.com/docs/shovel) on RabbitMQ, the [redrive script](/transports/redis#retries-and-dead-letters) on Redis, or a tool such as Service Bus Explorer in the Azure portal. The RabbitMQ and Azure Service Bus transports reset the attempt count of dead-lettered messages, so a replayed message gets all its attempts again. ## Receivers diff --git a/docs/index.md b/docs/index.md index 324a867a..6d08a061 100644 --- a/docs/index.md +++ b/docs/index.md @@ -70,6 +70,7 @@ Write messages and handlers once, and pick a transport for the queues and a pers Exchanges and queues declared for you, with retry queues that follow your recoverability policy. SNS topics fanned out to SQS queues, created and subscribed for you, or managed by your own infrastructure code. + A topic per message, forwarded into each service's queue, with scheduled retries and native dead-lettering. Handle SQS batches in AWS Lambda, with partial batch failures. Workflow state in a jsonb table per workflow, with indexes for its lookups. Workflow state in a collection per workflow, with optimistic concurrency. diff --git a/docs/package.json b/docs/package.json index eae9b325..e9c70f89 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,9 +17,11 @@ "check:message-types": "bus generate-message-types --entry 'snippets/**/*.ts' --out snippets/message-types.generated.ts --check" }, "devDependencies": { + "@azure/service-bus": "^7.10.0", "@nestjs/common": "^12.1.2", "@nestjs/core": "^12.1.2", "@nestjs/testing": "^12.1.2", + "@node-ts/bus-azure-service-bus": "workspace:^", "@node-ts/bus-cli": "workspace:^", "@node-ts/bus-core": "workspace:^", "@node-ts/bus-messages": "workspace:^", diff --git a/docs/snippets/azure-service-bus-emulator.ts b/docs/snippets/azure-service-bus-emulator.ts new file mode 100644 index 00000000..6c25e56a --- /dev/null +++ b/docs/snippets/azure-service-bus-emulator.ts @@ -0,0 +1,15 @@ +import { + ServiceBusAdministrationClient, + ServiceBusClient +} from '@azure/service-bus' +import { AzureServiceBusTransport } from '@node-ts/bus-azure-service-bus' + +// The emulator's fixed development connection string. Its management API is on another port. +const emulator = (port: number) => + `Endpoint=sb://localhost:${port};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;` + +export const transport = new AzureServiceBusTransport( + { queueName: 'reservations-service' }, + new ServiceBusClient(emulator(5672)), + new ServiceBusAdministrationClient(emulator(5300)) +) diff --git a/docs/snippets/azure-service-bus.ts b/docs/snippets/azure-service-bus.ts new file mode 100644 index 00000000..cc6e4b5b --- /dev/null +++ b/docs/snippets/azure-service-bus.ts @@ -0,0 +1,29 @@ +import { + AzureServiceBusTransport, + AzureServiceBusTransportConfiguration +} from '@node-ts/bus-azure-service-bus' +import { Bus } from '@node-ts/bus-core' +import { reserveRoomHandler } from './handlers/reserve-room-handler' +import { messageTypes } from './message-types.generated' + +const serviceBusConfiguration: AzureServiceBusTransportConfiguration = { + queueName: 'reservations-service', + deadLetterQueueName: 'reservations-service-dead-letter', + // Or fullyQualifiedNamespace and a credential, such as new DefaultAzureCredential() from @azure/identity + connectionString: process.env.SERVICE_BUS_CONNECTION_STRING +} +const serviceBusTransport = new AzureServiceBusTransport( + serviceBusConfiguration +) + +const bus = Bus.configure() + .withMessageTypes(messageTypes) + .withTransport(serviceBusTransport) + .withHandler(reserveRoomHandler) + // For local development: creates the topics, queues and subscriptions when the bus initializes. In production, + // create them at deploy time with `bus provision` instead. + .withAutoProvision() + .build() + +await bus.initialize() +await bus.start() diff --git a/docs/transports.md b/docs/transports.md index 88a11e1d..5c02883e 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -12,6 +12,7 @@ By default the bus uses an in-memory queue, `InMemoryQueue`, which needs nothing An AMQP broker. The transport declares the exchanges and queues, and retries messages after the delay the recoverability policy chooses. AWS' managed queues, with SNS topics for events. `bus provision` creates the topics, queues and subscriptions, or you manage them. + Microsoft's managed broker, with a topic per message forwarded into each service's queue. `bus provision` creates the topics, queues and subscriptions. Handle SQS messages in AWS Lambda, which reads the queue and passes batches to the bus. Queues in the Postgres database you already have, with no broker to run. Suits one service, or services that share a database. Queues in Redis Streams, on the Redis or Valkey you may already run. Retries and dead letters are built on top, with no modules. diff --git a/docs/transports/azure-service-bus.md b/docs/transports/azure-service-bus.md new file mode 100644 index 00000000..ed9a1f92 --- /dev/null +++ b/docs/transports/azure-service-bus.md @@ -0,0 +1,127 @@ +--- +title: Azure Service Bus +description: Run @node-ts/bus on Azure Service Bus with @node-ts/bus-azure-service-bus. +--- + +# Azure Service Bus + +[Azure Service Bus](https://learn.microsoft.com/azure/service-bus-messaging/service-bus-messaging-overview) is Microsoft's managed message broker. `@node-ts/bus-azure-service-bus` sends each message to a topic of its own, and each service receives from its own queue, which a subscription on every topic it handles forwards into. Failed messages are retried after the delay your [recoverability policy](/guide/recoverability) chooses, and `bus provision` creates the topics, queues and subscriptions at deploy time. This page covers installing, configuring and provisioning it. + + + +## Installation + +::: code-group + +```sh [npm] +npm i @node-ts/bus-azure-service-bus @node-ts/bus-core +``` + +```sh [pnpm] +pnpm add @node-ts/bus-azure-service-bus @node-ts/bus-core +``` + +```sh [yarn] +yarn add @node-ts/bus-azure-service-bus @node-ts/bus-core +``` + +::: + +Configure an `AzureServiceBusTransport` and pass it to the bus configuration: + +<<< @/snippets/azure-service-bus.ts + +The namespace must be on the Standard or Premium tier. The Basic tier has no topics or forwarding, so `provision()` throws `AzureServiceBusTierNotSupported` on it. + +## Configuration + +| Option | Default | Description | +| -------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `queueName` | | The service queue to read messages from. It's also the name of the service's subscription on each topic, shortened and hashed when it's over 50 characters. | +| `connectionString` | | A connection string for the namespace. Give this, or `fullyQualifiedNamespace` and `credential`. | +| `fullyQualifiedNamespace` | | The namespace's host name, such as `my-namespace.servicebus.windows.net`, used with `credential`. | +| `credential` | | A Microsoft Entra ID `TokenCredential`, such as `new DefaultAzureCredential()` from `@azure/identity`. | +| `deadLetterQueueName` | `dead-letter` | Where messages go once they're out of attempts. Every service shares the default, so give each its own. | +| `lockDuration` | `PT1M` | How long a received message is locked to the service, as an ISO 8601 duration of at most 5 minutes. Set on the service queue. | +| `maxAutoLockRenewalDurationInMs` | `300000` | How long the transport keeps renewing the lock of a message that's being handled. A handler that runs longer loses the lock, and the message is delivered again. `0` turns renewal off. | +| `maxDeliveryCount` | `10` | How many deliveries Service Bus allows before it dead-letters a message itself. Each retry is a new copy of the message, so this only catches messages that crash the process. Set on the service queue. | +| `resolveTopicName` | | Maps a message name to its topic. By default a leading `@` is dropped, and characters Service Bus doesn't allow become `-`, so `@acme/orders/order-placed` is sent to `acme-orders-order-placed`. | +| `verifySubscriptions` | `false` | Whether `initialize()` also checks the dead letter queue and the service's subscriptions, which needs the Manage right. See [checks at startup](#checks-at-startup). | + +The transport creates a `ServiceBusClient` from the configuration, and a `ServiceBusAdministrationClient` when it provisions. Pass your own as the constructor's second and third arguments to share a client between transports, or to point them somewhere else, such as [the emulator](#running-service-bus-locally). The transport doesn't close a client it's given. + +The transport receives `concurrency` messages at a time in peek-lock mode, and renews each one's lock while it's being handled. When the bus stops, messages that were delivered but not yet handled are abandoned, so Service Bus delivers them again straight away. + +## Topology + +For a service queue called ``, the transport uses: + +- a topic for each message, named by `resolveTopicName`. `send()` and `publish()` both send to it. +- ``, the service queue. +- a subscription called `` on the topic of each message the service handles, and on each `topicIdentifier` of a [custom handler](/guide/messages/system-messages), that forwards every message into the service queue. Messages that no service subscribes to are dropped by their topic. +- the dead letter queue. The service queue and its subscriptions forward their own dead letters to it. + +## Retries and dead-lettering + +When the recoverability policy retries a message, the transport schedules a copy of it on the service queue for the delay the policy chose, then completes the original. The copy has one more failed attempt in its `failedAttempts` application property, and a native message id of the bus' message id and the attempt, such as `9c1f…:3`, so [duplicate detection](https://learn.microsoft.com/azure/service-bus-messaging/duplicate-detection) doesn't drop it. The copy is scheduled before the original is completed, so if completing fails the message is handled twice rather than lost. Service Bus' own delivery count isn't used. + +When the policy dead-letters a message, the transport uses Service Bus' dead-lettering, with the error's name as the dead letter reason, its message as the description, and the [failure metadata](/guide/recoverability#failure-metadata) in a `bus-failure` application property. The service queue forwards it to the dead letter queue, with its attributes and headers. Its `failedAttempts` is reset to 0, so a message moved back to the service queue gets all its attempts again. A message that Service Bus dead-letters itself, after `maxDeliveryCount` deliveries or when it can't be forwarded, reaches the same queue without failure metadata. + +## Provisioning + +The transport creates nothing when the service starts. Create its entities at deploy time with [`bus provision`](/guide/provisioning), or with `withAutoProvision()` for local development and tests. It provisions: + +| Type | Resource | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `azure-service-bus-topic` | A topic for each message the bus handles or has message types for | +| `azure-service-bus-queue` | The dead letter queue, then the service queue with its `lockDuration`, `maxDeliveryCount` and `forwardDeadLetteredMessagesTo` | +| `azure-service-bus-subscription` | A subscription on the topic of each message the service handles, and on each custom handler's `topicIdentifier`, with `forwardTo` the service queue and `forwardDeadLetteredMessagesTo` the dead letter queue | + +A send-only bus only creates the topics of its messages. The topics of custom handlers are managed outside the bus, so they're subscribed to but not created. Provisioning is idempotent: an entity that exists is left as it is, except that the settings in the table are updated when they differ. Provisioning needs the Manage right, or the Azure Service Bus Data Owner role, on the namespace: Service Bus only lets an identity that can manage both a subscription and the queue it forwards to set up the forwarding. + +### Runtime permissions + +Once provisioned, the service doesn't need the Manage right. `bus provision --dry-run --permissions` prints the role assignments it needs, in the `azure-rbac` format: each one has the role's name, its `roleDefinitionId` and a `scope` relative to the namespace's resource ID, such as `/queues/orders`, where `''` is the namespace itself. + +| Role | Scope | Why | +| ------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| Azure Service Bus Data Receiver | the service queue | receiving and settling messages, and checking the queue exists | +| Azure Service Bus Data Sender | the service queue | scheduling retries | +| Azure Service Bus Data Sender | the topic of each message, or the whole namespace for a scheduler | sending and publishing | +| Azure Service Bus Data Owner | the dead letter queue and each subscribed topic, only with `verifySubscriptions` | reading them through the administration client at startup | + +Role assignments can't be scoped to a pattern of names, so a [scheduler](/guide/provisioning#schedulers-and-shared-stores) gets the Data Sender role on the namespace. Replies are sent to other services' queues, which aren't in the plan: give a service that replies the Data Sender role on the queues of the services it replies to. With a connection string, the equivalent shared access rights are Listen and Send on the service queue, and Send on the topics. + +### Checks at startup + +At `initialize()` the transport checks the service queue exists with a peek, which only needs the Listen right, and throws `ResourcesNotProvisioned` naming it if it doesn't. Service Bus doesn't let a client read a subscription that forwards, so checking the subscriptions and the dead letter queue needs the administration client, and with it the Manage right. Turn that on with `verifySubscriptions: true` for an identity that has it. A send-only bus checks nothing at startup, and sending to a topic that doesn't exist throws `ResourcesNotProvisioned`. + +## Message attributes + +The message's [`messageId`](/guide/message-attributes/message-id) is the native message id of the message as it's sent, and is also kept in a `messageId` application property, since each retry is a copy with its own native id. The `TransportMessage.id` is the native id. The correlation id and the return address are the native `correlationId` and `replyTo` fields, and the message's `$name` is its `subject`. `sentAt` is a `sentAt` application property, and `attributes` and `stickyAttributes` are application properties named `attributes.` and `stickyAttributes.`, keeping their types. The body is the serialized message, as UTF-8 bytes with the content type `application/json`. + +[Transport headers](/guide/middleware#transport-headers) set by outgoing middleware are application properties under their own names. `messageId`, `sentAt`, `failedAttempts`, `bus-failure`, `DeadLetterReason`, `DeadLetterErrorDescription` and names starting `attributes.` or `stickyAttributes.` are reserved. + +A message, with its application properties, can be at most 256 KB on the Standard tier, or 1 MB by default on Premium. Service Bus rejects a larger one, and the transport throws `AzureServiceBusMessageTooLarge`, naming the message. Keep large payloads elsewhere, such as in blob storage, and send a reference to them. + +## Replies + +A [reply](/guide/workflows/request-reply) from `ctx.reply()` is sent straight to the requester's queue, not to the reply's topic, so no other service receives it. The queue is the request's return address, its `replyTo` field, which is the `queueName` of the bus that sent it, in the same namespace. A reply to a queue that doesn't exist throws `EndpointNotFound`, which the default recoverability policy dead-letters at once. + +## Sessions + +The transport doesn't use [sessions](https://learn.microsoft.com/azure/service-bus-messaging/message-sessions), so messages are handled in any order. A queue or subscription with sessions turned on can't forward its messages, so it can't be used with this topology. + +## Running Service Bus locally + +Microsoft's [Service Bus emulator](https://learn.microsoft.com/azure/service-bus-messaging/overview-emulator) runs in Docker with a SQL Server container, and needs about 2 GB of RAM. Its AMQP endpoint and its management API are on different ports, so give the transport clients for each: + +<<< @/snippets/azure-service-bus-emulator.ts + +The emulator holds at most 50 queues and topics, takes at most 10 connections, which is one per client, and keeps messages for at most an hour. Its entities are lost when it restarts. + +## See also + +- [Provisioning](/guide/provisioning) +- [Recoverability](/guide/recoverability) +- [`AzureServiceBusTransportConfiguration`](/api/bus-azure-service-bus/interfaces/AzureServiceBusTransportConfiguration) in the API reference diff --git a/docs/typedoc.json b/docs/typedoc.json index cc83510d..74cf4e9c 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -9,6 +9,7 @@ "../packages/bus-sqs", "../packages/bus-sqs-lambda", "../packages/bus-rabbitmq", + "../packages/bus-azure-service-bus", "../packages/bus-postgres", "../packages/bus-redis", "../packages/bus-mongodb", diff --git a/packages/bus-azure-service-bus/CLAUDE.md b/packages/bus-azure-service-bus/CLAUDE.md new file mode 100644 index 00000000..021ed254 --- /dev/null +++ b/packages/bus-azure-service-bus/CLAUDE.md @@ -0,0 +1,25 @@ +# bus-azure-service-bus + +An Azure Service Bus transport (`@azure/service-bus` 7.10+). Read the root `CLAUDE.md` first. + +## Design + +- **Config** (`src/azure-service-bus-transport-configuration.ts`): `queueName`, `deadLetterQueueName` (default core's `dead-letter`), `connectionString` or `fullyQualifiedNamespace` + `credential` (a `TokenCredential`; there's no `@azure/identity` dependency), `lockDuration` (`PT1M`), `maxAutoLockRenewalDurationInMs` (5 min), `maxDeliveryCount` (10), `resolveTopicName`, `verifySubscriptions` (false). The constructor takes an optional `ServiceBusClient` and `ServiceBusAdministrationClient`; the transport only closes a client it created (`ownsClient`). Without config or clients, `connect()`/`provision()` throw `AzureServiceBusConnectionNotConfigured`. +- **Topology**: a topic per message (`resolveTopicName` in `src/entity-names.ts`: drop a leading `@`, characters outside `[a-zA-Z0-9._-]` become `-`, over 260 chars shortened with an 8-hex sha256 suffix). One queue per endpoint. On each handled topic and each external custom-handler topic, a subscription named `resolveSubscriptionName(queueName)` (50 chars max, same hashing) with `forwardTo` the queue and `forwardDeadLetteredMessagesTo` the DLQ; it keeps the default `$Default` (1=1) rule. A receiver can't be created on a forwarding subscription. +- **Lifecycle**: + - `connect` stores `concurrency` and creates the client. `start` creates a peek-lock receiver (`skipParsingBodyAsJson`, auto lock renewal) and `subscribe()`s with `maxConcurrentCalls = concurrency` and `autoCompleteMessages: false`. Each `processMessage` pushes the message into `receivedMessages` and waits on its entry in `settlements` until `deleteMessage`/`returnMessage`/`fail` settles it (`settle()`), so at most `concurrency` messages are held. The SDK doesn't allow concurrent `receiveMessages` calls on one receiver, which is why it's push-based. + - `stop` (which the bus calls before its workers finish) releases waiting reads, suspends the subscription and abandons unread messages; the receiver stays open so in-flight messages can still settle. `disconnect` closes the receiver, senders and an owned client. + - `provision` (deploy time or `withAutoProvision()`): `getNamespaceProperties()` (Basic tier → `AzureServiceBusTierNotSupported`), topics, then the DLQ, then the service queue (a forwarding target must exist first), then subscriptions. 409 counts as success; then the queue's `lockDuration`/`maxDeliveryCount`/`forwardDeadLetteredMessagesTo` and the subscription's `forwardTo`/`forwardDeadLetteredMessagesTo` are compared (`settingsDiffer`: forward targets by last URL segment, case-insensitively) and updated if they differ. A dry run makes no calls. The plan's `runtimePermissions` is `azure-rbac`: `{ roleAssignments: [{ role, roleDefinitionId, scope }] }`, scopes relative to the namespace resource id (`/queues/`, `/topics/`, `''` = namespace for `sendsAnyMessage`): Data Receiver + Data Sender on the queue, Data Sender per topic, Data Owner on the DLQ and subscribed topics with `verifySubscriptions`. + - `initialize` creates nothing, and checks nothing when send-only or `verifyResources` is off. Otherwise it peeks the service queue (Listen only; `MessagingEntityNotFound` = missing), and with `verifySubscriptions` checks the DLQ, topics and subscriptions through the admin client. Missing ones go in one `ResourcesNotProvisioned`. Publishing to a missing topic throws `ResourcesNotProvisioned`; only with `autoProvision` does `publishMessage` create a topic it hasn't seen (`knownTopics`). +- **Messages**: the body is the serialized message as a UTF-8 `Buffer` with `contentType: application/json` (read back from bytes, a string, or JSON of an object another client sent). Native `messageId` = bus `messageId`, `correlationId`, `replyTo`, `subject` = `$name`. Application properties (`src/message-properties.ts`): headers under their own names, `attributes.`, `stickyAttributes.` (types kept, `undefined` dropped), `messageId`, `sentAt`, plus `failedAttempts` on retry copies. Reserved header names: `messageId`, `sentAt`, `failedAttempts`, `bus-failure`, `DeadLetterReason`, `DeadLetterErrorDescription`, `attributes.*`, `stickyAttributes.*`. `TransportMessage.id` is the native id. +- `returnAddress` is unset, so it's `endpointName`. `sendToAddress` sends to the queue by name in the same namespace and maps `MessagingEntityNotFound` to `EndpointNotFound`. A send that fails with `MessageSizeExceeded` throws `AzureServiceBusMessageTooLarge`. Senders are cached per entity and dropped when the entity is missing. + +## Retry and failure + +- `returnMessage(message, delay)` schedules a copy on the service queue at now + delay (`scheduleMessages`, or `sendMessages` for 0), with `failedAttempts` + 1 and native id `:` (`toRetryCopy`), then completes the original. Schedule first, so a failed complete means a duplicate, not a loss. `failedAttempts` comes only from that property, never `deliveryCount` (abandoning unread messages at shutdown bumps it). +- `fail(message, failure)` uses native `deadLetterMessage` with `deadLetterReason` = error name, `deadLetterErrorDescription` = error message, `bus-failure` = `toFailureHeader(failure)` and `failedAttempts: 0`. The queue's `forwardDeadLetteredMessagesTo` moves it, with its properties, to the shared DLQ (verified on the emulator), so no runtime permission on the DLQ is needed. Unparseable messages go the same way. `maxDeliveryCount` dead-letters (no `bus-failure`) only catch crash loops. + +## Tests + +- Integration tests need the emulator: `docker compose --profile azure-service-bus up -d` from the repo root, then wait for `curl localhost:5300/health`. Clients come from `src/test/service-bus-emulator.ts` (AMQP on 5673, management on 5300, overridable with `SERVICE_BUS_EMULATOR_*`). The admin client only works against the emulator from `@azure/service-bus` 7.10, which uses HTTP when the connection string has `UseDevelopmentEmulator=true`. +- Emulator limits: 50 queues + topics, 10 connections (one per `ServiceBusClient`, so tests share one), 256 KB messages, 1 h TTL, `maxDeliveryCount` 1-10, lock at least 5 s, nothing survives a restart, and `getQueueRuntimeProperties` doesn't parse. The suite deletes every entity before and after it runs (`deleteAllEntities`), and each describe deletes its own. diff --git a/packages/bus-azure-service-bus/README.md b/packages/bus-azure-service-bus/README.md new file mode 100644 index 00000000..a50bcfd3 --- /dev/null +++ b/packages/bus-azure-service-bus/README.md @@ -0,0 +1,76 @@ +# @node-ts/bus-azure-service-bus + +An [Azure Service Bus](https://learn.microsoft.com/azure/service-bus-messaging/service-bus-messaging-overview) transport for [@node-ts/bus](https://node-ts.github.io/bus). It sends each message to a topic of its own, forwards the ones a service handles into its queue, and retries failed messages after the delay the bus' recoverability policy chooses. + +[![npm](https://img.shields.io/npm/v/@node-ts/bus-azure-service-bus)](https://www.npmjs.com/package/@node-ts/bus-azure-service-bus) + +**[Documentation](https://node-ts.github.io/bus/transports/azure-service-bus)** · [Changelog](https://github.com/node-ts/bus/blob/master/packages/bus-azure-service-bus/CHANGELOG.md) + +## Installation + +Requires Node.js 24 or later. + +```sh +npm i @node-ts/bus-azure-service-bus @node-ts/bus-core +``` + +## Usage + +Configure an `AzureServiceBusTransport` and pass it to the bus configuration: + + + +```ts +import { + AzureServiceBusTransport, + AzureServiceBusTransportConfiguration +} from '@node-ts/bus-azure-service-bus' +import { Bus } from '@node-ts/bus-core' +import { reserveRoomHandler } from './handlers/reserve-room-handler' +import { messageTypes } from './message-types.generated' + +const serviceBusConfiguration: AzureServiceBusTransportConfiguration = { + queueName: 'reservations-service', + deadLetterQueueName: 'reservations-service-dead-letter', + // Or fullyQualifiedNamespace and a credential, such as new DefaultAzureCredential() from @azure/identity + connectionString: process.env.SERVICE_BUS_CONNECTION_STRING +} +const serviceBusTransport = new AzureServiceBusTransport( + serviceBusConfiguration +) + +const bus = Bus.configure() + .withMessageTypes(messageTypes) + .withTransport(serviceBusTransport) + .withHandler(reserveRoomHandler) + // For local development: creates the topics, queues and subscriptions when the bus initializes. In production, + // create them at deploy time with `bus provision` instead. + .withAutoProvision() + .build() + +await bus.initialize() +await bus.start() +``` + +## Configuration + +| Option | Default | Description | +| -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `queueName` | | The service queue to read messages from, and the name of its subscription on each topic. | +| `connectionString` | | A connection string for the namespace. Give this, or `fullyQualifiedNamespace` and `credential`. | +| `fullyQualifiedNamespace` | | The namespace's host name, such as `my-namespace.servicebus.windows.net`. | +| `credential` | | A Microsoft Entra ID `TokenCredential`, such as `new DefaultAzureCredential()` from `@azure/identity`. | +| `deadLetterQueueName` | `dead-letter` | Where messages go once they're out of attempts, or when a handler fails them. Every service shares the default, so give each its own. | +| `lockDuration` | `PT1M` | How long a received message is locked to the service, as an ISO 8601 duration of at most 5 minutes. | +| `maxAutoLockRenewalDurationInMs` | `300000` | How long the transport keeps renewing the lock of a message that's being handled. | +| `maxDeliveryCount` | `10` | How many deliveries Service Bus allows before it dead-letters a message itself, which only catches messages that crash the process. | +| `resolveTopicName` | | Maps a message name to its topic. By default a leading `@` is dropped and characters Service Bus doesn't allow become `-`. | +| `verifySubscriptions` | `false` | Whether `initialize()` also checks the dead letter queue and the service's subscriptions, which needs the Manage right. | + +The namespace must be on the Standard or Premium tier. Retries are scheduled copies of the message, and dead-lettered messages carry why they failed in a `bus-failure` application property. Pass your own `ServiceBusClient` and `ServiceBusAdministrationClient` to the constructor to share a client, or to use the Service Bus emulator. + +## Learn more + +- [Azure Service Bus](https://node-ts.github.io/bus/transports/azure-service-bus): the topology `bus provision` creates, the roles it needs at runtime, and running the emulator locally +- [Provisioning](https://node-ts.github.io/bus/guide/provisioning) +- [Recoverability](https://node-ts.github.io/bus/guide/recoverability) diff --git a/packages/bus-azure-service-bus/emulator/config.json b/packages/bus-azure-service-bus/emulator/config.json new file mode 100644 index 00000000..c5df3622 --- /dev/null +++ b/packages/bus-azure-service-bus/emulator/config.json @@ -0,0 +1,14 @@ +{ + "UserConfig": { + "Namespaces": [ + { + "Name": "sbemulatorns", + "Queues": [], + "Topics": [] + } + ], + "Logging": { + "Type": "Console" + } + } +} diff --git a/packages/bus-azure-service-bus/package.json b/packages/bus-azure-service-bus/package.json new file mode 100644 index 00000000..be6c63fc --- /dev/null +++ b/packages/bus-azure-service-bus/package.json @@ -0,0 +1,73 @@ +{ + "name": "@node-ts/bus-azure-service-bus", + "description": "An Azure Service Bus transport adapter for @node-ts/bus-core.", + "homepage": "https://node-ts.github.io/bus/transports/azure-service-bus", + "version": "0.0.0", + "license": "MIT", + "engines": { + "node": ">=24" + }, + "type": "commonjs", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "CHANGELOG.md" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/node-ts/bus.git", + "directory": "packages/bus-azure-service-bus" + }, + "scripts": { + "clean": "rm -rf dist", + "build": "tsc", + "build:watch": "pnpm run build --incremental --watch --preserveWatchOutput", + "generate:message-types": "bus generate-message-types --entry 'src/test/**/*.ts' --exclude src/test/service-bus-emulator.ts --out src/test/message-types.generated.ts", + "check:message-types": "bus generate-message-types --entry 'src/test/**/*.ts' --exclude src/test/service-bus-emulator.ts --out src/test/message-types.generated.ts --check" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@azure/service-bus": "^7.10.0", + "@node-ts/bus-messages": "workspace:^", + "serialize-error": "8", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@node-ts/bus-cli": "workspace:^", + "@node-ts/bus-core": "workspace:^", + "@node-ts/bus-test": "workspace:^", + "typemoq": "^2.1.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "@node-ts/bus-core": "^1.3.3" + }, + "keywords": [ + "esb", + "azure", + "azure service bus", + "service bus", + "typescript", + "enterprise integration patterns", + "bus", + "messaging", + "microservices", + "distributed systems" + ] +} diff --git a/packages/bus-azure-service-bus/src/azure-service-bus-transport-configuration.ts b/packages/bus-azure-service-bus/src/azure-service-bus-transport-configuration.ts new file mode 100644 index 00000000..d1203c83 --- /dev/null +++ b/packages/bus-azure-service-bus/src/azure-service-bus-transport-configuration.ts @@ -0,0 +1,86 @@ +import type { TokenCredential } from '@azure/service-bus' +import { TransportConfiguration } from '@node-ts/bus-core' + +/** + * How the Azure Service Bus transport connects, names its entities and receives messages. Give either a + * `connectionString`, or a `fullyQualifiedNamespace` and a `credential`, unless you pass your own clients to the + * transport's constructor. + */ +export interface AzureServiceBusTransportConfiguration extends TransportConfiguration { + /** + * The name of the queue the service receives from. It's also the name of its subscription on each topic it handles, + * shortened and hashed when it's longer than Service Bus' 50 characters for subscription names. + * @example order-booking-service + */ + queueName: string + + /** + * The name of the queue that dead-lettered messages end up in. Each service queue and subscription forwards its own + * dead letters to it, so it's shared by every service that uses the same name. + * @default dead-letter + */ + deadLetterQueueName?: string + + /** + * A connection string for the namespace, with a shared access policy that has the rights the transport needs: Send + * and Listen at runtime, and Manage to provision. + * @example Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=orders;SharedAccessKey=... + */ + connectionString?: string + + /** + * The host name of the namespace, used with `credential` instead of a connection string + * @example my-namespace.servicebus.windows.net + */ + fullyQualifiedNamespace?: string + + /** + * A Microsoft Entra ID credential for `fullyQualifiedNamespace`, such as `new DefaultAzureCredential()` from + * `@azure/identity`, whose identity has the Service Bus roles in the provisioning plan's runtime permissions + */ + credential?: TokenCredential + + /** + * How long a received message stays locked to this service before Service Bus delivers it again, as an ISO 8601 + * duration of at most 5 minutes. The transport renews the lock while a message is being handled (see + * `maxAutoLockRenewalDurationInMs`), so this mostly sets how soon a message held by a process that died is + * delivered again. `provision()` sets it on the service queue. + * @default PT1M + */ + lockDuration?: string + + /** + * How long the transport keeps renewing the lock of a message it has received, in milliseconds. A handler that runs + * longer than this loses the lock, and the message is delivered again. `0` turns renewal off. + * @default 300000 (5 minutes) + */ + maxAutoLockRenewalDurationInMs?: number + + /** + * How many times Service Bus delivers a message from the service queue before it dead-letters it itself. The bus' + * recoverability policy decides when a failed message is dead-lettered, and each retry is a new copy of the + * message, so this is only a backstop for a message that crashes the process before the bus can settle it. Such a + * message reaches the dead letter queue without failure metadata. `provision()` sets it on the service queue. + * @default 10 + */ + maxDeliveryCount?: number + + /** + * Maps a message name to the name of its topic. The default replaces each character Service Bus doesn't allow in + * entity names with `-`, after dropping a leading `@`, and shortens and hashes a name over 260 characters. + * @param messageName the `$name` of the message + * @returns the topic name, which every service that sends or handles the message must resolve the same way + * @example + * resolveTopicName: messageName => `production.${resolveTopicName(messageName)}` + */ + resolveTopicName?: (messageName: string) => string + + /** + * Whether `initialize()` also checks, with the administration client, that the dead letter queue and the + * subscription of each topic the service handles exist. Reading them needs the Manage right (or the Azure Service + * Bus Data Owner role), which a service shouldn't usually have at runtime, so by default only the service queue is + * checked, with a peek that only needs Listen. + * @default false + */ + verifySubscriptions?: boolean +} diff --git a/packages/bus-azure-service-bus/src/azure-service-bus-transport.integration.ts b/packages/bus-azure-service-bus/src/azure-service-bus-transport.integration.ts new file mode 100644 index 00000000..a03903ab --- /dev/null +++ b/packages/bus-azure-service-bus/src/azure-service-bus-transport.integration.ts @@ -0,0 +1,441 @@ +import { ServiceBusClient, ServiceBusReceivedMessage } from '@azure/service-bus' +import { + Bus, + BusInstance, + EndpointNotFound, + FAILURE_HEADER, + fromFailureHeader, + handlerFor, + Logger, + MessageFailure, + ProvisioningPlan, + ResourcesNotProvisioned, + TransportHeaderReserved +} from '@node-ts/bus-core' +import { Message, MessageAttributes } from '@node-ts/bus-messages' +import { + DeadLetteredMessage, + TestSystemMessage, + transportTests +} from '@node-ts/bus-test' +import { EventEmitter, once } from 'node:events' +import { Mock } from 'typemoq' +import { AzureServiceBusTransport } from './azure-service-bus-transport' +import { AzureServiceBusMessageTooLarge } from './error' +import { toMessageAttributes } from './message-properties' +import { + createEmulatorAdministrationClient, + createEmulatorClient, + deleteAllEntities, + messageTypes, + TestCommand +} from './test' + +jest.setTimeout(60_000) + +const resourcePrefix = 'bus-asb' +const systemMessageTopic = `${resourcePrefix}-test-system-message` + +/** + * Reads the messages on a queue, waiting up to 5 seconds for the first, then completes them + */ +const receiveAll = async ( + client: ServiceBusClient, + queueName: string +): Promise => { + const receiver = client.createReceiver(queueName, { + skipParsingBodyAsJson: true + }) + try { + const messages = await receiver.receiveMessages(10, { + maxWaitTimeInMs: 5_000 + }) + await Promise.all(messages.map(async m => receiver.completeMessage(m))) + return messages + } finally { + await receiver.close() + } +} + +const readBody = (message: ServiceBusReceivedMessage): Message => + JSON.parse((message.body as Buffer).toString('utf8')) as Message + +describe('AzureServiceBusTransport', () => { + const client = createEmulatorClient() + const administrationClient = createEmulatorAdministrationClient() + const deadLetterQueueName = `${resourcePrefix}-test-dead-letter` + const transport = new AzureServiceBusTransport( + { queueName: `${resourcePrefix}-test`, deadLetterQueueName }, + client, + administrationClient + ) + + beforeAll(async () => { + // Entities don't survive the emulator restarting, but they do survive an earlier run that failed part way + await deleteAllEntities(administrationClient) + await administrationClient.createTopic(systemMessageTopic) + }) + + afterAll(async () => { + await deleteAllEntities(administrationClient) + await client.close() + }) + + const publishSystemMessage = async (systemMessageAttribute: string) => { + const sender = client.createSender(systemMessageTopic) + await sender.sendMessages({ + body: Buffer.from(JSON.stringify(new TestSystemMessage())), + contentType: 'application/json', + applicationProperties: { + 'attributes.systemMessage': systemMessageAttribute + } + }) + await sender.close() + } + + const readAllFromDeadLetterQueue = async (): Promise< + DeadLetteredMessage[] + > => { + const messages = await receiveAll(client, deadLetterQueueName) + return messages.map(message => ({ + message: readBody(message), + attributes: toMessageAttributes(message), + failure: fromFailureHeader( + message.applicationProperties?.[FAILURE_HEADER] as string | undefined + ) + })) + } + + transportTests( + transport, + publishSystemMessage, + systemMessageTopic, + readAllFromDeadLetterQueue + ) + + describe('when a bus initializes before its resources are provisioned', () => { + const queueName = `${resourcePrefix}-unprovisioned` + let bus: BusInstance + let error: unknown + let queueCreated: boolean + + beforeAll(async () => { + bus = Bus.configure() + .withMessageTypes(messageTypes) + .withTransport( + new AzureServiceBusTransport( + { queueName, deadLetterQueueName: `${queueName}-dead-letter` }, + client, + administrationClient + ) + ) + .withLogger(() => Mock.ofType().object) + .withHandler(handlerFor(TestCommand, () => undefined)) + .build() + error = await bus.initialize().catch((e: unknown) => e) + queueCreated = await administrationClient.queueExists(queueName) + }) + + afterAll(async () => bus.dispose()) + + it('should fail with ResourcesNotProvisioned, naming the missing queue', () => { + expect(error).toBeInstanceOf(ResourcesNotProvisioned) + expect((error as ResourcesNotProvisioned).missingResources).toEqual([ + `Service Bus queue ${queueName}` + ]) + }) + + it('should not create the queue', () => { + expect(queueCreated).toEqual(false) + }) + }) + + describe('when a bus that verifies subscriptions initializes without them', () => { + const queueName = `${resourcePrefix}-unsubscribed` + let bus: BusInstance + let error: unknown + + beforeAll(async () => { + await administrationClient.createQueue(queueName) + bus = Bus.configure() + .withMessageTypes(messageTypes) + .withTransport( + new AzureServiceBusTransport( + { + queueName, + deadLetterQueueName: `${queueName}-dead-letter`, + verifySubscriptions: true + }, + client, + administrationClient + ) + ) + .withLogger(() => Mock.ofType().object) + .withHandler(handlerFor(TestCommand, () => undefined)) + .build() + error = await bus.initialize().catch((e: unknown) => e) + }) + + afterAll(async () => { + await bus.dispose() + await administrationClient.deleteQueue(queueName) + }) + + it('should name the dead letter queue, topic and subscription that are missing', () => { + expect(error).toBeInstanceOf(ResourcesNotProvisioned) + expect((error as ResourcesNotProvisioned).missingResources).toEqual([ + `Service Bus queue ${queueName}-dead-letter`, + 'Service Bus topic node-ts-bus-azure-service-bus-test-command', + `Service Bus subscription ${queueName} on topic node-ts-bus-azure-service-bus-test-command` + ]) + }) + }) + + describe('when a dry run is provisioned', () => { + const queueName = `${resourcePrefix}-dry-run` + let plans: ProvisioningPlan[] + let queueCreated: boolean + + beforeAll(async () => { + const bus = Bus.configure() + .withMessageTypes(messageTypes) + .withTransport( + new AzureServiceBusTransport( + { queueName, deadLetterQueueName: `${queueName}-dead-letter` }, + client, + administrationClient + ) + ) + .withLogger(() => Mock.ofType().object) + .withHandler(handlerFor(TestCommand, () => undefined)) + .build() + plans = await bus.provision({ dryRun: true }) + await bus.dispose() + queueCreated = await administrationClient.queueExists(queueName) + }) + + it('should plan the queue', () => { + expect(plans[0].resources).toContainEqual( + expect.objectContaining({ + type: 'azure-service-bus-queue', + name: queueName + }) + ) + }) + + it('should plan the role assignments the service needs at runtime', () => { + expect(plans[0].runtimePermissions?.format).toEqual('azure-rbac') + }) + + it('should not create it', () => { + expect(queueCreated).toEqual(false) + }) + }) + + describe('when provisioning a subscription that exists and forwards elsewhere', () => { + const queueName = `${resourcePrefix}-resync` + const topicName = 'node-ts-bus-azure-service-bus-test-command' + let forwardTo: string | undefined + let forwardDeadLetteredMessagesTo: string | undefined + + beforeAll(async () => { + await administrationClient.createTopic(topicName) + await administrationClient.createSubscription(topicName, queueName) + const bus = Bus.configure() + .withMessageTypes(messageTypes) + .withTransport( + new AzureServiceBusTransport( + { queueName, deadLetterQueueName: `${queueName}-dead-letter` }, + client, + administrationClient + ) + ) + .withLogger(() => Mock.ofType().object) + .withHandler(handlerFor(TestCommand, () => undefined)) + .build() + // Twice, to check provisioning what exists doesn't fail + await bus.provision() + await bus.provision() + await bus.dispose() + ;({ forwardTo, forwardDeadLetteredMessagesTo } = + await administrationClient.getSubscription(topicName, queueName)) + }) + + afterAll(async () => { + await administrationClient.deleteTopic(topicName) + await administrationClient.deleteQueue(queueName) + await administrationClient.deleteQueue(`${queueName}-dead-letter`) + }) + + it('should make it forward to the service queue', () => { + expect(forwardTo?.toLowerCase()).toMatch(new RegExp(`${queueName}$`)) + }) + + it('should make it forward its dead letters to the dead letter queue', () => { + expect(forwardDeadLetteredMessagesTo?.toLowerCase()).toMatch( + new RegExp(`${queueName}-dead-letter$`) + ) + }) + }) + + describe('when outgoing middleware sets headers', () => { + const queueName = `${resourcePrefix}-headers` + const headersDeadLetterQueueName = `${queueName}-dead-letter` + let bus: BusInstance + let receivedProperties: Record | undefined + let deadLetterProperties: Record | undefined + let deadLetterFailure: MessageFailure | undefined + let reservedHeaderError: unknown + + beforeAll(async () => { + const handled = new EventEmitter() + let sendReservedHeader = true + bus = Bus.configure() + .withMessageTypes(messageTypes) + .withTransport( + new AzureServiceBusTransport( + { queueName, deadLetterQueueName: headersDeadLetterQueueName }, + client, + administrationClient + ) + ) + .withLogger(() => Mock.ofType().object) + .withAutoProvision() + .withMiddleware({ + outgoing: async (context, next) => { + if (sendReservedHeader) { + context.headers.sentAt = 'from-a-header' + } else { + context.headers['x-tenant'] = 'acme' + context.headers['x-priority'] = 3 + } + await next() + }, + incoming: async (context, next) => { + const raw = context.transportMessage + .raw as ServiceBusReceivedMessage + receivedProperties = raw.applicationProperties + await next() + handled.emit('received') + } + }) + .withHandler( + // Fails the message, to check its headers survive the dead letter queue + handlerFor(TestCommand, async (_m, _a, ctx) => ctx.failMessage()) + ) + .build() + await bus.initialize() + await bus.start() + + reservedHeaderError = await bus + .send(new TestCommand('reserved')) + .catch((error: unknown) => error) + sendReservedHeader = false + + const received = once(handled, 'received') + await bus.send(new TestCommand('headers')) + await received + + const [deadLetter] = await receiveAll(client, headersDeadLetterQueueName) + deadLetterProperties = deadLetter?.applicationProperties + deadLetterFailure = fromFailureHeader( + deadLetterProperties?.[FAILURE_HEADER] as string | undefined + ) + }) + + afterAll(async () => { + await bus.dispose() + await administrationClient.deleteTopic( + 'node-ts-bus-azure-service-bus-test-command' + ) + await administrationClient.deleteQueue(queueName) + await administrationClient.deleteQueue(headersDeadLetterQueueName) + }) + + it('should send each header as an application property under its own name', () => { + expect(receivedProperties).toMatchObject({ + 'x-tenant': 'acme', + 'x-priority': 3 + }) + }) + + it('should keep the headers when the message is forwarded to the dead letter queue', () => { + expect(deadLetterProperties).toMatchObject({ 'x-tenant': 'acme' }) + }) + + it('should keep the failure metadata when the message is forwarded to the dead letter queue', () => { + expect(deadLetterFailure).toMatchObject({ endpoint: queueName }) + }) + + it('should throw TransportHeaderReserved for a name the transport writes itself', () => { + expect(reservedHeaderError).toBeInstanceOf(TransportHeaderReserved) + expect(reservedHeaderError).toMatchObject({ + headerName: 'sentAt', + transportName: 'AzureServiceBusTransport' + }) + }) + }) + + describe('when sending messages it can not deliver', () => { + const queueName = `${resourcePrefix}-undeliverable` + const sut = new AzureServiceBusTransport( + { queueName, deadLetterQueueName: `${queueName}-dead-letter` }, + client, + administrationClient + ) + let bus: BusInstance + let tooLargeError: unknown + let missingQueueError: unknown + let missingTopicError: unknown + + beforeAll(async () => { + bus = Bus.configure() + .withMessageTypes(messageTypes) + .withTransport(sut) + .withLogger(() => Mock.ofType().object) + .asSendOnly() + .build() + await bus.provision() + await bus.initialize() + tooLargeError = await bus + .send(new TestCommand('x'.repeat(300 * 1024))) + .catch((error: unknown) => error) + const attributes: MessageAttributes = { + attributes: {}, + stickyAttributes: {} + } + missingQueueError = await sut + .sendToAddress( + `${resourcePrefix}-no-such-queue`, + new TestCommand('reply'), + attributes + ) + .catch((error: unknown) => error) + missingTopicError = await sut + .publish(new TestSystemMessage(), attributes) + .catch((error: unknown) => error) + }) + + afterAll(async () => { + await bus.dispose() + await administrationClient.deleteTopic( + 'node-ts-bus-azure-service-bus-test-command' + ) + }) + + it('should throw AzureServiceBusMessageTooLarge for a message over the size limit', () => { + expect(tooLargeError).toBeInstanceOf(AzureServiceBusMessageTooLarge) + expect(tooLargeError).toMatchObject({ + messageName: TestCommand.NAME + }) + }) + + it('should throw EndpointNotFound for a reply to a queue that does not exist', () => { + expect(missingQueueError).toBeInstanceOf(EndpointNotFound) + }) + + it('should throw ResourcesNotProvisioned for a message whose topic does not exist', () => { + expect(missingTopicError).toBeInstanceOf(ResourcesNotProvisioned) + }) + }) +}) diff --git a/packages/bus-azure-service-bus/src/azure-service-bus-transport.spec.ts b/packages/bus-azure-service-bus/src/azure-service-bus-transport.spec.ts new file mode 100644 index 00000000..fd974813 --- /dev/null +++ b/packages/bus-azure-service-bus/src/azure-service-bus-transport.spec.ts @@ -0,0 +1,611 @@ +import { + ServiceBusAdministrationClient, + ServiceBusClient, + ServiceBusError, + ServiceBusMessage, + ServiceBusReceivedMessage, + ServiceBusReceiver, + ServiceBusSender +} from '@azure/service-bus' +import { + CoreDependencies, + DefaultHandlerRegistry, + FAILURE_HEADER, + fromFailureHeader, + JsonSerializer, + Logger, + MessageFailure, + MessageSerializer, + ProvisioningPlan, + ResourcesNotProvisioned, + TransportMessage, + TransportProvisionOptions +} from '@node-ts/bus-core' +import { Event } from '@node-ts/bus-messages' +import { IMock, It, Mock, Times } from 'typemoq' +import { AzureServiceBusTransport } from './azure-service-bus-transport' +import { AzureServiceBusTransportConfiguration } from './azure-service-bus-transport-configuration' +import { + AzureServiceBusConnectionNotConfigured, + AzureServiceBusMessageTooLarge, + AzureServiceBusTierNotSupported +} from './error' + +class TestEvent extends Event { + static NAME = '@node-ts/bus-azure-service-bus/test-event' + $name = TestEvent.NAME + $version = 0 +} + +const configuration: AzureServiceBusTransportConfiguration = { + queueName: 'orders', + deadLetterQueueName: 'orders-dead-letter' +} + +/** + * The handler registry of a bus that handles `TestEvent`, and a custom handler's topic + */ +const handlerRegistry = { + getMessageNames: () => [TestEvent.NAME], + getExternallyManagedTopicIdentifiers: () => ['system-topic'] +} as unknown as TransportProvisionOptions['handlerRegistry'] + +const provisionOptions = ( + options: Partial = {} +): TransportProvisionOptions => ({ + handlerRegistry, + sendOnly: false, + messageNames: ['@node-ts/bus-azure-service-bus/sent-command'], + sendsAnyMessage: false, + dryRun: false, + ...options +}) + +const prepare = (sut: AzureServiceBusTransport) => + sut.prepare({ + loggerFactory: () => Mock.ofType().object, + messageSerializer: new MessageSerializer( + new JsonSerializer(), + new DefaultHandlerRegistry(), + { messages: {}, types: {} } + ) + } as unknown as CoreDependencies) + +const serviceBusError = (code: ServiceBusError['code']) => + new ServiceBusError('rejected', code) + +const alreadyExists = () => + Object.assign(new Error('Conflict'), { + statusCode: 409, + code: 'MessageEntityAlreadyExistsError' + }) + +const receivedMessage = ( + properties: Partial = {} +): ServiceBusReceivedMessage => + ({ + body: Buffer.from(JSON.stringify(new TestEvent())), + messageId: 'message-1', + subject: TestEvent.NAME, + contentType: 'application/json', + correlationId: 'correlation-1', + replyTo: 'orders', + applicationProperties: { messageId: 'message-1', 'attributes.a': 1 }, + ...properties + }) as ServiceBusReceivedMessage + +/** + * A started transport over mocked clients + */ +const startedTransport = async () => { + const client = Mock.ofType() + const receiver = Mock.ofType() + const sender = Mock.ofType() + client + .setup(c => c.createReceiver(It.isAny(), It.isAny())) + .returns(() => receiver.object) + client.setup(c => c.createSender(It.isAny())).returns(() => sender.object) + receiver + .setup(r => r.subscribe(It.isAny(), It.isAny())) + .returns(() => ({ close: async () => undefined })) + const sut = new AzureServiceBusTransport(configuration, client.object) + prepare(sut) + await sut.connect({ concurrency: 1 }) + await sut.start() + return { sut, client, receiver, sender } +} + +const toTransportMessage = ( + raw: ServiceBusReceivedMessage, + failedAttempts = 0 +): TransportMessage => ({ + id: raw.messageId?.toString(), + raw, + domainMessage: new TestEvent(), + attributes: { attributes: {}, stickyAttributes: {} }, + failedAttempts +}) + +describe('AzureServiceBusTransport', () => { + describe('when connecting without a connection string, credential or client', () => { + let error: unknown + + beforeAll(async () => { + const sut = new AzureServiceBusTransport(configuration) + prepare(sut) + error = await sut.connect({ concurrency: 1 }).catch(e => e) + }) + + it('should throw AzureServiceBusConnectionNotConfigured', () => { + expect(error).toBeInstanceOf(AzureServiceBusConnectionNotConfigured) + }) + }) + + describe('when a dry run is provisioned', () => { + const administrationClient = Mock.ofType() + let plan: ProvisioningPlan + + beforeAll(async () => { + const sut = new AzureServiceBusTransport( + configuration, + undefined, + administrationClient.object + ) + prepare(sut) + plan = await sut.provision(provisionOptions({ dryRun: true })) + }) + + it('should make no calls', () => { + administrationClient.verify( + a => a.getNamespaceProperties(), + Times.never() + ) + administrationClient.verify( + a => a.createTopic(It.isAny(), It.isAny()), + Times.never() + ) + administrationClient.verify( + a => a.createQueue(It.isAny(), It.isAny()), + Times.never() + ) + }) + + it('should plan a topic for each message it sends and handles, but not for external topics', () => { + expect( + plan.resources + .filter(({ type }) => type === 'azure-service-bus-topic') + .map(({ name }) => name) + ).toEqual([ + 'node-ts-bus-azure-service-bus-sent-command', + 'node-ts-bus-azure-service-bus-test-event' + ]) + }) + + it('should plan the dead letter queue before the service queue that forwards to it', () => { + expect( + plan.resources.filter(({ type }) => type === 'azure-service-bus-queue') + ).toEqual([ + { + type: 'azure-service-bus-queue', + name: 'orders-dead-letter', + properties: {} + }, + { + type: 'azure-service-bus-queue', + name: 'orders', + properties: { + lockDuration: 'PT1M', + maxDeliveryCount: 10, + forwardDeadLetteredMessagesTo: 'orders-dead-letter' + } + } + ]) + }) + + it('should plan a forwarding subscription on each handled and external topic', () => { + expect( + plan.resources.filter( + ({ type }) => type === 'azure-service-bus-subscription' + ) + ).toEqual([ + { + type: 'azure-service-bus-subscription', + name: 'node-ts-bus-azure-service-bus-test-event/subscriptions/orders', + properties: { + topicName: 'node-ts-bus-azure-service-bus-test-event', + subscriptionName: 'orders', + forwardTo: 'orders', + forwardDeadLetteredMessagesTo: 'orders-dead-letter' + } + }, + { + type: 'azure-service-bus-subscription', + name: 'system-topic/subscriptions/orders', + properties: { + topicName: 'system-topic', + subscriptionName: 'orders', + forwardTo: 'orders', + forwardDeadLetteredMessagesTo: 'orders-dead-letter', + externalTopic: true + } + } + ]) + }) + + it('should plan the roles to receive from and send to the queue, and send to its topics', () => { + expect(plan.runtimePermissions).toEqual({ + format: 'azure-rbac', + document: { + roleAssignments: [ + { + role: 'Azure Service Bus Data Receiver', + roleDefinitionId: '4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0', + scope: '/queues/orders' + }, + { + role: 'Azure Service Bus Data Sender', + roleDefinitionId: '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39', + scope: '/queues/orders' + }, + { + role: 'Azure Service Bus Data Sender', + roleDefinitionId: '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39', + scope: '/topics/node-ts-bus-azure-service-bus-sent-command' + }, + { + role: 'Azure Service Bus Data Sender', + roleDefinitionId: '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39', + scope: '/topics/node-ts-bus-azure-service-bus-test-event' + } + ] + } + }) + }) + }) + + describe('when a scheduler that sends any message is provisioned', () => { + let plan: ProvisioningPlan + + beforeAll(async () => { + const sut = new AzureServiceBusTransport(configuration) + prepare(sut) + plan = await sut.provision( + provisionOptions({ + dryRun: true, + sendOnly: true, + sendsAnyMessage: true + }) + ) + }) + + it('should plan the role to send to the whole namespace, and none to receive', () => { + expect(plan.runtimePermissions?.document).toEqual({ + roleAssignments: [ + { + role: 'Azure Service Bus Data Sender', + roleDefinitionId: '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39', + scope: '' + } + ] + }) + }) + }) + + describe('when provisioning a namespace on the Basic tier', () => { + const administrationClient = Mock.ofType() + let error: unknown + + beforeAll(async () => { + administrationClient + .setup(a => a.getNamespaceProperties()) + .returns(async () => ({ name: 'ns', messagingSku: 'Basic' }) as any) + const sut = new AzureServiceBusTransport( + configuration, + undefined, + administrationClient.object + ) + prepare(sut) + error = await sut.provision(provisionOptions()).catch(e => e) + }) + + it('should throw AzureServiceBusTierNotSupported', () => { + expect(error).toBeInstanceOf(AzureServiceBusTierNotSupported) + expect(error).toMatchObject({ namespace: 'ns', tier: 'Basic' }) + }) + + it('should create nothing', () => { + administrationClient.verify( + a => a.createTopic(It.isAny(), It.isAny()), + Times.never() + ) + }) + }) + + describe('when provisioning entities that exist', () => { + const administrationClient = Mock.ofType() + + beforeAll(async () => { + administrationClient + .setup(a => a.getNamespaceProperties()) + .returns(async () => ({ name: 'ns', messagingSku: 'Standard' }) as any) + administrationClient + .setup(a => a.createTopic(It.isAny(), It.isAny())) + .returns(async () => Promise.reject(alreadyExists())) + administrationClient + .setup(a => a.createQueue(It.isAny(), It.isAny())) + .returns(async () => Promise.reject(alreadyExists())) + administrationClient + .setup(a => a.createSubscription(It.isAny(), It.isAny(), It.isAny())) + .returns(async () => Promise.reject(alreadyExists())) + // The service queue doesn't forward its dead letters yet + administrationClient + .setup(a => a.getQueue('orders')) + .returns( + async () => + ({ + name: 'orders', + lockDuration: 'PT1M', + maxDeliveryCount: 10 + }) as any + ) + // Service Bus reports where a subscription forwards to as a URL + administrationClient + .setup(a => a.getSubscription(It.isAny(), It.isAny())) + .returns( + async () => + ({ + forwardTo: 'sb://ns.servicebus.windows.net/Orders', + forwardDeadLetteredMessagesTo: + 'sb://ns.servicebus.windows.net/orders-dead-letter' + }) as any + ) + const sut = new AzureServiceBusTransport( + configuration, + undefined, + administrationClient.object + ) + prepare(sut) + await sut.provision(provisionOptions()) + }) + + it('should update a queue whose settings differ', () => { + administrationClient.verify( + a => + a.updateQueue( + It.isObjectWith({ + name: 'orders', + forwardDeadLetteredMessagesTo: 'orders-dead-letter' + }) as any + ), + Times.once() + ) + }) + + it('should leave subscriptions that already forward to the queue as they are', () => { + administrationClient.verify( + a => a.updateSubscription(It.isAny()), + Times.never() + ) + }) + }) + + describe('when publishing to a topic that does not exist', () => { + let error: unknown + + beforeAll(async () => { + const { sut, sender } = await startedTransport() + sender + .setup(s => s.sendMessages(It.isAny())) + .returns(async () => + Promise.reject(serviceBusError('MessagingEntityNotFound')) + ) + error = await sut.publish(new TestEvent()).catch(e => e) + }) + + it('should throw ResourcesNotProvisioned naming the topic', () => { + expect(error).toBeInstanceOf(ResourcesNotProvisioned) + expect((error as ResourcesNotProvisioned).missingResources).toEqual([ + 'Service Bus topic node-ts-bus-azure-service-bus-test-event' + ]) + }) + }) + + describe('when Service Bus rejects a message as too large', () => { + let error: unknown + + beforeAll(async () => { + const { sut, sender } = await startedTransport() + sender + .setup(s => s.sendMessages(It.isAny())) + .returns(async () => + Promise.reject(serviceBusError('MessageSizeExceeded')) + ) + error = await sut.publish(new TestEvent()).catch(e => e) + }) + + it('should throw AzureServiceBusMessageTooLarge naming the message', () => { + expect(error).toBeInstanceOf(AzureServiceBusMessageTooLarge) + expect(error).toMatchObject({ messageName: TestEvent.NAME }) + }) + }) + + describe('when returning a message', () => { + const raw = receivedMessage() + let scheduled: ServiceBusMessage + let scheduledFor: Date + let receiver: IMock + const returnedAt = Date.now() + + beforeAll(async () => { + const started = await startedTransport() + receiver = started.receiver + started.sender + .setup(s => s.scheduleMessages(It.isAny(), It.isAny())) + .callback((message: ServiceBusMessage, at: Date) => { + scheduled = message + scheduledFor = at + }) + .returns(async () => []) + await started.sut.returnMessage(toTransportMessage(raw, 2), 5_000) + }) + + it('should schedule a copy for the delay from now', () => { + expect(scheduledFor.getTime()).toBeGreaterThanOrEqual(returnedAt + 5_000) + expect(scheduled.body).toEqual(raw.body) + }) + + it('should count one more failed attempt on the copy', () => { + expect(scheduled.applicationProperties).toMatchObject({ + failedAttempts: 3, + messageId: 'message-1', + 'attributes.a': 1 + }) + }) + + it("should give the copy a native message id of the bus' id and the attempt", () => { + expect(scheduled.messageId).toEqual('message-1:3') + }) + + it('should complete the original', () => { + receiver.verify(r => r.completeMessage(raw), Times.once()) + }) + }) + + describe('when failing a message', () => { + const raw = receivedMessage() + const failure: MessageFailure = { + error: { name: 'ValidationError', message: 'Bad input' }, + failedAttempts: 3, + endpoint: 'orders', + messageId: 'message-1', + failedAt: new Date().toISOString() + } + let options: Record + + beforeAll(async () => { + const { sut, receiver } = await startedTransport() + receiver + .setup(r => r.deadLetterMessage(It.isAny(), It.isAny())) + .callback((_, deadLetterOptions: Record) => { + options = deadLetterOptions + }) + .returns(async () => undefined) + await sut.fail(toTransportMessage(raw), failure) + }) + + it('should dead-letter it with the error as the reason and description', () => { + expect(options).toMatchObject({ + deadLetterReason: 'ValidationError', + deadLetterErrorDescription: 'Bad input' + }) + }) + + it('should add the failure metadata', () => { + expect( + fromFailureHeader(options[FAILURE_HEADER] as string) + ).toMatchObject(failure) + }) + + it('should reset its failed attempts, so a replay starts again', () => { + expect(options.failedAttempts).toEqual(0) + }) + }) + + describe('when reading a message whose body can not be parsed', () => { + let result: unknown + let deadLetterOptions: Record + + beforeAll(async () => { + const client = Mock.ofType() + const receiver = Mock.ofType() + client + .setup(c => c.createReceiver(It.isAny(), It.isAny())) + .returns(() => receiver.object) + receiver + .setup(r => r.deadLetterMessage(It.isAny(), It.isAny())) + .callback((_, options: Record) => { + deadLetterOptions = options + }) + .returns(async () => undefined) + receiver + .setup(r => r.subscribe(It.isAny(), It.isAny())) + .callback(({ processMessage }) => { + // Not awaited: the callback is held until the message is settled + void processMessage( + receivedMessage({ body: Buffer.from('not json') }) + ) + }) + .returns(() => ({ close: async () => undefined })) + const sut = new AzureServiceBusTransport(configuration, client.object) + prepare(sut) + await sut.connect({ concurrency: 1 }) + await sut.start() + result = await sut.readNextMessage() + }) + + it('should return nothing', () => { + expect(result).toBeUndefined() + }) + + it('should dead-letter it with the parse error', () => { + expect( + fromFailureHeader(deadLetterOptions[FAILURE_HEADER] as string) + ).toMatchObject({ + endpoint: 'orders', + messageId: 'message-1', + failedAttempts: 1 + }) + }) + }) + + describe('when the transport is started again after it stops', () => { + const firstReceiver = Mock.ofType() + const secondReceiver = Mock.ofType() + + beforeAll(async () => { + const client = Mock.ofType() + const receivers = [firstReceiver, secondReceiver] + receivers.forEach(receiver => { + receiver + .setup(r => r.subscribe(It.isAny(), It.isAny())) + .returns(() => ({ close: async () => undefined })) + receiver.setup(r => r.close()).returns(async () => undefined) + }) + let created = 0 + client + .setup(c => c.createReceiver(It.isAny(), It.isAny())) + .returns(() => receivers[created++].object) + const sut = new AzureServiceBusTransport(configuration, client.object) + prepare(sut) + await sut.connect({ concurrency: 1 }) + await sut.start() + await sut.stop() + await sut.start() + await sut.stop() + await sut.disconnect() + }) + + it('should close the first receiver', () => { + firstReceiver.verify(r => r.close(), Times.once()) + }) + + it('should close the second receiver', () => { + secondReceiver.verify(r => r.close(), Times.once()) + }) + }) + + describe('when the transport stops while a read is waiting', () => { + let result: unknown + + beforeAll(async () => { + const { sut } = await startedTransport() + const read = sut.readNextMessage() + await sut.stop() + result = await read + }) + + it('should release the read with nothing', () => { + expect(result).toBeUndefined() + }) + }) +}) diff --git a/packages/bus-azure-service-bus/src/azure-service-bus-transport.ts b/packages/bus-azure-service-bus/src/azure-service-bus-transport.ts new file mode 100644 index 00000000..349ab78a --- /dev/null +++ b/packages/bus-azure-service-bus/src/azure-service-bus-transport.ts @@ -0,0 +1,1173 @@ +import { + isServiceBusError, + ServiceBusAdministrationClient, + ServiceBusClient, + ServiceBusMessage, + ServiceBusReceivedMessage, + ServiceBusReceiver, + ServiceBusSender +} from '@azure/service-bus' +import { + CoreDependencies, + createMessageFailure, + DEFAULT_DEAD_LETTER_QUEUE_NAME, + EndpointNotFound, + FAILURE_HEADER, + JsonValue, + Logger, + MessageFailure, + Milliseconds, + ProvisionedResource, + ProvisioningPlan, + ResourcesNotProvisioned, + toFailureHeader, + Transport, + TransportConnectionOptions, + TransportInitializationOptions, + TransportMessage, + TransportProvisionOptions, + TransportSendOptions +} from '@node-ts/bus-core' +import { + Command, + Event, + Message, + MessageAttributes +} from '@node-ts/bus-messages' +import { EventEmitter } from 'node:events' +import { serializeError } from 'serialize-error' +import { AzureServiceBusTransportConfiguration } from './azure-service-bus-transport-configuration' +import { + resolveTopicName as defaultResolveTopicName, + resolveSubscriptionName +} from './entity-names' +import { + AzureServiceBusConnectionNotConfigured, + AzureServiceBusMessageTooLarge, + AzureServiceBusTierNotSupported +} from './error' +import { mapWithConcurrency } from './map-with-concurrency' +import { + assertHeadersNotReserved, + FAILED_ATTEMPTS_PROPERTY, + MESSAGE_ID_PROPERTY, + toApplicationProperties, + toFailedAttempts, + toMessageAttributes +} from './message-properties' + +const DEFAULT_LOCK_DURATION = 'PT1M' +const DEFAULT_MAX_AUTO_LOCK_RENEWAL_DURATION_MS: Milliseconds = 5 * 60 * 1000 +const DEFAULT_MAX_DELIVERY_COUNT = 10 +/** + * The most administration calls `provision()` and `initialize()` make at once, to stay clear of throttling + */ +const ADMINISTRATION_CONCURRENCY = 10 +const CONTENT_TYPE = 'application/json' +const TRANSPORT_NAME = 'AzureServiceBusTransport' + +/** + * A retry copy's native message id: the bus' message id and the attempt it's for + */ +const RETRY_MESSAGE_ID_SUFFIX = /:\d+$/ + +/** + * Azure's built-in Service Bus roles + */ +const ROLES = { + owner: { + role: 'Azure Service Bus Data Owner', + roleDefinitionId: '090c5cfd-751d-490a-894a-3ce6f1109419' + }, + receiver: { + role: 'Azure Service Bus Data Receiver', + roleDefinitionId: '4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0' + }, + sender: { + role: 'Azure Service Bus Data Sender', + roleDefinitionId: '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39' + } +} as const + +enum ReceivedEvent { + Received = 'received', + Stopped = 'stopped' +} + +interface ServiceBusQueue { + name: string + /** + * The settings the queue is created with and kept in step with + */ + properties: { + lockDuration?: string + maxDeliveryCount?: number + forwardDeadLetteredMessagesTo?: string + } +} + +interface ServiceBusSubscription { + topicName: string + subscriptionName: string + forwardTo: string + forwardDeadLetteredMessagesTo: string + /** + * Whether the topic is managed outside the bus, as the topic of a custom handler is, so it isn't created + */ + external: boolean +} + +/** + * Everything the transport provisions + */ +interface ServiceBusResources { + /** + * The bus' own topics: one for each message it handles or has message types for + */ + topics: string[] + /** + * The dead letter queue then the service queue, unless the transport only sends + */ + queues: ServiceBusQueue[] + /** + * A subscription on each topic the service handles, forwarding into the service queue + */ + subscriptions: ServiceBusSubscription[] + sendOnly: boolean + sendsAnyMessage: boolean +} + +/** + * A transport that sends each message to a Service Bus topic named after it, and receives from a queue per service, + * which a subscription on each topic it handles forwards into. Retries are scheduled copies of the message, and + * dead-lettered messages are forwarded to a shared dead letter queue. + * @example + * const transport = new AzureServiceBusTransport({ + * connectionString: process.env.SERVICE_BUS_CONNECTION_STRING, + * queueName: 'order-booking-service' + * }) + */ +export class AzureServiceBusTransport implements Transport { + /** + * The name of the service queue, from `queueName`. It's the return address replies are sent to, in the same + * namespace. + */ + readonly endpointName: string + + private coreDependencies: CoreDependencies + private logger: Logger + private client: ServiceBusClient | undefined + private administrationClient: ServiceBusAdministrationClient | undefined + /** + * Whether the transport created its client, and so closes it on `disconnect()` + */ + private ownsClient = false + private readonly deadLetterQueueName: string + private readonly resolveTopicName: (messageName: string) => string + private concurrency = 1 + private receiver: ServiceBusReceiver | undefined + private subscription: { close(): Promise } | undefined + private readonly senders = new Map() + private isStarted = false + /** + * Messages Service Bus has delivered that `readNextMessage()` hasn't returned yet + */ + private readonly receivedMessages: ServiceBusReceivedMessage[] = [] + private readonly receivedEvents = new EventEmitter() + /** + * Releases the handler callback holding each delivered message, once the message is settled + */ + private readonly settlements = new Map< + ServiceBusReceivedMessage, + () => void + >() + /** + * Whether the bus provisioned at startup, so the topic of a message that wasn't provisioned is created as it's sent + */ + private autoProvision = false + /** + * Topics that are known to exist, so an auto-provisioned send doesn't create them again + */ + private readonly knownTopics = new Set() + + /** + * An Azure Service Bus transport adapter for @node-ts/bus + * @param configuration how to connect, the queue names and the receive settings + * @param client a client to use instead of one created from the configuration, such as one shared by several + * transports. The transport doesn't close a client it's given. + * @param administrationClient an administration client for `provision()` and `verifySubscriptions`, such as one + * pointed at the Service Bus emulator's management port + */ + constructor( + private readonly configuration: AzureServiceBusTransportConfiguration, + client?: ServiceBusClient, + administrationClient?: ServiceBusAdministrationClient + ) { + this.endpointName = configuration.queueName + this.deadLetterQueueName = + configuration.deadLetterQueueName || DEFAULT_DEAD_LETTER_QUEUE_NAME + this.resolveTopicName = + configuration.resolveTopicName ?? defaultResolveTopicName + this.client = client + this.administrationClient = administrationClient + // Each waiting read listens for both events, and the bus reads `concurrency` messages at once + this.receivedEvents.setMaxListeners(0) + } + + prepare(coreDependencies: CoreDependencies): void { + this.coreDependencies = coreDependencies + this.logger = coreDependencies.loggerFactory( + '@node-ts/bus-azure-service-bus:azure-service-bus-transport' + ) + } + + /** + * Creates the client, unless one was passed to the constructor. Service Bus clients connect on first use. + * @param options how many messages the bus handles at once, which is how many the transport receives at once + * @throws AzureServiceBusConnectionNotConfigured if there's no way to create the client + */ + async connect(options: TransportConnectionOptions): Promise { + this.concurrency = options.concurrency + this.getClient() + } + + /** + * Closes the receiver and senders, and the client if the transport created it + */ + async disconnect(): Promise { + // Settled messages have released their callbacks, so this only frees callbacks of messages that weren't settled + this.settlements.forEach(release => release()) + this.settlements.clear() + await this.receiver?.close() + this.receiver = undefined + this.subscription = undefined + await Promise.allSettled([...this.senders.values()].map(s => s.close())) + this.senders.clear() + if (this.ownsClient) { + await this.client?.close() + this.client = undefined + this.ownsClient = false + } + } + + /** + * Starts receiving from the service queue, `concurrency` messages at a time, in peek-lock mode with the locks renewed + * while the messages are handled + */ + async start(): Promise { + // A restarted bus would otherwise leave the previous receiver's link open. Messages it delivered that are still + // being handled settle by their lock tokens, which doesn't need the receiver they came from. + await this.receiver?.close() + this.isStarted = true + this.receiver = this.getClient().createReceiver(this.endpointName, { + receiveMode: 'peekLock', + maxAutoLockRenewalDurationInMs: + this.configuration.maxAutoLockRenewalDurationInMs ?? + DEFAULT_MAX_AUTO_LOCK_RENEWAL_DURATION_MS, + // The body is the serialized message, which the bus' serializer reads + skipParsingBodyAsJson: true + }) + this.subscription = this.receiver.subscribe( + { + processMessage: async message => this.holdUntilSettled(message), + processError: async ({ error, errorSource, entityPath }) => { + this.logger.error('Error receiving from Service Bus', { + errorSource, + entityPath, + error: serializeError(error) + }) + } + }, + { autoCompleteMessages: false, maxConcurrentCalls: this.concurrency } + ) + } + + /** + * Stops receiving, releases any reads still waiting, and abandons messages that were delivered but not read, so + * Service Bus delivers them again straight away. Messages being handled are still settled, through the receiver, + * which stays open until `disconnect()`. + */ + async stop(): Promise { + this.isStarted = false + this.receivedEvents.emit(ReceivedEvent.Stopped) + await this.subscription?.close() + const unread = this.receivedMessages.splice(0) + await Promise.all( + unread.map(async message => + this.settle(message, 'abandoned', async () => + this.receiver!.abandonMessage(message) + ).catch(error => + this.logger.warn( + "Couldn't abandon a message that wasn't handled before the transport stopped. It's delivered again once its lock expires.", + { messageId: message.messageId, error: serializeError(error) } + ) + ) + ) + ) + } + + /** + * Checks, unless `verifyResources` is off or the transport only sends, that the service queue exists, with a peek + * that only needs the Listen right. With `verifySubscriptions`, it also checks the dead letter queue and the + * subscription on each topic the service handles, through the administration client. It creates nothing. + * @param options the messages the bus handles and sends, and whether to check its resources + * @throws ResourcesNotProvisioned if a queue, topic or subscription doesn't exist + */ + async initialize(options: TransportInitializationOptions): Promise { + this.autoProvision = options.autoProvision + if (!options.verifyResources || options.sendOnly) { + return + } + this.logger.info('Checking Service Bus resources exist', { + queueName: this.endpointName, + verifySubscriptions: !!this.configuration.verifySubscriptions + }) + const missingResources: string[] = [] + if (!(await this.queueExists(this.endpointName))) { + missingResources.push(`Service Bus queue ${this.endpointName}`) + } + if (this.configuration.verifySubscriptions) { + missingResources.push( + ...(await this.findMissingSubscriptions(this.planResources(options))) + ) + } + if (missingResources.length) { + throw new ResourcesNotProvisioned(TRANSPORT_NAME, missingResources) + } + } + + /** + * Creates a topic for each message, and unless the transport only sends, the dead letter queue, the service queue + * (forwarding its dead letters to the dead letter queue) and a subscription on each topic the service handles that + * forwards into the service queue, including the topics of custom handlers, which it doesn't create. An entity that + * exists is left as it is, except that the settings the transport relies on are updated when they differ. + * + * It needs the Manage right, or the Azure Service Bus Data Owner role, and fails on the Basic tier. + * @param options the messages the bus handles and sends, and whether it's a dry run, which makes no calls + * @returns the topics, queues and subscriptions, and the Azure role assignments the transport needs at runtime + * @throws AzureServiceBusTierNotSupported if the namespace is on the Basic tier + */ + async provision( + options: TransportProvisionOptions + ): Promise { + const resources = this.planResources(options) + const plan: ProvisioningPlan = { + adapter: TRANSPORT_NAME, + resources: describeResources(resources), + runtimePermissions: { + format: 'azure-rbac', + document: this.runtimeRoleAssignments(resources) + } + } + if (options.dryRun) { + return plan + } + + const administrationClient = this.getAdministrationClient() + this.logger.info('Provisioning Service Bus resources', { + resources: plan.resources.length + }) + const namespace = await administrationClient.getNamespaceProperties() + if (namespace.messagingSku === 'Basic') { + throw new AzureServiceBusTierNotSupported( + namespace.name, + namespace.messagingSku + ) + } + await mapWithConcurrency( + resources.topics, + ADMINISTRATION_CONCURRENCY, + async topicName => this.createTopic(topicName) + ) + resources.topics.forEach(topicName => this.knownTopics.add(topicName)) + // A queue that forwards must be created after the queue it forwards to + for (const queue of resources.queues) { + await this.createOrUpdateQueue(queue) + } + await mapWithConcurrency( + resources.subscriptions, + ADMINISTRATION_CONCURRENCY, + async subscription => this.createOrUpdateSubscription(subscription) + ) + return plan + } + + /** + * Checks the headers set by outgoing middleware before the bus buffers or sends the message + * @param sendOptions the options the message will be sent with + * @throws TransportHeaderReserved if a header is named `messageId`, `sentAt`, `failedAttempts`, `bus-failure`, + * `DeadLetterReason` or `DeadLetterErrorDescription`, or starts with `attributes.` or `stickyAttributes.` + */ + assertSendOptions(sendOptions: TransportSendOptions): void { + assertHeadersNotReserved(sendOptions.headers ?? {}) + } + + /** + * Publishes an event to its topic + * @param event the event to publish + * @param messageAttributes the attributes to publish it with. `messageId`, `correlationId` and `replyTo` are native + * fields, and the rest are application properties. + * @param sendOptions native headers from outgoing middleware, each written as an application property under its own + * name + * @throws TransportHeaderReserved if a header has a name the transport writes itself (see `assertSendOptions`) + * @throws ResourcesNotProvisioned if the topic doesn't exist + * @throws AzureServiceBusMessageTooLarge if Service Bus rejects the message as too large + */ + async publish( + event: TEvent, + messageAttributes?: MessageAttributes, + sendOptions?: TransportSendOptions + ): Promise { + await this.publishMessage(event, messageAttributes, sendOptions) + } + + /** + * Sends a command to its topic + * @param command the command to send + * @param messageAttributes the attributes to send it with, written as for `publish` + * @param sendOptions native headers from outgoing middleware, written as for `publish` + * @throws TransportHeaderReserved if a header has a name the transport writes itself (see `assertSendOptions`) + * @throws ResourcesNotProvisioned if the topic doesn't exist + * @throws AzureServiceBusMessageTooLarge if Service Bus rejects the message as too large + */ + async send( + command: TCommand, + messageAttributes?: MessageAttributes, + sendOptions?: TransportSendOptions + ): Promise { + await this.publishMessage(command, messageAttributes, sendOptions) + } + + /** + * Sends a message straight to the queue at a return address, in the same namespace, without sending it to its + * topic, so only that queue receives it. The bus calls it for `ctx.reply()`. The sender needs the Send right on the + * queue. + * @param address the name of the queue, which is the `queueName` of the transport that reads it + * @param message the command or event to send + * @param messageAttributes the attributes to send it with, written as for `send` + * @param sendOptions native headers from outgoing middleware, written as for `send` + * @throws TransportHeaderReserved if a header has a name the transport writes itself + * @throws EndpointNotFound if Service Bus reports that the queue doesn't exist + * @throws AzureServiceBusMessageTooLarge if Service Bus rejects the message as too large + */ + async sendToAddress( + address: string, + message: Message, + messageAttributes?: MessageAttributes, + sendOptions?: TransportSendOptions + ): Promise { + try { + await this.sendTo(address, message, messageAttributes, sendOptions) + } catch (error) { + if (isEntityMissing(error)) { + throw new EndpointNotFound(address, TRANSPORT_NAME, error) + } + throw error + } + } + + /** + * Waits for the next message Service Bus delivers. A message whose body can't be deserialized is dead-lettered, + * and `undefined` is returned, as it is once the transport stops. + */ + async readNextMessage(): Promise< + TransportMessage | undefined + > { + const message = await this.nextReceivedMessage() + if (!message) { + return undefined + } + try { + return this.toTransportMessage(message) + } catch (error) { + // Parsing fails the same way on every delivery, so retrying can't help + this.logger.warn( + 'Could not parse message. It will be sent to the dead letter queue', + { messageId: message.messageId, error: serializeError(error) } + ) + const messageId = message.applicationProperties?.[MESSAGE_ID_PROPERTY] + await this.deadLetter( + message, + createMessageFailure(error, { + failedAttempts: toFailedAttempts(message) + 1, + endpoint: this.endpointName, + messageId: typeof messageId === 'string' ? messageId : undefined + }) + ) + return undefined + } + } + + /** + * Completes a message, removing it from the service queue + * @param message the message to remove + */ + async deleteMessage( + message: TransportMessage + ): Promise { + await this.settle(message.raw, 'completed', async () => + this.receiver!.completeMessage(message.raw) + ) + } + + /** + * Schedules a copy of a message on the service queue for `delay` from now, with one more failed attempt in its + * `failedAttempts` property and its own native message id, then completes the original. The copy is scheduled + * first, so if completing fails the message is handled again rather than lost. + * @param message the message to return + * @param delay how long until the copy can be received, in milliseconds + */ + async returnMessage( + message: TransportMessage, + delay: Milliseconds + ): Promise { + const raw = message.raw as ServiceBusReceivedMessage + const failedAttempts = message.failedAttempts + 1 + const copy = toRetryCopy(raw, failedAttempts) + await this.settle(raw, 'returned', async () => { + const sender = this.senderFor(this.endpointName) + if (delay > 0) { + await sender.scheduleMessages(copy, new Date(Date.now() + delay)) + } else { + await sender.sendMessages(copy) + } + await this.receiver!.completeMessage(raw) + }) + this.logger.debug('Message returned to the queue', { + messageId: copy.messageId, + failedAttempts, + delay + }) + } + + /** + * Dead-letters a message with Service Bus' own dead-lettering, with its failure metadata in a `bus-failure` + * application property and its `failedAttempts` reset, so a replayed message starts again. The service queue + * forwards it to the shared dead letter queue. + * @param transportMessage the message to dead-letter + * @param failure why and where it failed + */ + async fail( + transportMessage: TransportMessage, + failure: MessageFailure + ): Promise { + await this.deadLetter( + transportMessage.raw as ServiceBusReceivedMessage, + failure + ) + } + + private async deadLetter( + message: ServiceBusReceivedMessage, + failure: MessageFailure + ): Promise { + await this.settle(message, 'dead-lettered', async () => + this.receiver!.deadLetterMessage(message, { + deadLetterReason: failure.error.name, + deadLetterErrorDescription: failure.error.message, + [FAILURE_HEADER]: toFailureHeader(failure), + [FAILED_ATTEMPTS_PROPERTY]: 0 + }) + ) + this.logger.debug('Message dead-lettered', { + messageId: message.messageId, + deadLetterQueue: this.deadLetterQueueName + }) + } + + /** + * Hands a delivered message to `readNextMessage()`, and holds the receiver's callback until the message is settled, + * so Service Bus delivers at most `concurrency` messages at a time and keeps renewing their locks + */ + private async holdUntilSettled( + message: ServiceBusReceivedMessage + ): Promise { + if (!this.isStarted) { + await this.receiver?.abandonMessage(message).catch(() => undefined) + return + } + const settled = new Promise(resolve => + this.settlements.set(message, resolve) + ) + this.receivedMessages.push(message) + this.receivedEvents.emit(ReceivedEvent.Received) + await settled + } + + /** + * Waits for a message to be delivered, or for the transport to stop + */ + private async nextReceivedMessage(): Promise< + ServiceBusReceivedMessage | undefined + > { + const message = this.receivedMessages.shift() + if (message || !this.isStarted) { + return message + } + return new Promise(resolve => { + const onReceived = () => { + const received = this.receivedMessages.shift() + if (received) { + unsubscribe() + resolve(received) + } + } + const onStopped = () => { + unsubscribe() + resolve(undefined) + } + const unsubscribe = () => { + this.receivedEvents.off(ReceivedEvent.Received, onReceived) + this.receivedEvents.off(ReceivedEvent.Stopped, onStopped) + } + this.receivedEvents.on(ReceivedEvent.Received, onReceived) + this.receivedEvents.on(ReceivedEvent.Stopped, onStopped) + }) + } + + /** + * Settles a message, then releases its receiver callback, whether or not settling succeeded + */ + private async settle( + message: ServiceBusReceivedMessage, + outcome: string, + settlement: () => Promise + ): Promise { + try { + await settlement() + } catch (error) { + this.logger.warn('Could not settle message', { + messageId: message.messageId, + outcome, + error: serializeError(error) + }) + throw error + } finally { + this.settlements.get(message)?.() + this.settlements.delete(message) + } + } + + private toTransportMessage( + message: ServiceBusReceivedMessage + ): TransportMessage { + const domainMessage = this.coreDependencies.messageSerializer.deserialize( + bodyToString(message.body) + ) + return { + id: message.messageId?.toString(), + raw: message, + domainMessage, + attributes: toMessageAttributes(message), + failedAttempts: toFailedAttempts(message) + } + } + + private async publishMessage( + message: Message, + messageAttributes?: MessageAttributes, + sendOptions?: TransportSendOptions + ): Promise { + const topicName = this.resolveTopicName(message.$name) + if (this.autoProvision && !this.knownTopics.has(topicName)) { + await this.createTopic(topicName) + this.knownTopics.add(topicName) + } + try { + await this.sendTo(topicName, message, messageAttributes, sendOptions) + } catch (error) { + if (isEntityMissing(error)) { + throw new ResourcesNotProvisioned(TRANSPORT_NAME, [ + `Service Bus topic ${topicName}` + ]) + } + throw error + } + } + + /** + * Sends a message to a topic or queue, with its attributes and headers + */ + private async sendTo( + entityName: string, + message: Message, + messageAttributes: MessageAttributes = { + attributes: {}, + stickyAttributes: {} + }, + sendOptions: TransportSendOptions = {} + ): Promise { + const applicationProperties = toApplicationProperties( + messageAttributes, + sendOptions.headers ?? {} + ) + const body = Buffer.from( + this.coreDependencies.messageSerializer.serialize(message), + 'utf8' + ) + const serviceBusMessage: ServiceBusMessage = { + body, + contentType: CONTENT_TYPE, + subject: message.$name, + applicationProperties, + ...(messageAttributes.messageId + ? { messageId: messageAttributes.messageId } + : {}), + ...(messageAttributes.correlationId + ? { correlationId: messageAttributes.correlationId } + : {}), + ...(messageAttributes.replyTo + ? { replyTo: messageAttributes.replyTo } + : {}) + } + this.logger.debug('Sending message to Service Bus', { + entityName, + messageName: message.$name, + messageId: messageAttributes.messageId + }) + try { + await this.senderFor(entityName).sendMessages(serviceBusMessage) + } catch (error) { + if (isMessageTooLarge(error)) { + throw new AzureServiceBusMessageTooLarge( + message.$name, + body.length, + error + ) + } + if (isEntityMissing(error)) { + // Its link failed to attach, so a later send opens a new one, which works once the entity is created + await this.closeSender(entityName) + } + throw error + } + } + + /** + * Gets the sender for a topic or queue, which is created once and reused + */ + private senderFor(entityName: string): ServiceBusSender { + let sender = this.senders.get(entityName) + if (!sender) { + sender = this.getClient().createSender(entityName) + this.senders.set(entityName, sender) + } + return sender + } + + private async closeSender(entityName: string): Promise { + const sender = this.senders.get(entityName) + this.senders.delete(entityName) + try { + await sender?.close() + } catch (error) { + this.logger.debug('Could not close the sender of a missing entity', { + entityName, + error: serializeError(error) + }) + } + } + + /** + * Checks a queue exists with a peek, which only needs the Listen right + * @returns false if Service Bus reports that it doesn't + */ + private async queueExists(queueName: string): Promise { + const receiver = this.getClient().createReceiver(queueName) + try { + await receiver.peekMessages(1) + return true + } catch (error) { + if (isEntityMissing(error)) { + return false + } + throw error + } finally { + await receiver.close().catch(() => undefined) + } + } + + /** + * Checks the dead letter queue, and the topic and subscription of each topic the service handles, through the + * administration client + * @returns the description of each one that's missing + */ + private async findMissingSubscriptions( + resources: ServiceBusResources + ): Promise { + const administrationClient = this.getAdministrationClient() + const missingResources: string[] = [] + if (!(await administrationClient.queueExists(this.deadLetterQueueName))) { + missingResources.push(`Service Bus queue ${this.deadLetterQueueName}`) + } + const subscriptionChecks = await mapWithConcurrency( + resources.subscriptions, + ADMINISTRATION_CONCURRENCY, + async ({ topicName, subscriptionName }) => { + const subscription = `Service Bus subscription ${subscriptionName} on topic ${topicName}` + if (!(await administrationClient.topicExists(topicName))) { + return [`Service Bus topic ${topicName}`, subscription] + } + return (await administrationClient.subscriptionExists( + topicName, + subscriptionName + )) + ? [] + : [subscription] + } + ) + return [...missingResources, ...subscriptionChecks.flat()] + } + + /** + * Works out every topic, queue and subscription the bus needs + */ + private planResources({ + handlerRegistry, + sendOnly, + messageNames, + sendsAnyMessage = false + }: Pick< + TransportProvisionOptions, + 'handlerRegistry' | 'sendOnly' | 'messageNames' + > & + Partial< + Pick + >): ServiceBusResources { + const topics = new Set( + messageNames.map(name => this.resolveTopicName(name)) + ) + if (sendOnly) { + return { + topics: [...topics], + queues: [], + subscriptions: [], + sendOnly, + sendsAnyMessage + } + } + const handledTopics = new Set( + handlerRegistry.getMessageNames().map(name => this.resolveTopicName(name)) + ) + handledTopics.forEach(topicName => topics.add(topicName)) + // The topics of custom handlers are managed outside the bus, so they're only subscribed to + const externalTopics = new Set( + handlerRegistry + .getExternallyManagedTopicIdentifiers() + .filter(topicName => !handledTopics.has(topicName)) + ) + const subscriptionName = resolveSubscriptionName(this.endpointName) + const toSubscription = ( + topicName: string, + external: boolean + ): ServiceBusSubscription => ({ + topicName, + subscriptionName, + forwardTo: this.endpointName, + forwardDeadLetteredMessagesTo: this.deadLetterQueueName, + external + }) + return { + topics: [...topics], + queues: [ + { name: this.deadLetterQueueName, properties: {} }, + { + name: this.endpointName, + properties: { + lockDuration: + this.configuration.lockDuration ?? DEFAULT_LOCK_DURATION, + maxDeliveryCount: + this.configuration.maxDeliveryCount ?? DEFAULT_MAX_DELIVERY_COUNT, + forwardDeadLetteredMessagesTo: this.deadLetterQueueName + } + } + ], + subscriptions: [ + ...[...handledTopics].map(topicName => + toSubscription(topicName, false) + ), + ...[...externalTopics].map(topicName => toSubscription(topicName, true)) + ], + sendOnly, + sendsAnyMessage + } + } + + /** + * The Azure role assignments of the least the transport needs at runtime, once its resources are provisioned. Each + * scope is relative to the namespace's resource ID, and `''` is the namespace itself. + */ + private runtimeRoleAssignments(resources: ServiceBusResources): JsonValue { + const roleAssignments: JsonValue[] = [] + const assign = (role: (typeof ROLES)[keyof typeof ROLES], scope: string) => + roleAssignments.push({ ...role, scope }) + if (!resources.sendOnly) { + // Receiving and checking the queue exists, and scheduling retries on it + assign(ROLES.receiver, `/queues/${this.endpointName}`) + assign(ROLES.sender, `/queues/${this.endpointName}`) + } + if (resources.sendsAnyMessage) { + // Role assignments can't be scoped to a pattern of topic names + assign(ROLES.sender, '') + } else { + resources.topics.forEach(topicName => + assign(ROLES.sender, `/topics/${topicName}`) + ) + } + if (this.configuration.verifySubscriptions && !resources.sendOnly) { + // Reading entities through the administration client needs the Data Owner role + assign(ROLES.owner, `/queues/${this.deadLetterQueueName}`) + new Set( + resources.subscriptions.map(({ topicName }) => topicName) + ).forEach(topicName => assign(ROLES.owner, `/topics/${topicName}`)) + } + return { roleAssignments } + } + + /** + * Creates a topic, or leaves it as it is if it exists + */ + private async createTopic(topicName: string): Promise { + this.logger.debug("Creating Service Bus topic if it doesn't exist", { + topicName + }) + await ignoreAlreadyExists( + this.getAdministrationClient().createTopic(topicName) + ) + } + + /** + * Creates a queue, or updates the settings the transport relies on if it exists with others + */ + private async createOrUpdateQueue({ + name, + properties + }: ServiceBusQueue): Promise { + const administrationClient = this.getAdministrationClient() + this.logger.info('Creating Service Bus queue', { + queueName: name, + properties + }) + const created = await ignoreAlreadyExists( + administrationClient.createQueue(name, properties) + ) + if (created || !Object.keys(properties).length) { + return + } + const existing = await administrationClient.getQueue(name) + if (!settingsDiffer(existing, properties)) { + return + } + this.logger.info('Updating Service Bus queue settings', { + queueName: name, + properties + }) + await administrationClient.updateQueue({ ...existing, ...properties }) + } + + /** + * Creates a subscription, or updates where it forwards to if it exists and forwards elsewhere + */ + private async createOrUpdateSubscription({ + topicName, + subscriptionName, + forwardTo, + forwardDeadLetteredMessagesTo + }: ServiceBusSubscription): Promise { + const administrationClient = this.getAdministrationClient() + const properties = { forwardTo, forwardDeadLetteredMessagesTo } + this.logger.info('Creating Service Bus subscription', { + topicName, + subscriptionName, + properties + }) + const created = await ignoreAlreadyExists( + administrationClient.createSubscription( + topicName, + subscriptionName, + properties + ) + ) + if (created) { + return + } + const existing = await administrationClient.getSubscription( + topicName, + subscriptionName + ) + if (!settingsDiffer(existing, properties)) { + return + } + this.logger.info('Updating Service Bus subscription settings', { + topicName, + subscriptionName, + properties + }) + await administrationClient.updateSubscription({ + ...existing, + ...properties + }) + } + + private getClient(): ServiceBusClient { + if (!this.client) { + const { connectionString, fullyQualifiedNamespace, credential } = + this.configuration + if (connectionString) { + this.client = new ServiceBusClient(connectionString) + } else if (fullyQualifiedNamespace && credential) { + this.client = new ServiceBusClient(fullyQualifiedNamespace, credential) + } else { + throw new AzureServiceBusConnectionNotConfigured('ServiceBusClient') + } + this.ownsClient = true + } + return this.client + } + + private getAdministrationClient(): ServiceBusAdministrationClient { + if (!this.administrationClient) { + const { connectionString, fullyQualifiedNamespace, credential } = + this.configuration + if (connectionString) { + this.administrationClient = new ServiceBusAdministrationClient( + connectionString + ) + } else if (fullyQualifiedNamespace && credential) { + this.administrationClient = new ServiceBusAdministrationClient( + fullyQualifiedNamespace, + credential + ) + } else { + throw new AzureServiceBusConnectionNotConfigured( + 'ServiceBusAdministrationClient' + ) + } + } + return this.administrationClient + } +} + +/** + * Lists the plan's resources, as `provision()` creates them + */ +const describeResources = ( + resources: ServiceBusResources +): ProvisionedResource[] => [ + ...resources.topics.map(topicName => ({ + type: 'azure-service-bus-topic', + name: topicName + })), + ...resources.queues.map(({ name, properties }) => ({ + type: 'azure-service-bus-queue', + name, + properties: { ...properties } + })), + ...resources.subscriptions.map( + ({ topicName, subscriptionName, forwardTo, external, ...rest }) => ({ + type: 'azure-service-bus-subscription', + name: `${topicName}/subscriptions/${subscriptionName}`, + properties: { + topicName, + subscriptionName, + forwardTo, + forwardDeadLetteredMessagesTo: rest.forwardDeadLetteredMessagesTo, + ...(external ? { externalTopic: true } : {}) + } + }) + ) +] + +/** + * Builds the copy of a message that `returnMessage` schedules: the same body, native fields and application + * properties, with the new `failedAttempts`, and a native message id of the bus' message id and the attempt, so + * duplicate detection doesn't drop it as a copy of the original + */ +const toRetryCopy = ( + message: ServiceBusReceivedMessage, + failedAttempts: number +): ServiceBusMessage => { + const busMessageId = message.applicationProperties?.[MESSAGE_ID_PROPERTY] + const baseId = + typeof busMessageId === 'string' + ? busMessageId + : String(message.messageId ?? '').replace(RETRY_MESSAGE_ID_SUFFIX, '') + return { + body: message.body, + contentType: message.contentType, + subject: message.subject, + correlationId: message.correlationId, + replyTo: message.replyTo, + messageId: `${baseId}:${failedAttempts}`, + applicationProperties: { + ...message.applicationProperties, + [FAILED_ATTEMPTS_PROPERTY]: failedAttempts + } + } +} + +/** + * Reads a message body as the string the serializer wrote: bytes as UTF-8, a string as it is, and anything else, + * such as an object another sender wrote as an AMQP value, as JSON + */ +const bodyToString = (body: unknown): string => { + if (typeof body === 'string') { + return body + } + if (body instanceof Uint8Array) { + return Buffer.from(body).toString('utf8') + } + return JSON.stringify(body) +} + +/** + * Compares the settings the transport relies on with what an entity has. Entity names are compared without case, + * and by their last segment, since Service Bus may report a forwarding target as a full URL. + */ +const settingsDiffer = ( + existing: object, + expected: Record +): boolean => + Object.entries(expected).some(([key, value]) => { + const actual = (existing as Record)[key] + if (typeof value === 'string' && key.startsWith('forward')) { + return toEntityName(actual) !== toEntityName(value) + } + return actual !== value + }) + +const toEntityName = (value: unknown): string => + typeof value === 'string' + ? (value.split('/').filter(Boolean).pop() ?? '').toLowerCase() + : '' + +/** + * Runs an administration call that creates an entity, treating a conflict because it exists as success + * @returns whether the entity was created + */ +const ignoreAlreadyExists = async ( + create: Promise +): Promise => { + try { + await create + return true + } catch (error) { + const { statusCode, code } = error as { statusCode?: number; code?: string } + if (statusCode === 409 || code === 'MessageEntityAlreadyExistsError') { + return false + } + throw error + } +} + +/** + * Whether Service Bus rejected a call because its queue, topic or subscription doesn't exist + */ +const isEntityMissing = (error: unknown): boolean => + isServiceBusError(error) && error.code === 'MessagingEntityNotFound' + +/** + * Whether Service Bus rejected a message as larger than it allows + */ +const isMessageTooLarge = (error: unknown): boolean => + (error as { code?: string } | undefined)?.code === 'MessageSizeExceeded' diff --git a/packages/bus-azure-service-bus/src/entity-names.spec.ts b/packages/bus-azure-service-bus/src/entity-names.spec.ts new file mode 100644 index 00000000..4d35e597 --- /dev/null +++ b/packages/bus-azure-service-bus/src/entity-names.spec.ts @@ -0,0 +1,49 @@ +import { + MAX_SUBSCRIPTION_NAME_LENGTH, + MAX_TOPIC_NAME_LENGTH, + resolveSubscriptionName, + resolveTopicName +} from './entity-names' + +describe('resolveTopicName', () => { + describe('when the message name has characters Service Bus does not allow', () => { + it('should drop a leading @ and replace the rest with hyphens', () => { + expect(resolveTopicName('@node-ts/bus-test/test-command')).toEqual( + 'node-ts-bus-test-test-command' + ) + }) + + it('should keep letters, digits, periods, hyphens and underscores', () => { + expect(resolveTopicName('Orders.v2_created-event')).toEqual( + 'Orders.v2_created-event' + ) + }) + }) + + describe('when the message name is longer than a topic name can be', () => { + const longName = 'a'.repeat(MAX_TOPIC_NAME_LENGTH + 10) + + it('should shorten it to the limit', () => { + expect(resolveTopicName(longName)).toHaveLength(MAX_TOPIC_NAME_LENGTH) + }) + + it('should give names that share a beginning different topics', () => { + expect(resolveTopicName(`${longName}-x`)).not.toEqual( + resolveTopicName(`${longName}-y`) + ) + }) + }) +}) + +describe('resolveSubscriptionName', () => { + it('should be the queue name when it fits', () => { + expect(resolveSubscriptionName('orders')).toEqual('orders') + }) + + it('should shorten and hash a queue name over 50 characters', () => { + const queueName = `${'q'.repeat(MAX_SUBSCRIPTION_NAME_LENGTH)}-orders` + const subscriptionName = resolveSubscriptionName(queueName) + expect(subscriptionName).toHaveLength(MAX_SUBSCRIPTION_NAME_LENGTH) + expect(subscriptionName).toMatch(/^q+-[0-9a-f]{8}$/) + }) +}) diff --git a/packages/bus-azure-service-bus/src/entity-names.ts b/packages/bus-azure-service-bus/src/entity-names.ts new file mode 100644 index 00000000..6ebca731 --- /dev/null +++ b/packages/bus-azure-service-bus/src/entity-names.ts @@ -0,0 +1,61 @@ +import { createHash } from 'node:crypto' + +/** + * The longest topic or queue name Service Bus accepts + */ +export const MAX_TOPIC_NAME_LENGTH = 260 + +/** + * The longest subscription or rule name Service Bus accepts + */ +export const MAX_SUBSCRIPTION_NAME_LENGTH = 50 + +/** + * How many hex characters of a hash are appended to a name that had to be shortened + */ +const HASH_LENGTH = 8 + +/** + * Characters Service Bus doesn't allow in an entity name. It allows `/` as a path separator, but the transport + * replaces it so each topic is a single segment, as on other brokers. + */ +const INVALID_ENTITY_NAME_CHARACTERS = /[^a-zA-Z0-9._-]/g + +/** + * Shortens a name to `maxLength`, replacing its end with a hash of the whole name, so two long names that share a + * beginning still get different results. A name that fits is returned as it is. + * @param name the name to shorten + * @param maxLength the longest name allowed + */ +export const shortenName = (name: string, maxLength: number): string => { + if (name.length <= maxLength) { + return name + } + const hash = createHash('sha256') + .update(name) + .digest('hex') + .slice(0, HASH_LENGTH) + return `${name.slice(0, maxLength - HASH_LENGTH - 1)}-${hash}` +} + +/** + * Resolves the name of the topic a message is sent to: its `$name` without a leading `@`, with each character Service + * Bus doesn't allow replaced by `-`, and shortened and hashed when it's longer than 260 characters. + * @param messageName the `$name` of the message + * @returns a valid Service Bus topic name + * @example + * resolveTopicName('@node-ts/bus-test/test-command') // 'node-ts-bus-test-test-command' + */ +export const resolveTopicName = (messageName: string): string => + shortenName( + messageName.replace(/^@/, '').replace(INVALID_ENTITY_NAME_CHARACTERS, '-'), + MAX_TOPIC_NAME_LENGTH + ) + +/** + * Resolves the name of a service's subscription on a topic: its queue name, shortened and hashed when it's longer + * than 50 characters + * @param queueName the name of the service queue the subscription forwards to + */ +export const resolveSubscriptionName = (queueName: string): string => + shortenName(queueName, MAX_SUBSCRIPTION_NAME_LENGTH) diff --git a/packages/bus-azure-service-bus/src/error/azure-service-bus-connection-not-configured.ts b/packages/bus-azure-service-bus/src/error/azure-service-bus-connection-not-configured.ts new file mode 100644 index 00000000..8ae06098 --- /dev/null +++ b/packages/bus-azure-service-bus/src/error/azure-service-bus-connection-not-configured.ts @@ -0,0 +1,21 @@ +/** + * Thrown when the Azure Service Bus transport has no way to connect: no `connectionString`, no + * `fullyQualifiedNamespace` and `credential`, and no client passed to its constructor + */ +export class AzureServiceBusConnectionNotConfigured extends Error { + readonly help: string + + /** + * @param client which client couldn't be created + */ + constructor( + readonly client: 'ServiceBusClient' | 'ServiceBusAdministrationClient' + ) { + super( + `AzureServiceBusTransport can't create a ${client}, since it was configured with neither a connectionString nor a fullyQualifiedNamespace and credential` + ) + this.help = `Set connectionString, or fullyQualifiedNamespace and credential (such as new DefaultAzureCredential() from @azure/identity), in the AzureServiceBusTransportConfiguration. Or pass your own ${client} to the AzureServiceBusTransport constructor.` + + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/packages/bus-azure-service-bus/src/error/azure-service-bus-message-too-large.ts b/packages/bus-azure-service-bus/src/error/azure-service-bus-message-too-large.ts new file mode 100644 index 00000000..b49b30a4 --- /dev/null +++ b/packages/bus-azure-service-bus/src/error/azure-service-bus-message-too-large.ts @@ -0,0 +1,25 @@ +/** + * Thrown when Service Bus rejects a message because it's larger than the namespace allows: 256 KB on the Standard + * tier, including its application properties, or 1 MB by default on Premium + */ +export class AzureServiceBusMessageTooLarge extends Error { + readonly help: string + + /** + * @param messageName the `$name` of the message + * @param bodySize the size of the serialized message body, in bytes + * @param error the error Service Bus rejected it with + */ + constructor( + readonly messageName: string, + readonly bodySize: number, + readonly error: unknown + ) { + super( + `Service Bus rejected the message ${messageName} as too large. Its body is ${bodySize} bytes, and with its application properties it's over the namespace's limit` + ) + this.help = `Keep messages small: store a large payload elsewhere, such as in blob storage, and send a reference to it. A Premium namespace allows 1 MB by default, and up to 100 MB per queue or topic with large message support. Sending it again won't help.` + + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/packages/bus-azure-service-bus/src/error/azure-service-bus-tier-not-supported.ts b/packages/bus-azure-service-bus/src/error/azure-service-bus-tier-not-supported.ts new file mode 100644 index 00000000..dc88726d --- /dev/null +++ b/packages/bus-azure-service-bus/src/error/azure-service-bus-tier-not-supported.ts @@ -0,0 +1,22 @@ +/** + * Thrown by `provision()` when the namespace is on the Basic tier, which has no topics or automatic forwarding + */ +export class AzureServiceBusTierNotSupported extends Error { + readonly help: string + + /** + * @param namespace the name of the namespace + * @param tier the tier Service Bus reported for it + */ + constructor( + readonly namespace: string, + readonly tier: string + ) { + super( + `AzureServiceBusTransport can't provision the Service Bus namespace ${namespace}, since it's on the ${tier} tier, which has no topics or automatic forwarding` + ) + this.help = `Use a namespace on the Standard or Premium tier. The transport sends each message to a topic and forwards it from a subscription into the service queue, and the Basic tier supports neither.` + + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/packages/bus-azure-service-bus/src/error/index.ts b/packages/bus-azure-service-bus/src/error/index.ts new file mode 100644 index 00000000..939fa68b --- /dev/null +++ b/packages/bus-azure-service-bus/src/error/index.ts @@ -0,0 +1,3 @@ +export * from './azure-service-bus-connection-not-configured' +export * from './azure-service-bus-message-too-large' +export * from './azure-service-bus-tier-not-supported' diff --git a/packages/bus-azure-service-bus/src/index.mts b/packages/bus-azure-service-bus/src/index.mts new file mode 100644 index 00000000..e2c8de2d --- /dev/null +++ b/packages/bus-azure-service-bus/src/index.mts @@ -0,0 +1,5 @@ +// The ESM entry point re-exports the CommonJS build instead of being a second +// build. `import` and `require` then share one module instance, so +// `instanceof` checks on error and message classes still work when an app +// loads this package both ways. +export * from './index.js' diff --git a/packages/bus-azure-service-bus/src/index.ts b/packages/bus-azure-service-bus/src/index.ts new file mode 100644 index 00000000..54e6c69e --- /dev/null +++ b/packages/bus-azure-service-bus/src/index.ts @@ -0,0 +1,4 @@ +export * from './azure-service-bus-transport' +export * from './azure-service-bus-transport-configuration' +export { resolveSubscriptionName, resolveTopicName } from './entity-names' +export * from './error' diff --git a/packages/bus-azure-service-bus/src/map-with-concurrency.ts b/packages/bus-azure-service-bus/src/map-with-concurrency.ts new file mode 100644 index 00000000..a9f6ac11 --- /dev/null +++ b/packages/bus-azure-service-bus/src/map-with-concurrency.ts @@ -0,0 +1,25 @@ +/** + * Maps items with an async function, running at most `concurrency` at once, keeping their order + * @param items the items to map + * @param concurrency the most calls to run at once + * @param map the async function to map each item with + * @returns the results, in the order of `items` + */ +export const mapWithConcurrency = async ( + items: TItem[], + concurrency: number, + map: (item: TItem) => Promise +): Promise => { + const results: TResult[] = new Array(items.length) + let next = 0 + const worker = async () => { + while (next < items.length) { + const index = next++ + results[index] = await map(items[index]) + } + } + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, worker) + ) + return results +} diff --git a/packages/bus-azure-service-bus/src/message-properties.spec.ts b/packages/bus-azure-service-bus/src/message-properties.spec.ts new file mode 100644 index 00000000..6c458413 --- /dev/null +++ b/packages/bus-azure-service-bus/src/message-properties.spec.ts @@ -0,0 +1,85 @@ +import { TransportHeaderReserved } from '@node-ts/bus-core' +import { MessageAttributes } from '@node-ts/bus-messages' +import { + ApplicationProperties, + toApplicationProperties, + toFailedAttempts, + toMessageAttributes +} from './message-properties' + +describe('message-properties', () => { + describe('when converting attributes and headers to application properties and back', () => { + const messageAttributes: MessageAttributes = { + correlationId: 'correlation-1', + messageId: 'message-1', + sentAt: '2026-10-08T00:00:00.000Z', + replyTo: 'orders', + attributes: { flag: true, off: false, zero: 0, name: 'x', empty: '' }, + stickyAttributes: { tenant: 'acme', missing: undefined } + } + let properties: ApplicationProperties + let readBack: MessageAttributes + + beforeAll(() => { + properties = toApplicationProperties(messageAttributes, { + 'x-tenant': 'acme' + }) + readBack = toMessageAttributes({ + applicationProperties: properties, + correlationId: messageAttributes.correlationId, + replyTo: messageAttributes.replyTo + }) + }) + + it('should write each header under its own name', () => { + expect(properties['x-tenant']).toEqual('acme') + }) + + it('should not write correlationId and replyTo, which are native fields', () => { + expect(properties).not.toHaveProperty('correlationId') + expect(properties).not.toHaveProperty('replyTo') + }) + + it('should leave out undefined values', () => { + expect(properties).not.toHaveProperty('stickyAttributes.missing') + }) + + it('should read the attributes back with their values and types', () => { + expect(readBack).toEqual({ + ...messageAttributes, + stickyAttributes: { tenant: 'acme' } + }) + }) + }) + + describe('when a header has a name the transport writes itself', () => { + it.each([ + 'messageId', + 'sentAt', + 'failedAttempts', + 'bus-failure', + 'DeadLetterReason', + 'attributes.a', + 'stickyAttributes.b' + ])('should throw TransportHeaderReserved for %s', name => { + expect(() => + toApplicationProperties( + { attributes: {}, stickyAttributes: {} }, + { [name]: 'x' } + ) + ).toThrow(TransportHeaderReserved) + }) + }) + + describe('when reading failed attempts', () => { + it('should be 0 for a message that has not been retried', () => { + expect(toFailedAttempts({ applicationProperties: {} })).toEqual(0) + }) + + it('should read the failedAttempts property of a retry copy', () => { + expect( + toFailedAttempts({ applicationProperties: { failedAttempts: 3 } }) + ).toEqual(3) + }) + }) +}) diff --git a/packages/bus-azure-service-bus/src/message-properties.ts b/packages/bus-azure-service-bus/src/message-properties.ts new file mode 100644 index 00000000..b4acbf14 --- /dev/null +++ b/packages/bus-azure-service-bus/src/message-properties.ts @@ -0,0 +1,186 @@ +import type { + ServiceBusMessage, + ServiceBusReceivedMessage +} from '@azure/service-bus' +import { + FAILURE_HEADER, + TransportHeaderReserved, + TransportHeaders +} from '@node-ts/bus-core' +import { MessageAttributeMap, MessageAttributes } from '@node-ts/bus-messages' + +/** + * The application properties of a Service Bus message + */ +export type ApplicationProperties = NonNullable< + ServiceBusMessage['applicationProperties'] +> + +/** + * The application property holding the bus' `messageId`. The native message id is the bus' id for a message as it + * was sent, but each retry is a copy with its own native id, so the bus' id travels in a property too. + */ +export const MESSAGE_ID_PROPERTY = 'messageId' + +/** + * The application property holding the time the message was sent, as an ISO 8601 string + */ +export const SENT_AT_PROPERTY = 'sentAt' + +/** + * The application property holding how many times handling the message has failed, set on each retry copy + */ +export const FAILED_ATTEMPTS_PROPERTY = 'failedAttempts' + +const ATTRIBUTES_PREFIX = 'attributes.' +const STICKY_ATTRIBUTES_PREFIX = 'stickyAttributes.' + +/** + * The application properties Service Bus writes itself on a message it dead-letters + */ +const DEAD_LETTER_PROPERTIES = [ + 'DeadLetterReason', + 'DeadLetterErrorDescription' +] + +/** + * The application property names the transport or Service Bus write themselves, which outgoing middleware can't set + * as headers. `bus-failure` is written on dead-lettered messages. + */ +const RESERVED_HEADERS = new Set([ + MESSAGE_ID_PROPERTY, + SENT_AT_PROPERTY, + FAILED_ATTEMPTS_PROPERTY, + FAILURE_HEADER, + ...DEAD_LETTER_PROPERTIES +]) + +/** + * Checks that no header set by outgoing middleware has a name the transport or Service Bus writes application + * properties under + * @param headers the headers set by outgoing middleware + * @throws TransportHeaderReserved if a header is named `messageId`, `sentAt`, `failedAttempts`, `bus-failure`, + * `DeadLetterReason` or `DeadLetterErrorDescription`, or starts with `attributes.` or `stickyAttributes.` + */ +export const assertHeadersNotReserved = (headers: TransportHeaders): void => { + const reservedHeader = Object.keys(headers).find( + name => + RESERVED_HEADERS.has(name) || + name.startsWith(ATTRIBUTES_PREFIX) || + name.startsWith(STICKY_ATTRIBUTES_PREFIX) + ) + if (reservedHeader) { + throw new TransportHeaderReserved( + reservedHeader, + 'AzureServiceBusTransport' + ) + } +} + +/** + * Converts the attributes of a message and the headers set by outgoing middleware to Service Bus application + * properties: each header under its own name, then `attributes.`, `stickyAttributes.`, `messageId` and + * `sentAt`. Strings, numbers and booleans keep their types; `undefined` values are left out. `correlationId` and + * `replyTo` are native message fields, so they aren't properties. + * @param messageAttributes the attributes of the message being sent + * @param headers the headers set by outgoing middleware + * @throws TransportHeaderReserved if a header has a name the transport writes itself + */ +export const toApplicationProperties = ( + messageAttributes: MessageAttributes, + headers: TransportHeaders +): ApplicationProperties => { + assertHeadersNotReserved(headers) + const properties: ApplicationProperties = { ...headers } + const addAttributes = ( + prefix: string, + attributes: MessageAttributeMap | undefined + ) => + Object.entries(attributes ?? {}).forEach(([key, value]) => { + if (value !== undefined) { + properties[`${prefix}${key}`] = value + } + }) + addAttributes(ATTRIBUTES_PREFIX, messageAttributes.attributes) + addAttributes(STICKY_ATTRIBUTES_PREFIX, messageAttributes.stickyAttributes) + if (messageAttributes.messageId) { + properties[MESSAGE_ID_PROPERTY] = messageAttributes.messageId + } + if (messageAttributes.sentAt) { + properties[SENT_AT_PROPERTY] = messageAttributes.sentAt + } + return properties +} + +/** + * Reads an application property value back as an attribute value. Dates, which other senders may write, become + * ISO 8601 strings, or numbers when the client has already converted them to milliseconds since the epoch. + */ +const toAttributeValue = ( + value: ApplicationProperties[string] +): string | number | boolean | undefined => { + if (value === null || value === undefined) { + return undefined + } + if (value instanceof Date) { + return value.toISOString() + } + return value +} + +/** + * Reads the bus' attributes of a received message from its native fields and application properties + * @param message the message as Service Bus delivered it + */ +export const toMessageAttributes = ( + message: Pick< + ServiceBusReceivedMessage, + 'applicationProperties' | 'correlationId' | 'replyTo' + > +): MessageAttributes => { + const properties = message.applicationProperties ?? {} + const attributes: MessageAttributeMap = {} + const stickyAttributes: MessageAttributeMap = {} + Object.entries(properties).forEach(([name, value]) => { + if (name.startsWith(ATTRIBUTES_PREFIX)) { + attributes[name.slice(ATTRIBUTES_PREFIX.length)] = toAttributeValue(value) + } else if (name.startsWith(STICKY_ATTRIBUTES_PREFIX)) { + stickyAttributes[name.slice(STICKY_ATTRIBUTES_PREFIX.length)] = + toAttributeValue(value) + } + }) + const messageAttributes: MessageAttributes = { attributes, stickyAttributes } + if (message.correlationId !== undefined && message.correlationId !== null) { + messageAttributes.correlationId = message.correlationId.toString() + } + if (message.replyTo) { + messageAttributes.replyTo = message.replyTo + } + const messageId = properties[MESSAGE_ID_PROPERTY] + if (typeof messageId === 'string') { + messageAttributes.messageId = messageId + } + const sentAt = toAttributeValue(properties[SENT_AT_PROPERTY]) + if (typeof sentAt === 'string') { + messageAttributes.sentAt = sentAt + } + return messageAttributes +} + +/** + * Reads how many times handling a received message has failed from its `failedAttempts` application property, + * which each retry copy carries. Service Bus' own delivery count isn't used, since a message that's released at + * shutdown is delivered again without having failed. + * @param message the message as Service Bus delivered it + * @returns the count, or 0 for a message that hasn't been retried + */ +export const toFailedAttempts = ( + message: Pick +): number => { + const failedAttempts = Number( + message.applicationProperties?.[FAILED_ATTEMPTS_PROPERTY] + ) + return Number.isInteger(failedAttempts) && failedAttempts > 0 + ? failedAttempts + : 0 +} diff --git a/packages/bus-azure-service-bus/src/test/index.ts b/packages/bus-azure-service-bus/src/test/index.ts new file mode 100644 index 00000000..bcd94c7f --- /dev/null +++ b/packages/bus-azure-service-bus/src/test/index.ts @@ -0,0 +1,3 @@ +export * from './message-types.generated' +export * from './service-bus-emulator' +export * from './test-command' diff --git a/packages/bus-azure-service-bus/src/test/message-types.generated.ts b/packages/bus-azure-service-bus/src/test/message-types.generated.ts new file mode 100644 index 00000000..2e5c2982 --- /dev/null +++ b/packages/bus-azure-service-bus/src/test/message-types.generated.ts @@ -0,0 +1,18 @@ +// Generated by `bus generate-message-types` from '@node-ts/bus-cli'. Don't edit it by hand. +import type { MessageTypes } from '@node-ts/bus-messages' +import { TestCommand } from './test-command.js' + +// Pass to the bus with Bus.configure().withMessageTypes(messageTypes) +export const messageTypes: MessageTypes = { + source: '@node-ts/bus-azure-service-bus/src/test/message-types.generated', + messages: { + '@node-ts/bus-azure-service-bus/test-command': + '@node-ts/bus-azure-service-bus/src/test/test-command#TestCommand' + }, + types: { + '@node-ts/bus-azure-service-bus/src/test/test-command#TestCommand': { + class: TestCommand, + fields: {} + } + } +} diff --git a/packages/bus-azure-service-bus/src/test/service-bus-emulator.ts b/packages/bus-azure-service-bus/src/test/service-bus-emulator.ts new file mode 100644 index 00000000..35884f52 --- /dev/null +++ b/packages/bus-azure-service-bus/src/test/service-bus-emulator.ts @@ -0,0 +1,61 @@ +import { + ServiceBusAdministrationClient, + ServiceBusClient +} from '@azure/service-bus' + +// Overridable so CI and contributors can point at an emulator elsewhere. The defaults match docker-compose.yml. +const EMULATOR_HOST = process.env.SERVICE_BUS_EMULATOR_HOST || 'localhost' +const EMULATOR_AMQP_PORT = process.env.SERVICE_BUS_EMULATOR_AMQP_PORT || '5673' +const EMULATOR_MANAGEMENT_PORT = + process.env.SERVICE_BUS_EMULATOR_MANAGEMENT_PORT || '5300' + +/** + * The emulator's fixed development connection string, for the given port + */ +const emulatorConnectionString = (port: string) => + `Endpoint=sb://${EMULATOR_HOST}:${port};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;` + +/** + * The connection string for sending and receiving over AMQP + */ +export const EMULATOR_CONNECTION_STRING = + emulatorConnectionString(EMULATOR_AMQP_PORT) + +/** + * Creates a client for the emulator. The emulator allows 10 connections, and each client is one, so tests share a + * client between transports. + */ +export const createEmulatorClient = () => + new ServiceBusClient(EMULATOR_CONNECTION_STRING) + +/** + * Creates an administration client for the emulator's management API, which is on its own port + */ +export const createEmulatorAdministrationClient = () => + new ServiceBusAdministrationClient( + emulatorConnectionString(EMULATOR_MANAGEMENT_PORT) + ) + +/** + * Deletes every topic and queue in the emulator's namespace, which holds at most 50 + * @param administrationClient a client for the emulator's management API + */ +export const deleteAllEntities = async ( + administrationClient: ServiceBusAdministrationClient +): Promise => { + const topics: string[] = [] + for await (const topic of administrationClient.listTopics()) { + topics.push(topic.name) + } + const queues: string[] = [] + for await (const queue of administrationClient.listQueues()) { + queues.push(queue.name) + } + // Subscriptions forward to queues, so topics go first + await Promise.all( + topics.map(async name => administrationClient.deleteTopic(name)) + ) + await Promise.all( + queues.map(async name => administrationClient.deleteQueue(name)) + ) +} diff --git a/packages/bus-azure-service-bus/src/test/test-command.ts b/packages/bus-azure-service-bus/src/test/test-command.ts new file mode 100644 index 00000000..8bb883fd --- /dev/null +++ b/packages/bus-azure-service-bus/src/test/test-command.ts @@ -0,0 +1,14 @@ +import { Command } from '@node-ts/bus-messages' + +export class TestCommand extends Command { + static NAME = '@node-ts/bus-azure-service-bus/test-command' + $name = TestCommand.NAME + $version = 0 + + /** + * @param value Identifies the command in assertions, or makes it as large as a test needs + */ + constructor(readonly value: string) { + super() + } +} diff --git a/packages/bus-azure-service-bus/tsconfig.json b/packages/bus-azure-service-bus/tsconfig.json new file mode 100644 index 00000000..55d02cd4 --- /dev/null +++ b/packages/bus-azure-service-bus/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts", "src/index.mts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.integration.ts", "src/**/test"], + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 949197c0..fb26c106 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,6 +235,9 @@ importers: docs: devDependencies: + '@azure/service-bus': + specifier: ^7.10.0 + version: 7.10.0(supports-color@11.0.0) '@nestjs/common': specifier: ^12.1.2 version: 12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@11.0.0) @@ -244,6 +247,9 @@ importers: '@nestjs/testing': specifier: ^12.1.2 version: 12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@11.0.0))(@nestjs/core@12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@11.0.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@node-ts/bus-azure-service-bus': + specifier: workspace:^ + version: link:../packages/bus-azure-service-bus '@node-ts/bus-cli': specifier: workspace:^ version: link:../packages/bus-cli @@ -326,6 +332,37 @@ importers: specifier: ^3.19.0 version: 3.19.0 + packages/bus-azure-service-bus: + dependencies: + '@azure/service-bus': + specifier: ^7.10.0 + version: 7.10.0(supports-color@11.0.0) + '@node-ts/bus-messages': + specifier: workspace:^ + version: link:../bus-messages + serialize-error: + specifier: '8' + version: 8.1.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@node-ts/bus-cli': + specifier: workspace:^ + version: link:../bus-cli + '@node-ts/bus-core': + specifier: workspace:^ + version: link:../bus-core + '@node-ts/bus-test': + specifier: workspace:^ + version: link:../bus-test + typemoq: + specifier: ^2.1.0 + version: 2.1.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/bus-cli: dependencies: tslib: @@ -932,6 +969,50 @@ packages: resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} + '@azure/abort-controller@2.2.0': + resolution: {integrity: sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==} + engines: {node: '>=22.0.0'} + + '@azure/core-amqp@4.5.1': + resolution: {integrity: sha512-G+SURt2u/G+oj5J6Z8Ma7pbRwn5Rq9sRIh+oSejHTUjTNR431KGTQrf4aiFJXKuWD0QXZwxnFCFK//6iDL/QbQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-auth@1.11.0': + resolution: {integrity: sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==} + engines: {node: '>=22.0.0'} + + '@azure/core-client@1.11.1': + resolution: {integrity: sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-paging@1.7.0': + resolution: {integrity: sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g==} + engines: {node: '>=22.0.0'} + + '@azure/core-rest-pipeline@1.25.0': + resolution: {integrity: sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==} + engines: {node: '>=22.0.0'} + + '@azure/core-tracing@1.4.0': + resolution: {integrity: sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-util@1.14.0': + resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} + engines: {node: '>=22.0.0'} + + '@azure/core-xml@1.6.0': + resolution: {integrity: sha512-e7lX/dk//F6Qf7BB6PTY4+p2yuOQtyOeHGyapYHNwqSp2OnYpwQt49A/Nin2XmKBQ69pwagR4k/lQBq8lbHQkA==} + engines: {node: '>=22.0.0'} + + '@azure/logger@1.4.0': + resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} + engines: {node: '>=22.0.0'} + + '@azure/service-bus@7.10.0': + resolution: {integrity: sha512-UDTnLCISBpyQlwVnYpOio7lXIGCGlzxfashdDVIRcRlEe8MIrTQu+BFY1DxKh1lOUaHp+qLmWSjLXDunWeJDAQ==} + engines: {node: '>=22.0.0'} + '@babel/code-frame@7.23.5': resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==} engines: {node: '>=6.9.0'} @@ -1910,6 +1991,9 @@ packages: '@nestjs/platform-express': optional: true + '@nodable/entities@3.1.0': + resolution: {integrity: sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==} + '@node-ts/code-standards@0.0.10': resolution: {integrity: sha512-6tWLW8r5HV8BGBwGZ6QBFKDpNrGJZfyCd8KkUk+BXAJX/NS/6qw8dxVwjk6atCbSgSxjpp37CjCH9uDRPdLnBA==} @@ -2488,6 +2572,10 @@ packages: resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typespec/ts-http-runtime@0.3.9': + resolution: {integrity: sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==} + engines: {node: '>=22.0.0'} + '@ungap/structured-clone@1.4.0': resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} @@ -2723,6 +2811,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -2773,6 +2865,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -2785,6 +2880,10 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2840,6 +2939,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.11.26: resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} engines: {node: '>=6.0.0'} @@ -2886,6 +2988,9 @@ packages: resolution: {integrity: sha512-oz3LwE3qGEhTCSyhzQRLY3Bj1NLOsjFMf6tcmY5J8M+3R7EVcmoBfSHWvjUK3yXCLDUsGpIuB3aQL4nDZ1EV/g==} engines: {node: '>=20.19.0'} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} @@ -2893,6 +2998,18 @@ packages: cacheable@2.5.0: resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -3072,6 +3189,10 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -3107,6 +3228,10 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3150,9 +3275,21 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -3243,6 +3380,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -3283,6 +3424,13 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fast-xml-builder@1.3.1: + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==} + + fast-xml-parser@5.11.2: + resolution: {integrity: sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==} + hasBin: true + fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} @@ -3335,6 +3483,10 @@ packages: focus-trap@7.8.0: resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -3351,6 +3503,13 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -3359,10 +3518,18 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -3392,6 +3559,10 @@ packages: resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} engines: {node: '>=18'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -3408,10 +3579,25 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hashery@1.5.1: resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -3436,6 +3622,14 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-id@4.2.1: resolution: {integrity: sha512-zPGsiS+dWoTZtZ4AtpA9Y+BdSFSNWvnouNlWNoUFyAM6xHOHmdCvqO3k8AIbdamCOv4gUFUVNPf6rJFfc4UiJw==} hasBin: true @@ -3479,9 +3673,21 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-extendable@0.1.1: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} @@ -3498,6 +3704,10 @@ packages: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -3510,10 +3720,21 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unsafe@2.0.2: + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3728,6 +3949,9 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jssha@3.3.2: + resolution: {integrity: sha512-0RhFxzijV4W0qTXa97D5b6twAWXX7SEOpw8qzAZyZ+oeGOP9e84PnDSLjl7SBB1vBQ+chSUwsZR06i1AzA+nEA==} + keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} @@ -3782,6 +4006,9 @@ packages: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -3837,6 +4064,10 @@ packages: engines: {node: '>= 16'} hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} @@ -4118,6 +4349,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -4202,6 +4437,10 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -4262,6 +4501,10 @@ packages: resolution: {integrity: sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -4338,6 +4581,12 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rhea-promise@3.0.3: + resolution: {integrity: sha512-a875P5YcMkePSTEWMsnmCQS7Y4v/XvIw7ZoMtJxqtQRZsqSA6PsZxuz4vktyRykPuUgdNsA6F84dS3iEXZoYnQ==} + + rhea@3.0.5: + resolution: {integrity: sha512-Ye1gzqH9DoCGMTaSfLfGmDdoderMshyVU5rdFES+D3N1U5PASZcNCKqhWJJUOOj8INtdrIPOfDALrRcoRRL7jw==} + rollup@4.63.6: resolution: {integrity: sha512-w4+GKyTBqshj84F1GF3s2zxAPFZPEmNkCbQtqilsYS7GsksmX6VoV7UggfZg8sUHCCOkBe7UtQp6fZwbH311FQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4353,6 +4602,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -4382,6 +4635,10 @@ packages: resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} engines: {node: '>=10'} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4489,6 +4746,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} + strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} @@ -4761,6 +5021,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -4844,6 +5107,10 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4883,6 +5150,10 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5245,6 +5516,109 @@ snapshots: '@aws/lambda-invoke-store@0.3.0': {} + '@azure/abort-controller@2.2.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-amqp@4.5.1(supports-color@11.0.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0(supports-color@11.0.0) + '@azure/core-util': 1.14.0(supports-color@11.0.0) + '@azure/logger': 1.4.0(supports-color@11.0.0) + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + rhea: 3.0.5(supports-color@11.0.0) + rhea-promise: 3.0.3(supports-color@11.0.0) + tslib: 2.8.1 + util: 0.12.5 + transitivePeerDependencies: + - supports-color + + '@azure/core-auth@1.11.0(supports-color@11.0.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0(supports-color@11.0.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.11.1(supports-color@11.0.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0(supports-color@11.0.0) + '@azure/core-rest-pipeline': 1.25.0(supports-color@11.0.0) + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@11.0.0) + '@azure/logger': 1.4.0(supports-color@11.0.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-paging@1.7.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-rest-pipeline@1.25.0(supports-color@11.0.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0(supports-color@11.0.0) + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@11.0.0) + '@azure/logger': 1.4.0(supports-color@11.0.0) + '@typespec/ts-http-runtime': 0.3.9(supports-color@11.0.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.4.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.14.0(supports-color@11.0.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@typespec/ts-http-runtime': 0.3.9(supports-color@11.0.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-xml@1.6.0': + dependencies: + fast-xml-parser: 5.11.2 + tslib: 2.8.1 + + '@azure/logger@1.4.0(supports-color@11.0.0)': + dependencies: + '@typespec/ts-http-runtime': 0.3.9(supports-color@11.0.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/service-bus@7.10.0(supports-color@11.0.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-amqp': 4.5.1(supports-color@11.0.0) + '@azure/core-auth': 1.11.0(supports-color@11.0.0) + '@azure/core-client': 1.11.1(supports-color@11.0.0) + '@azure/core-paging': 1.7.0 + '@azure/core-rest-pipeline': 1.25.0(supports-color@11.0.0) + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@11.0.0) + '@azure/core-xml': 1.6.0 + '@azure/logger': 1.4.0(supports-color@11.0.0) + buffer: 6.0.3 + is-buffer: 2.0.5 + jssha: 3.3.2 + long: 5.3.2 + process: 0.11.10 + rhea-promise: 3.0.3(supports-color@11.0.0) + tslib: 2.8.1 + util: 0.12.5 + transitivePeerDependencies: + - supports-color + '@babel/code-frame@7.23.5': dependencies: '@babel/highlight': 7.23.4 @@ -6308,6 +6682,8 @@ snapshots: '@nestjs/core': 12.1.2(@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@11.0.0))(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 + '@nodable/entities@3.1.0': {} + '@node-ts/code-standards@0.0.10': {} '@opentelemetry/api@1.9.1': {} @@ -6846,6 +7222,14 @@ snapshots: '@typescript-eslint/types': 8.71.0 eslint-visitor-keys: 5.0.1 + '@typespec/ts-http-runtime@0.3.9(supports-color@11.0.0)': + dependencies: + http-proxy-agent: 7.0.2(supports-color@11.0.0) + https-proxy-agent: 7.0.6(supports-color@11.0.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@ungap/structured-clone@1.4.0': {} '@unrs/resolver-binding-android-arm-eabi@1.12.2': @@ -7032,6 +7416,8 @@ snapshots: acorn@8.18.0: {} + agent-base@7.1.4: {} + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -7089,6 +7475,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + anynum@1.0.1: {} + arg@4.1.3: {} argparse@1.0.10: @@ -7099,6 +7487,10 @@ snapshots: async@3.2.6: {} + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + babel-jest@29.7.0(@babel/core@7.29.7(supports-color@11.0.0))(supports-color@11.0.0): dependencies: '@babel/core': 7.29.7(supports-color@11.0.0) @@ -7197,6 +7589,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.11.26: {} birpc@2.9.0: {} @@ -7247,6 +7641,11 @@ snapshots: bson@7.3.3: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + cac@7.0.0: {} cacheable@2.5.0: @@ -7257,6 +7656,23 @@ snapshots: keyv: 5.6.0 qified: 0.10.1 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} camelcase@5.3.1: {} @@ -7402,6 +7818,12 @@ snapshots: deepmerge@4.3.1: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -7429,6 +7851,12 @@ snapshots: dotenv@17.4.2: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + eastasianwidth@0.2.0: {} electron-to-chromium@1.4.651: {} @@ -7457,8 +7885,16 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -7599,6 +8035,8 @@ snapshots: esutils@2.0.3: {} + events@3.3.0: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -7646,6 +8084,20 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.11.2: + dependencies: + '@nodable/entities': 3.1.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + fault@2.0.1: dependencies: format: 0.2.2 @@ -7704,6 +8156,10 @@ snapshots: dependencies: tabbable: 6.5.0 + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -7717,12 +8173,34 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + generator-function@2.0.1: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + get-package-type@0.1.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-stream@6.0.1: {} glob-parent@6.0.2: @@ -7758,6 +8236,8 @@ snapshots: globals@17.12.0: {} + gopd@1.2.0: {} + graceful-fs@4.2.11: {} handlebars@4.7.9: @@ -7773,10 +8253,24 @@ snapshots: has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hashery@1.5.1: dependencies: hookified: 1.15.1 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.5 @@ -7807,6 +8301,20 @@ snapshots: html-void-elements@3.0.0: {} + http-proxy-agent@7.0.2(supports-color@11.0.0): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@11.0.0) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6(supports-color@11.0.0): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@11.0.0) + transitivePeerDependencies: + - supports-color + human-id@4.2.1: {} human-signals@2.1.0: {} @@ -7836,8 +8344,17 @@ snapshots: inherits@2.0.4: {} + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-arrayish@0.2.1: {} + is-buffer@2.0.5: {} + + is-callable@1.2.7: {} + is-extendable@0.1.1: {} is-extglob@2.1.1: {} @@ -7846,6 +8363,14 @@ snapshots: is-generator-fn@2.1.0: {} + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -7855,8 +8380,21 @@ snapshots: is-plain-obj@4.1.0: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + is-stream@2.0.1: {} + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.24 + + is-unsafe@2.0.2: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -8293,6 +8831,8 @@ snapshots: jsonc-parser@3.3.1: {} + jssha@3.3.2: {} + keyv@5.6.0: dependencies: '@keyv/serialize': 1.1.1 @@ -8350,6 +8890,8 @@ snapshots: safe-stable-stringify: 2.5.0 triple-beam: 1.4.1 + long@5.3.2: {} + longest-streak@3.1.0: {} lru-cache@10.4.3: {} @@ -8407,6 +8949,8 @@ snapshots: marked@9.1.6: {} + math-intrinsics@1.1.0: {} + mdast-util-from-markdown@2.0.3(supports-color@11.0.0): dependencies: '@types/mdast': 4.0.4 @@ -8776,6 +9320,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.6.2: {} + path-is-absolute@1.0.1: optional: true @@ -8848,6 +9394,8 @@ snapshots: dependencies: find-up: 4.1.0 + possible-typed-array-names@1.1.0: {} + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -8886,6 +9434,8 @@ snapshots: '@jest/schemas': 30.5.0 ansi-styles: 5.2.0 + process@0.11.10: {} + property-information@7.2.0: {} publint@0.3.24: @@ -8981,6 +9531,20 @@ snapshots: rfdc@1.4.1: {} + rhea-promise@3.0.3(supports-color@11.0.0): + dependencies: + debug: 4.4.3(supports-color@11.0.0) + rhea: 3.0.5(supports-color@11.0.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + rhea@3.0.5(supports-color@11.0.0): + dependencies: + debug: 4.4.3(supports-color@11.0.0) + transitivePeerDependencies: + - supports-color + rollup@4.63.6: dependencies: '@types/estree': 1.0.9 @@ -9023,6 +9587,12 @@ snapshots: safe-buffer@5.2.1: {} + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} search-insights@2.17.3: {} @@ -9044,6 +9614,15 @@ snapshots: dependencies: type-fest: 0.20.2 + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -9139,6 +9718,10 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -9427,6 +10010,14 @@ snapshots: util-deprecate@1.0.2: {} + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.24 + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.2.0: @@ -9547,6 +10138,16 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + which-typed-array@1.1.24: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -9601,6 +10202,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + xml-naming@0.3.0: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/test.env b/test.env index c5f79baf..c23c31b8 100644 --- a/test.env +++ b/test.env @@ -19,3 +19,6 @@ NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning" # POSTGRES_URL=postgres://postgres:password@localhost:6432/postgres # REDIS_URL=redis://localhost:6380 # MONGODB_URL=mongodb://localhost:27017/workflows?directConnection=true +# SERVICE_BUS_EMULATOR_HOST=localhost +# SERVICE_BUS_EMULATOR_AMQP_PORT=5673 +# SERVICE_BUS_EMULATOR_MANAGEMENT_PORT=5300