@voltro/plugin-webhooks 0.85.0 → 0.87.0

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.
@@ -0,0 +1,41 @@
1
+ import { and as e, eq as t } from "@voltro/database";
2
+ //#region src/autoDisable.ts
3
+ var n = "_voltro_webhook_targets", r = 32, i = async (e, n) => (await e.query({
4
+ table: "_voltro_webhook_targets",
5
+ predicate: t("id", n),
6
+ order: [],
7
+ take: 1,
8
+ skip: void 0,
9
+ projection: void 0
10
+ }))[0] ?? null, a = async (n, a, o, s, c = /* @__PURE__ */ new Date(), l = !1) => {
11
+ for (let u = 0; u < r; u++) {
12
+ u > 0 && await new Promise((e) => setTimeout(e, u));
13
+ let r = await i(n, a);
14
+ if (r === null) return {
15
+ consecutiveFailures: 0,
16
+ autoDisabled: !1
17
+ };
18
+ let d = r.consecutiveFailures ?? 0;
19
+ if (o) {
20
+ if (d === 0 || await n.updateMany("_voltro_webhook_targets", {
21
+ consecutiveFailures: 0,
22
+ updatedAt: c
23
+ }, { where: e(t("id", a), t("consecutiveFailures", d)) }) === 1) return {
24
+ consecutiveFailures: 0,
25
+ autoDisabled: !1
26
+ };
27
+ continue;
28
+ }
29
+ let f = d + 1, p = r.autoDisableAfter, m = r.active && (l || typeof p == "number" && p > 0 && f >= p), h = {
30
+ consecutiveFailures: f,
31
+ updatedAt: c
32
+ };
33
+ if (m && (h.active = !1, h.autoDisabledAt = c, h.autoDisableReason = s.slice(0, 500)), await n.updateMany("_voltro_webhook_targets", h, { where: e(t("id", a), t("consecutiveFailures", d)) }) === 1) return {
34
+ consecutiveFailures: f,
35
+ autoDisabled: m
36
+ };
37
+ }
38
+ throw Error(`webhook auto-disable: streak CAS contention on target ${a} exceeded ${r} attempts`);
39
+ };
40
+ //#endregion
41
+ export { n as TARGETS_TABLE, a as recordDeliveryOutcome };
@@ -0,0 +1,17 @@
1
+ import { defineOutboxHandler as e } from "@voltro/runtime";
2
+ //#region src/deferredEmit.ts
3
+ var t = "voltro.webhook.emit", n = (e) => typeof e.event == "string", r = (r) => e({
4
+ effect: t,
5
+ maxAttempts: 1,
6
+ handler: async ({ payload: e, outboxId: i }) => {
7
+ if (!n(e)) throw Error(`${t}: row payload has no \`event\` name — cannot resolve a descriptor. Got keys: ${Object.keys(e).join(", ") || "(none)"}`);
8
+ let a = r();
9
+ if (a === void 0) throw Error(`${t}: the outgoing-webhooks service is not available in this process. An emit was enqueued by a build that had the plugin registered; this one does not.`);
10
+ await a.emit(e.event, e.payload, {
11
+ tenantId: e.tenantId,
12
+ eventId: i
13
+ });
14
+ }
15
+ });
16
+ //#endregion
17
+ export { t as WEBHOOK_EMIT_EFFECT, r as makeDeferredEmitHandler };
@@ -0,0 +1,350 @@
1
+ import { signRequest as e } from "./signing.js";
2
+ import { nextRetry as t } from "./retry.js";
3
+ import { WebhookPayloadUnrepresentable as n } from "./errors.js";
4
+ import { acquireRateSlots as r } from "./rateLimit.js";
5
+ import { encodePayload as i } from "./encode.js";
6
+ import { recordDeliveryOutcome as a } from "./autoDisable.js";
7
+ import { Effect as o, Schema as s } from "effect";
8
+ import { eq as c } from "@voltro/database";
9
+ import { assertPublicUrl as l } from "@voltro/integration-http";
10
+ import { createLogger as u } from "@voltro/logger";
11
+ import { Activity as d, DurableClock as f, Workflow as p } from "effect/workflow";
12
+ //#region src/deliverWorkflow.ts
13
+ var m = s.Struct({
14
+ status: s.Number,
15
+ body: s.String,
16
+ latencyMs: s.Number,
17
+ retryAfterSec: s.Union([s.Number, s.Undefined]),
18
+ transportError: s.Union([s.String, s.Undefined]),
19
+ encodeError: s.Union([s.String, s.Undefined])
20
+ }), h = s.Struct({
21
+ outcome: s.Literals([
22
+ "succeeded",
23
+ "failed",
24
+ "retry"
25
+ ]),
26
+ delayMs: s.Union([s.Number, s.Undefined]),
27
+ reason: s.Union([s.String, s.Undefined]),
28
+ autoDisabled: s.Union([s.Boolean, s.Undefined]),
29
+ consecutiveFailures: s.Union([s.Number, s.Undefined])
30
+ }), g = s.Struct({
31
+ acquired: s.Boolean,
32
+ retryInMs: s.Number
33
+ }), _ = {
34
+ deliveryId: s.String,
35
+ targetId: s.String,
36
+ event: s.String,
37
+ eventId: s.String,
38
+ payloadJson: s.String,
39
+ attemptEpoch: s.optional(s.Number)
40
+ }, v = p.make("voltro.deliverWebhook", {
41
+ payload: _,
42
+ success: s.Struct({
43
+ finalStatus: s.Literals([
44
+ "succeeded",
45
+ "failed",
46
+ "deferred"
47
+ ]),
48
+ attempts: s.Number
49
+ }),
50
+ idempotencyKey: ({ deliveryId: e, attemptEpoch: t }) => `voltro.deliverWebhook:${e}:${t ?? 0}`
51
+ }), y = 3e4, b = (e, t) => {
52
+ if (e !== void 0) return e;
53
+ let n = Number(t.VOLTRO_WEBHOOK_TIMEOUT_MS);
54
+ return Number.isFinite(n) && n > 0 ? n : y;
55
+ }, x = (p, _ = {}) => (v, y) => {
56
+ let { deliveryId: x, targetId: S, event: C, eventId: w, payloadJson: T } = v, E = u({ scope: `webhook:${x}` });
57
+ return o.gen(function* () {
58
+ let u = yield* d.make({
59
+ name: "fetch-target",
60
+ success: s.Union([s.Null, s.Struct({
61
+ id: s.String,
62
+ event: s.String,
63
+ url: s.String,
64
+ secret: s.String,
65
+ previousSecret: s.optional(s.Union([s.String, s.Null])),
66
+ previousSecretUntil: s.optional(s.Union([s.String, s.Null])),
67
+ signing: s.Any,
68
+ retry: s.Any,
69
+ headers: s.Union([s.Record(s.String, s.String), s.Null]),
70
+ rateLimitPerMinute: s.Union([s.Number, s.Null]),
71
+ active: s.Boolean,
72
+ format: s.Literals([
73
+ "json",
74
+ "form",
75
+ "xml"
76
+ ]),
77
+ tenantId: s.Union([s.String, s.Null])
78
+ })]),
79
+ execute: o.tryPromise({
80
+ try: async () => {
81
+ let e = (await p.store.query({
82
+ table: "_voltro_webhook_targets",
83
+ predicate: c("id", S),
84
+ order: [{
85
+ column: "createdAt",
86
+ direction: "asc"
87
+ }],
88
+ take: 1,
89
+ skip: void 0,
90
+ projection: void 0
91
+ }))[0];
92
+ if (e === void 0) return null;
93
+ let t = e.previousSecretUntil;
94
+ return {
95
+ ...e,
96
+ previousSecret: e.previousSecret ?? null,
97
+ previousSecretUntil: t instanceof Date ? t.toISOString() : t ?? null
98
+ };
99
+ },
100
+ catch: (e) => (E.debug("fetch-target raw error", {
101
+ targetId: S,
102
+ event: C,
103
+ cause: String(e)
104
+ }), /* @__PURE__ */ Error(`fetch-target failed: ${e.message}`))
105
+ }).pipe(o.orDie)
106
+ });
107
+ if (u === null) return {
108
+ finalStatus: "failed",
109
+ attempts: 0
110
+ };
111
+ let v = async (e, t, n) => {
112
+ await p.store.update("_voltro_webhook_deliveries", e, {
113
+ ...n,
114
+ updatedAt: /* @__PURE__ */ new Date()
115
+ }) === null && await p.store.insert("_voltro_webhook_deliveries", {
116
+ id: e,
117
+ deliveryId: x,
118
+ targetId: S,
119
+ tenantId: u.tenantId ?? null,
120
+ event: C,
121
+ eventId: w,
122
+ attempt: t,
123
+ payload: T,
124
+ scheduledAt: /* @__PURE__ */ new Date(),
125
+ createdAt: /* @__PURE__ */ new Date(),
126
+ updatedAt: /* @__PURE__ */ new Date(),
127
+ ...n
128
+ });
129
+ };
130
+ if (u.active !== !0) return yield* d.make({
131
+ name: "record-paused-pending",
132
+ success: s.Void,
133
+ execute: o.tryPromise({
134
+ try: () => v(`${x}:1`, 1, { status: "pending" }),
135
+ catch: (e) => /* @__PURE__ */ Error(`record-paused-pending failed: ${e.message}`)
136
+ }).pipe(o.orDie)
137
+ }), {
138
+ finalStatus: "deferred",
139
+ attempts: 0
140
+ };
141
+ let y = (e) => typeof e == "string" ? JSON.parse(e) : e, D = y(u.retry), O = y(u.signing), k = u.headers === null ? null : y(u.headers), A = _.rateWindowMs ?? 6e4, j = b(_.timeoutMs, process.env), M = _.events?.find((e) => e.id === C)?.globalRateLimit, N = [...typeof u.rateLimitPerMinute == "number" && u.rateLimitPerMinute > 0 ? [{
142
+ key: `target:${u.id}`,
143
+ limit: u.rateLimitPerMinute
144
+ }] : [], ...M !== void 0 && M.perMinute > 0 ? [{
145
+ key: `event:${C}`,
146
+ limit: M.perMinute
147
+ }] : []];
148
+ for (let c = 1; c <= D.maxAttempts; c++) {
149
+ let _ = `${x}:${c}`;
150
+ if (N.length > 0) for (let e = 0;; e++) {
151
+ let t = yield* d.make({
152
+ name: `rate-acquire-${c}-${e}`,
153
+ success: g,
154
+ execute: o.tryPromise({
155
+ try: () => r(p.store, N, Date.now(), A),
156
+ catch: (e) => (E.debug("rate-acquire raw error", {
157
+ targetId: S,
158
+ event: C,
159
+ attempt: c,
160
+ cause: String(e)
161
+ }), /* @__PURE__ */ Error(`rate-acquire failed: ${e.message}`))
162
+ }).pipe(o.orDie)
163
+ });
164
+ if (t.acquired) break;
165
+ yield* d.make({
166
+ name: `record-rate-deferral-${c}-${e}`,
167
+ success: s.Void,
168
+ execute: o.tryPromise({
169
+ try: () => v(_, c, {
170
+ status: "pending",
171
+ nextAttemptAt: new Date(Date.now() + t.retryInMs)
172
+ }),
173
+ catch: (e) => /* @__PURE__ */ Error(`record-rate-deferral failed: ${e.message}`)
174
+ }).pipe(o.orDie)
175
+ }), yield* f.sleep({
176
+ name: `rate-window-sleep-${c}-${e}`,
177
+ duration: `${t.retryInMs} millis`
178
+ });
179
+ }
180
+ yield* d.make({
181
+ name: `record-attempt-start-${c}`,
182
+ success: s.Void,
183
+ execute: o.tryPromise({
184
+ try: () => v(_, c, {
185
+ status: "inFlight",
186
+ nextAttemptAt: null,
187
+ scheduledAt: /* @__PURE__ */ new Date()
188
+ }),
189
+ catch: (e) => /* @__PURE__ */ Error(`record-attempt-start failed: ${e.message}`)
190
+ }).pipe(o.orDie)
191
+ });
192
+ let y = yield* d.make({
193
+ name: `sign-and-post-${c}`,
194
+ success: m,
195
+ execute: o.promise(async () => {
196
+ let t = Date.now(), r;
197
+ try {
198
+ r = i(u.format, T);
199
+ } catch (e) {
200
+ if (e instanceof n) return {
201
+ status: 0,
202
+ body: "",
203
+ latencyMs: Date.now() - t,
204
+ retryAfterSec: void 0,
205
+ transportError: void 0,
206
+ encodeError: e.reason
207
+ };
208
+ throw e;
209
+ }
210
+ let a = Buffer.from(r.bytes), o = u.previousSecret != null && u.previousSecretUntil != null && Date.parse(u.previousSecretUntil) > Date.now() && O._tag === "standardWebhooks" && O.previousSecret === void 0 ? {
211
+ ...O,
212
+ previousSecret: u.previousSecret
213
+ } : O, { headers: s } = e(o, {
214
+ rawBody: a,
215
+ secret: u.secret,
216
+ messageId: x
217
+ });
218
+ process.env.NODE_ENV === "production" && l(u.url);
219
+ try {
220
+ let e = await fetch(u.url, {
221
+ method: "POST",
222
+ headers: {
223
+ "content-type": r.contentType,
224
+ ...s,
225
+ "x-voltro-event": C,
226
+ "x-voltro-event-id": w,
227
+ "x-voltro-delivery-id": x,
228
+ "x-voltro-attempt": String(c),
229
+ "idempotency-key": x,
230
+ ...k ?? {}
231
+ },
232
+ body: a,
233
+ redirect: "manual",
234
+ signal: AbortSignal.timeout(j)
235
+ }), n = (await e.text()).slice(0, 8192), i = e.headers.get("retry-after");
236
+ return {
237
+ status: e.status,
238
+ body: n,
239
+ latencyMs: Date.now() - t,
240
+ retryAfterSec: i !== null && /^\d+$/.test(i) ? Number(i) : void 0,
241
+ transportError: void 0,
242
+ encodeError: void 0
243
+ };
244
+ } catch (e) {
245
+ return {
246
+ status: 0,
247
+ body: "",
248
+ latencyMs: Date.now() - t,
249
+ retryAfterSec: void 0,
250
+ transportError: e.message,
251
+ encodeError: void 0
252
+ };
253
+ }
254
+ })
255
+ }), b = yield* d.make({
256
+ name: `classify-and-record-${c}`,
257
+ success: h,
258
+ execute: o.tryPromise({
259
+ try: async () => {
260
+ let e = y.status >= 200 && y.status < 300, n = y.transportError !== void 0, r = y.encodeError !== void 0, i = y.status === 410, o = (e, t) => a(p.store, S, e, t, /* @__PURE__ */ new Date(), !e && i);
261
+ if (e) {
262
+ await p.store.update("_voltro_webhook_deliveries", _, {
263
+ status: "succeeded",
264
+ responseStatus: y.status,
265
+ responseBody: y.body,
266
+ latencyMs: y.latencyMs,
267
+ updatedAt: /* @__PURE__ */ new Date()
268
+ });
269
+ let e = await o(!0, "");
270
+ return {
271
+ outcome: "succeeded",
272
+ delayMs: void 0,
273
+ reason: void 0,
274
+ autoDisabled: e.autoDisabled,
275
+ consecutiveFailures: e.consecutiveFailures
276
+ };
277
+ }
278
+ let s = r ? null : t(D, c, {
279
+ ...n ? {} : { status: y.status },
280
+ ...y.retryAfterSec === void 0 ? {} : { retryAfterSeconds: y.retryAfterSec }
281
+ });
282
+ if (s === null) {
283
+ let e = r ? `encode (${u.format}): ${y.encodeError}` : n ? `transport: ${y.transportError}` : `http ${y.status}: ${y.body.slice(0, 200)}`;
284
+ await p.store.update("_voltro_webhook_deliveries", _, {
285
+ status: "failed",
286
+ responseStatus: n || r ? null : y.status,
287
+ responseBody: n || r ? null : y.body,
288
+ errorMessage: r ? e : y.transportError ?? null,
289
+ latencyMs: y.latencyMs,
290
+ updatedAt: /* @__PURE__ */ new Date()
291
+ });
292
+ let t = await o(!1, e);
293
+ return {
294
+ outcome: "failed",
295
+ delayMs: void 0,
296
+ reason: e,
297
+ autoDisabled: t.autoDisabled,
298
+ consecutiveFailures: t.consecutiveFailures
299
+ };
300
+ }
301
+ return await p.store.update("_voltro_webhook_deliveries", _, {
302
+ status: "retryScheduled",
303
+ responseStatus: n ? null : y.status,
304
+ responseBody: n ? null : y.body,
305
+ errorMessage: y.transportError ?? null,
306
+ latencyMs: y.latencyMs,
307
+ nextAttemptAt: new Date(Date.now() + s.delayMs),
308
+ updatedAt: /* @__PURE__ */ new Date()
309
+ }), {
310
+ outcome: "retry",
311
+ delayMs: s.delayMs,
312
+ reason: void 0,
313
+ autoDisabled: void 0,
314
+ consecutiveFailures: void 0
315
+ };
316
+ },
317
+ catch: (e) => (E.debug("classify-and-record raw error", {
318
+ targetId: S,
319
+ event: C,
320
+ attempt: c,
321
+ cause: String(e)
322
+ }), /* @__PURE__ */ Error(`classify-and-record failed: ${e.message}`))
323
+ }).pipe(o.orDie)
324
+ });
325
+ if (b.autoDisabled === !0 && E.warn("target auto-disabled after consecutive failures", {
326
+ targetId: S,
327
+ event: C,
328
+ consecutiveFailures: b.consecutiveFailures ?? null,
329
+ reason: b.reason ?? null
330
+ }), b.outcome === "succeeded") return {
331
+ finalStatus: "succeeded",
332
+ attempts: c
333
+ };
334
+ if (b.outcome === "failed") return {
335
+ finalStatus: "failed",
336
+ attempts: c
337
+ };
338
+ b.delayMs !== void 0 && (yield* f.sleep({
339
+ name: `retry-sleep-${c}`,
340
+ duration: `${b.delayMs} millis`
341
+ }));
342
+ }
343
+ return {
344
+ finalStatus: "failed",
345
+ attempts: D.maxAttempts
346
+ };
347
+ });
348
+ };
349
+ //#endregion
350
+ export { y as DEFAULT_WEBHOOK_TIMEOUT_MS, x as buildDeliverWebhookExecute, v as deliverWebhookWorkflow };
package/dist/dsl.js ADDED
@@ -0,0 +1,14 @@
1
+ //#region src/dsl.ts
2
+ var e = (e) => ({
3
+ _tag: "incomingWebhook",
4
+ ...e
5
+ }), t = (e) => ({
6
+ _tag: "webhookProvider",
7
+ ...e
8
+ }), n = (e) => {
9
+ if (typeof e != "object" || !e) return !1;
10
+ let t = e._tag;
11
+ return t === "outgoingEvent" || t === "incomingWebhook" || t === "webhookProvider";
12
+ };
13
+ //#endregion
14
+ export { e as defineIncomingWebhook, t as defineWebhookProvider, n as isWebhookDescriptor };
package/dist/encode.js ADDED
@@ -0,0 +1,53 @@
1
+ import { WebhookPayloadUnrepresentable as e } from "./errors.js";
2
+ //#region src/encode.ts
3
+ var t = (t, r) => {
4
+ if (t === "json") return {
5
+ contentType: "application/json",
6
+ bytes: Buffer.from(r, "utf8")
7
+ };
8
+ let i;
9
+ try {
10
+ i = JSON.parse(r);
11
+ } catch (n) {
12
+ throw new e({
13
+ format: t,
14
+ reason: `payload is not valid JSON: ${n.message}`
15
+ });
16
+ }
17
+ return t === "form" ? {
18
+ contentType: "application/x-www-form-urlencoded",
19
+ bytes: Buffer.from(n(i), "utf8")
20
+ } : {
21
+ contentType: "application/xml",
22
+ bytes: Buffer.from(a(i), "utf8")
23
+ };
24
+ }, n = (t) => {
25
+ if (typeof t != "object" || !t || Array.isArray(t)) throw new e({
26
+ format: "form",
27
+ reason: `form encoding requires a JSON object at the top level, got ${l(t)}`
28
+ });
29
+ let n = new URLSearchParams();
30
+ for (let [e, i] of Object.entries(t)) r(n, e, i);
31
+ return n.toString();
32
+ }, r = (e, t, n) => {
33
+ if (n === null) e.append(t, "");
34
+ else if (Array.isArray(n)) n.forEach((n, i) => r(e, `${t}[${i}]`, n));
35
+ else if (typeof n == "object") for (let [i, a] of Object.entries(n)) r(e, `${t}[${i}]`, a);
36
+ else e.append(t, String(n));
37
+ }, i = /^[A-Za-z_][A-Za-z0-9_.-]*$/, a = (e) => `<?xml version="1.0" encoding="UTF-8"?>${o("webhook", e)}`, o = (t, n) => {
38
+ if (!i.test(t)) throw new e({
39
+ format: "xml",
40
+ reason: `object key ${JSON.stringify(t)} is not a valid XML element name`
41
+ });
42
+ return `<${t}>${s(t, n)}</${t}>`;
43
+ }, s = (e, t) => t == null ? "" : Array.isArray(t) ? t.map((e) => o("item", e)).join("") : typeof t == "object" ? Object.entries(t).map(([e, t]) => o(e, t)).join("") : c(String(t)), c = (e) => e.replace(/[&<>"']/g, (e) => {
44
+ switch (e) {
45
+ case "&": return "&amp;";
46
+ case "<": return "&lt;";
47
+ case ">": return "&gt;";
48
+ case "\"": return "&quot;";
49
+ default: return "&apos;";
50
+ }
51
+ }), l = (e) => e === null ? "null" : Array.isArray(e) ? "array" : typeof e;
52
+ //#endregion
53
+ export { t as encodePayload };
@@ -0,0 +1,14 @@
1
+ //#region src/eventAudience.ts
2
+ var e = (e) => e.webhook !== void 0, t = (e) => {
3
+ let t = e.webhook;
4
+ if (t !== void 0) return {
5
+ _tag: "outgoingEvent",
6
+ id: e.name,
7
+ payload: e.payload,
8
+ ...t.description === void 0 ? {} : { description: t.description },
9
+ ...t.version === void 0 ? {} : { version: t.version },
10
+ ...t.rateLimit === void 0 ? {} : { globalRateLimit: t.rateLimit }
11
+ };
12
+ };
13
+ //#endregion
14
+ export { t as asOutgoingEvent, e as hasWebhookAudience };
@@ -0,0 +1,49 @@
1
+ //#region src/idempotency.ts
2
+ var e = class {
3
+ capacity;
4
+ cache = /* @__PURE__ */ new Map();
5
+ constructor(e = 1e4) {
6
+ this.capacity = e;
7
+ }
8
+ evictExpired() {
9
+ let e = Date.now();
10
+ for (let [t, n] of this.cache) n.expiresAt < e && this.cache.delete(t);
11
+ }
12
+ claim(e, t) {
13
+ let n = this.cache.get(e);
14
+ if (n !== void 0 && n.expiresAt > Date.now()) return this.cache.delete(e), this.cache.set(e, n), n.value === "inflight" ? "inflight" : "duplicate";
15
+ if (this.cache.set(e, {
16
+ value: "inflight",
17
+ expiresAt: Date.now() + t
18
+ }), this.cache.size > this.capacity) {
19
+ let e = this.cache.keys().next().value;
20
+ e !== void 0 && this.cache.delete(e);
21
+ }
22
+ return "fresh";
23
+ }
24
+ commit(e) {
25
+ let t = this.cache.get(e);
26
+ t !== void 0 && this.cache.set(e, {
27
+ value: "processed",
28
+ expiresAt: t.expiresAt
29
+ });
30
+ }
31
+ release(e) {
32
+ this.cache.delete(e);
33
+ }
34
+ size() {
35
+ return this.evictExpired(), this.cache.size;
36
+ }
37
+ clear() {
38
+ this.cache.clear();
39
+ }
40
+ }, t = null, n = () => (t === null && (t = new e()), t), r = {
41
+ "5m": 3e5,
42
+ "1h": 36e5,
43
+ "6h": 216e5,
44
+ "1d": 864e5,
45
+ "7d": 6048e5,
46
+ "30d": 2592e6
47
+ }, i = (e) => e === void 0 ? r["7d"] : r[e] ?? r["7d"];
48
+ //#endregion
49
+ export { e as IdempotencyCache, n as getIdempotencyCache, i as parseTtl };