@rotorsoft/act-http 0.2.0 → 1.1.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.
Files changed (59) hide show
  1. package/README.md +214 -110
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/receiver/check.d.ts +66 -0
  4. package/dist/@types/receiver/check.d.ts.map +1 -0
  5. package/dist/@types/receiver/express/index.d.ts +51 -0
  6. package/dist/@types/receiver/express/index.d.ts.map +1 -0
  7. package/dist/@types/receiver/extract.d.ts +24 -0
  8. package/dist/@types/receiver/extract.d.ts.map +1 -0
  9. package/dist/@types/receiver/fastify/index.d.ts +55 -0
  10. package/dist/@types/receiver/fastify/index.d.ts.map +1 -0
  11. package/dist/@types/receiver/hono/index.d.ts +60 -0
  12. package/dist/@types/receiver/hono/index.d.ts.map +1 -0
  13. package/dist/@types/receiver/index.d.ts +39 -0
  14. package/dist/@types/receiver/index.d.ts.map +1 -0
  15. package/dist/@types/receiver/start.d.ts +48 -0
  16. package/dist/@types/receiver/start.d.ts.map +1 -0
  17. package/dist/@types/receiver/trpc/index.d.ts +16 -0
  18. package/dist/@types/receiver/trpc/index.d.ts.map +1 -0
  19. package/dist/@types/receiver/verify.d.ts +57 -0
  20. package/dist/@types/receiver/verify.d.ts.map +1 -0
  21. package/dist/@types/webhook/classify.d.ts +59 -0
  22. package/dist/@types/webhook/classify.d.ts.map +1 -0
  23. package/dist/@types/webhook/index.d.ts +3 -2
  24. package/dist/@types/webhook/index.d.ts.map +1 -1
  25. package/dist/@types/webhook/sign.d.ts +25 -0
  26. package/dist/@types/webhook/sign.d.ts.map +1 -0
  27. package/dist/@types/webhook/types.d.ts +61 -20
  28. package/dist/@types/webhook/types.d.ts.map +1 -1
  29. package/dist/chunk-F7VWYZ37.js +29 -0
  30. package/dist/chunk-F7VWYZ37.js.map +1 -0
  31. package/dist/chunk-NOIXOF2I.js +78 -0
  32. package/dist/chunk-NOIXOF2I.js.map +1 -0
  33. package/dist/dist-NWMJQI4E.js +647 -0
  34. package/dist/dist-NWMJQI4E.js.map +1 -0
  35. package/dist/receiver/express/index.cjs +128 -0
  36. package/dist/receiver/express/index.cjs.map +1 -0
  37. package/dist/receiver/express/index.js +33 -0
  38. package/dist/receiver/express/index.js.map +1 -0
  39. package/dist/receiver/fastify/index.cjs +120 -0
  40. package/dist/receiver/fastify/index.cjs.map +1 -0
  41. package/dist/receiver/fastify/index.js +25 -0
  42. package/dist/receiver/fastify/index.js.map +1 -0
  43. package/dist/receiver/hono/index.cjs +123 -0
  44. package/dist/receiver/hono/index.cjs.map +1 -0
  45. package/dist/receiver/hono/index.js +8 -0
  46. package/dist/receiver/hono/index.js.map +1 -0
  47. package/dist/receiver/index.cjs +2943 -0
  48. package/dist/receiver/index.cjs.map +1 -0
  49. package/dist/receiver/index.js +2162 -0
  50. package/dist/receiver/index.js.map +1 -0
  51. package/dist/receiver/trpc/index.cjs +126 -0
  52. package/dist/receiver/trpc/index.cjs.map +1 -0
  53. package/dist/receiver/trpc/index.js +31 -0
  54. package/dist/receiver/trpc/index.js.map +1 -0
  55. package/dist/webhook/index.cjs +66 -6
  56. package/dist/webhook/index.cjs.map +1 -1
  57. package/dist/webhook/index.js +62 -6
  58. package/dist/webhook/index.js.map +1 -1
  59. package/package.json +47 -3
package/README.md CHANGED
@@ -4,30 +4,37 @@
4
4
  [![NPM Downloads](https://img.shields.io/npm/dm/@rotorsoft/act-http.svg)](https://www.npmjs.com/package/@rotorsoft/act-http)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- HTTP integrations for [@rotorsoft/act](https://www.npmjs.com/package/@rotorsoft/act) event-sourced apps.
7
+ _HTTP integrations for [@rotorsoft/act](https://www.npmjs.com/package/@rotorsoft/act) — outbound webhooks and incremental state broadcast over Server-Sent Events._
8
8
 
9
- > **Stability:** Public API governed by the [Act Stability Charter](../../STABILITY.md). Charter takes effect at 1.0 (gated on [milestone 1.0](https://github.com/Rotorsoft/act-root/milestone/1)).
9
+ > **Note.** This package consolidates the SSE integration that previously shipped as standalone `@rotorsoft/act-sse`. New projects should install this umbrella and import from the `/sse` subpath; existing `@rotorsoft/act-sse` users can migrate with a one-import change. See [@rotorsoft/act-sse](https://www.npmjs.com/package/@rotorsoft/act-sse) for the deprecation note + migration steps.
10
+
11
+ ## Why this package
12
+
13
+ Most Act apps reach beyond their own process eventually — POSTing committed events to a downstream service, broadcasting state to a live UI, or both. The patterns are different (outbound HTTP vs long-lived `text/event-stream`), but they share a transport (HTTP) and an integration mental model ("Act over the wire"). Combining them under one umbrella with subpath exports gives you one install + one mental model, without conflating two implementations.
14
+
15
+ `webhook()` is sugar on top of `.do(handler, { backoff })` — the same `fetch` wrapper most teams end up writing (timeout, idempotency key, status-classified errors, JSON serialization). The SSE surface is the verbatim continuation of `@rotorsoft/act-sse`. Nothing in `webhook` depends on `sse` or vice versa — pay only for what you import.
10
16
 
11
17
  ## Installation
12
18
 
13
- ```sh
19
+ ```bash
14
20
  pnpm add @rotorsoft/act-http
15
21
  ```
16
22
 
17
- Two subpath exports, picked at import time so you only pay for what you use:
23
+ Three independent subpath exports:
18
24
 
19
25
  | Import path | What you get |
20
26
  |---|---|
21
- | `@rotorsoft/act-http/webhook` | `webhook()` — reaction handler that POSTs committed events to an external URL with timeout, auto idempotency key, and status-classified errors. |
22
- | `@rotorsoft/act-http/sse` | `BroadcastChannel`, `PresenceTracker`, `StateCache`, `applyPatchMessage` — server-side broadcast + client-side patch applicator for incremental state sync over Server-Sent Events. |
27
+ | `@rotorsoft/act-http/webhook` | `webhook()` — reaction handler that POSTs committed events with timeout, auto `Idempotency-Key`, and status-classified errors. |
28
+ | `@rotorsoft/act-http/sse` | `BroadcastChannel`, `PresenceTracker`, `StateCache`, `applyPatchMessage` — server-side broadcast + client-side patch applicator for incremental state sync. |
29
+ | `@rotorsoft/act-http/receiver` | `receiver()` builder (high-level Hono-backed runtime) + `extractIdempotencyKey` + `verifyWebhook` + `checkWebhook` (framework-agnostic core composing both with `IdempotencyStore.claim`). |
30
+ | `@rotorsoft/act-http/receiver/trpc` | `webhookMiddleware` — tRPC middleware adapter. |
31
+ | `@rotorsoft/act-http/receiver/express` | `webhookMiddleware` — Express middleware adapter. |
32
+ | `@rotorsoft/act-http/receiver/fastify` | `webhookMiddleware` — Fastify `preHandler` adapter. |
33
+ | `@rotorsoft/act-http/receiver/hono` | `webhookMiddleware` — Hono middleware adapter. |
23
34
 
24
- The two are independent; nothing in `webhook` depends on `sse` or vice versa.
35
+ ## Quick start
25
36
 
26
- ---
27
-
28
- ## `webhook` — outbound HTTP from reactions
29
-
30
- Sugar on top of `.do(handler, { backoff })` from ACT-601. Drops the same `fetch` wrapper most teams end up writing: timeout, `Idempotency-Key`, JSON serialization, and status-coded errors.
37
+ ### `webhook` — outbound POST from a reaction
31
38
 
32
39
  ```ts
33
40
  import { webhook } from "@rotorsoft/act-http/webhook";
@@ -48,80 +55,182 @@ import { webhook } from "@rotorsoft/act-http/webhook";
48
55
  .to(resolver)
49
56
  ```
50
57
 
51
- ### Behavior
58
+ ### `receiver` — high-level builder (the canonical path)
52
59
 
53
- | Outcome | Thrown | Drain behavior |
54
- |---|---|---|
55
- | 2xx response | (resolves) | — |
56
- | 5xx response | `WebhookError` | retries per `maxRetries` + `backoff` |
57
- | Network error | `WebhookError` (`status: 0`) | retries per `maxRetries` + `backoff` |
58
- | Timeout | `WebhookError` (`status: 0`, abort) | retries per `maxRetries` + `backoff` |
59
- | 4xx response | `NonRetryableWebhookError` | **blocks on first attempt** (when `blockOnError` is true) |
60
+ The `receiver` builder from `@rotorsoft/act-http/receiver` is Hono-backed and runs on every fetch-shaped runtime — long-running Node (via `.listen()`), AWS Lambda, Cloudflare Workers, Vercel Edge, Bun, Deno (all via `.fetch()`). Declare typed handlers with Zod schemas, call `.build()`, and the runtime handles signature verification, dedup, raw-body capture, schema validation, and HTTP server lifecycle:
60
61
 
61
- The two-class split is the retry signal — `NonRetryableWebhookError` extends [`NonRetryableError`](https://github.com/Rotorsoft/act-root/blob/master/libs/act/src/types/errors.ts) (from `@rotorsoft/act`), which the drain finalizer recognizes and treats as "block immediately, no more retries." Permanent client errors don't burn the retry budget or pace through the backoff window.
62
+ ```ts
63
+ import { receiver } from "@rotorsoft/act-http/receiver";
64
+ import { InMemoryIdempotencyStore } from "@rotorsoft/act-ops/idempotency";
65
+ import { z } from "zod";
66
+
67
+ const escalations = receiver({
68
+ port: 4001,
69
+ store: new InMemoryIdempotencyStore(),
70
+ secret: process.env.WEBHOOK_SECRET,
71
+ })
72
+ .on("OrderConfirmed", z.object({ orderId: z.string(), total: z.number() }),
73
+ async (event, ctx) => { await processOrder(event.orderId, event.total); })
74
+ .build();
75
+
76
+ await escalations.listen(); // Node
77
+ // export default { fetch: escalations.fetch }; // Cloudflare / Vercel / Bun / Deno
78
+ ```
62
79
 
63
- A `NonRetryableWebhookError` does not override `blockOnError: false`. If the operator explicitly chose "retry forever," the framework respects that.
80
+ Naming convention: type `Receiver` (PascalCase), factory `receiver` (lowercase) matches Act's existing `act` / `state` / `slice` / `projection` builder analogs.
64
81
 
65
- ### Idempotency key
82
+ ### `receiver/<framework>` — low-level middleware
66
83
 
67
- The helper sets `Idempotency-Key: <event.id>` by default. `event.id` is the framework's immutable, per-event monotonic integer perfect for downstream dedup. Override with a function that returns a string (or `null` to skip the header):
84
+ When the receiver needs to compose with an existing HTTP stack (auth middleware, route-level rate limiting, an app already serving other routes), reach for the per-framework `webhookMiddleware` factories. They compose `extractIdempotencyKey` + `verifyWebhook` + `IdempotencyStore.claim` and translate the result into the framework's idiomatic 400/401 response:
68
85
 
69
86
  ```ts
70
- webhook({
71
- url: "...",
72
- idempotencyKey: (event) => `${event.stream}-${event.id}`,
73
- })
87
+ // tRPC
88
+ import { webhookMiddleware } from "@rotorsoft/act-http/receiver/trpc";
89
+ import { InMemoryIdempotencyStore } from "@rotorsoft/act-ops/idempotency";
90
+
91
+ const dedup = new InMemoryIdempotencyStore();
92
+ const idempotent = t.procedure.use(
93
+ webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET })
94
+ );
95
+
96
+ const router = t.router({
97
+ webhook: idempotent.input(Schema).mutation(({ input, ctx }) => {
98
+ const { key, deduped } = ctx.idempotency;
99
+ if (deduped) return { status: "dedup-skipped", key };
100
+ return { status: "processed", key };
101
+ }),
102
+ });
74
103
  ```
75
104
 
76
- A caller-supplied `Idempotency-Key` header (case-insensitive) always wins; the auto-derived value is only applied when the header is absent.
105
+ Each adapter follows the same shape:
77
106
 
78
- ### Configuration
107
+ ```ts
108
+ import { webhookMiddleware } from "@rotorsoft/act-http/receiver/express";
109
+ app.post("/webhook", webhookMiddleware({ store, secret }), (req, res) => {
110
+ const { key, deduped } = (req as any).idempotency;
111
+ // …
112
+ });
113
+ ```
79
114
 
80
- | Option | Type | Default |
115
+ ```ts
116
+ import { webhookMiddleware } from "@rotorsoft/act-http/receiver/fastify";
117
+ app.post("/webhook", { preHandler: webhookMiddleware({ store, secret }) }, async (req) => {
118
+ const { key, deduped } = (req as any).idempotency;
119
+ // …
120
+ });
121
+ ```
122
+
123
+ ```ts
124
+ import { webhookMiddleware } from "@rotorsoft/act-http/receiver/hono";
125
+ app.post("/webhook", webhookMiddleware({ store, secret }), (c) => {
126
+ const { key, deduped } = c.get("idempotency");
127
+ // …
128
+ });
129
+ ```
130
+
131
+ On failure: the adapter responds with the framework's idiomatic 400 (`missing-key`) or 401 (one of five verification reasons — `missing-signature`, `missing-timestamp`, `stale`, `future`, `bad-signature`) and short-circuits the handler. On success: `{ key, deduped }` is injected into the request context.
132
+
133
+ ### `receiver` primitives — when neither builder nor middleware fits
134
+
135
+ The framework-agnostic core (`checkWebhook`) and the underlying primitives (`extractIdempotencyKey`, `verifyWebhook`) are exported from `@rotorsoft/act-http/receiver` for receivers whose framework isn't in the adapter list (Koa, raw Node `http`, gRPC-over-HTTP, …) or for receivers with custom policy (e.g. "missing key falls back to body-derived dedup"). Use the `receiver` builder when you can; fall back to the framework `webhookMiddleware`, then the primitives.
136
+
137
+ ### `sse` — live state broadcast
138
+
139
+ ```ts
140
+ import { BroadcastChannel, applyPatchMessage } from "@rotorsoft/act-http/sse";
141
+
142
+ // Server: after every app.do()
143
+ const snaps = await app.do(action, target, payload);
144
+ const patches = snaps.map((s) => s.patch).filter(Boolean);
145
+ const state = deriveState(snaps.at(-1)!);
146
+ broadcast.publish(streamId, state, patches);
147
+
148
+ // Client: in your SSE onData handler
149
+ onData: (msg) => {
150
+ const cached = utils.getState.getData({ streamId });
151
+ const result = applyPatchMessage(msg, cached);
152
+ if (result.ok) utils.getState.setData({ streamId }, result.state);
153
+ else if (result.reason === "behind") utils.getState.invalidate({ streamId });
154
+ };
155
+ ```
156
+
157
+ ## API
158
+
159
+ ### `/webhook` subpath
160
+
161
+ - **`webhook(config)`** — reaction-handler factory. Returns a function compatible with `.do(handler, opts)`.
162
+ - **`tryOk(response, { url, label? })`** — collapses the classify-and-throw block to one line for **custom HTTP-like reactions** (gRPC bridges, SDK-based deliveries). Returns void on 2xx; throws `RetryableHttpError` on 5xx; throws `NonRetryableHttpError` on 3xx/4xx. Captures the response body (best-effort) onto the thrown error.
163
+ - **`classifyHttpResponse(response)`** — the underlying `"ok" | "retry" | "block"` classifier. Reach for it directly when you need custom error classes; otherwise `tryOk` wraps it.
164
+ - **`RetryableHttpError`** — generic retryable delivery error. Extends `Error`. Thrown by `tryOk` on 5xx. `WebhookError` extends it.
165
+ - **`NonRetryableHttpError`** — generic non-retryable delivery error. Extends `NonRetryableError` from `@rotorsoft/act`, so the drain finalizer blocks the stream on first failed attempt. Thrown by `tryOk` on 3xx/4xx. `NonRetryableWebhookError` extends it.
166
+ - **`WebhookError`** — webhook-specific subclass of `RetryableHttpError`, thrown by the `webhook` helper. Existing `instanceof WebhookError` checks continue to work; new code targeting any HTTP integration can catch `RetryableHttpError` to handle both webhook + custom-integration errors uniformly.
167
+ - **`NonRetryableWebhookError`** — webhook-specific subclass of `NonRetryableHttpError`, thrown by `webhook` on 3xx/4xx. Same backward-compat story as `WebhookError`.
168
+ - **`WebhookConfig`** — TypeScript type for the helper options.
169
+ - **`HttpDisposition`** — the `"ok" | "retry" | "block"` discriminator returned by `classifyHttpResponse`.
170
+ - **`HttpDeliveryErrorInit`** — common `{ status, url, responseBody? }` shape passed to every HTTP error class.
171
+ - **`TryOkOptions`** — `{ url, label? }` shape passed to `tryOk`.
172
+
173
+ ### `/receiver` subpath
174
+
175
+ - **`checkWebhook(headers, body, options)`** — framework-agnostic core. Composes `verifyWebhook` (when `options.secret` is set) + `extractIdempotencyKey` + `options.store.claim`. Returns `{ ok: false; status: 400|401; reason }` on failure or `{ ok: true; key; deduped }` on success. The per-framework adapters wrap this and translate the outcome into the framework's idiomatic response.
176
+ - **`extractIdempotencyKey(headers)`** — case-insensitive `Idempotency-Key` header parser. Returns `undefined` when the header carries no usable key: missing, array-valued (ambiguous), or empty string. Validation beyond "is there a usable key?" (length, format) is intentionally out of scope.
177
+ - **`verifyWebhook(headers, body, secret, opts?)`** — HMAC-SHA256 signature + timestamp window verifier. Returns `{ ok: true }` or `{ ok: false; reason }` where reason is one of `missing-signature` / `missing-timestamp` / `stale` / `future` / `bad-signature`. Default timestamp window is ±300 seconds; override via `opts.maxAgeSeconds`. Uses `crypto.timingSafeEqual` to avoid timing attacks. Pair with `webhook({ secret })` on the sender side.
178
+ - **Types**: `CheckResult`, `CheckWebhookOptions`, `CheckFailureReason`, `VerifyResult`, `VerifyOptions`.
179
+
180
+ ### `/receiver/<framework>` subpaths
181
+
182
+ Each framework adapter exports a single function `webhookMiddleware(options)` that returns the framework's native middleware shape. Options are `{ store, secret?, verify? }` — the same `CheckWebhookOptions` as the core. Failure → 400/401 with `{ error: <reason> }`; success → `{ key, deduped }` is injected:
183
+
184
+ | Subpath | Injection site | Failure response |
81
185
  |---|---|---|
82
- | `url` | `string \| (event) => string` | required |
83
- | `method` | `"POST" \| "PUT" \| "PATCH" \| "DELETE"` | `"POST"` |
84
- | `headers` | `Record<string, string> \| (event) => Record<string, string>` | `{}` |
85
- | `body` | `unknown \| (event) => unknown` | the committed event (JSON-serialized) |
86
- | `timeoutMs` | `number` | `5000` |
87
- | `idempotencyKey` | `(event) => string \| null` | `String(event.id)` |
88
- | `fetch` | `typeof fetch` | `globalThis.fetch` |
186
+ | `/receiver/trpc` | `ctx.idempotency` | throws `TRPCError({ code, message: reason })` |
187
+ | `/receiver/express` | `req.idempotency` | `res.status(...).json({ error: reason })` |
188
+ | `/receiver/fastify` | `request.idempotency` | `reply.status(...).send({ error: reason })` |
189
+ | `/receiver/hono` | `c.get("idempotency")` (typed via `Variables`) | `c.json({ error: reason }, status)` |
89
190
 
90
- Strings as `body` are sent as-is. Anything else is `JSON.stringify`'d, and `Content-Type: application/json` is set automatically (unless the caller supplies their own).
191
+ ### `/sse` subpath
91
192
 
92
- ---
193
+ - **`BroadcastChannel<S>`** — server-side broadcast manager with per-stream subscriber sets and an LRU state cache.
194
+ - **`PresenceTracker`** — ref-counted online-status tracker for multi-tab clients.
195
+ - **`StateCache<S>`** — the generic LRU used internally by `BroadcastChannel`.
196
+ - **`applyPatchMessage(msg, cached)`** — client-side patch applicator. Returns `{ ok: true, state }` or `{ ok: false, reason: "stale" | "behind" }`.
197
+ - **`patch(original, patches)`** — browser-safe deep-merge utility (re-exported from `@rotorsoft/act-patch`).
198
+ - **Types**: `BroadcastState`, `PatchMessage<S>`, `Subscriber<S>`.
93
199
 
94
- ### When `webhook` is the right tool — and when it isn't
200
+ ## Configuration
95
201
 
96
- `webhook` is built for **fire-and-forget delivery to a cooperative receiver**: timeouts shorter than the drain lease, retries paced by `backoff`, and idempotent endpoints that can absorb the occasional duplicate.
202
+ ### `webhook` options
97
203
 
98
- > **Keep `timeoutMs` below `leaseMillis`.** The drain lease is what stops competing workers from re-dispatching while your handler is still in flight. The default lease is a few seconds; the default `timeoutMs` is `5000`. If you set `timeoutMs` to something approaching or exceeding the lease, a slow receiver can hold the lease through expiry, at which point another worker will claim the stream and dispatch the same event in parallel. The downstream `Idempotency-Key` then becomes load-bearing — if your receiver doesn't deduplicate, you'll deliver twice.
99
- >
100
- > Concretely: keep `timeoutMs leaseMillis - safety_margin`. If you need a longer window, bump `leaseMillis` globally on the Act options.
204
+ | Option | Type | Default |
205
+ |---|---|---|
206
+ | `url` | `string` or `(event) => string` | required |
207
+ | `method` | `"POST" | "PUT" | "PATCH" | "DELETE"` | `"POST"` |
208
+ | `headers` | `Record<string, string>` or `(event) => …` | `{}` |
209
+ | `body` | `unknown` or `(event) => unknown` | the committed event (JSON-serialized) |
210
+ | `timeoutMs` | `number` | `5000` |
211
+ | `idempotencyKey` | `(event) => string | null` | `String(event.id)` |
212
+ | `secret` | `string` | unset (unsigned) |
213
+ | `fetch` | `typeof fetch` | `globalThis.fetch` |
101
214
 
102
- **For heavy or long-running delivery, don't use `webhook`.** Drain leases aren't free, and holding one for tens of seconds while a slow API churns is the wrong shape. The Act-native pattern is an **outbox-style fan-out**: emit a "needs delivery" event from your reaction (a cheap, local operation), and let a separate consumer — a downstream worker, a Kafka/SQS pipeline, an external scheduler — pick it up and do the long work. Drain stays responsive; the slow path runs at its own pace.
215
+ Strings as `body` are sent as-is; anything else is `JSON.stringify`'d and `Content-Type: application/json` is set automatically (unless the caller supplies it).
103
216
 
104
- | Shape of work | Right tool |
105
- |---|---|
106
- | 1–2s POST to a fast, idempotent API | `webhook` directly |
107
- | Webhook to a flaky-but-fast third party | `webhook` + aggressive `backoff` |
108
- | Multi-second / multi-minute API call | Emit an event, drain hands off to a bus; bus worker calls the API |
109
- | Bulk fan-out (10k+ receivers) | Emit a "fanout" event, let a dedicated consumer enumerate receivers |
110
- | Streaming / long-poll / large file transfer | Not `webhook` — write a dedicated worker |
217
+ A caller-supplied `Idempotency-Key` header (case-insensitive) always wins; the auto-derived `event.id` is only applied when the header is absent. `event.id` is the framework's immutable, per-event monotonic integer — well-suited to downstream dedup.
111
218
 
112
- See the forthcoming [external integration guide](https://rotorsoft.github.io/act-root/docs/guides/external-integration) (ACT-603) for the outbox pattern in detail.
219
+ When `secret` is set, the helper signs each request with HMAC-SHA256 over `${timestamp}.${body}` (the final serialized body) and attaches `X-Webhook-Signature: sha256=<hex>` + `X-Webhook-Timestamp: <unix-seconds>`. Caller-supplied versions of either header (case-insensitive) win, the same way the `Idempotency-Key` and `Content-Type` defaults yield to caller intent. Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on the receiving side — the protocol matches Stripe / GitHub / Slack conventions modulo the `X-Webhook-*` prefix.
113
220
 
114
- ### Retry & block semantics
221
+ ## Common patterns
115
222
 
116
- The drain pipeline retries on `WebhookError` per the reaction's `maxRetries` and paces with `backoff`. It blocks immediately on `NonRetryableWebhookError` (when `blockOnError` is true) — no retry budget consumed.
223
+ ### Retry & block semantics
117
224
 
118
- Common shapes:
225
+ The drain pipeline retries on `WebhookError` per `maxRetries` and paces with `backoff`. It blocks immediately on `NonRetryableWebhookError` (when `blockOnError` is true) — no retry budget consumed.
119
226
 
120
- - **"Be patient with the receiver"** — `{ maxRetries: 5, backoff: { strategy: "exponential", baseMs: 200, maxMs: 30_000, jitter: true } }`. The 80% default. 5xx and network errors retry; 4xx blocks immediately.
121
- - **"Never give up"** — `{ maxRetries: Infinity, blockOnError: false, backoff: {...} }`. For sinks that *must* eventually succeed. 4xx falls back to the same loop because `blockOnError: false` overrides the non-retryable signal.
122
- - **"Strict block on any failure"** `{ maxRetries: 0 }`. Useful for endpoints with strong idempotency where any failed POST warrants operator review.
227
+ | Shape | Config |
228
+ |---|---|
229
+ | **Be patient with the receiver** (the 80% default) | `{ maxRetries: 5, backoff: { strategy: "exponential", baseMs: 200, maxMs: 30_000, jitter: true } }` |
230
+ | **Never give up** | `{ maxRetries: Infinity, blockOnError: false, backoff: {…} }` — for sinks that *must* eventually succeed. 4xx falls back to the same loop. |
231
+ | **Strict — block on any failure** | `{ maxRetries: 0 }` — useful for endpoints with strong idempotency where any failed POST warrants operator review. |
123
232
 
124
- To distinguish retryable from non-retryable webhook failures in catch blocks, check both classes (or check the shared `status` field):
233
+ In catch blocks, distinguish retryable from non-retryable via the two classes (or the shared `status` field):
125
234
 
126
235
  ```ts
127
236
  try {
@@ -135,80 +244,75 @@ try {
135
244
  }
136
245
  ```
137
246
 
138
- Generic catch sites can detect any handler-signaled permanent failure via `NonRetryableError` (the base class exported from `@rotorsoft/act`).
247
+ Generic catch sites that don't care about HTTP specifics can match on the base `NonRetryableError` from `@rotorsoft/act`.
139
248
 
140
249
  ### Recovering a blocked stream
141
250
 
142
- When the helper blocks a stream — whether on first attempt (4xx → `NonRetryableWebhookError`) or after exhausting `maxRetries` — the operator's recovery path is `app.unblock(input)` from `@rotorsoft/act`. It clears the blocked flag and resumes from where the stream stopped, *not* from the beginning. Don't use `app.reset()` for this — `reset` rebuilds from event 0 and would re-fire every historical webhook.
251
+ When `webhook` blocks a stream — whether on first attempt (4xx) or after exhausting retries — the operator's recovery path is `app.unblock(input)` from `@rotorsoft/act`. It clears the blocked flag and resumes from where the stream stopped, *not* from the beginning. Don't use `app.reset()` — `reset` rebuilds from event 0 and would re-fire every historical webhook.
143
252
 
144
253
  ```ts
145
- // Single targeted recovery, by name.
146
- await app.unblock(["webhooks-out-customer-42"]);
147
-
148
- // Bulk recovery — every blocked stream in the webhook family.
149
- await app.unblock({ stream: "^webhooks-out-" });
254
+ await app.unblock(["webhooks-out-customer-42"]); // by name
255
+ await app.unblock({ stream: "^webhooks-out-" }); // bulk by pattern
150
256
  ```
151
257
 
152
- To discover what's currently blocked first, use `app.blocked_streams()`:
258
+ Use `app.blocked_streams()` to discover what's currently blocked.
259
+
260
+ ### SSE wire format
261
+
262
+ Version-keyed domain patches; keys are the state version *after* the patch applies:
153
263
 
154
264
  ```ts
155
- const blocked = await app.blocked_streams();
156
- // → [{ stream, source, at, retry, blocked: true, error, ... }, …]
265
+ {
266
+ "5": { territories: { brazil: { armies: 3 } } },
267
+ "6": { currentPlayerIndex: 2, phase: "reinforce" }
268
+ }
157
269
  ```
158
270
 
159
- ---
271
+ Multi-event commits produce multiple entries in one message. Version gaps trigger full state refetch on the client (`applyPatchMessage` returns `{ ok: false, reason: "behind" }`).
160
272
 
161
- ## `sse` — incremental state broadcast
273
+ ## When `webhook` is the right tool and when it isn't
162
274
 
163
- Server-Sent Events for live UIs. Sends only the domain patches your event handlers compute, not the full state on every update.
275
+ `webhook` is built for **fire-and-forget delivery to a cooperative receiver**: timeouts shorter than the drain lease, retries paced by `backoff`, idempotent endpoints.
164
276
 
165
- ```ts
166
- import { BroadcastChannel, applyPatchMessage } from "@rotorsoft/act-http/sse";
277
+ **Keep `timeoutMs` below `leaseMillis`.** The drain lease stops competing workers from re-dispatching while your handler is in flight. The default lease is a few seconds; the default `timeoutMs` is `5000`. If `timeoutMs` approaches or exceeds the lease, a slow receiver can hold the lease through expiry, another worker claims the stream, and the same event POSTs twice. The downstream `Idempotency-Key` then becomes load-bearing — if your receiver doesn't dedup, you'll deliver twice. Rule: `timeoutMs ≤ leaseMillis - safety_margin`.
167
278
 
168
- // Server: after every app.do()
169
- const snaps = await app.do(action, target, payload);
170
- const patches = snaps.map(s => s.patch).filter(Boolean);
171
- const state = deriveState(snaps.at(-1));
172
- broadcast.publish(streamId, state, patches);
173
-
174
- // Client: in your SSE handler
175
- onData: (msg) => {
176
- const cached = utils.getState.getData({ streamId });
177
- const result = applyPatchMessage(msg, cached);
178
- if (result.ok) utils.getState.setData({ streamId }, result.state);
179
- else if (result.reason === "behind") utils.getState.invalidate({ streamId });
180
- }
181
- ```
279
+ **For heavy or long-running delivery, don't use `webhook` directly.** Drain leases aren't free, and holding one for tens of seconds while a slow API churns is the wrong shape. The Act-native pattern is **outbox-style fan-out**: emit a "needs delivery" event from your reaction (a cheap, local operation), and let a separate consumer — a downstream worker, a Kafka/SQS pipeline, an external scheduler — do the long work at its own pace.
182
280
 
183
- This subpath is a verbatim copy of `@rotorsoft/act-sse`. The standalone package still publishes; this is the consolidation point for HTTP-shaped integrations going forward. See the [SSE module docs](./src/sse/index.ts) for the full surface.
281
+ | Shape of work | Right tool |
282
+ |---|---|
283
+ | 1–2s POST to a fast, idempotent API | `webhook` directly |
284
+ | Webhook to a flaky-but-fast third party | `webhook` + aggressive `backoff` |
285
+ | Multi-second / multi-minute API call | Emit a "needs delivery" event; bus worker calls the API |
286
+ | Bulk fan-out (10k+ receivers) | Emit a "fanout" event; dedicated consumer enumerates receivers |
287
+ | Streaming / long-poll / large file transfer | Not `webhook` — write a dedicated worker |
184
288
 
185
- ### Wire format
289
+ For the recommended receiver-side idempotency contract that pairs with `webhook`, see the [external integration guide](https://rotorsoft.github.io/act-root/docs/guides/external-integration).
186
290
 
187
- ```ts
188
- // Version-keyed domain patches; keys = state version after the patch applies.
189
- {
190
- "5": { territories: { brazil: { armies: 3 } } },
191
- "6": { currentPlayerIndex: 2, phase: "reinforce" }
192
- }
193
- ```
291
+ ## Compatibility
194
292
 
195
- Multi-event commits produce multiple entries. Version gaps trigger full state refetch on the client.
293
+ - **Node**: >=22.18.0
294
+ - **Peer**: `@rotorsoft/act` (workspace version)
295
+ - **Runtime deps**: `@rotorsoft/act-patch` (used by the SSE subpath for state merging)
296
+ - **Module formats**: ESM + CJS, dual subpath exports
297
+ - **Browser**: the `/sse` client-side helpers (`applyPatchMessage`, `patch`, types) are browser-safe and have no Node-specific dependencies
196
298
 
197
- ---
299
+ ## Stability
198
300
 
199
- ## Why an umbrella package
301
+ Public API governed by the [Act Stability Charter](../../STABILITY.md). Both subpaths (`@rotorsoft/act-http/webhook` and `@rotorsoft/act-http/sse`) are covered by the charter. The `sse` subpath hosts the surface formerly published as `@rotorsoft/act-sse`, now deprecated. Charter is **in effect as of 1.0.0**; the milestone tracker is [milestone 1.0](https://github.com/Rotorsoft/act-root/milestone/1).
200
302
 
201
- SSE is HTTP (long-lived `text/event-stream` response). Webhooks are HTTP (outbound POST). They share a transport and an integration mental model, but their code surfaces are disjoint. Combining them under one package with subpath exports gives one install + one mental model ("Act over HTTP"), without conflating the two implementations.
303
+ ## Related packages
202
304
 
203
- Future HTTP-adjacent integrations (OAuth refresh, gRPC-web, signed-webhook senders) will live as additional subpaths rather than separate packages.
305
+ - **[@rotorsoft/act](https://www.npmjs.com/package/@rotorsoft/act)** core framework. `webhook` composes with `.do(handler, { backoff })`; `BroadcastChannel` publishes from `app.do()` snapshots.
306
+ - **[@rotorsoft/act-sse](https://www.npmjs.com/package/@rotorsoft/act-sse)** — predecessor of the `/sse` subpath here. Being deprecated; migrate to `@rotorsoft/act-http/sse`.
307
+ - **[@rotorsoft/act-patch](https://www.npmjs.com/package/@rotorsoft/act-patch)** — the immutable patch utility that powers the SSE state merge.
308
+ - **[@rotorsoft/act-pg](https://www.npmjs.com/package/@rotorsoft/act-pg)** / **[@rotorsoft/act-sqlite](https://www.npmjs.com/package/@rotorsoft/act-sqlite)** — store adapters. `webhook` reactions persist their watermarks through whichever store you've wired.
204
309
 
205
- ## Related
310
+ ## Documentation
206
311
 
207
- - [@rotorsoft/act](https://www.npmjs.com/package/@rotorsoft/act) — core framework
208
- - [@rotorsoft/act-pg](https://www.npmjs.com/package/@rotorsoft/act-pg) — PostgreSQL store
209
- - [Documentation](https://rotorsoft.github.io/act-root/)
210
- - [Examples](https://github.com/rotorsoft/act-root/tree/master/packages)
312
+ - **[External integration patterns](https://rotorsoft.github.io/act-root/docs/guides/external-integration)**inline `webhook` vs forwarded bus, receiver-side idempotency contract, the recovery loop.
313
+ - **[Real-time with SSE](https://rotorsoft.github.io/act-root/docs/concepts/real-time)**concept guide for the `/sse` surface.
314
+ - **[Error handling](https://rotorsoft.github.io/act-root/docs/concepts/error-handling)** — backoff, `NonRetryableError`, blocked streams, `unblock` recovery.
211
315
 
212
316
  ## License
213
317
 
214
- [MIT](https://github.com/rotorsoft/act-root/blob/master/LICENSE)
318
+ MIT