diff --git a/.changeset/nestjs-module.md b/.changeset/nestjs-module.md new file mode 100644 index 00000000..93b979fd --- /dev/null +++ b/.changeset/nestjs-module.md @@ -0,0 +1,14 @@ +--- +'@node-ts/bus-nestjs': minor +'@node-ts/bus-cli': minor +'@node-ts/bus-core': minor +--- + +Add `@node-ts/bus-nestjs`, a NestJS module for the bus (#268), for NestJS 11 and 12. + +- `BusModule.forRoot({ configure })` and `BusModule.forRootAsync({ imports, inject, useFactory })` register a bus, configured by a factory that's given a `BusConfiguration` (logging through Nest's `Logger`, with no interrupt signals) and returns it with the transport, persistence and message types. The bus is injected as `BusInstance`; named buses with `@InjectBus(name)` and `getBusToken(name)`. +- Class handlers and workflows are providers of your modules, registered with the bus by `@BusHandler()` and `@BusWorkflow()` or by listing them in `BusModule.forFeature()`, and resolved from Nest's container for each message by `nestContainer(moduleRef)`. Function handlers and `defineWorkflow()` workflows are registered with `BusModule.forFeatureAsync()`, given the providers they close over. Request-scoped providers get a request scope per received message, with the message and its attributes as Nest's `REQUEST` (`BusRequest`). +- The bus is built when the application initializes, and initialized and started when it bootstraps (or by the application, with `lifecycle: 'manual'`); if that fails, it's disposed. Since `BusModule` is global, Nest stops it after the `onModuleDestroy` of every module that isn't global, and disposes it after their `onApplicationShutdown`, so release what handlers use in `beforeApplicationShutdown` or `onApplicationShutdown`. Startup fails with `BusClassNotProvided`, `BusNotRegistered`, `BusAlreadyRegistered`, `BusFeatureNotStatic`, `WorkflowResolvedWithoutMessage` or `BusCoreVersionNotSupported`, naming the fix. +- `createBusForProvisioning(AppModule)` returns the application's bus, built but not initialized, for `bus provision`. + +`bus provision` in `@node-ts/bus-cli` also accepts a module that exports a built, uninitialized bus, or a function that returns one, and exits once it has printed its report, even if the module left handles open. `BusInstance.canStart` in `@node-ts/bus-core` says whether `start()` can be called: false for a send-only bus or one with a `Receiver`. diff --git a/CLAUDE.md b/CLAUDE.md index ba88e9cc..4e162605 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,8 +50,9 @@ pnpm exec dotenv -e test.env -- jest packages/bus-sqs/src/sqs-transport.spec.ts - 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`. -- `bus-cli` — the `bus` command line. `bus provision ` builds the `BusConfiguration` a module exports and calls `bus.provision()` (see Provisioning below). `bus generate-message-types` reads message classes with the TypeScript compiler API and writes a file exporting a `MessageTypes` map, which users pass to `Bus.configure().withMessageTypes()`. There's no global registry: the bus merges the maps it's given (`mergeMessageTypes` in bus-messages) and passes them to its serializer on every read, so Dates, Maps, Sets, bigints and nested classes are restored without decorators. See `packages/bus-cli/CLAUDE.md`. -- `bus-test` — shared conformance suites. New transports call `transportTests(transport, publishSystemMessage, systemMessageTopicIdentifier, readAllFromDeadLetterQueue)` from their `*.integration.ts` (see bus-sqs / bus-rabbitmq); persistence adapters call `workflowStateRoundTripTests(persistence)` and, when they store outgoing messages, `scheduledMessageRoundTripTests(persistence)` with an instance on its own schema or database, and when they implement `beginTransaction()`, `outboxTests(persistence)` and `inboxTests(persistence)` the same way. Its fixtures' message types are generated into `src/helpers/message-types.generated.ts`, exported as `messageTypes`, and its suites pass them to their buses. Every adapter generates the message types of its own test fixtures the same way (bus-sqs, bus-rabbitmq, bus-sqs-lambda and bus-opentelemetry in `src/test`, bus-postgres and bus-mongodb in `test/`), and every test bus that receives messages calls `.withMessageTypes(...)` with them. bus-core can't depend on bus-cli, so its tests use `testMessageTypes` and `messageTypesFor(...)` from `src/test`. After changing a fixture run `pnpm --filter run generate:message-types` and commit the file (CI runs `pnpm check:message-types`). +- `bus-nestjs` — `BusModule` for NestJS (peers `@nestjs/common` and `@nestjs/core` 11 or 12; 12 is ESM-only, loaded through `require(esm)`): `forRoot`/`forRootAsync` with a factory given a seeded `BusConfiguration`, `forFeature`/`forFeatureAsync` and `@BusHandler()`/`@BusWorkflow()` (via `DiscoveryService.createDecorator()`), `nestContainer(moduleRef)` with a per-message request scope, the bus run from Nest's lifecycle hooks, and `createBusForProvisioning()`. See `packages/bus-nestjs/CLAUDE.md`. +- `bus-cli` — the `bus` command line. `bus provision ` builds the `BusConfiguration` a module exports (or takes a built, uninitialized `BusInstance`) and calls `bus.provision()` (see Provisioning below). `bus generate-message-types` reads message classes with the TypeScript compiler API and writes a file exporting a `MessageTypes` map, which users pass to `Bus.configure().withMessageTypes()`. There's no global registry: the bus merges the maps it's given (`mergeMessageTypes` in bus-messages) and passes them to its serializer on every read, so Dates, Maps, Sets, bigints and nested classes are restored without decorators. See `packages/bus-cli/CLAUDE.md`. +- `bus-test` — shared conformance suites. New transports call `transportTests(transport, publishSystemMessage, systemMessageTopicIdentifier, readAllFromDeadLetterQueue)` from their `*.integration.ts` (see bus-sqs / bus-rabbitmq); persistence adapters call `workflowStateRoundTripTests(persistence)` and, when they store outgoing messages, `scheduledMessageRoundTripTests(persistence)` with an instance on its own schema or database, and when they implement `beginTransaction()`, `outboxTests(persistence)` and `inboxTests(persistence)` the same way. Its fixtures' message types are generated into `src/helpers/message-types.generated.ts`, exported as `messageTypes`, and its suites pass them to their buses. Every adapter generates the message types of its own test fixtures the same way (bus-sqs, bus-rabbitmq, bus-sqs-lambda, bus-opentelemetry and bus-nestjs in `src/test`, bus-postgres and bus-mongodb in `test/`), and every test bus that receives messages calls `.withMessageTypes(...)` with them. bus-core can't depend on bus-cli, so its tests use `testMessageTypes` and `messageTypesFor(...)` from `src/test`. After changing a fixture run `pnpm --filter run generate:message-types` and commit the file (CI runs `pnpm check:message-types`). ### Bus lifecycle (bus-core/src/service-bus) @@ -59,7 +60,7 @@ pnpm exec dotenv -e test.env -- jest packages/bus-sqs/src/sqs-transport.spec.ts - **Buses share nothing**, as if each ran in its own process (design principle 3). The handling context, lifecycle context, message types and default logger factory are per bus; don't add module-level or `globalThis` state that holds per-bus or per-message data. A transport instance is one queue and one connection, so `build()` throws `TransportAlreadyInUse` for a transport another bus uses (a `WeakSet` in `bus-configuration.ts`). Serializers and persistence may be shared: serializers get the bus' message types per call, persistence stores plain JSON while `WorkflowRegistry` does `toPlain`/`toClass` with its own bus' serializer and types, and a shared persistence is only disposed by the last bus using it (a `WeakMap` count in `bus-instance.ts`, released once per bus however often it's disposed). Every bus prepares its persistence, including send-only ones, and `initialize()` initializes it when the bus has workflows or the persistence stores outgoing messages. - At `initialize()`, a bus that receives (handlers or workflows, polling or with a `Receiver`) throws `MessageTypesMissing` unless its message types cover every handled `$name` and workflow state. Send-only buses and buses with no handlers aren't checked, and custom-handler messages are exempt. - **Provisioning** (`provisioning/`, #333): nothing is created at runtime by default. `bus.provision({ dryRun? })` registers the workflows, calls the optional `Persistence.provision({ workflows, dryRun })` (when the bus uses persistence) then, after `connect()` unless a dry run, `Transport.provision({ handlerRegistry, sendOnly, messageNames, sendsAnyMessage, dryRun })` (`sendsAnyMessage` is true for a scheduler, so its runtime permissions allow publishing anywhere; a send-only bus with no message types logs a warning), and returns their `ProvisioningPlan`s (`adapter`, `resources` of `{ type, name, properties? }`, `runtimePermissions` `{ format, document }`); it never initializes or starts, so callers `dispose()`. `messageNames` (`getMessageNames` in `bus-instance.ts`) is every handled `$name` plus every message-types entry that isn't a workflow state (by registered state name, or a class extending `WorkflowState`, also matched by base class name for another copy of bus-core), so send-only buses provision topics for their message types. `initialize()` creates nothing: adapters get `verifyResources` (from `withResourceVerification()`, default true, false right after auto-provisioning) and throw `ResourcesNotProvisioned(adapterName, missing)` from read-only checks; transports also get `autoProvision` and may only create things at runtime (e.g. a topic on first publish) when it's set. `withAutoProvision()` runs `provision()` at the start of `initialize()`. The transport is connected once (`connectTransport`) and workflows registered once (`registerWorkflows` caches the registration promise, so a failure is rethrown, never skipped), whichever runs first. Transports check only what the bus receives through at startup. `WorkflowRegistry.initialize` only registers handlers and records `getPersistedWorkflows()`; `Persistence.initializeWorkflow` no longer exists. Test buses on real infra use `.withAutoProvision()`; bus-test's suites call `bus.provision()` then `initialize()`, so provision-then-run is covered without runtime provisioning. -- `BusInstance.initialize()` connects the transport and lets it check its queues/subscriptions; `start()` runs `concurrency` application loops that `readNextMessage()` from the transport; after an empty read a loop sleeps only what's left of 500 ms (`EMPTY_QUEUE_SLEEP_MS`), so a transport that already waited, like the in-memory queue or an SQS long poll, isn't slowed. With a `Receiver` configured, messages are pushed in via `receive()` instead. The policy still applies: a retried message is returned to the transport with the delay and its error rethrown to the host, and a dead-lettered one goes through `transport.fail` and is reported as handled. +- `BusInstance.initialize()` connects the transport and lets it check its queues/subscriptions; `start()` (allowed when `canStart`: not send-only and no `Receiver`) runs `concurrency` application loops that `readNextMessage()` from the transport; after an empty read a loop sleeps only what's left of 500 ms (`EMPTY_QUEUE_SLEEP_MS`), so a transport that already waited, like the in-memory queue or an SQS long poll, isn't slowed. With a `Receiver` configured, messages are pushed in via `receive()` instead. The policy still applies: a retried message is returned to the transport with the delay and its error rethrown to the host, and a dead-lettered one goes through `transport.fail` and is reported as handled. - Message flow: transport message → the bus' `messageHandlingContext.run` (an `AsyncLocalStorage` per bus; deep code reads the current message with `bus.getHandlingContext()`) → the bus' `messageLifecycleContext` → `incoming` middleware → dispatch to all matching handlers/workflows, which share the message's outbox, each wrapped in the `handler` middleware. The delete, return or dead-letter happens once, after the incoming chain settles, outside it (`settleMessage` in `bus-instance.ts`), so an incoming middleware that doesn't call `next()` has its message deleted. Success deletes the message. `failMessage()` only flags the lifecycle context, and the message is dead-lettered (`transport.fail(message, failure)`) even if something then throws. A throw or `returnMessage()` asks the `RecoverabilityPolicy` (`recoverability/`, set by `withRecoverability`, default `defaultRecoverability()`), with `failedAttempts` = `TransportMessage.failedAttempts + 1`: `retry(delay)` calls `transport.returnMessage(message, delay)` and `deadLetter()` calls `transport.fail`. A policy that throws, or returns anything but `deadLetter()` or `retry()` with a finite delay ≥ 0 (`isRecoverabilityAction`), dead-letters. Transports never count attempts or dead-letter returned messages themselves; `fail` writes the `MessageFailure` (`createMessageFailure`) as one JSON `bus-failure` header (`FAILURE_HEADER`, `toFailureHeader`/`fromFailureHeader`), which core rejects in `dispatchOutgoing` and every transport also reserves. A message with no handler is discarded (deleted), not returned, retried or dead-lettered. This holds for any queue or transport, including one that checks before queuing (`transport/in-memory-queue.ts`). - **Outbox**: `send`/`publish` run the `outgoing` middleware first (after correlation and sticky attributes are merged), and its last step buffers the message in the outbox, which is per received message and shared by all its handlers (`runInOutbox` around `dispatchMessageToHandlers`, innermost in the incoming chain), so it's only dispatched once every handler resolves, and dropped if any fails. Workflow state is held there too: `runInOutbox` puts a store in the bus' internal `UnitOfWorkContext` (`outbox/unit-of-work-context.ts`, shared with `WorkflowRegistry` through `prepare`), which is the transaction with `withOutbox()` or a `BufferedWorkflowStateStore` without it; the registry reads and saves through it, and `flushOutbox` applies the buffered saves (each still checking `$version`, a conflict failing the message) before storing delayed messages and dispatching (`dispatchOutboxed`). Each buffered message and save is tagged with its `UnitOfWorkScope` (`UnitOfWorkContext.runInScope`: one per handler call in `dispatchMessageToHandler`, and one per workflow instance in the registry's `dispatchMessageToWorkflowInstance`, so a `when` that matches several instances tags each separately); when a save fails part way, `sendForSavedScopes` still sends the messages of the scopes whose own state was kept (a retry's guard would skip them), then the outbox is discarded and the message fails. If `failMessage()` or `returnMessage()` was called by then, `flushOutbox` discards it all first (`discardOutbox`), before anything is dispatched, and the workflow registry doesn't save the state it returned. Sends with no outbox (incoming middleware, or after the handlers resolved) go straight to the transport; late sends after a failure are dropped with a warning. Flushing the outbox calls the transport with the finished message and doesn't rerun middleware. `correlationId` and `stickyAttributes` propagate from the incoming message to outgoing ones; `messageId` and `sentAt` never do: the bus stamps new ones on every outgoing message unless the caller passes them, before the outgoing middleware runs, so middleware sees them. - **Middleware** (`middleware/`): `withMiddleware(...BusMiddleware[])` with `incoming`, `handler` and `outgoing` stages, each an async `(context, next)` function run in registration order (first outermost) by the internal `MiddlewarePipeline`, which the configuration builds per bus. Not calling `next()` short-circuits (message deleted / handler skipped / message dropped); a second `next()` throws `MiddlewareNextCalledTwice`. Outgoing middleware sets transport-native `headers`, passed to `Transport.send/publish` as `TransportSendOptions`. The terminal step snapshots the context (attributes two levels deep, headers) and calls the optional `Transport.assertSendOptions` before buffering or sending, so a reserved name (`TransportHeaderReserved`) rejects the caller's `send()` rather than failing the outbox flush. `IncomingContext.requestedSettlement()` reads whether `failMessage()`/`returnMessage()` was called (from the lifecycle context), and `OutgoingContext.dispatched` settles when the message reaches the transport or a delayed one is stored (`storeAndSettle`; `dueAt` is on the send and publish contexts), or rejects with the transport or persistence error or `OutgoingMessageDropped` (`dropOutgoing`/`discardOutbox`; a buffered message is flushed inside an `AsyncLocalStorage.snapshot()` of its `next()`). There are no lifecycle emitters: extend the middleware contexts instead. diff --git a/README.md b/README.md index 3395a3dd..295f5444 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Requires Node.js 24 or later. - [@node-ts/bus-postgres](https://github.com/node-ts/bus/tree/master/packages/bus-postgres) - A Postgres persistence for workflow state - [@node-ts/bus-mongodb](https://github.com/node-ts/bus/tree/master/packages/bus-mongodb) - A MongoDB persistence for workflow state - [@node-ts/bus-opentelemetry](https://github.com/node-ts/bus/tree/master/packages/bus-opentelemetry) - OpenTelemetry tracing and metrics, as bus middleware +- [@node-ts/bus-nestjs](https://github.com/node-ts/bus/tree/master/packages/bus-nestjs) - A NestJS module: handlers and workflows as providers, and the bus run by Nest's lifecycle - [@node-ts/bus-test](https://github.com/node-ts/bus/tree/master/packages/bus-test) - The conformance test suites for transport and persistence adapters ## Contributing diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 94ed4f1e..6212d000 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -92,6 +92,7 @@ const guide: DefaultTheme.SidebarItem[] = [ { text: 'Transactional outbox', link: '/guide/outbox' }, { text: 'OpenTelemetry', link: '/guide/opentelemetry' }, { text: 'Dependency injection', link: '/guide/dependency-injection' }, + { text: 'NestJS', link: '/guide/nestjs' }, { text: 'Long running processes', link: '/guide/long-running-processes' diff --git a/docs/guide/dependency-injection.md b/docs/guide/dependency-injection.md index fedf0425..04ec2dac 100644 --- a/docs/guide/dependency-injection.md +++ b/docs/guide/dependency-injection.md @@ -27,7 +27,10 @@ To construct them with their dependencies, pass an adapter to your IoC container `get` is also given the message being handled and its attributes, for containers that resolve differently per message, and may return a promise. +In a NestJS application, [`@node-ts/bus-nestjs`](/guide/nestjs) registers handlers and workflows as providers and resolves them from Nest's container. + ## See also - [Commands](/guide/messages/commands), for class handlers +- [NestJS](/guide/nestjs) - [`ContainerAdapter`](/api/bus-core/interfaces/ContainerAdapter) in the API reference diff --git a/docs/guide/nestjs.md b/docs/guide/nestjs.md new file mode 100644 index 00000000..d7679a8a --- /dev/null +++ b/docs/guide/nestjs.md @@ -0,0 +1,138 @@ +--- +title: NestJS +description: Run a bus in a NestJS application with @node-ts/bus-nestjs, with handlers and workflows as providers and the bus started and stopped by Nest's lifecycle. +--- + +# NestJS + +`@node-ts/bus-nestjs` runs a bus inside a [NestJS](https://nestjs.com) application. Class handlers and workflows are providers, resolved from Nest's container with their dependencies, function handlers close over providers they're given, and the bus is started and stopped with the application. This page covers registering the bus and its handlers, injecting the bus, the lifecycle, request-scoped providers, provisioning and testing. + + + +## Installation + +It works with NestJS 11 and 12. + +::: code-group + +```sh [npm] +npm i @node-ts/bus-nestjs @node-ts/bus-core +``` + +```sh [pnpm] +pnpm add @node-ts/bus-nestjs @node-ts/bus-core +``` + +```sh [yarn] +yarn add @node-ts/bus-nestjs @node-ts/bus-core +``` + +::: + +## Registering the bus + +Import `BusModule.forRoot()` or `BusModule.forRootAsync()` once, in the root module. Its factory is given a [bus configuration](/getting-started/handling-messages) to add the transport, persistence, [message types](/guide/serializers/message-types) and anything else to, and `forRootAsync()` also gives it the providers in `inject`: + +<<< @/snippets/nestjs.ts#app-module + +`BusModule` adds the handlers and workflows of every module and a container backed by Nest's, then builds the bus. The configuration it gives the factory logs through Nest's `Logger`, so the bus' logs follow the application's log levels. Call `withLogger()` in the factory to log elsewhere. The module is global, so feature modules don't import it again. + +## Handlers and workflows + +A class handler or class workflow is a provider, with its dependencies injected by Nest. Decorate it with `@BusHandler()` or `@BusWorkflow()`, and add it to a module's `providers`: + +<<< @/snippets/nestjs.ts#class-handler + +Function handlers, and workflows declared with `defineWorkflow()`, are registered with `BusModule.forFeatureAsync()`, whose factory is given the providers they need. It runs once, when the application starts, so it can only inject singletons: + +<<< @/snippets/nestjs.ts#function-handler + +<<< @/snippets/nestjs.ts#feature-module + +Class handlers and workflows can also be registered by listing them in `BusModule.forFeature()` instead of decorating them. That only registers them with the bus: they're still providers of your module, with their dependencies injected as usual: + +<<< @/snippets/nestjs.ts#for-feature + +When the application starts, it fails with `BusClassNotProvided` if a class handler or workflow isn't a provider, and with `BusNotRegistered` if they're registered with a bus no `forRoot()` registers. + +## Sending from a service + +The bus is injected as `BusInstance`: + +<<< @/snippets/nestjs.ts#inject-bus + +The bus is built in `BusModule`'s `onModuleInit`, so use it from `onApplicationBootstrap()` or later, such as from a request handler, and not in a constructor, a factory provider or `onModuleInit()`. It throws `BusNotBuilt` if it's used before then, or rejects with it from an async method such as `send()`. Nest runs the `onModuleInit` of global modules first, so a `@Global()` module listed before `BusModule.forRoot()` can't use the bus in its `onModuleInit` either. Handlers send through their [handler context](/guide/dependency-injection#with-closures) rather than an injected bus. + +## Several buses + +Give each bus a name. A named bus is injected with `@InjectBus(name)`, and handlers are registered with it with `@BusHandler({ bus })`, `forFeature({ bus })` or `forFeatureAsync({ bus })`: + +<<< @/snippets/nestjs.ts#named-buses + +Each bus needs a transport of its own. See [Several buses](/guide/multiple-buses). + +## Starting and stopping + +`BusModule` runs the bus with the application: + +| Nest hook | What happens to the bus | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| `onModuleInit` | Built, with the handlers and workflows of every module | +| `onApplicationBootstrap` | Initialized, then started, unless it's send-only or has a `Receiver`. If either fails, it's disposed and the application fails to start | +| `onModuleDestroy` | Stopped, after the `onModuleDestroy` of every module that isn't global: it takes no more messages and finishes those it's handling | +| `onApplicationShutdown` | Disposed, after the `onApplicationShutdown` of every module that isn't global | + +`BusModule` is global, and Nest runs each shutdown hook of global modules after that hook of every module that isn't. So while other modules' `onModuleDestroy` run, the bus is still handling messages, and taking new ones. Release what handlers use, such as a database pool, in `beforeApplicationShutdown` or `onApplicationShutdown`, which run once the bus has stopped. Other `@Global()` modules run their hooks in import order relative to `BusModule`: one imported before `BusModule.forRoot()` shouldn't send from its `onApplicationBootstrap`, since the bus isn't initialized yet, and one imported after it runs its `onModuleDestroy` while the bus is still handling messages, so it should release resources in `beforeApplicationShutdown` or `onApplicationShutdown` too: + +<<< @/snippets/nestjs.ts#release-resources + +The bus doesn't listen for `SIGINT` and `SIGTERM` itself, so call `enableShutdownHooks()`, or a `SIGTERM` ends the process without stopping it, and the messages it was handling are retried: + +<<< @/snippets/nestjs.ts#main + +Nest also runs `BusModule`'s `onApplicationBootstrap` before the root module's, so the bus may handle messages before the rest of the application has finished bootstrapping. For full control, set `lifecycle: 'manual'`, and initialize and start the bus yourself: + +<<< @/snippets/nestjs.ts#manual-lifecycle + +It's still stopped and disposed with the application. To stop it before anything else shuts down, stop it before closing the application: + +<<< @/snippets/nestjs.ts#manual-stop + +## Request-scoped providers + +The bus resolves class handlers and workflows for each message. Request-scoped providers, and providers that depend on them, get a request scope for each received message, shared by every handler and workflow that handles it. Nest's `REQUEST` is a `BusRequest`, with the message and its attributes: + +<<< @/snippets/nestjs.ts#request-scope + +A request-scoped class workflow is also resolved once when the bus initializes, to read its `configureWorkflow()`, with no message, so `REQUEST` is `undefined` then. Keep the request in its constructor, and the constructors of the request-scoped providers it depends on, and read its message and attributes when a message is handled. If resolving it fails then, the application fails to start with `WorkflowResolvedWithoutMessage`. + +Outside `BusModule`, `nestContainer(moduleRef)` gives `withContainer()` the same container adapter. + +## Provisioning + +[`bus provision`](/guide/provisioning) needs the bus with every handler and workflow, which only exist once the application has started. Export `createBusForProvisioning()` from a module of its own. It creates the application as a standalone application context in which the bus is built but never initialized or started: + +<<< @/snippets/nestjs.ts#provisioning + +```sh +npx bus provision dist/provision-bus.js +``` + +Creating the application runs the hooks of its other providers, so keep anything that shouldn't happen during a deploy out of them. `bus provision` exits when it's done, without closing the application. + +## Testing + +Handlers are plain classes and functions, so unit test them without Nest, with fakes of their dependencies and the [fake handler context](/guide/testing#testing-a-handler): + +<<< @/snippets/nestjs-testing.ts#unit + +To test a module, use Nest's `Test.createTestingModule()` with an `InMemoryQueue`. `init()` starts the bus, `queue.idle()` waits until it has handled every message, and `close()` stops and disposes it: + +<<< @/snippets/nestjs-testing.ts#testing-module + +## See also + +- [Dependency injection](/guide/dependency-injection) +- [Provisioning](/guide/provisioning) +- [Testing](/guide/testing) +- [`BusModule`](/api/bus-nestjs/classes/BusModule) in the API reference diff --git a/docs/guide/provisioning.md b/docs/guide/provisioning.md index 8d6e6b38..d26f027d 100644 --- a/docs/guide/provisioning.md +++ b/docs/guide/provisioning.md @@ -34,6 +34,8 @@ The service builds the same configuration when it starts: <<< @/snippets/provisioning.ts#start +A bus that a framework builds, such as one registered with [`@node-ts/bus-nestjs`](/guide/nestjs#provisioning), can be exported built but not initialized instead, or as a function that returns one. + ## Running bus provision `bus provision` comes with `@node-ts/bus-cli`. Run it in your deploy pipeline, with credentials that can create the resources, before the new version of the service starts: diff --git a/docs/package.json b/docs/package.json index 93212154..0ea4102c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,10 +17,14 @@ "check:message-types": "bus generate-message-types --entry 'snippets/**/*.ts' --out snippets/message-types.generated.ts --check" }, "devDependencies": { + "@nestjs/common": "^12.1.2", + "@nestjs/core": "^12.1.2", + "@nestjs/testing": "^12.1.2", "@node-ts/bus-cli": "workspace:^", "@node-ts/bus-core": "workspace:^", "@node-ts/bus-messages": "workspace:^", "@node-ts/bus-mongodb": "workspace:^", + "@node-ts/bus-nestjs": "workspace:^", "@node-ts/bus-opentelemetry": "workspace:^", "@node-ts/bus-postgres": "workspace:^", "@node-ts/bus-rabbitmq": "workspace:^", @@ -32,6 +36,8 @@ "@types/jest": "^30.0.0", "@types/node": "^24.19.0", "mongodb": "^7.7.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", "typedoc": "^0.28.20", "typedoc-plugin-markdown": "^4.13.1", "typedoc-vitepress-theme": "^1.1.4", diff --git a/docs/snippets/nestjs-testing.ts b/docs/snippets/nestjs-testing.ts new file mode 100644 index 00000000..8e0f60d2 --- /dev/null +++ b/docs/snippets/nestjs-testing.ts @@ -0,0 +1,50 @@ +import { Test } from '@nestjs/testing' +import { BusInstance, InMemoryQueue } from '@node-ts/bus-core' +import { BusModule } from '@node-ts/bus-nestjs' +import 'reflect-metadata' +import { messageTypes } from './message-types.generated' +import { ChargeCreditCard, CreditCardCharged } from './messages' +import { + ChargeCreditCardHandler, + PaymentGateway, + PaymentsModule, + Receipts, + creditCardChargedHandler +} from './nestjs' + +// #region unit +// Handlers are plain functions and classes, so call them with fakes of what they're given +const receipts = new Receipts() +await creditCardChargedHandler(receipts).messageHandler( + new CreditCardCharged('tok_visa', 25, new Date()) +) + +const handler = new ChargeCreditCardHandler(new PaymentGateway()) +await handler.handle(new ChargeCreditCard('tok_visa', 25)) +// #endregion unit + +// #region testing-module +const queue = new InMemoryQueue() +const fakeGateway = { charge: async () => undefined } + +const app = await Test.createTestingModule({ + imports: [ + BusModule.forRoot({ + configure: bus => bus.withMessageTypes(messageTypes).withTransport(queue) + }), + PaymentsModule + ] +}) + .overrideProvider(PaymentGateway) + .useValue(fakeGateway) + .compile() +// Builds, initializes and starts the bus +await app.init() + +await app.get(BusInstance).send(new ChargeCreditCard('tok_visa', 25)) +// Waits until the handlers have handled every message +await queue.idle() + +// Stops and disposes the bus +await app.close() +// #endregion testing-module diff --git a/docs/snippets/nestjs.ts b/docs/snippets/nestjs.ts new file mode 100644 index 00000000..c20d13e9 --- /dev/null +++ b/docs/snippets/nestjs.ts @@ -0,0 +1,242 @@ +import { + INestApplication, + Inject, + Injectable, + Module, + OnApplicationBootstrap, + OnApplicationShutdown, + Scope +} from '@nestjs/common' +import { NestFactory, REQUEST } from '@nestjs/core' +import { BusInstance, Handler, handlerFor } from '@node-ts/bus-core' +import { + BusHandler, + BusModule, + BusRequest, + InjectBus, + createBusForProvisioning +} from '@node-ts/bus-nestjs' +import { RabbitMqTransport } from '@node-ts/bus-rabbitmq' +import 'reflect-metadata' +import { messageTypes } from './message-types.generated' +import { ChargeCreditCard, CreditCardCharged } from './messages' + +/** + * Stands in for the application's own configuration service + */ +@Injectable() +export class AppConfig { + readonly rabbitMqUrl = 'amqp://guest:guest@localhost' +} + +@Module({ providers: [AppConfig], exports: [AppConfig] }) +export class AppConfigModule {} + +// #region payment-gateway +@Injectable() +export class PaymentGateway { + async charge(_creditCardToken: string, _amount: number): Promise {} +} + +@Injectable() +export class Receipts { + async record(_creditCardToken: string, _amount: number): Promise {} +} + +@Module({ + providers: [PaymentGateway, Receipts], + exports: [PaymentGateway, Receipts] +}) +export class PaymentGatewayModule {} +// #endregion payment-gateway + +// #region class-handler +@BusHandler() +@Injectable() +export class ChargeCreditCardHandler implements Handler { + constructor(private readonly gateway: PaymentGateway) {} + + get messageType() { + return ChargeCreditCard + } + + async handle(command: ChargeCreditCard) { + await this.gateway.charge(command.creditCardToken, command.amount) + } +} +// #endregion class-handler + +// #region function-handler +export const creditCardChargedHandler = (receipts: Receipts) => + handlerFor(CreditCardCharged, async event => + receipts.record(event.creditCardToken, event.amount) + ) +// #endregion function-handler + +// #region feature-module +@Module({ + imports: [ + PaymentGatewayModule, + // Function handlers and workflows, declared with the providers they need + BusModule.forFeatureAsync({ + imports: [PaymentGatewayModule], + inject: [Receipts], + useFactory: (receipts: Receipts) => ({ + handlers: [creditCardChargedHandler(receipts)] + }) + }) + ], + // Class handlers are providers, found by @BusHandler() + providers: [ChargeCreditCardHandler] +}) +export class PaymentsModule {} +// #endregion feature-module + +// #region for-feature +@Module({ + imports: [ + PaymentGatewayModule, + // Registers the handler with the bus, without @BusHandler() + BusModule.forFeature({ handlers: [ChargeCreditCardHandler] }) + ], + // It's still a provider of the module + providers: [ChargeCreditCardHandler] +}) +export class PaymentsWithoutDecoratorsModule {} +// #endregion for-feature + +// #region app-module +@Module({ + imports: [ + BusModule.forRootAsync({ + imports: [AppConfigModule], + inject: [AppConfig], + useFactory: (bus, config: AppConfig) => + bus.withMessageTypes(messageTypes).withTransport( + new RabbitMqTransport({ + queueName: 'payments-service', + deadLetterQueueName: 'payments-service-dead-letter', + connectionString: config.rabbitMqUrl + }) + ) + }), + PaymentsModule + ] +}) +export class AppModule {} +// #endregion app-module + +const bootstrap = async () => { + // #region main + // src/main.ts + const app = await NestFactory.create(AppModule) + // Without it, a SIGTERM ends the process without stopping the bus, so the messages it's handling are retried + app.enableShutdownHooks() + await app.listen(3000) + // #endregion main +} + +// #region inject-bus +@Injectable() +export class CheckoutService { + constructor(private readonly bus: BusInstance) {} + + async checkout(creditCardToken: string, amount: number) { + await this.bus.send(new ChargeCreditCard(creditCardToken, amount)) + } +} +// #endregion inject-bus + +// #region named-buses +@Module({ + imports: [ + BusModule.forRoot({ + configure: bus => bus.withMessageTypes(messageTypes) + }), + BusModule.forRoot({ + name: 'billing', + configure: bus => bus.withMessageTypes(messageTypes) + }) + ] +}) +export class TwoBusesModule {} + +@BusHandler({ bus: 'billing' }) +@Injectable() +export class InvoiceHandler implements Handler { + get messageType() { + return CreditCardCharged + } + + async handle() {} +} + +@Injectable() +export class InvoiceService { + constructor(@InjectBus('billing') private readonly billingBus: BusInstance) {} +} +// #endregion named-buses + +// #region request-scope +@Injectable({ scope: Scope.REQUEST }) +export class MessageAudit { + constructor(@Inject(REQUEST) private readonly request: BusRequest) {} + + get messageId() { + return this.request.attributes?.messageId + } +} +// #endregion request-scope + +// #region manual-lifecycle +@Module({ + imports: [ + BusModule.forRoot({ + lifecycle: 'manual', + configure: bus => bus.withMessageTypes(messageTypes) + }) + ] +}) +export class ManualModule {} + +@Injectable() +export class StartBusAfterWarmUp implements OnApplicationBootstrap { + constructor(private readonly bus: BusInstance) {} + + async onApplicationBootstrap() { + // ...once the application is ready to handle messages + await this.bus.initialize() + await this.bus.start() + } +} +// #endregion manual-lifecycle + +// #region release-resources +@Injectable() +export class DatabasePool implements OnApplicationShutdown { + async query(_sql: string): Promise { + return [] + } + + // Not onModuleDestroy, which runs before the bus has finished the messages it's handling + async onApplicationShutdown() { + // ...close the pool + } +} +// #endregion release-resources + +const shutDown = async (app: INestApplication) => { + // #region manual-stop + // Stop taking messages and finish those being handled, then shut the rest of the application down + await app.get(BusInstance).stop() + await app.close() + // #endregion manual-stop +} + +// #region provisioning +// src/provision-bus.ts, run with `bus provision dist/provision-bus.js` +export default () => createBusForProvisioning(AppModule) +// #endregion provisioning + +await bootstrap() +await shutDown(await NestFactory.create(AppModule)) diff --git a/docs/tsconfig.json b/docs/tsconfig.json index 5f856233..7f951e1b 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -13,6 +13,10 @@ "declaration": false, "importHelpers": false, "resolveJsonModule": true, + // The NestJS snippets use Nest's decorators, which need the legacy + // decorators and their metadata. Messages never use decorators. + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "types": ["node"] }, "include": ["snippets/**/*.ts", ".vitepress/**/*.ts", ".vitepress/**/*.mts"], diff --git a/docs/typedoc.json b/docs/typedoc.json index b55adb48..22403eea 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -12,6 +12,7 @@ "../packages/bus-postgres", "../packages/bus-mongodb", "../packages/bus-opentelemetry", + "../packages/bus-nestjs", "../packages/bus-test" ], "packageOptions": { diff --git a/packages/bus-cli/CLAUDE.md b/packages/bus-cli/CLAUDE.md index 986cfa5c..0ca5351a 100644 --- a/packages/bus-cli/CLAUDE.md +++ b/packages/bus-cli/CLAUDE.md @@ -4,7 +4,7 @@ The `bus` command line. Read the root `CLAUDE.md` first. - `bus.mjs` is the bin. It's committed outside `dist` (and `bin/` is gitignored) so pnpm links it into the workspace before the package is built. It only calls `runCli` from `dist/cli.js`. Add commands to `COMMANDS` in `src/cli.ts`, one folder per command (#283 and #269 add more). - `generate-message-types` reads the project with the TypeScript compiler API, read-only, using the **project's** `typescript` (a peer dependency, loaded by `load-type-script.ts` from `cwd`), so its defaults match the user's build. Only `import type` TypeScript in the source and take enums like `ts.TypeFlags` from the loaded module, since their values differ between versions. The out file is replaced by a stub in the compiler host, so a project that imports it type checks before the file exists. Syntax errors and the unresolved-name/module codes in `BLOCKING_DIAGNOSTIC_CODES` stop generation; other semantic errors are returned as `warnings`. `MessageTypeReader` walks the exports declared in the entry files (re-exports are skipped): non-abstract classes with a `$name`, `defineCommand`/`defineEvent` definitions (recognised by shape: callable, with a `NAME`, returning a type with `$name`), and, after every file, interfaces and type aliases with a literal `$name` that don't just describe a message already read. It records only the fields that need restoring. Every other declaration with a `$name` is pushed onto `warnings` with the reason (unexported, abstract, unset, non-literal, generic, shadowed by a class, a definition inside an exported object, an inherited static `NAME`, re-exported from a non-entry file). `export default` definitions are read, keyed `#default`. A class whose static `NAME` isn't its `$name` is a problem. Anything it can't describe is pushed onto `problems` rather than thrown, so one run reports every problem. `message-types-model.ts` must stay in step with `MessageFieldType`/`MessageTypes` in `@node-ts/bus-messages` and the reviver in `bus-core/src/serialization/message-type-reviver.ts`. -- `provision` (`src/provision/`): `loadBusConfiguration` imports the module with Node's own `import()` (kept as a real dynamic import in the CJS build, so ESM, CJS and type-strippable TypeScript all load), reads the export (falling back to a CommonJS module's `default` object for named exports), calls it if it's a function, and recognises a `BusConfiguration` by shape (a `build` function), since the module may use another copy of bus-core. bus-core is only a devDependency, used for types. `runProvision` builds, calls `bus.provision({ dryRun })` and always disposes. Output is `formatProvisionReport` or the JSON `ProvisionReport` (exported; `formatVersion: 1`, only ever add fields; `runtimePermissions` only with `--permissions`). Exit codes: 0, 1 (load/provision failure), 2 (usage). `run-provision.spec.ts` injects a fake importer; `run-provision.integration.ts` runs `bus.mjs` on `test/provision/bus.ts` (its own `type: module` package.json, type stripped by Node). +- `provision` (`src/provision/`): `loadBusConfiguration` imports the module with Node's own `import()` (kept as a real dynamic import in the CJS build, so ESM, CJS and type-strippable TypeScript all load), reads the export (falling back to a CommonJS module's `default` object for named exports), calls it if it's a function, and recognises a `BusConfiguration` by shape (a `build` function), or else a built, uninitialized `BusInstance` (a `provision` function, for framework integrations such as bus-nestjs' `createBusForProvisioning`), since the module may use another copy of bus-core. bus-core is only a devDependency, used for types. `runProvision` builds (unless given a bus), calls `bus.provision({ dryRun })` and always disposes. `bus.mjs` exits explicitly once stdout and stderr are flushed, since a loaded app may leave handles open. Output is `formatProvisionReport` or the JSON `ProvisionReport` (exported; `formatVersion: 1`, only ever add fields; `runtimePermissions` only with `--permissions`). Exit codes: 0, 1 (load/provision failure), 2 (usage). `run-provision.spec.ts` injects a fake importer; `run-provision.integration.ts` runs `bus.mjs` on `test/provision/bus.ts` (its own `type: module` package.json, type stripped by Node). - Type keys are `/#` (the package is the nearest `package.json` above the tsconfig), so two declarations with the same name, in one library or two, never collide. Anonymous object types are keyed by the field they're in (`…#Order.meta`). Fields typed as an abstract class are a problem, since the subclass can't be told apart. - The generated file sets `source` (package + out path, named in `MessageTypesConflict`) and only exports `messageTypes`, with no side effects: users pass it to `Bus.configure().withMessageTypes()`, and nothing is registered globally. The generator warns when nothing in the program imports the out file (the stub is only in the program if something imports it), since it then can't reach a bus. `--entry` files are added to the program even if the tsconfig leaves them out, e.g. adapters' `test/` fixtures, and `rootDir` is ignored. - `writeMessageTypes` emits the file. Imports end in `.js` under `node16`/`nodenext` module resolution, and have no extension otherwise. Import names are the export names, made unique (`Address_2`) and kept clear of the names the file declares. diff --git a/packages/bus-cli/bus.mjs b/packages/bus-cli/bus.mjs index be6f1a84..afcc2990 100755 --- a/packages/bus-cli/bus.mjs +++ b/packages/bus-cli/bus.mjs @@ -3,4 +3,10 @@ // built, e.g. in this repo's workspace import { runCli } from './dist/cli.js' -process.exitCode = await runCli(process.argv.slice(2)) +const exitCode = await runCli(process.argv.slice(2)) + +// Exit once the output is written, rather than waiting for the event loop to empty: a module that +// `bus provision` loads may leave handles open, such as an application's database pool +const flush = stream => new Promise(resolve => stream.write('', resolve)) +await Promise.all([flush(process.stdout), flush(process.stderr)]) +process.exit(exitCode) diff --git a/packages/bus-cli/src/provision/error/bus-configuration-not-exported.ts b/packages/bus-cli/src/provision/error/bus-configuration-not-exported.ts index fd0bad06..d4920e3c 100644 --- a/packages/bus-cli/src/provision/error/bus-configuration-not-exported.ts +++ b/packages/bus-cli/src/provision/error/bus-configuration-not-exported.ts @@ -1,6 +1,6 @@ /** - * Thrown by `bus provision` when the module doesn't export a bus configuration, or a function that returns one, - * under the export it was told to use + * Thrown by `bus provision` when the module doesn't export a bus configuration or a built bus, or a function that + * returns one, under the export it was told to use */ export class BusConfigurationNotExported extends Error { readonly help: string @@ -16,9 +16,9 @@ export class BusConfigurationNotExported extends Error { readonly found: string ) { super( - `The ${exportName} export of ${modulePath} isn't a bus configuration or a function that returns one, it's ${found}` + `The ${exportName} export of ${modulePath} isn't a bus configuration or a bus, or a function that returns one, it's ${found}` ) - this.help = `Export the configuration before it's built, e.g. \`export default Bus.configure().withTransport(transport).withHandler(handler)\`, or a function that returns one, e.g. \`export const busConfiguration = async () => Bus.configure()...\`, and pass --export busConfiguration to use a named export` + this.help = `Export the configuration before it's built, e.g. \`export default Bus.configure().withTransport(transport).withHandler(handler)\`, or a function that returns one, e.g. \`export const busConfiguration = async () => Bus.configure()...\`, and pass --export busConfiguration to use a named export. A bus built by a framework, such as NestJS, can be exported built but not initialized, e.g. \`export default () => createBusForProvisioning(AppModule)\`` Object.setPrototypeOf(this, new.target.prototype) } diff --git a/packages/bus-cli/src/provision/load-bus-configuration.ts b/packages/bus-cli/src/provision/load-bus-configuration.ts index 1d6d82c7..29f3017c 100644 --- a/packages/bus-cli/src/provision/load-bus-configuration.ts +++ b/packages/bus-cli/src/provision/load-bus-configuration.ts @@ -1,4 +1,4 @@ -import type { BusConfiguration } from '@node-ts/bus-core' +import type { BusConfiguration, BusInstance } from '@node-ts/bus-core' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { @@ -26,6 +26,15 @@ const isBusConfiguration = (value: unknown): value is BusConfiguration => value !== null && typeof (value as { build?: unknown }).build === 'function' +/** + * Whether a value is a built bus, from whichever copy of @node-ts/bus-core the module uses, recognised by its shape. + * Checked after `isBusConfiguration`, since a configuration has no `provision`. + */ +const isBusInstance = (value: unknown): value is BusInstance => + typeof value === 'object' && + value !== null && + typeof (value as { provision?: unknown }).provision === 'function' + /** * Describes what a value is, for an error */ @@ -40,22 +49,23 @@ const describeValue = (value: unknown): string => { } /** - * Loads the bus configuration a module exports: a `BusConfiguration`, or a function, sync or async, that returns - * one + * Loads the bus a module exports: a `BusConfiguration`, or a function, sync or async, that returns one. It can also + * be a bus that's been built but not initialized, or a function that returns one, for a bus built by a framework, + * such as `createBusForProvisioning()` from @node-ts/bus-nestjs. * @param modulePath the module's path, relative to `cwd` * @param exportName the export to read, such as `default` * @param cwd the directory the path is resolved against * @param load how to import the module - * @returns the configuration, not built yet + * @returns the configuration, not built yet, or the built bus * @throws BusConfigurationModuleNotLoaded if the module can't be imported - * @throws BusConfigurationNotExported if the export isn't a configuration, or a function that returns one + * @throws BusConfigurationNotExported if the export isn't a configuration or a bus, or a function that returns one */ export const loadBusConfiguration = async ( modulePath: string, exportName: string, cwd: string, load: ImportModule = importModule -): Promise => { +): Promise => { let module: Record try { module = await load(pathToFileURL(resolve(cwd, modulePath)).href) @@ -79,7 +89,7 @@ export const loadBusConfiguration = async ( typeof exported === 'function' ? await (exported as () => unknown)() : exported - if (!isBusConfiguration(configuration)) { + if (!isBusConfiguration(configuration) && !isBusInstance(configuration)) { throw new BusConfigurationNotExported( modulePath, exportName, diff --git a/packages/bus-cli/src/provision/run-provision.integration.ts b/packages/bus-cli/src/provision/run-provision.integration.ts index e9516c9c..2b20fff3 100644 --- a/packages/bus-cli/src/provision/run-provision.integration.ts +++ b/packages/bus-cli/src/provision/run-provision.integration.ts @@ -87,6 +87,25 @@ describe('bus provision', () => { }) }) + describe('when given an export that returns a built bus and leaves a handle open', () => { + let exitCode: number + let stdout: string + + beforeAll(async () => { + ;({ exitCode, stdout } = await runBus( + 'provision', + 'test/provision/bus.ts', + '--export', + 'createBuiltBus' + )) + }) + + it('should provision the bus and exit', () => { + expect(exitCode).toEqual(0) + expect(stdout).toContain('topic fixture/order-placed') + }) + }) + describe('when the export is not a bus configuration', () => { let exitCode: number let stderr: string diff --git a/packages/bus-cli/src/provision/run-provision.spec.ts b/packages/bus-cli/src/provision/run-provision.spec.ts index 32dd0153..154cf88e 100644 --- a/packages/bus-cli/src/provision/run-provision.spec.ts +++ b/packages/bus-cli/src/provision/run-provision.spec.ts @@ -154,6 +154,30 @@ describe('runProvision', () => { }) }) + describe('when a module exports an async function that returns a built bus', () => { + let transport: ProvisionedQueue + let exitCode: number + + beforeAll(async () => { + transport = new ProvisionedQueue() + ;({ exitCode } = await run( + { default: async () => configure(transport).build() }, + 'src/bus.js' + )) + }) + + it('should provision the bus without building it again', () => { + expect(exitCode).toEqual(0) + expect(transport.provisionOptions).toMatchObject({ + messageNames: ['test/order-placed'] + }) + }) + + it('should dispose the bus', () => { + expect(transport.wasDisposed).toEqual(true) + }) + }) + describe('when a CommonJS module exports a function under a name', () => { let transport: ProvisionedQueue let exitCode: number @@ -270,7 +294,7 @@ describe('runProvision', () => { it('should fail, saying what to export', () => { expect(exitCode).toEqual(1) expect(output.errors.join('\n')).toContain( - "The default export of src/bus.js isn't a bus configuration or a function that returns one, it's a number" + "The default export of src/bus.js isn't a bus configuration or a bus, or a function that returns one, it's a number" ) expect(output.errors.join('\n')).toContain('--export') }) diff --git a/packages/bus-cli/src/provision/run-provision.ts b/packages/bus-cli/src/provision/run-provision.ts index 681f6ebe..0b44da01 100644 --- a/packages/bus-cli/src/provision/run-provision.ts +++ b/packages/bus-cli/src/provision/run-provision.ts @@ -20,7 +20,8 @@ queues, topics, subscriptions, exchanges, tables and indexes. It's idempotent, s deploy credentials, before the service starts. It never initializes or starts the bus. The module's default export, or the one named by --export, is a bus configuration (Bus.configure()..., -not built), or a function, sync or async, that returns one. It's loaded with import(), so it can be JavaScript, +not built), or a function, sync or async, that returns one. It can also be a bus that's built but not +initialized, or a function that returns one, such as createBusForProvisioning() from @node-ts/bus-nestjs. It's loaded with import(), so it can be JavaScript, or TypeScript that Node can run by stripping its types. Options: @@ -109,13 +110,13 @@ export const runProvision = async ( let plans: Awaited> try { - const configuration = await loadBusConfiguration( + const loaded = await loadBusConfiguration( modulePath, values.export, cwd, load ) - const bus = configuration.build() + const bus = 'build' in loaded ? loaded.build() : loaded if (typeof bus.provision !== 'function') { output.error( `The bus in ${modulePath} can't be provisioned. Upgrade @node-ts/bus-core to a version with bus.provision().` diff --git a/packages/bus-cli/test/provision/bus.ts b/packages/bus-cli/test/provision/bus.ts index 68edd96e..c9d64b3f 100644 --- a/packages/bus-cli/test/provision/bus.ts +++ b/packages/bus-cli/test/provision/bus.ts @@ -35,3 +35,12 @@ export default configure() export const createBusConfiguration = async () => configure() export const notABus = 42 + +/** + * Returns the bus built, as a framework integration does, and leaves a timer running, as an application's + * connection pool would, so the command has to exit by itself + */ +export const createBuiltBus = async () => { + setInterval(() => undefined, 60_000) + return configure().build() +} diff --git a/packages/bus-core/src/service-bus/bus-instance.ts b/packages/bus-core/src/service-bus/bus-instance.ts index 2c6fa745..1e3441a4 100644 --- a/packages/bus-core/src/service-bus/bus-instance.ts +++ b/packages/bus-core/src/service-bus/bus-instance.ts @@ -1127,6 +1127,20 @@ export class BusInstance implements BusSender { return this.internalState } + /** + * Whether `start()` can be called on the bus: false for a send-only bus, and for a bus with a `Receiver`, whose + * messages are passed in with `receive()`. Code that runs the bus' lifecycle, such as a framework integration, + * reads it to know whether to call `start()` after `initialize()`. + * @example + * await bus.initialize() + * if (bus.canStart) { + * await bus.start() + * } + */ + get canStart(): boolean { + return !this.sendOnly && !this.receiver + } + /** * The return address stamped on outgoing messages, so replies come back to this bus' queue: the transport's * `returnAddress`, or its `endpointName` when it has none. `undefined` for a send-only bus, a scheduler or a diff --git a/packages/bus-core/src/service-bus/bus.spec.ts b/packages/bus-core/src/service-bus/bus.spec.ts index 9c9a4560..baf46506 100644 --- a/packages/bus-core/src/service-bus/bus.spec.ts +++ b/packages/bus-core/src/service-bus/bus.spec.ts @@ -270,6 +270,28 @@ describe('Bus', () => { }) }) + describe('when checking whether a bus can be started', () => { + const receiver = Mock.ofType().object + + it('should be true for a bus that reads from its transport', () => { + expect(Bus.configure().build().canStart).toEqual(true) + }) + + it('should be true for a scheduler', () => { + expect(Bus.configure().asScheduler().build().canStart).toEqual(true) + }) + + it('should be false for a send-only bus', () => { + expect(Bus.configure().asSendOnly().build().canStart).toEqual(false) + }) + + it('should be false for a bus with a receiver', () => { + expect(Bus.configure().withReceiver(receiver).build().canStart).toEqual( + false + ) + }) + }) + describe('when disposing the bus', () => { describe('after its been initialized', () => { it('should dispose', async () => { diff --git a/packages/bus-nestjs/CLAUDE.md b/packages/bus-nestjs/CLAUDE.md new file mode 100644 index 00000000..604cd2d0 --- /dev/null +++ b/packages/bus-nestjs/CLAUDE.md @@ -0,0 +1,19 @@ +# bus-nestjs + +A NestJS module that runs a bus in a Nest application. Read the root `CLAUDE.md` first. Consumer docs: `docs/guide/nestjs.md`. + +## Design + +- Peers `@nestjs/common` and `@nestjs/core` `^11 || ^12`; only use APIs both have. Nest 12 is ESM-only: the CJS build `require()`s it (Node's `require(esm)`), and tsc allows that under `nodenext`. Jest only loads it with `--experimental-vm-modules`, which `test.env` sets in `NODE_OPTIONS`, so always run tests through `dotenv -e test.env`. Dev and CI test Nest 12. +- `src/` uses no decorator syntax (the repo's tsconfig has no `experimentalDecorators`): providers are factory providers with explicit `inject`, `BusModule` is undecorated (dynamic modules carry their own metadata), and `@BusHandler()`/`@BusWorkflow()` wrap discoverable decorators from `DiscoveryService.createDecorator()` (`bus-discovery-metadata.ts`, not exported), typed so only `Handler`/`Workflow` classes can be decorated. Test fixtures apply decorators by calling them (`Injectable()(X)`, `Inject(T)(X, undefined, 0)`). The docs snippets use real decorators (`docs/tsconfig.json` turns on `experimentalDecorators` and `emitDecoratorMetadata`). +- `BusModule.forRootAsync` (and `forRoot`, which calls it) is global and provides a `BusLifecycleHost` under a per-call symbol, plus the bus under `getBusToken(name)` (`BusInstance` for the default bus, a string otherwise). The factory is given `Bus.configure().withLogger(nestLoggerFactory).withInterruptSignals([])`, so a user's `withLogger()`/`withInterruptSignals()` override those; `withContainer(nestContainer(moduleRef))` is always applied after it. +- **The bus is built in `onModuleInit`**, not in a provider factory: `forFeatureAsync` registrations come from factory providers that may be created after the bus' provider, but every provider exists before any `onModuleInit`. So the provided bus is `createLazyBus()`, a `Proxy` over `Object.create(BusInstance.prototype)` that forwards to the built bus. Before it's built, members that aren't own members of `BusInstance.prototype` read from the target (so `toString` works, and the hooks and `then` Nest probes providers for are `undefined`), accessors throw `BusNotBuilt`, and methods return a function that calls the bus once it's built, throwing `BusNotBuilt` before then, or rejecting with it for async methods (`instanceof AsyncFunction`). +- `forFeature`/`forFeatureAsync` only register: they never make classes providers (a dynamic module only sees its own `imports`, so a class provided there couldn't get its module's providers). Users keep classes in their own module's `providers`, and `BusClassNotProvided` reports one that isn't. `forFeatureAsync`'s `imports`/`inject` are only for its factory. +- `BusLifecycleHost.onModuleInit` finds registrations with `DiscoveryService`: `BusFeatureRegistration` instances (from `forFeature`'s value provider or `forFeatureAsync`'s factory, each under a `Symbol` whose description is `BUS_FEATURE_TOKEN_PREFIX` + bus name, so one Nest didn't create, because it injects a request-scoped provider, throws `BusFeatureNotStatic`), and decorated providers (`classOf()` reads a factory or value provider's class from its instance, as Nest's discovery does). Every host checks for duplicate bus names (`BusAlreadyRegistered`) and registrations for unknown buses (`BusNotRegistered`), then registers its own, checks each class handler and workflow with `moduleRef.introspect` (`BusClassNotProvided`), builds, and throws `BusCoreVersionNotSupported` if the bus has no boolean `canStart` (an older bus-core peer). `forRootAsync` warns when the factory returns a configuration other than the seed it was given. +- Lifecycle: `onApplicationBootstrap` initializes and, when `bus.canStart` (bus-core), starts, unless `lifecycle: 'manual'`, and disposes the bus (once) before rethrowing if either fails; `onModuleDestroy` stops a started bus; `onApplicationShutdown` disposes. `BusModule` is global, and Nest runs each shutdown hook of global modules after that hook of every non-global module, so the bus keeps handling messages through other modules' `onModuleDestroy`, stops before any `beforeApplicationShutdown`, and is disposed after other modules' `onApplicationShutdown` (pinned by the shutdown-order test; the same in Nest 11.0.0). Other `@Global()` modules run their hooks in import order relative to `BusModule`: one imported before `forRoot()` bootstraps before the bus is initialized, and one imported after it runs `onModuleDestroy` while the bus is still handling messages. Docs tell users to release resources in `beforeApplicationShutdown`/`onApplicationShutdown`, or use `lifecycle: 'manual'` and `bus.stop()` before `app.close()`. In an application from `createBusForProvisioning()` (which adds a global module providing `BUS_PROVISIONING`), hosts only build. The app is never closed there: `bus provision` disposes the bus and `bus.mjs` exits. +- `nestContainer(moduleRef)`: `introspect` gives the scope (`REQUEST` also for providers that depend on request-scoped ones). Singletons use `moduleRef.get(type, { strict: false })`; others `moduleRef.resolve` with a `ContextId` per message object (a `WeakMap`), registering `{ message, attributes }` (`BusRequest`) as `REQUEST` once. No message (bus-core resolving a class workflow to read `configureWorkflow()`) gets a fresh context with no request, and a failure then is rethrown as `WorkflowResolvedWithoutMessage`. The request scope is keyed by the message object, so the in-memory queue's retries of the same object share it (#353). Reading `configureWorkflow()` without resolving the workflow is #354. + +## Tests + +- `src/bus-module.integration.ts` builds Nest testing modules over `InMemoryQueue`/`ProvisionedQueue` (no infra), `src/create-bus-for-provisioning.integration.ts` uses `NestFactory`, and the specs cover the lazy bus, the container adapter (with a hand-written `ModuleRef` fake), the lifecycle host (an old bus-core, and a bus whose initialize and dispose both reject) and the logger factory. Fixtures are in `src/test`; Nest's own logs go to `testLogger()` (silent unless `BUS_TEST_LOGS=true`). After changing a fixture message, run `pnpm --filter @node-ts/bus-nestjs run generate:message-types`. +- A Nest app whose `init()` failed rethrows the same error from `close()`, so the failing-start tests don't close it. diff --git a/packages/bus-nestjs/README.md b/packages/bus-nestjs/README.md new file mode 100644 index 00000000..09224937 --- /dev/null +++ b/packages/bus-nestjs/README.md @@ -0,0 +1,80 @@ +# @node-ts/bus-nestjs + +A [NestJS](https://nestjs.com) module for [@node-ts/bus](https://node-ts.github.io/bus): class handlers and workflows are providers, resolved from Nest's container with their dependencies, and the bus is started and stopped with the application. + +[![npm](https://img.shields.io/npm/v/@node-ts/bus-nestjs)](https://www.npmjs.com/package/@node-ts/bus-nestjs) + +**[Documentation](https://node-ts.github.io/bus/guide/nestjs)** · [Changelog](https://github.com/node-ts/bus/blob/master/packages/bus-nestjs/CHANGELOG.md) + +## Installation + +Requires Node.js 24 or later. Works with NestJS 11 and 12. + +```sh +npm i @node-ts/bus-nestjs @node-ts/bus-core +``` + +## Usage + +Register the bus once, in the root module: + + + +```ts +@Module({ + imports: [ + BusModule.forRootAsync({ + imports: [AppConfigModule], + inject: [AppConfig], + useFactory: (bus, config: AppConfig) => + bus.withMessageTypes(messageTypes).withTransport( + new RabbitMqTransport({ + queueName: 'payments-service', + deadLetterQueueName: 'payments-service-dead-letter', + connectionString: config.rabbitMqUrl + }) + ) + }), + PaymentsModule + ] +}) +export class AppModule {} +``` + +Class handlers are providers, found by `@BusHandler()`: + + + +```ts +@BusHandler() +@Injectable() +export class ChargeCreditCardHandler implements Handler { + constructor(private readonly gateway: PaymentGateway) {} + + get messageType() { + return ChargeCreditCard + } + + async handle(command: ChargeCreditCard) { + await this.gateway.charge(command.creditCardToken, command.amount) + } +} +``` + +The bus is initialized and started when the application bootstraps. Call `app.enableShutdownHooks()` so a `SIGTERM` stops it gracefully: + + + +```ts +// src/main.ts +const app = await NestFactory.create(AppModule) +// Without it, a SIGTERM ends the process without stopping the bus, so the messages it's handling are retried +app.enableShutdownHooks() +await app.listen(3000) +``` + +## Learn more + +- [NestJS](https://node-ts.github.io/bus/guide/nestjs): function handlers, workflows, several buses, the lifecycle, request scope, provisioning and testing +- [Dependency injection](https://node-ts.github.io/bus/guide/dependency-injection) +- [Provisioning](https://node-ts.github.io/bus/guide/provisioning) diff --git a/packages/bus-nestjs/package.json b/packages/bus-nestjs/package.json new file mode 100644 index 00000000..d28de49c --- /dev/null +++ b/packages/bus-nestjs/package.json @@ -0,0 +1,80 @@ +{ + "name": "@node-ts/bus-nestjs", + "version": "0.0.0", + "description": "A NestJS module for @node-ts/bus-core: handlers and workflows as providers, and the bus run by Nest's lifecycle.", + "homepage": "https://node-ts.github.io/bus/guide/nestjs", + "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-nestjs" + }, + "bugs": { + "url": "https://github.com/node-ts/bus/issues" + }, + "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' --out src/test/message-types.generated.ts", + "check:message-types": "bus generate-message-types --entry 'src/test/**/*.ts' --out src/test/message-types.generated.ts --check" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@node-ts/bus-messages": "workspace:^", + "serialize-error": "8", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@nestjs/common": "^12.1.2", + "@nestjs/core": "^12.1.2", + "@nestjs/testing": "^12.1.2", + "@node-ts/bus-cli": "workspace:^", + "@node-ts/bus-core": "workspace:^", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "typemoq": "^2.1.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0 || ^12.0.0", + "@nestjs/core": "^11.0.0 || ^12.0.0", + "@node-ts/bus-core": "^1.3.3" + }, + "keywords": [ + "esb", + "nestjs", + "nest", + "dependency injection", + "typescript", + "bus", + "messaging", + "microservices", + "distributed systems" + ] +} diff --git a/packages/bus-nestjs/src/bus-discovery-metadata.ts b/packages/bus-nestjs/src/bus-discovery-metadata.ts new file mode 100644 index 00000000..4f6d6a18 --- /dev/null +++ b/packages/bus-nestjs/src/bus-discovery-metadata.ts @@ -0,0 +1,14 @@ +import { DiscoveryService } from '@nestjs/core' +import { BusRegistrationOptions } from './bus-registration-options' + +/** + * Marks the classes `@BusHandler()` decorates, so `BusModule` can find them with Nest's `DiscoveryService` + */ +export const BUS_HANDLER_METADATA = + DiscoveryService.createDecorator() + +/** + * Marks the classes `@BusWorkflow()` decorates, so `BusModule` can find them with Nest's `DiscoveryService` + */ +export const BUS_WORKFLOW_METADATA = + DiscoveryService.createDecorator() diff --git a/packages/bus-nestjs/src/bus-feature-options.ts b/packages/bus-nestjs/src/bus-feature-options.ts new file mode 100644 index 00000000..b9af2bea --- /dev/null +++ b/packages/bus-nestjs/src/bus-feature-options.ts @@ -0,0 +1,46 @@ +import { + InjectionToken, + ModuleMetadata, + OptionalFactoryDependency +} from '@nestjs/common' +import { BusFeature } from './bus-feature' + +/** + * Options for `BusModule.forFeature()` + */ +export interface BusFeatureOptions extends BusFeature { + /** + * The name of the bus to register the handlers and workflows with, as given to `BusModule.forRoot({ name })` + * @default 'default' + */ + bus?: string +} + +/** + * Options for `BusModule.forFeatureAsync()`, which declares handlers and workflows with providers injected from + * Nest's container + */ +export interface BusFeatureAsyncOptions< + TDependencies extends unknown[] = unknown[] +> extends Pick { + /** + * The name of the bus to register the handlers and workflows with, as given to `BusModule.forRoot({ name })` + * @default 'default' + */ + bus?: string + /** + * The providers to pass to `useFactory`, in order, from the modules in `imports`. They must be singletons, since + * the factory runs once. + */ + inject?: (InjectionToken | OptionalFactoryDependency)[] + /** + * Declares the handlers and workflows, usually with `handlerFor()` and `defineWorkflow()` closing over the + * injected providers + * @example + * inject: [PaymentGateway], + * useFactory: (gateway: PaymentGateway) => ({ handlers: [chargeCreditCardHandler(gateway)] }) + */ + useFactory: ( + ...dependencies: TDependencies + ) => BusFeature | Promise +} diff --git a/packages/bus-nestjs/src/bus-feature-registration.ts b/packages/bus-nestjs/src/bus-feature-registration.ts new file mode 100644 index 00000000..cdcb0176 --- /dev/null +++ b/packages/bus-nestjs/src/bus-feature-registration.ts @@ -0,0 +1,22 @@ +import { BusFeature } from './bus-feature' + +/** + * Starts the description of each token `BusModule.forFeature()` and `forFeatureAsync()` provide a registration + * under, followed by the bus' name, so a registration Nest couldn't create once can still be named in an error + */ +export const BUS_FEATURE_TOKEN_PREFIX = '@node-ts/bus-nestjs:feature:' + +/** + * The handlers and workflows `BusModule.forFeature()` or `forFeatureAsync()` register with a bus, provided under a + * token of their own so each bus can find them with Nest's `DiscoveryService` + */ +export class BusFeatureRegistration { + /** + * @param busName the bus to register them with + * @param feature the handlers and workflows + */ + constructor( + readonly busName: string, + readonly feature: BusFeature + ) {} +} diff --git a/packages/bus-nestjs/src/bus-feature.ts b/packages/bus-nestjs/src/bus-feature.ts new file mode 100644 index 00000000..0d6b2057 --- /dev/null +++ b/packages/bus-nestjs/src/bus-feature.ts @@ -0,0 +1,38 @@ +import { + ClassConstructor, + FunctionWorkflow, + Handler, + HandlerDefinition, + HandlerFor, + Workflow +} from '@node-ts/bus-core' +import { Message } from '@node-ts/bus-messages' + +/** + * A handler to register with `BusModule.forFeature()`: a class that implements `Handler`, or a handler declared + * with `handlerFor()` + */ +export type BusFeatureHandler = + | ClassConstructor> + | HandlerFor> + +/** + * A workflow to register with `BusModule.forFeature()`: a class that extends `Workflow`, or a workflow declared + * with `defineWorkflow()` + */ +export type BusFeatureWorkflow = + ClassConstructor> | FunctionWorkflow + +/** + * The handlers and workflows a feature module registers with a bus + */ +export interface BusFeature { + /** + * Class handlers, which must also be providers, and handlers declared with `handlerFor()` + */ + handlers?: BusFeatureHandler[] + /** + * Class workflows, which must also be providers, and workflows declared with `defineWorkflow()` + */ + workflows?: BusFeatureWorkflow[] +} diff --git a/packages/bus-nestjs/src/bus-handler.ts b/packages/bus-nestjs/src/bus-handler.ts new file mode 100644 index 00000000..701d05de --- /dev/null +++ b/packages/bus-nestjs/src/bus-handler.ts @@ -0,0 +1,33 @@ +import { ClassConstructor, Handler } from '@node-ts/bus-core' +import { BUS_HANDLER_METADATA } from './bus-discovery-metadata' +import { BusRegistrationOptions } from './bus-registration-options' + +/** + * Registers a class handler that's a Nest provider with a bus, which resolves it from Nest's container for each + * message it handles. Only classes that implement `Handler` can be decorated. The class must still be in a module's + * `providers`. Function handlers are registered with `BusModule.forFeature()` instead. + * @param options which bus to register it with + * @example + * ```ts + * @BusHandler() + * @Injectable() + * export class ChargeCreditCardHandler implements Handler { + * constructor(private readonly gateway: PaymentGateway) {} + * + * get messageType() { + * return ChargeCreditCard + * } + * + * async handle(command: ChargeCreditCard) { + * await this.gateway.charge(command.creditCardToken, command.amount) + * } + * } + * ``` + */ +export const BusHandler = + (options?: BusRegistrationOptions) => + >>( + target: THandler + ): void => { + BUS_HANDLER_METADATA(options)(target) + } diff --git a/packages/bus-nestjs/src/bus-lifecycle-host.spec.ts b/packages/bus-nestjs/src/bus-lifecycle-host.spec.ts new file mode 100644 index 00000000..bc6ff800 --- /dev/null +++ b/packages/bus-nestjs/src/bus-lifecycle-host.spec.ts @@ -0,0 +1,108 @@ +import { Logger } from '@nestjs/common' +import { DiscoveryService, ModuleRef } from '@nestjs/core' +import { BusConfiguration, BusInstance, BusState } from '@node-ts/bus-core' +import { It, Mock } from 'typemoq' +import { BusLifecycleHost } from './bus-lifecycle-host' +import { BusCoreVersionNotSupported } from './error' +import { RecordingLogger } from './test/recording-logger' + +/** + * Creates a host whose configuration builds `bus`, in an application with no handlers or workflows + */ +const createHost = (bus: object): BusLifecycleHost => { + const discovery = Mock.ofType() + discovery.setup(d => d.getProviders(It.isAny())).returns(() => []) + const configuration = Mock.ofType() + configuration + .setup(c => c.withContainer(It.isAny())) + .returns(() => configuration.object) + configuration.setup(c => c.build()).returns(() => bus as BusInstance) + return new BusLifecycleHost( + 'default', + configuration.object, + 'auto', + false, + discovery.object, + Mock.ofType().object + ) +} + +describe('BusLifecycleHost', () => { + describe('when the installed bus-core is too old to say whether a bus can start', () => { + let error: unknown + + beforeAll(() => { + // A bus from a bus-core with no canStart + const sut = createHost({}) + try { + sut.onModuleInit() + } catch (e) { + error = e + } + }) + + it('should throw BusCoreVersionNotSupported, saying to upgrade it', () => { + expect(error).toBeInstanceOf(BusCoreVersionNotSupported) + expect((error as BusCoreVersionNotSupported).missing).toEqual( + 'BusInstance.canStart' + ) + expect((error as BusCoreVersionNotSupported).help).toContain( + 'Upgrade @node-ts/bus-core' + ) + }) + }) + + describe('when the bus fails to initialize and then to dispose', () => { + const initializeError = new Error('Connection refused') + const disposeError = new Error('Connection already closed') + const logger = new RecordingLogger() + const bus = { + canStart: true, + state: BusState.Stopped, + disposeCalls: 0, + async initialize(): Promise { + throw initializeError + }, + async start(): Promise {}, + async dispose(): Promise { + bus.disposeCalls++ + throw disposeError + } + } + let error: unknown + + beforeAll(async () => { + Logger.overrideLogger(logger) + const sut = createHost(bus) + sut.onModuleInit() + try { + await sut.onApplicationBootstrap() + } catch (e) { + error = e + } + // Nest doesn't run the shutdown hooks of an application that failed to start, but the bus mustn't be + // disposed twice if it does + await sut.onApplicationShutdown() + }) + + it('should rethrow the error the bus failed to initialize with', () => { + expect(error).toBe(initializeError) + }) + + it('should dispose the bus once', () => { + expect(bus.disposeCalls).toEqual(1) + }) + + it('should log the error the bus failed to dispose with', () => { + expect(logger.calls).toContainEqual([ + 'error', + 'Failed to dispose the bus after it failed to start', + { + bus: 'default', + error: expect.objectContaining({ message: disposeError.message }) + }, + '@node-ts/bus-nestjs:bus-lifecycle-host' + ]) + }) + }) +}) diff --git a/packages/bus-nestjs/src/bus-lifecycle-host.ts b/packages/bus-nestjs/src/bus-lifecycle-host.ts new file mode 100644 index 00000000..ace99608 --- /dev/null +++ b/packages/bus-nestjs/src/bus-lifecycle-host.ts @@ -0,0 +1,346 @@ +import { + Logger, + OnApplicationBootstrap, + OnApplicationShutdown, + OnModuleDestroy, + OnModuleInit +} from '@nestjs/common' +import { DiscoveryService, ModuleRef } from '@nestjs/core' +import { + BusConfiguration, + BusInstance, + BusState, + ClassConstructor, + Handler, + isClassHandler +} from '@node-ts/bus-core' +import { serializeError } from 'serialize-error' +import { + BUS_HANDLER_METADATA, + BUS_WORKFLOW_METADATA +} from './bus-discovery-metadata' +import { BusFeatureHandler, BusFeatureWorkflow } from './bus-feature' +import { + BUS_FEATURE_TOKEN_PREFIX, + BusFeatureRegistration +} from './bus-feature-registration' +import { BusLifecycle } from './bus-module-options' +import { createLazyBus } from './create-lazy-bus' +import { + BusAlreadyRegistered, + BusClassNotProvided, + BusCoreVersionNotSupported, + BusFeatureNotStatic, + BusNotRegistered +} from './error' +import { DEFAULT_BUS_NAME } from './get-bus-token' +import { nestContainer } from './nest-container' + +type ProviderWrapper = ReturnType[number] + +/** + * What registers handlers or workflows with one bus + */ +interface BusRegistrations { + handlers: Set + workflows: Set + /** + * What registered them, for errors + */ + registeredBy: Set +} + +const STARTED_STATES = [BusState.Started, BusState.Starting] + +/** + * Gets the class of a decorated provider. A factory or value provider's metatype isn't the class, so it's read from + * the instance, as Nest's `DiscoveryService` does. + */ +const classOf = ( + wrapper: ProviderWrapper +): ClassConstructor | undefined => { + const instance: unknown = wrapper.instance + const type: unknown = + !wrapper.metatype || wrapper.inject + ? (instance as object | undefined)?.constructor + : wrapper.metatype + return typeof type === 'function' + ? (type as ClassConstructor) + : undefined +} + +/** + * Builds one bus `BusModule` registers, and runs its lifecycle from Nest's lifecycle hooks: + * - `onModuleInit`: finds the handlers and workflows registered with it, and builds it + * - `onApplicationBootstrap`: initializes it, and starts it if it reads from its transport + * - `onModuleDestroy`: stops it, so it takes no more messages and finishes those it's handling + * - `onApplicationShutdown`: disposes it + * + * `BusModule` is global, and Nest calls each shutdown hook of global modules after that hook of every module that + * isn't. So the bus stops after the `onModuleDestroy` of every module that isn't global, and before any module's + * `beforeApplicationShutdown` and `onApplicationShutdown`, and is disposed after the `onApplicationShutdown` of every + * module that isn't global. Until it stops, it keeps handling messages and taking new ones. Providers its handlers + * use should release their resources in `beforeApplicationShutdown` or `onApplicationShutdown`, not + * `onModuleDestroy`. + * + * In an application made by `createBusForProvisioning()`, it only builds the bus. + */ +export class BusLifecycleHost + implements + OnModuleInit, + OnApplicationBootstrap, + OnModuleDestroy, + OnApplicationShutdown +{ + /** + * What's provided as the bus. It passes everything on to the bus once it's built. + */ + readonly bus: BusInstance + private builtBus: BusInstance | undefined + private disposed = false + private readonly logger = new Logger('@node-ts/bus-nestjs:bus-lifecycle-host') + + /** + * @param name the bus' name + * @param configuration the bus' configuration, from `configure` or `useFactory` + * @param lifecycle whether to initialize and start the bus when the application bootstraps + * @param provisioning whether the application was made by `createBusForProvisioning()` + * @param discovery finds the handlers, workflows and buses in the application + * @param moduleRef resolves class handlers and workflows + */ + constructor( + readonly name: string, + private readonly configuration: BusConfiguration, + private readonly lifecycle: BusLifecycle, + private readonly provisioning: boolean, + private readonly discovery: DiscoveryService, + private readonly moduleRef: ModuleRef + ) { + this.bus = createLazyBus(name, () => this.builtBus) + } + + /** + * Builds the bus with the handlers and workflows registered with it + * @throws BusAlreadyRegistered if another `BusModule.forRoot()` registers a bus with the same name + * @throws BusNotRegistered if handlers or workflows are registered with a bus that isn't + * @throws BusFeatureNotStatic if a `forFeatureAsync()` factory injects a request-scoped or transient provider + * @throws BusClassNotProvided if a class handler or workflow isn't a provider + * @throws BusCoreVersionNotSupported if the installed @node-ts/bus-core is too old + */ + onModuleInit(): void { + const registrations = this.findRegistrations() + const own = registrations.get(this.name) + + const classes: ClassConstructor[] = [] + for (const handler of own?.handlers ?? []) { + if (typeof handler === 'function') { + this.configuration.withHandler(handler) + classes.push(handler) + } else { + this.configuration.withHandler(handler) + if (isClassHandler(handler.messageHandler)) { + classes.push(handler.messageHandler as ClassConstructor) + } + } + } + const workflows = [...(own?.workflows ?? [])] + if (workflows.length) { + this.configuration.withWorkflow(...workflows) + } + classes.push( + ...workflows.filter( + (workflow): workflow is ClassConstructor => + typeof workflow === 'function' + ) + ) + this.assertProvided(classes) + + const bus = this.configuration + .withContainer(nestContainer(this.moduleRef)) + .build() + if (typeof (bus as Partial).canStart !== 'boolean') { + throw new BusCoreVersionNotSupported('BusInstance.canStart') + } + this.builtBus = bus + } + + /** + * Initializes the bus and, if it reads from its transport, starts it, unless its lifecycle is manual. If either + * fails, it disposes the bus, so its connections don't keep the process running, and rethrows. + */ + async onApplicationBootstrap(): Promise { + if (this.provisioning || this.lifecycle === 'manual') { + return + } + const bus = this.getBuiltBus() + try { + await bus.initialize() + if (bus.canStart) { + await bus.start() + } + } catch (error) { + try { + await this.dispose(bus) + } catch (disposeError) { + this.logger.error( + 'Failed to dispose the bus after it failed to start', + { + bus: this.name, + error: serializeError(disposeError) + } + ) + } + throw error + } + } + + /** + * Stops the bus, if it's started, waiting for the messages it's handling. It runs after the `onModuleDestroy` of + * every module that isn't global, since `BusModule` is. + */ + async onModuleDestroy(): Promise { + if (this.provisioning || !this.builtBus) { + return + } + if (STARTED_STATES.includes(this.builtBus.state)) { + await this.builtBus.stop() + } + } + + /** + * Disposes the bus, after the `onApplicationShutdown` of every module that isn't global + */ + async onApplicationShutdown(): Promise { + if (this.provisioning || !this.builtBus) { + return + } + await this.dispose(this.builtBus) + } + + /** + * Disposes the bus once, whether the application failed to bootstrap or shut down + */ + private async dispose(bus: BusInstance): Promise { + if (this.disposed) { + return + } + this.disposed = true + await bus.dispose() + } + + private getBuiltBus(): BusInstance { + // onModuleInit always runs first, and builds it or throws + return this.builtBus as BusInstance + } + + /** + * Finds every handler and workflow registered with any bus in the application, by bus name, and checks every bus + * they're registered with exists, once + */ + private findRegistrations(): Map { + const registrations = new Map() + const registrationsFor = (busName: string): BusRegistrations => { + let found = registrations.get(busName) + if (!found) { + found = { + handlers: new Set(), + workflows: new Set(), + registeredBy: new Set() + } + registrations.set(busName, found) + } + return found + } + + const busNames = new Set() + for (const wrapper of this.discovery.getProviders()) { + const instance: unknown = wrapper.instance + if (instance instanceof BusLifecycleHost) { + if (busNames.has(instance.name)) { + throw new BusAlreadyRegistered(instance.name) + } + busNames.add(instance.name) + } else if (instance instanceof BusFeatureRegistration) { + const found = registrationsFor(instance.busName) + instance.feature.handlers?.forEach(handler => + found.handlers.add(handler) + ) + instance.feature.workflows?.forEach(workflow => + found.workflows.add(workflow) + ) + found.registeredBy.add('BusModule.forFeature()') + } else { + this.assertFeatureResolved(wrapper) + } + } + + for (const wrapper of this.discovery.getProviders({ + metadataKey: BUS_HANDLER_METADATA.KEY + })) { + const handler = classOf(wrapper) + if (!handler) { + continue + } + const options = this.discovery.getMetadataByDecorator( + BUS_HANDLER_METADATA, + wrapper + ) + const found = registrationsFor(options?.bus ?? DEFAULT_BUS_NAME) + found.handlers.add(handler) + found.registeredBy.add(handler.name) + } + for (const wrapper of this.discovery.getProviders({ + metadataKey: BUS_WORKFLOW_METADATA.KEY + })) { + const workflow = classOf(wrapper) + if (!workflow) { + continue + } + const options = this.discovery.getMetadataByDecorator( + BUS_WORKFLOW_METADATA, + wrapper + ) + const found = registrationsFor(options?.bus ?? DEFAULT_BUS_NAME) + found.workflows.add(workflow) + found.registeredBy.add(workflow.name) + } + + for (const [busName, { registeredBy }] of registrations) { + if (!busNames.has(busName)) { + throw new BusNotRegistered(busName, [...registeredBy]) + } + } + return registrations + } + + /** + * Checks a `forFeatureAsync()` registration was created. Nest doesn't create a factory provider when it starts if + * it depends on a request-scoped or transient provider, so its handlers would be silently left out. + * @throws BusFeatureNotStatic if it wasn't + */ + private assertFeatureResolved(wrapper: ProviderWrapper): void { + const token: unknown = wrapper.token + if ( + typeof token === 'symbol' && + token.description?.startsWith(BUS_FEATURE_TOKEN_PREFIX) + ) { + throw new BusFeatureNotStatic( + token.description.slice(BUS_FEATURE_TOKEN_PREFIX.length) + ) + } + } + + /** + * Checks every class handler and workflow is a provider, so the bus can resolve it + * @throws BusClassNotProvided for the first that isn't + */ + private assertProvided(classes: ClassConstructor[]): void { + for (const type of classes) { + try { + this.moduleRef.introspect(type) + } catch (error) { + throw new BusClassNotProvided(type.name, error) + } + } + } +} diff --git a/packages/bus-nestjs/src/bus-module-options.ts b/packages/bus-nestjs/src/bus-module-options.ts new file mode 100644 index 00000000..f6bb55d8 --- /dev/null +++ b/packages/bus-nestjs/src/bus-module-options.ts @@ -0,0 +1,82 @@ +import { + InjectionToken, + ModuleMetadata, + OptionalFactoryDependency +} from '@nestjs/common' +import { BusConfiguration } from '@node-ts/bus-core' + +/** + * When `BusModule` runs the bus' lifecycle: + * - `auto`: it initializes and starts the bus when the application bootstraps (`onApplicationBootstrap`) + * - `manual`: the application calls `bus.initialize()` and `bus.start()` itself, such as after `app.listen()` + * + * Either way, it stops the bus in its `onModuleDestroy` and disposes it in its `onApplicationShutdown`, which Nest + * runs after those of every module that isn't global. With `manual`, call `bus.stop()` before `app.close()` to stop + * it before anything else shuts down. + */ +export type BusLifecycle = 'auto' | 'manual' + +/** + * Options for `BusModule.forRoot()` + */ +export interface BusModuleOptions { + /** + * The name of the bus, to inject it with `@InjectBus(name)` and register handlers with it with + * `BusModule.forFeature({ bus: name })`, when the application has several + * @default 'default' + */ + name?: string + /** + * Configures the bus. It's given a configuration with Nest's logger and no interrupt signals, so Nest's + * shutdown hooks stop the bus, and returns it with the bus' transport, persistence, message types and anything + * else. `BusModule` adds the handlers and workflows of every feature, and a container backed by Nest's, then + * builds it. + * @example + * configure: bus => bus.withTransport(new RabbitMqTransport(configuration)).withMessageTypes(messageTypes) + */ + configure: ( + bus: BusConfiguration + ) => BusConfiguration | Promise + /** + * When `BusModule` initializes and starts the bus + * @default 'auto' + */ + lifecycle?: BusLifecycle +} + +/** + * Options for `BusModule.forRootAsync()`, which configures the bus with providers injected from Nest's container + */ +export interface BusModuleAsyncOptions< + TDependencies extends unknown[] = unknown[] +> extends Pick { + /** + * The name of the bus, to inject it with `@InjectBus(name)` and register handlers with it with + * `BusModule.forFeature({ bus: name })`, when the application has several + * @default 'default' + */ + name?: string + /** + * The providers to pass to `useFactory`, after the configuration, in order + */ + inject?: (InjectionToken | OptionalFactoryDependency)[] + /** + * Configures the bus. It's given a configuration with Nest's logger and no interrupt signals, so Nest's + * shutdown hooks stop the bus, then the providers in `inject`, and returns the configuration with the bus' + * transport, persistence, message types and anything else. `BusModule` adds the handlers and workflows of every + * feature, and a container backed by Nest's, then builds it. + * @example + * inject: [ConfigService], + * useFactory: (bus, config: ConfigService) => + * bus.withTransport(new SqsTransport({ queueName: config.getOrThrow('QUEUE_NAME') })) + */ + useFactory: ( + bus: BusConfiguration, + ...dependencies: TDependencies + ) => BusConfiguration | Promise + /** + * When `BusModule` initializes and starts the bus + * @default 'auto' + */ + lifecycle?: BusLifecycle +} diff --git a/packages/bus-nestjs/src/bus-module.integration.ts b/packages/bus-nestjs/src/bus-module.integration.ts new file mode 100644 index 00000000..ad46603f --- /dev/null +++ b/packages/bus-nestjs/src/bus-module.integration.ts @@ -0,0 +1,793 @@ +import 'reflect-metadata' + +import { + BeforeApplicationShutdown, + Injectable, + OnApplicationShutdown, + OnModuleDestroy, + Scope +} from '@nestjs/common' +import { Test, TestingModule } from '@nestjs/testing' +import { + Bus, + BusInstance, + BusMiddleware, + BusState, + ClassHandlerNotResolved, + Handler, + HandlerDispatchRejected, + InMemoryQueue, + Logger, + deadLetter, + handlerFor +} from '@node-ts/bus-core' +import { EventEmitter } from 'node:events' +import { Mock } from 'typemoq' +import { BusModule } from './bus-module' +import { + BusAlreadyRegistered, + BusClassNotProvided, + BusFeatureNotStatic, + BusNotBuilt, + BusNotRegistered, + WorkflowResolvedWithoutMessage +} from './error' +import { getBusToken } from './get-bus-token' +import { InjectBus } from './inject-bus' +import { + BillingHandler, + ChargeCreditCard, + ChargeCreditCardHandler, + FirstScopedHandler, + FulfilmentWorkflow, + MessageScope, + OrderPlaced, + OrderPlacedHandler, + ProvisionedQueue, + Recorder, + RecordingLogger, + RequestScopedWorkflow, + SecondScopedHandler, + messageTypes, + orderPlacedAuditHandler, + recorderModule, + shippingWorkflow, + testLogger +} from './test' + +/** + * A class handler that no module provides + */ +class UnprovidedHandler implements Handler { + get messageType() { + return ChargeCreditCard + } + + async handle(): Promise { + // Never reached, the bus can't resolve it + } +} + +/** + * Compiles a testing module with the tests' logger and runs its bootstrap hooks + */ +const startApp = async ( + metadata: Parameters[0] +): Promise => { + const app = await Test.createTestingModule(metadata) + .setLogger(testLogger() || console) + .compile() + await app.init() + return app +} + +/** + * Captures the errors that fail the handling of a message + */ +const captureErrors = (errors: EventEmitter): BusMiddleware => ({ + incoming: async (_, next) => { + try { + await next() + } catch (error) { + errors.emit('error-captured', error) + throw error + } + } +}) + +describe('BusModule', () => { + describe('when handlers and workflows are registered in every way', () => { + const queue = new ProvisionedQueue() + let app: TestingModule + let bus: BusInstance + let recorder: Recorder + let signalListenersBefore: number + let signalListenersWhileRunning: number + + beforeAll(async () => { + signalListenersBefore = process.listenerCount('SIGTERM') + app = await startApp({ + imports: [ + recorderModule(), + BusModule.forRoot({ + configure: configuration => + configuration.withTransport(queue).withMessageTypes(messageTypes) + }), + BusModule.forFeature({ handlers: [OrderPlacedHandler] }), + BusModule.forFeatureAsync({ + inject: [Recorder], + useFactory: (injected: Recorder) => ({ + handlers: [orderPlacedAuditHandler(injected)], + workflows: [shippingWorkflow(injected)] + }) + }) + ], + providers: [ + ChargeCreditCardHandler, + OrderPlacedHandler, + FulfilmentWorkflow, + MessageScope, + FirstScopedHandler, + SecondScopedHandler + ] + }) + signalListenersWhileRunning = process.listenerCount('SIGTERM') + bus = app.get(BusInstance) + recorder = app.get(Recorder) + + await bus.send(new ChargeCreditCard('order-1', 10)) + await bus.publish(new OrderPlaced('order-1')) + await bus.publish(new OrderPlaced('order-2')) + await queue.idle() + }) + + afterAll(async () => app.close()) + + it('should start the bus when the application bootstraps', () => { + expect(bus.state).toEqual(BusState.Started) + expect(queue.calls).toEqual(['start']) + }) + + it('should provide the bus as a BusInstance', () => { + expect(bus).toBeInstanceOf(BusInstance) + }) + + it('should not listen for interrupt signals, leaving them to Nest', () => { + expect(signalListenersWhileRunning).toEqual(signalListenersBefore) + }) + + it('should resolve a class handler decorated with @BusHandler() with its dependencies', () => { + expect(recorder.by(ChargeCreditCardHandler.name)).toHaveLength(1) + }) + + it('should resolve a class handler registered with forFeature()', () => { + expect(recorder.by(OrderPlacedHandler.name)).toHaveLength(2) + }) + + it('should dispatch to a function handler registered with forFeatureAsync()', () => { + expect(recorder.by('orderPlacedAuditHandler')).toHaveLength(2) + }) + + it('should start a class workflow decorated with @BusWorkflow()', () => { + expect(recorder.by(FulfilmentWorkflow.name)).toHaveLength(2) + }) + + it('should start a function workflow registered with forFeatureAsync()', () => { + expect(recorder.by('shippingWorkflow')).toHaveLength(2) + }) + + it('should give the handlers of one message the same request-scoped provider, with the message as the request', () => { + for (const orderId of ['order-1', 'order-2']) { + const [first] = recorder + .by(FirstScopedHandler.name) + .filter(({ message }) => (message as OrderPlaced).orderId === orderId) + const [second] = recorder + .by(SecondScopedHandler.name) + .filter(({ message }) => (message as OrderPlaced).orderId === orderId) + const scope = first.detail as MessageScope + expect(scope).toBeInstanceOf(MessageScope) + expect(second.detail).toBe(scope) + expect(scope.request.message).toMatchObject({ orderId }) + expect(scope.request.attributes?.messageId).toEqual(expect.any(String)) + } + }) + + it('should give each message a request scope of its own', () => { + const [first, second] = recorder.by(FirstScopedHandler.name) + expect(first.detail).not.toBe(second.detail) + }) + }) + + describe('when the application shuts down while a message is being handled', () => { + const events: string[] = [] + let bus: BusInstance + + /** + * A queue that lets the handler finish once the bus stops it, so the message is in flight while Nest runs the + * other modules' onModuleDestroy + */ + class GatedQueue extends ProvisionedQueue { + constructor(private readonly onStop: () => void) { + super(events) + } + + async stop(): Promise { + await super.stop() + this.onStop() + } + } + + /** + * A provider of a feature module, such as a database client, that records Nest's shutdown hooks + */ + class Database + implements + OnModuleDestroy, + BeforeApplicationShutdown, + OnApplicationShutdown + { + onModuleDestroy(): void { + events.push('database onModuleDestroy') + } + + beforeApplicationShutdown(): void { + events.push('database beforeApplicationShutdown') + } + + onApplicationShutdown(): void { + events.push('database onApplicationShutdown') + } + } + class DatabaseModule {} + + beforeAll(async () => { + let release: () => void = () => undefined + const released = new Promise(resolve => (release = resolve)) + let handling: () => void = () => undefined + const handlerStarted = new Promise(resolve => (handling = resolve)) + const queue = new GatedQueue(() => release()) + + const app = await startApp({ + imports: [ + { module: DatabaseModule, providers: [Database] }, + BusModule.forRoot({ + configure: configuration => + configuration + .withTransport(queue) + .withMessageTypes(messageTypes) + .withHandler( + handlerFor(ChargeCreditCard, async () => { + events.push('handler started') + handling() + await released + events.push('handler finished') + }) + ) + }) + ] + }) + bus = app.get(BusInstance) + await bus.send(new ChargeCreditCard('order-1', 10)) + await handlerStarted + await app.close() + }) + + it('should stop the bus after the onModuleDestroy of other modules, finishing the message before their later shutdown hooks', () => { + expect(events).toEqual([ + 'start', + 'handler started', + 'database onModuleDestroy', + 'stop', + 'handler finished', + 'database beforeApplicationShutdown', + 'database onApplicationShutdown', + 'dispose' + ]) + }) + + it('should leave the bus stopped', () => { + expect(bus.state).toEqual(BusState.Stopped) + }) + }) + + describe('when configure() returns a configuration it was not given', () => { + const logger = new RecordingLogger() + let app: TestingModule + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + BusModule.forRoot({ + configure: () => + Bus.configure() + .withLogger(() => Mock.ofType().object) + .withInterruptSignals([]) + }) + ] + }) + .setLogger(logger) + .compile() + await app.init() + }) + + afterAll(async () => app.close()) + + it('should warn that the logger and shutdown handling of Nest are lost', () => { + expect(logger.calls).toContainEqual([ + 'warn', + expect.stringContaining("isn't the one BusModule gave its factory"), + { bus: 'default' }, + '@node-ts/bus-nestjs:bus-module' + ]) + }) + }) + + describe('when the bus is configured by forRootAsync() with injected providers', () => { + const QUEUE = 'QUEUE' + const queue = new InMemoryQueue() + let app: TestingModule + let recorder: Recorder + + beforeAll(async () => { + class QueueModule {} + app = await startApp({ + imports: [ + recorderModule(), + BusModule.forRootAsync({ + imports: [ + { + module: QueueModule, + providers: [{ provide: QUEUE, useValue: queue }], + exports: [QUEUE] + } + ], + inject: [QUEUE], + useFactory: (configuration, injectedQueue: InMemoryQueue) => + configuration + .withTransport(injectedQueue) + .withMessageTypes(messageTypes) + }) + ], + providers: [ChargeCreditCardHandler] + }) + recorder = app.get(Recorder) + await app.get(BusInstance).send(new ChargeCreditCard('order-1', 10)) + await queue.idle() + }) + + afterAll(async () => app.close()) + + it('should run the bus on what the factory was given', () => { + expect(recorder.by(ChargeCreditCardHandler.name)).toHaveLength(1) + }) + }) + + describe('when the lifecycle is manual', () => { + const queue = new ProvisionedQueue() + let app: TestingModule + let bus: BusInstance + let stateAfterBootstrap: BusState + + beforeAll(async () => { + app = await startApp({ + imports: [ + recorderModule(), + BusModule.forRoot({ + lifecycle: 'manual', + configure: configuration => + configuration.withTransport(queue).withMessageTypes(messageTypes) + }) + ], + providers: [ChargeCreditCardHandler] + }) + bus = app.get(BusInstance) + stateAfterBootstrap = bus.state + await bus.initialize() + await bus.start() + await app.close() + }) + + it('should leave initializing and starting the bus to the application', () => { + expect(stateAfterBootstrap).toEqual(BusState.Stopped) + }) + + it('should still stop and dispose it when the application shuts down', () => { + expect(queue.calls).toEqual(['start', 'stop', 'dispose']) + }) + }) + + describe('when the bus is send-only', () => { + const queue = new ProvisionedQueue() + let app: TestingModule + let bus: BusInstance + let sent: Promise + + beforeAll(async () => { + app = await startApp({ + imports: [ + BusModule.forRoot({ + configure: configuration => + configuration.withTransport(queue).asSendOnly() + }) + ] + }) + bus = app.get(BusInstance) + sent = bus.send(new ChargeCreditCard('order-1', 10)) + await sent + }) + + afterAll(async () => app.close()) + + it('should initialize it without starting it', async () => { + expect(queue.calls).toEqual([]) + expect(bus.state).toEqual(BusState.Stopped) + }) + + it('should send', async () => { + await expect(sent).resolves.toBeUndefined() + }) + }) + + describe('when a testing module overrides a dependency of a handler', () => { + const queue = new InMemoryQueue() + const fake = new Recorder() + let app: TestingModule + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + recorderModule(), + BusModule.forRoot({ + configure: configuration => + configuration.withTransport(queue).withMessageTypes(messageTypes) + }) + ], + providers: [ChargeCreditCardHandler] + }) + .overrideProvider(Recorder) + .useValue(fake) + .setLogger(testLogger() || console) + .compile() + await app.init() + await app.get(BusInstance).send(new ChargeCreditCard('order-1', 10)) + await queue.idle() + }) + + afterAll(async () => app.close()) + + it('should give the handler the override', () => { + expect(fake.by(ChargeCreditCardHandler.name)).toHaveLength(1) + }) + }) + + describe('when an application has several buses', () => { + const defaultQueue = new InMemoryQueue() + const billingQueue = new InMemoryQueue() + let app: TestingModule + let recorder: Recorder + let billingService: BillingService + + class BillingService { + constructor(readonly bus: BusInstance) {} + } + Injectable()(BillingService) + InjectBus('billing')(BillingService, undefined, 0) + + beforeAll(async () => { + app = await startApp({ + imports: [ + recorderModule(), + BusModule.forRoot({ + configure: configuration => + configuration + .withTransport(defaultQueue) + .withMessageTypes(messageTypes) + }), + BusModule.forRoot({ + name: 'billing', + configure: configuration => + configuration + .withTransport(billingQueue) + .withMessageTypes(messageTypes) + }) + ], + providers: [ChargeCreditCardHandler, BillingHandler, BillingService] + }) + recorder = app.get(Recorder) + billingService = app.get(BillingService) + + await app.get(BusInstance).send(new ChargeCreditCard('order-1', 10)) + await billingService.bus.send(new ChargeCreditCard('order-2', 20)) + await Promise.all([defaultQueue.idle(), billingQueue.idle()]) + }) + + afterAll(async () => app.close()) + + it('should inject a named bus with @InjectBus()', () => { + expect(billingService.bus).toBe(app.get(getBusToken('billing'))) + expect(billingService.bus).not.toBe(app.get(BusInstance)) + }) + + it('should register each handler with its own bus', () => { + expect( + recorder.by(ChargeCreditCardHandler.name).map(({ message }) => message) + ).toEqual([expect.objectContaining({ orderId: 'order-1' })]) + expect( + recorder.by(BillingHandler.name).map(({ message }) => message) + ).toEqual([expect.objectContaining({ orderId: 'order-2' })]) + }) + }) + + describe('when a class handler added in configure() is not a provider', () => { + const queue = new InMemoryQueue() + const errors = new EventEmitter() + let app: TestingModule + let error: Error + + beforeAll(async () => { + app = await startApp({ + imports: [ + BusModule.forRoot({ + configure: configuration => + configuration + .withTransport(queue) + .withMessageTypes(messageTypes) + .withMiddleware(captureErrors(errors)) + .withRecoverability(() => deadLetter()) + .withHandler(UnprovidedHandler) + }) + ] + }) + const captured = new Promise(resolve => + errors.once('error-captured', resolve) + ) + await app.get(BusInstance).send(new ChargeCreditCard('order-1', 10)) + error = await captured + }) + + afterAll(async () => app.close()) + + it('should fail the message, saying to make it a provider', () => { + const [rejection] = (error as HandlerDispatchRejected).rejections + expect(rejection).toBeInstanceOf(ClassHandlerNotResolved) + const cause = (rejection as ClassHandlerNotResolved).cause + expect(cause).toBeInstanceOf(BusClassNotProvided) + expect((cause as BusClassNotProvided).help).toContain('providers') + }) + }) + + describe('when the application fails to start', () => { + const startFails = async ( + metadata: Parameters[0] + ): Promise => { + const app = await Test.createTestingModule(metadata) + .setLogger(testLogger() || console) + .compile() + try { + await app.init() + } catch (error) { + // Closing an application that failed to initialize rethrows the error, and nothing was built to dispose + return error + } + await app.close() + throw new Error('Expected the application to fail to start') + } + + const forRoot = (name?: string) => + BusModule.forRoot({ + name, + configure: configuration => + configuration + .withTransport(new InMemoryQueue()) + .withMessageTypes(messageTypes) + }) + + describe('and handlers are registered with a bus that is not registered', () => { + let error: unknown + + beforeAll(async () => { + error = await startFails({ + imports: [ + recorderModule(), + forRoot(), + BusModule.forFeature({ + bus: 'reporting', + handlers: [OrderPlacedHandler] + }) + ], + providers: [BillingHandler] + }) + }) + + it('should throw BusNotRegistered, naming the bus and what registered with it', () => { + expect(error).toBeInstanceOf(BusNotRegistered) + const busNotRegistered = error as BusNotRegistered + expect(['reporting', 'billing']).toContain(busNotRegistered.busName) + expect(busNotRegistered.help).toContain('BusModule.forRoot') + }) + }) + + describe('and two buses have the same name', () => { + let error: unknown + + beforeAll(async () => { + error = await startFails({ imports: [forRoot(), forRoot()] }) + }) + + it('should throw BusAlreadyRegistered', () => { + expect(error).toBeInstanceOf(BusAlreadyRegistered) + expect((error as BusAlreadyRegistered).busName).toEqual('default') + }) + }) + + describe('and forFeatureAsync() injects a request-scoped provider', () => { + let error: unknown + + beforeAll(async () => { + class ScopeModule {} + error = await startFails({ + imports: [ + forRoot(), + BusModule.forFeatureAsync({ + imports: [ + { + module: ScopeModule, + providers: [ + { + provide: 'SCOPED', + scope: Scope.REQUEST, + useFactory: () => 'scoped' + } + ], + exports: ['SCOPED'] + } + ], + inject: ['SCOPED'], + useFactory: () => ({ handlers: [] }) + }) + ] + }) + }) + + it('should throw BusFeatureNotStatic', () => { + expect(error).toBeInstanceOf(BusFeatureNotStatic) + }) + }) + + describe('and a class registered with forFeature() is not a provider', () => { + let error: unknown + + beforeAll(async () => { + error = await startFails({ + imports: [ + recorderModule(), + forRoot(), + BusModule.forFeature({ handlers: [OrderPlacedHandler] }) + ] + }) + }) + + it('should throw BusClassNotProvided, naming the class and saying to add it to providers', () => { + expect(error).toBeInstanceOf(BusClassNotProvided) + const notProvided = error as BusClassNotProvided + expect(notProvided.className).toEqual(OrderPlacedHandler.name) + expect(notProvided.help).toContain('providers') + }) + }) + + describe('and a request-scoped class workflow reads REQUEST in its constructor', () => { + const queue = new ProvisionedQueue() + let error: unknown + + beforeAll(async () => { + error = await startFails({ + imports: [ + BusModule.forRoot({ + configure: configuration => + configuration + .withTransport(queue) + .withMessageTypes(messageTypes) + }), + BusModule.forFeature({ workflows: [RequestScopedWorkflow] }) + ], + providers: [RequestScopedWorkflow] + }) + }) + + it('should throw WorkflowResolvedWithoutMessage, naming the workflow', () => { + expect(error).toBeInstanceOf(WorkflowResolvedWithoutMessage) + const resolvedWithoutMessage = error as WorkflowResolvedWithoutMessage + expect(resolvedWithoutMessage.className).toEqual( + RequestScopedWorkflow.name + ) + expect(resolvedWithoutMessage.cause).toBeInstanceOf(TypeError) + expect(resolvedWithoutMessage.help).toContain('REQUEST') + }) + + it('should dispose the bus', () => { + expect(queue.calls).toEqual(['dispose']) + }) + }) + + describe('and the transport fails to initialize', () => { + const transportError = new Error('Connection refused') + const queue = new ProvisionedQueue([], transportError) + let error: unknown + + beforeAll(async () => { + error = await startFails({ + imports: [ + BusModule.forRoot({ + configure: configuration => + configuration + .withTransport(queue) + .withMessageTypes(messageTypes) + }) + ] + }) + }) + + it('should rethrow the error', () => { + expect(error).toBe(transportError) + }) + + it('should dispose the bus', () => { + expect(queue.calls).toEqual(['dispose']) + }) + }) + + describe('and a class handler declared with handlerFor() is not a provider', () => { + let error: unknown + + beforeAll(async () => { + error = await startFails({ + imports: [ + forRoot(), + BusModule.forFeatureAsync({ + useFactory: () => ({ + handlers: [handlerFor(ChargeCreditCard, UnprovidedHandler)] + }) + }) + ] + }) + }) + + it('should throw BusClassNotProvided, naming the class', () => { + expect(error).toBeInstanceOf(BusClassNotProvided) + expect((error as BusClassNotProvided).className).toEqual( + UnprovidedHandler.name + ) + }) + }) + }) + + describe('when a provider uses the bus before it is built', () => { + let error: unknown + + beforeAll(async () => { + try { + await Test.createTestingModule({ + imports: [ + BusModule.forRoot({ configure: configuration => configuration }) + ], + providers: [ + { + provide: 'EAGER', + inject: [BusInstance], + useFactory: (bus: BusInstance) => bus.state + } + ] + }) + .setLogger(testLogger() || console) + .compile() + } catch (e) { + error = e + } + }) + + it('should throw BusNotBuilt, naming what was used', () => { + expect(error).toBeInstanceOf(BusNotBuilt) + expect((error as BusNotBuilt).member).toEqual('state') + }) + }) +}) diff --git a/packages/bus-nestjs/src/bus-module.ts b/packages/bus-nestjs/src/bus-module.ts new file mode 100644 index 00000000..ec369756 --- /dev/null +++ b/packages/bus-nestjs/src/bus-module.ts @@ -0,0 +1,194 @@ +import { DynamicModule, Logger, Provider } from '@nestjs/common' +import { DiscoveryModule, DiscoveryService, ModuleRef } from '@nestjs/core' +import { Bus } from '@node-ts/bus-core' +import { + BusFeatureAsyncOptions, + BusFeatureOptions +} from './bus-feature-options' +import { + BUS_FEATURE_TOKEN_PREFIX, + BusFeatureRegistration +} from './bus-feature-registration' +import { BusLifecycleHost } from './bus-lifecycle-host' +import { BusModuleAsyncOptions, BusModuleOptions } from './bus-module-options' +import { BUS_PROVISIONING } from './bus-provisioning' +import { DEFAULT_BUS_NAME, getBusToken } from './get-bus-token' +import { nestLoggerFactory } from './nest-logger-factory' + +/** + * Runs a bus in a NestJS application. `forRoot()` or `forRootAsync()`, imported once in the root module, + * registers the bus, which is injected as `BusInstance` anywhere in the application. Feature modules register + * their handlers and workflows with `forFeature()` or `forFeatureAsync()`, or class handlers and workflows that are + * providers can be decorated with `@BusHandler()` and `@BusWorkflow()`. Class handlers and workflows are resolved + * from Nest's container for each message. + * + * The bus is built when the application initializes and initialized and started when it bootstraps. When the + * application shuts down, it's stopped after the `onModuleDestroy` of every module that isn't global, and disposed + * after their `onApplicationShutdown`, so release what handlers use in `beforeApplicationShutdown` or + * `onApplicationShutdown`. Call `app.enableShutdownHooks()` so a SIGTERM shuts it down gracefully. + * @example + * ```ts + * @Module({ + * imports: [ + * BusModule.forRoot({ + * configure: bus => bus.withTransport(new RabbitMqTransport(configuration)).withMessageTypes(messageTypes) + * }), + * PaymentsModule + * ] + * }) + * export class AppModule {} + * ``` + */ +export class BusModule { + /** + * Registers a bus, configured by a function + * @param options how to configure the bus + * @returns a global module that provides the bus + * @example + * BusModule.forRoot({ configure: bus => bus.withTransport(transport).withMessageTypes(messageTypes) }) + */ + static forRoot(options: BusModuleOptions): DynamicModule { + return BusModule.forRootAsync({ + name: options.name, + lifecycle: options.lifecycle, + useFactory: bus => options.configure(bus) + }) + } + + /** + * Registers a bus, configured by a factory that's given providers from Nest's container + * @param options how to configure the bus, and the providers to configure it with + * @returns a global module that provides the bus + * @example + * BusModule.forRootAsync({ + * imports: [ConfigModule], + * inject: [ConfigService], + * useFactory: (bus, config: ConfigService) => + * bus.withTransport(new SqsTransport({ queueName: config.getOrThrow('QUEUE_NAME') })).withMessageTypes(messageTypes) + * }) + */ + static forRootAsync( + options: BusModuleAsyncOptions + ): DynamicModule { + const name = options.name ?? DEFAULT_BUS_NAME + const hostToken = Symbol(`@node-ts/bus-nestjs:lifecycle:${name}`) + const busToken = getBusToken(name) + + const providers: Provider[] = [ + { + provide: hostToken, + inject: [ + DiscoveryService, + ModuleRef, + { token: BUS_PROVISIONING, optional: true }, + ...(options.inject ?? []) + ], + useFactory: async ( + discovery: DiscoveryService, + moduleRef: ModuleRef, + provisioning: boolean | undefined, + ...dependencies: unknown[] + ) => { + // Nest's shutdown hooks stop the bus, so the bus doesn't listen for signals itself + const seed = Bus.configure() + .withLogger(nestLoggerFactory) + .withInterruptSignals([]) + const configuration = await options.useFactory( + seed, + ...(dependencies as TDependencies) + ) + if (configuration !== seed) { + new Logger('@node-ts/bus-nestjs:bus-module').warn( + `The configuration of the bus '${name}' isn't the one BusModule gave its factory, so the bus logs with the default logger rather than Nest's, and stops itself on SIGINT and SIGTERM, alongside Nest's shutdown hooks. Configure and return the configuration the factory is given.`, + { bus: name } + ) + } + return new BusLifecycleHost( + name, + configuration, + options.lifecycle ?? 'auto', + provisioning === true, + discovery, + moduleRef + ) + } + }, + { + provide: busToken, + inject: [hostToken], + useFactory: (host: BusLifecycleHost) => host.bus + } + ] + + return { + module: BusModule, + global: true, + imports: [DiscoveryModule, ...(options.imports ?? [])], + providers, + exports: [busToken] + } + } + + /** + * Registers handlers and workflows with a bus. It only registers them: class handlers and workflows must also be + * providers, in the `providers` of the module that imports this or another module of the application. + * @param options the handlers and workflows, and the bus to register them with + * @returns a module to import in the feature module + * @example + * ```ts + * @Module({ + * imports: [BusModule.forFeature({ handlers: [ChargeCreditCardHandler, refundHandler], workflows: [shippingWorkflow] })], + * providers: [ChargeCreditCardHandler] + * }) + * export class PaymentsModule {} + * ``` + */ + static forFeature(options: BusFeatureOptions): DynamicModule { + const { bus, ...feature } = options + const busName = bus ?? DEFAULT_BUS_NAME + return { + module: BusModule, + providers: [ + { + provide: Symbol(`${BUS_FEATURE_TOKEN_PREFIX}${busName}`), + useValue: new BusFeatureRegistration(busName, feature) + } + ] + } + } + + /** + * Registers handlers and workflows with a bus, declared by a factory that's given providers from Nest's + * container, such as function handlers that close over them. The factory runs once, so it can only inject + * singletons. Its module only sees the providers of the modules in `imports`. Class handlers and workflows it + * returns must also be providers, as with `forFeature()`. + * @param options the factory, the providers to give it, and the bus to register them with + * @returns a module to import in the feature module + * @example + * BusModule.forFeatureAsync({ + * imports: [PaymentGatewayModule], + * inject: [PaymentGateway], + * useFactory: (gateway: PaymentGateway) => ({ handlers: [chargeCreditCardHandler(gateway)] }) + * }) + */ + static forFeatureAsync( + options: BusFeatureAsyncOptions + ): DynamicModule { + const busName = options.bus ?? DEFAULT_BUS_NAME + return { + module: BusModule, + imports: options.imports ?? [], + providers: [ + { + provide: Symbol(`${BUS_FEATURE_TOKEN_PREFIX}${busName}`), + inject: options.inject ?? [], + useFactory: async (...dependencies: unknown[]) => + new BusFeatureRegistration( + busName, + await options.useFactory(...(dependencies as TDependencies)) + ) + } + ] + } + } +} diff --git a/packages/bus-nestjs/src/bus-provisioning.ts b/packages/bus-nestjs/src/bus-provisioning.ts new file mode 100644 index 00000000..f59556fd --- /dev/null +++ b/packages/bus-nestjs/src/bus-provisioning.ts @@ -0,0 +1,11 @@ +/** + * Provided, as `true`, by `createBusForProvisioning()`'s application, so each `BusModule` only builds its bus, + * without initializing, starting or disposing it, and `bus provision` can provision it + */ +export const BUS_PROVISIONING = Symbol('@node-ts/bus-nestjs:provisioning') + +/** + * The global module `createBusForProvisioning()` adds to the application, to provide `BUS_PROVISIONING` to every + * `BusModule` in it + */ +export class BusProvisioningModule {} diff --git a/packages/bus-nestjs/src/bus-registration-options.ts b/packages/bus-nestjs/src/bus-registration-options.ts new file mode 100644 index 00000000..546890fc --- /dev/null +++ b/packages/bus-nestjs/src/bus-registration-options.ts @@ -0,0 +1,10 @@ +/** + * Options for `@BusHandler()` and `@BusWorkflow()` + */ +export interface BusRegistrationOptions { + /** + * The name of the bus, as given to `BusModule.forRoot({ name })`, to register the class with + * @default 'default' + */ + bus?: string +} diff --git a/packages/bus-nestjs/src/bus-request.ts b/packages/bus-nestjs/src/bus-request.ts new file mode 100644 index 00000000..5464fc1b --- /dev/null +++ b/packages/bus-nestjs/src/bus-request.ts @@ -0,0 +1,27 @@ +import { MessageAttributes } from '@node-ts/bus-messages' + +/** + * What a request-scoped provider gets when it injects Nest's `REQUEST` while the bus resolves it to handle a + * message: the message and its attributes. Each received message has its own request scope, shared by every class + * handler and workflow that handles it. + * + * A request-scoped class workflow is also resolved once without a message, when the bus reads its + * `configureWorkflow()`, so `REQUEST` is `undefined` then. + * @example + * ```ts + * @Injectable({ scope: Scope.REQUEST }) + * export class TenantRepository { + * constructor(@Inject(REQUEST) private readonly request: BusRequest) {} + * } + * ``` + */ +export interface BusRequest { + /** + * The message being handled + */ + message: object + /** + * The message's attributes + */ + attributes: MessageAttributes | undefined +} diff --git a/packages/bus-nestjs/src/bus-workflow.ts b/packages/bus-nestjs/src/bus-workflow.ts new file mode 100644 index 00000000..fd31c6fa --- /dev/null +++ b/packages/bus-nestjs/src/bus-workflow.ts @@ -0,0 +1,26 @@ +import { ClassConstructor, Workflow } from '@node-ts/bus-core' +import { BUS_WORKFLOW_METADATA } from './bus-discovery-metadata' +import { BusRegistrationOptions } from './bus-registration-options' + +/** + * Registers a class workflow that's a Nest provider with a bus, which resolves it from Nest's container when it + * reads the workflow's `configureWorkflow()` and for each message it handles. Only classes that extend `Workflow` + * can be decorated. The class must still be in a module's `providers`. Workflows declared with `defineWorkflow()` + * are registered with `BusModule.forFeature()` instead. + * @param options which bus to register it with + * @example + * ```ts + * @BusWorkflow() + * @Injectable() + * export class ShippingWorkflow extends Workflow { + * ... + * } + * ``` + */ +export const BusWorkflow = + (options?: BusRegistrationOptions) => + >>( + target: TWorkflow + ): void => { + BUS_WORKFLOW_METADATA(options)(target) + } diff --git a/packages/bus-nestjs/src/create-bus-for-provisioning.integration.ts b/packages/bus-nestjs/src/create-bus-for-provisioning.integration.ts new file mode 100644 index 00000000..a8e4ef6f --- /dev/null +++ b/packages/bus-nestjs/src/create-bus-for-provisioning.integration.ts @@ -0,0 +1,64 @@ +import 'reflect-metadata' + +import { DynamicModule } from '@nestjs/common' +import { BusInstance, BusState, ProvisioningPlan } from '@node-ts/bus-core' +import { BusModule } from './bus-module' +import { createBusForProvisioning } from './create-bus-for-provisioning' +import { + ChargeCreditCard, + ChargeCreditCardHandler, + ProvisionedQueue, + messageTypes, + recorderModule, + testLogger +} from './test' + +class AppModule {} + +/** + * The application's root module, with a bus that handles `ChargeCreditCard` + */ +const appModule = (queue: ProvisionedQueue): DynamicModule => ({ + module: AppModule, + imports: [ + recorderModule(), + BusModule.forRoot({ + configure: configuration => + configuration.withTransport(queue).withMessageTypes(messageTypes) + }) + ], + providers: [ChargeCreditCardHandler] +}) + +describe('createBusForProvisioning', () => { + describe('when given the application module', () => { + const queue = new ProvisionedQueue() + let bus: BusInstance + let plans: ProvisioningPlan[] + + beforeAll(async () => { + bus = await createBusForProvisioning(appModule(queue), { + logger: testLogger() + }) + plans = await bus.provision() + await bus.dispose() + }) + + it('should return the bus built but not initialized or started', () => { + expect(bus).toBeInstanceOf(BusInstance) + expect(bus.state).toEqual(BusState.Stopped) + expect(queue.calls).toEqual(['provision', 'dispose']) + }) + + it('should provision for the handlers registered in the application', () => { + expect(plans).toEqual([ + { + adapter: 'ProvisionedQueue', + resources: expect.arrayContaining([ + { type: 'topic', name: ChargeCreditCard.NAME } + ]) + } + ]) + }) + }) +}) diff --git a/packages/bus-nestjs/src/create-bus-for-provisioning.ts b/packages/bus-nestjs/src/create-bus-for-provisioning.ts new file mode 100644 index 00000000..95ffffb2 --- /dev/null +++ b/packages/bus-nestjs/src/create-bus-for-provisioning.ts @@ -0,0 +1,59 @@ +import { DynamicModule, LoggerService, LogLevel, Type } from '@nestjs/common' +import { NestFactory } from '@nestjs/core' +import { BusInstance } from '@node-ts/bus-core' +import { BUS_PROVISIONING, BusProvisioningModule } from './bus-provisioning' +import { getBusToken } from './get-bus-token' + +/** + * Options for `createBusForProvisioning()` + */ +export interface CreateBusForProvisioningOptions { + /** + * The name of the bus to return, as given to `BusModule.forRoot({ name })` + * @default 'default' + */ + bus?: string + /** + * The logger, or log levels, of the application it creates. Nest writes most logs to stdout, which would mix + * with `bus provision --json`'s report. + * @default ['error'] + */ + logger?: LoggerService | LogLevel[] | false +} + +/** + * Creates the application as a standalone application context in which each `BusModule` builds its bus but doesn't + * initialize, start or dispose it, and returns the bus, for `bus provision` from @node-ts/bus-cli. The bus has the + * handlers and workflows of every module, just as when the application runs. + * + * The application is never closed: `bus provision` disposes the bus and then exits. Creating it runs the + * `onModuleInit` and `onApplicationBootstrap` hooks of the application's other providers, so keep side effects that + * shouldn't happen on a deploy, such as starting a scheduled job, out of them or behind configuration. + * @param appModule the application's root module + * @param options which bus to return, and the application's logger + * @returns the bus, built but not initialized + * @example + * // src/provision-bus.ts, run with `bus provision dist/provision-bus.js` + * export default () => createBusForProvisioning(AppModule) + */ +export const createBusForProvisioning = async ( + appModule: Type | DynamicModule, + options: CreateBusForProvisioningOptions = {} +): Promise => { + const provisioningModule: DynamicModule = { + module: BusProvisioningModule, + global: true, + providers: [{ provide: BUS_PROVISIONING, useValue: true }], + exports: [BUS_PROVISIONING] + } + // A class of its own for each application, so its module is never confused with another's + class BusProvisioningApplication {} + const app = await NestFactory.createApplicationContext( + { + module: BusProvisioningApplication, + imports: [appModule, provisioningModule] + }, + { logger: options.logger ?? ['error'], abortOnError: false } + ) + return app.get(getBusToken(options.bus), { strict: false }) +} diff --git a/packages/bus-nestjs/src/create-lazy-bus.spec.ts b/packages/bus-nestjs/src/create-lazy-bus.spec.ts new file mode 100644 index 00000000..e6cdcae6 --- /dev/null +++ b/packages/bus-nestjs/src/create-lazy-bus.spec.ts @@ -0,0 +1,110 @@ +import { Bus, BusInstance, BusState } from '@node-ts/bus-core' +import { createLazyBus } from './create-lazy-bus' +import { BusNotBuilt } from './error' + +/** + * Runs `act`, returning what it threw + */ +const thrownBy = (act: () => unknown): unknown => { + try { + act() + } catch (error) { + return error + } + return undefined +} + +describe('createLazyBus', () => { + describe('when the bus is not built', () => { + let sut: BusInstance + let probe: Record + let readingState: unknown + let callingSyncMethod: unknown + let sending: Promise + let sendThrewSynchronously: unknown + let asString: string + + beforeAll(() => { + sut = createLazyBus('billing', () => undefined) + probe = sut as unknown as Record + asString = String(sut) + readingState = thrownBy(() => sut.state) + callingSyncMethod = thrownBy(() => sut.getHandlingContext()) + sendThrewSynchronously = thrownBy(() => { + sending = sut.send({ $name: 'test/command', $version: 0 }) + }) + }) + + afterAll(async () => sending.catch(() => undefined)) + + it('should be a BusInstance', () => { + expect(sut).toBeInstanceOf(BusInstance) + expect(sut.constructor).toBe(BusInstance) + }) + + it('should throw BusNotBuilt, naming the bus and member, when an accessor is read', () => { + expect(readingState).toBeInstanceOf(BusNotBuilt) + expect((readingState as BusNotBuilt).busName).toEqual('billing') + expect((readingState as BusNotBuilt).member).toEqual('state') + }) + + it('should throw BusNotBuilt when a method is called', () => { + expect(callingSyncMethod).toBeInstanceOf(BusNotBuilt) + }) + + it('should reject with BusNotBuilt, rather than throw, when an async method is called', async () => { + expect(sendThrewSynchronously).toBeUndefined() + await expect(sending).rejects.toBeInstanceOf(BusNotBuilt) + }) + + it('should have the members of every object', () => { + expect(asString).toEqual('[object Object]') + expect(probe.toString).toBe(Object.prototype.toString) + }) + + it('should have nothing else that is not a member of a bus, so Nest finds no lifecycle hooks or promise on it', () => { + expect(probe.then).toBeUndefined() + expect(probe.onModuleInit).toBeUndefined() + }) + }) + + describe('when the bus is built after a method is read', () => { + let bus: BusInstance | undefined + let sut: BusInstance + let getHandlingContextReadEarly: BusInstance['getHandlingContext'] + let state: BusState + let canStart: boolean + let handlingContextFromEarlyRead: unknown + let bound: BusInstance['getHandlingContext'] + let boundAgain: BusInstance['getHandlingContext'] + let handlingContextFromBound: unknown + + beforeAll(() => { + sut = createLazyBus('default', () => bus) + getHandlingContextReadEarly = sut.getHandlingContext + bus = Bus.configure().build() + state = sut.state + canStart = sut.canStart + handlingContextFromEarlyRead = getHandlingContextReadEarly() + bound = sut.getHandlingContext + boundAgain = sut.getHandlingContext + handlingContextFromBound = bound() + }) + + afterAll(async () => bus?.dispose()) + + it('should pass members on to the bus', () => { + expect(state).toEqual(BusState.Stopped) + expect(canStart).toEqual(true) + }) + + it('should call the bus from a method read before it was built', () => { + expect(handlingContextFromEarlyRead).toBeUndefined() + }) + + it('should bind methods to the bus, once', () => { + expect(handlingContextFromBound).toBeUndefined() + expect(boundAgain).toBe(bound) + }) + }) +}) diff --git a/packages/bus-nestjs/src/create-lazy-bus.ts b/packages/bus-nestjs/src/create-lazy-bus.ts new file mode 100644 index 00000000..8ebf057e --- /dev/null +++ b/packages/bus-nestjs/src/create-lazy-bus.ts @@ -0,0 +1,84 @@ +import { BusInstance } from '@node-ts/bus-core' +import { BusNotBuilt } from './error' + +const AsyncFunction = Object.getPrototypeOf(async () => undefined) + .constructor as FunctionConstructor + +/** + * Gets a member of every bus: a method or accessor of `BusInstance`, rather than something Nest probes providers + * for, such as `onModuleInit` or `then`, or a member of every object, such as `toString` + */ +const busMember = (member: string | symbol): PropertyDescriptor | undefined => + typeof member === 'string' + ? Object.getOwnPropertyDescriptor(BusInstance.prototype, member) + : undefined + +/** + * Creates the object `BusModule` provides as the bus. A bus can only be built once every module's handlers and + * workflows are known, which is after Nest has created every provider, so providers are given this stand-in, which + * passes everything on to the bus once it's built. It's an `instanceof BusInstance`. + * + * Before the bus is built, reading an accessor such as `state` throws `BusNotBuilt`, and calling a method throws it, + * or rejects with it for an async method such as `send()`. A method read before then calls the bus once it's built. + * @param busName the bus' name, for errors + * @param getBus gets the bus, or `undefined` before it's built + * @returns the stand-in + */ +export const createLazyBus = ( + busName: string, + getBus: () => BusInstance | undefined +): BusInstance => { + const boundMethods = new Map() + + /** + * Calls a method of the bus, or throws `BusNotBuilt` if it isn't built yet + */ + const callBus = (member: string, args: unknown[]): unknown => { + const builtBus = getBus() + if (!builtBus) { + throw new BusNotBuilt(busName, member) + } + const method = Reflect.get(builtBus, member, builtBus) as ( + ...args: unknown[] + ) => unknown + return method.apply(builtBus, args) + } + + return new Proxy(Object.create(BusInstance.prototype) as BusInstance, { + get(target, member) { + if (member === 'constructor') { + return BusInstance + } + const bus = getBus() + if (!bus) { + const descriptor = busMember(member) + if (!descriptor) { + // Object members, such as toString, work. Anything else, such as a lifecycle hook Nest looks for, is undefined. + return Reflect.get(target, member) + } + if (typeof descriptor.value !== 'function') { + throw new BusNotBuilt(busName, String(member)) + } + const name = String(member) + return descriptor.value instanceof AsyncFunction + ? async (...args: unknown[]) => callBus(name, args) + : (...args: unknown[]) => callBus(name, args) + } + + const value: unknown = Reflect.get(bus, member, bus) + if (typeof value !== 'function') { + return value + } + let bound = boundMethods.get(member) + if (!bound) { + bound = (value as (...args: unknown[]) => unknown).bind(bus) + boundMethods.set(member, bound) + } + return bound + }, + has(target, member) { + const bus = getBus() + return bus ? member in bus : member in target + } + }) +} diff --git a/packages/bus-nestjs/src/error/bus-already-registered.ts b/packages/bus-nestjs/src/error/bus-already-registered.ts new file mode 100644 index 00000000..500a1563 --- /dev/null +++ b/packages/bus-nestjs/src/error/bus-already-registered.ts @@ -0,0 +1,17 @@ +/** + * Thrown when an application imports `BusModule.forRoot()` or `BusModule.forRootAsync()` more than once with the + * same bus name + */ +export class BusAlreadyRegistered extends Error { + readonly help: string + + /** + * @param busName the name both register + */ + constructor(readonly busName: string) { + super(`More than one BusModule.forRoot() registers the bus '${busName}'`) + this.help = `Import BusModule.forRoot() once for each bus, in the application's root module, and give each bus its own name, e.g. BusModule.forRoot({ name: 'billing', configure }). The bus module is global, so feature modules don't import it again.` + + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/packages/bus-nestjs/src/error/bus-class-not-provided.ts b/packages/bus-nestjs/src/error/bus-class-not-provided.ts new file mode 100644 index 00000000..1ab16402 --- /dev/null +++ b/packages/bus-nestjs/src/error/bus-class-not-provided.ts @@ -0,0 +1,24 @@ +/** + * Thrown when a class handler or class workflow registered with a bus isn't a provider in Nest's container, so the + * bus can't resolve it + */ +export class BusClassNotProvided extends Error { + readonly help: string + + /** + * @param className the class handler or workflow + * @param cause the error Nest's container threw, if any + */ + constructor( + readonly className: string, + cause?: unknown + ) { + super( + `${className} is registered with the bus, but isn't a provider in the Nest application`, + { cause } + ) + this.help = `Add ${className} to the providers of a module in the application, such as the module that registers it with BusModule.forFeature(). Registering a class with the bus doesn't make it a provider.` + + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/packages/bus-nestjs/src/error/bus-core-version-not-supported.ts b/packages/bus-nestjs/src/error/bus-core-version-not-supported.ts new file mode 100644 index 00000000..30029789 --- /dev/null +++ b/packages/bus-nestjs/src/error/bus-core-version-not-supported.ts @@ -0,0 +1,18 @@ +/** + * Thrown when the installed `@node-ts/bus-core` is older than `@node-ts/bus-nestjs` needs + */ +export class BusCoreVersionNotSupported extends Error { + readonly help: string + + /** + * @param missing what the installed `@node-ts/bus-core` doesn't have, such as `BusInstance.canStart` + */ + constructor(readonly missing: string) { + super( + `The installed @node-ts/bus-core has no ${missing}, which @node-ts/bus-nestjs needs` + ) + this.help = `Upgrade @node-ts/bus-core to the version @node-ts/bus-nestjs' peer dependency asks for, and make sure the application has only one copy of it.` + + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/packages/bus-nestjs/src/error/bus-feature-not-static.ts b/packages/bus-nestjs/src/error/bus-feature-not-static.ts new file mode 100644 index 00000000..03c841c1 --- /dev/null +++ b/packages/bus-nestjs/src/error/bus-feature-not-static.ts @@ -0,0 +1,19 @@ +/** + * Thrown when the factory given to `BusModule.forFeatureAsync()` injects a request-scoped or transient provider. + * The factory runs once, when the application starts, so it can only inject singletons. + */ +export class BusFeatureNotStatic extends Error { + readonly help: string + + /** + * @param busName the name of the bus the feature registers handlers or workflows with + */ + constructor(readonly busName: string) { + super( + `A BusModule.forFeatureAsync() for the bus '${busName}' injects a request-scoped or transient provider, so its handlers and workflows can't be registered when the application starts` + ) + this.help = `Inject only singleton providers into forFeatureAsync()'s factory. To use a request-scoped provider, inject it into a class handler or workflow, which the bus resolves for each message.` + + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/packages/bus-nestjs/src/error/bus-not-built.ts b/packages/bus-nestjs/src/error/bus-not-built.ts new file mode 100644 index 00000000..09a8d857 --- /dev/null +++ b/packages/bus-nestjs/src/error/bus-not-built.ts @@ -0,0 +1,22 @@ +/** + * Thrown when a bus `BusModule` registers is used before it's built. `BusModule` builds the bus when the + * application initializes (`onModuleInit`), once every module's handlers and workflows are known, so the bus can be + * injected into a constructor but not used there. + */ +export class BusNotBuilt extends Error { + readonly help: string + + /** + * @param busName the bus' name, as given to `BusModule.forRoot({ name })` + * @param member the member of the bus that was used, such as `send` + */ + constructor( + readonly busName: string, + readonly member: string + ) { + super(`The bus '${busName}' was used (${member}) before BusModule built it`) + this.help = `BusModule builds the bus in its onModuleInit(), which may run after the onModuleInit() of other global modules. Use the bus from onApplicationBootstrap() or later, such as from a request handler, rather than from a constructor, a factory provider or onModuleInit().` + + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/packages/bus-nestjs/src/error/bus-not-registered.ts b/packages/bus-nestjs/src/error/bus-not-registered.ts new file mode 100644 index 00000000..d1112084 --- /dev/null +++ b/packages/bus-nestjs/src/error/bus-not-registered.ts @@ -0,0 +1,23 @@ +/** + * Thrown when a handler or workflow is registered with a bus that no `BusModule.forRoot()` or + * `BusModule.forRootAsync()` in the application registers + */ +export class BusNotRegistered extends Error { + readonly help: string + + /** + * @param busName the name of the bus the handlers or workflows are registered with + * @param registeredBy what registered them, such as `ChargeCreditCardHandler` or `BusModule.forFeature()` + */ + constructor( + readonly busName: string, + readonly registeredBy: string[] + ) { + super( + `${registeredBy.join(', ')} register${registeredBy.length === 1 ? 's' : ''} handlers or workflows with the bus '${busName}', but no BusModule.forRoot() registers a bus with that name` + ) + this.help = `Import BusModule.forRoot({ name: '${busName}', configure }) in the application's root module, or register the handlers and workflows with a bus it has.` + + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/packages/bus-nestjs/src/error/index.ts b/packages/bus-nestjs/src/error/index.ts new file mode 100644 index 00000000..03a64483 --- /dev/null +++ b/packages/bus-nestjs/src/error/index.ts @@ -0,0 +1,7 @@ +export * from './bus-already-registered' +export * from './bus-class-not-provided' +export * from './bus-core-version-not-supported' +export * from './bus-feature-not-static' +export * from './bus-not-built' +export * from './bus-not-registered' +export * from './workflow-resolved-without-message' diff --git a/packages/bus-nestjs/src/error/workflow-resolved-without-message.ts b/packages/bus-nestjs/src/error/workflow-resolved-without-message.ts new file mode 100644 index 00000000..0d82bd9c --- /dev/null +++ b/packages/bus-nestjs/src/error/workflow-resolved-without-message.ts @@ -0,0 +1,25 @@ +/** + * Thrown when a request-scoped class workflow, or a class workflow that depends on a request-scoped provider, can't + * be created without a message. The bus creates each class workflow once when it initializes, to read its + * `configureWorkflow()`, and there's no message then, so Nest's `REQUEST` is `undefined`. + */ +export class WorkflowResolvedWithoutMessage extends Error { + readonly help: string + + /** + * @param className the class workflow + * @param cause the error creating it threw + */ + constructor( + readonly className: string, + cause?: unknown + ) { + super( + `${className} couldn't be created without a message, which the bus does once when it initializes to read its configureWorkflow()`, + { cause } + ) + this.help = `Don't read REQUEST in the constructors of ${className} or the request-scoped providers it depends on: keep the request, and read its message and attributes when a message is handled, e.g. in a getter or the handler method.` + + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/packages/bus-nestjs/src/get-bus-token.ts b/packages/bus-nestjs/src/get-bus-token.ts new file mode 100644 index 00000000..b543ca67 --- /dev/null +++ b/packages/bus-nestjs/src/get-bus-token.ts @@ -0,0 +1,21 @@ +import { BusInstance } from '@node-ts/bus-core' + +/** + * The name of the bus `BusModule.forRoot()` registers when it isn't given one + */ +export const DEFAULT_BUS_NAME = 'default' + +/** + * Gets the token a bus registered with `BusModule.forRoot()` is injected by. The default bus is injected by the + * `BusInstance` class, so a constructor parameter typed `BusInstance` gets it without `@Inject()`. A named bus is + * injected by a string token, usually through `@InjectBus(name)`. + * @param name the bus' name, as given to `BusModule.forRoot({ name })` + * @default 'default' + * @returns the injection token + * @example + * const bus = app.get(getBusToken('billing')) + */ +export const getBusToken = ( + name: string = DEFAULT_BUS_NAME +): typeof BusInstance | string => + name === DEFAULT_BUS_NAME ? BusInstance : `@node-ts/bus-nestjs:bus:${name}` diff --git a/packages/bus-nestjs/src/index.mts b/packages/bus-nestjs/src/index.mts new file mode 100644 index 00000000..e2c8de2d --- /dev/null +++ b/packages/bus-nestjs/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-nestjs/src/index.ts b/packages/bus-nestjs/src/index.ts new file mode 100644 index 00000000..9ff0f642 --- /dev/null +++ b/packages/bus-nestjs/src/index.ts @@ -0,0 +1,14 @@ +export * from './bus-feature' +export * from './bus-feature-options' +export * from './bus-handler' +export * from './bus-module' +export * from './bus-module-options' +export * from './bus-registration-options' +export * from './bus-request' +export * from './bus-workflow' +export * from './create-bus-for-provisioning' +export * from './error' +export * from './get-bus-token' +export * from './inject-bus' +export * from './nest-container' +export * from './nest-logger-factory' diff --git a/packages/bus-nestjs/src/inject-bus.ts b/packages/bus-nestjs/src/inject-bus.ts new file mode 100644 index 00000000..2339aded --- /dev/null +++ b/packages/bus-nestjs/src/inject-bus.ts @@ -0,0 +1,15 @@ +import { Inject } from '@nestjs/common' +import { getBusToken } from './get-bus-token' + +/** + * Injects a bus registered with `BusModule.forRoot()`. Only needed for a named bus: the default one is injected by + * a parameter typed `BusInstance`. + * @param name the bus' name, as given to `BusModule.forRoot({ name })` + * @default 'default' + * @example + * class InvoiceService { + * constructor(@InjectBus('billing') private readonly billingBus: BusInstance) {} + * } + */ +export const InjectBus = (name?: string): ReturnType => + Inject(getBusToken(name)) diff --git a/packages/bus-nestjs/src/nest-container.spec.ts b/packages/bus-nestjs/src/nest-container.spec.ts new file mode 100644 index 00000000..6854c475 --- /dev/null +++ b/packages/bus-nestjs/src/nest-container.spec.ts @@ -0,0 +1,167 @@ +import { Scope } from '@nestjs/common' +import { ContextId, ModuleRef } from '@nestjs/core' +import { ContainerAdapter } from '@node-ts/bus-core' +import { MessageAttributes } from '@node-ts/bus-messages' +import { Mock } from 'typemoq' +import { BusRequest } from './bus-request' +import { BusClassNotProvided, WorkflowResolvedWithoutMessage } from './error' +import { nestContainer } from './nest-container' +import { OrderPlaced } from './test/order-placed' + +class SingletonHandler {} +class ScopedHandler {} +class MissingHandler {} + +/** + * The part of a `ModuleRef` the adapter uses, recording how it's called + */ +class FakeModuleRef { + readonly singleton = new SingletonHandler() + readonly resolvedIn: ContextId[] = [] + readonly requests: BusRequest[] = [] + + /** + * @param failToResolveWith an error to reject with when resolving a request-scoped provider + */ + constructor(private readonly failToResolveWith?: Error) {} + + introspect(type: unknown): { scope: Scope } { + if (type === SingletonHandler) { + return { scope: Scope.DEFAULT } + } + if (type === ScopedHandler) { + return { scope: Scope.REQUEST } + } + throw new Error('Nest could not find MissingHandler element') + } + + get(type: unknown): unknown { + return type === SingletonHandler ? this.singleton : undefined + } + + async resolve(_type: unknown, contextId: ContextId): Promise { + this.resolvedIn.push(contextId) + if (this.failToResolveWith) { + throw this.failToResolveWith + } + return new ScopedHandler() + } + + registerRequestByContextId(request: BusRequest): void { + this.requests.push(request) + } +} + +describe('nestContainer', () => { + describe('when a singleton is resolved', () => { + const moduleRef = new FakeModuleRef() + let resolved: unknown + + beforeAll(async () => { + const sut = nestContainer(moduleRef as unknown as ModuleRef) + resolved = await sut.get(SingletonHandler, { + message: new OrderPlaced('order-1') + }) + }) + + it('should get it from the container without a request scope', () => { + expect(resolved).toBe(moduleRef.singleton) + expect(moduleRef.resolvedIn).toEqual([]) + expect(moduleRef.requests).toEqual([]) + }) + }) + + describe('when a request-scoped provider is resolved for the handlers of messages', () => { + const moduleRef = new FakeModuleRef() + const message = new OrderPlaced('order-1') + const attributes = Mock.ofType().object + + beforeAll(async () => { + const sut: ContainerAdapter = nestContainer( + moduleRef as unknown as ModuleRef + ) + await sut.get(ScopedHandler, { message, messageAttributes: attributes }) + await sut.get(ScopedHandler, { message, messageAttributes: attributes }) + await sut.get(ScopedHandler, { message: new OrderPlaced('order-2') }) + await sut.get(ScopedHandler) + }) + + it('should resolve the handlers of one message in one request scope', () => { + const [first, second, third, fourth] = moduleRef.resolvedIn + expect(first).toBe(second) + expect(third).not.toBe(first) + expect(fourth).not.toBe(first) + expect(fourth).not.toBe(third) + }) + + it('should register each message and its attributes as the request once', () => { + expect(moduleRef.requests).toHaveLength(2) + expect(moduleRef.requests[0]).toEqual({ message, attributes }) + }) + }) + + describe('when a request-scoped class fails to resolve without a message', () => { + const cause = new TypeError( + "Cannot read properties of undefined (reading 'message')" + ) + let error: unknown + + beforeAll(async () => { + const sut = nestContainer( + new FakeModuleRef(cause) as unknown as ModuleRef + ) + try { + await sut.get(ScopedHandler) + } catch (e) { + error = e + } + }) + + it('should throw WorkflowResolvedWithoutMessage, naming the class, with the error as its cause', () => { + expect(error).toBeInstanceOf(WorkflowResolvedWithoutMessage) + expect((error as WorkflowResolvedWithoutMessage).className).toEqual( + 'ScopedHandler' + ) + expect((error as WorkflowResolvedWithoutMessage).cause).toBe(cause) + }) + }) + + describe('when a request-scoped class fails to resolve for a message', () => { + const cause = new Error('Database unavailable') + let error: unknown + + beforeAll(async () => { + const sut = nestContainer( + new FakeModuleRef(cause) as unknown as ModuleRef + ) + try { + await sut.get(ScopedHandler, { message: new OrderPlaced('order-1') }) + } catch (e) { + error = e + } + }) + + it('should rethrow the error as it is', () => { + expect(error).toBe(cause) + }) + }) + + describe('when a class is not a provider', () => { + let error: unknown + + beforeAll(async () => { + const sut = nestContainer(new FakeModuleRef() as unknown as ModuleRef) + try { + await sut.get(MissingHandler) + } catch (e) { + error = e + } + }) + + it('should throw BusClassNotProvided, naming the class', () => { + expect(error).toBeInstanceOf(BusClassNotProvided) + expect((error as BusClassNotProvided).className).toEqual('MissingHandler') + expect((error as BusClassNotProvided).cause).toBeInstanceOf(Error) + }) + }) +}) diff --git a/packages/bus-nestjs/src/nest-container.ts b/packages/bus-nestjs/src/nest-container.ts new file mode 100644 index 00000000..cca47e79 --- /dev/null +++ b/packages/bus-nestjs/src/nest-container.ts @@ -0,0 +1,77 @@ +import { Scope } from '@nestjs/common' +import { ContextId, ContextIdFactory, ModuleRef } from '@nestjs/core' +import { ClassConstructor, ContainerAdapter } from '@node-ts/bus-core' +import { Message, MessageAttributes } from '@node-ts/bus-messages' +import { BusRequest } from './bus-request' +import { BusClassNotProvided, WorkflowResolvedWithoutMessage } from './error' + +/** + * Creates a `ContainerAdapter` that resolves class handlers and workflows from Nest's container. `BusModule` + * configures each bus with one; use it directly to build a bus inside a Nest application without `BusModule`. + * + * Singleton providers are resolved with `moduleRef.get()`. Request-scoped and transient providers, and providers + * that depend on them, are resolved with `moduleRef.resolve()` in a request scope of their own for each received + * message, shared by every handler and workflow that handles it, with the message and its attributes registered + * as Nest's `REQUEST` (see `BusRequest`). + * @param moduleRef a `ModuleRef` from the application, which finds providers in any of its modules + * @returns the adapter, for `withContainer()` + * @throws BusClassNotProvided from `get`, when the class isn't a provider in the application + * @throws WorkflowResolvedWithoutMessage from `get`, when a request-scoped class workflow can't be created without a + * message + * @example + * Bus.configure().withContainer(nestContainer(app.get(ModuleRef))) + */ +export const nestContainer = (moduleRef: ModuleRef): ContainerAdapter => { + // Keyed by the message object, so the handlers and workflows of one message share a scope. Received messages + // are dropped once handled, which drops their scopes with them. + const scopes = new WeakMap() + + const scopeFor = ( + message: Message | undefined, + attributes: MessageAttributes | undefined + ): ContextId => { + if (typeof message !== 'object' || message === null) { + return ContextIdFactory.create() + } + let contextId = scopes.get(message) + if (!contextId) { + contextId = ContextIdFactory.create() + scopes.set(message, contextId) + moduleRef.registerRequestByContextId( + { message, attributes }, + contextId + ) + } + return contextId + } + + return { + get( + type: ClassConstructor, + context?: { message?: Message; messageAttributes?: MessageAttributes } + ): T | Promise { + let scope: Scope | undefined + try { + scope = moduleRef.introspect(type).scope + } catch (error) { + throw new BusClassNotProvided(type.name, error) + } + if (scope === Scope.DEFAULT) { + return moduleRef.get(type, { strict: false }) + } + const message = context?.message + const resolved = moduleRef.resolve( + type, + scopeFor(message, context?.messageAttributes), + { strict: false } + ) + if (message !== undefined) { + return resolved + } + // Only class workflows are resolved without a message, when the bus reads their configureWorkflow() + return resolved.catch((error: unknown) => { + throw new WorkflowResolvedWithoutMessage(type.name, error) + }) + } + } +} diff --git a/packages/bus-nestjs/src/nest-logger-factory.spec.ts b/packages/bus-nestjs/src/nest-logger-factory.spec.ts new file mode 100644 index 00000000..810311a5 --- /dev/null +++ b/packages/bus-nestjs/src/nest-logger-factory.spec.ts @@ -0,0 +1,30 @@ +import { Logger } from '@nestjs/common' +import { nestLoggerFactory } from './nest-logger-factory' +import { RecordingLogger } from './test/recording-logger' + +describe('nestLoggerFactory', () => { + const nestLogger = new RecordingLogger() + const target = '@node-ts/bus-core:service-bus' + + beforeAll(() => { + Logger.overrideLogger(nestLogger) + const sut = nestLoggerFactory(target) + sut.debug('Debug message', { id: 1 }) + sut.trace('Trace message') + sut.info('Info message', { id: 2 }) + sut.warn('Warn message') + sut.error('Error message', { id: 3 }) + sut.fatal('Fatal message') + }) + + it('should write each level to Nest, with the component as the context', () => { + expect(nestLogger.calls).toEqual([ + ['debug', 'Debug message', { id: 1 }, target], + ['verbose', 'Trace message', target], + ['log', 'Info message', { id: 2 }, target], + ['warn', 'Warn message', target], + ['error', 'Error message', { id: 3 }, target], + ['fatal', 'Fatal message', target] + ]) + }) +}) diff --git a/packages/bus-nestjs/src/nest-logger-factory.ts b/packages/bus-nestjs/src/nest-logger-factory.ts new file mode 100644 index 00000000..22104ffc --- /dev/null +++ b/packages/bus-nestjs/src/nest-logger-factory.ts @@ -0,0 +1,29 @@ +import { Logger as NestLogger } from '@nestjs/common' +import { Logger, LoggerFactory } from '@node-ts/bus-core' + +/** + * Passes the context object to Nest's logger only when there is one, so it doesn't print `undefined` + */ +const withMeta = (meta: object | undefined): object[] => + meta === undefined ? [] : [meta] + +/** + * A logger factory that writes the bus' logs with Nest's `Logger`, with the bus component as the context, so they + * follow the application's logger and log levels. `BusModule` configures every bus with it, unless `configure` + * calls `withLogger()`. The bus' `trace` logs are Nest's `verbose`, and its `info` logs are Nest's `log`. + * @param target the bus component, such as `@node-ts/bus-core:service-bus` + * @returns a logger for the component + * @example + * Bus.configure().withLogger(nestLoggerFactory) + */ +export const nestLoggerFactory: LoggerFactory = (target: string): Logger => { + const logger = new NestLogger(target) + return { + debug: (message, meta) => logger.debug(message, ...withMeta(meta)), + trace: (message, meta) => logger.verbose(message, ...withMeta(meta)), + info: (message, meta) => logger.log(message, ...withMeta(meta)), + warn: (message, meta) => logger.warn(message, ...withMeta(meta)), + error: (message, meta) => logger.error(message, ...withMeta(meta)), + fatal: (message, meta) => logger.fatal(message, ...withMeta(meta)) + } +} diff --git a/packages/bus-nestjs/src/test/charge-credit-card.ts b/packages/bus-nestjs/src/test/charge-credit-card.ts new file mode 100644 index 00000000..9d61c8da --- /dev/null +++ b/packages/bus-nestjs/src/test/charge-credit-card.ts @@ -0,0 +1,21 @@ +import { Command } from '@node-ts/bus-messages' + +/** + * A command handled by a class handler that's a Nest provider + */ +export class ChargeCreditCard extends Command { + static NAME = '@node-ts/bus-nestjs/charge-credit-card' + $name = ChargeCreditCard.NAME + $version = 0 + + /** + * @param orderId the order to charge for + * @param amount how much to charge + */ + constructor( + readonly orderId: string, + readonly amount: number + ) { + super() + } +} diff --git a/packages/bus-nestjs/src/test/fulfilment-state.ts b/packages/bus-nestjs/src/test/fulfilment-state.ts new file mode 100644 index 00000000..b84ab503 --- /dev/null +++ b/packages/bus-nestjs/src/test/fulfilment-state.ts @@ -0,0 +1,11 @@ +import { WorkflowState } from '@node-ts/bus-core' + +/** + * The state of `FulfilmentWorkflow`, a class workflow + */ +export class FulfilmentState extends WorkflowState { + static NAME = '@node-ts/bus-nestjs/fulfilment-state' + $name = FulfilmentState.NAME + + orderId: string +} diff --git a/packages/bus-nestjs/src/test/handlers.ts b/packages/bus-nestjs/src/test/handlers.ts new file mode 100644 index 00000000..3f3ee98e --- /dev/null +++ b/packages/bus-nestjs/src/test/handlers.ts @@ -0,0 +1,129 @@ +import { Inject, Injectable, Scope } from '@nestjs/common' +import { REQUEST } from '@nestjs/core' +import { Handler, handlerFor } from '@node-ts/bus-core' +import { BusHandler } from '../bus-handler' +import { BusRequest } from '../bus-request' +import { ChargeCreditCard } from './charge-credit-card' +import { OrderPlaced } from './order-placed' +import { Recorder } from './recorder' + +// The tests compile without experimentalDecorators, so decorators are applied by calling them + +/** + * A class handler found by `@BusHandler()` + */ +export class ChargeCreditCardHandler implements Handler { + constructor(private readonly recorder: Recorder) {} + + get messageType() { + return ChargeCreditCard + } + + async handle(command: ChargeCreditCard): Promise { + this.recorder.record({ by: ChargeCreditCardHandler.name, message: command }) + } +} +Injectable()(ChargeCreditCardHandler) +Inject(Recorder)(ChargeCreditCardHandler, undefined, 0) +BusHandler()(ChargeCreditCardHandler) + +/** + * A class handler registered with `BusModule.forFeature()` + */ +export class OrderPlacedHandler implements Handler { + constructor(private readonly recorder: Recorder) {} + + get messageType() { + return OrderPlaced + } + + async handle(event: OrderPlaced): Promise { + this.recorder.record({ by: OrderPlacedHandler.name, message: event }) + } +} +Injectable()(OrderPlacedHandler) +Inject(Recorder)(OrderPlacedHandler, undefined, 0) + +/** + * A function handler registered with `BusModule.forFeatureAsync()`, which closes over the recorder it's given + */ +export const orderPlacedAuditHandler = (recorder: Recorder) => + handlerFor(OrderPlaced, async event => + recorder.record({ by: 'orderPlacedAuditHandler', message: event }) + ) + +/** + * A request-scoped provider, which gets the message being handled from Nest's `REQUEST` + */ +export class MessageScope { + constructor(readonly request: BusRequest) {} +} +Injectable({ scope: Scope.REQUEST })(MessageScope) +Inject(REQUEST)(MessageScope, undefined, 0) + +/** + * Two class handlers of the same event that depend on the request-scoped `MessageScope` + */ +export class FirstScopedHandler implements Handler { + constructor( + private readonly recorder: Recorder, + private readonly scope: MessageScope + ) {} + + get messageType() { + return OrderPlaced + } + + async handle(event: OrderPlaced): Promise { + this.recorder.record({ + by: FirstScopedHandler.name, + message: event, + detail: this.scope + }) + } +} +Injectable()(FirstScopedHandler) +Inject(Recorder)(FirstScopedHandler, undefined, 0) +Inject(MessageScope)(FirstScopedHandler, undefined, 1) +BusHandler()(FirstScopedHandler) + +export class SecondScopedHandler implements Handler { + constructor( + private readonly recorder: Recorder, + private readonly scope: MessageScope + ) {} + + get messageType() { + return OrderPlaced + } + + async handle(event: OrderPlaced): Promise { + this.recorder.record({ + by: SecondScopedHandler.name, + message: event, + detail: this.scope + }) + } +} +Injectable()(SecondScopedHandler) +Inject(Recorder)(SecondScopedHandler, undefined, 0) +Inject(MessageScope)(SecondScopedHandler, undefined, 1) +BusHandler()(SecondScopedHandler) + +/** + * A class handler of a named bus + */ +export class BillingHandler implements Handler { + constructor(private readonly recorder: Recorder) {} + + get messageType() { + return ChargeCreditCard + } + + async handle(command: ChargeCreditCard): Promise { + this.recorder.record({ by: BillingHandler.name, message: command }) + } +} +Injectable()(BillingHandler) +Inject(Recorder)(BillingHandler, undefined, 0) +BusHandler({ bus: 'billing' })(BillingHandler) diff --git a/packages/bus-nestjs/src/test/index.ts b/packages/bus-nestjs/src/test/index.ts new file mode 100644 index 00000000..440e60af --- /dev/null +++ b/packages/bus-nestjs/src/test/index.ts @@ -0,0 +1,12 @@ +export * from './charge-credit-card' +export * from './fulfilment-state' +export * from './handlers' +export * from './message-types.generated' +export * from './order-placed' +export * from './provisioned-queue' +export * from './recorder' +export * from './recorder-module' +export * from './recording-logger' +export * from './shipping-state' +export * from './test-logger' +export * from './workflows' diff --git a/packages/bus-nestjs/src/test/message-types.generated.ts b/packages/bus-nestjs/src/test/message-types.generated.ts new file mode 100644 index 00000000..4ee03acd --- /dev/null +++ b/packages/bus-nestjs/src/test/message-types.generated.ts @@ -0,0 +1,39 @@ +// 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 { ChargeCreditCard } from './charge-credit-card.js' +import { FulfilmentState } from './fulfilment-state.js' +import { OrderPlaced } from './order-placed.js' +import { ShippingState } from './shipping-state.js' + +// Pass to the bus with Bus.configure().withMessageTypes(messageTypes) +export const messageTypes: MessageTypes = { + source: '@node-ts/bus-nestjs/src/test/message-types.generated', + messages: { + '@node-ts/bus-nestjs/charge-credit-card': + '@node-ts/bus-nestjs/src/test/charge-credit-card#ChargeCreditCard', + '@node-ts/bus-nestjs/fulfilment-state': + '@node-ts/bus-nestjs/src/test/fulfilment-state#FulfilmentState', + '@node-ts/bus-nestjs/order-placed': + '@node-ts/bus-nestjs/src/test/order-placed#OrderPlaced', + '@node-ts/bus-nestjs/shipping-state': + '@node-ts/bus-nestjs/src/test/shipping-state#ShippingState' + }, + types: { + '@node-ts/bus-nestjs/src/test/charge-credit-card#ChargeCreditCard': { + class: ChargeCreditCard, + fields: {} + }, + '@node-ts/bus-nestjs/src/test/fulfilment-state#FulfilmentState': { + class: FulfilmentState, + fields: {} + }, + '@node-ts/bus-nestjs/src/test/order-placed#OrderPlaced': { + class: OrderPlaced, + fields: {} + }, + '@node-ts/bus-nestjs/src/test/shipping-state#ShippingState': { + class: ShippingState, + fields: {} + } + } +} diff --git a/packages/bus-nestjs/src/test/order-placed.ts b/packages/bus-nestjs/src/test/order-placed.ts new file mode 100644 index 00000000..c6f41c28 --- /dev/null +++ b/packages/bus-nestjs/src/test/order-placed.ts @@ -0,0 +1,17 @@ +import { Event } from '@node-ts/bus-messages' + +/** + * An event handled by function and class handlers, and that starts the test workflows + */ +export class OrderPlaced extends Event { + static NAME = '@node-ts/bus-nestjs/order-placed' + $name = OrderPlaced.NAME + $version = 0 + + /** + * @param orderId the order that was placed + */ + constructor(readonly orderId: string) { + super() + } +} diff --git a/packages/bus-nestjs/src/test/provisioned-queue.ts b/packages/bus-nestjs/src/test/provisioned-queue.ts new file mode 100644 index 00000000..b060f9dc --- /dev/null +++ b/packages/bus-nestjs/src/test/provisioned-queue.ts @@ -0,0 +1,52 @@ +import { + InMemoryQueue, + ProvisioningPlan, + TransportInitializationOptions, + TransportProvisionOptions +} from '@node-ts/bus-core' + +/** + * An in-memory queue that plans a topic for each message, and records what the bus does with it + */ +export class ProvisionedQueue extends InMemoryQueue { + /** + * @param calls where to record the calls, so a test can see them in order with what else it records + * @param failToInitializeWith an error to throw from `initialize()` + */ + constructor( + readonly calls: string[] = [], + private readonly failToInitializeWith?: Error + ) { + super() + } + + async provision( + options: TransportProvisionOptions + ): Promise { + this.calls.push('provision') + return { + adapter: 'ProvisionedQueue', + resources: options.messageNames.map(name => ({ type: 'topic', name })) + } + } + + async initialize(options?: TransportInitializationOptions): Promise { + if (this.failToInitializeWith) { + throw this.failToInitializeWith + } + await super.initialize(options) + } + + async start(): Promise { + this.calls.push('start') + } + + async stop(): Promise { + this.calls.push('stop') + } + + async dispose(): Promise { + this.calls.push('dispose') + await super.dispose() + } +} diff --git a/packages/bus-nestjs/src/test/recorder-module.ts b/packages/bus-nestjs/src/test/recorder-module.ts new file mode 100644 index 00000000..4daad9fb --- /dev/null +++ b/packages/bus-nestjs/src/test/recorder-module.ts @@ -0,0 +1,14 @@ +import { DynamicModule } from '@nestjs/common' +import { Recorder } from './recorder' + +class RecorderHostModule {} + +/** + * A global module that provides a `Recorder`, created for each test application + */ +export const recorderModule = (): DynamicModule => ({ + module: RecorderHostModule, + global: true, + providers: [Recorder], + exports: [Recorder] +}) diff --git a/packages/bus-nestjs/src/test/recorder.ts b/packages/bus-nestjs/src/test/recorder.ts new file mode 100644 index 00000000..6ce71f0f --- /dev/null +++ b/packages/bus-nestjs/src/test/recorder.ts @@ -0,0 +1,49 @@ +import { EventEmitter } from 'node:events' + +/** + * What a test handler recorded + */ +export interface Recording { + /** + * The handler or workflow that recorded it + */ + by: string + /** + * The message it handled + */ + message: object + /** + * Anything else it recorded, such as the request-scoped provider it was given + */ + detail?: unknown +} + +/** + * A provider the test handlers record what they handle with, so a test can wait for and check them + */ +export class Recorder { + readonly recordings: Recording[] = [] + private readonly events = new EventEmitter() + + record(recording: Recording): void { + this.recordings.push(recording) + this.events.emit('recorded') + } + + /** + * Waits until at least `count` recordings were made + */ + async waitFor(count: number): Promise { + while (this.recordings.length < count) { + await new Promise(resolve => this.events.once('recorded', resolve)) + } + return this.recordings + } + + /** + * The recordings made by one handler or workflow + */ + by(name: string): Recording[] { + return this.recordings.filter(recording => recording.by === name) + } +} diff --git a/packages/bus-nestjs/src/test/recording-logger.ts b/packages/bus-nestjs/src/test/recording-logger.ts new file mode 100644 index 00000000..552163d6 --- /dev/null +++ b/packages/bus-nestjs/src/test/recording-logger.ts @@ -0,0 +1,32 @@ +import { LoggerService } from '@nestjs/common' + +/** + * A Nest logger that records each call, with its level first + */ +export class RecordingLogger implements LoggerService { + readonly calls: unknown[][] = [] + + log(...args: unknown[]): void { + this.calls.push(['log', ...args]) + } + + error(...args: unknown[]): void { + this.calls.push(['error', ...args]) + } + + warn(...args: unknown[]): void { + this.calls.push(['warn', ...args]) + } + + debug(...args: unknown[]): void { + this.calls.push(['debug', ...args]) + } + + verbose(...args: unknown[]): void { + this.calls.push(['verbose', ...args]) + } + + fatal(...args: unknown[]): void { + this.calls.push(['fatal', ...args]) + } +} diff --git a/packages/bus-nestjs/src/test/shipping-state.ts b/packages/bus-nestjs/src/test/shipping-state.ts new file mode 100644 index 00000000..ddebb01b --- /dev/null +++ b/packages/bus-nestjs/src/test/shipping-state.ts @@ -0,0 +1,11 @@ +import { WorkflowState } from '@node-ts/bus-core' + +/** + * The state of `shippingWorkflow`, a function workflow + */ +export class ShippingState extends WorkflowState { + static NAME = '@node-ts/bus-nestjs/shipping-state' + $name = ShippingState.NAME + + orderId: string +} diff --git a/packages/bus-nestjs/src/test/test-logger.ts b/packages/bus-nestjs/src/test/test-logger.ts new file mode 100644 index 00000000..bcf7e478 --- /dev/null +++ b/packages/bus-nestjs/src/test/test-logger.ts @@ -0,0 +1,19 @@ +import { LoggerService } from '@nestjs/common' + +/** + * The logger of the tests' Nest applications, which the bus also logs through. It drops everything, unless + * BUS_TEST_LOGS=true, to see the logs when debugging a failing test. + */ +export const testLogger = (): LoggerService | false => + process.env.BUS_TEST_LOGS === 'true' ? false : SILENT_LOGGER + +const ignore = (): void => undefined + +const SILENT_LOGGER: LoggerService = { + log: ignore, + error: ignore, + warn: ignore, + debug: ignore, + verbose: ignore, + fatal: ignore +} diff --git a/packages/bus-nestjs/src/test/workflows.ts b/packages/bus-nestjs/src/test/workflows.ts new file mode 100644 index 00000000..fba661c3 --- /dev/null +++ b/packages/bus-nestjs/src/test/workflows.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable, Scope } from '@nestjs/common' +import { REQUEST } from '@nestjs/core' +import { defineWorkflow, Workflow, WorkflowMapper } from '@node-ts/bus-core' +import { BusRequest } from '../bus-request' +import { BusWorkflow } from '../bus-workflow' +import { FulfilmentState } from './fulfilment-state' +import { OrderPlaced } from './order-placed' +import { Recorder } from './recorder' +import { ShippingState } from './shipping-state' + +/** + * A class workflow found by `@BusWorkflow()`, which records with a provider it's given + */ +export class FulfilmentWorkflow extends Workflow { + constructor(private readonly recorder: Recorder) { + super() + } + + configureWorkflow( + mapper: WorkflowMapper + ): void { + mapper.withState(FulfilmentState).startedBy(OrderPlaced, 'start') + } + + async start(event: OrderPlaced): Promise> { + this.recorder.record({ by: FulfilmentWorkflow.name, message: event }) + return { orderId: event.orderId } + } +} +Injectable()(FulfilmentWorkflow) +Inject(Recorder)(FulfilmentWorkflow, undefined, 0) +BusWorkflow()(FulfilmentWorkflow) + +/** + * A function workflow registered with `BusModule.forFeatureAsync()`, which closes over the recorder it's given + */ +export const shippingWorkflow = (recorder: Recorder) => + defineWorkflow(ShippingState).startedBy(OrderPlaced, async event => { + recorder.record({ by: 'shippingWorkflow', message: event }) + return { orderId: event.orderId } + }) + +/** + * A request-scoped class workflow that reads the message from `REQUEST` in its constructor, which fails when the + * bus creates it without a message to read its `configureWorkflow()` + */ +export class RequestScopedWorkflow extends Workflow { + readonly orderId: string + + constructor(request: BusRequest) { + super() + this.orderId = (request.message as OrderPlaced).orderId + } + + configureWorkflow( + mapper: WorkflowMapper + ): void { + mapper.withState(FulfilmentState).startedBy(OrderPlaced, 'start') + } + + async start(): Promise> { + return { orderId: this.orderId } + } +} +Injectable({ scope: Scope.REQUEST })(RequestScopedWorkflow) +Inject(REQUEST)(RequestScopedWorkflow, undefined, 0) diff --git a/packages/bus-nestjs/tsconfig.json b/packages/bus-nestjs/tsconfig.json new file mode 100644 index 00000000..55d02cd4 --- /dev/null +++ b/packages/bus-nestjs/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 0fb9b42b..d3817547 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,6 +235,15 @@ importers: docs: devDependencies: + '@nestjs/common': + specifier: ^12.1.2 + version: 12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@11.0.0) + '@nestjs/core': + 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))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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-cli': specifier: workspace:^ version: link:../packages/bus-cli @@ -247,6 +256,9 @@ importers: '@node-ts/bus-mongodb': specifier: workspace:^ version: link:../packages/bus-mongodb + '@node-ts/bus-nestjs': + specifier: workspace:^ + version: link:../packages/bus-nestjs '@node-ts/bus-opentelemetry': specifier: workspace:^ version: link:../packages/bus-opentelemetry @@ -280,6 +292,12 @@ importers: mongodb: specifier: ^7.7.0 version: 7.7.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 typedoc: specifier: ^0.28.20 version: 0.28.20(typescript@6.0.3) @@ -429,6 +447,46 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/bus-nestjs: + dependencies: + '@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: + '@nestjs/common': + specifier: ^12.1.2 + version: 12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@11.0.0) + '@nestjs/core': + 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))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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-cli': + specifier: workspace:^ + version: link:../bus-cli + '@node-ts/bus-core': + specifier: workspace:^ + version: link:../bus-core + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + typemoq: + specifier: ^2.1.0 + version: 2.1.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/bus-opentelemetry: dependencies: tslib: @@ -1096,6 +1154,9 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@braidai/lang@1.1.2': resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} @@ -1738,6 +1799,10 @@ packages: '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + '@manypkg/find-root@3.1.0': resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} engines: {node: '>=20.0.0'} @@ -1767,6 +1832,50 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@nestjs/common@12.1.2': + resolution: {integrity: sha512-e2tvLcEaG18sJy5IXrdSLv0ZBpR5djWxypCLkLuMAEFCvVPYL1lEIhvAMBkarlg+ZA8tbZN40J0tFurUl9NpdA==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/core@12.1.2': + resolution: {integrity: sha512-ieNRDv6P6dLsF/wMz9IUjwnyQbj7/Oluq1FXe3Xhje7TFSKPprLc83QniA2I6tjkr7YVDosdL2Dj7CVv9kU8og==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^12.0.0 + '@nestjs/microservices': ^12.0.0 + '@nestjs/platform-express': ^12.0.0 + '@nestjs/websockets': ^12.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/testing@12.1.2': + resolution: {integrity: sha512-JgetYeGHGK6iUEztXyiQLOfYLWqUgAyexLMzhuAMasgLiDgYShh/PoRESE9tzpRZo4eWKme3uSZ9DFR6Myuojw==} + peerDependencies: + '@nestjs/common': ^12.0.0 + '@nestjs/core': ^12.0.0 + '@nestjs/microservices': ^12.0.0 + '@nestjs/platform-express': ^12.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@node-ts/code-standards@0.0.10': resolution: {integrity: sha512-6tWLW8r5HV8BGBwGZ6QBFKDpNrGJZfyCd8KkUk+BXAJX/NS/6qw8dxVwjk6atCbSgSxjpp37CjCH9uDRPdLnBA==} @@ -2129,6 +2238,16 @@ packages: '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tsconfig/node10@1.0.9': resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} @@ -3082,6 +3201,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -3115,6 +3237,10 @@ packages: file-entry-cache@11.1.5: resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} + file-type@22.1.1: + resolution: {integrity: sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg==} + engines: {node: '>=22'} + fill-range@7.0.1: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} @@ -3253,6 +3379,9 @@ packages: engines: {node: '>=18'} hasBin: true + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3346,6 +3475,10 @@ packages: resolution: {integrity: sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==} engines: {node: '>=8'} + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -3557,6 +3690,10 @@ packages: engines: {node: '>=22.22.1'} hasBin: true + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -3930,6 +4067,9 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -4085,6 +4225,9 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -4126,6 +4269,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -4269,6 +4415,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} @@ -4340,6 +4490,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + tokenx@1.6.0: resolution: {integrity: sha512-CKTjk345ajvBAUp5xUI9a5KKN0zU0lBueVHQbCskH1Hp6WkUKsPW2qGCYNs0pxNyfzxfo+IIjdt2W4sMbw/qBw==} @@ -4476,6 +4630,14 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.6.0: + resolution: {integrity: sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==} + engines: {node: '>=18'} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -5325,6 +5487,8 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@borewit/text-codec@0.2.2': {} + '@braidai/lang@1.1.2': {} '@cacheable/memory@2.2.0': @@ -6009,6 +6173,8 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 + '@lukeed/csprng@1.1.0': {} + '@manypkg/find-root@3.1.0': dependencies: '@manypkg/tools': 2.1.2 @@ -6038,6 +6204,36 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true + '@nestjs/common@12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@11.0.0)': + dependencies: + '@standard-schema/spec': 1.1.0 + file-type: 22.1.1(supports-color@11.0.0) + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@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)': + dependencies: + '@nestjs/common': 12.1.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@11.0.0) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + + '@nestjs/testing@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))': + dependencies: + '@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) + tslib: 2.8.1 + '@node-ts/code-standards@0.0.10': {} '@opentelemetry/api@1.9.1': {} @@ -6333,6 +6529,17 @@ snapshots: color: 5.0.3 text-hex: 1.0.0 + '@standard-schema/spec@1.1.0': {} + + '@tokenizer/inflate@0.4.1(supports-color@11.0.0)': + dependencies: + debug: 4.4.3(supports-color@11.0.0) + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tsconfig/node10@1.0.9': {} '@tsconfig/node12@1.0.11': {} @@ -7333,6 +7540,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -7363,6 +7572,15 @@ snapshots: dependencies: flat-cache: 6.1.23 + file-type@22.1.1(supports-color@11.0.0): + dependencies: + '@tokenizer/inflate': 0.4.1(supports-color@11.0.0) + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.6.0 + transitivePeerDependencies: + - supports-color + fill-range@7.0.1: dependencies: to-regex-range: 5.0.1 @@ -7501,6 +7719,8 @@ snapshots: husky@9.1.7: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.10: {} @@ -7597,6 +7817,8 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + iterare@1.2.1: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -8011,6 +8233,8 @@ snapshots: optionalDependencies: yaml: 2.9.1 + load-esm@1.0.3: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -8475,6 +8699,8 @@ snapshots: path-to-regexp@6.3.0: {} + path-to-regexp@8.4.2: {} + perfect-debounce@1.0.0: {} pg-cloudflare@1.4.0: @@ -8595,6 +8821,8 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + reflect-metadata@0.2.2: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -8680,6 +8908,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.63.6 fsevents: 2.3.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + sade@1.8.1: dependencies: mri: 1.2.0 @@ -8802,6 +9034,10 @@ snapshots: strip-json-comments@3.1.1: {} + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + superjson@2.2.6: dependencies: copy-anything: 4.1.5 @@ -8873,6 +9109,12 @@ snapshots: is-number: 7.0.0 optional: true + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tokenx@1.6.0: {} tr46@5.1.1: @@ -8986,6 +9228,12 @@ snapshots: uglify-js@3.19.3: optional: true + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.6.0: {} + undici-types@7.24.6: {} unicode-emoji-modifier-base@1.0.0: {} diff --git a/test.env b/test.env index da8065a3..d581bafd 100644 --- a/test.env +++ b/test.env @@ -3,11 +3,12 @@ AWS_ACCOUNT_ID=000000000000 AWS_ACCESS_KEY_ID=dummy_key_id AWS_SECRET_ACCESS_KEY=dummy_access_key -# Only for the mongodb driver: 7.6+ loads `os` with a dynamic import() during -# the handshake, which jest only supports with vm modules on. Without it the -# server rejects the connection with "Missing required sub-document 'driver'". -# Remove once NODE-7832 is released: -# https://github.com/mongodb/node-mongodb-native/pull/5059 +# Lets jest load ES modules, which it only does with vm modules on: +# - NestJS 12 is ESM only, and bus-nestjs' tests require() it. +# - The mongodb driver 7.6+ loads `os` with a dynamic import() during the +# handshake. Without it the server rejects the connection with "Missing +# required sub-document 'driver'" (NODE-7832, +# https://github.com/mongodb/node-mongodb-native/pull/5059). NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning" # Integration test endpoints. Each has a default in its *.integration.ts that