@nimbusnexus/webhooks-sdk 0.1.1 → 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/dist/index.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,16 +17,31 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
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
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
21
31
  var src_exports = {};
22
32
  __export(src_exports, {
33
+ DEAD_NEXT_ATTEMPT_MS: () => DEAD_NEXT_ATTEMPT_MS,
23
34
  DEFAULT_TOLERANCE_SECONDS: () => DEFAULT_TOLERANCE_SECONDS,
35
+ FileStore: () => FileStore,
36
+ MemoryStore: () => MemoryStore,
37
+ PostgresStore: () => PostgresStore,
38
+ RedisStore: () => RedisStore,
39
+ SqliteStore: () => SqliteStore,
24
40
  VERSION: () => VERSION,
25
41
  WebhookdApiError: () => WebhookdApiError,
26
42
  WebhookdClient: () => WebhookdClient,
27
43
  WebhookdError: () => WebhookdError,
44
+ isDead: () => isDead,
28
45
  sign: () => sign,
29
46
  verify: () => verify
30
47
  });
@@ -62,6 +79,9 @@ function verify(secret, rawBody, signature, opts = {}) {
62
79
  return false;
63
80
  }
64
81
 
82
+ // src/client.ts
83
+ var import_node_crypto3 = require("crypto");
84
+
65
85
  // src/errors.ts
66
86
  var WebhookdError = class extends Error {
67
87
  constructor(message) {
@@ -80,6 +100,443 @@ var WebhookdApiError = class extends WebhookdError {
80
100
  }
81
101
  };
82
102
 
103
+ // src/outbox.ts
104
+ var import_node_fs = require("fs");
105
+ var import_node_path = require("path");
106
+ var import_node_crypto2 = require("crypto");
107
+ var import_node_module = require("module");
108
+
109
+ // src/stores/redis.ts
110
+ function byCreatedAt(a, b) {
111
+ return a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
112
+ }
113
+ var RedisStore = class {
114
+ url;
115
+ keyPrefix;
116
+ client;
117
+ connecting;
118
+ constructor(opts = {}) {
119
+ this.url = opts.url;
120
+ this.client = opts.client;
121
+ this.keyPrefix = opts.keyPrefix ?? "webhookd:outbox";
122
+ }
123
+ get zsetKey() {
124
+ return `${this.keyPrefix}:due`;
125
+ }
126
+ get hashKey() {
127
+ return `${this.keyPrefix}:records`;
128
+ }
129
+ /** Lazily import the driver + connect exactly once. */
130
+ async ensure() {
131
+ if (this.client && this.client.isOpen) return this.client;
132
+ if (this.connecting) return this.connecting;
133
+ this.connecting = (async () => {
134
+ if (!this.client) {
135
+ const { createClient: create } = await import("redis");
136
+ this.client = create({ url: this.url });
137
+ }
138
+ if (!this.client.isOpen) await this.client.connect();
139
+ return this.client;
140
+ })();
141
+ try {
142
+ return await this.connecting;
143
+ } finally {
144
+ this.connecting = void 0;
145
+ }
146
+ }
147
+ async save(record) {
148
+ const client = await this.ensure();
149
+ await client.hSet(this.hashKey, record.id, JSON.stringify(record));
150
+ await client.zAdd(this.zsetKey, { score: record.nextAttemptAt, value: record.id });
151
+ }
152
+ async listPending(limit) {
153
+ const client = await this.ensure();
154
+ const ids = await client.zRangeByScore(this.zsetKey, "-inf", Date.now());
155
+ return this.loadSorted(client, ids, limit);
156
+ }
157
+ async markSent(id) {
158
+ const client = await this.ensure();
159
+ await client.hDel(this.hashKey, id);
160
+ await client.zRem(this.zsetKey, id);
161
+ }
162
+ async markFailed(id, error, attempts, nextAttemptAt) {
163
+ const client = await this.ensure();
164
+ const raw = await client.hGet(this.hashKey, id);
165
+ if (raw === void 0 || raw === null) return;
166
+ const record = JSON.parse(raw);
167
+ record.attempts = attempts;
168
+ record.lastError = error;
169
+ record.nextAttemptAt = nextAttemptAt;
170
+ await client.hSet(this.hashKey, id, JSON.stringify(record));
171
+ await client.zAdd(this.zsetKey, { score: nextAttemptAt, value: id });
172
+ }
173
+ async size() {
174
+ const client = await this.ensure();
175
+ return client.hLen(this.hashKey);
176
+ }
177
+ async listDead(limit) {
178
+ const client = await this.ensure();
179
+ const ids = await client.zRangeByScore(this.zsetKey, DEAD_NEXT_ATTEMPT_MS, "+inf");
180
+ const dead = (await this.loadSorted(client, ids, ids.length)).filter(isDead);
181
+ return limit === void 0 ? dead : dead.slice(0, limit);
182
+ }
183
+ async close() {
184
+ if (this.client && this.client.isOpen) await this.client.close();
185
+ }
186
+ /** Load records for `ids` from the hash, drop any missing, sort oldest-first, cap at `limit`. */
187
+ async loadSorted(client, ids, limit) {
188
+ const records = [];
189
+ for (const id of ids) {
190
+ const raw = await client.hGet(this.hashKey, id);
191
+ if (raw === void 0 || raw === null) continue;
192
+ records.push(cloneRecord(JSON.parse(raw)));
193
+ }
194
+ return records.sort(byCreatedAt).slice(0, limit);
195
+ }
196
+ };
197
+
198
+ // src/stores/postgres.ts
199
+ function rowToRecord(row) {
200
+ return {
201
+ id: row.id,
202
+ eventType: row.event_type,
203
+ payload: row.payload,
204
+ environment: row.environment,
205
+ application: row.application,
206
+ source: row.source,
207
+ createdAt: Number(row.created_at),
208
+ attempts: row.attempts,
209
+ lastError: row.last_error,
210
+ nextAttemptAt: Number(row.next_attempt_at)
211
+ };
212
+ }
213
+ var PostgresStore = class {
214
+ connectionString;
215
+ table;
216
+ pool;
217
+ ready;
218
+ constructor(opts = {}) {
219
+ this.connectionString = opts.connectionString;
220
+ this.pool = opts.pool;
221
+ const table = opts.table ?? "webhookd_outbox";
222
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
223
+ throw new Error(`invalid table name: ${table}`);
224
+ }
225
+ this.table = table;
226
+ }
227
+ /** Lazily import the driver, open the pool, and create the table exactly once. */
228
+ async ensure() {
229
+ if (this.ready) return this.ready;
230
+ this.ready = (async () => {
231
+ if (!this.pool) {
232
+ const pg = await import("pg");
233
+ const Pool = pg.Pool ?? pg.default?.Pool;
234
+ if (!Pool) throw new Error("pg: could not resolve Pool export");
235
+ this.pool = new Pool({ connectionString: this.connectionString });
236
+ }
237
+ await this.pool.query(
238
+ `CREATE TABLE IF NOT EXISTS ${this.table} (
239
+ id TEXT PRIMARY KEY,
240
+ event_type TEXT NOT NULL,
241
+ payload JSONB NOT NULL,
242
+ environment TEXT NOT NULL,
243
+ application TEXT NOT NULL,
244
+ source TEXT,
245
+ sent BOOLEAN NOT NULL DEFAULT FALSE,
246
+ attempts INTEGER NOT NULL,
247
+ last_error TEXT,
248
+ next_attempt_at BIGINT NOT NULL,
249
+ created_at BIGINT NOT NULL
250
+ )`
251
+ );
252
+ return this.pool;
253
+ })();
254
+ return this.ready;
255
+ }
256
+ async save(record) {
257
+ const pool = await this.ensure();
258
+ await pool.query(
259
+ `INSERT INTO ${this.table}
260
+ (id, event_type, payload, environment, application, source, sent, attempts, last_error, next_attempt_at, created_at)
261
+ VALUES ($1, $2, $3, $4, $5, $6, FALSE, $7, $8, $9, $10)
262
+ ON CONFLICT (id) DO UPDATE SET
263
+ event_type = EXCLUDED.event_type,
264
+ payload = EXCLUDED.payload,
265
+ environment = EXCLUDED.environment,
266
+ application = EXCLUDED.application,
267
+ source = EXCLUDED.source,
268
+ sent = EXCLUDED.sent,
269
+ attempts = EXCLUDED.attempts,
270
+ last_error = EXCLUDED.last_error,
271
+ next_attempt_at = EXCLUDED.next_attempt_at,
272
+ created_at = EXCLUDED.created_at`,
273
+ [
274
+ record.id,
275
+ record.eventType,
276
+ JSON.stringify(record.payload),
277
+ record.environment,
278
+ record.application,
279
+ record.source,
280
+ record.attempts,
281
+ record.lastError,
282
+ record.nextAttemptAt,
283
+ record.createdAt
284
+ ]
285
+ );
286
+ }
287
+ async listPending(limit) {
288
+ const pool = await this.ensure();
289
+ const res = await pool.query(
290
+ `SELECT * FROM ${this.table}
291
+ WHERE NOT sent AND next_attempt_at <= $1 AND next_attempt_at < $2
292
+ ORDER BY created_at ASC, id ASC
293
+ LIMIT $3`,
294
+ [Date.now(), DEAD_NEXT_ATTEMPT_MS, limit]
295
+ );
296
+ return res.rows.map(rowToRecord);
297
+ }
298
+ async markSent(id) {
299
+ const pool = await this.ensure();
300
+ await pool.query(`UPDATE ${this.table} SET sent = TRUE WHERE id = $1`, [id]);
301
+ }
302
+ async markFailed(id, error, attempts, nextAttemptAt) {
303
+ const pool = await this.ensure();
304
+ await pool.query(
305
+ `UPDATE ${this.table} SET attempts = $2, last_error = $3, next_attempt_at = $4 WHERE id = $1`,
306
+ [id, attempts, error, nextAttemptAt]
307
+ );
308
+ }
309
+ async size() {
310
+ const pool = await this.ensure();
311
+ const res = await pool.query(
312
+ `SELECT COUNT(*) AS n FROM ${this.table} WHERE NOT sent`
313
+ );
314
+ return Number(res.rows[0].n);
315
+ }
316
+ async listDead(limit) {
317
+ const pool = await this.ensure();
318
+ const res = await pool.query(
319
+ `SELECT * FROM ${this.table}
320
+ WHERE NOT sent AND next_attempt_at >= $1
321
+ ORDER BY created_at ASC, id ASC
322
+ ${limit === void 0 ? "" : "LIMIT $2"}`,
323
+ limit === void 0 ? [DEAD_NEXT_ATTEMPT_MS] : [DEAD_NEXT_ATTEMPT_MS, limit]
324
+ );
325
+ return res.rows.map(rowToRecord);
326
+ }
327
+ async close() {
328
+ if (this.pool) await this.pool.end();
329
+ }
330
+ };
331
+
332
+ // src/outbox.ts
333
+ var import_meta = {};
334
+ var DEAD_NEXT_ATTEMPT_MS = 864e13;
335
+ function isDead(record) {
336
+ return record.nextAttemptAt >= DEAD_NEXT_ATTEMPT_MS;
337
+ }
338
+ function cloneRecord(record) {
339
+ return { ...record, payload: structuredClone(record.payload) };
340
+ }
341
+ function byCreatedAt2(a, b) {
342
+ return a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
343
+ }
344
+ var MemoryStore = class {
345
+ records = /* @__PURE__ */ new Map();
346
+ save(record) {
347
+ this.records.set(record.id, cloneRecord(record));
348
+ }
349
+ listPending(limit) {
350
+ const now = Date.now();
351
+ return [...this.records.values()].filter((r) => r.nextAttemptAt <= now).sort(byCreatedAt2).slice(0, limit).map(cloneRecord);
352
+ }
353
+ markSent(id) {
354
+ this.records.delete(id);
355
+ }
356
+ markFailed(id, error, attempts, nextAttemptAt) {
357
+ const record = this.records.get(id);
358
+ if (!record) return;
359
+ record.attempts = attempts;
360
+ record.lastError = error;
361
+ record.nextAttemptAt = nextAttemptAt;
362
+ }
363
+ size() {
364
+ return this.records.size;
365
+ }
366
+ listDead(limit) {
367
+ const dead = [...this.records.values()].filter(isDead).sort(byCreatedAt2).map(cloneRecord);
368
+ return limit === void 0 ? dead : dead.slice(0, limit);
369
+ }
370
+ close() {
371
+ this.records.clear();
372
+ }
373
+ };
374
+ var FileStore = class {
375
+ dir;
376
+ constructor(dir) {
377
+ this.dir = dir;
378
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true });
379
+ }
380
+ /** Map an arbitrary id to a safe filename (ids may be caller-supplied idempotency keys). */
381
+ pathFor(id) {
382
+ return (0, import_node_path.join)(this.dir, `${encodeURIComponent(id)}.json`);
383
+ }
384
+ readAll() {
385
+ const out = [];
386
+ for (const name of (0, import_node_fs.readdirSync)(this.dir)) {
387
+ if (!name.endsWith(".json")) continue;
388
+ try {
389
+ out.push(JSON.parse((0, import_node_fs.readFileSync)((0, import_node_path.join)(this.dir, name), "utf8")));
390
+ } catch {
391
+ }
392
+ }
393
+ return out;
394
+ }
395
+ save(record) {
396
+ const dest = this.pathFor(record.id);
397
+ const tmp = `${dest}.tmp-${(0, import_node_crypto2.randomBytes)(6).toString("hex")}`;
398
+ (0, import_node_fs.writeFileSync)(tmp, JSON.stringify(record), "utf8");
399
+ (0, import_node_fs.renameSync)(tmp, dest);
400
+ }
401
+ listPending(limit) {
402
+ const now = Date.now();
403
+ return this.readAll().filter((r) => r.nextAttemptAt <= now).sort(byCreatedAt2).slice(0, limit);
404
+ }
405
+ markSent(id) {
406
+ (0, import_node_fs.rmSync)(this.pathFor(id), { force: true });
407
+ }
408
+ markFailed(id, error, attempts, nextAttemptAt) {
409
+ let record;
410
+ try {
411
+ record = JSON.parse((0, import_node_fs.readFileSync)(this.pathFor(id), "utf8"));
412
+ } catch {
413
+ return;
414
+ }
415
+ record.attempts = attempts;
416
+ record.lastError = error;
417
+ record.nextAttemptAt = nextAttemptAt;
418
+ this.save(record);
419
+ }
420
+ size() {
421
+ return (0, import_node_fs.readdirSync)(this.dir).filter((n) => n.endsWith(".json")).length;
422
+ }
423
+ listDead(limit) {
424
+ const dead = this.readAll().filter(isDead).sort(byCreatedAt2);
425
+ return limit === void 0 ? dead : dead.slice(0, limit);
426
+ }
427
+ close() {
428
+ }
429
+ };
430
+ function rowToRecord2(row) {
431
+ return {
432
+ id: row.id,
433
+ eventType: row.event_type,
434
+ payload: JSON.parse(row.payload),
435
+ environment: row.environment,
436
+ application: row.application,
437
+ source: row.source,
438
+ createdAt: row.created_at,
439
+ attempts: row.attempts,
440
+ lastError: row.last_error,
441
+ nextAttemptAt: row.next_attempt_at
442
+ };
443
+ }
444
+ var SqliteStore = class {
445
+ db;
446
+ constructor(path = ":memory:") {
447
+ let DatabaseSync;
448
+ try {
449
+ let load;
450
+ try {
451
+ load = (0, import_node_module.createRequire)(import_meta.url);
452
+ } catch {
453
+ load = require;
454
+ }
455
+ ({ DatabaseSync } = load("node:sqlite"));
456
+ } catch (err) {
457
+ throw new Error(
458
+ `SqliteStore requires the built-in node:sqlite (Node >= 22.5): ${String(err)}`
459
+ );
460
+ }
461
+ this.db = new DatabaseSync(path);
462
+ this.db.exec(
463
+ `CREATE TABLE IF NOT EXISTS webhookd_outbox (
464
+ id TEXT PRIMARY KEY,
465
+ event_type TEXT NOT NULL,
466
+ payload TEXT NOT NULL,
467
+ environment TEXT NOT NULL,
468
+ application TEXT NOT NULL,
469
+ source TEXT,
470
+ created_at INTEGER NOT NULL,
471
+ attempts INTEGER NOT NULL,
472
+ last_error TEXT,
473
+ next_attempt_at INTEGER NOT NULL
474
+ )`
475
+ );
476
+ }
477
+ save(record) {
478
+ this.db.prepare(
479
+ `INSERT INTO webhookd_outbox
480
+ (id, event_type, payload, environment, application, source, created_at, attempts, last_error, next_attempt_at)
481
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
482
+ ON CONFLICT(id) DO UPDATE SET
483
+ event_type = excluded.event_type,
484
+ payload = excluded.payload,
485
+ environment = excluded.environment,
486
+ application = excluded.application,
487
+ source = excluded.source,
488
+ created_at = excluded.created_at,
489
+ attempts = excluded.attempts,
490
+ last_error = excluded.last_error,
491
+ next_attempt_at = excluded.next_attempt_at`
492
+ ).run(
493
+ record.id,
494
+ record.eventType,
495
+ JSON.stringify(record.payload),
496
+ record.environment,
497
+ record.application,
498
+ record.source,
499
+ record.createdAt,
500
+ record.attempts,
501
+ record.lastError,
502
+ record.nextAttemptAt
503
+ );
504
+ }
505
+ listPending(limit) {
506
+ const rows = this.db.prepare(
507
+ `SELECT * FROM webhookd_outbox
508
+ WHERE next_attempt_at <= ? AND next_attempt_at < ?
509
+ ORDER BY created_at ASC, id ASC
510
+ LIMIT ?`
511
+ ).all(Date.now(), DEAD_NEXT_ATTEMPT_MS, limit);
512
+ return rows.map(rowToRecord2);
513
+ }
514
+ markSent(id) {
515
+ this.db.prepare(`DELETE FROM webhookd_outbox WHERE id = ?`).run(id);
516
+ }
517
+ markFailed(id, error, attempts, nextAttemptAt) {
518
+ this.db.prepare(
519
+ `UPDATE webhookd_outbox SET attempts = ?, last_error = ?, next_attempt_at = ? WHERE id = ?`
520
+ ).run(attempts, error, nextAttemptAt, id);
521
+ }
522
+ size() {
523
+ const row = this.db.prepare(`SELECT COUNT(*) AS n FROM webhookd_outbox`).get();
524
+ return Number(row.n);
525
+ }
526
+ listDead(limit) {
527
+ const rows = this.db.prepare(
528
+ `SELECT * FROM webhookd_outbox
529
+ WHERE next_attempt_at >= ?
530
+ ORDER BY created_at ASC, id ASC
531
+ ${limit === void 0 ? "" : "LIMIT ?"}`
532
+ ).all(...limit === void 0 ? [DEAD_NEXT_ATTEMPT_MS] : [DEAD_NEXT_ATTEMPT_MS, limit]);
533
+ return rows.map(rowToRecord2);
534
+ }
535
+ close() {
536
+ this.db.close();
537
+ }
538
+ };
539
+
83
540
  // src/client.ts
84
541
  var RETRY_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
85
542
  var WebhookdClient = class {
@@ -88,12 +545,24 @@ var WebhookdClient = class {
88
545
  timeoutMs;
89
546
  maxRetries;
90
547
  fetchImpl;
548
+ store;
549
+ maxAttempts;
550
+ drainBatchLimit;
551
+ onDead;
552
+ onDrainError;
553
+ drainTimer;
554
+ draining = false;
91
555
  constructor(opts) {
92
556
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
93
557
  this.apiKey = opts.apiKey;
94
558
  this.timeoutMs = opts.timeoutMs ?? 1e4;
95
559
  this.maxRetries = opts.maxRetries ?? 2;
96
560
  this.fetchImpl = opts.fetchImpl ?? fetch;
561
+ this.store = opts.store;
562
+ this.maxAttempts = opts.maxAttempts ?? 10;
563
+ this.drainBatchLimit = opts.drainBatchLimit ?? 100;
564
+ this.onDead = opts.onDead;
565
+ this.onDrainError = opts.onDrainError;
97
566
  }
98
567
  async publish(eventType, payload, opts = {}) {
99
568
  const body = {
@@ -103,12 +572,9 @@ var WebhookdClient = class {
103
572
  application: opts.application ?? "default"
104
573
  };
105
574
  if (opts.source !== void 0) body.source = opts.source;
106
- const headers = {
107
- "Content-Type": "application/json",
108
- Authorization: `Bearer ${this.apiKey}`
109
- };
575
+ const headers = {};
110
576
  if (opts.idempotencyKey !== void 0) headers["Idempotency-Key"] = opts.idempotencyKey;
111
- const resp = await this.post("/v1/events", JSON.stringify(body), headers);
577
+ const resp = await this.request("POST", "/v1/events", { body, headers });
112
578
  const data = await resp.json();
113
579
  return {
114
580
  id: String(data.id),
@@ -120,14 +586,234 @@ var WebhookdClient = class {
120
586
  source: data.source ?? null
121
587
  };
122
588
  }
123
- async post(path, body, headers) {
124
- const url = `${this.baseUrl}${path}`;
589
+ // ── Outbox (write-first, durable) ────────────────────────────────────────────
590
+ /**
591
+ * Durably buffer an event and return IMMEDIATELY — NO network call. Builds an {@link OutboxRecord}
592
+ * (id = `opts.idempotencyKey` or a fresh UUID v4), saves it to the configured store, and returns the
593
+ * id. Ship it later with {@link drain} (or the background drainer). Requires a `store` in options.
594
+ */
595
+ async enqueue(eventType, payload, opts = {}) {
596
+ const store = this.requireStore();
597
+ const now = Date.now();
598
+ const record = {
599
+ id: opts.idempotencyKey ?? (0, import_node_crypto3.randomUUID)(),
600
+ eventType,
601
+ payload,
602
+ environment: opts.environment ?? "prod",
603
+ application: opts.application ?? "default",
604
+ source: opts.source ?? null,
605
+ createdAt: now,
606
+ attempts: 0,
607
+ lastError: null,
608
+ nextAttemptAt: now
609
+ };
610
+ await store.save(record);
611
+ return { id: record.id };
612
+ }
613
+ /**
614
+ * Send buffered records to webhookd. Pulls a due batch (oldest first), POSTs each to `/v1/events`
615
+ * with header `Idempotency-Key = record.id` (so a re-drain after a crash never double-publishes —
616
+ * webhookd dedupes). On 2xx the record is marked sent; on error `attempts` is bumped and the record
617
+ * is rescheduled with capped exponential backoff, or parked dead once it hits `maxAttempts` (the
618
+ * `onDead` hook fires, and it is retrievable via `store.listDead()`). Requires a `store`.
619
+ */
620
+ async drain(opts = {}) {
621
+ const store = this.requireStore();
622
+ const batchLimit = opts.batchLimit ?? this.drainBatchLimit;
623
+ const maxAttempts = opts.maxAttempts ?? this.maxAttempts;
624
+ const rows = await store.listPending(batchLimit);
625
+ let sent = 0;
626
+ let failed = 0;
627
+ for (const record of rows) {
628
+ const body = {
629
+ event_type: record.eventType,
630
+ payload: record.payload,
631
+ environment: record.environment,
632
+ application: record.application
633
+ };
634
+ if (record.source !== null) body.source = record.source;
635
+ try {
636
+ await this.request("POST", "/v1/events", {
637
+ body,
638
+ headers: { "Idempotency-Key": record.id }
639
+ });
640
+ await store.markSent(record.id);
641
+ sent += 1;
642
+ } catch (err) {
643
+ const attempts = record.attempts + 1;
644
+ const message = err instanceof Error ? err.message : String(err);
645
+ if (attempts >= maxAttempts) {
646
+ await store.markFailed(record.id, message, attempts, DEAD_NEXT_ATTEMPT_MS);
647
+ this.onDead?.({
648
+ ...record,
649
+ attempts,
650
+ lastError: message,
651
+ nextAttemptAt: DEAD_NEXT_ATTEMPT_MS
652
+ });
653
+ } else {
654
+ await store.markFailed(record.id, message, attempts, Date.now() + backoffMs(attempts - 1));
655
+ }
656
+ failed += 1;
657
+ }
658
+ }
659
+ return { sent, failed, remaining: await store.size() };
660
+ }
661
+ /**
662
+ * Start a background loop calling {@link drain} every `intervalSeconds`. Overlapping ticks are
663
+ * skipped while a drain is in flight, and a tick that throws is swallowed (routed to `onDrainError`)
664
+ * so the loop keeps running. The timer is `unref`'d so it never blocks process exit. Idempotent.
665
+ */
666
+ startDrainer(intervalSeconds) {
667
+ this.requireStore();
668
+ if (this.drainTimer) return;
669
+ const ms = Math.max(1, Math.floor(intervalSeconds * 1e3));
670
+ this.drainTimer = setInterval(() => void this.drainTick(), ms);
671
+ this.drainTimer.unref?.();
672
+ }
673
+ /** Stop the background drainer started by {@link startDrainer}. Safe to call when not running. */
674
+ stopDrainer() {
675
+ if (this.drainTimer) {
676
+ clearInterval(this.drainTimer);
677
+ this.drainTimer = void 0;
678
+ }
679
+ }
680
+ async drainTick() {
681
+ if (this.draining) return;
682
+ this.draining = true;
683
+ try {
684
+ await this.drain();
685
+ } catch (err) {
686
+ this.onDrainError?.(err);
687
+ } finally {
688
+ this.draining = false;
689
+ }
690
+ }
691
+ requireStore() {
692
+ if (!this.store) {
693
+ throw new WebhookdError("no outbox store configured \u2014 pass `store` in ClientOptions");
694
+ }
695
+ return this.store;
696
+ }
697
+ // ── Endpoints ──────────────────────────────────────────────────────────────
698
+ /** Create an endpoint. The response includes the signing `secret` exactly once — persist it. */
699
+ async createEndpoint(url, opts = {}) {
700
+ const body = {
701
+ url,
702
+ environment: opts.environment ?? "prod",
703
+ application: opts.application ?? "default"
704
+ };
705
+ if (opts.subscriptions !== void 0) body.subscriptions = opts.subscriptions;
706
+ if (opts.secret !== void 0) body.secret = opts.secret;
707
+ if (opts.maxAttempts !== void 0) body.max_attempts = opts.maxAttempts;
708
+ if (opts.retrySchedule !== void 0) body.retry_schedule = opts.retrySchedule;
709
+ if (opts.description !== void 0) body.description = opts.description;
710
+ if (opts.customHeaders !== void 0) body.custom_headers = opts.customHeaders;
711
+ if (opts.deliveryTimeoutMs !== void 0) body.delivery_timeout_ms = opts.deliveryTimeoutMs;
712
+ return this.requestJson("POST", "/v1/endpoints", { body });
713
+ }
714
+ /** List endpoints for an environment (defaults to `prod`). */
715
+ async listEndpoints(opts = {}) {
716
+ const query = { environment: opts.environment ?? "prod" };
717
+ if (opts.offset !== void 0) query.offset = opts.offset;
718
+ if (opts.limit !== void 0) query.limit = opts.limit;
719
+ return this.requestJson("GET", "/v1/endpoints", { query });
720
+ }
721
+ /** Fetch a single endpoint by id. */
722
+ async getEndpoint(id) {
723
+ return this.requestJson("GET", `/v1/endpoints/${encodeURIComponent(id)}`);
724
+ }
725
+ /**
726
+ * Partially update an endpoint. `patch` is sent verbatim (snake_case keys): an omitted key is left
727
+ * unchanged, an explicit `null` clears the field. No defaults are injected.
728
+ */
729
+ async updateEndpoint(id, patch) {
730
+ return this.requestJson("PATCH", `/v1/endpoints/${encodeURIComponent(id)}`, {
731
+ body: patch
732
+ });
733
+ }
734
+ /** Delete an endpoint. Returns nothing (the server replies `204 No Content`). */
735
+ async deleteEndpoint(id) {
736
+ await this.request("DELETE", `/v1/endpoints/${encodeURIComponent(id)}`);
737
+ }
738
+ /** Rotate an endpoint's signing secret. The response includes the new `secret` exactly once. */
739
+ async rotateEndpointSecret(id) {
740
+ return this.requestJson("POST", `/v1/endpoints/${encodeURIComponent(id)}/rotate-secret`);
741
+ }
742
+ /** Re-enable an endpoint that webhookd auto-disabled after repeated delivery failures. */
743
+ async enableEndpoint(id) {
744
+ return this.requestJson("POST", `/v1/endpoints/${encodeURIComponent(id)}/enable`);
745
+ }
746
+ // ── API keys ───────────────────────────────────────────────────────────────
747
+ /** Create an API key. The response includes the raw `key` exactly once — persist it. */
748
+ async createApiKey(opts = {}) {
749
+ const body = {
750
+ name: opts.name ?? "",
751
+ scope: opts.scope ?? "admin"
752
+ };
753
+ if (opts.expiresInDays !== void 0) body.expires_in_days = opts.expiresInDays;
754
+ return this.requestJson("POST", "/v1/api-keys", { body });
755
+ }
756
+ /** Revoke an API key. Returns nothing (the server replies `204 No Content`). */
757
+ async revokeApiKey(id) {
758
+ await this.request("DELETE", `/v1/api-keys/${encodeURIComponent(id)}`);
759
+ }
760
+ // ── Deliveries ─────────────────────────────────────────────────────────────
761
+ /** List deliveries. Only the filters you provide are sent as query params. */
762
+ async listDeliveries(opts = {}) {
763
+ const query = {};
764
+ if (opts.status !== void 0) query.status = opts.status;
765
+ if (opts.endpointId !== void 0) query.endpoint_id = opts.endpointId;
766
+ if (opts.eventType !== void 0) query.event_type = opts.eventType;
767
+ if (opts.since !== void 0) query.since = opts.since;
768
+ if (opts.until !== void 0) query.until = opts.until;
769
+ if (opts.q !== void 0) query.q = opts.q;
770
+ if (opts.offset !== void 0) query.offset = opts.offset;
771
+ if (opts.limit !== void 0) query.limit = opts.limit;
772
+ return this.requestJson("GET", "/v1/deliveries", { query });
773
+ }
774
+ /** Re-queue a delivery for another attempt. */
775
+ async redeliver(deliveryId) {
776
+ return this.requestJson(
777
+ "POST",
778
+ `/v1/deliveries/${encodeURIComponent(deliveryId)}/redeliver`
779
+ );
780
+ }
781
+ // ── Internals ──────────────────────────────────────────────────────────────
782
+ /** Issue a request and parse the JSON response as `T`. Never use for 204s (there's no body). */
783
+ async requestJson(method, path, opts = {}) {
784
+ const resp = await this.request(method, path, opts);
785
+ return await resp.json();
786
+ }
787
+ /**
788
+ * The single request path used by every method: builds the URL (+ query), attaches auth, and runs
789
+ * the shared retry loop (connection errors + 429 honoring Retry-After + 5xx, capped exponential
790
+ * backoff), raising a typed {@link WebhookdApiError} on other 4xx/5xx. The caller reads the body.
791
+ */
792
+ async request(method, path, opts = {}) {
793
+ let url = `${this.baseUrl}${path}`;
794
+ if (opts.query) {
795
+ const qs = new URLSearchParams();
796
+ for (const [key, value] of Object.entries(opts.query)) {
797
+ if (value !== void 0 && value !== null) qs.append(key, String(value));
798
+ }
799
+ const suffix = qs.toString();
800
+ if (suffix) url += `?${suffix}`;
801
+ }
802
+ const headers = {
803
+ Authorization: `Bearer ${this.apiKey}`,
804
+ ...opts.headers
805
+ };
806
+ let body;
807
+ if (opts.body !== void 0) {
808
+ body = JSON.stringify(opts.body);
809
+ headers["Content-Type"] = "application/json";
810
+ }
125
811
  let lastErr;
126
812
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
127
813
  let resp;
128
814
  try {
129
815
  resp = await this.fetchImpl(url, {
130
- method: "POST",
816
+ method,
131
817
  headers,
132
818
  body,
133
819
  signal: AbortSignal.timeout(this.timeoutMs)
@@ -159,14 +845,14 @@ function retryAfterMs(resp) {
159
845
  return null;
160
846
  }
161
847
  async function apiError(resp) {
848
+ const text = await resp.text();
162
849
  let code = "error";
163
- let message = "";
850
+ let message = text;
164
851
  try {
165
- const data = await resp.json();
852
+ const data = JSON.parse(text);
166
853
  code = data.error?.code ?? code;
167
- message = data.error?.message ?? "";
854
+ message = data.error?.message ?? message;
168
855
  } catch {
169
- message = "";
170
856
  }
171
857
  return new WebhookdApiError(resp.status, code, message);
172
858
  }
@@ -175,14 +861,21 @@ function sleep(ms) {
175
861
  }
176
862
 
177
863
  // src/index.ts
178
- var VERSION = "0.1.0";
864
+ var VERSION = "0.3.0";
179
865
  // Annotate the CommonJS export names for ESM import in node:
180
866
  0 && (module.exports = {
867
+ DEAD_NEXT_ATTEMPT_MS,
181
868
  DEFAULT_TOLERANCE_SECONDS,
869
+ FileStore,
870
+ MemoryStore,
871
+ PostgresStore,
872
+ RedisStore,
873
+ SqliteStore,
182
874
  VERSION,
183
875
  WebhookdApiError,
184
876
  WebhookdClient,
185
877
  WebhookdError,
878
+ isDead,
186
879
  sign,
187
880
  verify
188
881
  });