@rotorsoft/act-http 1.11.1 → 1.11.2

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 (63) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/@types/api/index.d.ts +3 -3
  3. package/dist/@types/api/sse-wiring.d.ts +2 -2
  4. package/dist/@types/receiver/extract.d.ts +4 -4
  5. package/dist/@types/receiver/hono/index.d.ts +1 -1
  6. package/dist/@types/receiver/verify.d.ts +2 -2
  7. package/dist/@types/sse/apply-patch.d.ts.map +1 -1
  8. package/dist/@types/sse/broadcast.d.ts +6 -24
  9. package/dist/@types/sse/broadcast.d.ts.map +1 -1
  10. package/dist/@types/sse/state-cache.d.ts +1 -1
  11. package/dist/@types/sse/types.d.ts +4 -5
  12. package/dist/@types/sse/types.d.ts.map +1 -1
  13. package/dist/@types/trpc/index.d.ts.map +1 -1
  14. package/dist/@types/webhook/index.d.ts +1 -1
  15. package/dist/@types/webhook/index.d.ts.map +1 -1
  16. package/dist/@types/webhook/types.d.ts +1 -1
  17. package/dist/api/index.cjs.map +1 -1
  18. package/dist/api/index.js +2 -2
  19. package/dist/{chunk-FSN52BXM.js → chunk-75DA7NJI.js} +2 -2
  20. package/dist/chunk-75DA7NJI.js.map +1 -0
  21. package/dist/{chunk-MKD3SM3N.js → chunk-BOJG4555.js} +1 -1
  22. package/dist/chunk-BOJG4555.js.map +1 -0
  23. package/dist/{chunk-EERALS5E.js → chunk-CVCTDLJA.js} +1 -1
  24. package/dist/chunk-CVCTDLJA.js.map +1 -0
  25. package/dist/{chunk-G5FNOSDX.js → chunk-ET2PACPO.js} +1 -1
  26. package/dist/chunk-ET2PACPO.js.map +1 -0
  27. package/dist/{chunk-TTVMQYNN.js → chunk-OEMQP5ZM.js} +2 -2
  28. package/dist/chunk-OEMQP5ZM.js.map +1 -0
  29. package/dist/{chunk-5KZI37IT.js → chunk-QM6YP2XH.js} +3 -3
  30. package/dist/hono/index.cjs.map +1 -1
  31. package/dist/hono/index.js +4 -4
  32. package/dist/hono/index.js.map +1 -1
  33. package/dist/openapi/index.cjs +2 -2
  34. package/dist/openapi/index.cjs.map +1 -1
  35. package/dist/openapi/index.js +2 -2
  36. package/dist/openapi/index.js.map +1 -1
  37. package/dist/receiver/express/index.cjs.map +1 -1
  38. package/dist/receiver/express/index.js +1 -1
  39. package/dist/receiver/fastify/index.cjs.map +1 -1
  40. package/dist/receiver/fastify/index.js +1 -1
  41. package/dist/receiver/hono/index.cjs.map +1 -1
  42. package/dist/receiver/hono/index.js +2 -2
  43. package/dist/receiver/index.cjs.map +1 -1
  44. package/dist/receiver/index.js +3 -3
  45. package/dist/receiver/index.js.map +1 -1
  46. package/dist/receiver/trpc/index.cjs.map +1 -1
  47. package/dist/receiver/trpc/index.js +1 -1
  48. package/dist/sse/index.cjs +7 -25
  49. package/dist/sse/index.cjs.map +1 -1
  50. package/dist/sse/index.js +8 -26
  51. package/dist/sse/index.js.map +1 -1
  52. package/dist/trpc/index.cjs.map +1 -1
  53. package/dist/trpc/index.js +4 -4
  54. package/dist/trpc/index.js.map +1 -1
  55. package/dist/webhook/index.cjs.map +1 -1
  56. package/dist/webhook/index.js.map +1 -1
  57. package/package.json +1 -1
  58. package/dist/chunk-EERALS5E.js.map +0 -1
  59. package/dist/chunk-FSN52BXM.js.map +0 -1
  60. package/dist/chunk-G5FNOSDX.js.map +0 -1
  61. package/dist/chunk-MKD3SM3N.js.map +0 -1
  62. package/dist/chunk-TTVMQYNN.js.map +0 -1
  63. /package/dist/{chunk-5KZI37IT.js.map → chunk-QM6YP2XH.js.map} +0 -0
@@ -1,17 +1,17 @@
1
1
  import {
2
2
  make_dispatch
3
- } from "../chunk-5KZI37IT.js";
3
+ } from "../chunk-QM6YP2XH.js";
4
4
  import {
5
5
  SseConnectionCounter,
6
6
  resolveSseConfig,
7
7
  runSseSubscription,
8
8
  toApiError
9
- } from "../chunk-FSN52BXM.js";
9
+ } from "../chunk-75DA7NJI.js";
10
10
  import {
11
11
  external_exports
12
12
  } from "../chunk-WZ6QI2OI.js";
13
- import "../chunk-EERALS5E.js";
14
- import "../chunk-MKD3SM3N.js";
13
+ import "../chunk-CVCTDLJA.js";
14
+ import "../chunk-BOJG4555.js";
15
15
  import "../chunk-PZ5AY32C.js";
16
16
 
17
17
  // src/trpc/index.ts
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/trpc/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/trpc\n *\n * tRPC subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and emits a typed\n * router that exposes every registered action as a flat `mutation`,\n * matching the shape hand-written tRPC routers in the Act ecosystem\n * already use.\n *\n * Usage:\n *\n * ```ts\n * import { trpc } from \"@rotorsoft/act-http/trpc\";\n *\n * type Ctx = { user: { id: string; name: string }; tenant: string };\n *\n * const router = trpc<Ctx, typeof app>(app, {\n * actor: (ctx) => {\n * const { user } = ctx as Ctx; // the extractor receives `unknown`\n * return { id: user.id, name: user.name };\n * },\n * stream: (action, input, ctx) => `tenant-${ctx.tenant}`,\n * });\n *\n * // Client sees:\n * // appRouter.PressKey.mutate({ key: \"5\" })\n * // appRouter.OpenTicket.mutate({ title: \"...\" })\n * ```\n *\n * The route shape is flat: one procedure per registered action at\n * the top level. Action names are the unique identifiers across\n * the registry (the framework already enforces no duplicates), so\n * an extra namespace tier would be overhead in the common case.\n * Pure mutations for this slice; query/projection wiring lives in\n * a follow-up. See `libs/act-http/README.md` for the full surface\n * walkthrough.\n *\n * **Actor lives on the context.** The generator wires an internal\n * tRPC middleware that runs the host's\n * {@link TrpcOptions.actor | actor extractor} once per call and\n * injects the resolved {@link Actor} onto the downstream context as\n * `ctx.actor`. Every generated mutation reads from there — auth\n * resolves at the procedure boundary, not inside each action body,\n * and any composed middleware (logging, tracing) sees the same\n * `ctx.actor`. The middleware factory is also exported as\n * {@link authenticated} for hosts that need to compose it into\n * their own procedure chain.\n *\n * Composes the shared utilities at `@rotorsoft/act-http/api`:\n * {@link ActorExtractor} for the auth seam, {@link toApiError} for\n * the uniform error→status/code mapping, {@link withIdempotency}\n * for `Idempotency-Key` dedup. Three transports (this one, Hono,\n * OpenAPI) ride the same utilities so a client talking to two\n * transports never sees two error shapes for the same framework\n * error.\n */\nimport { type Actor, validate } from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport {\n type AnyTRPCMiddlewareFunction,\n type AnyTRPCMutationProcedure,\n type AnyTRPCRootTypes,\n initTRPC,\n type TRPCBuiltRouter,\n TRPCError,\n} from \"@trpc/server\";\nimport { z } from \"zod\";\nimport { type ActSurface, make_dispatch } from \"../api/dispatch.js\";\nimport {\n type ActorExtractor,\n resolveSseConfig,\n runSseSubscription,\n SseConnectionCounter,\n type SseOptions,\n toApiError,\n} from \"../api/index.js\";\n\n/**\n * Per-call options for {@link trpc}. The host supplies the three\n * seams the package can't make decisions on:\n *\n * - `actor` — auth resolver. Receives the tRPC context, returns the\n * `Actor` that flows onto `ctx.actor` for every generated\n * procedure (via an internal {@link authenticated}).\n * - `stream` — stream-id resolver. Receives the action name, the\n * validated action input, and the original tRPC context; returns\n * the target stream. Singleton aggregates return a constant;\n * per-tenant aggregates pull from input or context.\n * - `expectedVersion` (optional) — optimistic-concurrency resolver.\n * When set, the procedure threads the resolved value through\n * `target.expectedVersion` so `app.do` refuses to commit if the\n * stream has moved. Hosts typically read it from an `If-Match`\n * header on the underlying HTTP request, or pull it from the\n * client's last-known snapshot. Returning `undefined` skips the\n * check for that call — handy when only some actions are\n * concurrency-sensitive.\n * - `idempotency` (optional) — when set, the procedure honors\n * `Idempotency-Key` via the shared\n * {@link withIdempotency} helper. The host supplies the\n * `IdempotencyStore` and a `keyFrom` extractor that reads the key\n * out of the tRPC context (typically a header). A duplicate of a key\n * that already succeeded throws a `CONFLICT` `TRPCError` — the\n * receiver-side convention of \"ack the duplicate\" doesn't carry\n * over because the contract intentionally does not cache the\n * original handler's result. A duplicate that arrives while the\n * original is still running throws a retryable\n * `SERVICE_UNAVAILABLE` (503) instead: the original may yet fail and\n * release the key, and a client told \"already used\" would never retry.\n *\n * @template Ctx The host's tRPC context shape. Flows end-to-end —\n * procedures see this exact context type (plus `actor`) at\n * runtime.\n */\nexport type TrpcOptions<Ctx> = {\n readonly actor: ActorExtractor;\n readonly stream: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => number | undefined | Promise<number | undefined>;\n readonly idempotency?: {\n readonly store: IdempotencyStore;\n readonly keyFrom: (ctx: Ctx) => string | undefined;\n };\n /**\n * Optional SSE wiring. When set, the generator emits one\n * subscription per unique state name in the registry, grouped on\n * the returned router as\n * `router.subscribe.<stateName>.useSubscription({ stream })`.\n * Each subscription yields `{ kind: \"state\", data }` once with\n * the cached state (when present) and then `{ kind: \"patch\",\n * data }` for every patch the shared\n * {@link SseOptions.channel} publishes. The per-process\n * connection cap surfaces as a `TOO_MANY_REQUESTS` `TRPCError`.\n *\n * Off by default — most APIs don't expose live state to every\n * client, and opening one is widening both the auth and cost\n * surface.\n */\n readonly sse?: SseOptions;\n};\n\n/**\n * Build a tRPC middleware that runs an {@link ActorExtractor} once\n * per call and forwards the resolved {@link Actor} on the\n * downstream context as `ctx.actor`. The generator at\n * {@link trpc} uses this internally; it's also exported so hosts\n * that want to compose their own procedure chain (logging,\n * tracing, custom auth flavors) can wire it directly:\n *\n * ```ts\n * import { initTRPC } from \"@trpc/server\";\n * import { authenticated } from \"@rotorsoft/act-http/trpc\";\n *\n * type Ctx = { user?: { id: string; name: string } };\n * const t = initTRPC.context<Ctx>().create();\n *\n * const authed = t.procedure.use(\n * authenticated((ctx) => {\n * if (!ctx.user) throw new Error(\"not authenticated\");\n * return { id: ctx.user.id, name: ctx.user.name };\n * })\n * );\n *\n * // Inside any procedure built off `authed`, `ctx.actor: Actor`.\n * ```\n *\n * The middleware is unaware of the host's `t` instance — it returns\n * a structurally-typed function that any tRPC `procedure.use(...)`\n * accepts. Errors thrown by the extractor surface unchanged\n * (typically wrap them as `TRPCError({ code: \"UNAUTHORIZED\" })`\n * in the extractor body).\n */\nexport function authenticated(\n extractor: ActorExtractor\n): AnyTRPCMiddlewareFunction {\n // tRPC's middleware function type lives behind\n // `unstable-core-do-not-import`; we satisfy it structurally with the\n // shape `procedure.use(...)` actually requires (an `{ ctx, next }`\n // bag whose `next` callback is invoked once with the augmented ctx)\n // and cast the structurally-typed result up to the named export. The\n // type cast carries no `any`; both sides are structurally compatible\n // at the call site.\n const middleware = async function actor_middleware({\n ctx,\n next,\n }: {\n ctx: object;\n next: (opts: { ctx: object }) => Promise<unknown>;\n }) {\n const actor = await extractor(ctx);\n return next({ ctx: { ...ctx, actor } });\n };\n return middleware as unknown as AnyTRPCMiddlewareFunction;\n}\n\n/**\n * Map a thrown framework error onto a `TRPCError`. Known errors get\n * their conventional tRPC code (`ConcurrencyError` → `PRECONDITION_FAILED`,\n * `StreamClosedError` → `NOT_FOUND`, `ValidationError` →\n * `UNPROCESSABLE_CONTENT`, etc.); unknown throws\n * surface as `INTERNAL_SERVER_ERROR`. Every transport in the\n * act-http epic passes through the same {@link toApiError} table,\n * so a client talking to both this subpath and the REST sibling\n * sees one envelope per framework error.\n *\n * @internal\n */\nfunction to_trpc_error(err: unknown): TRPCError {\n const { status, body } = toApiError(err);\n const code = status_to_trpc_code(status);\n return new TRPCError({\n code,\n message: body.detail ?? body.error,\n cause: err instanceof Error ? err : undefined,\n });\n}\n\n/**\n * Minimal status → tRPC code map. Only the statuses\n * {@link ERROR_MAP} produces (plus 500) appear here; anything else\n * collapses to `INTERNAL_SERVER_ERROR`.\n *\n * @internal\n */\nfunction status_to_trpc_code(status: number): TRPCError[\"code\"] {\n switch (status) {\n case 400:\n return \"BAD_REQUEST\";\n case 409:\n return \"CONFLICT\";\n case 410:\n // StreamClosedError is HTTP 410 Gone. tRPC has no 410 code, so map to\n // NOT_FOUND (404) — its closest \"resource is gone\" semantics. This is\n // the one unavoidable cross-transport status difference (Hono/OpenAPI\n // keep 410); see auto-generated-api.md.\n return \"NOT_FOUND\";\n case 412:\n // ConcurrencyError is HTTP 412 Precondition Failed. Map by error\n // identity, not by round-tripping the status: PRECONDITION_FAILED\n // serializes back to 412, matching Hono/OpenAPI (#1280).\n return \"PRECONDITION_FAILED\";\n case 422:\n return \"UNPROCESSABLE_CONTENT\";\n default:\n return \"INTERNAL_SERVER_ERROR\";\n }\n}\n\n/**\n * Generated router type — one mutation procedure per action name in\n * `TApp`'s registry. Mapped over the concrete action keys so\n * `createTRPCReact<typeof router>()` sees specific procedure names\n * (`PressKey`, `Clear`, …) instead of a wide `Record<string, ...>`\n * index signature — which is what trips tRPC React's\n * \"useContext / Provider / useUtils collides\" check on widely-typed\n * routers.\n *\n * @internal\n */\ntype GeneratedRouter<TApp> = TRPCBuiltRouter<\n AnyTRPCRootTypes,\n TApp extends { registry: { actions: infer TActions } }\n ? { [K in keyof TActions]: AnyTRPCMutationProcedure }\n : Record<string, AnyTRPCMutationProcedure>\n>;\n\n/**\n * Build a typed tRPC router from a built `Act` instance.\n *\n * Walks `app.registry.actions` once and emits one flat mutation per\n * action under `router.<actionName>`. The framework already enforces\n * no duplicate action names across states, so a flat tier reads\n * cleanly at the client and matches the hand-written router shape\n * adopters already have. Each mutation:\n *\n * 1. Runs the internal {@link authenticated} — `options.actor`\n * resolves the {@link Actor} once and injects it onto\n * `ctx.actor`.\n * 2. Resolves the target stream via `options.stream(name, input, ctx)`.\n * 3. (Optionally) claims the `Idempotency-Key` via\n * {@link withIdempotency} — throws `CONFLICT` on a duplicate of a\n * committed key, `SERVICE_UNAVAILABLE` on one still in flight.\n * 4. Calls `app.do(name, { stream, actor: ctx.actor }, input)`.\n * 5. Maps any thrown framework error onto a `TRPCError` via\n * {@link toApiError}.\n *\n * The returned router's procedure inputs come straight from each\n * action's Zod schema; outputs are the framework's\n * `Snapshot<TState, TEvents>[]` shape. Strong typing flows\n * end-to-end via tRPC's normal inference path — the client sees\n * `appRouter.Calculator.PressKey.mutate({ key: \"5\" })` as a typed\n * call without any codegen.\n *\n * @template Ctx The tRPC context shape the host's runtime feeds in.\n * Flows through `actor`, `stream`, and `idempotency.keyFrom` so\n * the operator's existing context types just work. The internal\n * middleware augments `Ctx` with `{ actor: Actor }` on the\n * downstream procedure context.\n *\n * @param app A built `Act` orchestrator.\n * @param options Auth, stream, and (optional) idempotency seams.\n * @returns A tRPC router covering every registered action, grouped\n * by owning state.\n */\nexport function trpc<\n Ctx extends object = object,\n TApp extends ActSurface = ActSurface,\n>(app: TApp, options: TrpcOptions<Ctx>): GeneratedRouter<TApp> {\n const t = initTRPC.context<Ctx>().create();\n const dispatch = make_dispatch<Ctx>(app, options, options.idempotency?.store);\n\n // Resolve the actor inline per mutation instead of via\n // `t.procedure.use(authenticated(...))`. The middleware path threads\n // tRPC's internal `Unwrap` type into each procedure's inferred shape,\n // which the d.ts emitter can't name portably (`unstable-core-do-not-\n // import` has a hashed file name that varies per build). Inlining\n // sidesteps the issue entirely — the standalone `authenticated`\n // export still works for hosts who want middleware composition.\n /**\n * Run the actor extractor for a generated procedure, mapping a\n * \"throw to deny\" onto `UNAUTHORIZED` rather than a framework 500.\n *\n * Shared by mutations and subscriptions on purpose. Each used to carry\n * its own copy of this block, and the subscription copy was simply\n * missing — a denied caller got a live subscription and the cached\n * state frame with it (#1620). One helper is what keeps the two paths\n * from drifting again.\n */\n const resolve_actor = async (host_ctx: Ctx): Promise<Actor> => {\n try {\n return await options.actor(host_ctx);\n } catch (err) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: err instanceof Error ? err.message : undefined,\n cause: err instanceof Error ? err : undefined,\n });\n }\n };\n\n const handlers: Record<\n string,\n ReturnType<ReturnType<typeof t.procedure.input>[\"mutation\"]>\n > = {};\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const action_schema = state.actions[action_name];\n handlers[action_name] = t.procedure\n // Passthrough parser: tRPC's own `.input()` validation is deliberately\n // bypassed because a Zod failure there surfaces as a hardcoded\n // BAD_REQUEST (400) that never reaches `to_trpc_error`. Validation runs\n // in the resolver (below) instead, so a malformed body maps to the SAME\n // 422 / ApiError a `ValidationError` thrown inside `app.do` produces —\n // cross-transport parity with Hono/OpenAPI (#1295).\n .input((raw: unknown) => raw)\n .mutation(async ({ input, ctx }) => {\n const host_ctx = ctx as unknown as Ctx;\n // Resolve the actor first, mirroring Hono's `authenticated` middleware:\n // a \"throw to deny\" from the extractor is 401 UNAUTHORIZED, not a 500.\n // The actor step is auth — distinct from a framework error inside\n // `app.do`, which the main try below maps via `to_trpc_error` (#1286).\n const actor: Actor = await resolve_actor(host_ctx);\n try {\n // Validate the payload before the stream / expectedVersion resolvers\n // see it, mirroring Hono's zValidator-then-handler order. `validate`\n // throws the framework `ValidationError`, which `to_trpc_error` maps\n // to 422 / UNPROCESSABLE_CONTENT with the shared ApiError envelope\n // (#1295). The resolvers get the parsed value, but `app.do` gets the\n // raw `input`: it parses again itself, and a second parse of the\n // output would run every `.transform()` twice.\n const validated = validate(\n action_name,\n input as never,\n action_schema as never\n );\n const outcome = await dispatch(\n action_name,\n validated,\n input,\n host_ctx,\n actor,\n () => options.idempotency?.keyFrom(host_ctx)\n );\n if (outcome.kind === \"done\") return outcome.result;\n throw new TRPCError(\n outcome.kind === \"missing-key\"\n ? { code: \"BAD_REQUEST\", message: \"Idempotency-Key required\" }\n : outcome.kind === \"in-flight\"\n ? {\n code: \"SERVICE_UNAVAILABLE\",\n message:\n \"Idempotency-Key in flight; the original request has not finished, retry later\",\n }\n : {\n code: \"CONFLICT\",\n message:\n \"Idempotency-Key already used; original result not cached\",\n }\n );\n } catch (err) {\n if (err instanceof TRPCError) throw err;\n throw to_trpc_error(err);\n }\n });\n }\n\n if (options.sse) {\n // SSE subscriptions mount under the `subscribe` key of the same router\n // as the action mutations, so an action named `subscribe` would be\n // silently replaced. Refuse it at generation instead.\n if (\"subscribe\" in app.registry.actions)\n throw new Error(\n 'trpc: an action named \"subscribe\" collides with the SSE subscription router (router.subscribe.<stateName>); rename the action or generate the router without `sse`.'\n );\n const sse_config = resolveSseConfig(options.sse);\n const sse_counter = new SseConnectionCounter(sse_config.maxConnections);\n const state_names = new Set(\n Object.values(app.registry.actions).map((a) => a.name)\n );\n const sse_handlers: Record<string, unknown> = {};\n for (const state_name of state_names) {\n sse_handlers[state_name] = t.procedure\n .input(z.object({ stream: z.string().min(1) }))\n .subscription(async function* ({ input, ctx, signal }) {\n // Step 1 of the documented subscription contract: the actor\n // extractor runs before a slot is taken or a frame is yielded,\n // so a denying extractor denies the open. Hono gets this from\n // the `authenticated` middleware it mounts across `/api/*`;\n // this generator is where the same rule lives for tRPC, which\n // resolves the actor inline rather than through middleware.\n await resolve_actor(ctx as unknown as Ctx);\n yield* runSseSubscription(\n sse_config.channel,\n input.stream,\n sse_counter,\n signal,\n {\n maxPending: sse_config.maxPendingPerConnection,\n on_cap_exceeded: () => {\n throw new TRPCError({\n code: \"TOO_MANY_REQUESTS\",\n message: \"max concurrent SSE subscriptions reached\",\n });\n },\n }\n );\n });\n }\n handlers.subscribe = t.router(sse_handlers as never) as never;\n }\n\n // The runtime router IS structurally compatible with\n // `GeneratedRouter<TApp>` (one mutation per action key), but tRPC's\n // `BuiltRouter` carries an internal `Unwrap<Ctx>` that the d.ts\n // emitter can't name portably. Bridging through `unknown` keeps the\n // caller-visible type clean (specific procedure names, no\n // index-signature widening that trips React tRPC's collision check).\n return t.router(handlers) as unknown as GeneratedRouter<TApp>;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAyDA,SAAqB,gBAAgB;AAErC;AAAA,EAIE;AAAA,EAEA;AAAA,OACK;AAiHA,SAAS,cACd,WAC2B;AAQ3B,QAAM,aAAa,eAAe,iBAAiB;AAAA,IACjD;AAAA,IACA;AAAA,EACF,GAGG;AACD,UAAM,QAAQ,MAAM,UAAU,GAAG;AACjC,WAAO,KAAK,EAAE,KAAK,EAAE,GAAG,KAAK,MAAM,EAAE,CAAC;AAAA,EACxC;AACA,SAAO;AACT;AAcA,SAAS,cAAc,KAAyB;AAC9C,QAAM,EAAE,QAAQ,KAAK,IAAI,WAAW,GAAG;AACvC,QAAM,OAAO,oBAAoB,MAAM;AACvC,SAAO,IAAI,UAAU;AAAA,IACnB;AAAA,IACA,SAAS,KAAK,UAAU,KAAK;AAAA,IAC7B,OAAO,eAAe,QAAQ,MAAM;AAAA,EACtC,CAAC;AACH;AASA,SAAS,oBAAoB,QAAmC;AAC9D,UAAQ,QAAQ;AAAA,IACd,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAKH,aAAO;AAAA,IACT,KAAK;AAIH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;AA0DO,SAAS,KAGd,KAAW,SAAkD;AAC7D,QAAM,IAAI,SAAS,QAAa,EAAE,OAAO;AACzC,QAAM,WAAW,cAAmB,KAAK,SAAS,QAAQ,aAAa,KAAK;AAmB5E,QAAM,gBAAgB,OAAO,aAAkC;AAC7D,QAAI;AACF,aAAO,MAAM,QAAQ,MAAM,QAAQ;AAAA,IACrC,SAAS,KAAK;AACZ,YAAM,IAAI,UAAU;AAAA,QAClB,MAAM;AAAA,QACN,SAAS,eAAe,QAAQ,IAAI,UAAU;AAAA,QAC9C,OAAO,eAAe,QAAQ,MAAM;AAAA,MACtC,CAAC;AAAA,IACH;AAAA,EACF;AAEA,QAAM,WAGF,CAAC;AACL,aAAW,CAAC,aAAa,KAAK,KAAK,OAAO,QAAQ,IAAI,SAAS,OAAO,GAAG;AACvE,UAAM,gBAAgB,MAAM,QAAQ,WAAW;AAC/C,aAAS,WAAW,IAAI,EAAE,UAOvB,MAAM,CAAC,QAAiB,GAAG,EAC3B,SAAS,OAAO,EAAE,OAAO,IAAI,MAAM;AAClC,YAAM,WAAW;AAKjB,YAAM,QAAe,MAAM,cAAc,QAAQ;AACjD,UAAI;AAQF,cAAM,YAAY;AAAA,UAChB;AAAA,UACA;AAAA,UACA;AAAA,QACF;AACA,cAAM,UAAU,MAAM;AAAA,UACpB;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA,MAAM,QAAQ,aAAa,QAAQ,QAAQ;AAAA,QAC7C;AACA,YAAI,QAAQ,SAAS,OAAQ,QAAO,QAAQ;AAC5C,cAAM,IAAI;AAAA,UACR,QAAQ,SAAS,gBACb,EAAE,MAAM,eAAe,SAAS,2BAA2B,IAC3D,QAAQ,SAAS,cACf;AAAA,YACE,MAAM;AAAA,YACN,SACE;AAAA,UACJ,IACA;AAAA,YACE,MAAM;AAAA,YACN,SACE;AAAA,UACJ;AAAA,QACR;AAAA,MACF,SAAS,KAAK;AACZ,YAAI,eAAe,UAAW,OAAM;AACpC,cAAM,cAAc,GAAG;AAAA,MACzB;AAAA,IACF,CAAC;AAAA,EACL;AAEA,MAAI,QAAQ,KAAK;AAIf,QAAI,eAAe,IAAI,SAAS;AAC9B,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AACF,UAAM,aAAa,iBAAiB,QAAQ,GAAG;AAC/C,UAAM,cAAc,IAAI,qBAAqB,WAAW,cAAc;AACtE,UAAM,cAAc,IAAI;AAAA,MACtB,OAAO,OAAO,IAAI,SAAS,OAAO,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI;AAAA,IACvD;AACA,UAAM,eAAwC,CAAC;AAC/C,eAAW,cAAc,aAAa;AACpC,mBAAa,UAAU,IAAI,EAAE,UAC1B,MAAM,iBAAE,OAAO,EAAE,QAAQ,iBAAE,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC,EAC7C,aAAa,iBAAiB,EAAE,OAAO,KAAK,OAAO,GAAG;AAOrD,cAAM,cAAc,GAAqB;AACzC,eAAO;AAAA,UACL,WAAW;AAAA,UACX,MAAM;AAAA,UACN;AAAA,UACA;AAAA,UACA;AAAA,YACE,YAAY,WAAW;AAAA,YACvB,iBAAiB,MAAM;AACrB,oBAAM,IAAI,UAAU;AAAA,gBAClB,MAAM;AAAA,gBACN,SAAS;AAAA,cACX,CAAC;AAAA,YACH;AAAA,UACF;AAAA,QACF;AAAA,MACF,CAAC;AAAA,IACL;AACA,aAAS,YAAY,EAAE,OAAO,YAAqB;AAAA,EACrD;AAQA,SAAO,EAAE,OAAO,QAAQ;AAC1B;","names":[]}
1
+ {"version":3,"sources":["../../src/trpc/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/trpc\n *\n * tRPC subpath of the auto-generated API. Walks the\n * registry of a built `Act` once at function call and emits a typed\n * router that exposes every registered action as a flat `mutation`,\n * matching the shape hand-written tRPC routers in the Act ecosystem\n * already use.\n *\n * Usage:\n *\n * ```ts\n * import { trpc } from \"@rotorsoft/act-http/trpc\";\n *\n * type Ctx = { user: { id: string; name: string }; tenant: string };\n *\n * const router = trpc<Ctx, typeof app>(app, {\n * actor: (ctx) => {\n * const { user } = ctx as Ctx; // the extractor receives `unknown`\n * return { id: user.id, name: user.name };\n * },\n * stream: (action, input, ctx) => `tenant-${ctx.tenant}`,\n * });\n *\n * // Client sees:\n * // appRouter.PressKey.mutate({ key: \"5\" })\n * // appRouter.OpenTicket.mutate({ title: \"...\" })\n * ```\n *\n * The route shape is flat: one procedure per registered action at\n * the top level. Action names are the unique identifiers across\n * the registry (the framework already enforces no duplicates), so\n * an extra namespace tier would be overhead in the common case.\n * See `libs/act-http/README.md` for the full surface walkthrough.\n *\n * **Actor lives on the context.** The generator wires an internal\n * tRPC middleware that runs the host's\n * {@link TrpcOptions.actor | actor extractor} once per call and\n * injects the resolved {@link Actor} onto the downstream context as\n * `ctx.actor`. Every generated mutation reads from there — auth\n * resolves at the procedure boundary, not inside each action body,\n * and any composed middleware (logging, tracing) sees the same\n * `ctx.actor`. The middleware factory is also exported as\n * {@link authenticated} for hosts that need to compose it into\n * their own procedure chain.\n *\n * Composes the shared utilities at `@rotorsoft/act-http/api`:\n * {@link ActorExtractor} for the auth seam, {@link toApiError} for\n * the uniform error→status/code mapping, {@link withIdempotency}\n * for `Idempotency-Key` dedup. Three transports (this one, Hono,\n * OpenAPI) ride the same utilities so a client talking to two\n * transports never sees two error shapes for the same framework\n * error.\n */\nimport { type Actor, validate } from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport {\n type AnyTRPCMiddlewareFunction,\n type AnyTRPCMutationProcedure,\n type AnyTRPCRootTypes,\n initTRPC,\n type TRPCBuiltRouter,\n TRPCError,\n} from \"@trpc/server\";\nimport { z } from \"zod\";\nimport { type ActSurface, make_dispatch } from \"../api/dispatch.js\";\nimport {\n type ActorExtractor,\n resolveSseConfig,\n runSseSubscription,\n SseConnectionCounter,\n type SseOptions,\n toApiError,\n} from \"../api/index.js\";\n\n/**\n * Per-call options for {@link trpc}. The host supplies the three\n * seams the package can't make decisions on:\n *\n * - `actor` — auth resolver. Receives the tRPC context, returns the\n * `Actor` that flows onto `ctx.actor` for every generated\n * procedure (via an internal {@link authenticated}).\n * - `stream` — stream-id resolver. Receives the action name, the\n * validated action input, and the original tRPC context; returns\n * the target stream. Singleton aggregates return a constant;\n * per-tenant aggregates pull from input or context.\n * - `expectedVersion` (optional) — optimistic-concurrency resolver.\n * When set, the procedure threads the resolved value through\n * `target.expectedVersion` so `app.do` refuses to commit if the\n * stream has moved. Hosts typically read it from an `If-Match`\n * header on the underlying HTTP request, or pull it from the\n * client's last-known snapshot. Returning `undefined` skips the\n * check for that call — handy when only some actions are\n * concurrency-sensitive.\n * - `idempotency` (optional) — when set, the procedure honors\n * `Idempotency-Key` via the shared\n * {@link withIdempotency} helper. The host supplies the\n * `IdempotencyStore` and a `keyFrom` extractor that reads the key\n * out of the tRPC context (typically a header). A duplicate of a key\n * that already succeeded throws a `CONFLICT` `TRPCError` — the\n * receiver-side convention of \"ack the duplicate\" doesn't carry\n * over because the contract intentionally does not cache the\n * original handler's result. A duplicate that arrives while the\n * original is still running throws a retryable\n * `SERVICE_UNAVAILABLE` (503) instead: the original may yet fail and\n * release the key, and a client told \"already used\" would never retry.\n *\n * @template Ctx The host's tRPC context shape. Flows end-to-end —\n * procedures see this exact context type (plus `actor`) at\n * runtime.\n */\nexport type TrpcOptions<Ctx> = {\n readonly actor: ActorExtractor;\n readonly stream: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => number | undefined | Promise<number | undefined>;\n readonly idempotency?: {\n readonly store: IdempotencyStore;\n readonly keyFrom: (ctx: Ctx) => string | undefined;\n };\n /**\n * Optional SSE wiring. When set, the generator emits one\n * subscription per unique state name in the registry, grouped on\n * the returned router as\n * `router.subscribe.<stateName>.useSubscription({ stream })`.\n * Each subscription yields `{ kind: \"state\", data }` once with\n * the cached state (when present) and then `{ kind: \"patch\",\n * data }` for every patch the shared\n * {@link SseOptions.channel} publishes. The per-process\n * connection cap surfaces as a `TOO_MANY_REQUESTS` `TRPCError`.\n *\n * Off by default — most APIs don't expose live state to every\n * client, and opening one is widening both the auth and cost\n * surface.\n */\n readonly sse?: SseOptions;\n};\n\n/**\n * Build a tRPC middleware that runs an {@link ActorExtractor} once\n * per call and forwards the resolved {@link Actor} on the\n * downstream context as `ctx.actor`. The generator at\n * {@link trpc} uses this internally; it's also exported so hosts\n * that want to compose their own procedure chain (logging,\n * tracing, custom auth flavors) can wire it directly:\n *\n * ```ts\n * import { initTRPC } from \"@trpc/server\";\n * import { authenticated } from \"@rotorsoft/act-http/trpc\";\n *\n * type Ctx = { user?: { id: string; name: string } };\n * const t = initTRPC.context<Ctx>().create();\n *\n * const authed = t.procedure.use(\n * authenticated((ctx) => {\n * if (!ctx.user) throw new Error(\"not authenticated\");\n * return { id: ctx.user.id, name: ctx.user.name };\n * })\n * );\n *\n * // Inside any procedure built off `authed`, `ctx.actor: Actor`.\n * ```\n *\n * The middleware is unaware of the host's `t` instance — it returns\n * a structurally-typed function that any tRPC `procedure.use(...)`\n * accepts. Errors thrown by the extractor surface unchanged\n * (typically wrap them as `TRPCError({ code: \"UNAUTHORIZED\" })`\n * in the extractor body).\n */\nexport function authenticated(\n extractor: ActorExtractor\n): AnyTRPCMiddlewareFunction {\n // tRPC's middleware function type lives behind\n // `unstable-core-do-not-import`; we satisfy it structurally with the\n // shape `procedure.use(...)` actually requires (an `{ ctx, next }`\n // bag whose `next` callback is invoked once with the augmented ctx)\n // and cast the structurally-typed result up to the named export. The\n // type cast carries no `any`; both sides are structurally compatible\n // at the call site.\n const middleware = async function actor_middleware({\n ctx,\n next,\n }: {\n ctx: object;\n next: (opts: { ctx: object }) => Promise<unknown>;\n }) {\n const actor = await extractor(ctx);\n return next({ ctx: { ...ctx, actor } });\n };\n return middleware as unknown as AnyTRPCMiddlewareFunction;\n}\n\n/**\n * Map a thrown framework error onto a `TRPCError`. Known errors get\n * their conventional tRPC code (`ConcurrencyError` → `PRECONDITION_FAILED`,\n * `StreamClosedError` → `NOT_FOUND`, `ValidationError` →\n * `UNPROCESSABLE_CONTENT`, etc.); unknown throws\n * surface as `INTERNAL_SERVER_ERROR`. Every act-http transport\n * passes through the same {@link toApiError} table,\n * so a client talking to both this subpath and the REST sibling\n * sees one envelope per framework error.\n *\n * @internal\n */\nfunction to_trpc_error(err: unknown): TRPCError {\n const { status, body } = toApiError(err);\n const code = status_to_trpc_code(status);\n return new TRPCError({\n code,\n message: body.detail ?? body.error,\n cause: err instanceof Error ? err : undefined,\n });\n}\n\n/**\n * Minimal status → tRPC code map. Only the statuses\n * {@link ERROR_MAP} produces (plus 500) appear here; anything else\n * collapses to `INTERNAL_SERVER_ERROR`.\n *\n * @internal\n */\nfunction status_to_trpc_code(status: number): TRPCError[\"code\"] {\n switch (status) {\n case 400:\n return \"BAD_REQUEST\";\n case 409:\n return \"CONFLICT\";\n case 410:\n // StreamClosedError is HTTP 410 Gone. tRPC has no 410 code, so map to\n // NOT_FOUND (404) — its closest \"resource is gone\" semantics. This is\n // the one unavoidable cross-transport status difference (Hono/OpenAPI\n // keep 410); see auto-generated-api.md.\n return \"NOT_FOUND\";\n case 412:\n // ConcurrencyError is HTTP 412 Precondition Failed. Map by error\n // identity, not by round-tripping the status: PRECONDITION_FAILED\n // serializes back to 412, matching Hono/OpenAPI.\n return \"PRECONDITION_FAILED\";\n case 422:\n return \"UNPROCESSABLE_CONTENT\";\n default:\n return \"INTERNAL_SERVER_ERROR\";\n }\n}\n\n/**\n * Generated router type — one mutation procedure per action name in\n * `TApp`'s registry. Mapped over the concrete action keys so\n * `createTRPCReact<typeof router>()` sees specific procedure names\n * (`PressKey`, `Clear`, …) instead of a wide `Record<string, ...>`\n * index signature — which is what trips tRPC React's\n * \"useContext / Provider / useUtils collides\" check on widely-typed\n * routers.\n *\n * @internal\n */\ntype GeneratedRouter<TApp> = TRPCBuiltRouter<\n AnyTRPCRootTypes,\n TApp extends { registry: { actions: infer TActions } }\n ? { [K in keyof TActions]: AnyTRPCMutationProcedure }\n : Record<string, AnyTRPCMutationProcedure>\n>;\n\n/**\n * Build a typed tRPC router from a built `Act` instance.\n *\n * Walks `app.registry.actions` once and emits one flat mutation per\n * action under `router.<actionName>`. The framework already enforces\n * no duplicate action names across states, so a flat tier reads\n * cleanly at the client and matches the hand-written router shape\n * adopters already have. Each mutation:\n *\n * 1. Runs the internal {@link authenticated} — `options.actor`\n * resolves the {@link Actor} once and injects it onto\n * `ctx.actor`.\n * 2. Resolves the target stream via `options.stream(name, input, ctx)`.\n * 3. (Optionally) claims the `Idempotency-Key` via\n * {@link withIdempotency} — throws `CONFLICT` on a duplicate of a\n * committed key, `SERVICE_UNAVAILABLE` on one still in flight.\n * 4. Calls `app.do(name, { stream, actor: ctx.actor }, input)`.\n * 5. Maps any thrown framework error onto a `TRPCError` via\n * {@link toApiError}.\n *\n * The returned router's procedure inputs come straight from each\n * action's Zod schema; outputs are the framework's\n * `Snapshot<TState, TEvents>[]` shape. Strong typing flows\n * end-to-end via tRPC's normal inference path — the client sees\n * `appRouter.Calculator.PressKey.mutate({ key: \"5\" })` as a typed\n * call without any codegen.\n *\n * @template Ctx The tRPC context shape the host's runtime feeds in.\n * Flows through `actor`, `stream`, and `idempotency.keyFrom` so\n * the operator's existing context types just work. The internal\n * middleware augments `Ctx` with `{ actor: Actor }` on the\n * downstream procedure context.\n *\n * @param app A built `Act` orchestrator.\n * @param options Auth, stream, and (optional) idempotency seams.\n * @returns A tRPC router covering every registered action, grouped\n * by owning state.\n */\nexport function trpc<\n Ctx extends object = object,\n TApp extends ActSurface = ActSurface,\n>(app: TApp, options: TrpcOptions<Ctx>): GeneratedRouter<TApp> {\n const t = initTRPC.context<Ctx>().create();\n const dispatch = make_dispatch<Ctx>(app, options, options.idempotency?.store);\n\n // Resolve the actor inline per mutation instead of via\n // `t.procedure.use(authenticated(...))`. The middleware path threads\n // tRPC's internal `Unwrap` type into each procedure's inferred shape,\n // which the d.ts emitter can't name portably (`unstable-core-do-not-\n // import` has a hashed file name that varies per build). Inlining\n // sidesteps the issue entirely — the standalone `authenticated`\n // export still works for hosts who want middleware composition.\n /**\n * Run the actor extractor for a generated procedure, mapping a\n * \"throw to deny\" onto `UNAUTHORIZED` rather than a framework 500.\n *\n * Shared by mutations and subscriptions, so a denied caller gets\n * neither a mutation nor a live subscription.\n */\n const resolve_actor = async (host_ctx: Ctx): Promise<Actor> => {\n try {\n return await options.actor(host_ctx);\n } catch (err) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: err instanceof Error ? err.message : undefined,\n cause: err instanceof Error ? err : undefined,\n });\n }\n };\n\n const handlers: Record<\n string,\n ReturnType<ReturnType<typeof t.procedure.input>[\"mutation\"]>\n > = {};\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const action_schema = state.actions[action_name];\n handlers[action_name] = t.procedure\n // Passthrough parser: tRPC's own `.input()` validation is deliberately\n // bypassed because a Zod failure there surfaces as a hardcoded\n // BAD_REQUEST (400) that never reaches `to_trpc_error`. Validation runs\n // in the resolver (below) instead, so a malformed body maps to the SAME\n // 422 / ApiError a `ValidationError` thrown inside `app.do` produces,\n // as Hono and OpenAPI do.\n .input((raw: unknown) => raw)\n .mutation(async ({ input, ctx }) => {\n const host_ctx = ctx as unknown as Ctx;\n // Resolve the actor first, mirroring Hono's `authenticated` middleware:\n // a \"throw to deny\" from the extractor is 401 UNAUTHORIZED, not a 500.\n // The actor step is auth — distinct from a framework error inside\n // `app.do`, which the main try below maps via `to_trpc_error`.\n const actor: Actor = await resolve_actor(host_ctx);\n try {\n // Validate the payload before the stream / expectedVersion resolvers\n // see it, mirroring Hono's zValidator-then-handler order. `validate`\n // throws the framework `ValidationError`, which `to_trpc_error` maps\n // to 422 / UNPROCESSABLE_CONTENT with the shared ApiError envelope.\n // The resolvers get the parsed value, but `app.do` gets the\n // raw `input`: it parses again itself, and a second parse of the\n // output would run every `.transform()` twice.\n const validated = validate(\n action_name,\n input as never,\n action_schema as never\n );\n const outcome = await dispatch(\n action_name,\n validated,\n input,\n host_ctx,\n actor,\n () => options.idempotency?.keyFrom(host_ctx)\n );\n if (outcome.kind === \"done\") return outcome.result;\n throw new TRPCError(\n outcome.kind === \"missing-key\"\n ? { code: \"BAD_REQUEST\", message: \"Idempotency-Key required\" }\n : outcome.kind === \"in-flight\"\n ? {\n code: \"SERVICE_UNAVAILABLE\",\n message:\n \"Idempotency-Key in flight; the original request has not finished, retry later\",\n }\n : {\n code: \"CONFLICT\",\n message:\n \"Idempotency-Key already used; original result not cached\",\n }\n );\n } catch (err) {\n if (err instanceof TRPCError) throw err;\n throw to_trpc_error(err);\n }\n });\n }\n\n if (options.sse) {\n // SSE subscriptions mount under the `subscribe` key of the same router\n // as the action mutations, so an action named `subscribe` would be\n // silently replaced. Refuse it at generation instead.\n if (\"subscribe\" in app.registry.actions)\n throw new Error(\n 'trpc: an action named \"subscribe\" collides with the SSE subscription router (router.subscribe.<stateName>); rename the action or generate the router without `sse`.'\n );\n const sse_config = resolveSseConfig(options.sse);\n const sse_counter = new SseConnectionCounter(sse_config.maxConnections);\n const state_names = new Set(\n Object.values(app.registry.actions).map((a) => a.name)\n );\n const sse_handlers: Record<string, unknown> = {};\n for (const state_name of state_names) {\n sse_handlers[state_name] = t.procedure\n .input(z.object({ stream: z.string().min(1) }))\n .subscription(async function* ({ input, ctx, signal }) {\n // Step 1 of the documented subscription contract: the actor\n // extractor runs before a slot is taken or a frame is yielded,\n // so a denying extractor denies the open. Hono gets this from\n // the `authenticated` middleware it mounts across `/api/*`;\n // this generator is where the same rule lives for tRPC, which\n // resolves the actor inline rather than through middleware.\n await resolve_actor(ctx as unknown as Ctx);\n yield* runSseSubscription(\n sse_config.channel,\n input.stream,\n sse_counter,\n signal,\n {\n maxPending: sse_config.maxPendingPerConnection,\n on_cap_exceeded: () => {\n throw new TRPCError({\n code: \"TOO_MANY_REQUESTS\",\n message: \"max concurrent SSE subscriptions reached\",\n });\n },\n }\n );\n });\n }\n handlers.subscribe = t.router(sse_handlers as never) as never;\n }\n\n // The runtime router IS structurally compatible with\n // `GeneratedRouter<TApp>` (one mutation per action key), but tRPC's\n // `BuiltRouter` carries an internal `Unwrap<Ctx>` that the d.ts\n // emitter can't name portably. Bridging through `unknown` keeps the\n // caller-visible type clean (specific procedure names, no\n // index-signature widening that trips React tRPC's collision check).\n return t.router(handlers) as unknown as GeneratedRouter<TApp>;\n}\n"],"mappings":"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","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/webhook/index.ts","../../src/webhook/types.ts","../../src/webhook/classify.ts","../../src/webhook/sign.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/webhook\n *\n * Reaction-handler sugar for POSTing committed events to external URLs.\n *\n * Wraps `fetch` with timeouts, automatic `Idempotency-Key` derivation, and\n * status-classified errors. Designed to be composed with the reaction\n * options shipped in ACT-601 (`maxRetries`, `blockOnError`, `backoff`):\n *\n * ```ts\n * import { webhook } from \"@rotorsoft/act-http/webhook\";\n *\n * .on(\"OrderConfirmed\")\n * .do(\n * webhook({\n * url: \"https://api.example.com/webhooks/orders\",\n * headers: (e) => ({ Authorization: \"Bearer ...\" }),\n * body: (e) => ({ orderId: e.stream, total: e.data.total }),\n * timeoutMs: 5_000,\n * }),\n * { maxRetries: 5, backoff: { strategy: \"exponential\", baseMs: 200, maxMs: 30_000 } }\n * )\n * .to(resolver);\n * ```\n */\n\nimport type { Committed, ReactionHandler, Schemas } from \"@rotorsoft/act\";\nimport { classifyHttpResponse } from \"./classify.js\";\nimport { sign_request } from \"./sign.js\";\nimport {\n NonRetryableWebhookError,\n type WebhookConfig,\n WebhookError,\n} from \"./types.js\";\n\nexport type { HttpDisposition, TryOkOptions } from \"./classify.js\";\n// Documented in this subpath's README as public API since the helpers were\n// written — implemented, tested, and with a doc-comment describing external\n// use — but never actually exported (#1621). See rfcs/1621-webhook-classify-exports.md.\nexport { classifyHttpResponse, tryOk } from \"./classify.js\";\nexport type {\n HttpDeliveryErrorInit,\n WebhookBody,\n WebhookConfig,\n WebhookResolver,\n} from \"./types.js\";\nexport {\n NonRetryableHttpError,\n NonRetryableWebhookError,\n RetryableHttpError,\n WebhookError,\n} from \"./types.js\";\n\nfunction resolve<TEvents extends Schemas, T>(\n resolver: T | ((e: Committed<TEvents, keyof TEvents>) => T) | undefined,\n event: Committed<TEvents, keyof TEvents>,\n fallback: T\n): T {\n if (resolver === undefined) return fallback;\n return typeof resolver === \"function\"\n ? (resolver as (e: Committed<TEvents, keyof TEvents>) => T)(event)\n : resolver;\n}\n\n/** Case-insensitive lookup; returns true if a header is already set. */\nfunction has_header(headers: Record<string, string>, name: string): boolean {\n const lower = name.toLowerCase();\n for (const k of Object.keys(headers)) {\n if (k.toLowerCase() === lower) return true;\n }\n return false;\n}\n\n/**\n * Build a reaction handler that POSTs each event to an external URL.\n *\n * Behavior:\n *\n * - 2xx and 3xx return successfully.\n * - 5xx responses, network errors, and timeouts throw\n * {@link WebhookError} (`status: 0` for network/timeout). Drain\n * retries per the reaction's `maxRetries` / `backoff`.\n * - 4xx responses throw {@link NonRetryableWebhookError}, which\n * extends `NonRetryableError`. The drain finalizer blocks the\n * stream immediately (when `blockOnError` is true) without\n * consuming the retry budget.\n */\nexport function webhook<TEvents extends Schemas = Schemas>(\n config: WebhookConfig<TEvents>\n): ReactionHandler<TEvents, keyof TEvents> {\n const timeoutMs = config.timeoutMs ?? 5_000;\n const method = config.method ?? \"POST\";\n const fetch_impl = config.fetch ?? globalThis.fetch;\n\n // Named function: slice/act builders require non-anonymous reaction\n // handlers so lifecycle telemetry can attribute work.\n return async function webhook_deliver(event) {\n const url = resolve(config.url, event, \"\");\n\n const custom_headers = resolve(\n config.headers,\n event,\n {} as Record<string, string>\n );\n const headers: Record<string, string> = { ...custom_headers };\n\n if (!has_header(headers, \"content-type\")) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n if (!has_header(headers, \"idempotency-key\")) {\n const key = config.idempotencyKey\n ? config.idempotencyKey(event)\n : String(event.id);\n if (key !== null) headers[\"Idempotency-Key\"] = key;\n }\n\n const rawBody = resolve(config.body, event, event as unknown);\n const body =\n typeof rawBody === \"string\" ? rawBody : JSON.stringify(rawBody);\n\n if (config.secret && !has_header(headers, \"x-webhook-signature\")) {\n const { signature, timestamp } = sign_request(body, config.secret);\n headers[\"X-Webhook-Signature\"] = signature;\n if (!has_header(headers, \"x-webhook-timestamp\")) {\n headers[\"X-Webhook-Timestamp\"] = timestamp;\n }\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n // The timer has to outlive the fetch. `fetch` resolves on response\n // HEADERS, so clearing it as soon as the fetch settled left the\n // error-path body read below unbounded: a receiver that answers with\n // headers and then never ends the body hung the handler forever, with\n // no timer and no signal still watching it (#1701). A hang is worse\n // than a throw here — an overrun lease is re-claimed by the next\n // holder and the attempt never spends its retry budget loudly, so the\n // stream never reaches its `blockOnError` verdict (the #1418 shape).\n try {\n let response: Response;\n try {\n response = await fetch_impl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n const aborted = controller.signal.aborted;\n throw new WebhookError(\n aborted\n ? `webhook ${method} ${url} timed out after ${timeoutMs}ms`\n : `webhook ${method} ${url} failed: ${(err as Error).message}`,\n { status: 0, url }\n );\n }\n\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // Best-effort body capture — a read that fails for its own reasons\n // (a reset mid-body) still reports the status below. An abort lands\n // here too, and that one is NOT best-effort: the timeout fired while\n // reading, so report the timeout rather than a response never read.\n if (controller.signal.aborted)\n throw new WebhookError(\n `webhook ${method} ${url} timed out after ${timeoutMs}ms reading the response body`,\n { status: response.status, url }\n );\n }\n\n const ErrorClass =\n disposition === \"retry\" ? WebhookError : NonRetryableWebhookError;\n throw new ErrorClass(\n `webhook ${method} ${url} responded ${response.status}`,\n { status: response.status, url, responseBody }\n );\n } finally {\n clearTimeout(timer);\n }\n };\n}\n","import {\n type Committed,\n NonRetryableError,\n type Schemas,\n} from \"@rotorsoft/act\";\n\n/**\n * Function or static value resolver. Used so callers can pass either a\n * constant or a per-event function for headers / body / url.\n *\n * The static side `T` is constrained to non-function types so that a\n * passed `(event) => ...` is unambiguously typed as the function variant.\n */\nexport type WebhookResolver<TEvents extends Schemas, T> =\n | T\n | ((event: Committed<TEvents, keyof TEvents>) => T);\n\n/**\n * Plain-data body shape the helper accepts as a static value. Functions\n * are deliberately excluded so the union with the resolver function is\n * unambiguous at the call site (TypeScript can discriminate by shape).\n */\nexport type WebhookBody =\n | string\n | { readonly [k: string]: unknown }\n | readonly unknown[];\n\n/**\n * Configuration for {@link webhook}.\n *\n * @template TEvents - Event schemas; resolvers receive the typed committed event.\n */\nexport type WebhookConfig<TEvents extends Schemas = Schemas> = {\n /** Target URL — static string or per-event function. */\n readonly url: WebhookResolver<TEvents, string>;\n /** HTTP method. Defaults to `\"POST\"`. */\n readonly method?: \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n /**\n * Headers to send. Resolver may return a record per event. The\n * `Content-Type: application/json` and `Idempotency-Key` headers are\n * applied automatically; both can be overridden by returning a header\n * with the same name (case-insensitive).\n */\n readonly headers?: WebhookResolver<TEvents, Record<string, string>>;\n /**\n * Request body. Static plain data (object, array, string) or a\n * per-event function returning the same. Strings are sent as-is;\n * anything else is JSON-serialized. Defaults to the committed event\n * itself.\n */\n readonly body?:\n | WebhookBody\n | ((event: Committed<TEvents, keyof TEvents>) => WebhookBody);\n /**\n * Per-request timeout in milliseconds. Defaults to 5000.\n * The handler throws after the timeout via `AbortController`.\n *\n * The budget covers the **whole** exchange, not just the connection:\n * `fetch` resolves on response headers, so the timer stays armed through\n * the error-path body read as well. A receiver that answers with headers\n * and then stalls its body times out like any other overrun instead of\n * hanging the handler forever (#1701).\n */\n readonly timeoutMs?: number;\n /**\n * Override for the auto-generated `Idempotency-Key`. By default, the\n * helper sends `event.id` (the immutable, monotonic event identifier).\n * Return a string to override; return `null` to skip the header entirely.\n */\n readonly idempotencyKey?: (\n event: Committed<TEvents, keyof TEvents>\n ) => string | null;\n /**\n * Injection point for tests. Defaults to global `fetch`.\n */\n readonly fetch?: typeof fetch;\n /**\n * HMAC-SHA256 signing key. When set, the webhook helper attaches\n * two headers to every request:\n *\n * - `X-Webhook-Signature: sha256=<hex>` — HMAC of\n * `${timestamp}.${body}` (`body` is the final serialized payload)\n * - `X-Webhook-Timestamp: <unix-seconds>`\n *\n * Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on\n * the receiving side. When undefined, no signature headers are\n * added — back-compat with consumers that don't need signing.\n *\n * Callers can override either header by returning it from the\n * `headers` resolver (case-insensitive), the same way the\n * `Idempotency-Key` and `Content-Type` defaults yield to caller\n * intent.\n */\n readonly secret?: string;\n};\n\n/**\n * Common fields carried on every HTTP delivery error in this package.\n */\nexport type HttpDeliveryErrorInit = {\n status: number;\n url: string;\n responseBody?: string;\n};\n\n/**\n * Thrown when an HTTP delivery fails in a way the drain pipeline\n * should retry: network failure, timeout, or 5xx response. `status` is\n * `0` for network / timeout errors, the HTTP status code otherwise.\n *\n * The class itself is the retry signal — if a reaction throws this,\n * drain treats it like any other error (counts against `maxRetries`,\n * paces with `backoff`). For permanent failures, throw\n * {@link NonRetryableHttpError} instead.\n *\n * Generic enough to cover any custom HTTP-like integration (gRPC\n * bridges, SDK-based reactions). {@link WebhookError} is a\n * webhook-specific subclass kept for backward compatibility.\n */\nexport class RetryableHttpError extends Error {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"RetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Thrown when an HTTP delivery returns a 3xx or 4xx response —\n * permanent client errors that won't recover on retry. Extends\n * {@link NonRetryableError} so the drain finalizer blocks the stream\n * on the first failed attempt (when `blockOnError` is true) — no\n * wasted retries on a malformed payload or wrong URL.\n *\n * Generic enough to cover any custom HTTP-like integration.\n * {@link NonRetryableWebhookError} is a webhook-specific subclass kept\n * for backward compatibility.\n */\nexport class NonRetryableHttpError extends NonRetryableError {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"NonRetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Webhook-specific subclass of {@link RetryableHttpError}. Thrown by\n * the {@link webhook} helper on 5xx responses, network failures, and\n * timeouts. Existing `instanceof WebhookError` checks continue to\n * work; new code targeting the generic HTTP integration shape can\n * catch {@link RetryableHttpError} instead and handle webhook +\n * custom integrations uniformly.\n */\nexport class WebhookError extends RetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"WebhookError\";\n }\n}\n\n/**\n * Webhook-specific subclass of {@link NonRetryableHttpError}. Thrown\n * by the {@link webhook} helper on 3xx/4xx responses. Existing\n * `instanceof NonRetryableWebhookError` checks continue to work; new\n * code can catch {@link NonRetryableHttpError} or\n * {@link NonRetryableError} for broader coverage.\n */\nexport class NonRetryableWebhookError extends NonRetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"NonRetryableWebhookError\";\n }\n}\n","import { NonRetryableHttpError, RetryableHttpError } from \"./types.js\";\n\n/**\n * Three buckets for an HTTP response from an outbound delivery:\n *\n * - `ok` — the receiver accepted the delivery (2xx). Stop and return.\n * - `retry` — the receiver had a transient problem (5xx). Throw a\n * retryable error; drain will pace the next attempt per `backoff`.\n * - `block` — the receiver rejected the delivery permanently (3xx\n * or 4xx). Throw a non-retryable error; drain blocks the stream\n * on the first failed attempt (when `blockOnError` is true) and\n * surfaces it via the `\"blocked\"` lifecycle event.\n *\n * The 3xx → `block` mapping is intentional: a redirect at the\n * delivery layer means the configured URL is wrong, and retrying\n * the same URL won't fix that. Manual operator review is the right\n * next step, which is what the block path produces.\n */\nexport type HttpDisposition = \"ok\" | \"retry\" | \"block\";\n\n/**\n * Classify an HTTP response as `ok` (2xx), `retry` (5xx), or\n * `block` (3xx, 4xx). The classification {@link webhook} uses\n * internally, lifted here so custom integrations (gRPC bridges,\n * SDK-based reactions, etc.) can apply the same retry semantics\n * without inventing a parallel rule.\n */\nexport function classifyHttpResponse(response: Response): HttpDisposition {\n if (response.ok) return \"ok\";\n if (response.status >= 500) return \"retry\";\n return \"block\";\n}\n\n/** Options for {@link tryOk}. */\nexport type TryOkOptions = {\n /** The endpoint that received the request. Surfaced on the thrown error and in its message. */\n url: string;\n /**\n * Label prefixed onto the error message — typically the\n * integration's identity (`\"webhook\"`, `\"my_sdk\"`, `\"grpc\"`).\n * Default: `\"request\"`.\n */\n label?: string;\n};\n\n/**\n * If `response` is 2xx, return. Otherwise, capture the response body\n * (best-effort) and throw a {@link RetryableHttpError} (for 5xx) or\n * {@link NonRetryableHttpError} (for 3xx/4xx). Collapses the\n * classify-and-throw boilerplate every custom HTTP-like reaction\n * would otherwise write into one line:\n *\n * ```ts\n * .on(\"OrderConfirmed\").do(async function deliverOrder(event) {\n * const response = await my_sdk.deliver(event);\n * await try_ok(response, { url: my_sdk.url, label: \"my_sdk\" });\n * // ...response was 2xx; continue with downstream work...\n * });\n * ```\n *\n * The {@link webhook} helper throws webhook-specific subclasses\n * ({@link WebhookError} / {@link NonRetryableWebhookError}) for\n * backward compatibility — both extend the generic classes thrown\n * here, so `instanceof RetryableHttpError` matches both webhook and\n * custom-integration errors uniformly.\n */\nexport async function tryOk(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const label = options.label ?? \"request\";\n const ErrorClass =\n disposition === \"retry\" ? RetryableHttpError : NonRetryableHttpError;\n throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {\n status: response.status,\n url: options.url,\n responseBody,\n });\n}\n","import { createHmac } from \"node:crypto\";\n\n/**\n * Compute the HMAC-SHA256 signature for an outbound webhook request.\n *\n * The signed payload is `${timestamp}.${body}` — Stripe-style. The\n * timestamp is included so the receiver can reject replays via a\n * window check, and the dot separator prevents `timestamp + body`\n * ambiguity (12 + 345 vs 123 + 45).\n *\n * Returns `{ signature, timestamp }` so the webhook helper can attach\n * both as headers — `X-Webhook-Signature: sha256=<hex>` and\n * `X-Webhook-Timestamp: <unix-seconds>` — for the receiver to verify\n * via `verifyWebhook` from `@rotorsoft/act-http/receiver`.\n *\n * `now` is exposed for tests; production callers should leave it\n * undefined so wall-clock is used.\n *\n * @internal Reachable from tests via the source path. Not re-exported\n * from the package's `./webhook` entry — the webhook helper calls\n * it internally, and operators don't need it directly.\n */\nexport function sign_request(\n body: string,\n secret: string,\n now: number = Math.floor(Date.now() / 1000)\n): { signature: string; timestamp: string } {\n const timestamp = String(now);\n const payload = `${timestamp}.${body}`;\n const hex = createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n return { signature: `sha256=${hex}`, timestamp };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/webhook/index.ts","../../src/webhook/types.ts","../../src/webhook/classify.ts","../../src/webhook/sign.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/webhook\n *\n * Reaction-handler sugar for POSTing committed events to external URLs.\n *\n * Wraps `fetch` with timeouts, automatic `Idempotency-Key` derivation, and\n * status-classified errors. Designed to be composed with the reaction\n * options (`maxRetries`, `blockOnError`, `backoff`):\n *\n * ```ts\n * import { webhook } from \"@rotorsoft/act-http/webhook\";\n *\n * .on(\"OrderConfirmed\")\n * .do(\n * webhook({\n * url: \"https://api.example.com/webhooks/orders\",\n * headers: (e) => ({ Authorization: \"Bearer ...\" }),\n * body: (e) => ({ orderId: e.stream, total: e.data.total }),\n * timeoutMs: 5_000,\n * }),\n * { maxRetries: 5, backoff: { strategy: \"exponential\", baseMs: 200, maxMs: 30_000 } }\n * )\n * .to(resolver);\n * ```\n */\n\nimport type { Committed, ReactionHandler, Schemas } from \"@rotorsoft/act\";\nimport { classifyHttpResponse } from \"./classify.js\";\nimport { sign_request } from \"./sign.js\";\nimport {\n NonRetryableWebhookError,\n type WebhookConfig,\n WebhookError,\n} from \"./types.js\";\n\nexport type { HttpDisposition, TryOkOptions } from \"./classify.js\";\n// See rfcs/1621-webhook-classify-exports.md.\nexport { classifyHttpResponse, tryOk } from \"./classify.js\";\nexport type {\n HttpDeliveryErrorInit,\n WebhookBody,\n WebhookConfig,\n WebhookResolver,\n} from \"./types.js\";\nexport {\n NonRetryableHttpError,\n NonRetryableWebhookError,\n RetryableHttpError,\n WebhookError,\n} from \"./types.js\";\n\nfunction resolve<TEvents extends Schemas, T>(\n resolver: T | ((e: Committed<TEvents, keyof TEvents>) => T) | undefined,\n event: Committed<TEvents, keyof TEvents>,\n fallback: T\n): T {\n if (resolver === undefined) return fallback;\n return typeof resolver === \"function\"\n ? (resolver as (e: Committed<TEvents, keyof TEvents>) => T)(event)\n : resolver;\n}\n\n/** Case-insensitive lookup; returns true if a header is already set. */\nfunction has_header(headers: Record<string, string>, name: string): boolean {\n const lower = name.toLowerCase();\n for (const k of Object.keys(headers)) {\n if (k.toLowerCase() === lower) return true;\n }\n return false;\n}\n\n/**\n * Build a reaction handler that POSTs each event to an external URL.\n *\n * Behavior:\n *\n * - 2xx and 3xx return successfully.\n * - 5xx responses, network errors, and timeouts throw\n * {@link WebhookError} (`status: 0` for network/timeout). Drain\n * retries per the reaction's `maxRetries` / `backoff`.\n * - 4xx responses throw {@link NonRetryableWebhookError}, which\n * extends `NonRetryableError`. The drain finalizer blocks the\n * stream immediately (when `blockOnError` is true) without\n * consuming the retry budget.\n */\nexport function webhook<TEvents extends Schemas = Schemas>(\n config: WebhookConfig<TEvents>\n): ReactionHandler<TEvents, keyof TEvents> {\n const timeoutMs = config.timeoutMs ?? 5_000;\n const method = config.method ?? \"POST\";\n const fetch_impl = config.fetch ?? globalThis.fetch;\n\n // Named function: slice/act builders require non-anonymous reaction\n // handlers so lifecycle telemetry can attribute work.\n return async function webhook_deliver(event) {\n const url = resolve(config.url, event, \"\");\n\n const custom_headers = resolve(\n config.headers,\n event,\n {} as Record<string, string>\n );\n const headers: Record<string, string> = { ...custom_headers };\n\n if (!has_header(headers, \"content-type\")) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n if (!has_header(headers, \"idempotency-key\")) {\n const key = config.idempotencyKey\n ? config.idempotencyKey(event)\n : String(event.id);\n if (key !== null) headers[\"Idempotency-Key\"] = key;\n }\n\n const rawBody = resolve(config.body, event, event as unknown);\n const body =\n typeof rawBody === \"string\" ? rawBody : JSON.stringify(rawBody);\n\n if (config.secret && !has_header(headers, \"x-webhook-signature\")) {\n const { signature, timestamp } = sign_request(body, config.secret);\n headers[\"X-Webhook-Signature\"] = signature;\n if (!has_header(headers, \"x-webhook-timestamp\")) {\n headers[\"X-Webhook-Timestamp\"] = timestamp;\n }\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n // The timer has to outlive the fetch: `fetch` resolves on response\n // headers, so the error-path body read below needs the timer too, or a\n // receiver that never ends the body hangs the handler. A hang is worse\n // than a throw: the lease is re-claimed and the attempt never spends its\n // retry budget, so the stream never reaches its `blockOnError` verdict.\n try {\n let response: Response;\n try {\n response = await fetch_impl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n const aborted = controller.signal.aborted;\n throw new WebhookError(\n aborted\n ? `webhook ${method} ${url} timed out after ${timeoutMs}ms`\n : `webhook ${method} ${url} failed: ${(err as Error).message}`,\n { status: 0, url }\n );\n }\n\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // Best-effort body capture — a read that fails for its own reasons\n // (a reset mid-body) still reports the status below. An abort lands\n // here too, and that one is NOT best-effort: the timeout fired while\n // reading, so report the timeout rather than a response never read.\n if (controller.signal.aborted)\n throw new WebhookError(\n `webhook ${method} ${url} timed out after ${timeoutMs}ms reading the response body`,\n { status: response.status, url }\n );\n }\n\n const ErrorClass =\n disposition === \"retry\" ? WebhookError : NonRetryableWebhookError;\n throw new ErrorClass(\n `webhook ${method} ${url} responded ${response.status}`,\n { status: response.status, url, responseBody }\n );\n } finally {\n clearTimeout(timer);\n }\n };\n}\n","import {\n type Committed,\n NonRetryableError,\n type Schemas,\n} from \"@rotorsoft/act\";\n\n/**\n * Function or static value resolver. Used so callers can pass either a\n * constant or a per-event function for headers / body / url.\n *\n * The static side `T` is constrained to non-function types so that a\n * passed `(event) => ...` is unambiguously typed as the function variant.\n */\nexport type WebhookResolver<TEvents extends Schemas, T> =\n | T\n | ((event: Committed<TEvents, keyof TEvents>) => T);\n\n/**\n * Plain-data body shape the helper accepts as a static value. Functions\n * are deliberately excluded so the union with the resolver function is\n * unambiguous at the call site (TypeScript can discriminate by shape).\n */\nexport type WebhookBody =\n | string\n | { readonly [k: string]: unknown }\n | readonly unknown[];\n\n/**\n * Configuration for {@link webhook}.\n *\n * @template TEvents - Event schemas; resolvers receive the typed committed event.\n */\nexport type WebhookConfig<TEvents extends Schemas = Schemas> = {\n /** Target URL — static string or per-event function. */\n readonly url: WebhookResolver<TEvents, string>;\n /** HTTP method. Defaults to `\"POST\"`. */\n readonly method?: \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n /**\n * Headers to send. Resolver may return a record per event. The\n * `Content-Type: application/json` and `Idempotency-Key` headers are\n * applied automatically; both can be overridden by returning a header\n * with the same name (case-insensitive).\n */\n readonly headers?: WebhookResolver<TEvents, Record<string, string>>;\n /**\n * Request body. Static plain data (object, array, string) or a\n * per-event function returning the same. Strings are sent as-is;\n * anything else is JSON-serialized. Defaults to the committed event\n * itself.\n */\n readonly body?:\n | WebhookBody\n | ((event: Committed<TEvents, keyof TEvents>) => WebhookBody);\n /**\n * Per-request timeout in milliseconds. Defaults to 5000.\n * The handler throws after the timeout via `AbortController`.\n *\n * The budget covers the **whole** exchange, not just the connection:\n * `fetch` resolves on response headers, so the timer stays armed through\n * the error-path body read as well. A receiver that answers with headers\n * and then stalls its body times out like any other overrun instead of\n * hanging the handler forever.\n */\n readonly timeoutMs?: number;\n /**\n * Override for the auto-generated `Idempotency-Key`. By default, the\n * helper sends `event.id` (the immutable, monotonic event identifier).\n * Return a string to override; return `null` to skip the header entirely.\n */\n readonly idempotencyKey?: (\n event: Committed<TEvents, keyof TEvents>\n ) => string | null;\n /**\n * Injection point for tests. Defaults to global `fetch`.\n */\n readonly fetch?: typeof fetch;\n /**\n * HMAC-SHA256 signing key. When set, the webhook helper attaches\n * two headers to every request:\n *\n * - `X-Webhook-Signature: sha256=<hex>` — HMAC of\n * `${timestamp}.${body}` (`body` is the final serialized payload)\n * - `X-Webhook-Timestamp: <unix-seconds>`\n *\n * Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on\n * the receiving side. When undefined, no signature headers are\n * added — back-compat with consumers that don't need signing.\n *\n * Callers can override either header by returning it from the\n * `headers` resolver (case-insensitive), the same way the\n * `Idempotency-Key` and `Content-Type` defaults yield to caller\n * intent.\n */\n readonly secret?: string;\n};\n\n/**\n * Common fields carried on every HTTP delivery error in this package.\n */\nexport type HttpDeliveryErrorInit = {\n status: number;\n url: string;\n responseBody?: string;\n};\n\n/**\n * Thrown when an HTTP delivery fails in a way the drain pipeline\n * should retry: network failure, timeout, or 5xx response. `status` is\n * `0` for network / timeout errors, the HTTP status code otherwise.\n *\n * The class itself is the retry signal — if a reaction throws this,\n * drain treats it like any other error (counts against `maxRetries`,\n * paces with `backoff`). For permanent failures, throw\n * {@link NonRetryableHttpError} instead.\n *\n * Generic enough to cover any custom HTTP-like integration (gRPC\n * bridges, SDK-based reactions). {@link WebhookError} is a\n * webhook-specific subclass kept for backward compatibility.\n */\nexport class RetryableHttpError extends Error {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"RetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Thrown when an HTTP delivery returns a 3xx or 4xx response —\n * permanent client errors that won't recover on retry. Extends\n * {@link NonRetryableError} so the drain finalizer blocks the stream\n * on the first failed attempt (when `blockOnError` is true) — no\n * wasted retries on a malformed payload or wrong URL.\n *\n * Generic enough to cover any custom HTTP-like integration.\n * {@link NonRetryableWebhookError} is a webhook-specific subclass kept\n * for backward compatibility.\n */\nexport class NonRetryableHttpError extends NonRetryableError {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"NonRetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Webhook-specific subclass of {@link RetryableHttpError}. Thrown by\n * the {@link webhook} helper on 5xx responses, network failures, and\n * timeouts. Existing `instanceof WebhookError` checks continue to\n * work; new code targeting the generic HTTP integration shape can\n * catch {@link RetryableHttpError} instead and handle webhook +\n * custom integrations uniformly.\n */\nexport class WebhookError extends RetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"WebhookError\";\n }\n}\n\n/**\n * Webhook-specific subclass of {@link NonRetryableHttpError}. Thrown\n * by the {@link webhook} helper on 3xx/4xx responses. Existing\n * `instanceof NonRetryableWebhookError` checks continue to work; new\n * code can catch {@link NonRetryableHttpError} or\n * {@link NonRetryableError} for broader coverage.\n */\nexport class NonRetryableWebhookError extends NonRetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"NonRetryableWebhookError\";\n }\n}\n","import { NonRetryableHttpError, RetryableHttpError } from \"./types.js\";\n\n/**\n * Three buckets for an HTTP response from an outbound delivery:\n *\n * - `ok` — the receiver accepted the delivery (2xx). Stop and return.\n * - `retry` — the receiver had a transient problem (5xx). Throw a\n * retryable error; drain will pace the next attempt per `backoff`.\n * - `block` — the receiver rejected the delivery permanently (3xx\n * or 4xx). Throw a non-retryable error; drain blocks the stream\n * on the first failed attempt (when `blockOnError` is true) and\n * surfaces it via the `\"blocked\"` lifecycle event.\n *\n * The 3xx → `block` mapping is intentional: a redirect at the\n * delivery layer means the configured URL is wrong, and retrying\n * the same URL won't fix that. Manual operator review is the right\n * next step, which is what the block path produces.\n */\nexport type HttpDisposition = \"ok\" | \"retry\" | \"block\";\n\n/**\n * Classify an HTTP response as `ok` (2xx), `retry` (5xx), or\n * `block` (3xx, 4xx). The classification {@link webhook} uses\n * internally, lifted here so custom integrations (gRPC bridges,\n * SDK-based reactions, etc.) can apply the same retry semantics\n * without inventing a parallel rule.\n */\nexport function classifyHttpResponse(response: Response): HttpDisposition {\n if (response.ok) return \"ok\";\n if (response.status >= 500) return \"retry\";\n return \"block\";\n}\n\n/** Options for {@link tryOk}. */\nexport type TryOkOptions = {\n /** The endpoint that received the request. Surfaced on the thrown error and in its message. */\n url: string;\n /**\n * Label prefixed onto the error message — typically the\n * integration's identity (`\"webhook\"`, `\"my_sdk\"`, `\"grpc\"`).\n * Default: `\"request\"`.\n */\n label?: string;\n};\n\n/**\n * If `response` is 2xx, return. Otherwise, capture the response body\n * (best-effort) and throw a {@link RetryableHttpError} (for 5xx) or\n * {@link NonRetryableHttpError} (for 3xx/4xx). Collapses the\n * classify-and-throw boilerplate every custom HTTP-like reaction\n * would otherwise write into one line:\n *\n * ```ts\n * .on(\"OrderConfirmed\").do(async function deliverOrder(event) {\n * const response = await my_sdk.deliver(event);\n * await try_ok(response, { url: my_sdk.url, label: \"my_sdk\" });\n * // ...response was 2xx; continue with downstream work...\n * });\n * ```\n *\n * The {@link webhook} helper throws webhook-specific subclasses\n * ({@link WebhookError} / {@link NonRetryableWebhookError}) for\n * backward compatibility — both extend the generic classes thrown\n * here, so `instanceof RetryableHttpError` matches both webhook and\n * custom-integration errors uniformly.\n */\nexport async function tryOk(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const label = options.label ?? \"request\";\n const ErrorClass =\n disposition === \"retry\" ? RetryableHttpError : NonRetryableHttpError;\n throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {\n status: response.status,\n url: options.url,\n responseBody,\n });\n}\n","import { createHmac } from \"node:crypto\";\n\n/**\n * Compute the HMAC-SHA256 signature for an outbound webhook request.\n *\n * The signed payload is `${timestamp}.${body}` — Stripe-style. The\n * timestamp is included so the receiver can reject replays via a\n * window check, and the dot separator prevents `timestamp + body`\n * ambiguity (12 + 345 vs 123 + 45).\n *\n * Returns `{ signature, timestamp }` so the webhook helper can attach\n * both as headers — `X-Webhook-Signature: sha256=<hex>` and\n * `X-Webhook-Timestamp: <unix-seconds>` — for the receiver to verify\n * via `verifyWebhook` from `@rotorsoft/act-http/receiver`.\n *\n * `now` is exposed for tests; production callers should leave it\n * undefined so wall-clock is used.\n *\n * @internal Reachable from tests via the source path. Not re-exported\n * from the package's `./webhook` entry — the webhook helper calls\n * it internally, and operators don't need it directly.\n */\nexport function sign_request(\n body: string,\n secret: string,\n now: number = Math.floor(Date.now() / 1000)\n): { signature: string; timestamp: string } {\n const timestamp = String(now);\n const payload = `${timestamp}.${body}`;\n const hex = createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n return { signature: `sha256=${hex}`, timestamp };\n}\n"],"mappings":"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","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/webhook/types.ts","../../src/webhook/classify.ts","../../src/webhook/sign.ts","../../src/webhook/index.ts"],"sourcesContent":["import {\n type Committed,\n NonRetryableError,\n type Schemas,\n} from \"@rotorsoft/act\";\n\n/**\n * Function or static value resolver. Used so callers can pass either a\n * constant or a per-event function for headers / body / url.\n *\n * The static side `T` is constrained to non-function types so that a\n * passed `(event) => ...` is unambiguously typed as the function variant.\n */\nexport type WebhookResolver<TEvents extends Schemas, T> =\n | T\n | ((event: Committed<TEvents, keyof TEvents>) => T);\n\n/**\n * Plain-data body shape the helper accepts as a static value. Functions\n * are deliberately excluded so the union with the resolver function is\n * unambiguous at the call site (TypeScript can discriminate by shape).\n */\nexport type WebhookBody =\n | string\n | { readonly [k: string]: unknown }\n | readonly unknown[];\n\n/**\n * Configuration for {@link webhook}.\n *\n * @template TEvents - Event schemas; resolvers receive the typed committed event.\n */\nexport type WebhookConfig<TEvents extends Schemas = Schemas> = {\n /** Target URL — static string or per-event function. */\n readonly url: WebhookResolver<TEvents, string>;\n /** HTTP method. Defaults to `\"POST\"`. */\n readonly method?: \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n /**\n * Headers to send. Resolver may return a record per event. The\n * `Content-Type: application/json` and `Idempotency-Key` headers are\n * applied automatically; both can be overridden by returning a header\n * with the same name (case-insensitive).\n */\n readonly headers?: WebhookResolver<TEvents, Record<string, string>>;\n /**\n * Request body. Static plain data (object, array, string) or a\n * per-event function returning the same. Strings are sent as-is;\n * anything else is JSON-serialized. Defaults to the committed event\n * itself.\n */\n readonly body?:\n | WebhookBody\n | ((event: Committed<TEvents, keyof TEvents>) => WebhookBody);\n /**\n * Per-request timeout in milliseconds. Defaults to 5000.\n * The handler throws after the timeout via `AbortController`.\n *\n * The budget covers the **whole** exchange, not just the connection:\n * `fetch` resolves on response headers, so the timer stays armed through\n * the error-path body read as well. A receiver that answers with headers\n * and then stalls its body times out like any other overrun instead of\n * hanging the handler forever (#1701).\n */\n readonly timeoutMs?: number;\n /**\n * Override for the auto-generated `Idempotency-Key`. By default, the\n * helper sends `event.id` (the immutable, monotonic event identifier).\n * Return a string to override; return `null` to skip the header entirely.\n */\n readonly idempotencyKey?: (\n event: Committed<TEvents, keyof TEvents>\n ) => string | null;\n /**\n * Injection point for tests. Defaults to global `fetch`.\n */\n readonly fetch?: typeof fetch;\n /**\n * HMAC-SHA256 signing key. When set, the webhook helper attaches\n * two headers to every request:\n *\n * - `X-Webhook-Signature: sha256=<hex>` — HMAC of\n * `${timestamp}.${body}` (`body` is the final serialized payload)\n * - `X-Webhook-Timestamp: <unix-seconds>`\n *\n * Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on\n * the receiving side. When undefined, no signature headers are\n * added — back-compat with consumers that don't need signing.\n *\n * Callers can override either header by returning it from the\n * `headers` resolver (case-insensitive), the same way the\n * `Idempotency-Key` and `Content-Type` defaults yield to caller\n * intent.\n */\n readonly secret?: string;\n};\n\n/**\n * Common fields carried on every HTTP delivery error in this package.\n */\nexport type HttpDeliveryErrorInit = {\n status: number;\n url: string;\n responseBody?: string;\n};\n\n/**\n * Thrown when an HTTP delivery fails in a way the drain pipeline\n * should retry: network failure, timeout, or 5xx response. `status` is\n * `0` for network / timeout errors, the HTTP status code otherwise.\n *\n * The class itself is the retry signal — if a reaction throws this,\n * drain treats it like any other error (counts against `maxRetries`,\n * paces with `backoff`). For permanent failures, throw\n * {@link NonRetryableHttpError} instead.\n *\n * Generic enough to cover any custom HTTP-like integration (gRPC\n * bridges, SDK-based reactions). {@link WebhookError} is a\n * webhook-specific subclass kept for backward compatibility.\n */\nexport class RetryableHttpError extends Error {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"RetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Thrown when an HTTP delivery returns a 3xx or 4xx response —\n * permanent client errors that won't recover on retry. Extends\n * {@link NonRetryableError} so the drain finalizer blocks the stream\n * on the first failed attempt (when `blockOnError` is true) — no\n * wasted retries on a malformed payload or wrong URL.\n *\n * Generic enough to cover any custom HTTP-like integration.\n * {@link NonRetryableWebhookError} is a webhook-specific subclass kept\n * for backward compatibility.\n */\nexport class NonRetryableHttpError extends NonRetryableError {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"NonRetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Webhook-specific subclass of {@link RetryableHttpError}. Thrown by\n * the {@link webhook} helper on 5xx responses, network failures, and\n * timeouts. Existing `instanceof WebhookError` checks continue to\n * work; new code targeting the generic HTTP integration shape can\n * catch {@link RetryableHttpError} instead and handle webhook +\n * custom integrations uniformly.\n */\nexport class WebhookError extends RetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"WebhookError\";\n }\n}\n\n/**\n * Webhook-specific subclass of {@link NonRetryableHttpError}. Thrown\n * by the {@link webhook} helper on 3xx/4xx responses. Existing\n * `instanceof NonRetryableWebhookError` checks continue to work; new\n * code can catch {@link NonRetryableHttpError} or\n * {@link NonRetryableError} for broader coverage.\n */\nexport class NonRetryableWebhookError extends NonRetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"NonRetryableWebhookError\";\n }\n}\n","import { NonRetryableHttpError, RetryableHttpError } from \"./types.js\";\n\n/**\n * Three buckets for an HTTP response from an outbound delivery:\n *\n * - `ok` — the receiver accepted the delivery (2xx). Stop and return.\n * - `retry` — the receiver had a transient problem (5xx). Throw a\n * retryable error; drain will pace the next attempt per `backoff`.\n * - `block` — the receiver rejected the delivery permanently (3xx\n * or 4xx). Throw a non-retryable error; drain blocks the stream\n * on the first failed attempt (when `blockOnError` is true) and\n * surfaces it via the `\"blocked\"` lifecycle event.\n *\n * The 3xx → `block` mapping is intentional: a redirect at the\n * delivery layer means the configured URL is wrong, and retrying\n * the same URL won't fix that. Manual operator review is the right\n * next step, which is what the block path produces.\n */\nexport type HttpDisposition = \"ok\" | \"retry\" | \"block\";\n\n/**\n * Classify an HTTP response as `ok` (2xx), `retry` (5xx), or\n * `block` (3xx, 4xx). The classification {@link webhook} uses\n * internally, lifted here so custom integrations (gRPC bridges,\n * SDK-based reactions, etc.) can apply the same retry semantics\n * without inventing a parallel rule.\n */\nexport function classifyHttpResponse(response: Response): HttpDisposition {\n if (response.ok) return \"ok\";\n if (response.status >= 500) return \"retry\";\n return \"block\";\n}\n\n/** Options for {@link tryOk}. */\nexport type TryOkOptions = {\n /** The endpoint that received the request. Surfaced on the thrown error and in its message. */\n url: string;\n /**\n * Label prefixed onto the error message — typically the\n * integration's identity (`\"webhook\"`, `\"my_sdk\"`, `\"grpc\"`).\n * Default: `\"request\"`.\n */\n label?: string;\n};\n\n/**\n * If `response` is 2xx, return. Otherwise, capture the response body\n * (best-effort) and throw a {@link RetryableHttpError} (for 5xx) or\n * {@link NonRetryableHttpError} (for 3xx/4xx). Collapses the\n * classify-and-throw boilerplate every custom HTTP-like reaction\n * would otherwise write into one line:\n *\n * ```ts\n * .on(\"OrderConfirmed\").do(async function deliverOrder(event) {\n * const response = await my_sdk.deliver(event);\n * await try_ok(response, { url: my_sdk.url, label: \"my_sdk\" });\n * // ...response was 2xx; continue with downstream work...\n * });\n * ```\n *\n * The {@link webhook} helper throws webhook-specific subclasses\n * ({@link WebhookError} / {@link NonRetryableWebhookError}) for\n * backward compatibility — both extend the generic classes thrown\n * here, so `instanceof RetryableHttpError` matches both webhook and\n * custom-integration errors uniformly.\n */\nexport async function tryOk(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const label = options.label ?? \"request\";\n const ErrorClass =\n disposition === \"retry\" ? RetryableHttpError : NonRetryableHttpError;\n throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {\n status: response.status,\n url: options.url,\n responseBody,\n });\n}\n","import { createHmac } from \"node:crypto\";\n\n/**\n * Compute the HMAC-SHA256 signature for an outbound webhook request.\n *\n * The signed payload is `${timestamp}.${body}` — Stripe-style. The\n * timestamp is included so the receiver can reject replays via a\n * window check, and the dot separator prevents `timestamp + body`\n * ambiguity (12 + 345 vs 123 + 45).\n *\n * Returns `{ signature, timestamp }` so the webhook helper can attach\n * both as headers — `X-Webhook-Signature: sha256=<hex>` and\n * `X-Webhook-Timestamp: <unix-seconds>` — for the receiver to verify\n * via `verifyWebhook` from `@rotorsoft/act-http/receiver`.\n *\n * `now` is exposed for tests; production callers should leave it\n * undefined so wall-clock is used.\n *\n * @internal Reachable from tests via the source path. Not re-exported\n * from the package's `./webhook` entry — the webhook helper calls\n * it internally, and operators don't need it directly.\n */\nexport function sign_request(\n body: string,\n secret: string,\n now: number = Math.floor(Date.now() / 1000)\n): { signature: string; timestamp: string } {\n const timestamp = String(now);\n const payload = `${timestamp}.${body}`;\n const hex = createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n return { signature: `sha256=${hex}`, timestamp };\n}\n","/**\n * @packageDocumentation\n * @module act-http/webhook\n *\n * Reaction-handler sugar for POSTing committed events to external URLs.\n *\n * Wraps `fetch` with timeouts, automatic `Idempotency-Key` derivation, and\n * status-classified errors. Designed to be composed with the reaction\n * options shipped in ACT-601 (`maxRetries`, `blockOnError`, `backoff`):\n *\n * ```ts\n * import { webhook } from \"@rotorsoft/act-http/webhook\";\n *\n * .on(\"OrderConfirmed\")\n * .do(\n * webhook({\n * url: \"https://api.example.com/webhooks/orders\",\n * headers: (e) => ({ Authorization: \"Bearer ...\" }),\n * body: (e) => ({ orderId: e.stream, total: e.data.total }),\n * timeoutMs: 5_000,\n * }),\n * { maxRetries: 5, backoff: { strategy: \"exponential\", baseMs: 200, maxMs: 30_000 } }\n * )\n * .to(resolver);\n * ```\n */\n\nimport type { Committed, ReactionHandler, Schemas } from \"@rotorsoft/act\";\nimport { classifyHttpResponse } from \"./classify.js\";\nimport { sign_request } from \"./sign.js\";\nimport {\n NonRetryableWebhookError,\n type WebhookConfig,\n WebhookError,\n} from \"./types.js\";\n\nexport type { HttpDisposition, TryOkOptions } from \"./classify.js\";\n// Documented in this subpath's README as public API since the helpers were\n// written — implemented, tested, and with a doc-comment describing external\n// use — but never actually exported (#1621). See rfcs/1621-webhook-classify-exports.md.\nexport { classifyHttpResponse, tryOk } from \"./classify.js\";\nexport type {\n HttpDeliveryErrorInit,\n WebhookBody,\n WebhookConfig,\n WebhookResolver,\n} from \"./types.js\";\nexport {\n NonRetryableHttpError,\n NonRetryableWebhookError,\n RetryableHttpError,\n WebhookError,\n} from \"./types.js\";\n\nfunction resolve<TEvents extends Schemas, T>(\n resolver: T | ((e: Committed<TEvents, keyof TEvents>) => T) | undefined,\n event: Committed<TEvents, keyof TEvents>,\n fallback: T\n): T {\n if (resolver === undefined) return fallback;\n return typeof resolver === \"function\"\n ? (resolver as (e: Committed<TEvents, keyof TEvents>) => T)(event)\n : resolver;\n}\n\n/** Case-insensitive lookup; returns true if a header is already set. */\nfunction has_header(headers: Record<string, string>, name: string): boolean {\n const lower = name.toLowerCase();\n for (const k of Object.keys(headers)) {\n if (k.toLowerCase() === lower) return true;\n }\n return false;\n}\n\n/**\n * Build a reaction handler that POSTs each event to an external URL.\n *\n * Behavior:\n *\n * - 2xx and 3xx return successfully.\n * - 5xx responses, network errors, and timeouts throw\n * {@link WebhookError} (`status: 0` for network/timeout). Drain\n * retries per the reaction's `maxRetries` / `backoff`.\n * - 4xx responses throw {@link NonRetryableWebhookError}, which\n * extends `NonRetryableError`. The drain finalizer blocks the\n * stream immediately (when `blockOnError` is true) without\n * consuming the retry budget.\n */\nexport function webhook<TEvents extends Schemas = Schemas>(\n config: WebhookConfig<TEvents>\n): ReactionHandler<TEvents, keyof TEvents> {\n const timeoutMs = config.timeoutMs ?? 5_000;\n const method = config.method ?? \"POST\";\n const fetch_impl = config.fetch ?? globalThis.fetch;\n\n // Named function: slice/act builders require non-anonymous reaction\n // handlers so lifecycle telemetry can attribute work.\n return async function webhook_deliver(event) {\n const url = resolve(config.url, event, \"\");\n\n const custom_headers = resolve(\n config.headers,\n event,\n {} as Record<string, string>\n );\n const headers: Record<string, string> = { ...custom_headers };\n\n if (!has_header(headers, \"content-type\")) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n if (!has_header(headers, \"idempotency-key\")) {\n const key = config.idempotencyKey\n ? config.idempotencyKey(event)\n : String(event.id);\n if (key !== null) headers[\"Idempotency-Key\"] = key;\n }\n\n const rawBody = resolve(config.body, event, event as unknown);\n const body =\n typeof rawBody === \"string\" ? rawBody : JSON.stringify(rawBody);\n\n if (config.secret && !has_header(headers, \"x-webhook-signature\")) {\n const { signature, timestamp } = sign_request(body, config.secret);\n headers[\"X-Webhook-Signature\"] = signature;\n if (!has_header(headers, \"x-webhook-timestamp\")) {\n headers[\"X-Webhook-Timestamp\"] = timestamp;\n }\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n // The timer has to outlive the fetch. `fetch` resolves on response\n // HEADERS, so clearing it as soon as the fetch settled left the\n // error-path body read below unbounded: a receiver that answers with\n // headers and then never ends the body hung the handler forever, with\n // no timer and no signal still watching it (#1701). A hang is worse\n // than a throw here — an overrun lease is re-claimed by the next\n // holder and the attempt never spends its retry budget loudly, so the\n // stream never reaches its `blockOnError` verdict (the #1418 shape).\n try {\n let response: Response;\n try {\n response = await fetch_impl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n const aborted = controller.signal.aborted;\n throw new WebhookError(\n aborted\n ? `webhook ${method} ${url} timed out after ${timeoutMs}ms`\n : `webhook ${method} ${url} failed: ${(err as Error).message}`,\n { status: 0, url }\n );\n }\n\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // Best-effort body capture — a read that fails for its own reasons\n // (a reset mid-body) still reports the status below. An abort lands\n // here too, and that one is NOT best-effort: the timeout fired while\n // reading, so report the timeout rather than a response never read.\n if (controller.signal.aborted)\n throw new WebhookError(\n `webhook ${method} ${url} timed out after ${timeoutMs}ms reading the response body`,\n { status: response.status, url }\n );\n }\n\n const ErrorClass =\n disposition === \"retry\" ? WebhookError : NonRetryableWebhookError;\n throw new ErrorClass(\n `webhook ${method} ${url} responded ${response.status}`,\n { status: response.status, url, responseBody }\n );\n } finally {\n clearTimeout(timer);\n }\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/webhook/types.ts","../../src/webhook/classify.ts","../../src/webhook/sign.ts","../../src/webhook/index.ts"],"sourcesContent":["import {\n type Committed,\n NonRetryableError,\n type Schemas,\n} from \"@rotorsoft/act\";\n\n/**\n * Function or static value resolver. Used so callers can pass either a\n * constant or a per-event function for headers / body / url.\n *\n * The static side `T` is constrained to non-function types so that a\n * passed `(event) => ...` is unambiguously typed as the function variant.\n */\nexport type WebhookResolver<TEvents extends Schemas, T> =\n | T\n | ((event: Committed<TEvents, keyof TEvents>) => T);\n\n/**\n * Plain-data body shape the helper accepts as a static value. Functions\n * are deliberately excluded so the union with the resolver function is\n * unambiguous at the call site (TypeScript can discriminate by shape).\n */\nexport type WebhookBody =\n | string\n | { readonly [k: string]: unknown }\n | readonly unknown[];\n\n/**\n * Configuration for {@link webhook}.\n *\n * @template TEvents - Event schemas; resolvers receive the typed committed event.\n */\nexport type WebhookConfig<TEvents extends Schemas = Schemas> = {\n /** Target URL — static string or per-event function. */\n readonly url: WebhookResolver<TEvents, string>;\n /** HTTP method. Defaults to `\"POST\"`. */\n readonly method?: \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n /**\n * Headers to send. Resolver may return a record per event. The\n * `Content-Type: application/json` and `Idempotency-Key` headers are\n * applied automatically; both can be overridden by returning a header\n * with the same name (case-insensitive).\n */\n readonly headers?: WebhookResolver<TEvents, Record<string, string>>;\n /**\n * Request body. Static plain data (object, array, string) or a\n * per-event function returning the same. Strings are sent as-is;\n * anything else is JSON-serialized. Defaults to the committed event\n * itself.\n */\n readonly body?:\n | WebhookBody\n | ((event: Committed<TEvents, keyof TEvents>) => WebhookBody);\n /**\n * Per-request timeout in milliseconds. Defaults to 5000.\n * The handler throws after the timeout via `AbortController`.\n *\n * The budget covers the **whole** exchange, not just the connection:\n * `fetch` resolves on response headers, so the timer stays armed through\n * the error-path body read as well. A receiver that answers with headers\n * and then stalls its body times out like any other overrun instead of\n * hanging the handler forever.\n */\n readonly timeoutMs?: number;\n /**\n * Override for the auto-generated `Idempotency-Key`. By default, the\n * helper sends `event.id` (the immutable, monotonic event identifier).\n * Return a string to override; return `null` to skip the header entirely.\n */\n readonly idempotencyKey?: (\n event: Committed<TEvents, keyof TEvents>\n ) => string | null;\n /**\n * Injection point for tests. Defaults to global `fetch`.\n */\n readonly fetch?: typeof fetch;\n /**\n * HMAC-SHA256 signing key. When set, the webhook helper attaches\n * two headers to every request:\n *\n * - `X-Webhook-Signature: sha256=<hex>` — HMAC of\n * `${timestamp}.${body}` (`body` is the final serialized payload)\n * - `X-Webhook-Timestamp: <unix-seconds>`\n *\n * Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on\n * the receiving side. When undefined, no signature headers are\n * added — back-compat with consumers that don't need signing.\n *\n * Callers can override either header by returning it from the\n * `headers` resolver (case-insensitive), the same way the\n * `Idempotency-Key` and `Content-Type` defaults yield to caller\n * intent.\n */\n readonly secret?: string;\n};\n\n/**\n * Common fields carried on every HTTP delivery error in this package.\n */\nexport type HttpDeliveryErrorInit = {\n status: number;\n url: string;\n responseBody?: string;\n};\n\n/**\n * Thrown when an HTTP delivery fails in a way the drain pipeline\n * should retry: network failure, timeout, or 5xx response. `status` is\n * `0` for network / timeout errors, the HTTP status code otherwise.\n *\n * The class itself is the retry signal — if a reaction throws this,\n * drain treats it like any other error (counts against `maxRetries`,\n * paces with `backoff`). For permanent failures, throw\n * {@link NonRetryableHttpError} instead.\n *\n * Generic enough to cover any custom HTTP-like integration (gRPC\n * bridges, SDK-based reactions). {@link WebhookError} is a\n * webhook-specific subclass kept for backward compatibility.\n */\nexport class RetryableHttpError extends Error {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"RetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Thrown when an HTTP delivery returns a 3xx or 4xx response —\n * permanent client errors that won't recover on retry. Extends\n * {@link NonRetryableError} so the drain finalizer blocks the stream\n * on the first failed attempt (when `blockOnError` is true) — no\n * wasted retries on a malformed payload or wrong URL.\n *\n * Generic enough to cover any custom HTTP-like integration.\n * {@link NonRetryableWebhookError} is a webhook-specific subclass kept\n * for backward compatibility.\n */\nexport class NonRetryableHttpError extends NonRetryableError {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"NonRetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Webhook-specific subclass of {@link RetryableHttpError}. Thrown by\n * the {@link webhook} helper on 5xx responses, network failures, and\n * timeouts. Existing `instanceof WebhookError` checks continue to\n * work; new code targeting the generic HTTP integration shape can\n * catch {@link RetryableHttpError} instead and handle webhook +\n * custom integrations uniformly.\n */\nexport class WebhookError extends RetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"WebhookError\";\n }\n}\n\n/**\n * Webhook-specific subclass of {@link NonRetryableHttpError}. Thrown\n * by the {@link webhook} helper on 3xx/4xx responses. Existing\n * `instanceof NonRetryableWebhookError` checks continue to work; new\n * code can catch {@link NonRetryableHttpError} or\n * {@link NonRetryableError} for broader coverage.\n */\nexport class NonRetryableWebhookError extends NonRetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"NonRetryableWebhookError\";\n }\n}\n","import { NonRetryableHttpError, RetryableHttpError } from \"./types.js\";\n\n/**\n * Three buckets for an HTTP response from an outbound delivery:\n *\n * - `ok` — the receiver accepted the delivery (2xx). Stop and return.\n * - `retry` — the receiver had a transient problem (5xx). Throw a\n * retryable error; drain will pace the next attempt per `backoff`.\n * - `block` — the receiver rejected the delivery permanently (3xx\n * or 4xx). Throw a non-retryable error; drain blocks the stream\n * on the first failed attempt (when `blockOnError` is true) and\n * surfaces it via the `\"blocked\"` lifecycle event.\n *\n * The 3xx → `block` mapping is intentional: a redirect at the\n * delivery layer means the configured URL is wrong, and retrying\n * the same URL won't fix that. Manual operator review is the right\n * next step, which is what the block path produces.\n */\nexport type HttpDisposition = \"ok\" | \"retry\" | \"block\";\n\n/**\n * Classify an HTTP response as `ok` (2xx), `retry` (5xx), or\n * `block` (3xx, 4xx). The classification {@link webhook} uses\n * internally, lifted here so custom integrations (gRPC bridges,\n * SDK-based reactions, etc.) can apply the same retry semantics\n * without inventing a parallel rule.\n */\nexport function classifyHttpResponse(response: Response): HttpDisposition {\n if (response.ok) return \"ok\";\n if (response.status >= 500) return \"retry\";\n return \"block\";\n}\n\n/** Options for {@link tryOk}. */\nexport type TryOkOptions = {\n /** The endpoint that received the request. Surfaced on the thrown error and in its message. */\n url: string;\n /**\n * Label prefixed onto the error message — typically the\n * integration's identity (`\"webhook\"`, `\"my_sdk\"`, `\"grpc\"`).\n * Default: `\"request\"`.\n */\n label?: string;\n};\n\n/**\n * If `response` is 2xx, return. Otherwise, capture the response body\n * (best-effort) and throw a {@link RetryableHttpError} (for 5xx) or\n * {@link NonRetryableHttpError} (for 3xx/4xx). Collapses the\n * classify-and-throw boilerplate every custom HTTP-like reaction\n * would otherwise write into one line:\n *\n * ```ts\n * .on(\"OrderConfirmed\").do(async function deliverOrder(event) {\n * const response = await my_sdk.deliver(event);\n * await try_ok(response, { url: my_sdk.url, label: \"my_sdk\" });\n * // ...response was 2xx; continue with downstream work...\n * });\n * ```\n *\n * The {@link webhook} helper throws webhook-specific subclasses\n * ({@link WebhookError} / {@link NonRetryableWebhookError}) for\n * backward compatibility — both extend the generic classes thrown\n * here, so `instanceof RetryableHttpError` matches both webhook and\n * custom-integration errors uniformly.\n */\nexport async function tryOk(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const label = options.label ?? \"request\";\n const ErrorClass =\n disposition === \"retry\" ? RetryableHttpError : NonRetryableHttpError;\n throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {\n status: response.status,\n url: options.url,\n responseBody,\n });\n}\n","import { createHmac } from \"node:crypto\";\n\n/**\n * Compute the HMAC-SHA256 signature for an outbound webhook request.\n *\n * The signed payload is `${timestamp}.${body}` — Stripe-style. The\n * timestamp is included so the receiver can reject replays via a\n * window check, and the dot separator prevents `timestamp + body`\n * ambiguity (12 + 345 vs 123 + 45).\n *\n * Returns `{ signature, timestamp }` so the webhook helper can attach\n * both as headers — `X-Webhook-Signature: sha256=<hex>` and\n * `X-Webhook-Timestamp: <unix-seconds>` — for the receiver to verify\n * via `verifyWebhook` from `@rotorsoft/act-http/receiver`.\n *\n * `now` is exposed for tests; production callers should leave it\n * undefined so wall-clock is used.\n *\n * @internal Reachable from tests via the source path. Not re-exported\n * from the package's `./webhook` entry — the webhook helper calls\n * it internally, and operators don't need it directly.\n */\nexport function sign_request(\n body: string,\n secret: string,\n now: number = Math.floor(Date.now() / 1000)\n): { signature: string; timestamp: string } {\n const timestamp = String(now);\n const payload = `${timestamp}.${body}`;\n const hex = createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n return { signature: `sha256=${hex}`, timestamp };\n}\n","/**\n * @packageDocumentation\n * @module act-http/webhook\n *\n * Reaction-handler sugar for POSTing committed events to external URLs.\n *\n * Wraps `fetch` with timeouts, automatic `Idempotency-Key` derivation, and\n * status-classified errors. Designed to be composed with the reaction\n * options (`maxRetries`, `blockOnError`, `backoff`):\n *\n * ```ts\n * import { webhook } from \"@rotorsoft/act-http/webhook\";\n *\n * .on(\"OrderConfirmed\")\n * .do(\n * webhook({\n * url: \"https://api.example.com/webhooks/orders\",\n * headers: (e) => ({ Authorization: \"Bearer ...\" }),\n * body: (e) => ({ orderId: e.stream, total: e.data.total }),\n * timeoutMs: 5_000,\n * }),\n * { maxRetries: 5, backoff: { strategy: \"exponential\", baseMs: 200, maxMs: 30_000 } }\n * )\n * .to(resolver);\n * ```\n */\n\nimport type { Committed, ReactionHandler, Schemas } from \"@rotorsoft/act\";\nimport { classifyHttpResponse } from \"./classify.js\";\nimport { sign_request } from \"./sign.js\";\nimport {\n NonRetryableWebhookError,\n type WebhookConfig,\n WebhookError,\n} from \"./types.js\";\n\nexport type { HttpDisposition, TryOkOptions } from \"./classify.js\";\n// See rfcs/1621-webhook-classify-exports.md.\nexport { classifyHttpResponse, tryOk } from \"./classify.js\";\nexport type {\n HttpDeliveryErrorInit,\n WebhookBody,\n WebhookConfig,\n WebhookResolver,\n} from \"./types.js\";\nexport {\n NonRetryableHttpError,\n NonRetryableWebhookError,\n RetryableHttpError,\n WebhookError,\n} from \"./types.js\";\n\nfunction resolve<TEvents extends Schemas, T>(\n resolver: T | ((e: Committed<TEvents, keyof TEvents>) => T) | undefined,\n event: Committed<TEvents, keyof TEvents>,\n fallback: T\n): T {\n if (resolver === undefined) return fallback;\n return typeof resolver === \"function\"\n ? (resolver as (e: Committed<TEvents, keyof TEvents>) => T)(event)\n : resolver;\n}\n\n/** Case-insensitive lookup; returns true if a header is already set. */\nfunction has_header(headers: Record<string, string>, name: string): boolean {\n const lower = name.toLowerCase();\n for (const k of Object.keys(headers)) {\n if (k.toLowerCase() === lower) return true;\n }\n return false;\n}\n\n/**\n * Build a reaction handler that POSTs each event to an external URL.\n *\n * Behavior:\n *\n * - 2xx and 3xx return successfully.\n * - 5xx responses, network errors, and timeouts throw\n * {@link WebhookError} (`status: 0` for network/timeout). Drain\n * retries per the reaction's `maxRetries` / `backoff`.\n * - 4xx responses throw {@link NonRetryableWebhookError}, which\n * extends `NonRetryableError`. The drain finalizer blocks the\n * stream immediately (when `blockOnError` is true) without\n * consuming the retry budget.\n */\nexport function webhook<TEvents extends Schemas = Schemas>(\n config: WebhookConfig<TEvents>\n): ReactionHandler<TEvents, keyof TEvents> {\n const timeoutMs = config.timeoutMs ?? 5_000;\n const method = config.method ?? \"POST\";\n const fetch_impl = config.fetch ?? globalThis.fetch;\n\n // Named function: slice/act builders require non-anonymous reaction\n // handlers so lifecycle telemetry can attribute work.\n return async function webhook_deliver(event) {\n const url = resolve(config.url, event, \"\");\n\n const custom_headers = resolve(\n config.headers,\n event,\n {} as Record<string, string>\n );\n const headers: Record<string, string> = { ...custom_headers };\n\n if (!has_header(headers, \"content-type\")) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n if (!has_header(headers, \"idempotency-key\")) {\n const key = config.idempotencyKey\n ? config.idempotencyKey(event)\n : String(event.id);\n if (key !== null) headers[\"Idempotency-Key\"] = key;\n }\n\n const rawBody = resolve(config.body, event, event as unknown);\n const body =\n typeof rawBody === \"string\" ? rawBody : JSON.stringify(rawBody);\n\n if (config.secret && !has_header(headers, \"x-webhook-signature\")) {\n const { signature, timestamp } = sign_request(body, config.secret);\n headers[\"X-Webhook-Signature\"] = signature;\n if (!has_header(headers, \"x-webhook-timestamp\")) {\n headers[\"X-Webhook-Timestamp\"] = timestamp;\n }\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n // The timer has to outlive the fetch: `fetch` resolves on response\n // headers, so the error-path body read below needs the timer too, or a\n // receiver that never ends the body hangs the handler. A hang is worse\n // than a throw: the lease is re-claimed and the attempt never spends its\n // retry budget, so the stream never reaches its `blockOnError` verdict.\n try {\n let response: Response;\n try {\n response = await fetch_impl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n const aborted = controller.signal.aborted;\n throw new WebhookError(\n aborted\n ? `webhook ${method} ${url} timed out after ${timeoutMs}ms`\n : `webhook ${method} ${url} failed: ${(err as Error).message}`,\n { status: 0, url }\n );\n }\n\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // Best-effort body capture — a read that fails for its own reasons\n // (a reset mid-body) still reports the status below. An abort lands\n // here too, and that one is NOT best-effort: the timeout fired while\n // reading, so report the timeout rather than a response never read.\n if (controller.signal.aborted)\n throw new WebhookError(\n `webhook ${method} ${url} timed out after ${timeoutMs}ms reading the response body`,\n { status: response.status, url }\n );\n }\n\n const ErrorClass =\n disposition === \"retry\" ? WebhookError : NonRetryableWebhookError;\n throw new ErrorClass(\n `webhook ${method} ${url} responded ${response.status}`,\n { status: response.status, url, responseBody }\n );\n } finally {\n clearTimeout(timer);\n }\n };\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rotorsoft/act-http",
3
3
  "type": "module",
4
- "version": "1.11.1",
4
+ "version": "1.11.2",
5
5
  "description": "HTTP integrations for act apps — webhooks and SSE",
6
6
  "keywords": [
7
7
  "typescript",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/api/in-flight.ts"],"sourcesContent":["/**\n * @module act-http/api/in-flight\n *\n * Tells a duplicate of a delivery that is still running apart from a\n * duplicate of one that already succeeded. An {@link IdempotencyStore}\n * can't: its `claim` returns `false` in both cases. Answering the first\n * \"accepted, stop retrying\" loses the delivery if the original then fails\n * and releases the key, so the receiver and the generated API both ask\n * the sender to retry while the original is in flight.\n *\n * Internal — not re-exported from the package. Process-local, which\n * matches `InMemoryIdempotencyStore`'s single-process scope.\n */\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/** A wrapped store plus the in-flight check it maintains. @internal */\nexport type InFlightTracker = {\n /** Pass this to the code that claims, commits, and releases. */\n readonly store: IdempotencyStore;\n /**\n * True while a claim on `key` is pending, or was won and is not yet\n * committed or released. Read it after your own claim lost.\n */\n in_flight(key: string): boolean;\n};\n\n/**\n * Wraps `store` so claims are counted per key. A key is counted **before**\n * the claim is awaited, not when its reply arrives: with an async store\n * the loser's `false` can arrive before the winner's `true`, and marking\n * only on the winner's reply would leave the loser a window to see the key\n * unmarked and be answered as a settled duplicate. A claim that loses (or\n * throws) uncounts itself at once; a claim that wins stays counted until\n * its commit or release lands.\n *\n * A concurrent duplicate of an already-committed key can therefore read\n * as in flight while another duplicate's claim is pending. That costs one\n * extra retry, never a lost delivery.\n *\n * @internal\n */\nexport function track_in_flight(store: IdempotencyStore): InFlightTracker {\n // One tracker per store, shared by every generator and receiver built on\n // it: a duplicate arriving through one generator while the original runs\n // in another must still read as in flight, which separate counts can't\n // see (#1782).\n let tracker = trackers.get(store);\n if (!tracker) {\n tracker = make_tracker(store);\n trackers.set(store, tracker);\n }\n return tracker;\n}\n\nconst trackers = new WeakMap<IdempotencyStore, InFlightTracker>();\n\nfunction make_tracker(store: IdempotencyStore): InFlightTracker {\n const pending = new Map<string, number>();\n const up = (key: string) => pending.set(key, (pending.get(key) ?? 0) + 1);\n const down = (key: string) => {\n const left = (pending.get(key) ?? 0) - 1;\n if (left > 0) pending.set(key, left);\n else pending.delete(key);\n };\n const settle = (key: string, won: boolean) => {\n if (!won) down(key);\n return won;\n };\n return {\n in_flight: (key) => pending.has(key),\n store: {\n claim(key, now) {\n up(key);\n let won: boolean | Promise<boolean>;\n try {\n won = store.claim(key, now);\n } catch (err) {\n down(key);\n throw err;\n }\n return typeof won === \"boolean\"\n ? settle(key, won)\n : won.then(\n (w) => settle(key, w),\n (err: unknown) => {\n down(key);\n throw err;\n }\n );\n },\n async commit(key, now) {\n try {\n await store.commit(key, now);\n } finally {\n down(key);\n }\n },\n async release(key) {\n try {\n await store.release(key);\n } finally {\n down(key);\n }\n },\n },\n };\n}\n"],"mappings":";AAyCO,SAAS,gBAAgB,OAA0C;AAKxE,MAAI,UAAU,SAAS,IAAI,KAAK;AAChC,MAAI,CAAC,SAAS;AACZ,cAAU,aAAa,KAAK;AAC5B,aAAS,IAAI,OAAO,OAAO;AAAA,EAC7B;AACA,SAAO;AACT;AAEA,IAAM,WAAW,oBAAI,QAA2C;AAEhE,SAAS,aAAa,OAA0C;AAC9D,QAAM,UAAU,oBAAI,IAAoB;AACxC,QAAM,KAAK,CAAC,QAAgB,QAAQ,IAAI,MAAM,QAAQ,IAAI,GAAG,KAAK,KAAK,CAAC;AACxE,QAAM,OAAO,CAAC,QAAgB;AAC5B,UAAM,QAAQ,QAAQ,IAAI,GAAG,KAAK,KAAK;AACvC,QAAI,OAAO,EAAG,SAAQ,IAAI,KAAK,IAAI;AAAA,QAC9B,SAAQ,OAAO,GAAG;AAAA,EACzB;AACA,QAAM,SAAS,CAAC,KAAa,QAAiB;AAC5C,QAAI,CAAC,IAAK,MAAK,GAAG;AAClB,WAAO;AAAA,EACT;AACA,SAAO;AAAA,IACL,WAAW,CAAC,QAAQ,QAAQ,IAAI,GAAG;AAAA,IACnC,OAAO;AAAA,MACL,MAAM,KAAK,KAAK;AACd,WAAG,GAAG;AACN,YAAI;AACJ,YAAI;AACF,gBAAM,MAAM,MAAM,KAAK,GAAG;AAAA,QAC5B,SAAS,KAAK;AACZ,eAAK,GAAG;AACR,gBAAM;AAAA,QACR;AACA,eAAO,OAAO,QAAQ,YAClB,OAAO,KAAK,GAAG,IACf,IAAI;AAAA,UACF,CAAC,MAAM,OAAO,KAAK,CAAC;AAAA,UACpB,CAAC,QAAiB;AAChB,iBAAK,GAAG;AACR,kBAAM;AAAA,UACR;AAAA,QACF;AAAA,MACN;AAAA,MACA,MAAM,OAAO,KAAK,KAAK;AACrB,YAAI;AACF,gBAAM,MAAM,OAAO,KAAK,GAAG;AAAA,QAC7B,UAAE;AACA,eAAK,GAAG;AAAA,QACV;AAAA,MACF;AAAA,MACA,MAAM,QAAQ,KAAK;AACjB,YAAI;AACF,gBAAM,MAAM,QAAQ,GAAG;AAAA,QACzB,UAAE;AACA,eAAK,GAAG;AAAA,QACV;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/api/errors.ts","../src/api/idempotency.ts","../src/api/sse-wiring.ts"],"sourcesContent":["import {\n ConcurrencyError,\n InvariantError,\n NonRetryableError,\n PreconditionError,\n StreamClosedError,\n ValidationError,\n} from \"@rotorsoft/act\";\n\n/**\n * Uniform error envelope shipped over the wire by every act-http\n * transport. Hosts get the same shape from REST, tRPC, and OpenAPI —\n * a client that talks to two transports doesn't have to invent two\n * error parsers.\n *\n * - `error` — the framework error name (`\"ValidationError\"`,\n * `\"PreconditionError\"`, …). Stable identifier, safe to switch on.\n * - `detail` — the framework's message text. Human-readable; not\n * parsed by clients.\n * - `code` — a machine-readable status code from {@link ERROR_MAP}\n * for clients that prefer enum-style branching over name strings.\n */\nexport type ApiError = {\n error: string;\n detail?: string;\n code?: string;\n};\n\n/**\n * Status + code pair for one known framework error.\n */\nexport type ErrorMapEntry = {\n status: number;\n code: string;\n};\n\n/**\n * The single table that maps framework error types to HTTP status\n * codes and machine-readable codes. One table, three consumers\n * (Hono, tRPC, OpenAPI) — cross-transport consistency by\n * construction.\n *\n * Operators wanting different mappings wrap the generated transport\n * rather than mutating this — the consistency is the load-bearing\n * property, not the specific status codes.\n */\nexport const ERROR_MAP = {\n ValidationError: { status: 422, code: \"VALIDATION\" },\n PreconditionError: { status: 409, code: \"PRECONDITION\" },\n /** @deprecated Thrown only by the deprecated `.given` rules. Removed in 2.0. */\n InvariantError: { status: 409, code: \"INVARIANT\" },\n ConcurrencyError: { status: 412, code: \"CONCURRENCY\" },\n StreamClosedError: { status: 410, code: \"STREAM_CLOSED\" },\n NonRetryableError: { status: 400, code: \"NON_RETRYABLE\" },\n} as const satisfies Record<string, ErrorMapEntry>;\n\nconst lookup_known = (\n err: unknown\n): { name: keyof typeof ERROR_MAP } | null => {\n if (err instanceof ValidationError) return { name: \"ValidationError\" };\n // Before `InvariantError`, which it extends.\n if (err instanceof PreconditionError) return { name: \"PreconditionError\" };\n if (err instanceof InvariantError) return { name: \"InvariantError\" };\n if (err instanceof ConcurrencyError) return { name: \"ConcurrencyError\" };\n if (err instanceof StreamClosedError) return { name: \"StreamClosedError\" };\n if (err instanceof NonRetryableError) return { name: \"NonRetryableError\" };\n return null;\n};\n\n/**\n * Translate an unknown thrown value into the canonical\n * {@link ApiError} envelope plus HTTP status. Each transport's error\n * boundary calls this once and forwards the result to the wire.\n *\n * Known framework errors map per {@link ERROR_MAP}. Everything else\n * surfaces as a 500 with `code: \"INTERNAL\"`; the `detail` field is\n * populated when the throw was an `Error` instance, omitted\n * otherwise (a thrown string or object doesn't get to leak its\n * payload to the client).\n */\nexport function toApiError(err: unknown): { status: number; body: ApiError } {\n const known = lookup_known(err);\n if (known) {\n const entry = ERROR_MAP[known.name];\n return {\n status: entry.status,\n body: {\n error: known.name,\n detail: (err as Error).message,\n code: entry.code,\n },\n };\n }\n if (err instanceof Error) {\n return {\n status: 500,\n body: { error: \"InternalError\", detail: err.message, code: \"INTERNAL\" },\n };\n }\n return {\n status: 500,\n body: { error: \"InternalError\", code: \"INTERNAL\" },\n };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * Result of a {@link withIdempotency} call.\n *\n * - `{ deduped: false, result }` — the claim was fresh; the handler\n * ran and produced `result`.\n * - `{ deduped: true }` — the claim was already taken; the handler\n * was *not* invoked. The caller decides how to respond (typically\n * a 2xx with no body, matching the receiver-side convention).\n *\n * Note: the contract does not cache the previous response. A\n * duplicate call returns the deduped marker only — replaying the\n * original handler's output would require a response-caching\n * adapter, which is out of scope here. The receiver-side convention\n * (and the convention the generated transports follow) is \"ack the\n * duplicate; do nothing else.\"\n */\nexport type IdempotencyResult<T> =\n | { deduped: false; result: T }\n | { deduped: true };\n\n/**\n * Wrap an action handler so the framework honors `Idempotency-Key`\n * dedup. Acquires the key via {@link IdempotencyStore.claim} (a\n * *tentative* reservation), runs the handler exactly when the claim\n * was fresh, and skips the handler entirely on a duplicate.\n *\n * The claim is finalized two-phase: on handler success the key is\n * {@link IdempotencyStore.commit committed} so later retries dedup;\n * on handler failure it is {@link IdempotencyStore.release released}\n * and the rejection re-thrown, so the sender's retry re-processes\n * instead of the delivery being permanently dropped. The tentative\n * claim still dedups a concurrent duplicate mid-flight.\n *\n * Reuses the contract `@rotorsoft/act-ops/idempotency` already\n * defines for the receiver-side `Idempotency-Key` story. A single\n * `IdempotencyStore` implementation covers both halves of the \"Act\n * over the wire\" surface — receiver and generated API.\n */\nexport async function withIdempotency<T>(\n store: IdempotencyStore,\n key: string,\n handler: () => Promise<T>\n): Promise<IdempotencyResult<T>> {\n const fresh = await store.claim(key);\n if (!fresh) {\n return { deduped: true };\n }\n let result: T;\n try {\n result = await handler();\n } catch (err) {\n await store.release(key);\n throw err;\n }\n await store.commit(key);\n return { deduped: false, result };\n}\n","import { z } from \"zod\";\nimport type { BroadcastChannel } from \"../sse/broadcast.js\";\nimport {\n type BroadcastState,\n is_version_neutral,\n type PatchMessage,\n resync_frame,\n} from \"../sse/types.js\";\n\n/**\n * SSE wiring options shared by the auto-generated `trpc` and `hono`\n * transports. When supplied, each transport emits one streaming\n * subscription per registered state name, all reading from the same\n * host-supplied {@link BroadcastChannel}.\n *\n * The host owns publication: every `app.do(...)` call site must\n * forward the resulting snapshots to `channel.publish(...)` for\n * subscribers to see updates. The framework deliberately doesn't\n * auto-publish because the derived state usually needs app-specific\n * overlays (presence, computed fields) that only the host can supply.\n *\n * Defaults are sized for typical business-app dashboards (dozens to\n * hundreds of human viewers per process). Both numeric knobs are\n * validated at transport-function construction; out-of-range values\n * throw `ZodError` immediately so misconfiguration surfaces at startup\n * instead of at first connection (the framework-wide convention for\n * config bags — see .claude/rules/config-validation.md).\n */\nexport type SseOptions = {\n /**\n * The {@link BroadcastChannel} the generator's subscriptions hook\n * into. Host owns its lifecycle; the generator does not construct\n * one for you because the channel's `<S>` shape is app-specific and\n * passing it in keeps the type chain typed end-to-end at the host.\n */\n // `any`: the channel's BroadcastState shape is opaque to the generator — narrowing it would force every state into one structural bound that doesn't fit real apps\n readonly channel: BroadcastChannel<any>;\n /**\n * Hard cap on concurrent open subscriptions per generator\n * instance. The 501st simultaneous open returns `503` (Hono) or\n * throws `TOO_MANY_REQUESTS` (tRPC) — never a silent stall.\n *\n * Default `500`. Validated range `[1, 10_000]`. Above 10k the\n * framework refuses to construct: at that point the FD ceiling\n * and per-connection memory force horizontal scaling regardless\n * of tuning, so the right move is sticky LB + per-process cap,\n * not a higher single-process limit.\n */\n readonly maxConnections?: number;\n /**\n * Keep-alive cadence in milliseconds. Most reverse proxies idle\n * connections out after ~60 seconds; the default `30_000` stays\n * comfortably under.\n *\n * Validated range `[15_000, 300_000]`. Under 15 s wastes\n * bandwidth on a business workload; over 5 min risks proxy drops.\n */\n readonly heartbeatMs?: number;\n /**\n * Hard cap on the per-connection in-memory backlog of undelivered\n * broadcast frames. A consumer that stalls (slow client, paused\n * tab) can't drain frames as fast as a busy stream publishes them;\n * without a bound the backlog grows until the process runs out of\n * memory. When the backlog is full the oldest **version-keyed** frame\n * is dropped to make room for the newest: the consumer sees the\n * version gap, reports `behind`, and refetches, so it still converges\n * on current state having skipped intermediate versions it was too\n * slow to see.\n *\n * Version-neutral frames (`_overlay`, `_resync`) are never dropped\n * silently — they create no gap to notice, so a backlog made only of\n * them collapses into a single `_resync` instead (#1649).\n *\n * Default `256`. Validated range `[1, 100_000]`.\n */\n readonly maxPendingPerConnection?: number;\n};\n\n/**\n * Resolved SSE configuration after validation and default expansion.\n * Internal — the resolved values are what every transport actually\n * runs against.\n *\n * @internal\n */\nexport type SseConfig = {\n readonly channel: BroadcastChannel<BroadcastState>;\n readonly maxConnections: number;\n readonly heartbeatMs: number;\n readonly maxPendingPerConnection: number;\n};\n\n/**\n * Default cap on concurrent open subscriptions per generator\n * instance. Sized for typical business-app dashboards (dozens to a\n * few hundred viewers); high enough not to surprise small\n * deployments, low enough not to mask a missing horizontal-scale\n * strategy on big ones.\n */\nexport const DEFAULT_SSE_MAX_CONNECTIONS = 500;\n\n/**\n * Default keep-alive cadence (30 s). Sits comfortably under the\n * 60 s idle timeout most reverse proxies impose.\n */\nexport const DEFAULT_SSE_HEARTBEAT_MS = 30_000;\n\n/**\n * Default cap on the per-connection undelivered-frame backlog. Sized\n * so a briefly-stalled consumer on a busy stream keeps recent history\n * without letting a permanently-wedged client pin unbounded memory.\n * At overflow the oldest version-keyed frame is dropped: it is a full\n * version-keyed patch, so the consumer sees the gap and converges on\n * current state regardless of which intermediate versions it missed.\n * Version-neutral frames create no gap, so they collapse into a\n * `_resync` rather than being dropped silently (#1649).\n */\nexport const DEFAULT_SSE_MAX_PENDING_PER_CONNECTION = 256;\n\n/**\n * Zod schema for the numeric knobs on {@link SseOptions}. Defaults,\n * ranges, and (eventual) integer constraints live in one place — same\n * declarative-validation pattern the rest of the framework uses for\n * configuration shapes.\n *\n * @internal\n */\nconst SseOptionsSchema = z.object({\n maxConnections: z\n .number()\n .int()\n .min(1)\n .max(10_000)\n .default(DEFAULT_SSE_MAX_CONNECTIONS),\n heartbeatMs: z\n .number()\n .int()\n .min(15_000)\n .max(300_000)\n .default(DEFAULT_SSE_HEARTBEAT_MS),\n maxPendingPerConnection: z\n .number()\n .int()\n .min(1)\n .max(100_000)\n .default(DEFAULT_SSE_MAX_PENDING_PER_CONNECTION),\n});\n\n/**\n * Validate and apply defaults to host-supplied {@link SseOptions}.\n *\n * Out-of-range values throw at the call site — each transport calls\n * this once at the start of `hono(...)` / `trpc(...)` so\n * misconfiguration surfaces at construction, not at first\n * connection.\n */\nexport function resolveSseConfig(options: SseOptions): SseConfig {\n const parsed = SseOptionsSchema.parse({\n maxConnections: options.maxConnections,\n heartbeatMs: options.heartbeatMs,\n maxPendingPerConnection: options.maxPendingPerConnection,\n });\n return {\n channel: options.channel,\n maxConnections: parsed.maxConnections,\n heartbeatMs: parsed.heartbeatMs,\n maxPendingPerConnection: parsed.maxPendingPerConnection,\n };\n}\n\n/**\n * Per-generator concurrent-open counter. Constructed once inside\n * `hono(...)` / `trpc(...)` and consulted at every subscription\n * open: failure to acquire means the cap has been hit and the\n * transport reports `503` / `TOO_MANY_REQUESTS` to the caller.\n *\n * The counter is intentionally process-local. SSE doesn't multiplex\n * across processes; operators wanting more than `maxConnections`\n * viewers run multiple processes behind a sticky load balancer.\n */\nexport class SseConnectionCounter {\n public readonly limit: number;\n private _open = 0;\n\n constructor(limit: number) {\n this.limit = limit;\n }\n\n /**\n * Reserve one slot. Returns `true` when accepted (caller must\n * eventually {@link release} the slot, even on error paths) or\n * `false` when the cap is already reached.\n */\n acquire(): boolean {\n if (this._open >= this.limit) return false;\n this._open++;\n return true;\n }\n\n /**\n * Free a previously-{@link acquire}d slot. Always runs from a\n * `finally` block in the transport's subscription handler so\n * crashed handlers don't leak count.\n */\n release(): void {\n if (this._open > 0) this._open--;\n }\n\n /** Currently-open count. Test/observability hook. */\n get open(): number {\n return this._open;\n }\n}\n\n/**\n * Wrap a fire-and-forget Promise-returning operation so its\n * rejection is swallowed in place of leaking an unhandled\n * rejection. Used by the Hono heartbeat tick (the interval callback\n * isn't `await`ed, so its rejection has nowhere to go) and by any\n * other \"best-effort write\" the transports run.\n *\n * Pure: same input → same output, no side effects on the caller.\n * Returns the chained Promise so the caller can also `await`\n * completion if they want to (the Hono heartbeat doesn't).\n */\nexport function fireAndForget<T>(op: () => Promise<T>): Promise<T | undefined> {\n return op().catch(() => undefined);\n}\n\n/**\n * Frame yielded by the per-state SSE subscription generator. The\n * initial cached state (when present) arrives as `kind: \"state\"`;\n * every subsequent broadcast publication arrives as `kind: \"patch\"`\n * with the channel's `PatchMessage` payload.\n */\nexport type SseSubscriptionFrame<S> =\n | { readonly kind: \"state\"; readonly data: S }\n | { readonly kind: \"patch\"; readonly data: unknown };\n\n/**\n * Subscription accounting hook. Implementations have a chance to\n * acquire a slot before the loop starts and to release it once the\n * loop's `finally` block runs — used by the tRPC side to enforce\n * `maxConnections` from inside the generator, where it can throw\n * `TRPCError` cleanly. Hono enforces the cap outside the loop (so a\n * full counter returns `503` *before* streamSSE writes headers), then\n * passes `undefined` here.\n */\nexport type SseAccounting = {\n acquire(): boolean;\n release(): void;\n};\n\n/**\n * Per-subscription knobs for {@link runSseSubscription}, bundled so the\n * two transports (and their tests) pass one options object rather than\n * a growing positional tail.\n */\nexport type RunSseOptions = {\n /**\n * Invoked when {@link SseAccounting.acquire} returns `false` (the\n * connection cap is full). tRPC throws `TRPCError` here to reject the\n * subscription cleanly from inside the generator; Hono enforces the\n * cap outside the loop and omits this.\n */\n readonly on_cap_exceeded?: () => never;\n /**\n * Upper bound on the per-connection undelivered-frame backlog. When\n * the backlog reaches this many frames the oldest is dropped to make\n * room for the newest (drop-oldest). Defaults to\n * {@link DEFAULT_SSE_MAX_PENDING_PER_CONNECTION} when omitted.\n */\n readonly maxPending?: number;\n};\n\n/**\n * The shared subscription loop both transports run. Optionally\n * acquires one slot on the supplied {@link SseAccounting}, yields the\n * cached state (when present), then forwards every channel\n * publication as a `kind: \"patch\"` frame until either the consumer\n * breaks (calling `iter.return()`) or the supplied abort signal fires.\n *\n * Exported as the testable seam — the Hono and tRPC sibling\n * generators wrap this so the signal-driven cleanup and counter\n * accounting live in one place.\n */\n// `any`: the channel's BroadcastState shape is opaque to the loop — narrowing it would force every state into one structural bound that doesn't fit real apps\nexport async function* runSseSubscription<S extends { _v: number } = any>(\n channel: BroadcastChannel<S>,\n stream_id: string,\n accounting: SseAccounting | undefined,\n signal: AbortSignal | undefined,\n options?: RunSseOptions\n): AsyncGenerator<SseSubscriptionFrame<S>> {\n // Track whether we actually reserved a slot. Releasing in `finally`\n // unconditionally would decrement the counter below the true open\n // count when acquire() fails without an `on_cap_exceeded` handler —\n // inflating the effective cap over time. Only release what we\n // acquired.\n let acquired = false;\n if (accounting) {\n acquired = accounting.acquire();\n if (!acquired && options?.on_cap_exceeded) {\n options.on_cap_exceeded();\n }\n }\n const max_pending =\n options?.maxPending ?? DEFAULT_SSE_MAX_PENDING_PER_CONNECTION;\n const pending: unknown[] = [];\n let resolve_wait: (() => void) | null = null;\n const unsubscribe = channel.subscribe(stream_id, (msg) => {\n enqueue(msg as PatchMessage<never>);\n resolve_wait?.();\n });\n /**\n * Bound the per-connection backlog: a stalled consumer (slow client,\n * paused tab) can't drain as fast as a busy stream publishes, so a frame\n * has to go.\n *\n * Which frame is not arbitrary. A version-keyed patch can be dropped\n * safely — the client sees the version gap, reports `behind`, and\n * refetches. A version-neutral frame (`_overlay`, `_resync`) carries no\n * versions at all, so dropping one leaves no gap to notice and the update\n * is lost silently (#1649). So: evict the oldest version-keyed frame if\n * there is one, and only if the whole backlog is version-neutral collapse\n * it into the single frame that says \"you missed something\".\n */\n const enqueue = (msg: PatchMessage<never>) => {\n if (pending.length < max_pending) {\n pending.push(msg);\n return;\n }\n const versioned = pending.findIndex(\n (m) => !is_version_neutral(m as PatchMessage<never>)\n );\n if (versioned >= 0) {\n pending.splice(versioned, 1);\n pending.push(msg);\n return;\n }\n // Every pending frame is version-neutral. Collapsing them into one\n // resync loses no signal — a resync supersedes anything it replaces,\n // including `msg` when the cap leaves no room for it.\n pending.splice(0, pending.length, resync_frame());\n if (pending.length < max_pending) pending.push(msg);\n };\n\n const on_abort = () => resolve_wait?.();\n signal?.addEventListener(\"abort\", on_abort);\n try {\n const cached = channel.state(stream_id);\n if (cached !== undefined) {\n yield { kind: \"state\", data: cached };\n }\n while (!signal?.aborted) {\n if (pending.length === 0) {\n await new Promise<void>((resolve) => {\n resolve_wait = resolve;\n });\n if (signal?.aborted) break;\n }\n const msg = pending.shift();\n yield { kind: \"patch\", data: msg };\n }\n } finally {\n signal?.removeEventListener(\"abort\", on_abort);\n unsubscribe();\n if (acquired) accounting?.release();\n }\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/receiver/extract.ts","../src/receiver/verify.ts","../src/receiver/check.ts","../src/receiver/finalize.ts"],"sourcesContent":["/**\n * Pull the `Idempotency-Key` header from a Node-style headers bag,\n * case-insensitive. Returns `undefined` when any of the following\n * carries no usable key:\n *\n * - the header is missing\n * - its value is an array (ambiguous — can't pick one without a\n * policy the receiver hasn't declared)\n * - its value is the empty string (carries no idempotency\n * information; structurally equivalent to \"no header at all\")\n *\n * Pair with `IdempotencyStore.claim` from\n * `@rotorsoft/act-ops/idempotency`: extract the key from the inbound\n * request, claim it on the store, return a `deduped` marker when the\n * claim fails. The framework-agnostic middleware that wires these\n * together lands in #744.\n *\n * Validation beyond \"is there a usable key?\" (length bounds, format\n * checks, normalization) is intentionally out of scope. Receivers\n * picking a policy can layer it on top — or, when #744 ships, opt\n * into the middleware's opinionated defaults.\n */\nexport function extractIdempotencyKey(\n headers: Record<string, string | string[] | undefined>\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== \"idempotency-key\") continue;\n if (Array.isArray(value)) return undefined;\n if (value === \"\") return undefined;\n return value;\n }\n return undefined;\n}\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Outcome of {@link verifyWebhook}. Either the request signature\n * checks out, or one of five distinct failure reasons applies. Each\n * reason maps to an operator-meaningful telemetry bucket — separated\n * deliberately so dashboards can distinguish \"client lost its secret\"\n * from \"client clock is wrong\" from \"this is a replay attack.\"\n */\nexport type VerifyResult =\n | { ok: true }\n | {\n ok: false;\n reason:\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n };\n\n/** Options for {@link verifyWebhook}. */\nexport type VerifyOptions = {\n /**\n * Maximum acceptable timestamp drift in either direction, in\n * seconds. Default: 300 (±5 minutes) — matches Stripe / GitHub /\n * Slack conventions. Tightening narrows the replay window;\n * loosening accommodates clients with worse clock sync.\n */\n maxAgeSeconds?: number;\n /**\n * Current Unix-seconds time. Exposed for tests; production\n * callers should leave it undefined so wall-clock is used.\n */\n now?: number;\n};\n\n/**\n * Verify an inbound webhook's signature and timestamp against the\n * shared secret. Pair with the sender side: configure\n * `webhook({ secret })` from `@rotorsoft/act-http/webhook`.\n *\n * Returns `{ ok: true }` on success or `{ ok: false; reason }` on\n * failure. The reasons are:\n *\n * - `missing-signature` — no `X-Webhook-Signature` header, value\n * was an array, or value was empty.\n * - `missing-timestamp` — no `X-Webhook-Timestamp` header, value\n * was empty, or value isn't a parseable integer.\n * - `stale` — timestamp older than `maxAgeSeconds` from `now`.\n * - `future` — timestamp more than `maxAgeSeconds` ahead of `now`.\n * - `bad-signature` — signature header didn't start with `sha256=`,\n * wasn't 64 hex chars, or the recomputed HMAC didn't match\n * (constant-time compare).\n *\n * The signed payload is `${timestamp}.${body}`, so `body` must be\n * the **raw request body bytes**. Any pre-parse normalization\n * (whitespace trimming, JSON re-stringification) would change the\n * hash and reject every otherwise-valid request. Framework adapters\n * in #744 will provide the raw body alongside the parsed one.\n *\n * Uses Node's `crypto.timingSafeEqual` for the final comparison to\n * avoid signature-equality timing attacks.\n */\nexport function verifyWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n secret: string,\n options?: VerifyOptions\n): VerifyResult {\n const maxAgeSeconds = options?.maxAgeSeconds ?? 300;\n const now = options?.now ?? Math.floor(Date.now() / 1000);\n\n const signature = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\n return { ok: false, reason: \"missing-timestamp\" };\n }\n\n const delta = now - timestamp;\n if (delta > maxAgeSeconds) return { ok: false, reason: \"stale\" };\n if (delta < -maxAgeSeconds) return { ok: false, reason: \"future\" };\n\n if (!signature.startsWith(\"sha256=\")) {\n return { ok: false, reason: \"bad-signature\" };\n }\n const provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) continue;\n if (Array.isArray(value) || value === undefined || value === \"\") {\n return undefined;\n }\n return value;\n }\n return undefined;\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport { extractIdempotencyKey } from \"./extract.js\";\nimport { type VerifyOptions, verifyWebhook } from \"./verify.js\";\n\n/**\n * Failure reasons returned by {@link checkWebhook}. The shape splits\n * client/configuration errors (`missing-key`, `empty-body` — HTTP 400)\n * from the five verification failures (authentication errors, HTTP 401)\n * so each maps to its own telemetry bucket. `empty-body` is the\n * misconfigured-raw-parser signal: `secret` is set but the resolved\n * body is empty, so hashing it would compute an HMAC over `${ts}.` and\n * reject every otherwise-valid signed request with a misleading\n * bad-signature — a distinct config error is far easier to diagnose.\n */\nexport type CheckFailureReason =\n | \"missing-key\"\n | \"empty-body\"\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n\n/**\n * Outcome of {@link checkWebhook}. Either the request passed every\n * configured check and carries a usable idempotency key, or it\n * failed one of them and the framework adapter should reply with the\n * corresponding HTTP status.\n */\nexport type CheckResult =\n | { ok: false; status: 400 | 401; reason: CheckFailureReason }\n | { ok: true; key: string; deduped: boolean };\n\n/** Options for {@link checkWebhook}. */\nexport type CheckWebhookOptions = {\n /** Idempotency store the framework-agnostic core claims the key on. */\n store: IdempotencyStore;\n /**\n * Optional HMAC-SHA256 secret. When set, the request's\n * `X-Webhook-Signature` and `X-Webhook-Timestamp` headers are\n * verified before the dedup claim. When omitted, signature\n * verification is skipped (unsigned receivers).\n */\n secret?: string;\n /**\n * Verification options forwarded to {@link verifyWebhook}. Only\n * meaningful when `secret` is set. Defaults to a ±300-second\n * timestamp window.\n */\n verify?: VerifyOptions;\n};\n\n/**\n * Framework-agnostic receiver check: verify the signature (when a\n * secret is configured), extract the `Idempotency-Key`, and claim\n * it on the store. Returns the request's fate as a discriminated\n * union the per-framework adapter translates into the framework's\n * idiomatic 4xx response or context injection.\n *\n * **Order of checks** (matters):\n *\n * 1. Verify signature + timestamp window (when `secret` is set).\n * Rejecting bad signatures *before* extracting and claiming the\n * key keeps attacker-supplied keys out of the dedup store —\n * otherwise a flood of spoofed POSTs would pollute the LRU.\n * 2. Extract the `Idempotency-Key`. Missing → reject with 400.\n * 3. Claim the key on the store. If already seen, return\n * `{ ok: true; deduped: true }` so the framework adapter can\n * short-circuit the handler without re-running side effects.\n *\n * The dedup store may be sync (`InMemoryIdempotencyStore`) or async\n * (durable adapters like a future `PostgresIdempotencyStore`); the\n * core awaits unconditionally so both shapes compose cleanly.\n */\nexport async function checkWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n options: CheckWebhookOptions\n): Promise<CheckResult> {\n if (options.secret !== undefined) {\n // Raw body not captured (default JSON parser ate the bytes) but a\n // secret is configured — hashing the empty string is a guaranteed\n // bad-signature. Surface a distinct configuration error instead of a\n // misleading 401 so the operator mounts the raw-body parser.\n if (body === \"\") {\n return { ok: false, status: 400, reason: \"empty-body\" };\n }\n const verification = verifyWebhook(\n headers,\n body,\n options.secret,\n options.verify\n );\n if (!verification.ok) {\n return { ok: false, status: 401, reason: verification.reason };\n }\n }\n\n const key = extractIdempotencyKey(headers);\n if (!key) return { ok: false, status: 400, reason: \"missing-key\" };\n\n const claimed = await options.store.claim(key);\n return { ok: true, key, deduped: !claimed };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * The two-phase finalizers a receiver adapter binds to a single\n * inbound delivery. The claim `checkWebhook` makes is *tentative*;\n * exactly one of these promotes or drops it once the business handler\n * resolves its outcome.\n */\nexport type Finalizers = {\n /**\n * Promote the tentative claim to a durable record so every later\n * retry of this key dedups. Call on handler success.\n */\n commit: () => void | Promise<void>;\n /**\n * Drop the tentative claim so the sender's retry re-processes.\n * Call on transient handler failure.\n */\n release: () => void | Promise<void>;\n};\n\n/**\n * The `idempotency` bag a receiver adapter hands its route for one\n * delivery: the claimed key, whether it was a duplicate, and the\n * {@link Finalizers}.\n */\nexport type Idempotency = { key: string; deduped: boolean } & Finalizers;\n\n/**\n * Build the {@link Idempotency} bag for one delivery that passed\n * `checkWebhook`.\n *\n * Guarantees:\n *\n * - **Deduped deliveries are inert.** When `deduped` is `true` the key\n * was already committed by an earlier delivery; both finalizers are\n * no-ops so a duplicate can never release someone else's committed\n * claim.\n * - **Finalize-once.** After the first `commit` or `release` fires,\n * further calls are no-ops — an adapter that both auto-finalizes and\n * lets the operator call `commit()`/`release()` can't double-fire.\n */\nexport function make_idempotency(\n store: IdempotencyStore,\n { key, deduped }: { key: string; deduped: boolean }\n): Idempotency {\n let settled = deduped;\n return {\n key,\n deduped,\n async commit() {\n if (settled) return;\n settled = true;\n await store.commit(key);\n },\n async release() {\n if (settled) return;\n settled = true;\n await store.release(key);\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/sse/types.ts"],"sourcesContent":["import type { DeepPartial } from \"@rotorsoft/act-patch\";\n\n/**\n * Base constraint for state objects managed by the broadcast system.\n * Apps extend this with their own domain state shape.\n */\nexport type BroadcastState = Record<string, unknown> & {\n /** Event store stream version — set by the broadcast layer from snap.event.version */\n _v: number;\n};\n\n/**\n * SSE message: version-keyed domain patches.\n * Keys are stringified version numbers, values are domain patches (deep partials).\n * Multi-event commits produce multiple version-keyed entries.\n *\n * The optional `_overlay` marker flags a version-neutral update (presence,\n * computed-field refresh) emitted by {@link BroadcastChannel.overlay} — a\n * single entry keyed at the *current* version. It tells `applyPatchMessage`\n * to merge the entry on top of the client's caught-up state instead of\n * rejecting it as stale (a same-version patch WITHOUT the marker stays\n * stale). Ordinary version-bumping patches from `publish()` omit it.\n *\n * `_resync` carries no versions at all. The server emits it when it cannot\n * construct a patch — today, when `overlay()` finds the stream's baseline\n * evicted from the LRU. A client that receives one always reports `behind`\n * and refetches. Without it the server had nothing to say in that case, so\n * live subscribers silently stopped receiving presence updates and had no\n * way to notice (#1423).\n */\nexport type PatchMessage<S extends BroadcastState = BroadcastState> = Record<\n number,\n DeepPartial<S>\n> & { readonly _overlay?: true; readonly _resync?: true };\n\n/**\n * Subscriber callback — receives version-keyed patch messages.\n */\nexport type Subscriber<S extends BroadcastState = BroadcastState> = (\n msg: PatchMessage<S>\n) => void;\n\n/**\n * The frame that tells a client to refetch: no versions, so\n * `applyPatchMessage` always reports `behind`.\n *\n * Every producer of a resync uses this — `overlay()` on a missing baseline,\n * the cache on evicting overlay state, and the SSE backlog when it cannot\n * drop a frame without losing it silently. One factory so the shape cannot\n * drift between them.\n */\nexport const resync_frame = <S extends BroadcastState>(): PatchMessage<S> =>\n ({ _resync: true }) as PatchMessage<S>;\n\n/**\n * Whether a frame carries no versions, so `applyPatchMessage` can never\n * classify it as a gap.\n *\n * A version-keyed patch that goes missing is self-announcing: the next frame\n * skips a version and the client refetches. An `_overlay` or `_resync` frame\n * is not, which is why anything that drops frames has to tell them apart.\n */\nexport const is_version_neutral = <S extends BroadcastState>(\n msg: PatchMessage<S>\n): boolean => msg._overlay === true || msg._resync === true;\n"],"mappings":";AAmDO,IAAM,eAAe,OACzB,EAAE,SAAS,KAAK;AAUZ,IAAM,qBAAqB,CAChC,QACY,IAAI,aAAa,QAAQ,IAAI,YAAY;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/receiver/hono/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/hono\n *\n * Hono adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import { Hono } from \"hono\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/hono\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = new Hono();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * async (c) => {\n * const idem = c.get(\"idempotency\") as { key: string; deduped: boolean };\n * if (idem.deduped) return c.json({ status: \"dedup-skipped\", key: idem.key });\n * // ... process the inbound event ...\n * return c.json({ status: \"processed\", key: idem.key });\n * }\n * );\n * ```\n *\n * On failure: returns `c.json({ error: <reason> }, status)` directly\n * (Hono short-circuits when middleware returns a Response). On\n * success: stashes `c.set(\"idempotency\", { key, deduped })` and\n * continues with `await next()`.\n *\n * **Two-phase dedup**: the claim `checkWebhook` makes is *tentative*.\n * Because Hono middleware wraps the downstream chain, this adapter\n * finalizes the claim automatically after `await next()`: a downstream\n * 2xx/3xx **commits** the key (later retries dedup); any **4xx or 5xx**\n * (or a thrown handler) **releases** it, so the sender's corrected retry\n * re-processes instead of being silently dropped (#1364). Fail-safe is\n * toward reprocessing: releasing a deterministic 4xx costs bounded\n * re-delivery, while committing it would lose a delivery the handler\n * never accepted (the shipped `receiver()` returns 422 on a schema\n * mismatch, where the handler never runs). Operators who need explicit\n * control can still call `c.get(\"idempotency\").commit()` / `.release()`.\n *\n * **Raw body**: Hono exposes `await c.req.text()` natively, which\n * the middleware reads when `secret` is configured. No extra setup\n * needed.\n */\nimport type { MiddlewareHandler } from \"hono\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { make_idempotency } from \"../finalize.js\";\n\n/**\n * Variables this middleware contributes to the Hono context. The\n * generic on the returned {@link MiddlewareHandler} threads it\n * through so route handlers downstream of `app.post(..., webhookMiddleware(...), handler)`\n * see `c.get(\"idempotency\")` typed without a manual cast.\n *\n * `commit` / `release` finalize the tentative claim — call one after\n * the handler resolves its outcome. This adapter also finalizes\n * automatically based on the response status, so explicit calls are\n * only needed when the auto-detection doesn't fit.\n */\nexport type WebhookVariables = {\n idempotency: {\n key: string;\n deduped: boolean;\n commit: () => void | Promise<void>;\n release: () => void | Promise<void>;\n };\n};\n\n/**\n * Build a Hono middleware that verifies the request signature (when\n * `secret` is set), enforces `Idempotency-Key`, and claims the key\n * on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): MiddlewareHandler<{ Variables: WebhookVariables }> {\n return async function check(c, next) {\n const headers = headers_bag(c.req.raw.headers);\n const rawBody = await c.req.text();\n const result = await checkWebhook(headers, rawBody, options);\n if (!result.ok) {\n return c.json({ error: result.reason }, result.status);\n }\n const idempotency = make_idempotency(options.store, result);\n c.set(\"idempotency\", idempotency);\n try {\n await next();\n } catch (err) {\n await idempotency.release();\n throw err;\n }\n // Commit only a genuine success (2xx/3xx); release on any 4xx/5xx so a\n // failed delivery's corrected same-key retry re-processes instead of being\n // deduped into a silent success (#1364).\n if (c.res.status >= 400) await idempotency.release();\n else await idempotency.commit();\n };\n}\n\nfunction headers_bag(\n headers: Headers\n): Record<string, string | string[] | undefined> {\n const out: Record<string, string | string[] | undefined> = {};\n headers.forEach((value, key) => {\n out[key] = value;\n });\n return out;\n}\n"],"mappings":";;;;;;AA8EO,SAAS,kBACd,SACoD;AACpD,SAAO,eAAe,MAAM,GAAG,MAAM;AACnC,UAAM,UAAU,YAAY,EAAE,IAAI,IAAI,OAAO;AAC7C,UAAM,UAAU,MAAM,EAAE,IAAI,KAAK;AACjC,UAAM,SAAS,MAAM,aAAa,SAAS,SAAS,OAAO;AAC3D,QAAI,CAAC,OAAO,IAAI;AACd,aAAO,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,GAAG,OAAO,MAAM;AAAA,IACvD;AACA,UAAM,cAAc,iBAAiB,QAAQ,OAAO,MAAM;AAC1D,MAAE,IAAI,eAAe,WAAW;AAChC,QAAI;AACF,YAAM,KAAK;AAAA,IACb,SAAS,KAAK;AACZ,YAAM,YAAY,QAAQ;AAC1B,YAAM;AAAA,IACR;AAIA,QAAI,EAAE,IAAI,UAAU,IAAK,OAAM,YAAY,QAAQ;AAAA,QAC9C,OAAM,YAAY,OAAO;AAAA,EAChC;AACF;AAEA,SAAS,YACP,SAC+C;AAC/C,QAAM,MAAqD,CAAC;AAC5D,UAAQ,QAAQ,CAAC,OAAO,QAAQ;AAC9B,QAAI,GAAG,IAAI;AAAA,EACb,CAAC;AACD,SAAO;AACT;","names":[]}