@checkstack/integration-webhook-backend 0.0.34 → 0.1.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/src/provider.ts DELETED
@@ -1,347 +0,0 @@
1
- import { z } from "zod";
2
- import {
3
- Versioned,
4
- configNumber,
5
- configString,
6
- requestTimeoutMs,
7
- } from "@checkstack/backend-api";
8
- import type {
9
- IntegrationProvider,
10
- IntegrationDeliveryContext,
11
- IntegrationDeliveryResult,
12
- } from "@checkstack/integration-backend";
13
- import { extractErrorMessage } from "@checkstack/common";
14
-
15
- // =============================================================================
16
- // Template Expansion Helper
17
- // =============================================================================
18
-
19
- /**
20
- * Expand mustache-style templates with context values.
21
- * Supports {{path.to.value}} syntax with nested object access.
22
- */
23
- function expandTemplate(
24
- template: string,
25
- context: Record<string, unknown>,
26
- ): string {
27
- return template.replaceAll(/\{\{([^}]+)\}\}/g, (_match, path: string) => {
28
- const trimmedPath = path.trim();
29
- const parts = trimmedPath.split(".");
30
-
31
- let value: unknown = context;
32
- for (const part of parts) {
33
- if (value === null || value === undefined) {
34
- return "";
35
- }
36
- value = (value as Record<string, unknown>)[part];
37
- }
38
-
39
- // Return JSON stringified for objects/arrays, raw string for primitives
40
- if (value === null || value === undefined) {
41
- return "";
42
- }
43
- if (typeof value === "object") {
44
- return JSON.stringify(value);
45
- }
46
- return String(value);
47
- });
48
- }
49
-
50
- // =============================================================================
51
- // Webhook Configuration Schema
52
- // =============================================================================
53
-
54
- /**
55
- * Header configuration for custom HTTP headers.
56
- */
57
- const webhookHeaderSchema = z.object({
58
- name: z.string().min(1).describe("Header name"),
59
- value: z.string().describe("Header value"),
60
- });
61
-
62
- /**
63
- * Webhook provider configuration schema.
64
- * Supports various authentication methods and customization options.
65
- */
66
- export const webhookConfigSchemaV1 = z.object({
67
- url: configString({})
68
- .url()
69
- .describe("The webhook endpoint URL to send events to"),
70
- method: z
71
- .enum(["POST", "PUT", "PATCH"])
72
- .default("POST")
73
- .describe("HTTP method to use"),
74
- contentType: z
75
- .enum(["application/json", "application/x-www-form-urlencoded"])
76
- .default("application/json")
77
- .describe("Content-Type header for the request"),
78
-
79
- // Authentication
80
- authType: z
81
- .enum(["none", "bearer", "basic", "header"])
82
- .default("none")
83
- .describe("Authentication method"),
84
- bearerToken: configString({ "x-secret": true })
85
- .describe("Bearer token for authentication")
86
- .optional(),
87
- basicUsername: configString({})
88
- .optional()
89
- .describe("Username for Basic auth"),
90
- basicPassword: configString({ "x-secret": true })
91
- .describe("Password for Basic auth")
92
- .optional(),
93
- authHeaderName: configString({})
94
- .optional()
95
- .describe("Custom header name for token auth (e.g., X-API-Key)"),
96
- authHeaderValue: configString({ "x-secret": true })
97
- .describe("Custom header value")
98
- .optional(),
99
-
100
- // Additional options
101
- customHeaders: z
102
- .array(webhookHeaderSchema)
103
- .optional()
104
- .describe("Additional custom headers to include"),
105
-
106
- /** Custom body template. Use {{payload.field}} syntax for templating. */
107
- bodyTemplate: configString({
108
- "x-editor-types": ["raw", "json", "yaml", "xml", "formdata"],
109
- })
110
- .optional()
111
- .describe(
112
- "Custom request body template. Use {{payload.field}} syntax to include event data. Leave empty for default JSON payload.",
113
- ),
114
-
115
- timeout: requestTimeoutMs().describe("Request timeout in milliseconds"),
116
- retryOnStatus: z
117
- .array(configNumber({}))
118
- .optional()
119
- .describe("HTTP status codes that should trigger a retry (e.g., 429, 503)"),
120
- });
121
-
122
- export type WebhookConfig = z.infer<typeof webhookConfigSchemaV1>;
123
-
124
- // =============================================================================
125
- // Webhook Provider Implementation
126
- // =============================================================================
127
-
128
- /**
129
- * Webhook integration provider.
130
- * Delivers events as HTTP POST requests to configured endpoints.
131
- */
132
- export const webhookProvider: IntegrationProvider<WebhookConfig> = {
133
- id: "webhook",
134
- displayName: "Webhook",
135
- description: "Deliver events via HTTP POST to external endpoints",
136
- icon: "Webhook",
137
-
138
- config: new Versioned({
139
- version: 1,
140
- schema: webhookConfigSchemaV1,
141
- }),
142
-
143
- documentation: {
144
- setupGuide: `Your endpoint will receive HTTP POST requests with JSON payloads.
145
-
146
- Configure your server to:
147
- 1. Accept POST requests at your configured URL
148
- 2. Return a 2xx status code on success
149
- 3. Optionally return a JSON response with an \`id\` field for tracking`,
150
- examplePayload: JSON.stringify(
151
- {
152
- id: "del_abc123",
153
- eventType: "incident.created",
154
- timestamp: "2024-01-15T10:30:00.000Z",
155
- subscription: {
156
- id: "sub_xyz",
157
- name: "My Webhook",
158
- },
159
- data: {
160
- incidentId: "inc_123",
161
- title: "API degraded performance",
162
- severity: "warning",
163
- },
164
- },
165
-
166
- null,
167
- 2,
168
- ),
169
- headers: [
170
- {
171
- name: "Content-Type",
172
- description: "application/json (or application/x-www-form-urlencoded)",
173
- },
174
- {
175
- name: "X-Delivery-Id",
176
- description: "Unique ID for this delivery attempt",
177
- },
178
- {
179
- name: "X-Event-Type",
180
- description: "The event type (e.g., incident.created)",
181
- },
182
- { name: "User-Agent", description: "Checkstack-Integration/1.0" },
183
- ],
184
- },
185
-
186
- async deliver(
187
- context: IntegrationDeliveryContext<WebhookConfig>,
188
- ): Promise<IntegrationDeliveryResult> {
189
- const { event, subscription, providerConfig, logger } = context;
190
-
191
- // Validate config
192
- const config = webhookConfigSchemaV1.parse(providerConfig);
193
-
194
- // Build template context for all template-able fields
195
- const templateContext = {
196
- deliveryId: event.deliveryId,
197
- eventType: event.eventId,
198
- eventId: event.eventId,
199
- timestamp: event.timestamp,
200
- subscriptionId: subscription.id,
201
- subscriptionName: subscription.name,
202
- payload: event.payload,
203
- };
204
-
205
- // Expand URL template if it contains template syntax
206
- const url = expandTemplate(config.url, templateContext);
207
-
208
- // Build headers
209
- const headers: Record<string, string> = {
210
- "Content-Type": config.contentType,
211
- "User-Agent": "Checkstack-Integration/1.0",
212
- "X-Delivery-Id": event.deliveryId,
213
- "X-Event-Type": event.eventId,
214
- };
215
-
216
- // Add authentication headers
217
- switch (config.authType) {
218
- case "bearer": {
219
- if (config.bearerToken) {
220
- headers["Authorization"] = `Bearer ${config.bearerToken}`;
221
- }
222
- break;
223
- }
224
- case "basic": {
225
- if (config.basicUsername && config.basicPassword) {
226
- const credentials = Buffer.from(
227
- `${config.basicUsername}:${config.basicPassword}`,
228
- ).toString("base64");
229
- headers["Authorization"] = `Basic ${credentials}`;
230
- }
231
- break;
232
- }
233
- case "header": {
234
- if (config.authHeaderName && config.authHeaderValue) {
235
- headers[config.authHeaderName] = config.authHeaderValue;
236
- }
237
- break;
238
- }
239
- }
240
-
241
- // Add custom headers (with template expansion)
242
- if (config.customHeaders) {
243
- for (const header of config.customHeaders) {
244
- headers[header.name] = expandTemplate(header.value, templateContext);
245
- }
246
- }
247
-
248
- // Build request body
249
- let body: string;
250
-
251
- if (config.bodyTemplate) {
252
- // Use custom template with context
253
- body = expandTemplate(config.bodyTemplate, templateContext);
254
- } else {
255
- // Default payload format
256
- const payload = {
257
- id: event.deliveryId,
258
- eventType: event.eventId,
259
- timestamp: event.timestamp,
260
- subscription: {
261
- id: subscription.id,
262
- name: subscription.name,
263
- },
264
- data: event.payload,
265
- };
266
-
267
- body =
268
- config.contentType === "application/json"
269
- ? JSON.stringify(payload)
270
- : new URLSearchParams({
271
- payload: JSON.stringify(payload),
272
- }).toString();
273
- }
274
-
275
- logger.debug(`Delivering webhook to ${url} for event ${event.eventId}`);
276
-
277
- try {
278
- const response = await fetch(url, {
279
- method: config.method,
280
- headers,
281
- body,
282
- signal: AbortSignal.timeout(config.timeout),
283
- });
284
-
285
- const responseText = await response.text();
286
-
287
- // Check if we should retry based on status code
288
- if (config.retryOnStatus?.includes(response.status)) {
289
- // Check for Retry-After header
290
- const retryAfter = response.headers.get("Retry-After");
291
- const retryAfterMs = retryAfter
292
- ? Number.parseInt(retryAfter, 10) * 1000
293
- : 30_000;
294
-
295
- return {
296
- success: false,
297
- error: `Received status ${response.status} (retryable)`,
298
- retryAfterMs,
299
- };
300
- }
301
-
302
- if (!response.ok) {
303
- return {
304
- success: false,
305
- error: `HTTP ${response.status}: ${responseText.slice(0, 200)}`,
306
- };
307
- }
308
-
309
- logger.debug(`Webhook delivered successfully: ${response.status}`);
310
-
311
- // Try to extract external ID from response
312
- let externalId: string | undefined;
313
- try {
314
- const json = JSON.parse(responseText);
315
- externalId = json.id ?? json.externalId ?? json.messageId;
316
- } catch {
317
- // Response wasn't JSON, that's fine
318
- }
319
-
320
- return {
321
- success: true,
322
- externalId,
323
- };
324
- } catch (error) {
325
- const message = extractErrorMessage(error);
326
- logger.error(`Webhook delivery failed: ${message}`);
327
-
328
- // Network errors should trigger retry
329
- if (
330
- message.includes("timeout") ||
331
- message.includes("ECONNREFUSED") ||
332
- message.includes("ENOTFOUND")
333
- ) {
334
- return {
335
- success: false,
336
- error: message,
337
- retryAfterMs: 30_000,
338
- };
339
- }
340
-
341
- return {
342
- success: false,
343
- error: message,
344
- };
345
- }
346
- },
347
- };