@nimbusnexus/webhooks-sdk 0.2.0 → 0.3.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/README.md +53 -0
- package/dist/index.cjs +585 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +237 -2
- package/dist/index.d.ts +237 -2
- package/dist/index.js +574 -1
- package/dist/index.js.map +1 -1
- package/package.json +22 -3
package/dist/index.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
1
8
|
// src/signature.ts
|
|
2
9
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
3
10
|
var PREFIX = "sha256=";
|
|
@@ -30,6 +37,9 @@ function verify(secret, rawBody, signature, opts = {}) {
|
|
|
30
37
|
return false;
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
// src/client.ts
|
|
41
|
+
import { randomUUID } from "crypto";
|
|
42
|
+
|
|
33
43
|
// src/errors.ts
|
|
34
44
|
var WebhookdError = class extends Error {
|
|
35
45
|
constructor(message) {
|
|
@@ -48,6 +58,442 @@ var WebhookdApiError = class extends WebhookdError {
|
|
|
48
58
|
}
|
|
49
59
|
};
|
|
50
60
|
|
|
61
|
+
// src/outbox.ts
|
|
62
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
63
|
+
import { join } from "path";
|
|
64
|
+
import { randomBytes } from "crypto";
|
|
65
|
+
import { createRequire } from "module";
|
|
66
|
+
|
|
67
|
+
// src/stores/redis.ts
|
|
68
|
+
function byCreatedAt(a, b) {
|
|
69
|
+
return a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
70
|
+
}
|
|
71
|
+
var RedisStore = class {
|
|
72
|
+
url;
|
|
73
|
+
keyPrefix;
|
|
74
|
+
client;
|
|
75
|
+
connecting;
|
|
76
|
+
constructor(opts = {}) {
|
|
77
|
+
this.url = opts.url;
|
|
78
|
+
this.client = opts.client;
|
|
79
|
+
this.keyPrefix = opts.keyPrefix ?? "webhookd:outbox";
|
|
80
|
+
}
|
|
81
|
+
get zsetKey() {
|
|
82
|
+
return `${this.keyPrefix}:due`;
|
|
83
|
+
}
|
|
84
|
+
get hashKey() {
|
|
85
|
+
return `${this.keyPrefix}:records`;
|
|
86
|
+
}
|
|
87
|
+
/** Lazily import the driver + connect exactly once. */
|
|
88
|
+
async ensure() {
|
|
89
|
+
if (this.client && this.client.isOpen) return this.client;
|
|
90
|
+
if (this.connecting) return this.connecting;
|
|
91
|
+
this.connecting = (async () => {
|
|
92
|
+
if (!this.client) {
|
|
93
|
+
const { createClient: create } = await import("redis");
|
|
94
|
+
this.client = create({ url: this.url });
|
|
95
|
+
}
|
|
96
|
+
if (!this.client.isOpen) await this.client.connect();
|
|
97
|
+
return this.client;
|
|
98
|
+
})();
|
|
99
|
+
try {
|
|
100
|
+
return await this.connecting;
|
|
101
|
+
} finally {
|
|
102
|
+
this.connecting = void 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async save(record) {
|
|
106
|
+
const client = await this.ensure();
|
|
107
|
+
await client.hSet(this.hashKey, record.id, JSON.stringify(record));
|
|
108
|
+
await client.zAdd(this.zsetKey, { score: record.nextAttemptAt, value: record.id });
|
|
109
|
+
}
|
|
110
|
+
async listPending(limit) {
|
|
111
|
+
const client = await this.ensure();
|
|
112
|
+
const ids = await client.zRangeByScore(this.zsetKey, "-inf", Date.now());
|
|
113
|
+
return this.loadSorted(client, ids, limit);
|
|
114
|
+
}
|
|
115
|
+
async markSent(id) {
|
|
116
|
+
const client = await this.ensure();
|
|
117
|
+
await client.hDel(this.hashKey, id);
|
|
118
|
+
await client.zRem(this.zsetKey, id);
|
|
119
|
+
}
|
|
120
|
+
async markFailed(id, error, attempts, nextAttemptAt) {
|
|
121
|
+
const client = await this.ensure();
|
|
122
|
+
const raw = await client.hGet(this.hashKey, id);
|
|
123
|
+
if (raw === void 0 || raw === null) return;
|
|
124
|
+
const record = JSON.parse(raw);
|
|
125
|
+
record.attempts = attempts;
|
|
126
|
+
record.lastError = error;
|
|
127
|
+
record.nextAttemptAt = nextAttemptAt;
|
|
128
|
+
await client.hSet(this.hashKey, id, JSON.stringify(record));
|
|
129
|
+
await client.zAdd(this.zsetKey, { score: nextAttemptAt, value: id });
|
|
130
|
+
}
|
|
131
|
+
async size() {
|
|
132
|
+
const client = await this.ensure();
|
|
133
|
+
return client.hLen(this.hashKey);
|
|
134
|
+
}
|
|
135
|
+
async listDead(limit) {
|
|
136
|
+
const client = await this.ensure();
|
|
137
|
+
const ids = await client.zRangeByScore(this.zsetKey, DEAD_NEXT_ATTEMPT_MS, "+inf");
|
|
138
|
+
const dead = (await this.loadSorted(client, ids, ids.length)).filter(isDead);
|
|
139
|
+
return limit === void 0 ? dead : dead.slice(0, limit);
|
|
140
|
+
}
|
|
141
|
+
async close() {
|
|
142
|
+
if (this.client && this.client.isOpen) await this.client.close();
|
|
143
|
+
}
|
|
144
|
+
/** Load records for `ids` from the hash, drop any missing, sort oldest-first, cap at `limit`. */
|
|
145
|
+
async loadSorted(client, ids, limit) {
|
|
146
|
+
const records = [];
|
|
147
|
+
for (const id of ids) {
|
|
148
|
+
const raw = await client.hGet(this.hashKey, id);
|
|
149
|
+
if (raw === void 0 || raw === null) continue;
|
|
150
|
+
records.push(cloneRecord(JSON.parse(raw)));
|
|
151
|
+
}
|
|
152
|
+
return records.sort(byCreatedAt).slice(0, limit);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// src/stores/postgres.ts
|
|
157
|
+
function rowToRecord(row) {
|
|
158
|
+
return {
|
|
159
|
+
id: row.id,
|
|
160
|
+
eventType: row.event_type,
|
|
161
|
+
payload: row.payload,
|
|
162
|
+
environment: row.environment,
|
|
163
|
+
application: row.application,
|
|
164
|
+
source: row.source,
|
|
165
|
+
createdAt: Number(row.created_at),
|
|
166
|
+
attempts: row.attempts,
|
|
167
|
+
lastError: row.last_error,
|
|
168
|
+
nextAttemptAt: Number(row.next_attempt_at)
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
var PostgresStore = class {
|
|
172
|
+
connectionString;
|
|
173
|
+
table;
|
|
174
|
+
pool;
|
|
175
|
+
ready;
|
|
176
|
+
constructor(opts = {}) {
|
|
177
|
+
this.connectionString = opts.connectionString;
|
|
178
|
+
this.pool = opts.pool;
|
|
179
|
+
const table = opts.table ?? "webhookd_outbox";
|
|
180
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
|
|
181
|
+
throw new Error(`invalid table name: ${table}`);
|
|
182
|
+
}
|
|
183
|
+
this.table = table;
|
|
184
|
+
}
|
|
185
|
+
/** Lazily import the driver, open the pool, and create the table exactly once. */
|
|
186
|
+
async ensure() {
|
|
187
|
+
if (this.ready) return this.ready;
|
|
188
|
+
this.ready = (async () => {
|
|
189
|
+
if (!this.pool) {
|
|
190
|
+
const pg = await import("pg");
|
|
191
|
+
const Pool = pg.Pool ?? pg.default?.Pool;
|
|
192
|
+
if (!Pool) throw new Error("pg: could not resolve Pool export");
|
|
193
|
+
this.pool = new Pool({ connectionString: this.connectionString });
|
|
194
|
+
}
|
|
195
|
+
await this.pool.query(
|
|
196
|
+
`CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
197
|
+
id TEXT PRIMARY KEY,
|
|
198
|
+
event_type TEXT NOT NULL,
|
|
199
|
+
payload JSONB NOT NULL,
|
|
200
|
+
environment TEXT NOT NULL,
|
|
201
|
+
application TEXT NOT NULL,
|
|
202
|
+
source TEXT,
|
|
203
|
+
sent BOOLEAN NOT NULL DEFAULT FALSE,
|
|
204
|
+
attempts INTEGER NOT NULL,
|
|
205
|
+
last_error TEXT,
|
|
206
|
+
next_attempt_at BIGINT NOT NULL,
|
|
207
|
+
created_at BIGINT NOT NULL
|
|
208
|
+
)`
|
|
209
|
+
);
|
|
210
|
+
return this.pool;
|
|
211
|
+
})();
|
|
212
|
+
return this.ready;
|
|
213
|
+
}
|
|
214
|
+
async save(record) {
|
|
215
|
+
const pool = await this.ensure();
|
|
216
|
+
await pool.query(
|
|
217
|
+
`INSERT INTO ${this.table}
|
|
218
|
+
(id, event_type, payload, environment, application, source, sent, attempts, last_error, next_attempt_at, created_at)
|
|
219
|
+
VALUES ($1, $2, $3, $4, $5, $6, FALSE, $7, $8, $9, $10)
|
|
220
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
221
|
+
event_type = EXCLUDED.event_type,
|
|
222
|
+
payload = EXCLUDED.payload,
|
|
223
|
+
environment = EXCLUDED.environment,
|
|
224
|
+
application = EXCLUDED.application,
|
|
225
|
+
source = EXCLUDED.source,
|
|
226
|
+
sent = EXCLUDED.sent,
|
|
227
|
+
attempts = EXCLUDED.attempts,
|
|
228
|
+
last_error = EXCLUDED.last_error,
|
|
229
|
+
next_attempt_at = EXCLUDED.next_attempt_at,
|
|
230
|
+
created_at = EXCLUDED.created_at`,
|
|
231
|
+
[
|
|
232
|
+
record.id,
|
|
233
|
+
record.eventType,
|
|
234
|
+
JSON.stringify(record.payload),
|
|
235
|
+
record.environment,
|
|
236
|
+
record.application,
|
|
237
|
+
record.source,
|
|
238
|
+
record.attempts,
|
|
239
|
+
record.lastError,
|
|
240
|
+
record.nextAttemptAt,
|
|
241
|
+
record.createdAt
|
|
242
|
+
]
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
async listPending(limit) {
|
|
246
|
+
const pool = await this.ensure();
|
|
247
|
+
const res = await pool.query(
|
|
248
|
+
`SELECT * FROM ${this.table}
|
|
249
|
+
WHERE NOT sent AND next_attempt_at <= $1 AND next_attempt_at < $2
|
|
250
|
+
ORDER BY created_at ASC, id ASC
|
|
251
|
+
LIMIT $3`,
|
|
252
|
+
[Date.now(), DEAD_NEXT_ATTEMPT_MS, limit]
|
|
253
|
+
);
|
|
254
|
+
return res.rows.map(rowToRecord);
|
|
255
|
+
}
|
|
256
|
+
async markSent(id) {
|
|
257
|
+
const pool = await this.ensure();
|
|
258
|
+
await pool.query(`UPDATE ${this.table} SET sent = TRUE WHERE id = $1`, [id]);
|
|
259
|
+
}
|
|
260
|
+
async markFailed(id, error, attempts, nextAttemptAt) {
|
|
261
|
+
const pool = await this.ensure();
|
|
262
|
+
await pool.query(
|
|
263
|
+
`UPDATE ${this.table} SET attempts = $2, last_error = $3, next_attempt_at = $4 WHERE id = $1`,
|
|
264
|
+
[id, attempts, error, nextAttemptAt]
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
async size() {
|
|
268
|
+
const pool = await this.ensure();
|
|
269
|
+
const res = await pool.query(
|
|
270
|
+
`SELECT COUNT(*) AS n FROM ${this.table} WHERE NOT sent`
|
|
271
|
+
);
|
|
272
|
+
return Number(res.rows[0].n);
|
|
273
|
+
}
|
|
274
|
+
async listDead(limit) {
|
|
275
|
+
const pool = await this.ensure();
|
|
276
|
+
const res = await pool.query(
|
|
277
|
+
`SELECT * FROM ${this.table}
|
|
278
|
+
WHERE NOT sent AND next_attempt_at >= $1
|
|
279
|
+
ORDER BY created_at ASC, id ASC
|
|
280
|
+
${limit === void 0 ? "" : "LIMIT $2"}`,
|
|
281
|
+
limit === void 0 ? [DEAD_NEXT_ATTEMPT_MS] : [DEAD_NEXT_ATTEMPT_MS, limit]
|
|
282
|
+
);
|
|
283
|
+
return res.rows.map(rowToRecord);
|
|
284
|
+
}
|
|
285
|
+
async close() {
|
|
286
|
+
if (this.pool) await this.pool.end();
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// src/outbox.ts
|
|
291
|
+
var DEAD_NEXT_ATTEMPT_MS = 864e13;
|
|
292
|
+
function isDead(record) {
|
|
293
|
+
return record.nextAttemptAt >= DEAD_NEXT_ATTEMPT_MS;
|
|
294
|
+
}
|
|
295
|
+
function cloneRecord(record) {
|
|
296
|
+
return { ...record, payload: structuredClone(record.payload) };
|
|
297
|
+
}
|
|
298
|
+
function byCreatedAt2(a, b) {
|
|
299
|
+
return a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
300
|
+
}
|
|
301
|
+
var MemoryStore = class {
|
|
302
|
+
records = /* @__PURE__ */ new Map();
|
|
303
|
+
save(record) {
|
|
304
|
+
this.records.set(record.id, cloneRecord(record));
|
|
305
|
+
}
|
|
306
|
+
listPending(limit) {
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
return [...this.records.values()].filter((r) => r.nextAttemptAt <= now).sort(byCreatedAt2).slice(0, limit).map(cloneRecord);
|
|
309
|
+
}
|
|
310
|
+
markSent(id) {
|
|
311
|
+
this.records.delete(id);
|
|
312
|
+
}
|
|
313
|
+
markFailed(id, error, attempts, nextAttemptAt) {
|
|
314
|
+
const record = this.records.get(id);
|
|
315
|
+
if (!record) return;
|
|
316
|
+
record.attempts = attempts;
|
|
317
|
+
record.lastError = error;
|
|
318
|
+
record.nextAttemptAt = nextAttemptAt;
|
|
319
|
+
}
|
|
320
|
+
size() {
|
|
321
|
+
return this.records.size;
|
|
322
|
+
}
|
|
323
|
+
listDead(limit) {
|
|
324
|
+
const dead = [...this.records.values()].filter(isDead).sort(byCreatedAt2).map(cloneRecord);
|
|
325
|
+
return limit === void 0 ? dead : dead.slice(0, limit);
|
|
326
|
+
}
|
|
327
|
+
close() {
|
|
328
|
+
this.records.clear();
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
var FileStore = class {
|
|
332
|
+
dir;
|
|
333
|
+
constructor(dir) {
|
|
334
|
+
this.dir = dir;
|
|
335
|
+
mkdirSync(dir, { recursive: true });
|
|
336
|
+
}
|
|
337
|
+
/** Map an arbitrary id to a safe filename (ids may be caller-supplied idempotency keys). */
|
|
338
|
+
pathFor(id) {
|
|
339
|
+
return join(this.dir, `${encodeURIComponent(id)}.json`);
|
|
340
|
+
}
|
|
341
|
+
readAll() {
|
|
342
|
+
const out = [];
|
|
343
|
+
for (const name of readdirSync(this.dir)) {
|
|
344
|
+
if (!name.endsWith(".json")) continue;
|
|
345
|
+
try {
|
|
346
|
+
out.push(JSON.parse(readFileSync(join(this.dir, name), "utf8")));
|
|
347
|
+
} catch {
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
save(record) {
|
|
353
|
+
const dest = this.pathFor(record.id);
|
|
354
|
+
const tmp = `${dest}.tmp-${randomBytes(6).toString("hex")}`;
|
|
355
|
+
writeFileSync(tmp, JSON.stringify(record), "utf8");
|
|
356
|
+
renameSync(tmp, dest);
|
|
357
|
+
}
|
|
358
|
+
listPending(limit) {
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
return this.readAll().filter((r) => r.nextAttemptAt <= now).sort(byCreatedAt2).slice(0, limit);
|
|
361
|
+
}
|
|
362
|
+
markSent(id) {
|
|
363
|
+
rmSync(this.pathFor(id), { force: true });
|
|
364
|
+
}
|
|
365
|
+
markFailed(id, error, attempts, nextAttemptAt) {
|
|
366
|
+
let record;
|
|
367
|
+
try {
|
|
368
|
+
record = JSON.parse(readFileSync(this.pathFor(id), "utf8"));
|
|
369
|
+
} catch {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
record.attempts = attempts;
|
|
373
|
+
record.lastError = error;
|
|
374
|
+
record.nextAttemptAt = nextAttemptAt;
|
|
375
|
+
this.save(record);
|
|
376
|
+
}
|
|
377
|
+
size() {
|
|
378
|
+
return readdirSync(this.dir).filter((n) => n.endsWith(".json")).length;
|
|
379
|
+
}
|
|
380
|
+
listDead(limit) {
|
|
381
|
+
const dead = this.readAll().filter(isDead).sort(byCreatedAt2);
|
|
382
|
+
return limit === void 0 ? dead : dead.slice(0, limit);
|
|
383
|
+
}
|
|
384
|
+
close() {
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
function rowToRecord2(row) {
|
|
388
|
+
return {
|
|
389
|
+
id: row.id,
|
|
390
|
+
eventType: row.event_type,
|
|
391
|
+
payload: JSON.parse(row.payload),
|
|
392
|
+
environment: row.environment,
|
|
393
|
+
application: row.application,
|
|
394
|
+
source: row.source,
|
|
395
|
+
createdAt: row.created_at,
|
|
396
|
+
attempts: row.attempts,
|
|
397
|
+
lastError: row.last_error,
|
|
398
|
+
nextAttemptAt: row.next_attempt_at
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
var SqliteStore = class {
|
|
402
|
+
db;
|
|
403
|
+
constructor(path = ":memory:") {
|
|
404
|
+
let DatabaseSync;
|
|
405
|
+
try {
|
|
406
|
+
let load;
|
|
407
|
+
try {
|
|
408
|
+
load = createRequire(import.meta.url);
|
|
409
|
+
} catch {
|
|
410
|
+
load = __require;
|
|
411
|
+
}
|
|
412
|
+
({ DatabaseSync } = load("node:sqlite"));
|
|
413
|
+
} catch (err) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`SqliteStore requires the built-in node:sqlite (Node >= 22.5): ${String(err)}`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
this.db = new DatabaseSync(path);
|
|
419
|
+
this.db.exec(
|
|
420
|
+
`CREATE TABLE IF NOT EXISTS webhookd_outbox (
|
|
421
|
+
id TEXT PRIMARY KEY,
|
|
422
|
+
event_type TEXT NOT NULL,
|
|
423
|
+
payload TEXT NOT NULL,
|
|
424
|
+
environment TEXT NOT NULL,
|
|
425
|
+
application TEXT NOT NULL,
|
|
426
|
+
source TEXT,
|
|
427
|
+
created_at INTEGER NOT NULL,
|
|
428
|
+
attempts INTEGER NOT NULL,
|
|
429
|
+
last_error TEXT,
|
|
430
|
+
next_attempt_at INTEGER NOT NULL
|
|
431
|
+
)`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
save(record) {
|
|
435
|
+
this.db.prepare(
|
|
436
|
+
`INSERT INTO webhookd_outbox
|
|
437
|
+
(id, event_type, payload, environment, application, source, created_at, attempts, last_error, next_attempt_at)
|
|
438
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
439
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
440
|
+
event_type = excluded.event_type,
|
|
441
|
+
payload = excluded.payload,
|
|
442
|
+
environment = excluded.environment,
|
|
443
|
+
application = excluded.application,
|
|
444
|
+
source = excluded.source,
|
|
445
|
+
created_at = excluded.created_at,
|
|
446
|
+
attempts = excluded.attempts,
|
|
447
|
+
last_error = excluded.last_error,
|
|
448
|
+
next_attempt_at = excluded.next_attempt_at`
|
|
449
|
+
).run(
|
|
450
|
+
record.id,
|
|
451
|
+
record.eventType,
|
|
452
|
+
JSON.stringify(record.payload),
|
|
453
|
+
record.environment,
|
|
454
|
+
record.application,
|
|
455
|
+
record.source,
|
|
456
|
+
record.createdAt,
|
|
457
|
+
record.attempts,
|
|
458
|
+
record.lastError,
|
|
459
|
+
record.nextAttemptAt
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
listPending(limit) {
|
|
463
|
+
const rows = this.db.prepare(
|
|
464
|
+
`SELECT * FROM webhookd_outbox
|
|
465
|
+
WHERE next_attempt_at <= ? AND next_attempt_at < ?
|
|
466
|
+
ORDER BY created_at ASC, id ASC
|
|
467
|
+
LIMIT ?`
|
|
468
|
+
).all(Date.now(), DEAD_NEXT_ATTEMPT_MS, limit);
|
|
469
|
+
return rows.map(rowToRecord2);
|
|
470
|
+
}
|
|
471
|
+
markSent(id) {
|
|
472
|
+
this.db.prepare(`DELETE FROM webhookd_outbox WHERE id = ?`).run(id);
|
|
473
|
+
}
|
|
474
|
+
markFailed(id, error, attempts, nextAttemptAt) {
|
|
475
|
+
this.db.prepare(
|
|
476
|
+
`UPDATE webhookd_outbox SET attempts = ?, last_error = ?, next_attempt_at = ? WHERE id = ?`
|
|
477
|
+
).run(attempts, error, nextAttemptAt, id);
|
|
478
|
+
}
|
|
479
|
+
size() {
|
|
480
|
+
const row = this.db.prepare(`SELECT COUNT(*) AS n FROM webhookd_outbox`).get();
|
|
481
|
+
return Number(row.n);
|
|
482
|
+
}
|
|
483
|
+
listDead(limit) {
|
|
484
|
+
const rows = this.db.prepare(
|
|
485
|
+
`SELECT * FROM webhookd_outbox
|
|
486
|
+
WHERE next_attempt_at >= ?
|
|
487
|
+
ORDER BY created_at ASC, id ASC
|
|
488
|
+
${limit === void 0 ? "" : "LIMIT ?"}`
|
|
489
|
+
).all(...limit === void 0 ? [DEAD_NEXT_ATTEMPT_MS] : [DEAD_NEXT_ATTEMPT_MS, limit]);
|
|
490
|
+
return rows.map(rowToRecord2);
|
|
491
|
+
}
|
|
492
|
+
close() {
|
|
493
|
+
this.db.close();
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
51
497
|
// src/client.ts
|
|
52
498
|
var RETRY_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
53
499
|
var WebhookdClient = class {
|
|
@@ -56,12 +502,24 @@ var WebhookdClient = class {
|
|
|
56
502
|
timeoutMs;
|
|
57
503
|
maxRetries;
|
|
58
504
|
fetchImpl;
|
|
505
|
+
store;
|
|
506
|
+
maxAttempts;
|
|
507
|
+
drainBatchLimit;
|
|
508
|
+
onDead;
|
|
509
|
+
onDrainError;
|
|
510
|
+
drainTimer;
|
|
511
|
+
draining = false;
|
|
59
512
|
constructor(opts) {
|
|
60
513
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
61
514
|
this.apiKey = opts.apiKey;
|
|
62
515
|
this.timeoutMs = opts.timeoutMs ?? 1e4;
|
|
63
516
|
this.maxRetries = opts.maxRetries ?? 2;
|
|
64
517
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
518
|
+
this.store = opts.store;
|
|
519
|
+
this.maxAttempts = opts.maxAttempts ?? 10;
|
|
520
|
+
this.drainBatchLimit = opts.drainBatchLimit ?? 100;
|
|
521
|
+
this.onDead = opts.onDead;
|
|
522
|
+
this.onDrainError = opts.onDrainError;
|
|
65
523
|
}
|
|
66
524
|
async publish(eventType, payload, opts = {}) {
|
|
67
525
|
const body = {
|
|
@@ -85,6 +543,114 @@ var WebhookdClient = class {
|
|
|
85
543
|
source: data.source ?? null
|
|
86
544
|
};
|
|
87
545
|
}
|
|
546
|
+
// ── Outbox (write-first, durable) ────────────────────────────────────────────
|
|
547
|
+
/**
|
|
548
|
+
* Durably buffer an event and return IMMEDIATELY — NO network call. Builds an {@link OutboxRecord}
|
|
549
|
+
* (id = `opts.idempotencyKey` or a fresh UUID v4), saves it to the configured store, and returns the
|
|
550
|
+
* id. Ship it later with {@link drain} (or the background drainer). Requires a `store` in options.
|
|
551
|
+
*/
|
|
552
|
+
async enqueue(eventType, payload, opts = {}) {
|
|
553
|
+
const store = this.requireStore();
|
|
554
|
+
const now = Date.now();
|
|
555
|
+
const record = {
|
|
556
|
+
id: opts.idempotencyKey ?? randomUUID(),
|
|
557
|
+
eventType,
|
|
558
|
+
payload,
|
|
559
|
+
environment: opts.environment ?? "prod",
|
|
560
|
+
application: opts.application ?? "default",
|
|
561
|
+
source: opts.source ?? null,
|
|
562
|
+
createdAt: now,
|
|
563
|
+
attempts: 0,
|
|
564
|
+
lastError: null,
|
|
565
|
+
nextAttemptAt: now
|
|
566
|
+
};
|
|
567
|
+
await store.save(record);
|
|
568
|
+
return { id: record.id };
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Send buffered records to webhookd. Pulls a due batch (oldest first), POSTs each to `/v1/events`
|
|
572
|
+
* with header `Idempotency-Key = record.id` (so a re-drain after a crash never double-publishes —
|
|
573
|
+
* webhookd dedupes). On 2xx the record is marked sent; on error `attempts` is bumped and the record
|
|
574
|
+
* is rescheduled with capped exponential backoff, or parked dead once it hits `maxAttempts` (the
|
|
575
|
+
* `onDead` hook fires, and it is retrievable via `store.listDead()`). Requires a `store`.
|
|
576
|
+
*/
|
|
577
|
+
async drain(opts = {}) {
|
|
578
|
+
const store = this.requireStore();
|
|
579
|
+
const batchLimit = opts.batchLimit ?? this.drainBatchLimit;
|
|
580
|
+
const maxAttempts = opts.maxAttempts ?? this.maxAttempts;
|
|
581
|
+
const rows = await store.listPending(batchLimit);
|
|
582
|
+
let sent = 0;
|
|
583
|
+
let failed = 0;
|
|
584
|
+
for (const record of rows) {
|
|
585
|
+
const body = {
|
|
586
|
+
event_type: record.eventType,
|
|
587
|
+
payload: record.payload,
|
|
588
|
+
environment: record.environment,
|
|
589
|
+
application: record.application
|
|
590
|
+
};
|
|
591
|
+
if (record.source !== null) body.source = record.source;
|
|
592
|
+
try {
|
|
593
|
+
await this.request("POST", "/v1/events", {
|
|
594
|
+
body,
|
|
595
|
+
headers: { "Idempotency-Key": record.id }
|
|
596
|
+
});
|
|
597
|
+
await store.markSent(record.id);
|
|
598
|
+
sent += 1;
|
|
599
|
+
} catch (err) {
|
|
600
|
+
const attempts = record.attempts + 1;
|
|
601
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
602
|
+
if (attempts >= maxAttempts) {
|
|
603
|
+
await store.markFailed(record.id, message, attempts, DEAD_NEXT_ATTEMPT_MS);
|
|
604
|
+
this.onDead?.({
|
|
605
|
+
...record,
|
|
606
|
+
attempts,
|
|
607
|
+
lastError: message,
|
|
608
|
+
nextAttemptAt: DEAD_NEXT_ATTEMPT_MS
|
|
609
|
+
});
|
|
610
|
+
} else {
|
|
611
|
+
await store.markFailed(record.id, message, attempts, Date.now() + backoffMs(attempts - 1));
|
|
612
|
+
}
|
|
613
|
+
failed += 1;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return { sent, failed, remaining: await store.size() };
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Start a background loop calling {@link drain} every `intervalSeconds`. Overlapping ticks are
|
|
620
|
+
* skipped while a drain is in flight, and a tick that throws is swallowed (routed to `onDrainError`)
|
|
621
|
+
* so the loop keeps running. The timer is `unref`'d so it never blocks process exit. Idempotent.
|
|
622
|
+
*/
|
|
623
|
+
startDrainer(intervalSeconds) {
|
|
624
|
+
this.requireStore();
|
|
625
|
+
if (this.drainTimer) return;
|
|
626
|
+
const ms = Math.max(1, Math.floor(intervalSeconds * 1e3));
|
|
627
|
+
this.drainTimer = setInterval(() => void this.drainTick(), ms);
|
|
628
|
+
this.drainTimer.unref?.();
|
|
629
|
+
}
|
|
630
|
+
/** Stop the background drainer started by {@link startDrainer}. Safe to call when not running. */
|
|
631
|
+
stopDrainer() {
|
|
632
|
+
if (this.drainTimer) {
|
|
633
|
+
clearInterval(this.drainTimer);
|
|
634
|
+
this.drainTimer = void 0;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
async drainTick() {
|
|
638
|
+
if (this.draining) return;
|
|
639
|
+
this.draining = true;
|
|
640
|
+
try {
|
|
641
|
+
await this.drain();
|
|
642
|
+
} catch (err) {
|
|
643
|
+
this.onDrainError?.(err);
|
|
644
|
+
} finally {
|
|
645
|
+
this.draining = false;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
requireStore() {
|
|
649
|
+
if (!this.store) {
|
|
650
|
+
throw new WebhookdError("no outbox store configured \u2014 pass `store` in ClientOptions");
|
|
651
|
+
}
|
|
652
|
+
return this.store;
|
|
653
|
+
}
|
|
88
654
|
// ── Endpoints ──────────────────────────────────────────────────────────────
|
|
89
655
|
/** Create an endpoint. The response includes the signing `secret` exactly once — persist it. */
|
|
90
656
|
async createEndpoint(url, opts = {}) {
|
|
@@ -252,13 +818,20 @@ function sleep(ms) {
|
|
|
252
818
|
}
|
|
253
819
|
|
|
254
820
|
// src/index.ts
|
|
255
|
-
var VERSION = "0.
|
|
821
|
+
var VERSION = "0.3.0";
|
|
256
822
|
export {
|
|
823
|
+
DEAD_NEXT_ATTEMPT_MS,
|
|
257
824
|
DEFAULT_TOLERANCE_SECONDS,
|
|
825
|
+
FileStore,
|
|
826
|
+
MemoryStore,
|
|
827
|
+
PostgresStore,
|
|
828
|
+
RedisStore,
|
|
829
|
+
SqliteStore,
|
|
258
830
|
VERSION,
|
|
259
831
|
WebhookdApiError,
|
|
260
832
|
WebhookdClient,
|
|
261
833
|
WebhookdError,
|
|
834
|
+
isDead,
|
|
262
835
|
sign,
|
|
263
836
|
verify
|
|
264
837
|
};
|