@rotorsoft/act-http 1.8.3 → 1.8.4

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 (51) hide show
  1. package/README.md +31 -11
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/api/idempotency.d.ts +10 -3
  4. package/dist/@types/api/idempotency.d.ts.map +1 -1
  5. package/dist/@types/receiver/express/index.d.ts +17 -1
  6. package/dist/@types/receiver/express/index.d.ts.map +1 -1
  7. package/dist/@types/receiver/fastify/index.d.ts +17 -2
  8. package/dist/@types/receiver/fastify/index.d.ts.map +1 -1
  9. package/dist/@types/receiver/finalize.d.ts +34 -0
  10. package/dist/@types/receiver/finalize.d.ts.map +1 -0
  11. package/dist/@types/receiver/hono/index.d.ts +15 -0
  12. package/dist/@types/receiver/hono/index.d.ts.map +1 -1
  13. package/dist/@types/receiver/start.d.ts.map +1 -1
  14. package/dist/@types/receiver/trpc/index.d.ts.map +1 -1
  15. package/dist/api/index.cjs +9 -1
  16. package/dist/api/index.cjs.map +1 -1
  17. package/dist/api/index.js +1 -1
  18. package/dist/{chunk-K4HAOBRF.js → chunk-6YOMQRZO.js} +23 -5
  19. package/dist/chunk-6YOMQRZO.js.map +1 -0
  20. package/dist/{chunk-OQKYUDEK.js → chunk-FIYLGJAE.js} +10 -2
  21. package/dist/{chunk-OQKYUDEK.js.map → chunk-FIYLGJAE.js.map} +1 -1
  22. package/dist/{chunk-4CGAUB5H.js → chunk-NXSPB2NW.js} +20 -2
  23. package/dist/chunk-NXSPB2NW.js.map +1 -0
  24. package/dist/hono/index.cjs +9 -1
  25. package/dist/hono/index.cjs.map +1 -1
  26. package/dist/hono/index.js +1 -1
  27. package/dist/receiver/express/index.cjs +25 -1
  28. package/dist/receiver/express/index.cjs.map +1 -1
  29. package/dist/receiver/express/index.js +11 -3
  30. package/dist/receiver/express/index.js.map +1 -1
  31. package/dist/receiver/fastify/index.cjs +28 -1
  32. package/dist/receiver/fastify/index.cjs.map +1 -1
  33. package/dist/receiver/fastify/index.js +14 -3
  34. package/dist/receiver/fastify/index.js.map +1 -1
  35. package/dist/receiver/hono/index.cjs +36 -2
  36. package/dist/receiver/hono/index.cjs.map +1 -1
  37. package/dist/receiver/hono/index.js +2 -2
  38. package/dist/receiver/index.cjs +38 -2
  39. package/dist/receiver/index.cjs.map +1 -1
  40. package/dist/receiver/index.js +4 -2
  41. package/dist/receiver/index.js.map +1 -1
  42. package/dist/receiver/trpc/index.cjs +45 -6
  43. package/dist/receiver/trpc/index.cjs.map +1 -1
  44. package/dist/receiver/trpc/index.js +31 -8
  45. package/dist/receiver/trpc/index.js.map +1 -1
  46. package/dist/trpc/index.cjs +9 -1
  47. package/dist/trpc/index.cjs.map +1 -1
  48. package/dist/trpc/index.js +1 -1
  49. package/package.json +2 -2
  50. package/dist/chunk-4CGAUB5H.js.map +0 -1
  51. package/dist/chunk-K4HAOBRF.js.map +0 -1
package/README.md CHANGED
@@ -82,7 +82,7 @@ Naming convention: type `Receiver` (PascalCase), factory `receiver` (lowercase)
82
82
 
83
83
  ### `receiver/<framework>` — low-level middleware
84
84
 
85
- 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:
85
+ 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` (a *tentative* claim — see the two-phase note below) and translate the result into the framework's idiomatic 400/401 response:
86
86
 
87
87
  ```ts
88
88
  // tRPC
@@ -107,17 +107,33 @@ Each adapter follows the same shape:
107
107
 
108
108
  ```ts
109
109
  import { webhookMiddleware } from "@rotorsoft/act-http/receiver/express";
110
- app.post("/webhook", webhookMiddleware({ store, secret }), (req, res) => {
111
- const { key, deduped } = (req as any).idempotency;
112
- // …
110
+ app.post("/webhook", webhookMiddleware({ store, secret }), async (req, res) => {
111
+ const { key, deduped, commit, release } = (req as any).idempotency;
112
+ if (deduped) return res.status(204).end();
113
+ try {
114
+ // … process the event …
115
+ await commit(); // success — later retries dedup
116
+ res.status(204).end();
117
+ } catch (err) {
118
+ await release(); // transient — the sender's retry re-processes
119
+ res.status(500).json({ error: "handler-failed" });
120
+ }
113
121
  });
114
122
  ```
115
123
 
116
124
  ```ts
117
125
  import { webhookMiddleware } from "@rotorsoft/act-http/receiver/fastify";
118
- app.post("/webhook", { preHandler: webhookMiddleware({ store, secret }) }, async (req) => {
119
- const { key, deduped } = (req as any).idempotency;
120
- // …
126
+ app.post("/webhook", { preHandler: webhookMiddleware({ store, secret }) }, async (req, reply) => {
127
+ const { key, deduped, commit, release } = (req as any).idempotency;
128
+ if (deduped) return reply.status(204).send();
129
+ try {
130
+ // … process the event …
131
+ await commit();
132
+ return reply.status(204).send();
133
+ } catch (err) {
134
+ await release();
135
+ return reply.status(500).send({ error: "handler-failed" });
136
+ }
121
137
  });
122
138
  ```
123
139
 
@@ -125,11 +141,15 @@ app.post("/webhook", { preHandler: webhookMiddleware({ store, secret }) }, async
125
141
  import { webhookMiddleware } from "@rotorsoft/act-http/receiver/hono";
126
142
  app.post("/webhook", webhookMiddleware({ store, secret }), (c) => {
127
143
  const { key, deduped } = c.get("idempotency");
144
+ // Hono auto-finalizes off the response: a 2xx commits, a 5xx/throw releases.
128
145
  // …
146
+ return c.body(null, 204);
129
147
  });
130
148
  ```
131
149
 
132
- 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.
150
+ 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, commit, release }` is injected into the request context.
151
+
152
+ **Two-phase dedup.** The `claim` a middleware makes is *tentative*: it dedups a concurrent duplicate mid-flight but is not durable until confirmed, so a transient handler failure never claims-then-loses the delivery. **Hono** and **tRPC** wrap the downstream chain and finalize automatically (2xx / resolved → `commit`; 5xx / thrown / `{ ok: false }` → `release`). **Express** and **Fastify** middleware complete before the route handler, so the handler must call `commit()` on success or `release()` on a transient failure — skipping both leaves the claim tentative (dedups concurrent duplicates, expires on TTL, never permanently lost).
133
153
 
134
154
  ### `receiver` primitives — when neither builder nor middleware fits
135
155
 
@@ -305,14 +325,14 @@ onData: (msg) => {
305
325
 
306
326
  ### `/receiver` subpath
307
327
 
308
- - **`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.
328
+ - **`checkWebhook(headers, body, options)`** — framework-agnostic core. Composes `verifyWebhook` (when `options.secret` is set) + `extractIdempotencyKey` + `options.store.claim` (a *tentative* claim). Returns `{ ok: false; status: 400|401; reason }` on failure or `{ ok: true; key; deduped }` on success. The caller owns finalization: `options.store.commit(key)` on handler success, `options.store.release(key)` on a transient failure. The per-framework adapters wrap this and translate the outcome into the framework's idiomatic response.
309
329
  - **`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.
310
330
  - **`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.
311
331
  - **Types**: `CheckResult`, `CheckWebhookOptions`, `CheckFailureReason`, `VerifyResult`, `VerifyOptions`.
312
332
 
313
333
  ### `/receiver/<framework>` subpaths
314
334
 
315
- 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:
335
+ 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, commit, release }` is injected. Hono and tRPC auto-finalize off the downstream outcome; Express and Fastify require the route handler to call `commit()` / `release()` (see the two-phase note above):
316
336
 
317
337
  | Subpath | Injection site | Failure response |
318
338
  |---|---|---|
@@ -329,7 +349,7 @@ Shared utilities consumed by every transport in the auto-generated API umbrella
329
349
  - **`ApiError`** — uniform envelope `{ error, detail?, code? }` shipped over the wire by every transport. Hosts get the same shape from REST, tRPC, and OpenAPI.
330
350
  - **`ERROR_MAP`** — `as const` table mapping framework error types to `{ status, code }`. `ValidationError → 422 / VALIDATION`, `InvariantError → 409 / INVARIANT`, `ConcurrencyError → 412 / CONCURRENCY`, `StreamClosedError → 410 / STREAM_CLOSED`, `NonRetryableError → 400 / NON_RETRYABLE`.
331
351
  - **`toApiError(err) → { status, body }`** — the single mapping helper every transport calls in its error boundary. Known framework errors map per `ERROR_MAP`; everything else surfaces as 500 / `INTERNAL` (with `detail` only when the throw was an `Error` — thrown strings or objects don't leak payloads).
332
- - **`withIdempotency(store, key, handler)`** — wraps an action handler in an `Idempotency-Key` claim. Reuses `@rotorsoft/act-ops/idempotency` — same contract `@rotorsoft/act-http/receiver` already speaks, so one `IdempotencyStore` covers both halves of the "Act over the wire" surface. Returns `{ deduped: false, result }` on fresh claim, `{ deduped: true }` on duplicate (handler is not called).
352
+ - **`withIdempotency(store, key, handler)`** — wraps an action handler in a two-phase `Idempotency-Key` claim: it `claim`s tentatively, runs the handler, then `commit`s the key on success or `release`s it and re-throws on a handler rejection (so a transient failure re-processes on retry rather than deduping into a silent success). Reuses `@rotorsoft/act-ops/idempotency` — same contract `@rotorsoft/act-http/receiver` already speaks, so one `IdempotencyStore` covers both halves of the "Act over the wire" surface. Returns `{ deduped: false, result }` on fresh claim, `{ deduped: true }` on duplicate (handler is not called).
333
353
  - **`SseOptions`** — `{ channel: BroadcastChannel<S>, maxConnections?, heartbeatMs? }`. Shared SSE wiring options consumed by both `trpc(app, { sse })` and `hono(app, { sse })`. Defaults: `maxConnections=500` (validated `[1, 10_000]`), `heartbeatMs=30_000` (validated `[15_000, 300_000]`). Out-of-range values throw `RangeError` at transport construction so misconfiguration surfaces at startup, not at first connection.
334
354
  - **`SseConnectionCounter`** — per-process slot counter. The 501st concurrent open is refused (`503 / SSE_BUSY` with `Retry-After: 1` on Hono, `TOO_MANY_REQUESTS` on tRPC). Internal — both transports construct one each, shared by every state-name's subscription on a single generator instance.
335
355
  - **`runSseSubscription(channel, streamId, accounting?, signal?, on_cap_exceeded?)`** — the shared subscription loop both transports run. Acquires one slot (when an `accounting` is supplied), yields the cached state if present, then forwards every channel publication as a `{ kind: "patch", data }` frame until the consumer breaks or the signal aborts. Used internally; exported for adopters who want to build their own transport surface against the same accounting / cancellation discipline.