@objectstack/plugin-webhooks 5.1.0 → 6.0.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.
- package/.turbo/turbo-build.log +35 -13
- package/CHANGELOG.md +17 -33
- package/dist/chunk-33LYZT7O.js +184 -0
- package/dist/chunk-33LYZT7O.js.map +1 -0
- package/dist/chunk-BS2QTZH3.js +256 -0
- package/dist/chunk-BS2QTZH3.js.map +1 -0
- package/dist/chunk-FA66GQEO.cjs +256 -0
- package/dist/chunk-FA66GQEO.cjs.map +1 -0
- package/dist/chunk-MJZGD37S.cjs +184 -0
- package/dist/chunk-MJZGD37S.cjs.map +1 -0
- package/dist/index.cjs +908 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +469 -0
- package/dist/index.d.ts +435 -70
- package/dist/index.js +872 -217
- package/dist/index.js.map +1 -1
- package/dist/outbox-CIn7LSyB.d.cts +155 -0
- package/dist/outbox-CIn7LSyB.d.ts +155 -0
- package/dist/schema.cjs +9 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +4787 -0
- package/dist/schema.d.ts +4787 -0
- package/dist/schema.js +9 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql-outbox.cjs +8 -0
- package/dist/sql-outbox.cjs.map +1 -0
- package/dist/sql-outbox.d.cts +55 -0
- package/dist/sql-outbox.d.ts +55 -0
- package/dist/sql-outbox.js +8 -0
- package/dist/sql-outbox.js.map +1 -0
- package/package.json +30 -10
- package/src/auto-enqueuer.test.ts +391 -0
- package/src/auto-enqueuer.ts +335 -0
- package/src/dispatcher.test.ts +324 -0
- package/src/dispatcher.ts +218 -0
- package/src/http-sender.ts +187 -0
- package/src/index.ts +49 -12
- package/src/memory-outbox.test.ts +86 -0
- package/src/memory-outbox.ts +155 -0
- package/src/outbox.ts +175 -0
- package/src/partition.ts +19 -0
- package/src/retention.test.ts +116 -0
- package/src/retention.ts +144 -0
- package/src/schema.ts +22 -0
- package/src/sql-outbox.test.ts +490 -0
- package/src/sql-outbox.ts +343 -0
- package/src/sys-webhook-delivery.object.ts +224 -0
- package/src/webhook-outbox-plugin.ts +442 -0
- package/tsconfig.json +5 -13
- package/tsup.config.ts +14 -0
- package/dist/index.d.mts +0 -104
- package/dist/index.mjs +0 -216
- package/dist/index.mjs.map +0 -1
- package/src/webhooks-plugin.test.ts +0 -218
- package/src/webhooks-plugin.ts +0 -294
package/dist/index.js
CHANGED
|
@@ -1,253 +1,908 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
-
};
|
|
12
|
-
var __copyProps = (to, from, except, desc) => {
|
|
13
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
-
for (let key of __getOwnPropNames(from))
|
|
15
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
-
}
|
|
18
|
-
return to;
|
|
19
|
-
};
|
|
20
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
-
mod
|
|
27
|
-
));
|
|
28
|
-
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
1
|
+
import {
|
|
2
|
+
RedeliverError,
|
|
3
|
+
SqlWebhookOutbox,
|
|
4
|
+
hashPartition
|
|
5
|
+
} from "./chunk-BS2QTZH3.js";
|
|
6
|
+
import {
|
|
7
|
+
SYS_WEBHOOK_DELIVERY,
|
|
8
|
+
SysWebhookDelivery
|
|
9
|
+
} from "./chunk-33LYZT7O.js";
|
|
29
10
|
|
|
30
|
-
// src/
|
|
31
|
-
|
|
32
|
-
__export(index_exports, {
|
|
33
|
-
WebhooksPlugin: () => WebhooksPlugin
|
|
34
|
-
});
|
|
35
|
-
module.exports = __toCommonJS(index_exports);
|
|
11
|
+
// src/webhook-outbox-plugin.ts
|
|
12
|
+
import { SysWebhook } from "@objectstack/platform-objects/integration";
|
|
36
13
|
|
|
37
|
-
// src/
|
|
38
|
-
var
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
this.
|
|
46
|
-
this.
|
|
47
|
-
this.
|
|
48
|
-
this.dependencies = ["com.objectstack.service.realtime"];
|
|
49
|
-
this.subscriptionIds = [];
|
|
50
|
-
this.sinks = [];
|
|
51
|
-
this.options = options;
|
|
14
|
+
// src/auto-enqueuer.ts
|
|
15
|
+
var AutoEnqueuer = class {
|
|
16
|
+
constructor(engine, realtime, outbox, opts = {}) {
|
|
17
|
+
this.engine = engine;
|
|
18
|
+
this.realtime = realtime;
|
|
19
|
+
this.outbox = outbox;
|
|
20
|
+
this.subscriptions = /* @__PURE__ */ new Map();
|
|
21
|
+
this.running = false;
|
|
22
|
+
this.subscriptionsObject = opts.subscriptionsObject ?? "sys_webhook";
|
|
23
|
+
this.refreshIntervalMs = opts.refreshIntervalMs ?? 6e4;
|
|
24
|
+
this.logger = opts.logger ?? {};
|
|
52
25
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Load the subscription cache and start listening for events.
|
|
28
|
+
*/
|
|
29
|
+
async start() {
|
|
30
|
+
if (this.running) return;
|
|
31
|
+
this.running = true;
|
|
32
|
+
await this.refresh();
|
|
33
|
+
this.subId = await this.realtime.subscribe(
|
|
34
|
+
"webhook-auto-enqueuer",
|
|
35
|
+
(event) => this.handleEvent(event)
|
|
36
|
+
);
|
|
37
|
+
this.subIdSelfHeal = await this.realtime.subscribe(
|
|
38
|
+
"webhook-auto-enqueuer-self-heal",
|
|
39
|
+
(event) => this.handleSelfHealEvent(event),
|
|
40
|
+
{ object: this.subscriptionsObject }
|
|
41
|
+
);
|
|
42
|
+
if (this.refreshIntervalMs > 0) {
|
|
43
|
+
this.refreshTimer = setInterval(() => {
|
|
44
|
+
this.refresh().catch(
|
|
45
|
+
(err) => this.logger.warn?.("[webhook-auto-enqueuer] periodic refresh failed", err)
|
|
46
|
+
);
|
|
47
|
+
}, this.refreshIntervalMs);
|
|
48
|
+
this.refreshTimer.unref?.();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async stop() {
|
|
52
|
+
if (!this.running) return;
|
|
53
|
+
this.running = false;
|
|
54
|
+
if (this.subId) await this.realtime.unsubscribe(this.subId);
|
|
55
|
+
if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);
|
|
56
|
+
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
57
|
+
this.subId = void 0;
|
|
58
|
+
this.subIdSelfHeal = void 0;
|
|
59
|
+
this.refreshTimer = void 0;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Force-refresh the subscription cache from storage. Concurrent
|
|
63
|
+
* callers share a single in-flight refresh.
|
|
64
|
+
*/
|
|
65
|
+
async refresh() {
|
|
66
|
+
if (this.refreshing) return this.refreshing;
|
|
67
|
+
this.refreshing = this.doRefresh().finally(() => {
|
|
68
|
+
this.refreshing = void 0;
|
|
69
|
+
});
|
|
70
|
+
return this.refreshing;
|
|
71
|
+
}
|
|
72
|
+
async doRefresh() {
|
|
73
|
+
let rows;
|
|
74
|
+
try {
|
|
75
|
+
rows = await this.engine.find(this.subscriptionsObject, {
|
|
76
|
+
where: { active: true }
|
|
77
|
+
});
|
|
78
|
+
} catch (err) {
|
|
79
|
+
this.logger.warn?.(
|
|
80
|
+
`[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,
|
|
81
|
+
err
|
|
59
82
|
);
|
|
60
83
|
return;
|
|
61
84
|
}
|
|
62
|
-
|
|
85
|
+
const next = /* @__PURE__ */ new Map();
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
const sub = this.parseRow(row);
|
|
88
|
+
if (!sub) continue;
|
|
89
|
+
const key = sub.objectName ?? "*";
|
|
90
|
+
const arr = next.get(key) ?? [];
|
|
91
|
+
arr.push(sub);
|
|
92
|
+
next.set(key, arr);
|
|
93
|
+
}
|
|
94
|
+
this.subscriptions.clear();
|
|
95
|
+
for (const [k, v] of next) this.subscriptions.set(k, v);
|
|
96
|
+
this.logger.debug?.("[webhook-auto-enqueuer] cache refreshed", {
|
|
97
|
+
objects: this.subscriptions.size,
|
|
98
|
+
rows: rows.length
|
|
99
|
+
});
|
|
63
100
|
}
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
101
|
+
parseRow(row) {
|
|
102
|
+
if (!row?.id || !row?.url) return null;
|
|
103
|
+
const triggersField = row.triggers ?? "";
|
|
104
|
+
const triggers = new Set(
|
|
105
|
+
triggersField.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
106
|
+
);
|
|
107
|
+
if (triggers.size === 0) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
let defn = {};
|
|
111
|
+
if (typeof row.definition_json === "string" && row.definition_json.length > 0) {
|
|
67
112
|
try {
|
|
68
|
-
|
|
113
|
+
defn = JSON.parse(row.definition_json) ?? {};
|
|
69
114
|
} catch {
|
|
70
|
-
|
|
71
|
-
return;
|
|
115
|
+
defn = {};
|
|
72
116
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
id: row.id,
|
|
120
|
+
name: row.name ?? row.id,
|
|
121
|
+
objectName: row.object_name ? String(row.object_name) : void 0,
|
|
122
|
+
triggers,
|
|
123
|
+
url: String(row.url),
|
|
124
|
+
method: row.method ?? defn.method ?? "POST",
|
|
125
|
+
headers: defn.headers,
|
|
126
|
+
secret: defn.secret,
|
|
127
|
+
timeoutMs: defn.timeoutMs
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Handler for the firehose subscription.
|
|
132
|
+
*
|
|
133
|
+
* NOTE: we intentionally `void` the inner enqueue() so the realtime
|
|
134
|
+
* publisher (and therefore the user's request) is never blocked on
|
|
135
|
+
* webhook persistence.
|
|
136
|
+
*/
|
|
137
|
+
handleEvent(event) {
|
|
138
|
+
if (!event.type?.startsWith("data.record.")) return;
|
|
139
|
+
if (!event.object) return;
|
|
140
|
+
if (event.object === this.subscriptionsObject) return;
|
|
141
|
+
const action = event.type.slice("data.record.".length);
|
|
142
|
+
const trigger = mapActionToTrigger(action);
|
|
143
|
+
if (!trigger) return;
|
|
144
|
+
const subs = [
|
|
145
|
+
...this.subscriptions.get(event.object) ?? [],
|
|
146
|
+
...this.subscriptions.get("*") ?? []
|
|
147
|
+
];
|
|
148
|
+
if (subs.length === 0) return;
|
|
149
|
+
const payload = event.payload ?? {};
|
|
150
|
+
const recordId = payload.recordId ?? payload.id ?? payload.after?.id ?? payload.before?.id ?? "unknown";
|
|
151
|
+
const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;
|
|
152
|
+
for (const sub of subs) {
|
|
153
|
+
if (!sub.triggers.has(trigger)) continue;
|
|
154
|
+
void this.outbox.enqueue({
|
|
155
|
+
webhookId: sub.id,
|
|
156
|
+
eventId,
|
|
157
|
+
eventType: event.type,
|
|
158
|
+
url: sub.url,
|
|
159
|
+
method: sub.method,
|
|
160
|
+
headers: sub.headers,
|
|
161
|
+
secret: sub.secret,
|
|
162
|
+
timeoutMs: sub.timeoutMs,
|
|
163
|
+
payload: {
|
|
164
|
+
object: event.object,
|
|
165
|
+
recordId,
|
|
166
|
+
action,
|
|
167
|
+
timestamp: event.timestamp,
|
|
168
|
+
...payload
|
|
169
|
+
}
|
|
170
|
+
}).catch(
|
|
171
|
+
(err) => this.logger.warn?.("[webhook-auto-enqueuer] enqueue failed", {
|
|
172
|
+
webhook: sub.name,
|
|
173
|
+
eventId,
|
|
174
|
+
err: err?.message ?? err
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
handleSelfHealEvent(event) {
|
|
180
|
+
if (event.object !== this.subscriptionsObject) return;
|
|
181
|
+
if (!event.type?.startsWith("data.record.")) return;
|
|
182
|
+
this.refresh().catch(
|
|
183
|
+
(err) => this.logger.warn?.("[webhook-auto-enqueuer] self-heal refresh failed", err)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
/** Test / admin accessor. */
|
|
187
|
+
snapshot() {
|
|
188
|
+
return this.subscriptions;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
function mapActionToTrigger(action) {
|
|
192
|
+
switch (action) {
|
|
193
|
+
case "created":
|
|
194
|
+
return "create";
|
|
195
|
+
case "updated":
|
|
196
|
+
return "update";
|
|
197
|
+
case "deleted":
|
|
198
|
+
return "delete";
|
|
199
|
+
case "undeleted":
|
|
200
|
+
return "undelete";
|
|
201
|
+
default:
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/http-sender.ts
|
|
207
|
+
import { createHmac, randomUUID } from "crypto";
|
|
208
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
209
|
+
var RESPONSE_BODY_CAP = 16 * 1024;
|
|
210
|
+
async function sendOnce(delivery, fetchImpl) {
|
|
211
|
+
const body = typeof delivery.payload === "string" ? delivery.payload : JSON.stringify(delivery.payload);
|
|
212
|
+
const headers = {
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
"User-Agent": "ObjectStack-Webhooks/1.0",
|
|
215
|
+
"X-Objectstack-Event": delivery.eventType,
|
|
216
|
+
"X-Objectstack-Delivery": delivery.id,
|
|
217
|
+
"X-Objectstack-Attempt": String(delivery.attempts + 1),
|
|
218
|
+
...delivery.headers ?? {}
|
|
219
|
+
};
|
|
220
|
+
if (delivery.secret) {
|
|
221
|
+
const sig = createHmac("sha256", delivery.secret).update(body).digest("hex");
|
|
222
|
+
headers["X-Objectstack-Signature"] = `sha256=${sig}`;
|
|
223
|
+
}
|
|
224
|
+
const timeoutMs = delivery.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
225
|
+
const controller = new AbortController();
|
|
226
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
227
|
+
const start = Date.now();
|
|
228
|
+
try {
|
|
229
|
+
const res = await fetchImpl(delivery.url, {
|
|
230
|
+
method: delivery.method ?? "POST",
|
|
231
|
+
headers,
|
|
232
|
+
body,
|
|
233
|
+
signal: controller.signal
|
|
88
234
|
});
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
const responseText = await safeReadBody(res);
|
|
237
|
+
const durationMs = Date.now() - start;
|
|
238
|
+
if (res.ok) {
|
|
239
|
+
return { success: true, httpStatus: res.status, responseBody: responseText, durationMs };
|
|
240
|
+
}
|
|
241
|
+
const retriable = res.status === 408 || res.status === 429 || res.status >= 500;
|
|
242
|
+
return {
|
|
243
|
+
success: false,
|
|
244
|
+
retriable,
|
|
245
|
+
httpStatus: res.status,
|
|
246
|
+
responseBody: responseText,
|
|
247
|
+
error: `HTTP ${res.status}`,
|
|
248
|
+
durationMs
|
|
249
|
+
};
|
|
250
|
+
} catch (err) {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
const durationMs = Date.now() - start;
|
|
253
|
+
const e = err;
|
|
254
|
+
const error = e?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : e?.message ?? String(err);
|
|
255
|
+
return { success: false, retriable: true, error, durationMs };
|
|
89
256
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
257
|
+
}
|
|
258
|
+
async function safeReadBody(res) {
|
|
259
|
+
try {
|
|
260
|
+
const text = await res.text();
|
|
261
|
+
return text.length > RESPONSE_BODY_CAP ? text.slice(0, RESPONSE_BODY_CAP) : text;
|
|
262
|
+
} catch {
|
|
263
|
+
return void 0;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function nextRetryDelayMs(attemptsSoFar, rng = Math.random) {
|
|
267
|
+
const SCHEDULE = [1e3, 1e4, 6e4, 6e5, 36e5, 216e5, 864e5];
|
|
268
|
+
if (attemptsSoFar < 1 || attemptsSoFar > SCHEDULE.length) return null;
|
|
269
|
+
const base = SCHEDULE[attemptsSoFar - 1];
|
|
270
|
+
const jitter = 0.8 + rng() * 0.4;
|
|
271
|
+
return Math.floor(base * jitter);
|
|
272
|
+
}
|
|
273
|
+
function classifyAttempt(outcome, attemptsSoFar, now = Date.now(), rng) {
|
|
274
|
+
if (outcome.success) return outcome;
|
|
275
|
+
if (!outcome.retriable) {
|
|
276
|
+
return {
|
|
277
|
+
success: false,
|
|
278
|
+
httpStatus: outcome.httpStatus,
|
|
279
|
+
responseBody: outcome.responseBody,
|
|
280
|
+
error: outcome.error,
|
|
281
|
+
durationMs: outcome.durationMs,
|
|
282
|
+
dead: true
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const delay = nextRetryDelayMs(attemptsSoFar + 1, rng);
|
|
286
|
+
if (delay === null) {
|
|
287
|
+
return {
|
|
288
|
+
success: false,
|
|
289
|
+
httpStatus: outcome.httpStatus,
|
|
290
|
+
responseBody: outcome.responseBody,
|
|
291
|
+
error: outcome.error,
|
|
292
|
+
durationMs: outcome.durationMs,
|
|
293
|
+
dead: true
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
success: false,
|
|
298
|
+
httpStatus: outcome.httpStatus,
|
|
299
|
+
responseBody: outcome.responseBody,
|
|
300
|
+
error: outcome.error,
|
|
301
|
+
durationMs: outcome.durationMs,
|
|
302
|
+
nextRetryAt: now + delay
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/dispatcher.ts
|
|
307
|
+
var WebhookDispatcher = class {
|
|
308
|
+
constructor(options) {
|
|
309
|
+
this.running = false;
|
|
310
|
+
const intervalMs = options.intervalMs ?? 250;
|
|
311
|
+
const lockTtlMs = options.lockTtlMs ?? intervalMs * 5;
|
|
312
|
+
this.opts = {
|
|
313
|
+
nodeId: options.nodeId,
|
|
314
|
+
cluster: options.cluster,
|
|
315
|
+
outbox: options.outbox,
|
|
316
|
+
partitionCount: options.partitionCount ?? 8,
|
|
317
|
+
batchSize: options.batchSize ?? 32,
|
|
318
|
+
intervalMs,
|
|
319
|
+
lockTtlMs,
|
|
320
|
+
claimTtlMs: options.claimTtlMs ?? lockTtlMs * 2,
|
|
321
|
+
onAttempt: options.onAttempt,
|
|
322
|
+
fetchImpl: options.fetchImpl,
|
|
323
|
+
rng: options.rng,
|
|
324
|
+
logger: options.logger
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/** Begin the periodic loop. Safe to call once; subsequent calls are no-ops. */
|
|
328
|
+
start() {
|
|
329
|
+
if (this.running) return;
|
|
330
|
+
this.running = true;
|
|
331
|
+
this.scheduleTick();
|
|
332
|
+
this.timer = setInterval(() => this.scheduleTick(), this.opts.intervalMs);
|
|
333
|
+
}
|
|
334
|
+
/** Stop the loop and wait for the in-flight tick to drain. */
|
|
335
|
+
async stop() {
|
|
336
|
+
if (!this.running) return;
|
|
337
|
+
this.running = false;
|
|
338
|
+
if (this.timer) {
|
|
339
|
+
clearInterval(this.timer);
|
|
340
|
+
this.timer = void 0;
|
|
341
|
+
}
|
|
342
|
+
if (this.inflightTick) {
|
|
93
343
|
try {
|
|
94
|
-
await this.
|
|
95
|
-
} catch
|
|
96
|
-
ctx.logger.debug("WebhooksPlugin: unsubscribe failed", { id, err });
|
|
344
|
+
await this.inflightTick;
|
|
345
|
+
} catch {
|
|
97
346
|
}
|
|
98
347
|
}
|
|
99
|
-
this.subscriptionIds = [];
|
|
100
348
|
}
|
|
101
349
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
350
|
+
* Run one full tick (all partitions, single attempt each). Exposed for
|
|
351
|
+
* deterministic tests that want to step the dispatcher manually.
|
|
104
352
|
*/
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const urlEnv = process.env.OBJECTSTACK_WEBHOOK_URL;
|
|
108
|
-
if (!urlEnv) return [];
|
|
109
|
-
const urls = urlEnv.split(",").map((s) => s.trim()).filter(Boolean);
|
|
110
|
-
const secret = process.env.OBJECTSTACK_WEBHOOK_SECRET;
|
|
111
|
-
const objectsEnv = process.env.OBJECTSTACK_WEBHOOK_OBJECTS;
|
|
112
|
-
const eventsEnv = process.env.OBJECTSTACK_WEBHOOK_EVENTS;
|
|
113
|
-
const objects = objectsEnv ? objectsEnv.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
114
|
-
const eventTypes = eventsEnv ? eventsEnv.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
115
|
-
return urls.map((url, idx) => ({
|
|
116
|
-
id: `env-${idx + 1}`,
|
|
117
|
-
url,
|
|
118
|
-
...secret ? { secret } : {},
|
|
119
|
-
...objects ? { objects } : {},
|
|
120
|
-
...eventTypes ? { eventTypes } : {}
|
|
121
|
-
}));
|
|
353
|
+
async tick() {
|
|
354
|
+
await this.runTick();
|
|
122
355
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
356
|
+
scheduleTick() {
|
|
357
|
+
if (this.inflightTick) return;
|
|
358
|
+
this.inflightTick = this.runTick().catch((err) => {
|
|
359
|
+
this.opts.logger?.warn?.("webhook-dispatcher: tick failed", {
|
|
360
|
+
nodeId: this.opts.nodeId,
|
|
361
|
+
error: err?.message ?? String(err)
|
|
362
|
+
});
|
|
363
|
+
}).finally(() => {
|
|
364
|
+
this.inflightTick = void 0;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
async runTick() {
|
|
368
|
+
const partitionCount = this.opts.partitionCount;
|
|
369
|
+
const offset = stableNodeOffset(this.opts.nodeId, partitionCount);
|
|
370
|
+
for (let step = 0; step < partitionCount; step++) {
|
|
371
|
+
const i = (offset + step) % partitionCount;
|
|
372
|
+
await this.runPartition(i);
|
|
132
373
|
}
|
|
133
|
-
|
|
134
|
-
|
|
374
|
+
}
|
|
375
|
+
async runPartition(index) {
|
|
376
|
+
const key = `webhook.dispatcher.partition.${index}`;
|
|
377
|
+
const handle = await this.opts.cluster.lock.acquire(key, {
|
|
378
|
+
ttlMs: this.opts.lockTtlMs,
|
|
379
|
+
// waitMs=0 → fail-fast; we'll try this partition again next tick.
|
|
380
|
+
waitMs: 0
|
|
381
|
+
});
|
|
382
|
+
if (!handle) return;
|
|
383
|
+
try {
|
|
384
|
+
const claimed = await this.opts.outbox.claim({
|
|
385
|
+
nodeId: this.opts.nodeId,
|
|
386
|
+
limit: this.opts.batchSize,
|
|
387
|
+
partition: { index, count: this.opts.partitionCount },
|
|
388
|
+
claimTtlMs: this.opts.claimTtlMs
|
|
389
|
+
});
|
|
390
|
+
if (claimed.length === 0) return;
|
|
391
|
+
await handle.renew(this.opts.lockTtlMs);
|
|
392
|
+
for (const row of claimed) {
|
|
393
|
+
if (!handle.isHeld()) break;
|
|
394
|
+
await this.processRow(row);
|
|
395
|
+
}
|
|
396
|
+
} finally {
|
|
397
|
+
await handle.release();
|
|
135
398
|
}
|
|
136
|
-
|
|
399
|
+
}
|
|
400
|
+
async processRow(row) {
|
|
401
|
+
const fetchImpl = this.opts.fetchImpl ?? globalThis.fetch;
|
|
137
402
|
if (!fetchImpl) {
|
|
138
|
-
this.logger?.warn("
|
|
403
|
+
this.opts.logger?.warn?.("webhook-dispatcher: no fetch impl available", {
|
|
404
|
+
rowId: row.id
|
|
405
|
+
});
|
|
406
|
+
await this.opts.outbox.ack(row.id, {
|
|
407
|
+
success: false,
|
|
408
|
+
error: "no fetch implementation",
|
|
409
|
+
durationMs: 0,
|
|
410
|
+
dead: true
|
|
411
|
+
});
|
|
139
412
|
return;
|
|
140
413
|
}
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
414
|
+
const outcome = await sendOnce(row, fetchImpl);
|
|
415
|
+
const result = classifyAttempt(outcome, row.attempts, Date.now(), this.opts.rng);
|
|
416
|
+
await this.opts.outbox.ack(row.id, result);
|
|
417
|
+
this.opts.onAttempt?.(row, result.success);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
function stableNodeOffset(nodeId, partitionCount) {
|
|
421
|
+
let h = 0;
|
|
422
|
+
for (let i = 0; i < nodeId.length; i++) {
|
|
423
|
+
h = h * 31 + nodeId.charCodeAt(i) | 0;
|
|
424
|
+
}
|
|
425
|
+
return Math.abs(h) % partitionCount;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/memory-outbox.ts
|
|
429
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
430
|
+
var MemoryWebhookOutbox = class {
|
|
431
|
+
constructor() {
|
|
432
|
+
this.rows = /* @__PURE__ */ new Map();
|
|
433
|
+
/** Dedup index keyed by `${eventId}::${webhookId}` -> row id. */
|
|
434
|
+
this.dedup = /* @__PURE__ */ new Map();
|
|
435
|
+
}
|
|
436
|
+
async enqueue(input) {
|
|
437
|
+
const dedupKey = `${input.eventId}::${input.webhookId}`;
|
|
438
|
+
const existing = this.dedup.get(dedupKey);
|
|
439
|
+
if (existing) return existing;
|
|
440
|
+
const id = randomUUID2();
|
|
441
|
+
const now = Date.now();
|
|
442
|
+
const row = {
|
|
443
|
+
id,
|
|
444
|
+
webhookId: input.webhookId,
|
|
445
|
+
eventId: input.eventId,
|
|
446
|
+
eventType: input.eventType,
|
|
447
|
+
url: input.url,
|
|
448
|
+
method: input.method ?? "POST",
|
|
449
|
+
headers: input.headers,
|
|
450
|
+
secret: input.secret,
|
|
451
|
+
timeoutMs: input.timeoutMs,
|
|
452
|
+
payload: input.payload,
|
|
453
|
+
status: "pending",
|
|
454
|
+
attempts: 0,
|
|
455
|
+
createdAt: now,
|
|
456
|
+
updatedAt: now
|
|
149
457
|
};
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
458
|
+
this.rows.set(id, row);
|
|
459
|
+
this.dedup.set(dedupKey, id);
|
|
460
|
+
return id;
|
|
461
|
+
}
|
|
462
|
+
async claim(opts) {
|
|
463
|
+
const now = opts.now ?? Date.now();
|
|
464
|
+
const claimed = [];
|
|
465
|
+
for (const row of this.rows.values()) {
|
|
466
|
+
if (row.status === "in_flight" && row.claimedAt !== void 0 && now - row.claimedAt > opts.claimTtlMs) {
|
|
467
|
+
row.status = "pending";
|
|
468
|
+
row.claimedBy = void 0;
|
|
469
|
+
row.claimedAt = void 0;
|
|
470
|
+
row.updatedAt = now;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
for (const row of this.rows.values()) {
|
|
474
|
+
if (claimed.length >= opts.limit) break;
|
|
475
|
+
if (row.status !== "pending") continue;
|
|
476
|
+
if (row.nextRetryAt !== void 0 && row.nextRetryAt > now) continue;
|
|
477
|
+
if (opts.partition) {
|
|
478
|
+
const p = hashPartition(row.webhookId, opts.partition.count);
|
|
479
|
+
if (p !== opts.partition.index) continue;
|
|
480
|
+
}
|
|
481
|
+
row.status = "in_flight";
|
|
482
|
+
row.claimedBy = opts.nodeId;
|
|
483
|
+
row.claimedAt = now;
|
|
484
|
+
row.updatedAt = now;
|
|
485
|
+
claimed.push({ ...row });
|
|
486
|
+
}
|
|
487
|
+
return claimed;
|
|
488
|
+
}
|
|
489
|
+
async ack(id, result) {
|
|
490
|
+
const row = this.rows.get(id);
|
|
491
|
+
if (!row) return;
|
|
492
|
+
const now = Date.now();
|
|
493
|
+
row.attempts += 1;
|
|
494
|
+
row.lastAttemptedAt = now;
|
|
495
|
+
row.updatedAt = now;
|
|
496
|
+
row.claimedBy = void 0;
|
|
497
|
+
row.claimedAt = void 0;
|
|
498
|
+
row.responseCode = result.httpStatus;
|
|
499
|
+
row.responseBody = result.responseBody;
|
|
500
|
+
let status;
|
|
501
|
+
if (result.success) {
|
|
502
|
+
status = "success";
|
|
503
|
+
row.nextRetryAt = void 0;
|
|
504
|
+
row.error = void 0;
|
|
505
|
+
} else if (result.dead) {
|
|
506
|
+
status = "dead";
|
|
507
|
+
row.error = result.error;
|
|
508
|
+
row.nextRetryAt = void 0;
|
|
509
|
+
} else {
|
|
510
|
+
status = "pending";
|
|
511
|
+
row.error = result.error;
|
|
512
|
+
row.nextRetryAt = result.nextRetryAt;
|
|
513
|
+
}
|
|
514
|
+
row.status = status;
|
|
515
|
+
}
|
|
516
|
+
async list(filter) {
|
|
517
|
+
const all = Array.from(this.rows.values()).map((r) => ({ ...r }));
|
|
518
|
+
return filter?.status ? all.filter((r) => r.status === filter.status) : all;
|
|
519
|
+
}
|
|
520
|
+
async redeliver(id) {
|
|
521
|
+
const row = this.rows.get(id);
|
|
522
|
+
if (!row) {
|
|
523
|
+
throw new RedeliverError(
|
|
524
|
+
`Delivery row '${id}' not found`,
|
|
525
|
+
"not_found"
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (row.status !== "success" && row.status !== "failed" && row.status !== "dead") {
|
|
529
|
+
throw new RedeliverError(
|
|
530
|
+
`Delivery row '${id}' is '${row.status}', expected one of: success, failed, dead`,
|
|
531
|
+
"not_eligible"
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
const now = Date.now();
|
|
535
|
+
row.status = "pending";
|
|
536
|
+
row.attempts = 0;
|
|
537
|
+
row.claimedBy = void 0;
|
|
538
|
+
row.claimedAt = void 0;
|
|
539
|
+
row.nextRetryAt = void 0;
|
|
540
|
+
row.error = void 0;
|
|
541
|
+
row.responseCode = void 0;
|
|
542
|
+
row.responseBody = void 0;
|
|
543
|
+
row.updatedAt = now;
|
|
544
|
+
return { ...row };
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// src/retention.ts
|
|
549
|
+
var DEFAULTS = {
|
|
550
|
+
successTtlMs: 7 * 24 * 60 * 60 * 1e3,
|
|
551
|
+
deadTtlMs: 30 * 24 * 60 * 60 * 1e3,
|
|
552
|
+
sweepIntervalMs: 60 * 60 * 1e3
|
|
553
|
+
};
|
|
554
|
+
var DeliveryRetentionSweeper = class {
|
|
555
|
+
constructor(engine, opts = {}) {
|
|
556
|
+
this.engine = engine;
|
|
557
|
+
this.running = false;
|
|
558
|
+
this.objectName = opts.objectName ?? SYS_WEBHOOK_DELIVERY;
|
|
559
|
+
this.successTtlMs = opts.successTtlMs ?? DEFAULTS.successTtlMs;
|
|
560
|
+
this.deadTtlMs = opts.deadTtlMs ?? DEFAULTS.deadTtlMs;
|
|
561
|
+
this.sweepIntervalMs = opts.sweepIntervalMs ?? DEFAULTS.sweepIntervalMs;
|
|
562
|
+
this.logger = opts.logger ?? {};
|
|
563
|
+
}
|
|
564
|
+
start() {
|
|
565
|
+
if (this.running) return;
|
|
566
|
+
this.running = true;
|
|
567
|
+
this.timer = setInterval(() => {
|
|
568
|
+
this.sweep().catch(
|
|
569
|
+
(err) => this.logger.warn?.("[webhook-retention] sweep failed", err)
|
|
570
|
+
);
|
|
571
|
+
}, this.sweepIntervalMs);
|
|
572
|
+
this.timer.unref?.();
|
|
573
|
+
}
|
|
574
|
+
stop() {
|
|
575
|
+
if (!this.running) return;
|
|
576
|
+
this.running = false;
|
|
577
|
+
if (this.timer) clearInterval(this.timer);
|
|
578
|
+
this.timer = void 0;
|
|
579
|
+
}
|
|
580
|
+
/** Run one sweep immediately. Returns the number of rows deleted. */
|
|
581
|
+
async sweep(now = Date.now()) {
|
|
582
|
+
let successDeleted = 0;
|
|
583
|
+
let deadDeleted = 0;
|
|
584
|
+
if (this.successTtlMs > 0) {
|
|
159
585
|
try {
|
|
160
|
-
const res = await
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
586
|
+
const res = await this.engine.delete(this.objectName, {
|
|
587
|
+
where: {
|
|
588
|
+
status: "success",
|
|
589
|
+
updated_at: { $lt: now - this.successTtlMs }
|
|
590
|
+
}
|
|
165
591
|
});
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
});
|
|
178
|
-
if (status === "failed") {
|
|
179
|
-
this.logger?.warn("WebhooksPlugin: sink rejected event", {
|
|
180
|
-
sinkId: sink.id,
|
|
181
|
-
status: res.status,
|
|
182
|
-
eventType: event.type
|
|
183
|
-
});
|
|
592
|
+
successDeleted = res?.affected ?? 0;
|
|
593
|
+
} catch (err) {
|
|
594
|
+
this.logger.warn?.("[webhook-retention] success sweep failed", err);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (this.deadTtlMs > 0) {
|
|
598
|
+
try {
|
|
599
|
+
const res = await this.engine.delete(this.objectName, {
|
|
600
|
+
where: {
|
|
601
|
+
status: "dead",
|
|
602
|
+
updated_at: { $lt: now - this.deadTtlMs }
|
|
184
603
|
}
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
if (attempt === maxAttempts) {
|
|
188
|
-
this.options.onDelivery?.({
|
|
189
|
-
sinkId: sink.id,
|
|
190
|
-
url: sink.url,
|
|
191
|
-
eventType: event.type,
|
|
192
|
-
object: event.object,
|
|
193
|
-
status: "failed",
|
|
194
|
-
httpStatus: res.status,
|
|
195
|
-
attempt
|
|
196
|
-
});
|
|
197
|
-
this.logger?.warn("WebhooksPlugin: max retries exhausted", {
|
|
198
|
-
sinkId: sink.id,
|
|
199
|
-
status: res.status,
|
|
200
|
-
eventType: event.type
|
|
201
|
-
});
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
this.options.onDelivery?.({
|
|
205
|
-
sinkId: sink.id,
|
|
206
|
-
url: sink.url,
|
|
207
|
-
eventType: event.type,
|
|
208
|
-
object: event.object,
|
|
209
|
-
status: "retrying",
|
|
210
|
-
httpStatus: res.status,
|
|
211
|
-
attempt
|
|
212
604
|
});
|
|
605
|
+
deadDeleted = res?.affected ?? 0;
|
|
213
606
|
} catch (err) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
607
|
+
this.logger.warn?.("[webhook-retention] dead sweep failed", err);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (successDeleted + deadDeleted > 0) {
|
|
611
|
+
this.logger.info?.("[webhook-retention] sweep complete", {
|
|
612
|
+
success: successDeleted,
|
|
613
|
+
dead: deadDeleted
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return { success: successDeleted, dead: deadDeleted };
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// src/webhook-outbox-plugin.ts
|
|
621
|
+
var WebhookOutboxPlugin = class {
|
|
622
|
+
constructor(options = {}) {
|
|
623
|
+
this.options = options;
|
|
624
|
+
this.name = "com.objectstack.plugin-webhook-outbox";
|
|
625
|
+
this.version = "1.1.0";
|
|
626
|
+
this.type = "standard";
|
|
627
|
+
this.dependencies = ["com.objectstack.service.cluster"];
|
|
628
|
+
}
|
|
629
|
+
async init(ctx) {
|
|
630
|
+
const cluster = ctx.getService("cluster");
|
|
631
|
+
if (!cluster) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
'WebhookOutboxPlugin: required service "cluster" not found \u2014 register ClusterServicePlugin first'
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
const manifest = ctx.getService("manifest");
|
|
637
|
+
if (manifest && typeof manifest.register === "function") {
|
|
638
|
+
manifest.register({
|
|
639
|
+
id: "com.objectstack.plugin-webhook-outbox.schema",
|
|
640
|
+
namespace: "sys",
|
|
641
|
+
version: this.version,
|
|
642
|
+
type: "plugin",
|
|
643
|
+
scope: "system",
|
|
644
|
+
name: "Webhook Outbox Schemas",
|
|
645
|
+
description: "Registers sys_webhook (configuration) and sys_webhook_delivery (durable outbox telemetry).",
|
|
646
|
+
objects: [SysWebhook, SysWebhookDelivery]
|
|
647
|
+
});
|
|
648
|
+
} else {
|
|
649
|
+
ctx.logger.warn?.(
|
|
650
|
+
"[webhook-outbox] manifest service unavailable \u2014 sys_webhook / sys_webhook_delivery will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin."
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
const outbox = this.resolveOutbox(ctx);
|
|
654
|
+
this.outboxInstance = outbox;
|
|
655
|
+
const nodeId = this.options.nodeId ?? process.env.OBJECTSTACK_NODE_ID ?? `node-${Math.random().toString(36).slice(2, 10)}`;
|
|
656
|
+
const dispatcher = new WebhookDispatcher({
|
|
657
|
+
nodeId,
|
|
658
|
+
cluster,
|
|
659
|
+
outbox,
|
|
660
|
+
partitionCount: this.options.partitionCount,
|
|
661
|
+
batchSize: this.options.batchSize,
|
|
662
|
+
intervalMs: this.options.intervalMs,
|
|
663
|
+
lockTtlMs: this.options.lockTtlMs,
|
|
664
|
+
claimTtlMs: this.options.claimTtlMs,
|
|
665
|
+
fetchImpl: this.options.fetchImpl,
|
|
666
|
+
onAttempt: this.options.onAttempt,
|
|
667
|
+
rng: this.options.rng,
|
|
668
|
+
logger: ctx.logger
|
|
669
|
+
});
|
|
670
|
+
this.dispatcher = dispatcher;
|
|
671
|
+
ctx.registerService("webhook.outbox", outbox);
|
|
672
|
+
ctx.registerService("webhook.dispatcher", dispatcher);
|
|
673
|
+
if (this.options.autoStart !== false) {
|
|
674
|
+
dispatcher.start();
|
|
675
|
+
}
|
|
676
|
+
const usingMemoryOutbox = outbox instanceof MemoryWebhookOutbox;
|
|
677
|
+
if (usingMemoryOutbox && process.env.NODE_ENV === "production") {
|
|
678
|
+
ctx.logger.warn?.(
|
|
679
|
+
"[webhook-outbox] MemoryWebhookOutbox in production \u2014 webhook deliveries WILL be lost on process exit. Ensure ObjectQL is registered before WebhookOutboxPlugin so the SQL outbox can auto-select."
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
const autoEnqueueOpt = this.options.autoEnqueue ?? true;
|
|
683
|
+
const retentionOpt = this.options.retention ?? true;
|
|
684
|
+
const needsReadyHook = autoEnqueueOpt !== false || retentionOpt !== false;
|
|
685
|
+
if (needsReadyHook && typeof ctx.hook === "function") {
|
|
686
|
+
ctx.hook("kernel:ready", async () => {
|
|
687
|
+
await this.bootAutoEnqueue(ctx, autoEnqueueOpt);
|
|
688
|
+
this.bootRetention(ctx, retentionOpt);
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
if (typeof ctx.hook === "function") {
|
|
692
|
+
ctx.hook("kernel:ready", () => {
|
|
693
|
+
this.registerAdminRoutes(ctx);
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
ctx.logger.info?.("[webhook-outbox] initialised", {
|
|
697
|
+
nodeId,
|
|
698
|
+
partitions: this.options.partitionCount ?? 8,
|
|
699
|
+
interval: this.options.intervalMs ?? 250,
|
|
700
|
+
autoEnqueue: autoEnqueueOpt !== false,
|
|
701
|
+
retention: retentionOpt !== false
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
async dispose() {
|
|
705
|
+
await this.autoEnqueuer?.stop();
|
|
706
|
+
this.retention?.stop();
|
|
707
|
+
await this.dispatcher?.stop();
|
|
708
|
+
}
|
|
709
|
+
resolveOutbox(ctx) {
|
|
710
|
+
const opt = this.options.outbox;
|
|
711
|
+
if (opt) {
|
|
712
|
+
return typeof opt === "function" ? opt(ctx) : opt;
|
|
713
|
+
}
|
|
714
|
+
const engine = this.tryGetService(ctx, ["objectql", "data"]);
|
|
715
|
+
if (engine) {
|
|
716
|
+
const partitionCount = this.options.partitionCount ?? 8;
|
|
717
|
+
const sql = new SqlWebhookOutbox(engine, { partitionCount });
|
|
718
|
+
ctx.logger.info?.(
|
|
719
|
+
"[webhook-outbox] auto-selected SqlWebhookOutbox (objectql engine detected)",
|
|
720
|
+
{ partitionCount }
|
|
721
|
+
);
|
|
722
|
+
return sql;
|
|
723
|
+
}
|
|
724
|
+
ctx.logger.warn?.(
|
|
725
|
+
"[webhook-outbox] no IDataEngine available \u2014 falling back to MemoryWebhookOutbox. Deliveries will NOT survive process restart and the redeliver REST endpoint will not see rows written through ObjectQL."
|
|
726
|
+
);
|
|
727
|
+
return new MemoryWebhookOutbox();
|
|
728
|
+
}
|
|
729
|
+
async bootAutoEnqueue(ctx, opt) {
|
|
730
|
+
if (opt === false) return;
|
|
731
|
+
const engine = this.tryGetService(ctx, ["objectql", "data"]);
|
|
732
|
+
const realtime = this.tryGetService(ctx, ["realtime"]);
|
|
733
|
+
if (!engine || !realtime) {
|
|
734
|
+
ctx.logger.warn?.(
|
|
735
|
+
"[webhook-auto-enqueuer] disabled \u2014 ObjectQL or Realtime service not available",
|
|
736
|
+
{ hasEngine: !!engine, hasRealtime: !!realtime }
|
|
737
|
+
);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (!this.outboxInstance) return;
|
|
741
|
+
const enqOpts = typeof opt === "object" ? opt : {};
|
|
742
|
+
this.autoEnqueuer = new AutoEnqueuer(
|
|
743
|
+
engine,
|
|
744
|
+
realtime,
|
|
745
|
+
this.outboxInstance,
|
|
746
|
+
{ ...enqOpts, logger: ctx.logger }
|
|
747
|
+
);
|
|
748
|
+
await this.autoEnqueuer.start();
|
|
749
|
+
ctx.registerService("webhook.autoEnqueuer", this.autoEnqueuer);
|
|
750
|
+
ctx.logger.info?.("[webhook-auto-enqueuer] started");
|
|
751
|
+
}
|
|
752
|
+
bootRetention(ctx, opt) {
|
|
753
|
+
if (opt === false) return;
|
|
754
|
+
if (this.outboxInstance instanceof MemoryWebhookOutbox) return;
|
|
755
|
+
const engine = this.tryGetService(ctx, ["objectql", "data"]);
|
|
756
|
+
if (!engine) {
|
|
757
|
+
ctx.logger.warn?.(
|
|
758
|
+
"[webhook-retention] disabled \u2014 ObjectQL service not available"
|
|
759
|
+
);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const retOpts = typeof opt === "object" ? opt : {};
|
|
763
|
+
this.retention = new DeliveryRetentionSweeper(engine, {
|
|
764
|
+
...retOpts,
|
|
765
|
+
logger: ctx.logger
|
|
766
|
+
});
|
|
767
|
+
this.retention.start();
|
|
768
|
+
ctx.registerService("webhook.retention", this.retention);
|
|
769
|
+
ctx.logger.info?.("[webhook-retention] sweeper started");
|
|
770
|
+
}
|
|
771
|
+
tryGetService(ctx, names) {
|
|
772
|
+
for (const n of names) {
|
|
773
|
+
try {
|
|
774
|
+
const svc = ctx.getService(n);
|
|
775
|
+
if (svc) return svc;
|
|
776
|
+
} catch {
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return void 0;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one
|
|
783
|
+
* is available. Silently no-ops in environments without an HTTP
|
|
784
|
+
* server (MSW, edge tests, pure library use). Auth is delegated to
|
|
785
|
+
* the better-auth session cookie — every authenticated user counts.
|
|
786
|
+
*/
|
|
787
|
+
registerAdminRoutes(ctx) {
|
|
788
|
+
const http = this.tryGetService(ctx, ["http-server"]);
|
|
789
|
+
if (!http || typeof http.getRawApp !== "function") {
|
|
790
|
+
ctx.logger.debug?.(
|
|
791
|
+
"[webhook-outbox] HTTP server not available; redeliver REST endpoint not mounted"
|
|
792
|
+
);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const rawApp = http.getRawApp();
|
|
796
|
+
const outbox = this.outboxInstance;
|
|
797
|
+
if (!rawApp || !outbox) return;
|
|
798
|
+
rawApp.post("/api/v1/webhooks/redeliver", async (c) => {
|
|
799
|
+
const userId = await this.resolveSessionUserId(ctx, c);
|
|
800
|
+
if (!userId) {
|
|
801
|
+
return c.json(
|
|
802
|
+
{
|
|
803
|
+
success: false,
|
|
804
|
+
error: "unauthenticated",
|
|
805
|
+
message: "Sign in to redeliver webhook deliveries."
|
|
806
|
+
},
|
|
807
|
+
401
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
let body;
|
|
811
|
+
try {
|
|
812
|
+
body = await c.req.json();
|
|
813
|
+
} catch {
|
|
814
|
+
return c.json(
|
|
815
|
+
{
|
|
816
|
+
success: false,
|
|
817
|
+
error: "invalid_body",
|
|
818
|
+
message: "Request body must be JSON."
|
|
819
|
+
},
|
|
820
|
+
400
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
const deliveryId = typeof body?.deliveryId === "string" ? body.deliveryId.trim() : "";
|
|
824
|
+
if (!deliveryId) {
|
|
825
|
+
return c.json(
|
|
826
|
+
{
|
|
827
|
+
success: false,
|
|
828
|
+
error: "missing_delivery_id",
|
|
829
|
+
message: "Body must include `deliveryId: string`."
|
|
830
|
+
},
|
|
831
|
+
400
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
const row = await outbox.redeliver(deliveryId);
|
|
836
|
+
ctx.logger.info?.("[webhook-outbox] redelivered", {
|
|
837
|
+
deliveryId,
|
|
838
|
+
requestedBy: userId
|
|
241
839
|
});
|
|
840
|
+
return c.json({ success: true, data: { id: row.id, status: row.status } });
|
|
841
|
+
} catch (err) {
|
|
842
|
+
const code = err?.code;
|
|
843
|
+
if (code === "not_found") {
|
|
844
|
+
return c.json(
|
|
845
|
+
{ success: false, error: "not_found", message: err.message },
|
|
846
|
+
404
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
if (code === "not_eligible") {
|
|
850
|
+
return c.json(
|
|
851
|
+
{ success: false, error: "not_eligible", message: err.message },
|
|
852
|
+
409
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
ctx.logger.error?.(
|
|
856
|
+
"[webhook-outbox] redeliver failed",
|
|
857
|
+
err
|
|
858
|
+
);
|
|
859
|
+
return c.json(
|
|
860
|
+
{
|
|
861
|
+
success: false,
|
|
862
|
+
error: "internal_error",
|
|
863
|
+
message: err?.message ?? String(err)
|
|
864
|
+
},
|
|
865
|
+
500
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
ctx.logger.info?.(
|
|
870
|
+
"[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver"
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Resolve the requesting user's id from a better-auth session cookie.
|
|
875
|
+
* Returns `undefined` for anonymous callers — the caller decides
|
|
876
|
+
* whether that's a 401.
|
|
877
|
+
*/
|
|
878
|
+
async resolveSessionUserId(ctx, c) {
|
|
879
|
+
try {
|
|
880
|
+
const authService = this.tryGetService(ctx, ["auth"]);
|
|
881
|
+
if (!authService) return void 0;
|
|
882
|
+
let api = authService.api;
|
|
883
|
+
if (!api && typeof authService.getApi === "function") {
|
|
884
|
+
api = await authService.getApi();
|
|
242
885
|
}
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
886
|
+
if (!api?.getSession) return void 0;
|
|
887
|
+
const session = await api.getSession({ headers: c.req.raw.headers });
|
|
888
|
+
const uid = session?.user?.id;
|
|
889
|
+
return typeof uid === "string" && uid.length > 0 ? uid : void 0;
|
|
890
|
+
} catch {
|
|
891
|
+
return void 0;
|
|
246
892
|
}
|
|
247
893
|
}
|
|
248
894
|
};
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
895
|
+
export {
|
|
896
|
+
AutoEnqueuer,
|
|
897
|
+
DEFAULT_TIMEOUT_MS,
|
|
898
|
+
DeliveryRetentionSweeper,
|
|
899
|
+
MemoryWebhookOutbox,
|
|
900
|
+
RedeliverError,
|
|
901
|
+
WebhookDispatcher,
|
|
902
|
+
WebhookOutboxPlugin,
|
|
903
|
+
classifyAttempt,
|
|
904
|
+
hashPartition,
|
|
905
|
+
nextRetryDelayMs,
|
|
906
|
+
sendOnce
|
|
907
|
+
};
|
|
253
908
|
//# sourceMappingURL=index.js.map
|