@blokjs/trigger-webhook 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,480 @@
1
+ /**
2
+ * WebhookTrigger - Handle webhook events from external services
3
+ *
4
+ * Extends TriggerBase to process webhook events from:
5
+ * - GitHub (push, pull_request, issues, etc.)
6
+ * - Stripe (payment_intent, checkout.session, etc.)
7
+ * - Shopify (orders, products, customers)
8
+ * - Custom webhooks
9
+ *
10
+ * Features:
11
+ * - Signature verification for security
12
+ * - Event type filtering
13
+ * - Retry support
14
+ * - Dead letter handling
15
+ */
16
+
17
+ import crypto from "node:crypto";
18
+ import type { HelperResponse, WebhookTriggerOpts } from "@blokjs/helper";
19
+ import {
20
+ DefaultLogger,
21
+ type GlobalOptions,
22
+ type BlokService,
23
+ NodeMap,
24
+ TriggerBase,
25
+ type TriggerResponse,
26
+ } from "@blokjs/runner";
27
+ import type { Context, RequestContext } from "@blokjs/shared";
28
+ import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
29
+ import { v4 as uuid } from "uuid";
30
+
31
+ /**
32
+ * Webhook event structure
33
+ */
34
+ export interface WebhookEvent {
35
+ /** Unique event ID */
36
+ id: string;
37
+ /** Source service (github, stripe, shopify, custom) */
38
+ source: string;
39
+ /** Event type (e.g., push, payment_intent.succeeded) */
40
+ eventType: string;
41
+ /** Event payload */
42
+ payload: unknown;
43
+ /** Request headers */
44
+ headers: Record<string, string>;
45
+ /** Signature (if provided) */
46
+ signature?: string;
47
+ /** Timestamp */
48
+ timestamp: Date;
49
+ /** Raw request body */
50
+ rawBody: string;
51
+ }
52
+
53
+ /**
54
+ * Signature verification result
55
+ */
56
+ export interface VerificationResult {
57
+ valid: boolean;
58
+ error?: string;
59
+ }
60
+
61
+ /**
62
+ * Webhook source handlers
63
+ */
64
+ export interface WebhookSourceHandler {
65
+ /** Extract event type from request */
66
+ getEventType(headers: Record<string, string>, body: unknown): string;
67
+ /** Get signature from request */
68
+ getSignature(headers: Record<string, string>): string | undefined;
69
+ /** Verify signature */
70
+ verifySignature(rawBody: string, signature: string, secret: string): VerificationResult;
71
+ /** Get event ID */
72
+ getEventId(headers: Record<string, string>, body: unknown): string;
73
+ }
74
+
75
+ /**
76
+ * Workflow model with webhook trigger configuration
77
+ */
78
+ interface WebhookWorkflowModel {
79
+ path: string;
80
+ config: {
81
+ name: string;
82
+ version: string;
83
+ trigger?: {
84
+ webhook?: WebhookTriggerOpts;
85
+ [key: string]: unknown;
86
+ };
87
+ [key: string]: unknown;
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Built-in source handlers
93
+ */
94
+ const sourceHandlers: Record<string, WebhookSourceHandler> = {
95
+ github: {
96
+ getEventType: (headers) => headers["x-github-event"] || "unknown",
97
+ getSignature: (headers) => headers["x-hub-signature-256"] || headers["x-hub-signature"],
98
+ verifySignature: (rawBody, signature, secret) => {
99
+ const hmac = crypto.createHmac("sha256", secret);
100
+ const digest = `sha256=${hmac.update(rawBody).digest("hex")}`;
101
+ const sigBuffer = Buffer.from(signature);
102
+ const digestBuffer = Buffer.from(digest);
103
+ // Length check first to avoid timing attack on length
104
+ if (sigBuffer.length !== digestBuffer.length) {
105
+ return { valid: false, error: "Invalid GitHub signature" };
106
+ }
107
+ const valid = crypto.timingSafeEqual(sigBuffer, digestBuffer);
108
+ return { valid, error: valid ? undefined : "Invalid GitHub signature" };
109
+ },
110
+ getEventId: (headers) => headers["x-github-delivery"] || uuid(),
111
+ },
112
+
113
+ stripe: {
114
+ getEventType: (_, body) => (body as { type?: string })?.type || "unknown",
115
+ getSignature: (headers) => headers["stripe-signature"],
116
+ verifySignature: (rawBody, signature, secret) => {
117
+ // Stripe signature format: t=timestamp,v1=signature
118
+ const parts = signature.split(",").reduce(
119
+ (acc, part) => {
120
+ const [key, value] = part.split("=");
121
+ acc[key] = value;
122
+ return acc;
123
+ },
124
+ {} as Record<string, string>,
125
+ );
126
+
127
+ const timestamp = parts.t;
128
+ const expectedSig = parts.v1;
129
+
130
+ if (!timestamp || !expectedSig) {
131
+ return { valid: false, error: "Invalid Stripe signature format" };
132
+ }
133
+
134
+ const payload = `${timestamp}.${rawBody}`;
135
+ const hmac = crypto.createHmac("sha256", secret);
136
+ const computedSig = hmac.update(payload).digest("hex");
137
+
138
+ const sigBuffer = Buffer.from(expectedSig);
139
+ const computedBuffer = Buffer.from(computedSig);
140
+ if (sigBuffer.length !== computedBuffer.length) {
141
+ return { valid: false, error: "Invalid Stripe signature" };
142
+ }
143
+ const valid = crypto.timingSafeEqual(sigBuffer, computedBuffer);
144
+ return { valid, error: valid ? undefined : "Invalid Stripe signature" };
145
+ },
146
+ getEventId: (_, body) => (body as { id?: string })?.id || uuid(),
147
+ },
148
+
149
+ shopify: {
150
+ getEventType: (headers) => headers["x-shopify-topic"] || "unknown",
151
+ getSignature: (headers) => headers["x-shopify-hmac-sha256"],
152
+ verifySignature: (rawBody, signature, secret) => {
153
+ const hmac = crypto.createHmac("sha256", secret);
154
+ const digest = hmac.update(rawBody, "utf8").digest("base64");
155
+ const sigBuffer = Buffer.from(signature, "base64");
156
+ const digestBuffer = Buffer.from(digest, "base64");
157
+ if (sigBuffer.length !== digestBuffer.length) {
158
+ return { valid: false, error: "Invalid Shopify signature" };
159
+ }
160
+ const valid = crypto.timingSafeEqual(sigBuffer, digestBuffer);
161
+ return { valid, error: valid ? undefined : "Invalid Shopify signature" };
162
+ },
163
+ getEventId: (headers) => headers["x-shopify-webhook-id"] || uuid(),
164
+ },
165
+
166
+ custom: {
167
+ getEventType: (headers, body) => headers["x-event-type"] || (body as { event?: string })?.event || "custom",
168
+ getSignature: (headers) => headers["x-signature"] || headers["x-webhook-signature"],
169
+ verifySignature: (rawBody, signature, secret) => {
170
+ // Default: HMAC-SHA256
171
+ const hmac = crypto.createHmac("sha256", secret);
172
+ const digest = hmac.update(rawBody).digest("hex");
173
+ const valid = signature === digest || signature === `sha256=${digest}`;
174
+ return { valid, error: valid ? undefined : "Invalid signature" };
175
+ },
176
+ getEventId: (headers, body) => headers["x-event-id"] || (body as { id?: string })?.id || uuid(),
177
+ },
178
+ };
179
+
180
+ /**
181
+ * WebhookTrigger - Handle webhook events
182
+ */
183
+ export abstract class WebhookTrigger extends TriggerBase {
184
+ protected nodeMap: GlobalOptions = {} as GlobalOptions;
185
+ protected readonly tracer = trace.getTracer(
186
+ process.env.PROJECT_NAME || "trigger-webhook-workflow",
187
+ process.env.PROJECT_VERSION || "0.0.1",
188
+ );
189
+ protected readonly logger = new DefaultLogger();
190
+ protected webhookWorkflows: WebhookWorkflowModel[] = [];
191
+
192
+ // Subclasses provide these
193
+ protected abstract nodes: Record<string, BlokService<unknown>>;
194
+ protected abstract workflows: Record<string, HelperResponse>;
195
+
196
+ constructor() {
197
+ super();
198
+ this.loadNodes();
199
+ this.loadWorkflows();
200
+ }
201
+
202
+ /**
203
+ * Load nodes into the node map
204
+ */
205
+ loadNodes(): void {
206
+ this.nodeMap.nodes = new NodeMap();
207
+ const nodeKeys = Object.keys(this.nodes);
208
+ for (const key of nodeKeys) {
209
+ this.nodeMap.nodes.addNode(key, this.nodes[key]);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Load workflows into the workflow map
215
+ */
216
+ loadWorkflows(): void {
217
+ this.nodeMap.workflows = this.workflows;
218
+ }
219
+
220
+ /**
221
+ * Initialize webhook trigger (call after loading workflows)
222
+ */
223
+ async listen(): Promise<number> {
224
+ const startTime = this.startCounter();
225
+
226
+ // Find all workflows with webhook triggers
227
+ this.webhookWorkflows = this.getWebhookWorkflows();
228
+
229
+ if (this.webhookWorkflows.length === 0) {
230
+ this.logger.log("No workflows with webhook triggers found");
231
+ } else {
232
+ this.logger.log(`Webhook trigger initialized. ${this.webhookWorkflows.length} workflow(s) registered`);
233
+ }
234
+
235
+ // Enable HMR in development mode
236
+ if (process.env.BLOK_HMR === "true" || process.env.NODE_ENV === "development") {
237
+ await this.enableHotReload();
238
+ }
239
+
240
+ return this.endCounter(startTime);
241
+ }
242
+
243
+ /**
244
+ * Stop the webhook trigger
245
+ */
246
+ async stop(): Promise<void> {
247
+ this.webhookWorkflows = [];
248
+ this.logger.log("Webhook trigger stopped");
249
+ }
250
+
251
+ protected override async onHmrWorkflowChange(): Promise<void> {
252
+ this.loadWorkflows();
253
+ this.webhookWorkflows = this.getWebhookWorkflows();
254
+ this.logger.log(`[HMR] Webhook workflows reloaded. ${this.webhookWorkflows.length} workflow(s) registered`);
255
+ }
256
+
257
+ /**
258
+ * Process an incoming webhook request
259
+ * Call this from your HTTP endpoint handler
260
+ */
261
+ async handleWebhook(
262
+ source: string,
263
+ rawBody: string,
264
+ headers: Record<string, string>,
265
+ ): Promise<TriggerResponse | null> {
266
+ const handler = sourceHandlers[source] || sourceHandlers.custom;
267
+
268
+ // Parse body
269
+ let body: unknown;
270
+ try {
271
+ body = JSON.parse(rawBody);
272
+ } catch {
273
+ body = rawBody;
274
+ }
275
+
276
+ // Create webhook event
277
+ const event: WebhookEvent = {
278
+ id: handler.getEventId(headers, body),
279
+ source,
280
+ eventType: handler.getEventType(headers, body),
281
+ payload: body,
282
+ headers,
283
+ signature: handler.getSignature(headers),
284
+ timestamp: new Date(),
285
+ rawBody,
286
+ };
287
+
288
+ // Find matching workflow
289
+ const workflow = this.findMatchingWorkflow(event);
290
+ if (!workflow) {
291
+ this.logger.log(`No matching workflow for webhook: ${source}/${event.eventType}`);
292
+ return null;
293
+ }
294
+
295
+ const config = workflow.config.trigger?.webhook as WebhookTriggerOpts;
296
+
297
+ // Verify signature if secret is configured
298
+ if (config.secret && event.signature) {
299
+ const verification = handler.verifySignature(rawBody, event.signature, config.secret);
300
+ if (!verification.valid) {
301
+ this.logger.error(`Webhook signature verification failed: ${verification.error}`);
302
+ throw new Error(`Signature verification failed: ${verification.error}`);
303
+ }
304
+ } else if (config.secret && !event.signature) {
305
+ this.logger.error("Webhook signature missing but secret is configured");
306
+ throw new Error("Signature missing");
307
+ }
308
+
309
+ return this.executeWorkflow(event, workflow, config);
310
+ }
311
+
312
+ /**
313
+ * Get all workflows that have webhook triggers
314
+ */
315
+ protected getWebhookWorkflows(): WebhookWorkflowModel[] {
316
+ const workflows: WebhookWorkflowModel[] = [];
317
+
318
+ for (const [path, workflow] of Object.entries(this.nodeMap.workflows || {})) {
319
+ const workflowConfig = (workflow as unknown as { _config: WebhookWorkflowModel["config"] })._config;
320
+
321
+ if (workflowConfig?.trigger) {
322
+ const triggerType = Object.keys(workflowConfig.trigger)[0];
323
+
324
+ if (triggerType === "webhook" && workflowConfig.trigger.webhook) {
325
+ workflows.push({
326
+ path,
327
+ config: workflowConfig,
328
+ });
329
+ }
330
+ }
331
+ }
332
+
333
+ return workflows;
334
+ }
335
+
336
+ /**
337
+ * Find workflow matching the webhook event
338
+ */
339
+ protected findMatchingWorkflow(event: WebhookEvent): WebhookWorkflowModel | null {
340
+ for (const workflow of this.webhookWorkflows) {
341
+ const config = workflow.config.trigger?.webhook;
342
+ if (!config) continue;
343
+
344
+ // Check source match
345
+ if (config.source !== event.source) continue;
346
+
347
+ // Check event type match
348
+ if (config.events && config.events.length > 0) {
349
+ const matches = config.events.some((pattern) => {
350
+ // Support wildcards (e.g., "push", "pull_request.*")
351
+ if (pattern === "*") return true;
352
+ if (pattern.endsWith(".*")) {
353
+ const prefix = pattern.slice(0, -2);
354
+ return event.eventType.startsWith(prefix);
355
+ }
356
+ return pattern === event.eventType;
357
+ });
358
+ if (!matches) continue;
359
+ }
360
+
361
+ return workflow;
362
+ }
363
+
364
+ return null;
365
+ }
366
+
367
+ /**
368
+ * Execute a workflow for a webhook event
369
+ */
370
+ protected async executeWorkflow(
371
+ event: WebhookEvent,
372
+ workflow: WebhookWorkflowModel,
373
+ _config: WebhookTriggerOpts,
374
+ ): Promise<TriggerResponse> {
375
+ const executionId = uuid();
376
+
377
+ const defaultMeter = metrics.getMeter("default");
378
+ const webhookExecutions = defaultMeter.createCounter("webhook_executions", {
379
+ description: "Webhook executions",
380
+ });
381
+ const webhookErrors = defaultMeter.createCounter("webhook_errors", {
382
+ description: "Webhook execution errors",
383
+ });
384
+
385
+ return new Promise((resolve) => {
386
+ this.tracer.startActiveSpan(`webhook:${event.source}/${event.eventType}`, async (span: Span) => {
387
+ try {
388
+ const start = performance.now();
389
+
390
+ // Initialize configuration for this workflow
391
+ await this.configuration.init(workflow.path, this.nodeMap);
392
+
393
+ // Create context
394
+ const ctx: Context = this.createContext(undefined, workflow.path, executionId);
395
+
396
+ // Populate request with webhook event
397
+ ctx.request = {
398
+ body: event.payload,
399
+ headers: event.headers,
400
+ query: {},
401
+ params: {
402
+ source: event.source,
403
+ eventType: event.eventType,
404
+ eventId: event.id,
405
+ },
406
+ } as unknown as RequestContext;
407
+
408
+ // Store webhook context in vars
409
+ if (!ctx.vars) ctx.vars = {};
410
+ ctx.vars._webhook_event = {
411
+ id: event.id,
412
+ source: event.source,
413
+ eventType: event.eventType,
414
+ timestamp: event.timestamp.toISOString(),
415
+ hasSignature: String(!!event.signature),
416
+ };
417
+
418
+ ctx.logger.log(`Processing webhook: ${event.source}/${event.eventType} (${event.id})`);
419
+
420
+ // Execute workflow
421
+ const response: TriggerResponse = await this.run(ctx);
422
+ const end = performance.now();
423
+
424
+ // Set span attributes
425
+ span.setAttribute("success", true);
426
+ span.setAttribute("event_id", event.id);
427
+ span.setAttribute("source", event.source);
428
+ span.setAttribute("event_type", event.eventType);
429
+ span.setAttribute("workflow_path", workflow.path);
430
+ span.setAttribute("elapsed_ms", end - start);
431
+ span.setStatus({ code: SpanStatusCode.OK });
432
+
433
+ // Record metrics
434
+ webhookExecutions.add(1, {
435
+ env: process.env.NODE_ENV,
436
+ source: event.source,
437
+ event_type: event.eventType,
438
+ workflow_name: this.configuration.name,
439
+ success: "true",
440
+ });
441
+
442
+ ctx.logger.log(`Webhook processed in ${(end - start).toFixed(2)}ms: ${event.id}`);
443
+
444
+ resolve(response);
445
+ } catch (error) {
446
+ const errorMessage = (error as Error).message;
447
+
448
+ // Set span error
449
+ span.setAttribute("success", false);
450
+ span.recordException(error as Error);
451
+ span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage });
452
+
453
+ // Record error metrics
454
+ webhookErrors.add(1, {
455
+ env: process.env.NODE_ENV,
456
+ source: event.source,
457
+ event_type: event.eventType,
458
+ workflow_name: this.configuration?.name || "unknown",
459
+ });
460
+
461
+ this.logger.error(`Webhook failed ${event.id}: ${errorMessage}`, (error as Error).stack);
462
+
463
+ throw error;
464
+ } finally {
465
+ span.end();
466
+ }
467
+ });
468
+ });
469
+ }
470
+
471
+ /**
472
+ * Register a custom source handler
473
+ */
474
+ static registerSourceHandler(source: string, handler: WebhookSourceHandler): void {
475
+ sourceHandlers[source] = handler;
476
+ }
477
+ }
478
+
479
+ export default WebhookTrigger;
480
+ export { sourceHandlers };
package/src/index.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @blokjs/trigger-webhook
3
+ *
4
+ * Webhook trigger for Blok workflows.
5
+ * Handle webhook events from external services.
6
+ *
7
+ * Supported Services:
8
+ * - GitHub (push, pull_request, issues, releases, etc.)
9
+ * - Stripe (payment_intent, checkout.session, customer, etc.)
10
+ * - Shopify (orders, products, customers, etc.)
11
+ * - Custom webhooks (any service with signature verification)
12
+ *
13
+ * Features:
14
+ * - Signature verification (HMAC-SHA256)
15
+ * - Event type filtering
16
+ * - Source-specific handlers
17
+ * - Custom source registration
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import { WebhookTrigger } from "@blokjs/trigger-webhook";
22
+ *
23
+ * class MyWebhookTrigger extends WebhookTrigger {
24
+ * protected nodes = myNodes;
25
+ * protected workflows = myWorkflows;
26
+ * }
27
+ *
28
+ * const trigger = new MyWebhookTrigger();
29
+ * await trigger.listen();
30
+ *
31
+ * // In your HTTP endpoint handler:
32
+ * app.post("/webhooks/:source", async (req, res) => {
33
+ * const rawBody = JSON.stringify(req.body);
34
+ * const result = await trigger.handleWebhook(
35
+ * req.params.source,
36
+ * rawBody,
37
+ * req.headers as Record<string, string>
38
+ * );
39
+ * res.status(200).json({ received: true });
40
+ * });
41
+ * ```
42
+ *
43
+ * Workflow Definition:
44
+ * ```typescript
45
+ * Workflow({ name: "github-push", version: "1.0.0" })
46
+ * .addTrigger("webhook", {
47
+ * source: "github",
48
+ * events: ["push", "pull_request.*"],
49
+ * secret: process.env.GITHUB_WEBHOOK_SECRET,
50
+ * })
51
+ * .addStep({ ... });
52
+ * ```
53
+ *
54
+ * Custom Source Handler:
55
+ * ```typescript
56
+ * import { WebhookTrigger } from "@blokjs/trigger-webhook";
57
+ *
58
+ * WebhookTrigger.registerSourceHandler("my-service", {
59
+ * getEventType: (headers, body) => body.event_type,
60
+ * getSignature: (headers) => headers["x-my-signature"],
61
+ * verifySignature: (rawBody, signature, secret) => {
62
+ * // Your verification logic
63
+ * return { valid: true };
64
+ * },
65
+ * getEventId: (headers, body) => body.id,
66
+ * });
67
+ * ```
68
+ */
69
+
70
+ // Core exports
71
+ export {
72
+ WebhookTrigger,
73
+ sourceHandlers,
74
+ type WebhookEvent,
75
+ type VerificationResult,
76
+ type WebhookSourceHandler,
77
+ } from "./WebhookTrigger";
78
+
79
+ // Re-export types from helper for convenience
80
+ export type { WebhookTriggerOpts } from "@blokjs/helper";
package/tsconfig.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "ts-node": {
3
+ "transpileOnly": true
4
+ },
5
+ "compilerOptions": {
6
+ "target": "ES2022",
7
+ "module": "es2022",
8
+ "lib": ["ES2022"],
9
+ "declaration": true,
10
+ "strict": true,
11
+ "noImplicitAny": true,
12
+ "strictNullChecks": true,
13
+ "noImplicitThis": true,
14
+ "alwaysStrict": true,
15
+ "noUnusedLocals": false,
16
+ "noUnusedParameters": false,
17
+ "noImplicitReturns": true,
18
+ "noFallthroughCasesInSwitch": false,
19
+ "inlineSourceMap": true,
20
+ "inlineSources": true,
21
+ "experimentalDecorators": true,
22
+ "emitDecoratorMetadata": true,
23
+ "skipLibCheck": true,
24
+ "esModuleInterop": true,
25
+ "resolveJsonModule": true,
26
+ "outDir": "./dist",
27
+ "rootDir": "./src",
28
+ "moduleResolution": "bundler"
29
+ },
30
+ "include": ["src/**/*"],
31
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
32
+ }