@objectstack/plugin-webhooks 5.0.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +35 -13
- package/CHANGELOG.md +9 -28
- package/dist/chunk-JN76ZRWN.js +164 -0
- package/dist/chunk-JN76ZRWN.js.map +1 -0
- package/dist/chunk-M4M5FWIH.cjs +15 -0
- package/dist/chunk-M4M5FWIH.cjs.map +1 -0
- package/dist/chunk-NYSUNT6X.js +15 -0
- package/dist/chunk-NYSUNT6X.js.map +1 -0
- package/dist/chunk-OW7ESXOK.cjs +164 -0
- package/dist/chunk-OW7ESXOK.cjs.map +1 -0
- package/dist/index.cjs +747 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +455 -0
- package/dist/index.d.ts +425 -74
- package/dist/index.js +712 -218
- package/dist/index.js.map +1 -1
- package/dist/outbox-bPQmKYPN.d.cts +128 -0
- package/dist/outbox-bPQmKYPN.d.ts +128 -0
- package/dist/schema.cjs +9 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +4772 -0
- package/dist/schema.d.ts +4772 -0
- package/dist/schema.js +9 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql-outbox.cjs +184 -0
- package/dist/sql-outbox.cjs.map +1 -0
- package/dist/sql-outbox.d.cts +54 -0
- package/dist/sql-outbox.d.ts +54 -0
- package/dist/sql-outbox.js +184 -0
- package/dist/sql-outbox.js.map +1 -0
- package/package.json +30 -10
- package/src/auto-enqueuer.test.ts +391 -0
- package/src/auto-enqueuer.ts +335 -0
- package/src/dispatcher.test.ts +324 -0
- package/src/dispatcher.ts +218 -0
- package/src/http-sender.ts +187 -0
- package/src/index.ts +48 -12
- package/src/memory-outbox.ts +127 -0
- package/src/outbox.ts +141 -0
- package/src/partition.ts +19 -0
- package/src/retention.test.ts +116 -0
- package/src/retention.ts +144 -0
- package/src/schema.ts +22 -0
- package/src/sql-outbox.test.ts +410 -0
- package/src/sql-outbox.ts +282 -0
- package/src/sys-webhook-delivery.object.ts +202 -0
- package/src/webhook-outbox-plugin.ts +280 -0
- package/tsconfig.json +5 -13
- package/tsup.config.ts +14 -0
- package/dist/index.d.mts +0 -104
- package/dist/index.mjs +0 -216
- package/dist/index.mjs.map +0 -1
- package/src/webhooks-plugin.test.ts +0 -218
- package/src/webhooks-plugin.ts +0 -294
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { Field, ObjectSchema } from '@objectstack/spec/data';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* sys_webhook_delivery — Durable outbox row for one HTTP attempt.
|
|
7
|
+
*
|
|
8
|
+
* Schema is owned by `@objectstack/plugin-webhooks`. Add it to your stack
|
|
9
|
+
* via:
|
|
10
|
+
*
|
|
11
|
+
* import { SysWebhookDelivery } from '@objectstack/plugin-webhooks/schema';
|
|
12
|
+
* defineStack({ objects: [SysWebhookDelivery, ...], plugins: [...] });
|
|
13
|
+
*
|
|
14
|
+
* Designed for the SqlWebhookOutbox claim algorithm:
|
|
15
|
+
*
|
|
16
|
+
* 1. Producers INSERT pending rows (dedup'd by (event_id, webhook_id)).
|
|
17
|
+
* 2. The dispatcher's per-partition lock-holder runs:
|
|
18
|
+
* SELECT id WHERE status='pending' AND partition_key=? AND (next_retry_at <= now OR null)
|
|
19
|
+
* UPDATE SET status='in_flight' WHERE id IN (...) AND status='pending' ← atomic claim
|
|
20
|
+
* POST to target URL
|
|
21
|
+
* UPDATE SET status=success/pending/dead, attempts=attempts+1, ...
|
|
22
|
+
*
|
|
23
|
+
* `partition_key` is precomputed on enqueue (hash(webhook_id) mod N) so the
|
|
24
|
+
* dispatcher can filter cheaply without DB-side hash functions.
|
|
25
|
+
*
|
|
26
|
+
* Indexes are tuned for the hot path: `(status, partition_key, next_retry_at)`
|
|
27
|
+
* is the claim query; `(event_id, webhook_id)` is the dedup uniqueness.
|
|
28
|
+
*
|
|
29
|
+
* @namespace sys
|
|
30
|
+
*/
|
|
31
|
+
export const SysWebhookDelivery = ObjectSchema.create({
|
|
32
|
+
name: 'sys_webhook_delivery',
|
|
33
|
+
label: 'Webhook Delivery',
|
|
34
|
+
pluralLabel: 'Webhook Deliveries',
|
|
35
|
+
icon: 'package',
|
|
36
|
+
isSystem: true,
|
|
37
|
+
managedBy: 'config',
|
|
38
|
+
userActions: { create: false, edit: false, delete: false, import: false },
|
|
39
|
+
description:
|
|
40
|
+
'Durable outbox row for one webhook attempt. Managed by @objectstack/plugin-webhooks; do not write directly.',
|
|
41
|
+
displayNameField: 'id',
|
|
42
|
+
titleFormat: '{event_type} → {url}',
|
|
43
|
+
compactLayout: ['event_type', 'url', 'status', 'attempts', 'next_retry_at'],
|
|
44
|
+
|
|
45
|
+
listViews: {
|
|
46
|
+
recent: {
|
|
47
|
+
type: 'grid',
|
|
48
|
+
name: 'recent',
|
|
49
|
+
label: 'Recent',
|
|
50
|
+
data: { provider: 'object', object: 'sys_webhook_delivery' },
|
|
51
|
+
columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'updated_at'],
|
|
52
|
+
sort: [{ field: 'updated_at', order: 'desc' }],
|
|
53
|
+
pagination: { pageSize: 50 },
|
|
54
|
+
},
|
|
55
|
+
failures: {
|
|
56
|
+
type: 'grid',
|
|
57
|
+
name: 'failures',
|
|
58
|
+
label: 'Failures',
|
|
59
|
+
data: { provider: 'object', object: 'sys_webhook_delivery' },
|
|
60
|
+
columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'error', 'updated_at'],
|
|
61
|
+
filter: [{ field: 'status', operator: 'in', value: ['failed', 'dead'] }],
|
|
62
|
+
sort: [{ field: 'updated_at', order: 'desc' }],
|
|
63
|
+
pagination: { pageSize: 50 },
|
|
64
|
+
},
|
|
65
|
+
in_flight: {
|
|
66
|
+
type: 'grid',
|
|
67
|
+
name: 'in_flight',
|
|
68
|
+
label: 'In Flight',
|
|
69
|
+
data: { provider: 'object', object: 'sys_webhook_delivery' },
|
|
70
|
+
columns: ['event_type', 'url', 'attempts', 'claimed_by', 'claimed_at'],
|
|
71
|
+
filter: [{ field: 'status', operator: 'equals', value: 'in_flight' }],
|
|
72
|
+
sort: [{ field: 'claimed_at', order: 'desc' }],
|
|
73
|
+
pagination: { pageSize: 50 },
|
|
74
|
+
},
|
|
75
|
+
pending: {
|
|
76
|
+
type: 'grid',
|
|
77
|
+
name: 'pending',
|
|
78
|
+
label: 'Pending',
|
|
79
|
+
data: { provider: 'object', object: 'sys_webhook_delivery' },
|
|
80
|
+
columns: ['event_type', 'url', 'attempts', 'next_retry_at', 'updated_at'],
|
|
81
|
+
filter: [{ field: 'status', operator: 'equals', value: 'pending' }],
|
|
82
|
+
sort: [{ field: 'next_retry_at', order: 'asc' }],
|
|
83
|
+
pagination: { pageSize: 50 },
|
|
84
|
+
},
|
|
85
|
+
by_status: {
|
|
86
|
+
type: 'grid',
|
|
87
|
+
name: 'by_status',
|
|
88
|
+
label: 'By Status',
|
|
89
|
+
data: { provider: 'object', object: 'sys_webhook_delivery' },
|
|
90
|
+
columns: ['status', 'event_type', 'url', 'attempts', 'updated_at'],
|
|
91
|
+
sort: [{ field: 'status', order: 'asc' }, { field: 'updated_at', order: 'desc' }],
|
|
92
|
+
grouping: { fields: [{ field: 'status', order: 'asc', collapsed: false }] },
|
|
93
|
+
pagination: { pageSize: 100 },
|
|
94
|
+
},
|
|
95
|
+
by_webhook: {
|
|
96
|
+
type: 'grid',
|
|
97
|
+
name: 'by_webhook',
|
|
98
|
+
label: 'By Webhook',
|
|
99
|
+
data: { provider: 'object', object: 'sys_webhook_delivery' },
|
|
100
|
+
columns: ['webhook_id', 'event_type', 'status', 'attempts', 'updated_at'],
|
|
101
|
+
sort: [{ field: 'webhook_id', order: 'asc' }, { field: 'updated_at', order: 'desc' }],
|
|
102
|
+
grouping: { fields: [{ field: 'webhook_id', order: 'asc', collapsed: true }] },
|
|
103
|
+
pagination: { pageSize: 100 },
|
|
104
|
+
},
|
|
105
|
+
all_deliveries: {
|
|
106
|
+
type: 'grid',
|
|
107
|
+
name: 'all_deliveries',
|
|
108
|
+
label: 'All',
|
|
109
|
+
data: { provider: 'object', object: 'sys_webhook_delivery' },
|
|
110
|
+
columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'updated_at'],
|
|
111
|
+
sort: [{ field: 'updated_at', order: 'desc' }],
|
|
112
|
+
pagination: { pageSize: 100 },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
fields: {
|
|
117
|
+
id: Field.text({
|
|
118
|
+
label: 'Delivery ID',
|
|
119
|
+
required: true,
|
|
120
|
+
maxLength: 64,
|
|
121
|
+
description: 'UUID — also doubles as the receiver-side idempotency key',
|
|
122
|
+
}),
|
|
123
|
+
|
|
124
|
+
webhook_id: Field.text({
|
|
125
|
+
label: 'Webhook ID',
|
|
126
|
+
required: true,
|
|
127
|
+
maxLength: 64,
|
|
128
|
+
description: 'FK to sys_webhook.id (loosely coupled — denormalised URL/secret on row)',
|
|
129
|
+
}),
|
|
130
|
+
|
|
131
|
+
event_id: Field.text({
|
|
132
|
+
label: 'Event ID',
|
|
133
|
+
required: true,
|
|
134
|
+
maxLength: 128,
|
|
135
|
+
description: 'Source event id; UNIQUE(event_id, webhook_id) for dedup',
|
|
136
|
+
}),
|
|
137
|
+
|
|
138
|
+
event_type: Field.text({
|
|
139
|
+
label: 'Event Type',
|
|
140
|
+
required: true,
|
|
141
|
+
maxLength: 128,
|
|
142
|
+
description: 'e.g. data.record.created',
|
|
143
|
+
}),
|
|
144
|
+
|
|
145
|
+
url: Field.text({
|
|
146
|
+
label: 'Target URL',
|
|
147
|
+
required: true,
|
|
148
|
+
maxLength: 2048,
|
|
149
|
+
description: 'Snapshotted at enqueue so config edits do not rewrite live rows',
|
|
150
|
+
}),
|
|
151
|
+
|
|
152
|
+
method: Field.text({ label: 'Method', required: false, maxLength: 10 }),
|
|
153
|
+
headers_json: Field.textarea({ label: 'Headers JSON', required: false }),
|
|
154
|
+
secret: Field.text({ label: 'HMAC Secret', required: false, maxLength: 256 }),
|
|
155
|
+
timeout_ms: Field.number({ label: 'Timeout (ms)', required: false }),
|
|
156
|
+
payload_json: Field.textarea({ label: 'Payload JSON', required: true }),
|
|
157
|
+
|
|
158
|
+
partition_key: Field.number({
|
|
159
|
+
label: 'Partition',
|
|
160
|
+
required: true,
|
|
161
|
+
description: 'hash(webhook_id) mod partitionCount — precomputed for cheap WHERE',
|
|
162
|
+
}),
|
|
163
|
+
|
|
164
|
+
status: Field.text({
|
|
165
|
+
label: 'Status',
|
|
166
|
+
required: true,
|
|
167
|
+
defaultValue: 'pending',
|
|
168
|
+
maxLength: 16,
|
|
169
|
+
description: 'pending | in_flight | success | failed | dead',
|
|
170
|
+
}),
|
|
171
|
+
|
|
172
|
+
attempts: Field.number({
|
|
173
|
+
label: 'Attempts',
|
|
174
|
+
required: true,
|
|
175
|
+
defaultValue: 0,
|
|
176
|
+
description: 'Number of POST attempts made so far',
|
|
177
|
+
}),
|
|
178
|
+
|
|
179
|
+
claimed_by: Field.text({ label: 'Claimed By', required: false, maxLength: 128 }),
|
|
180
|
+
claimed_at: Field.number({ label: 'Claimed At (ms)', required: false }),
|
|
181
|
+
next_retry_at: Field.number({ label: 'Next Retry At (ms)', required: false }),
|
|
182
|
+
last_attempted_at: Field.number({ label: 'Last Attempted At (ms)', required: false }),
|
|
183
|
+
response_code: Field.number({ label: 'HTTP Status', required: false }),
|
|
184
|
+
response_body: Field.textarea({ label: 'Response Body (capped)', required: false }),
|
|
185
|
+
error: Field.textarea({ label: 'Error', required: false }),
|
|
186
|
+
|
|
187
|
+
created_at: Field.number({ label: 'Created At (ms)', required: true }),
|
|
188
|
+
updated_at: Field.number({ label: 'Updated At (ms)', required: true }),
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
indexes: [
|
|
192
|
+
{ fields: ['event_id', 'webhook_id'], unique: true },
|
|
193
|
+
// Hot path: claim query
|
|
194
|
+
{ fields: ['status', 'partition_key', 'next_retry_at'] },
|
|
195
|
+
// Reaper: scan stale in_flight rows by claimed_at
|
|
196
|
+
{ fields: ['status', 'claimed_at'] },
|
|
197
|
+
{ fields: ['webhook_id'] },
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/** Canonical object name — exported so SqlWebhookOutbox callers can override if needed. */
|
|
202
|
+
export const SYS_WEBHOOK_DELIVERY = 'sys_webhook_delivery' as const;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
+
import type {
|
|
5
|
+
IClusterService,
|
|
6
|
+
IDataEngine,
|
|
7
|
+
IRealtimeService,
|
|
8
|
+
} from '@objectstack/spec/contracts';
|
|
9
|
+
import { SysWebhook } from '@objectstack/platform-objects/integration';
|
|
10
|
+
import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';
|
|
11
|
+
import { WebhookDispatcher, type DispatcherOptions } from './dispatcher.js';
|
|
12
|
+
import { MemoryWebhookOutbox } from './memory-outbox.js';
|
|
13
|
+
import type { IWebhookOutbox } from './outbox.js';
|
|
14
|
+
import {
|
|
15
|
+
DeliveryRetentionSweeper,
|
|
16
|
+
type DeliveryRetentionOptions,
|
|
17
|
+
} from './retention.js';
|
|
18
|
+
import { SysWebhookDelivery } from './sys-webhook-delivery.object.js';
|
|
19
|
+
|
|
20
|
+
export interface WebhookOutboxPluginOptions
|
|
21
|
+
extends Partial<Omit<DispatcherOptions, 'cluster' | 'outbox' | 'nodeId'>> {
|
|
22
|
+
/**
|
|
23
|
+
* Override the outbox backend. If omitted a fresh `MemoryWebhookOutbox`
|
|
24
|
+
* is used — fine for local development, **not for production**: each
|
|
25
|
+
* node will see only its own rows.
|
|
26
|
+
*
|
|
27
|
+
* Pass a factory if you need the kernel-resolved `IDataEngine`:
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* outbox: (ctx) => new SqlWebhookOutbox(
|
|
31
|
+
* ctx.getService('objectql'), { partitionCount: 8 },
|
|
32
|
+
* ),
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
outbox?: IWebhookOutbox | ((ctx: PluginContext) => IWebhookOutbox);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Stable node id. If omitted, uses `process.env.OBJECTSTACK_NODE_ID`
|
|
39
|
+
* or a random UUID generated at plugin init.
|
|
40
|
+
*/
|
|
41
|
+
nodeId?: string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* If `false`, the plugin registers the outbox/dispatcher services but
|
|
45
|
+
* does NOT auto-start the loop — useful for tests that want to step
|
|
46
|
+
* the dispatcher manually via `dispatcher.tick()`.
|
|
47
|
+
*
|
|
48
|
+
* Default: true.
|
|
49
|
+
*/
|
|
50
|
+
autoStart?: boolean;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Auto-enqueue config. When enabled (default `true` if the realtime
|
|
54
|
+
* + data engine services are available), the plugin subscribes to
|
|
55
|
+
* `data.record.*` events emitted by the engine and automatically
|
|
56
|
+
* enqueues a delivery row for every matching `sys_webhook` row.
|
|
57
|
+
*
|
|
58
|
+
* Set `false` to disable and only use the imperative
|
|
59
|
+
* `outbox.enqueue()` API.
|
|
60
|
+
*/
|
|
61
|
+
autoEnqueue?: boolean | AutoEnqueuerOptions;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Retention sweep config. When enabled (default `true` if a SQL
|
|
65
|
+
* outbox is in use), a periodic timer prunes old `success` and
|
|
66
|
+
* `dead` rows from `sys_webhook_delivery`.
|
|
67
|
+
*
|
|
68
|
+
* Set `false` to disable (e.g. when using `MemoryWebhookOutbox`).
|
|
69
|
+
*/
|
|
70
|
+
retention?: boolean | DeliveryRetentionOptions;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Wires a persistent, cluster-aware webhook outbox into the kernel.
|
|
75
|
+
*
|
|
76
|
+
* Registered services:
|
|
77
|
+
* - `webhook.outbox` → `IWebhookOutbox` (enqueue / claim / ack / list)
|
|
78
|
+
* - `webhook.dispatcher` → `WebhookDispatcher` (manual `tick()` if needed)
|
|
79
|
+
* - `webhook.autoEnqueuer` → `AutoEnqueuer` when auto-enqueue is on
|
|
80
|
+
* - `webhook.retention` → `DeliveryRetentionSweeper` when retention is on
|
|
81
|
+
*
|
|
82
|
+
* End-to-end flow once auto-enqueue is enabled:
|
|
83
|
+
*
|
|
84
|
+
* engine.insert('contact', {...})
|
|
85
|
+
* → engine publishes data.record.created via IRealtimeService
|
|
86
|
+
* → AutoEnqueuer matches active sys_webhook rows in O(1)
|
|
87
|
+
* → outbox.enqueue() runs fire-and-forget (not on the write path)
|
|
88
|
+
* → dispatcher claims and POSTs (cluster-coordinated)
|
|
89
|
+
*
|
|
90
|
+
* **Cluster requirement** — this plugin depends on the cluster service
|
|
91
|
+
* (`ClusterServicePlugin`). With the default `memory` driver the
|
|
92
|
+
* dispatcher works correctly inside a single process; with a real driver
|
|
93
|
+
* (`@objectstack/service-cluster-redis`) it correctly coordinates work
|
|
94
|
+
* across nodes.
|
|
95
|
+
*/
|
|
96
|
+
export class WebhookOutboxPlugin implements Plugin {
|
|
97
|
+
name = 'com.objectstack.plugin-webhook-outbox';
|
|
98
|
+
version = '1.1.0';
|
|
99
|
+
type = 'standard' as const;
|
|
100
|
+
dependencies = ['com.objectstack.service.cluster'];
|
|
101
|
+
|
|
102
|
+
private dispatcher: WebhookDispatcher | undefined;
|
|
103
|
+
private autoEnqueuer: AutoEnqueuer | undefined;
|
|
104
|
+
private retention: DeliveryRetentionSweeper | undefined;
|
|
105
|
+
private outboxInstance: IWebhookOutbox | undefined;
|
|
106
|
+
|
|
107
|
+
constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}
|
|
108
|
+
|
|
109
|
+
async init(ctx: PluginContext): Promise<void> {
|
|
110
|
+
const cluster = ctx.getService<IClusterService>('cluster');
|
|
111
|
+
if (!cluster) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'WebhookOutboxPlugin: required service "cluster" not found — register ClusterServicePlugin first',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Register the schemas this plugin owns at runtime. `sys_webhook`
|
|
118
|
+
// (config) lives in @objectstack/platform-objects but no other
|
|
119
|
+
// plugin claims it — the webhook plugin is the natural owner
|
|
120
|
+
// since it's the consumer of those rows. `sys_webhook_delivery`
|
|
121
|
+
// (telemetry) is plugin-private. Registering them here means a
|
|
122
|
+
// stack just needs `plugins: [new WebhookOutboxPlugin(...)]`
|
|
123
|
+
// and both objects auto-appear in REST/Studio/Setup nav.
|
|
124
|
+
const manifest = ctx.getService<{ register(m: any): void }>('manifest');
|
|
125
|
+
if (manifest && typeof manifest.register === 'function') {
|
|
126
|
+
manifest.register({
|
|
127
|
+
id: 'com.objectstack.plugin-webhook-outbox.schema',
|
|
128
|
+
namespace: 'sys',
|
|
129
|
+
version: this.version,
|
|
130
|
+
type: 'plugin',
|
|
131
|
+
scope: 'system',
|
|
132
|
+
name: 'Webhook Outbox Schemas',
|
|
133
|
+
description:
|
|
134
|
+
'Registers sys_webhook (configuration) and sys_webhook_delivery (durable outbox telemetry).',
|
|
135
|
+
objects: [SysWebhook, SysWebhookDelivery],
|
|
136
|
+
});
|
|
137
|
+
} else {
|
|
138
|
+
ctx.logger.warn?.(
|
|
139
|
+
'[webhook-outbox] manifest service unavailable — sys_webhook / sys_webhook_delivery will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const outbox = this.resolveOutbox(ctx);
|
|
144
|
+
this.outboxInstance = outbox;
|
|
145
|
+
const nodeId =
|
|
146
|
+
this.options.nodeId ??
|
|
147
|
+
process.env.OBJECTSTACK_NODE_ID ??
|
|
148
|
+
`node-${Math.random().toString(36).slice(2, 10)}`;
|
|
149
|
+
|
|
150
|
+
const dispatcher = new WebhookDispatcher({
|
|
151
|
+
nodeId,
|
|
152
|
+
cluster,
|
|
153
|
+
outbox,
|
|
154
|
+
partitionCount: this.options.partitionCount,
|
|
155
|
+
batchSize: this.options.batchSize,
|
|
156
|
+
intervalMs: this.options.intervalMs,
|
|
157
|
+
lockTtlMs: this.options.lockTtlMs,
|
|
158
|
+
claimTtlMs: this.options.claimTtlMs,
|
|
159
|
+
fetchImpl: this.options.fetchImpl,
|
|
160
|
+
onAttempt: this.options.onAttempt,
|
|
161
|
+
rng: this.options.rng,
|
|
162
|
+
logger: ctx.logger,
|
|
163
|
+
});
|
|
164
|
+
this.dispatcher = dispatcher;
|
|
165
|
+
|
|
166
|
+
ctx.registerService('webhook.outbox', outbox);
|
|
167
|
+
ctx.registerService('webhook.dispatcher', dispatcher);
|
|
168
|
+
|
|
169
|
+
if (this.options.autoStart !== false) {
|
|
170
|
+
dispatcher.start();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Loud warning when running with the in-memory outbox in production —
|
|
174
|
+
// it loses data on restart and never shares rows across nodes.
|
|
175
|
+
const usingMemoryOutbox = outbox instanceof MemoryWebhookOutbox;
|
|
176
|
+
if (usingMemoryOutbox && process.env.NODE_ENV === 'production') {
|
|
177
|
+
ctx.logger.warn?.(
|
|
178
|
+
'[webhook-outbox] MemoryWebhookOutbox in production — webhook deliveries WILL be lost on process exit. Pass `outbox: (ctx) => new SqlWebhookOutbox(ctx.getService("objectql"), { partitionCount: 8 })` from `@objectstack/plugin-webhooks/sql`.',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Auto-enqueue + retention need the kernel to be fully ready
|
|
183
|
+
// before ObjectQL / Realtime services are resolvable.
|
|
184
|
+
const autoEnqueueOpt = this.options.autoEnqueue ?? true;
|
|
185
|
+
const retentionOpt = this.options.retention ?? true;
|
|
186
|
+
|
|
187
|
+
const needsReadyHook = autoEnqueueOpt !== false || retentionOpt !== false;
|
|
188
|
+
if (needsReadyHook && typeof (ctx as any).hook === 'function') {
|
|
189
|
+
(ctx as any).hook('kernel:ready', async () => {
|
|
190
|
+
await this.bootAutoEnqueue(ctx, autoEnqueueOpt);
|
|
191
|
+
this.bootRetention(ctx, retentionOpt);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
ctx.logger.info?.('[webhook-outbox] initialised', {
|
|
196
|
+
nodeId,
|
|
197
|
+
partitions: this.options.partitionCount ?? 8,
|
|
198
|
+
interval: this.options.intervalMs ?? 250,
|
|
199
|
+
autoEnqueue: autoEnqueueOpt !== false,
|
|
200
|
+
retention: retentionOpt !== false,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async dispose(): Promise<void> {
|
|
205
|
+
await this.autoEnqueuer?.stop();
|
|
206
|
+
this.retention?.stop();
|
|
207
|
+
await this.dispatcher?.stop();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private resolveOutbox(ctx: PluginContext): IWebhookOutbox {
|
|
211
|
+
const opt = this.options.outbox;
|
|
212
|
+
if (!opt) return new MemoryWebhookOutbox();
|
|
213
|
+
if (typeof opt === 'function') return (opt as (c: PluginContext) => IWebhookOutbox)(ctx);
|
|
214
|
+
return opt;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private async bootAutoEnqueue(
|
|
218
|
+
ctx: PluginContext,
|
|
219
|
+
opt: boolean | AutoEnqueuerOptions,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
if (opt === false) return;
|
|
222
|
+
const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
|
|
223
|
+
const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);
|
|
224
|
+
if (!engine || !realtime) {
|
|
225
|
+
ctx.logger.warn?.(
|
|
226
|
+
'[webhook-auto-enqueuer] disabled — ObjectQL or Realtime service not available',
|
|
227
|
+
{ hasEngine: !!engine, hasRealtime: !!realtime },
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!this.outboxInstance) return;
|
|
232
|
+
|
|
233
|
+
const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;
|
|
234
|
+
this.autoEnqueuer = new AutoEnqueuer(
|
|
235
|
+
engine,
|
|
236
|
+
realtime,
|
|
237
|
+
this.outboxInstance,
|
|
238
|
+
{ ...enqOpts, logger: ctx.logger },
|
|
239
|
+
);
|
|
240
|
+
await this.autoEnqueuer.start();
|
|
241
|
+
ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);
|
|
242
|
+
ctx.logger.info?.('[webhook-auto-enqueuer] started');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private bootRetention(
|
|
246
|
+
ctx: PluginContext,
|
|
247
|
+
opt: boolean | DeliveryRetentionOptions,
|
|
248
|
+
): void {
|
|
249
|
+
if (opt === false) return;
|
|
250
|
+
// Only meaningful for SQL outbox — Memory has its own (process-lifetime) GC.
|
|
251
|
+
if (this.outboxInstance instanceof MemoryWebhookOutbox) return;
|
|
252
|
+
const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
|
|
253
|
+
if (!engine) {
|
|
254
|
+
ctx.logger.warn?.(
|
|
255
|
+
'[webhook-retention] disabled — ObjectQL service not available',
|
|
256
|
+
);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const retOpts = (typeof opt === 'object' ? opt : {}) as DeliveryRetentionOptions;
|
|
260
|
+
this.retention = new DeliveryRetentionSweeper(engine, {
|
|
261
|
+
...retOpts,
|
|
262
|
+
logger: ctx.logger,
|
|
263
|
+
});
|
|
264
|
+
this.retention.start();
|
|
265
|
+
ctx.registerService('webhook.retention', this.retention);
|
|
266
|
+
ctx.logger.info?.('[webhook-retention] sweeper started');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {
|
|
270
|
+
for (const n of names) {
|
|
271
|
+
try {
|
|
272
|
+
const svc = ctx.getService<T>(n);
|
|
273
|
+
if (svc) return svc;
|
|
274
|
+
} catch {
|
|
275
|
+
// fall through
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"extends": "../../../tsconfig.json",
|
|
3
3
|
"compilerOptions": {
|
|
4
|
-
"outDir": "
|
|
5
|
-
"rootDir": "
|
|
6
|
-
"types": [
|
|
7
|
-
"node"
|
|
8
|
-
]
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"rootDir": "src",
|
|
6
|
+
"types": ["node"]
|
|
9
7
|
},
|
|
10
|
-
"include": [
|
|
11
|
-
|
|
12
|
-
],
|
|
13
|
-
"exclude": [
|
|
14
|
-
"dist",
|
|
15
|
-
"node_modules",
|
|
16
|
-
"**/*.test.ts"
|
|
17
|
-
]
|
|
8
|
+
"include": ["src"],
|
|
9
|
+
"exclude": ["node_modules", "dist"]
|
|
18
10
|
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { defineConfig } from 'tsup';
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
entry: ['src/index.ts', 'src/sql-outbox.ts', 'src/schema.ts'],
|
|
7
|
+
splitting: true,
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
clean: true,
|
|
10
|
+
dts: true,
|
|
11
|
+
format: ['esm', 'cjs'],
|
|
12
|
+
target: 'es2020',
|
|
13
|
+
external: ['vitest'],
|
|
14
|
+
});
|
package/dist/index.d.mts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { Plugin, PluginContext } from '@objectstack/core';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A single webhook delivery target.
|
|
5
|
-
*/
|
|
6
|
-
interface WebhookSink {
|
|
7
|
-
/** Unique sink id used for log correlation. */
|
|
8
|
-
id: string;
|
|
9
|
-
/** Target HTTPS URL. */
|
|
10
|
-
url: string;
|
|
11
|
-
/** Optional HMAC-SHA256 secret. When set, `X-Objectstack-Signature: sha256=…` is added. */
|
|
12
|
-
secret?: string;
|
|
13
|
-
/**
|
|
14
|
-
* Restrict to specific object names (logical names, e.g. `lead`, `account`).
|
|
15
|
-
* Omit / empty → all objects.
|
|
16
|
-
*/
|
|
17
|
-
objects?: string[];
|
|
18
|
-
/**
|
|
19
|
-
* Restrict to specific event types. Omit / empty → all `data.record.*` events.
|
|
20
|
-
*/
|
|
21
|
-
eventTypes?: string[];
|
|
22
|
-
/** Extra headers to send (Authorization, Tenant, etc.). */
|
|
23
|
-
headers?: Record<string, string>;
|
|
24
|
-
/** Per-request timeout in milliseconds. Default 5000. */
|
|
25
|
-
timeoutMs?: number;
|
|
26
|
-
/** Retry attempts on transient failure. Default 3. Set 0 to disable retries. */
|
|
27
|
-
retries?: number;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Delivery attempt outcome surfaced to in-process listeners / tests.
|
|
31
|
-
*/
|
|
32
|
-
type WebhookDeliveryStatus = 'ok' | 'retrying' | 'failed';
|
|
33
|
-
interface WebhookDeliveryRecord {
|
|
34
|
-
sinkId: string;
|
|
35
|
-
url: string;
|
|
36
|
-
eventType: string;
|
|
37
|
-
object?: string;
|
|
38
|
-
status: WebhookDeliveryStatus;
|
|
39
|
-
httpStatus?: number;
|
|
40
|
-
attempt: number;
|
|
41
|
-
error?: string;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Plugin configuration.
|
|
45
|
-
*
|
|
46
|
-
* Sinks may be supplied programmatically OR via env vars when none are
|
|
47
|
-
* passed (suitable for 12-factor / Docker deployments):
|
|
48
|
-
*
|
|
49
|
-
* OBJECTSTACK_WEBHOOK_URL — single URL, or comma-separated URLs.
|
|
50
|
-
* OBJECTSTACK_WEBHOOK_SECRET — HMAC secret applied to all env-sourced URLs.
|
|
51
|
-
* OBJECTSTACK_WEBHOOK_OBJECTS — comma-separated object whitelist.
|
|
52
|
-
* OBJECTSTACK_WEBHOOK_EVENTS — comma-separated event-type whitelist
|
|
53
|
-
* (e.g. `data.record.created`).
|
|
54
|
-
*/
|
|
55
|
-
interface WebhooksPluginOptions {
|
|
56
|
-
/** Explicit sink list (takes precedence over env vars). */
|
|
57
|
-
sinks?: WebhookSink[];
|
|
58
|
-
/** Override fetch (mainly for tests). Defaults to globalThis.fetch. */
|
|
59
|
-
fetchImpl?: typeof fetch;
|
|
60
|
-
/** Hook invoked with each delivery outcome (mainly for tests / metrics). */
|
|
61
|
-
onDelivery?: (record: WebhookDeliveryRecord) => void;
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* WebhooksPlugin — fan out data.record.* events to external HTTP endpoints.
|
|
65
|
-
*
|
|
66
|
-
* @example
|
|
67
|
-
* ```ts
|
|
68
|
-
* kernel.use(new WebhooksPlugin({
|
|
69
|
-
* sinks: [
|
|
70
|
-
* { id: 'crm-sync', url: 'https://hooks.example.com/in',
|
|
71
|
-
* secret: process.env.HOOK_SECRET, objects: ['lead', 'account'] },
|
|
72
|
-
* ],
|
|
73
|
-
* }));
|
|
74
|
-
* ```
|
|
75
|
-
*/
|
|
76
|
-
declare class WebhooksPlugin implements Plugin {
|
|
77
|
-
name: string;
|
|
78
|
-
version: string;
|
|
79
|
-
type: string;
|
|
80
|
-
dependencies: string[];
|
|
81
|
-
private readonly options;
|
|
82
|
-
private subscriptionIds;
|
|
83
|
-
private realtime?;
|
|
84
|
-
private sinks;
|
|
85
|
-
private logger?;
|
|
86
|
-
constructor(options?: WebhooksPluginOptions);
|
|
87
|
-
init(ctx: PluginContext): Promise<void>;
|
|
88
|
-
start(ctx: PluginContext): Promise<void>;
|
|
89
|
-
stop(ctx: PluginContext): Promise<void>;
|
|
90
|
-
/**
|
|
91
|
-
* Resolve sinks from constructor options, falling back to env vars when
|
|
92
|
-
* none provided. Exposed for testing.
|
|
93
|
-
*/
|
|
94
|
-
private resolveSinks;
|
|
95
|
-
/**
|
|
96
|
-
* Dispatch a single event to a sink, with HMAC signing, timeout, and
|
|
97
|
-
* exponential-backoff retry. Failures past the retry budget are logged
|
|
98
|
-
* but never thrown — webhook delivery must never break the originating
|
|
99
|
-
* mutation.
|
|
100
|
-
*/
|
|
101
|
-
private dispatch;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export { type WebhookDeliveryRecord, type WebhookDeliveryStatus, type WebhookSink, WebhooksPlugin, type WebhooksPluginOptions };
|