@objectstack/plugin-webhooks 5.0.0 → 5.2.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 +9 -28
- package/dist/chunk-JN76ZRWN.js +164 -0
- package/dist/chunk-JN76ZRWN.js.map +1 -0
- package/dist/chunk-M4M5FWIH.cjs +15 -0
- package/dist/chunk-M4M5FWIH.cjs.map +1 -0
- package/dist/chunk-NYSUNT6X.js +15 -0
- package/dist/chunk-NYSUNT6X.js.map +1 -0
- package/dist/chunk-OW7ESXOK.cjs +164 -0
- package/dist/chunk-OW7ESXOK.cjs.map +1 -0
- package/dist/index.cjs +747 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +455 -0
- package/dist/index.d.ts +425 -74
- package/dist/index.js +712 -218
- package/dist/index.js.map +1 -1
- package/dist/outbox-bPQmKYPN.d.cts +128 -0
- package/dist/outbox-bPQmKYPN.d.ts +128 -0
- package/dist/schema.cjs +9 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +4772 -0
- package/dist/schema.d.ts +4772 -0
- package/dist/schema.js +9 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql-outbox.cjs +184 -0
- package/dist/sql-outbox.cjs.map +1 -0
- package/dist/sql-outbox.d.cts +54 -0
- package/dist/sql-outbox.d.ts +54 -0
- package/dist/sql-outbox.js +184 -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 +48 -12
- package/src/memory-outbox.ts +127 -0
- package/src/outbox.ts +141 -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 +410 -0
- package/src/sql-outbox.ts +282 -0
- package/src/sys-webhook-delivery.object.ts +202 -0
- package/src/webhook-outbox-plugin.ts +280 -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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { IClusterService, LockHandle } from '@objectstack/spec/contracts';
|
|
4
|
+
import type { FetchImpl } from './http-sender.js';
|
|
5
|
+
import { classifyAttempt, sendOnce } from './http-sender.js';
|
|
6
|
+
import type { IWebhookOutbox, WebhookDelivery } from './outbox.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Minimal logger surface — kernel's `Logger` is compatible (extra params
|
|
10
|
+
* accepted). Keeping it permissive avoids a hard dependency on the spec
|
|
11
|
+
* Logger interface here.
|
|
12
|
+
*/
|
|
13
|
+
export interface DispatcherLogger {
|
|
14
|
+
warn: (msg: string, meta?: any) => void;
|
|
15
|
+
info?: (msg: string, meta?: any) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DispatcherOptions {
|
|
19
|
+
/** Stable id identifying this dispatcher node. */
|
|
20
|
+
nodeId: string;
|
|
21
|
+
/** Cluster service providing `lock` (and optional metrics). */
|
|
22
|
+
cluster: IClusterService;
|
|
23
|
+
/** Outbox backend. */
|
|
24
|
+
outbox: IWebhookOutbox;
|
|
25
|
+
/**
|
|
26
|
+
* How many partitions to split work across. Each tick the dispatcher
|
|
27
|
+
* attempts to acquire each partition's lock independently — the node
|
|
28
|
+
* that wins owns that partition for the duration of the batch.
|
|
29
|
+
*
|
|
30
|
+
* Default: 8 (matches webhook-delivery.mdx §4 example).
|
|
31
|
+
*/
|
|
32
|
+
partitionCount?: number;
|
|
33
|
+
/** Max rows to claim from each partition per tick. Default 32. */
|
|
34
|
+
batchSize?: number;
|
|
35
|
+
/** Tick interval in ms. Default 250. */
|
|
36
|
+
intervalMs?: number;
|
|
37
|
+
/** Per-partition lock TTL. Default = 5 × intervalMs. */
|
|
38
|
+
lockTtlMs?: number;
|
|
39
|
+
/** Visibility timeout for claimed rows. Default = 2 × lockTtlMs. */
|
|
40
|
+
claimTtlMs?: number;
|
|
41
|
+
/** Override `globalThis.fetch` (tests). */
|
|
42
|
+
fetchImpl?: FetchImpl;
|
|
43
|
+
/** Hook fired after every attempt — observability hook. */
|
|
44
|
+
onAttempt?: (delivery: WebhookDelivery, success: boolean) => void;
|
|
45
|
+
/** RNG override for the retry-jitter schedule (tests). */
|
|
46
|
+
rng?: () => number;
|
|
47
|
+
/** Logger callback (optional). */
|
|
48
|
+
logger?: DispatcherLogger;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Cross-node webhook dispatcher.
|
|
53
|
+
*
|
|
54
|
+
* **Design** — each tick the dispatcher iterates over `partitionCount`
|
|
55
|
+
* logical partitions. For each, it tries to acquire a cluster-scoped lock
|
|
56
|
+
* (`webhook.dispatcher.partition.{i}`) with a short TTL. If it wins the
|
|
57
|
+
* lock, it claims up to `batchSize` ready rows whose `hash(webhookId) mod
|
|
58
|
+
* partitionCount === i`, POSTs them, and acks. The lock is released
|
|
59
|
+
* immediately after the batch so other nodes can fairly rotate through.
|
|
60
|
+
*
|
|
61
|
+
* **Why per-partition locks rather than one global lock?**
|
|
62
|
+
*
|
|
63
|
+
* 1. Throughput — N nodes can process N partitions concurrently.
|
|
64
|
+
* 2. Partition affinity — rows for the same webhook always sort into the
|
|
65
|
+
* same partition, preserving in-order delivery per webhook.
|
|
66
|
+
* 3. Failure isolation — a stuck node only blocks its partition until the
|
|
67
|
+
* TTL elapses; other partitions keep moving.
|
|
68
|
+
*
|
|
69
|
+
* **At-least-once, not exactly-once.** Receivers MUST be idempotent on the
|
|
70
|
+
* `X-Objectstack-Delivery` (== row id) header. If the HTTP call succeeds
|
|
71
|
+
* but the ack write fails, the row reverts to pending after the claim TTL
|
|
72
|
+
* and will be re-posted.
|
|
73
|
+
*/
|
|
74
|
+
export class WebhookDispatcher {
|
|
75
|
+
private readonly opts: Required<
|
|
76
|
+
Omit<DispatcherOptions, 'onAttempt' | 'fetchImpl' | 'rng' | 'logger'>
|
|
77
|
+
> & Pick<DispatcherOptions, 'onAttempt' | 'fetchImpl' | 'rng' | 'logger'>;
|
|
78
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
79
|
+
private running = false;
|
|
80
|
+
private inflightTick: Promise<void> | undefined;
|
|
81
|
+
|
|
82
|
+
constructor(options: DispatcherOptions) {
|
|
83
|
+
const intervalMs = options.intervalMs ?? 250;
|
|
84
|
+
const lockTtlMs = options.lockTtlMs ?? intervalMs * 5;
|
|
85
|
+
this.opts = {
|
|
86
|
+
nodeId: options.nodeId,
|
|
87
|
+
cluster: options.cluster,
|
|
88
|
+
outbox: options.outbox,
|
|
89
|
+
partitionCount: options.partitionCount ?? 8,
|
|
90
|
+
batchSize: options.batchSize ?? 32,
|
|
91
|
+
intervalMs,
|
|
92
|
+
lockTtlMs,
|
|
93
|
+
claimTtlMs: options.claimTtlMs ?? lockTtlMs * 2,
|
|
94
|
+
onAttempt: options.onAttempt,
|
|
95
|
+
fetchImpl: options.fetchImpl,
|
|
96
|
+
rng: options.rng,
|
|
97
|
+
logger: options.logger,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Begin the periodic loop. Safe to call once; subsequent calls are no-ops. */
|
|
102
|
+
start(): void {
|
|
103
|
+
if (this.running) return;
|
|
104
|
+
this.running = true;
|
|
105
|
+
// Fire one tick immediately so single-row tests don't wait the interval.
|
|
106
|
+
this.scheduleTick();
|
|
107
|
+
this.timer = setInterval(() => this.scheduleTick(), this.opts.intervalMs);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Stop the loop and wait for the in-flight tick to drain. */
|
|
111
|
+
async stop(): Promise<void> {
|
|
112
|
+
if (!this.running) return;
|
|
113
|
+
this.running = false;
|
|
114
|
+
if (this.timer) {
|
|
115
|
+
clearInterval(this.timer);
|
|
116
|
+
this.timer = undefined;
|
|
117
|
+
}
|
|
118
|
+
if (this.inflightTick) {
|
|
119
|
+
try {
|
|
120
|
+
await this.inflightTick;
|
|
121
|
+
} catch {
|
|
122
|
+
/* swallow — already logged */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Run one full tick (all partitions, single attempt each). Exposed for
|
|
129
|
+
* deterministic tests that want to step the dispatcher manually.
|
|
130
|
+
*/
|
|
131
|
+
async tick(): Promise<void> {
|
|
132
|
+
await this.runTick();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private scheduleTick(): void {
|
|
136
|
+
if (this.inflightTick) return; // skip if previous tick still running
|
|
137
|
+
this.inflightTick = this.runTick()
|
|
138
|
+
.catch((err) => {
|
|
139
|
+
this.opts.logger?.warn?.('webhook-dispatcher: tick failed', {
|
|
140
|
+
nodeId: this.opts.nodeId,
|
|
141
|
+
error: (err as Error)?.message ?? String(err),
|
|
142
|
+
});
|
|
143
|
+
})
|
|
144
|
+
.finally(() => {
|
|
145
|
+
this.inflightTick = undefined;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async runTick(): Promise<void> {
|
|
150
|
+
const partitionCount = this.opts.partitionCount;
|
|
151
|
+
// Walk partitions in a rotated order per node so contention spreads.
|
|
152
|
+
const offset = stableNodeOffset(this.opts.nodeId, partitionCount);
|
|
153
|
+
for (let step = 0; step < partitionCount; step++) {
|
|
154
|
+
const i = (offset + step) % partitionCount;
|
|
155
|
+
await this.runPartition(i);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async runPartition(index: number): Promise<void> {
|
|
160
|
+
const key = `webhook.dispatcher.partition.${index}`;
|
|
161
|
+
const handle: LockHandle | null = await this.opts.cluster.lock.acquire(key, {
|
|
162
|
+
ttlMs: this.opts.lockTtlMs,
|
|
163
|
+
// waitMs=0 → fail-fast; we'll try this partition again next tick.
|
|
164
|
+
waitMs: 0,
|
|
165
|
+
});
|
|
166
|
+
if (!handle) return;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const claimed = await this.opts.outbox.claim({
|
|
170
|
+
nodeId: this.opts.nodeId,
|
|
171
|
+
limit: this.opts.batchSize,
|
|
172
|
+
partition: { index, count: this.opts.partitionCount },
|
|
173
|
+
claimTtlMs: this.opts.claimTtlMs,
|
|
174
|
+
});
|
|
175
|
+
if (claimed.length === 0) return;
|
|
176
|
+
// Renew before potentially long HTTP work — and bound batch time.
|
|
177
|
+
await handle.renew(this.opts.lockTtlMs);
|
|
178
|
+
for (const row of claimed) {
|
|
179
|
+
if (!handle.isHeld()) break; // lost the lock — abandon remaining rows
|
|
180
|
+
await this.processRow(row);
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
await handle.release();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async processRow(row: WebhookDelivery): Promise<void> {
|
|
188
|
+
const fetchImpl = (this.opts.fetchImpl ?? (globalThis.fetch as unknown as FetchImpl)) as FetchImpl | undefined;
|
|
189
|
+
if (!fetchImpl) {
|
|
190
|
+
this.opts.logger?.warn?.('webhook-dispatcher: no fetch impl available', {
|
|
191
|
+
rowId: row.id,
|
|
192
|
+
});
|
|
193
|
+
await this.opts.outbox.ack(row.id, {
|
|
194
|
+
success: false,
|
|
195
|
+
error: 'no fetch implementation',
|
|
196
|
+
durationMs: 0,
|
|
197
|
+
dead: true,
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const outcome = await sendOnce(row, fetchImpl);
|
|
202
|
+
const result = classifyAttempt(outcome, row.attempts, Date.now(), this.opts.rng);
|
|
203
|
+
await this.opts.outbox.ack(row.id, result);
|
|
204
|
+
this.opts.onAttempt?.(row, result.success);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Spread starting partition per node so a 2-node cluster with 8 partitions
|
|
210
|
+
* doesn't have both nodes serialise on partition 0 every tick.
|
|
211
|
+
*/
|
|
212
|
+
function stableNodeOffset(nodeId: string, partitionCount: number): number {
|
|
213
|
+
let h = 0;
|
|
214
|
+
for (let i = 0; i < nodeId.length; i++) {
|
|
215
|
+
h = (h * 31 + nodeId.charCodeAt(i)) | 0;
|
|
216
|
+
}
|
|
217
|
+
return Math.abs(h) % partitionCount;
|
|
218
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { createHmac, randomUUID } from 'node:crypto';
|
|
4
|
+
import type { WebhookDelivery, AckResult } from './outbox.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default per-request timeout. Receivers SHOULD respond within ~30s; we
|
|
8
|
+
* cap aggressively to free dispatcher slots.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
11
|
+
|
|
12
|
+
/** Truncate response bodies to keep storage cost predictable. */
|
|
13
|
+
const RESPONSE_BODY_CAP = 16 * 1024;
|
|
14
|
+
|
|
15
|
+
export type FetchImpl = (
|
|
16
|
+
input: string,
|
|
17
|
+
init: {
|
|
18
|
+
method: string;
|
|
19
|
+
headers: Record<string, string>;
|
|
20
|
+
body: string;
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
},
|
|
23
|
+
) => Promise<{
|
|
24
|
+
ok: boolean;
|
|
25
|
+
status: number;
|
|
26
|
+
text(): Promise<string>;
|
|
27
|
+
}>;
|
|
28
|
+
|
|
29
|
+
/** Single HTTP attempt classified to an `AckResult` shape (without nextRetryAt). */
|
|
30
|
+
export type AttemptOutcome =
|
|
31
|
+
| { success: true; httpStatus: number; responseBody?: string; durationMs: number }
|
|
32
|
+
| {
|
|
33
|
+
success: false;
|
|
34
|
+
retriable: boolean;
|
|
35
|
+
httpStatus?: number;
|
|
36
|
+
responseBody?: string;
|
|
37
|
+
error?: string;
|
|
38
|
+
durationMs: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Send one HTTP attempt for the delivery. Pure (no DB writes) so the
|
|
43
|
+
* dispatcher owns retry-schedule + ack logic.
|
|
44
|
+
*
|
|
45
|
+
* - 2xx → success
|
|
46
|
+
* - 4xx (except 408/429) → permanent failure (retriable = false → goes to `dead`)
|
|
47
|
+
* - 408, 429, 5xx, transport → retriable
|
|
48
|
+
*/
|
|
49
|
+
export async function sendOnce(
|
|
50
|
+
delivery: WebhookDelivery,
|
|
51
|
+
fetchImpl: FetchImpl,
|
|
52
|
+
): Promise<AttemptOutcome> {
|
|
53
|
+
const body =
|
|
54
|
+
typeof delivery.payload === 'string'
|
|
55
|
+
? delivery.payload
|
|
56
|
+
: JSON.stringify(delivery.payload);
|
|
57
|
+
|
|
58
|
+
const headers: Record<string, string> = {
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
'User-Agent': 'ObjectStack-Webhooks/1.0',
|
|
61
|
+
'X-Objectstack-Event': delivery.eventType,
|
|
62
|
+
'X-Objectstack-Delivery': delivery.id,
|
|
63
|
+
'X-Objectstack-Attempt': String(delivery.attempts + 1),
|
|
64
|
+
...(delivery.headers ?? {}),
|
|
65
|
+
};
|
|
66
|
+
if (delivery.secret) {
|
|
67
|
+
const sig = createHmac('sha256', delivery.secret).update(body).digest('hex');
|
|
68
|
+
headers['X-Objectstack-Signature'] = `sha256=${sig}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const timeoutMs = delivery.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
74
|
+
const start = Date.now();
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetchImpl(delivery.url, {
|
|
77
|
+
method: delivery.method ?? 'POST',
|
|
78
|
+
headers,
|
|
79
|
+
body,
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
});
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
const responseText = await safeReadBody(res);
|
|
84
|
+
const durationMs = Date.now() - start;
|
|
85
|
+
if (res.ok) {
|
|
86
|
+
return { success: true, httpStatus: res.status, responseBody: responseText, durationMs };
|
|
87
|
+
}
|
|
88
|
+
const retriable = res.status === 408 || res.status === 429 || res.status >= 500;
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
retriable,
|
|
92
|
+
httpStatus: res.status,
|
|
93
|
+
responseBody: responseText,
|
|
94
|
+
error: `HTTP ${res.status}`,
|
|
95
|
+
durationMs,
|
|
96
|
+
};
|
|
97
|
+
} catch (err: unknown) {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
const durationMs = Date.now() - start;
|
|
100
|
+
const e = err as { name?: string; message?: string };
|
|
101
|
+
const error = e?.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : e?.message ?? String(err);
|
|
102
|
+
return { success: false, retriable: true, error, durationMs };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function safeReadBody(res: { text(): Promise<string> }): Promise<string | undefined> {
|
|
107
|
+
try {
|
|
108
|
+
const text = await res.text();
|
|
109
|
+
return text.length > RESPONSE_BODY_CAP ? text.slice(0, RESPONSE_BODY_CAP) : text;
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Stripe-style retry schedule. Returns the next `nextRetryAt` ms (relative
|
|
117
|
+
* to `now`) given how many attempts have already happened, or `null` if
|
|
118
|
+
* the row should be moved to `dead`.
|
|
119
|
+
*
|
|
120
|
+
* attempt 1 fails -> retry in ~1s
|
|
121
|
+
* attempt 2 fails -> ~10s
|
|
122
|
+
* attempt 3 fails -> ~1m
|
|
123
|
+
* attempt 4 fails -> ~10m
|
|
124
|
+
* attempt 5 fails -> ~1h
|
|
125
|
+
* attempt 6 fails -> ~6h
|
|
126
|
+
* attempt 7 fails -> ~24h
|
|
127
|
+
* attempt 8+ fails -> dead
|
|
128
|
+
*
|
|
129
|
+
* Each delay is multiplied by jitter ∈ [0.8, 1.2].
|
|
130
|
+
*/
|
|
131
|
+
export function nextRetryDelayMs(
|
|
132
|
+
attemptsSoFar: number,
|
|
133
|
+
rng: () => number = Math.random,
|
|
134
|
+
): number | null {
|
|
135
|
+
const SCHEDULE = [1_000, 10_000, 60_000, 600_000, 3_600_000, 21_600_000, 86_400_000];
|
|
136
|
+
if (attemptsSoFar < 1 || attemptsSoFar > SCHEDULE.length) return null;
|
|
137
|
+
const base = SCHEDULE[attemptsSoFar - 1];
|
|
138
|
+
const jitter = 0.8 + rng() * 0.4;
|
|
139
|
+
return Math.floor(base * jitter);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compose an `AckResult` from an `AttemptOutcome`, applying the retry
|
|
144
|
+
* schedule on retriable failures.
|
|
145
|
+
*/
|
|
146
|
+
export function classifyAttempt(
|
|
147
|
+
outcome: AttemptOutcome,
|
|
148
|
+
attemptsSoFar: number,
|
|
149
|
+
now: number = Date.now(),
|
|
150
|
+
rng?: () => number,
|
|
151
|
+
): AckResult {
|
|
152
|
+
if (outcome.success) return outcome;
|
|
153
|
+
if (!outcome.retriable) {
|
|
154
|
+
return {
|
|
155
|
+
success: false,
|
|
156
|
+
httpStatus: outcome.httpStatus,
|
|
157
|
+
responseBody: outcome.responseBody,
|
|
158
|
+
error: outcome.error,
|
|
159
|
+
durationMs: outcome.durationMs,
|
|
160
|
+
dead: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const delay = nextRetryDelayMs(attemptsSoFar + 1, rng);
|
|
164
|
+
if (delay === null) {
|
|
165
|
+
return {
|
|
166
|
+
success: false,
|
|
167
|
+
httpStatus: outcome.httpStatus,
|
|
168
|
+
responseBody: outcome.responseBody,
|
|
169
|
+
error: outcome.error,
|
|
170
|
+
durationMs: outcome.durationMs,
|
|
171
|
+
dead: true,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
success: false,
|
|
176
|
+
httpStatus: outcome.httpStatus,
|
|
177
|
+
responseBody: outcome.responseBody,
|
|
178
|
+
error: outcome.error,
|
|
179
|
+
durationMs: outcome.durationMs,
|
|
180
|
+
nextRetryAt: now + delay,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Generate a fresh delivery id (UUID v4). Exposed for tests. */
|
|
185
|
+
export function newDeliveryId(): string {
|
|
186
|
+
return randomUUID();
|
|
187
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,19 +1,55 @@
|
|
|
1
|
-
// Copyright (c)
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @objectstack/plugin-webhooks
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Persistent, cluster-aware webhook outbox + dispatcher.
|
|
7
|
+
*
|
|
8
|
+
* Implements stages 3–5 of the pipeline in
|
|
9
|
+
* `content/docs/concepts/webhook-delivery.mdx` (Persist · Dispatch ·
|
|
10
|
+
* Retry). Stages 1–2 (Event capture · Match) integrate via the
|
|
11
|
+
* `webhook.outbox.enqueue()` service consumers call after persistence.
|
|
12
|
+
*
|
|
13
|
+
* The first real cross-node consumer of `cluster.lock`.
|
|
14
|
+
*
|
|
15
|
+
* ## Subpath exports
|
|
16
|
+
* - `@objectstack/plugin-webhooks/sql` — `SqlWebhookOutbox` (production
|
|
17
|
+
* storage; durable rows via ObjectQL / any driver)
|
|
18
|
+
* - `@objectstack/plugin-webhooks/schema` — `SysWebhookDelivery` object
|
|
19
|
+
* schema to register in `defineStack({ objects: [...] })`
|
|
20
|
+
*
|
|
21
|
+
* The main entry intentionally ships only the `MemoryWebhookOutbox` so
|
|
22
|
+
* downstream bundles don't pay for the SQL impl unless they import it.
|
|
11
23
|
*/
|
|
12
24
|
|
|
13
|
-
export {
|
|
25
|
+
export {
|
|
26
|
+
WebhookOutboxPlugin,
|
|
27
|
+
type WebhookOutboxPluginOptions,
|
|
28
|
+
} from './webhook-outbox-plugin.js';
|
|
29
|
+
|
|
30
|
+
export { WebhookDispatcher, type DispatcherOptions } from './dispatcher.js';
|
|
31
|
+
export { MemoryWebhookOutbox } from './memory-outbox.js';
|
|
32
|
+
export { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';
|
|
33
|
+
export {
|
|
34
|
+
DeliveryRetentionSweeper,
|
|
35
|
+
type DeliveryRetentionOptions,
|
|
36
|
+
} from './retention.js';
|
|
37
|
+
export { hashPartition } from './partition.js';
|
|
38
|
+
export {
|
|
39
|
+
sendOnce,
|
|
40
|
+
classifyAttempt,
|
|
41
|
+
nextRetryDelayMs,
|
|
42
|
+
DEFAULT_TIMEOUT_MS,
|
|
43
|
+
type AttemptOutcome,
|
|
44
|
+
type FetchImpl,
|
|
45
|
+
} from './http-sender.js';
|
|
14
46
|
export type {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
47
|
+
AckFailure,
|
|
48
|
+
AckResult,
|
|
49
|
+
AckSuccess,
|
|
50
|
+
ClaimOptions,
|
|
51
|
+
DeliveryStatus,
|
|
52
|
+
EnqueueInput,
|
|
53
|
+
IWebhookOutbox,
|
|
54
|
+
WebhookDelivery,
|
|
55
|
+
} from './outbox.js';
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import type {
|
|
5
|
+
AckResult,
|
|
6
|
+
ClaimOptions,
|
|
7
|
+
EnqueueInput,
|
|
8
|
+
DeliveryStatus,
|
|
9
|
+
IWebhookOutbox,
|
|
10
|
+
WebhookDelivery,
|
|
11
|
+
} from './outbox.js';
|
|
12
|
+
import { hashPartition } from './partition.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* In-memory `IWebhookOutbox` for tests and single-process development.
|
|
16
|
+
*
|
|
17
|
+
* Implements the atomic-claim semantics by running its claim/ack logic
|
|
18
|
+
* synchronously (single-threaded JS event loop) inside one `Map`. Two
|
|
19
|
+
* `MemoryWebhookOutbox` instances do NOT share state — for the cross-node
|
|
20
|
+
* test the *same* instance is passed to both dispatchers (simulating one
|
|
21
|
+
* shared database).
|
|
22
|
+
*
|
|
23
|
+
* A production SQL-backed implementation will live in a sibling file and
|
|
24
|
+
* use `SELECT ... FOR UPDATE SKIP LOCKED`.
|
|
25
|
+
*/
|
|
26
|
+
export class MemoryWebhookOutbox implements IWebhookOutbox {
|
|
27
|
+
private readonly rows = new Map<string, WebhookDelivery>();
|
|
28
|
+
/** Dedup index keyed by `${eventId}::${webhookId}` -> row id. */
|
|
29
|
+
private readonly dedup = new Map<string, string>();
|
|
30
|
+
|
|
31
|
+
async enqueue(input: EnqueueInput): Promise<string> {
|
|
32
|
+
const dedupKey = `${input.eventId}::${input.webhookId}`;
|
|
33
|
+
const existing = this.dedup.get(dedupKey);
|
|
34
|
+
if (existing) return existing;
|
|
35
|
+
|
|
36
|
+
const id = randomUUID();
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const row: WebhookDelivery = {
|
|
39
|
+
id,
|
|
40
|
+
webhookId: input.webhookId,
|
|
41
|
+
eventId: input.eventId,
|
|
42
|
+
eventType: input.eventType,
|
|
43
|
+
url: input.url,
|
|
44
|
+
method: input.method ?? 'POST',
|
|
45
|
+
headers: input.headers,
|
|
46
|
+
secret: input.secret,
|
|
47
|
+
timeoutMs: input.timeoutMs,
|
|
48
|
+
payload: input.payload,
|
|
49
|
+
status: 'pending',
|
|
50
|
+
attempts: 0,
|
|
51
|
+
createdAt: now,
|
|
52
|
+
updatedAt: now,
|
|
53
|
+
};
|
|
54
|
+
this.rows.set(id, row);
|
|
55
|
+
this.dedup.set(dedupKey, id);
|
|
56
|
+
return id;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async claim(opts: ClaimOptions): Promise<WebhookDelivery[]> {
|
|
60
|
+
const now = opts.now ?? Date.now();
|
|
61
|
+
const claimed: WebhookDelivery[] = [];
|
|
62
|
+
|
|
63
|
+
// First pass: reap expired in_flight rows (visibility timeout).
|
|
64
|
+
for (const row of this.rows.values()) {
|
|
65
|
+
if (
|
|
66
|
+
row.status === 'in_flight' &&
|
|
67
|
+
row.claimedAt !== undefined &&
|
|
68
|
+
now - row.claimedAt > opts.claimTtlMs
|
|
69
|
+
) {
|
|
70
|
+
row.status = 'pending';
|
|
71
|
+
row.claimedBy = undefined;
|
|
72
|
+
row.claimedAt = undefined;
|
|
73
|
+
row.updatedAt = now;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const row of this.rows.values()) {
|
|
78
|
+
if (claimed.length >= opts.limit) break;
|
|
79
|
+
if (row.status !== 'pending') continue;
|
|
80
|
+
if (row.nextRetryAt !== undefined && row.nextRetryAt > now) continue;
|
|
81
|
+
if (opts.partition) {
|
|
82
|
+
const p = hashPartition(row.webhookId, opts.partition.count);
|
|
83
|
+
if (p !== opts.partition.index) continue;
|
|
84
|
+
}
|
|
85
|
+
row.status = 'in_flight';
|
|
86
|
+
row.claimedBy = opts.nodeId;
|
|
87
|
+
row.claimedAt = now;
|
|
88
|
+
row.updatedAt = now;
|
|
89
|
+
claimed.push({ ...row });
|
|
90
|
+
}
|
|
91
|
+
return claimed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async ack(id: string, result: AckResult): Promise<void> {
|
|
95
|
+
const row = this.rows.get(id);
|
|
96
|
+
if (!row) return;
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
row.attempts += 1;
|
|
99
|
+
row.lastAttemptedAt = now;
|
|
100
|
+
row.updatedAt = now;
|
|
101
|
+
row.claimedBy = undefined;
|
|
102
|
+
row.claimedAt = undefined;
|
|
103
|
+
row.responseCode = result.httpStatus;
|
|
104
|
+
row.responseBody = result.responseBody;
|
|
105
|
+
|
|
106
|
+
let status: DeliveryStatus;
|
|
107
|
+
if (result.success) {
|
|
108
|
+
status = 'success';
|
|
109
|
+
row.nextRetryAt = undefined;
|
|
110
|
+
row.error = undefined;
|
|
111
|
+
} else if (result.dead) {
|
|
112
|
+
status = 'dead';
|
|
113
|
+
row.error = result.error;
|
|
114
|
+
row.nextRetryAt = undefined;
|
|
115
|
+
} else {
|
|
116
|
+
status = 'pending';
|
|
117
|
+
row.error = result.error;
|
|
118
|
+
row.nextRetryAt = result.nextRetryAt;
|
|
119
|
+
}
|
|
120
|
+
row.status = status;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async list(filter?: { status?: DeliveryStatus }): Promise<WebhookDelivery[]> {
|
|
124
|
+
const all = Array.from(this.rows.values()).map((r) => ({ ...r }));
|
|
125
|
+
return filter?.status ? all.filter((r) => r.status === filter.status) : all;
|
|
126
|
+
}
|
|
127
|
+
}
|