@objectstack/plugin-webhooks 7.5.0 → 7.7.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 +20 -32
- package/CHANGELOG.md +60 -0
- package/dist/chunk-HWFTXTTI.js +138 -0
- package/dist/chunk-HWFTXTTI.js.map +1 -0
- package/dist/chunk-KPKLAXNA.cjs +138 -0
- package/dist/chunk-KPKLAXNA.cjs.map +1 -0
- package/dist/index.cjs +62 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -325
- package/dist/index.d.ts +41 -325
- package/dist/index.js +52 -606
- package/dist/index.js.map +1 -1
- package/dist/schema.cjs +2 -6
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +5 -4764
- package/dist/schema.d.ts +5 -4764
- package/dist/schema.js +3 -7
- package/package.json +4 -11
- package/src/auto-enqueuer.test.ts +83 -116
- package/src/auto-enqueuer.ts +38 -27
- package/src/index.ts +13 -40
- package/src/schema.ts +11 -16
- package/src/webhook-outbox-plugin.ts +80 -296
- package/tsup.config.ts +1 -1
- package/dist/chunk-7HS5DLU2.js +0 -319
- package/dist/chunk-7HS5DLU2.js.map +0 -1
- package/dist/chunk-HF7CCDPB.cjs +0 -256
- package/dist/chunk-HF7CCDPB.cjs.map +0 -1
- package/dist/chunk-KNGLLSSP.js +0 -256
- package/dist/chunk-KNGLLSSP.js.map +0 -1
- package/dist/chunk-TDSI7UHY.cjs +0 -319
- package/dist/chunk-TDSI7UHY.cjs.map +0 -1
- package/dist/outbox-CIn7LSyB.d.cts +0 -155
- package/dist/outbox-CIn7LSyB.d.ts +0 -155
- package/dist/sql-outbox.cjs +0 -8
- package/dist/sql-outbox.cjs.map +0 -1
- package/dist/sql-outbox.d.cts +0 -55
- package/dist/sql-outbox.d.ts +0 -55
- package/dist/sql-outbox.js +0 -8
- package/dist/sql-outbox.js.map +0 -1
- package/src/dispatcher.test.ts +0 -324
- package/src/dispatcher.ts +0 -218
- package/src/http-sender.ts +0 -187
- package/src/memory-outbox.test.ts +0 -86
- package/src/memory-outbox.ts +0 -155
- package/src/outbox.ts +0 -175
- package/src/partition.ts +0 -19
- package/src/retention.test.ts +0 -116
- package/src/retention.ts +0 -144
- package/src/sql-outbox.test.ts +0 -490
- package/src/sql-outbox.ts +0 -343
- package/src/sys-webhook-delivery.object.ts +0 -224
|
@@ -1,129 +1,74 @@
|
|
|
1
1
|
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
3
|
import type { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
-
import {
|
|
5
|
-
import type {
|
|
6
|
-
IClusterService,
|
|
7
|
-
IDataEngine,
|
|
8
|
-
IRealtimeService,
|
|
9
|
-
} from '@objectstack/spec/contracts';
|
|
4
|
+
import type { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts';
|
|
5
|
+
import type { EnqueueHttpInput } from '@objectstack/service-messaging';
|
|
10
6
|
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 { SqlWebhookOutbox } from './sql-outbox.js';
|
|
19
7
|
import { SysWebhook } from './sys-webhook.object.js';
|
|
20
|
-
import { SysWebhookDelivery } from './sys-webhook-delivery.object.js';
|
|
21
8
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
* ctx.getService('objectql'), { partitionCount: 8 },
|
|
34
|
-
* ),
|
|
35
|
-
* ```
|
|
36
|
-
*/
|
|
37
|
-
outbox?: IWebhookOutbox | ((ctx: PluginContext) => IWebhookOutbox);
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Stable node id. If omitted, uses `process.env.OS_NODE_ID`
|
|
41
|
-
* (legacy `OBJECTSTACK_NODE_ID` still honoured with deprecation warning)
|
|
42
|
-
* or a random UUID generated at plugin init.
|
|
43
|
-
*/
|
|
44
|
-
nodeId?: string;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* If `false`, the plugin registers the outbox/dispatcher services but
|
|
48
|
-
* does NOT auto-start the loop — useful for tests that want to step
|
|
49
|
-
* the dispatcher manually via `dispatcher.tick()`.
|
|
50
|
-
*
|
|
51
|
-
* Default: true.
|
|
52
|
-
*/
|
|
53
|
-
autoStart?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Structural view of `@objectstack/service-messaging`'s HTTP-outbox surface
|
|
11
|
+
* (ADR-0018 M3) — declared locally so this plugin doesn't take a hard runtime
|
|
12
|
+
* import on the service. Webhook deliveries are enqueued onto the shared
|
|
13
|
+
* `sys_http_delivery` outbox and drained by the messaging `HttpDispatcher`.
|
|
14
|
+
*/
|
|
15
|
+
interface MessagingHttpSurface {
|
|
16
|
+
isHttpDeliveryReady(): boolean;
|
|
17
|
+
enqueueHttp(input: EnqueueHttpInput): Promise<string>;
|
|
18
|
+
redeliverHttp(id: string): Promise<{ id: string; status: string }>;
|
|
19
|
+
}
|
|
54
20
|
|
|
21
|
+
export interface WebhookOutboxPluginOptions {
|
|
55
22
|
/**
|
|
56
|
-
* Auto-enqueue config. When enabled (default `true` if the realtime
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
23
|
+
* Auto-enqueue config. When enabled (default `true` if the realtime + data
|
|
24
|
+
* engine services are available), the plugin subscribes to `data.record.*`
|
|
25
|
+
* events and enqueues a delivery onto the shared messaging HTTP outbox for
|
|
26
|
+
* every matching `sys_webhook` row.
|
|
60
27
|
*
|
|
61
|
-
* Set `false` to disable and
|
|
62
|
-
* `outbox.enqueue()` API.
|
|
28
|
+
* Set `false` to disable and enqueue webhooks imperatively elsewhere.
|
|
63
29
|
*/
|
|
64
30
|
autoEnqueue?: boolean | AutoEnqueuerOptions;
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Retention sweep config. When enabled (default `true` if a SQL
|
|
68
|
-
* outbox is in use), a periodic timer prunes old `success` and
|
|
69
|
-
* `dead` rows from `sys_webhook_delivery`.
|
|
70
|
-
*
|
|
71
|
-
* Set `false` to disable (e.g. when using `MemoryWebhookOutbox`).
|
|
72
|
-
*/
|
|
73
|
-
retention?: boolean | DeliveryRetentionOptions;
|
|
74
31
|
}
|
|
75
32
|
|
|
76
33
|
/**
|
|
77
|
-
* Wires
|
|
34
|
+
* Wires webhook fan-out on top of the shared outbound-HTTP delivery substrate
|
|
35
|
+
* (ADR-0018 M3).
|
|
78
36
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
37
|
+
* Webhooks are no longer their own delivery engine: the durable outbox, the
|
|
38
|
+
* cluster-coordinated dispatcher, the retry/backoff/dead-letter schedule, and
|
|
39
|
+
* the retention sweep all live in `@objectstack/service-messaging`
|
|
40
|
+
* (`sys_http_delivery` + `HttpDispatcher`). This plugin owns only the
|
|
41
|
+
* webhook-specific concerns:
|
|
42
|
+
* - the `sys_webhook` configuration object,
|
|
43
|
+
* - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox
|
|
44
|
+
* rows (`source: 'webhook'`), and
|
|
45
|
+
* - the redeliver admin endpoint.
|
|
84
46
|
*
|
|
85
|
-
* End-to-end flow
|
|
47
|
+
* End-to-end flow:
|
|
86
48
|
*
|
|
87
49
|
* engine.insert('contact', {...})
|
|
88
50
|
* → engine publishes data.record.created via IRealtimeService
|
|
89
51
|
* → AutoEnqueuer matches active sys_webhook rows in O(1)
|
|
90
|
-
* →
|
|
91
|
-
* →
|
|
52
|
+
* → messaging.enqueueHttp() runs fire-and-forget (off the write path)
|
|
53
|
+
* → messaging HttpDispatcher claims and POSTs (cluster-coordinated, retried)
|
|
92
54
|
*
|
|
93
|
-
* **
|
|
94
|
-
*
|
|
95
|
-
* dispatcher works correctly inside a single process; with a real driver
|
|
96
|
-
* (`@objectstack/service-cluster-redis`) it correctly coordinates work
|
|
97
|
-
* across nodes.
|
|
55
|
+
* **Requires** `MessagingServicePlugin` (`@objectstack/service-messaging`),
|
|
56
|
+
* which is a foundational, always-on capability.
|
|
98
57
|
*/
|
|
99
58
|
export class WebhookOutboxPlugin implements Plugin {
|
|
100
59
|
name = 'com.objectstack.plugin-webhook-outbox';
|
|
101
|
-
version = '
|
|
60
|
+
version = '2.0.0';
|
|
102
61
|
type = 'standard' as const;
|
|
103
|
-
dependencies = ['com.objectstack.service.
|
|
62
|
+
dependencies = ['com.objectstack.service.messaging'];
|
|
104
63
|
|
|
105
|
-
private dispatcher: WebhookDispatcher | undefined;
|
|
106
64
|
private autoEnqueuer: AutoEnqueuer | undefined;
|
|
107
|
-
private retention: DeliveryRetentionSweeper | undefined;
|
|
108
|
-
private outboxInstance: IWebhookOutbox | undefined;
|
|
109
65
|
|
|
110
66
|
constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}
|
|
111
67
|
|
|
112
68
|
async init(ctx: PluginContext): Promise<void> {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
'WebhookOutboxPlugin: required service "cluster" not found — register ClusterServicePlugin first',
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Register the schemas this plugin owns at runtime (ADR-0029 K2.a).
|
|
121
|
-
// Both `sys_webhook` (config) and `sys_webhook_delivery` (telemetry)
|
|
122
|
-
// are now defined and owned here — the webhook plugin ships its data
|
|
123
|
-
// model and behavior as one unit instead of importing `sys_webhook`
|
|
124
|
-
// from the @objectstack/platform-objects monolith. Registering them
|
|
125
|
-
// here means a stack just needs `plugins: [new WebhookOutboxPlugin(...)]`
|
|
126
|
-
// and both objects auto-appear in REST/Studio/Setup nav.
|
|
69
|
+
// Register the webhook config object (ADR-0029 K2.a). The delivery
|
|
70
|
+
// telemetry now lives in messaging's `sys_http_delivery`, so the nav's
|
|
71
|
+
// "Deliveries" entry points there (filtered to source=webhook in views).
|
|
127
72
|
const manifest = ctx.getService<{ register(m: any): void }>('manifest');
|
|
128
73
|
if (manifest && typeof manifest.register === 'function') {
|
|
129
74
|
manifest.register({
|
|
@@ -132,14 +77,9 @@ export class WebhookOutboxPlugin implements Plugin {
|
|
|
132
77
|
version: this.version,
|
|
133
78
|
type: 'plugin',
|
|
134
79
|
scope: 'system',
|
|
135
|
-
name: 'Webhook
|
|
136
|
-
description:
|
|
137
|
-
|
|
138
|
-
objects: [SysWebhook, SysWebhookDelivery],
|
|
139
|
-
// ADR-0029 D7 — contribute the Webhooks entries into the
|
|
140
|
-
// Setup app's `group_integrations` slot. The plugin owns these
|
|
141
|
-
// objects (K2.a), so it ships their menu too; when the plugin
|
|
142
|
-
// isn't installed the slot stays empty.
|
|
80
|
+
name: 'Webhook Schemas',
|
|
81
|
+
description: 'Registers sys_webhook (configuration). Deliveries use messaging\'s sys_http_delivery outbox.',
|
|
82
|
+
objects: [SysWebhook],
|
|
143
83
|
navigationContributions: [
|
|
144
84
|
{
|
|
145
85
|
app: 'setup',
|
|
@@ -147,19 +87,18 @@ export class WebhookOutboxPlugin implements Plugin {
|
|
|
147
87
|
priority: 100,
|
|
148
88
|
items: [
|
|
149
89
|
{ id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', icon: 'webhook', requiresObject: 'sys_webhook' },
|
|
150
|
-
{ id: '
|
|
90
|
+
{ id: 'nav_http_deliveries', type: 'object', label: 'HTTP Deliveries', objectName: 'sys_http_delivery', icon: 'send', requiresObject: 'sys_http_delivery' },
|
|
151
91
|
],
|
|
152
92
|
},
|
|
153
93
|
],
|
|
154
94
|
});
|
|
155
95
|
} else {
|
|
156
96
|
ctx.logger.warn?.(
|
|
157
|
-
'[webhook-outbox] manifest service unavailable — sys_webhook
|
|
97
|
+
'[webhook-outbox] manifest service unavailable — sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',
|
|
158
98
|
);
|
|
159
99
|
}
|
|
160
100
|
|
|
161
|
-
// ADR-0029 D8 — contribute
|
|
162
|
-
// i18n service on kernel:ready (the i18n plugin may register later).
|
|
101
|
+
// ADR-0029 D8 — contribute object translations once i18n is up.
|
|
163
102
|
if (typeof (ctx as any).hook === 'function') {
|
|
164
103
|
(ctx as any).hook('kernel:ready', async () => {
|
|
165
104
|
try {
|
|
@@ -174,117 +113,27 @@ export class WebhookOutboxPlugin implements Plugin {
|
|
|
174
113
|
});
|
|
175
114
|
}
|
|
176
115
|
|
|
177
|
-
const outbox = this.resolveOutbox(ctx);
|
|
178
|
-
this.outboxInstance = outbox;
|
|
179
|
-
const nodeId =
|
|
180
|
-
this.options.nodeId ??
|
|
181
|
-
readEnvWithDeprecation('OS_NODE_ID', 'OBJECTSTACK_NODE_ID') ??
|
|
182
|
-
`node-${Math.random().toString(36).slice(2, 10)}`;
|
|
183
|
-
|
|
184
|
-
const dispatcher = new WebhookDispatcher({
|
|
185
|
-
nodeId,
|
|
186
|
-
cluster,
|
|
187
|
-
outbox,
|
|
188
|
-
partitionCount: this.options.partitionCount,
|
|
189
|
-
batchSize: this.options.batchSize,
|
|
190
|
-
intervalMs: this.options.intervalMs,
|
|
191
|
-
lockTtlMs: this.options.lockTtlMs,
|
|
192
|
-
claimTtlMs: this.options.claimTtlMs,
|
|
193
|
-
fetchImpl: this.options.fetchImpl,
|
|
194
|
-
onAttempt: this.options.onAttempt,
|
|
195
|
-
rng: this.options.rng,
|
|
196
|
-
logger: ctx.logger,
|
|
197
|
-
});
|
|
198
|
-
this.dispatcher = dispatcher;
|
|
199
|
-
|
|
200
|
-
ctx.registerService('webhook.outbox', outbox);
|
|
201
|
-
ctx.registerService('webhook.dispatcher', dispatcher);
|
|
202
|
-
|
|
203
|
-
if (this.options.autoStart !== false) {
|
|
204
|
-
dispatcher.start();
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Loud warning when running with the in-memory outbox in production —
|
|
208
|
-
// it loses data on restart and never shares rows across nodes. With
|
|
209
|
-
// the auto-pick logic above this only fires when no IDataEngine is
|
|
210
|
-
// available, but flag it loudly anyway.
|
|
211
|
-
const usingMemoryOutbox = outbox instanceof MemoryWebhookOutbox;
|
|
212
|
-
if (usingMemoryOutbox && process.env.NODE_ENV === 'production') {
|
|
213
|
-
ctx.logger.warn?.(
|
|
214
|
-
'[webhook-outbox] MemoryWebhookOutbox in production — webhook deliveries WILL be lost on process exit. Ensure ObjectQL is registered before WebhookOutboxPlugin so the SQL outbox can auto-select.',
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Auto-enqueue + retention need the kernel to be fully ready
|
|
219
|
-
// before ObjectQL / Realtime services are resolvable.
|
|
220
116
|
const autoEnqueueOpt = this.options.autoEnqueue ?? true;
|
|
221
|
-
const retentionOpt = this.options.retention ?? true;
|
|
222
117
|
|
|
223
|
-
|
|
224
|
-
if (needsReadyHook && typeof (ctx as any).hook === 'function') {
|
|
118
|
+
if (typeof (ctx as any).hook === 'function') {
|
|
225
119
|
(ctx as any).hook('kernel:ready', async () => {
|
|
226
120
|
await this.bootAutoEnqueue(ctx, autoEnqueueOpt);
|
|
227
|
-
this.bootRetention(ctx, retentionOpt);
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Admin REST endpoint — POST /api/v1/webhooks/redeliver { deliveryId }.
|
|
232
|
-
// Wired in `kernel:ready` so the auth + http services are guaranteed
|
|
233
|
-
// resolvable. Gated on a session cookie so anonymous callers cannot
|
|
234
|
-
// replay deliveries; finer-grained RBAC (e.g. "only admins") can be
|
|
235
|
-
// layered on later — for now any signed-in user with access to the
|
|
236
|
-
// Setup app can redeliver. The action is also `disabled`-gated by
|
|
237
|
-
// status on the Studio side so the button only lights up on
|
|
238
|
-
// success / failed / dead rows.
|
|
239
|
-
if (typeof (ctx as any).hook === 'function') {
|
|
240
|
-
(ctx as any).hook('kernel:ready', () => {
|
|
241
121
|
this.registerAdminRoutes(ctx);
|
|
242
122
|
});
|
|
243
123
|
}
|
|
244
124
|
|
|
245
|
-
ctx.logger.info?.('[webhook-outbox] initialised', {
|
|
246
|
-
nodeId,
|
|
247
|
-
partitions: this.options.partitionCount ?? 8,
|
|
248
|
-
interval: this.options.intervalMs ?? 250,
|
|
125
|
+
ctx.logger.info?.('[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)', {
|
|
249
126
|
autoEnqueue: autoEnqueueOpt !== false,
|
|
250
|
-
retention: retentionOpt !== false,
|
|
251
127
|
});
|
|
252
128
|
}
|
|
253
129
|
|
|
254
130
|
async dispose(): Promise<void> {
|
|
255
131
|
await this.autoEnqueuer?.stop();
|
|
256
|
-
this.retention?.stop();
|
|
257
|
-
await this.dispatcher?.stop();
|
|
258
132
|
}
|
|
259
133
|
|
|
260
|
-
private
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
return typeof opt === 'function'
|
|
264
|
-
? (opt as (c: PluginContext) => IWebhookOutbox)(ctx)
|
|
265
|
-
: opt;
|
|
266
|
-
}
|
|
267
|
-
// No explicit override — auto-pick the right backend for the host.
|
|
268
|
-
// SqlWebhookOutbox needs an `IDataEngine`; if one is resolvable
|
|
269
|
-
// (the usual case in CLI-served stacks), use it so durable rows
|
|
270
|
-
// in `sys_webhook_delivery` actually round-trip through the
|
|
271
|
-
// dispatcher and the redeliver REST endpoint. Memory is only a
|
|
272
|
-
// last-resort fallback for tests / edge environments without an
|
|
273
|
-
// engine.
|
|
274
|
-
const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
|
|
275
|
-
if (engine) {
|
|
276
|
-
const partitionCount = this.options.partitionCount ?? 8;
|
|
277
|
-
const sql = new SqlWebhookOutbox(engine, { partitionCount });
|
|
278
|
-
ctx.logger.info?.(
|
|
279
|
-
'[webhook-outbox] auto-selected SqlWebhookOutbox (objectql engine detected)',
|
|
280
|
-
{ partitionCount },
|
|
281
|
-
);
|
|
282
|
-
return sql;
|
|
283
|
-
}
|
|
284
|
-
ctx.logger.warn?.(
|
|
285
|
-
'[webhook-outbox] no IDataEngine available — falling back to MemoryWebhookOutbox. Deliveries will NOT survive process restart and the redeliver REST endpoint will not see rows written through ObjectQL.',
|
|
286
|
-
);
|
|
287
|
-
return new MemoryWebhookOutbox();
|
|
134
|
+
private getMessaging(ctx: PluginContext): MessagingHttpSurface | undefined {
|
|
135
|
+
const svc = this.tryGetService<MessagingHttpSurface>(ctx, ['messaging']);
|
|
136
|
+
return svc && typeof svc.enqueueHttp === 'function' ? svc : undefined;
|
|
288
137
|
}
|
|
289
138
|
|
|
290
139
|
private async bootAutoEnqueue(
|
|
@@ -294,49 +143,30 @@ export class WebhookOutboxPlugin implements Plugin {
|
|
|
294
143
|
if (opt === false) return;
|
|
295
144
|
const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
|
|
296
145
|
const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);
|
|
297
|
-
|
|
146
|
+
const messaging = this.getMessaging(ctx);
|
|
147
|
+
if (!engine || !realtime || !messaging) {
|
|
298
148
|
ctx.logger.warn?.(
|
|
299
|
-
'[webhook-auto-enqueuer] disabled — ObjectQL or
|
|
300
|
-
{ hasEngine: !!engine, hasRealtime: !!realtime },
|
|
149
|
+
'[webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available',
|
|
150
|
+
{ hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging },
|
|
301
151
|
);
|
|
302
152
|
return;
|
|
303
153
|
}
|
|
304
|
-
if (!
|
|
154
|
+
if (!messaging.isHttpDeliveryReady()) {
|
|
155
|
+
ctx.logger.warn?.(
|
|
156
|
+
'[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) — webhook deliveries will not be durable',
|
|
157
|
+
);
|
|
158
|
+
}
|
|
305
159
|
|
|
306
160
|
const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;
|
|
307
161
|
this.autoEnqueuer = new AutoEnqueuer(
|
|
308
162
|
engine,
|
|
309
163
|
realtime,
|
|
310
|
-
|
|
164
|
+
(input) => messaging.enqueueHttp(input),
|
|
311
165
|
{ ...enqOpts, logger: ctx.logger },
|
|
312
166
|
);
|
|
313
167
|
await this.autoEnqueuer.start();
|
|
314
168
|
ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);
|
|
315
|
-
ctx.logger.info?.('[webhook-auto-enqueuer] started');
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
private bootRetention(
|
|
319
|
-
ctx: PluginContext,
|
|
320
|
-
opt: boolean | DeliveryRetentionOptions,
|
|
321
|
-
): void {
|
|
322
|
-
if (opt === false) return;
|
|
323
|
-
// Only meaningful for SQL outbox — Memory has its own (process-lifetime) GC.
|
|
324
|
-
if (this.outboxInstance instanceof MemoryWebhookOutbox) return;
|
|
325
|
-
const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
|
|
326
|
-
if (!engine) {
|
|
327
|
-
ctx.logger.warn?.(
|
|
328
|
-
'[webhook-retention] disabled — ObjectQL service not available',
|
|
329
|
-
);
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
const retOpts = (typeof opt === 'object' ? opt : {}) as DeliveryRetentionOptions;
|
|
333
|
-
this.retention = new DeliveryRetentionSweeper(engine, {
|
|
334
|
-
...retOpts,
|
|
335
|
-
logger: ctx.logger,
|
|
336
|
-
});
|
|
337
|
-
this.retention.start();
|
|
338
|
-
ctx.registerService('webhook.retention', this.retention);
|
|
339
|
-
ctx.logger.info?.('[webhook-retention] sweeper started');
|
|
169
|
+
ctx.logger.info?.('[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)');
|
|
340
170
|
}
|
|
341
171
|
|
|
342
172
|
private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {
|
|
@@ -352,33 +182,25 @@ export class WebhookOutboxPlugin implements Plugin {
|
|
|
352
182
|
}
|
|
353
183
|
|
|
354
184
|
/**
|
|
355
|
-
* Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
* the better-auth session cookie — every authenticated user counts.
|
|
185
|
+
* Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is
|
|
186
|
+
* available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the
|
|
187
|
+
* better-auth session cookie — every authenticated user counts.
|
|
359
188
|
*/
|
|
360
189
|
private registerAdminRoutes(ctx: PluginContext): void {
|
|
361
190
|
const http = this.tryGetService<any>(ctx, ['http-server']);
|
|
362
191
|
if (!http || typeof http.getRawApp !== 'function') {
|
|
363
|
-
ctx.logger.debug?.(
|
|
364
|
-
'[webhook-outbox] HTTP server not available; redeliver REST endpoint not mounted',
|
|
365
|
-
);
|
|
192
|
+
ctx.logger.debug?.('[webhook-outbox] HTTP server not available; redeliver endpoint not mounted');
|
|
366
193
|
return;
|
|
367
194
|
}
|
|
368
195
|
const rawApp = http.getRawApp();
|
|
369
|
-
const
|
|
370
|
-
if (!rawApp || !
|
|
196
|
+
const messaging = this.getMessaging(ctx);
|
|
197
|
+
if (!rawApp || !messaging) return;
|
|
371
198
|
|
|
372
199
|
rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {
|
|
373
|
-
// Auth gate — require a signed-in session.
|
|
374
200
|
const userId = await this.resolveSessionUserId(ctx, c);
|
|
375
201
|
if (!userId) {
|
|
376
202
|
return c.json(
|
|
377
|
-
{
|
|
378
|
-
success: false,
|
|
379
|
-
error: 'unauthenticated',
|
|
380
|
-
message: 'Sign in to redeliver webhook deliveries.',
|
|
381
|
-
},
|
|
203
|
+
{ success: false, error: 'unauthenticated', message: 'Sign in to redeliver webhook deliveries.' },
|
|
382
204
|
401,
|
|
383
205
|
);
|
|
384
206
|
}
|
|
@@ -386,77 +208,39 @@ export class WebhookOutboxPlugin implements Plugin {
|
|
|
386
208
|
try {
|
|
387
209
|
body = await c.req.json();
|
|
388
210
|
} catch {
|
|
389
|
-
return c.json(
|
|
390
|
-
{
|
|
391
|
-
success: false,
|
|
392
|
-
error: 'invalid_body',
|
|
393
|
-
message: 'Request body must be JSON.',
|
|
394
|
-
},
|
|
395
|
-
400,
|
|
396
|
-
);
|
|
211
|
+
return c.json({ success: false, error: 'invalid_body', message: 'Request body must be JSON.' }, 400);
|
|
397
212
|
}
|
|
398
|
-
const deliveryId =
|
|
399
|
-
typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';
|
|
213
|
+
const deliveryId = typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';
|
|
400
214
|
if (!deliveryId) {
|
|
401
215
|
return c.json(
|
|
402
|
-
{
|
|
403
|
-
success: false,
|
|
404
|
-
error: 'missing_delivery_id',
|
|
405
|
-
message: 'Body must include `deliveryId: string`.',
|
|
406
|
-
},
|
|
216
|
+
{ success: false, error: 'missing_delivery_id', message: 'Body must include `deliveryId: string`.' },
|
|
407
217
|
400,
|
|
408
218
|
);
|
|
409
219
|
}
|
|
410
220
|
try {
|
|
411
|
-
const row = await
|
|
412
|
-
ctx.logger.info?.('[webhook-outbox] redelivered', {
|
|
413
|
-
deliveryId,
|
|
414
|
-
requestedBy: userId,
|
|
415
|
-
});
|
|
221
|
+
const row = await messaging.redeliverHttp(deliveryId);
|
|
222
|
+
ctx.logger.info?.('[webhook-outbox] redelivered', { deliveryId, requestedBy: userId });
|
|
416
223
|
return c.json({ success: true, data: { id: row.id, status: row.status } });
|
|
417
224
|
} catch (err: any) {
|
|
418
225
|
const code = err?.code;
|
|
419
226
|
if (code === 'not_found') {
|
|
420
|
-
return c.json(
|
|
421
|
-
{ success: false, error: 'not_found', message: err.message },
|
|
422
|
-
404,
|
|
423
|
-
);
|
|
227
|
+
return c.json({ success: false, error: 'not_found', message: err.message }, 404);
|
|
424
228
|
}
|
|
425
229
|
if (code === 'not_eligible') {
|
|
426
|
-
return c.json(
|
|
427
|
-
{ success: false, error: 'not_eligible', message: err.message },
|
|
428
|
-
409,
|
|
429
|
-
);
|
|
230
|
+
return c.json({ success: false, error: 'not_eligible', message: err.message }, 409);
|
|
430
231
|
}
|
|
431
|
-
ctx.logger.error?.(
|
|
432
|
-
'[webhook-outbox] redeliver failed',
|
|
433
|
-
err as Error,
|
|
434
|
-
);
|
|
232
|
+
ctx.logger.error?.('[webhook-outbox] redeliver failed', err as Error);
|
|
435
233
|
return c.json(
|
|
436
|
-
{
|
|
437
|
-
success: false,
|
|
438
|
-
error: 'internal_error',
|
|
439
|
-
message: err?.message ?? String(err),
|
|
440
|
-
},
|
|
234
|
+
{ success: false, error: 'internal_error', message: err?.message ?? String(err) },
|
|
441
235
|
500,
|
|
442
236
|
);
|
|
443
237
|
}
|
|
444
238
|
});
|
|
445
239
|
|
|
446
|
-
ctx.logger.info?.(
|
|
447
|
-
'[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver',
|
|
448
|
-
);
|
|
240
|
+
ctx.logger.info?.('[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver');
|
|
449
241
|
}
|
|
450
242
|
|
|
451
|
-
|
|
452
|
-
* Resolve the requesting user's id from a better-auth session cookie.
|
|
453
|
-
* Returns `undefined` for anonymous callers — the caller decides
|
|
454
|
-
* whether that's a 401.
|
|
455
|
-
*/
|
|
456
|
-
private async resolveSessionUserId(
|
|
457
|
-
ctx: PluginContext,
|
|
458
|
-
c: any,
|
|
459
|
-
): Promise<string | undefined> {
|
|
243
|
+
private async resolveSessionUserId(ctx: PluginContext, c: any): Promise<string | undefined> {
|
|
460
244
|
try {
|
|
461
245
|
const authService: any = this.tryGetService<any>(ctx, ['auth']);
|
|
462
246
|
if (!authService) return undefined;
|