@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.
package/dist/index.js ADDED
@@ -0,0 +1,253 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
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
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ WebhooksPlugin: () => WebhooksPlugin
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+
37
+ // src/webhooks-plugin.ts
38
+ var import_node_crypto = __toESM(require("crypto"));
39
+ var DEFAULT_TIMEOUT_MS = 5e3;
40
+ var DEFAULT_RETRIES = 3;
41
+ var BACKOFF_BASE_MS = 250;
42
+ var BACKOFF_MAX_MS = 5e3;
43
+ var WebhooksPlugin = class {
44
+ constructor(options = {}) {
45
+ this.name = "com.objectstack.webhooks";
46
+ this.version = "1.0.0";
47
+ this.type = "standard";
48
+ this.dependencies = ["com.objectstack.service.realtime"];
49
+ this.subscriptionIds = [];
50
+ this.sinks = [];
51
+ this.options = options;
52
+ }
53
+ async init(ctx) {
54
+ this.logger = ctx.logger;
55
+ this.sinks = this.resolveSinks();
56
+ if (this.sinks.length === 0) {
57
+ ctx.logger.info(
58
+ "WebhooksPlugin: no sinks configured (options.sinks empty and OBJECTSTACK_WEBHOOK_URL unset) \u2014 plugin is dormant"
59
+ );
60
+ return;
61
+ }
62
+ ctx.logger.info(`WebhooksPlugin: ${this.sinks.length} sink(s) configured`);
63
+ }
64
+ async start(ctx) {
65
+ if (this.sinks.length === 0) return;
66
+ ctx.hook("kernel:ready", async () => {
67
+ try {
68
+ this.realtime = ctx.getService("realtime");
69
+ } catch {
70
+ ctx.logger.warn("WebhooksPlugin: realtime service unavailable \u2014 events will not be forwarded");
71
+ return;
72
+ }
73
+ for (const sink of this.sinks) {
74
+ const opts = sink.objects && sink.objects.length === 1 || sink.eventTypes && sink.eventTypes.length > 0 ? {
75
+ ...sink.objects && sink.objects.length === 1 ? { object: sink.objects[0] } : {},
76
+ ...sink.eventTypes && sink.eventTypes.length > 0 ? { eventTypes: sink.eventTypes } : {}
77
+ } : void 0;
78
+ const id = await this.realtime.subscribe(
79
+ "data.record",
80
+ async (event) => {
81
+ await this.dispatch(sink, event);
82
+ },
83
+ opts
84
+ );
85
+ this.subscriptionIds.push(id);
86
+ }
87
+ ctx.logger.info(`WebhooksPlugin: subscribed ${this.subscriptionIds.length} realtime listener(s)`);
88
+ });
89
+ }
90
+ async stop(ctx) {
91
+ if (!this.realtime) return;
92
+ for (const id of this.subscriptionIds) {
93
+ try {
94
+ await this.realtime.unsubscribe(id);
95
+ } catch (err) {
96
+ ctx.logger.debug("WebhooksPlugin: unsubscribe failed", { id, err });
97
+ }
98
+ }
99
+ this.subscriptionIds = [];
100
+ }
101
+ /**
102
+ * Resolve sinks from constructor options, falling back to env vars when
103
+ * none provided. Exposed for testing.
104
+ */
105
+ resolveSinks() {
106
+ if (this.options.sinks && this.options.sinks.length > 0) return this.options.sinks;
107
+ const urlEnv = process.env.OBJECTSTACK_WEBHOOK_URL;
108
+ if (!urlEnv) return [];
109
+ const urls = urlEnv.split(",").map((s) => s.trim()).filter(Boolean);
110
+ const secret = process.env.OBJECTSTACK_WEBHOOK_SECRET;
111
+ const objectsEnv = process.env.OBJECTSTACK_WEBHOOK_OBJECTS;
112
+ const eventsEnv = process.env.OBJECTSTACK_WEBHOOK_EVENTS;
113
+ const objects = objectsEnv ? objectsEnv.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
114
+ const eventTypes = eventsEnv ? eventsEnv.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
115
+ return urls.map((url, idx) => ({
116
+ id: `env-${idx + 1}`,
117
+ url,
118
+ ...secret ? { secret } : {},
119
+ ...objects ? { objects } : {},
120
+ ...eventTypes ? { eventTypes } : {}
121
+ }));
122
+ }
123
+ /**
124
+ * Dispatch a single event to a sink, with HMAC signing, timeout, and
125
+ * exponential-backoff retry. Failures past the retry budget are logged
126
+ * but never thrown — webhook delivery must never break the originating
127
+ * mutation.
128
+ */
129
+ async dispatch(sink, event) {
130
+ if (sink.objects && sink.objects.length > 0 && event.object && !sink.objects.includes(event.object)) {
131
+ return;
132
+ }
133
+ if (sink.eventTypes && sink.eventTypes.length > 0 && !sink.eventTypes.includes(event.type)) {
134
+ return;
135
+ }
136
+ const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
137
+ if (!fetchImpl) {
138
+ this.logger?.warn("WebhooksPlugin: no fetch implementation available \u2014 dropping event", { sinkId: sink.id });
139
+ return;
140
+ }
141
+ const body = JSON.stringify(event);
142
+ const headers = {
143
+ "Content-Type": "application/json",
144
+ "User-Agent": "ObjectStack-Webhooks/1.0",
145
+ "X-Objectstack-Event": event.type,
146
+ ...event.object ? { "X-Objectstack-Object": event.object } : {},
147
+ "X-Objectstack-Delivery": import_node_crypto.default.randomUUID(),
148
+ ...sink.headers ?? {}
149
+ };
150
+ if (sink.secret) {
151
+ const sig = import_node_crypto.default.createHmac("sha256", sink.secret).update(body).digest("hex");
152
+ headers["X-Objectstack-Signature"] = `sha256=${sig}`;
153
+ }
154
+ const timeoutMs = sink.timeoutMs ?? DEFAULT_TIMEOUT_MS;
155
+ const maxAttempts = (sink.retries ?? DEFAULT_RETRIES) + 1;
156
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
157
+ const controller = new AbortController();
158
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
159
+ try {
160
+ const res = await fetchImpl(sink.url, {
161
+ method: "POST",
162
+ headers,
163
+ body,
164
+ signal: controller.signal
165
+ });
166
+ clearTimeout(timer);
167
+ if (res.ok || res.status >= 400 && res.status < 500) {
168
+ const status = res.ok ? "ok" : "failed";
169
+ this.options.onDelivery?.({
170
+ sinkId: sink.id,
171
+ url: sink.url,
172
+ eventType: event.type,
173
+ object: event.object,
174
+ status,
175
+ httpStatus: res.status,
176
+ attempt
177
+ });
178
+ if (status === "failed") {
179
+ this.logger?.warn("WebhooksPlugin: sink rejected event", {
180
+ sinkId: sink.id,
181
+ status: res.status,
182
+ eventType: event.type
183
+ });
184
+ }
185
+ return;
186
+ }
187
+ if (attempt === maxAttempts) {
188
+ this.options.onDelivery?.({
189
+ sinkId: sink.id,
190
+ url: sink.url,
191
+ eventType: event.type,
192
+ object: event.object,
193
+ status: "failed",
194
+ httpStatus: res.status,
195
+ attempt
196
+ });
197
+ this.logger?.warn("WebhooksPlugin: max retries exhausted", {
198
+ sinkId: sink.id,
199
+ status: res.status,
200
+ eventType: event.type
201
+ });
202
+ return;
203
+ }
204
+ this.options.onDelivery?.({
205
+ sinkId: sink.id,
206
+ url: sink.url,
207
+ eventType: event.type,
208
+ object: event.object,
209
+ status: "retrying",
210
+ httpStatus: res.status,
211
+ attempt
212
+ });
213
+ } catch (err) {
214
+ clearTimeout(timer);
215
+ const errMessage = err?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : err?.message ?? String(err);
216
+ if (attempt === maxAttempts) {
217
+ this.options.onDelivery?.({
218
+ sinkId: sink.id,
219
+ url: sink.url,
220
+ eventType: event.type,
221
+ object: event.object,
222
+ status: "failed",
223
+ attempt,
224
+ error: errMessage
225
+ });
226
+ this.logger?.warn("WebhooksPlugin: delivery failed", {
227
+ sinkId: sink.id,
228
+ eventType: event.type,
229
+ error: errMessage
230
+ });
231
+ return;
232
+ }
233
+ this.options.onDelivery?.({
234
+ sinkId: sink.id,
235
+ url: sink.url,
236
+ eventType: event.type,
237
+ object: event.object,
238
+ status: "retrying",
239
+ attempt,
240
+ error: errMessage
241
+ });
242
+ }
243
+ const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1));
244
+ const jittered = Math.floor(Math.random() * delay);
245
+ await new Promise((r) => setTimeout(r, jittered));
246
+ }
247
+ }
248
+ };
249
+ // Annotate the CommonJS export names for ESM import in node:
250
+ 0 && (module.exports = {
251
+ WebhooksPlugin
252
+ });
253
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/webhooks-plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * @objectstack/plugin-webhooks\n *\n * Outbound webhook delivery for `data.record.created`,\n * `data.record.updated`, and `data.record.deleted` events. Subscribes to\n * the realtime service, fans events out to one or more configured HTTP\n * sinks, signs each request with HMAC-SHA256, and retries transient\n * failures with exponential backoff.\n */\n\nexport { WebhooksPlugin } from './webhooks-plugin.js';\nexport type {\n WebhooksPluginOptions,\n WebhookSink,\n WebhookDeliveryRecord,\n WebhookDeliveryStatus,\n} from './webhooks-plugin.js';\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport crypto from 'node:crypto';\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type {\n IRealtimeService,\n RealtimeEventPayload,\n RealtimeSubscriptionOptions,\n} from '@objectstack/spec/contracts';\n\n/**\n * A single webhook delivery target.\n */\nexport interface WebhookSink {\n /** Unique sink id used for log correlation. */\n id: string;\n /** Target HTTPS URL. */\n url: string;\n /** Optional HMAC-SHA256 secret. When set, `X-Objectstack-Signature: sha256=…` is added. */\n secret?: string;\n /**\n * Restrict to specific object names (logical names, e.g. `lead`, `account`).\n * Omit / empty → all objects.\n */\n objects?: string[];\n /**\n * Restrict to specific event types. Omit / empty → all `data.record.*` events.\n */\n eventTypes?: string[];\n /** Extra headers to send (Authorization, Tenant, etc.). */\n headers?: Record<string, string>;\n /** Per-request timeout in milliseconds. Default 5000. */\n timeoutMs?: number;\n /** Retry attempts on transient failure. Default 3. Set 0 to disable retries. */\n retries?: number;\n}\n\n/**\n * Delivery attempt outcome surfaced to in-process listeners / tests.\n */\nexport type WebhookDeliveryStatus = 'ok' | 'retrying' | 'failed';\n\nexport interface WebhookDeliveryRecord {\n sinkId: string;\n url: string;\n eventType: string;\n object?: string;\n status: WebhookDeliveryStatus;\n httpStatus?: number;\n attempt: number;\n error?: string;\n}\n\n/**\n * Plugin configuration.\n *\n * Sinks may be supplied programmatically OR via env vars when none are\n * passed (suitable for 12-factor / Docker deployments):\n *\n * OBJECTSTACK_WEBHOOK_URL — single URL, or comma-separated URLs.\n * OBJECTSTACK_WEBHOOK_SECRET — HMAC secret applied to all env-sourced URLs.\n * OBJECTSTACK_WEBHOOK_OBJECTS — comma-separated object whitelist.\n * OBJECTSTACK_WEBHOOK_EVENTS — comma-separated event-type whitelist\n * (e.g. `data.record.created`).\n */\nexport interface WebhooksPluginOptions {\n /** Explicit sink list (takes precedence over env vars). */\n sinks?: WebhookSink[];\n /** Override fetch (mainly for tests). Defaults to globalThis.fetch. */\n fetchImpl?: typeof fetch;\n /** Hook invoked with each delivery outcome (mainly for tests / metrics). */\n onDelivery?: (record: WebhookDeliveryRecord) => void;\n}\n\nconst DEFAULT_TIMEOUT_MS = 5_000;\nconst DEFAULT_RETRIES = 3;\nconst BACKOFF_BASE_MS = 250;\nconst BACKOFF_MAX_MS = 5_000;\n\n/**\n * WebhooksPlugin — fan out data.record.* events to external HTTP endpoints.\n *\n * @example\n * ```ts\n * kernel.use(new WebhooksPlugin({\n * sinks: [\n * { id: 'crm-sync', url: 'https://hooks.example.com/in',\n * secret: process.env.HOOK_SECRET, objects: ['lead', 'account'] },\n * ],\n * }));\n * ```\n */\nexport class WebhooksPlugin implements Plugin {\n name = 'com.objectstack.webhooks';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.service.realtime'];\n\n private readonly options: WebhooksPluginOptions;\n private subscriptionIds: string[] = [];\n private realtime?: IRealtimeService;\n private sinks: WebhookSink[] = [];\n private logger?: PluginContext['logger'];\n\n constructor(options: WebhooksPluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n this.logger = ctx.logger;\n this.sinks = this.resolveSinks();\n if (this.sinks.length === 0) {\n ctx.logger.info(\n 'WebhooksPlugin: no sinks configured (options.sinks empty and OBJECTSTACK_WEBHOOK_URL unset) — plugin is dormant',\n );\n return;\n }\n ctx.logger.info(`WebhooksPlugin: ${this.sinks.length} sink(s) configured`);\n }\n\n async start(ctx: PluginContext): Promise<void> {\n if (this.sinks.length === 0) return;\n ctx.hook('kernel:ready', async () => {\n try {\n this.realtime = ctx.getService<IRealtimeService>('realtime');\n } catch {\n ctx.logger.warn('WebhooksPlugin: realtime service unavailable — events will not be forwarded');\n return;\n }\n\n // We subscribe once per sink so the realtime service can apply each\n // sink's object / eventTypes filter at the channel layer where\n // possible. This also lets us cleanly unsubscribe on stop().\n for (const sink of this.sinks) {\n const opts: RealtimeSubscriptionOptions | undefined =\n (sink.objects && sink.objects.length === 1) ||\n (sink.eventTypes && sink.eventTypes.length > 0)\n ? {\n ...(sink.objects && sink.objects.length === 1 ? { object: sink.objects[0] } : {}),\n ...(sink.eventTypes && sink.eventTypes.length > 0 ? { eventTypes: sink.eventTypes } : {}),\n }\n : undefined;\n const id = await this.realtime.subscribe(\n 'data.record',\n async (event) => { await this.dispatch(sink, event); },\n opts,\n );\n this.subscriptionIds.push(id);\n }\n ctx.logger.info(`WebhooksPlugin: subscribed ${this.subscriptionIds.length} realtime listener(s)`);\n });\n }\n\n async stop(ctx: PluginContext): Promise<void> {\n if (!this.realtime) return;\n for (const id of this.subscriptionIds) {\n try { await this.realtime.unsubscribe(id); }\n catch (err) { ctx.logger.debug('WebhooksPlugin: unsubscribe failed', { id, err }); }\n }\n this.subscriptionIds = [];\n }\n\n /**\n * Resolve sinks from constructor options, falling back to env vars when\n * none provided. Exposed for testing.\n */\n private resolveSinks(): WebhookSink[] {\n if (this.options.sinks && this.options.sinks.length > 0) return this.options.sinks;\n\n const urlEnv = process.env.OBJECTSTACK_WEBHOOK_URL;\n if (!urlEnv) return [];\n\n const urls = urlEnv.split(',').map(s => s.trim()).filter(Boolean);\n const secret = process.env.OBJECTSTACK_WEBHOOK_SECRET;\n const objectsEnv = process.env.OBJECTSTACK_WEBHOOK_OBJECTS;\n const eventsEnv = process.env.OBJECTSTACK_WEBHOOK_EVENTS;\n const objects = objectsEnv ? objectsEnv.split(',').map(s => s.trim()).filter(Boolean) : undefined;\n const eventTypes = eventsEnv ? eventsEnv.split(',').map(s => s.trim()).filter(Boolean) : undefined;\n\n return urls.map((url, idx) => ({\n id: `env-${idx + 1}`,\n url,\n ...(secret ? { secret } : {}),\n ...(objects ? { objects } : {}),\n ...(eventTypes ? { eventTypes } : {}),\n }));\n }\n\n /**\n * Dispatch a single event to a sink, with HMAC signing, timeout, and\n * exponential-backoff retry. Failures past the retry budget are logged\n * but never thrown — webhook delivery must never break the originating\n * mutation.\n */\n private async dispatch(sink: WebhookSink, event: RealtimeEventPayload): Promise<void> {\n // Defence in depth: the realtime layer already filters by single-object\n // subscriptions, but multi-object whitelists are applied here.\n if (sink.objects && sink.objects.length > 0 && event.object && !sink.objects.includes(event.object)) {\n return;\n }\n if (sink.eventTypes && sink.eventTypes.length > 0 && !sink.eventTypes.includes(event.type)) {\n return;\n }\n\n const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;\n if (!fetchImpl) {\n this.logger?.warn('WebhooksPlugin: no fetch implementation available — dropping event', { sinkId: sink.id });\n return;\n }\n\n const body = JSON.stringify(event);\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n 'User-Agent': 'ObjectStack-Webhooks/1.0',\n 'X-Objectstack-Event': event.type,\n ...(event.object ? { 'X-Objectstack-Object': event.object } : {}),\n 'X-Objectstack-Delivery': crypto.randomUUID(),\n ...(sink.headers ?? {}),\n };\n if (sink.secret) {\n const sig = crypto.createHmac('sha256', sink.secret).update(body).digest('hex');\n headers['X-Objectstack-Signature'] = `sha256=${sig}`;\n }\n\n const timeoutMs = sink.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const maxAttempts = (sink.retries ?? DEFAULT_RETRIES) + 1;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const res = await fetchImpl(sink.url, {\n method: 'POST',\n headers,\n body,\n signal: controller.signal,\n });\n clearTimeout(timer);\n if (res.ok || (res.status >= 400 && res.status < 500)) {\n // 4xx is \"permanent\" — don't retry; only 2xx counts as success.\n const status: WebhookDeliveryStatus = res.ok ? 'ok' : 'failed';\n this.options.onDelivery?.({\n sinkId: sink.id, url: sink.url, eventType: event.type,\n object: event.object, status, httpStatus: res.status, attempt,\n });\n if (status === 'failed') {\n this.logger?.warn('WebhooksPlugin: sink rejected event', {\n sinkId: sink.id, status: res.status, eventType: event.type,\n });\n }\n return;\n }\n // 5xx → fall through to retry.\n if (attempt === maxAttempts) {\n this.options.onDelivery?.({\n sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,\n status: 'failed', httpStatus: res.status, attempt,\n });\n this.logger?.warn('WebhooksPlugin: max retries exhausted', {\n sinkId: sink.id, status: res.status, eventType: event.type,\n });\n return;\n }\n this.options.onDelivery?.({\n sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,\n status: 'retrying', httpStatus: res.status, attempt,\n });\n } catch (err: any) {\n clearTimeout(timer);\n const errMessage = err?.name === 'AbortError'\n ? `timeout after ${timeoutMs}ms`\n : (err?.message ?? String(err));\n if (attempt === maxAttempts) {\n this.options.onDelivery?.({\n sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,\n status: 'failed', attempt, error: errMessage,\n });\n this.logger?.warn('WebhooksPlugin: delivery failed', {\n sinkId: sink.id, eventType: event.type, error: errMessage,\n });\n return;\n }\n this.options.onDelivery?.({\n sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,\n status: 'retrying', attempt, error: errMessage,\n });\n }\n // Exponential backoff with full jitter.\n const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1));\n const jittered = Math.floor(Math.random() * delay);\n await new Promise(r => setTimeout(r, jittered));\n }\n }\n}\n"],"mappings":"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","names":["crypto"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,216 @@
1
+ // src/webhooks-plugin.ts
2
+ import crypto from "crypto";
3
+ var DEFAULT_TIMEOUT_MS = 5e3;
4
+ var DEFAULT_RETRIES = 3;
5
+ var BACKOFF_BASE_MS = 250;
6
+ var BACKOFF_MAX_MS = 5e3;
7
+ var WebhooksPlugin = class {
8
+ constructor(options = {}) {
9
+ this.name = "com.objectstack.webhooks";
10
+ this.version = "1.0.0";
11
+ this.type = "standard";
12
+ this.dependencies = ["com.objectstack.service.realtime"];
13
+ this.subscriptionIds = [];
14
+ this.sinks = [];
15
+ this.options = options;
16
+ }
17
+ async init(ctx) {
18
+ this.logger = ctx.logger;
19
+ this.sinks = this.resolveSinks();
20
+ if (this.sinks.length === 0) {
21
+ ctx.logger.info(
22
+ "WebhooksPlugin: no sinks configured (options.sinks empty and OBJECTSTACK_WEBHOOK_URL unset) \u2014 plugin is dormant"
23
+ );
24
+ return;
25
+ }
26
+ ctx.logger.info(`WebhooksPlugin: ${this.sinks.length} sink(s) configured`);
27
+ }
28
+ async start(ctx) {
29
+ if (this.sinks.length === 0) return;
30
+ ctx.hook("kernel:ready", async () => {
31
+ try {
32
+ this.realtime = ctx.getService("realtime");
33
+ } catch {
34
+ ctx.logger.warn("WebhooksPlugin: realtime service unavailable \u2014 events will not be forwarded");
35
+ return;
36
+ }
37
+ for (const sink of this.sinks) {
38
+ const opts = sink.objects && sink.objects.length === 1 || sink.eventTypes && sink.eventTypes.length > 0 ? {
39
+ ...sink.objects && sink.objects.length === 1 ? { object: sink.objects[0] } : {},
40
+ ...sink.eventTypes && sink.eventTypes.length > 0 ? { eventTypes: sink.eventTypes } : {}
41
+ } : void 0;
42
+ const id = await this.realtime.subscribe(
43
+ "data.record",
44
+ async (event) => {
45
+ await this.dispatch(sink, event);
46
+ },
47
+ opts
48
+ );
49
+ this.subscriptionIds.push(id);
50
+ }
51
+ ctx.logger.info(`WebhooksPlugin: subscribed ${this.subscriptionIds.length} realtime listener(s)`);
52
+ });
53
+ }
54
+ async stop(ctx) {
55
+ if (!this.realtime) return;
56
+ for (const id of this.subscriptionIds) {
57
+ try {
58
+ await this.realtime.unsubscribe(id);
59
+ } catch (err) {
60
+ ctx.logger.debug("WebhooksPlugin: unsubscribe failed", { id, err });
61
+ }
62
+ }
63
+ this.subscriptionIds = [];
64
+ }
65
+ /**
66
+ * Resolve sinks from constructor options, falling back to env vars when
67
+ * none provided. Exposed for testing.
68
+ */
69
+ resolveSinks() {
70
+ if (this.options.sinks && this.options.sinks.length > 0) return this.options.sinks;
71
+ const urlEnv = process.env.OBJECTSTACK_WEBHOOK_URL;
72
+ if (!urlEnv) return [];
73
+ const urls = urlEnv.split(",").map((s) => s.trim()).filter(Boolean);
74
+ const secret = process.env.OBJECTSTACK_WEBHOOK_SECRET;
75
+ const objectsEnv = process.env.OBJECTSTACK_WEBHOOK_OBJECTS;
76
+ const eventsEnv = process.env.OBJECTSTACK_WEBHOOK_EVENTS;
77
+ const objects = objectsEnv ? objectsEnv.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
78
+ const eventTypes = eventsEnv ? eventsEnv.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
79
+ return urls.map((url, idx) => ({
80
+ id: `env-${idx + 1}`,
81
+ url,
82
+ ...secret ? { secret } : {},
83
+ ...objects ? { objects } : {},
84
+ ...eventTypes ? { eventTypes } : {}
85
+ }));
86
+ }
87
+ /**
88
+ * Dispatch a single event to a sink, with HMAC signing, timeout, and
89
+ * exponential-backoff retry. Failures past the retry budget are logged
90
+ * but never thrown — webhook delivery must never break the originating
91
+ * mutation.
92
+ */
93
+ async dispatch(sink, event) {
94
+ if (sink.objects && sink.objects.length > 0 && event.object && !sink.objects.includes(event.object)) {
95
+ return;
96
+ }
97
+ if (sink.eventTypes && sink.eventTypes.length > 0 && !sink.eventTypes.includes(event.type)) {
98
+ return;
99
+ }
100
+ const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
101
+ if (!fetchImpl) {
102
+ this.logger?.warn("WebhooksPlugin: no fetch implementation available \u2014 dropping event", { sinkId: sink.id });
103
+ return;
104
+ }
105
+ const body = JSON.stringify(event);
106
+ const headers = {
107
+ "Content-Type": "application/json",
108
+ "User-Agent": "ObjectStack-Webhooks/1.0",
109
+ "X-Objectstack-Event": event.type,
110
+ ...event.object ? { "X-Objectstack-Object": event.object } : {},
111
+ "X-Objectstack-Delivery": crypto.randomUUID(),
112
+ ...sink.headers ?? {}
113
+ };
114
+ if (sink.secret) {
115
+ const sig = crypto.createHmac("sha256", sink.secret).update(body).digest("hex");
116
+ headers["X-Objectstack-Signature"] = `sha256=${sig}`;
117
+ }
118
+ const timeoutMs = sink.timeoutMs ?? DEFAULT_TIMEOUT_MS;
119
+ const maxAttempts = (sink.retries ?? DEFAULT_RETRIES) + 1;
120
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
121
+ const controller = new AbortController();
122
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
123
+ try {
124
+ const res = await fetchImpl(sink.url, {
125
+ method: "POST",
126
+ headers,
127
+ body,
128
+ signal: controller.signal
129
+ });
130
+ clearTimeout(timer);
131
+ if (res.ok || res.status >= 400 && res.status < 500) {
132
+ const status = res.ok ? "ok" : "failed";
133
+ this.options.onDelivery?.({
134
+ sinkId: sink.id,
135
+ url: sink.url,
136
+ eventType: event.type,
137
+ object: event.object,
138
+ status,
139
+ httpStatus: res.status,
140
+ attempt
141
+ });
142
+ if (status === "failed") {
143
+ this.logger?.warn("WebhooksPlugin: sink rejected event", {
144
+ sinkId: sink.id,
145
+ status: res.status,
146
+ eventType: event.type
147
+ });
148
+ }
149
+ return;
150
+ }
151
+ if (attempt === maxAttempts) {
152
+ this.options.onDelivery?.({
153
+ sinkId: sink.id,
154
+ url: sink.url,
155
+ eventType: event.type,
156
+ object: event.object,
157
+ status: "failed",
158
+ httpStatus: res.status,
159
+ attempt
160
+ });
161
+ this.logger?.warn("WebhooksPlugin: max retries exhausted", {
162
+ sinkId: sink.id,
163
+ status: res.status,
164
+ eventType: event.type
165
+ });
166
+ return;
167
+ }
168
+ this.options.onDelivery?.({
169
+ sinkId: sink.id,
170
+ url: sink.url,
171
+ eventType: event.type,
172
+ object: event.object,
173
+ status: "retrying",
174
+ httpStatus: res.status,
175
+ attempt
176
+ });
177
+ } catch (err) {
178
+ clearTimeout(timer);
179
+ const errMessage = err?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : err?.message ?? String(err);
180
+ if (attempt === maxAttempts) {
181
+ this.options.onDelivery?.({
182
+ sinkId: sink.id,
183
+ url: sink.url,
184
+ eventType: event.type,
185
+ object: event.object,
186
+ status: "failed",
187
+ attempt,
188
+ error: errMessage
189
+ });
190
+ this.logger?.warn("WebhooksPlugin: delivery failed", {
191
+ sinkId: sink.id,
192
+ eventType: event.type,
193
+ error: errMessage
194
+ });
195
+ return;
196
+ }
197
+ this.options.onDelivery?.({
198
+ sinkId: sink.id,
199
+ url: sink.url,
200
+ eventType: event.type,
201
+ object: event.object,
202
+ status: "retrying",
203
+ attempt,
204
+ error: errMessage
205
+ });
206
+ }
207
+ const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1));
208
+ const jittered = Math.floor(Math.random() * delay);
209
+ await new Promise((r) => setTimeout(r, jittered));
210
+ }
211
+ }
212
+ };
213
+ export {
214
+ WebhooksPlugin
215
+ };
216
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/webhooks-plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport crypto from 'node:crypto';\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type {\n IRealtimeService,\n RealtimeEventPayload,\n RealtimeSubscriptionOptions,\n} from '@objectstack/spec/contracts';\n\n/**\n * A single webhook delivery target.\n */\nexport interface WebhookSink {\n /** Unique sink id used for log correlation. */\n id: string;\n /** Target HTTPS URL. */\n url: string;\n /** Optional HMAC-SHA256 secret. When set, `X-Objectstack-Signature: sha256=…` is added. */\n secret?: string;\n /**\n * Restrict to specific object names (logical names, e.g. `lead`, `account`).\n * Omit / empty → all objects.\n */\n objects?: string[];\n /**\n * Restrict to specific event types. Omit / empty → all `data.record.*` events.\n */\n eventTypes?: string[];\n /** Extra headers to send (Authorization, Tenant, etc.). */\n headers?: Record<string, string>;\n /** Per-request timeout in milliseconds. Default 5000. */\n timeoutMs?: number;\n /** Retry attempts on transient failure. Default 3. Set 0 to disable retries. */\n retries?: number;\n}\n\n/**\n * Delivery attempt outcome surfaced to in-process listeners / tests.\n */\nexport type WebhookDeliveryStatus = 'ok' | 'retrying' | 'failed';\n\nexport interface WebhookDeliveryRecord {\n sinkId: string;\n url: string;\n eventType: string;\n object?: string;\n status: WebhookDeliveryStatus;\n httpStatus?: number;\n attempt: number;\n error?: string;\n}\n\n/**\n * Plugin configuration.\n *\n * Sinks may be supplied programmatically OR via env vars when none are\n * passed (suitable for 12-factor / Docker deployments):\n *\n * OBJECTSTACK_WEBHOOK_URL — single URL, or comma-separated URLs.\n * OBJECTSTACK_WEBHOOK_SECRET — HMAC secret applied to all env-sourced URLs.\n * OBJECTSTACK_WEBHOOK_OBJECTS — comma-separated object whitelist.\n * OBJECTSTACK_WEBHOOK_EVENTS — comma-separated event-type whitelist\n * (e.g. `data.record.created`).\n */\nexport interface WebhooksPluginOptions {\n /** Explicit sink list (takes precedence over env vars). */\n sinks?: WebhookSink[];\n /** Override fetch (mainly for tests). Defaults to globalThis.fetch. */\n fetchImpl?: typeof fetch;\n /** Hook invoked with each delivery outcome (mainly for tests / metrics). */\n onDelivery?: (record: WebhookDeliveryRecord) => void;\n}\n\nconst DEFAULT_TIMEOUT_MS = 5_000;\nconst DEFAULT_RETRIES = 3;\nconst BACKOFF_BASE_MS = 250;\nconst BACKOFF_MAX_MS = 5_000;\n\n/**\n * WebhooksPlugin — fan out data.record.* events to external HTTP endpoints.\n *\n * @example\n * ```ts\n * kernel.use(new WebhooksPlugin({\n * sinks: [\n * { id: 'crm-sync', url: 'https://hooks.example.com/in',\n * secret: process.env.HOOK_SECRET, objects: ['lead', 'account'] },\n * ],\n * }));\n * ```\n */\nexport class WebhooksPlugin implements Plugin {\n name = 'com.objectstack.webhooks';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.service.realtime'];\n\n private readonly options: WebhooksPluginOptions;\n private subscriptionIds: string[] = [];\n private realtime?: IRealtimeService;\n private sinks: WebhookSink[] = [];\n private logger?: PluginContext['logger'];\n\n constructor(options: WebhooksPluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n this.logger = ctx.logger;\n this.sinks = this.resolveSinks();\n if (this.sinks.length === 0) {\n ctx.logger.info(\n 'WebhooksPlugin: no sinks configured (options.sinks empty and OBJECTSTACK_WEBHOOK_URL unset) — plugin is dormant',\n );\n return;\n }\n ctx.logger.info(`WebhooksPlugin: ${this.sinks.length} sink(s) configured`);\n }\n\n async start(ctx: PluginContext): Promise<void> {\n if (this.sinks.length === 0) return;\n ctx.hook('kernel:ready', async () => {\n try {\n this.realtime = ctx.getService<IRealtimeService>('realtime');\n } catch {\n ctx.logger.warn('WebhooksPlugin: realtime service unavailable — events will not be forwarded');\n return;\n }\n\n // We subscribe once per sink so the realtime service can apply each\n // sink's object / eventTypes filter at the channel layer where\n // possible. This also lets us cleanly unsubscribe on stop().\n for (const sink of this.sinks) {\n const opts: RealtimeSubscriptionOptions | undefined =\n (sink.objects && sink.objects.length === 1) ||\n (sink.eventTypes && sink.eventTypes.length > 0)\n ? {\n ...(sink.objects && sink.objects.length === 1 ? { object: sink.objects[0] } : {}),\n ...(sink.eventTypes && sink.eventTypes.length > 0 ? { eventTypes: sink.eventTypes } : {}),\n }\n : undefined;\n const id = await this.realtime.subscribe(\n 'data.record',\n async (event) => { await this.dispatch(sink, event); },\n opts,\n );\n this.subscriptionIds.push(id);\n }\n ctx.logger.info(`WebhooksPlugin: subscribed ${this.subscriptionIds.length} realtime listener(s)`);\n });\n }\n\n async stop(ctx: PluginContext): Promise<void> {\n if (!this.realtime) return;\n for (const id of this.subscriptionIds) {\n try { await this.realtime.unsubscribe(id); }\n catch (err) { ctx.logger.debug('WebhooksPlugin: unsubscribe failed', { id, err }); }\n }\n this.subscriptionIds = [];\n }\n\n /**\n * Resolve sinks from constructor options, falling back to env vars when\n * none provided. Exposed for testing.\n */\n private resolveSinks(): WebhookSink[] {\n if (this.options.sinks && this.options.sinks.length > 0) return this.options.sinks;\n\n const urlEnv = process.env.OBJECTSTACK_WEBHOOK_URL;\n if (!urlEnv) return [];\n\n const urls = urlEnv.split(',').map(s => s.trim()).filter(Boolean);\n const secret = process.env.OBJECTSTACK_WEBHOOK_SECRET;\n const objectsEnv = process.env.OBJECTSTACK_WEBHOOK_OBJECTS;\n const eventsEnv = process.env.OBJECTSTACK_WEBHOOK_EVENTS;\n const objects = objectsEnv ? objectsEnv.split(',').map(s => s.trim()).filter(Boolean) : undefined;\n const eventTypes = eventsEnv ? eventsEnv.split(',').map(s => s.trim()).filter(Boolean) : undefined;\n\n return urls.map((url, idx) => ({\n id: `env-${idx + 1}`,\n url,\n ...(secret ? { secret } : {}),\n ...(objects ? { objects } : {}),\n ...(eventTypes ? { eventTypes } : {}),\n }));\n }\n\n /**\n * Dispatch a single event to a sink, with HMAC signing, timeout, and\n * exponential-backoff retry. Failures past the retry budget are logged\n * but never thrown — webhook delivery must never break the originating\n * mutation.\n */\n private async dispatch(sink: WebhookSink, event: RealtimeEventPayload): Promise<void> {\n // Defence in depth: the realtime layer already filters by single-object\n // subscriptions, but multi-object whitelists are applied here.\n if (sink.objects && sink.objects.length > 0 && event.object && !sink.objects.includes(event.object)) {\n return;\n }\n if (sink.eventTypes && sink.eventTypes.length > 0 && !sink.eventTypes.includes(event.type)) {\n return;\n }\n\n const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;\n if (!fetchImpl) {\n this.logger?.warn('WebhooksPlugin: no fetch implementation available — dropping event', { sinkId: sink.id });\n return;\n }\n\n const body = JSON.stringify(event);\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n 'User-Agent': 'ObjectStack-Webhooks/1.0',\n 'X-Objectstack-Event': event.type,\n ...(event.object ? { 'X-Objectstack-Object': event.object } : {}),\n 'X-Objectstack-Delivery': crypto.randomUUID(),\n ...(sink.headers ?? {}),\n };\n if (sink.secret) {\n const sig = crypto.createHmac('sha256', sink.secret).update(body).digest('hex');\n headers['X-Objectstack-Signature'] = `sha256=${sig}`;\n }\n\n const timeoutMs = sink.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const maxAttempts = (sink.retries ?? DEFAULT_RETRIES) + 1;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const res = await fetchImpl(sink.url, {\n method: 'POST',\n headers,\n body,\n signal: controller.signal,\n });\n clearTimeout(timer);\n if (res.ok || (res.status >= 400 && res.status < 500)) {\n // 4xx is \"permanent\" — don't retry; only 2xx counts as success.\n const status: WebhookDeliveryStatus = res.ok ? 'ok' : 'failed';\n this.options.onDelivery?.({\n sinkId: sink.id, url: sink.url, eventType: event.type,\n object: event.object, status, httpStatus: res.status, attempt,\n });\n if (status === 'failed') {\n this.logger?.warn('WebhooksPlugin: sink rejected event', {\n sinkId: sink.id, status: res.status, eventType: event.type,\n });\n }\n return;\n }\n // 5xx → fall through to retry.\n if (attempt === maxAttempts) {\n this.options.onDelivery?.({\n sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,\n status: 'failed', httpStatus: res.status, attempt,\n });\n this.logger?.warn('WebhooksPlugin: max retries exhausted', {\n sinkId: sink.id, status: res.status, eventType: event.type,\n });\n return;\n }\n this.options.onDelivery?.({\n sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,\n status: 'retrying', httpStatus: res.status, attempt,\n });\n } catch (err: any) {\n clearTimeout(timer);\n const errMessage = err?.name === 'AbortError'\n ? `timeout after ${timeoutMs}ms`\n : (err?.message ?? String(err));\n if (attempt === maxAttempts) {\n this.options.onDelivery?.({\n sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,\n status: 'failed', attempt, error: errMessage,\n });\n this.logger?.warn('WebhooksPlugin: delivery failed', {\n sinkId: sink.id, eventType: event.type, error: errMessage,\n });\n return;\n }\n this.options.onDelivery?.({\n sinkId: sink.id, url: sink.url, eventType: event.type, object: event.object,\n status: 'retrying', attempt, error: errMessage,\n });\n }\n // Exponential backoff with full jitter.\n const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1));\n const jittered = Math.floor(Math.random() * delay);\n await new Promise(r => setTimeout(r, jittered));\n }\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@objectstack/plugin-webhooks",
3
+ "version": "4.0.1",
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
+ "dependencies": {
16
+ "@objectstack/core": "4.1.0",
17
+ "@objectstack/spec": "4.1.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^25.9.1",
21
+ "typescript": "^6.0.3",
22
+ "vitest": "^4.1.7"
23
+ },
24
+ "keywords": [
25
+ "objectstack",
26
+ "plugin",
27
+ "webhooks",
28
+ "events"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsup --config ../../../tsup.config.ts",
32
+ "test": "vitest run --passWithNoTests"
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -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';