Skip to content

Commit 174ebd4

Browse files
committed
plugins: new @objectstack/plugin-webhooks for outbound HTTP delivery (C.21)
Add a plugin that subscribes to the realtime service and fans data.record.created / updated / deleted events out to one or more external HTTP sinks. Completes the second half of M10.21 / C.21 (the first half — streaming CSV / JSON export — landed in 9a1c97f). Features: * Sinks may be supplied programmatically or via env vars (OBJECTSTACK_WEBHOOK_URL, _SECRET, _OBJECTS, _EVENTS) for 12-factor deployments. * Per-sink filters: object whitelist + event-type whitelist. * HMAC-SHA256 signing via X-Objectstack-Signature: sha256=<hex>. * Standard envelope headers (Event / Object / Delivery UUID). * AbortController timeout (default 5s, configurable per sink). * Retry policy: exponential backoff with jitter on transient failures (5xx, network errors, timeouts). 4xx responses are treated as permanent rejections and NOT retried. * All delivery outcomes routed through an optional onDelivery hook for metrics / dead-letter integration. * stop() cleanly unsubscribes from the realtime service. Failure isolation: delivery errors are caught and logged; webhook problems never bubble up to break the originating mutation. Tests (7): - dormant when no sinks configured - subscribes + POSTs with correct HMAC + headers - multi-object whitelist filter - 5xx retry-then-succeed (exercises backoff path) - 4xx is permanent (no retry) - env-var configuration path - stop() unsubscribes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9a1c97f commit 174ebd4

6 files changed

Lines changed: 602 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "@objectstack/plugin-webhooks",
3+
"version": "4.0.0",
4+
"license": "Apache-2.0",
5+
"description": "Outbound webhook delivery plugin for ObjectStack — fan-out data.record.* events to external HTTP(S) sinks with HMAC signing and retry.",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.mjs",
12+
"require": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsup --config ../../../tsup.config.ts",
17+
"test": "vitest run --passWithNoTests"
18+
},
19+
"dependencies": {
20+
"@objectstack/core": "workspace:*",
21+
"@objectstack/spec": "workspace:*"
22+
},
23+
"devDependencies": {
24+
"@types/node": "^25.8.0",
25+
"typescript": "^6.0.3",
26+
"vitest": "^4.1.6"
27+
},
28+
"keywords": [
29+
"objectstack",
30+
"plugin",
31+
"webhooks",
32+
"events"
33+
]
34+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* @objectstack/plugin-webhooks
5+
*
6+
* Outbound webhook delivery for `data.record.created`,
7+
* `data.record.updated`, and `data.record.deleted` events. Subscribes to
8+
* the realtime service, fans events out to one or more configured HTTP
9+
* sinks, signs each request with HMAC-SHA256, and retries transient
10+
* failures with exponential backoff.
11+
*/
12+
13+
export { WebhooksPlugin } from './webhooks-plugin.js';
14+
export type {
15+
WebhooksPluginOptions,
16+
WebhookSink,
17+
WebhookDeliveryRecord,
18+
WebhookDeliveryStatus,
19+
} from './webhooks-plugin.js';
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)