/ opencodehub / src / lib / webhooks.ts
swadhinbiswas fix(webhooks): harden event parsing and await dispatch results · 2/18/2026
0d21323
160 lines · 4.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
/**
 * Webhook Dispatch Service
 * Handles triggering and delivering webhooks
 */

import crypto from "crypto";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { getDatabase, schema } from "@/db";
import { generateId } from "./utils";
import { eq, and } from "drizzle-orm";
import { logger } from "./logger";

interface WebhookPayload {
    [key: string]: any;
}

/**
 * Trigger webhooks for a specific repository and event
 */
export async function triggerWebhooks(
    repositoryId: string,
    event: string,
    payload: WebhookPayload
): Promise<void> {
    const db = getDatabase() as NodePgDatabase<typeof schema>;

    // Find active webhooks for this repo
    const webhooks = await db.query.webhooks.findMany({
        where: and(
            eq(schema.webhooks.repositoryId, repositoryId),
            eq(schema.webhooks.active, true)
        ),
    });

    // Filter webhooks that subscribe to this event
    const matchingWebhooks = webhooks.filter((hook) => {
        let events: string[] = [];
        try {
            events = Array.isArray(hook.events) ? hook.events : JSON.parse(String(hook.events));
        } catch {
            logger.warn({ webhookId: hook.id }, "Skipping webhook with invalid events config");
            return false;
        }
        return events.includes(event) || events.includes("*");
    });

    if (matchingWebhooks.length === 0) return;

    logger.info(
        { repositoryId, event, webhooks: matchingWebhooks.length },
        "Triggering webhooks"
    );

    const results = await Promise.allSettled(
        matchingWebhooks.map((hook) => dispatchWebhook(hook, event, payload))
    );

    const failures = results.filter((result) => result.status === "rejected").length;
    if (failures > 0) {
        logger.warn({ repositoryId, event, failures }, "Some webhook dispatches failed");
    }
}

/**
 * Dispatch a single webhook
 */
async function dispatchWebhook(
    webhook: typeof schema.webhooks.$inferSelect,
    event: string,
    payload: WebhookPayload
): Promise<void> {
    const db = getDatabase() as NodePgDatabase<typeof schema>;
    const deliveryId = generateId();
    const startTime = Date.now();

    try {
        // Generate signature
        const signature = signPayload(payload, webhook.secret || "");

        // Prepare headers
        const headers: Record<string, string> = {
            "Content-Type": "application/json",
            "User-Agent": "OpenCodeHub-Hookshot/1.0",
            "X-OpenCodeHub-Event": event,
            "X-OpenCodeHub-Delivery": deliveryId,
            "X-Hub-Signature-256": `sha256=${signature}`,
        };

        // Make request
        const response = await fetch(webhook.url, {
            method: "POST",
            headers,
            body: JSON.stringify(payload),
        });

        const durationMs = Date.now() - startTime;
        const responseBody = await response.text();

        // Log delivery
        await db.insert(schema.webhookDeliveries).values({
            id: deliveryId,
            webhookId: webhook.id,
            event,
            payload: JSON.stringify(payload),
            status: response.ok ? "success" : "failure",
            responseCode: response.status,
            responseBody: responseBody.slice(0, 1000), // Truncate
            durationMs,
            requestHeaders: JSON.stringify(headers),
            responseHeaders: JSON.stringify(Object.fromEntries(response.headers.entries())),
        });

        // Update webhook stats
        await db
            .update(schema.webhooks)
            .set({
                deliveryCount: (webhook.deliveryCount || 0) + 1,
                lastDeliveryStatus: response.ok ? "success" : "failure",
                lastDeliveryAt: new Date(),
            })
            .where(eq(schema.webhooks.id, webhook.id));

    } catch (error: any) {
        const durationMs = Date.now() - startTime;

        // Log failure
        await db.insert(schema.webhookDeliveries).values({
            id: deliveryId,
            webhookId: webhook.id,
            event,
            payload: JSON.stringify(payload),
            status: "failure",
            responseCode: 0,
            error: error.message,
            durationMs,
        });

        // Update webhook stats
        await db
            .update(schema.webhooks)
            .set({
                deliveryCount: (webhook.deliveryCount || 0) + 1,
                lastDeliveryStatus: "failure",
                lastDeliveryAt: new Date(),
            })
            .where(eq(schema.webhooks.id, webhook.id));
    }
}

/**
 * Sign payload with secret using HMAC-SHA256
 */
function signPayload(payload: any, secret: string): string {
    if (!secret) return "";
    return crypto
        .createHmac("sha256", secret)
        .update(JSON.stringify(payload))
        .digest("hex");
}