webspresso 0.0.98 → 0.0.100
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/.agents/ADMIN.md +11 -1
- package/.agents/AGENTS.md +2 -2
- package/.agents/AUTH.md +415 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +178 -4
- package/.agents/PLUGINS.md +113 -8
- package/.agents/QUEUE.md +92 -0
- package/.agents/ROUTING.md +160 -7
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/index.js +123 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +477 -3
- package/core/auth/middleware.js +118 -5
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +507 -0
- package/core/auth/user-agent.js +161 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +10 -1
- package/core/orm/model.js +55 -1
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +327 -16
- package/core/orm/scopes.js +3 -2
- package/core/orm/search.js +213 -0
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +544 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +100 -1
- package/core/queue/scheduler.js +415 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +794 -6
- package/index.js +48 -0
- package/package.json +7 -5
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/cors.js +13 -2
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/file-manager/admin-component.js +69 -53
- package/plugins/file-manager/index.js +25 -8
- package/plugins/index.js +8 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -0
- package/src/bundle/index.js +2 -1
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +310 -104
- package/src/helpers.js +16 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +490 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Dispatcher Engine
|
|
3
|
+
* Manages webhook endpoints, event matching, background delivery, retries, and history
|
|
4
|
+
* @module plugins/webhooks/dispatcher
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const EventEmitter = require('events');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { generateSecret } = require('./crypto');
|
|
12
|
+
const { matchesAnyEvent, normalizeEvents } = require('./matcher');
|
|
13
|
+
const { sendWebhook } = require('./sender');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate a standard unique delivery identifier
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function generateDeliveryId() {
|
|
20
|
+
if (typeof crypto.randomUUID === 'function') {
|
|
21
|
+
return `dlv_${crypto.randomUUID().replace(/-/g, '')}`;
|
|
22
|
+
}
|
|
23
|
+
return `dlv_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class WebhookDispatcher extends EventEmitter {
|
|
27
|
+
/**
|
|
28
|
+
* @param {Object} [options]
|
|
29
|
+
* @param {any} [options.db] - Webspresso database or Knex instance
|
|
30
|
+
* @param {any} [options.knex] - Knex instance
|
|
31
|
+
* @param {string} [options.endpointsTable='webhook_endpoints']
|
|
32
|
+
* @param {string} [options.deliveriesTable='webhook_deliveries']
|
|
33
|
+
* @param {number} [options.maxAttempts=3] - Maximum retry attempts
|
|
34
|
+
* @param {number} [options.backoffBaseMs=1000] - Base delay for exponential backoff (ms)
|
|
35
|
+
* @param {number} [options.timeoutMs=10000] - Request timeout (ms)
|
|
36
|
+
* @param {boolean} [options.wrapPayload=true] - Wrap data in standard event envelope
|
|
37
|
+
* @param {any} [options.queue] - Webspresso QueueManager instance
|
|
38
|
+
* @param {Array<Object>} [options.endpoints] - Static in-memory endpoints
|
|
39
|
+
* @param {any} [options.logger] - Logger instance
|
|
40
|
+
*/
|
|
41
|
+
constructor(options = {}) {
|
|
42
|
+
super();
|
|
43
|
+
this.options = options;
|
|
44
|
+
this.db = options.db || null;
|
|
45
|
+
this.knex = options.knex || (this.db && this.db.knex) || (typeof this.db === 'function' ? this.db : null);
|
|
46
|
+
this.endpointsTable = options.endpointsTable || 'webhook_endpoints';
|
|
47
|
+
this.deliveriesTable = options.deliveriesTable || 'webhook_deliveries';
|
|
48
|
+
this.maxAttempts = typeof options.maxAttempts === 'number' ? options.maxAttempts : 3;
|
|
49
|
+
this.backoffBaseMs = typeof options.backoffBaseMs === 'number' ? options.backoffBaseMs : 1000;
|
|
50
|
+
this.timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 10000;
|
|
51
|
+
this.wrapPayload = options.wrapPayload !== undefined ? options.wrapPayload : true;
|
|
52
|
+
this.queue = options.queue || null;
|
|
53
|
+
this.logger = options.logger || console;
|
|
54
|
+
this._staticEndpoints = Array.isArray(options.endpoints) ? [...options.endpoints] : null;
|
|
55
|
+
this._activeTimers = new Set();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve Knex query builder
|
|
60
|
+
* @returns {any}
|
|
61
|
+
*/
|
|
62
|
+
resolveKnex() {
|
|
63
|
+
return this.knex || (this.db && this.db.knex) || (typeof this.db === 'function' ? this.db : null);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set or update queue manager
|
|
68
|
+
* @param {any} queue
|
|
69
|
+
*/
|
|
70
|
+
setQueue(queue) {
|
|
71
|
+
this.queue = queue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Cleanup any pending in-memory retry timers on shutdown
|
|
76
|
+
*/
|
|
77
|
+
dispose() {
|
|
78
|
+
for (const timer of this._activeTimers) {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
this._activeTimers.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Endpoints CRUD
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Retrieve active endpoints that subscribe to a given event
|
|
90
|
+
*
|
|
91
|
+
* @param {string} eventName
|
|
92
|
+
* @returns {Promise<Array<Object>>}
|
|
93
|
+
*/
|
|
94
|
+
async getEndpointsForEvent(eventName) {
|
|
95
|
+
if (this._staticEndpoints) {
|
|
96
|
+
return this._staticEndpoints.filter((ep) => {
|
|
97
|
+
const isActive = ep.is_active !== false && ep.active !== false;
|
|
98
|
+
return isActive && matchesAnyEvent(ep.events, eventName);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const kx = this.resolveKnex();
|
|
103
|
+
if (!kx) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rows = await kx(this.endpointsTable).where({ is_active: true });
|
|
108
|
+
return rows.filter((ep) => matchesAnyEvent(ep.events, eventName));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* List all webhook endpoints
|
|
113
|
+
*
|
|
114
|
+
* @param {Object} [filters]
|
|
115
|
+
* @param {boolean} [filters.active]
|
|
116
|
+
* @param {string} [filters.search]
|
|
117
|
+
* @param {number} [filters.limit]
|
|
118
|
+
* @param {number} [filters.offset]
|
|
119
|
+
* @returns {Promise<Array<Object>>}
|
|
120
|
+
*/
|
|
121
|
+
async listEndpoints(filters = {}) {
|
|
122
|
+
if (this._staticEndpoints) {
|
|
123
|
+
let list = [...this._staticEndpoints];
|
|
124
|
+
if (filters.active !== undefined) {
|
|
125
|
+
list = list.filter((ep) => (ep.is_active !== false) === !!filters.active);
|
|
126
|
+
}
|
|
127
|
+
if (filters.search) {
|
|
128
|
+
const q = filters.search.toLowerCase();
|
|
129
|
+
list = list.filter((ep) => (ep.name && ep.name.toLowerCase().includes(q)) || (ep.url && ep.url.toLowerCase().includes(q)));
|
|
130
|
+
}
|
|
131
|
+
return list;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const kx = this.resolveKnex();
|
|
135
|
+
if (!kx) return [];
|
|
136
|
+
|
|
137
|
+
let query = kx(this.endpointsTable).select('*');
|
|
138
|
+
if (filters.active !== undefined) {
|
|
139
|
+
query = query.where({ is_active: !!filters.active });
|
|
140
|
+
}
|
|
141
|
+
if (filters.search) {
|
|
142
|
+
query = query.where((q) => {
|
|
143
|
+
q.where('name', 'like', `%${filters.search}%`)
|
|
144
|
+
.orWhere('url', 'like', `%${filters.search}%`);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
query = query.orderBy('created_at', 'desc');
|
|
148
|
+
if (filters.limit) query = query.limit(filters.limit);
|
|
149
|
+
if (filters.offset) query = query.offset(filters.offset);
|
|
150
|
+
|
|
151
|
+
return query;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get an endpoint by ID
|
|
156
|
+
*
|
|
157
|
+
* @param {number|string} id
|
|
158
|
+
* @returns {Promise<Object|null>}
|
|
159
|
+
*/
|
|
160
|
+
async getEndpoint(id) {
|
|
161
|
+
if (this._staticEndpoints) {
|
|
162
|
+
return this._staticEndpoints.find((ep) => String(ep.id) === String(id)) || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const kx = this.resolveKnex();
|
|
166
|
+
if (!kx) return null;
|
|
167
|
+
const ep = await kx(this.endpointsTable).where({ id }).first();
|
|
168
|
+
return ep || null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Create a new webhook endpoint
|
|
173
|
+
*
|
|
174
|
+
* @param {Object} data
|
|
175
|
+
* @returns {Promise<Object>}
|
|
176
|
+
*/
|
|
177
|
+
async createEndpoint(data) {
|
|
178
|
+
if (!data.name || typeof data.name !== 'string') {
|
|
179
|
+
throw new Error('Endpoint name is required.');
|
|
180
|
+
}
|
|
181
|
+
if (!data.url || typeof data.url !== 'string') {
|
|
182
|
+
throw new Error('Endpoint URL is required.');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const secret = data.secret || generateSecret();
|
|
186
|
+
const eventsList = normalizeEvents(data.events);
|
|
187
|
+
|
|
188
|
+
const record = {
|
|
189
|
+
name: data.name.trim(),
|
|
190
|
+
url: data.url.trim(),
|
|
191
|
+
secret,
|
|
192
|
+
events: JSON.stringify(eventsList),
|
|
193
|
+
is_active: data.is_active !== undefined ? !!data.is_active : (data.active !== undefined ? !!data.active : true),
|
|
194
|
+
description: data.description || null,
|
|
195
|
+
headers: data.headers ? (typeof data.headers === 'string' ? data.headers : JSON.stringify(data.headers)) : null,
|
|
196
|
+
metadata: data.metadata ? (typeof data.metadata === 'string' ? data.metadata : JSON.stringify(data.metadata)) : null,
|
|
197
|
+
created_at: new Date(),
|
|
198
|
+
updated_at: new Date(),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (this._staticEndpoints) {
|
|
202
|
+
record.id = this._staticEndpoints.length + 1;
|
|
203
|
+
this._staticEndpoints.push(record);
|
|
204
|
+
return record;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const kx = this.resolveKnex();
|
|
208
|
+
if (!kx) {
|
|
209
|
+
record.id = 1;
|
|
210
|
+
return record;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const insertRes = await kx(this.endpointsTable).insert(record);
|
|
214
|
+
let insertedId = 1;
|
|
215
|
+
if (Array.isArray(insertRes) && insertRes.length > 0) {
|
|
216
|
+
insertedId = typeof insertRes[0] === 'object' && insertRes[0] !== null ? insertRes[0].id : insertRes[0];
|
|
217
|
+
} else if (typeof insertRes === 'number') {
|
|
218
|
+
insertedId = insertRes;
|
|
219
|
+
}
|
|
220
|
+
record.id = insertedId;
|
|
221
|
+
return record;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Update an existing webhook endpoint
|
|
226
|
+
*
|
|
227
|
+
* @param {number|string} id
|
|
228
|
+
* @param {Object} data
|
|
229
|
+
* @returns {Promise<Object|null>}
|
|
230
|
+
*/
|
|
231
|
+
async updateEndpoint(id, data) {
|
|
232
|
+
const updatePayload = {
|
|
233
|
+
updated_at: new Date(),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
if (data.name !== undefined) updatePayload.name = String(data.name).trim();
|
|
237
|
+
if (data.url !== undefined) updatePayload.url = String(data.url).trim();
|
|
238
|
+
if (data.secret !== undefined) updatePayload.secret = String(data.secret);
|
|
239
|
+
if (data.events !== undefined) updatePayload.events = JSON.stringify(normalizeEvents(data.events));
|
|
240
|
+
if (data.is_active !== undefined) updatePayload.is_active = !!data.is_active;
|
|
241
|
+
if (data.active !== undefined) updatePayload.is_active = !!data.active;
|
|
242
|
+
if (data.description !== undefined) updatePayload.description = data.description;
|
|
243
|
+
if (data.headers !== undefined) {
|
|
244
|
+
updatePayload.headers = data.headers ? (typeof data.headers === 'string' ? data.headers : JSON.stringify(data.headers)) : null;
|
|
245
|
+
}
|
|
246
|
+
if (data.metadata !== undefined) {
|
|
247
|
+
updatePayload.metadata = data.metadata ? (typeof data.metadata === 'string' ? data.metadata : JSON.stringify(data.metadata)) : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (this._staticEndpoints) {
|
|
251
|
+
const idx = this._staticEndpoints.findIndex((ep) => String(ep.id) === String(id));
|
|
252
|
+
if (idx === -1) return null;
|
|
253
|
+
this._staticEndpoints[idx] = { ...this._staticEndpoints[idx], ...updatePayload };
|
|
254
|
+
return this._staticEndpoints[idx];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const kx = this.resolveKnex();
|
|
258
|
+
if (!kx) return null;
|
|
259
|
+
|
|
260
|
+
const rowsUpdated = await kx(this.endpointsTable).where({ id }).update(updatePayload);
|
|
261
|
+
if (rowsUpdated === 0) return null;
|
|
262
|
+
|
|
263
|
+
return this.getEndpoint(id);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Delete a webhook endpoint
|
|
268
|
+
*
|
|
269
|
+
* @param {number|string} id
|
|
270
|
+
* @returns {Promise<boolean>}
|
|
271
|
+
*/
|
|
272
|
+
async deleteEndpoint(id) {
|
|
273
|
+
if (this._staticEndpoints) {
|
|
274
|
+
const initialLen = this._staticEndpoints.length;
|
|
275
|
+
this._staticEndpoints = this._staticEndpoints.filter((ep) => String(ep.id) !== String(id));
|
|
276
|
+
return this._staticEndpoints.length < initialLen;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const kx = this.resolveKnex();
|
|
280
|
+
if (!kx) return false;
|
|
281
|
+
|
|
282
|
+
// Delete associated deliveries first
|
|
283
|
+
await kx(this.deliveriesTable).where({ endpoint_id: id }).delete();
|
|
284
|
+
const rowsDeleted = await kx(this.endpointsTable).where({ id }).delete();
|
|
285
|
+
return rowsDeleted > 0;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// Webhook Dispatch & Execution
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Dispatch an event to all subscribed webhook endpoints
|
|
294
|
+
*
|
|
295
|
+
* @param {string} event - Event name (e.g. 'order.created')
|
|
296
|
+
* @param {any} payload - Event data payload
|
|
297
|
+
* @param {Object} [options]
|
|
298
|
+
* @param {boolean} [options.wait=false] - Whether to await all HTTP deliveries before returning
|
|
299
|
+
* @param {number} [options.endpointId] - Limit dispatch to a specific endpoint
|
|
300
|
+
* @returns {Promise<{ dispatched: number, deliveries: Array<Object> }>}
|
|
301
|
+
*/
|
|
302
|
+
async dispatch(event, payload, options = {}) {
|
|
303
|
+
if (!event || typeof event !== 'string') {
|
|
304
|
+
throw new Error('Event name must be a non-empty string.');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let endpoints = await this.getEndpointsForEvent(event);
|
|
308
|
+
if (options.endpointId) {
|
|
309
|
+
endpoints = endpoints.filter((ep) => String(ep.id) === String(options.endpointId));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (endpoints.length === 0) {
|
|
313
|
+
return { dispatched: 0, deliveries: [] };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const kx = this.resolveKnex();
|
|
317
|
+
const deliveries = [];
|
|
318
|
+
const executionPromises = [];
|
|
319
|
+
|
|
320
|
+
for (const endpoint of endpoints) {
|
|
321
|
+
const deliveryId = generateDeliveryId();
|
|
322
|
+
const payloadStr = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
323
|
+
|
|
324
|
+
const deliveryRecord = {
|
|
325
|
+
endpoint_id: endpoint.id,
|
|
326
|
+
event,
|
|
327
|
+
payload: payloadStr,
|
|
328
|
+
status_code: null,
|
|
329
|
+
success: false,
|
|
330
|
+
duration_ms: null,
|
|
331
|
+
error: null,
|
|
332
|
+
response_body: null,
|
|
333
|
+
attempt: 1,
|
|
334
|
+
max_attempts: this.maxAttempts,
|
|
335
|
+
next_retry_at: null,
|
|
336
|
+
created_at: new Date(),
|
|
337
|
+
updated_at: new Date(),
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
if (kx) {
|
|
341
|
+
try {
|
|
342
|
+
const insertRes = await kx(this.deliveriesTable).insert(deliveryRecord);
|
|
343
|
+
let dbId = deliveryId;
|
|
344
|
+
if (Array.isArray(insertRes) && insertRes.length > 0) {
|
|
345
|
+
dbId = typeof insertRes[0] === 'object' && insertRes[0] !== null ? insertRes[0].id : insertRes[0];
|
|
346
|
+
} else if (typeof insertRes === 'number') {
|
|
347
|
+
dbId = insertRes;
|
|
348
|
+
}
|
|
349
|
+
deliveryRecord.id = dbId;
|
|
350
|
+
} catch (dbErr) {
|
|
351
|
+
deliveryRecord.id = deliveryId;
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
deliveryRecord.id = deliveryId;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
deliveries.push(deliveryRecord);
|
|
358
|
+
|
|
359
|
+
// Determine dispatch mechanism: Queue vs In-process
|
|
360
|
+
if (this.queue && typeof this.queue.dispatch === 'function') {
|
|
361
|
+
const queuePromise = this.queue.dispatch('webhooks.deliver', {
|
|
362
|
+
deliveryId: deliveryRecord.id,
|
|
363
|
+
endpointId: endpoint.id,
|
|
364
|
+
event,
|
|
365
|
+
payload,
|
|
366
|
+
attempt: 1,
|
|
367
|
+
}, {
|
|
368
|
+
attempts: this.maxAttempts,
|
|
369
|
+
backoff: { type: 'exponential', delay: this.backoffBaseMs },
|
|
370
|
+
timeout: this.timeoutMs,
|
|
371
|
+
});
|
|
372
|
+
executionPromises.push(queuePromise);
|
|
373
|
+
} else {
|
|
374
|
+
const execPromise = this.executeDelivery({
|
|
375
|
+
endpoint,
|
|
376
|
+
deliveryId: deliveryRecord.id,
|
|
377
|
+
event,
|
|
378
|
+
payload,
|
|
379
|
+
attempt: 1,
|
|
380
|
+
});
|
|
381
|
+
executionPromises.push(execPromise);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.emit('dispatched', { event, count: endpoints.length, endpoints: endpoints.map((e) => e.id) });
|
|
386
|
+
|
|
387
|
+
if (options.wait) {
|
|
388
|
+
const results = await Promise.allSettled(executionPromises);
|
|
389
|
+
return {
|
|
390
|
+
dispatched: endpoints.length,
|
|
391
|
+
deliveries,
|
|
392
|
+
results: results.map((r) => (r.status === 'fulfilled' ? r.value : { error: r.reason?.message })),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { dispatched: endpoints.length, deliveries };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Execute single HTTP delivery to an endpoint
|
|
401
|
+
*
|
|
402
|
+
* @param {Object} params
|
|
403
|
+
* @param {Object} params.endpoint
|
|
404
|
+
* @param {number|string} params.deliveryId
|
|
405
|
+
* @param {string} params.event
|
|
406
|
+
* @param {any} params.payload
|
|
407
|
+
* @param {number} [params.attempt=1]
|
|
408
|
+
* @param {boolean} [params.autoRetry=true]
|
|
409
|
+
* @returns {Promise<Object>}
|
|
410
|
+
*/
|
|
411
|
+
async executeDelivery(params) {
|
|
412
|
+
const {
|
|
413
|
+
endpoint,
|
|
414
|
+
deliveryId,
|
|
415
|
+
event,
|
|
416
|
+
payload,
|
|
417
|
+
attempt = 1,
|
|
418
|
+
autoRetry = true,
|
|
419
|
+
} = params;
|
|
420
|
+
|
|
421
|
+
const res = await sendWebhook({
|
|
422
|
+
url: endpoint.url,
|
|
423
|
+
event,
|
|
424
|
+
payload,
|
|
425
|
+
secret: endpoint.secret,
|
|
426
|
+
deliveryId: String(deliveryId),
|
|
427
|
+
headers: endpoint.headers,
|
|
428
|
+
timeoutMs: this.timeoutMs,
|
|
429
|
+
wrapPayload: this.wrapPayload,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const kx = this.resolveKnex();
|
|
433
|
+
const updateData = {
|
|
434
|
+
status_code: res.statusCode || null,
|
|
435
|
+
success: !!res.success,
|
|
436
|
+
duration_ms: res.durationMs,
|
|
437
|
+
error: res.error || null,
|
|
438
|
+
response_body: res.responseBody || null,
|
|
439
|
+
attempt,
|
|
440
|
+
updated_at: new Date(),
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
let willRetry = false;
|
|
444
|
+
let nextRetryAt = null;
|
|
445
|
+
|
|
446
|
+
if (!res.success && attempt < this.maxAttempts) {
|
|
447
|
+
willRetry = true;
|
|
448
|
+
const delayMs = this.backoffBaseMs * Math.pow(2, attempt - 1);
|
|
449
|
+
nextRetryAt = new Date(Date.now() + delayMs);
|
|
450
|
+
updateData.next_retry_at = nextRetryAt;
|
|
451
|
+
|
|
452
|
+
// In-process retry timer if no queue is managing it
|
|
453
|
+
if (autoRetry && !this.queue) {
|
|
454
|
+
const timer = setTimeout(() => {
|
|
455
|
+
this._activeTimers.delete(timer);
|
|
456
|
+
this.executeDelivery({
|
|
457
|
+
endpoint,
|
|
458
|
+
deliveryId,
|
|
459
|
+
event,
|
|
460
|
+
payload,
|
|
461
|
+
attempt: attempt + 1,
|
|
462
|
+
autoRetry: true,
|
|
463
|
+
}).catch((err) => {
|
|
464
|
+
this.emit('error', err);
|
|
465
|
+
});
|
|
466
|
+
}, delayMs);
|
|
467
|
+
this._activeTimers.add(timer);
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
updateData.next_retry_at = null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (kx && deliveryId) {
|
|
474
|
+
try {
|
|
475
|
+
await kx(this.deliveriesTable).where({ id: deliveryId }).update(updateData);
|
|
476
|
+
} catch (dbErr) {
|
|
477
|
+
// Log or handle gracefully
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const outcome = {
|
|
482
|
+
deliveryId,
|
|
483
|
+
endpointId: endpoint.id,
|
|
484
|
+
event,
|
|
485
|
+
attempt,
|
|
486
|
+
success: res.success,
|
|
487
|
+
statusCode: res.statusCode,
|
|
488
|
+
durationMs: res.durationMs,
|
|
489
|
+
error: res.error,
|
|
490
|
+
willRetry,
|
|
491
|
+
nextRetryAt,
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
if (res.success) {
|
|
495
|
+
this.emit('delivered', outcome);
|
|
496
|
+
} else {
|
|
497
|
+
this.emit('failed', outcome);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return outcome;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Manually retry a specific delivery
|
|
505
|
+
*
|
|
506
|
+
* @param {number|string} deliveryId
|
|
507
|
+
* @returns {Promise<Object>}
|
|
508
|
+
*/
|
|
509
|
+
async retryDelivery(deliveryId) {
|
|
510
|
+
const kx = this.resolveKnex();
|
|
511
|
+
if (!kx) {
|
|
512
|
+
throw new Error('Database connection required to retry delivery.');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const delivery = await kx(this.deliveriesTable).where({ id: deliveryId }).first();
|
|
516
|
+
if (!delivery) {
|
|
517
|
+
throw new Error(`Delivery #${deliveryId} not found.`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const endpoint = await this.getEndpoint(delivery.endpoint_id);
|
|
521
|
+
if (!endpoint) {
|
|
522
|
+
throw new Error(`Target endpoint #${delivery.endpoint_id} no longer exists.`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
let parsedPayload = delivery.payload;
|
|
526
|
+
try {
|
|
527
|
+
parsedPayload = JSON.parse(delivery.payload);
|
|
528
|
+
} catch (e) {
|
|
529
|
+
// Keep string if not json
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const nextAttempt = (delivery.attempt || 1) + 1;
|
|
533
|
+
return this.executeDelivery({
|
|
534
|
+
endpoint,
|
|
535
|
+
deliveryId,
|
|
536
|
+
event: delivery.event,
|
|
537
|
+
payload: parsedPayload,
|
|
538
|
+
attempt: nextAttempt,
|
|
539
|
+
autoRetry: false,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Send a test ping webhook to an endpoint
|
|
545
|
+
*
|
|
546
|
+
* @param {number|string} endpointId
|
|
547
|
+
* @returns {Promise<Object>}
|
|
548
|
+
*/
|
|
549
|
+
async pingEndpoint(endpointId) {
|
|
550
|
+
const endpoint = await this.getEndpoint(endpointId);
|
|
551
|
+
if (!endpoint) {
|
|
552
|
+
throw new Error(`Endpoint #${endpointId} not found.`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const pingPayload = {
|
|
556
|
+
ping: true,
|
|
557
|
+
message: 'Webspresso Webhook ping test',
|
|
558
|
+
timestamp: new Date().toISOString(),
|
|
559
|
+
endpoint: {
|
|
560
|
+
id: endpoint.id,
|
|
561
|
+
name: endpoint.name,
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const deliveryId = generateDeliveryId();
|
|
566
|
+
const payloadStr = JSON.stringify(pingPayload);
|
|
567
|
+
const kx = this.resolveKnex();
|
|
568
|
+
|
|
569
|
+
const deliveryRecord = {
|
|
570
|
+
endpoint_id: endpoint.id,
|
|
571
|
+
event: 'ping',
|
|
572
|
+
payload: payloadStr,
|
|
573
|
+
status_code: null,
|
|
574
|
+
success: false,
|
|
575
|
+
duration_ms: null,
|
|
576
|
+
error: null,
|
|
577
|
+
response_body: null,
|
|
578
|
+
attempt: 1,
|
|
579
|
+
max_attempts: 1,
|
|
580
|
+
next_retry_at: null,
|
|
581
|
+
created_at: new Date(),
|
|
582
|
+
updated_at: new Date(),
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
if (kx) {
|
|
586
|
+
try {
|
|
587
|
+
const insertRes = await kx(this.deliveriesTable).insert(deliveryRecord);
|
|
588
|
+
let dbId = deliveryId;
|
|
589
|
+
if (Array.isArray(insertRes) && insertRes.length > 0) {
|
|
590
|
+
dbId = typeof insertRes[0] === 'object' && insertRes[0] !== null ? insertRes[0].id : insertRes[0];
|
|
591
|
+
} else if (typeof insertRes === 'number') {
|
|
592
|
+
dbId = insertRes;
|
|
593
|
+
}
|
|
594
|
+
deliveryRecord.id = dbId;
|
|
595
|
+
} catch (err) {
|
|
596
|
+
deliveryRecord.id = deliveryId;
|
|
597
|
+
}
|
|
598
|
+
} else {
|
|
599
|
+
deliveryRecord.id = deliveryId;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return this.executeDelivery({
|
|
603
|
+
endpoint,
|
|
604
|
+
deliveryId: deliveryRecord.id,
|
|
605
|
+
event: 'ping',
|
|
606
|
+
payload: pingPayload,
|
|
607
|
+
attempt: 1,
|
|
608
|
+
autoRetry: false,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
// Delivery Logs & Stats
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* List delivery logs with pagination & filtering
|
|
618
|
+
*
|
|
619
|
+
* @param {Object} [filters]
|
|
620
|
+
* @param {number|string} [filters.endpointId]
|
|
621
|
+
* @param {string} [filters.event]
|
|
622
|
+
* @param {boolean} [filters.success]
|
|
623
|
+
* @param {number} [filters.page=1]
|
|
624
|
+
* @param {number} [filters.limit=25]
|
|
625
|
+
* @returns {Promise<{ items: Array<Object>, total: number, page: number, limit: number, pages: number }>}
|
|
626
|
+
*/
|
|
627
|
+
async listDeliveries(filters = {}) {
|
|
628
|
+
const kx = this.resolveKnex();
|
|
629
|
+
if (!kx) {
|
|
630
|
+
return { items: [], total: 0, page: 1, limit: 25, pages: 0 };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const page = Math.max(1, parseInt(filters.page, 10) || 1);
|
|
634
|
+
const limit = Math.max(1, Math.min(100, parseInt(filters.limit, 10) || 25));
|
|
635
|
+
const offset = (page - 1) * limit;
|
|
636
|
+
|
|
637
|
+
let baseQuery = kx(this.deliveriesTable);
|
|
638
|
+
if (filters.endpointId) {
|
|
639
|
+
baseQuery = baseQuery.where({ endpoint_id: filters.endpointId });
|
|
640
|
+
}
|
|
641
|
+
if (filters.event) {
|
|
642
|
+
baseQuery = baseQuery.where('event', 'like', `%${filters.event}%`);
|
|
643
|
+
}
|
|
644
|
+
if (filters.success !== undefined && filters.success !== null && filters.success !== '') {
|
|
645
|
+
baseQuery = baseQuery.where({ success: filters.success === true || filters.success === 'true' });
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const countRes = await baseQuery.clone().count({ count: '*' });
|
|
649
|
+
let total = 0;
|
|
650
|
+
if (Array.isArray(countRes) && countRes.length > 0) {
|
|
651
|
+
total = parseInt(countRes[0].count || countRes[0]['count(*)'] || 0, 10);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const items = await baseQuery
|
|
655
|
+
.clone()
|
|
656
|
+
.select('*')
|
|
657
|
+
.orderBy('created_at', 'desc')
|
|
658
|
+
.limit(limit)
|
|
659
|
+
.offset(offset);
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
items,
|
|
663
|
+
total,
|
|
664
|
+
page,
|
|
665
|
+
limit,
|
|
666
|
+
pages: Math.ceil(total / limit),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Get single delivery record by ID
|
|
672
|
+
*
|
|
673
|
+
* @param {number|string} id
|
|
674
|
+
* @returns {Promise<Object|null>}
|
|
675
|
+
*/
|
|
676
|
+
async getDelivery(id) {
|
|
677
|
+
const kx = this.resolveKnex();
|
|
678
|
+
if (!kx) return null;
|
|
679
|
+
const row = await kx(this.deliveriesTable).where({ id }).first();
|
|
680
|
+
return row || null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Purge old delivery logs
|
|
685
|
+
*
|
|
686
|
+
* @param {Object} [options]
|
|
687
|
+
* @param {number} [options.days=30]
|
|
688
|
+
* @returns {Promise<number>} Number of deleted rows
|
|
689
|
+
*/
|
|
690
|
+
async purgeDeliveries(options = {}) {
|
|
691
|
+
const kx = this.resolveKnex();
|
|
692
|
+
if (!kx) return 0;
|
|
693
|
+
|
|
694
|
+
const days = typeof options.days === 'number' ? options.days : 30;
|
|
695
|
+
const cutoffDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
696
|
+
|
|
697
|
+
const deleted = await kx(this.deliveriesTable)
|
|
698
|
+
.where('created_at', '<', cutoffDate)
|
|
699
|
+
.delete();
|
|
700
|
+
|
|
701
|
+
return deleted;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
module.exports = {
|
|
706
|
+
WebhookDispatcher,
|
|
707
|
+
generateDeliveryId,
|
|
708
|
+
};
|