@rotorsoft/act-http 1.6.0 → 1.7.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 CHANGED
@@ -235,6 +235,38 @@ The same shared `ApiError` envelope underwrites cross-transport consistency: it'
235
235
 
236
236
  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.
237
237
 
238
+ ### `trpc` + `hono` SSE subscriptions — live state from the generators
239
+
240
+ 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.
241
+
242
+ ```ts
243
+ import { BroadcastChannel } from "@rotorsoft/act-http/sse";
244
+ import { trpc } from "@rotorsoft/act-http/trpc";
245
+ import { hono } from "@rotorsoft/act-http/hono";
246
+
247
+ const broadcast = new BroadcastChannel<MyState>();
248
+
249
+ // Server-side: after every app.do(...), publish the derived state.
250
+ const snaps = await app.do(action, target, payload);
251
+ broadcast.publish(target.stream, deriveState(snaps), snaps.map((s) => s.patch).filter(Boolean));
252
+
253
+ // tRPC — emits `router.subscribe.<stateName>.useSubscription({ stream })`.
254
+ const router = trpc(app, {
255
+ actor,
256
+ stream,
257
+ sse: { channel: broadcast, maxConnections: 500, heartbeatMs: 30_000 },
258
+ });
259
+
260
+ // Hono — emits `GET /api/sse/<stateName>?stream=<id>` per registered state.
261
+ const api = hono(app, {
262
+ actor,
263
+ stream,
264
+ sse: { channel: broadcast },
265
+ });
266
+ ```
267
+
268
+ Defaults are sized for typical business-app dashboards: `maxConnections = 500` (range `[1, 10_000]`), `heartbeatMs = 30_000` (range `[15_000, 300_000]`). The 501st concurrent open returns `503 / SSE_BUSY` with `Retry-After: 1` (Hono) or throws `TOO_MANY_REQUESTS` (tRPC). Streams cleaned up via the consumer's `iter.return()` / disconnect both run the loop's `finally` block: unsubscribe from the channel, release the slot, clear the heartbeat. See [@rotorsoft/act-http/sse](#sse--live-state-broadcast) below for the channel/cache primitives this wires.
269
+
238
270
  ### `sse` — live state broadcast
239
271
 
240
272
  ```ts
@@ -298,7 +330,11 @@ Shared utilities consumed by every transport in the auto-generated API umbrella
298
330
  - **`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`.
299
331
  - **`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).
300
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).
301
- - **Types**: `IdempotencyResult<T>`, `ErrorMapEntry`.
333
+ - **`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
+ - **`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
+ - **`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.
336
+ - **`resolveSseConfig(options)`** + **`DEFAULT_SSE_HEARTBEAT_MS`** / **`DEFAULT_SSE_MAX_CONNECTIONS`** — validation helper and exposed defaults.
337
+ - **Types**: `IdempotencyResult<T>`, `ErrorMapEntry`, `SseConfig`, `SseAccounting`, `SseSubscriptionFrame<S>`.
302
338
 
303
339
  ### `/trpc` subpath
304
340
 
@@ -306,7 +342,7 @@ Auto-generated tRPC router for the act-http-api epic (#835).
306
342
 
307
343
  - **`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`.
308
344
  - **`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.
309
- - **`TrpcOptions<Ctx>`** — `{ actor, stream, expectedVersion?, idempotency? }`. `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).
345
+ - **`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`.
310
346
 
311
347
  ### `/hono` subpath
312
348
 
@@ -314,7 +350,7 @@ Auto-generated REST surface for the act-http-api epic (#835).
314
350
 
315
351
  - **`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.
316
352
  - **`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`.
317
- - **`HonoOptions`** — `{ actor, stream, expectedVersion?, idempotency?, 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`. `basePath` defaults to `/api`.
353
+ - **`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`.
318
354
  - **`ActMiddlewareVariables`** — `{ actor: Actor }`. Use as the `Variables` generic on a host Hono instance to get `c.get("actor")` typed downstream of `authenticated`.
319
355
 
320
356
  ### `/openapi` subpath
@@ -446,6 +482,7 @@ Public API governed by the [Act Stability Charter](../../STABILITY.md). Both sub
446
482
 
447
483
  ## Documentation
448
484
 
485
+ - **[Auto-generated API surfaces](https://rotorsoft.github.io/act-root/docs/guides/auto-generated-api)** — narrative walkthrough of the `/trpc`, `/hono`, `/openapi` subpaths plus the shared utilities at `/api`. Authentication seam, idempotency, optimistic concurrency, deployment recipes (Node, Lambda, Cloudflare, Next.js), and migrating from a hand-written router.
449
486
  - **[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.
450
487
  - **[Real-time with SSE](https://rotorsoft.github.io/act-root/docs/concepts/real-time)** — concept guide for the `/sse` surface.
451
488
  - **[Error handling](https://rotorsoft.github.io/act-root/docs/concepts/error-handling)** — backoff, `NonRetryableError`, blocked streams, `unblock` recovery.