@rotorsoft/act-http 1.10.16 → 1.10.18

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 CHANGED
@@ -156,137 +156,31 @@ On failure: the adapter responds with the framework's idiomatic 400 (`missing-ke
156
156
 
157
157
  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.
158
158
 
159
- ### `trpc` — auto-generated tRPC router
159
+ ### `trpc`, `hono`, `openapi` — APIs generated from the registry
160
+
161
+ Each generator walks `app.registry.actions` and emits one route per action, so adding an action to the app adds it to every transport:
160
162
 
161
163
  ```ts
164
+ import { hono } from "@rotorsoft/act-http/hono";
165
+ import { openapi } from "@rotorsoft/act-http/openapi";
162
166
  import { trpc } from "@rotorsoft/act-http/trpc";
163
- import { initTRPC } from "@trpc/server";
164
- import { createHTTPServer } from "@trpc/server/adapters/standalone";
165
167
 
166
168
  const router = trpc(app, {
167
- // ActorExtractor from `@rotorsoft/act-http/api` — host's auth seam.
168
169
  actor: (ctx) => ({ id: ctx.user.id, name: ctx.user.name }),
169
- // Resolve the target stream per call; singleton aggregates return a constant.
170
170
  stream: (action, input, ctx) => `tenant-${ctx.tenant}`,
171
- // Optional optimistic concurrency — return `undefined` to skip the check.
172
- expectedVersion: (action, input, ctx) => readIfMatchHeader(ctx),
173
171
  });
174
172
 
175
- createHTTPServer({ router }).listen(4000);
176
-
177
- // Client (same types, no codegen):
178
- // await client.OpenTicket.mutate({ title: "support" });
179
- ```
180
-
181
- The generator walks `app.registry.actions` once and emits a flat top-level mutation per action. The internal `authenticated(extractor)` middleware runs `options.actor` once per call and injects the resolved `Actor` onto `ctx.actor`, so any procedure logic downstream of the chain sees the same actor. `actor` and `stream` are required; `idempotency` is optional — when configured, the mutation reads `Idempotency-Key` via `keyFrom(ctx)` and throws `CONFLICT` on duplicate claims (the contract intentionally doesn't cache original results, matching the receiver-side "ack the duplicate" semantics).
182
-
183
- Errors map through the shared `toApiError(...)` table at `@rotorsoft/act-http/api`: `ConcurrencyError` → `CONFLICT`, `InvariantError` → `CONFLICT`, `ValidationError` → `UNPROCESSABLE_CONTENT`, `StreamClosedError` → `PRECONDITION_FAILED`, `NonRetryableError` → `BAD_REQUEST`, anything else → `INTERNAL_SERVER_ERROR`. The same envelope flows from every transport sibling so a client speaking two of them never sees two shapes for the same framework error.
184
-
185
- ```ts
186
- // Compose the auth middleware into your own procedure chain instead:
187
- import { authenticated } from "@rotorsoft/act-http/trpc";
188
-
189
- const t = initTRPC.context<MyCtx>().create();
190
- const authed = t.procedure.use(authenticated(myExtractor));
191
- // `authed` carries `ctx.actor: Actor` downstream — use it for hand-written
192
- // procedures alongside the generated ones.
193
- ```
194
-
195
- ### `hono` — auto-generated REST surface
196
-
197
- ```ts
198
- import { hono } from "@rotorsoft/act-http/hono";
199
- import { serve } from "@hono/node-server";
200
-
201
173
  const api = hono(app, {
202
- // ActorExtractor from `@rotorsoft/act-http/api` — host's auth seam.
203
174
  actor: (c) => resolveActorFromJwt(c),
204
- // Resolve the target stream per call; singleton aggregates return a constant.
205
175
  stream: (action, input, c) => `tenant-${c.req.header("x-tenant")}`,
206
- // Optional optimistic concurrency — return `undefined` to skip the check.
207
- expectedVersion: (action, input, c) => {
208
- const v = c.req.header("if-match");
209
- return v ? Number.parseInt(v, 10) : undefined;
210
- },
211
- });
212
-
213
- serve({ fetch: api.fetch, port: 4000 });
214
- // POST /api/actions/OpenTicket body: { title }
215
- // → 200 [Snapshot] | 4xx ApiError envelope
216
- ```
217
-
218
- The generator walks `app.registry.actions` once and registers one `POST /actions/<actionName>` per action under the configured `basePath` (default `/api`). Bodies are validated with `@hono/zod-validator` against the action's registered Zod schema; failures short-circuit with `400`. The internal `authenticated(extractor)` middleware runs `options.actor` once per request and stashes the resolved `Actor` under `c.get("actor")`, available to any downstream middleware (logging, tracing, hand-written routes mounted on the same Hono instance). `Idempotency-Key` is read from the request header by default; `keyFrom` overrides for hosts that prefer a custom convention.
219
-
220
- Errors map through the shared `toApiError(...)` table at `@rotorsoft/act-http/api`: `ConcurrencyError → 412 / CONCURRENCY`, `InvariantError → 409 / INVARIANT`, `ValidationError → 422 / VALIDATION`, `StreamClosedError → 410 / STREAM_CLOSED`, `NonRetryableError → 400 / NON_RETRYABLE`. The envelope (`{ error, detail?, code? }`) is the same shape every other act-http transport ships so a client speaking REST plus tRPC never sees two formats for the same framework error.
221
-
222
- Edge-runtime ready — Hono runs unchanged on Node, Bun, Cloudflare Workers, Vercel Edge, and AWS Lambda. Operators wiring `idempotency` on edge runtimes should verify the `IdempotencyStore` is edge-compatible (in-memory works per worker; cross-worker requires a distributed store).
223
-
224
- ```ts
225
- // Compose the auth middleware into your own Hono chain instead:
226
- import { Hono } from "hono";
227
- import { authenticated, type ActMiddlewareVariables } from "@rotorsoft/act-http/hono";
228
-
229
- const api = new Hono<{ Variables: ActMiddlewareVariables }>();
230
- api.use("*", authenticated((c) => resolveActorFromJwt(c)));
231
- // Hand-written routes alongside the generated ones now see c.get("actor") typed.
232
- ```
233
-
234
- ### `openapi` — OpenAPI 3.1 document emitter
235
-
236
- ```ts
237
- import { openapi } from "@rotorsoft/act-http/openapi";
238
-
239
- const doc = openapi(app, {
240
- info: { title: "Wolfdesk API", version: "1.0.0" },
241
- servers: [{ url: "https://api.example.com" }],
242
- // Document the optional cross-cutting headers that the live REST API accepts.
243
- idempotency: true,
244
- expectedVersion: true,
245
- });
246
-
247
- // Serve alongside the Hono adapter:
248
- api.get("/openapi.json", (c) => c.json(doc));
249
- ```
250
-
251
- Pure data emit — no runtime dep on Hono or tRPC. The doc walks `app.registry.actions` once at function call and emits one `POST <basePath>/actions/<actionName>` operation per action, deriving the request-body schema from each action's Zod definition via Zod 4's native `z.toJSONSchema` (OpenAPI 3.1 uses JSON Schema 2020-12 as its schema dialect — no conversion layer needed).
252
-
253
- **The doc describes the Hono REST surface, not tRPC.** tRPC's URL convention (`POST /trpc/<procedure>`, JSON-RPC-style body framing, batching) doesn't model cleanly as OpenAPI operations; tRPC consumers share types directly via `typeof router` and don't need a doc. The path shape this emitter produces matches `@rotorsoft/act-http/hono` by construction: same default `basePath` (`/api`), same request/response shapes, same error envelope. If the operator overrides `basePath` on the Hono adapter, pass the same value here so the doc keeps describing the live routes. tRPC and Hono can run side-by-side at different mount points on the same Act instance.
254
-
255
- The same shared `ApiError` envelope underwrites cross-transport consistency: it's referenced once from `components.responses` and every error code (`400`, `409`, `410`, `412`, `422`, `500`) points at it. The doc and the live REST API agree on framework-error shapes by construction.
256
-
257
- Output is deterministic given the same registry — entries land in `Object.entries(app.registry.actions)` iteration order. CI can snapshot the result to catch unintended API-surface changes; one merge that quietly changes a Zod schema or adds a new action surfaces as a doc diff in the same PR.
258
-
259
- ### `trpc` + `hono` SSE subscriptions — live state from the generators
260
-
261
- Both generators accept an optional `sse` option that walks the registry once and emits a typed subscription per unique state name, all reading from a host-supplied `BroadcastChannel`. Hosts continue to own publication (`channel.publish(streamId, state, patches)` after each `app.do(...)`); the generators own subscription, accounting, cleanup, and the wire format.
262
-
263
- ```ts
264
- import { BroadcastChannel } from "@rotorsoft/act-http/sse";
265
- import { trpc } from "@rotorsoft/act-http/trpc";
266
- import { hono } from "@rotorsoft/act-http/hono";
267
-
268
- const broadcast = new BroadcastChannel<MyState>();
269
-
270
- // Server-side: after every app.do(...), publish the derived state.
271
- const snaps = await app.do(action, target, payload);
272
- broadcast.publish(target.stream, deriveState(snaps), snaps.map((s) => s.patch).filter(Boolean));
273
-
274
- // tRPC — emits `router.subscribe.<stateName>.useSubscription({ stream })`.
275
- const router = trpc(app, {
276
- actor,
277
- stream,
278
- sse: { channel: broadcast, maxConnections: 500, heartbeatMs: 30_000 },
279
- });
176
+ }); // POST /api/actions/<action>
280
177
 
281
- // Hono — emits `GET /api/sse/<stateName>?stream=<id>` per registered state.
282
- const api = hono(app, {
283
- actor,
284
- stream,
285
- sse: { channel: broadcast },
286
- });
178
+ api.get("/openapi.json", (c) =>
179
+ c.json(openapi(app, { info: { title: "My API", version: "1.0.0" } }))
180
+ );
287
181
  ```
288
182
 
289
- Defaults are sized for typical business-app dashboards: `maxConnections = 500` (range `[1, 10_000]`), `heartbeatMs = 30_000` (range `[15_000, 300_000]`), `maxPendingPerConnection = 256` (range `[1, 100_000]`). The 501st concurrent open returns `503 / SSE_BUSY` with `Retry-After: 1` (Hono) or throws `TOO_MANY_REQUESTS` (tRPC). Each connection's undelivered-frame backlog is bounded by `maxPendingPerConnection`: a stalled consumer on a busy stream drops its oldest queued frame (drop-oldest) rather than growing memory without bound — each frame is a full version-keyed patch, so the consumer still converges on current state. Streams cleaned up via the consumer's `iter.return()` / disconnect both run the loop's `finally` block: unsubscribe from the channel, release the slot (only if one was acquired), clear the heartbeat. See [@rotorsoft/act-http/sse](#sse--live-state-broadcast) below for the channel/cache primitives this wires.
183
+ Framework errors share one envelope (`{ error, detail?, code? }`) and one status table: `ValidationError` → 422, `InvariantError` → 409, `ConcurrencyError` → 412, `StreamClosedError` → 410, `NonRetryableError` → 400, anything else → 500. tRPC has no 410, so `StreamClosedError` is `NOT_FOUND` there. Optimistic concurrency (`expectedVersion`), `Idempotency-Key` handling, SSE subscriptions (`sse: { channel }`), auth composition and deployment recipes are in the [auto-generated API guide](https://rotorsoft.github.io/act-root/docs/guides/auto-generated-api).
290
184
 
291
185
  ### `sse` — live state broadcast
292
186
 
@@ -342,45 +236,14 @@ Each framework adapter exports a single function `webhookMiddleware(options)` th
342
236
  | `/receiver/fastify` | `request.idempotency` | `reply.status(...).send({ error: reason })` |
343
237
  | `/receiver/hono` | `c.get("idempotency")` (typed via `Variables`) | `c.json({ error: reason }, status)` |
344
238
 
345
- ### `/api` subpath
346
-
347
- Shared utilities consumed by every transport in the auto-generated API umbrella (act-http-api epic #835). Three concerns surfaced once, not per-transport:
348
-
349
- - **`ActorExtractor`** — type alias `(request: unknown) => Actor | Promise<Actor>`. The host-supplied closure resolving an `Actor` from an incoming request. Required on every transport (`trpc(app, { actor })`, `hono(app, { actor })`). Auth (JWT, session, API key) stays in the host; the package only asks for this function.
350
- - **`ApiError`** — uniform envelope `{ error, detail?, code? }` shipped over the wire by every transport. Hosts get the same shape from REST, tRPC, and OpenAPI.
351
- - **`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`.
352
- - **`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).
353
- - **`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).
354
- - **`SseOptions`** — `{ channel: BroadcastChannel<S>, maxConnections?, heartbeatMs?, maxPendingPerConnection? }`. 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]`), `maxPendingPerConnection=256` (validated `[1, 100_000]`). Out-of-range values throw `RangeError` at transport construction so misconfiguration surfaces at startup, not at first connection.
355
- - **`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.
356
- - **`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.
357
- - **`resolveSseConfig(options)`** + **`DEFAULT_SSE_HEARTBEAT_MS`** / **`DEFAULT_SSE_MAX_CONNECTIONS`** — validation helper and exposed defaults.
358
- - **Types**: `IdempotencyResult<T>`, `ErrorMapEntry`, `SseConfig`, `SseAccounting`, `SseSubscriptionFrame<S>`.
359
-
360
- ### `/trpc` subpath
361
-
362
- Auto-generated tRPC router for the act-http-api epic (#835).
363
-
364
- - **`trpc(app, options) → Router`** — generator function. Walks `app.registry.actions` once and emits a flat top-level mutation per action. Each procedure runs the internal `authenticated(options.actor)` middleware (so `ctx.actor: Actor` is set on the downstream context), resolves the target stream via `options.stream(action, input, ctx)`, and calls `app.do(action, { stream, actor }, input)`. Known framework errors map through `toApiError` to the conventional tRPC codes; unknown throws surface as `INTERNAL_SERVER_ERROR`.
365
- - **`authenticated(extractor) → middleware`** — standalone export of the auth middleware the generator uses internally. Hosts composing their own procedure chain (logging + tracing + custom auth flavors) use `t.procedure.use(authenticated(extractor))` to inject `ctx.actor` without going through the generator. The middleware is structurally-typed so any host `t` instance accepts it.
366
- - **`TrpcOptions<Ctx>`** — `{ actor, stream, expectedVersion?, idempotency?, sse? }`. `actor` is the `ActorExtractor` from `@rotorsoft/act-http/api`. `stream` returns the target stream per call. `expectedVersion` is optional — `(action, input, ctx) => number | undefined` — when set, the procedure threads the resolved value through `Target.expectedVersion` so `app.do` enforces optimistic concurrency; hosts typically read it from an `If-Match` header or the client's last-known snapshot, and returning `undefined` skips the check for that call. `idempotency` is optional — `{ store: IdempotencyStore; keyFrom: (ctx) => string | undefined }` — when set, the procedure honors `Idempotency-Key` via `withIdempotency`. Duplicate claims throw `CONFLICT` (the contract intentionally doesn't cache the original handler's result). `sse` is optional — when set, the generator emits one subscription per unique registered state name under the nested `router.subscribe.<stateName>` namespace; each yields `{ kind: "state", data }` once (when the channel has a cached state) and then `{ kind: "patch", data }` per channel publication until the consumer breaks. The cap surfaces as `TOO_MANY_REQUESTS`.
239
+ ### `/api`, `/trpc`, `/hono`, `/openapi` subpaths
367
240
 
368
- ### `/hono` subpath
241
+ - **`/api`** — what every transport shares: `ActorExtractor`, the `ApiError` envelope, `ERROR_MAP` and `toApiError(err)`, `withIdempotency(store, key, handler)`, and the SSE wiring (`SseOptions`, `resolveSseConfig`, `runSseSubscription`). Bad SSE options throw `ZodError` when the transport is built.
242
+ - **`/trpc`** — `trpc(app, options)`, the `authenticated(extractor)` middleware, and `TrpcOptions`.
243
+ - **`/hono`** — `hono(app, options)`, the `authenticated(extractor)` middleware, `HonoOptions` and `ActMiddlewareVariables`. A body that fails its action's schema returns `422` with the `ApiError` envelope.
244
+ - **`/openapi`** — `openapi(app, options)`, which returns an OpenAPI 3.1 document describing the `hono` routes, plus `OpenAPIOptions` and the document types.
369
245
 
370
- Auto-generated REST surface for the act-http-api epic (#835).
371
-
372
- - **`hono(app, options) → Hono`** — generator function. Walks `app.registry.actions` once and registers a `POST /actions/<actionName>` per action under `basePath` (default `/api`). Body is validated with `@hono/zod-validator` against the action's Zod schema; failures short-circuit with `400`. The internal `authenticated(options.actor)` middleware stashes the resolved `Actor` under `c.get("actor")`. Routes return `200 Snapshot[]` on success, `4xx ApiError` envelope (with the conventional HTTP status) on framework errors, `500 / INTERNAL` on unknown throws.
373
- - **`authenticated(extractor) → MiddlewareHandler`** — standalone export of the auth middleware the generator uses internally. Hosts composing their own Hono chain wire `api.use("*", authenticated(extractor))` and downstream routes read `c.get("actor"): Actor`. Errors thrown by the extractor surface as `401 / UNAUTHORIZED`.
374
- - **`HonoOptions`** — `{ actor, stream, expectedVersion?, idempotency?, sse?, basePath? }`. `actor` is the `ActorExtractor`. `stream` returns the target stream per call. `expectedVersion` threads `Target.expectedVersion` for optimistic concurrency — returning `undefined` skips the check. `idempotency` is optional — `{ store: IdempotencyStore; keyFrom?: (c) => string | undefined }` — when set, the route honors `Idempotency-Key` via `withIdempotency`; default `keyFrom` reads the `Idempotency-Key` header. Duplicate claims return `409 / CONFLICT`. `sse` is optional — when set, the generator emits one `GET <basePath>/sse/<stateName>?stream=<id>` route per unique registered state name; each route opens a `text/event-stream`, yields the cached state (`event: state`) when present, then forwards every channel publication as `event: patch`. The connection cap surfaces as `503 / SSE_BUSY` with `Retry-After: 1`. A heartbeat ping every `heartbeatMs` keeps proxies from idling the connection out. `basePath` defaults to `/api`.
375
- - **`ActMiddlewareVariables`** — `{ actor: Actor }`. Use as the `Variables` generic on a host Hono instance to get `c.get("actor")` typed downstream of `authenticated`.
376
-
377
- ### `/openapi` subpath
378
-
379
- OpenAPI 3.1 document emitter for the act-http-api epic (#835).
380
-
381
- - **`openapi(app, options) → OpenAPIDocument`** — generator function. Walks `app.registry.actions` once and returns a valid OpenAPI 3.1 document object. Each action becomes a `POST <basePath>/actions/<actionName>` operation; the request-body schema comes from Zod 4's `z.toJSONSchema` against the action's registered schema. Error responses reference the shared `#/components/responses/ApiError`. Output is pure data — serve as `/openapi.json`, write to disk during CI, pipe to client codegen.
382
- - **`OpenAPIOptions`** — `{ info, servers?, basePath?, idempotency?, expectedVersion? }`. `info.title` and `info.version` are required non-empty strings. `servers[].url` may contain `{variable}` template syntax; bare URLs are validated through `URL`'s parser. `basePath` defaults to `/api` (mirrors the Hono sibling). `idempotency` and `expectedVersion` are booleans (default `false`); when `true`, every mutation operation documents the corresponding header — `Idempotency-Key` is marked **required** (the route 400s without it), `If-Match` is optional.
383
- - **`OpenAPIDocument`** + a structural subset of the OpenAPI 3.1 types (`OpenAPIInfo`, `OpenAPIServer`, `OpenAPIPathItem`, `OpenAPIOperation`, `OpenAPIParameter`, `OpenAPIRequestBody`, `OpenAPIResponse`, `OpenAPIComponents`). Intentionally lighter than the full `openapi-types` package so post-processing doesn't fight the type system; cast to a fuller type when downstream consumers need it.
246
+ Every option is described in the [auto-generated API guide](https://rotorsoft.github.io/act-root/docs/guides/auto-generated-api).
384
247
 
385
248
  ### `/sse` subpath
386
249
 
@@ -442,14 +305,7 @@ Generic catch sites that don't care about HTTP specifics can match on the base `
442
305
 
443
306
  ### Recovering a blocked stream
444
307
 
445
- 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.
446
-
447
- ```ts
448
- await app.unblock(["webhooks-out-customer-42"]); // by name
449
- await app.unblock({ stream: "^webhooks-out-" }); // bulk by pattern
450
- ```
451
-
452
- Use `app.blocked_streams()` to discover what's currently blocked.
308
+ When `webhook` blocks a stream (on a 4xx, or after exhausting retries), recover with `app.unblock(...)`, which resumes from where the stream stopped. Never `app.reset()` it: that replays from event 0 and re-fires every historical webhook. See [Blocked streams](https://rotorsoft.github.io/act-root/docs/concepts/error-handling#blocked-streams).
453
309
 
454
310
  ### SSE wire format
455
311