{"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":[]}