@btravstack/amqp-worker 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benoit TRAVERS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # @btravstack/amqp-worker
2
+
3
+ > The **consuming half** of AMQP for [`@btravstack/core`](../core) โ€” publishing
4
+ > belongs to `@amqp-contract/client`, which the outbox relay in
5
+ > `examples/order-amqp-worker` uses directly. An
6
+ > [`amqp-contract`](https://github.com/btravstack/amqp-contract) worker under
7
+ > the kernel's lifecycle โ€” one unit per delivery, and a drain with exactly one
8
+ > deadline, the kernel's own.
9
+
10
+ ๐Ÿ“– **[Documentation](https://btravstack.github.io/btravstack/how-to/consume-amqp-messages)** ยท
11
+ [Reference](https://btravstack.github.io/btravstack/reference/amqp-worker) ยท
12
+ [API Reference](https://btravstack.github.io/btravstack/api/amqp-worker/)
13
+
14
+ ```sh
15
+ pnpm add @btravstack/amqp-worker @btravstack/core @btravstack/config @btravstack/di unthrown \
16
+ @amqp-contract/worker @opentelemetry/api
17
+ ```
18
+
19
+ All six are peer dependencies โ€” install them (`@opentelemetry/api` because
20
+ `@amqp-contract/worker` itself peers on it). Node `>=20`.
21
+
22
+ ## A worked example
23
+
24
+ <!-- doctest: prelude
25
+ import { NonRetryableError, RetryableError } from "@amqp-contract/worker";
26
+ import { orderContract } from "@btravstack/example-order-amqp-contract";
27
+ import { PlaceOrder } from "@btravstack/example-order-application";
28
+ import { TenantId } from "@btravstack/example-order-domain";
29
+ import { Module } from "@btravstack/di";
30
+ import { OrderApplicationModule, OrderRepository, Outbox } from "@btravstack/example-order-application";
31
+ import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure";
32
+ import { observability } from "@btravstack/observability";
33
+
34
+ // The application module the README's composition imports โ€” the orders
35
+ // vertical plus its persistence, as an application would compose it.
36
+ const AppModule = Module("App")({
37
+ imports: [OrderApplicationModule, OrderPersistenceModule, observability()],
38
+ exports: [PlaceOrder, OrderRepository, Outbox, Logger],
39
+ });
40
+ -->
41
+
42
+ ```ts
43
+ import { Env } from "@btravstack/config";
44
+ import { AmqpHandlers, AmqpModule } from "@btravstack/amqp-worker";
45
+ import { runMain, Logger } from "@btravstack/core";
46
+ import { ErrAsync, OkAsync, P } from "unthrown";
47
+
48
+ // The handlers, as a service: one entry per consumer the contract declares,
49
+ // built by di from the use cases it lists โ€” no injected context โ€” on the
50
+ // starter's own handlers port, typed by the contract (a consumer serves one
51
+ // handlers record, so there is nothing to name).
52
+ const orderHandlers = AmqpHandlers(orderContract)(
53
+ { placeOrder: PlaceOrder },
54
+ {
55
+ sync: ({ placeOrder }) => ({
56
+ orderNotifications: (message) =>
57
+ placeOrder
58
+ .execute(
59
+ TenantId(message.payload.tenantId),
60
+ message.payload.id,
61
+ message.payload.payload?.quantity ?? 0,
62
+ )
63
+ .map(() => undefined)
64
+ // A modeled domain error is permanent: dead-letter it, no retry.
65
+ .mapErrCases((matcher) =>
66
+ matcher.with(
67
+ P.tag("InvalidQuantity"),
68
+ P.tag("InvalidOrderId"),
69
+ P.tag("DuplicateOrder"),
70
+ (error) => new NonRetryableError(error._tag, error),
71
+ ),
72
+ )
73
+ // Infrastructure failing is not: recover the defect into a retry.
74
+ .recoverDefect((cause) =>
75
+ ErrAsync(new RetryableError("placing the order failed", cause)),
76
+ ),
77
+ // Every consumer the contract declares must be covered โ€” an uncovered
78
+ // key is refused at this call, not on the first delivery.
79
+ orderAudit: () => OkAsync(undefined),
80
+ }),
81
+ },
82
+ );
83
+
84
+ const Worker = AmqpModule("Worker")({
85
+ needs: [Env],
86
+ contract: orderContract,
87
+ handlers: orderHandlers,
88
+ imports: [AppModule],
89
+ });
90
+
91
+ await runMain(Worker);
92
+ ```
93
+
94
+ `AmqpModule` imports the starter โ€” `AmqpRuntime`, `AmqpConfig` bound from
95
+ `AMQP_URL` (default `amqp://127.0.0.1:5672`) โ€” provides the handlers and
96
+ exports the runtime port; the handlers are checked against the contract at the
97
+ call, not on the first delivery. `runtimeInfo()` reads `{ queues }` back once
98
+ consuming.
99
+
100
+ A worker with several consumers can be several slices instead of one record:
101
+ `AmqpHandler(contract, key)({ name: Dep }, arm)` mints a provider for ONE consumer or
102
+ rpc, typed by the key alone, and `AmqpHandlers(contract)([...])` composes an
103
+ array of them into the same handlers provider `AmqpModule` takes โ€” the array
104
+ must cover every key the contract declares, and each piece's own port must
105
+ still be discharged (`provides`), since the composed provider's deps are the
106
+ pieces' ports, not what they close over. Two slices claiming one key are di's
107
+ duplicate-provider defect at build, which is the point: a consumer belongs to
108
+ exactly one slice.
109
+
110
+ ## Options
111
+
112
+ `AmqpModule(name)({...})` takes `amqp()`'s options plus `handlers` and the
113
+ module lists (`imports`, `provides`, `exports`, `needs`). The `amqp()`
114
+ primitive does not take `handlers` as an option โ€” it **needs** the handlers
115
+ provider on its own port, which is how the composition root supplies it:
116
+
117
+ | Option | What it is |
118
+ | ------------------------ | --------------------------------------------------------------------------------------------------------------- |
119
+ | `contract` | the `amqp-contract` contract; the queues consumed are read off it |
120
+ | `handlers` | the handlers provider โ€” what `AmqpHandlers(contract)(...)` returns for this contract |
121
+ | `url` | pins the broker instead of reading `AMQP_URL` (default `amqp://127.0.0.1:5672`) โ€” a test's container |
122
+ | `connectionOptions` | `AmqpConnectionOptions`, the library's own connection tuning: heartbeat, reconnect interval, `findServers`, TLS |
123
+ | `defaultConsumerOptions` | the library's `ConsumerOptions`, applied to every handler: `prefetch` (the throughput knob), `priority`, โ€ฆ |
124
+ | `connectTimeoutMs` | how long startup waits before an unreachable broker is a `RuntimeStartFailed` (the library defaults to 30 s) |
125
+
126
+ The full table โ€” required/optional, defaults, and the reasoning โ€” lives on
127
+ [the reference page](https://btravstack.github.io/btravstack/reference/amqp-worker),
128
+ which is this list's one detailed home.
129
+
130
+ ## What it decides, and what it does not
131
+
132
+ `Result` โ†’ ack / retry / dead-letter is a **three-way** split and none of it is
133
+ this package's: a `RetryableError` / `NonRetryableError` is routed by
134
+ `amqp-contract`'s own dispatch against the queue's `retry` config, and a
135
+ `Defect` is nacked once, straight to the dead-letter queue, never touching that
136
+ budget โ€” which is why the handler above recovers its own defects into a
137
+ `RetryableError`. The drain calls `worker.close({ drainTimeoutMs: null })`
138
+ raced against the kernel's deadline: the library is told to wait forever, so
139
+ there is no second timeout in the process to keep in step. Each delivery is one
140
+ unit, `traceId` the publisher's `messageId`. The rest is on the
141
+ [documentation site](https://btravstack.github.io/btravstack/reference/amqp-worker).
142
+
143
+ ## License
144
+
145
+ [MIT](./LICENSE) ยฉ Benoit TRAVERS
package/dist/index.cjs ADDED
@@ -0,0 +1,215 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _btravstack_di = require("@btravstack/di");
3
+ let _amqp_contract_worker = require("@amqp-contract/worker");
4
+ let _btravstack_config = require("@btravstack/config");
5
+ let _btravstack_core = require("@btravstack/core");
6
+ let unthrown = require("unthrown");
7
+ let node_crypto = require("node:crypto");
8
+ //#region src/handler.ts
9
+ /** The prefix a piece's port id carries; the composing form strips it to recover the key. */
10
+ const HANDLER_PREFIX = "AmqpHandler:";
11
+ /**
12
+ * One consumer or rpc of a contract, as a provider on a port of its own.
13
+ *
14
+ * A worker with several consumers is several pieces, each declaring the services
15
+ * its own handler calls; `AmqpHandlers(contract)([...])` composes them.
16
+ * `contract` is read for its TYPE only, and types both `key` and the handler โ€”
17
+ * so a consumer the contract does not declare is a compile error here.
18
+ *
19
+ * There is no name to give: the contract key IS the port's name.
20
+ */
21
+ const AmqpHandler = (contract, key) => {
22
+ const port = class extends (0, _btravstack_di.Port)(`${HANDLER_PREFIX}${key}`) {};
23
+ return (0, _btravstack_di.Provider)(port);
24
+ };
25
+ //#endregion
26
+ //#region src/message-units.ts
27
+ /**
28
+ * Open one kernel unit per delivery. It injects nothing โ€” `next()` unchanged is
29
+ * the whole of the chain โ€” so the ambient `currentUnit()` record is what the
30
+ * unit leaves for the adapters that read it.
31
+ *
32
+ * **The kernel's per-unit `AbortSignal` rides that record too**, and it is the
33
+ * only route to it here: a handler has no parameter to receive one through. A
34
+ * handler that must stop when the kernel stops waiting reads
35
+ * `currentUnit()?.signal`.
36
+ */
37
+ const messageUnits = (host) => (args, next) => host.run(metaFor(args.rawMessage), () => next());
38
+ /**
39
+ * `UnitMeta.id` must be unique per unit, and a **delivery tag is not one**: tags
40
+ * are per-channel and restart at `1` after a reconnect, which
41
+ * amqp-connection-manager performs silently underneath this worker.
42
+ * `consumerTag + deliveryTag` almost fixes it, until `ConsumerOptions` lets a
43
+ * caller pin `consumerTag`. Minting is the only form the rule survives.
44
+ *
45
+ * A W3C `traceparent` header wins when the publisher sends one; otherwise
46
+ * `messageId`, with `correlationId` as the RPC-shaped fallback.
47
+ *
48
+ * Only a NON-BLANK id is adopted, and the trim is load-bearing: `??` guards
49
+ * nullish alone, so a publisher setting `messageId: ""` would give every
50
+ * delivery the same blank trace id.
51
+ */
52
+ const metaFor = (raw) => {
53
+ const id = (0, node_crypto.randomUUID)();
54
+ const parent = raw.properties.headers?.["traceparent"];
55
+ const fromParent = typeof parent === "string" ? traceIdOfTraceparent(parent) : void 0;
56
+ const inbound = [raw.properties.messageId, raw.properties.correlationId].map((value) => value?.trim() ?? "").find((value) => value !== "");
57
+ return {
58
+ kind: "delivery",
59
+ id,
60
+ traceId: fromParent ?? inbound ?? id
61
+ };
62
+ };
63
+ /**
64
+ * The trace id inside a W3C `traceparent` header, which outranks `messageId`
65
+ * because it is the one value minted to span processes. Only the trace-id field
66
+ * is taken: `traceId` is a correlation id, not a span context, so the parent's
67
+ * span id is dropped rather than half-carried. The all-zero id is the spec's own
68
+ * "invalid" and is refused like a malformed header.
69
+ */
70
+ const traceIdOfTraceparent = (header) => {
71
+ const traceId = /^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/.exec(header.trim())?.[1];
72
+ return traceId === void 0 || /^0{32}$/.test(traceId) ? void 0 : traceId;
73
+ };
74
+ //#endregion
75
+ //#region src/amqp-runtime.ts
76
+ /**
77
+ * The broker, as a service: `amqp()` binds it from `AMQP_URL` (default
78
+ * `amqp://127.0.0.1:5672`) unless pinned, and anything else in the graph may
79
+ * read it โ€” a publisher sharing the consumer's broker, say.
80
+ */
81
+ var AmqpConfig = class extends (0, _btravstack_di.Port)("AmqpConfig") {};
82
+ /** The runtime's port: what `amqp()` provides, and what the module `start` boots must export. */
83
+ var AmqpRuntime = class extends _btravstack_core.RuntimePort {};
84
+ /**
85
+ * The handlers' port โ€” one id, the starter's own, which an application never
86
+ * names. Generic at the value level (one `Port(...)` call, so no duplicate-id
87
+ * warning however many contracts instantiate it) and fixed per contract at the
88
+ * type level through `HandlersPortOf<C>`, so a provider built for one contract
89
+ * cannot be handed to a module declaring another. Exported for this package's
90
+ * tests, not from `index.ts`.
91
+ */
92
+ const AmqpHandlersPort = (0, _btravstack_di.Port)("AmqpHandlers");
93
+ /**
94
+ * The AMQP starter: a module providing the runtime and its configuration (bound
95
+ * from `AMQP_URL` unless pinned), built over the handlers the application
96
+ * provides on the starter's own handlers port โ€” the module's one need. Import
97
+ * it next to the application, provide the handlers, export `AmqpRuntime`.
98
+ *
99
+ * With `url` pinned the module reads nothing from the environment.
100
+ */
101
+ const amqp = (options) => {
102
+ const config = options.url === void 0 ? _btravstack_config.Config.provider(AmqpConfig)(_btravstack_config.Config.object({ url: _btravstack_config.Config.string("AMQP_URL", { default: "amqp://127.0.0.1:5672" }) })) : (0, _btravstack_di.Provider)(AmqpConfig)({ value: { url: options.url } });
103
+ return (0, _btravstack_di.Module)("Amqp")({
104
+ needs: [_btravstack_config.Env, AmqpHandlersPort],
105
+ provides: [config, (0, _btravstack_di.Provider)(AmqpRuntime)({
106
+ config: AmqpConfig,
107
+ handlers: AmqpHandlersPort
108
+ }, { sync: ({ config: bound, handlers }) => ({
109
+ name: "amqp",
110
+ resolves: [],
111
+ start: (host) => createWorker(host, bound, options, handlers)
112
+ }) })],
113
+ exports: [AmqpRuntime, AmqpConfig]
114
+ });
115
+ };
116
+ /**
117
+ * The handlers as a provider, from the contract. Three call forms, one port.
118
+ *
119
+ * ```ts
120
+ * AmqpHandlers(orderContract)({ logger: Logger }, { sync: ({ logger }) => ({ orderNotifications: (m) => โ€ฆ }) })
121
+ * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
122
+ * ```
123
+ *
124
+ * The first two are di's own `Provider(port)` on the starter's handlers port
125
+ * typed for the contract. The third takes the pieces `AmqpHandler(contract,
126
+ * key)` builds: they are the provider's deps, keyed by the contract key each
127
+ * piece's port id carries, so the services record IS the handlers record. Every
128
+ * declared key must be covered, and two slices claiming one key are di's
129
+ * duplicate-provider defect at build.
130
+ */
131
+ const AmqpHandlers = (contract) => {
132
+ const build = (0, _btravstack_di.Provider)(AmqpHandlersPort);
133
+ const compose = (pieces) => build(Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(12), piece.port])), { sync: (services) => services });
134
+ return ((first, second) => second === void 0 && Array.isArray(first) ? compose(first) : build(first, second));
135
+ };
136
+ const startFailed = (cause) => new _btravstack_core.RuntimeStartFailed({
137
+ runtime: "amqp",
138
+ cause
139
+ });
140
+ /**
141
+ * Every queue the contract's consumers and RPCs drain, sorted and
142
+ * de-duplicated. Derived rather than configured, so `Serving.info` cannot
143
+ * disagree with what the worker actually consumes.
144
+ */
145
+ const queuesOf = (contract) => [...new Set(Object.values({
146
+ ...contract.consumers,
147
+ ...contract.rpcs
148
+ }).map((entry) => entry.queue.name))].sort();
149
+ const createWorker = (host, config, options, handlers) => _amqp_contract_worker.TypedAmqpWorker.create({
150
+ contract: options.contract,
151
+ handlers,
152
+ middleware: messageUnits(host),
153
+ urls: [config.url],
154
+ ...options.connectionOptions === void 0 ? {} : { connectionOptions: options.connectionOptions },
155
+ ...options.defaultConsumerOptions === void 0 ? {} : { defaultConsumerOptions: options.defaultConsumerOptions },
156
+ ...options.connectTimeoutMs === void 0 ? {} : { connectTimeoutMs: options.connectTimeoutMs }
157
+ }).map((worker) => consume(worker, queuesOf(options.contract))).recoverDefect((cause) => (0, unthrown.ErrAsync)(startFailed(cause)));
158
+ const consume = (worker, queues) => {
159
+ let closing;
160
+ const beginClose = () => closing ??= worker.close({ drainTimeoutMs: null });
161
+ let deadline;
162
+ const stopped = () => deadline === void 0 ? beginClose() : (0, _btravstack_core.releasedBy)(deadline, beginClose());
163
+ return {
164
+ info: { queues },
165
+ drain: (signal) => {
166
+ deadline = signal;
167
+ return stopped();
168
+ },
169
+ stop: () => stopped()
170
+ };
171
+ };
172
+ //#endregion
173
+ //#region src/amqp-module.ts
174
+ /**
175
+ * `Module(name)({...})` for an AMQP deployment: everything a di module takes,
176
+ * plus the contract and the handlers provider. The sugar imports the starter,
177
+ * provides the handlers and exports `AmqpRuntime`, handing back exactly the
178
+ * module `Module(...)` would have declared over the augmented tuples.
179
+ *
180
+ * ```ts
181
+ * export const OrderAmqpWorker = AmqpModule("OrderAmqpWorker")({
182
+ * contract: orderContract,
183
+ * handlers: orderHandlers,
184
+ * imports: [OrderApplicationModule, OrderPersistenceModule],
185
+ * exports: [Logger],
186
+ * });
187
+ * await runMain(OrderAmqpWorker);
188
+ * ```
189
+ */
190
+ const AmqpModule = (name) => (options) => {
191
+ const { contract, handlers, url, connectionOptions, defaultConsumerOptions, connectTimeoutMs } = options;
192
+ const imports = options.imports ?? [];
193
+ const provides = options.provides ?? [];
194
+ const exports = options.exports ?? [];
195
+ const starter = amqp({
196
+ contract,
197
+ ...url === void 0 ? {} : { url },
198
+ ...connectionOptions === void 0 ? {} : { connectionOptions },
199
+ ...defaultConsumerOptions === void 0 ? {} : { defaultConsumerOptions },
200
+ ...connectTimeoutMs === void 0 ? {} : { connectTimeoutMs }
201
+ });
202
+ return (0, _btravstack_di.Module)(name)({
203
+ imports: [...imports, starter],
204
+ provides: [handlers, ...provides],
205
+ exports: [AmqpRuntime, ...exports],
206
+ needs: options.needs ?? []
207
+ });
208
+ };
209
+ //#endregion
210
+ exports.AmqpConfig = AmqpConfig;
211
+ exports.AmqpHandler = AmqpHandler;
212
+ exports.AmqpHandlers = AmqpHandlers;
213
+ exports.AmqpModule = AmqpModule;
214
+ exports.AmqpRuntime = AmqpRuntime;
215
+ exports.amqp = amqp;
@@ -0,0 +1,178 @@
1
+ import { ConsumerOptions, TypedAmqpWorker, WorkerInferHandlers } from "@amqp-contract/worker";
2
+ import { ConfigInvalid, Env } from "@btravstack/config";
3
+ import { AnyModule, AnyPort, AnyProvider, Exportable, Module, NeedsGate, PortClassOf, PortInstance, Provider } from "@btravstack/di";
4
+ import { Runtime, RuntimePort } from "@btravstack/core";
5
+ //#region src/handler.d.ts
6
+ /** The consumer and rpc names `C` declares โ€” the keys of its handlers record. */
7
+ type HandlerKeyOf<C extends AnyAmqpContract> = keyof WorkerInferHandlers<C> & string;
8
+ /**
9
+ * The port one piece targets. Its id carries the contract key, which is what
10
+ * makes two slices claiming one consumer di's duplicate-provider defect rather
11
+ * than a silent merge.
12
+ */
13
+ type HandlerPortOf<C extends AnyAmqpContract, K extends HandlerKeyOf<C>> = PortClassOf<`${typeof HANDLER_PREFIX}${K}`, WorkerInferHandlers<C>[K]>;
14
+ /** The prefix a piece's port id carries; the composing form strips it to recover the key. */
15
+ declare const HANDLER_PREFIX = "AmqpHandler:";
16
+ /**
17
+ * One consumer or rpc of a contract, as a provider on a port of its own.
18
+ *
19
+ * A worker with several consumers is several pieces, each declaring the services
20
+ * its own handler calls; `AmqpHandlers(contract)([...])` composes them.
21
+ * `contract` is read for its TYPE only, and types both `key` and the handler โ€”
22
+ * so a consumer the contract does not declare is a compile error here.
23
+ *
24
+ * There is no name to give: the contract key IS the port's name.
25
+ */
26
+ declare const AmqpHandler: <C extends AnyAmqpContract, const K extends HandlerKeyOf<C>>(contract: C, key: K) => ReturnType<typeof Provider<HandlerPortOf<C, K>>>;
27
+ //#endregion
28
+ //#region src/amqp-runtime.d.ts
29
+ /** What the worker publishes once it is consuming, read back through `RunningApp.runtimeInfo()`. */
30
+ type AmqpInfo = {
31
+ readonly queues: readonly string[];
32
+ };
33
+ declare const AmqpConfig_base: import("@btravstack/di").PortClass<"AmqpConfig">;
34
+ /**
35
+ * The broker, as a service: `amqp()` binds it from `AMQP_URL` (default
36
+ * `amqp://127.0.0.1:5672`) unless pinned, and anything else in the graph may
37
+ * read it โ€” a publisher sharing the consumer's broker, say.
38
+ */
39
+ declare class AmqpConfig extends AmqpConfig_base<{
40
+ readonly url: string;
41
+ }> {}
42
+ /** The runtime's port: what `amqp()` provides, and what the module `start` boots must export. */
43
+ declare class AmqpRuntime extends RuntimePort<Runtime<never, AmqpInfo>> {}
44
+ /**
45
+ * The contract type `TypedAmqpWorker.create` accepts, extracted rather than
46
+ * imported by name so `@amqp-contract/contract` stays out of the peer range.
47
+ */
48
+ type AnyAmqpContract = Parameters<typeof TypedAmqpWorker.create>[0]["contract"];
49
+ /**
50
+ * The connection tuning `TypedAmqpWorker.create` accepts โ€” heartbeat,
51
+ * reconnect interval, `findServers`, TLS/socket options. The library declares
52
+ * it (`AmqpConnectionManagerOptions`) without exporting it by name, so it is
53
+ * reached by index for the same reason {@link AnyAmqpContract} is.
54
+ */
55
+ type AmqpConnectionOptions = NonNullable<Parameters<typeof TypedAmqpWorker.create>[0]["connectionOptions"]>;
56
+ /** The handlers port class, typed for `C`: what `AmqpHandlers(contract)(โ€ฆ).port` is. */
57
+ type HandlersPortOf<C extends AnyAmqpContract> = PortClassOf<"AmqpHandlers", WorkerInferHandlers<C>>;
58
+ /** The handlers port's instance for `C` โ€” the module's one need. */
59
+ type HandlersInstanceOf<C extends AnyAmqpContract> = PortInstance<"AmqpHandlers", WorkerInferHandlers<C>>;
60
+ type AmqpOptions<TContract extends AnyAmqpContract> = {
61
+ /**
62
+ * The contract; the handlers port is typed by it โ€” one handler per
63
+ * `consumers` / `rpcs` key, with no injected context, since a handler is
64
+ * built by di from the services it declares.
65
+ */
66
+ readonly contract: TContract;
67
+ /** Pins the broker instead of reading `AMQP_URL` โ€” a test's container. */
68
+ readonly url?: string;
69
+ /** Connection tuning, `@amqp-contract/worker`'s own type: heartbeat, reconnect interval, `findServers`, TLS/socket options. */
70
+ readonly connectionOptions?: AmqpConnectionOptions;
71
+ /** Consumer defaults applied to every handler, the library's own type: `prefetch`, `priority`, `arguments`, `consumerTag`, `exclusive`. */
72
+ readonly defaultConsumerOptions?: ConsumerOptions;
73
+ /**
74
+ * How long `create` waits for the connection before failing. Passed straight
75
+ * through โ€” it is a top-level `CreateWorkerOptions` field, NOT nested under
76
+ * `connectionOptions`, where setting it is silently inert. Without it an
77
+ * unreachable broker takes the library's 30s default to report.
78
+ */
79
+ readonly connectTimeoutMs?: number;
80
+ };
81
+ /**
82
+ * The AMQP starter: a module providing the runtime and its configuration (bound
83
+ * from `AMQP_URL` unless pinned), built over the handlers the application
84
+ * provides on the starter's own handlers port โ€” the module's one need. Import
85
+ * it next to the application, provide the handlers, export `AmqpRuntime`.
86
+ *
87
+ * With `url` pinned the module reads nothing from the environment.
88
+ */
89
+ declare const amqp: <TContract extends AnyAmqpContract>(options: AmqpOptions<TContract>) => Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract>>;
90
+ /** One piece of the handlers record โ€” what `AmqpHandler(contract, key)(โ€ฆ)` returns, as the composing form consumes it. */
91
+ type PieceOf<C extends AnyAmqpContract> = { readonly [K in HandlerKeyOf<C>]: {
92
+ readonly port: HandlerPortOf<C, K>;
93
+ }; }[HandlerKeyOf<C>];
94
+ /** The key a piece carries, read back off its port id. */
95
+ type KeyOfPiece<P> = P extends {
96
+ readonly port: {
97
+ readonly portId: `${typeof HANDLER_PREFIX}${infer K}`;
98
+ };
99
+ } ? K : never;
100
+ /** The consumers and rpcs no piece in `T` covers. */
101
+ type Uncovered<C extends AnyAmqpContract, T extends readonly PieceOf<C>[]> = Exclude<HandlerKeyOf<C>, KeyOfPiece<T[number]>>;
102
+ /**
103
+ * The composing arm. Declared LAST in the intersection below on purpose:
104
+ * TypeScript reports the last overload's failure, so a non-covering array is
105
+ * refused against the `"UNCOVERED HANDLERS โ€” โ€ฆ"` marker rather than degrading
106
+ * to di's `Qualification`, which names nothing.
107
+ *
108
+ * The marker is a sentence rather than a bare label because it prints last,
109
+ * after the caller's own several-hundred-character piece type.
110
+ */
111
+ type Compose<C extends AnyAmqpContract> = <const T extends readonly PieceOf<C>[]>(pieces: [Uncovered<C, T>] extends [never] ? T : readonly ["UNCOVERED HANDLERS โ€” the contract declares a consumer this array does not cover", Uncovered<C, T>]) => Provider<HandlersInstanceOf<C>, never, InstanceType<T[number]["port"]>> & {
112
+ readonly port: HandlersPortOf<C>;
113
+ };
114
+ /**
115
+ * The handlers as a provider, from the contract. Three call forms, one port.
116
+ *
117
+ * ```ts
118
+ * AmqpHandlers(orderContract)({ logger: Logger }, { sync: ({ logger }) => ({ orderNotifications: (m) => โ€ฆ }) })
119
+ * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
120
+ * ```
121
+ *
122
+ * The first two are di's own `Provider(port)` on the starter's handlers port
123
+ * typed for the contract. The third takes the pieces `AmqpHandler(contract,
124
+ * key)` builds: they are the provider's deps, keyed by the contract key each
125
+ * piece's port id carries, so the services record IS the handlers record. Every
126
+ * declared key must be covered, and two slices claiming one key are di's
127
+ * duplicate-provider defect at build.
128
+ */
129
+ declare const AmqpHandlers: <C extends AnyAmqpContract>(contract: C) => ReturnType<typeof Provider<HandlersPortOf<C>>> & Compose<C>;
130
+ //#endregion
131
+ //#region src/amqp-module.d.ts
132
+ /** The starter's own module, as the sugar adds it to the application's imports. */
133
+ type AmqpStarter<TContract extends AnyAmqpContract> = Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract>>;
134
+ /** The application's imports plus the starter โ€” the tuple `Module(name)` is handed. */
135
+ type Imports<I extends readonly AnyModule[], TContract extends AnyAmqpContract> = readonly [...I, AmqpStarter<TContract>];
136
+ /** The handlers provider plus the application's own โ€” the tuple `Module(name)` is handed. */
137
+ type Provides<P extends readonly AnyProvider[], TContract extends AnyAmqpContract, HandlersError, HandlersNeeds> = readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P];
138
+ type AmqpModuleOptions<TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, I extends readonly AnyModule[], P extends readonly AnyProvider[], X extends readonly Exportable<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>>[], N extends readonly AnyPort[]> = {
139
+ readonly contract: TContract;
140
+ /** The application's handlers โ€” what `AmqpHandlers(contract)(โ€ฆ)` returns for THIS contract. */
141
+ readonly handlers: Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>;
142
+ /** Pins the broker instead of reading `AMQP_URL` โ€” a test's container. */
143
+ readonly url?: string;
144
+ /** See `AmqpOptions.connectionOptions`. */
145
+ readonly connectionOptions?: AmqpConnectionOptions;
146
+ /** See `AmqpOptions.defaultConsumerOptions`. */
147
+ readonly defaultConsumerOptions?: ConsumerOptions;
148
+ /** See `AmqpOptions.connectTimeoutMs`. */
149
+ readonly connectTimeoutMs?: number;
150
+ readonly imports?: I;
151
+ readonly provides?: P;
152
+ /** The application's own exports; `AmqpRuntime` is added, since `start` resolves it. */
153
+ readonly exports?: X;
154
+ /**
155
+ * What this root's OWN providers expect from outside. di's gate is re-stated
156
+ * over the augmented tuples below, so forgetting one is an error at THIS call.
157
+ */
158
+ readonly needs?: N;
159
+ } & NeedsGate<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>, N>;
160
+ /**
161
+ * `Module(name)({...})` for an AMQP deployment: everything a di module takes,
162
+ * plus the contract and the handlers provider. The sugar imports the starter,
163
+ * provides the handlers and exports `AmqpRuntime`, handing back exactly the
164
+ * module `Module(...)` would have declared over the augmented tuples.
165
+ *
166
+ * ```ts
167
+ * export const OrderAmqpWorker = AmqpModule("OrderAmqpWorker")({
168
+ * contract: orderContract,
169
+ * handlers: orderHandlers,
170
+ * imports: [OrderApplicationModule, OrderPersistenceModule],
171
+ * exports: [Logger],
172
+ * });
173
+ * await runMain(OrderAmqpWorker);
174
+ * ```
175
+ */
176
+ declare const AmqpModule: <const Name extends string>(name: Name) => <TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, const I extends readonly AnyModule[] = [], const P extends readonly AnyProvider[] = [], const X extends readonly Exportable<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>>[] = [], const N extends readonly AnyPort[] = []>(options: AmqpModuleOptions<TContract, HandlersError, HandlersNeeds, I, P, X, N>) => Module<(Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> extends (infer T) ? T extends Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> ? T extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [typeof AmqpRuntime, ...X])[number] extends (infer E_1) ? E_1 extends AnyPort ? InstanceType<E_1> : E_1 extends AnyProvider ? InstanceType<E_1["port"]> : never : never), ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_1) ? T_1 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_1 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[1] | ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_2) ? T_2 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_2 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[1], Exclude<((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]> | Exclude<((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]>>;
177
+ //#endregion
178
+ export { AmqpConfig, type AmqpConnectionOptions, AmqpHandler, AmqpHandlers, type AmqpInfo, AmqpModule, type AmqpModuleOptions, type AmqpOptions, AmqpRuntime, type HandlerPortOf, type HandlersInstanceOf, type HandlersPortOf, amqp };
@@ -0,0 +1,178 @@
1
+ import { AnyModule, AnyPort, AnyProvider, Exportable, Module, NeedsGate, PortClassOf, PortInstance, Provider } from "@btravstack/di";
2
+ import { ConsumerOptions, TypedAmqpWorker, WorkerInferHandlers } from "@amqp-contract/worker";
3
+ import { ConfigInvalid, Env } from "@btravstack/config";
4
+ import { Runtime, RuntimePort } from "@btravstack/core";
5
+ //#region src/handler.d.ts
6
+ /** The consumer and rpc names `C` declares โ€” the keys of its handlers record. */
7
+ type HandlerKeyOf<C extends AnyAmqpContract> = keyof WorkerInferHandlers<C> & string;
8
+ /**
9
+ * The port one piece targets. Its id carries the contract key, which is what
10
+ * makes two slices claiming one consumer di's duplicate-provider defect rather
11
+ * than a silent merge.
12
+ */
13
+ type HandlerPortOf<C extends AnyAmqpContract, K extends HandlerKeyOf<C>> = PortClassOf<`${typeof HANDLER_PREFIX}${K}`, WorkerInferHandlers<C>[K]>;
14
+ /** The prefix a piece's port id carries; the composing form strips it to recover the key. */
15
+ declare const HANDLER_PREFIX = "AmqpHandler:";
16
+ /**
17
+ * One consumer or rpc of a contract, as a provider on a port of its own.
18
+ *
19
+ * A worker with several consumers is several pieces, each declaring the services
20
+ * its own handler calls; `AmqpHandlers(contract)([...])` composes them.
21
+ * `contract` is read for its TYPE only, and types both `key` and the handler โ€”
22
+ * so a consumer the contract does not declare is a compile error here.
23
+ *
24
+ * There is no name to give: the contract key IS the port's name.
25
+ */
26
+ declare const AmqpHandler: <C extends AnyAmqpContract, const K extends HandlerKeyOf<C>>(contract: C, key: K) => ReturnType<typeof Provider<HandlerPortOf<C, K>>>;
27
+ //#endregion
28
+ //#region src/amqp-runtime.d.ts
29
+ /** What the worker publishes once it is consuming, read back through `RunningApp.runtimeInfo()`. */
30
+ type AmqpInfo = {
31
+ readonly queues: readonly string[];
32
+ };
33
+ declare const AmqpConfig_base: import("@btravstack/di").PortClass<"AmqpConfig">;
34
+ /**
35
+ * The broker, as a service: `amqp()` binds it from `AMQP_URL` (default
36
+ * `amqp://127.0.0.1:5672`) unless pinned, and anything else in the graph may
37
+ * read it โ€” a publisher sharing the consumer's broker, say.
38
+ */
39
+ declare class AmqpConfig extends AmqpConfig_base<{
40
+ readonly url: string;
41
+ }> {}
42
+ /** The runtime's port: what `amqp()` provides, and what the module `start` boots must export. */
43
+ declare class AmqpRuntime extends RuntimePort<Runtime<never, AmqpInfo>> {}
44
+ /**
45
+ * The contract type `TypedAmqpWorker.create` accepts, extracted rather than
46
+ * imported by name so `@amqp-contract/contract` stays out of the peer range.
47
+ */
48
+ type AnyAmqpContract = Parameters<typeof TypedAmqpWorker.create>[0]["contract"];
49
+ /**
50
+ * The connection tuning `TypedAmqpWorker.create` accepts โ€” heartbeat,
51
+ * reconnect interval, `findServers`, TLS/socket options. The library declares
52
+ * it (`AmqpConnectionManagerOptions`) without exporting it by name, so it is
53
+ * reached by index for the same reason {@link AnyAmqpContract} is.
54
+ */
55
+ type AmqpConnectionOptions = NonNullable<Parameters<typeof TypedAmqpWorker.create>[0]["connectionOptions"]>;
56
+ /** The handlers port class, typed for `C`: what `AmqpHandlers(contract)(โ€ฆ).port` is. */
57
+ type HandlersPortOf<C extends AnyAmqpContract> = PortClassOf<"AmqpHandlers", WorkerInferHandlers<C>>;
58
+ /** The handlers port's instance for `C` โ€” the module's one need. */
59
+ type HandlersInstanceOf<C extends AnyAmqpContract> = PortInstance<"AmqpHandlers", WorkerInferHandlers<C>>;
60
+ type AmqpOptions<TContract extends AnyAmqpContract> = {
61
+ /**
62
+ * The contract; the handlers port is typed by it โ€” one handler per
63
+ * `consumers` / `rpcs` key, with no injected context, since a handler is
64
+ * built by di from the services it declares.
65
+ */
66
+ readonly contract: TContract;
67
+ /** Pins the broker instead of reading `AMQP_URL` โ€” a test's container. */
68
+ readonly url?: string;
69
+ /** Connection tuning, `@amqp-contract/worker`'s own type: heartbeat, reconnect interval, `findServers`, TLS/socket options. */
70
+ readonly connectionOptions?: AmqpConnectionOptions;
71
+ /** Consumer defaults applied to every handler, the library's own type: `prefetch`, `priority`, `arguments`, `consumerTag`, `exclusive`. */
72
+ readonly defaultConsumerOptions?: ConsumerOptions;
73
+ /**
74
+ * How long `create` waits for the connection before failing. Passed straight
75
+ * through โ€” it is a top-level `CreateWorkerOptions` field, NOT nested under
76
+ * `connectionOptions`, where setting it is silently inert. Without it an
77
+ * unreachable broker takes the library's 30s default to report.
78
+ */
79
+ readonly connectTimeoutMs?: number;
80
+ };
81
+ /**
82
+ * The AMQP starter: a module providing the runtime and its configuration (bound
83
+ * from `AMQP_URL` unless pinned), built over the handlers the application
84
+ * provides on the starter's own handlers port โ€” the module's one need. Import
85
+ * it next to the application, provide the handlers, export `AmqpRuntime`.
86
+ *
87
+ * With `url` pinned the module reads nothing from the environment.
88
+ */
89
+ declare const amqp: <TContract extends AnyAmqpContract>(options: AmqpOptions<TContract>) => Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract>>;
90
+ /** One piece of the handlers record โ€” what `AmqpHandler(contract, key)(โ€ฆ)` returns, as the composing form consumes it. */
91
+ type PieceOf<C extends AnyAmqpContract> = { readonly [K in HandlerKeyOf<C>]: {
92
+ readonly port: HandlerPortOf<C, K>;
93
+ }; }[HandlerKeyOf<C>];
94
+ /** The key a piece carries, read back off its port id. */
95
+ type KeyOfPiece<P> = P extends {
96
+ readonly port: {
97
+ readonly portId: `${typeof HANDLER_PREFIX}${infer K}`;
98
+ };
99
+ } ? K : never;
100
+ /** The consumers and rpcs no piece in `T` covers. */
101
+ type Uncovered<C extends AnyAmqpContract, T extends readonly PieceOf<C>[]> = Exclude<HandlerKeyOf<C>, KeyOfPiece<T[number]>>;
102
+ /**
103
+ * The composing arm. Declared LAST in the intersection below on purpose:
104
+ * TypeScript reports the last overload's failure, so a non-covering array is
105
+ * refused against the `"UNCOVERED HANDLERS โ€” โ€ฆ"` marker rather than degrading
106
+ * to di's `Qualification`, which names nothing.
107
+ *
108
+ * The marker is a sentence rather than a bare label because it prints last,
109
+ * after the caller's own several-hundred-character piece type.
110
+ */
111
+ type Compose<C extends AnyAmqpContract> = <const T extends readonly PieceOf<C>[]>(pieces: [Uncovered<C, T>] extends [never] ? T : readonly ["UNCOVERED HANDLERS โ€” the contract declares a consumer this array does not cover", Uncovered<C, T>]) => Provider<HandlersInstanceOf<C>, never, InstanceType<T[number]["port"]>> & {
112
+ readonly port: HandlersPortOf<C>;
113
+ };
114
+ /**
115
+ * The handlers as a provider, from the contract. Three call forms, one port.
116
+ *
117
+ * ```ts
118
+ * AmqpHandlers(orderContract)({ logger: Logger }, { sync: ({ logger }) => ({ orderNotifications: (m) => โ€ฆ }) })
119
+ * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
120
+ * ```
121
+ *
122
+ * The first two are di's own `Provider(port)` on the starter's handlers port
123
+ * typed for the contract. The third takes the pieces `AmqpHandler(contract,
124
+ * key)` builds: they are the provider's deps, keyed by the contract key each
125
+ * piece's port id carries, so the services record IS the handlers record. Every
126
+ * declared key must be covered, and two slices claiming one key are di's
127
+ * duplicate-provider defect at build.
128
+ */
129
+ declare const AmqpHandlers: <C extends AnyAmqpContract>(contract: C) => ReturnType<typeof Provider<HandlersPortOf<C>>> & Compose<C>;
130
+ //#endregion
131
+ //#region src/amqp-module.d.ts
132
+ /** The starter's own module, as the sugar adds it to the application's imports. */
133
+ type AmqpStarter<TContract extends AnyAmqpContract> = Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract>>;
134
+ /** The application's imports plus the starter โ€” the tuple `Module(name)` is handed. */
135
+ type Imports<I extends readonly AnyModule[], TContract extends AnyAmqpContract> = readonly [...I, AmqpStarter<TContract>];
136
+ /** The handlers provider plus the application's own โ€” the tuple `Module(name)` is handed. */
137
+ type Provides<P extends readonly AnyProvider[], TContract extends AnyAmqpContract, HandlersError, HandlersNeeds> = readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P];
138
+ type AmqpModuleOptions<TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, I extends readonly AnyModule[], P extends readonly AnyProvider[], X extends readonly Exportable<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>>[], N extends readonly AnyPort[]> = {
139
+ readonly contract: TContract;
140
+ /** The application's handlers โ€” what `AmqpHandlers(contract)(โ€ฆ)` returns for THIS contract. */
141
+ readonly handlers: Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>;
142
+ /** Pins the broker instead of reading `AMQP_URL` โ€” a test's container. */
143
+ readonly url?: string;
144
+ /** See `AmqpOptions.connectionOptions`. */
145
+ readonly connectionOptions?: AmqpConnectionOptions;
146
+ /** See `AmqpOptions.defaultConsumerOptions`. */
147
+ readonly defaultConsumerOptions?: ConsumerOptions;
148
+ /** See `AmqpOptions.connectTimeoutMs`. */
149
+ readonly connectTimeoutMs?: number;
150
+ readonly imports?: I;
151
+ readonly provides?: P;
152
+ /** The application's own exports; `AmqpRuntime` is added, since `start` resolves it. */
153
+ readonly exports?: X;
154
+ /**
155
+ * What this root's OWN providers expect from outside. di's gate is re-stated
156
+ * over the augmented tuples below, so forgetting one is an error at THIS call.
157
+ */
158
+ readonly needs?: N;
159
+ } & NeedsGate<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>, N>;
160
+ /**
161
+ * `Module(name)({...})` for an AMQP deployment: everything a di module takes,
162
+ * plus the contract and the handlers provider. The sugar imports the starter,
163
+ * provides the handlers and exports `AmqpRuntime`, handing back exactly the
164
+ * module `Module(...)` would have declared over the augmented tuples.
165
+ *
166
+ * ```ts
167
+ * export const OrderAmqpWorker = AmqpModule("OrderAmqpWorker")({
168
+ * contract: orderContract,
169
+ * handlers: orderHandlers,
170
+ * imports: [OrderApplicationModule, OrderPersistenceModule],
171
+ * exports: [Logger],
172
+ * });
173
+ * await runMain(OrderAmqpWorker);
174
+ * ```
175
+ */
176
+ declare const AmqpModule: <const Name extends string>(name: Name) => <TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, const I extends readonly AnyModule[] = [], const P extends readonly AnyProvider[] = [], const X extends readonly Exportable<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>>[] = [], const N extends readonly AnyPort[] = []>(options: AmqpModuleOptions<TContract, HandlersError, HandlersNeeds, I, P, X, N>) => Module<(Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> extends (infer T) ? T extends Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> ? T extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [typeof AmqpRuntime, ...X])[number] extends (infer E_1) ? E_1 extends AnyPort ? InstanceType<E_1> : E_1 extends AnyProvider ? InstanceType<E_1["port"]> : never : never), ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_1) ? T_1 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_1 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[1] | ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_2) ? T_2 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_2 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[1], Exclude<((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]> | Exclude<((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]>>;
177
+ //#endregion
178
+ export { AmqpConfig, type AmqpConnectionOptions, AmqpHandler, AmqpHandlers, type AmqpInfo, AmqpModule, type AmqpModuleOptions, type AmqpOptions, AmqpRuntime, type HandlerPortOf, type HandlersInstanceOf, type HandlersPortOf, amqp };
package/dist/index.mjs ADDED
@@ -0,0 +1,209 @@
1
+ import { Module, Port, Provider } from "@btravstack/di";
2
+ import { TypedAmqpWorker } from "@amqp-contract/worker";
3
+ import { Config, Env } from "@btravstack/config";
4
+ import { RuntimePort, RuntimeStartFailed, releasedBy } from "@btravstack/core";
5
+ import { ErrAsync } from "unthrown";
6
+ import { randomUUID } from "node:crypto";
7
+ //#region src/handler.ts
8
+ /** The prefix a piece's port id carries; the composing form strips it to recover the key. */
9
+ const HANDLER_PREFIX = "AmqpHandler:";
10
+ /**
11
+ * One consumer or rpc of a contract, as a provider on a port of its own.
12
+ *
13
+ * A worker with several consumers is several pieces, each declaring the services
14
+ * its own handler calls; `AmqpHandlers(contract)([...])` composes them.
15
+ * `contract` is read for its TYPE only, and types both `key` and the handler โ€”
16
+ * so a consumer the contract does not declare is a compile error here.
17
+ *
18
+ * There is no name to give: the contract key IS the port's name.
19
+ */
20
+ const AmqpHandler = (contract, key) => {
21
+ const port = class extends Port(`${HANDLER_PREFIX}${key}`) {};
22
+ return Provider(port);
23
+ };
24
+ //#endregion
25
+ //#region src/message-units.ts
26
+ /**
27
+ * Open one kernel unit per delivery. It injects nothing โ€” `next()` unchanged is
28
+ * the whole of the chain โ€” so the ambient `currentUnit()` record is what the
29
+ * unit leaves for the adapters that read it.
30
+ *
31
+ * **The kernel's per-unit `AbortSignal` rides that record too**, and it is the
32
+ * only route to it here: a handler has no parameter to receive one through. A
33
+ * handler that must stop when the kernel stops waiting reads
34
+ * `currentUnit()?.signal`.
35
+ */
36
+ const messageUnits = (host) => (args, next) => host.run(metaFor(args.rawMessage), () => next());
37
+ /**
38
+ * `UnitMeta.id` must be unique per unit, and a **delivery tag is not one**: tags
39
+ * are per-channel and restart at `1` after a reconnect, which
40
+ * amqp-connection-manager performs silently underneath this worker.
41
+ * `consumerTag + deliveryTag` almost fixes it, until `ConsumerOptions` lets a
42
+ * caller pin `consumerTag`. Minting is the only form the rule survives.
43
+ *
44
+ * A W3C `traceparent` header wins when the publisher sends one; otherwise
45
+ * `messageId`, with `correlationId` as the RPC-shaped fallback.
46
+ *
47
+ * Only a NON-BLANK id is adopted, and the trim is load-bearing: `??` guards
48
+ * nullish alone, so a publisher setting `messageId: ""` would give every
49
+ * delivery the same blank trace id.
50
+ */
51
+ const metaFor = (raw) => {
52
+ const id = randomUUID();
53
+ const parent = raw.properties.headers?.["traceparent"];
54
+ const fromParent = typeof parent === "string" ? traceIdOfTraceparent(parent) : void 0;
55
+ const inbound = [raw.properties.messageId, raw.properties.correlationId].map((value) => value?.trim() ?? "").find((value) => value !== "");
56
+ return {
57
+ kind: "delivery",
58
+ id,
59
+ traceId: fromParent ?? inbound ?? id
60
+ };
61
+ };
62
+ /**
63
+ * The trace id inside a W3C `traceparent` header, which outranks `messageId`
64
+ * because it is the one value minted to span processes. Only the trace-id field
65
+ * is taken: `traceId` is a correlation id, not a span context, so the parent's
66
+ * span id is dropped rather than half-carried. The all-zero id is the spec's own
67
+ * "invalid" and is refused like a malformed header.
68
+ */
69
+ const traceIdOfTraceparent = (header) => {
70
+ const traceId = /^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/.exec(header.trim())?.[1];
71
+ return traceId === void 0 || /^0{32}$/.test(traceId) ? void 0 : traceId;
72
+ };
73
+ //#endregion
74
+ //#region src/amqp-runtime.ts
75
+ /**
76
+ * The broker, as a service: `amqp()` binds it from `AMQP_URL` (default
77
+ * `amqp://127.0.0.1:5672`) unless pinned, and anything else in the graph may
78
+ * read it โ€” a publisher sharing the consumer's broker, say.
79
+ */
80
+ var AmqpConfig = class extends Port("AmqpConfig") {};
81
+ /** The runtime's port: what `amqp()` provides, and what the module `start` boots must export. */
82
+ var AmqpRuntime = class extends RuntimePort {};
83
+ /**
84
+ * The handlers' port โ€” one id, the starter's own, which an application never
85
+ * names. Generic at the value level (one `Port(...)` call, so no duplicate-id
86
+ * warning however many contracts instantiate it) and fixed per contract at the
87
+ * type level through `HandlersPortOf<C>`, so a provider built for one contract
88
+ * cannot be handed to a module declaring another. Exported for this package's
89
+ * tests, not from `index.ts`.
90
+ */
91
+ const AmqpHandlersPort = Port("AmqpHandlers");
92
+ /**
93
+ * The AMQP starter: a module providing the runtime and its configuration (bound
94
+ * from `AMQP_URL` unless pinned), built over the handlers the application
95
+ * provides on the starter's own handlers port โ€” the module's one need. Import
96
+ * it next to the application, provide the handlers, export `AmqpRuntime`.
97
+ *
98
+ * With `url` pinned the module reads nothing from the environment.
99
+ */
100
+ const amqp = (options) => {
101
+ const config = options.url === void 0 ? Config.provider(AmqpConfig)(Config.object({ url: Config.string("AMQP_URL", { default: "amqp://127.0.0.1:5672" }) })) : Provider(AmqpConfig)({ value: { url: options.url } });
102
+ return Module("Amqp")({
103
+ needs: [Env, AmqpHandlersPort],
104
+ provides: [config, Provider(AmqpRuntime)({
105
+ config: AmqpConfig,
106
+ handlers: AmqpHandlersPort
107
+ }, { sync: ({ config: bound, handlers }) => ({
108
+ name: "amqp",
109
+ resolves: [],
110
+ start: (host) => createWorker(host, bound, options, handlers)
111
+ }) })],
112
+ exports: [AmqpRuntime, AmqpConfig]
113
+ });
114
+ };
115
+ /**
116
+ * The handlers as a provider, from the contract. Three call forms, one port.
117
+ *
118
+ * ```ts
119
+ * AmqpHandlers(orderContract)({ logger: Logger }, { sync: ({ logger }) => ({ orderNotifications: (m) => โ€ฆ }) })
120
+ * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
121
+ * ```
122
+ *
123
+ * The first two are di's own `Provider(port)` on the starter's handlers port
124
+ * typed for the contract. The third takes the pieces `AmqpHandler(contract,
125
+ * key)` builds: they are the provider's deps, keyed by the contract key each
126
+ * piece's port id carries, so the services record IS the handlers record. Every
127
+ * declared key must be covered, and two slices claiming one key are di's
128
+ * duplicate-provider defect at build.
129
+ */
130
+ const AmqpHandlers = (contract) => {
131
+ const build = Provider(AmqpHandlersPort);
132
+ const compose = (pieces) => build(Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(12), piece.port])), { sync: (services) => services });
133
+ return ((first, second) => second === void 0 && Array.isArray(first) ? compose(first) : build(first, second));
134
+ };
135
+ const startFailed = (cause) => new RuntimeStartFailed({
136
+ runtime: "amqp",
137
+ cause
138
+ });
139
+ /**
140
+ * Every queue the contract's consumers and RPCs drain, sorted and
141
+ * de-duplicated. Derived rather than configured, so `Serving.info` cannot
142
+ * disagree with what the worker actually consumes.
143
+ */
144
+ const queuesOf = (contract) => [...new Set(Object.values({
145
+ ...contract.consumers,
146
+ ...contract.rpcs
147
+ }).map((entry) => entry.queue.name))].sort();
148
+ const createWorker = (host, config, options, handlers) => TypedAmqpWorker.create({
149
+ contract: options.contract,
150
+ handlers,
151
+ middleware: messageUnits(host),
152
+ urls: [config.url],
153
+ ...options.connectionOptions === void 0 ? {} : { connectionOptions: options.connectionOptions },
154
+ ...options.defaultConsumerOptions === void 0 ? {} : { defaultConsumerOptions: options.defaultConsumerOptions },
155
+ ...options.connectTimeoutMs === void 0 ? {} : { connectTimeoutMs: options.connectTimeoutMs }
156
+ }).map((worker) => consume(worker, queuesOf(options.contract))).recoverDefect((cause) => ErrAsync(startFailed(cause)));
157
+ const consume = (worker, queues) => {
158
+ let closing;
159
+ const beginClose = () => closing ??= worker.close({ drainTimeoutMs: null });
160
+ let deadline;
161
+ const stopped = () => deadline === void 0 ? beginClose() : releasedBy(deadline, beginClose());
162
+ return {
163
+ info: { queues },
164
+ drain: (signal) => {
165
+ deadline = signal;
166
+ return stopped();
167
+ },
168
+ stop: () => stopped()
169
+ };
170
+ };
171
+ //#endregion
172
+ //#region src/amqp-module.ts
173
+ /**
174
+ * `Module(name)({...})` for an AMQP deployment: everything a di module takes,
175
+ * plus the contract and the handlers provider. The sugar imports the starter,
176
+ * provides the handlers and exports `AmqpRuntime`, handing back exactly the
177
+ * module `Module(...)` would have declared over the augmented tuples.
178
+ *
179
+ * ```ts
180
+ * export const OrderAmqpWorker = AmqpModule("OrderAmqpWorker")({
181
+ * contract: orderContract,
182
+ * handlers: orderHandlers,
183
+ * imports: [OrderApplicationModule, OrderPersistenceModule],
184
+ * exports: [Logger],
185
+ * });
186
+ * await runMain(OrderAmqpWorker);
187
+ * ```
188
+ */
189
+ const AmqpModule = (name) => (options) => {
190
+ const { contract, handlers, url, connectionOptions, defaultConsumerOptions, connectTimeoutMs } = options;
191
+ const imports = options.imports ?? [];
192
+ const provides = options.provides ?? [];
193
+ const exports = options.exports ?? [];
194
+ const starter = amqp({
195
+ contract,
196
+ ...url === void 0 ? {} : { url },
197
+ ...connectionOptions === void 0 ? {} : { connectionOptions },
198
+ ...defaultConsumerOptions === void 0 ? {} : { defaultConsumerOptions },
199
+ ...connectTimeoutMs === void 0 ? {} : { connectTimeoutMs }
200
+ });
201
+ return Module(name)({
202
+ imports: [...imports, starter],
203
+ provides: [handlers, ...provides],
204
+ exports: [AmqpRuntime, ...exports],
205
+ needs: options.needs ?? []
206
+ });
207
+ };
208
+ //#endregion
209
+ export { AmqpConfig, AmqpHandler, AmqpHandlers, AmqpModule, AmqpRuntime, amqp };
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@btravstack/amqp-worker",
3
+ "version": "0.3.0",
4
+ "description": "The AMQP starter for @btravstack/core: the handlers as a port, one unit per delivery, and a drain the kernel's deadline releases",
5
+ "keywords": [
6
+ "amqp",
7
+ "graceful-shutdown",
8
+ "lifecycle",
9
+ "rabbitmq",
10
+ "runtime",
11
+ "typescript",
12
+ "unthrown"
13
+ ],
14
+ "homepage": "https://github.com/btravstack/btravstack#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/btravstack/btravstack/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Benoit TRAVERS <benoit.travers.fr@gmail.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/btravstack/btravstack.git",
23
+ "directory": "packages/amqp-worker"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.mjs",
32
+ "types": "./dist/index.d.cts",
33
+ "exports": {
34
+ ".": {
35
+ "import": {
36
+ "types": "./dist/index.d.mts",
37
+ "default": "./dist/index.mjs"
38
+ },
39
+ "require": {
40
+ "types": "./dist/index.d.cts",
41
+ "default": "./dist/index.cjs"
42
+ }
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "devDependencies": {
47
+ "@amqp-contract/contract": "3.0.0-beta.6",
48
+ "@amqp-contract/testing": "3.0.0-beta.6",
49
+ "@amqp-contract/worker": "3.0.0-beta.6",
50
+ "@btravstack/tsconfig": "0.2.0",
51
+ "@opentelemetry/api": "1.9.1",
52
+ "@types/node": "26.2.0",
53
+ "@unthrown/vitest": "5.7.0",
54
+ "@vitest/coverage-v8": "4.1.11",
55
+ "tsdown": "0.22.14",
56
+ "typescript": "7.0.2",
57
+ "unthrown": "5.7.0",
58
+ "vitest": "4.1.11",
59
+ "zod": "4.4.3",
60
+ "@btravstack/config": "0.3.0",
61
+ "@btravstack/core": "0.3.0",
62
+ "@btravstack/internal-test-infra": "0.0.0",
63
+ "@btravstack/testing": "0.3.0",
64
+ "@btravstack/di": "0.3.0"
65
+ },
66
+ "peerDependencies": {
67
+ "@amqp-contract/worker": "^3.0.0-beta.6",
68
+ "@opentelemetry/api": "^1",
69
+ "unthrown": "^5.0.0",
70
+ "@btravstack/config": "^0.3.0",
71
+ "@btravstack/core": "^0.3.0",
72
+ "@btravstack/di": "^0.3.0"
73
+ },
74
+ "engines": {
75
+ "node": ">=20"
76
+ },
77
+ "scripts": {
78
+ "build": "tsdown src/index.ts --format cjs,esm --dts --clean",
79
+ "dev": "tsdown src/index.ts --format cjs,esm --dts --watch",
80
+ "test": "vitest run --coverage",
81
+ "test:types": "tsc --noEmit -p tsconfig.test-d.json",
82
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
83
+ }
84
+ }