@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/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +10 -0
- package/LICENSE +202 -0
- package/dist/index.d.mts +104 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +253 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +216 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +34 -0
- package/src/index.ts +19 -0
- package/src/webhooks-plugin.test.ts +218 -0
- package/src/webhooks-plugin.ts +294 -0
- package/tsconfig.json +18 -0
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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACEA,yBAAmB;AAwEnB,IAAM,qBAAqB;AAC3B,IAAM,kBAAkB;AACxB,IAAM,kBAAkB;AACxB,IAAM,iBAAiB;AAehB,IAAM,iBAAN,MAAuC;AAAA,EAY5C,YAAY,UAAiC,CAAC,GAAG;AAXjD,gBAAO;AACP,mBAAU;AACV,gBAAO;AACP,wBAAe,CAAC,kCAAkC;AAGlD,SAAQ,kBAA4B,CAAC;AAErC,SAAQ,QAAuB,CAAC;AAI9B,SAAK,UAAU;AAAA,EACjB;AAAA,EAEA,MAAM,KAAK,KAAmC;AAC5C,SAAK,SAAS,IAAI;AAClB,SAAK,QAAQ,KAAK,aAAa;AAC/B,QAAI,KAAK,MAAM,WAAW,GAAG;AAC3B,UAAI,OAAO;AAAA,QACT;AAAA,MACF;AACA;AAAA,IACF;AACA,QAAI,OAAO,KAAK,mBAAmB,KAAK,MAAM,MAAM,qBAAqB;AAAA,EAC3E;AAAA,EAEA,MAAM,MAAM,KAAmC;AAC7C,QAAI,KAAK,MAAM,WAAW,EAAG;AAC7B,QAAI,KAAK,gBAAgB,YAAY;AACnC,UAAI;AACF,aAAK,WAAW,IAAI,WAA6B,UAAU;AAAA,MAC7D,QAAQ;AACN,YAAI,OAAO,KAAK,kFAA6E;AAC7F;AAAA,MACF;AAKA,iBAAW,QAAQ,KAAK,OAAO;AAC7B,cAAM,OACH,KAAK,WAAW,KAAK,QAAQ,WAAW,KACxC,KAAK,cAAc,KAAK,WAAW,SAAS,IACzC;AAAA,UACE,GAAI,KAAK,WAAW,KAAK,QAAQ,WAAW,IAAI,EAAE,QAAQ,KAAK,QAAQ,CAAC,EAAE,IAAI,CAAC;AAAA,UAC/E,GAAI,KAAK,cAAc,KAAK,WAAW,SAAS,IAAI,EAAE,YAAY,KAAK,WAAW,IAAI,CAAC;AAAA,QACzF,IACA;AACN,cAAM,KAAK,MAAM,KAAK,SAAS;AAAA,UAC7B;AAAA,UACA,OAAO,UAAU;AAAE,kBAAM,KAAK,SAAS,MAAM,KAAK;AAAA,UAAG;AAAA,UACrD;AAAA,QACF;AACA,aAAK,gBAAgB,KAAK,EAAE;AAAA,MAC9B;AACA,UAAI,OAAO,KAAK,8BAA8B,KAAK,gBAAgB,MAAM,uBAAuB;AAAA,IAClG,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,KAAK,KAAmC;AAC5C,QAAI,CAAC,KAAK,SAAU;AACpB,eAAW,MAAM,KAAK,iBAAiB;AACrC,UAAI;AAAE,cAAM,KAAK,SAAS,YAAY,EAAE;AAAA,MAAG,SACpC,KAAK;AAAE,YAAI,OAAO,MAAM,sCAAsC,EAAE,IAAI,IAAI,CAAC;AAAA,MAAG;AAAA,IACrF;AACA,SAAK,kBAAkB,CAAC;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMQ,eAA8B;AACpC,QAAI,KAAK,QAAQ,SAAS,KAAK,QAAQ,MAAM,SAAS,EAAG,QAAO,KAAK,QAAQ;AAE7E,UAAM,SAAS,QAAQ,IAAI;AAC3B,QAAI,CAAC,OAAQ,QAAO,CAAC;AAErB,UAAM,OAAO,OAAO,MAAM,GAAG,EAAE,IAAI,OAAK,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO;AAChE,UAAM,SAAS,QAAQ,IAAI;AAC3B,UAAM,aAAa,QAAQ,IAAI;AAC/B,UAAM,YAAY,QAAQ,IAAI;AAC9B,UAAM,UAAU,aAAa,WAAW,MAAM,GAAG,EAAE,IAAI,OAAK,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO,IAAI;AACxF,UAAM,aAAa,YAAY,UAAU,MAAM,GAAG,EAAE,IAAI,OAAK,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO,IAAI;AAEzF,WAAO,KAAK,IAAI,CAAC,KAAK,SAAS;AAAA,MAC7B,IAAI,OAAO,MAAM,CAAC;AAAA,MAClB;AAAA,MACA,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;AAAA,MAC3B,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;AAAA,MAC7B,GAAI,aAAa,EAAE,WAAW,IAAI,CAAC;AAAA,IACrC,EAAE;AAAA,EACJ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAc,SAAS,MAAmB,OAA4C;AAGpF,QAAI,KAAK,WAAW,KAAK,QAAQ,SAAS,KAAK,MAAM,UAAU,CAAC,KAAK,QAAQ,SAAS,MAAM,MAAM,GAAG;AACnG;AAAA,IACF;AACA,QAAI,KAAK,cAAc,KAAK,WAAW,SAAS,KAAK,CAAC,KAAK,WAAW,SAAS,MAAM,IAAI,GAAG;AAC1F;AAAA,IACF;AAEA,UAAM,YAAY,KAAK,QAAQ,aAAa,WAAW;AACvD,QAAI,CAAC,WAAW;AACd,WAAK,QAAQ,KAAK,2EAAsE,EAAE,QAAQ,KAAK,GAAG,CAAC;AAC3G;AAAA,IACF;AAEA,UAAM,OAAO,KAAK,UAAU,KAAK;AACjC,UAAM,UAAkC;AAAA,MACtC,gBAAgB;AAAA,MAChB,cAAc;AAAA,MACd,uBAAuB,MAAM;AAAA,MAC7B,GAAI,MAAM,SAAS,EAAE,wBAAwB,MAAM,OAAO,IAAI,CAAC;AAAA,MAC/D,0BAA0B,mBAAAA,QAAO,WAAW;AAAA,MAC5C,GAAI,KAAK,WAAW,CAAC;AAAA,IACvB;AACA,QAAI,KAAK,QAAQ;AACf,YAAM,MAAM,mBAAAA,QAAO,WAAW,UAAU,KAAK,MAAM,EAAE,OAAO,IAAI,EAAE,OAAO,KAAK;AAC9E,cAAQ,yBAAyB,IAAI,UAAU,GAAG;AAAA,IACpD;AAEA,UAAM,YAAY,KAAK,aAAa;AACpC,UAAM,eAAe,KAAK,WAAW,mBAAmB;AAExD,aAAS,UAAU,GAAG,WAAW,aAAa,WAAW;AACvD,YAAM,aAAa,IAAI,gBAAgB;AACvC,YAAM,QAAQ,WAAW,MAAM,WAAW,MAAM,GAAG,SAAS;AAC5D,UAAI;AACF,cAAM,MAAM,MAAM,UAAU,KAAK,KAAK;AAAA,UACpC,QAAQ;AAAA,UACR;AAAA,UACA;AAAA,UACA,QAAQ,WAAW;AAAA,QACrB,CAAC;AACD,qBAAa,KAAK;AAClB,YAAI,IAAI,MAAO,IAAI,UAAU,OAAO,IAAI,SAAS,KAAM;AAErD,gBAAM,SAAgC,IAAI,KAAK,OAAO;AACtD,eAAK,QAAQ,aAAa;AAAA,YACxB,QAAQ,KAAK;AAAA,YAAI,KAAK,KAAK;AAAA,YAAK,WAAW,MAAM;AAAA,YACjD,QAAQ,MAAM;AAAA,YAAQ;AAAA,YAAQ,YAAY,IAAI;AAAA,YAAQ;AAAA,UACxD,CAAC;AACD,cAAI,WAAW,UAAU;AACvB,iBAAK,QAAQ,KAAK,uCAAuC;AAAA,cACvD,QAAQ,KAAK;AAAA,cAAI,QAAQ,IAAI;AAAA,cAAQ,WAAW,MAAM;AAAA,YACxD,CAAC;AAAA,UACH;AACA;AAAA,QACF;AAEA,YAAI,YAAY,aAAa;AAC3B,eAAK,QAAQ,aAAa;AAAA,YACxB,QAAQ,KAAK;AAAA,YAAI,KAAK,KAAK;AAAA,YAAK,WAAW,MAAM;AAAA,YAAM,QAAQ,MAAM;AAAA,YACrE,QAAQ;AAAA,YAAU,YAAY,IAAI;AAAA,YAAQ;AAAA,UAC5C,CAAC;AACD,eAAK,QAAQ,KAAK,yCAAyC;AAAA,YACzD,QAAQ,KAAK;AAAA,YAAI,QAAQ,IAAI;AAAA,YAAQ,WAAW,MAAM;AAAA,UACxD,CAAC;AACD;AAAA,QACF;AACA,aAAK,QAAQ,aAAa;AAAA,UACxB,QAAQ,KAAK;AAAA,UAAI,KAAK,KAAK;AAAA,UAAK,WAAW,MAAM;AAAA,UAAM,QAAQ,MAAM;AAAA,UACrE,QAAQ;AAAA,UAAY,YAAY,IAAI;AAAA,UAAQ;AAAA,QAC9C,CAAC;AAAA,MACH,SAAS,KAAU;AACjB,qBAAa,KAAK;AAClB,cAAM,aAAa,KAAK,SAAS,eAC7B,iBAAiB,SAAS,OACzB,KAAK,WAAW,OAAO,GAAG;AAC/B,YAAI,YAAY,aAAa;AAC3B,eAAK,QAAQ,aAAa;AAAA,YACxB,QAAQ,KAAK;AAAA,YAAI,KAAK,KAAK;AAAA,YAAK,WAAW,MAAM;AAAA,YAAM,QAAQ,MAAM;AAAA,YACrE,QAAQ;AAAA,YAAU;AAAA,YAAS,OAAO;AAAA,UACpC,CAAC;AACD,eAAK,QAAQ,KAAK,mCAAmC;AAAA,YACnD,QAAQ,KAAK;AAAA,YAAI,WAAW,MAAM;AAAA,YAAM,OAAO;AAAA,UACjD,CAAC;AACD;AAAA,QACF;AACA,aAAK,QAAQ,aAAa;AAAA,UACxB,QAAQ,KAAK;AAAA,UAAI,KAAK,KAAK;AAAA,UAAK,WAAW,MAAM;AAAA,UAAM,QAAQ,MAAM;AAAA,UACrE,QAAQ;AAAA,UAAY;AAAA,UAAS,OAAO;AAAA,QACtC,CAAC;AAAA,MACH;AAEA,YAAM,QAAQ,KAAK,IAAI,gBAAgB,kBAAkB,MAAM,UAAU,EAAE;AAC3E,YAAM,WAAW,KAAK,MAAM,KAAK,OAAO,IAAI,KAAK;AACjD,YAAM,IAAI,QAAQ,OAAK,WAAW,GAAG,QAAQ,CAAC;AAAA,IAChD;AAAA,EACF;AACF;","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":";AAEA,OAAO,YAAY;AAwEnB,IAAM,qBAAqB;AAC3B,IAAM,kBAAkB;AACxB,IAAM,kBAAkB;AACxB,IAAM,iBAAiB;AAehB,IAAM,iBAAN,MAAuC;AAAA,EAY5C,YAAY,UAAiC,CAAC,GAAG;AAXjD,gBAAO;AACP,mBAAU;AACV,gBAAO;AACP,wBAAe,CAAC,kCAAkC;AAGlD,SAAQ,kBAA4B,CAAC;AAErC,SAAQ,QAAuB,CAAC;AAI9B,SAAK,UAAU;AAAA,EACjB;AAAA,EAEA,MAAM,KAAK,KAAmC;AAC5C,SAAK,SAAS,IAAI;AAClB,SAAK,QAAQ,KAAK,aAAa;AAC/B,QAAI,KAAK,MAAM,WAAW,GAAG;AAC3B,UAAI,OAAO;AAAA,QACT;AAAA,MACF;AACA;AAAA,IACF;AACA,QAAI,OAAO,KAAK,mBAAmB,KAAK,MAAM,MAAM,qBAAqB;AAAA,EAC3E;AAAA,EAEA,MAAM,MAAM,KAAmC;AAC7C,QAAI,KAAK,MAAM,WAAW,EAAG;AAC7B,QAAI,KAAK,gBAAgB,YAAY;AACnC,UAAI;AACF,aAAK,WAAW,IAAI,WAA6B,UAAU;AAAA,MAC7D,QAAQ;AACN,YAAI,OAAO,KAAK,kFAA6E;AAC7F;AAAA,MACF;AAKA,iBAAW,QAAQ,KAAK,OAAO;AAC7B,cAAM,OACH,KAAK,WAAW,KAAK,QAAQ,WAAW,KACxC,KAAK,cAAc,KAAK,WAAW,SAAS,IACzC;AAAA,UACE,GAAI,KAAK,WAAW,KAAK,QAAQ,WAAW,IAAI,EAAE,QAAQ,KAAK,QAAQ,CAAC,EAAE,IAAI,CAAC;AAAA,UAC/E,GAAI,KAAK,cAAc,KAAK,WAAW,SAAS,IAAI,EAAE,YAAY,KAAK,WAAW,IAAI,CAAC;AAAA,QACzF,IACA;AACN,cAAM,KAAK,MAAM,KAAK,SAAS;AAAA,UAC7B;AAAA,UACA,OAAO,UAAU;AAAE,kBAAM,KAAK,SAAS,MAAM,KAAK;AAAA,UAAG;AAAA,UACrD;AAAA,QACF;AACA,aAAK,gBAAgB,KAAK,EAAE;AAAA,MAC9B;AACA,UAAI,OAAO,KAAK,8BAA8B,KAAK,gBAAgB,MAAM,uBAAuB;AAAA,IAClG,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,KAAK,KAAmC;AAC5C,QAAI,CAAC,KAAK,SAAU;AACpB,eAAW,MAAM,KAAK,iBAAiB;AACrC,UAAI;AAAE,cAAM,KAAK,SAAS,YAAY,EAAE;AAAA,MAAG,SACpC,KAAK;AAAE,YAAI,OAAO,MAAM,sCAAsC,EAAE,IAAI,IAAI,CAAC;AAAA,MAAG;AAAA,IACrF;AACA,SAAK,kBAAkB,CAAC;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMQ,eAA8B;AACpC,QAAI,KAAK,QAAQ,SAAS,KAAK,QAAQ,MAAM,SAAS,EAAG,QAAO,KAAK,QAAQ;AAE7E,UAAM,SAAS,QAAQ,IAAI;AAC3B,QAAI,CAAC,OAAQ,QAAO,CAAC;AAErB,UAAM,OAAO,OAAO,MAAM,GAAG,EAAE,IAAI,OAAK,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO;AAChE,UAAM,SAAS,QAAQ,IAAI;AAC3B,UAAM,aAAa,QAAQ,IAAI;AAC/B,UAAM,YAAY,QAAQ,IAAI;AAC9B,UAAM,UAAU,aAAa,WAAW,MAAM,GAAG,EAAE,IAAI,OAAK,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO,IAAI;AACxF,UAAM,aAAa,YAAY,UAAU,MAAM,GAAG,EAAE,IAAI,OAAK,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO,IAAI;AAEzF,WAAO,KAAK,IAAI,CAAC,KAAK,SAAS;AAAA,MAC7B,IAAI,OAAO,MAAM,CAAC;AAAA,MAClB;AAAA,MACA,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;AAAA,MAC3B,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;AAAA,MAC7B,GAAI,aAAa,EAAE,WAAW,IAAI,CAAC;AAAA,IACrC,EAAE;AAAA,EACJ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAc,SAAS,MAAmB,OAA4C;AAGpF,QAAI,KAAK,WAAW,KAAK,QAAQ,SAAS,KAAK,MAAM,UAAU,CAAC,KAAK,QAAQ,SAAS,MAAM,MAAM,GAAG;AACnG;AAAA,IACF;AACA,QAAI,KAAK,cAAc,KAAK,WAAW,SAAS,KAAK,CAAC,KAAK,WAAW,SAAS,MAAM,IAAI,GAAG;AAC1F;AAAA,IACF;AAEA,UAAM,YAAY,KAAK,QAAQ,aAAa,WAAW;AACvD,QAAI,CAAC,WAAW;AACd,WAAK,QAAQ,KAAK,2EAAsE,EAAE,QAAQ,KAAK,GAAG,CAAC;AAC3G;AAAA,IACF;AAEA,UAAM,OAAO,KAAK,UAAU,KAAK;AACjC,UAAM,UAAkC;AAAA,MACtC,gBAAgB;AAAA,MAChB,cAAc;AAAA,MACd,uBAAuB,MAAM;AAAA,MAC7B,GAAI,MAAM,SAAS,EAAE,wBAAwB,MAAM,OAAO,IAAI,CAAC;AAAA,MAC/D,0BAA0B,OAAO,WAAW;AAAA,MAC5C,GAAI,KAAK,WAAW,CAAC;AAAA,IACvB;AACA,QAAI,KAAK,QAAQ;AACf,YAAM,MAAM,OAAO,WAAW,UAAU,KAAK,MAAM,EAAE,OAAO,IAAI,EAAE,OAAO,KAAK;AAC9E,cAAQ,yBAAyB,IAAI,UAAU,GAAG;AAAA,IACpD;AAEA,UAAM,YAAY,KAAK,aAAa;AACpC,UAAM,eAAe,KAAK,WAAW,mBAAmB;AAExD,aAAS,UAAU,GAAG,WAAW,aAAa,WAAW;AACvD,YAAM,aAAa,IAAI,gBAAgB;AACvC,YAAM,QAAQ,WAAW,MAAM,WAAW,MAAM,GAAG,SAAS;AAC5D,UAAI;AACF,cAAM,MAAM,MAAM,UAAU,KAAK,KAAK;AAAA,UACpC,QAAQ;AAAA,UACR;AAAA,UACA;AAAA,UACA,QAAQ,WAAW;AAAA,QACrB,CAAC;AACD,qBAAa,KAAK;AAClB,YAAI,IAAI,MAAO,IAAI,UAAU,OAAO,IAAI,SAAS,KAAM;AAErD,gBAAM,SAAgC,IAAI,KAAK,OAAO;AACtD,eAAK,QAAQ,aAAa;AAAA,YACxB,QAAQ,KAAK;AAAA,YAAI,KAAK,KAAK;AAAA,YAAK,WAAW,MAAM;AAAA,YACjD,QAAQ,MAAM;AAAA,YAAQ;AAAA,YAAQ,YAAY,IAAI;AAAA,YAAQ;AAAA,UACxD,CAAC;AACD,cAAI,WAAW,UAAU;AACvB,iBAAK,QAAQ,KAAK,uCAAuC;AAAA,cACvD,QAAQ,KAAK;AAAA,cAAI,QAAQ,IAAI;AAAA,cAAQ,WAAW,MAAM;AAAA,YACxD,CAAC;AAAA,UACH;AACA;AAAA,QACF;AAEA,YAAI,YAAY,aAAa;AAC3B,eAAK,QAAQ,aAAa;AAAA,YACxB,QAAQ,KAAK;AAAA,YAAI,KAAK,KAAK;AAAA,YAAK,WAAW,MAAM;AAAA,YAAM,QAAQ,MAAM;AAAA,YACrE,QAAQ;AAAA,YAAU,YAAY,IAAI;AAAA,YAAQ;AAAA,UAC5C,CAAC;AACD,eAAK,QAAQ,KAAK,yCAAyC;AAAA,YACzD,QAAQ,KAAK;AAAA,YAAI,QAAQ,IAAI;AAAA,YAAQ,WAAW,MAAM;AAAA,UACxD,CAAC;AACD;AAAA,QACF;AACA,aAAK,QAAQ,aAAa;AAAA,UACxB,QAAQ,KAAK;AAAA,UAAI,KAAK,KAAK;AAAA,UAAK,WAAW,MAAM;AAAA,UAAM,QAAQ,MAAM;AAAA,UACrE,QAAQ;AAAA,UAAY,YAAY,IAAI;AAAA,UAAQ;AAAA,QAC9C,CAAC;AAAA,MACH,SAAS,KAAU;AACjB,qBAAa,KAAK;AAClB,cAAM,aAAa,KAAK,SAAS,eAC7B,iBAAiB,SAAS,OACzB,KAAK,WAAW,OAAO,GAAG;AAC/B,YAAI,YAAY,aAAa;AAC3B,eAAK,QAAQ,aAAa;AAAA,YACxB,QAAQ,KAAK;AAAA,YAAI,KAAK,KAAK;AAAA,YAAK,WAAW,MAAM;AAAA,YAAM,QAAQ,MAAM;AAAA,YACrE,QAAQ;AAAA,YAAU;AAAA,YAAS,OAAO;AAAA,UACpC,CAAC;AACD,eAAK,QAAQ,KAAK,mCAAmC;AAAA,YACnD,QAAQ,KAAK;AAAA,YAAI,WAAW,MAAM;AAAA,YAAM,OAAO;AAAA,UACjD,CAAC;AACD;AAAA,QACF;AACA,aAAK,QAAQ,aAAa;AAAA,UACxB,QAAQ,KAAK;AAAA,UAAI,KAAK,KAAK;AAAA,UAAK,WAAW,MAAM;AAAA,UAAM,QAAQ,MAAM;AAAA,UACrE,QAAQ;AAAA,UAAY;AAAA,UAAS,OAAO;AAAA,QACtC,CAAC;AAAA,MACH;AAEA,YAAM,QAAQ,KAAK,IAAI,gBAAgB,kBAAkB,MAAM,UAAU,EAAE;AAC3E,YAAM,WAAW,KAAK,MAAM,KAAK,OAAO,IAAI,KAAK;AACjD,YAAM,IAAI,QAAQ,OAAK,WAAW,GAAG,QAAQ,CAAC;AAAA,IAChD;AAAA,EACF;AACF;","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';
|