@objectstack/plugin-webhooks 5.1.0 → 5.2.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.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +35 -13
  2. package/CHANGELOG.md +9 -37
  3. package/dist/chunk-JN76ZRWN.js +164 -0
  4. package/dist/chunk-JN76ZRWN.js.map +1 -0
  5. package/dist/chunk-M4M5FWIH.cjs +15 -0
  6. package/dist/chunk-M4M5FWIH.cjs.map +1 -0
  7. package/dist/chunk-NYSUNT6X.js +15 -0
  8. package/dist/chunk-NYSUNT6X.js.map +1 -0
  9. package/dist/chunk-OW7ESXOK.cjs +164 -0
  10. package/dist/chunk-OW7ESXOK.cjs.map +1 -0
  11. package/dist/index.cjs +747 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +455 -0
  14. package/dist/index.d.ts +425 -74
  15. package/dist/index.js +712 -218
  16. package/dist/index.js.map +1 -1
  17. package/dist/outbox-bPQmKYPN.d.cts +128 -0
  18. package/dist/outbox-bPQmKYPN.d.ts +128 -0
  19. package/dist/schema.cjs +9 -0
  20. package/dist/schema.cjs.map +1 -0
  21. package/dist/schema.d.cts +4772 -0
  22. package/dist/schema.d.ts +4772 -0
  23. package/dist/schema.js +9 -0
  24. package/dist/schema.js.map +1 -0
  25. package/dist/sql-outbox.cjs +184 -0
  26. package/dist/sql-outbox.cjs.map +1 -0
  27. package/dist/sql-outbox.d.cts +54 -0
  28. package/dist/sql-outbox.d.ts +54 -0
  29. package/dist/sql-outbox.js +184 -0
  30. package/dist/sql-outbox.js.map +1 -0
  31. package/package.json +30 -10
  32. package/src/auto-enqueuer.test.ts +391 -0
  33. package/src/auto-enqueuer.ts +335 -0
  34. package/src/dispatcher.test.ts +324 -0
  35. package/src/dispatcher.ts +218 -0
  36. package/src/http-sender.ts +187 -0
  37. package/src/index.ts +48 -12
  38. package/src/memory-outbox.ts +127 -0
  39. package/src/outbox.ts +141 -0
  40. package/src/partition.ts +19 -0
  41. package/src/retention.test.ts +116 -0
  42. package/src/retention.ts +144 -0
  43. package/src/schema.ts +22 -0
  44. package/src/sql-outbox.test.ts +410 -0
  45. package/src/sql-outbox.ts +282 -0
  46. package/src/sys-webhook-delivery.object.ts +202 -0
  47. package/src/webhook-outbox-plugin.ts +280 -0
  48. package/tsconfig.json +5 -13
  49. package/tsup.config.ts +14 -0
  50. package/dist/index.d.mts +0 -104
  51. package/dist/index.mjs +0 -216
  52. package/dist/index.mjs.map +0 -1
  53. package/src/webhooks-plugin.test.ts +0 -218
  54. package/src/webhooks-plugin.ts +0 -294
@@ -1,294 +0,0 @@
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
- }