@objectstack/plugin-webhooks 7.5.0 → 7.6.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 +20 -32
- package/CHANGELOG.md +49 -0
- package/dist/chunk-HWFTXTTI.js +138 -0
- package/dist/chunk-HWFTXTTI.js.map +1 -0
- package/dist/chunk-KPKLAXNA.cjs +138 -0
- package/dist/chunk-KPKLAXNA.cjs.map +1 -0
- package/dist/index.cjs +62 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -325
- package/dist/index.d.ts +41 -325
- package/dist/index.js +52 -606
- package/dist/index.js.map +1 -1
- package/dist/schema.cjs +2 -6
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +5 -4764
- package/dist/schema.d.ts +5 -4764
- package/dist/schema.js +3 -7
- package/package.json +4 -11
- package/src/auto-enqueuer.test.ts +83 -116
- package/src/auto-enqueuer.ts +38 -27
- package/src/index.ts +13 -40
- package/src/schema.ts +11 -16
- package/src/webhook-outbox-plugin.ts +80 -296
- package/tsup.config.ts +1 -1
- package/dist/chunk-7HS5DLU2.js +0 -319
- package/dist/chunk-7HS5DLU2.js.map +0 -1
- package/dist/chunk-HF7CCDPB.cjs +0 -256
- package/dist/chunk-HF7CCDPB.cjs.map +0 -1
- package/dist/chunk-KNGLLSSP.js +0 -256
- package/dist/chunk-KNGLLSSP.js.map +0 -1
- package/dist/chunk-TDSI7UHY.cjs +0 -319
- package/dist/chunk-TDSI7UHY.cjs.map +0 -1
- package/dist/outbox-CIn7LSyB.d.cts +0 -155
- package/dist/outbox-CIn7LSyB.d.ts +0 -155
- package/dist/sql-outbox.cjs +0 -8
- package/dist/sql-outbox.cjs.map +0 -1
- package/dist/sql-outbox.d.cts +0 -55
- package/dist/sql-outbox.d.ts +0 -55
- package/dist/sql-outbox.js +0 -8
- package/dist/sql-outbox.js.map +0 -1
- package/src/dispatcher.test.ts +0 -324
- package/src/dispatcher.ts +0 -218
- package/src/http-sender.ts +0 -187
- package/src/memory-outbox.test.ts +0 -86
- package/src/memory-outbox.ts +0 -155
- package/src/outbox.ts +0 -175
- package/src/partition.ts +0 -19
- package/src/retention.test.ts +0 -116
- package/src/retention.ts +0 -144
- package/src/sql-outbox.test.ts +0 -490
- package/src/sql-outbox.ts +0 -343
- package/src/sys-webhook-delivery.object.ts +0 -224
package/dist/index.js
CHANGED
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
hashPartition
|
|
5
|
-
} from "./chunk-KNGLLSSP.js";
|
|
6
|
-
import {
|
|
7
|
-
SYS_WEBHOOK_DELIVERY,
|
|
8
|
-
SysWebhook,
|
|
9
|
-
SysWebhookDelivery
|
|
10
|
-
} from "./chunk-7HS5DLU2.js";
|
|
11
|
-
|
|
12
|
-
// src/webhook-outbox-plugin.ts
|
|
13
|
-
import { readEnvWithDeprecation } from "@objectstack/types";
|
|
2
|
+
SysWebhook
|
|
3
|
+
} from "./chunk-HWFTXTTI.js";
|
|
14
4
|
|
|
15
5
|
// src/auto-enqueuer.ts
|
|
16
6
|
var AutoEnqueuer = class {
|
|
17
|
-
constructor(engine, realtime,
|
|
7
|
+
constructor(engine, realtime, enqueue, opts = {}) {
|
|
18
8
|
this.engine = engine;
|
|
19
9
|
this.realtime = realtime;
|
|
20
|
-
this.
|
|
10
|
+
this.enqueue = enqueue;
|
|
21
11
|
this.subscriptions = /* @__PURE__ */ new Map();
|
|
22
12
|
this.running = false;
|
|
23
13
|
this.subscriptionsObject = opts.subscriptionsObject ?? "sys_webhook";
|
|
@@ -152,14 +142,15 @@ var AutoEnqueuer = class {
|
|
|
152
142
|
const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;
|
|
153
143
|
for (const sub of subs) {
|
|
154
144
|
if (!sub.triggers.has(trigger)) continue;
|
|
155
|
-
void this.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
145
|
+
void this.enqueue({
|
|
146
|
+
source: "webhook",
|
|
147
|
+
refId: sub.id,
|
|
148
|
+
dedupKey: `${sub.id}:${eventId}`,
|
|
149
|
+
label: event.type,
|
|
159
150
|
url: sub.url,
|
|
160
151
|
method: sub.method,
|
|
161
152
|
headers: sub.headers,
|
|
162
|
-
|
|
153
|
+
signingSecret: sub.secret,
|
|
163
154
|
timeoutMs: sub.timeoutMs,
|
|
164
155
|
payload: {
|
|
165
156
|
object: event.object,
|
|
@@ -204,436 +195,16 @@ function mapActionToTrigger(action) {
|
|
|
204
195
|
}
|
|
205
196
|
}
|
|
206
197
|
|
|
207
|
-
// src/http-sender.ts
|
|
208
|
-
import { createHmac, randomUUID } from "crypto";
|
|
209
|
-
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
210
|
-
var RESPONSE_BODY_CAP = 16 * 1024;
|
|
211
|
-
async function sendOnce(delivery, fetchImpl) {
|
|
212
|
-
const body = typeof delivery.payload === "string" ? delivery.payload : JSON.stringify(delivery.payload);
|
|
213
|
-
const headers = {
|
|
214
|
-
"Content-Type": "application/json",
|
|
215
|
-
"User-Agent": "ObjectStack-Webhooks/1.0",
|
|
216
|
-
"X-Objectstack-Event": delivery.eventType,
|
|
217
|
-
"X-Objectstack-Delivery": delivery.id,
|
|
218
|
-
"X-Objectstack-Attempt": String(delivery.attempts + 1),
|
|
219
|
-
...delivery.headers ?? {}
|
|
220
|
-
};
|
|
221
|
-
if (delivery.secret) {
|
|
222
|
-
const sig = createHmac("sha256", delivery.secret).update(body).digest("hex");
|
|
223
|
-
headers["X-Objectstack-Signature"] = `sha256=${sig}`;
|
|
224
|
-
}
|
|
225
|
-
const timeoutMs = delivery.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
226
|
-
const controller = new AbortController();
|
|
227
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
228
|
-
const start = Date.now();
|
|
229
|
-
try {
|
|
230
|
-
const res = await fetchImpl(delivery.url, {
|
|
231
|
-
method: delivery.method ?? "POST",
|
|
232
|
-
headers,
|
|
233
|
-
body,
|
|
234
|
-
signal: controller.signal
|
|
235
|
-
});
|
|
236
|
-
clearTimeout(timer);
|
|
237
|
-
const responseText = await safeReadBody(res);
|
|
238
|
-
const durationMs = Date.now() - start;
|
|
239
|
-
if (res.ok) {
|
|
240
|
-
return { success: true, httpStatus: res.status, responseBody: responseText, durationMs };
|
|
241
|
-
}
|
|
242
|
-
const retriable = res.status === 408 || res.status === 429 || res.status >= 500;
|
|
243
|
-
return {
|
|
244
|
-
success: false,
|
|
245
|
-
retriable,
|
|
246
|
-
httpStatus: res.status,
|
|
247
|
-
responseBody: responseText,
|
|
248
|
-
error: `HTTP ${res.status}`,
|
|
249
|
-
durationMs
|
|
250
|
-
};
|
|
251
|
-
} catch (err) {
|
|
252
|
-
clearTimeout(timer);
|
|
253
|
-
const durationMs = Date.now() - start;
|
|
254
|
-
const e = err;
|
|
255
|
-
const error = e?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : e?.message ?? String(err);
|
|
256
|
-
return { success: false, retriable: true, error, durationMs };
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
async function safeReadBody(res) {
|
|
260
|
-
try {
|
|
261
|
-
const text = await res.text();
|
|
262
|
-
return text.length > RESPONSE_BODY_CAP ? text.slice(0, RESPONSE_BODY_CAP) : text;
|
|
263
|
-
} catch {
|
|
264
|
-
return void 0;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
function nextRetryDelayMs(attemptsSoFar, rng = Math.random) {
|
|
268
|
-
const SCHEDULE = [1e3, 1e4, 6e4, 6e5, 36e5, 216e5, 864e5];
|
|
269
|
-
if (attemptsSoFar < 1 || attemptsSoFar > SCHEDULE.length) return null;
|
|
270
|
-
const base = SCHEDULE[attemptsSoFar - 1];
|
|
271
|
-
const jitter = 0.8 + rng() * 0.4;
|
|
272
|
-
return Math.floor(base * jitter);
|
|
273
|
-
}
|
|
274
|
-
function classifyAttempt(outcome, attemptsSoFar, now = Date.now(), rng) {
|
|
275
|
-
if (outcome.success) return outcome;
|
|
276
|
-
if (!outcome.retriable) {
|
|
277
|
-
return {
|
|
278
|
-
success: false,
|
|
279
|
-
httpStatus: outcome.httpStatus,
|
|
280
|
-
responseBody: outcome.responseBody,
|
|
281
|
-
error: outcome.error,
|
|
282
|
-
durationMs: outcome.durationMs,
|
|
283
|
-
dead: true
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
const delay = nextRetryDelayMs(attemptsSoFar + 1, rng);
|
|
287
|
-
if (delay === null) {
|
|
288
|
-
return {
|
|
289
|
-
success: false,
|
|
290
|
-
httpStatus: outcome.httpStatus,
|
|
291
|
-
responseBody: outcome.responseBody,
|
|
292
|
-
error: outcome.error,
|
|
293
|
-
durationMs: outcome.durationMs,
|
|
294
|
-
dead: true
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
return {
|
|
298
|
-
success: false,
|
|
299
|
-
httpStatus: outcome.httpStatus,
|
|
300
|
-
responseBody: outcome.responseBody,
|
|
301
|
-
error: outcome.error,
|
|
302
|
-
durationMs: outcome.durationMs,
|
|
303
|
-
nextRetryAt: now + delay
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// src/dispatcher.ts
|
|
308
|
-
var WebhookDispatcher = class {
|
|
309
|
-
constructor(options) {
|
|
310
|
-
this.running = false;
|
|
311
|
-
const intervalMs = options.intervalMs ?? 250;
|
|
312
|
-
const lockTtlMs = options.lockTtlMs ?? intervalMs * 5;
|
|
313
|
-
this.opts = {
|
|
314
|
-
nodeId: options.nodeId,
|
|
315
|
-
cluster: options.cluster,
|
|
316
|
-
outbox: options.outbox,
|
|
317
|
-
partitionCount: options.partitionCount ?? 8,
|
|
318
|
-
batchSize: options.batchSize ?? 32,
|
|
319
|
-
intervalMs,
|
|
320
|
-
lockTtlMs,
|
|
321
|
-
claimTtlMs: options.claimTtlMs ?? lockTtlMs * 2,
|
|
322
|
-
onAttempt: options.onAttempt,
|
|
323
|
-
fetchImpl: options.fetchImpl,
|
|
324
|
-
rng: options.rng,
|
|
325
|
-
logger: options.logger
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
/** Begin the periodic loop. Safe to call once; subsequent calls are no-ops. */
|
|
329
|
-
start() {
|
|
330
|
-
if (this.running) return;
|
|
331
|
-
this.running = true;
|
|
332
|
-
this.scheduleTick();
|
|
333
|
-
this.timer = setInterval(() => this.scheduleTick(), this.opts.intervalMs);
|
|
334
|
-
}
|
|
335
|
-
/** Stop the loop and wait for the in-flight tick to drain. */
|
|
336
|
-
async stop() {
|
|
337
|
-
if (!this.running) return;
|
|
338
|
-
this.running = false;
|
|
339
|
-
if (this.timer) {
|
|
340
|
-
clearInterval(this.timer);
|
|
341
|
-
this.timer = void 0;
|
|
342
|
-
}
|
|
343
|
-
if (this.inflightTick) {
|
|
344
|
-
try {
|
|
345
|
-
await this.inflightTick;
|
|
346
|
-
} catch {
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* Run one full tick (all partitions, single attempt each). Exposed for
|
|
352
|
-
* deterministic tests that want to step the dispatcher manually.
|
|
353
|
-
*/
|
|
354
|
-
async tick() {
|
|
355
|
-
await this.runTick();
|
|
356
|
-
}
|
|
357
|
-
scheduleTick() {
|
|
358
|
-
if (this.inflightTick) return;
|
|
359
|
-
this.inflightTick = this.runTick().catch((err) => {
|
|
360
|
-
this.opts.logger?.warn?.("webhook-dispatcher: tick failed", {
|
|
361
|
-
nodeId: this.opts.nodeId,
|
|
362
|
-
error: err?.message ?? String(err)
|
|
363
|
-
});
|
|
364
|
-
}).finally(() => {
|
|
365
|
-
this.inflightTick = void 0;
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
async runTick() {
|
|
369
|
-
const partitionCount = this.opts.partitionCount;
|
|
370
|
-
const offset = stableNodeOffset(this.opts.nodeId, partitionCount);
|
|
371
|
-
for (let step = 0; step < partitionCount; step++) {
|
|
372
|
-
const i = (offset + step) % partitionCount;
|
|
373
|
-
await this.runPartition(i);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
async runPartition(index) {
|
|
377
|
-
const key = `webhook.dispatcher.partition.${index}`;
|
|
378
|
-
const handle = await this.opts.cluster.lock.acquire(key, {
|
|
379
|
-
ttlMs: this.opts.lockTtlMs,
|
|
380
|
-
// waitMs=0 → fail-fast; we'll try this partition again next tick.
|
|
381
|
-
waitMs: 0
|
|
382
|
-
});
|
|
383
|
-
if (!handle) return;
|
|
384
|
-
try {
|
|
385
|
-
const claimed = await this.opts.outbox.claim({
|
|
386
|
-
nodeId: this.opts.nodeId,
|
|
387
|
-
limit: this.opts.batchSize,
|
|
388
|
-
partition: { index, count: this.opts.partitionCount },
|
|
389
|
-
claimTtlMs: this.opts.claimTtlMs
|
|
390
|
-
});
|
|
391
|
-
if (claimed.length === 0) return;
|
|
392
|
-
await handle.renew(this.opts.lockTtlMs);
|
|
393
|
-
for (const row of claimed) {
|
|
394
|
-
if (!handle.isHeld()) break;
|
|
395
|
-
await this.processRow(row);
|
|
396
|
-
}
|
|
397
|
-
} finally {
|
|
398
|
-
await handle.release();
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
async processRow(row) {
|
|
402
|
-
const fetchImpl = this.opts.fetchImpl ?? globalThis.fetch;
|
|
403
|
-
if (!fetchImpl) {
|
|
404
|
-
this.opts.logger?.warn?.("webhook-dispatcher: no fetch impl available", {
|
|
405
|
-
rowId: row.id
|
|
406
|
-
});
|
|
407
|
-
await this.opts.outbox.ack(row.id, {
|
|
408
|
-
success: false,
|
|
409
|
-
error: "no fetch implementation",
|
|
410
|
-
durationMs: 0,
|
|
411
|
-
dead: true
|
|
412
|
-
});
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
const outcome = await sendOnce(row, fetchImpl);
|
|
416
|
-
const result = classifyAttempt(outcome, row.attempts, Date.now(), this.opts.rng);
|
|
417
|
-
await this.opts.outbox.ack(row.id, result);
|
|
418
|
-
this.opts.onAttempt?.(row, result.success);
|
|
419
|
-
}
|
|
420
|
-
};
|
|
421
|
-
function stableNodeOffset(nodeId, partitionCount) {
|
|
422
|
-
let h = 0;
|
|
423
|
-
for (let i = 0; i < nodeId.length; i++) {
|
|
424
|
-
h = h * 31 + nodeId.charCodeAt(i) | 0;
|
|
425
|
-
}
|
|
426
|
-
return Math.abs(h) % partitionCount;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// src/memory-outbox.ts
|
|
430
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
431
|
-
var MemoryWebhookOutbox = class {
|
|
432
|
-
constructor() {
|
|
433
|
-
this.rows = /* @__PURE__ */ new Map();
|
|
434
|
-
/** Dedup index keyed by `${eventId}::${webhookId}` -> row id. */
|
|
435
|
-
this.dedup = /* @__PURE__ */ new Map();
|
|
436
|
-
}
|
|
437
|
-
async enqueue(input) {
|
|
438
|
-
const dedupKey = `${input.eventId}::${input.webhookId}`;
|
|
439
|
-
const existing = this.dedup.get(dedupKey);
|
|
440
|
-
if (existing) return existing;
|
|
441
|
-
const id = randomUUID2();
|
|
442
|
-
const now = Date.now();
|
|
443
|
-
const row = {
|
|
444
|
-
id,
|
|
445
|
-
webhookId: input.webhookId,
|
|
446
|
-
eventId: input.eventId,
|
|
447
|
-
eventType: input.eventType,
|
|
448
|
-
url: input.url,
|
|
449
|
-
method: input.method ?? "POST",
|
|
450
|
-
headers: input.headers,
|
|
451
|
-
secret: input.secret,
|
|
452
|
-
timeoutMs: input.timeoutMs,
|
|
453
|
-
payload: input.payload,
|
|
454
|
-
status: "pending",
|
|
455
|
-
attempts: 0,
|
|
456
|
-
createdAt: now,
|
|
457
|
-
updatedAt: now
|
|
458
|
-
};
|
|
459
|
-
this.rows.set(id, row);
|
|
460
|
-
this.dedup.set(dedupKey, id);
|
|
461
|
-
return id;
|
|
462
|
-
}
|
|
463
|
-
async claim(opts) {
|
|
464
|
-
const now = opts.now ?? Date.now();
|
|
465
|
-
const claimed = [];
|
|
466
|
-
for (const row of this.rows.values()) {
|
|
467
|
-
if (row.status === "in_flight" && row.claimedAt !== void 0 && now - row.claimedAt > opts.claimTtlMs) {
|
|
468
|
-
row.status = "pending";
|
|
469
|
-
row.claimedBy = void 0;
|
|
470
|
-
row.claimedAt = void 0;
|
|
471
|
-
row.updatedAt = now;
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
for (const row of this.rows.values()) {
|
|
475
|
-
if (claimed.length >= opts.limit) break;
|
|
476
|
-
if (row.status !== "pending") continue;
|
|
477
|
-
if (row.nextRetryAt !== void 0 && row.nextRetryAt > now) continue;
|
|
478
|
-
if (opts.partition) {
|
|
479
|
-
const p = hashPartition(row.webhookId, opts.partition.count);
|
|
480
|
-
if (p !== opts.partition.index) continue;
|
|
481
|
-
}
|
|
482
|
-
row.status = "in_flight";
|
|
483
|
-
row.claimedBy = opts.nodeId;
|
|
484
|
-
row.claimedAt = now;
|
|
485
|
-
row.updatedAt = now;
|
|
486
|
-
claimed.push({ ...row });
|
|
487
|
-
}
|
|
488
|
-
return claimed;
|
|
489
|
-
}
|
|
490
|
-
async ack(id, result) {
|
|
491
|
-
const row = this.rows.get(id);
|
|
492
|
-
if (!row) return;
|
|
493
|
-
const now = Date.now();
|
|
494
|
-
row.attempts += 1;
|
|
495
|
-
row.lastAttemptedAt = now;
|
|
496
|
-
row.updatedAt = now;
|
|
497
|
-
row.claimedBy = void 0;
|
|
498
|
-
row.claimedAt = void 0;
|
|
499
|
-
row.responseCode = result.httpStatus;
|
|
500
|
-
row.responseBody = result.responseBody;
|
|
501
|
-
let status;
|
|
502
|
-
if (result.success) {
|
|
503
|
-
status = "success";
|
|
504
|
-
row.nextRetryAt = void 0;
|
|
505
|
-
row.error = void 0;
|
|
506
|
-
} else if (result.dead) {
|
|
507
|
-
status = "dead";
|
|
508
|
-
row.error = result.error;
|
|
509
|
-
row.nextRetryAt = void 0;
|
|
510
|
-
} else {
|
|
511
|
-
status = "pending";
|
|
512
|
-
row.error = result.error;
|
|
513
|
-
row.nextRetryAt = result.nextRetryAt;
|
|
514
|
-
}
|
|
515
|
-
row.status = status;
|
|
516
|
-
}
|
|
517
|
-
async list(filter) {
|
|
518
|
-
const all = Array.from(this.rows.values()).map((r) => ({ ...r }));
|
|
519
|
-
return filter?.status ? all.filter((r) => r.status === filter.status) : all;
|
|
520
|
-
}
|
|
521
|
-
async redeliver(id) {
|
|
522
|
-
const row = this.rows.get(id);
|
|
523
|
-
if (!row) {
|
|
524
|
-
throw new RedeliverError(
|
|
525
|
-
`Delivery row '${id}' not found`,
|
|
526
|
-
"not_found"
|
|
527
|
-
);
|
|
528
|
-
}
|
|
529
|
-
if (row.status !== "success" && row.status !== "failed" && row.status !== "dead") {
|
|
530
|
-
throw new RedeliverError(
|
|
531
|
-
`Delivery row '${id}' is '${row.status}', expected one of: success, failed, dead`,
|
|
532
|
-
"not_eligible"
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
const now = Date.now();
|
|
536
|
-
row.status = "pending";
|
|
537
|
-
row.attempts = 0;
|
|
538
|
-
row.claimedBy = void 0;
|
|
539
|
-
row.claimedAt = void 0;
|
|
540
|
-
row.nextRetryAt = void 0;
|
|
541
|
-
row.error = void 0;
|
|
542
|
-
row.responseCode = void 0;
|
|
543
|
-
row.responseBody = void 0;
|
|
544
|
-
row.updatedAt = now;
|
|
545
|
-
return { ...row };
|
|
546
|
-
}
|
|
547
|
-
};
|
|
548
|
-
|
|
549
|
-
// src/retention.ts
|
|
550
|
-
var DEFAULTS = {
|
|
551
|
-
successTtlMs: 7 * 24 * 60 * 60 * 1e3,
|
|
552
|
-
deadTtlMs: 30 * 24 * 60 * 60 * 1e3,
|
|
553
|
-
sweepIntervalMs: 60 * 60 * 1e3
|
|
554
|
-
};
|
|
555
|
-
var DeliveryRetentionSweeper = class {
|
|
556
|
-
constructor(engine, opts = {}) {
|
|
557
|
-
this.engine = engine;
|
|
558
|
-
this.running = false;
|
|
559
|
-
this.objectName = opts.objectName ?? SYS_WEBHOOK_DELIVERY;
|
|
560
|
-
this.successTtlMs = opts.successTtlMs ?? DEFAULTS.successTtlMs;
|
|
561
|
-
this.deadTtlMs = opts.deadTtlMs ?? DEFAULTS.deadTtlMs;
|
|
562
|
-
this.sweepIntervalMs = opts.sweepIntervalMs ?? DEFAULTS.sweepIntervalMs;
|
|
563
|
-
this.logger = opts.logger ?? {};
|
|
564
|
-
}
|
|
565
|
-
start() {
|
|
566
|
-
if (this.running) return;
|
|
567
|
-
this.running = true;
|
|
568
|
-
this.timer = setInterval(() => {
|
|
569
|
-
this.sweep().catch(
|
|
570
|
-
(err) => this.logger.warn?.("[webhook-retention] sweep failed", err)
|
|
571
|
-
);
|
|
572
|
-
}, this.sweepIntervalMs);
|
|
573
|
-
this.timer.unref?.();
|
|
574
|
-
}
|
|
575
|
-
stop() {
|
|
576
|
-
if (!this.running) return;
|
|
577
|
-
this.running = false;
|
|
578
|
-
if (this.timer) clearInterval(this.timer);
|
|
579
|
-
this.timer = void 0;
|
|
580
|
-
}
|
|
581
|
-
/** Run one sweep immediately. Returns the number of rows deleted. */
|
|
582
|
-
async sweep(now = Date.now()) {
|
|
583
|
-
let successDeleted = 0;
|
|
584
|
-
let deadDeleted = 0;
|
|
585
|
-
if (this.successTtlMs > 0) {
|
|
586
|
-
try {
|
|
587
|
-
const res = await this.engine.delete(this.objectName, {
|
|
588
|
-
where: {
|
|
589
|
-
status: "success",
|
|
590
|
-
updated_at: { $lt: now - this.successTtlMs }
|
|
591
|
-
}
|
|
592
|
-
});
|
|
593
|
-
successDeleted = res?.affected ?? 0;
|
|
594
|
-
} catch (err) {
|
|
595
|
-
this.logger.warn?.("[webhook-retention] success sweep failed", err);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
if (this.deadTtlMs > 0) {
|
|
599
|
-
try {
|
|
600
|
-
const res = await this.engine.delete(this.objectName, {
|
|
601
|
-
where: {
|
|
602
|
-
status: "dead",
|
|
603
|
-
updated_at: { $lt: now - this.deadTtlMs }
|
|
604
|
-
}
|
|
605
|
-
});
|
|
606
|
-
deadDeleted = res?.affected ?? 0;
|
|
607
|
-
} catch (err) {
|
|
608
|
-
this.logger.warn?.("[webhook-retention] dead sweep failed", err);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
if (successDeleted + deadDeleted > 0) {
|
|
612
|
-
this.logger.info?.("[webhook-retention] sweep complete", {
|
|
613
|
-
success: successDeleted,
|
|
614
|
-
dead: deadDeleted
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
return { success: successDeleted, dead: deadDeleted };
|
|
618
|
-
}
|
|
619
|
-
};
|
|
620
|
-
|
|
621
198
|
// src/webhook-outbox-plugin.ts
|
|
622
199
|
var WebhookOutboxPlugin = class {
|
|
623
200
|
constructor(options = {}) {
|
|
624
201
|
this.options = options;
|
|
625
202
|
this.name = "com.objectstack.plugin-webhook-outbox";
|
|
626
|
-
this.version = "
|
|
203
|
+
this.version = "2.0.0";
|
|
627
204
|
this.type = "standard";
|
|
628
|
-
this.dependencies = ["com.objectstack.service.
|
|
205
|
+
this.dependencies = ["com.objectstack.service.messaging"];
|
|
629
206
|
}
|
|
630
207
|
async init(ctx) {
|
|
631
|
-
const cluster = ctx.getService("cluster");
|
|
632
|
-
if (!cluster) {
|
|
633
|
-
throw new Error(
|
|
634
|
-
'WebhookOutboxPlugin: required service "cluster" not found \u2014 register ClusterServicePlugin first'
|
|
635
|
-
);
|
|
636
|
-
}
|
|
637
208
|
const manifest = ctx.getService("manifest");
|
|
638
209
|
if (manifest && typeof manifest.register === "function") {
|
|
639
210
|
manifest.register({
|
|
@@ -642,13 +213,9 @@ var WebhookOutboxPlugin = class {
|
|
|
642
213
|
version: this.version,
|
|
643
214
|
type: "plugin",
|
|
644
215
|
scope: "system",
|
|
645
|
-
name: "Webhook
|
|
646
|
-
description: "Registers sys_webhook (configuration)
|
|
647
|
-
objects: [SysWebhook
|
|
648
|
-
// ADR-0029 D7 — contribute the Webhooks entries into the
|
|
649
|
-
// Setup app's `group_integrations` slot. The plugin owns these
|
|
650
|
-
// objects (K2.a), so it ships their menu too; when the plugin
|
|
651
|
-
// isn't installed the slot stays empty.
|
|
216
|
+
name: "Webhook Schemas",
|
|
217
|
+
description: "Registers sys_webhook (configuration). Deliveries use messaging's sys_http_delivery outbox.",
|
|
218
|
+
objects: [SysWebhook],
|
|
652
219
|
navigationContributions: [
|
|
653
220
|
{
|
|
654
221
|
app: "setup",
|
|
@@ -656,14 +223,14 @@ var WebhookOutboxPlugin = class {
|
|
|
656
223
|
priority: 100,
|
|
657
224
|
items: [
|
|
658
225
|
{ id: "nav_webhooks", type: "object", label: "Webhooks", objectName: "sys_webhook", icon: "webhook", requiresObject: "sys_webhook" },
|
|
659
|
-
{ id: "
|
|
226
|
+
{ id: "nav_http_deliveries", type: "object", label: "HTTP Deliveries", objectName: "sys_http_delivery", icon: "send", requiresObject: "sys_http_delivery" }
|
|
660
227
|
]
|
|
661
228
|
}
|
|
662
229
|
]
|
|
663
230
|
});
|
|
664
231
|
} else {
|
|
665
232
|
ctx.logger.warn?.(
|
|
666
|
-
"[webhook-outbox] manifest service unavailable \u2014 sys_webhook
|
|
233
|
+
"[webhook-outbox] manifest service unavailable \u2014 sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin."
|
|
667
234
|
);
|
|
668
235
|
}
|
|
669
236
|
if (typeof ctx.hook === "function") {
|
|
@@ -680,123 +247,51 @@ var WebhookOutboxPlugin = class {
|
|
|
680
247
|
}
|
|
681
248
|
});
|
|
682
249
|
}
|
|
683
|
-
const outbox = this.resolveOutbox(ctx);
|
|
684
|
-
this.outboxInstance = outbox;
|
|
685
|
-
const nodeId = this.options.nodeId ?? readEnvWithDeprecation("OS_NODE_ID", "OBJECTSTACK_NODE_ID") ?? `node-${Math.random().toString(36).slice(2, 10)}`;
|
|
686
|
-
const dispatcher = new WebhookDispatcher({
|
|
687
|
-
nodeId,
|
|
688
|
-
cluster,
|
|
689
|
-
outbox,
|
|
690
|
-
partitionCount: this.options.partitionCount,
|
|
691
|
-
batchSize: this.options.batchSize,
|
|
692
|
-
intervalMs: this.options.intervalMs,
|
|
693
|
-
lockTtlMs: this.options.lockTtlMs,
|
|
694
|
-
claimTtlMs: this.options.claimTtlMs,
|
|
695
|
-
fetchImpl: this.options.fetchImpl,
|
|
696
|
-
onAttempt: this.options.onAttempt,
|
|
697
|
-
rng: this.options.rng,
|
|
698
|
-
logger: ctx.logger
|
|
699
|
-
});
|
|
700
|
-
this.dispatcher = dispatcher;
|
|
701
|
-
ctx.registerService("webhook.outbox", outbox);
|
|
702
|
-
ctx.registerService("webhook.dispatcher", dispatcher);
|
|
703
|
-
if (this.options.autoStart !== false) {
|
|
704
|
-
dispatcher.start();
|
|
705
|
-
}
|
|
706
|
-
const usingMemoryOutbox = outbox instanceof MemoryWebhookOutbox;
|
|
707
|
-
if (usingMemoryOutbox && process.env.NODE_ENV === "production") {
|
|
708
|
-
ctx.logger.warn?.(
|
|
709
|
-
"[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."
|
|
710
|
-
);
|
|
711
|
-
}
|
|
712
250
|
const autoEnqueueOpt = this.options.autoEnqueue ?? true;
|
|
713
|
-
|
|
714
|
-
const needsReadyHook = autoEnqueueOpt !== false || retentionOpt !== false;
|
|
715
|
-
if (needsReadyHook && typeof ctx.hook === "function") {
|
|
251
|
+
if (typeof ctx.hook === "function") {
|
|
716
252
|
ctx.hook("kernel:ready", async () => {
|
|
717
253
|
await this.bootAutoEnqueue(ctx, autoEnqueueOpt);
|
|
718
|
-
this.bootRetention(ctx, retentionOpt);
|
|
719
|
-
});
|
|
720
|
-
}
|
|
721
|
-
if (typeof ctx.hook === "function") {
|
|
722
|
-
ctx.hook("kernel:ready", () => {
|
|
723
254
|
this.registerAdminRoutes(ctx);
|
|
724
255
|
});
|
|
725
256
|
}
|
|
726
|
-
ctx.logger.info?.("[webhook-outbox] initialised", {
|
|
727
|
-
|
|
728
|
-
partitions: this.options.partitionCount ?? 8,
|
|
729
|
-
interval: this.options.intervalMs ?? 250,
|
|
730
|
-
autoEnqueue: autoEnqueueOpt !== false,
|
|
731
|
-
retention: retentionOpt !== false
|
|
257
|
+
ctx.logger.info?.("[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)", {
|
|
258
|
+
autoEnqueue: autoEnqueueOpt !== false
|
|
732
259
|
});
|
|
733
260
|
}
|
|
734
261
|
async dispose() {
|
|
735
262
|
await this.autoEnqueuer?.stop();
|
|
736
|
-
this.retention?.stop();
|
|
737
|
-
await this.dispatcher?.stop();
|
|
738
263
|
}
|
|
739
|
-
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
return typeof opt === "function" ? opt(ctx) : opt;
|
|
743
|
-
}
|
|
744
|
-
const engine = this.tryGetService(ctx, ["objectql", "data"]);
|
|
745
|
-
if (engine) {
|
|
746
|
-
const partitionCount = this.options.partitionCount ?? 8;
|
|
747
|
-
const sql = new SqlWebhookOutbox(engine, { partitionCount });
|
|
748
|
-
ctx.logger.info?.(
|
|
749
|
-
"[webhook-outbox] auto-selected SqlWebhookOutbox (objectql engine detected)",
|
|
750
|
-
{ partitionCount }
|
|
751
|
-
);
|
|
752
|
-
return sql;
|
|
753
|
-
}
|
|
754
|
-
ctx.logger.warn?.(
|
|
755
|
-
"[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."
|
|
756
|
-
);
|
|
757
|
-
return new MemoryWebhookOutbox();
|
|
264
|
+
getMessaging(ctx) {
|
|
265
|
+
const svc = this.tryGetService(ctx, ["messaging"]);
|
|
266
|
+
return svc && typeof svc.enqueueHttp === "function" ? svc : void 0;
|
|
758
267
|
}
|
|
759
268
|
async bootAutoEnqueue(ctx, opt) {
|
|
760
269
|
if (opt === false) return;
|
|
761
270
|
const engine = this.tryGetService(ctx, ["objectql", "data"]);
|
|
762
271
|
const realtime = this.tryGetService(ctx, ["realtime"]);
|
|
763
|
-
|
|
272
|
+
const messaging = this.getMessaging(ctx);
|
|
273
|
+
if (!engine || !realtime || !messaging) {
|
|
764
274
|
ctx.logger.warn?.(
|
|
765
|
-
"[webhook-auto-enqueuer] disabled \u2014 ObjectQL or
|
|
766
|
-
{ hasEngine: !!engine, hasRealtime: !!realtime }
|
|
275
|
+
"[webhook-auto-enqueuer] disabled \u2014 ObjectQL, Realtime, or Messaging service not available",
|
|
276
|
+
{ hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging }
|
|
767
277
|
);
|
|
768
278
|
return;
|
|
769
279
|
}
|
|
770
|
-
if (!
|
|
280
|
+
if (!messaging.isHttpDeliveryReady()) {
|
|
281
|
+
ctx.logger.warn?.(
|
|
282
|
+
"[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) \u2014 webhook deliveries will not be durable"
|
|
283
|
+
);
|
|
284
|
+
}
|
|
771
285
|
const enqOpts = typeof opt === "object" ? opt : {};
|
|
772
286
|
this.autoEnqueuer = new AutoEnqueuer(
|
|
773
287
|
engine,
|
|
774
288
|
realtime,
|
|
775
|
-
|
|
289
|
+
(input) => messaging.enqueueHttp(input),
|
|
776
290
|
{ ...enqOpts, logger: ctx.logger }
|
|
777
291
|
);
|
|
778
292
|
await this.autoEnqueuer.start();
|
|
779
293
|
ctx.registerService("webhook.autoEnqueuer", this.autoEnqueuer);
|
|
780
|
-
ctx.logger.info?.("[webhook-auto-enqueuer] started");
|
|
781
|
-
}
|
|
782
|
-
bootRetention(ctx, opt) {
|
|
783
|
-
if (opt === false) return;
|
|
784
|
-
if (this.outboxInstance instanceof MemoryWebhookOutbox) return;
|
|
785
|
-
const engine = this.tryGetService(ctx, ["objectql", "data"]);
|
|
786
|
-
if (!engine) {
|
|
787
|
-
ctx.logger.warn?.(
|
|
788
|
-
"[webhook-retention] disabled \u2014 ObjectQL service not available"
|
|
789
|
-
);
|
|
790
|
-
return;
|
|
791
|
-
}
|
|
792
|
-
const retOpts = typeof opt === "object" ? opt : {};
|
|
793
|
-
this.retention = new DeliveryRetentionSweeper(engine, {
|
|
794
|
-
...retOpts,
|
|
795
|
-
logger: ctx.logger
|
|
796
|
-
});
|
|
797
|
-
this.retention.start();
|
|
798
|
-
ctx.registerService("webhook.retention", this.retention);
|
|
799
|
-
ctx.logger.info?.("[webhook-retention] sweeper started");
|
|
294
|
+
ctx.logger.info?.("[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)");
|
|
800
295
|
}
|
|
801
296
|
tryGetService(ctx, names) {
|
|
802
297
|
for (const n of names) {
|
|
@@ -809,31 +304,24 @@ var WebhookOutboxPlugin = class {
|
|
|
809
304
|
return void 0;
|
|
810
305
|
}
|
|
811
306
|
/**
|
|
812
|
-
* Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
* the better-auth session cookie — every authenticated user counts.
|
|
307
|
+
* Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is
|
|
308
|
+
* available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the
|
|
309
|
+
* better-auth session cookie — every authenticated user counts.
|
|
816
310
|
*/
|
|
817
311
|
registerAdminRoutes(ctx) {
|
|
818
312
|
const http = this.tryGetService(ctx, ["http-server"]);
|
|
819
313
|
if (!http || typeof http.getRawApp !== "function") {
|
|
820
|
-
ctx.logger.debug?.(
|
|
821
|
-
"[webhook-outbox] HTTP server not available; redeliver REST endpoint not mounted"
|
|
822
|
-
);
|
|
314
|
+
ctx.logger.debug?.("[webhook-outbox] HTTP server not available; redeliver endpoint not mounted");
|
|
823
315
|
return;
|
|
824
316
|
}
|
|
825
317
|
const rawApp = http.getRawApp();
|
|
826
|
-
const
|
|
827
|
-
if (!rawApp || !
|
|
318
|
+
const messaging = this.getMessaging(ctx);
|
|
319
|
+
if (!rawApp || !messaging) return;
|
|
828
320
|
rawApp.post("/api/v1/webhooks/redeliver", async (c) => {
|
|
829
321
|
const userId = await this.resolveSessionUserId(ctx, c);
|
|
830
322
|
if (!userId) {
|
|
831
323
|
return c.json(
|
|
832
|
-
{
|
|
833
|
-
success: false,
|
|
834
|
-
error: "unauthenticated",
|
|
835
|
-
message: "Sign in to redeliver webhook deliveries."
|
|
836
|
-
},
|
|
324
|
+
{ success: false, error: "unauthenticated", message: "Sign in to redeliver webhook deliveries." },
|
|
837
325
|
401
|
|
838
326
|
);
|
|
839
327
|
}
|
|
@@ -841,70 +329,36 @@ var WebhookOutboxPlugin = class {
|
|
|
841
329
|
try {
|
|
842
330
|
body = await c.req.json();
|
|
843
331
|
} catch {
|
|
844
|
-
return c.json(
|
|
845
|
-
{
|
|
846
|
-
success: false,
|
|
847
|
-
error: "invalid_body",
|
|
848
|
-
message: "Request body must be JSON."
|
|
849
|
-
},
|
|
850
|
-
400
|
|
851
|
-
);
|
|
332
|
+
return c.json({ success: false, error: "invalid_body", message: "Request body must be JSON." }, 400);
|
|
852
333
|
}
|
|
853
334
|
const deliveryId = typeof body?.deliveryId === "string" ? body.deliveryId.trim() : "";
|
|
854
335
|
if (!deliveryId) {
|
|
855
336
|
return c.json(
|
|
856
|
-
{
|
|
857
|
-
success: false,
|
|
858
|
-
error: "missing_delivery_id",
|
|
859
|
-
message: "Body must include `deliveryId: string`."
|
|
860
|
-
},
|
|
337
|
+
{ success: false, error: "missing_delivery_id", message: "Body must include `deliveryId: string`." },
|
|
861
338
|
400
|
|
862
339
|
);
|
|
863
340
|
}
|
|
864
341
|
try {
|
|
865
|
-
const row = await
|
|
866
|
-
ctx.logger.info?.("[webhook-outbox] redelivered", {
|
|
867
|
-
deliveryId,
|
|
868
|
-
requestedBy: userId
|
|
869
|
-
});
|
|
342
|
+
const row = await messaging.redeliverHttp(deliveryId);
|
|
343
|
+
ctx.logger.info?.("[webhook-outbox] redelivered", { deliveryId, requestedBy: userId });
|
|
870
344
|
return c.json({ success: true, data: { id: row.id, status: row.status } });
|
|
871
345
|
} catch (err) {
|
|
872
346
|
const code = err?.code;
|
|
873
347
|
if (code === "not_found") {
|
|
874
|
-
return c.json(
|
|
875
|
-
{ success: false, error: "not_found", message: err.message },
|
|
876
|
-
404
|
|
877
|
-
);
|
|
348
|
+
return c.json({ success: false, error: "not_found", message: err.message }, 404);
|
|
878
349
|
}
|
|
879
350
|
if (code === "not_eligible") {
|
|
880
|
-
return c.json(
|
|
881
|
-
{ success: false, error: "not_eligible", message: err.message },
|
|
882
|
-
409
|
|
883
|
-
);
|
|
351
|
+
return c.json({ success: false, error: "not_eligible", message: err.message }, 409);
|
|
884
352
|
}
|
|
885
|
-
ctx.logger.error?.(
|
|
886
|
-
"[webhook-outbox] redeliver failed",
|
|
887
|
-
err
|
|
888
|
-
);
|
|
353
|
+
ctx.logger.error?.("[webhook-outbox] redeliver failed", err);
|
|
889
354
|
return c.json(
|
|
890
|
-
{
|
|
891
|
-
success: false,
|
|
892
|
-
error: "internal_error",
|
|
893
|
-
message: err?.message ?? String(err)
|
|
894
|
-
},
|
|
355
|
+
{ success: false, error: "internal_error", message: err?.message ?? String(err) },
|
|
895
356
|
500
|
|
896
357
|
);
|
|
897
358
|
}
|
|
898
359
|
});
|
|
899
|
-
ctx.logger.info?.(
|
|
900
|
-
"[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver"
|
|
901
|
-
);
|
|
360
|
+
ctx.logger.info?.("[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver");
|
|
902
361
|
}
|
|
903
|
-
/**
|
|
904
|
-
* Resolve the requesting user's id from a better-auth session cookie.
|
|
905
|
-
* Returns `undefined` for anonymous callers — the caller decides
|
|
906
|
-
* whether that's a 401.
|
|
907
|
-
*/
|
|
908
362
|
async resolveSessionUserId(ctx, c) {
|
|
909
363
|
try {
|
|
910
364
|
const authService = this.tryGetService(ctx, ["auth"]);
|
|
@@ -924,15 +378,7 @@ var WebhookOutboxPlugin = class {
|
|
|
924
378
|
};
|
|
925
379
|
export {
|
|
926
380
|
AutoEnqueuer,
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
MemoryWebhookOutbox,
|
|
930
|
-
RedeliverError,
|
|
931
|
-
WebhookDispatcher,
|
|
932
|
-
WebhookOutboxPlugin,
|
|
933
|
-
classifyAttempt,
|
|
934
|
-
hashPartition,
|
|
935
|
-
nextRetryDelayMs,
|
|
936
|
-
sendOnce
|
|
381
|
+
SysWebhook,
|
|
382
|
+
WebhookOutboxPlugin
|
|
937
383
|
};
|
|
938
384
|
//# sourceMappingURL=index.js.map
|