@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.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { Pool } from 'pg';
2
+ import { createClient } from 'redis';
3
+
1
4
  declare const DEFAULT_TOLERANCE_SECONDS = 300;
2
5
  /** The `sha256=<hex>` signature webhookd would send for `rawBody` (+ optional timestamp). */
3
6
  declare function sign(secret: string | Buffer, rawBody: string | Buffer, timestamp?: number | null): string;
@@ -17,6 +20,171 @@ interface VerifyOptions {
17
20
  */
18
21
  declare function verify(secret: string | Buffer, rawBody: string | Buffer, signature: string, opts?: VerifyOptions): boolean;
19
22
 
23
+ /**
24
+ * Redis-backed {@link Store}. Durable ordering via a sorted set scored on `nextAttemptAt` (for due
25
+ * filtering) plus a hash of record bodies keyed by id. The `redis` driver is an OPTIONAL dependency,
26
+ * imported lazily inside {@link RedisStore.ensure} — the SDK core stays zero-runtime-dependency and
27
+ * importing this module never pulls in `redis` unless you actually construct the store.
28
+ */
29
+
30
+ type RedisClient = ReturnType<typeof createClient>;
31
+ interface RedisStoreOptions {
32
+ /** Redis connection URL, e.g. `redis://localhost:6379`. Ignored if `client` is supplied. */
33
+ url?: string;
34
+ /** Reuse an already-created (not necessarily connected) `redis` client instead of `url`. */
35
+ client?: RedisClient;
36
+ /** Namespace for the two keys this store uses. Default `webhookd:outbox`. */
37
+ keyPrefix?: string;
38
+ }
39
+ declare class RedisStore implements Store {
40
+ private readonly url?;
41
+ private readonly keyPrefix;
42
+ private client;
43
+ private connecting;
44
+ constructor(opts?: RedisStoreOptions);
45
+ private get zsetKey();
46
+ private get hashKey();
47
+ /** Lazily import the driver + connect exactly once. */
48
+ private ensure;
49
+ save(record: OutboxRecord): Promise<void>;
50
+ listPending(limit: number): Promise<OutboxRecord[]>;
51
+ markSent(id: string): Promise<void>;
52
+ markFailed(id: string, error: string, attempts: number, nextAttemptAt: number): Promise<void>;
53
+ size(): Promise<number>;
54
+ listDead(limit?: number): Promise<OutboxRecord[]>;
55
+ close(): Promise<void>;
56
+ /** Load records for `ids` from the hash, drop any missing, sort oldest-first, cap at `limit`. */
57
+ private loadSorted;
58
+ }
59
+
60
+ /**
61
+ * Postgres-backed {@link Store}. A single `webhookd_outbox` table with a `sent` flag; upsert on `id`;
62
+ * pending = `WHERE NOT sent AND next_attempt_at <= now`. The `pg` driver is an OPTIONAL dependency,
63
+ * imported lazily inside {@link PostgresStore.ensure} — importing this module never pulls in `pg`
64
+ * unless you actually construct the store.
65
+ */
66
+
67
+ interface PostgresStoreOptions {
68
+ /** Postgres connection string, e.g. `postgres://user:pass@host:5432/db`. Ignored if `pool` is given. */
69
+ connectionString?: string;
70
+ /** Reuse an existing `pg` Pool instead of `connectionString`. */
71
+ pool?: Pool;
72
+ /** Table name (must be a plain identifier). Default `webhookd_outbox`. */
73
+ table?: string;
74
+ }
75
+ declare class PostgresStore implements Store {
76
+ private readonly connectionString?;
77
+ private readonly table;
78
+ private pool;
79
+ private ready;
80
+ constructor(opts?: PostgresStoreOptions);
81
+ /** Lazily import the driver, open the pool, and create the table exactly once. */
82
+ private ensure;
83
+ save(record: OutboxRecord): Promise<void>;
84
+ listPending(limit: number): Promise<OutboxRecord[]>;
85
+ markSent(id: string): Promise<void>;
86
+ markFailed(id: string, error: string, attempts: number, nextAttemptAt: number): Promise<void>;
87
+ size(): Promise<number>;
88
+ listDead(limit?: number): Promise<OutboxRecord[]>;
89
+ close(): Promise<void>;
90
+ }
91
+
92
+ /**
93
+ * A sentinel `nextAttemptAt` (JS max timestamp) used to park a record that has exhausted its retry
94
+ * budget. It is never `<= now`, so {@link Store.listPending} never returns it again — the record
95
+ * stays durably in the store, flagged dead, retrievable via {@link Store.listDead}.
96
+ */
97
+ declare const DEAD_NEXT_ATTEMPT_MS = 8640000000000000;
98
+ /**
99
+ * A single buffered event. `id` doubles as the webhookd `Idempotency-Key`, so re-saving the same
100
+ * `id` (an idempotent enqueue) simply overwrites, and re-draining after a crash never double-sends.
101
+ * Timestamps are epoch milliseconds.
102
+ */
103
+ interface OutboxRecord {
104
+ /** The Idempotency-Key — caller-supplied or a generated UUID v4. */
105
+ id: string;
106
+ eventType: string;
107
+ payload: Record<string, unknown>;
108
+ environment: string;
109
+ application: string;
110
+ source: string | null;
111
+ /** Epoch ms the record was first enqueued; `listPending` orders by this, oldest first. */
112
+ createdAt: number;
113
+ /** Delivery attempts made so far; starts at 0. */
114
+ attempts: number;
115
+ lastError: string | null;
116
+ /** Epoch ms the record next becomes due; initially `now`. `DEAD_NEXT_ATTEMPT_MS` when dead. */
117
+ nextAttemptAt: number;
118
+ }
119
+ /** Whether a record has been parked as dead (retry budget exhausted). */
120
+ declare function isDead(record: OutboxRecord): boolean;
121
+ /**
122
+ * A durable buffer of pending events. Implementations may be sync or async; every method returns a
123
+ * value or a promise, and callers always `await`. Built-ins: {@link MemoryStore}, {@link FileStore},
124
+ * {@link SqliteStore}, and (optional-dep) `RedisStore` / `PostgresStore`.
125
+ */
126
+ interface Store {
127
+ /** Insert-or-update by `record.id`. Idempotent: the same id overwrites, so enqueue is safe to repeat. */
128
+ save(record: OutboxRecord): void | Promise<void>;
129
+ /** Records not yet sent whose `nextAttemptAt <= now`, oldest first (by `createdAt`), capped at `limit`. */
130
+ listPending(limit: number): OutboxRecord[] | Promise<OutboxRecord[]>;
131
+ /** Remove (or flag sent) a record after a 2xx. */
132
+ markSent(id: string): void | Promise<void>;
133
+ /** Persist a failure + schedule the next retry (or park it dead via `DEAD_NEXT_ATTEMPT_MS`). */
134
+ markFailed(id: string, error: string, attempts: number, nextAttemptAt: number): void | Promise<void>;
135
+ /** Count of records still in the store (i.e. not yet sent), dead ones included. */
136
+ size(): number | Promise<number>;
137
+ /** Records parked dead, oldest first. */
138
+ listDead(limit?: number): OutboxRecord[] | Promise<OutboxRecord[]>;
139
+ /** Release any resources (file handles, DB connections, timers). */
140
+ close(): void | Promise<void>;
141
+ }
142
+ /** In-process, non-durable store. The default for tests and single-process best-effort buffering. */
143
+ declare class MemoryStore implements Store {
144
+ private readonly records;
145
+ save(record: OutboxRecord): void;
146
+ listPending(limit: number): OutboxRecord[];
147
+ markSent(id: string): void;
148
+ markFailed(id: string, error: string, attempts: number, nextAttemptAt: number): void;
149
+ size(): number;
150
+ listDead(limit?: number): OutboxRecord[];
151
+ close(): void;
152
+ }
153
+ /**
154
+ * Durable store backed by a directory of per-record JSON files with atomic writes (write-tmp +
155
+ * rename). Survives process restarts. `markSent` unlinks the file. Suitable for a single process; it
156
+ * does not coordinate concurrent drainers across processes.
157
+ */
158
+ declare class FileStore implements Store {
159
+ private readonly dir;
160
+ constructor(dir: string);
161
+ /** Map an arbitrary id to a safe filename (ids may be caller-supplied idempotency keys). */
162
+ private pathFor;
163
+ private readAll;
164
+ save(record: OutboxRecord): void;
165
+ listPending(limit: number): OutboxRecord[];
166
+ markSent(id: string): void;
167
+ markFailed(id: string, error: string, attempts: number, nextAttemptAt: number): void;
168
+ size(): number;
169
+ listDead(limit?: number): OutboxRecord[];
170
+ close(): void;
171
+ }
172
+ /**
173
+ * Durable, transactional store on the built-in `node:sqlite` (Node >= 22.5 — no native dependency).
174
+ * `markSent` deletes the row. Pass a file path to persist across restarts, or `":memory:"` for tests.
175
+ */
176
+ declare class SqliteStore implements Store {
177
+ private readonly db;
178
+ constructor(path?: string);
179
+ save(record: OutboxRecord): void;
180
+ listPending(limit: number): OutboxRecord[];
181
+ markSent(id: string): void;
182
+ markFailed(id: string, error: string, attempts: number, nextAttemptAt: number): void;
183
+ size(): number;
184
+ listDead(limit?: number): OutboxRecord[];
185
+ close(): void;
186
+ }
187
+
20
188
  /** The published event, as returned by `POST /v1/events` (webhookd's `EventOut`). */
21
189
  interface WebhookdEvent {
22
190
  id: string;
@@ -27,6 +195,62 @@ interface WebhookdEvent {
27
195
  deliveriesCreated: number;
28
196
  source: string | null;
29
197
  }
198
+ /** A subscription filter attached to an endpoint. */
199
+ interface Subscription {
200
+ match_kind: "exact" | "prefix" | "suffix" | "all";
201
+ pattern: string;
202
+ }
203
+ /**
204
+ * An endpoint, as returned by the management endpoints (webhookd's `EndpointOut`).
205
+ * Fields mirror the server's snake_case wire shape verbatim. `secret` is present ONLY on the
206
+ * create + rotate-secret responses (returned exactly once).
207
+ */
208
+ interface Endpoint {
209
+ id: string;
210
+ url: string;
211
+ environment: string;
212
+ application: string;
213
+ status: string;
214
+ subscriptions: Subscription[];
215
+ /** The signing secret — returned ONCE, on create + rotate-secret only. */
216
+ secret?: string;
217
+ max_attempts?: number;
218
+ retry_schedule?: number[];
219
+ description?: string | null;
220
+ custom_headers?: Record<string, string> | null;
221
+ delivery_timeout_ms?: number | null;
222
+ created_at?: string;
223
+ updated_at?: string;
224
+ }
225
+ /**
226
+ * An API key, as returned by `POST /v1/api-keys` (webhookd's `ApiKeyOut`). `key` is present ONLY on
227
+ * the create response (returned exactly once).
228
+ */
229
+ interface ApiKey {
230
+ id: string;
231
+ name: string;
232
+ scope: string;
233
+ /** The raw key — returned ONCE, on create only. */
234
+ key?: string;
235
+ created_at?: string;
236
+ expires_at?: string | null;
237
+ }
238
+ /** A delivery attempt record, as returned by the deliveries endpoints (webhookd's `DeliveryOut`). */
239
+ interface Delivery {
240
+ id: string;
241
+ endpoint_id: string;
242
+ event_id?: string;
243
+ event_type?: string;
244
+ status: string;
245
+ attempts?: number;
246
+ created_at?: string;
247
+ updated_at?: string;
248
+ }
249
+ /** A single page of a list endpoint — items plus the cursor for the next page (`null` at the end). */
250
+ interface Page<T> {
251
+ items: T[];
252
+ next_offset: number | null;
253
+ }
30
254
  interface ClientOptions {
31
255
  baseUrl: string;
32
256
  /** A per-tenant API key (`whsk_…`) or a service token. */
@@ -35,6 +259,40 @@ interface ClientOptions {
35
259
  maxRetries?: number;
36
260
  /** Inject a `fetch` implementation (defaults to the global `fetch`); used for tests. */
37
261
  fetchImpl?: typeof fetch;
262
+ /**
263
+ * Durable outbox store. When set, {@link WebhookdClient.enqueue} / {@link WebhookdClient.drain}
264
+ * (and the background drainer) become available. Omit to use only the live {@link WebhookdClient.publish}.
265
+ */
266
+ store?: Store;
267
+ /** Max delivery attempts before a record is parked dead (default 10). */
268
+ maxAttempts?: number;
269
+ /** How many records a single {@link WebhookdClient.drain} pulls from the store (default 100). */
270
+ drainBatchLimit?: number;
271
+ /** Invoked once when `drain` parks a record dead (retry budget exhausted). */
272
+ onDead?: (record: OutboxRecord) => void;
273
+ /** Invoked when a background-drainer tick throws (a foreground `drain` still rejects normally). */
274
+ onDrainError?: (error: unknown) => void;
275
+ }
276
+ interface EnqueueOptions {
277
+ environment?: string;
278
+ application?: string;
279
+ source?: string;
280
+ /** The Idempotency-Key / record id. Defaults to a generated UUID v4. Re-enqueuing the same key is a no-op overwrite. */
281
+ idempotencyKey?: string;
282
+ }
283
+ interface DrainOptions {
284
+ /** Override the client's `drainBatchLimit` for this call. */
285
+ batchLimit?: number;
286
+ /** Override the client's `maxAttempts` for this call. */
287
+ maxAttempts?: number;
288
+ }
289
+ interface DrainResult {
290
+ /** Records delivered (2xx) and marked sent this drain. */
291
+ sent: number;
292
+ /** Records that failed this drain (rescheduled or newly parked dead). */
293
+ failed: number;
294
+ /** Records still buffered in the store afterwards (`store.size()`). */
295
+ remaining: number;
38
296
  }
39
297
  interface PublishOptions {
40
298
  environment?: string;
@@ -43,15 +301,124 @@ interface PublishOptions {
43
301
  /** Makes the publish safe to retry — a replay returns the original event without re-fanning-out. */
44
302
  idempotencyKey?: string;
45
303
  }
304
+ interface CreateEndpointOptions {
305
+ environment?: string;
306
+ subscriptions?: Subscription[];
307
+ application?: string;
308
+ secret?: string;
309
+ maxAttempts?: number;
310
+ retrySchedule?: number[];
311
+ description?: string;
312
+ customHeaders?: Record<string, string>;
313
+ deliveryTimeoutMs?: number;
314
+ }
315
+ interface ListEndpointsOptions {
316
+ environment?: string;
317
+ offset?: number;
318
+ limit?: number;
319
+ }
320
+ /**
321
+ * A raw PATCH mapping for `updateEndpoint`, keyed with the server's snake_case names. PATCH semantics:
322
+ * an OMITTED key is left unchanged; an explicit `null` CLEARS the field. The object is sent verbatim.
323
+ */
324
+ interface EndpointPatch {
325
+ url?: string;
326
+ max_attempts?: number | null;
327
+ retry_schedule?: number[] | null;
328
+ status?: "enabled" | "disabled";
329
+ description?: string | null;
330
+ custom_headers?: Record<string, string> | null;
331
+ delivery_timeout_ms?: number | null;
332
+ }
333
+ interface CreateApiKeyOptions {
334
+ name?: string;
335
+ scope?: "admin" | "publish";
336
+ expiresInDays?: number;
337
+ }
338
+ interface ListDeliveriesOptions {
339
+ status?: "queued" | "sending" | "sent" | "failed" | "dead";
340
+ endpointId?: string;
341
+ eventType?: string;
342
+ since?: string | number;
343
+ until?: string | number;
344
+ q?: string;
345
+ offset?: number;
346
+ limit?: number;
347
+ }
46
348
  declare class WebhookdClient {
47
349
  private readonly baseUrl;
48
350
  private readonly apiKey;
49
351
  private readonly timeoutMs;
50
352
  private readonly maxRetries;
51
353
  private readonly fetchImpl;
354
+ private readonly store?;
355
+ private readonly maxAttempts;
356
+ private readonly drainBatchLimit;
357
+ private readonly onDead?;
358
+ private readonly onDrainError?;
359
+ private drainTimer?;
360
+ private draining;
52
361
  constructor(opts: ClientOptions);
53
362
  publish(eventType: string, payload: Record<string, unknown>, opts?: PublishOptions): Promise<WebhookdEvent>;
54
- private post;
363
+ /**
364
+ * Durably buffer an event and return IMMEDIATELY — NO network call. Builds an {@link OutboxRecord}
365
+ * (id = `opts.idempotencyKey` or a fresh UUID v4), saves it to the configured store, and returns the
366
+ * id. Ship it later with {@link drain} (or the background drainer). Requires a `store` in options.
367
+ */
368
+ enqueue(eventType: string, payload: Record<string, unknown>, opts?: EnqueueOptions): Promise<{
369
+ id: string;
370
+ }>;
371
+ /**
372
+ * Send buffered records to webhookd. Pulls a due batch (oldest first), POSTs each to `/v1/events`
373
+ * with header `Idempotency-Key = record.id` (so a re-drain after a crash never double-publishes —
374
+ * webhookd dedupes). On 2xx the record is marked sent; on error `attempts` is bumped and the record
375
+ * is rescheduled with capped exponential backoff, or parked dead once it hits `maxAttempts` (the
376
+ * `onDead` hook fires, and it is retrievable via `store.listDead()`). Requires a `store`.
377
+ */
378
+ drain(opts?: DrainOptions): Promise<DrainResult>;
379
+ /**
380
+ * Start a background loop calling {@link drain} every `intervalSeconds`. Overlapping ticks are
381
+ * skipped while a drain is in flight, and a tick that throws is swallowed (routed to `onDrainError`)
382
+ * so the loop keeps running. The timer is `unref`'d so it never blocks process exit. Idempotent.
383
+ */
384
+ startDrainer(intervalSeconds: number): void;
385
+ /** Stop the background drainer started by {@link startDrainer}. Safe to call when not running. */
386
+ stopDrainer(): void;
387
+ private drainTick;
388
+ private requireStore;
389
+ /** Create an endpoint. The response includes the signing `secret` exactly once — persist it. */
390
+ createEndpoint(url: string, opts?: CreateEndpointOptions): Promise<Endpoint>;
391
+ /** List endpoints for an environment (defaults to `prod`). */
392
+ listEndpoints(opts?: ListEndpointsOptions): Promise<Page<Endpoint>>;
393
+ /** Fetch a single endpoint by id. */
394
+ getEndpoint(id: string): Promise<Endpoint>;
395
+ /**
396
+ * Partially update an endpoint. `patch` is sent verbatim (snake_case keys): an omitted key is left
397
+ * unchanged, an explicit `null` clears the field. No defaults are injected.
398
+ */
399
+ updateEndpoint(id: string, patch: EndpointPatch): Promise<Endpoint>;
400
+ /** Delete an endpoint. Returns nothing (the server replies `204 No Content`). */
401
+ deleteEndpoint(id: string): Promise<void>;
402
+ /** Rotate an endpoint's signing secret. The response includes the new `secret` exactly once. */
403
+ rotateEndpointSecret(id: string): Promise<Endpoint>;
404
+ /** Re-enable an endpoint that webhookd auto-disabled after repeated delivery failures. */
405
+ enableEndpoint(id: string): Promise<Endpoint>;
406
+ /** Create an API key. The response includes the raw `key` exactly once — persist it. */
407
+ createApiKey(opts?: CreateApiKeyOptions): Promise<ApiKey>;
408
+ /** Revoke an API key. Returns nothing (the server replies `204 No Content`). */
409
+ revokeApiKey(id: string): Promise<void>;
410
+ /** List deliveries. Only the filters you provide are sent as query params. */
411
+ listDeliveries(opts?: ListDeliveriesOptions): Promise<Page<Delivery>>;
412
+ /** Re-queue a delivery for another attempt. */
413
+ redeliver(deliveryId: string): Promise<Delivery>;
414
+ /** Issue a request and parse the JSON response as `T`. Never use for 204s (there's no body). */
415
+ private requestJson;
416
+ /**
417
+ * The single request path used by every method: builds the URL (+ query), attaches auth, and runs
418
+ * the shared retry loop (connection errors + 429 honoring Retry-After + 5xx, capped exponential
419
+ * backoff), raising a typed {@link WebhookdApiError} on other 4xx/5xx. The caller reads the body.
420
+ */
421
+ private request;
55
422
  }
56
423
 
57
424
  /** Base class for all webhookd SDK errors (network failures, etc.). */
@@ -76,6 +443,6 @@ declare class WebhookdApiError extends WebhookdError {
76
443
  * - `WebhookdClient` — publish events to webhookd (for producers).
77
444
  */
78
445
 
79
- declare const VERSION = "0.1.0";
446
+ declare const VERSION = "0.3.0";
80
447
 
81
- export { type ClientOptions, DEFAULT_TOLERANCE_SECONDS, type PublishOptions, VERSION, type VerifyOptions, WebhookdApiError, WebhookdClient, WebhookdError, type WebhookdEvent, sign, verify };
448
+ export { type ApiKey, type ClientOptions, type CreateApiKeyOptions, type CreateEndpointOptions, DEAD_NEXT_ATTEMPT_MS, DEFAULT_TOLERANCE_SECONDS, type Delivery, type DrainOptions, type DrainResult, type Endpoint, type EndpointPatch, type EnqueueOptions, FileStore, type ListDeliveriesOptions, type ListEndpointsOptions, MemoryStore, type OutboxRecord, type Page, PostgresStore, type PostgresStoreOptions, type PublishOptions, RedisStore, type RedisStoreOptions, SqliteStore, type Store, type Subscription, VERSION, type VerifyOptions, WebhookdApiError, WebhookdClient, WebhookdError, type WebhookdEvent, isDead, sign, verify };