@rotorsoft/act-http 1.8.4 → 1.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/api/errors.d.ts.map +1 -1
- package/dist/@types/api/index.d.ts +1 -1
- package/dist/@types/api/index.d.ts.map +1 -1
- package/dist/@types/api/sse-wiring.d.ts +45 -1
- package/dist/@types/api/sse-wiring.d.ts.map +1 -1
- package/dist/@types/hono/index.d.ts.map +1 -1
- package/dist/@types/openapi/index.d.ts.map +1 -1
- package/dist/@types/receiver/check.d.ts +8 -4
- package/dist/@types/receiver/check.d.ts.map +1 -1
- package/dist/@types/receiver/express/index.d.ts +6 -1
- package/dist/@types/receiver/express/index.d.ts.map +1 -1
- package/dist/@types/receiver/fastify/index.d.ts +5 -1
- package/dist/@types/receiver/fastify/index.d.ts.map +1 -1
- package/dist/@types/sse/broadcast.d.ts.map +1 -1
- package/dist/@types/sse/presence.d.ts.map +1 -1
- package/dist/@types/sse/state-cache.d.ts.map +1 -1
- package/dist/@types/trpc/index.d.ts.map +1 -1
- package/dist/@types/webhook/types.d.ts.map +1 -1
- package/dist/api/index.cjs +19 -7
- package/dist/api/index.cjs.map +1 -1
- package/dist/api/index.js +3 -1
- package/dist/{chunk-NXSPB2NW.js → chunk-DMDU25DN.js} +4 -1
- package/dist/chunk-DMDU25DN.js.map +1 -0
- package/dist/{chunk-FIYLGJAE.js → chunk-IKLTEKBL.js} +19 -8
- package/dist/chunk-IKLTEKBL.js.map +1 -0
- package/dist/{chunk-6YOMQRZO.js → chunk-M7XFCGYR.js} +2 -2
- package/dist/hono/index.cjs +36 -9
- package/dist/hono/index.cjs.map +1 -1
- package/dist/hono/index.js +20 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/openapi/index.cjs +22 -2
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.js +22 -2
- package/dist/openapi/index.js.map +1 -1
- package/dist/receiver/express/index.cjs +3 -0
- package/dist/receiver/express/index.cjs.map +1 -1
- package/dist/receiver/express/index.js +1 -1
- package/dist/receiver/express/index.js.map +1 -1
- package/dist/receiver/fastify/index.cjs +3 -0
- package/dist/receiver/fastify/index.cjs.map +1 -1
- package/dist/receiver/fastify/index.js +1 -1
- package/dist/receiver/fastify/index.js.map +1 -1
- package/dist/receiver/hono/index.cjs +3 -0
- package/dist/receiver/hono/index.cjs.map +1 -1
- package/dist/receiver/hono/index.js +2 -2
- package/dist/receiver/index.cjs +3 -0
- package/dist/receiver/index.cjs.map +1 -1
- package/dist/receiver/index.js +2 -2
- package/dist/receiver/trpc/index.cjs +3 -0
- package/dist/receiver/trpc/index.cjs.map +1 -1
- package/dist/receiver/trpc/index.js +1 -1
- package/dist/trpc/index.cjs +25 -12
- package/dist/trpc/index.cjs.map +1 -1
- package/dist/trpc/index.js +9 -6
- package/dist/trpc/index.js.map +1 -1
- package/package.json +4 -4
- package/dist/chunk-FIYLGJAE.js.map +0 -1
- package/dist/chunk-NXSPB2NW.js.map +0 -1
- /package/dist/{chunk-6YOMQRZO.js.map → chunk-M7XFCGYR.js.map} +0 -0
package/dist/api/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DEFAULT_SSE_HEARTBEAT_MS,
|
|
3
3
|
DEFAULT_SSE_MAX_CONNECTIONS,
|
|
4
|
+
DEFAULT_SSE_MAX_PENDING_PER_CONNECTION,
|
|
4
5
|
ERROR_MAP,
|
|
5
6
|
SseConnectionCounter,
|
|
6
7
|
fireAndForget,
|
|
@@ -8,12 +9,13 @@ import {
|
|
|
8
9
|
runSseSubscription,
|
|
9
10
|
toApiError,
|
|
10
11
|
withIdempotency
|
|
11
|
-
} from "../chunk-
|
|
12
|
+
} from "../chunk-IKLTEKBL.js";
|
|
12
13
|
import "../chunk-WGFX4G27.js";
|
|
13
14
|
import "../chunk-PZ5AY32C.js";
|
|
14
15
|
export {
|
|
15
16
|
DEFAULT_SSE_HEARTBEAT_MS,
|
|
16
17
|
DEFAULT_SSE_MAX_CONNECTIONS,
|
|
18
|
+
DEFAULT_SSE_MAX_PENDING_PER_CONNECTION,
|
|
17
19
|
ERROR_MAP,
|
|
18
20
|
SseConnectionCounter,
|
|
19
21
|
fireAndForget,
|
|
@@ -54,6 +54,9 @@ function pick_header(headers, lower_name) {
|
|
|
54
54
|
// src/receiver/check.ts
|
|
55
55
|
async function checkWebhook(headers, body, options) {
|
|
56
56
|
if (options.secret !== void 0) {
|
|
57
|
+
if (body === "") {
|
|
58
|
+
return { ok: false, status: 400, reason: "empty-body" };
|
|
59
|
+
}
|
|
57
60
|
const verification = verifyWebhook(
|
|
58
61
|
headers,
|
|
59
62
|
body,
|
|
@@ -93,4 +96,4 @@ export {
|
|
|
93
96
|
checkWebhook,
|
|
94
97
|
make_finalizers
|
|
95
98
|
};
|
|
96
|
-
//# sourceMappingURL=chunk-
|
|
99
|
+
//# sourceMappingURL=chunk-DMDU25DN.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/receiver/extract.ts","../src/receiver/verify.ts","../src/receiver/check.ts","../src/receiver/finalize.ts"],"sourcesContent":["/**\n * Pull the `Idempotency-Key` header from a Node-style headers bag,\n * case-insensitive. Returns `undefined` when any of the following\n * carries no usable key:\n *\n * - the header is missing\n * - its value is an array (ambiguous — can't pick one without a\n * policy the receiver hasn't declared)\n * - its value is the empty string (carries no idempotency\n * information; structurally equivalent to \"no header at all\")\n *\n * Pair with `IdempotencyStore.claim` from\n * `@rotorsoft/act-ops/idempotency`: extract the key from the inbound\n * request, claim it on the store, return a `deduped` marker when the\n * claim fails. The framework-agnostic middleware that wires these\n * together lands in #744.\n *\n * Validation beyond \"is there a usable key?\" (length bounds, format\n * checks, normalization) is intentionally out of scope. Receivers\n * picking a policy can layer it on top — or, when #744 ships, opt\n * into the middleware's opinionated defaults.\n */\nexport function extractIdempotencyKey(\n headers: Record<string, string | string[] | undefined>\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== \"idempotency-key\") continue;\n if (Array.isArray(value)) return undefined;\n if (value === \"\") return undefined;\n return value;\n }\n return undefined;\n}\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Outcome of {@link verifyWebhook}. Either the request signature\n * checks out, or one of five distinct failure reasons applies. Each\n * reason maps to an operator-meaningful telemetry bucket — separated\n * deliberately so dashboards can distinguish \"client lost its secret\"\n * from \"client clock is wrong\" from \"this is a replay attack.\"\n */\nexport type VerifyResult =\n | { ok: true }\n | {\n ok: false;\n reason:\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n };\n\n/** Options for {@link verifyWebhook}. */\nexport type VerifyOptions = {\n /**\n * Maximum acceptable timestamp drift in either direction, in\n * seconds. Default: 300 (±5 minutes) — matches Stripe / GitHub /\n * Slack conventions. Tightening narrows the replay window;\n * loosening accommodates clients with worse clock sync.\n */\n maxAgeSeconds?: number;\n /**\n * Current Unix-seconds time. Exposed for tests; production\n * callers should leave it undefined so wall-clock is used.\n */\n now?: number;\n};\n\n/**\n * Verify an inbound webhook's signature and timestamp against the\n * shared secret. Pair with the sender side: configure\n * `webhook({ secret })` from `@rotorsoft/act-http/webhook`.\n *\n * Returns `{ ok: true }` on success or `{ ok: false; reason }` on\n * failure. The reasons are:\n *\n * - `missing-signature` — no `X-Webhook-Signature` header, value\n * was an array, or value was empty.\n * - `missing-timestamp` — no `X-Webhook-Timestamp` header, value\n * was empty, or value isn't a parseable integer.\n * - `stale` — timestamp older than `maxAgeSeconds` from `now`.\n * - `future` — timestamp more than `maxAgeSeconds` ahead of `now`.\n * - `bad-signature` — signature header didn't start with `sha256=`,\n * wasn't 64 hex chars, or the recomputed HMAC didn't match\n * (constant-time compare).\n *\n * The signed payload is `${timestamp}.${body}`, so `body` must be\n * the **raw request body bytes**. Any pre-parse normalization\n * (whitespace trimming, JSON re-stringification) would change the\n * hash and reject every otherwise-valid request. Framework adapters\n * in #744 will provide the raw body alongside the parsed one.\n *\n * Uses Node's `crypto.timingSafeEqual` for the final comparison to\n * avoid signature-equality timing attacks.\n */\nexport function verifyWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n secret: string,\n options?: VerifyOptions\n): VerifyResult {\n const maxAgeSeconds = options?.maxAgeSeconds ?? 300;\n const now = options?.now ?? Math.floor(Date.now() / 1000);\n\n const signature = pick_header(headers, \"x-webhook-signature\");\n if (!signature) return { ok: false, reason: \"missing-signature\" };\n\n const timestamp_str = pick_header(headers, \"x-webhook-timestamp\");\n if (!timestamp_str) return { ok: false, reason: \"missing-timestamp\" };\n const timestamp = Number.parseInt(timestamp_str, 10);\n if (Number.isNaN(timestamp) || String(timestamp) !== timestamp_str) {\n return { ok: false, reason: \"missing-timestamp\" };\n }\n\n const delta = now - timestamp;\n if (delta > maxAgeSeconds) return { ok: false, reason: \"stale\" };\n if (delta < -maxAgeSeconds) return { ok: false, reason: \"future\" };\n\n if (!signature.startsWith(\"sha256=\")) {\n return { ok: false, reason: \"bad-signature\" };\n }\n const provided_hex = signature.slice(\"sha256=\".length);\n if (!/^[0-9a-fA-F]{64}$/.test(provided_hex)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n const expected_hex = createHmac(\"sha256\", secret)\n .update(`${timestamp_str}.${body}`)\n .digest(\"hex\");\n\n const a = Buffer.from(provided_hex, \"hex\");\n const b = Buffer.from(expected_hex, \"hex\");\n if (!timingSafeEqual(a, b)) {\n return { ok: false, reason: \"bad-signature\" };\n }\n\n return { ok: true };\n}\n\nfunction pick_header(\n headers: Record<string, string | string[] | undefined>,\n lower_name: string\n): string | undefined {\n for (const [name, value] of Object.entries(headers)) {\n if (name.toLowerCase() !== lower_name) continue;\n if (Array.isArray(value) || value === undefined || value === \"\") {\n return undefined;\n }\n return value;\n }\n return undefined;\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport { extractIdempotencyKey } from \"./extract.js\";\nimport { type VerifyOptions, verifyWebhook } from \"./verify.js\";\n\n/**\n * Failure reasons returned by {@link checkWebhook}. The shape splits\n * client/configuration errors (`missing-key`, `empty-body` — HTTP 400)\n * from the five verification failures (authentication errors, HTTP 401)\n * so each maps to its own telemetry bucket. `empty-body` is the\n * misconfigured-raw-parser signal: `secret` is set but the resolved\n * body is empty, so hashing it would compute an HMAC over `${ts}.` and\n * reject every otherwise-valid signed request with a misleading\n * bad-signature — a distinct config error is far easier to diagnose.\n */\nexport type CheckFailureReason =\n | \"missing-key\"\n | \"empty-body\"\n | \"missing-signature\"\n | \"missing-timestamp\"\n | \"stale\"\n | \"future\"\n | \"bad-signature\";\n\n/**\n * Outcome of {@link checkWebhook}. Either the request passed every\n * configured check and carries a usable idempotency key, or it\n * failed one of them and the framework adapter should reply with the\n * corresponding HTTP status.\n */\nexport type CheckResult =\n | { ok: false; status: 400 | 401; reason: CheckFailureReason }\n | { ok: true; key: string; deduped: boolean };\n\n/** Options for {@link checkWebhook}. */\nexport type CheckWebhookOptions = {\n /** Idempotency store the framework-agnostic core claims the key on. */\n store: IdempotencyStore;\n /**\n * Optional HMAC-SHA256 secret. When set, the request's\n * `X-Webhook-Signature` and `X-Webhook-Timestamp` headers are\n * verified before the dedup claim. When omitted, signature\n * verification is skipped (unsigned receivers).\n */\n secret?: string;\n /**\n * Verification options forwarded to {@link verifyWebhook}. Only\n * meaningful when `secret` is set. Defaults to a ±300-second\n * timestamp window.\n */\n verify?: VerifyOptions;\n};\n\n/**\n * Framework-agnostic receiver check: verify the signature (when a\n * secret is configured), extract the `Idempotency-Key`, and claim\n * it on the store. Returns the request's fate as a discriminated\n * union the per-framework adapter translates into the framework's\n * idiomatic 4xx response or context injection.\n *\n * **Order of checks** (matters):\n *\n * 1. Verify signature + timestamp window (when `secret` is set).\n * Rejecting bad signatures *before* extracting and claiming the\n * key keeps attacker-supplied keys out of the dedup store —\n * otherwise a flood of spoofed POSTs would pollute the LRU.\n * 2. Extract the `Idempotency-Key`. Missing → reject with 400.\n * 3. Claim the key on the store. If already seen, return\n * `{ ok: true; deduped: true }` so the framework adapter can\n * short-circuit the handler without re-running side effects.\n *\n * The dedup store may be sync (`InMemoryIdempotencyStore`) or async\n * (durable adapters like a future `PostgresIdempotencyStore`); the\n * core awaits unconditionally so both shapes compose cleanly.\n */\nexport async function checkWebhook(\n headers: Record<string, string | string[] | undefined>,\n body: string,\n options: CheckWebhookOptions\n): Promise<CheckResult> {\n if (options.secret !== undefined) {\n // Raw body not captured (default JSON parser ate the bytes) but a\n // secret is configured — hashing the empty string is a guaranteed\n // bad-signature. Surface a distinct configuration error instead of a\n // misleading 401 so the operator mounts the raw-body parser.\n if (body === \"\") {\n return { ok: false, status: 400, reason: \"empty-body\" };\n }\n const verification = verifyWebhook(\n headers,\n body,\n options.secret,\n options.verify\n );\n if (!verification.ok) {\n return { ok: false, status: 401, reason: verification.reason };\n }\n }\n\n const key = extractIdempotencyKey(headers);\n if (!key) return { ok: false, status: 400, reason: \"missing-key\" };\n\n const claimed = await options.store.claim(key);\n return { ok: true, key, deduped: !claimed };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * The two-phase finalizers a receiver adapter binds to a single\n * inbound delivery. The claim `checkWebhook` makes is *tentative*;\n * exactly one of these promotes or drops it once the business handler\n * resolves its outcome.\n */\nexport type Finalizers = {\n /**\n * Promote the tentative claim to a durable record so every later\n * retry of this key dedups. Call on handler success.\n */\n commit: () => void | Promise<void>;\n /**\n * Drop the tentative claim so the sender's retry re-processes.\n * Call on transient handler failure.\n */\n release: () => void | Promise<void>;\n};\n\n/**\n * 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"],"mappings":"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","names":[]}
|
|
@@ -70,19 +70,23 @@ async function withIdempotency(store, key, handler) {
|
|
|
70
70
|
// src/api/sse-wiring.ts
|
|
71
71
|
var DEFAULT_SSE_MAX_CONNECTIONS = 500;
|
|
72
72
|
var DEFAULT_SSE_HEARTBEAT_MS = 3e4;
|
|
73
|
+
var DEFAULT_SSE_MAX_PENDING_PER_CONNECTION = 256;
|
|
73
74
|
var SseOptionsSchema = external_exports.object({
|
|
74
75
|
maxConnections: external_exports.number().int().min(1).max(1e4).default(DEFAULT_SSE_MAX_CONNECTIONS),
|
|
75
|
-
heartbeatMs: external_exports.number().int().min(15e3).max(3e5).default(DEFAULT_SSE_HEARTBEAT_MS)
|
|
76
|
+
heartbeatMs: external_exports.number().int().min(15e3).max(3e5).default(DEFAULT_SSE_HEARTBEAT_MS),
|
|
77
|
+
maxPendingPerConnection: external_exports.number().int().min(1).max(1e5).default(DEFAULT_SSE_MAX_PENDING_PER_CONNECTION)
|
|
76
78
|
});
|
|
77
79
|
function resolveSseConfig(options) {
|
|
78
80
|
const parsed = SseOptionsSchema.parse({
|
|
79
81
|
maxConnections: options.maxConnections,
|
|
80
|
-
heartbeatMs: options.heartbeatMs
|
|
82
|
+
heartbeatMs: options.heartbeatMs,
|
|
83
|
+
maxPendingPerConnection: options.maxPendingPerConnection
|
|
81
84
|
});
|
|
82
85
|
return {
|
|
83
86
|
channel: options.channel,
|
|
84
87
|
maxConnections: parsed.maxConnections,
|
|
85
|
-
heartbeatMs: parsed.heartbeatMs
|
|
88
|
+
heartbeatMs: parsed.heartbeatMs,
|
|
89
|
+
maxPendingPerConnection: parsed.maxPendingPerConnection
|
|
86
90
|
};
|
|
87
91
|
}
|
|
88
92
|
var SseConnectionCounter = class {
|
|
@@ -117,13 +121,19 @@ var SseConnectionCounter = class {
|
|
|
117
121
|
function fireAndForget(op) {
|
|
118
122
|
return op().catch(() => void 0);
|
|
119
123
|
}
|
|
120
|
-
async function* runSseSubscription(channel, stream_id, accounting, signal,
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
async function* runSseSubscription(channel, stream_id, accounting, signal, options) {
|
|
125
|
+
let acquired = false;
|
|
126
|
+
if (accounting) {
|
|
127
|
+
acquired = accounting.acquire();
|
|
128
|
+
if (!acquired && options?.on_cap_exceeded) {
|
|
129
|
+
options.on_cap_exceeded();
|
|
130
|
+
}
|
|
123
131
|
}
|
|
132
|
+
const max_pending = options?.maxPending ?? DEFAULT_SSE_MAX_PENDING_PER_CONNECTION;
|
|
124
133
|
const pending = [];
|
|
125
134
|
let resolve_wait = null;
|
|
126
135
|
const unsubscribe = channel.subscribe(stream_id, (msg) => {
|
|
136
|
+
if (pending.length >= max_pending) pending.shift();
|
|
127
137
|
pending.push(msg);
|
|
128
138
|
resolve_wait?.();
|
|
129
139
|
});
|
|
@@ -147,7 +157,7 @@ async function* runSseSubscription(channel, stream_id, accounting, signal, on_ca
|
|
|
147
157
|
} finally {
|
|
148
158
|
signal?.removeEventListener("abort", on_abort);
|
|
149
159
|
unsubscribe();
|
|
150
|
-
accounting?.release();
|
|
160
|
+
if (acquired) accounting?.release();
|
|
151
161
|
}
|
|
152
162
|
}
|
|
153
163
|
|
|
@@ -157,9 +167,10 @@ export {
|
|
|
157
167
|
withIdempotency,
|
|
158
168
|
DEFAULT_SSE_MAX_CONNECTIONS,
|
|
159
169
|
DEFAULT_SSE_HEARTBEAT_MS,
|
|
170
|
+
DEFAULT_SSE_MAX_PENDING_PER_CONNECTION,
|
|
160
171
|
resolveSseConfig,
|
|
161
172
|
SseConnectionCounter,
|
|
162
173
|
fireAndForget,
|
|
163
174
|
runSseSubscription
|
|
164
175
|
};
|
|
165
|
-
//# sourceMappingURL=chunk-
|
|
176
|
+
//# sourceMappingURL=chunk-IKLTEKBL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/api/errors.ts","../src/api/idempotency.ts","../src/api/sse-wiring.ts"],"sourcesContent":["import {\n ConcurrencyError,\n InvariantError,\n NonRetryableError,\n StreamClosedError,\n ValidationError,\n} from \"@rotorsoft/act\";\n\n/**\n * Uniform error envelope shipped over the wire by every act-http\n * transport. Hosts get the same shape from REST, tRPC, and OpenAPI —\n * a client that talks to two transports doesn't have to invent two\n * error parsers.\n *\n * - `error` — the framework error name (`\"ValidationError\"`,\n * `\"InvariantError\"`, …). Stable identifier, safe to switch on.\n * - `detail` — the framework's message text. Human-readable; not\n * parsed by clients.\n * - `code` — a machine-readable status code from {@link ERROR_MAP}\n * for clients that prefer enum-style branching over name strings.\n */\nexport type ApiError = {\n error: string;\n detail?: string;\n code?: string;\n};\n\n/**\n * Status + code pair for one known framework error.\n */\nexport type ErrorMapEntry = {\n status: number;\n code: string;\n};\n\n/**\n * The single table that maps framework error types to HTTP status\n * codes and machine-readable codes. One table, three consumers\n * (Hono, tRPC, OpenAPI) — cross-transport consistency by\n * construction.\n *\n * Operators wanting different mappings wrap the generated transport\n * rather than mutating this — the consistency is the load-bearing\n * property, not the specific status codes.\n */\nexport const ERROR_MAP = {\n ValidationError: { status: 422, code: \"VALIDATION\" },\n InvariantError: { status: 409, code: \"INVARIANT\" },\n ConcurrencyError: { status: 412, code: \"CONCURRENCY\" },\n StreamClosedError: { status: 410, code: \"STREAM_CLOSED\" },\n NonRetryableError: { status: 400, code: \"NON_RETRYABLE\" },\n} as const satisfies Record<string, ErrorMapEntry>;\n\nconst lookup_known = (\n err: unknown\n): { name: keyof typeof ERROR_MAP } | null => {\n if (err instanceof ValidationError) return { name: \"ValidationError\" };\n if (err instanceof InvariantError) return { name: \"InvariantError\" };\n if (err instanceof ConcurrencyError) return { name: \"ConcurrencyError\" };\n if (err instanceof StreamClosedError) return { name: \"StreamClosedError\" };\n if (err instanceof NonRetryableError) return { name: \"NonRetryableError\" };\n return null;\n};\n\n/**\n * Translate an unknown thrown value into the canonical\n * {@link ApiError} envelope plus HTTP status. Each transport's error\n * boundary calls this once and forwards the result to the wire.\n *\n * Known framework errors map per {@link ERROR_MAP}. Everything else\n * surfaces as a 500 with `code: \"INTERNAL\"`; the `detail` field is\n * populated when the throw was an `Error` instance, omitted\n * otherwise (a thrown string or object doesn't get to leak its\n * payload to the client).\n */\nexport function toApiError(err: unknown): { status: number; body: ApiError } {\n const known = lookup_known(err);\n if (known) {\n const entry = ERROR_MAP[known.name];\n return {\n status: entry.status,\n body: {\n error: known.name,\n detail: (err as Error).message,\n code: entry.code,\n },\n };\n }\n if (err instanceof Error) {\n return {\n status: 500,\n body: { error: \"InternalError\", detail: err.message, code: \"INTERNAL\" },\n };\n }\n return {\n status: 500,\n body: { error: \"InternalError\", code: \"INTERNAL\" },\n };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * Result of a {@link withIdempotency} call.\n *\n * - `{ deduped: false, result }` — the claim was fresh; the handler\n * ran and produced `result`.\n * - `{ deduped: true }` — the claim was already taken; the handler\n * was *not* invoked. The caller decides how to respond (typically\n * a 2xx with no body, matching the receiver-side convention).\n *\n * Note: the contract does not cache the previous response. A\n * duplicate call returns the deduped marker only — replaying the\n * original handler's output would require a response-caching\n * adapter, which is out of scope here. The receiver-side convention\n * (and the convention the generated transports follow) is \"ack the\n * duplicate; do nothing else.\"\n */\nexport type IdempotencyResult<T> =\n | { deduped: false; result: T }\n | { deduped: true };\n\n/**\n * Wrap an action handler so the framework honors `Idempotency-Key`\n * dedup. Acquires the key via {@link IdempotencyStore.claim} (a\n * *tentative* reservation), runs the handler exactly when the claim\n * was fresh, and skips the handler entirely on a duplicate.\n *\n * The claim is finalized two-phase: on handler success the key is\n * {@link IdempotencyStore.commit committed} so later retries dedup;\n * on handler failure it is {@link IdempotencyStore.release released}\n * and the rejection re-thrown, so the sender's retry re-processes\n * instead of the delivery being permanently dropped. The tentative\n * claim still dedups a concurrent duplicate mid-flight.\n *\n * Reuses the contract `@rotorsoft/act-ops/idempotency` already\n * defines for the receiver-side `Idempotency-Key` story. A single\n * `IdempotencyStore` implementation covers both halves of the \"Act\n * over the wire\" surface — receiver and generated API.\n */\nexport async function withIdempotency<T>(\n store: IdempotencyStore,\n key: string,\n handler: () => Promise<T>\n): Promise<IdempotencyResult<T>> {\n const fresh = await store.claim(key);\n if (!fresh) {\n return { deduped: true };\n }\n let result: T;\n try {\n result = await handler();\n } catch (err) {\n await store.release(key);\n throw err;\n }\n await store.commit(key);\n return { deduped: false, result };\n}\n","import { z } from \"zod\";\nimport type { BroadcastChannel } from \"../sse/broadcast.js\";\nimport type { BroadcastState } from \"../sse/types.js\";\n\n/**\n * SSE wiring options shared by the auto-generated `trpc` and `hono`\n * transports. When supplied, each transport emits one streaming\n * subscription per registered state name, all reading from the same\n * host-supplied {@link BroadcastChannel}.\n *\n * The host owns publication: every `app.do(...)` call site must\n * forward the resulting snapshots to `channel.publish(...)` for\n * subscribers to see updates. The framework deliberately doesn't\n * auto-publish because the derived state usually needs app-specific\n * overlays (presence, computed fields) that only the host can supply.\n *\n * Defaults are sized for typical business-app dashboards (dozens to\n * hundreds of human viewers per process). Both numeric knobs are\n * validated at transport-function construction; out-of-range values\n * throw `RangeError` immediately so misconfiguration surfaces at\n * startup instead of at first connection.\n */\nexport type SseOptions = {\n /**\n * The {@link BroadcastChannel} the generator's subscriptions hook\n * into. Host owns its lifecycle; the generator does not construct\n * one for you because the channel's `<S>` shape is app-specific and\n * passing it in keeps the type chain typed end-to-end at the host.\n */\n // `any`: the channel's BroadcastState shape is opaque to the generator — narrowing it would force every state into one structural bound that doesn't fit real apps\n readonly channel: BroadcastChannel<any>;\n /**\n * Hard cap on concurrent open subscriptions per generator\n * instance. The 501st simultaneous open returns `503` (Hono) or\n * throws `TOO_MANY_REQUESTS` (tRPC) — never a silent stall.\n *\n * Default `500`. Validated range `[1, 10_000]`. Above 10k the\n * framework refuses to construct: at that point the FD ceiling\n * and per-connection memory force horizontal scaling regardless\n * of tuning, so the right move is sticky LB + per-process cap,\n * not a higher single-process limit.\n */\n readonly maxConnections?: number;\n /**\n * Keep-alive cadence in milliseconds. Most reverse proxies idle\n * connections out after ~60 seconds; the default `30_000` stays\n * comfortably under.\n *\n * Validated range `[15_000, 300_000]`. Under 15 s wastes\n * bandwidth on a business workload; over 5 min risks proxy drops.\n */\n readonly heartbeatMs?: number;\n /**\n * Hard cap on the per-connection in-memory backlog of undelivered\n * broadcast frames. A consumer that stalls (slow client, paused\n * tab) can't drain frames as fast as a busy stream publishes them;\n * without a bound the backlog grows until the process runs out of\n * memory. When the backlog is full the **oldest** frame is dropped\n * to make room for the newest — the consumer always converges on\n * current state (each frame carries a full version-keyed patch),\n * it just skips intermediate versions it was too slow to see.\n *\n * Default `256`. Validated range `[1, 100_000]`.\n */\n readonly maxPendingPerConnection?: number;\n};\n\n/**\n * Resolved SSE configuration after validation and default expansion.\n * Internal — the resolved values are what every transport actually\n * runs against.\n *\n * @internal\n */\nexport type SseConfig = {\n readonly channel: BroadcastChannel<BroadcastState>;\n readonly maxConnections: number;\n readonly heartbeatMs: number;\n readonly maxPendingPerConnection: number;\n};\n\n/**\n * Default cap on concurrent open subscriptions per generator\n * instance. Sized for typical business-app dashboards (dozens to a\n * few hundred viewers); high enough not to surprise small\n * deployments, low enough not to mask a missing horizontal-scale\n * strategy on big ones.\n */\nexport const DEFAULT_SSE_MAX_CONNECTIONS = 500;\n\n/**\n * Default keep-alive cadence (30 s). Sits comfortably under the\n * 60 s idle timeout most reverse proxies impose.\n */\nexport const DEFAULT_SSE_HEARTBEAT_MS = 30_000;\n\n/**\n * Default cap on the per-connection undelivered-frame backlog. Sized\n * so a briefly-stalled consumer on a busy stream keeps recent history\n * without letting a permanently-wedged client pin unbounded memory.\n * At overflow the oldest frame is dropped (drop-oldest) — each frame\n * is a full version-keyed patch, so the consumer converges on current\n * state regardless of which intermediate versions it missed.\n */\nexport const DEFAULT_SSE_MAX_PENDING_PER_CONNECTION = 256;\n\n/**\n * Zod schema for the numeric knobs on {@link SseOptions}. Defaults,\n * ranges, and (eventual) integer constraints live in one place — same\n * declarative-validation pattern the rest of the framework uses for\n * configuration shapes.\n *\n * @internal\n */\nconst SseOptionsSchema = z.object({\n maxConnections: z\n .number()\n .int()\n .min(1)\n .max(10_000)\n .default(DEFAULT_SSE_MAX_CONNECTIONS),\n heartbeatMs: z\n .number()\n .int()\n .min(15_000)\n .max(300_000)\n .default(DEFAULT_SSE_HEARTBEAT_MS),\n maxPendingPerConnection: z\n .number()\n .int()\n .min(1)\n .max(100_000)\n .default(DEFAULT_SSE_MAX_PENDING_PER_CONNECTION),\n});\n\n/**\n * Validate and apply defaults to host-supplied {@link SseOptions}.\n *\n * Out-of-range values throw at the call site — each transport calls\n * this once at the start of `hono(...)` / `trpc(...)` so\n * misconfiguration surfaces at construction, not at first\n * connection.\n */\nexport function resolveSseConfig(options: SseOptions): SseConfig {\n const parsed = SseOptionsSchema.parse({\n maxConnections: options.maxConnections,\n heartbeatMs: options.heartbeatMs,\n maxPendingPerConnection: options.maxPendingPerConnection,\n });\n return {\n channel: options.channel,\n maxConnections: parsed.maxConnections,\n heartbeatMs: parsed.heartbeatMs,\n maxPendingPerConnection: parsed.maxPendingPerConnection,\n };\n}\n\n/**\n * Per-generator concurrent-open counter. Constructed once inside\n * `hono(...)` / `trpc(...)` and consulted at every subscription\n * open: failure to acquire means the cap has been hit and the\n * transport reports `503` / `TOO_MANY_REQUESTS` to the caller.\n *\n * The counter is intentionally process-local. SSE doesn't multiplex\n * across processes; operators wanting more than `maxConnections`\n * viewers run multiple processes behind a sticky load balancer.\n */\nexport class SseConnectionCounter {\n public readonly limit: number;\n private _open = 0;\n\n constructor(limit: number) {\n this.limit = limit;\n }\n\n /**\n * Reserve one slot. Returns `true` when accepted (caller must\n * eventually {@link release} the slot, even on error paths) or\n * `false` when the cap is already reached.\n */\n acquire(): boolean {\n if (this._open >= this.limit) return false;\n this._open++;\n return true;\n }\n\n /**\n * Free a previously-{@link acquire}d slot. Always runs from a\n * `finally` block in the transport's subscription handler so\n * crashed handlers don't leak count.\n */\n release(): void {\n if (this._open > 0) this._open--;\n }\n\n /** Currently-open count. Test/observability hook. */\n get open(): number {\n return this._open;\n }\n}\n\n/**\n * Wrap a fire-and-forget Promise-returning operation so its\n * rejection is swallowed in place of leaking an unhandled\n * rejection. Used by the Hono heartbeat tick (the interval callback\n * isn't `await`ed, so its rejection has nowhere to go) and by any\n * other \"best-effort write\" the transports run.\n *\n * Pure: same input → same output, no side effects on the caller.\n * Returns the chained Promise so the caller can also `await`\n * completion if they want to (the Hono heartbeat doesn't).\n */\nexport function fireAndForget<T>(op: () => Promise<T>): Promise<T | undefined> {\n return op().catch(() => undefined);\n}\n\n/**\n * Frame yielded by the per-state SSE subscription generator. The\n * initial cached state (when present) arrives as `kind: \"state\"`;\n * every subsequent broadcast publication arrives as `kind: \"patch\"`\n * with the channel's `PatchMessage` payload.\n */\nexport type SseSubscriptionFrame<S> =\n | { readonly kind: \"state\"; readonly data: S }\n | { readonly kind: \"patch\"; readonly data: unknown };\n\n/**\n * Subscription accounting hook. Implementations have a chance to\n * acquire a slot before the loop starts and to release it once the\n * loop's `finally` block runs — used by the tRPC side to enforce\n * `maxConnections` from inside the generator, where it can throw\n * `TRPCError` cleanly. Hono enforces the cap outside the loop (so a\n * full counter returns `503` *before* streamSSE writes headers), then\n * passes `undefined` here.\n */\nexport type SseAccounting = {\n acquire(): boolean;\n release(): void;\n};\n\n/**\n * Per-subscription knobs for {@link runSseSubscription}, bundled so the\n * two transports (and their tests) pass one options object rather than\n * a growing positional tail.\n */\nexport type RunSseOptions = {\n /**\n * Invoked when {@link SseAccounting.acquire} returns `false` (the\n * connection cap is full). tRPC throws `TRPCError` here to reject the\n * subscription cleanly from inside the generator; Hono enforces the\n * cap outside the loop and omits this.\n */\n readonly on_cap_exceeded?: () => never;\n /**\n * Upper bound on the per-connection undelivered-frame backlog. When\n * the backlog reaches this many frames the oldest is dropped to make\n * room for the newest (drop-oldest). Defaults to\n * {@link DEFAULT_SSE_MAX_PENDING_PER_CONNECTION} when omitted.\n */\n readonly maxPending?: number;\n};\n\n/**\n * The shared subscription loop both transports run. Optionally\n * acquires one slot on the supplied {@link SseAccounting}, yields the\n * cached state (when present), then forwards every channel\n * publication as a `kind: \"patch\"` frame until either the consumer\n * breaks (calling `iter.return()`) or the supplied abort signal fires.\n *\n * Exported as the testable seam — the Hono and tRPC sibling\n * generators wrap this so the signal-driven cleanup and counter\n * accounting live in one place.\n */\n// `any`: the channel's BroadcastState shape is opaque to the loop — narrowing it would force every state into one structural bound that doesn't fit real apps\nexport async function* runSseSubscription<S extends { _v: number } = any>(\n channel: BroadcastChannel<S>,\n stream_id: string,\n accounting: SseAccounting | undefined,\n signal: AbortSignal | undefined,\n options?: RunSseOptions\n): AsyncGenerator<SseSubscriptionFrame<S>> {\n // Track whether we actually reserved a slot. Releasing in `finally`\n // unconditionally would decrement the counter below the true open\n // count when acquire() fails without an `on_cap_exceeded` handler —\n // inflating the effective cap over time. Only release what we\n // acquired.\n let acquired = false;\n if (accounting) {\n acquired = accounting.acquire();\n if (!acquired && options?.on_cap_exceeded) {\n options.on_cap_exceeded();\n }\n }\n const max_pending =\n options?.maxPending ?? DEFAULT_SSE_MAX_PENDING_PER_CONNECTION;\n const pending: unknown[] = [];\n let resolve_wait: (() => void) | null = null;\n const unsubscribe = channel.subscribe(stream_id, (msg) => {\n // Bound the per-connection backlog: a stalled consumer can't drain\n // as fast as a busy stream publishes, so drop the oldest frame to\n // cap memory. Each frame is a full version-keyed patch, so the\n // consumer converges on current state regardless of skips.\n if (pending.length >= max_pending) pending.shift();\n pending.push(msg);\n resolve_wait?.();\n });\n const on_abort = () => resolve_wait?.();\n signal?.addEventListener(\"abort\", on_abort);\n try {\n const cached = channel.state(stream_id);\n if (cached !== undefined) {\n yield { kind: \"state\", data: cached };\n }\n while (!signal?.aborted) {\n if (pending.length === 0) {\n await new Promise<void>((resolve) => {\n resolve_wait = resolve;\n });\n if (signal?.aborted) break;\n }\n const msg = pending.shift();\n yield { kind: \"patch\", data: msg };\n }\n } finally {\n signal?.removeEventListener(\"abort\", on_abort);\n unsubscribe();\n if (acquired) accounting?.release();\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
checkWebhook,
|
|
3
3
|
make_finalizers
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-DMDU25DN.js";
|
|
5
5
|
|
|
6
6
|
// src/receiver/hono/index.ts
|
|
7
7
|
function webhookMiddleware(options) {
|
|
@@ -44,4 +44,4 @@ function headers_bag(headers) {
|
|
|
44
44
|
export {
|
|
45
45
|
webhookMiddleware
|
|
46
46
|
};
|
|
47
|
-
//# sourceMappingURL=chunk-
|
|
47
|
+
//# sourceMappingURL=chunk-M7XFCGYR.js.map
|
package/dist/hono/index.cjs
CHANGED
|
@@ -14633,19 +14633,23 @@ config(en_default());
|
|
|
14633
14633
|
// src/api/sse-wiring.ts
|
|
14634
14634
|
var DEFAULT_SSE_MAX_CONNECTIONS = 500;
|
|
14635
14635
|
var DEFAULT_SSE_HEARTBEAT_MS = 3e4;
|
|
14636
|
+
var DEFAULT_SSE_MAX_PENDING_PER_CONNECTION = 256;
|
|
14636
14637
|
var SseOptionsSchema = external_exports.object({
|
|
14637
14638
|
maxConnections: external_exports.number().int().min(1).max(1e4).default(DEFAULT_SSE_MAX_CONNECTIONS),
|
|
14638
|
-
heartbeatMs: external_exports.number().int().min(15e3).max(3e5).default(DEFAULT_SSE_HEARTBEAT_MS)
|
|
14639
|
+
heartbeatMs: external_exports.number().int().min(15e3).max(3e5).default(DEFAULT_SSE_HEARTBEAT_MS),
|
|
14640
|
+
maxPendingPerConnection: external_exports.number().int().min(1).max(1e5).default(DEFAULT_SSE_MAX_PENDING_PER_CONNECTION)
|
|
14639
14641
|
});
|
|
14640
14642
|
function resolveSseConfig(options) {
|
|
14641
14643
|
const parsed = SseOptionsSchema.parse({
|
|
14642
14644
|
maxConnections: options.maxConnections,
|
|
14643
|
-
heartbeatMs: options.heartbeatMs
|
|
14645
|
+
heartbeatMs: options.heartbeatMs,
|
|
14646
|
+
maxPendingPerConnection: options.maxPendingPerConnection
|
|
14644
14647
|
});
|
|
14645
14648
|
return {
|
|
14646
14649
|
channel: options.channel,
|
|
14647
14650
|
maxConnections: parsed.maxConnections,
|
|
14648
|
-
heartbeatMs: parsed.heartbeatMs
|
|
14651
|
+
heartbeatMs: parsed.heartbeatMs,
|
|
14652
|
+
maxPendingPerConnection: parsed.maxPendingPerConnection
|
|
14649
14653
|
};
|
|
14650
14654
|
}
|
|
14651
14655
|
var SseConnectionCounter = class {
|
|
@@ -14680,13 +14684,19 @@ var SseConnectionCounter = class {
|
|
|
14680
14684
|
function fireAndForget(op) {
|
|
14681
14685
|
return op().catch(() => void 0);
|
|
14682
14686
|
}
|
|
14683
|
-
async function* runSseSubscription(channel, stream_id, accounting, signal,
|
|
14684
|
-
|
|
14685
|
-
|
|
14687
|
+
async function* runSseSubscription(channel, stream_id, accounting, signal, options) {
|
|
14688
|
+
let acquired = false;
|
|
14689
|
+
if (accounting) {
|
|
14690
|
+
acquired = accounting.acquire();
|
|
14691
|
+
if (!acquired && options?.on_cap_exceeded) {
|
|
14692
|
+
options.on_cap_exceeded();
|
|
14693
|
+
}
|
|
14686
14694
|
}
|
|
14695
|
+
const max_pending = options?.maxPending ?? DEFAULT_SSE_MAX_PENDING_PER_CONNECTION;
|
|
14687
14696
|
const pending = [];
|
|
14688
14697
|
let resolve_wait = null;
|
|
14689
14698
|
const unsubscribe = channel.subscribe(stream_id, (msg) => {
|
|
14699
|
+
if (pending.length >= max_pending) pending.shift();
|
|
14690
14700
|
pending.push(msg);
|
|
14691
14701
|
resolve_wait?.();
|
|
14692
14702
|
});
|
|
@@ -14710,7 +14720,7 @@ async function* runSseSubscription(channel, stream_id, accounting, signal, on_ca
|
|
|
14710
14720
|
} finally {
|
|
14711
14721
|
signal?.removeEventListener("abort", on_abort);
|
|
14712
14722
|
unsubscribe();
|
|
14713
|
-
accounting?.release();
|
|
14723
|
+
if (acquired) accounting?.release();
|
|
14714
14724
|
}
|
|
14715
14725
|
}
|
|
14716
14726
|
|
|
@@ -14779,7 +14789,8 @@ function hono(app, options) {
|
|
|
14779
14789
|
sse_config.channel,
|
|
14780
14790
|
stream_id,
|
|
14781
14791
|
void 0,
|
|
14782
|
-
controller.signal
|
|
14792
|
+
controller.signal,
|
|
14793
|
+
{ maxPending: sse_config.maxPendingPerConnection }
|
|
14783
14794
|
)) {
|
|
14784
14795
|
await sse_stream.writeSSE({
|
|
14785
14796
|
event: frame.kind,
|
|
@@ -14798,7 +14809,23 @@ function hono(app, options) {
|
|
|
14798
14809
|
const schema = state.actions[action_name];
|
|
14799
14810
|
api.post(
|
|
14800
14811
|
`/actions/${action_name}`,
|
|
14801
|
-
|
|
14812
|
+
// Custom hook funnels body-schema failures through the same
|
|
14813
|
+
// ApiError envelope + 422 status an in-`do` ValidationError maps
|
|
14814
|
+
// to (ERROR_MAP.ValidationError). Without it, @hono/zod-validator
|
|
14815
|
+
// short-circuits with its own 400 + raw body — two statuses and
|
|
14816
|
+
// two shapes for the same "malformed input" failure class on one
|
|
14817
|
+
// endpoint. The OpenAPI doc only documents the 422/ApiError path.
|
|
14818
|
+
zValidator("json", schema, (result, c) => {
|
|
14819
|
+
if (!result.success) {
|
|
14820
|
+
const message = result.error.message;
|
|
14821
|
+
const body = {
|
|
14822
|
+
error: "ValidationError",
|
|
14823
|
+
detail: message,
|
|
14824
|
+
code: "VALIDATION"
|
|
14825
|
+
};
|
|
14826
|
+
return c.json(body, 422);
|
|
14827
|
+
}
|
|
14828
|
+
}),
|
|
14802
14829
|
async (c) => {
|
|
14803
14830
|
try {
|
|
14804
14831
|
const input = c.req.valid("json");
|