@objectstack/plugin-webhooks 4.0.1

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.
@@ -0,0 +1,218 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ import crypto from 'node:crypto';
5
+ import { WebhooksPlugin, type WebhookDeliveryRecord } from './webhooks-plugin.js';
6
+
7
+ /**
8
+ * Build a minimal in-memory realtime stub that records subscriptions and
9
+ * exposes a publish() helper for tests.
10
+ */
11
+ function makeRealtime() {
12
+ const subs: Array<{
13
+ id: string;
14
+ channel: string;
15
+ handler: (event: any) => Promise<void> | void;
16
+ options?: any;
17
+ }> = [];
18
+ let counter = 0;
19
+ return {
20
+ subscribe: vi.fn(async (channel: string, handler: any, options?: any) => {
21
+ const id = `sub-${++counter}`;
22
+ subs.push({ id, channel, handler, options });
23
+ return id;
24
+ }),
25
+ unsubscribe: vi.fn(async (id: string) => {
26
+ const idx = subs.findIndex(s => s.id === id);
27
+ if (idx >= 0) subs.splice(idx, 1);
28
+ }),
29
+ publish: vi.fn(async (event: any) => {
30
+ for (const sub of [...subs]) {
31
+ const opts = sub.options ?? {};
32
+ if (opts.object && event.object !== opts.object) continue;
33
+ if (opts.eventTypes && opts.eventTypes.length > 0 && !opts.eventTypes.includes(event.type)) continue;
34
+ await sub.handler(event);
35
+ }
36
+ }),
37
+ _subs: subs,
38
+ };
39
+ }
40
+
41
+ function makeCtx(realtime: any) {
42
+ const hooks: Record<string, Array<() => Promise<void> | void>> = {};
43
+ return {
44
+ logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
45
+ getService: vi.fn((name: string) => {
46
+ if (name === 'realtime') return realtime;
47
+ throw new Error(`unknown service ${name}`);
48
+ }),
49
+ hook: vi.fn((name: string, fn: any) => {
50
+ (hooks[name] ||= []).push(fn);
51
+ }),
52
+ _hooks: hooks,
53
+ } as any;
54
+ }
55
+
56
+ describe('WebhooksPlugin', () => {
57
+ beforeEach(() => {
58
+ delete process.env.OBJECTSTACK_WEBHOOK_URL;
59
+ delete process.env.OBJECTSTACK_WEBHOOK_SECRET;
60
+ delete process.env.OBJECTSTACK_WEBHOOK_OBJECTS;
61
+ delete process.env.OBJECTSTACK_WEBHOOK_EVENTS;
62
+ });
63
+ afterEach(() => { vi.restoreAllMocks(); });
64
+
65
+ it('stays dormant when no sinks configured', async () => {
66
+ const realtime = makeRealtime();
67
+ const ctx = makeCtx(realtime);
68
+ const plugin = new WebhooksPlugin();
69
+ await plugin.init(ctx);
70
+ await plugin.start(ctx);
71
+ // No kernel:ready hook fired yet, but even if it did there would be no subs.
72
+ for (const fn of ctx._hooks['kernel:ready'] ?? []) await fn();
73
+ expect(realtime.subscribe).not.toHaveBeenCalled();
74
+ expect(ctx.logger.info).toHaveBeenCalledWith(
75
+ expect.stringContaining('no sinks configured'),
76
+ );
77
+ });
78
+
79
+ it('subscribes to realtime and POSTs on data.record.created with HMAC signature', async () => {
80
+ const realtime = makeRealtime();
81
+ const ctx = makeCtx(realtime);
82
+ const deliveries: WebhookDeliveryRecord[] = [];
83
+ const fetchImpl = vi.fn(async (_url: string, init: any) => ({
84
+ ok: true, status: 200, text: async () => '',
85
+ headers: new Map(), bodyEcho: init,
86
+ } as any));
87
+
88
+ const plugin = new WebhooksPlugin({
89
+ sinks: [{ id: 'crm', url: 'https://hooks.example.com/in', secret: 's3cret', objects: ['lead'] }],
90
+ fetchImpl: fetchImpl as any,
91
+ onDelivery: (rec) => deliveries.push(rec),
92
+ });
93
+ await plugin.init(ctx);
94
+ await plugin.start(ctx);
95
+ for (const fn of ctx._hooks['kernel:ready']) await fn();
96
+
97
+ expect(realtime.subscribe).toHaveBeenCalledTimes(1);
98
+ const event = {
99
+ type: 'data.record.created',
100
+ object: 'lead',
101
+ payload: { recordId: 'L1', after: { id: 'L1', name: 'Acme' } },
102
+ timestamp: new Date().toISOString(),
103
+ };
104
+ await realtime.publish(event);
105
+
106
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
107
+ const [, init] = fetchImpl.mock.calls[0]!;
108
+ expect(init.method).toBe('POST');
109
+ expect(init.headers['Content-Type']).toBe('application/json');
110
+ expect(init.headers['X-Objectstack-Event']).toBe('data.record.created');
111
+ expect(init.headers['X-Objectstack-Object']).toBe('lead');
112
+ const expectedSig = 'sha256=' + crypto.createHmac('sha256', 's3cret').update(init.body).digest('hex');
113
+ expect(init.headers['X-Objectstack-Signature']).toBe(expectedSig);
114
+ expect(JSON.parse(init.body)).toMatchObject({ type: 'data.record.created', object: 'lead' });
115
+ expect(deliveries[0]).toMatchObject({ status: 'ok', httpStatus: 200, attempt: 1 });
116
+ });
117
+
118
+ it('filters by object whitelist when sink lists multiple objects', async () => {
119
+ const realtime = makeRealtime();
120
+ const ctx = makeCtx(realtime);
121
+ const fetchImpl = vi.fn(async () => ({ ok: true, status: 200 } as any));
122
+ const plugin = new WebhooksPlugin({
123
+ sinks: [{ id: 'multi', url: 'https://x', objects: ['lead', 'account'] }],
124
+ fetchImpl: fetchImpl as any,
125
+ });
126
+ await plugin.init(ctx);
127
+ await plugin.start(ctx);
128
+ for (const fn of ctx._hooks['kernel:ready']) await fn();
129
+
130
+ await realtime.publish({ type: 'data.record.created', object: 'lead', payload: {}, timestamp: '' });
131
+ await realtime.publish({ type: 'data.record.created', object: 'contact', payload: {}, timestamp: '' });
132
+ await realtime.publish({ type: 'data.record.created', object: 'account', payload: {}, timestamp: '' });
133
+
134
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
135
+ });
136
+
137
+ it('retries on 5xx then succeeds', async () => {
138
+ const realtime = makeRealtime();
139
+ const ctx = makeCtx(realtime);
140
+ const deliveries: WebhookDeliveryRecord[] = [];
141
+ let calls = 0;
142
+ const fetchImpl = vi.fn(async () => {
143
+ calls++;
144
+ if (calls < 3) return { ok: false, status: 503 } as any;
145
+ return { ok: true, status: 200 } as any;
146
+ });
147
+ const plugin = new WebhooksPlugin({
148
+ sinks: [{ id: 'flaky', url: 'https://x', retries: 5 }],
149
+ fetchImpl: fetchImpl as any,
150
+ onDelivery: (rec) => deliveries.push(rec),
151
+ });
152
+ await plugin.init(ctx);
153
+ await plugin.start(ctx);
154
+ for (const fn of ctx._hooks['kernel:ready']) await fn();
155
+ await realtime.publish({ type: 'data.record.updated', object: 'lead', payload: {}, timestamp: '' });
156
+ expect(fetchImpl).toHaveBeenCalledTimes(3);
157
+ expect(deliveries.filter(d => d.status === 'retrying').length).toBe(2);
158
+ expect(deliveries.at(-1)).toMatchObject({ status: 'ok', attempt: 3 });
159
+ }, 20_000);
160
+
161
+ it('does NOT retry on 4xx (permanent rejection)', async () => {
162
+ const realtime = makeRealtime();
163
+ const ctx = makeCtx(realtime);
164
+ const deliveries: WebhookDeliveryRecord[] = [];
165
+ const fetchImpl = vi.fn(async () => ({ ok: false, status: 401 } as any));
166
+ const plugin = new WebhooksPlugin({
167
+ sinks: [{ id: 'auth', url: 'https://x', retries: 5 }],
168
+ fetchImpl: fetchImpl as any,
169
+ onDelivery: (r) => deliveries.push(r),
170
+ });
171
+ await plugin.init(ctx);
172
+ await plugin.start(ctx);
173
+ for (const fn of ctx._hooks['kernel:ready']) await fn();
174
+ await realtime.publish({ type: 'data.record.deleted', object: 'lead', payload: {}, timestamp: '' });
175
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
176
+ expect(deliveries.at(-1)).toMatchObject({ status: 'failed', httpStatus: 401, attempt: 1 });
177
+ });
178
+
179
+ it('reads URL+secret+filters from env vars when no sinks supplied', async () => {
180
+ process.env.OBJECTSTACK_WEBHOOK_URL = 'https://a.example,https://b.example';
181
+ process.env.OBJECTSTACK_WEBHOOK_SECRET = 'env-secret';
182
+ process.env.OBJECTSTACK_WEBHOOK_OBJECTS = 'lead,account';
183
+ process.env.OBJECTSTACK_WEBHOOK_EVENTS = 'data.record.created';
184
+ const realtime = makeRealtime();
185
+ const ctx = makeCtx(realtime);
186
+ const fetchImpl = vi.fn(async () => ({ ok: true, status: 200 } as any));
187
+ const plugin = new WebhooksPlugin({ fetchImpl: fetchImpl as any });
188
+ await plugin.init(ctx);
189
+ await plugin.start(ctx);
190
+ for (const fn of ctx._hooks['kernel:ready']) await fn();
191
+
192
+ // Two sinks → two realtime subscriptions.
193
+ expect(realtime.subscribe).toHaveBeenCalledTimes(2);
194
+ await realtime.publish({ type: 'data.record.created', object: 'lead', payload: {}, timestamp: '' });
195
+ // Two sinks both fire.
196
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
197
+ const [urlA] = fetchImpl.mock.calls[0]!;
198
+ const [urlB] = fetchImpl.mock.calls[1]!;
199
+ expect([urlA, urlB].sort()).toEqual(['https://a.example', 'https://b.example']);
200
+ });
201
+
202
+ it('unsubscribes on stop', async () => {
203
+ const realtime = makeRealtime();
204
+ const ctx = makeCtx(realtime);
205
+ const fetchImpl = vi.fn(async () => ({ ok: true, status: 200 } as any));
206
+ const plugin = new WebhooksPlugin({
207
+ sinks: [{ id: 'a', url: 'https://x' }],
208
+ fetchImpl: fetchImpl as any,
209
+ });
210
+ await plugin.init(ctx);
211
+ await plugin.start(ctx);
212
+ for (const fn of ctx._hooks['kernel:ready']) await fn();
213
+ expect(realtime._subs).toHaveLength(1);
214
+ await plugin.stop(ctx);
215
+ expect(realtime.unsubscribe).toHaveBeenCalled();
216
+ expect(realtime._subs).toHaveLength(0);
217
+ });
218
+ });
@@ -0,0 +1,294 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import crypto from 'node:crypto';
4
+ import type { Plugin, PluginContext } from '@objectstack/core';
5
+ import type {
6
+ IRealtimeService,
7
+ RealtimeEventPayload,
8
+ RealtimeSubscriptionOptions,
9
+ } from '@objectstack/spec/contracts';
10
+
11
+ /**
12
+ * A single webhook delivery target.
13
+ */
14
+ export interface WebhookSink {
15
+ /** Unique sink id used for log correlation. */
16
+ id: string;
17
+ /** Target HTTPS URL. */
18
+ url: string;
19
+ /** Optional HMAC-SHA256 secret. When set, `X-Objectstack-Signature: sha256=…` is added. */
20
+ secret?: string;
21
+ /**
22
+ * Restrict to specific object names (logical names, e.g. `lead`, `account`).
23
+ * Omit / empty → all objects.
24
+ */
25
+ objects?: string[];
26
+ /**
27
+ * Restrict to specific event types. Omit / empty → all `data.record.*` events.
28
+ */
29
+ eventTypes?: string[];
30
+ /** Extra headers to send (Authorization, Tenant, etc.). */
31
+ headers?: Record<string, string>;
32
+ /** Per-request timeout in milliseconds. Default 5000. */
33
+ timeoutMs?: number;
34
+ /** Retry attempts on transient failure. Default 3. Set 0 to disable retries. */
35
+ retries?: number;
36
+ }
37
+
38
+ /**
39
+ * Delivery attempt outcome surfaced to in-process listeners / tests.
40
+ */
41
+ export type WebhookDeliveryStatus = 'ok' | 'retrying' | 'failed';
42
+
43
+ export interface WebhookDeliveryRecord {
44
+ sinkId: string;
45
+ url: string;
46
+ eventType: string;
47
+ object?: string;
48
+ status: WebhookDeliveryStatus;
49
+ httpStatus?: number;
50
+ attempt: number;
51
+ error?: string;
52
+ }
53
+
54
+ /**
55
+ * Plugin configuration.
56
+ *
57
+ * Sinks may be supplied programmatically OR via env vars when none are
58
+ * passed (suitable for 12-factor / Docker deployments):
59
+ *
60
+ * OBJECTSTACK_WEBHOOK_URL — single URL, or comma-separated URLs.
61
+ * OBJECTSTACK_WEBHOOK_SECRET — HMAC secret applied to all env-sourced URLs.
62
+ * OBJECTSTACK_WEBHOOK_OBJECTS — comma-separated object whitelist.
63
+ * OBJECTSTACK_WEBHOOK_EVENTS — comma-separated event-type whitelist
64
+ * (e.g. `data.record.created`).
65
+ */
66
+ export interface WebhooksPluginOptions {
67
+ /** Explicit sink list (takes precedence over env vars). */
68
+ sinks?: WebhookSink[];
69
+ /** Override fetch (mainly for tests). Defaults to globalThis.fetch. */
70
+ fetchImpl?: typeof fetch;
71
+ /** Hook invoked with each delivery outcome (mainly for tests / metrics). */
72
+ onDelivery?: (record: WebhookDeliveryRecord) => void;
73
+ }
74
+
75
+ const DEFAULT_TIMEOUT_MS = 5_000;
76
+ const DEFAULT_RETRIES = 3;
77
+ const BACKOFF_BASE_MS = 250;
78
+ const BACKOFF_MAX_MS = 5_000;
79
+
80
+ /**
81
+ * WebhooksPlugin — fan out data.record.* events to external HTTP endpoints.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * kernel.use(new WebhooksPlugin({
86
+ * sinks: [
87
+ * { id: 'crm-sync', url: 'https://hooks.example.com/in',
88
+ * secret: process.env.HOOK_SECRET, objects: ['lead', 'account'] },
89
+ * ],
90
+ * }));
91
+ * ```
92
+ */
93
+ export class WebhooksPlugin implements Plugin {
94
+ name = 'com.objectstack.webhooks';
95
+ version = '1.0.0';
96
+ type = 'standard';
97
+ dependencies = ['com.objectstack.service.realtime'];
98
+
99
+ private readonly options: WebhooksPluginOptions;
100
+ private subscriptionIds: string[] = [];
101
+ private realtime?: IRealtimeService;
102
+ private sinks: WebhookSink[] = [];
103
+ private logger?: PluginContext['logger'];
104
+
105
+ constructor(options: WebhooksPluginOptions = {}) {
106
+ this.options = options;
107
+ }
108
+
109
+ async init(ctx: PluginContext): Promise<void> {
110
+ this.logger = ctx.logger;
111
+ this.sinks = this.resolveSinks();
112
+ if (this.sinks.length === 0) {
113
+ ctx.logger.info(
114
+ 'WebhooksPlugin: no sinks configured (options.sinks empty and OBJECTSTACK_WEBHOOK_URL unset) — plugin is dormant',
115
+ );
116
+ return;
117
+ }
118
+ ctx.logger.info(`WebhooksPlugin: ${this.sinks.length} sink(s) configured`);
119
+ }
120
+
121
+ async start(ctx: PluginContext): Promise<void> {
122
+ if (this.sinks.length === 0) return;
123
+ ctx.hook('kernel:ready', async () => {
124
+ try {
125
+ this.realtime = ctx.getService<IRealtimeService>('realtime');
126
+ } catch {
127
+ ctx.logger.warn('WebhooksPlugin: realtime service unavailable — events will not be forwarded');
128
+ return;
129
+ }
130
+
131
+ // We subscribe once per sink so the realtime service can apply each
132
+ // sink's object / eventTypes filter at the channel layer where
133
+ // possible. This also lets us cleanly unsubscribe on stop().
134
+ for (const sink of this.sinks) {
135
+ const opts: RealtimeSubscriptionOptions | undefined =
136
+ (sink.objects && sink.objects.length === 1) ||
137
+ (sink.eventTypes && sink.eventTypes.length > 0)
138
+ ? {
139
+ ...(sink.objects && sink.objects.length === 1 ? { object: sink.objects[0] } : {}),
140
+ ...(sink.eventTypes && sink.eventTypes.length > 0 ? { eventTypes: sink.eventTypes } : {}),
141
+ }
142
+ : undefined;
143
+ const id = await this.realtime.subscribe(
144
+ 'data.record',
145
+ async (event) => { await this.dispatch(sink, event); },
146
+ opts,
147
+ );
148
+ this.subscriptionIds.push(id);
149
+ }
150
+ ctx.logger.info(`WebhooksPlugin: subscribed ${this.subscriptionIds.length} realtime listener(s)`);
151
+ });
152
+ }
153
+
154
+ async stop(ctx: PluginContext): Promise<void> {
155
+ if (!this.realtime) return;
156
+ for (const id of this.subscriptionIds) {
157
+ try { await this.realtime.unsubscribe(id); }
158
+ catch (err) { ctx.logger.debug('WebhooksPlugin: unsubscribe failed', { id, err }); }
159
+ }
160
+ this.subscriptionIds = [];
161
+ }
162
+
163
+ /**
164
+ * Resolve sinks from constructor options, falling back to env vars when
165
+ * none provided. Exposed for testing.
166
+ */
167
+ private resolveSinks(): WebhookSink[] {
168
+ if (this.options.sinks && this.options.sinks.length > 0) return this.options.sinks;
169
+
170
+ const urlEnv = process.env.OBJECTSTACK_WEBHOOK_URL;
171
+ if (!urlEnv) return [];
172
+
173
+ const urls = urlEnv.split(',').map(s => s.trim()).filter(Boolean);
174
+ const secret = process.env.OBJECTSTACK_WEBHOOK_SECRET;
175
+ const objectsEnv = process.env.OBJECTSTACK_WEBHOOK_OBJECTS;
176
+ const eventsEnv = process.env.OBJECTSTACK_WEBHOOK_EVENTS;
177
+ const objects = objectsEnv ? objectsEnv.split(',').map(s => s.trim()).filter(Boolean) : undefined;
178
+ const eventTypes = eventsEnv ? eventsEnv.split(',').map(s => s.trim()).filter(Boolean) : undefined;
179
+
180
+ return urls.map((url, idx) => ({
181
+ id: `env-${idx + 1}`,
182
+ url,
183
+ ...(secret ? { secret } : {}),
184
+ ...(objects ? { objects } : {}),
185
+ ...(eventTypes ? { eventTypes } : {}),
186
+ }));
187
+ }
188
+
189
+ /**
190
+ * Dispatch a single event to a sink, with HMAC signing, timeout, and
191
+ * exponential-backoff retry. Failures past the retry budget are logged
192
+ * but never thrown — webhook delivery must never break the originating
193
+ * mutation.
194
+ */
195
+ private async dispatch(sink: WebhookSink, event: RealtimeEventPayload): Promise<void> {
196
+ // Defence in depth: the realtime layer already filters by single-object
197
+ // subscriptions, but multi-object whitelists are applied here.
198
+ if (sink.objects && sink.objects.length > 0 && event.object && !sink.objects.includes(event.object)) {
199
+ return;
200
+ }
201
+ if (sink.eventTypes && sink.eventTypes.length > 0 && !sink.eventTypes.includes(event.type)) {
202
+ return;
203
+ }
204
+
205
+ const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
206
+ if (!fetchImpl) {
207
+ this.logger?.warn('WebhooksPlugin: no fetch implementation available — dropping event', { sinkId: sink.id });
208
+ return;
209
+ }
210
+
211
+ const body = JSON.stringify(event);
212
+ const headers: Record<string, string> = {
213
+ 'Content-Type': 'application/json',
214
+ 'User-Agent': 'ObjectStack-Webhooks/1.0',
215
+ 'X-Objectstack-Event': event.type,
216
+ ...(event.object ? { 'X-Objectstack-Object': event.object } : {}),
217
+ 'X-Objectstack-Delivery': crypto.randomUUID(),
218
+ ...(sink.headers ?? {}),
219
+ };
220
+ if (sink.secret) {
221
+ const sig = crypto.createHmac('sha256', sink.secret).update(body).digest('hex');
222
+ headers['X-Objectstack-Signature'] = `sha256=${sig}`;
223
+ }
224
+
225
+ const timeoutMs = sink.timeoutMs ?? DEFAULT_TIMEOUT_MS;
226
+ const maxAttempts = (sink.retries ?? DEFAULT_RETRIES) + 1;
227
+
228
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
229
+ const controller = new AbortController();
230
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
231
+ try {
232
+ const res = await fetchImpl(sink.url, {
233
+ method: 'POST',
234
+ headers,
235
+ body,
236
+ signal: controller.signal,
237
+ });
238
+ clearTimeout(timer);
239
+ if (res.ok || (res.status >= 400 && res.status < 500)) {
240
+ // 4xx is "permanent" — don't retry; only 2xx counts as success.
241
+ const status: WebhookDeliveryStatus = res.ok ? 'ok' : 'failed';
242
+ this.options.onDelivery?.({
243
+ sinkId: sink.id, url: sink.url, eventType: event.type,
244
+ object: event.object, status, httpStatus: res.status, attempt,
245
+ });
246
+ if (status === 'failed') {
247
+ this.logger?.warn('WebhooksPlugin: sink rejected event', {
248
+ sinkId: sink.id, status: res.status, eventType: event.type,
249
+ });
250
+ }
251
+ return;
252
+ }
253
+ // 5xx → fall through to retry.
254
+ if (attempt === maxAttempts) {
255
+ this.options.onDelivery?.({
256
+ sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,
257
+ status: 'failed', httpStatus: res.status, attempt,
258
+ });
259
+ this.logger?.warn('WebhooksPlugin: max retries exhausted', {
260
+ sinkId: sink.id, status: res.status, eventType: event.type,
261
+ });
262
+ return;
263
+ }
264
+ this.options.onDelivery?.({
265
+ sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,
266
+ status: 'retrying', httpStatus: res.status, attempt,
267
+ });
268
+ } catch (err: any) {
269
+ clearTimeout(timer);
270
+ const errMessage = err?.name === 'AbortError'
271
+ ? `timeout after ${timeoutMs}ms`
272
+ : (err?.message ?? String(err));
273
+ if (attempt === maxAttempts) {
274
+ this.options.onDelivery?.({
275
+ sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,
276
+ status: 'failed', attempt, error: errMessage,
277
+ });
278
+ this.logger?.warn('WebhooksPlugin: delivery failed', {
279
+ sinkId: sink.id, eventType: event.type, error: errMessage,
280
+ });
281
+ return;
282
+ }
283
+ this.options.onDelivery?.({
284
+ sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,
285
+ status: 'retrying', attempt, error: errMessage,
286
+ });
287
+ }
288
+ // Exponential backoff with full jitter.
289
+ const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1));
290
+ const jittered = Math.floor(Math.random() * delay);
291
+ await new Promise(r => setTimeout(r, jittered));
292
+ }
293
+ }
294
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": [
7
+ "node"
8
+ ]
9
+ },
10
+ "include": [
11
+ "src/**/*"
12
+ ],
13
+ "exclude": [
14
+ "dist",
15
+ "node_modules",
16
+ "**/*.test.ts"
17
+ ]
18
+ }