@nest-batch/webhook 0.2.0 → 0.2.1

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.
Files changed (3) hide show
  1. package/README.ko.md +49 -0
  2. package/README.md +25 -370
  3. package/package.json +5 -4
package/README.ko.md ADDED
@@ -0,0 +1,49 @@
1
+ # @nest-batch/webhook
2
+
3
+ `@nest-batch/core` lifecycle event를 외부 URL로 보내는 webhook observer입니다.
4
+ payload는 HMAC-SHA256으로 서명됩니다.
5
+
6
+ English: [README.md](./README.md)
7
+
8
+ ## 설치
9
+
10
+ ```bash
11
+ pnpm add @nest-batch/core @nest-batch/webhook
12
+ ```
13
+
14
+ 패키지는 runtime `fetch`와 `AbortController` API를 사용하므로 Node 20 이상을
15
+ 권장합니다.
16
+
17
+ ## Public Import
18
+
19
+ ```ts
20
+ import {
21
+ WebhookBatchModule,
22
+ WebhookBatchObserver,
23
+ BATCH_EVENT,
24
+ type WebhookBatchModuleOptions,
25
+ } from '@nest-batch/webhook';
26
+ ```
27
+
28
+ ## Wiring
29
+
30
+ ```ts
31
+ import { WebhookBatchModule } from '@nest-batch/webhook';
32
+
33
+ @Module({
34
+ imports: [
35
+ NestBatchModule.forRoot({ adapters }),
36
+ WebhookBatchModule.forRoot({
37
+ secret: process.env.WEBHOOK_HMAC_SECRET,
38
+ urls: ['https://hooks.example.com/nest-batch'],
39
+ events: [BATCH_EVENT.JOB_COMPLETED, BATCH_EVENT.JOB_FAILED],
40
+ timeoutMs: 10_000,
41
+ }),
42
+ ],
43
+ })
44
+ export class AppModule {}
45
+ ```
46
+
47
+ 기본 subscription은 job completed, job failed, step failed입니다. HTTP 4xx 응답은
48
+ 설정 오류로 분류되고, HTTP 5xx, network error, timeout은 제한된 backoff로
49
+ retry됩니다.
package/README.md CHANGED
@@ -1,395 +1,50 @@
1
- # `@nest-batch/webhook`
1
+ # @nest-batch/webhook
2
2
 
3
- Webhook delivery observer for
4
- [`@nest-batch/core`](../core). The package ships a
5
- `WebhookBatchObserver` that subscribes to the `BATCH_EVENT.*`
6
- lifecycle stream and POSTs an HMAC-SHA256-signed JSON envelope
7
- to one or more URLs, with exponential-backoff retry on
8
- 5xx / network errors, a `logger.warn` dead-letter on final
9
- failure, and a hard `no retry on 4xx` rule.
3
+ Webhook observer for `@nest-batch/core` lifecycle events. It sends
4
+ HMAC-SHA256-signed JSON envelopes to one or more URLs.
10
5
 
11
- > **The observer is transport-agnostic.** It signs + POSTs
12
- > envelopes; it does not care whether the job was driven by
13
- > BullMQ, Kafka, or the in-process strategy. Any transport
14
- > package that bridges `QueueEvents` (or equivalent) to the
15
- > `BatchObserver.onEvent` entry point will deliver events to
16
- > this observer without any extra wiring on the host's side.
17
-
18
- The package is a **sibling**, not a replacement. The dependency
19
- direction is strict and one-way:
20
-
21
- ```
22
- @nest-batch/webhook ──▶ @nest-batch/core
23
-
24
- └──────────────▶ @nestjs/common, @nestjs/core (peer)
25
- ```
26
-
27
- `@nest-batch/core` does not know this package exists. It
28
- cannot — the boundary is enforced by
29
- [`packages/core/tests/core/boundary/no-forbidden-imports.test.ts`](../core/tests/core/boundary/no-forbidden-imports.test.ts),
30
- which scans the core source tree and fails the build if a
31
- forbidden package — `bullmq`, `kafkajs`, `mikro-orm`, `typeorm`,
32
- `drizzle-orm`, `cron` — appears as a core import.
33
-
34
- The observer uses **native `fetch`** (Node 20+). No HTTP client
35
- (`undici` / `axios` / `node-fetch`) is added as a peer dep —
36
- the host does not need to ship a separate HTTP library to
37
- enable webhook delivery. `AbortController` provides the
38
- per-attempt timeout.
39
-
40
- ---
6
+ Korean: [README.ko.md](./README.ko.md)
41
7
 
42
8
  ## Install
43
9
 
44
10
  ```bash
45
- pnpm add @nest-batch/webhook
11
+ pnpm add @nest-batch/core @nest-batch/webhook
46
12
  ```
47
13
 
48
- Peer dependencies the host must already provide:
49
-
50
- | Package | Range |
51
- | ------------------ | ------------- |
52
- | `@nest-batch/core` | `workspace:*` |
53
- | `@nestjs/common` | `^10 \|\| ^11` |
54
- | `@nestjs/core` | `^10 \|\| ^11` |
55
-
56
- Node 20+ is required for the native `fetch` / `AbortController`
57
- runtime. Older Node versions are not supported.
58
-
59
- ---
60
-
61
- ## Peer dependencies
14
+ Node 20 or newer is recommended because the package uses the runtime `fetch`
15
+ and `AbortController` APIs.
62
16
 
63
- | Package | Range | Notes |
64
- | ------------------ | --------------- | -------------------------------------------------------------------------------------- |
65
- | `@nest-batch/core` | `workspace:*` | The batch engine. The observer only consumes the `BatchObserver` / `BATCH_EVENT` surface. |
66
- | `@nestjs/common` | `^10 \|\| ^11` | For `@Module` / `Module` / injection tokens. Nest 10 and 11 are both supported. |
67
- | `@nestjs/core` | `^10 \|\| ^11` | Peer-declared for the dynamic-module surface; not used at runtime. |
17
+ ## Public Imports
68
18
 
69
- The package deliberately does **not** declare a peer dep on
70
- `undici` / `axios` / `node-fetch`. Webhook delivery uses the
71
- runtime's built-in `fetch` + `AbortController` (Node 20+). Hosts
72
- that prefer a different HTTP client can monkey-patch the global
73
- `fetch` at bootstrap time, but no such override is necessary in
74
- the common case.
75
-
76
- ---
19
+ ```ts
20
+ import {
21
+ WebhookBatchModule,
22
+ WebhookBatchObserver,
23
+ BATCH_EVENT,
24
+ type WebhookBatchModuleOptions,
25
+ } from '@nest-batch/webhook';
26
+ ```
77
27
 
78
28
  ## Wiring
79
29
 
80
30
  ```ts
81
- import { Module } from '@nestjs/common';
82
- import { NestBatchModule } from '@nest-batch/core';
83
- import { BullmqAdapter } from '@nest-batch/bullmq';
84
31
  import { WebhookBatchModule } from '@nest-batch/webhook';
85
32
 
86
33
  @Module({
87
34
  imports: [
88
- NestBatchModule.forRoot({
89
- // ... your persistence + transport adapters
90
- }),
91
- BullmqAdapter.forRoot({
92
- connection: { host: process.env.REDIS_HOST, port: 6379 },
93
- autoStartWorker: true,
94
- }),
35
+ NestBatchModule.forRoot({ adapters }),
95
36
  WebhookBatchModule.forRoot({
96
- secret: process.env.WEBHOOK_HMAC_SECRET, // 32+ bytes recommended
97
- urls: [
98
- 'https://hooks.example.com/nest-batch',
99
- 'https://ops.example.com/ingest/nest-batch',
100
- ],
37
+ secret: process.env.WEBHOOK_HMAC_SECRET,
38
+ urls: ['https://hooks.example.com/nest-batch'],
39
+ events: [BATCH_EVENT.JOB_COMPLETED, BATCH_EVENT.JOB_FAILED],
40
+ timeoutMs: 10_000,
101
41
  }),
102
42
  ],
103
43
  })
104
44
  export class AppModule {}
105
45
  ```
106
46
 
107
- `WebhookBatchModule.forRoot({...})` accepts:
108
-
109
- | Field | Type | Required | Default | Notes |
110
- | ------------ | --------------- | -------- | ----------------------------------------- | --------------------------------------------------------------------- |
111
- | `secret` | `string` | required (or via env) | — | Host-injected HMAC-SHA256 secret. 32+ bytes of randomness recommended. |
112
- | `urls` | `string[]` | yes | — | One or more absolute URLs the observer fans out to on every event. |
113
- | `events` | `BatchEventType[]` | no | `[JOB_COMPLETED, JOB_FAILED, STEP_FAILED]` | Subscription filter. Events not in the set are dropped silently. |
114
- | `attempts` | `number` | no | `4` | Total POST attempts. Clamped to `[1, 4]`. `1` = no retries. |
115
- | `timeoutMs` | `number` | no | `10_000` | Per-attempt HTTP timeout in ms. A timeout is treated as a network error. |
116
- | `logger` | `WebhookLogger` | no | `new Logger('WebhookBatchObserver')` | Nest-`Logger`-compatible surface for the dead-letter `warn` line. |
117
-
118
- `WebhookBatchModule` is registered as `global: true` (matching
119
- `NestBatchModule` and the transport adapters) so consumers do
120
- not need to re-import it in every sub-module. The observer is
121
- auto-registered against the `BatchObserver` token, so the
122
- executor / runtime services pick it up via the
123
- `@Optional() observer: BatchObserver = new NoopBatchObserver()`
124
- injection path without any extra wiring.
125
-
126
- ---
127
-
128
- ## Events
129
-
130
- The v1 subscription default is three events. A `BatchEvent`
131
- whose `type` is not in the set is dropped silently — the
132
- observer never holds the executor back.
133
-
134
- | Event | Constant | When it fires |
135
- | ---------------------------------- | ------------------------------ | -------------------------------------------- |
136
- | `nest-batch.job.completed` | `BATCH_EVENT.JOB_COMPLETED` | A `JobExecution` reached the `COMPLETED` terminal state. |
137
- | `nest-batch.job.failed` | `BATCH_EVENT.JOB_FAILED` | A `JobExecution` reached the `FAILED` terminal state. |
138
- | `nest-batch.step.failed` | `BATCH_EVENT.STEP_FAILED` | A `StepExecution` reached the `FAILED` terminal state. |
139
-
140
- Override the default via the `events` option:
141
-
142
- ```ts
143
- WebhookBatchModule.forRoot({
144
- secret: process.env.WEBHOOK_HMAC_SECRET,
145
- urls: ['https://hooks.example.com/nest-batch'],
146
- events: [BATCH_EVENT.JOB_COMPLETED, BATCH_EVENT.JOB_FAILED],
147
- });
148
- ```
149
-
150
- A future v2 may widen the default to include `STEP_*` /
151
- `CHUNK_*` / `ITEM_*` events; in v1 only the three
152
- terminal-state events trigger a POST.
153
-
154
- ---
155
-
156
- ## Retry policy
157
-
158
- The fixed backoff schedule is `[1s, 5s, 25s, 125s]` — four
159
- attempts total (one initial POST plus three retries). The
160
- schedule is the v1 contract; the test suite
161
- ([`tests/webhook-observer.test.ts`](./tests/webhook-observer.test.ts),
162
- T-AC-5) asserts against it.
163
-
164
- | Outcome | Behavior |
165
- | --------------- | --------------------------------------------------------------------------------------- |
166
- | `2xx` | Success. The observer marks the delivery done. |
167
- | `3xx` | Treated as a redirect. The observer follows the redirect once; if the redirect target returns 4xx / 5xx, that target's status drives the retry decision. |
168
- | `4xx` | **No retry.** Logged at `warn` level with the URL and the response body. The host is expected to fix the misconfiguration (bad URL, missing auth, malformed payload). |
169
- | `5xx` | **Retried** through the full 4-attempt budget at the 1s / 5s / 25s / 125s schedule. After the final attempt, dead-letter. |
170
- | Network error | **Retried** through the full 4-attempt budget. After the final attempt, dead-letter. |
171
- | Timeout | Treated as a network error. **Retried** through the full 4-attempt budget. |
172
-
173
- ### Fast-mode test override
174
-
175
- The retry schedule can be overridden to `[1ms, 5ms, 25ms, 125ms]`
176
- by setting the `WEBHOOK_TEST_FAST=1` env var. The override lets
177
- the test suite exercise the 4-attempt retry path in <200ms
178
- instead of the 156-second production schedule. The override is
179
- gated behind an env var so production cannot trip it by
180
- accident.
181
-
182
- ```bash
183
- WEBHOOK_TEST_FAST=1 pnpm --filter @nest-batch/webhook test -- tests/webhook-observer.test.ts
184
- ```
185
-
186
- ### Dead-letter
187
-
188
- After the final failed attempt (4xx not retried, or 5xx /
189
- network / timeout after all 4 attempts), the observer emits:
190
-
191
- ```ts
192
- logger.warn(
193
- `[WebhookBatchObserver] dead-letter url=${url} attempts=${attempts} ` +
194
- `lastStatus=${lastStatus ?? 'n/a'} lastError=${lastError} ` +
195
- `type=${event.type} jobExecutionId=${event.jobExecutionId} ` +
196
- `secret_sha256=${fingerprint}`,
197
- );
198
- ```
199
-
200
- The dead-letter is a log line, not a database row. The host
201
- ships its log aggregator (Datadog, CloudWatch, Loki, ...)
202
- to recover dead-lettered URLs. A future v2 may add a
203
- `DeadLetterStore` token; v1 ships the log line only.
204
-
205
- ---
206
-
207
- ## HMAC signature
208
-
209
- Every outbound POST carries a Stripe-style signature header:
210
-
211
- ```
212
- X-Nest-Batch-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>
213
- ```
214
-
215
- Where:
216
-
217
- - `t=<unix>` is the unix-seconds timestamp the signature is
218
- pinned to. The receiver uses it to enforce a replay window
219
- (recommended: 5 minutes). The timestamp is also sent as a
220
- separate `X-Nest-Batch-Timestamp: <unix>` header for
221
- receivers that prefer parsing HTTP headers to parsing the
222
- signature header.
223
- - `v1=<hex>` is the lowercase hex of
224
- `HMAC_SHA256(secret, "<unix>.<raw-body>")`.
225
- - The `<raw-body>` is the EXACT JSON-serialized request body
226
- bytes (not a re-serialization). The receiver MUST HMAC the
227
- body it received, byte-for-byte; the observer does not
228
- re-serialize, so a different key order on the receiver
229
- side will fail verification.
230
-
231
- ### Receiver-side verification
232
-
233
- The package exports `verifyV1` / `parseSignatureHeader` from
234
- `@nest-batch/webhook` so a Node receiver can verify the
235
- signature without re-implementing the crypto:
236
-
237
- ```ts
238
- import {
239
- parseSignatureHeader,
240
- verifyV1,
241
- SIGNATURE_HEADER_NAME,
242
- } from '@nest-batch/webhook';
243
-
244
- app.post('/hook', (req, res) => {
245
- const header = String(req.headers[SIGNATURE_HEADER_NAME.toLowerCase()]);
246
- const raw = req.rawBody; // capture in your body parser
247
- const { timestamp, v1 } = parseSignatureHeader(header);
248
- if (!verifyV1(process.env.WEBHOOK_HMAC_SECRET, timestamp, raw, v1)) {
249
- return res.status(401).json({ error: 'invalid signature' });
250
- }
251
- // ... accept the envelope
252
- res.status(200).json({ ok: true });
253
- });
254
- ```
255
-
256
- Express users: mount `express.raw({ type: 'application/json' })`
257
- on the webhook route so `req.body` is a `Buffer` (not the
258
- default JSON-parsed object). The v1 signature is over the
259
- **raw bytes**, not the parsed JSON. The example above
260
- assumes the host mounted a raw-body parser that stashes the
261
- bytes on `req.rawBody`.
262
-
263
- ### Why v1?
264
-
265
- The `v1` key is the signature-scheme version, not the
266
- envelope version. A future v2 may add `v2=`-prefixed schemes
267
- (e.g. a SHA-512 variant or a multi-rotation scheme);
268
- receivers MUST reject unknown `vN` keys. The v1 contract is
269
- HMAC-SHA256 over `<unix>.<raw-body>`, lowercase hex, single
270
- field.
271
-
272
- ---
273
-
274
- ## Secret handling
275
-
276
- The HMAC secret is the single most sensitive value the package
277
- handles. The contract:
278
-
279
- - **Host-injection is primary.** Pass `secret` to `forRoot({...})`
280
- at module-build time. The package binds it to the
281
- `WebhookBatchObserver` instance via `WEBHOOK_MODULE_OPTIONS`
282
- (a private `Symbol.for` token); it is not exported, not
283
- injectable, not reachable from any public API.
284
- - **Env fallback is secondary.** If `secret` is omitted,
285
- `forRoot({...})` falls back to `process.env.WEBHOOK_HMAC_SECRET`.
286
- Env is the safety net for hosts that do not want to thread
287
- the secret through their config service explicitly. The
288
- host's `ConfigModule` should set the env var from the
289
- secret manager.
290
- - **Neither is the secret on disk.** The package does not read
291
- a file, does not read a CLI arg, does not read a Vault path.
292
- The host owns the secret source.
293
- - **The secret is never logged.** The dead-letter line emits a
294
- SHA-256 fingerprint (`secret_sha256=abc123...`, first 12 hex
295
- chars) so operators can correlate dead-letters across
296
- services without exposing the secret. The full secret value
297
- is never written to a `logger` call, never serialized into a
298
- dead-letter body, never returned by any public API, never
299
- echoed in a stack trace.
300
-
301
- The pinned acceptance test (T-AC-5) asserts no
302
- `WEBHOOK_HMAC_SECRET` substring (or, equivalently, the
303
- `secret` value the test injected) appears in the captured log
304
- stream across the full 4-attempt retry budget, the 4xx
305
- dead-letter path, the 5xx dead-letter path, the success path,
306
- and the debug path.
307
-
308
- ---
309
-
310
- ## Launcher-only deployment — events do NOT fire without a worker
311
-
312
- `@nest-batch/webhook` is a `BatchObserver` — it consumes the
313
- event stream the executor / runtime services produce. The
314
- event stream only fires when the host has wired a transport
315
- **with** a running consumer:
316
-
317
- - `BullmqAdapter.forRoot({ autoStartWorker: true })` — events
318
- fire (the worker drives the lifecycle and the
319
- `QueueEvents` bridge fans them out).
320
- - `BullmqAdapter.forRoot({ autoStartWorker: false })` — **no
321
- events fire.** The launcher enqueues; no worker consumes;
322
- the lifecycle never reaches `COMPLETED` / `FAILED`; the
323
- observer never sees anything.
324
- - `InProcessAdapter.forRoot()` — events fire (the strategy
325
- runs the lifecycle in-process).
326
- - `KafkaAdapter.forRoot({ autoStartConsumer: true })` — events
327
- fire (the consumer drives the lifecycle and the consumer
328
- bridge fans them out).
329
- - `KafkaAdapter.forRoot({ autoStartConsumer: false })` — **no
330
- events fire.** Same reason as the BullMQ launcher-only case.
331
-
332
- This is the v1 contract. A launcher-only deployment (an API
333
- service that only enqueues) does NOT need to install
334
- `@nest-batch/webhook` — the observer would be dead code.
335
-
336
- ---
337
-
338
- ## What is NOT in this package
339
-
340
- - A persistence adapter. Use `@nest-batch/mikro-orm`,
341
- `@nest-batch/typeorm`, `@nest-batch/drizzle`, or
342
- `@nest-batch/prisma` to wire a `JobRepository`. The observer
343
- is event-stream-only; it does not read or write any
344
- `JobExecution` rows.
345
- - A batch engine. Job / Step / Chunk / Tasklet semantics,
346
- checkpoint, restart, skip, business retry, and the event
347
- stream itself live in
348
- [`@nest-batch/core`](../core). The observer is the
349
- downstream consumer.
350
- - A transport. Use `@nest-batch/bullmq` or
351
- `@nest-batch/kafka` to drive the lifecycle. The observer
352
- does not enqueue or consume.
353
- - A retry-policy module. The fixed `[1s, 5s, 25s, 125s]`
354
- backoff is the v1 contract. A future v2 may add a
355
- `retryDelaysMs` override; v1 ships the fixed schedule only.
356
- - A dead-letter database. The dead-letter is a `logger.warn`
357
- line; a future v2 may add a `DeadLetterStore` token.
358
- - A scheduler. Cron-style scheduling lives in
359
- `@nest-batch/core`'s `@BatchScheduled` decorator. The
360
- observer does not fire on a timer; it fires on the
361
- executor's event stream.
362
- - An admin UI, metrics backend, or tracing backend. Hook a
363
- different `BatchObserver` (or extend this one) to ship
364
- events where you need them.
365
- - Alternative HTTP transports (e.g. webhook-over-mqtt, gRPC
366
- webhooks). The observer uses HTTP POST + HMAC-SHA256. A
367
- future sibling package could ship a webhook-over-mqtt
368
- observer that implements the same `BatchObserver`
369
- contract.
370
-
371
- ---
372
-
373
- ## Scripts
374
-
375
- ```bash
376
- pnpm --filter @nest-batch/webhook build # SWC transpile + tsc declarations
377
- pnpm --filter @nest-batch/webhook test # vitest run (T-AC-5; see env note below)
378
- pnpm --filter @nest-batch/webhook test:watch # vitest watch
379
- pnpm --filter @nest-batch/webhook typecheck # tsc --noEmit
380
- ```
381
-
382
- The T-AC-5 test
383
- ([`tests/webhook-observer.test.ts`](./tests/webhook-observer.test.ts))
384
- uses `WEBHOOK_TEST_FAST=1` to override the retry schedule to
385
- milliseconds. Run it with:
386
-
387
- ```bash
388
- WEBHOOK_TEST_FAST=1 pnpm --filter @nest-batch/webhook test -- tests/webhook-observer.test.ts --reporter=verbose
389
- ```
390
-
391
- The full `pnpm test` run uses the same env var (set at the
392
- top of the test file) so the suite finishes in <1s. The test
393
- stands up a real `http.createServer().listen(0)` server on a
394
- random port; no external service (Redis, Postgres) is
395
- required.
47
+ The default subscription set is job completed, job failed, and step failed.
48
+ HTTP 4xx responses are treated as configuration failures and are not retried.
49
+ HTTP 5xx responses, network errors, and timeouts are retried with bounded
50
+ backoff.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nest-batch/webhook",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Webhook delivery observer for @nest-batch/core. Subscribes to BATCH_EVENT.* and POSTs HMAC-SHA256-signed JSON envelopes to one or more URLs with exponential-backoff retry and dead-letter logging. Uses native fetch (Node 20+); no HTTP client peer dep.",
5
5
  "license": "MIT",
6
6
  "author": "easdkr",
@@ -27,7 +27,8 @@
27
27
  "files": [
28
28
  "dist/src",
29
29
  "src",
30
- "README.md"
30
+ "README.md",
31
+ "README.ko.md"
31
32
  ],
32
33
  "publishConfig": {
33
34
  "access": "public"
@@ -35,7 +36,7 @@
35
36
  "peerDependencies": {
36
37
  "@nestjs/common": "^10 || ^11",
37
38
  "@nestjs/core": "^10 || ^11",
38
- "@nest-batch/core": "^0.2.0"
39
+ "@nest-batch/core": "^0.2.4"
39
40
  },
40
41
  "peerDependenciesMeta": {
41
42
  "@nest-batch/core": {
@@ -58,7 +59,7 @@
58
59
  "typescript": "^5.5.0",
59
60
  "unplugin-swc": "^1.5.0",
60
61
  "vitest": "^2.0.0",
61
- "@nest-batch/core": "0.2.0"
62
+ "@nest-batch/core": "0.2.4"
62
63
  },
63
64
  "scripts": {
64
65
  "build": "swc src -d dist --config-file ../../.swcrc && tsc --emitDeclarationOnly -p tsconfig.build.json",