@nimbusnexus/webhooks-sdk 0.2.0 → 0.4.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,438 @@ 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
+ projectId: row.project_id,
205
+ source: row.source,
206
+ createdAt: Number(row.created_at),
207
+ attempts: row.attempts,
208
+ lastError: row.last_error,
209
+ nextAttemptAt: Number(row.next_attempt_at)
210
+ };
211
+ }
212
+ var PostgresStore = class {
213
+ connectionString;
214
+ table;
215
+ pool;
216
+ ready;
217
+ constructor(opts = {}) {
218
+ this.connectionString = opts.connectionString;
219
+ this.pool = opts.pool;
220
+ const table = opts.table ?? "webhookd_outbox";
221
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
222
+ throw new Error(`invalid table name: ${table}`);
223
+ }
224
+ this.table = table;
225
+ }
226
+ /** Lazily import the driver, open the pool, and create the table exactly once. */
227
+ async ensure() {
228
+ if (this.ready) return this.ready;
229
+ this.ready = (async () => {
230
+ if (!this.pool) {
231
+ const pg = await import("pg");
232
+ const Pool = pg.Pool ?? pg.default?.Pool;
233
+ if (!Pool) throw new Error("pg: could not resolve Pool export");
234
+ this.pool = new Pool({ connectionString: this.connectionString });
235
+ }
236
+ await this.pool.query(
237
+ `CREATE TABLE IF NOT EXISTS ${this.table} (
238
+ id TEXT PRIMARY KEY,
239
+ event_type TEXT NOT NULL,
240
+ payload JSONB NOT NULL,
241
+ project_id TEXT,
242
+ source TEXT,
243
+ sent BOOLEAN NOT NULL DEFAULT FALSE,
244
+ attempts INTEGER NOT NULL,
245
+ last_error TEXT,
246
+ next_attempt_at BIGINT NOT NULL,
247
+ created_at BIGINT NOT NULL
248
+ )`
249
+ );
250
+ return this.pool;
251
+ })();
252
+ return this.ready;
253
+ }
254
+ async save(record) {
255
+ const pool = await this.ensure();
256
+ await pool.query(
257
+ `INSERT INTO ${this.table}
258
+ (id, event_type, payload, project_id, source, sent, attempts, last_error, next_attempt_at, created_at)
259
+ VALUES ($1, $2, $3, $4, $5, FALSE, $6, $7, $8, $9)
260
+ ON CONFLICT (id) DO UPDATE SET
261
+ event_type = EXCLUDED.event_type,
262
+ payload = EXCLUDED.payload,
263
+ project_id = EXCLUDED.project_id,
264
+ source = EXCLUDED.source,
265
+ sent = EXCLUDED.sent,
266
+ attempts = EXCLUDED.attempts,
267
+ last_error = EXCLUDED.last_error,
268
+ next_attempt_at = EXCLUDED.next_attempt_at,
269
+ created_at = EXCLUDED.created_at`,
270
+ [
271
+ record.id,
272
+ record.eventType,
273
+ JSON.stringify(record.payload),
274
+ record.projectId,
275
+ record.source,
276
+ record.attempts,
277
+ record.lastError,
278
+ record.nextAttemptAt,
279
+ record.createdAt
280
+ ]
281
+ );
282
+ }
283
+ async listPending(limit) {
284
+ const pool = await this.ensure();
285
+ const res = await pool.query(
286
+ `SELECT * FROM ${this.table}
287
+ WHERE NOT sent AND next_attempt_at <= $1 AND next_attempt_at < $2
288
+ ORDER BY created_at ASC, id ASC
289
+ LIMIT $3`,
290
+ [Date.now(), DEAD_NEXT_ATTEMPT_MS, limit]
291
+ );
292
+ return res.rows.map(rowToRecord);
293
+ }
294
+ async markSent(id) {
295
+ const pool = await this.ensure();
296
+ await pool.query(`UPDATE ${this.table} SET sent = TRUE WHERE id = $1`, [id]);
297
+ }
298
+ async markFailed(id, error, attempts, nextAttemptAt) {
299
+ const pool = await this.ensure();
300
+ await pool.query(
301
+ `UPDATE ${this.table} SET attempts = $2, last_error = $3, next_attempt_at = $4 WHERE id = $1`,
302
+ [id, attempts, error, nextAttemptAt]
303
+ );
304
+ }
305
+ async size() {
306
+ const pool = await this.ensure();
307
+ const res = await pool.query(
308
+ `SELECT COUNT(*) AS n FROM ${this.table} WHERE NOT sent`
309
+ );
310
+ return Number(res.rows[0].n);
311
+ }
312
+ async listDead(limit) {
313
+ const pool = await this.ensure();
314
+ const res = await pool.query(
315
+ `SELECT * FROM ${this.table}
316
+ WHERE NOT sent AND next_attempt_at >= $1
317
+ ORDER BY created_at ASC, id ASC
318
+ ${limit === void 0 ? "" : "LIMIT $2"}`,
319
+ limit === void 0 ? [DEAD_NEXT_ATTEMPT_MS] : [DEAD_NEXT_ATTEMPT_MS, limit]
320
+ );
321
+ return res.rows.map(rowToRecord);
322
+ }
323
+ async close() {
324
+ if (this.pool) await this.pool.end();
325
+ }
326
+ };
327
+
328
+ // src/outbox.ts
329
+ var import_meta = {};
330
+ var DEAD_NEXT_ATTEMPT_MS = 864e13;
331
+ function isDead(record) {
332
+ return record.nextAttemptAt >= DEAD_NEXT_ATTEMPT_MS;
333
+ }
334
+ function cloneRecord(record) {
335
+ return { ...record, payload: structuredClone(record.payload) };
336
+ }
337
+ function byCreatedAt2(a, b) {
338
+ return a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
339
+ }
340
+ var MemoryStore = class {
341
+ records = /* @__PURE__ */ new Map();
342
+ save(record) {
343
+ this.records.set(record.id, cloneRecord(record));
344
+ }
345
+ listPending(limit) {
346
+ const now = Date.now();
347
+ return [...this.records.values()].filter((r) => r.nextAttemptAt <= now).sort(byCreatedAt2).slice(0, limit).map(cloneRecord);
348
+ }
349
+ markSent(id) {
350
+ this.records.delete(id);
351
+ }
352
+ markFailed(id, error, attempts, nextAttemptAt) {
353
+ const record = this.records.get(id);
354
+ if (!record) return;
355
+ record.attempts = attempts;
356
+ record.lastError = error;
357
+ record.nextAttemptAt = nextAttemptAt;
358
+ }
359
+ size() {
360
+ return this.records.size;
361
+ }
362
+ listDead(limit) {
363
+ const dead = [...this.records.values()].filter(isDead).sort(byCreatedAt2).map(cloneRecord);
364
+ return limit === void 0 ? dead : dead.slice(0, limit);
365
+ }
366
+ close() {
367
+ this.records.clear();
368
+ }
369
+ };
370
+ var FileStore = class {
371
+ dir;
372
+ constructor(dir) {
373
+ this.dir = dir;
374
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true });
375
+ }
376
+ /** Map an arbitrary id to a safe filename (ids may be caller-supplied idempotency keys). */
377
+ pathFor(id) {
378
+ return (0, import_node_path.join)(this.dir, `${encodeURIComponent(id)}.json`);
379
+ }
380
+ readAll() {
381
+ const out = [];
382
+ for (const name of (0, import_node_fs.readdirSync)(this.dir)) {
383
+ if (!name.endsWith(".json")) continue;
384
+ try {
385
+ out.push(JSON.parse((0, import_node_fs.readFileSync)((0, import_node_path.join)(this.dir, name), "utf8")));
386
+ } catch {
387
+ }
388
+ }
389
+ return out;
390
+ }
391
+ save(record) {
392
+ const dest = this.pathFor(record.id);
393
+ const tmp = `${dest}.tmp-${(0, import_node_crypto2.randomBytes)(6).toString("hex")}`;
394
+ (0, import_node_fs.writeFileSync)(tmp, JSON.stringify(record), "utf8");
395
+ (0, import_node_fs.renameSync)(tmp, dest);
396
+ }
397
+ listPending(limit) {
398
+ const now = Date.now();
399
+ return this.readAll().filter((r) => r.nextAttemptAt <= now).sort(byCreatedAt2).slice(0, limit);
400
+ }
401
+ markSent(id) {
402
+ (0, import_node_fs.rmSync)(this.pathFor(id), { force: true });
403
+ }
404
+ markFailed(id, error, attempts, nextAttemptAt) {
405
+ let record;
406
+ try {
407
+ record = JSON.parse((0, import_node_fs.readFileSync)(this.pathFor(id), "utf8"));
408
+ } catch {
409
+ return;
410
+ }
411
+ record.attempts = attempts;
412
+ record.lastError = error;
413
+ record.nextAttemptAt = nextAttemptAt;
414
+ this.save(record);
415
+ }
416
+ size() {
417
+ return (0, import_node_fs.readdirSync)(this.dir).filter((n) => n.endsWith(".json")).length;
418
+ }
419
+ listDead(limit) {
420
+ const dead = this.readAll().filter(isDead).sort(byCreatedAt2);
421
+ return limit === void 0 ? dead : dead.slice(0, limit);
422
+ }
423
+ close() {
424
+ }
425
+ };
426
+ function rowToRecord2(row) {
427
+ return {
428
+ id: row.id,
429
+ eventType: row.event_type,
430
+ payload: JSON.parse(row.payload),
431
+ projectId: row.project_id,
432
+ source: row.source,
433
+ createdAt: row.created_at,
434
+ attempts: row.attempts,
435
+ lastError: row.last_error,
436
+ nextAttemptAt: row.next_attempt_at
437
+ };
438
+ }
439
+ var SqliteStore = class {
440
+ db;
441
+ constructor(path = ":memory:") {
442
+ let DatabaseSync;
443
+ try {
444
+ let load;
445
+ try {
446
+ load = (0, import_node_module.createRequire)(import_meta.url);
447
+ } catch {
448
+ load = require;
449
+ }
450
+ ({ DatabaseSync } = load("node:sqlite"));
451
+ } catch (err) {
452
+ throw new Error(
453
+ `SqliteStore requires the built-in node:sqlite (Node >= 22.5): ${String(err)}`
454
+ );
455
+ }
456
+ this.db = new DatabaseSync(path);
457
+ this.db.exec(
458
+ // `project_id` is NULLABLE: NULL = the workspace's default project (the publish body simply
459
+ // omits the field). Renamed from the legacy `project` slug column — this store has no
460
+ // schema-versioning mechanism, so a table created by an older SDK build is not upgraded.
461
+ `CREATE TABLE IF NOT EXISTS webhookd_outbox (
462
+ id TEXT PRIMARY KEY,
463
+ event_type TEXT NOT NULL,
464
+ payload TEXT NOT NULL,
465
+ project_id TEXT,
466
+ source TEXT,
467
+ created_at INTEGER NOT NULL,
468
+ attempts INTEGER NOT NULL,
469
+ last_error TEXT,
470
+ next_attempt_at INTEGER NOT NULL
471
+ )`
472
+ );
473
+ }
474
+ save(record) {
475
+ this.db.prepare(
476
+ `INSERT INTO webhookd_outbox
477
+ (id, event_type, payload, project_id, source, created_at, attempts, last_error, next_attempt_at)
478
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
479
+ ON CONFLICT(id) DO UPDATE SET
480
+ event_type = excluded.event_type,
481
+ payload = excluded.payload,
482
+ project_id = excluded.project_id,
483
+ source = excluded.source,
484
+ created_at = excluded.created_at,
485
+ attempts = excluded.attempts,
486
+ last_error = excluded.last_error,
487
+ next_attempt_at = excluded.next_attempt_at`
488
+ ).run(
489
+ record.id,
490
+ record.eventType,
491
+ JSON.stringify(record.payload),
492
+ record.projectId,
493
+ record.source,
494
+ record.createdAt,
495
+ record.attempts,
496
+ record.lastError,
497
+ record.nextAttemptAt
498
+ );
499
+ }
500
+ listPending(limit) {
501
+ const rows = this.db.prepare(
502
+ `SELECT * FROM webhookd_outbox
503
+ WHERE next_attempt_at <= ? AND next_attempt_at < ?
504
+ ORDER BY created_at ASC, id ASC
505
+ LIMIT ?`
506
+ ).all(Date.now(), DEAD_NEXT_ATTEMPT_MS, limit);
507
+ return rows.map(rowToRecord2);
508
+ }
509
+ markSent(id) {
510
+ this.db.prepare(`DELETE FROM webhookd_outbox WHERE id = ?`).run(id);
511
+ }
512
+ markFailed(id, error, attempts, nextAttemptAt) {
513
+ this.db.prepare(
514
+ `UPDATE webhookd_outbox SET attempts = ?, last_error = ?, next_attempt_at = ? WHERE id = ?`
515
+ ).run(attempts, error, nextAttemptAt, id);
516
+ }
517
+ size() {
518
+ const row = this.db.prepare(`SELECT COUNT(*) AS n FROM webhookd_outbox`).get();
519
+ return Number(row.n);
520
+ }
521
+ listDead(limit) {
522
+ const rows = this.db.prepare(
523
+ `SELECT * FROM webhookd_outbox
524
+ WHERE next_attempt_at >= ?
525
+ ORDER BY created_at ASC, id ASC
526
+ ${limit === void 0 ? "" : "LIMIT ?"}`
527
+ ).all(...limit === void 0 ? [DEAD_NEXT_ATTEMPT_MS] : [DEAD_NEXT_ATTEMPT_MS, limit]);
528
+ return rows.map(rowToRecord2);
529
+ }
530
+ close() {
531
+ this.db.close();
532
+ }
533
+ };
534
+
83
535
  // src/client.ts
84
536
  var RETRY_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
85
537
  var WebhookdClient = class {
@@ -88,20 +540,32 @@ var WebhookdClient = class {
88
540
  timeoutMs;
89
541
  maxRetries;
90
542
  fetchImpl;
543
+ store;
544
+ maxAttempts;
545
+ drainBatchLimit;
546
+ onDead;
547
+ onDrainError;
548
+ drainTimer;
549
+ draining = false;
91
550
  constructor(opts) {
92
551
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
93
552
  this.apiKey = opts.apiKey;
94
553
  this.timeoutMs = opts.timeoutMs ?? 1e4;
95
554
  this.maxRetries = opts.maxRetries ?? 2;
96
555
  this.fetchImpl = opts.fetchImpl ?? fetch;
556
+ this.store = opts.store;
557
+ this.maxAttempts = opts.maxAttempts ?? 10;
558
+ this.drainBatchLimit = opts.drainBatchLimit ?? 100;
559
+ this.onDead = opts.onDead;
560
+ this.onDrainError = opts.onDrainError;
97
561
  }
98
562
  async publish(eventType, payload, opts = {}) {
99
563
  const body = {
100
564
  event_type: eventType,
101
- payload,
102
- environment: opts.environment ?? "prod",
103
- application: opts.application ?? "default"
565
+ payload
104
566
  };
567
+ const projectId = normalizeProjectId(opts.projectId);
568
+ if (projectId !== null) body.project_id = projectId;
105
569
  if (opts.source !== void 0) body.source = opts.source;
106
570
  const headers = {};
107
571
  if (opts.idempotencyKey !== void 0) headers["Idempotency-Key"] = opts.idempotencyKey;
@@ -111,20 +575,124 @@ var WebhookdClient = class {
111
575
  id: String(data.id),
112
576
  eventUid: String(data.event_uid),
113
577
  eventType: String(data.event_type),
114
- application: String(data.application ?? "default"),
115
- environment: String(data.environment ?? "prod"),
578
+ projectId: String(data.project_id),
116
579
  deliveriesCreated: Number(data.deliveries_created ?? 0),
117
580
  source: data.source ?? null
118
581
  };
119
582
  }
583
+ // ── Outbox (write-first, durable) ────────────────────────────────────────────
584
+ /**
585
+ * Durably buffer an event and return IMMEDIATELY — NO network call. Builds an {@link OutboxRecord}
586
+ * (id = `opts.idempotencyKey` or a fresh UUID v4), saves it to the configured store, and returns the
587
+ * id. Ship it later with {@link drain} (or the background drainer). Requires a `store` in options.
588
+ */
589
+ async enqueue(eventType, payload, opts = {}) {
590
+ const store = this.requireStore();
591
+ const now = Date.now();
592
+ const record = {
593
+ id: opts.idempotencyKey ?? (0, import_node_crypto3.randomUUID)(),
594
+ eventType,
595
+ payload,
596
+ // null = the workspace's default project (the field is omitted from the publish body on drain).
597
+ projectId: normalizeProjectId(opts.projectId),
598
+ source: opts.source ?? null,
599
+ createdAt: now,
600
+ attempts: 0,
601
+ lastError: null,
602
+ nextAttemptAt: now
603
+ };
604
+ await store.save(record);
605
+ return { id: record.id };
606
+ }
607
+ /**
608
+ * Send buffered records to webhookd. Pulls a due batch (oldest first), POSTs each to `/v1/events`
609
+ * with header `Idempotency-Key = record.id` (so a re-drain after a crash never double-publishes —
610
+ * webhookd dedupes). On 2xx the record is marked sent; on error `attempts` is bumped and the record
611
+ * is rescheduled with capped exponential backoff, or parked dead once it hits `maxAttempts` (the
612
+ * `onDead` hook fires, and it is retrievable via `store.listDead()`). Requires a `store`.
613
+ */
614
+ async drain(opts = {}) {
615
+ const store = this.requireStore();
616
+ const batchLimit = opts.batchLimit ?? this.drainBatchLimit;
617
+ const maxAttempts = opts.maxAttempts ?? this.maxAttempts;
618
+ const rows = await store.listPending(batchLimit);
619
+ let sent = 0;
620
+ let failed = 0;
621
+ for (const record of rows) {
622
+ const body = {
623
+ event_type: record.eventType,
624
+ payload: record.payload
625
+ };
626
+ if (record.projectId !== null) body.project_id = record.projectId;
627
+ if (record.source !== null) body.source = record.source;
628
+ try {
629
+ await this.request("POST", "/v1/events", {
630
+ body,
631
+ headers: { "Idempotency-Key": record.id }
632
+ });
633
+ await store.markSent(record.id);
634
+ sent += 1;
635
+ } catch (err) {
636
+ const attempts = record.attempts + 1;
637
+ const message = err instanceof Error ? err.message : String(err);
638
+ if (attempts >= maxAttempts) {
639
+ await store.markFailed(record.id, message, attempts, DEAD_NEXT_ATTEMPT_MS);
640
+ this.onDead?.({
641
+ ...record,
642
+ attempts,
643
+ lastError: message,
644
+ nextAttemptAt: DEAD_NEXT_ATTEMPT_MS
645
+ });
646
+ } else {
647
+ await store.markFailed(record.id, message, attempts, Date.now() + backoffMs(attempts - 1));
648
+ }
649
+ failed += 1;
650
+ }
651
+ }
652
+ return { sent, failed, remaining: await store.size() };
653
+ }
654
+ /**
655
+ * Start a background loop calling {@link drain} every `intervalSeconds`. Overlapping ticks are
656
+ * skipped while a drain is in flight, and a tick that throws is swallowed (routed to `onDrainError`)
657
+ * so the loop keeps running. The timer is `unref`'d so it never blocks process exit. Idempotent.
658
+ */
659
+ startDrainer(intervalSeconds) {
660
+ this.requireStore();
661
+ if (this.drainTimer) return;
662
+ const ms = Math.max(1, Math.floor(intervalSeconds * 1e3));
663
+ this.drainTimer = setInterval(() => void this.drainTick(), ms);
664
+ this.drainTimer.unref?.();
665
+ }
666
+ /** Stop the background drainer started by {@link startDrainer}. Safe to call when not running. */
667
+ stopDrainer() {
668
+ if (this.drainTimer) {
669
+ clearInterval(this.drainTimer);
670
+ this.drainTimer = void 0;
671
+ }
672
+ }
673
+ async drainTick() {
674
+ if (this.draining) return;
675
+ this.draining = true;
676
+ try {
677
+ await this.drain();
678
+ } catch (err) {
679
+ this.onDrainError?.(err);
680
+ } finally {
681
+ this.draining = false;
682
+ }
683
+ }
684
+ requireStore() {
685
+ if (!this.store) {
686
+ throw new WebhookdError("no outbox store configured \u2014 pass `store` in ClientOptions");
687
+ }
688
+ return this.store;
689
+ }
120
690
  // ── Endpoints ──────────────────────────────────────────────────────────────
121
691
  /** Create an endpoint. The response includes the signing `secret` exactly once — persist it. */
122
692
  async createEndpoint(url, opts = {}) {
123
- const body = {
124
- url,
125
- environment: opts.environment ?? "prod",
126
- application: opts.application ?? "default"
127
- };
693
+ const body = { url };
694
+ const projectId = normalizeProjectId(opts.projectId);
695
+ if (projectId !== null) body.project_id = projectId;
128
696
  if (opts.subscriptions !== void 0) body.subscriptions = opts.subscriptions;
129
697
  if (opts.secret !== void 0) body.secret = opts.secret;
130
698
  if (opts.maxAttempts !== void 0) body.max_attempts = opts.maxAttempts;
@@ -134,9 +702,11 @@ var WebhookdClient = class {
134
702
  if (opts.deliveryTimeoutMs !== void 0) body.delivery_timeout_ms = opts.deliveryTimeoutMs;
135
703
  return this.requestJson("POST", "/v1/endpoints", { body });
136
704
  }
137
- /** List endpoints for an environment (defaults to `prod`). */
705
+ /** List endpoints for a project. Omit `projectId` for the workspace's default project. */
138
706
  async listEndpoints(opts = {}) {
139
- const query = { environment: opts.environment ?? "prod" };
707
+ const query = {};
708
+ const projectId = normalizeProjectId(opts.projectId);
709
+ if (projectId !== null) query.project_id = projectId;
140
710
  if (opts.offset !== void 0) query.offset = opts.offset;
141
711
  if (opts.limit !== void 0) query.limit = opts.limit;
142
712
  return this.requestJson("GET", "/v1/endpoints", { query });
@@ -259,6 +829,9 @@ var WebhookdClient = class {
259
829
  throw new WebhookdError(`request failed after retries: ${String(lastErr)}`);
260
830
  }
261
831
  };
832
+ function normalizeProjectId(projectId) {
833
+ return projectId === void 0 || projectId === null || projectId === "" ? null : projectId;
834
+ }
262
835
  function backoffMs(attempt) {
263
836
  return Math.min(2e3, 200 * 2 ** attempt);
264
837
  }
@@ -284,14 +857,21 @@ function sleep(ms) {
284
857
  }
285
858
 
286
859
  // src/index.ts
287
- var VERSION = "0.2.0";
860
+ var VERSION = "0.4.0";
288
861
  // Annotate the CommonJS export names for ESM import in node:
289
862
  0 && (module.exports = {
863
+ DEAD_NEXT_ATTEMPT_MS,
290
864
  DEFAULT_TOLERANCE_SECONDS,
865
+ FileStore,
866
+ MemoryStore,
867
+ PostgresStore,
868
+ RedisStore,
869
+ SqliteStore,
291
870
  VERSION,
292
871
  WebhookdApiError,
293
872
  WebhookdClient,
294
873
  WebhookdError,
874
+ isDead,
295
875
  sign,
296
876
  verify
297
877
  });