@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.
- package/README.md +214 -110
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/receiver/check.d.ts +66 -0
- package/dist/@types/receiver/check.d.ts.map +1 -0
- package/dist/@types/receiver/express/index.d.ts +51 -0
- package/dist/@types/receiver/express/index.d.ts.map +1 -0
- package/dist/@types/receiver/extract.d.ts +24 -0
- package/dist/@types/receiver/extract.d.ts.map +1 -0
- package/dist/@types/receiver/fastify/index.d.ts +55 -0
- package/dist/@types/receiver/fastify/index.d.ts.map +1 -0
- package/dist/@types/receiver/hono/index.d.ts +60 -0
- package/dist/@types/receiver/hono/index.d.ts.map +1 -0
- package/dist/@types/receiver/index.d.ts +39 -0
- package/dist/@types/receiver/index.d.ts.map +1 -0
- package/dist/@types/receiver/start.d.ts +48 -0
- package/dist/@types/receiver/start.d.ts.map +1 -0
- package/dist/@types/receiver/trpc/index.d.ts +16 -0
- package/dist/@types/receiver/trpc/index.d.ts.map +1 -0
- package/dist/@types/receiver/verify.d.ts +57 -0
- package/dist/@types/receiver/verify.d.ts.map +1 -0
- package/dist/@types/webhook/classify.d.ts +59 -0
- package/dist/@types/webhook/classify.d.ts.map +1 -0
- package/dist/@types/webhook/index.d.ts +3 -2
- package/dist/@types/webhook/index.d.ts.map +1 -1
- package/dist/@types/webhook/sign.d.ts +25 -0
- package/dist/@types/webhook/sign.d.ts.map +1 -0
- package/dist/@types/webhook/types.d.ts +61 -20
- package/dist/@types/webhook/types.d.ts.map +1 -1
- package/dist/chunk-F7VWYZ37.js +29 -0
- package/dist/chunk-F7VWYZ37.js.map +1 -0
- package/dist/chunk-NOIXOF2I.js +78 -0
- package/dist/chunk-NOIXOF2I.js.map +1 -0
- package/dist/dist-NWMJQI4E.js +647 -0
- package/dist/dist-NWMJQI4E.js.map +1 -0
- package/dist/receiver/express/index.cjs +128 -0
- package/dist/receiver/express/index.cjs.map +1 -0
- package/dist/receiver/express/index.js +33 -0
- package/dist/receiver/express/index.js.map +1 -0
- package/dist/receiver/fastify/index.cjs +120 -0
- package/dist/receiver/fastify/index.cjs.map +1 -0
- package/dist/receiver/fastify/index.js +25 -0
- package/dist/receiver/fastify/index.js.map +1 -0
- package/dist/receiver/hono/index.cjs +123 -0
- package/dist/receiver/hono/index.cjs.map +1 -0
- package/dist/receiver/hono/index.js +8 -0
- package/dist/receiver/hono/index.js.map +1 -0
- package/dist/receiver/index.cjs +2943 -0
- package/dist/receiver/index.cjs.map +1 -0
- package/dist/receiver/index.js +2162 -0
- package/dist/receiver/index.js.map +1 -0
- package/dist/receiver/trpc/index.cjs +126 -0
- package/dist/receiver/trpc/index.cjs.map +1 -0
- package/dist/receiver/trpc/index.js +31 -0
- package/dist/receiver/trpc/index.js.map +1 -0
- package/dist/webhook/index.cjs +66 -6
- package/dist/webhook/index.cjs.map +1 -1
- package/dist/webhook/index.js +62 -6
- package/dist/webhook/index.js.map +1 -1
- package/package.json +47 -3
package/README.md
CHANGED
|
@@ -4,30 +4,37 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@rotorsoft/act-http)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
> **
|
|
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
|
-
```
|
|
19
|
+
```bash
|
|
14
20
|
pnpm add @rotorsoft/act-http
|
|
15
21
|
```
|
|
16
22
|
|
|
17
|
-
|
|
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
|
|
22
|
-
| `@rotorsoft/act-http/sse` | `BroadcastChannel`, `PresenceTracker`, `StateCache`, `applyPatchMessage` — server-side broadcast + client-side patch applicator for incremental state sync
|
|
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
|
-
|
|
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
|
-
###
|
|
58
|
+
### `receiver` — high-level builder (the canonical path)
|
|
52
59
|
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
Naming convention: type `Receiver` (PascalCase), factory `receiver` (lowercase) — matches Act's existing `act` / `state` / `slice` / `projection` builder analogs.
|
|
64
81
|
|
|
65
|
-
###
|
|
82
|
+
### `receiver/<framework>` — low-level middleware
|
|
66
83
|
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
105
|
+
Each adapter follows the same shape:
|
|
77
106
|
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
200
|
+
## Configuration
|
|
95
201
|
|
|
96
|
-
`webhook`
|
|
202
|
+
### `webhook` options
|
|
97
203
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
## Common patterns
|
|
115
222
|
|
|
116
|
-
|
|
223
|
+
### Retry & block semantics
|
|
117
224
|
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
146
|
-
await app.unblock(
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
## `
|
|
273
|
+
## When `webhook` is the right tool — and when it isn't
|
|
162
274
|
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
+
## Related packages
|
|
202
304
|
|
|
203
|
-
|
|
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
|
-
##
|
|
310
|
+
## Documentation
|
|
206
311
|
|
|
207
|
-
- [
|
|
208
|
-
- [
|
|
209
|
-
- [
|
|
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
|
-
|
|
318
|
+
MIT
|