@fonderie/webhooks 1.0.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/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/index.cjs +519 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +68 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +492 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +7 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/sql/001_webhooks.sql +37 -0
- package/package.json +76 -0
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { IFonderieModule, IFonderieApp } from '@fonderie/core';
|
|
2
|
+
import { IStoreAdapter } from '@fonderie/store';
|
|
3
|
+
import { EventBus } from '@fonderie/events';
|
|
4
|
+
|
|
5
|
+
interface IWebhooksConfig {
|
|
6
|
+
maxAttempts?: number;
|
|
7
|
+
retryDelays?: number[];
|
|
8
|
+
retryInterval?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare class WebhooksModule implements IFonderieModule {
|
|
12
|
+
private readonly store;
|
|
13
|
+
private readonly config;
|
|
14
|
+
private readonly bus?;
|
|
15
|
+
readonly name = "@fonderie/webhooks";
|
|
16
|
+
readonly deps: string[];
|
|
17
|
+
private retryTimer?;
|
|
18
|
+
constructor(store: IStoreAdapter, config?: IWebhooksConfig, bus?: EventBus | undefined);
|
|
19
|
+
install(app: IFonderieApp): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type DeliveryStatus = 'pending' | 'delivered' | 'failed';
|
|
23
|
+
interface IWebhookEndpoint {
|
|
24
|
+
id: string;
|
|
25
|
+
workspaceId: string;
|
|
26
|
+
url: string;
|
|
27
|
+
secret: string;
|
|
28
|
+
events: string[];
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
createdAt: Date;
|
|
31
|
+
}
|
|
32
|
+
interface IWebhookDelivery {
|
|
33
|
+
id: string;
|
|
34
|
+
endpointId: string;
|
|
35
|
+
eventId: string;
|
|
36
|
+
eventType: string;
|
|
37
|
+
payload: Record<string, unknown>;
|
|
38
|
+
status: DeliveryStatus;
|
|
39
|
+
attempts: number;
|
|
40
|
+
responseStatus: number | null;
|
|
41
|
+
responseBody: string | null;
|
|
42
|
+
nextAttemptAt: Date | null;
|
|
43
|
+
deliveredAt: Date | null;
|
|
44
|
+
createdAt: Date;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface IWebhookEndpointDTO {
|
|
48
|
+
id: string;
|
|
49
|
+
url: string;
|
|
50
|
+
events: string[];
|
|
51
|
+
enabled: boolean;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
}
|
|
54
|
+
interface IWebhookEndpointCreatedDTO extends IWebhookEndpointDTO {
|
|
55
|
+
secret: string;
|
|
56
|
+
}
|
|
57
|
+
interface IWebhookDeliveryDTO {
|
|
58
|
+
id: string;
|
|
59
|
+
eventId: string;
|
|
60
|
+
eventType: string;
|
|
61
|
+
status: string;
|
|
62
|
+
attempts: number;
|
|
63
|
+
responseStatus: number | null;
|
|
64
|
+
deliveredAt: string | null;
|
|
65
|
+
createdAt: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { type DeliveryStatus, type IWebhookDelivery, type IWebhookDeliveryDTO, type IWebhookEndpoint, type IWebhookEndpointCreatedDTO, type IWebhookEndpointDTO, type IWebhooksConfig, WebhooksModule };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { IFonderieModule, IFonderieApp } from '@fonderie/core';
|
|
2
|
+
import { IStoreAdapter } from '@fonderie/store';
|
|
3
|
+
import { EventBus } from '@fonderie/events';
|
|
4
|
+
|
|
5
|
+
interface IWebhooksConfig {
|
|
6
|
+
maxAttempts?: number;
|
|
7
|
+
retryDelays?: number[];
|
|
8
|
+
retryInterval?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare class WebhooksModule implements IFonderieModule {
|
|
12
|
+
private readonly store;
|
|
13
|
+
private readonly config;
|
|
14
|
+
private readonly bus?;
|
|
15
|
+
readonly name = "@fonderie/webhooks";
|
|
16
|
+
readonly deps: string[];
|
|
17
|
+
private retryTimer?;
|
|
18
|
+
constructor(store: IStoreAdapter, config?: IWebhooksConfig, bus?: EventBus | undefined);
|
|
19
|
+
install(app: IFonderieApp): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type DeliveryStatus = 'pending' | 'delivered' | 'failed';
|
|
23
|
+
interface IWebhookEndpoint {
|
|
24
|
+
id: string;
|
|
25
|
+
workspaceId: string;
|
|
26
|
+
url: string;
|
|
27
|
+
secret: string;
|
|
28
|
+
events: string[];
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
createdAt: Date;
|
|
31
|
+
}
|
|
32
|
+
interface IWebhookDelivery {
|
|
33
|
+
id: string;
|
|
34
|
+
endpointId: string;
|
|
35
|
+
eventId: string;
|
|
36
|
+
eventType: string;
|
|
37
|
+
payload: Record<string, unknown>;
|
|
38
|
+
status: DeliveryStatus;
|
|
39
|
+
attempts: number;
|
|
40
|
+
responseStatus: number | null;
|
|
41
|
+
responseBody: string | null;
|
|
42
|
+
nextAttemptAt: Date | null;
|
|
43
|
+
deliveredAt: Date | null;
|
|
44
|
+
createdAt: Date;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface IWebhookEndpointDTO {
|
|
48
|
+
id: string;
|
|
49
|
+
url: string;
|
|
50
|
+
events: string[];
|
|
51
|
+
enabled: boolean;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
}
|
|
54
|
+
interface IWebhookEndpointCreatedDTO extends IWebhookEndpointDTO {
|
|
55
|
+
secret: string;
|
|
56
|
+
}
|
|
57
|
+
interface IWebhookDeliveryDTO {
|
|
58
|
+
id: string;
|
|
59
|
+
eventId: string;
|
|
60
|
+
eventType: string;
|
|
61
|
+
status: string;
|
|
62
|
+
attempts: number;
|
|
63
|
+
responseStatus: number | null;
|
|
64
|
+
deliveredAt: string | null;
|
|
65
|
+
createdAt: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { type DeliveryStatus, type IWebhookDelivery, type IWebhookDeliveryDTO, type IWebhookEndpoint, type IWebhookEndpointCreatedDTO, type IWebhookEndpointDTO, type IWebhooksConfig, WebhooksModule };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// src/models/endpoint.model.ts
|
|
2
|
+
var COLS = `id, workspace_id as "workspaceId", url, secret, events,
|
|
3
|
+
enabled, created_at as "createdAt"`;
|
|
4
|
+
var EndpointModel = class {
|
|
5
|
+
constructor(store) {
|
|
6
|
+
this.store = store;
|
|
7
|
+
}
|
|
8
|
+
store;
|
|
9
|
+
async create(data) {
|
|
10
|
+
const [row] = await this.store.query(
|
|
11
|
+
`INSERT INTO fonderie_webhook_endpoints (workspace_id, url, secret, events)
|
|
12
|
+
VALUES ($1, $2, $3, $4)
|
|
13
|
+
RETURNING ${COLS}`,
|
|
14
|
+
[data.workspaceId, data.url, data.secret, data.events]
|
|
15
|
+
);
|
|
16
|
+
return row;
|
|
17
|
+
}
|
|
18
|
+
list(workspaceId) {
|
|
19
|
+
return this.store.query(
|
|
20
|
+
`SELECT ${COLS} FROM fonderie_webhook_endpoints
|
|
21
|
+
WHERE workspace_id = $1
|
|
22
|
+
ORDER BY created_at DESC`,
|
|
23
|
+
[workspaceId]
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
async findById(id, workspaceId) {
|
|
27
|
+
const [row] = await this.store.query(
|
|
28
|
+
`SELECT ${COLS} FROM fonderie_webhook_endpoints
|
|
29
|
+
WHERE id = $1 AND workspace_id = $2`,
|
|
30
|
+
[id, workspaceId]
|
|
31
|
+
);
|
|
32
|
+
return row ?? null;
|
|
33
|
+
}
|
|
34
|
+
async update(id, workspaceId, data) {
|
|
35
|
+
const sets = [];
|
|
36
|
+
const params = [id, workspaceId];
|
|
37
|
+
if (data.url !== void 0) {
|
|
38
|
+
params.push(data.url);
|
|
39
|
+
sets.push(`url = $${params.length}`);
|
|
40
|
+
}
|
|
41
|
+
if (data.events !== void 0) {
|
|
42
|
+
params.push(data.events);
|
|
43
|
+
sets.push(`events = $${params.length}`);
|
|
44
|
+
}
|
|
45
|
+
if (data.enabled !== void 0) {
|
|
46
|
+
params.push(data.enabled);
|
|
47
|
+
sets.push(`enabled = $${params.length}`);
|
|
48
|
+
}
|
|
49
|
+
if (sets.length === 0) return this.findById(id, workspaceId);
|
|
50
|
+
const [row] = await this.store.query(
|
|
51
|
+
`UPDATE fonderie_webhook_endpoints SET ${sets.join(", ")}
|
|
52
|
+
WHERE id = $1 AND workspace_id = $2
|
|
53
|
+
RETURNING ${COLS}`,
|
|
54
|
+
params
|
|
55
|
+
);
|
|
56
|
+
return row ?? null;
|
|
57
|
+
}
|
|
58
|
+
async delete(id, workspaceId) {
|
|
59
|
+
const rows = await this.store.query(
|
|
60
|
+
`DELETE FROM fonderie_webhook_endpoints WHERE id = $1 AND workspace_id = $2 RETURNING id`,
|
|
61
|
+
[id, workspaceId]
|
|
62
|
+
);
|
|
63
|
+
return rows.length > 0;
|
|
64
|
+
}
|
|
65
|
+
findForEvent(workspaceId, eventType) {
|
|
66
|
+
return this.store.query(
|
|
67
|
+
`SELECT ${COLS} FROM fonderie_webhook_endpoints
|
|
68
|
+
WHERE workspace_id = $1
|
|
69
|
+
AND enabled = true
|
|
70
|
+
AND (events = '{}' OR $2 = ANY(events))`,
|
|
71
|
+
[workspaceId, eventType]
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// src/models/delivery.model.ts
|
|
77
|
+
var COLS2 = `id, endpoint_id as "endpointId", event_id as "eventId",
|
|
78
|
+
event_type as "eventType", payload, status, attempts,
|
|
79
|
+
response_status as "responseStatus", response_body as "responseBody",
|
|
80
|
+
next_attempt_at as "nextAttemptAt", delivered_at as "deliveredAt",
|
|
81
|
+
created_at as "createdAt"`;
|
|
82
|
+
var D_COLS = `d.id, d.endpoint_id as "endpointId", d.event_id as "eventId",
|
|
83
|
+
d.event_type as "eventType", d.payload, d.status, d.attempts,
|
|
84
|
+
d.response_status as "responseStatus", d.response_body as "responseBody",
|
|
85
|
+
d.next_attempt_at as "nextAttemptAt", d.delivered_at as "deliveredAt",
|
|
86
|
+
d.created_at as "createdAt"`;
|
|
87
|
+
var DeliveryModel = class {
|
|
88
|
+
constructor(store) {
|
|
89
|
+
this.store = store;
|
|
90
|
+
}
|
|
91
|
+
store;
|
|
92
|
+
async create(data) {
|
|
93
|
+
const [row] = await this.store.query(
|
|
94
|
+
`INSERT INTO fonderie_webhook_deliveries (endpoint_id, event_id, event_type, payload)
|
|
95
|
+
VALUES ($1, $2, $3, $4)
|
|
96
|
+
RETURNING ${COLS2}`,
|
|
97
|
+
[data.endpointId, data.eventId, data.eventType, JSON.stringify(data.payload)]
|
|
98
|
+
);
|
|
99
|
+
return row;
|
|
100
|
+
}
|
|
101
|
+
async markResult(id, result) {
|
|
102
|
+
await this.store.query(
|
|
103
|
+
`UPDATE fonderie_webhook_deliveries
|
|
104
|
+
SET status = $2,
|
|
105
|
+
attempts = attempts + 1,
|
|
106
|
+
response_status = $3,
|
|
107
|
+
response_body = $4,
|
|
108
|
+
next_attempt_at = $5,
|
|
109
|
+
delivered_at = $6
|
|
110
|
+
WHERE id = $1`,
|
|
111
|
+
[
|
|
112
|
+
id,
|
|
113
|
+
result.ok ? "delivered" : "failed",
|
|
114
|
+
result.responseStatus,
|
|
115
|
+
result.responseBody,
|
|
116
|
+
result.nextAttemptAt,
|
|
117
|
+
result.ok ? /* @__PURE__ */ new Date() : null
|
|
118
|
+
]
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
listByEndpoint(endpointId, limit = 50) {
|
|
122
|
+
return this.store.query(
|
|
123
|
+
`SELECT ${COLS2} FROM fonderie_webhook_deliveries
|
|
124
|
+
WHERE endpoint_id = $1
|
|
125
|
+
ORDER BY created_at DESC
|
|
126
|
+
LIMIT $2`,
|
|
127
|
+
[endpointId, limit]
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
claimForRetry(limit = 10) {
|
|
131
|
+
return this.store.query(
|
|
132
|
+
`SELECT ${D_COLS},
|
|
133
|
+
e.url, e.secret
|
|
134
|
+
FROM fonderie_webhook_deliveries d
|
|
135
|
+
JOIN fonderie_webhook_endpoints e ON e.id = d.endpoint_id
|
|
136
|
+
WHERE d.status = 'failed'
|
|
137
|
+
AND d.next_attempt_at IS NOT NULL
|
|
138
|
+
AND d.next_attempt_at <= now()
|
|
139
|
+
AND e.enabled = true
|
|
140
|
+
ORDER BY d.next_attempt_at
|
|
141
|
+
LIMIT $1`,
|
|
142
|
+
[limit]
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// src/signing.ts
|
|
148
|
+
import { createHmac, randomBytes } from "crypto";
|
|
149
|
+
function generateSecret() {
|
|
150
|
+
return randomBytes(32).toString("hex");
|
|
151
|
+
}
|
|
152
|
+
function signPayload(secret, body) {
|
|
153
|
+
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/dispatcher.ts
|
|
157
|
+
var WebhookDispatcher = class {
|
|
158
|
+
constructor(store, config = {}) {
|
|
159
|
+
this.store = store;
|
|
160
|
+
this.config = config;
|
|
161
|
+
this.maxAttempts = config.maxAttempts ?? 3;
|
|
162
|
+
this.retryDelays = config.retryDelays ?? [6e4, 3e5, 18e5];
|
|
163
|
+
}
|
|
164
|
+
store;
|
|
165
|
+
config;
|
|
166
|
+
maxAttempts;
|
|
167
|
+
retryDelays;
|
|
168
|
+
// Called by the bus consumer for every event.
|
|
169
|
+
// Skips events that don't carry a workspaceId — not workspace-scoped.
|
|
170
|
+
async dispatch(payload, meta) {
|
|
171
|
+
const workspaceId = payload["workspaceId"];
|
|
172
|
+
if (typeof workspaceId !== "string") return;
|
|
173
|
+
const endpoints = await new EndpointModel(this.store).findForEvent(workspaceId, meta.type);
|
|
174
|
+
if (endpoints.length === 0) return;
|
|
175
|
+
const deliveries = new DeliveryModel(this.store);
|
|
176
|
+
await Promise.allSettled(endpoints.map((ep) => this.deliver(ep, payload, meta, deliveries)));
|
|
177
|
+
}
|
|
178
|
+
// Retries failed deliveries whose next_attempt_at has passed.
|
|
179
|
+
async retry() {
|
|
180
|
+
const deliveries = new DeliveryModel(this.store);
|
|
181
|
+
const pending = await deliveries.claimForRetry();
|
|
182
|
+
await Promise.allSettled(
|
|
183
|
+
pending.map(
|
|
184
|
+
({ delivery, url, secret }) => this.attemptDelivery(url, secret, delivery, deliveries)
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
async deliver(endpoint, payload, meta, deliveries) {
|
|
189
|
+
const delivery = await deliveries.create({
|
|
190
|
+
endpointId: endpoint.id,
|
|
191
|
+
eventId: meta.id,
|
|
192
|
+
eventType: meta.type,
|
|
193
|
+
payload
|
|
194
|
+
});
|
|
195
|
+
await this.attemptDelivery(endpoint.url, endpoint.secret, delivery, deliveries);
|
|
196
|
+
}
|
|
197
|
+
async attemptDelivery(url, secret, delivery, deliveries) {
|
|
198
|
+
const body = JSON.stringify({
|
|
199
|
+
id: delivery.eventId,
|
|
200
|
+
type: delivery.eventType,
|
|
201
|
+
data: delivery.payload
|
|
202
|
+
});
|
|
203
|
+
const signature = signPayload(secret, body);
|
|
204
|
+
try {
|
|
205
|
+
const res = await fetch(url, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: {
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
"X-Webhook-Signature": signature,
|
|
210
|
+
"X-Webhook-Event": delivery.eventType,
|
|
211
|
+
"X-Webhook-ID": delivery.id
|
|
212
|
+
},
|
|
213
|
+
body,
|
|
214
|
+
signal: AbortSignal.timeout(1e4)
|
|
215
|
+
});
|
|
216
|
+
const responseBody = await res.text().catch(() => "");
|
|
217
|
+
await deliveries.markResult(delivery.id, {
|
|
218
|
+
ok: res.ok,
|
|
219
|
+
responseStatus: res.status,
|
|
220
|
+
responseBody,
|
|
221
|
+
nextAttemptAt: res.ok ? null : this.nextRetryAt(delivery.attempts)
|
|
222
|
+
});
|
|
223
|
+
} catch (err) {
|
|
224
|
+
await deliveries.markResult(delivery.id, {
|
|
225
|
+
ok: false,
|
|
226
|
+
responseStatus: null,
|
|
227
|
+
responseBody: err instanceof Error ? err.message : String(err),
|
|
228
|
+
nextAttemptAt: this.nextRetryAt(delivery.attempts)
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
nextRetryAt(currentAttempts) {
|
|
233
|
+
if (currentAttempts + 1 >= this.maxAttempts) return null;
|
|
234
|
+
const delay = this.retryDelays[currentAttempts] ?? this.retryDelays[this.retryDelays.length - 1];
|
|
235
|
+
return new Date(Date.now() + delay);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// src/routes.ts
|
|
240
|
+
import { setApiResponse, HTTP } from "@fonderie/core";
|
|
241
|
+
import { requireAuth } from "@fonderie/core/middlewares";
|
|
242
|
+
import { withBody } from "@fonderie/core/middlewares";
|
|
243
|
+
|
|
244
|
+
// src/dtos/webhook.ts
|
|
245
|
+
function toEndpointDTO(e) {
|
|
246
|
+
return {
|
|
247
|
+
id: e.id,
|
|
248
|
+
url: e.url,
|
|
249
|
+
events: e.events,
|
|
250
|
+
enabled: e.enabled,
|
|
251
|
+
createdAt: e.createdAt.toISOString()
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function toEndpointCreatedDTO(e) {
|
|
255
|
+
return { ...toEndpointDTO(e), secret: e.secret };
|
|
256
|
+
}
|
|
257
|
+
function toDeliveryDTO(d) {
|
|
258
|
+
return {
|
|
259
|
+
id: d.id,
|
|
260
|
+
eventId: d.eventId,
|
|
261
|
+
eventType: d.eventType,
|
|
262
|
+
status: d.status,
|
|
263
|
+
attempts: d.attempts,
|
|
264
|
+
responseStatus: d.responseStatus,
|
|
265
|
+
deliveredAt: d.deliveredAt?.toISOString() ?? null,
|
|
266
|
+
createdAt: d.createdAt.toISOString()
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// src/routes.ts
|
|
271
|
+
function buildWebhookRoutes(store, config = {}) {
|
|
272
|
+
return [
|
|
273
|
+
[
|
|
274
|
+
"POST",
|
|
275
|
+
"/webhooks",
|
|
276
|
+
requireAuth,
|
|
277
|
+
withBody,
|
|
278
|
+
async (ctx) => {
|
|
279
|
+
if (!ctx.workspace)
|
|
280
|
+
return setApiResponse(
|
|
281
|
+
HTTP.UNPROCESSABLE,
|
|
282
|
+
"MISSING_WORKSPACE",
|
|
283
|
+
"Workspace context required"
|
|
284
|
+
);
|
|
285
|
+
const body = ctx.meta["body"];
|
|
286
|
+
if (!body?.url)
|
|
287
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "MISSING_FIELD", "url is required");
|
|
288
|
+
const endpoint = await new EndpointModel(store).create({
|
|
289
|
+
workspaceId: ctx.workspace.id,
|
|
290
|
+
url: body.url,
|
|
291
|
+
secret: generateSecret(),
|
|
292
|
+
events: body.events ?? []
|
|
293
|
+
});
|
|
294
|
+
return setApiResponse(
|
|
295
|
+
HTTP.CREATED,
|
|
296
|
+
"WEBHOOK_CREATED",
|
|
297
|
+
"Webhook endpoint registered.",
|
|
298
|
+
toEndpointCreatedDTO(endpoint)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
],
|
|
302
|
+
[
|
|
303
|
+
"GET",
|
|
304
|
+
"/webhooks",
|
|
305
|
+
requireAuth,
|
|
306
|
+
async (ctx) => {
|
|
307
|
+
if (!ctx.workspace)
|
|
308
|
+
return setApiResponse(
|
|
309
|
+
HTTP.UNPROCESSABLE,
|
|
310
|
+
"MISSING_WORKSPACE",
|
|
311
|
+
"Workspace context required"
|
|
312
|
+
);
|
|
313
|
+
const list = await new EndpointModel(store).list(ctx.workspace.id);
|
|
314
|
+
return setApiResponse(HTTP.OK, "WEBHOOKS_FETCHED", "Webhook endpoints retrieved.", {
|
|
315
|
+
endpoints: list.map(toEndpointDTO)
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
],
|
|
319
|
+
[
|
|
320
|
+
"GET",
|
|
321
|
+
"/webhooks/:endpointId",
|
|
322
|
+
requireAuth,
|
|
323
|
+
async (ctx) => {
|
|
324
|
+
if (!ctx.workspace)
|
|
325
|
+
return setApiResponse(
|
|
326
|
+
HTTP.UNPROCESSABLE,
|
|
327
|
+
"MISSING_WORKSPACE",
|
|
328
|
+
"Workspace context required"
|
|
329
|
+
);
|
|
330
|
+
const { endpointId } = ctx.meta["params"];
|
|
331
|
+
const endpoint = await new EndpointModel(store).findById(endpointId, ctx.workspace.id);
|
|
332
|
+
if (!endpoint)
|
|
333
|
+
return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Webhook endpoint not found");
|
|
334
|
+
return setApiResponse(
|
|
335
|
+
HTTP.OK,
|
|
336
|
+
"WEBHOOK_FETCHED",
|
|
337
|
+
"Webhook endpoint retrieved.",
|
|
338
|
+
toEndpointDTO(endpoint)
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
],
|
|
342
|
+
[
|
|
343
|
+
"PATCH",
|
|
344
|
+
"/webhooks/:endpointId",
|
|
345
|
+
requireAuth,
|
|
346
|
+
withBody,
|
|
347
|
+
async (ctx) => {
|
|
348
|
+
if (!ctx.workspace)
|
|
349
|
+
return setApiResponse(
|
|
350
|
+
HTTP.UNPROCESSABLE,
|
|
351
|
+
"MISSING_WORKSPACE",
|
|
352
|
+
"Workspace context required"
|
|
353
|
+
);
|
|
354
|
+
const { endpointId } = ctx.meta["params"];
|
|
355
|
+
const body = ctx.meta["body"];
|
|
356
|
+
const patch = {};
|
|
357
|
+
if (body?.url !== void 0) patch.url = body.url;
|
|
358
|
+
if (body?.events !== void 0) patch.events = body.events;
|
|
359
|
+
if (body?.enabled !== void 0) patch.enabled = body.enabled;
|
|
360
|
+
const updated = await new EndpointModel(store).update(endpointId, ctx.workspace.id, patch);
|
|
361
|
+
if (!updated)
|
|
362
|
+
return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Webhook endpoint not found");
|
|
363
|
+
return setApiResponse(
|
|
364
|
+
HTTP.OK,
|
|
365
|
+
"WEBHOOK_UPDATED",
|
|
366
|
+
"Webhook endpoint updated.",
|
|
367
|
+
toEndpointDTO(updated)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
],
|
|
371
|
+
[
|
|
372
|
+
"DELETE",
|
|
373
|
+
"/webhooks/:endpointId",
|
|
374
|
+
requireAuth,
|
|
375
|
+
async (ctx) => {
|
|
376
|
+
if (!ctx.workspace)
|
|
377
|
+
return setApiResponse(
|
|
378
|
+
HTTP.UNPROCESSABLE,
|
|
379
|
+
"MISSING_WORKSPACE",
|
|
380
|
+
"Workspace context required"
|
|
381
|
+
);
|
|
382
|
+
const { endpointId } = ctx.meta["params"];
|
|
383
|
+
const deleted = await new EndpointModel(store).delete(endpointId, ctx.workspace.id);
|
|
384
|
+
if (!deleted)
|
|
385
|
+
return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Webhook endpoint not found");
|
|
386
|
+
return new Response(null, { status: HTTP.NO_CONTENT });
|
|
387
|
+
}
|
|
388
|
+
],
|
|
389
|
+
[
|
|
390
|
+
"GET",
|
|
391
|
+
"/webhooks/:endpointId/deliveries",
|
|
392
|
+
requireAuth,
|
|
393
|
+
async (ctx) => {
|
|
394
|
+
if (!ctx.workspace)
|
|
395
|
+
return setApiResponse(
|
|
396
|
+
HTTP.UNPROCESSABLE,
|
|
397
|
+
"MISSING_WORKSPACE",
|
|
398
|
+
"Workspace context required"
|
|
399
|
+
);
|
|
400
|
+
const { endpointId } = ctx.meta["params"];
|
|
401
|
+
const endpoint = await new EndpointModel(store).findById(endpointId, ctx.workspace.id);
|
|
402
|
+
if (!endpoint)
|
|
403
|
+
return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Webhook endpoint not found");
|
|
404
|
+
const list = await new DeliveryModel(store).listByEndpoint(endpointId);
|
|
405
|
+
return setApiResponse(HTTP.OK, "DELIVERIES_FETCHED", "Deliveries retrieved.", {
|
|
406
|
+
deliveries: list.map(toDeliveryDTO)
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
],
|
|
410
|
+
[
|
|
411
|
+
"POST",
|
|
412
|
+
"/webhooks/:endpointId/test",
|
|
413
|
+
requireAuth,
|
|
414
|
+
async (ctx) => {
|
|
415
|
+
if (!ctx.workspace)
|
|
416
|
+
return setApiResponse(
|
|
417
|
+
HTTP.UNPROCESSABLE,
|
|
418
|
+
"MISSING_WORKSPACE",
|
|
419
|
+
"Workspace context required"
|
|
420
|
+
);
|
|
421
|
+
const { endpointId } = ctx.meta["params"];
|
|
422
|
+
const endpoint = await new EndpointModel(store).findById(endpointId, ctx.workspace.id);
|
|
423
|
+
if (!endpoint)
|
|
424
|
+
return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Webhook endpoint not found");
|
|
425
|
+
const body = JSON.stringify({
|
|
426
|
+
id: `test-${Date.now()}`,
|
|
427
|
+
type: "webhook.test",
|
|
428
|
+
data: { workspaceId: ctx.workspace.id, message: "Test webhook delivery." }
|
|
429
|
+
});
|
|
430
|
+
try {
|
|
431
|
+
const res = await fetch(endpoint.url, {
|
|
432
|
+
method: "POST",
|
|
433
|
+
headers: {
|
|
434
|
+
"Content-Type": "application/json",
|
|
435
|
+
"X-Webhook-Signature": signPayload(endpoint.secret, body),
|
|
436
|
+
"X-Webhook-Event": "webhook.test"
|
|
437
|
+
},
|
|
438
|
+
body,
|
|
439
|
+
signal: AbortSignal.timeout(1e4)
|
|
440
|
+
});
|
|
441
|
+
return setApiResponse(HTTP.OK, "TEST_SENT", "Test delivery attempted.", {
|
|
442
|
+
status: res.status,
|
|
443
|
+
ok: res.ok
|
|
444
|
+
});
|
|
445
|
+
} catch (err) {
|
|
446
|
+
return setApiResponse(HTTP.OK, "TEST_SENT", "Test delivery attempted.", {
|
|
447
|
+
status: null,
|
|
448
|
+
ok: false,
|
|
449
|
+
error: err instanceof Error ? err.message : String(err)
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
]
|
|
454
|
+
];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/module.ts
|
|
458
|
+
var WebhooksModule = class {
|
|
459
|
+
constructor(store, config = {}, bus) {
|
|
460
|
+
this.store = store;
|
|
461
|
+
this.config = config;
|
|
462
|
+
this.bus = bus;
|
|
463
|
+
}
|
|
464
|
+
store;
|
|
465
|
+
config;
|
|
466
|
+
bus;
|
|
467
|
+
name = "@fonderie/webhooks";
|
|
468
|
+
deps = ["@fonderie/auth", "@fonderie/workspaces"];
|
|
469
|
+
retryTimer;
|
|
470
|
+
install(app) {
|
|
471
|
+
const dispatcher = new WebhookDispatcher(this.store, this.config);
|
|
472
|
+
this.bus?.on(
|
|
473
|
+
"*",
|
|
474
|
+
async (payload, meta) => {
|
|
475
|
+
await dispatcher.dispatch(payload, meta);
|
|
476
|
+
},
|
|
477
|
+
"webhooks"
|
|
478
|
+
);
|
|
479
|
+
const interval = this.config.retryInterval ?? 6e4;
|
|
480
|
+
this.retryTimer = setInterval(() => {
|
|
481
|
+
dispatcher.retry().catch((err) => console.error("[webhooks] retry error:", err));
|
|
482
|
+
}, interval);
|
|
483
|
+
const routes = buildWebhookRoutes(this.store, this.config);
|
|
484
|
+
for (const [method, path, ...handlers] of routes) {
|
|
485
|
+
app.addRoute(method, path, ...handlers);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
export {
|
|
490
|
+
WebhooksModule
|
|
491
|
+
};
|
|
492
|
+
//# sourceMappingURL=index.js.map
|