@rotorsoft/act-http 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/api/errors.d.ts.map +1 -1
- package/dist/@types/receiver/start.d.ts +1 -1
- package/dist/@types/sse/apply-patch.d.ts +3 -3
- package/dist/@types/sse/broadcast.d.ts +6 -6
- package/dist/@types/sse/broadcast.d.ts.map +1 -1
- package/dist/@types/sse/presence.d.ts +7 -7
- package/dist/@types/sse/presence.d.ts.map +1 -1
- package/dist/@types/webhook/classify.d.ts +6 -6
- package/dist/@types/webhook/classify.d.ts.map +1 -1
- package/dist/@types/webhook/index.d.ts +1 -1
- package/dist/@types/webhook/index.d.ts.map +1 -1
- package/dist/@types/webhook/sign.d.ts +1 -1
- package/dist/@types/webhook/sign.d.ts.map +1 -1
- package/dist/api/index.cjs +2 -2
- package/dist/api/index.cjs.map +1 -1
- package/dist/api/index.js +2 -2
- package/dist/api/index.js.map +1 -1
- package/dist/{chunk-NOIXOF2I.js → chunk-4CGAUB5H.js} +13 -13
- package/dist/chunk-4CGAUB5H.js.map +1 -0
- package/dist/{chunk-F7VWYZ37.js → chunk-K4HAOBRF.js} +4 -4
- package/dist/{chunk-F7VWYZ37.js.map → chunk-K4HAOBRF.js.map} +1 -1
- package/dist/receiver/express/index.cjs +14 -14
- package/dist/receiver/express/index.cjs.map +1 -1
- package/dist/receiver/express/index.js +3 -3
- package/dist/receiver/express/index.js.map +1 -1
- package/dist/receiver/fastify/index.cjs +12 -12
- package/dist/receiver/fastify/index.cjs.map +1 -1
- package/dist/receiver/fastify/index.js +1 -1
- package/dist/receiver/hono/index.cjs +14 -14
- package/dist/receiver/hono/index.cjs.map +1 -1
- package/dist/receiver/hono/index.js +2 -2
- package/dist/receiver/index.cjs +19 -2746
- package/dist/receiver/index.cjs.map +1 -1
- package/dist/receiver/index.js +5 -2077
- package/dist/receiver/index.js.map +1 -1
- package/dist/receiver/trpc/index.cjs +12 -12
- package/dist/receiver/trpc/index.cjs.map +1 -1
- package/dist/receiver/trpc/index.js +1 -1
- package/dist/sse/index.cjs +21 -21
- package/dist/sse/index.cjs.map +1 -1
- package/dist/sse/index.js +24 -24
- package/dist/sse/index.js.map +1 -1
- package/dist/webhook/index.cjs +14 -34
- package/dist/webhook/index.cjs.map +1 -1
- package/dist/webhook/index.js +14 -32
- package/dist/webhook/index.js.map +1 -1
- package/package.json +27 -10
- package/dist/chunk-NOIXOF2I.js.map +0 -1
- package/dist/dist-NWMJQI4E.js +0 -647
- package/dist/dist-NWMJQI4E.js.map +0 -1
package/dist/sse/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { patch } from "@rotorsoft/act-patch";
|
|
3
3
|
|
|
4
4
|
// src/sse/apply-patch.ts
|
|
5
|
-
import { patch as
|
|
5
|
+
import { patch as deep_merge } from "@rotorsoft/act-patch";
|
|
6
6
|
function applyPatchMessage(msg, cached) {
|
|
7
7
|
const cachedV = cached?._v ?? 0;
|
|
8
8
|
const versions = Object.keys(msg).map(Number).sort((a, b) => a - b);
|
|
@@ -14,13 +14,13 @@ function applyPatchMessage(msg, cached) {
|
|
|
14
14
|
let state = cached;
|
|
15
15
|
for (const v of versions) {
|
|
16
16
|
if (v <= cachedV) continue;
|
|
17
|
-
state = { ...
|
|
17
|
+
state = { ...deep_merge(state, msg[v]), _v: v };
|
|
18
18
|
}
|
|
19
19
|
return { ok: true, state };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// src/sse/broadcast.ts
|
|
23
|
-
import { patch as
|
|
23
|
+
import { patch as apply_patch } from "@rotorsoft/act-patch";
|
|
24
24
|
|
|
25
25
|
// src/sse/state-cache.ts
|
|
26
26
|
var StateCache = class {
|
|
@@ -67,9 +67,9 @@ var StateCache = class {
|
|
|
67
67
|
// src/sse/broadcast.ts
|
|
68
68
|
var BroadcastChannel = class {
|
|
69
69
|
channels = /* @__PURE__ */ new Map();
|
|
70
|
-
|
|
70
|
+
state_cache;
|
|
71
71
|
constructor(options) {
|
|
72
|
-
this.
|
|
72
|
+
this.state_cache = new StateCache(options?.cache_size ?? 50);
|
|
73
73
|
}
|
|
74
74
|
/**
|
|
75
75
|
* Publish domain patches from a commit.
|
|
@@ -80,7 +80,7 @@ var BroadcastChannel = class {
|
|
|
80
80
|
* @param patches - Array of domain patches, one per emitted event
|
|
81
81
|
*/
|
|
82
82
|
publish(streamId, state, patches = []) {
|
|
83
|
-
this.
|
|
83
|
+
this.state_cache.set(streamId, state);
|
|
84
84
|
const baseV = state._v - patches.length;
|
|
85
85
|
const msg = {};
|
|
86
86
|
patches.forEach((p, i) => {
|
|
@@ -97,12 +97,12 @@ var BroadcastChannel = class {
|
|
|
97
97
|
* (e.g. presence overlay, computed field refresh).
|
|
98
98
|
* Uses the same version as the cached state, single entry.
|
|
99
99
|
*/
|
|
100
|
-
|
|
101
|
-
const prev = this.
|
|
100
|
+
publish_overlay(streamId, overlay_patch) {
|
|
101
|
+
const prev = this.state_cache.get(streamId);
|
|
102
102
|
if (!prev) return void 0;
|
|
103
|
-
const state =
|
|
104
|
-
this.
|
|
105
|
-
const msg = { [state._v]:
|
|
103
|
+
const state = apply_patch(prev, overlay_patch);
|
|
104
|
+
this.state_cache.set(streamId, state);
|
|
105
|
+
const msg = { [state._v]: overlay_patch };
|
|
106
106
|
const subs = this.channels.get(streamId);
|
|
107
107
|
if (subs?.size) {
|
|
108
108
|
for (const cb of subs) cb(msg);
|
|
@@ -124,16 +124,16 @@ var BroadcastChannel = class {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
/** Get the number of subscribers for a stream. */
|
|
127
|
-
|
|
127
|
+
get_subscriber_count(streamId) {
|
|
128
128
|
return this.channels.get(streamId)?.size ?? 0;
|
|
129
129
|
}
|
|
130
130
|
/** Get the cached state for a stream (for reconnects / initial SSE yield). */
|
|
131
|
-
|
|
132
|
-
return this.
|
|
131
|
+
get_state(streamId) {
|
|
132
|
+
return this.state_cache.get(streamId);
|
|
133
133
|
}
|
|
134
134
|
/** Direct access to the state cache (for app-specific reads like presence). */
|
|
135
135
|
get cache() {
|
|
136
|
-
return this.
|
|
136
|
+
return this.state_cache;
|
|
137
137
|
}
|
|
138
138
|
};
|
|
139
139
|
|
|
@@ -141,28 +141,28 @@ var BroadcastChannel = class {
|
|
|
141
141
|
var PresenceTracker = class {
|
|
142
142
|
streams = /* @__PURE__ */ new Map();
|
|
143
143
|
/** Increment ref count for an identity on a stream. */
|
|
144
|
-
add(streamId,
|
|
144
|
+
add(streamId, identity_id) {
|
|
145
145
|
if (!this.streams.has(streamId)) this.streams.set(streamId, /* @__PURE__ */ new Map());
|
|
146
146
|
const counts = this.streams.get(streamId);
|
|
147
|
-
counts.set(
|
|
147
|
+
counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);
|
|
148
148
|
}
|
|
149
149
|
/** Decrement ref count. Removes the identity when count reaches 0. */
|
|
150
|
-
remove(streamId,
|
|
150
|
+
remove(streamId, identity_id) {
|
|
151
151
|
const counts = this.streams.get(streamId);
|
|
152
152
|
if (!counts) return;
|
|
153
|
-
const n = (counts.get(
|
|
154
|
-
if (n <= 0) counts.delete(
|
|
155
|
-
else counts.set(
|
|
153
|
+
const n = (counts.get(identity_id) ?? 1) - 1;
|
|
154
|
+
if (n <= 0) counts.delete(identity_id);
|
|
155
|
+
else counts.set(identity_id, n);
|
|
156
156
|
if (counts.size === 0) this.streams.delete(streamId);
|
|
157
157
|
}
|
|
158
158
|
/** Get the set of online identity IDs for a stream. */
|
|
159
|
-
|
|
159
|
+
get_online(streamId) {
|
|
160
160
|
const counts = this.streams.get(streamId);
|
|
161
161
|
return counts ? new Set(counts.keys()) : /* @__PURE__ */ new Set();
|
|
162
162
|
}
|
|
163
163
|
/** Check if a specific identity is online for a stream. */
|
|
164
|
-
|
|
165
|
-
return (this.streams.get(streamId)?.get(
|
|
164
|
+
is_online(streamId, identity_id) {
|
|
165
|
+
return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;
|
|
166
166
|
}
|
|
167
167
|
};
|
|
168
168
|
export {
|
package/dist/sse/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deepMerge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.getState.getData({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.getState.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.getState.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n const cachedV = cached?._v ?? 0;\n const versions = Object.keys(msg)\n .map(Number)\n .sort((a, b) => a - b);\n\n if (!versions.length) return { ok: false, reason: \"stale\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n if (maxV <= cachedV) return { ok: false, reason: \"stale\" };\n if (!cached || minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n let state = cached;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deepMerge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { patch as applyPatch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.getState(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private stateCache: StateCache<S>;\n\n constructor(options?: { cacheSize?: number }) {\n this.stateCache = new StateCache<S>(options?.cacheSize ?? 50);\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n this.stateCache.set(streamId, state);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = p;\n });\n\n const subs = this.channels.get(streamId);\n if (subs?.size) {\n for (const cb of subs) cb(msg);\n }\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n publishOverlay(\n streamId: string,\n overlayPatch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.stateCache.get(streamId);\n if (!prev) return undefined;\n\n const state = applyPatch(prev, overlayPatch) as S;\n this.stateCache.set(streamId, state);\n\n const msg: PatchMessage<S> = { [state._v]: overlayPatch };\n const subs = this.channels.get(streamId);\n if (subs?.size) {\n for (const cb of subs) cb(msg);\n }\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n getSubscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n getState(streamId: string): S | undefined {\n return this.stateCache.get(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.stateCache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n\n constructor(maxSize = 50) {\n this.maxSize = maxSize;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n this.cache.delete(this.cache.keys().next().value!);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(gameId, playerId);\n *\n * // On SSE disconnect:\n * presence.remove(gameId, playerId);\n *\n * // Query:\n * presence.getOnline(gameId); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identityId: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identityId, (counts.get(identityId) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identityId: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identityId) ?? 1) - 1;\n if (n <= 0) counts.delete(identityId);\n else counts.set(identityId, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n getOnline(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identityId: string): boolean {\n return (this.streams.get(streamId)?.get(identityId) ?? 0) > 0;\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n const cachedV = cached?._v ?? 0;\n const versions = Object.keys(msg)\n .map(Number)\n .sort((a, b) => a - b);\n\n if (!versions.length) return { ok: false, reason: \"stale\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n if (maxV <= cachedV) return { ok: false, reason: \"stale\" };\n if (!cached || minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n let state = cached;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { patch as apply_patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.get_state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n\n constructor(options?: { cache_size?: number }) {\n this.state_cache = new StateCache<S>(options?.cache_size ?? 50);\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n this.state_cache.set(streamId, state);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = p;\n });\n\n const subs = this.channels.get(streamId);\n if (subs?.size) {\n for (const cb of subs) cb(msg);\n }\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) return undefined;\n\n const state = apply_patch(prev, overlay_patch) as S;\n this.state_cache.set(streamId, state);\n\n const msg: PatchMessage<S> = { [state._v]: overlay_patch };\n const subs = this.channels.get(streamId);\n if (subs?.size) {\n for (const cb of subs) cb(msg);\n }\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n get_subscriber_count(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n get_state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n\n constructor(maxSize = 50) {\n this.maxSize = maxSize;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n this.cache.delete(this.cache.keys().next().value!);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.get_online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n get_online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n is_online(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/webhook/index.cjs
CHANGED
|
@@ -24,8 +24,6 @@ __export(webhook_exports, {
|
|
|
24
24
|
NonRetryableWebhookError: () => NonRetryableWebhookError,
|
|
25
25
|
RetryableHttpError: () => RetryableHttpError,
|
|
26
26
|
WebhookError: () => WebhookError,
|
|
27
|
-
classifyHttpResponse: () => classifyHttpResponse,
|
|
28
|
-
tryOk: () => tryOk,
|
|
29
27
|
webhook: () => webhook
|
|
30
28
|
});
|
|
31
29
|
module.exports = __toCommonJS(webhook_exports);
|
|
@@ -70,31 +68,15 @@ var NonRetryableWebhookError = class extends NonRetryableHttpError {
|
|
|
70
68
|
};
|
|
71
69
|
|
|
72
70
|
// src/webhook/classify.ts
|
|
73
|
-
function
|
|
71
|
+
function classify_http_response(response) {
|
|
74
72
|
if (response.ok) return "ok";
|
|
75
73
|
if (response.status >= 500) return "retry";
|
|
76
74
|
return "block";
|
|
77
75
|
}
|
|
78
|
-
async function tryOk(response, options) {
|
|
79
|
-
const disposition = classifyHttpResponse(response);
|
|
80
|
-
if (disposition === "ok") return;
|
|
81
|
-
let responseBody;
|
|
82
|
-
try {
|
|
83
|
-
responseBody = await response.text();
|
|
84
|
-
} catch {
|
|
85
|
-
}
|
|
86
|
-
const label = options.label ?? "request";
|
|
87
|
-
const ErrorClass = disposition === "retry" ? RetryableHttpError : NonRetryableHttpError;
|
|
88
|
-
throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {
|
|
89
|
-
status: response.status,
|
|
90
|
-
url: options.url,
|
|
91
|
-
responseBody
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
76
|
|
|
95
77
|
// src/webhook/sign.ts
|
|
96
78
|
var import_node_crypto = require("crypto");
|
|
97
|
-
function
|
|
79
|
+
function sign_request(body, secret, now = Math.floor(Date.now() / 1e3)) {
|
|
98
80
|
const timestamp = String(now);
|
|
99
81
|
const payload = `${timestamp}.${body}`;
|
|
100
82
|
const hex = (0, import_node_crypto.createHmac)("sha256", secret).update(payload).digest("hex");
|
|
@@ -106,7 +88,7 @@ function resolve(resolver, event, fallback) {
|
|
|
106
88
|
if (resolver === void 0) return fallback;
|
|
107
89
|
return typeof resolver === "function" ? resolver(event) : resolver;
|
|
108
90
|
}
|
|
109
|
-
function
|
|
91
|
+
function has_header(headers, name) {
|
|
110
92
|
const lower = name.toLowerCase();
|
|
111
93
|
for (const k of Object.keys(headers)) {
|
|
112
94
|
if (k.toLowerCase() === lower) return true;
|
|
@@ -116,28 +98,28 @@ function hasHeader(headers, name) {
|
|
|
116
98
|
function webhook(config) {
|
|
117
99
|
const timeoutMs = config.timeoutMs ?? 5e3;
|
|
118
100
|
const method = config.method ?? "POST";
|
|
119
|
-
const
|
|
120
|
-
return async function
|
|
101
|
+
const fetch_impl = config.fetch ?? globalThis.fetch;
|
|
102
|
+
return async function webhook_deliver(event) {
|
|
121
103
|
const url = resolve(config.url, event, "");
|
|
122
|
-
const
|
|
104
|
+
const custom_headers = resolve(
|
|
123
105
|
config.headers,
|
|
124
106
|
event,
|
|
125
107
|
{}
|
|
126
108
|
);
|
|
127
|
-
const headers = { ...
|
|
128
|
-
if (!
|
|
109
|
+
const headers = { ...custom_headers };
|
|
110
|
+
if (!has_header(headers, "content-type")) {
|
|
129
111
|
headers["Content-Type"] = "application/json";
|
|
130
112
|
}
|
|
131
|
-
if (!
|
|
113
|
+
if (!has_header(headers, "idempotency-key")) {
|
|
132
114
|
const key = config.idempotencyKey ? config.idempotencyKey(event) : String(event.id);
|
|
133
115
|
if (key !== null) headers["Idempotency-Key"] = key;
|
|
134
116
|
}
|
|
135
117
|
const rawBody = resolve(config.body, event, event);
|
|
136
118
|
const body = typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody);
|
|
137
|
-
if (config.secret && !
|
|
138
|
-
const { signature, timestamp } =
|
|
119
|
+
if (config.secret && !has_header(headers, "x-webhook-signature")) {
|
|
120
|
+
const { signature, timestamp } = sign_request(body, config.secret);
|
|
139
121
|
headers["X-Webhook-Signature"] = signature;
|
|
140
|
-
if (!
|
|
122
|
+
if (!has_header(headers, "x-webhook-timestamp")) {
|
|
141
123
|
headers["X-Webhook-Timestamp"] = timestamp;
|
|
142
124
|
}
|
|
143
125
|
}
|
|
@@ -145,7 +127,7 @@ function webhook(config) {
|
|
|
145
127
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
146
128
|
let response;
|
|
147
129
|
try {
|
|
148
|
-
response = await
|
|
130
|
+
response = await fetch_impl(url, {
|
|
149
131
|
method,
|
|
150
132
|
headers,
|
|
151
133
|
body,
|
|
@@ -160,7 +142,7 @@ function webhook(config) {
|
|
|
160
142
|
} finally {
|
|
161
143
|
clearTimeout(timer);
|
|
162
144
|
}
|
|
163
|
-
const disposition =
|
|
145
|
+
const disposition = classify_http_response(response);
|
|
164
146
|
if (disposition === "ok") return;
|
|
165
147
|
let responseBody;
|
|
166
148
|
try {
|
|
@@ -180,8 +162,6 @@ function webhook(config) {
|
|
|
180
162
|
NonRetryableWebhookError,
|
|
181
163
|
RetryableHttpError,
|
|
182
164
|
WebhookError,
|
|
183
|
-
classifyHttpResponse,
|
|
184
|
-
tryOk,
|
|
185
165
|
webhook
|
|
186
166
|
});
|
|
187
167
|
//# sourceMappingURL=index.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/webhook/index.ts","../../src/webhook/types.ts","../../src/webhook/classify.ts","../../src/webhook/sign.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/webhook\n *\n * Reaction-handler sugar for POSTing committed events to external URLs.\n *\n * Wraps `fetch` with timeouts, automatic `Idempotency-Key` derivation, and\n * status-classified errors. Designed to be composed with the reaction\n * options shipped in ACT-601 (`maxRetries`, `blockOnError`, `backoff`):\n *\n * ```ts\n * import { webhook } from \"@rotorsoft/act-http/webhook\";\n *\n * .on(\"OrderConfirmed\")\n * .do(\n * webhook({\n * url: \"https://api.example.com/webhooks/orders\",\n * headers: (e) => ({ Authorization: \"Bearer ...\" }),\n * body: (e) => ({ orderId: e.stream, total: e.data.total }),\n * timeoutMs: 5_000,\n * }),\n * { maxRetries: 5, backoff: { strategy: \"exponential\", baseMs: 200, maxMs: 30_000 } }\n * )\n * .to(resolver);\n * ```\n */\n\nimport type { Committed, ReactionHandler, Schemas } from \"@rotorsoft/act\";\nimport { classifyHttpResponse } from \"./classify.js\";\nimport { signRequest } from \"./sign.js\";\nimport {\n NonRetryableWebhookError,\n type WebhookConfig,\n WebhookError,\n} from \"./types.js\";\n\nexport {\n classifyHttpResponse,\n type HttpDisposition,\n type TryOkOptions,\n tryOk,\n} from \"./classify.js\";\nexport type {\n HttpDeliveryErrorInit,\n WebhookBody,\n WebhookConfig,\n WebhookResolver,\n} from \"./types.js\";\nexport {\n NonRetryableHttpError,\n NonRetryableWebhookError,\n RetryableHttpError,\n WebhookError,\n} from \"./types.js\";\n\nfunction resolve<TEvents extends Schemas, T>(\n resolver: T | ((e: Committed<TEvents, keyof TEvents>) => T) | undefined,\n event: Committed<TEvents, keyof TEvents>,\n fallback: T\n): T {\n if (resolver === undefined) return fallback;\n return typeof resolver === \"function\"\n ? (resolver as (e: Committed<TEvents, keyof TEvents>) => T)(event)\n : resolver;\n}\n\n/** Case-insensitive lookup; returns true if a header is already set. */\nfunction hasHeader(headers: Record<string, string>, name: string): boolean {\n const lower = name.toLowerCase();\n for (const k of Object.keys(headers)) {\n if (k.toLowerCase() === lower) return true;\n }\n return false;\n}\n\n/**\n * Build a reaction handler that POSTs each event to an external URL.\n *\n * Behavior:\n *\n * - 2xx and 3xx return successfully.\n * - 5xx responses, network errors, and timeouts throw\n * {@link WebhookError} (`status: 0` for network/timeout). Drain\n * retries per the reaction's `maxRetries` / `backoff`.\n * - 4xx responses throw {@link NonRetryableWebhookError}, which\n * extends `NonRetryableError`. The drain finalizer blocks the\n * stream immediately (when `blockOnError` is true) without\n * consuming the retry budget.\n */\nexport function webhook<TEvents extends Schemas = Schemas>(\n config: WebhookConfig<TEvents>\n): ReactionHandler<TEvents, keyof TEvents> {\n const timeoutMs = config.timeoutMs ?? 5_000;\n const method = config.method ?? \"POST\";\n const fetchImpl = config.fetch ?? globalThis.fetch;\n\n // Named function: slice/act builders require non-anonymous reaction\n // handlers so lifecycle telemetry can attribute work.\n return async function webhookDeliver(event) {\n const url = resolve(config.url, event, \"\");\n\n const customHeaders = resolve(\n config.headers,\n event,\n {} as Record<string, string>\n );\n const headers: Record<string, string> = { ...customHeaders };\n\n if (!hasHeader(headers, \"content-type\")) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n if (!hasHeader(headers, \"idempotency-key\")) {\n const key = config.idempotencyKey\n ? config.idempotencyKey(event)\n : String(event.id);\n if (key !== null) headers[\"Idempotency-Key\"] = key;\n }\n\n const rawBody = resolve(config.body, event, event as unknown);\n const body =\n typeof rawBody === \"string\" ? rawBody : JSON.stringify(rawBody);\n\n if (config.secret && !hasHeader(headers, \"x-webhook-signature\")) {\n const { signature, timestamp } = signRequest(body, config.secret);\n headers[\"X-Webhook-Signature\"] = signature;\n if (!hasHeader(headers, \"x-webhook-timestamp\")) {\n headers[\"X-Webhook-Timestamp\"] = timestamp;\n }\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let response: Response;\n try {\n response = await fetchImpl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n const aborted = controller.signal.aborted;\n throw new WebhookError(\n aborted\n ? `webhook ${method} ${url} timed out after ${timeoutMs}ms`\n : `webhook ${method} ${url} failed: ${(err as Error).message}`,\n { status: 0, url }\n );\n } finally {\n clearTimeout(timer);\n }\n\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const ErrorClass =\n disposition === \"retry\" ? WebhookError : NonRetryableWebhookError;\n throw new ErrorClass(\n `webhook ${method} ${url} responded ${response.status}`,\n { status: response.status, url, responseBody }\n );\n };\n}\n","import {\n type Committed,\n NonRetryableError,\n type Schemas,\n} from \"@rotorsoft/act\";\n\n/**\n * Function or static value resolver. Used so callers can pass either a\n * constant or a per-event function for headers / body / url.\n *\n * The static side `T` is constrained to non-function types so that a\n * passed `(event) => ...` is unambiguously typed as the function variant.\n */\nexport type WebhookResolver<TEvents extends Schemas, T> =\n | T\n | ((event: Committed<TEvents, keyof TEvents>) => T);\n\n/**\n * Plain-data body shape the helper accepts as a static value. Functions\n * are deliberately excluded so the union with the resolver function is\n * unambiguous at the call site (TypeScript can discriminate by shape).\n */\nexport type WebhookBody =\n | string\n | { readonly [k: string]: unknown }\n | readonly unknown[];\n\n/**\n * Configuration for {@link webhook}.\n *\n * @template TEvents - Event schemas; resolvers receive the typed committed event.\n */\nexport type WebhookConfig<TEvents extends Schemas = Schemas> = {\n /** Target URL — static string or per-event function. */\n readonly url: WebhookResolver<TEvents, string>;\n /** HTTP method. Defaults to `\"POST\"`. */\n readonly method?: \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n /**\n * Headers to send. Resolver may return a record per event. The\n * `Content-Type: application/json` and `Idempotency-Key` headers are\n * applied automatically; both can be overridden by returning a header\n * with the same name (case-insensitive).\n */\n readonly headers?: WebhookResolver<TEvents, Record<string, string>>;\n /**\n * Request body. Static plain data (object, array, string) or a\n * per-event function returning the same. Strings are sent as-is;\n * anything else is JSON-serialized. Defaults to the committed event\n * itself.\n */\n readonly body?:\n | WebhookBody\n | ((event: Committed<TEvents, keyof TEvents>) => WebhookBody);\n /**\n * Per-request timeout in milliseconds. Defaults to 5000.\n * The handler throws after the timeout via `AbortController`.\n */\n readonly timeoutMs?: number;\n /**\n * Override for the auto-generated `Idempotency-Key`. By default, the\n * helper sends `event.id` (the immutable, monotonic event identifier).\n * Return a string to override; return `null` to skip the header entirely.\n */\n readonly idempotencyKey?: (\n event: Committed<TEvents, keyof TEvents>\n ) => string | null;\n /**\n * Injection point for tests. Defaults to global `fetch`.\n */\n readonly fetch?: typeof fetch;\n /**\n * HMAC-SHA256 signing key. When set, the webhook helper attaches\n * two headers to every request:\n *\n * - `X-Webhook-Signature: sha256=<hex>` — HMAC of\n * `${timestamp}.${body}` (`body` is the final serialized payload)\n * - `X-Webhook-Timestamp: <unix-seconds>`\n *\n * Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on\n * the receiving side. When undefined, no signature headers are\n * added — back-compat with consumers that don't need signing.\n *\n * Callers can override either header by returning it from the\n * `headers` resolver (case-insensitive), the same way the\n * `Idempotency-Key` and `Content-Type` defaults yield to caller\n * intent.\n */\n readonly secret?: string;\n};\n\n/**\n * Common fields carried on every HTTP delivery error in this package.\n */\nexport type HttpDeliveryErrorInit = {\n status: number;\n url: string;\n responseBody?: string;\n};\n\n/**\n * Thrown when an HTTP delivery fails in a way the drain pipeline\n * should retry: network failure, timeout, or 5xx response. `status` is\n * `0` for network / timeout errors, the HTTP status code otherwise.\n *\n * The class itself is the retry signal — if a reaction throws this,\n * drain treats it like any other error (counts against `maxRetries`,\n * paces with `backoff`). For permanent failures, throw\n * {@link NonRetryableHttpError} instead.\n *\n * Generic enough to cover any custom HTTP-like integration (gRPC\n * bridges, SDK-based reactions). {@link WebhookError} is a\n * webhook-specific subclass kept for backward compatibility.\n */\nexport class RetryableHttpError extends Error {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"RetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Thrown when an HTTP delivery returns a 3xx or 4xx response —\n * permanent client errors that won't recover on retry. Extends\n * {@link NonRetryableError} so the drain finalizer blocks the stream\n * on the first failed attempt (when `blockOnError` is true) — no\n * wasted retries on a malformed payload or wrong URL.\n *\n * Generic enough to cover any custom HTTP-like integration.\n * {@link NonRetryableWebhookError} is a webhook-specific subclass kept\n * for backward compatibility.\n */\nexport class NonRetryableHttpError extends NonRetryableError {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"NonRetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Webhook-specific subclass of {@link RetryableHttpError}. Thrown by\n * the {@link webhook} helper on 5xx responses, network failures, and\n * timeouts. Existing `instanceof WebhookError` checks continue to\n * work; new code targeting the generic HTTP integration shape can\n * catch {@link RetryableHttpError} instead and handle webhook +\n * custom integrations uniformly.\n */\nexport class WebhookError extends RetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"WebhookError\";\n }\n}\n\n/**\n * Webhook-specific subclass of {@link NonRetryableHttpError}. Thrown\n * by the {@link webhook} helper on 3xx/4xx responses. Existing\n * `instanceof NonRetryableWebhookError` checks continue to work; new\n * code can catch {@link NonRetryableHttpError} or\n * {@link NonRetryableError} for broader coverage.\n */\nexport class NonRetryableWebhookError extends NonRetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"NonRetryableWebhookError\";\n }\n}\n","import { NonRetryableHttpError, RetryableHttpError } from \"./types.js\";\n\n/**\n * Three buckets for an HTTP response from an outbound delivery:\n *\n * - `ok` — the receiver accepted the delivery (2xx). Stop and return.\n * - `retry` — the receiver had a transient problem (5xx). Throw a\n * retryable error; drain will pace the next attempt per `backoff`.\n * - `block` — the receiver rejected the delivery permanently (3xx\n * or 4xx). Throw a non-retryable error; drain blocks the stream\n * on the first failed attempt (when `blockOnError` is true) and\n * surfaces it via the `\"blocked\"` lifecycle event.\n *\n * The 3xx → `block` mapping is intentional: a redirect at the\n * delivery layer means the configured URL is wrong, and retrying\n * the same URL won't fix that. Manual operator review is the right\n * next step, which is what the block path produces.\n */\nexport type HttpDisposition = \"ok\" | \"retry\" | \"block\";\n\n/**\n * Classify an HTTP response as `ok` (2xx), `retry` (5xx), or\n * `block` (3xx, 4xx). The classification {@link webhook} uses\n * internally, lifted here so custom integrations (gRPC bridges,\n * SDK-based reactions, etc.) can apply the same retry semantics\n * without inventing a parallel rule.\n */\nexport function classifyHttpResponse(response: Response): HttpDisposition {\n if (response.ok) return \"ok\";\n if (response.status >= 500) return \"retry\";\n return \"block\";\n}\n\n/** Options for {@link tryOk}. */\nexport type TryOkOptions = {\n /** The endpoint that received the request. Surfaced on the thrown error and in its message. */\n url: string;\n /**\n * Label prefixed onto the error message — typically the\n * integration's identity (`\"webhook\"`, `\"mySdk\"`, `\"grpc\"`).\n * Default: `\"request\"`.\n */\n label?: string;\n};\n\n/**\n * If `response` is 2xx, return. Otherwise, capture the response body\n * (best-effort) and throw a {@link RetryableHttpError} (for 5xx) or\n * {@link NonRetryableHttpError} (for 3xx/4xx). Collapses the\n * classify-and-throw boilerplate every custom HTTP-like reaction\n * would otherwise write into one line:\n *\n * ```ts\n * .on(\"OrderConfirmed\").do(async (event) => {\n * const response = await mySdk.deliver(event);\n * await tryOk(response, { url: mySdk.url, label: \"mySdk\" });\n * // ...response was 2xx; continue with downstream work...\n * });\n * ```\n *\n * The {@link webhook} helper throws webhook-specific subclasses\n * ({@link WebhookError} / {@link NonRetryableWebhookError}) for\n * backward compatibility — both extend the generic classes thrown\n * here, so `instanceof RetryableHttpError` matches both webhook and\n * custom-integration errors uniformly.\n */\nexport async function tryOk(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const label = options.label ?? \"request\";\n const ErrorClass =\n disposition === \"retry\" ? RetryableHttpError : NonRetryableHttpError;\n throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {\n status: response.status,\n url: options.url,\n responseBody,\n });\n}\n","import { createHmac } from \"node:crypto\";\n\n/**\n * Compute the HMAC-SHA256 signature for an outbound webhook request.\n *\n * The signed payload is `${timestamp}.${body}` — Stripe-style. The\n * timestamp is included so the receiver can reject replays via a\n * window check, and the dot separator prevents `timestamp + body`\n * ambiguity (12 + 345 vs 123 + 45).\n *\n * Returns `{ signature, timestamp }` so the webhook helper can attach\n * both as headers — `X-Webhook-Signature: sha256=<hex>` and\n * `X-Webhook-Timestamp: <unix-seconds>` — for the receiver to verify\n * via `verifyWebhook` from `@rotorsoft/act-http/receiver`.\n *\n * `now` is exposed for tests; production callers should leave it\n * undefined so wall-clock is used.\n *\n * @internal Reachable from tests via the source path. Not re-exported\n * from the package's `./webhook` entry — the webhook helper calls\n * it internally, and operators don't need it directly.\n */\nexport function signRequest(\n body: string,\n secret: string,\n now: number = Math.floor(Date.now() / 1000)\n): { signature: string; timestamp: string } {\n const timestamp = String(now);\n const payload = `${timestamp}.${body}`;\n const hex = createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n return { signature: `sha256=${hex}`, timestamp };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/webhook/index.ts","../../src/webhook/types.ts","../../src/webhook/classify.ts","../../src/webhook/sign.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/webhook\n *\n * Reaction-handler sugar for POSTing committed events to external URLs.\n *\n * Wraps `fetch` with timeouts, automatic `Idempotency-Key` derivation, and\n * status-classified errors. Designed to be composed with the reaction\n * options shipped in ACT-601 (`maxRetries`, `blockOnError`, `backoff`):\n *\n * ```ts\n * import { webhook } from \"@rotorsoft/act-http/webhook\";\n *\n * .on(\"OrderConfirmed\")\n * .do(\n * webhook({\n * url: \"https://api.example.com/webhooks/orders\",\n * headers: (e) => ({ Authorization: \"Bearer ...\" }),\n * body: (e) => ({ orderId: e.stream, total: e.data.total }),\n * timeoutMs: 5_000,\n * }),\n * { maxRetries: 5, backoff: { strategy: \"exponential\", baseMs: 200, maxMs: 30_000 } }\n * )\n * .to(resolver);\n * ```\n */\n\nimport type { Committed, ReactionHandler, Schemas } from \"@rotorsoft/act\";\nimport { classify_http_response } from \"./classify.js\";\nimport { sign_request } from \"./sign.js\";\nimport {\n NonRetryableWebhookError,\n type WebhookConfig,\n WebhookError,\n} from \"./types.js\";\n\nexport type { HttpDisposition } from \"./classify.js\";\nexport type {\n HttpDeliveryErrorInit,\n WebhookBody,\n WebhookConfig,\n WebhookResolver,\n} from \"./types.js\";\nexport {\n NonRetryableHttpError,\n NonRetryableWebhookError,\n RetryableHttpError,\n WebhookError,\n} from \"./types.js\";\n\nfunction resolve<TEvents extends Schemas, T>(\n resolver: T | ((e: Committed<TEvents, keyof TEvents>) => T) | undefined,\n event: Committed<TEvents, keyof TEvents>,\n fallback: T\n): T {\n if (resolver === undefined) return fallback;\n return typeof resolver === \"function\"\n ? (resolver as (e: Committed<TEvents, keyof TEvents>) => T)(event)\n : resolver;\n}\n\n/** Case-insensitive lookup; returns true if a header is already set. */\nfunction has_header(headers: Record<string, string>, name: string): boolean {\n const lower = name.toLowerCase();\n for (const k of Object.keys(headers)) {\n if (k.toLowerCase() === lower) return true;\n }\n return false;\n}\n\n/**\n * Build a reaction handler that POSTs each event to an external URL.\n *\n * Behavior:\n *\n * - 2xx and 3xx return successfully.\n * - 5xx responses, network errors, and timeouts throw\n * {@link WebhookError} (`status: 0` for network/timeout). Drain\n * retries per the reaction's `maxRetries` / `backoff`.\n * - 4xx responses throw {@link NonRetryableWebhookError}, which\n * extends `NonRetryableError`. The drain finalizer blocks the\n * stream immediately (when `blockOnError` is true) without\n * consuming the retry budget.\n */\nexport function webhook<TEvents extends Schemas = Schemas>(\n config: WebhookConfig<TEvents>\n): ReactionHandler<TEvents, keyof TEvents> {\n const timeoutMs = config.timeoutMs ?? 5_000;\n const method = config.method ?? \"POST\";\n const fetch_impl = config.fetch ?? globalThis.fetch;\n\n // Named function: slice/act builders require non-anonymous reaction\n // handlers so lifecycle telemetry can attribute work.\n return async function webhook_deliver(event) {\n const url = resolve(config.url, event, \"\");\n\n const custom_headers = resolve(\n config.headers,\n event,\n {} as Record<string, string>\n );\n const headers: Record<string, string> = { ...custom_headers };\n\n if (!has_header(headers, \"content-type\")) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n if (!has_header(headers, \"idempotency-key\")) {\n const key = config.idempotencyKey\n ? config.idempotencyKey(event)\n : String(event.id);\n if (key !== null) headers[\"Idempotency-Key\"] = key;\n }\n\n const rawBody = resolve(config.body, event, event as unknown);\n const body =\n typeof rawBody === \"string\" ? rawBody : JSON.stringify(rawBody);\n\n if (config.secret && !has_header(headers, \"x-webhook-signature\")) {\n const { signature, timestamp } = sign_request(body, config.secret);\n headers[\"X-Webhook-Signature\"] = signature;\n if (!has_header(headers, \"x-webhook-timestamp\")) {\n headers[\"X-Webhook-Timestamp\"] = timestamp;\n }\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let response: Response;\n try {\n response = await fetch_impl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n const aborted = controller.signal.aborted;\n throw new WebhookError(\n aborted\n ? `webhook ${method} ${url} timed out after ${timeoutMs}ms`\n : `webhook ${method} ${url} failed: ${(err as Error).message}`,\n { status: 0, url }\n );\n } finally {\n clearTimeout(timer);\n }\n\n const disposition = classify_http_response(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const ErrorClass =\n disposition === \"retry\" ? WebhookError : NonRetryableWebhookError;\n throw new ErrorClass(\n `webhook ${method} ${url} responded ${response.status}`,\n { status: response.status, url, responseBody }\n );\n };\n}\n","import {\n type Committed,\n NonRetryableError,\n type Schemas,\n} from \"@rotorsoft/act\";\n\n/**\n * Function or static value resolver. Used so callers can pass either a\n * constant or a per-event function for headers / body / url.\n *\n * The static side `T` is constrained to non-function types so that a\n * passed `(event) => ...` is unambiguously typed as the function variant.\n */\nexport type WebhookResolver<TEvents extends Schemas, T> =\n | T\n | ((event: Committed<TEvents, keyof TEvents>) => T);\n\n/**\n * Plain-data body shape the helper accepts as a static value. Functions\n * are deliberately excluded so the union with the resolver function is\n * unambiguous at the call site (TypeScript can discriminate by shape).\n */\nexport type WebhookBody =\n | string\n | { readonly [k: string]: unknown }\n | readonly unknown[];\n\n/**\n * Configuration for {@link webhook}.\n *\n * @template TEvents - Event schemas; resolvers receive the typed committed event.\n */\nexport type WebhookConfig<TEvents extends Schemas = Schemas> = {\n /** Target URL — static string or per-event function. */\n readonly url: WebhookResolver<TEvents, string>;\n /** HTTP method. Defaults to `\"POST\"`. */\n readonly method?: \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n /**\n * Headers to send. Resolver may return a record per event. The\n * `Content-Type: application/json` and `Idempotency-Key` headers are\n * applied automatically; both can be overridden by returning a header\n * with the same name (case-insensitive).\n */\n readonly headers?: WebhookResolver<TEvents, Record<string, string>>;\n /**\n * Request body. Static plain data (object, array, string) or a\n * per-event function returning the same. Strings are sent as-is;\n * anything else is JSON-serialized. Defaults to the committed event\n * itself.\n */\n readonly body?:\n | WebhookBody\n | ((event: Committed<TEvents, keyof TEvents>) => WebhookBody);\n /**\n * Per-request timeout in milliseconds. Defaults to 5000.\n * The handler throws after the timeout via `AbortController`.\n */\n readonly timeoutMs?: number;\n /**\n * Override for the auto-generated `Idempotency-Key`. By default, the\n * helper sends `event.id` (the immutable, monotonic event identifier).\n * Return a string to override; return `null` to skip the header entirely.\n */\n readonly idempotencyKey?: (\n event: Committed<TEvents, keyof TEvents>\n ) => string | null;\n /**\n * Injection point for tests. Defaults to global `fetch`.\n */\n readonly fetch?: typeof fetch;\n /**\n * HMAC-SHA256 signing key. When set, the webhook helper attaches\n * two headers to every request:\n *\n * - `X-Webhook-Signature: sha256=<hex>` — HMAC of\n * `${timestamp}.${body}` (`body` is the final serialized payload)\n * - `X-Webhook-Timestamp: <unix-seconds>`\n *\n * Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on\n * the receiving side. When undefined, no signature headers are\n * added — back-compat with consumers that don't need signing.\n *\n * Callers can override either header by returning it from the\n * `headers` resolver (case-insensitive), the same way the\n * `Idempotency-Key` and `Content-Type` defaults yield to caller\n * intent.\n */\n readonly secret?: string;\n};\n\n/**\n * Common fields carried on every HTTP delivery error in this package.\n */\nexport type HttpDeliveryErrorInit = {\n status: number;\n url: string;\n responseBody?: string;\n};\n\n/**\n * Thrown when an HTTP delivery fails in a way the drain pipeline\n * should retry: network failure, timeout, or 5xx response. `status` is\n * `0` for network / timeout errors, the HTTP status code otherwise.\n *\n * The class itself is the retry signal — if a reaction throws this,\n * drain treats it like any other error (counts against `maxRetries`,\n * paces with `backoff`). For permanent failures, throw\n * {@link NonRetryableHttpError} instead.\n *\n * Generic enough to cover any custom HTTP-like integration (gRPC\n * bridges, SDK-based reactions). {@link WebhookError} is a\n * webhook-specific subclass kept for backward compatibility.\n */\nexport class RetryableHttpError extends Error {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"RetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Thrown when an HTTP delivery returns a 3xx or 4xx response —\n * permanent client errors that won't recover on retry. Extends\n * {@link NonRetryableError} so the drain finalizer blocks the stream\n * on the first failed attempt (when `blockOnError` is true) — no\n * wasted retries on a malformed payload or wrong URL.\n *\n * Generic enough to cover any custom HTTP-like integration.\n * {@link NonRetryableWebhookError} is a webhook-specific subclass kept\n * for backward compatibility.\n */\nexport class NonRetryableHttpError extends NonRetryableError {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"NonRetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Webhook-specific subclass of {@link RetryableHttpError}. Thrown by\n * the {@link webhook} helper on 5xx responses, network failures, and\n * timeouts. Existing `instanceof WebhookError` checks continue to\n * work; new code targeting the generic HTTP integration shape can\n * catch {@link RetryableHttpError} instead and handle webhook +\n * custom integrations uniformly.\n */\nexport class WebhookError extends RetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"WebhookError\";\n }\n}\n\n/**\n * Webhook-specific subclass of {@link NonRetryableHttpError}. Thrown\n * by the {@link webhook} helper on 3xx/4xx responses. Existing\n * `instanceof NonRetryableWebhookError` checks continue to work; new\n * code can catch {@link NonRetryableHttpError} or\n * {@link NonRetryableError} for broader coverage.\n */\nexport class NonRetryableWebhookError extends NonRetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"NonRetryableWebhookError\";\n }\n}\n","import { NonRetryableHttpError, RetryableHttpError } from \"./types.js\";\n\n/**\n * Three buckets for an HTTP response from an outbound delivery:\n *\n * - `ok` — the receiver accepted the delivery (2xx). Stop and return.\n * - `retry` — the receiver had a transient problem (5xx). Throw a\n * retryable error; drain will pace the next attempt per `backoff`.\n * - `block` — the receiver rejected the delivery permanently (3xx\n * or 4xx). Throw a non-retryable error; drain blocks the stream\n * on the first failed attempt (when `blockOnError` is true) and\n * surfaces it via the `\"blocked\"` lifecycle event.\n *\n * The 3xx → `block` mapping is intentional: a redirect at the\n * delivery layer means the configured URL is wrong, and retrying\n * the same URL won't fix that. Manual operator review is the right\n * next step, which is what the block path produces.\n */\nexport type HttpDisposition = \"ok\" | \"retry\" | \"block\";\n\n/**\n * Classify an HTTP response as `ok` (2xx), `retry` (5xx), or\n * `block` (3xx, 4xx). The classification {@link webhook} uses\n * internally, lifted here so custom integrations (gRPC bridges,\n * SDK-based reactions, etc.) can apply the same retry semantics\n * without inventing a parallel rule.\n */\nexport function classify_http_response(response: Response): HttpDisposition {\n if (response.ok) return \"ok\";\n if (response.status >= 500) return \"retry\";\n return \"block\";\n}\n\n/** Options for {@link try_ok}. */\nexport type TryOkOptions = {\n /** The endpoint that received the request. Surfaced on the thrown error and in its message. */\n url: string;\n /**\n * Label prefixed onto the error message — typically the\n * integration's identity (`\"webhook\"`, `\"my_sdk\"`, `\"grpc\"`).\n * Default: `\"request\"`.\n */\n label?: string;\n};\n\n/**\n * If `response` is 2xx, return. Otherwise, capture the response body\n * (best-effort) and throw a {@link RetryableHttpError} (for 5xx) or\n * {@link NonRetryableHttpError} (for 3xx/4xx). Collapses the\n * classify-and-throw boilerplate every custom HTTP-like reaction\n * would otherwise write into one line:\n *\n * ```ts\n * .on(\"OrderConfirmed\").do(async (event) => {\n * const response = await my_sdk.deliver(event);\n * await try_ok(response, { url: my_sdk.url, label: \"my_sdk\" });\n * // ...response was 2xx; continue with downstream work...\n * });\n * ```\n *\n * The {@link webhook} helper throws webhook-specific subclasses\n * ({@link WebhookError} / {@link NonRetryableWebhookError}) for\n * backward compatibility — both extend the generic classes thrown\n * here, so `instanceof RetryableHttpError` matches both webhook and\n * custom-integration errors uniformly.\n */\nexport async function try_ok(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classify_http_response(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const label = options.label ?? \"request\";\n const ErrorClass =\n disposition === \"retry\" ? RetryableHttpError : NonRetryableHttpError;\n throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {\n status: response.status,\n url: options.url,\n responseBody,\n });\n}\n","import { createHmac } from \"node:crypto\";\n\n/**\n * Compute the HMAC-SHA256 signature for an outbound webhook request.\n *\n * The signed payload is `${timestamp}.${body}` — Stripe-style. The\n * timestamp is included so the receiver can reject replays via a\n * window check, and the dot separator prevents `timestamp + body`\n * ambiguity (12 + 345 vs 123 + 45).\n *\n * Returns `{ signature, timestamp }` so the webhook helper can attach\n * both as headers — `X-Webhook-Signature: sha256=<hex>` and\n * `X-Webhook-Timestamp: <unix-seconds>` — for the receiver to verify\n * via `verifyWebhook` from `@rotorsoft/act-http/receiver`.\n *\n * `now` is exposed for tests; production callers should leave it\n * undefined so wall-clock is used.\n *\n * @internal Reachable from tests via the source path. Not re-exported\n * from the package's `./webhook` entry — the webhook helper calls\n * it internally, and operators don't need it directly.\n */\nexport function sign_request(\n body: string,\n secret: string,\n now: number = Math.floor(Date.now() / 1000)\n): { signature: string; timestamp: string } {\n const timestamp = String(now);\n const payload = `${timestamp}.${body}`;\n const hex = createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n return { signature: `sha256=${hex}`, timestamp };\n}\n"],"mappings":"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","names":[]}
|
package/dist/webhook/index.js
CHANGED
|
@@ -40,31 +40,15 @@ var NonRetryableWebhookError = class extends NonRetryableHttpError {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
// src/webhook/classify.ts
|
|
43
|
-
function
|
|
43
|
+
function classify_http_response(response) {
|
|
44
44
|
if (response.ok) return "ok";
|
|
45
45
|
if (response.status >= 500) return "retry";
|
|
46
46
|
return "block";
|
|
47
47
|
}
|
|
48
|
-
async function tryOk(response, options) {
|
|
49
|
-
const disposition = classifyHttpResponse(response);
|
|
50
|
-
if (disposition === "ok") return;
|
|
51
|
-
let responseBody;
|
|
52
|
-
try {
|
|
53
|
-
responseBody = await response.text();
|
|
54
|
-
} catch {
|
|
55
|
-
}
|
|
56
|
-
const label = options.label ?? "request";
|
|
57
|
-
const ErrorClass = disposition === "retry" ? RetryableHttpError : NonRetryableHttpError;
|
|
58
|
-
throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {
|
|
59
|
-
status: response.status,
|
|
60
|
-
url: options.url,
|
|
61
|
-
responseBody
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
48
|
|
|
65
49
|
// src/webhook/sign.ts
|
|
66
50
|
import { createHmac } from "crypto";
|
|
67
|
-
function
|
|
51
|
+
function sign_request(body, secret, now = Math.floor(Date.now() / 1e3)) {
|
|
68
52
|
const timestamp = String(now);
|
|
69
53
|
const payload = `${timestamp}.${body}`;
|
|
70
54
|
const hex = createHmac("sha256", secret).update(payload).digest("hex");
|
|
@@ -76,7 +60,7 @@ function resolve(resolver, event, fallback) {
|
|
|
76
60
|
if (resolver === void 0) return fallback;
|
|
77
61
|
return typeof resolver === "function" ? resolver(event) : resolver;
|
|
78
62
|
}
|
|
79
|
-
function
|
|
63
|
+
function has_header(headers, name) {
|
|
80
64
|
const lower = name.toLowerCase();
|
|
81
65
|
for (const k of Object.keys(headers)) {
|
|
82
66
|
if (k.toLowerCase() === lower) return true;
|
|
@@ -86,28 +70,28 @@ function hasHeader(headers, name) {
|
|
|
86
70
|
function webhook(config) {
|
|
87
71
|
const timeoutMs = config.timeoutMs ?? 5e3;
|
|
88
72
|
const method = config.method ?? "POST";
|
|
89
|
-
const
|
|
90
|
-
return async function
|
|
73
|
+
const fetch_impl = config.fetch ?? globalThis.fetch;
|
|
74
|
+
return async function webhook_deliver(event) {
|
|
91
75
|
const url = resolve(config.url, event, "");
|
|
92
|
-
const
|
|
76
|
+
const custom_headers = resolve(
|
|
93
77
|
config.headers,
|
|
94
78
|
event,
|
|
95
79
|
{}
|
|
96
80
|
);
|
|
97
|
-
const headers = { ...
|
|
98
|
-
if (!
|
|
81
|
+
const headers = { ...custom_headers };
|
|
82
|
+
if (!has_header(headers, "content-type")) {
|
|
99
83
|
headers["Content-Type"] = "application/json";
|
|
100
84
|
}
|
|
101
|
-
if (!
|
|
85
|
+
if (!has_header(headers, "idempotency-key")) {
|
|
102
86
|
const key = config.idempotencyKey ? config.idempotencyKey(event) : String(event.id);
|
|
103
87
|
if (key !== null) headers["Idempotency-Key"] = key;
|
|
104
88
|
}
|
|
105
89
|
const rawBody = resolve(config.body, event, event);
|
|
106
90
|
const body = typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody);
|
|
107
|
-
if (config.secret && !
|
|
108
|
-
const { signature, timestamp } =
|
|
91
|
+
if (config.secret && !has_header(headers, "x-webhook-signature")) {
|
|
92
|
+
const { signature, timestamp } = sign_request(body, config.secret);
|
|
109
93
|
headers["X-Webhook-Signature"] = signature;
|
|
110
|
-
if (!
|
|
94
|
+
if (!has_header(headers, "x-webhook-timestamp")) {
|
|
111
95
|
headers["X-Webhook-Timestamp"] = timestamp;
|
|
112
96
|
}
|
|
113
97
|
}
|
|
@@ -115,7 +99,7 @@ function webhook(config) {
|
|
|
115
99
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
116
100
|
let response;
|
|
117
101
|
try {
|
|
118
|
-
response = await
|
|
102
|
+
response = await fetch_impl(url, {
|
|
119
103
|
method,
|
|
120
104
|
headers,
|
|
121
105
|
body,
|
|
@@ -130,7 +114,7 @@ function webhook(config) {
|
|
|
130
114
|
} finally {
|
|
131
115
|
clearTimeout(timer);
|
|
132
116
|
}
|
|
133
|
-
const disposition =
|
|
117
|
+
const disposition = classify_http_response(response);
|
|
134
118
|
if (disposition === "ok") return;
|
|
135
119
|
let responseBody;
|
|
136
120
|
try {
|
|
@@ -149,8 +133,6 @@ export {
|
|
|
149
133
|
NonRetryableWebhookError,
|
|
150
134
|
RetryableHttpError,
|
|
151
135
|
WebhookError,
|
|
152
|
-
classifyHttpResponse,
|
|
153
|
-
tryOk,
|
|
154
136
|
webhook
|
|
155
137
|
};
|
|
156
138
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/webhook/types.ts","../../src/webhook/classify.ts","../../src/webhook/sign.ts","../../src/webhook/index.ts"],"sourcesContent":["import {\n type Committed,\n NonRetryableError,\n type Schemas,\n} from \"@rotorsoft/act\";\n\n/**\n * Function or static value resolver. Used so callers can pass either a\n * constant or a per-event function for headers / body / url.\n *\n * The static side `T` is constrained to non-function types so that a\n * passed `(event) => ...` is unambiguously typed as the function variant.\n */\nexport type WebhookResolver<TEvents extends Schemas, T> =\n | T\n | ((event: Committed<TEvents, keyof TEvents>) => T);\n\n/**\n * Plain-data body shape the helper accepts as a static value. Functions\n * are deliberately excluded so the union with the resolver function is\n * unambiguous at the call site (TypeScript can discriminate by shape).\n */\nexport type WebhookBody =\n | string\n | { readonly [k: string]: unknown }\n | readonly unknown[];\n\n/**\n * Configuration for {@link webhook}.\n *\n * @template TEvents - Event schemas; resolvers receive the typed committed event.\n */\nexport type WebhookConfig<TEvents extends Schemas = Schemas> = {\n /** Target URL — static string or per-event function. */\n readonly url: WebhookResolver<TEvents, string>;\n /** HTTP method. Defaults to `\"POST\"`. */\n readonly method?: \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n /**\n * Headers to send. Resolver may return a record per event. The\n * `Content-Type: application/json` and `Idempotency-Key` headers are\n * applied automatically; both can be overridden by returning a header\n * with the same name (case-insensitive).\n */\n readonly headers?: WebhookResolver<TEvents, Record<string, string>>;\n /**\n * Request body. Static plain data (object, array, string) or a\n * per-event function returning the same. Strings are sent as-is;\n * anything else is JSON-serialized. Defaults to the committed event\n * itself.\n */\n readonly body?:\n | WebhookBody\n | ((event: Committed<TEvents, keyof TEvents>) => WebhookBody);\n /**\n * Per-request timeout in milliseconds. Defaults to 5000.\n * The handler throws after the timeout via `AbortController`.\n */\n readonly timeoutMs?: number;\n /**\n * Override for the auto-generated `Idempotency-Key`. By default, the\n * helper sends `event.id` (the immutable, monotonic event identifier).\n * Return a string to override; return `null` to skip the header entirely.\n */\n readonly idempotencyKey?: (\n event: Committed<TEvents, keyof TEvents>\n ) => string | null;\n /**\n * Injection point for tests. Defaults to global `fetch`.\n */\n readonly fetch?: typeof fetch;\n /**\n * HMAC-SHA256 signing key. When set, the webhook helper attaches\n * two headers to every request:\n *\n * - `X-Webhook-Signature: sha256=<hex>` — HMAC of\n * `${timestamp}.${body}` (`body` is the final serialized payload)\n * - `X-Webhook-Timestamp: <unix-seconds>`\n *\n * Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on\n * the receiving side. When undefined, no signature headers are\n * added — back-compat with consumers that don't need signing.\n *\n * Callers can override either header by returning it from the\n * `headers` resolver (case-insensitive), the same way the\n * `Idempotency-Key` and `Content-Type` defaults yield to caller\n * intent.\n */\n readonly secret?: string;\n};\n\n/**\n * Common fields carried on every HTTP delivery error in this package.\n */\nexport type HttpDeliveryErrorInit = {\n status: number;\n url: string;\n responseBody?: string;\n};\n\n/**\n * Thrown when an HTTP delivery fails in a way the drain pipeline\n * should retry: network failure, timeout, or 5xx response. `status` is\n * `0` for network / timeout errors, the HTTP status code otherwise.\n *\n * The class itself is the retry signal — if a reaction throws this,\n * drain treats it like any other error (counts against `maxRetries`,\n * paces with `backoff`). For permanent failures, throw\n * {@link NonRetryableHttpError} instead.\n *\n * Generic enough to cover any custom HTTP-like integration (gRPC\n * bridges, SDK-based reactions). {@link WebhookError} is a\n * webhook-specific subclass kept for backward compatibility.\n */\nexport class RetryableHttpError extends Error {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"RetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Thrown when an HTTP delivery returns a 3xx or 4xx response —\n * permanent client errors that won't recover on retry. Extends\n * {@link NonRetryableError} so the drain finalizer blocks the stream\n * on the first failed attempt (when `blockOnError` is true) — no\n * wasted retries on a malformed payload or wrong URL.\n *\n * Generic enough to cover any custom HTTP-like integration.\n * {@link NonRetryableWebhookError} is a webhook-specific subclass kept\n * for backward compatibility.\n */\nexport class NonRetryableHttpError extends NonRetryableError {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"NonRetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Webhook-specific subclass of {@link RetryableHttpError}. Thrown by\n * the {@link webhook} helper on 5xx responses, network failures, and\n * timeouts. Existing `instanceof WebhookError` checks continue to\n * work; new code targeting the generic HTTP integration shape can\n * catch {@link RetryableHttpError} instead and handle webhook +\n * custom integrations uniformly.\n */\nexport class WebhookError extends RetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"WebhookError\";\n }\n}\n\n/**\n * Webhook-specific subclass of {@link NonRetryableHttpError}. Thrown\n * by the {@link webhook} helper on 3xx/4xx responses. Existing\n * `instanceof NonRetryableWebhookError` checks continue to work; new\n * code can catch {@link NonRetryableHttpError} or\n * {@link NonRetryableError} for broader coverage.\n */\nexport class NonRetryableWebhookError extends NonRetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"NonRetryableWebhookError\";\n }\n}\n","import { NonRetryableHttpError, RetryableHttpError } from \"./types.js\";\n\n/**\n * Three buckets for an HTTP response from an outbound delivery:\n *\n * - `ok` — the receiver accepted the delivery (2xx). Stop and return.\n * - `retry` — the receiver had a transient problem (5xx). Throw a\n * retryable error; drain will pace the next attempt per `backoff`.\n * - `block` — the receiver rejected the delivery permanently (3xx\n * or 4xx). Throw a non-retryable error; drain blocks the stream\n * on the first failed attempt (when `blockOnError` is true) and\n * surfaces it via the `\"blocked\"` lifecycle event.\n *\n * The 3xx → `block` mapping is intentional: a redirect at the\n * delivery layer means the configured URL is wrong, and retrying\n * the same URL won't fix that. Manual operator review is the right\n * next step, which is what the block path produces.\n */\nexport type HttpDisposition = \"ok\" | \"retry\" | \"block\";\n\n/**\n * Classify an HTTP response as `ok` (2xx), `retry` (5xx), or\n * `block` (3xx, 4xx). The classification {@link webhook} uses\n * internally, lifted here so custom integrations (gRPC bridges,\n * SDK-based reactions, etc.) can apply the same retry semantics\n * without inventing a parallel rule.\n */\nexport function classifyHttpResponse(response: Response): HttpDisposition {\n if (response.ok) return \"ok\";\n if (response.status >= 500) return \"retry\";\n return \"block\";\n}\n\n/** Options for {@link tryOk}. */\nexport type TryOkOptions = {\n /** The endpoint that received the request. Surfaced on the thrown error and in its message. */\n url: string;\n /**\n * Label prefixed onto the error message — typically the\n * integration's identity (`\"webhook\"`, `\"mySdk\"`, `\"grpc\"`).\n * Default: `\"request\"`.\n */\n label?: string;\n};\n\n/**\n * If `response` is 2xx, return. Otherwise, capture the response body\n * (best-effort) and throw a {@link RetryableHttpError} (for 5xx) or\n * {@link NonRetryableHttpError} (for 3xx/4xx). Collapses the\n * classify-and-throw boilerplate every custom HTTP-like reaction\n * would otherwise write into one line:\n *\n * ```ts\n * .on(\"OrderConfirmed\").do(async (event) => {\n * const response = await mySdk.deliver(event);\n * await tryOk(response, { url: mySdk.url, label: \"mySdk\" });\n * // ...response was 2xx; continue with downstream work...\n * });\n * ```\n *\n * The {@link webhook} helper throws webhook-specific subclasses\n * ({@link WebhookError} / {@link NonRetryableWebhookError}) for\n * backward compatibility — both extend the generic classes thrown\n * here, so `instanceof RetryableHttpError` matches both webhook and\n * custom-integration errors uniformly.\n */\nexport async function tryOk(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const label = options.label ?? \"request\";\n const ErrorClass =\n disposition === \"retry\" ? RetryableHttpError : NonRetryableHttpError;\n throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {\n status: response.status,\n url: options.url,\n responseBody,\n });\n}\n","import { createHmac } from \"node:crypto\";\n\n/**\n * Compute the HMAC-SHA256 signature for an outbound webhook request.\n *\n * The signed payload is `${timestamp}.${body}` — Stripe-style. The\n * timestamp is included so the receiver can reject replays via a\n * window check, and the dot separator prevents `timestamp + body`\n * ambiguity (12 + 345 vs 123 + 45).\n *\n * Returns `{ signature, timestamp }` so the webhook helper can attach\n * both as headers — `X-Webhook-Signature: sha256=<hex>` and\n * `X-Webhook-Timestamp: <unix-seconds>` — for the receiver to verify\n * via `verifyWebhook` from `@rotorsoft/act-http/receiver`.\n *\n * `now` is exposed for tests; production callers should leave it\n * undefined so wall-clock is used.\n *\n * @internal Reachable from tests via the source path. Not re-exported\n * from the package's `./webhook` entry — the webhook helper calls\n * it internally, and operators don't need it directly.\n */\nexport function signRequest(\n body: string,\n secret: string,\n now: number = Math.floor(Date.now() / 1000)\n): { signature: string; timestamp: string } {\n const timestamp = String(now);\n const payload = `${timestamp}.${body}`;\n const hex = createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n return { signature: `sha256=${hex}`, timestamp };\n}\n","/**\n * @packageDocumentation\n * @module act-http/webhook\n *\n * Reaction-handler sugar for POSTing committed events to external URLs.\n *\n * Wraps `fetch` with timeouts, automatic `Idempotency-Key` derivation, and\n * status-classified errors. Designed to be composed with the reaction\n * options shipped in ACT-601 (`maxRetries`, `blockOnError`, `backoff`):\n *\n * ```ts\n * import { webhook } from \"@rotorsoft/act-http/webhook\";\n *\n * .on(\"OrderConfirmed\")\n * .do(\n * webhook({\n * url: \"https://api.example.com/webhooks/orders\",\n * headers: (e) => ({ Authorization: \"Bearer ...\" }),\n * body: (e) => ({ orderId: e.stream, total: e.data.total }),\n * timeoutMs: 5_000,\n * }),\n * { maxRetries: 5, backoff: { strategy: \"exponential\", baseMs: 200, maxMs: 30_000 } }\n * )\n * .to(resolver);\n * ```\n */\n\nimport type { Committed, ReactionHandler, Schemas } from \"@rotorsoft/act\";\nimport { classifyHttpResponse } from \"./classify.js\";\nimport { signRequest } from \"./sign.js\";\nimport {\n NonRetryableWebhookError,\n type WebhookConfig,\n WebhookError,\n} from \"./types.js\";\n\nexport {\n classifyHttpResponse,\n type HttpDisposition,\n type TryOkOptions,\n tryOk,\n} from \"./classify.js\";\nexport type {\n HttpDeliveryErrorInit,\n WebhookBody,\n WebhookConfig,\n WebhookResolver,\n} from \"./types.js\";\nexport {\n NonRetryableHttpError,\n NonRetryableWebhookError,\n RetryableHttpError,\n WebhookError,\n} from \"./types.js\";\n\nfunction resolve<TEvents extends Schemas, T>(\n resolver: T | ((e: Committed<TEvents, keyof TEvents>) => T) | undefined,\n event: Committed<TEvents, keyof TEvents>,\n fallback: T\n): T {\n if (resolver === undefined) return fallback;\n return typeof resolver === \"function\"\n ? (resolver as (e: Committed<TEvents, keyof TEvents>) => T)(event)\n : resolver;\n}\n\n/** Case-insensitive lookup; returns true if a header is already set. */\nfunction hasHeader(headers: Record<string, string>, name: string): boolean {\n const lower = name.toLowerCase();\n for (const k of Object.keys(headers)) {\n if (k.toLowerCase() === lower) return true;\n }\n return false;\n}\n\n/**\n * Build a reaction handler that POSTs each event to an external URL.\n *\n * Behavior:\n *\n * - 2xx and 3xx return successfully.\n * - 5xx responses, network errors, and timeouts throw\n * {@link WebhookError} (`status: 0` for network/timeout). Drain\n * retries per the reaction's `maxRetries` / `backoff`.\n * - 4xx responses throw {@link NonRetryableWebhookError}, which\n * extends `NonRetryableError`. The drain finalizer blocks the\n * stream immediately (when `blockOnError` is true) without\n * consuming the retry budget.\n */\nexport function webhook<TEvents extends Schemas = Schemas>(\n config: WebhookConfig<TEvents>\n): ReactionHandler<TEvents, keyof TEvents> {\n const timeoutMs = config.timeoutMs ?? 5_000;\n const method = config.method ?? \"POST\";\n const fetchImpl = config.fetch ?? globalThis.fetch;\n\n // Named function: slice/act builders require non-anonymous reaction\n // handlers so lifecycle telemetry can attribute work.\n return async function webhookDeliver(event) {\n const url = resolve(config.url, event, \"\");\n\n const customHeaders = resolve(\n config.headers,\n event,\n {} as Record<string, string>\n );\n const headers: Record<string, string> = { ...customHeaders };\n\n if (!hasHeader(headers, \"content-type\")) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n if (!hasHeader(headers, \"idempotency-key\")) {\n const key = config.idempotencyKey\n ? config.idempotencyKey(event)\n : String(event.id);\n if (key !== null) headers[\"Idempotency-Key\"] = key;\n }\n\n const rawBody = resolve(config.body, event, event as unknown);\n const body =\n typeof rawBody === \"string\" ? rawBody : JSON.stringify(rawBody);\n\n if (config.secret && !hasHeader(headers, \"x-webhook-signature\")) {\n const { signature, timestamp } = signRequest(body, config.secret);\n headers[\"X-Webhook-Signature\"] = signature;\n if (!hasHeader(headers, \"x-webhook-timestamp\")) {\n headers[\"X-Webhook-Timestamp\"] = timestamp;\n }\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let response: Response;\n try {\n response = await fetchImpl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n const aborted = controller.signal.aborted;\n throw new WebhookError(\n aborted\n ? `webhook ${method} ${url} timed out after ${timeoutMs}ms`\n : `webhook ${method} ${url} failed: ${(err as Error).message}`,\n { status: 0, url }\n );\n } finally {\n clearTimeout(timer);\n }\n\n const disposition = classifyHttpResponse(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const ErrorClass =\n disposition === \"retry\" ? WebhookError : NonRetryableWebhookError;\n throw new ErrorClass(\n `webhook ${method} ${url} responded ${response.status}`,\n { status: response.status, url, responseBody }\n );\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/webhook/types.ts","../../src/webhook/classify.ts","../../src/webhook/sign.ts","../../src/webhook/index.ts"],"sourcesContent":["import {\n type Committed,\n NonRetryableError,\n type Schemas,\n} from \"@rotorsoft/act\";\n\n/**\n * Function or static value resolver. Used so callers can pass either a\n * constant or a per-event function for headers / body / url.\n *\n * The static side `T` is constrained to non-function types so that a\n * passed `(event) => ...` is unambiguously typed as the function variant.\n */\nexport type WebhookResolver<TEvents extends Schemas, T> =\n | T\n | ((event: Committed<TEvents, keyof TEvents>) => T);\n\n/**\n * Plain-data body shape the helper accepts as a static value. Functions\n * are deliberately excluded so the union with the resolver function is\n * unambiguous at the call site (TypeScript can discriminate by shape).\n */\nexport type WebhookBody =\n | string\n | { readonly [k: string]: unknown }\n | readonly unknown[];\n\n/**\n * Configuration for {@link webhook}.\n *\n * @template TEvents - Event schemas; resolvers receive the typed committed event.\n */\nexport type WebhookConfig<TEvents extends Schemas = Schemas> = {\n /** Target URL — static string or per-event function. */\n readonly url: WebhookResolver<TEvents, string>;\n /** HTTP method. Defaults to `\"POST\"`. */\n readonly method?: \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n /**\n * Headers to send. Resolver may return a record per event. The\n * `Content-Type: application/json` and `Idempotency-Key` headers are\n * applied automatically; both can be overridden by returning a header\n * with the same name (case-insensitive).\n */\n readonly headers?: WebhookResolver<TEvents, Record<string, string>>;\n /**\n * Request body. Static plain data (object, array, string) or a\n * per-event function returning the same. Strings are sent as-is;\n * anything else is JSON-serialized. Defaults to the committed event\n * itself.\n */\n readonly body?:\n | WebhookBody\n | ((event: Committed<TEvents, keyof TEvents>) => WebhookBody);\n /**\n * Per-request timeout in milliseconds. Defaults to 5000.\n * The handler throws after the timeout via `AbortController`.\n */\n readonly timeoutMs?: number;\n /**\n * Override for the auto-generated `Idempotency-Key`. By default, the\n * helper sends `event.id` (the immutable, monotonic event identifier).\n * Return a string to override; return `null` to skip the header entirely.\n */\n readonly idempotencyKey?: (\n event: Committed<TEvents, keyof TEvents>\n ) => string | null;\n /**\n * Injection point for tests. Defaults to global `fetch`.\n */\n readonly fetch?: typeof fetch;\n /**\n * HMAC-SHA256 signing key. When set, the webhook helper attaches\n * two headers to every request:\n *\n * - `X-Webhook-Signature: sha256=<hex>` — HMAC of\n * `${timestamp}.${body}` (`body` is the final serialized payload)\n * - `X-Webhook-Timestamp: <unix-seconds>`\n *\n * Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on\n * the receiving side. When undefined, no signature headers are\n * added — back-compat with consumers that don't need signing.\n *\n * Callers can override either header by returning it from the\n * `headers` resolver (case-insensitive), the same way the\n * `Idempotency-Key` and `Content-Type` defaults yield to caller\n * intent.\n */\n readonly secret?: string;\n};\n\n/**\n * Common fields carried on every HTTP delivery error in this package.\n */\nexport type HttpDeliveryErrorInit = {\n status: number;\n url: string;\n responseBody?: string;\n};\n\n/**\n * Thrown when an HTTP delivery fails in a way the drain pipeline\n * should retry: network failure, timeout, or 5xx response. `status` is\n * `0` for network / timeout errors, the HTTP status code otherwise.\n *\n * The class itself is the retry signal — if a reaction throws this,\n * drain treats it like any other error (counts against `maxRetries`,\n * paces with `backoff`). For permanent failures, throw\n * {@link NonRetryableHttpError} instead.\n *\n * Generic enough to cover any custom HTTP-like integration (gRPC\n * bridges, SDK-based reactions). {@link WebhookError} is a\n * webhook-specific subclass kept for backward compatibility.\n */\nexport class RetryableHttpError extends Error {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"RetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Thrown when an HTTP delivery returns a 3xx or 4xx response —\n * permanent client errors that won't recover on retry. Extends\n * {@link NonRetryableError} so the drain finalizer blocks the stream\n * on the first failed attempt (when `blockOnError` is true) — no\n * wasted retries on a malformed payload or wrong URL.\n *\n * Generic enough to cover any custom HTTP-like integration.\n * {@link NonRetryableWebhookError} is a webhook-specific subclass kept\n * for backward compatibility.\n */\nexport class NonRetryableHttpError extends NonRetryableError {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"NonRetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Webhook-specific subclass of {@link RetryableHttpError}. Thrown by\n * the {@link webhook} helper on 5xx responses, network failures, and\n * timeouts. Existing `instanceof WebhookError` checks continue to\n * work; new code targeting the generic HTTP integration shape can\n * catch {@link RetryableHttpError} instead and handle webhook +\n * custom integrations uniformly.\n */\nexport class WebhookError extends RetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"WebhookError\";\n }\n}\n\n/**\n * Webhook-specific subclass of {@link NonRetryableHttpError}. Thrown\n * by the {@link webhook} helper on 3xx/4xx responses. Existing\n * `instanceof NonRetryableWebhookError` checks continue to work; new\n * code can catch {@link NonRetryableHttpError} or\n * {@link NonRetryableError} for broader coverage.\n */\nexport class NonRetryableWebhookError extends NonRetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"NonRetryableWebhookError\";\n }\n}\n","import { NonRetryableHttpError, RetryableHttpError } from \"./types.js\";\n\n/**\n * Three buckets for an HTTP response from an outbound delivery:\n *\n * - `ok` — the receiver accepted the delivery (2xx). Stop and return.\n * - `retry` — the receiver had a transient problem (5xx). Throw a\n * retryable error; drain will pace the next attempt per `backoff`.\n * - `block` — the receiver rejected the delivery permanently (3xx\n * or 4xx). Throw a non-retryable error; drain blocks the stream\n * on the first failed attempt (when `blockOnError` is true) and\n * surfaces it via the `\"blocked\"` lifecycle event.\n *\n * The 3xx → `block` mapping is intentional: a redirect at the\n * delivery layer means the configured URL is wrong, and retrying\n * the same URL won't fix that. Manual operator review is the right\n * next step, which is what the block path produces.\n */\nexport type HttpDisposition = \"ok\" | \"retry\" | \"block\";\n\n/**\n * Classify an HTTP response as `ok` (2xx), `retry` (5xx), or\n * `block` (3xx, 4xx). The classification {@link webhook} uses\n * internally, lifted here so custom integrations (gRPC bridges,\n * SDK-based reactions, etc.) can apply the same retry semantics\n * without inventing a parallel rule.\n */\nexport function classify_http_response(response: Response): HttpDisposition {\n if (response.ok) return \"ok\";\n if (response.status >= 500) return \"retry\";\n return \"block\";\n}\n\n/** Options for {@link try_ok}. */\nexport type TryOkOptions = {\n /** The endpoint that received the request. Surfaced on the thrown error and in its message. */\n url: string;\n /**\n * Label prefixed onto the error message — typically the\n * integration's identity (`\"webhook\"`, `\"my_sdk\"`, `\"grpc\"`).\n * Default: `\"request\"`.\n */\n label?: string;\n};\n\n/**\n * If `response` is 2xx, return. Otherwise, capture the response body\n * (best-effort) and throw a {@link RetryableHttpError} (for 5xx) or\n * {@link NonRetryableHttpError} (for 3xx/4xx). Collapses the\n * classify-and-throw boilerplate every custom HTTP-like reaction\n * would otherwise write into one line:\n *\n * ```ts\n * .on(\"OrderConfirmed\").do(async (event) => {\n * const response = await my_sdk.deliver(event);\n * await try_ok(response, { url: my_sdk.url, label: \"my_sdk\" });\n * // ...response was 2xx; continue with downstream work...\n * });\n * ```\n *\n * The {@link webhook} helper throws webhook-specific subclasses\n * ({@link WebhookError} / {@link NonRetryableWebhookError}) for\n * backward compatibility — both extend the generic classes thrown\n * here, so `instanceof RetryableHttpError` matches both webhook and\n * custom-integration errors uniformly.\n */\nexport async function try_ok(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classify_http_response(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const label = options.label ?? \"request\";\n const ErrorClass =\n disposition === \"retry\" ? RetryableHttpError : NonRetryableHttpError;\n throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {\n status: response.status,\n url: options.url,\n responseBody,\n });\n}\n","import { createHmac } from \"node:crypto\";\n\n/**\n * Compute the HMAC-SHA256 signature for an outbound webhook request.\n *\n * The signed payload is `${timestamp}.${body}` — Stripe-style. The\n * timestamp is included so the receiver can reject replays via a\n * window check, and the dot separator prevents `timestamp + body`\n * ambiguity (12 + 345 vs 123 + 45).\n *\n * Returns `{ signature, timestamp }` so the webhook helper can attach\n * both as headers — `X-Webhook-Signature: sha256=<hex>` and\n * `X-Webhook-Timestamp: <unix-seconds>` — for the receiver to verify\n * via `verifyWebhook` from `@rotorsoft/act-http/receiver`.\n *\n * `now` is exposed for tests; production callers should leave it\n * undefined so wall-clock is used.\n *\n * @internal Reachable from tests via the source path. Not re-exported\n * from the package's `./webhook` entry — the webhook helper calls\n * it internally, and operators don't need it directly.\n */\nexport function sign_request(\n body: string,\n secret: string,\n now: number = Math.floor(Date.now() / 1000)\n): { signature: string; timestamp: string } {\n const timestamp = String(now);\n const payload = `${timestamp}.${body}`;\n const hex = createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n return { signature: `sha256=${hex}`, timestamp };\n}\n","/**\n * @packageDocumentation\n * @module act-http/webhook\n *\n * Reaction-handler sugar for POSTing committed events to external URLs.\n *\n * Wraps `fetch` with timeouts, automatic `Idempotency-Key` derivation, and\n * status-classified errors. Designed to be composed with the reaction\n * options shipped in ACT-601 (`maxRetries`, `blockOnError`, `backoff`):\n *\n * ```ts\n * import { webhook } from \"@rotorsoft/act-http/webhook\";\n *\n * .on(\"OrderConfirmed\")\n * .do(\n * webhook({\n * url: \"https://api.example.com/webhooks/orders\",\n * headers: (e) => ({ Authorization: \"Bearer ...\" }),\n * body: (e) => ({ orderId: e.stream, total: e.data.total }),\n * timeoutMs: 5_000,\n * }),\n * { maxRetries: 5, backoff: { strategy: \"exponential\", baseMs: 200, maxMs: 30_000 } }\n * )\n * .to(resolver);\n * ```\n */\n\nimport type { Committed, ReactionHandler, Schemas } from \"@rotorsoft/act\";\nimport { classify_http_response } from \"./classify.js\";\nimport { sign_request } from \"./sign.js\";\nimport {\n NonRetryableWebhookError,\n type WebhookConfig,\n WebhookError,\n} from \"./types.js\";\n\nexport type { HttpDisposition } from \"./classify.js\";\nexport type {\n HttpDeliveryErrorInit,\n WebhookBody,\n WebhookConfig,\n WebhookResolver,\n} from \"./types.js\";\nexport {\n NonRetryableHttpError,\n NonRetryableWebhookError,\n RetryableHttpError,\n WebhookError,\n} from \"./types.js\";\n\nfunction resolve<TEvents extends Schemas, T>(\n resolver: T | ((e: Committed<TEvents, keyof TEvents>) => T) | undefined,\n event: Committed<TEvents, keyof TEvents>,\n fallback: T\n): T {\n if (resolver === undefined) return fallback;\n return typeof resolver === \"function\"\n ? (resolver as (e: Committed<TEvents, keyof TEvents>) => T)(event)\n : resolver;\n}\n\n/** Case-insensitive lookup; returns true if a header is already set. */\nfunction has_header(headers: Record<string, string>, name: string): boolean {\n const lower = name.toLowerCase();\n for (const k of Object.keys(headers)) {\n if (k.toLowerCase() === lower) return true;\n }\n return false;\n}\n\n/**\n * Build a reaction handler that POSTs each event to an external URL.\n *\n * Behavior:\n *\n * - 2xx and 3xx return successfully.\n * - 5xx responses, network errors, and timeouts throw\n * {@link WebhookError} (`status: 0` for network/timeout). Drain\n * retries per the reaction's `maxRetries` / `backoff`.\n * - 4xx responses throw {@link NonRetryableWebhookError}, which\n * extends `NonRetryableError`. The drain finalizer blocks the\n * stream immediately (when `blockOnError` is true) without\n * consuming the retry budget.\n */\nexport function webhook<TEvents extends Schemas = Schemas>(\n config: WebhookConfig<TEvents>\n): ReactionHandler<TEvents, keyof TEvents> {\n const timeoutMs = config.timeoutMs ?? 5_000;\n const method = config.method ?? \"POST\";\n const fetch_impl = config.fetch ?? globalThis.fetch;\n\n // Named function: slice/act builders require non-anonymous reaction\n // handlers so lifecycle telemetry can attribute work.\n return async function webhook_deliver(event) {\n const url = resolve(config.url, event, \"\");\n\n const custom_headers = resolve(\n config.headers,\n event,\n {} as Record<string, string>\n );\n const headers: Record<string, string> = { ...custom_headers };\n\n if (!has_header(headers, \"content-type\")) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n if (!has_header(headers, \"idempotency-key\")) {\n const key = config.idempotencyKey\n ? config.idempotencyKey(event)\n : String(event.id);\n if (key !== null) headers[\"Idempotency-Key\"] = key;\n }\n\n const rawBody = resolve(config.body, event, event as unknown);\n const body =\n typeof rawBody === \"string\" ? rawBody : JSON.stringify(rawBody);\n\n if (config.secret && !has_header(headers, \"x-webhook-signature\")) {\n const { signature, timestamp } = sign_request(body, config.secret);\n headers[\"X-Webhook-Signature\"] = signature;\n if (!has_header(headers, \"x-webhook-timestamp\")) {\n headers[\"X-Webhook-Timestamp\"] = timestamp;\n }\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let response: Response;\n try {\n response = await fetch_impl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n const aborted = controller.signal.aborted;\n throw new WebhookError(\n aborted\n ? `webhook ${method} ${url} timed out after ${timeoutMs}ms`\n : `webhook ${method} ${url} failed: ${(err as Error).message}`,\n { status: 0, url }\n );\n } finally {\n clearTimeout(timer);\n }\n\n const disposition = classify_http_response(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const ErrorClass =\n disposition === \"retry\" ? WebhookError : NonRetryableWebhookError;\n throw new ErrorClass(\n `webhook ${method} ${url} responded ${response.status}`,\n { status: response.status, url, responseBody }\n );\n };\n}\n"],"mappings":"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","names":[]}
|