@rotorsoft/act-http 1.10.12 → 1.10.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/api/in-flight.d.ts +41 -0
- package/dist/@types/api/in-flight.d.ts.map +1 -0
- package/dist/@types/hono/index.d.ts +9 -5
- package/dist/@types/hono/index.d.ts.map +1 -1
- package/dist/@types/openapi/index.d.ts.map +1 -1
- package/dist/@types/receiver/start.d.ts.map +1 -1
- package/dist/@types/trpc/index.d.ts +8 -4
- package/dist/@types/trpc/index.d.ts.map +1 -1
- package/dist/chunk-FXVWAQ2D.js +55 -0
- package/dist/chunk-FXVWAQ2D.js.map +1 -0
- package/dist/hono/index.cjs +65 -3
- package/dist/hono/index.cjs.map +1 -1
- package/dist/hono/index.js +17 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/openapi/index.cjs +3 -2
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.js +3 -2
- package/dist/openapi/index.js.map +1 -1
- package/dist/receiver/index.cjs +54 -29
- package/dist/receiver/index.cjs.map +1 -1
- package/dist/receiver/index.js +6 -29
- package/dist/receiver/index.js.map +1 -1
- package/dist/trpc/index.cjs +61 -7
- package/dist/trpc/index.cjs.map +1 -1
- package/dist/trpc/index.js +13 -7
- package/dist/trpc/index.js.map +1 -1
- package/package.json +2 -2
package/dist/openapi/index.js
CHANGED
|
@@ -98,7 +98,7 @@ function build_operation(action_name, body_schema, options) {
|
|
|
98
98
|
// enabled and the header is absent, so the doc must not advertise it as
|
|
99
99
|
// optional (#1287).
|
|
100
100
|
required: true,
|
|
101
|
-
description: "Required idempotency token.
|
|
101
|
+
description: "Required idempotency token. A duplicate of a request that already succeeded produces a 409 Conflict (the first result is not cached on the server). A duplicate that arrives while the original is still running produces a retryable 503 with Retry-After, because the original may yet fail.",
|
|
102
102
|
schema: { type: "string" }
|
|
103
103
|
});
|
|
104
104
|
}
|
|
@@ -136,7 +136,8 @@ function build_operation(action_name, body_schema, options) {
|
|
|
136
136
|
"410": { $ref: "#/components/responses/ApiError" },
|
|
137
137
|
"412": { $ref: "#/components/responses/ApiError" },
|
|
138
138
|
"422": { $ref: "#/components/responses/ApiError" },
|
|
139
|
-
"500": { $ref: "#/components/responses/ApiError" }
|
|
139
|
+
"500": { $ref: "#/components/responses/ApiError" },
|
|
140
|
+
...options.idempotency ? { "503": { $ref: "#/components/responses/ApiError" } } : {}
|
|
140
141
|
}
|
|
141
142
|
};
|
|
142
143
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/openapi/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/openapi\n *\n * OpenAPI subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and returns a\n * valid OpenAPI 3.1 document describing the same action surface the\n * Hono sibling at `@rotorsoft/act-http/hono` serves. The doc is\n * suitable for cross-language client codegen (Go, Python, Swift,\n * Kotlin, .NET), for serving over `/openapi.json` alongside a live\n * API, and for the broader OpenAPI tooling ecosystem (Postman,\n * Swagger UI, Redoc, schemathesis, API gateways).\n *\n * Usage:\n *\n * ```ts\n * import { openapi } from \"@rotorsoft/act-http/openapi\";\n *\n * const doc = openapi(app, {\n * info: { title: \"Wolfdesk API\", version: \"1.0.0\" },\n * servers: [{ url: \"https://api.example.com\" }],\n * });\n * // doc: OpenAPIDocument — valid OpenAPI 3.1 object\n * ```\n *\n * **Pure data emit.** No runtime dep on Hono or tRPC. The subpath is\n * consumable standalone for hosts that serve their API some other\n * way (Fastify, Lambda, edge) or that only want the doc for client\n * codegen. The emit walks the registry once at function call; cost\n * is linear in `N actions × M Zod fields each`, single-digit\n * milliseconds for a 100-endpoint registry.\n *\n * **Determinism.** Output is stable across runs given the same\n * registry — entries land in `Object.entries(app.registry.actions)`\n * iteration order, schemas come from the action's Zod definition,\n * and the rest of the doc is fixed content. CI can snapshot the\n * result to catch unintended API surface changes.\n *\n * The same shared utilities at `@rotorsoft/act-http/api` underwrite\n * cross-transport consistency: the `ApiError` envelope is referenced\n * once from `components.schemas`, every error response points at it,\n * and the same shape ships from the tRPC and Hono siblings at\n * runtime. A client speaking the doc and the live REST API sees one\n * envelope per framework error.\n *\n * **The doc describes the Hono surface, not tRPC.** tRPC's URL\n * shape (`POST /trpc/<procedure>`, JSON-RPC-style body framing,\n * batching) doesn't model cleanly as OpenAPI operations — and tRPC\n * consumers already share types directly via `typeof router`, so\n * OpenAPI buys them nothing. The path shape this emitter produces\n * (`POST <basePath>/actions/<name>`) matches the\n * `@rotorsoft/act-http/hono` sibling exactly: same default\n * `basePath`, same request/response shapes, same envelope. If the\n * operator overrides `basePath` on the Hono adapter, they pass the\n * same override here so the doc keeps matching the live routes by\n * construction. tRPC and Hono can run side-by-side at different\n * mount points on the same Act instance; the OpenAPI doc covers\n * the REST half only.\n */\nimport { pii_fields } from \"@rotorsoft/act\";\nimport { z } from \"zod\";\n\n/**\n * Minimal OpenAPI 3.1 document type. The emitter targets the 3.1\n * spec; the type is intentionally a structural subset rather than\n * the full surface from `openapi-types` so callers can post-process\n * without fighting the type system. Cast to a fuller type from\n * `openapi-types` when downstream consumers need it.\n */\nexport type OpenAPIDocument = {\n readonly openapi: \"3.1.0\";\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly paths: Record<string, OpenAPIPathItem>;\n readonly components: OpenAPIComponents;\n};\n\n/**\n * Per-call options for {@link openapi}. The host supplies the\n * document-shape fields the registry can't derive (title, version,\n * servers) and toggles for the cross-cutting headers that the live\n * REST API may accept.\n *\n * - `info` — required. `title` and `version` must be non-empty;\n * `info` may carry any other top-level OpenAPI info fields.\n * - `servers` — optional. Each entry's `url` may contain\n * `{variable}` template syntax; bare URLs are validated through\n * `URL`'s parser and reject malformed inputs.\n * - `basePath` — optional, default `/api`. Mirrors the Hono\n * sibling's default so the doc describes the same paths the\n * generated REST surface serves.\n * - `idempotency` — optional, default `false`. When `true`, every\n * mutation operation documents a **required** `Idempotency-Key`\n * request header (the generated route fail-closes with 400 when the\n * header is absent, so the doc marks it required, not optional).\n * - `expectedVersion` — optional, default `false`. When `true`,\n * every mutation operation documents an optional `If-Match`\n * request header carrying the expected stream version.\n */\nexport type OpenAPIOptions = {\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly basePath?: string;\n readonly idempotency?: boolean;\n readonly expectedVersion?: boolean;\n};\n\nexport type OpenAPIInfo = {\n readonly title: string;\n readonly version: string;\n readonly description?: string;\n readonly summary?: string;\n readonly termsOfService?: string;\n readonly contact?: {\n readonly name?: string;\n readonly url?: string;\n readonly email?: string;\n };\n readonly license?: {\n readonly name: string;\n readonly identifier?: string;\n readonly url?: string;\n };\n};\n\nexport type OpenAPIServer = {\n readonly url: string;\n readonly description?: string;\n readonly variables?: Record<\n string,\n {\n readonly default: string;\n readonly enum?: ReadonlyArray<string>;\n readonly description?: string;\n }\n >;\n};\n\nexport type OpenAPIPathItem = {\n readonly post?: OpenAPIOperation;\n};\n\nexport type OpenAPIOperation = {\n readonly operationId: string;\n readonly summary: string;\n readonly tags: ReadonlyArray<string>;\n readonly parameters?: ReadonlyArray<OpenAPIParameter>;\n readonly requestBody?: OpenAPIRequestBody;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nexport type OpenAPIParameter = {\n readonly name: string;\n readonly in: \"header\" | \"query\" | \"path\" | \"cookie\";\n readonly required?: boolean;\n readonly description?: string;\n readonly schema?: Record<string, unknown>;\n};\n\nexport type OpenAPIRequestBody = {\n readonly required?: boolean;\n readonly content: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n};\n\nexport type OpenAPIResponse =\n | {\n readonly description: string;\n readonly content?: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n }\n | { readonly $ref: string };\n\nexport type OpenAPIComponents = {\n readonly schemas: Record<string, Record<string, unknown>>;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nconst DEFAULT_BASE_PATH = \"/api\";\n\n/**\n * The shared `ApiError` envelope schema, ref'd by every error\n * response. Matches the runtime envelope at\n * `@rotorsoft/act-http/api`'s `toApiError` exactly — keep in sync if\n * the envelope shape evolves.\n */\nconst API_ERROR_SCHEMA: Record<string, unknown> = {\n type: \"object\",\n properties: {\n error: { type: \"string\" },\n detail: { type: \"string\" },\n code: { type: \"string\" },\n },\n required: [\"error\"],\n};\n\n/**\n * The generic snapshot-array response schema returned on a\n * successful mutation. Each action commits one or more events; the\n * shape here is `Snapshot[]` with the per-action state and event\n * data left as open `object` (deeper per-action typing would need\n * to walk `state.events[*]` and emit a discriminated union — a\n * future refinement once the doc consumers ask for it).\n */\nconst SNAPSHOT_ARRAY_SCHEMA: Record<string, unknown> = {\n type: \"array\",\n items: {\n type: \"object\",\n properties: {\n state: { type: \"object\" },\n event: { type: \"object\" },\n version: { type: \"integer\" },\n patches: { type: \"integer\" },\n snaps: { type: \"integer\" },\n cache_hit: { type: \"boolean\" },\n replayed: { type: \"integer\" },\n },\n required: [\"state\", \"version\", \"patches\", \"snaps\", \"cache_hit\", \"replayed\"],\n },\n};\n\n/**\n * The error-response shape every operation references via\n * `$ref: \"#/components/responses/ApiError\"`. Single source of\n * truth — bug fixes to the envelope flow to every operation\n * automatically.\n */\nconst API_ERROR_RESPONSE: OpenAPIResponse = {\n description: \"Error envelope per @rotorsoft/act-http/api's toApiError.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/ApiError\" },\n },\n },\n};\n\n/**\n * Server URL refinement: OpenAPI permits `{variable}` template syntax\n * inside server URLs, so we substitute each capture with `x` before\n * parsing. The character class forbids both `{` and `}` inside the\n * template, matching OpenAPI's variable-name grammar exactly and\n * eliminating the catastrophic-backtracking surface CodeQL flagged\n * on `[^}]+`.\n *\n * @internal\n */\nfunction is_valid_server_url(url: string): boolean {\n try {\n new URL(url.replace(/\\{[^{}]+\\}/g, \"x\"));\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Zod schema for the minimum required shape of {@link OpenAPIOptions}.\n * Same declarative-validation pattern the rest of the framework uses;\n * the OpenAPI surface is mostly free-form (`description`, `contact`,\n * `license` — the spec lets through anything that JSON-stringifies),\n * so only the load-bearing fields land here. Hosts overriding into\n * unusual shapes still get the rest of the doc emitted; this schema\n * only fails the construction call when `info.title` / `info.version`\n * is missing-or-empty or a `servers[].url` won't parse.\n *\n * @internal\n */\nconst OpenAPIOptionsSchema = z.object({\n info: z.object({\n title: z\n .string({ message: \"openapi: info.title is required (non-empty string)\" })\n .trim()\n .min(1, \"openapi: info.title is required (non-empty string)\"),\n version: z\n .string({\n message: \"openapi: info.version is required (non-empty string)\",\n })\n .trim()\n .min(1, \"openapi: info.version is required (non-empty string)\"),\n }),\n servers: z\n .array(\n z.object({\n url: z.string().refine(is_valid_server_url, {\n message: \"openapi: invalid server url\",\n }),\n })\n )\n .optional(),\n});\n\nfunction validate_options(options: OpenAPIOptions): void {\n OpenAPIOptionsSchema.parse({\n info: { title: options.info?.title, version: options.info?.version },\n servers: options.servers,\n });\n}\n\nfunction strip_json_schema_meta(\n schema: Record<string, unknown>\n): Record<string, unknown> {\n // `$schema` at the top of an inline schema is harmless in OpenAPI\n // 3.1 but adds noise to the doc. Drop it.\n const { $schema: _, ...rest } = schema;\n return rest;\n}\n\n/**\n * Annotate the emitted request-body schema's sensitive properties so\n * codegen / Swagger UI treat them as secrets rather than echoing them\n * freely. `z.toJSONSchema` has no knowledge of the sensitive registry\n * (`sensitive()` marks schemas out-of-band in a WeakMap), so we walk the\n * action's declared sensitive fields — via `pii_fields`, the same lookup\n * the orchestrator uses — and mark each matching property `writeOnly:\n * true` + `format: password`. Non-object schemas and actions with no\n * sensitive fields pass through untouched (the zero-cost common path).\n *\n * `pii_fields` only reports top-level keys of a `z.object`, and Zod's\n * JSON Schema emit always renders those as members of `properties` — so\n * once `fields` is non-empty, `body_schema.properties[field]` is\n * guaranteed present. No defensive fallback needed.\n *\n * @internal\n */\nfunction mark_sensitive_fields(\n body_schema: Record<string, unknown>,\n zod_schema: z.ZodType\n): Record<string, unknown> {\n const fields = pii_fields(zod_schema);\n if (fields.length === 0) return body_schema;\n const properties = body_schema.properties as Record<\n string,\n Record<string, unknown>\n >;\n const next_properties: Record<string, Record<string, unknown>> = {\n ...properties,\n };\n for (const field of fields) {\n next_properties[field] = {\n ...properties[field],\n writeOnly: true,\n format: \"password\",\n };\n }\n return { ...body_schema, properties: next_properties };\n}\n\nfunction build_operation(\n action_name: string,\n body_schema: Record<string, unknown>,\n options: OpenAPIOptions\n): OpenAPIOperation {\n const parameters: OpenAPIParameter[] = [];\n if (options.idempotency) {\n parameters.push({\n name: \"Idempotency-Key\",\n in: \"header\",\n // Required: the generated route fail-closes with 400 when idempotency is\n // enabled and the header is absent, so the doc must not advertise it as\n // optional (#1287).\n required: true,\n description:\n \"Required idempotency token. Duplicate values produce a 409 Conflict; first-claim result is not cached on the server.\",\n schema: { type: \"string\" },\n });\n }\n if (options.expectedVersion) {\n parameters.push({\n name: \"If-Match\",\n in: \"header\",\n required: false,\n description:\n \"Expected stream version for optimistic concurrency. Mismatch produces a 412 Precondition Failed via ConcurrencyError.\",\n schema: { type: \"string\" },\n });\n }\n\n return {\n operationId: action_name,\n summary: `Commit the ${action_name} action`,\n tags: [\"Actions\"],\n ...(parameters.length > 0 ? { parameters } : {}),\n requestBody: {\n required: true,\n content: {\n \"application/json\": { schema: body_schema },\n },\n },\n responses: {\n \"200\": {\n description: \"Action committed; returns the snapshot array.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/SnapshotArray\" },\n },\n },\n },\n \"400\": { $ref: \"#/components/responses/ApiError\" },\n \"409\": { $ref: \"#/components/responses/ApiError\" },\n \"410\": { $ref: \"#/components/responses/ApiError\" },\n \"412\": { $ref: \"#/components/responses/ApiError\" },\n \"422\": { $ref: \"#/components/responses/ApiError\" },\n \"500\": { $ref: \"#/components/responses/ApiError\" },\n },\n };\n}\n\n/**\n * Build an OpenAPI 3.1 document describing a built `Act` instance's\n * action surface.\n *\n * Walks `app.registry.actions` once and emits a `POST\n * <basePath>/actions/<actionName>` operation per action, deriving\n * the request-body schema from each action's Zod definition via\n * `z.toJSONSchema` (Zod 4's native JSON Schema 2020-12 emitter,\n * which is the OpenAPI 3.1 schema dialect — no conversion layer\n * needed). Error responses reference the shared\n * `#/components/responses/ApiError` so a single envelope shape\n * covers every error path.\n *\n * @param app A built `Act` orchestrator. Required.\n * @param options Document-shape fields (`info.title`, `info.version`,\n * servers, base path) and toggles for the cross-cutting headers\n * the live REST API may accept (`Idempotency-Key`, `If-Match`).\n * @returns A valid OpenAPI 3.1 document object — serve as\n * `/openapi.json`, ship to a CDN, write to disk during CI, or\n * pipe to a codegen tool.\n *\n * @throws Error if `info.title` / `info.version` is missing or\n * empty, or if any `servers[].url` fails URL parsing after\n * `{variable}` substitution.\n */\n/**\n * Structural shape of the Act surface this emitter walks. Letting\n * TApp infer to the caller's concrete `Act<TSchemaReg, ...>` instead\n * of forcing it to fit a narrow framework-typed upper bound keeps\n * the caller's variance from leaking — and avoids `any` in the\n * signature.\n *\n * @internal\n */\ntype ActRegistryView = {\n readonly registry: {\n actions: Record<\n string,\n {\n readonly name: string;\n readonly actions: Record<string, unknown>;\n }\n >;\n };\n};\n\nexport function openapi<TApp extends ActRegistryView>(\n app: TApp,\n options: OpenAPIOptions\n): OpenAPIDocument {\n validate_options(options);\n const base_path = options.basePath ?? DEFAULT_BASE_PATH;\n const paths: Record<string, OpenAPIPathItem> = {};\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const zod_schema = state.actions[action_name] as z.ZodType;\n const body_schema = mark_sensitive_fields(\n strip_json_schema_meta(\n // `unrepresentable: \"any\"` emits an open (`{}`) schema for a Zod type\n // with no JSON-Schema form (z.date/z.bigint/z.map/z.set/...) instead of\n // Zod 4's default `\"throw\"`, which would abort the WHOLE document over\n // one such field — while hono() serves the same action fine (#1328).\n z.toJSONSchema(zod_schema, {\n unrepresentable: \"any\",\n }) as Record<string, unknown>\n ),\n zod_schema\n );\n paths[`${base_path}/actions/${action_name}`] = {\n post: build_operation(action_name, body_schema, options),\n };\n }\n\n return {\n openapi: \"3.1.0\",\n info: options.info,\n ...(options.servers ? { servers: options.servers } : {}),\n paths,\n components: {\n schemas: {\n ApiError: API_ERROR_SCHEMA,\n SnapshotArray: SNAPSHOT_ARRAY_SCHEMA,\n },\n responses: {\n ApiError: API_ERROR_RESPONSE,\n },\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/openapi/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/openapi\n *\n * OpenAPI subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and returns a\n * valid OpenAPI 3.1 document describing the same action surface the\n * Hono sibling at `@rotorsoft/act-http/hono` serves. The doc is\n * suitable for cross-language client codegen (Go, Python, Swift,\n * Kotlin, .NET), for serving over `/openapi.json` alongside a live\n * API, and for the broader OpenAPI tooling ecosystem (Postman,\n * Swagger UI, Redoc, schemathesis, API gateways).\n *\n * Usage:\n *\n * ```ts\n * import { openapi } from \"@rotorsoft/act-http/openapi\";\n *\n * const doc = openapi(app, {\n * info: { title: \"Wolfdesk API\", version: \"1.0.0\" },\n * servers: [{ url: \"https://api.example.com\" }],\n * });\n * // doc: OpenAPIDocument — valid OpenAPI 3.1 object\n * ```\n *\n * **Pure data emit.** No runtime dep on Hono or tRPC. The subpath is\n * consumable standalone for hosts that serve their API some other\n * way (Fastify, Lambda, edge) or that only want the doc for client\n * codegen. The emit walks the registry once at function call; cost\n * is linear in `N actions × M Zod fields each`, single-digit\n * milliseconds for a 100-endpoint registry.\n *\n * **Determinism.** Output is stable across runs given the same\n * registry — entries land in `Object.entries(app.registry.actions)`\n * iteration order, schemas come from the action's Zod definition,\n * and the rest of the doc is fixed content. CI can snapshot the\n * result to catch unintended API surface changes.\n *\n * The same shared utilities at `@rotorsoft/act-http/api` underwrite\n * cross-transport consistency: the `ApiError` envelope is referenced\n * once from `components.schemas`, every error response points at it,\n * and the same shape ships from the tRPC and Hono siblings at\n * runtime. A client speaking the doc and the live REST API sees one\n * envelope per framework error.\n *\n * **The doc describes the Hono surface, not tRPC.** tRPC's URL\n * shape (`POST /trpc/<procedure>`, JSON-RPC-style body framing,\n * batching) doesn't model cleanly as OpenAPI operations — and tRPC\n * consumers already share types directly via `typeof router`, so\n * OpenAPI buys them nothing. The path shape this emitter produces\n * (`POST <basePath>/actions/<name>`) matches the\n * `@rotorsoft/act-http/hono` sibling exactly: same default\n * `basePath`, same request/response shapes, same envelope. If the\n * operator overrides `basePath` on the Hono adapter, they pass the\n * same override here so the doc keeps matching the live routes by\n * construction. tRPC and Hono can run side-by-side at different\n * mount points on the same Act instance; the OpenAPI doc covers\n * the REST half only.\n */\nimport { pii_fields } from \"@rotorsoft/act\";\nimport { z } from \"zod\";\n\n/**\n * Minimal OpenAPI 3.1 document type. The emitter targets the 3.1\n * spec; the type is intentionally a structural subset rather than\n * the full surface from `openapi-types` so callers can post-process\n * without fighting the type system. Cast to a fuller type from\n * `openapi-types` when downstream consumers need it.\n */\nexport type OpenAPIDocument = {\n readonly openapi: \"3.1.0\";\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly paths: Record<string, OpenAPIPathItem>;\n readonly components: OpenAPIComponents;\n};\n\n/**\n * Per-call options for {@link openapi}. The host supplies the\n * document-shape fields the registry can't derive (title, version,\n * servers) and toggles for the cross-cutting headers that the live\n * REST API may accept.\n *\n * - `info` — required. `title` and `version` must be non-empty;\n * `info` may carry any other top-level OpenAPI info fields.\n * - `servers` — optional. Each entry's `url` may contain\n * `{variable}` template syntax; bare URLs are validated through\n * `URL`'s parser and reject malformed inputs.\n * - `basePath` — optional, default `/api`. Mirrors the Hono\n * sibling's default so the doc describes the same paths the\n * generated REST surface serves.\n * - `idempotency` — optional, default `false`. When `true`, every\n * mutation operation documents a **required** `Idempotency-Key`\n * request header (the generated route fail-closes with 400 when the\n * header is absent, so the doc marks it required, not optional).\n * - `expectedVersion` — optional, default `false`. When `true`,\n * every mutation operation documents an optional `If-Match`\n * request header carrying the expected stream version.\n */\nexport type OpenAPIOptions = {\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly basePath?: string;\n readonly idempotency?: boolean;\n readonly expectedVersion?: boolean;\n};\n\nexport type OpenAPIInfo = {\n readonly title: string;\n readonly version: string;\n readonly description?: string;\n readonly summary?: string;\n readonly termsOfService?: string;\n readonly contact?: {\n readonly name?: string;\n readonly url?: string;\n readonly email?: string;\n };\n readonly license?: {\n readonly name: string;\n readonly identifier?: string;\n readonly url?: string;\n };\n};\n\nexport type OpenAPIServer = {\n readonly url: string;\n readonly description?: string;\n readonly variables?: Record<\n string,\n {\n readonly default: string;\n readonly enum?: ReadonlyArray<string>;\n readonly description?: string;\n }\n >;\n};\n\nexport type OpenAPIPathItem = {\n readonly post?: OpenAPIOperation;\n};\n\nexport type OpenAPIOperation = {\n readonly operationId: string;\n readonly summary: string;\n readonly tags: ReadonlyArray<string>;\n readonly parameters?: ReadonlyArray<OpenAPIParameter>;\n readonly requestBody?: OpenAPIRequestBody;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nexport type OpenAPIParameter = {\n readonly name: string;\n readonly in: \"header\" | \"query\" | \"path\" | \"cookie\";\n readonly required?: boolean;\n readonly description?: string;\n readonly schema?: Record<string, unknown>;\n};\n\nexport type OpenAPIRequestBody = {\n readonly required?: boolean;\n readonly content: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n};\n\nexport type OpenAPIResponse =\n | {\n readonly description: string;\n readonly content?: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n }\n | { readonly $ref: string };\n\nexport type OpenAPIComponents = {\n readonly schemas: Record<string, Record<string, unknown>>;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nconst DEFAULT_BASE_PATH = \"/api\";\n\n/**\n * The shared `ApiError` envelope schema, ref'd by every error\n * response. Matches the runtime envelope at\n * `@rotorsoft/act-http/api`'s `toApiError` exactly — keep in sync if\n * the envelope shape evolves.\n */\nconst API_ERROR_SCHEMA: Record<string, unknown> = {\n type: \"object\",\n properties: {\n error: { type: \"string\" },\n detail: { type: \"string\" },\n code: { type: \"string\" },\n },\n required: [\"error\"],\n};\n\n/**\n * The generic snapshot-array response schema returned on a\n * successful mutation. Each action commits one or more events; the\n * shape here is `Snapshot[]` with the per-action state and event\n * data left as open `object` (deeper per-action typing would need\n * to walk `state.events[*]` and emit a discriminated union — a\n * future refinement once the doc consumers ask for it).\n */\nconst SNAPSHOT_ARRAY_SCHEMA: Record<string, unknown> = {\n type: \"array\",\n items: {\n type: \"object\",\n properties: {\n state: { type: \"object\" },\n event: { type: \"object\" },\n version: { type: \"integer\" },\n patches: { type: \"integer\" },\n snaps: { type: \"integer\" },\n cache_hit: { type: \"boolean\" },\n replayed: { type: \"integer\" },\n },\n required: [\"state\", \"version\", \"patches\", \"snaps\", \"cache_hit\", \"replayed\"],\n },\n};\n\n/**\n * The error-response shape every operation references via\n * `$ref: \"#/components/responses/ApiError\"`. Single source of\n * truth — bug fixes to the envelope flow to every operation\n * automatically.\n */\nconst API_ERROR_RESPONSE: OpenAPIResponse = {\n description: \"Error envelope per @rotorsoft/act-http/api's toApiError.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/ApiError\" },\n },\n },\n};\n\n/**\n * Server URL refinement: OpenAPI permits `{variable}` template syntax\n * inside server URLs, so we substitute each capture with `x` before\n * parsing. The character class forbids both `{` and `}` inside the\n * template, matching OpenAPI's variable-name grammar exactly and\n * eliminating the catastrophic-backtracking surface CodeQL flagged\n * on `[^}]+`.\n *\n * @internal\n */\nfunction is_valid_server_url(url: string): boolean {\n try {\n new URL(url.replace(/\\{[^{}]+\\}/g, \"x\"));\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Zod schema for the minimum required shape of {@link OpenAPIOptions}.\n * Same declarative-validation pattern the rest of the framework uses;\n * the OpenAPI surface is mostly free-form (`description`, `contact`,\n * `license` — the spec lets through anything that JSON-stringifies),\n * so only the load-bearing fields land here. Hosts overriding into\n * unusual shapes still get the rest of the doc emitted; this schema\n * only fails the construction call when `info.title` / `info.version`\n * is missing-or-empty or a `servers[].url` won't parse.\n *\n * @internal\n */\nconst OpenAPIOptionsSchema = z.object({\n info: z.object({\n title: z\n .string({ message: \"openapi: info.title is required (non-empty string)\" })\n .trim()\n .min(1, \"openapi: info.title is required (non-empty string)\"),\n version: z\n .string({\n message: \"openapi: info.version is required (non-empty string)\",\n })\n .trim()\n .min(1, \"openapi: info.version is required (non-empty string)\"),\n }),\n servers: z\n .array(\n z.object({\n url: z.string().refine(is_valid_server_url, {\n message: \"openapi: invalid server url\",\n }),\n })\n )\n .optional(),\n});\n\nfunction validate_options(options: OpenAPIOptions): void {\n OpenAPIOptionsSchema.parse({\n info: { title: options.info?.title, version: options.info?.version },\n servers: options.servers,\n });\n}\n\nfunction strip_json_schema_meta(\n schema: Record<string, unknown>\n): Record<string, unknown> {\n // `$schema` at the top of an inline schema is harmless in OpenAPI\n // 3.1 but adds noise to the doc. Drop it.\n const { $schema: _, ...rest } = schema;\n return rest;\n}\n\n/**\n * Annotate the emitted request-body schema's sensitive properties so\n * codegen / Swagger UI treat them as secrets rather than echoing them\n * freely. `z.toJSONSchema` has no knowledge of the sensitive registry\n * (`sensitive()` marks schemas out-of-band in a WeakMap), so we walk the\n * action's declared sensitive fields — via `pii_fields`, the same lookup\n * the orchestrator uses — and mark each matching property `writeOnly:\n * true` + `format: password`. Non-object schemas and actions with no\n * sensitive fields pass through untouched (the zero-cost common path).\n *\n * `pii_fields` only reports top-level keys of a `z.object`, and Zod's\n * JSON Schema emit always renders those as members of `properties` — so\n * once `fields` is non-empty, `body_schema.properties[field]` is\n * guaranteed present. No defensive fallback needed.\n *\n * @internal\n */\nfunction mark_sensitive_fields(\n body_schema: Record<string, unknown>,\n zod_schema: z.ZodType\n): Record<string, unknown> {\n const fields = pii_fields(zod_schema);\n if (fields.length === 0) return body_schema;\n const properties = body_schema.properties as Record<\n string,\n Record<string, unknown>\n >;\n const next_properties: Record<string, Record<string, unknown>> = {\n ...properties,\n };\n for (const field of fields) {\n next_properties[field] = {\n ...properties[field],\n writeOnly: true,\n format: \"password\",\n };\n }\n return { ...body_schema, properties: next_properties };\n}\n\nfunction build_operation(\n action_name: string,\n body_schema: Record<string, unknown>,\n options: OpenAPIOptions\n): OpenAPIOperation {\n const parameters: OpenAPIParameter[] = [];\n if (options.idempotency) {\n parameters.push({\n name: \"Idempotency-Key\",\n in: \"header\",\n // Required: the generated route fail-closes with 400 when idempotency is\n // enabled and the header is absent, so the doc must not advertise it as\n // optional (#1287).\n required: true,\n description:\n \"Required idempotency token. A duplicate of a request that already succeeded produces a 409 Conflict (the first result is not cached on the server). A duplicate that arrives while the original is still running produces a retryable 503 with Retry-After, because the original may yet fail.\",\n schema: { type: \"string\" },\n });\n }\n if (options.expectedVersion) {\n parameters.push({\n name: \"If-Match\",\n in: \"header\",\n required: false,\n description:\n \"Expected stream version for optimistic concurrency. Mismatch produces a 412 Precondition Failed via ConcurrencyError.\",\n schema: { type: \"string\" },\n });\n }\n\n return {\n operationId: action_name,\n summary: `Commit the ${action_name} action`,\n tags: [\"Actions\"],\n ...(parameters.length > 0 ? { parameters } : {}),\n requestBody: {\n required: true,\n content: {\n \"application/json\": { schema: body_schema },\n },\n },\n responses: {\n \"200\": {\n description: \"Action committed; returns the snapshot array.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/SnapshotArray\" },\n },\n },\n },\n \"400\": { $ref: \"#/components/responses/ApiError\" },\n \"409\": { $ref: \"#/components/responses/ApiError\" },\n \"410\": { $ref: \"#/components/responses/ApiError\" },\n \"412\": { $ref: \"#/components/responses/ApiError\" },\n \"422\": { $ref: \"#/components/responses/ApiError\" },\n \"500\": { $ref: \"#/components/responses/ApiError\" },\n ...(options.idempotency\n ? { \"503\": { $ref: \"#/components/responses/ApiError\" } }\n : {}),\n },\n };\n}\n\n/**\n * Build an OpenAPI 3.1 document describing a built `Act` instance's\n * action surface.\n *\n * Walks `app.registry.actions` once and emits a `POST\n * <basePath>/actions/<actionName>` operation per action, deriving\n * the request-body schema from each action's Zod definition via\n * `z.toJSONSchema` (Zod 4's native JSON Schema 2020-12 emitter,\n * which is the OpenAPI 3.1 schema dialect — no conversion layer\n * needed). Error responses reference the shared\n * `#/components/responses/ApiError` so a single envelope shape\n * covers every error path.\n *\n * @param app A built `Act` orchestrator. Required.\n * @param options Document-shape fields (`info.title`, `info.version`,\n * servers, base path) and toggles for the cross-cutting headers\n * the live REST API may accept (`Idempotency-Key`, `If-Match`).\n * @returns A valid OpenAPI 3.1 document object — serve as\n * `/openapi.json`, ship to a CDN, write to disk during CI, or\n * pipe to a codegen tool.\n *\n * @throws Error if `info.title` / `info.version` is missing or\n * empty, or if any `servers[].url` fails URL parsing after\n * `{variable}` substitution.\n */\n/**\n * Structural shape of the Act surface this emitter walks. Letting\n * TApp infer to the caller's concrete `Act<TSchemaReg, ...>` instead\n * of forcing it to fit a narrow framework-typed upper bound keeps\n * the caller's variance from leaking — and avoids `any` in the\n * signature.\n *\n * @internal\n */\ntype ActRegistryView = {\n readonly registry: {\n actions: Record<\n string,\n {\n readonly name: string;\n readonly actions: Record<string, unknown>;\n }\n >;\n };\n};\n\nexport function openapi<TApp extends ActRegistryView>(\n app: TApp,\n options: OpenAPIOptions\n): OpenAPIDocument {\n validate_options(options);\n const base_path = options.basePath ?? DEFAULT_BASE_PATH;\n const paths: Record<string, OpenAPIPathItem> = {};\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const zod_schema = state.actions[action_name] as z.ZodType;\n const body_schema = mark_sensitive_fields(\n strip_json_schema_meta(\n // `unrepresentable: \"any\"` emits an open (`{}`) schema for a Zod type\n // with no JSON-Schema form (z.date/z.bigint/z.map/z.set/...) instead of\n // Zod 4's default `\"throw\"`, which would abort the WHOLE document over\n // one such field — while hono() serves the same action fine (#1328).\n z.toJSONSchema(zod_schema, {\n unrepresentable: \"any\",\n }) as Record<string, unknown>\n ),\n zod_schema\n );\n paths[`${base_path}/actions/${action_name}`] = {\n post: build_operation(action_name, body_schema, options),\n };\n }\n\n return {\n openapi: \"3.1.0\",\n info: options.info,\n ...(options.servers ? { servers: options.servers } : {}),\n paths,\n components: {\n schemas: {\n ApiError: API_ERROR_SCHEMA,\n SnapshotArray: SNAPSHOT_ARRAY_SCHEMA,\n },\n responses: {\n ApiError: API_ERROR_RESPONSE,\n },\n },\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/receiver/index.cjs
CHANGED
|
@@ -115,6 +115,57 @@ async function checkWebhook(headers, body, options) {
|
|
|
115
115
|
// src/receiver/start.ts
|
|
116
116
|
var import_hono = require("hono");
|
|
117
117
|
|
|
118
|
+
// src/api/in-flight.ts
|
|
119
|
+
function track_in_flight(store) {
|
|
120
|
+
const pending = /* @__PURE__ */ new Map();
|
|
121
|
+
const up = (key) => pending.set(key, (pending.get(key) ?? 0) + 1);
|
|
122
|
+
const down = (key) => {
|
|
123
|
+
const left = (pending.get(key) ?? 0) - 1;
|
|
124
|
+
if (left > 0) pending.set(key, left);
|
|
125
|
+
else pending.delete(key);
|
|
126
|
+
};
|
|
127
|
+
const settle = (key, won) => {
|
|
128
|
+
if (!won) down(key);
|
|
129
|
+
return won;
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
in_flight: (key) => pending.has(key),
|
|
133
|
+
store: {
|
|
134
|
+
claim(key, now) {
|
|
135
|
+
up(key);
|
|
136
|
+
let won;
|
|
137
|
+
try {
|
|
138
|
+
won = store.claim(key, now);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
down(key);
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
return typeof won === "boolean" ? settle(key, won) : won.then(
|
|
144
|
+
(w) => settle(key, w),
|
|
145
|
+
(err) => {
|
|
146
|
+
down(key);
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
async commit(key, now) {
|
|
152
|
+
try {
|
|
153
|
+
await store.commit(key, now);
|
|
154
|
+
} finally {
|
|
155
|
+
down(key);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
async release(key) {
|
|
159
|
+
try {
|
|
160
|
+
await store.release(key);
|
|
161
|
+
} finally {
|
|
162
|
+
down(key);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
118
169
|
// src/receiver/finalize.ts
|
|
119
170
|
function make_finalizers(store, key, deduped) {
|
|
120
171
|
let settled = deduped;
|
|
@@ -173,9 +224,9 @@ function headers_bag(headers) {
|
|
|
173
224
|
// src/receiver/start.ts
|
|
174
225
|
function receiver(options) {
|
|
175
226
|
const app = new import_hono.Hono();
|
|
176
|
-
const
|
|
227
|
+
const tracker = track_in_flight(options.store);
|
|
177
228
|
const middleware = webhookMiddleware({
|
|
178
|
-
store:
|
|
229
|
+
store: tracker.store,
|
|
179
230
|
secret: options.secret
|
|
180
231
|
});
|
|
181
232
|
let built = false;
|
|
@@ -201,7 +252,7 @@ function receiver(options) {
|
|
|
201
252
|
);
|
|
202
253
|
}
|
|
203
254
|
const idem = c.get("idempotency");
|
|
204
|
-
if (idem.deduped && in_flight
|
|
255
|
+
if (idem.deduped && tracker.in_flight(idem.key)) {
|
|
205
256
|
c.header("Retry-After", "1");
|
|
206
257
|
return c.json({ error: "in-flight" }, 503);
|
|
207
258
|
}
|
|
@@ -250,32 +301,6 @@ function receiver(options) {
|
|
|
250
301
|
};
|
|
251
302
|
return builder;
|
|
252
303
|
}
|
|
253
|
-
function track_in_flight(store, in_flight) {
|
|
254
|
-
const mark = (key, won) => {
|
|
255
|
-
if (won) in_flight.add(key);
|
|
256
|
-
return won;
|
|
257
|
-
};
|
|
258
|
-
return {
|
|
259
|
-
claim(key, now) {
|
|
260
|
-
const won = store.claim(key, now);
|
|
261
|
-
return typeof won === "boolean" ? mark(key, won) : won.then((w) => mark(key, w));
|
|
262
|
-
},
|
|
263
|
-
async commit(key, now) {
|
|
264
|
-
try {
|
|
265
|
-
await store.commit(key, now);
|
|
266
|
-
} finally {
|
|
267
|
-
in_flight.delete(key);
|
|
268
|
-
}
|
|
269
|
-
},
|
|
270
|
-
async release(key) {
|
|
271
|
-
try {
|
|
272
|
-
await store.release(key);
|
|
273
|
-
} finally {
|
|
274
|
-
in_flight.delete(key);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
304
|
// Annotate the CommonJS export names for ESM import in node:
|
|
280
305
|
0 && (module.exports = {
|
|
281
306
|
checkWebhook,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/receiver/index.ts","../../src/receiver/extract.ts","../../src/receiver/verify.ts","../../src/receiver/check.ts","../../src/receiver/start.ts","../../src/receiver/finalize.ts","../../src/receiver/hono/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver\n *\n * Server-side helpers for the inbound HTTP role — the receiver that\n * sits on the other end of an `@rotorsoft/act-http/webhook` POST.\n *\n * The subpath hosts two primitives today:\n *\n * - {@link extractIdempotencyKey} — case-insensitive\n * `Idempotency-Key` parser; pair with `IdempotencyStore.claim`\n * from `@rotorsoft/act-ops/idempotency` for dedup.\n * - {@link verifyWebhook} — HMAC-SHA256 signature + timestamp\n * verifier; pair with `webhook({ secret })` from\n * `@rotorsoft/act-http/webhook` for authenticated, replay-resistant\n * delivery.\n *\n * The framework-agnostic middleware that wires these into request\n * handlers, plus per-framework adapters (tRPC / Express / Fastify /\n * Hono), lands in #744 (ACT-1116).\n *\n * Sibling subpaths in the same package:\n *\n * - `@rotorsoft/act-http/webhook` — the sender side: outbound POSTs,\n * automatic `Idempotency-Key`, status-classified retries, optional\n * HMAC signing.\n * - `@rotorsoft/act-http/sse` — incremental state broadcast over\n * Server-Sent Events.\n *\n * The receiver subpath ships from the same package as `/webhook` so\n * a service that both sends and receives webhooks installs one\n * dependency. The dedup contract that links them lives in\n * `@rotorsoft/act-ops/idempotency`.\n */\n\nexport {\n type CheckFailureReason,\n type CheckResult,\n type CheckWebhookOptions,\n checkWebhook,\n} from \"./check.js\";\nexport { extractIdempotencyKey } from \"./extract.js\";\nexport { receiver } from \"./start.js\";\nexport {\n type VerifyOptions,\n type VerifyResult,\n verifyWebhook,\n} from \"./verify.js\";\n","/**\n * Pull the `Idempotency-Key` header from a Node-style headers bag,\n * case-insensitive. Returns `undefined` when any of the following\n * carries no usable key:\n *\n * - the header is missing\n * - its value is an array (ambiguous — can't pick one without a\n * policy the receiver hasn't declared)\n * - its value is the empty string (carries no idempotency\n * information; structurally equivalent to \"no header at all\")\n *\n * Pair with `IdempotencyStore.claim` from\n * `@rotorsoft/act-ops/idempotency`: extract the key from the inbound\n * request, claim it on the store, return a `deduped` marker when the\n * claim fails. The framework-agnostic middleware that wires these\n * together lands in #744.\n *\n * Validation beyond \"is there a usable key?\" (length bounds, format\n * checks, normalization) is intentionally out of scope. Receivers\n * picking a policy can layer it on top — or, when #744 ships, opt\n * into the middleware's opinionated defaults.\n */\nexport function extractIdempotencyKey(\n headers: Record<string, string | string[] | undefined>\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== \"idempotency-key\") continue;\n if (Array.isArray(value)) return undefined;\n if (value === \"\") return undefined;\n return value;\n }\n return undefined;\n}\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Outcome of {@link verifyWebhook}. Either the request signature\n * checks out, or one of five distinct failure reasons applies. Each\n * reason maps to an operator-meaningful telemetry bucket — separated\n * deliberately so dashboards can distinguish \"client lost its secret\"\n * from \"client clock is wrong\" from \"this is a replay attack.\"\n */\nexport type VerifyResult =\n | { ok: true }\n | {\n ok: false;\n reason:\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n };\n\n/** Options for {@link verifyWebhook}. */\nexport type VerifyOptions = {\n /**\n * Maximum acceptable timestamp drift in either direction, in\n * seconds. Default: 300 (±5 minutes) — matches Stripe / GitHub /\n * Slack conventions. Tightening narrows the replay window;\n * loosening accommodates clients with worse clock sync.\n */\n maxAgeSeconds?: number;\n /**\n * Current Unix-seconds time. Exposed for tests; production\n * callers should leave it undefined so wall-clock is used.\n */\n now?: number;\n};\n\n/**\n * Verify an inbound webhook's signature and timestamp against the\n * shared secret. Pair with the sender side: configure\n * `webhook({ secret })` from `@rotorsoft/act-http/webhook`.\n *\n * Returns `{ ok: true }` on success or `{ ok: false; reason }` on\n * failure. The reasons are:\n *\n * - `missing-signature` — no `X-Webhook-Signature` header, value\n * was an array, or value was empty.\n * - `missing-timestamp` — no `X-Webhook-Timestamp` header, value\n * was empty, or value isn't a parseable integer.\n * - `stale` — timestamp older than `maxAgeSeconds` from `now`.\n * - `future` — timestamp more than `maxAgeSeconds` ahead of `now`.\n * - `bad-signature` — signature header didn't start with `sha256=`,\n * wasn't 64 hex chars, or the recomputed HMAC didn't match\n * (constant-time compare).\n *\n * The signed payload is `${timestamp}.${body}`, so `body` must be\n * the **raw request body bytes**. Any pre-parse normalization\n * (whitespace trimming, JSON re-stringification) would change the\n * hash and reject every otherwise-valid request. Framework adapters\n * in #744 will provide the raw body alongside the parsed one.\n *\n * Uses Node's `crypto.timingSafeEqual` for the final comparison to\n * avoid signature-equality timing attacks.\n */\nexport function verifyWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n secret: string,\n options?: VerifyOptions\n): VerifyResult {\n const maxAgeSeconds = options?.maxAgeSeconds ?? 300;\n const now = options?.now ?? Math.floor(Date.now() / 1000);\n\n const signature = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\n return { ok: false, reason: \"missing-timestamp\" };\n }\n\n const delta = now - timestamp;\n if (delta > maxAgeSeconds) return { ok: false, reason: \"stale\" };\n if (delta < -maxAgeSeconds) return { ok: false, reason: \"future\" };\n\n if (!signature.startsWith(\"sha256=\")) {\n return { ok: false, reason: \"bad-signature\" };\n }\n const provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) continue;\n if (Array.isArray(value) || value === undefined || value === \"\") {\n return undefined;\n }\n return value;\n }\n return undefined;\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport { extractIdempotencyKey } from \"./extract.js\";\nimport { type VerifyOptions, verifyWebhook } from \"./verify.js\";\n\n/**\n * Failure reasons returned by {@link checkWebhook}. The shape splits\n * client/configuration errors (`missing-key`, `empty-body` — HTTP 400)\n * from the five verification failures (authentication errors, HTTP 401)\n * so each maps to its own telemetry bucket. `empty-body` is the\n * misconfigured-raw-parser signal: `secret` is set but the resolved\n * body is empty, so hashing it would compute an HMAC over `${ts}.` and\n * reject every otherwise-valid signed request with a misleading\n * bad-signature — a distinct config error is far easier to diagnose.\n */\nexport type CheckFailureReason =\n | \"missing-key\"\n | \"empty-body\"\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n\n/**\n * Outcome of {@link checkWebhook}. Either the request passed every\n * configured check and carries a usable idempotency key, or it\n * failed one of them and the framework adapter should reply with the\n * corresponding HTTP status.\n */\nexport type CheckResult =\n | { ok: false; status: 400 | 401; reason: CheckFailureReason }\n | { ok: true; key: string; deduped: boolean };\n\n/** Options for {@link checkWebhook}. */\nexport type CheckWebhookOptions = {\n /** Idempotency store the framework-agnostic core claims the key on. */\n store: IdempotencyStore;\n /**\n * Optional HMAC-SHA256 secret. When set, the request's\n * `X-Webhook-Signature` and `X-Webhook-Timestamp` headers are\n * verified before the dedup claim. When omitted, signature\n * verification is skipped (unsigned receivers).\n */\n secret?: string;\n /**\n * Verification options forwarded to {@link verifyWebhook}. Only\n * meaningful when `secret` is set. Defaults to a ±300-second\n * timestamp window.\n */\n verify?: VerifyOptions;\n};\n\n/**\n * Framework-agnostic receiver check: verify the signature (when a\n * secret is configured), extract the `Idempotency-Key`, and claim\n * it on the store. Returns the request's fate as a discriminated\n * union the per-framework adapter translates into the framework's\n * idiomatic 4xx response or context injection.\n *\n * **Order of checks** (matters):\n *\n * 1. Verify signature + timestamp window (when `secret` is set).\n * Rejecting bad signatures *before* extracting and claiming the\n * key keeps attacker-supplied keys out of the dedup store —\n * otherwise a flood of spoofed POSTs would pollute the LRU.\n * 2. Extract the `Idempotency-Key`. Missing → reject with 400.\n * 3. Claim the key on the store. If already seen, return\n * `{ ok: true; deduped: true }` so the framework adapter can\n * short-circuit the handler without re-running side effects.\n *\n * The dedup store may be sync (`InMemoryIdempotencyStore`) or async\n * (durable adapters like a future `PostgresIdempotencyStore`); the\n * core awaits unconditionally so both shapes compose cleanly.\n */\nexport async function checkWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n options: CheckWebhookOptions\n): Promise<CheckResult> {\n if (options.secret !== undefined) {\n // Raw body not captured (default JSON parser ate the bytes) but a\n // secret is configured — hashing the empty string is a guaranteed\n // bad-signature. Surface a distinct configuration error instead of a\n // misleading 401 so the operator mounts the raw-body parser.\n if (body === \"\") {\n return { ok: false, status: 400, reason: \"empty-body\" };\n }\n const verification = verifyWebhook(\n headers,\n body,\n options.secret,\n options.verify\n );\n if (!verification.ok) {\n return { ok: false, status: 401, reason: verification.reason };\n }\n }\n\n const key = extractIdempotencyKey(headers);\n if (!key) return { ok: false, status: 400, reason: \"missing-key\" };\n\n const claimed = await options.store.claim(key);\n return { ok: true, key, deduped: !claimed };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport type {\n Receiver,\n ReceiverBuilder,\n ReceiverContext,\n ReceiverOptions,\n Validator,\n} from \"@rotorsoft/act-ops/receiver\";\nimport { Hono } from \"hono\";\nimport { webhookMiddleware } from \"./hono/index.js\";\n\n/**\n * Recommended factory for \"I want to receive webhooks.\" Returns a\n * {@link ReceiverBuilder} the operator configures fluently:\n *\n * ```ts\n * import { receiver } from \"@rotorsoft/act-http/receiver\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n * import { z } from \"zod\";\n *\n * const r = receiver({\n * port: 4001,\n * store: new InMemoryIdempotencyStore(),\n * secret: process.env.WEBHOOK_SECRET,\n * })\n * .on(\"OrderConfirmed\", z.object({\n * orderId: z.string(),\n * total: z.number(),\n * }), async (event, ctx) => {\n * // event.orderId and event.total are typed\n * // ctx.key is the deduplicated Idempotency-Key\n * await process_order(event.orderId, event.total);\n * })\n * .build();\n *\n * await r.listen();\n * ```\n *\n * Matches Act's builder pattern: `receiver(...)` is the factory,\n * `.on()` registers handlers fluently, `.build()` finalizes and\n * produces an immutable {@link Receiver} — at which point the type\n * loses `.on()` and gains the runtime methods (`listen` / `close` /\n * `fetch`). The lifecycle phases are split at the type level.\n *\n * Internally uses Hono for routing — the universal-runtime choice\n * that gives one code path coverage across Node, AWS Lambda,\n * Cloudflare Workers, Vercel Edge, Bun, and Deno. For operators\n * with an existing tRPC / Express / Fastify / Hono app who need to\n * compose the receiver with their own middleware stack, the\n * lower-level `webhookMiddleware` from\n * `@rotorsoft/act-http/receiver/<framework>` is the escape hatch.\n *\n * `@hono/node-server` is imported lazily inside `.listen()` so\n * Lambda / edge consumers (who never call `.listen()`) don't need\n * it installed.\n */\nexport function receiver(options: ReceiverOptions): ReceiverBuilder {\n const app = new Hono<{\n Variables: { idempotency: { key: string; deduped: boolean } };\n }>();\n\n // Keys whose winning delivery is still running in this receiver. A\n // duplicate that loses the claim is either behind a committed success\n // (safe to answer 204, \"stop retrying\") or behind an attempt whose\n // outcome is still unknown. In the second case a 204 would end the\n // sender's retries, and if the original then fails and releases, the\n // delivery is lost. The store can't tell the two apart, so the receiver\n // remembers which claims it won and hasn't finalized yet.\n const in_flight = new Set<string>();\n const middleware = webhookMiddleware({\n store: track_in_flight(options.store, in_flight),\n secret: options.secret,\n });\n\n let built = false;\n\n const builder: ReceiverBuilder = {\n on<T>(\n name: string,\n schema: Validator<T>,\n handler: (event: T, ctx: ReceiverContext) => Promise<void>\n ): ReceiverBuilder {\n if (built) {\n throw new Error(\n `Cannot register handler \"${name}\" after .build() — handlers are frozen once the receiver is built.`\n );\n }\n\n app.post(`/${name}`, middleware, async (c) => {\n let validated: T;\n try {\n const body = await c.req.json();\n validated = schema.parse(body);\n } catch (err) {\n return c.json(\n {\n error: \"validation-failed\",\n detail: (err as Error).message,\n },\n 422\n );\n }\n\n const idem = c.get(\"idempotency\");\n if (idem.deduped && in_flight.has(idem.key)) {\n // The original is still running and may yet fail. Ask the\n // sender to come back instead of telling it to stop: a 5xx is\n // retryable by every sender, including Act's webhook reaction.\n c.header(\"Retry-After\", \"1\");\n return c.json({ error: \"in-flight\" }, 503);\n }\n if (!idem.deduped) {\n try {\n await handler(validated, { key: idem.key });\n } catch (err) {\n // Transient failure: release the tentative claim so the\n // sender's retry re-processes instead of being deduped\n // into a silent success and permanently lost. Finalize through\n // the context's `settled`-guarded finalizer, NOT the raw store —\n // the `webhookMiddleware` this route mounts auto-finalizes after\n // `next()` too, and hitting the raw store here double-fires (a\n // stale second `release` can delete a concurrent retry's live\n // claim, breaking exactly-once). The guard collapses both into\n // one store call (#1293).\n await idem.release();\n return c.json(\n {\n error: \"handler-failed\",\n detail: (err as Error).message,\n },\n 500\n );\n }\n // Success: promote the tentative claim to a durable record so every\n // later retry of this key dedups — again via the guarded finalizer,\n // not the raw store (#1293).\n await idem.commit();\n }\n\n return c.body(null, 204);\n });\n\n return builder;\n },\n\n build(): Receiver {\n built = true;\n\n // `any`: server lifecycle handle from @hono/node-server\n let server: any | undefined;\n\n return {\n async listen(): Promise<void> {\n const { serve } = await import(\"@hono/node-server\");\n const launched = serve({ fetch: app.fetch, port: options.port });\n server = launched;\n await new Promise<void>((resolve) => {\n launched.once(\"listening\", () => resolve());\n });\n },\n\n async close(): Promise<void> {\n if (!server) return;\n const s = server;\n server = undefined;\n await new Promise<void>((resolve) => s.close(() => resolve()));\n },\n\n async fetch(request: Request): Promise<Response> {\n return app.fetch(request);\n },\n };\n },\n };\n\n return builder;\n}\n\n/**\n * Wraps the idempotency store so every claim this receiver wins is\n * recorded in `in_flight` as it resolves, and dropped only once its\n * commit or release has landed. Marking the key in the claim's own\n * continuation, rather than later in the route, leaves no gap in which a\n * duplicate could lose the claim yet find the key unmarked.\n */\nfunction track_in_flight(\n store: IdempotencyStore,\n in_flight: Set<string>\n): IdempotencyStore {\n const mark = (key: string, won: boolean) => {\n if (won) in_flight.add(key);\n return won;\n };\n return {\n claim(key, now) {\n const won = store.claim(key, now);\n return typeof won === \"boolean\"\n ? mark(key, won)\n : won.then((w) => mark(key, w));\n },\n async commit(key, now) {\n try {\n await store.commit(key, now);\n } finally {\n in_flight.delete(key);\n }\n },\n async release(key) {\n try {\n await store.release(key);\n } finally {\n in_flight.delete(key);\n }\n },\n };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * The two-phase finalizers a receiver adapter binds to a single\n * inbound delivery. The claim `checkWebhook` makes is *tentative*;\n * exactly one of these promotes or drops it once the business handler\n * resolves its outcome.\n */\nexport type Finalizers = {\n /**\n * Promote the tentative claim to a durable record so every later\n * retry of this key dedups. Call on handler success.\n */\n commit: () => void | Promise<void>;\n /**\n * Drop the tentative claim so the sender's retry re-processes.\n * Call on transient handler failure.\n */\n release: () => void | Promise<void>;\n};\n\n/**\n * Build the {@link Finalizers} for one delivery.\n *\n * Guarantees:\n *\n * - **Deduped deliveries are inert.** When `deduped` is `true` the key\n * was already committed by an earlier delivery; both finalizers are\n * no-ops so a duplicate can never release someone else's committed\n * claim.\n * - **Finalize-once.** After the first `commit` or `release` fires,\n * further calls are no-ops — an adapter that both auto-finalizes and\n * lets the operator call `commit()`/`release()` can't double-fire.\n */\nexport function make_finalizers(\n store: IdempotencyStore,\n key: string,\n deduped: boolean\n): Finalizers {\n let settled = deduped;\n return {\n async commit() {\n if (settled) return;\n settled = true;\n await store.commit(key);\n },\n async release() {\n if (settled) return;\n settled = true;\n await store.release(key);\n },\n };\n}\n","/**\n * @packageDocumentation\n * @module act-http/receiver/hono\n *\n * Hono adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import { Hono } from \"hono\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/hono\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = new Hono();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * async (c) => {\n * const idem = c.get(\"idempotency\") as { key: string; deduped: boolean };\n * if (idem.deduped) return c.json({ status: \"dedup-skipped\", key: idem.key });\n * // ... process the inbound event ...\n * return c.json({ status: \"processed\", key: idem.key });\n * }\n * );\n * ```\n *\n * On failure: returns `c.json({ error: <reason> }, status)` directly\n * (Hono short-circuits when middleware returns a Response). On\n * success: stashes `c.set(\"idempotency\", { key, deduped })` and\n * continues with `await next()`.\n *\n * **Two-phase dedup**: the claim `checkWebhook` makes is *tentative*.\n * Because Hono middleware wraps the downstream chain, this adapter\n * finalizes the claim automatically after `await next()`: a downstream\n * 2xx/3xx **commits** the key (later retries dedup); any **4xx or 5xx**\n * (or a thrown handler) **releases** it, so the sender's corrected retry\n * re-processes instead of being silently dropped (#1364). Fail-safe is\n * toward reprocessing: releasing a deterministic 4xx costs bounded\n * re-delivery, while committing it would lose a delivery the handler\n * never accepted (the shipped `receiver()` returns 422 on a schema\n * mismatch, where the handler never runs). Operators who need explicit\n * control can still call `c.get(\"idempotency\").commit()` / `.release()`.\n *\n * **Raw body**: Hono exposes `await c.req.text()` natively, which\n * the middleware reads when `secret` is configured. No extra setup\n * needed.\n */\nimport type { MiddlewareHandler } from \"hono\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { make_finalizers } from \"../finalize.js\";\n\n/**\n * Variables this middleware contributes to the Hono context. The\n * generic on the returned {@link MiddlewareHandler} threads it\n * through so route handlers downstream of `app.post(..., webhookMiddleware(...), handler)`\n * see `c.get(\"idempotency\")` typed without a manual cast.\n *\n * `commit` / `release` finalize the tentative claim — call one after\n * the handler resolves its outcome. This adapter also finalizes\n * automatically based on the response status, so explicit calls are\n * only needed when the auto-detection doesn't fit.\n */\nexport type WebhookVariables = {\n idempotency: {\n key: string;\n deduped: boolean;\n commit: () => void | Promise<void>;\n release: () => void | Promise<void>;\n };\n};\n\n/**\n * Build a Hono middleware that verifies the request signature (when\n * `secret` is set), enforces `Idempotency-Key`, and claims the key\n * on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): MiddlewareHandler<{ Variables: WebhookVariables }> {\n return async function check(c, next) {\n const headers = headers_bag(c.req.raw.headers);\n const rawBody = await c.req.text();\n const result = await checkWebhook(headers, rawBody, options);\n if (!result.ok) {\n return c.json({ error: result.reason }, result.status);\n }\n const { commit, release } = make_finalizers(\n options.store,\n result.key,\n result.deduped\n );\n c.set(\"idempotency\", {\n key: result.key,\n deduped: result.deduped,\n commit,\n release,\n });\n try {\n await next();\n } catch (err) {\n await release();\n throw err;\n }\n // Commit only a genuine success (2xx/3xx); release on any 4xx/5xx so a\n // failed delivery's corrected same-key retry re-processes instead of being\n // deduped into a silent success (#1364).\n if (c.res.status >= 400) await release();\n else await commit();\n };\n}\n\nfunction headers_bag(\n headers: Headers\n): Record<string, string | string[] | undefined> {\n const out: Record<string, string | string[] | undefined> = {};\n headers.forEach((value, key) => {\n out[key] = value;\n });\n return out;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/receiver/index.ts","../../src/receiver/extract.ts","../../src/receiver/verify.ts","../../src/receiver/check.ts","../../src/receiver/start.ts","../../src/api/in-flight.ts","../../src/receiver/finalize.ts","../../src/receiver/hono/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver\n *\n * Server-side helpers for the inbound HTTP role — the receiver that\n * sits on the other end of an `@rotorsoft/act-http/webhook` POST.\n *\n * The subpath hosts two primitives today:\n *\n * - {@link extractIdempotencyKey} — case-insensitive\n * `Idempotency-Key` parser; pair with `IdempotencyStore.claim`\n * from `@rotorsoft/act-ops/idempotency` for dedup.\n * - {@link verifyWebhook} — HMAC-SHA256 signature + timestamp\n * verifier; pair with `webhook({ secret })` from\n * `@rotorsoft/act-http/webhook` for authenticated, replay-resistant\n * delivery.\n *\n * The framework-agnostic middleware that wires these into request\n * handlers, plus per-framework adapters (tRPC / Express / Fastify /\n * Hono), lands in #744 (ACT-1116).\n *\n * Sibling subpaths in the same package:\n *\n * - `@rotorsoft/act-http/webhook` — the sender side: outbound POSTs,\n * automatic `Idempotency-Key`, status-classified retries, optional\n * HMAC signing.\n * - `@rotorsoft/act-http/sse` — incremental state broadcast over\n * Server-Sent Events.\n *\n * The receiver subpath ships from the same package as `/webhook` so\n * a service that both sends and receives webhooks installs one\n * dependency. The dedup contract that links them lives in\n * `@rotorsoft/act-ops/idempotency`.\n */\n\nexport {\n type CheckFailureReason,\n type CheckResult,\n type CheckWebhookOptions,\n checkWebhook,\n} from \"./check.js\";\nexport { extractIdempotencyKey } from \"./extract.js\";\nexport { receiver } from \"./start.js\";\nexport {\n type VerifyOptions,\n type VerifyResult,\n verifyWebhook,\n} from \"./verify.js\";\n","/**\n * Pull the `Idempotency-Key` header from a Node-style headers bag,\n * case-insensitive. Returns `undefined` when any of the following\n * carries no usable key:\n *\n * - the header is missing\n * - its value is an array (ambiguous — can't pick one without a\n * policy the receiver hasn't declared)\n * - its value is the empty string (carries no idempotency\n * information; structurally equivalent to \"no header at all\")\n *\n * Pair with `IdempotencyStore.claim` from\n * `@rotorsoft/act-ops/idempotency`: extract the key from the inbound\n * request, claim it on the store, return a `deduped` marker when the\n * claim fails. The framework-agnostic middleware that wires these\n * together lands in #744.\n *\n * Validation beyond \"is there a usable key?\" (length bounds, format\n * checks, normalization) is intentionally out of scope. Receivers\n * picking a policy can layer it on top — or, when #744 ships, opt\n * into the middleware's opinionated defaults.\n */\nexport function extractIdempotencyKey(\n headers: Record<string, string | string[] | undefined>\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== \"idempotency-key\") continue;\n if (Array.isArray(value)) return undefined;\n if (value === \"\") return undefined;\n return value;\n }\n return undefined;\n}\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Outcome of {@link verifyWebhook}. Either the request signature\n * checks out, or one of five distinct failure reasons applies. Each\n * reason maps to an operator-meaningful telemetry bucket — separated\n * deliberately so dashboards can distinguish \"client lost its secret\"\n * from \"client clock is wrong\" from \"this is a replay attack.\"\n */\nexport type VerifyResult =\n | { ok: true }\n | {\n ok: false;\n reason:\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n };\n\n/** Options for {@link verifyWebhook}. */\nexport type VerifyOptions = {\n /**\n * Maximum acceptable timestamp drift in either direction, in\n * seconds. Default: 300 (±5 minutes) — matches Stripe / GitHub /\n * Slack conventions. Tightening narrows the replay window;\n * loosening accommodates clients with worse clock sync.\n */\n maxAgeSeconds?: number;\n /**\n * Current Unix-seconds time. Exposed for tests; production\n * callers should leave it undefined so wall-clock is used.\n */\n now?: number;\n};\n\n/**\n * Verify an inbound webhook's signature and timestamp against the\n * shared secret. Pair with the sender side: configure\n * `webhook({ secret })` from `@rotorsoft/act-http/webhook`.\n *\n * Returns `{ ok: true }` on success or `{ ok: false; reason }` on\n * failure. The reasons are:\n *\n * - `missing-signature` — no `X-Webhook-Signature` header, value\n * was an array, or value was empty.\n * - `missing-timestamp` — no `X-Webhook-Timestamp` header, value\n * was empty, or value isn't a parseable integer.\n * - `stale` — timestamp older than `maxAgeSeconds` from `now`.\n * - `future` — timestamp more than `maxAgeSeconds` ahead of `now`.\n * - `bad-signature` — signature header didn't start with `sha256=`,\n * wasn't 64 hex chars, or the recomputed HMAC didn't match\n * (constant-time compare).\n *\n * The signed payload is `${timestamp}.${body}`, so `body` must be\n * the **raw request body bytes**. Any pre-parse normalization\n * (whitespace trimming, JSON re-stringification) would change the\n * hash and reject every otherwise-valid request. Framework adapters\n * in #744 will provide the raw body alongside the parsed one.\n *\n * Uses Node's `crypto.timingSafeEqual` for the final comparison to\n * avoid signature-equality timing attacks.\n */\nexport function verifyWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n secret: string,\n options?: VerifyOptions\n): VerifyResult {\n const maxAgeSeconds = options?.maxAgeSeconds ?? 300;\n const now = options?.now ?? Math.floor(Date.now() / 1000);\n\n const signature = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\n return { ok: false, reason: \"missing-timestamp\" };\n }\n\n const delta = now - timestamp;\n if (delta > maxAgeSeconds) return { ok: false, reason: \"stale\" };\n if (delta < -maxAgeSeconds) return { ok: false, reason: \"future\" };\n\n if (!signature.startsWith(\"sha256=\")) {\n return { ok: false, reason: \"bad-signature\" };\n }\n const provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) continue;\n if (Array.isArray(value) || value === undefined || value === \"\") {\n return undefined;\n }\n return value;\n }\n return undefined;\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport { extractIdempotencyKey } from \"./extract.js\";\nimport { type VerifyOptions, verifyWebhook } from \"./verify.js\";\n\n/**\n * Failure reasons returned by {@link checkWebhook}. The shape splits\n * client/configuration errors (`missing-key`, `empty-body` — HTTP 400)\n * from the five verification failures (authentication errors, HTTP 401)\n * so each maps to its own telemetry bucket. `empty-body` is the\n * misconfigured-raw-parser signal: `secret` is set but the resolved\n * body is empty, so hashing it would compute an HMAC over `${ts}.` and\n * reject every otherwise-valid signed request with a misleading\n * bad-signature — a distinct config error is far easier to diagnose.\n */\nexport type CheckFailureReason =\n | \"missing-key\"\n | \"empty-body\"\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n\n/**\n * Outcome of {@link checkWebhook}. Either the request passed every\n * configured check and carries a usable idempotency key, or it\n * failed one of them and the framework adapter should reply with the\n * corresponding HTTP status.\n */\nexport type CheckResult =\n | { ok: false; status: 400 | 401; reason: CheckFailureReason }\n | { ok: true; key: string; deduped: boolean };\n\n/** Options for {@link checkWebhook}. */\nexport type CheckWebhookOptions = {\n /** Idempotency store the framework-agnostic core claims the key on. */\n store: IdempotencyStore;\n /**\n * Optional HMAC-SHA256 secret. When set, the request's\n * `X-Webhook-Signature` and `X-Webhook-Timestamp` headers are\n * verified before the dedup claim. When omitted, signature\n * verification is skipped (unsigned receivers).\n */\n secret?: string;\n /**\n * Verification options forwarded to {@link verifyWebhook}. Only\n * meaningful when `secret` is set. Defaults to a ±300-second\n * timestamp window.\n */\n verify?: VerifyOptions;\n};\n\n/**\n * Framework-agnostic receiver check: verify the signature (when a\n * secret is configured), extract the `Idempotency-Key`, and claim\n * it on the store. Returns the request's fate as a discriminated\n * union the per-framework adapter translates into the framework's\n * idiomatic 4xx response or context injection.\n *\n * **Order of checks** (matters):\n *\n * 1. Verify signature + timestamp window (when `secret` is set).\n * Rejecting bad signatures *before* extracting and claiming the\n * key keeps attacker-supplied keys out of the dedup store —\n * otherwise a flood of spoofed POSTs would pollute the LRU.\n * 2. Extract the `Idempotency-Key`. Missing → reject with 400.\n * 3. Claim the key on the store. If already seen, return\n * `{ ok: true; deduped: true }` so the framework adapter can\n * short-circuit the handler without re-running side effects.\n *\n * The dedup store may be sync (`InMemoryIdempotencyStore`) or async\n * (durable adapters like a future `PostgresIdempotencyStore`); the\n * core awaits unconditionally so both shapes compose cleanly.\n */\nexport async function checkWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n options: CheckWebhookOptions\n): Promise<CheckResult> {\n if (options.secret !== undefined) {\n // Raw body not captured (default JSON parser ate the bytes) but a\n // secret is configured — hashing the empty string is a guaranteed\n // bad-signature. Surface a distinct configuration error instead of a\n // misleading 401 so the operator mounts the raw-body parser.\n if (body === \"\") {\n return { ok: false, status: 400, reason: \"empty-body\" };\n }\n const verification = verifyWebhook(\n headers,\n body,\n options.secret,\n options.verify\n );\n if (!verification.ok) {\n return { ok: false, status: 401, reason: verification.reason };\n }\n }\n\n const key = extractIdempotencyKey(headers);\n if (!key) return { ok: false, status: 400, reason: \"missing-key\" };\n\n const claimed = await options.store.claim(key);\n return { ok: true, key, deduped: !claimed };\n}\n","import type {\n Receiver,\n ReceiverBuilder,\n ReceiverContext,\n ReceiverOptions,\n Validator,\n} from \"@rotorsoft/act-ops/receiver\";\nimport { Hono } from \"hono\";\nimport { track_in_flight } from \"../api/in-flight.js\";\nimport { webhookMiddleware } from \"./hono/index.js\";\n\n/**\n * Recommended factory for \"I want to receive webhooks.\" Returns a\n * {@link ReceiverBuilder} the operator configures fluently:\n *\n * ```ts\n * import { receiver } from \"@rotorsoft/act-http/receiver\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n * import { z } from \"zod\";\n *\n * const r = receiver({\n * port: 4001,\n * store: new InMemoryIdempotencyStore(),\n * secret: process.env.WEBHOOK_SECRET,\n * })\n * .on(\"OrderConfirmed\", z.object({\n * orderId: z.string(),\n * total: z.number(),\n * }), async (event, ctx) => {\n * // event.orderId and event.total are typed\n * // ctx.key is the deduplicated Idempotency-Key\n * await process_order(event.orderId, event.total);\n * })\n * .build();\n *\n * await r.listen();\n * ```\n *\n * Matches Act's builder pattern: `receiver(...)` is the factory,\n * `.on()` registers handlers fluently, `.build()` finalizes and\n * produces an immutable {@link Receiver} — at which point the type\n * loses `.on()` and gains the runtime methods (`listen` / `close` /\n * `fetch`). The lifecycle phases are split at the type level.\n *\n * Internally uses Hono for routing — the universal-runtime choice\n * that gives one code path coverage across Node, AWS Lambda,\n * Cloudflare Workers, Vercel Edge, Bun, and Deno. For operators\n * with an existing tRPC / Express / Fastify / Hono app who need to\n * compose the receiver with their own middleware stack, the\n * lower-level `webhookMiddleware` from\n * `@rotorsoft/act-http/receiver/<framework>` is the escape hatch.\n *\n * `@hono/node-server` is imported lazily inside `.listen()` so\n * Lambda / edge consumers (who never call `.listen()`) don't need\n * it installed.\n */\nexport function receiver(options: ReceiverOptions): ReceiverBuilder {\n const app = new Hono<{\n Variables: { idempotency: { key: string; deduped: boolean } };\n }>();\n\n // Keys whose winning delivery is still running in this receiver. A\n // duplicate that loses the claim is either behind a committed success\n // (safe to answer 204, \"stop retrying\") or behind an attempt whose\n // outcome is still unknown. In the second case a 204 would end the\n // sender's retries, and if the original then fails and releases, the\n // delivery is lost. The store can't tell the two apart, so the receiver\n // remembers which claims it won and hasn't finalized yet.\n const tracker = track_in_flight(options.store);\n const middleware = webhookMiddleware({\n store: tracker.store,\n secret: options.secret,\n });\n\n let built = false;\n\n const builder: ReceiverBuilder = {\n on<T>(\n name: string,\n schema: Validator<T>,\n handler: (event: T, ctx: ReceiverContext) => Promise<void>\n ): ReceiverBuilder {\n if (built) {\n throw new Error(\n `Cannot register handler \"${name}\" after .build() — handlers are frozen once the receiver is built.`\n );\n }\n\n app.post(`/${name}`, middleware, async (c) => {\n let validated: T;\n try {\n const body = await c.req.json();\n validated = schema.parse(body);\n } catch (err) {\n return c.json(\n {\n error: \"validation-failed\",\n detail: (err as Error).message,\n },\n 422\n );\n }\n\n const idem = c.get(\"idempotency\");\n if (idem.deduped && tracker.in_flight(idem.key)) {\n // The original is still running and may yet fail. Ask the\n // sender to come back instead of telling it to stop: a 5xx is\n // retryable by every sender, including Act's webhook reaction.\n c.header(\"Retry-After\", \"1\");\n return c.json({ error: \"in-flight\" }, 503);\n }\n if (!idem.deduped) {\n try {\n await handler(validated, { key: idem.key });\n } catch (err) {\n // Transient failure: release the tentative claim so the\n // sender's retry re-processes instead of being deduped\n // into a silent success and permanently lost. Finalize through\n // the context's `settled`-guarded finalizer, NOT the raw store —\n // the `webhookMiddleware` this route mounts auto-finalizes after\n // `next()` too, and hitting the raw store here double-fires (a\n // stale second `release` can delete a concurrent retry's live\n // claim, breaking exactly-once). The guard collapses both into\n // one store call (#1293).\n await idem.release();\n return c.json(\n {\n error: \"handler-failed\",\n detail: (err as Error).message,\n },\n 500\n );\n }\n // Success: promote the tentative claim to a durable record so every\n // later retry of this key dedups — again via the guarded finalizer,\n // not the raw store (#1293).\n await idem.commit();\n }\n\n return c.body(null, 204);\n });\n\n return builder;\n },\n\n build(): Receiver {\n built = true;\n\n // `any`: server lifecycle handle from @hono/node-server\n let server: any | undefined;\n\n return {\n async listen(): Promise<void> {\n const { serve } = await import(\"@hono/node-server\");\n const launched = serve({ fetch: app.fetch, port: options.port });\n server = launched;\n await new Promise<void>((resolve) => {\n launched.once(\"listening\", () => resolve());\n });\n },\n\n async close(): Promise<void> {\n if (!server) return;\n const s = server;\n server = undefined;\n await new Promise<void>((resolve) => s.close(() => resolve()));\n },\n\n async fetch(request: Request): Promise<Response> {\n return app.fetch(request);\n },\n };\n },\n };\n\n return builder;\n}\n","/**\n * @module act-http/api/in-flight\n *\n * Tells a duplicate of a delivery that is still running apart from a\n * duplicate of one that already succeeded. An {@link IdempotencyStore}\n * can't: its `claim` returns `false` in both cases. Answering the first\n * \"accepted, stop retrying\" loses the delivery if the original then fails\n * and releases the key, so the receiver and the generated API both ask\n * the sender to retry while the original is in flight.\n *\n * Internal — not re-exported from the package. Process-local, which\n * matches `InMemoryIdempotencyStore`'s single-process scope.\n */\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/** A wrapped store plus the in-flight check it maintains. @internal */\nexport type InFlightTracker = {\n /** Pass this to the code that claims, commits, and releases. */\n readonly store: IdempotencyStore;\n /**\n * True while a claim on `key` is pending, or was won and is not yet\n * committed or released. Read it after your own claim lost.\n */\n in_flight(key: string): boolean;\n};\n\n/**\n * Wraps `store` so claims are counted per key. A key is counted **before**\n * the claim is awaited, not when its reply arrives: with an async store\n * the loser's `false` can arrive before the winner's `true`, and marking\n * only on the winner's reply would leave the loser a window to see the key\n * unmarked and be answered as a settled duplicate. A claim that loses (or\n * throws) uncounts itself at once; a claim that wins stays counted until\n * its commit or release lands.\n *\n * A concurrent duplicate of an already-committed key can therefore read\n * as in flight while another duplicate's claim is pending. That costs one\n * extra retry, never a lost delivery.\n *\n * @internal\n */\nexport function track_in_flight(store: IdempotencyStore): InFlightTracker {\n const pending = new Map<string, number>();\n const up = (key: string) => pending.set(key, (pending.get(key) ?? 0) + 1);\n const down = (key: string) => {\n const left = (pending.get(key) ?? 0) - 1;\n if (left > 0) pending.set(key, left);\n else pending.delete(key);\n };\n const settle = (key: string, won: boolean) => {\n if (!won) down(key);\n return won;\n };\n return {\n in_flight: (key) => pending.has(key),\n store: {\n claim(key, now) {\n up(key);\n let won: boolean | Promise<boolean>;\n try {\n won = store.claim(key, now);\n } catch (err) {\n down(key);\n throw err;\n }\n return typeof won === \"boolean\"\n ? settle(key, won)\n : won.then(\n (w) => settle(key, w),\n (err: unknown) => {\n down(key);\n throw err;\n }\n );\n },\n async commit(key, now) {\n try {\n await store.commit(key, now);\n } finally {\n down(key);\n }\n },\n async release(key) {\n try {\n await store.release(key);\n } finally {\n down(key);\n }\n },\n },\n };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * The two-phase finalizers a receiver adapter binds to a single\n * inbound delivery. The claim `checkWebhook` makes is *tentative*;\n * exactly one of these promotes or drops it once the business handler\n * resolves its outcome.\n */\nexport type Finalizers = {\n /**\n * Promote the tentative claim to a durable record so every later\n * retry of this key dedups. Call on handler success.\n */\n commit: () => void | Promise<void>;\n /**\n * Drop the tentative claim so the sender's retry re-processes.\n * Call on transient handler failure.\n */\n release: () => void | Promise<void>;\n};\n\n/**\n * Build the {@link Finalizers} for one delivery.\n *\n * Guarantees:\n *\n * - **Deduped deliveries are inert.** When `deduped` is `true` the key\n * was already committed by an earlier delivery; both finalizers are\n * no-ops so a duplicate can never release someone else's committed\n * claim.\n * - **Finalize-once.** After the first `commit` or `release` fires,\n * further calls are no-ops — an adapter that both auto-finalizes and\n * lets the operator call `commit()`/`release()` can't double-fire.\n */\nexport function make_finalizers(\n store: IdempotencyStore,\n key: string,\n deduped: boolean\n): Finalizers {\n let settled = deduped;\n return {\n async commit() {\n if (settled) return;\n settled = true;\n await store.commit(key);\n },\n async release() {\n if (settled) return;\n settled = true;\n await store.release(key);\n },\n };\n}\n","/**\n * @packageDocumentation\n * @module act-http/receiver/hono\n *\n * Hono adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import { Hono } from \"hono\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/hono\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = new Hono();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * async (c) => {\n * const idem = c.get(\"idempotency\") as { key: string; deduped: boolean };\n * if (idem.deduped) return c.json({ status: \"dedup-skipped\", key: idem.key });\n * // ... process the inbound event ...\n * return c.json({ status: \"processed\", key: idem.key });\n * }\n * );\n * ```\n *\n * On failure: returns `c.json({ error: <reason> }, status)` directly\n * (Hono short-circuits when middleware returns a Response). On\n * success: stashes `c.set(\"idempotency\", { key, deduped })` and\n * continues with `await next()`.\n *\n * **Two-phase dedup**: the claim `checkWebhook` makes is *tentative*.\n * Because Hono middleware wraps the downstream chain, this adapter\n * finalizes the claim automatically after `await next()`: a downstream\n * 2xx/3xx **commits** the key (later retries dedup); any **4xx or 5xx**\n * (or a thrown handler) **releases** it, so the sender's corrected retry\n * re-processes instead of being silently dropped (#1364). Fail-safe is\n * toward reprocessing: releasing a deterministic 4xx costs bounded\n * re-delivery, while committing it would lose a delivery the handler\n * never accepted (the shipped `receiver()` returns 422 on a schema\n * mismatch, where the handler never runs). Operators who need explicit\n * control can still call `c.get(\"idempotency\").commit()` / `.release()`.\n *\n * **Raw body**: Hono exposes `await c.req.text()` natively, which\n * the middleware reads when `secret` is configured. No extra setup\n * needed.\n */\nimport type { MiddlewareHandler } from \"hono\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\nimport { make_finalizers } from \"../finalize.js\";\n\n/**\n * Variables this middleware contributes to the Hono context. The\n * generic on the returned {@link MiddlewareHandler} threads it\n * through so route handlers downstream of `app.post(..., webhookMiddleware(...), handler)`\n * see `c.get(\"idempotency\")` typed without a manual cast.\n *\n * `commit` / `release` finalize the tentative claim — call one after\n * the handler resolves its outcome. This adapter also finalizes\n * automatically based on the response status, so explicit calls are\n * only needed when the auto-detection doesn't fit.\n */\nexport type WebhookVariables = {\n idempotency: {\n key: string;\n deduped: boolean;\n commit: () => void | Promise<void>;\n release: () => void | Promise<void>;\n };\n};\n\n/**\n * Build a Hono middleware that verifies the request signature (when\n * `secret` is set), enforces `Idempotency-Key`, and claims the key\n * on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): MiddlewareHandler<{ Variables: WebhookVariables }> {\n return async function check(c, next) {\n const headers = headers_bag(c.req.raw.headers);\n const rawBody = await c.req.text();\n const result = await checkWebhook(headers, rawBody, options);\n if (!result.ok) {\n return c.json({ error: result.reason }, result.status);\n }\n const { commit, release } = make_finalizers(\n options.store,\n result.key,\n result.deduped\n );\n c.set(\"idempotency\", {\n key: result.key,\n deduped: result.deduped,\n commit,\n release,\n });\n try {\n await next();\n } catch (err) {\n await release();\n throw err;\n }\n // Commit only a genuine success (2xx/3xx); release on any 4xx/5xx so a\n // failed delivery's corrected same-key retry re-processes instead of being\n // deduped into a silent success (#1364).\n if (c.res.status >= 400) await release();\n else await commit();\n };\n}\n\nfunction headers_bag(\n headers: Headers\n): Record<string, string | string[] | undefined> {\n const out: Record<string, string | string[] | undefined> = {};\n headers.forEach((value, key) => {\n out[key] = value;\n });\n return out;\n}\n"],"mappings":"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","names":[]}
|
package/dist/receiver/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
webhookMiddleware
|
|
3
3
|
} from "../chunk-KC2NW7XQ.js";
|
|
4
|
+
import {
|
|
5
|
+
track_in_flight
|
|
6
|
+
} from "../chunk-FXVWAQ2D.js";
|
|
4
7
|
import {
|
|
5
8
|
checkWebhook,
|
|
6
9
|
extractIdempotencyKey,
|
|
@@ -12,9 +15,9 @@ import "../chunk-PZ5AY32C.js";
|
|
|
12
15
|
import { Hono } from "hono";
|
|
13
16
|
function receiver(options) {
|
|
14
17
|
const app = new Hono();
|
|
15
|
-
const
|
|
18
|
+
const tracker = track_in_flight(options.store);
|
|
16
19
|
const middleware = webhookMiddleware({
|
|
17
|
-
store:
|
|
20
|
+
store: tracker.store,
|
|
18
21
|
secret: options.secret
|
|
19
22
|
});
|
|
20
23
|
let built = false;
|
|
@@ -40,7 +43,7 @@ function receiver(options) {
|
|
|
40
43
|
);
|
|
41
44
|
}
|
|
42
45
|
const idem = c.get("idempotency");
|
|
43
|
-
if (idem.deduped && in_flight
|
|
46
|
+
if (idem.deduped && tracker.in_flight(idem.key)) {
|
|
44
47
|
c.header("Retry-After", "1");
|
|
45
48
|
return c.json({ error: "in-flight" }, 503);
|
|
46
49
|
}
|
|
@@ -89,32 +92,6 @@ function receiver(options) {
|
|
|
89
92
|
};
|
|
90
93
|
return builder;
|
|
91
94
|
}
|
|
92
|
-
function track_in_flight(store, in_flight) {
|
|
93
|
-
const mark = (key, won) => {
|
|
94
|
-
if (won) in_flight.add(key);
|
|
95
|
-
return won;
|
|
96
|
-
};
|
|
97
|
-
return {
|
|
98
|
-
claim(key, now) {
|
|
99
|
-
const won = store.claim(key, now);
|
|
100
|
-
return typeof won === "boolean" ? mark(key, won) : won.then((w) => mark(key, w));
|
|
101
|
-
},
|
|
102
|
-
async commit(key, now) {
|
|
103
|
-
try {
|
|
104
|
-
await store.commit(key, now);
|
|
105
|
-
} finally {
|
|
106
|
-
in_flight.delete(key);
|
|
107
|
-
}
|
|
108
|
-
},
|
|
109
|
-
async release(key) {
|
|
110
|
-
try {
|
|
111
|
-
await store.release(key);
|
|
112
|
-
} finally {
|
|
113
|
-
in_flight.delete(key);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
95
|
export {
|
|
119
96
|
checkWebhook,
|
|
120
97
|
extractIdempotencyKey,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/receiver/start.ts"],"sourcesContent":["import type {
|
|
1
|
+
{"version":3,"sources":["../../src/receiver/start.ts"],"sourcesContent":["import type {\n Receiver,\n ReceiverBuilder,\n ReceiverContext,\n ReceiverOptions,\n Validator,\n} from \"@rotorsoft/act-ops/receiver\";\nimport { Hono } from \"hono\";\nimport { track_in_flight } from \"../api/in-flight.js\";\nimport { webhookMiddleware } from \"./hono/index.js\";\n\n/**\n * Recommended factory for \"I want to receive webhooks.\" Returns a\n * {@link ReceiverBuilder} the operator configures fluently:\n *\n * ```ts\n * import { receiver } from \"@rotorsoft/act-http/receiver\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n * import { z } from \"zod\";\n *\n * const r = receiver({\n * port: 4001,\n * store: new InMemoryIdempotencyStore(),\n * secret: process.env.WEBHOOK_SECRET,\n * })\n * .on(\"OrderConfirmed\", z.object({\n * orderId: z.string(),\n * total: z.number(),\n * }), async (event, ctx) => {\n * // event.orderId and event.total are typed\n * // ctx.key is the deduplicated Idempotency-Key\n * await process_order(event.orderId, event.total);\n * })\n * .build();\n *\n * await r.listen();\n * ```\n *\n * Matches Act's builder pattern: `receiver(...)` is the factory,\n * `.on()` registers handlers fluently, `.build()` finalizes and\n * produces an immutable {@link Receiver} — at which point the type\n * loses `.on()` and gains the runtime methods (`listen` / `close` /\n * `fetch`). The lifecycle phases are split at the type level.\n *\n * Internally uses Hono for routing — the universal-runtime choice\n * that gives one code path coverage across Node, AWS Lambda,\n * Cloudflare Workers, Vercel Edge, Bun, and Deno. For operators\n * with an existing tRPC / Express / Fastify / Hono app who need to\n * compose the receiver with their own middleware stack, the\n * lower-level `webhookMiddleware` from\n * `@rotorsoft/act-http/receiver/<framework>` is the escape hatch.\n *\n * `@hono/node-server` is imported lazily inside `.listen()` so\n * Lambda / edge consumers (who never call `.listen()`) don't need\n * it installed.\n */\nexport function receiver(options: ReceiverOptions): ReceiverBuilder {\n const app = new Hono<{\n Variables: { idempotency: { key: string; deduped: boolean } };\n }>();\n\n // Keys whose winning delivery is still running in this receiver. A\n // duplicate that loses the claim is either behind a committed success\n // (safe to answer 204, \"stop retrying\") or behind an attempt whose\n // outcome is still unknown. In the second case a 204 would end the\n // sender's retries, and if the original then fails and releases, the\n // delivery is lost. The store can't tell the two apart, so the receiver\n // remembers which claims it won and hasn't finalized yet.\n const tracker = track_in_flight(options.store);\n const middleware = webhookMiddleware({\n store: tracker.store,\n secret: options.secret,\n });\n\n let built = false;\n\n const builder: ReceiverBuilder = {\n on<T>(\n name: string,\n schema: Validator<T>,\n handler: (event: T, ctx: ReceiverContext) => Promise<void>\n ): ReceiverBuilder {\n if (built) {\n throw new Error(\n `Cannot register handler \"${name}\" after .build() — handlers are frozen once the receiver is built.`\n );\n }\n\n app.post(`/${name}`, middleware, async (c) => {\n let validated: T;\n try {\n const body = await c.req.json();\n validated = schema.parse(body);\n } catch (err) {\n return c.json(\n {\n error: \"validation-failed\",\n detail: (err as Error).message,\n },\n 422\n );\n }\n\n const idem = c.get(\"idempotency\");\n if (idem.deduped && tracker.in_flight(idem.key)) {\n // The original is still running and may yet fail. Ask the\n // sender to come back instead of telling it to stop: a 5xx is\n // retryable by every sender, including Act's webhook reaction.\n c.header(\"Retry-After\", \"1\");\n return c.json({ error: \"in-flight\" }, 503);\n }\n if (!idem.deduped) {\n try {\n await handler(validated, { key: idem.key });\n } catch (err) {\n // Transient failure: release the tentative claim so the\n // sender's retry re-processes instead of being deduped\n // into a silent success and permanently lost. Finalize through\n // the context's `settled`-guarded finalizer, NOT the raw store —\n // the `webhookMiddleware` this route mounts auto-finalizes after\n // `next()` too, and hitting the raw store here double-fires (a\n // stale second `release` can delete a concurrent retry's live\n // claim, breaking exactly-once). The guard collapses both into\n // one store call (#1293).\n await idem.release();\n return c.json(\n {\n error: \"handler-failed\",\n detail: (err as Error).message,\n },\n 500\n );\n }\n // Success: promote the tentative claim to a durable record so every\n // later retry of this key dedups — again via the guarded finalizer,\n // not the raw store (#1293).\n await idem.commit();\n }\n\n return c.body(null, 204);\n });\n\n return builder;\n },\n\n build(): Receiver {\n built = true;\n\n // `any`: server lifecycle handle from @hono/node-server\n let server: any | undefined;\n\n return {\n async listen(): Promise<void> {\n const { serve } = await import(\"@hono/node-server\");\n const launched = serve({ fetch: app.fetch, port: options.port });\n server = launched;\n await new Promise<void>((resolve) => {\n launched.once(\"listening\", () => resolve());\n });\n },\n\n async close(): Promise<void> {\n if (!server) return;\n const s = server;\n server = undefined;\n await new Promise<void>((resolve) => s.close(() => resolve()));\n },\n\n async fetch(request: Request): Promise<Response> {\n return app.fetch(request);\n },\n };\n },\n };\n\n return builder;\n}\n"],"mappings":";;;;;;;;;;;;;;AAOA,SAAS,YAAY;AAiDd,SAAS,SAAS,SAA2C;AAClE,QAAM,MAAM,IAAI,KAEb;AASH,QAAM,UAAU,gBAAgB,QAAQ,KAAK;AAC7C,QAAM,aAAa,kBAAkB;AAAA,IACnC,OAAO,QAAQ;AAAA,IACf,QAAQ,QAAQ;AAAA,EAClB,CAAC;AAED,MAAI,QAAQ;AAEZ,QAAM,UAA2B;AAAA,IAC/B,GACE,MACA,QACA,SACiB;AACjB,UAAI,OAAO;AACT,cAAM,IAAI;AAAA,UACR,4BAA4B,IAAI;AAAA,QAClC;AAAA,MACF;AAEA,UAAI,KAAK,IAAI,IAAI,IAAI,YAAY,OAAO,MAAM;AAC5C,YAAI;AACJ,YAAI;AACF,gBAAM,OAAO,MAAM,EAAE,IAAI,KAAK;AAC9B,sBAAY,OAAO,MAAM,IAAI;AAAA,QAC/B,SAAS,KAAK;AACZ,iBAAO,EAAE;AAAA,YACP;AAAA,cACE,OAAO;AAAA,cACP,QAAS,IAAc;AAAA,YACzB;AAAA,YACA;AAAA,UACF;AAAA,QACF;AAEA,cAAM,OAAO,EAAE,IAAI,aAAa;AAChC,YAAI,KAAK,WAAW,QAAQ,UAAU,KAAK,GAAG,GAAG;AAI/C,YAAE,OAAO,eAAe,GAAG;AAC3B,iBAAO,EAAE,KAAK,EAAE,OAAO,YAAY,GAAG,GAAG;AAAA,QAC3C;AACA,YAAI,CAAC,KAAK,SAAS;AACjB,cAAI;AACF,kBAAM,QAAQ,WAAW,EAAE,KAAK,KAAK,IAAI,CAAC;AAAA,UAC5C,SAAS,KAAK;AAUZ,kBAAM,KAAK,QAAQ;AACnB,mBAAO,EAAE;AAAA,cACP;AAAA,gBACE,OAAO;AAAA,gBACP,QAAS,IAAc;AAAA,cACzB;AAAA,cACA;AAAA,YACF;AAAA,UACF;AAIA,gBAAM,KAAK,OAAO;AAAA,QACpB;AAEA,eAAO,EAAE,KAAK,MAAM,GAAG;AAAA,MACzB,CAAC;AAED,aAAO;AAAA,IACT;AAAA,IAEA,QAAkB;AAChB,cAAQ;AAGR,UAAI;AAEJ,aAAO;AAAA,QACL,MAAM,SAAwB;AAC5B,gBAAM,EAAE,MAAM,IAAI,MAAM,OAAO,mBAAmB;AAClD,gBAAM,WAAW,MAAM,EAAE,OAAO,IAAI,OAAO,MAAM,QAAQ,KAAK,CAAC;AAC/D,mBAAS;AACT,gBAAM,IAAI,QAAc,CAAC,YAAY;AACnC,qBAAS,KAAK,aAAa,MAAM,QAAQ,CAAC;AAAA,UAC5C,CAAC;AAAA,QACH;AAAA,QAEA,MAAM,QAAuB;AAC3B,cAAI,CAAC,OAAQ;AACb,gBAAM,IAAI;AACV,mBAAS;AACT,gBAAM,IAAI,QAAc,CAAC,YAAY,EAAE,MAAM,MAAM,QAAQ,CAAC,CAAC;AAAA,QAC/D;AAAA,QAEA,MAAM,MAAM,SAAqC;AAC/C,iBAAO,IAAI,MAAM,OAAO;AAAA,QAC1B;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
|
package/dist/trpc/index.cjs
CHANGED
|
@@ -19695,6 +19695,57 @@ function date4(params) {
|
|
|
19695
19695
|
return _coercedDate(ZodDate, params);
|
|
19696
19696
|
}
|
|
19697
19697
|
|
|
19698
|
+
// src/api/in-flight.ts
|
|
19699
|
+
function track_in_flight(store) {
|
|
19700
|
+
const pending = /* @__PURE__ */ new Map();
|
|
19701
|
+
const up = (key) => pending.set(key, (pending.get(key) ?? 0) + 1);
|
|
19702
|
+
const down = (key) => {
|
|
19703
|
+
const left = (pending.get(key) ?? 0) - 1;
|
|
19704
|
+
if (left > 0) pending.set(key, left);
|
|
19705
|
+
else pending.delete(key);
|
|
19706
|
+
};
|
|
19707
|
+
const settle2 = (key, won) => {
|
|
19708
|
+
if (!won) down(key);
|
|
19709
|
+
return won;
|
|
19710
|
+
};
|
|
19711
|
+
return {
|
|
19712
|
+
in_flight: (key) => pending.has(key),
|
|
19713
|
+
store: {
|
|
19714
|
+
claim(key, now) {
|
|
19715
|
+
up(key);
|
|
19716
|
+
let won;
|
|
19717
|
+
try {
|
|
19718
|
+
won = store.claim(key, now);
|
|
19719
|
+
} catch (err) {
|
|
19720
|
+
down(key);
|
|
19721
|
+
throw err;
|
|
19722
|
+
}
|
|
19723
|
+
return typeof won === "boolean" ? settle2(key, won) : won.then(
|
|
19724
|
+
(w) => settle2(key, w),
|
|
19725
|
+
(err) => {
|
|
19726
|
+
down(key);
|
|
19727
|
+
throw err;
|
|
19728
|
+
}
|
|
19729
|
+
);
|
|
19730
|
+
},
|
|
19731
|
+
async commit(key, now) {
|
|
19732
|
+
try {
|
|
19733
|
+
await store.commit(key, now);
|
|
19734
|
+
} finally {
|
|
19735
|
+
down(key);
|
|
19736
|
+
}
|
|
19737
|
+
},
|
|
19738
|
+
async release(key) {
|
|
19739
|
+
try {
|
|
19740
|
+
await store.release(key);
|
|
19741
|
+
} finally {
|
|
19742
|
+
down(key);
|
|
19743
|
+
}
|
|
19744
|
+
}
|
|
19745
|
+
}
|
|
19746
|
+
};
|
|
19747
|
+
}
|
|
19748
|
+
|
|
19698
19749
|
// src/api/errors.ts
|
|
19699
19750
|
var import_act = require("@rotorsoft/act");
|
|
19700
19751
|
var ERROR_MAP = {
|
|
@@ -19902,6 +19953,7 @@ function status_to_trpc_code(status) {
|
|
|
19902
19953
|
}
|
|
19903
19954
|
function trpc(app, options) {
|
|
19904
19955
|
const t = import_server.initTRPC.context().create();
|
|
19956
|
+
const tracker = options.idempotency ? track_in_flight(options.idempotency.store) : void 0;
|
|
19905
19957
|
const resolve_actor = async (host_ctx) => {
|
|
19906
19958
|
try {
|
|
19907
19959
|
return await options.actor(host_ctx);
|
|
@@ -19937,14 +19989,16 @@ function trpc(app, options) {
|
|
|
19937
19989
|
});
|
|
19938
19990
|
}
|
|
19939
19991
|
const outcome = await withIdempotency(
|
|
19940
|
-
|
|
19992
|
+
tracker.store,
|
|
19941
19993
|
key,
|
|
19942
|
-
() => app.do(
|
|
19943
|
-
action_name,
|
|
19944
|
-
target,
|
|
19945
|
-
validated
|
|
19946
|
-
)
|
|
19994
|
+
() => app.do(action_name, target, input2)
|
|
19947
19995
|
);
|
|
19996
|
+
if (outcome.deduped && tracker.in_flight(key)) {
|
|
19997
|
+
throw new import_server.TRPCError({
|
|
19998
|
+
code: "SERVICE_UNAVAILABLE",
|
|
19999
|
+
message: "Idempotency-Key in flight; the original request has not finished, retry later"
|
|
20000
|
+
});
|
|
20001
|
+
}
|
|
19948
20002
|
if (outcome.deduped) {
|
|
19949
20003
|
throw new import_server.TRPCError({
|
|
19950
20004
|
code: "CONFLICT",
|
|
@@ -19956,7 +20010,7 @@ function trpc(app, options) {
|
|
|
19956
20010
|
return await app.do(
|
|
19957
20011
|
action_name,
|
|
19958
20012
|
target,
|
|
19959
|
-
|
|
20013
|
+
input2
|
|
19960
20014
|
);
|
|
19961
20015
|
} catch (err) {
|
|
19962
20016
|
if (err instanceof import_server.TRPCError) throw err;
|