@rotorsoft/act-http 1.9.12 → 1.10.1

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.
@@ -9,16 +9,13 @@ import {
9
9
  import "../chunk-WGFX4G27.js";
10
10
  import "../chunk-PZ5AY32C.js";
11
11
 
12
- // ../../node_modules/.pnpm/@hono+zod-validator@0.9.0_hono@4.13.5_zod@4.4.3/node_modules/@hono/zod-validator/dist/index.mjs
12
+ // ../../node_modules/.pnpm/@hono+zod-validator@0.9.1_hono@4.13.5_zod@4.4.3/node_modules/@hono/zod-validator/dist/index.mjs
13
13
  import { validator } from "hono/validator";
14
14
  function zValidatorFunction(target, schema, hook, options) {
15
+ const caseInsensitiveKeymap = target === "header" && ("_def" in schema || "_zod" in schema) ? Object.fromEntries(Object.keys("in" in schema ? schema.in.shape : schema.shape).map((key) => [key.toLowerCase(), key])) : void 0;
15
16
  return validator(target, async (value, c) => {
16
17
  let validatorValue = value;
17
- if (target === "header" && "_def" in schema || target === "header" && "_zod" in schema) {
18
- const schemaKeys = Object.keys("in" in schema ? schema.in.shape : schema.shape);
19
- const caseInsensitiveKeymap = Object.fromEntries(schemaKeys.map((key) => [key.toLowerCase(), key]));
20
- validatorValue = Object.fromEntries(Object.entries(value).map(([key, value2]) => [caseInsensitiveKeymap[key] || key, value2]));
21
- }
18
+ if (caseInsensitiveKeymap) validatorValue = Object.fromEntries(Object.entries(value).map(([key, value2]) => [caseInsensitiveKeymap[key] || key, value2]));
22
19
  const result = options && options.validationFunction ? await options.validationFunction(schema, validatorValue) : await schema.safeParseAsync(validatorValue);
23
20
  if (hook) {
24
21
  const hookResult = await hook({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../node_modules/.pnpm/@hono+zod-validator@0.9.0_hono@4.13.5_zod@4.4.3/node_modules/@hono/zod-validator/src/index.ts","../../src/hono/index.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono'\nimport { validator } from 'hono/validator'\nimport type { InferInput } from 'hono/validator'\nimport type * as v3 from 'zod/v3'\nimport type { ZodSafeParseResult as v4ZodSafeParseResult } from 'zod/v4'\nimport type * as v4 from 'zod/v4/core'\n\ntype ZodSchema = v3.ZodType | v4.$ZodType\ntype ZodError<T extends ZodSchema> = T extends v4.$ZodType\n ? v4.$ZodError<v4.output<T>>\n : v3.ZodError\ntype ZodSafeParseResult<T, T2, T3 extends ZodSchema> = T3 extends v4.$ZodType\n ? v4ZodSafeParseResult<T>\n : v3.SafeParseReturnType<T, T2>\ntype zInput<T> = T extends v3.ZodType ? v3.input<T> : T extends v4.$ZodType ? v4.input<T> : never\ntype zOutput<T> = T extends v3.ZodType ? v3.output<T> : T extends v4.$ZodType ? v4.output<T> : never\ntype zInfer<T> = T extends v3.ZodType ? v3.infer<T> : T extends v4.$ZodType ? v4.infer<T> : never\n\n// Failure variant of `safeParseAsync`'s return value.\n// Mirrors the runtime body returned by `c.json(result, 400)` on validation failure.\ntype ZodValidatorFailureBody<T extends ZodSchema> = T extends v4.$ZodType\n ? Extract<v4ZodSafeParseResult<v4.output<T>>, { success: false }>\n : T extends v3.ZodType\n ? Extract<v3.SafeParseReturnType<v3.input<T>, v3.output<T>>, { success: false }>\n : never\n\ntype ZodValidatorFailureResponse<T extends ZodSchema> = Response &\n TypedResponse<ZodValidatorFailureBody<T>, 400, 'json'>\n\nexport type Hook<\n T,\n E extends Env,\n P extends string,\n Target extends keyof ValidationTargets = keyof ValidationTargets,\n O = {},\n Schema extends ZodSchema = any,\n> = (\n result: ({ success: true; data: T } | { success: false; error: ZodError<Schema>; data: T }) & {\n target: Target\n },\n c: Context<E, P>\n) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>\n\ntype HasUndefined<T> = undefined extends T ? true : false\n\ntype ExtractValidationResponse<VF> = VF extends (value: any, c: any) => infer R\n ? R extends Promise<infer PR>\n ? PR extends TypedResponse<infer T, infer S, infer F>\n ? Response & TypedResponse<T, S, F>\n : PR extends Response\n ? PR\n : PR extends undefined\n ? never\n : never\n : R extends TypedResponse<infer T, infer S, infer F>\n ? Response & TypedResponse<T, S, F>\n : R extends Response\n ? R\n : R extends undefined\n ? never\n : never\n : never\n\ntype ValidationFunctionOption<T extends ZodSchema, Target extends keyof ValidationTargets> = {\n validationFunction: (\n schema: T,\n value: ValidationTargets[Target]\n ) => ZodSafeParseResult<any, any, T> | Promise<ZodSafeParseResult<any, any, T>>\n}\n\ntype DefaultInput<Target extends keyof ValidationTargets, In, Out> = {\n in: HasUndefined<In> extends true\n ? {\n [K in Target]?: [In] extends [ValidationTargets[K]] ? In : InferInput<In, K>\n }\n : {\n [K in Target]: [In] extends [ValidationTargets[K]] ? In : InferInput<In, K>\n }\n out: { [K in Target]: Out }\n}\n\n// without hook (with or without options)\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n>(\n target: Target,\n schema: T,\n hook?: undefined,\n options?: ValidationFunctionOption<T, Target>\n): MiddlewareHandler<E, P, V, ZodValidatorFailureResponse<T>>\n\n// with hook and options\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n HookFn extends Hook<InferredValue, E, P, Target, {}, T>,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n InferredValue = zInfer<T>,\n>(\n target: Target,\n schema: T,\n hook?: HookFn,\n options?: ValidationFunctionOption<T, Target>\n): MiddlewareHandler<E, P, V, ExtractValidationResponse<HookFn>>\n\n// implementation\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n HookFn extends Hook<InferredValue, E, P, Target, {}, T>,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n InferredValue = zInfer<T>,\n>(\n target: Target,\n schema: T,\n hook?: HookFn,\n options?: ValidationFunctionOption<T, Target>\n):\n | MiddlewareHandler<E, P, V, ZodValidatorFailureResponse<T>>\n | MiddlewareHandler<E, P, V, ExtractValidationResponse<HookFn>> {\n // @ts-expect-error not typed well\n return validator(target, async (value: ValidationTargets[Target], c) => {\n let validatorValue = value\n\n // in case where our `target` === `header`, Hono parses all of the headers into lowercase.\n // this might not match the Zod schema, so we want to make sure that we account for that when parsing the schema.\n if ((target === 'header' && '_def' in schema) || (target === 'header' && '_zod' in schema)) {\n // create an object that maps lowercase schema keys to lowercase\n // @ts-expect-error the schema is a Zod Schema\n const schemaKeys = Object.keys('in' in schema ? schema.in.shape : schema.shape)\n const caseInsensitiveKeymap = Object.fromEntries(\n schemaKeys.map((key) => [key.toLowerCase(), key])\n )\n\n validatorValue = Object.fromEntries(\n Object.entries(value).map(([key, value]) => [caseInsensitiveKeymap[key] || key, value])\n )\n }\n\n const result =\n options && options.validationFunction\n ? await options.validationFunction(schema, validatorValue)\n : // @ts-expect-error z4.$ZodType has safeParseAsync\n await schema.safeParseAsync(validatorValue)\n\n if (hook) {\n const hookResult = await hook({ data: validatorValue, ...result, target }, c)\n if (hookResult) {\n if (hookResult instanceof Response) {\n return hookResult\n }\n\n if ('response' in hookResult) {\n return hookResult.response\n }\n }\n }\n\n if (!result.success) {\n return c.json(result, 400)\n }\n\n return result.data as zInfer<T>\n })\n}\n\nexport const zValidator: typeof zValidatorFunction = zValidatorFunction\n","/**\n * @packageDocumentation\n * @module act-http/hono\n *\n * Hono subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and emits a Hono\n * app with one `POST /actions/<name>` route per registered action,\n * Zod-validated bodies, and the shared error envelope.\n *\n * Usage:\n *\n * ```ts\n * import { hono } from \"@rotorsoft/act-http/hono\";\n *\n * const api = hono(app, {\n * actor: (c) => ({ id: c.req.header(\"x-user-id\")!, name: \"...\" }),\n * stream: (action, input, c) => `tenant-${c.req.header(\"x-tenant\")}`,\n * });\n *\n * // Mounts at /api by default — pass `basePath` to override.\n * // POST /api/actions/OpenTicket body: { title } → 200 Snapshot[] | 4xx ApiError\n * ```\n *\n * The route shape matches the flat tRPC sibling at\n * `@rotorsoft/act-http/trpc`: one endpoint per action, no\n * state-name tier. Action names are unique across the registry —\n * the framework enforces no duplicates — so an extra namespace\n * level would be overhead. Queries live in a follow-up; this\n * slice is mutations only.\n *\n * **Actor lives on the context.** The generator wires an internal\n * middleware that runs the host's\n * {@link HonoOptions.actor | actor extractor} once per call and\n * stashes the resolved {@link Actor} under `c.get(\"actor\")`. Every\n * generated route reads from there — auth resolves at one seam,\n * downstream middleware (logging, tracing) sees the same actor.\n * The middleware factory is also exported as {@link authenticated}\n * for hosts that want to compose it into their own Hono chain\n * alongside hand-written routes.\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 (tRPC, this one,\n * OpenAPI) ride the same utilities so a client speaking two of\n * them never sees two shapes for the same framework error.\n */\nimport { zValidator } from \"@hono/zod-validator\";\nimport type { Actor, Target } from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport type { Context, MiddlewareHandler } from \"hono\";\nimport { Hono } from \"hono\";\nimport { streamSSE } from \"hono/streaming\";\nimport {\n type ActorExtractor,\n type ApiError,\n fireAndForget,\n resolveSseConfig,\n runSseSubscription,\n SseConnectionCounter,\n type SseOptions,\n toApiError,\n withIdempotency,\n} from \"../api/index.js\";\n\n/**\n * Hono context variables this subpath contributes. The generic on\n * the returned middleware threads it through so routes downstream\n * of {@link authenticated} see `c.get(\"actor\")` typed without a\n * manual cast.\n */\nexport type ActMiddlewareVariables = {\n actor: Actor;\n};\n\n/**\n * Per-call options for {@link hono}. The host supplies the four\n * seams the package can't make decisions on:\n *\n * - `actor` — auth resolver. Receives the Hono context, returns the\n * `Actor` that flows onto `c.get(\"actor\")` for every generated\n * route (via an internal {@link authenticated}).\n * - `stream` — stream-id resolver. Receives the action name, the\n * validated body, and the Hono context; returns the target\n * stream. Singleton aggregates return a constant; per-tenant\n * aggregates pull from headers, path, or body.\n * - `expectedVersion` (optional) — optimistic-concurrency resolver.\n * Hosts typically read it from an `If-Match` header on the\n * request, or from the client's last-known snapshot. Returning\n * `undefined` skips the check for that call — handy when only\n * some actions are concurrency-sensitive.\n * - `idempotency` (optional) — when set, the route honors\n * `Idempotency-Key` via {@link withIdempotency}. The host\n * supplies the `IdempotencyStore`; `keyFrom` defaults to reading\n * the `Idempotency-Key` header. On a duplicate claim, the route\n * responds `409 Conflict` — the contract intentionally doesn't\n * cache the original handler's result, matching the\n * receiver-side \"ack the duplicate\" semantics.\n * - `basePath` (optional, default `\"/api\"`) — Hono basePath the\n * routes mount under.\n */\nexport type HonoOptions = {\n readonly actor: ActorExtractor;\n readonly stream: (\n action_name: string,\n input: unknown,\n c: Context\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n c: Context\n ) => number | undefined | Promise<number | undefined>;\n readonly idempotency?: {\n readonly store: IdempotencyStore;\n readonly keyFrom?: (c: Context) => string | undefined;\n };\n /**\n * Optional SSE wiring. When set, the generator emits one\n * `GET <basePath>/sse/<stateName>?stream=<streamId>` per unique\n * state name in the registry. The endpoint runs the host\n * {@link actor} extractor, looks up the streamId from\n * `?stream=...`, opens a `text/event-stream`, yields the cached\n * state (if any), and forwards every patch published to the\n * shared {@link SseOptions.channel}. A heartbeat keeps proxies\n * from idling the connection out; the per-process connection cap\n * returns `503 Service Unavailable` (with `Retry-After: 1`) when\n * full so operators never see silent stalls.\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 readonly basePath?: string;\n};\n\n/**\n * Build a Hono middleware that runs an {@link ActorExtractor} once\n * per call and stashes the resolved {@link Actor} under\n * `c.set(\"actor\", ...)` so downstream routes can read it via\n * `c.get(\"actor\")`. The generator at {@link hono} uses this\n * internally; it's also exported so hosts that want to compose\n * their own route chain (logging, tracing, custom auth flavors)\n * can wire it directly:\n *\n * ```ts\n * import { Hono } from \"hono\";\n * import { authenticated, type ActMiddlewareVariables } from \"@rotorsoft/act-http/hono\";\n *\n * const api = new Hono<{ Variables: ActMiddlewareVariables }>();\n * api.use(\"*\", authenticated((c) => resolveUserFromJwt(c)));\n * api.get(\"/me\", (c) => c.json(c.get(\"actor\"))); // typed\n * ```\n *\n * Errors thrown by the extractor surface as `401 Unauthorized` with\n * the shared {@link ApiError} envelope.\n */\nexport function authenticated(\n extractor: ActorExtractor\n): MiddlewareHandler<{ Variables: ActMiddlewareVariables }> {\n return async (c, next) => {\n try {\n const actor = await extractor(c);\n c.set(\"actor\", actor);\n await next();\n } catch (err) {\n const body: ApiError = {\n error: \"Unauthorized\",\n detail: err instanceof Error ? err.message : undefined,\n code: \"UNAUTHORIZED\",\n };\n return c.json(body, 401);\n }\n };\n}\n\nconst default_key_from = (c: Context): string | undefined =>\n c.req.header(\"idempotency-key\");\n\n/**\n * Build a typed Hono REST surface from a built `Act` instance.\n *\n * Walks `app.registry.actions` once and emits one\n * `POST /actions/<actionName>` per action under the configured\n * `basePath` (default `/api`). Each route:\n *\n * 1. Runs the internal {@link authenticated} middleware —\n * `options.actor` resolves the {@link Actor} once and stashes\n * it under `c.get(\"actor\")`.\n * 2. Validates the JSON body against the action's Zod schema via\n * `@hono/zod-validator`. Failures short-circuit with `422`.\n * 3. Resolves the target stream via\n * `options.stream(name, input, c)`.\n * 4. (Optionally) resolves `expectedVersion` for optimistic\n * concurrency.\n * 5. (Optionally) claims the `Idempotency-Key` via\n * {@link withIdempotency} — responds `409` on duplicate.\n * 6. Calls\n * `app.do(name, { stream, actor, expectedVersion? }, input)`.\n * 7. Maps any thrown framework error onto the shared\n * {@link ApiError} envelope via {@link toApiError}, returning\n * the conventional HTTP status (`409` / `412` for concurrency,\n * `422` for validation, `400` for non-retryable, etc.).\n *\n * @param app A built `Act` orchestrator.\n * @param options Auth, stream, expected-version, idempotency, and\n * base-path seams.\n * @returns A Hono app covering every registered action under\n * `<basePath>/actions/<name>`.\n */\n/**\n * Structural shape of the Act surface this generator walks at\n * runtime — the registry's action-name → owning-state map plus the\n * `do(...)` dispatch. Letting TApp infer to the caller's concrete\n * `Act<...>` against this structural bound keeps nested variance\n * out of the signature and avoids `any` in either direction.\n *\n * @internal\n */\ntype ActSurface = {\n readonly registry: {\n actions: Record<\n string,\n {\n readonly name: string;\n readonly actions: Record<string, unknown>;\n }\n >;\n };\n do(action: string, target: Target, payload: unknown): Promise<unknown>;\n};\n\nexport function hono<TApp extends ActSurface = ActSurface>(\n app: TApp,\n options: HonoOptions\n): Hono<{ Variables: ActMiddlewareVariables }> {\n const base_path = options.basePath ?? \"/api\";\n const api = new Hono<{ Variables: ActMiddlewareVariables }>().basePath(\n base_path\n );\n api.use(\"*\", authenticated(options.actor));\n\n const key_from = options.idempotency?.keyFrom ?? default_key_from;\n\n // Wire SSE subscriptions before the mutation routes so the\n // `/sse/*` paths sit alongside `/actions/*`. The cap counter is\n // shared by every state-name's GET route on this generator\n // instance so a noisy room on one state doesn't get a free pass\n // on another.\n if (options.sse) {\n const sse_config = resolveSseConfig(options.sse);\n const sse_counter = new SseConnectionCounter(sse_config.maxConnections);\n const state_names = new Set<string>();\n for (const action of Object.values(app.registry.actions)) {\n state_names.add(action.name);\n }\n for (const state_name of state_names) {\n api.get(`/sse/${state_name}`, async (c) => {\n const stream_id = c.req.query(\"stream\");\n if (!stream_id) {\n const body: ApiError = {\n error: \"BadRequest\",\n detail: \"stream query parameter is required\",\n code: \"BAD_REQUEST\",\n };\n return c.json(body, 400);\n }\n if (!sse_counter.acquire()) {\n c.header(\"Retry-After\", \"1\");\n const body: ApiError = {\n error: \"ServiceUnavailable\",\n detail: \"max concurrent SSE connections reached\",\n code: \"SSE_BUSY\",\n };\n return c.json(body, 503);\n }\n // The route already acquired the slot above so streamSSE\n // could return `503` before writing headers if the cap was\n // full. The shared loop runs without accounting; the route\n // releases the slot in `finally`.\n const controller = new AbortController();\n return streamSSE(c, async (sse_stream) => {\n sse_stream.onAbort(() => controller.abort());\n const heartbeat = setInterval(() => {\n fireAndForget(() =>\n sse_stream.writeSSE({ event: \"ping\", data: \"\" })\n );\n }, sse_config.heartbeatMs);\n try {\n for await (const frame of runSseSubscription(\n sse_config.channel,\n stream_id,\n undefined,\n controller.signal,\n { maxPending: sse_config.maxPendingPerConnection }\n )) {\n await sse_stream.writeSSE({\n event: frame.kind,\n data: JSON.stringify(frame.data),\n });\n }\n } finally {\n clearInterval(heartbeat);\n sse_counter.release();\n }\n });\n });\n }\n }\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const schema = state.actions[action_name];\n api.post(\n `/actions/${action_name}`,\n // Custom hook funnels body-schema failures through the same\n // ApiError envelope + 422 status an in-`do` ValidationError maps\n // to (ERROR_MAP.ValidationError). Without it, @hono/zod-validator\n // short-circuits with its own 400 + raw body — two statuses and\n // two shapes for the same \"malformed input\" failure class on one\n // endpoint. The OpenAPI doc only documents the 422/ApiError path.\n zValidator(\"json\", schema as never, (result, c) => {\n if (!result.success) {\n // `result.error` is a ZodError; the schema's `never` typing\n // erases its shape, so read `.message` through a narrow cast.\n const message = (result.error as { message?: string }).message;\n const body: ApiError = {\n error: \"ValidationError\",\n detail: message,\n code: \"VALIDATION\",\n };\n return c.json(body, 422);\n }\n }),\n async (c) => {\n try {\n const input = c.req.valid(\"json\" as never) as unknown;\n const actor = c.get(\"actor\");\n const stream = await options.stream(action_name, input, c);\n const expected_version = options.expectedVersion\n ? await options.expectedVersion(action_name, input, c)\n : undefined;\n const target =\n expected_version === undefined\n ? { stream, actor }\n : { stream, actor, expectedVersion: expected_version };\n\n if (options.idempotency) {\n const key = key_from(c);\n if (!key) {\n const body: ApiError = {\n error: \"BadRequest\",\n detail: \"Idempotency-Key required\",\n code: \"BAD_REQUEST\",\n };\n return c.json(body, 400);\n }\n const outcome = await withIdempotency(\n options.idempotency.store,\n key,\n () =>\n app.do(action_name as never, target as never, input as never)\n );\n if (outcome.deduped) {\n const body: ApiError = {\n error: \"Conflict\",\n detail:\n \"Idempotency-Key already used; original result not cached\",\n code: \"CONFLICT\",\n };\n return c.json(body, 409);\n }\n return c.json(outcome.result);\n }\n\n const snapshots = await app.do(\n action_name as never,\n target as never,\n input as never\n );\n return c.json(snapshots);\n } catch (err) {\n const { status, body } = toApiError(err);\n // Hono types narrow `status` to its known status-number union;\n // `toApiError` returns a numeric status from a closed set that\n // matches but TypeScript can't prove it through the union.\n return c.json(body, status as 400 | 409 | 410 | 412 | 422 | 500);\n }\n }\n );\n }\n\n return api;\n}\n"],"mappings":"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","names":["value"]}
1
+ {"version":3,"sources":["../../../../node_modules/.pnpm/@hono+zod-validator@0.9.1_hono@4.13.5_zod@4.4.3/node_modules/@hono/zod-validator/src/index.ts","../../src/hono/index.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono'\nimport { validator } from 'hono/validator'\nimport type { InferInput } from 'hono/validator'\nimport type * as v3 from 'zod/v3'\nimport type { ZodSafeParseResult as v4ZodSafeParseResult } from 'zod/v4'\nimport type * as v4 from 'zod/v4/core'\n\ntype ZodSchema = v3.ZodType | v4.$ZodType\ntype ZodError<T extends ZodSchema> = T extends v4.$ZodType\n ? v4.$ZodError<v4.output<T>>\n : v3.ZodError\ntype ZodSafeParseResult<T, T2, T3 extends ZodSchema> = T3 extends v4.$ZodType\n ? v4ZodSafeParseResult<T>\n : v3.SafeParseReturnType<T, T2>\ntype zInput<T> = T extends v3.ZodType ? v3.input<T> : T extends v4.$ZodType ? v4.input<T> : never\ntype zOutput<T> = T extends v3.ZodType ? v3.output<T> : T extends v4.$ZodType ? v4.output<T> : never\ntype zInfer<T> = T extends v3.ZodType ? v3.infer<T> : T extends v4.$ZodType ? v4.infer<T> : never\n\n// Failure variant of `safeParseAsync`'s return value.\n// Mirrors the runtime body returned by `c.json(result, 400)` on validation failure.\ntype ZodValidatorFailureBody<T extends ZodSchema> = T extends v4.$ZodType\n ? Extract<v4ZodSafeParseResult<v4.output<T>>, { success: false }>\n : T extends v3.ZodType\n ? Extract<v3.SafeParseReturnType<v3.input<T>, v3.output<T>>, { success: false }>\n : never\n\ntype ZodValidatorFailureResponse<T extends ZodSchema> = Response &\n TypedResponse<ZodValidatorFailureBody<T>, 400, 'json'>\n\nexport type Hook<\n T,\n E extends Env,\n P extends string,\n Target extends keyof ValidationTargets = keyof ValidationTargets,\n O = {},\n Schema extends ZodSchema = any,\n> = (\n result: ({ success: true; data: T } | { success: false; error: ZodError<Schema>; data: T }) & {\n target: Target\n },\n c: Context<E, P>\n) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>\n\ntype HasUndefined<T> = undefined extends T ? true : false\n\ntype ExtractValidationResponse<VF> = VF extends (value: any, c: any) => infer R\n ? R extends Promise<infer PR>\n ? PR extends TypedResponse<infer T, infer S, infer F>\n ? Response & TypedResponse<T, S, F>\n : PR extends Response\n ? PR\n : PR extends undefined\n ? never\n : never\n : R extends TypedResponse<infer T, infer S, infer F>\n ? Response & TypedResponse<T, S, F>\n : R extends Response\n ? R\n : R extends undefined\n ? never\n : never\n : never\n\ntype ValidationFunctionOption<T extends ZodSchema, Target extends keyof ValidationTargets> = {\n validationFunction: (\n schema: T,\n value: ValidationTargets[Target]\n ) => ZodSafeParseResult<any, any, T> | Promise<ZodSafeParseResult<any, any, T>>\n}\n\ntype DefaultInput<Target extends keyof ValidationTargets, In, Out> = {\n in: HasUndefined<In> extends true\n ? {\n [K in Target]?: [In] extends [ValidationTargets[K]] ? In : InferInput<In, K>\n }\n : {\n [K in Target]: [In] extends [ValidationTargets[K]] ? In : InferInput<In, K>\n }\n out: { [K in Target]: Out }\n}\n\n// without hook (with or without options)\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n>(\n target: Target,\n schema: T,\n hook?: undefined,\n options?: ValidationFunctionOption<T, Target>\n): MiddlewareHandler<E, P, V, ZodValidatorFailureResponse<T>>\n\n// with hook and options\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n HookFn extends Hook<InferredValue, E, P, Target, {}, T>,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n InferredValue = zInfer<T>,\n>(\n target: Target,\n schema: T,\n hook?: HookFn,\n options?: ValidationFunctionOption<T, Target>\n): MiddlewareHandler<E, P, V, ExtractValidationResponse<HookFn>>\n\n// implementation\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n HookFn extends Hook<InferredValue, E, P, Target, {}, T>,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n InferredValue = zInfer<T>,\n>(\n target: Target,\n schema: T,\n hook?: HookFn,\n options?: ValidationFunctionOption<T, Target>\n):\n | MiddlewareHandler<E, P, V, ZodValidatorFailureResponse<T>>\n | MiddlewareHandler<E, P, V, ExtractValidationResponse<HookFn>> {\n const caseInsensitiveKeymap =\n target === 'header' && ('_def' in schema || '_zod' in schema)\n ? Object.fromEntries(\n // @ts-expect-error the schema is a Zod Schema\n Object.keys('in' in schema ? schema.in.shape : schema.shape).map((key) => [\n key.toLowerCase(),\n key,\n ])\n )\n : undefined\n\n // @ts-expect-error not typed well\n return validator(target, async (value: ValidationTargets[Target], c) => {\n let validatorValue = value\n\n // in case where our `target` === `header`, Hono parses all of the headers into lowercase.\n // this might not match the Zod schema, so we want to make sure that we account for that when parsing the schema.\n if (caseInsensitiveKeymap) {\n validatorValue = Object.fromEntries(\n Object.entries(value).map(([key, value]) => [caseInsensitiveKeymap[key] || key, value])\n )\n }\n\n const result =\n options && options.validationFunction\n ? await options.validationFunction(schema, validatorValue)\n : // @ts-expect-error z4.$ZodType has safeParseAsync\n await schema.safeParseAsync(validatorValue)\n\n if (hook) {\n const hookResult = await hook({ data: validatorValue, ...result, target }, c)\n if (hookResult) {\n if (hookResult instanceof Response) {\n return hookResult\n }\n\n if ('response' in hookResult) {\n return hookResult.response\n }\n }\n }\n\n if (!result.success) {\n return c.json(result, 400)\n }\n\n return result.data as zInfer<T>\n })\n}\n\nexport const zValidator: typeof zValidatorFunction = zValidatorFunction\n","/**\n * @packageDocumentation\n * @module act-http/hono\n *\n * Hono subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and emits a Hono\n * app with one `POST /actions/<name>` route per registered action,\n * Zod-validated bodies, and the shared error envelope.\n *\n * Usage:\n *\n * ```ts\n * import { hono } from \"@rotorsoft/act-http/hono\";\n *\n * const api = hono(app, {\n * actor: (c) => ({ id: c.req.header(\"x-user-id\")!, name: \"...\" }),\n * stream: (action, input, c) => `tenant-${c.req.header(\"x-tenant\")}`,\n * });\n *\n * // Mounts at /api by default — pass `basePath` to override.\n * // POST /api/actions/OpenTicket body: { title } → 200 Snapshot[] | 4xx ApiError\n * ```\n *\n * The route shape matches the flat tRPC sibling at\n * `@rotorsoft/act-http/trpc`: one endpoint per action, no\n * state-name tier. Action names are unique across the registry —\n * the framework enforces no duplicates — so an extra namespace\n * level would be overhead. Queries live in a follow-up; this\n * slice is mutations only.\n *\n * **Actor lives on the context.** The generator wires an internal\n * middleware that runs the host's\n * {@link HonoOptions.actor | actor extractor} once per call and\n * stashes the resolved {@link Actor} under `c.get(\"actor\")`. Every\n * generated route reads from there — auth resolves at one seam,\n * downstream middleware (logging, tracing) sees the same actor.\n * The middleware factory is also exported as {@link authenticated}\n * for hosts that want to compose it into their own Hono chain\n * alongside hand-written routes.\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 (tRPC, this one,\n * OpenAPI) ride the same utilities so a client speaking two of\n * them never sees two shapes for the same framework error.\n */\nimport { zValidator } from \"@hono/zod-validator\";\nimport type { Actor, Target } from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport type { Context, MiddlewareHandler } from \"hono\";\nimport { Hono } from \"hono\";\nimport { streamSSE } from \"hono/streaming\";\nimport {\n type ActorExtractor,\n type ApiError,\n fireAndForget,\n resolveSseConfig,\n runSseSubscription,\n SseConnectionCounter,\n type SseOptions,\n toApiError,\n withIdempotency,\n} from \"../api/index.js\";\n\n/**\n * Hono context variables this subpath contributes. The generic on\n * the returned middleware threads it through so routes downstream\n * of {@link authenticated} see `c.get(\"actor\")` typed without a\n * manual cast.\n */\nexport type ActMiddlewareVariables = {\n actor: Actor;\n};\n\n/**\n * Per-call options for {@link hono}. The host supplies the four\n * seams the package can't make decisions on:\n *\n * - `actor` — auth resolver. Receives the Hono context, returns the\n * `Actor` that flows onto `c.get(\"actor\")` for every generated\n * route (via an internal {@link authenticated}).\n * - `stream` — stream-id resolver. Receives the action name, the\n * validated body, and the Hono context; returns the target\n * stream. Singleton aggregates return a constant; per-tenant\n * aggregates pull from headers, path, or body.\n * - `expectedVersion` (optional) — optimistic-concurrency resolver.\n * Hosts typically read it from an `If-Match` header on the\n * request, or from the client's last-known snapshot. Returning\n * `undefined` skips the check for that call — handy when only\n * some actions are concurrency-sensitive.\n * - `idempotency` (optional) — when set, the route honors\n * `Idempotency-Key` via {@link withIdempotency}. The host\n * supplies the `IdempotencyStore`; `keyFrom` defaults to reading\n * the `Idempotency-Key` header. On a duplicate claim, the route\n * responds `409 Conflict` — the contract intentionally doesn't\n * cache the original handler's result, matching the\n * receiver-side \"ack the duplicate\" semantics.\n * - `basePath` (optional, default `\"/api\"`) — Hono basePath the\n * routes mount under.\n */\nexport type HonoOptions = {\n readonly actor: ActorExtractor;\n readonly stream: (\n action_name: string,\n input: unknown,\n c: Context\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n c: Context\n ) => number | undefined | Promise<number | undefined>;\n readonly idempotency?: {\n readonly store: IdempotencyStore;\n readonly keyFrom?: (c: Context) => string | undefined;\n };\n /**\n * Optional SSE wiring. When set, the generator emits one\n * `GET <basePath>/sse/<stateName>?stream=<streamId>` per unique\n * state name in the registry. The endpoint runs the host\n * {@link actor} extractor, looks up the streamId from\n * `?stream=...`, opens a `text/event-stream`, yields the cached\n * state (if any), and forwards every patch published to the\n * shared {@link SseOptions.channel}. A heartbeat keeps proxies\n * from idling the connection out; the per-process connection cap\n * returns `503 Service Unavailable` (with `Retry-After: 1`) when\n * full so operators never see silent stalls.\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 readonly basePath?: string;\n};\n\n/**\n * Build a Hono middleware that runs an {@link ActorExtractor} once\n * per call and stashes the resolved {@link Actor} under\n * `c.set(\"actor\", ...)` so downstream routes can read it via\n * `c.get(\"actor\")`. The generator at {@link hono} uses this\n * internally; it's also exported so hosts that want to compose\n * their own route chain (logging, tracing, custom auth flavors)\n * can wire it directly:\n *\n * ```ts\n * import { Hono } from \"hono\";\n * import { authenticated, type ActMiddlewareVariables } from \"@rotorsoft/act-http/hono\";\n *\n * const api = new Hono<{ Variables: ActMiddlewareVariables }>();\n * api.use(\"*\", authenticated((c) => resolveUserFromJwt(c)));\n * api.get(\"/me\", (c) => c.json(c.get(\"actor\"))); // typed\n * ```\n *\n * Errors thrown by the extractor surface as `401 Unauthorized` with\n * the shared {@link ApiError} envelope.\n */\nexport function authenticated(\n extractor: ActorExtractor\n): MiddlewareHandler<{ Variables: ActMiddlewareVariables }> {\n return async (c, next) => {\n try {\n const actor = await extractor(c);\n c.set(\"actor\", actor);\n await next();\n } catch (err) {\n const body: ApiError = {\n error: \"Unauthorized\",\n detail: err instanceof Error ? err.message : undefined,\n code: \"UNAUTHORIZED\",\n };\n return c.json(body, 401);\n }\n };\n}\n\nconst default_key_from = (c: Context): string | undefined =>\n c.req.header(\"idempotency-key\");\n\n/**\n * Build a typed Hono REST surface from a built `Act` instance.\n *\n * Walks `app.registry.actions` once and emits one\n * `POST /actions/<actionName>` per action under the configured\n * `basePath` (default `/api`). Each route:\n *\n * 1. Runs the internal {@link authenticated} middleware —\n * `options.actor` resolves the {@link Actor} once and stashes\n * it under `c.get(\"actor\")`.\n * 2. Validates the JSON body against the action's Zod schema via\n * `@hono/zod-validator`. Failures short-circuit with `422`.\n * 3. Resolves the target stream via\n * `options.stream(name, input, c)`.\n * 4. (Optionally) resolves `expectedVersion` for optimistic\n * concurrency.\n * 5. (Optionally) claims the `Idempotency-Key` via\n * {@link withIdempotency} — responds `409` on duplicate.\n * 6. Calls\n * `app.do(name, { stream, actor, expectedVersion? }, input)`.\n * 7. Maps any thrown framework error onto the shared\n * {@link ApiError} envelope via {@link toApiError}, returning\n * the conventional HTTP status (`409` / `412` for concurrency,\n * `422` for validation, `400` for non-retryable, etc.).\n *\n * @param app A built `Act` orchestrator.\n * @param options Auth, stream, expected-version, idempotency, and\n * base-path seams.\n * @returns A Hono app covering every registered action under\n * `<basePath>/actions/<name>`.\n */\n/**\n * Structural shape of the Act surface this generator walks at\n * runtime — the registry's action-name → owning-state map plus the\n * `do(...)` dispatch. Letting TApp infer to the caller's concrete\n * `Act<...>` against this structural bound keeps nested variance\n * out of the signature and avoids `any` in either direction.\n *\n * @internal\n */\ntype ActSurface = {\n readonly registry: {\n actions: Record<\n string,\n {\n readonly name: string;\n readonly actions: Record<string, unknown>;\n }\n >;\n };\n do(action: string, target: Target, payload: unknown): Promise<unknown>;\n};\n\nexport function hono<TApp extends ActSurface = ActSurface>(\n app: TApp,\n options: HonoOptions\n): Hono<{ Variables: ActMiddlewareVariables }> {\n const base_path = options.basePath ?? \"/api\";\n const api = new Hono<{ Variables: ActMiddlewareVariables }>().basePath(\n base_path\n );\n api.use(\"*\", authenticated(options.actor));\n\n const key_from = options.idempotency?.keyFrom ?? default_key_from;\n\n // Wire SSE subscriptions before the mutation routes so the\n // `/sse/*` paths sit alongside `/actions/*`. The cap counter is\n // shared by every state-name's GET route on this generator\n // instance so a noisy room on one state doesn't get a free pass\n // on another.\n if (options.sse) {\n const sse_config = resolveSseConfig(options.sse);\n const sse_counter = new SseConnectionCounter(sse_config.maxConnections);\n const state_names = new Set<string>();\n for (const action of Object.values(app.registry.actions)) {\n state_names.add(action.name);\n }\n for (const state_name of state_names) {\n api.get(`/sse/${state_name}`, async (c) => {\n const stream_id = c.req.query(\"stream\");\n if (!stream_id) {\n const body: ApiError = {\n error: \"BadRequest\",\n detail: \"stream query parameter is required\",\n code: \"BAD_REQUEST\",\n };\n return c.json(body, 400);\n }\n if (!sse_counter.acquire()) {\n c.header(\"Retry-After\", \"1\");\n const body: ApiError = {\n error: \"ServiceUnavailable\",\n detail: \"max concurrent SSE connections reached\",\n code: \"SSE_BUSY\",\n };\n return c.json(body, 503);\n }\n // The route already acquired the slot above so streamSSE\n // could return `503` before writing headers if the cap was\n // full. The shared loop runs without accounting; the route\n // releases the slot in `finally`.\n const controller = new AbortController();\n return streamSSE(c, async (sse_stream) => {\n sse_stream.onAbort(() => controller.abort());\n const heartbeat = setInterval(() => {\n fireAndForget(() =>\n sse_stream.writeSSE({ event: \"ping\", data: \"\" })\n );\n }, sse_config.heartbeatMs);\n try {\n for await (const frame of runSseSubscription(\n sse_config.channel,\n stream_id,\n undefined,\n controller.signal,\n { maxPending: sse_config.maxPendingPerConnection }\n )) {\n await sse_stream.writeSSE({\n event: frame.kind,\n data: JSON.stringify(frame.data),\n });\n }\n } finally {\n clearInterval(heartbeat);\n sse_counter.release();\n }\n });\n });\n }\n }\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const schema = state.actions[action_name];\n api.post(\n `/actions/${action_name}`,\n // Custom hook funnels body-schema failures through the same\n // ApiError envelope + 422 status an in-`do` ValidationError maps\n // to (ERROR_MAP.ValidationError). Without it, @hono/zod-validator\n // short-circuits with its own 400 + raw body — two statuses and\n // two shapes for the same \"malformed input\" failure class on one\n // endpoint. The OpenAPI doc only documents the 422/ApiError path.\n zValidator(\"json\", schema as never, (result, c) => {\n if (!result.success) {\n // `result.error` is a ZodError; the schema's `never` typing\n // erases its shape, so read `.message` through a narrow cast.\n const message = (result.error as { message?: string }).message;\n const body: ApiError = {\n error: \"ValidationError\",\n detail: message,\n code: \"VALIDATION\",\n };\n return c.json(body, 422);\n }\n }),\n async (c) => {\n try {\n const input = c.req.valid(\"json\" as never) as unknown;\n const actor = c.get(\"actor\");\n const stream = await options.stream(action_name, input, c);\n const expected_version = options.expectedVersion\n ? await options.expectedVersion(action_name, input, c)\n : undefined;\n const target =\n expected_version === undefined\n ? { stream, actor }\n : { stream, actor, expectedVersion: expected_version };\n\n if (options.idempotency) {\n const key = key_from(c);\n if (!key) {\n const body: ApiError = {\n error: \"BadRequest\",\n detail: \"Idempotency-Key required\",\n code: \"BAD_REQUEST\",\n };\n return c.json(body, 400);\n }\n const outcome = await withIdempotency(\n options.idempotency.store,\n key,\n () =>\n app.do(action_name as never, target as never, input as never)\n );\n if (outcome.deduped) {\n const body: ApiError = {\n error: \"Conflict\",\n detail:\n \"Idempotency-Key already used; original result not cached\",\n code: \"CONFLICT\",\n };\n return c.json(body, 409);\n }\n return c.json(outcome.result);\n }\n\n const snapshots = await app.do(\n action_name as never,\n target as never,\n input as never\n );\n return c.json(snapshots);\n } catch (err) {\n const { status, body } = toApiError(err);\n // Hono types narrow `status` to its known status-number union;\n // `toApiError` returns a numeric status from a closed set that\n // matches but TypeScript can't prove it through the union.\n return c.json(body, status as 400 | 409 | 410 | 412 | 422 | 500);\n }\n }\n );\n }\n\n return api;\n}\n"],"mappings":"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","names":["value"]}
@@ -24,6 +24,8 @@ __export(webhook_exports, {
24
24
  NonRetryableWebhookError: () => NonRetryableWebhookError,
25
25
  RetryableHttpError: () => RetryableHttpError,
26
26
  WebhookError: () => WebhookError,
27
+ classifyHttpResponse: () => classifyHttpResponse,
28
+ tryOk: () => tryOk,
27
29
  webhook: () => webhook
28
30
  });
29
31
  module.exports = __toCommonJS(webhook_exports);
@@ -68,11 +70,27 @@ var NonRetryableWebhookError = class extends NonRetryableHttpError {
68
70
  };
69
71
 
70
72
  // src/webhook/classify.ts
71
- function classify_http_response(response) {
73
+ function classifyHttpResponse(response) {
72
74
  if (response.ok) return "ok";
73
75
  if (response.status >= 500) return "retry";
74
76
  return "block";
75
77
  }
78
+ async function tryOk(response, options) {
79
+ const disposition = classifyHttpResponse(response);
80
+ if (disposition === "ok") return;
81
+ let responseBody;
82
+ try {
83
+ responseBody = await response.text();
84
+ } catch {
85
+ }
86
+ const label = options.label ?? "request";
87
+ const ErrorClass = disposition === "retry" ? RetryableHttpError : NonRetryableHttpError;
88
+ throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {
89
+ status: response.status,
90
+ url: options.url,
91
+ responseBody
92
+ });
93
+ }
76
94
 
77
95
  // src/webhook/sign.ts
78
96
  var import_node_crypto = require("crypto");
@@ -142,7 +160,7 @@ function webhook(config) {
142
160
  } finally {
143
161
  clearTimeout(timer);
144
162
  }
145
- const disposition = classify_http_response(response);
163
+ const disposition = classifyHttpResponse(response);
146
164
  if (disposition === "ok") return;
147
165
  let responseBody;
148
166
  try {
@@ -162,6 +180,8 @@ function webhook(config) {
162
180
  NonRetryableWebhookError,
163
181
  RetryableHttpError,
164
182
  WebhookError,
183
+ classifyHttpResponse,
184
+ tryOk,
165
185
  webhook
166
186
  });
167
187
  //# sourceMappingURL=index.cjs.map
@@ -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 { classify_http_response } 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 } 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 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 } finally {\n clearTimeout(timer);\n }\n\n const disposition = classify_http_response(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 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 };\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 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 classify_http_response(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 try_ok}. */\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 (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 try_ok(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classify_http_response(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 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 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 } finally {\n clearTimeout(timer);\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; ignore read errors\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 };\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 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 (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":[]}
@@ -42,11 +42,27 @@ var NonRetryableWebhookError = class extends NonRetryableHttpError {
42
42
  };
43
43
 
44
44
  // src/webhook/classify.ts
45
- function classify_http_response(response) {
45
+ function classifyHttpResponse(response) {
46
46
  if (response.ok) return "ok";
47
47
  if (response.status >= 500) return "retry";
48
48
  return "block";
49
49
  }
50
+ async function tryOk(response, options) {
51
+ const disposition = classifyHttpResponse(response);
52
+ if (disposition === "ok") return;
53
+ let responseBody;
54
+ try {
55
+ responseBody = await response.text();
56
+ } catch {
57
+ }
58
+ const label = options.label ?? "request";
59
+ const ErrorClass = disposition === "retry" ? RetryableHttpError : NonRetryableHttpError;
60
+ throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {
61
+ status: response.status,
62
+ url: options.url,
63
+ responseBody
64
+ });
65
+ }
50
66
 
51
67
  // src/webhook/sign.ts
52
68
  import { createHmac } from "crypto";
@@ -116,7 +132,7 @@ function webhook(config) {
116
132
  } finally {
117
133
  clearTimeout(timer);
118
134
  }
119
- const disposition = classify_http_response(response);
135
+ const disposition = classifyHttpResponse(response);
120
136
  if (disposition === "ok") return;
121
137
  let responseBody;
122
138
  try {
@@ -135,6 +151,8 @@ export {
135
151
  NonRetryableWebhookError,
136
152
  RetryableHttpError,
137
153
  WebhookError,
154
+ classifyHttpResponse,
155
+ tryOk,
138
156
  webhook
139
157
  };
140
158
  //# sourceMappingURL=index.js.map
@@ -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 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 classify_http_response(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 try_ok}. */\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 (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 try_ok(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classify_http_response(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 { classify_http_response } 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 } 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 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 } finally {\n clearTimeout(timer);\n }\n\n const disposition = classify_http_response(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 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 };\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 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 (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 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 } finally {\n clearTimeout(timer);\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; ignore read errors\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 };\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.9.12",
4
+ "version": "1.10.1",
5
5
  "description": "HTTP integrations for act apps — webhooks and SSE",
6
6
  "keywords": [
7
7
  "typescript",
@@ -91,11 +91,11 @@
91
91
  },
92
92
  "peerDependencies": {
93
93
  "@hono/node-server": "^2.1.1",
94
- "@rotorsoft/act": "^1.31.17",
94
+ "@rotorsoft/act": "^1.31.19",
95
95
  "@rotorsoft/act-ops": "^0.2.7",
96
96
  "@trpc/server": ">=11.18.0",
97
97
  "express": "^5.2.1",
98
- "fastify": "^5.12.1",
98
+ "fastify": "^5.12.3",
99
99
  "hono": "^4.13.5"
100
100
  },
101
101
  "peerDependenciesMeta": {
@@ -123,7 +123,7 @@
123
123
  },
124
124
  "devDependencies": {
125
125
  "@hono/node-server": "^2.1.1",
126
- "@hono/zod-validator": "^0.9.0",
126
+ "@hono/zod-validator": "^0.9.1",
127
127
  "@rotorsoft/act-ops": "^0.2.7",
128
128
  "@stryker-mutator/core": "^10.0.0",
129
129
  "@stryker-mutator/vitest-runner": "^10.0.0",
@@ -132,7 +132,7 @@
132
132
  "express": "^5.2.1",
133
133
  "fastify": "^5.12.1",
134
134
  "hono": "^4.13.5",
135
- "zod": "^4.4.3"
135
+ "zod": "^4.5.4"
136
136
  },
137
137
  "scripts": {
138
138
  "clean": "rm -rf dist",