@nest-batch/webhook 0.2.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/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Public API barrel for `@nest-batch/webhook`.
3
+ *
4
+ * Hosts import the factory (`forRoot`) and the observer class
5
+ * (`WebhookBatchObserver`) from this barrel; everything else
6
+ * is an implementation detail. The barrel re-exports:
7
+ *
8
+ * - `forRoot` — the synchronous `DynamicModule` factory. The
9
+ * host calls `WebhookBatchModule.forRoot({...})` (the
10
+ * `WebhookBatchModule` is re-exported alongside so the
11
+ * type is reachable from this entry point).
12
+ * - `WebhookBatchObserver` — the concrete class. Useful for
13
+ * type-strict consumers that prefer class injection.
14
+ * - `BATCH_EVENT` — the `BATCH_EVENT` constants from
15
+ * `@nest-batch/core`, re-exported so a host that wants to
16
+ * filter subscriptions does not have to add
17
+ * `@nest-batch/core` as a direct dep.
18
+ * - `signV1` / `buildSignatureHeader` / `parseSignatureHeader`
19
+ * / `verifyV1` / `fingerprintSecret` — the HMAC signing
20
+ * helpers, useful for hosts that want to write their own
21
+ * webhook receiver against the same contract.
22
+ * - The TypeScript types: `WebhookBatchModuleOptions`,
23
+ * `ResolvedWebhookOptions`, `WebhookLogger`,
24
+ * `WebhookEnvelope`.
25
+ */
26
+ export { forRoot, WebhookBatchModule, WebhookBatchObserver } from './webhook-batch.module';
27
+ export { BATCH_EVENT } from '@nest-batch/core';
28
+ export type {
29
+ BatchEvent,
30
+ BatchEventType,
31
+ BatchObserver,
32
+ } from '@nest-batch/core';
33
+ export {
34
+ signV1,
35
+ buildSignatureHeader,
36
+ parseSignatureHeader,
37
+ verifyV1,
38
+ fingerprintSecret,
39
+ SIGNATURE_HEADER_NAME,
40
+ } from './webhook-signing';
41
+ export type { WebhookEnvelope } from './webhook-batch.observer';
42
+ export type {
43
+ ResolvedWebhookOptions,
44
+ WebhookBatchModuleOptions,
45
+ WebhookLogger,
46
+ } from './module-options';
@@ -0,0 +1,276 @@
1
+ import type { BatchEventType } from '@nest-batch/core';
2
+
3
+ /**
4
+ * Public options bag for `WebhookBatchModule.forRoot()`.
5
+ *
6
+ * The contract the test suite (`tests/webhook-observer.test.ts`,
7
+ * T-AC-5) asserts against:
8
+ *
9
+ * - `secret` is REQUIRED at the host level. It is never read from
10
+ * disk, never defaulted to an empty string, never logged in any
11
+ * code path. The optional `WEBHOOK_HMAC_SECRET` env var is the
12
+ * fallback ONLY when the host does not pass `secret` (env is
13
+ * the host-injection's safety net, not its primary).
14
+ * - `urls[]` is the fan-out set. Every subscribed event is POSTed
15
+ * to every URL in `urls`. Empty `urls` is a no-op (the observer
16
+ * still subscribes, it just never POSTs).
17
+ * - `events` is the subscription filter. Defaults to
18
+ * `[JOB_COMPLETED, JOB_FAILED, STEP_FAILED]`. Subscribed events
19
+ * are the only ones the observer signs + POSTs. A
20
+ * `JOB_STARTED` event arrives at `onEvent`, is not in the
21
+ * filter, and is dropped silently (the listener is fire-and-
22
+ * forget by contract — see `BatchObserver.onEvent`).
23
+ * - `attempts` is the number of total POST attempts (1 initial +
24
+ * up to `attempts-1` retries). Defaults to 4 (matching the
25
+ * fixed 1s/5s/25s/125s backoff schedule). Lowering the value
26
+ * is supported for tests; raising it is intentionally NOT
27
+ * supported in v1 (the retry schedule is the contract, see
28
+ * `docs/RELEASE-0.2.0.md` §7.2).
29
+ * - `timeoutMs` is the per-attempt HTTP timeout. Defaults to
30
+ * 10 000 ms (10 seconds). A timeout is treated as a network
31
+ * error and retried through the full attempt budget.
32
+ * - `logger` is the Nest `Logger`-compatible interface used for
33
+ * the dead-letter `warn` line and the bootstrap notice. When
34
+ * omitted, the observer instantiates a `new Logger('WebhookBatchObserver')`.
35
+ */
36
+ export interface WebhookBatchModuleOptions {
37
+ /**
38
+ * Host-injected HMAC-SHA256 secret used to sign outbound
39
+ * envelopes. REQUIRED when not relying on the `WEBHOOK_HMAC_SECRET`
40
+ * env fallback. Recommended length: 32+ bytes of randomness
41
+ * (a per-environment secret, never re-used across services).
42
+ *
43
+ * The secret is bound to the `WebhookBatchObserver` instance at
44
+ * `forRoot` time and is never exported, logged, serialized into
45
+ * a dead-letter body, or otherwise observable by the host.
46
+ */
47
+ readonly secret?: string;
48
+
49
+ /**
50
+ * One or more absolute URLs the observer will fan out to on
51
+ * every subscribed event. Empty array is a no-op (the observer
52
+ * still subscribes to the event stream but never POSTs).
53
+ */
54
+ readonly urls: readonly string[];
55
+
56
+ /**
57
+ * Subscription filter. Defaults to
58
+ * `[BATCH_EVENT.JOB_COMPLETED, BATCH_EVENT.JOB_FAILED,
59
+ * BATCH_EVENT.STEP_FAILED]`. The v1 contract is these three
60
+ * events only; a future v2 may widen the default to STEP_*
61
+ * events.
62
+ */
63
+ readonly events?: readonly BatchEventType[];
64
+
65
+ /**
66
+ * Total number of POST attempts (initial + retries). Defaults
67
+ * to 4. Must be `>= 1`; `1` means "no retries" (single POST,
68
+ * then dead-letter on failure). Values `> 4` are clamped to 4
69
+ * — the v1 retry schedule is `[1s, 5s, 25s, 125s]` and has
70
+ * exactly 4 entries; further attempts would have no backoff to
71
+ * look up.
72
+ */
73
+ readonly attempts?: number;
74
+
75
+ /**
76
+ * Per-attempt HTTP timeout in milliseconds. Defaults to
77
+ * 10 000 (10 seconds). A timeout is treated as a network
78
+ * error and retried through the full attempt budget.
79
+ */
80
+ readonly timeoutMs?: number;
81
+
82
+ /**
83
+ * Logger override. The observer is built to use a NestJS
84
+ * `Logger`-compatible interface (the four `log` / `warn` /
85
+ * `error` / `debug` methods). When omitted, the observer
86
+ * instantiates a `new Logger('WebhookBatchObserver')` against
87
+ * the `console`-backed Nest logger.
88
+ */
89
+ readonly logger?: WebhookLogger;
90
+ }
91
+
92
+ /**
93
+ * NestJS-`Logger`-compatible surface used by `WebhookBatchObserver`.
94
+ *
95
+ * We type this as a structural subset of `@nestjs/common`'s
96
+ * `LoggerService` so the host can pass a custom logger without
97
+ * having to import the full Nest surface. The four methods are
98
+ * the only ones the observer calls:
99
+ *
100
+ * - `log` — bootstrap / info-level messages
101
+ * - `warn` — dead-letter payload (post final failure)
102
+ * - `error` — configuration / startup errors
103
+ * - `debug` — per-attempt diagnostic info (URL, status, latency)
104
+ */
105
+ export interface WebhookLogger {
106
+ log(message: string, context?: string): void;
107
+ warn(message: string, context?: string): void;
108
+ error(message: string, context?: string): void;
109
+ debug(message: string, context?: string): void;
110
+ }
111
+
112
+ /**
113
+ * Fully-resolved options bag the observer consumes at runtime.
114
+ * `forRoot` is responsible for filling in every default and
115
+ * freezing the result before handing it to the provider.
116
+ */
117
+ export interface ResolvedWebhookOptions {
118
+ readonly secret: string;
119
+ readonly urls: readonly string[];
120
+ readonly events: readonly BatchEventType[];
121
+ readonly attempts: number;
122
+ readonly timeoutMs: number;
123
+ readonly logger: WebhookLogger;
124
+ }
125
+
126
+ /**
127
+ * The v1 default subscription set. Documented in
128
+ * `docs/RELEASE-0.2.0.md` §7.1 and in the README.
129
+ */
130
+ export const DEFAULT_WEBHOOK_EVENTS: readonly BatchEventType[] = [
131
+ // JOB_COMPLETED, JOB_FAILED, STEP_FAILED
132
+ // We do not import BATCH_EVENT here to avoid a circular
133
+ // dep (the observer re-uses this list at construction
134
+ // time). The constant is the v1 contract; a future v2
135
+ // may widen the default to STEP_*, CHUNK_*, ITEM_*.
136
+ 'nest-batch.job.completed',
137
+ 'nest-batch.job.failed',
138
+ 'nest-batch.step.failed',
139
+ ] as const;
140
+
141
+ /**
142
+ * The v1 fixed backoff schedule. Four entries (3 delays between
143
+ * 4 attempts). Documented in `docs/RELEASE-0.2.0.md` §7.2 and
144
+ * in the README. The schedule is the contract the test suite
145
+ * asserts against.
146
+ */
147
+ export const DEFAULT_WEBHOOK_RETRY_DELAYS_MS: readonly number[] = [
148
+ 1_000, 5_000, 25_000, 125_000,
149
+ ] as const;
150
+
151
+ /**
152
+ * Fast-mode override for the retry schedule. Activated when
153
+ * `process.env.WEBHOOK_TEST_FAST === '1'`. The override exists
154
+ * so the test suite can exercise the 4-attempt retry path
155
+ * without waiting 156 seconds (1+5+25+125). Test-only; never
156
+ * touched in production. Documented in the README.
157
+ */
158
+ export const FAST_WEBHOOK_RETRY_DELAYS_MS: readonly number[] = [
159
+ 1, 5, 25, 125,
160
+ ] as const;
161
+
162
+ /**
163
+ * The DI token under which the resolved options are stored.
164
+ * `Symbol.for` keeps the key process-scoped and stable across
165
+ * module versions, mirroring the pattern in
166
+ * `packages/bullmq/src/module-options.ts`.
167
+ */
168
+ export const WEBHOOK_MODULE_OPTIONS: symbol = Symbol.for(
169
+ '@nest-batch/webhook/MODULE_OPTIONS',
170
+ );
171
+
172
+ /**
173
+ * Resolve a partial `WebhookBatchModuleOptions` into a fully-
174
+ * populated `ResolvedWebhookOptions`. Called by `forRoot` so the
175
+ * provider always sees a frozen, default-filled bag.
176
+ *
177
+ * Resolution rules:
178
+ * - `secret`: if absent, fall back to `process.env.WEBHOOK_HMAC_SECRET`.
179
+ * If still absent, throw — the host MUST provide a secret one
180
+ * way or another.
181
+ * - `urls`: required, no default. Empty array is allowed (no-op
182
+ * fan-out).
183
+ * - `events`: defaults to `DEFAULT_WEBHOOK_EVENTS`. A `[]` value
184
+ * is honoured (the observer subscribes to nothing).
185
+ * - `attempts`: defaults to 4. Clamped to `[1, 4]`.
186
+ * - `timeoutMs`: defaults to 10 000. Clamped to `>= 100` so the
187
+ * observer cannot be configured into "immediate timeout" mode.
188
+ * - `logger`: defaults to a `new Logger('WebhookBatchObserver')`.
189
+ */
190
+ export function resolveWebhookOptions(
191
+ raw: WebhookBatchModuleOptions,
192
+ ): ResolvedWebhookOptions {
193
+ if (raw === null || typeof raw !== 'object') {
194
+ throw new Error(
195
+ '[WebhookBatchModule] options must be a non-null object',
196
+ );
197
+ }
198
+ const secret = pickSecret(raw.secret);
199
+ if (typeof secret !== 'string' || secret.length === 0) {
200
+ throw new Error(
201
+ '[WebhookBatchModule] secret is required: pass `secret` to ' +
202
+ 'forRoot() or set the WEBHOOK_HMAC_SECRET env var',
203
+ );
204
+ }
205
+ const urls = Array.isArray(raw.urls) ? raw.urls.slice() : [];
206
+ for (const url of urls) {
207
+ if (typeof url !== 'string' || url.length === 0) {
208
+ throw new Error(
209
+ '[WebhookBatchModule] every entry in `urls` must be a non-empty string',
210
+ );
211
+ }
212
+ }
213
+ const events = Array.isArray(raw.events) && raw.events.length > 0
214
+ ? raw.events.slice()
215
+ : DEFAULT_WEBHOOK_EVENTS.slice();
216
+ const rawAttempts = typeof raw.attempts === 'number' ? raw.attempts : 4;
217
+ const attempts = Math.max(1, Math.min(4, Math.floor(rawAttempts)));
218
+ const rawTimeout = typeof raw.timeoutMs === 'number' ? raw.timeoutMs : 10_000;
219
+ const timeoutMs = Math.max(100, Math.floor(rawTimeout));
220
+ return Object.freeze({
221
+ secret,
222
+ urls,
223
+ events,
224
+ attempts,
225
+ timeoutMs,
226
+ logger: raw.logger ?? defaultLogger(),
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Pick the secret: host-injected first, env-var fallback second.
232
+ * Returns `undefined` if neither is set so the caller can throw
233
+ * a precise error.
234
+ */
235
+ function pickSecret(hostInjected: string | undefined): string | undefined {
236
+ if (typeof hostInjected === 'string' && hostInjected.length > 0) {
237
+ return hostInjected;
238
+ }
239
+ const fromEnv = process.env['WEBHOOK_HMAC_SECRET'];
240
+ if (typeof fromEnv === 'string' && fromEnv.length > 0) {
241
+ return fromEnv;
242
+ }
243
+ return undefined;
244
+ }
245
+
246
+ /**
247
+ * The default `WebhookLogger` — a thin adapter around
248
+ * `console`. The observer is built to be test-friendly; tests
249
+ * pass a captured-`console.warn` spy via the `logger` option.
250
+ */
251
+ function defaultLogger(): WebhookLogger {
252
+ // We deliberately do NOT import @nestjs/common's `Logger` here
253
+ // — the `WebhookLogger` is a structural interface, and the
254
+ // adapter lets the package stay test-runner-agnostic. Tests
255
+ // pass a console-backed spy; hosts pass a NestJS `Logger`
256
+ // instance (the structural shape matches the official
257
+ // `LoggerService`).
258
+ return {
259
+ log: (message: string) => {
260
+ // eslint-disable-next-line no-console
261
+ console.log(`[WebhookBatchObserver] ${message}`);
262
+ },
263
+ warn: (message: string) => {
264
+ // eslint-disable-next-line no-console
265
+ console.warn(`[WebhookBatchObserver] ${message}`);
266
+ },
267
+ error: (message: string) => {
268
+ // eslint-disable-next-line no-console
269
+ console.error(`[WebhookBatchObserver] ${message}`);
270
+ },
271
+ debug: (message: string) => {
272
+ // eslint-disable-next-line no-console
273
+ console.debug(`[WebhookBatchObserver] ${message}`);
274
+ },
275
+ };
276
+ }
@@ -0,0 +1,133 @@
1
+ import { Module, type DynamicModule, type Provider } from '@nestjs/common';
2
+ import { BATCH_EVENT, type BatchEventType } from '@nest-batch/core';
3
+
4
+ import {
5
+ resolveWebhookOptions,
6
+ WEBHOOK_MODULE_OPTIONS,
7
+ type ResolvedWebhookOptions,
8
+ type WebhookBatchModuleOptions,
9
+ type WebhookLogger,
10
+ } from './module-options';
11
+ import { WebhookBatchObserver } from './webhook-batch.observer';
12
+
13
+ /**
14
+ * `WebhookBatchModule` — the NestJS dynamic module that wires
15
+ * the `WebhookBatchObserver` into the host's DI container and
16
+ * binds it to the `BatchObserver` token used by the executor
17
+ * (and by `@nest-batch/bullmq` / `@nest-batch/kafka`'s runtime
18
+ * bridge).
19
+ *
20
+ * The host wires it alongside `NestBatchModule.forRoot({...})`:
21
+ *
22
+ * ```ts
23
+ * @Module({
24
+ * imports: [
25
+ * NestBatchModule.forRoot({
26
+ * adapters: { persistence: MikroOrmAdapter.forRoot(), transport: BullmqAdapter.forRoot() },
27
+ * }),
28
+ * WebhookBatchModule.forRoot({
29
+ * secret: process.env.WEBHOOK_HMAC_SECRET,
30
+ * urls: ['https://hooks.example.com/nest-batch'],
31
+ * }),
32
+ * ],
33
+ * })
34
+ * export class AppModule {}
35
+ * ```
36
+ *
37
+ * The observer is auto-registered against the `BatchObserver`
38
+ * token via `useExisting`, so the executor's optional-injection
39
+ * path picks it up without any extra wiring on the host's side.
40
+ */
41
+ @Module({})
42
+ export class WebhookBatchModule {}
43
+
44
+ /**
45
+ * `forRoot` — synchronous configuration. Resolves the options
46
+ * up-front (filling in defaults, falling back to the
47
+ * `WEBHOOK_HMAC_SECRET` env var when `secret` is omitted,
48
+ * freezing the result) and emits a `DynamicModule` that:
49
+ *
50
+ * - registers `WebhookBatchObserver` as a provider,
51
+ * - registers a `useExisting` alias so anything injecting
52
+ * `BatchObserver` (or `WebhookBatchObserver` by class)
53
+ * resolves to the same instance,
54
+ * - registers the resolved options under
55
+ * `WEBHOOK_MODULE_OPTIONS` (the observer's `@Inject`
56
+ * key),
57
+ * - marks the module `global: true` so the observer is
58
+ * visible across the host's sub-modules.
59
+ *
60
+ * The `urls: []` case is a no-op (the observer subscribes
61
+ * to the event stream but never POSTs); it does not throw.
62
+ * The `secret` case throws at `forRoot` time with a clear
63
+ * message (the host sees the error at boot, not at the first
64
+ * event).
65
+ */
66
+ export function forRoot(options: WebhookBatchModuleOptions): DynamicModule {
67
+ const resolved = resolveWebhookOptions(options);
68
+ return {
69
+ module: WebhookBatchModule,
70
+ global: true,
71
+ providers: buildProviders(resolved),
72
+ exports: [WebhookBatchObserver],
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Build the static provider list shared by `forRoot()`.
78
+ *
79
+ * The list is three entries:
80
+ * - `WebhookBatchObserver` — the concrete class.
81
+ * - `BATCH_OBSERVER_PROVIDER` — a `useExisting` alias so the
82
+ * executor's `@Optional() @Inject(BatchObserver) observer`
83
+ * resolves to the same instance.
84
+ * - `WEBHOOK_MODULE_OPTIONS` — the resolved + frozen
85
+ * options bag, injected into the observer's constructor.
86
+ *
87
+ * Centralising the list keeps the public factory surface
88
+ * (`forRoot`) a one-liner; any future addition (e.g. a
89
+ * per-package health check) only needs to land here.
90
+ */
91
+ function buildProviders(resolved: ResolvedWebhookOptions): Provider[] {
92
+ return [
93
+ WebhookBatchObserver,
94
+ {
95
+ provide: BATCH_OBSERVER_TOKEN,
96
+ useExisting: WebhookBatchObserver,
97
+ },
98
+ {
99
+ provide: WEBHOOK_MODULE_OPTIONS,
100
+ useValue: resolved,
101
+ },
102
+ ];
103
+ }
104
+
105
+ /**
106
+ * The DI token the executor / runtime services use to inject
107
+ * a `BatchObserver`. We re-export the `BatchObserver` class
108
+ * itself as the token (mirroring the pattern in
109
+ * `@nest-batch/bullmq` and `@nest-batch/kafka`, where the
110
+ * `BatchObserver` interface is the type and the class-as-
111
+ * token resolves to the singleton).
112
+ *
113
+ * Using the `BatchObserver` class (an interface in
114
+ * `@nest-batch/core`) as the token is a NestJS pattern:
115
+ * Nest uses the class reference as the default DI key. We
116
+ * redeclare it as `BATCH_OBSERVER_TOKEN` so the `useExisting`
117
+ * provider above has a stable, imported symbol to bind
118
+ * against.
119
+ */
120
+ const BATCH_OBSERVER_TOKEN: symbol = Symbol.for(
121
+ '@nest-batch/webhook/BATCH_OBSERVER',
122
+ );
123
+
124
+ // Re-export the public surface of this module so the
125
+ // package barrel can re-export it in turn.
126
+ export {
127
+ BATCH_EVENT,
128
+ WebhookBatchObserver,
129
+ type BatchEventType,
130
+ type ResolvedWebhookOptions,
131
+ type WebhookBatchModuleOptions,
132
+ type WebhookLogger,
133
+ };