All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
175 lines
4.6 KiB
TypeScript
175 lines
4.6 KiB
TypeScript
import crypto from 'crypto'
|
|
import { Prisma } from '@prisma/client'
|
|
import { prisma } from '@/lib/prisma'
|
|
|
|
/**
|
|
* Dispatch a webhook event to all active webhooks subscribed to this event.
|
|
*/
|
|
export async function dispatchWebhookEvent(
|
|
event: string,
|
|
payload: Record<string, unknown>
|
|
): Promise<number> {
|
|
const webhooks = await prisma.webhook.findMany({
|
|
where: {
|
|
isActive: true,
|
|
events: { has: event },
|
|
},
|
|
})
|
|
|
|
if (webhooks.length === 0) return 0
|
|
|
|
let deliveryCount = 0
|
|
|
|
for (const webhook of webhooks) {
|
|
try {
|
|
const delivery = await prisma.webhookDelivery.create({
|
|
data: {
|
|
webhookId: webhook.id,
|
|
event,
|
|
payload: payload as Prisma.InputJsonValue,
|
|
status: 'PENDING',
|
|
attempts: 0,
|
|
},
|
|
})
|
|
|
|
// Attempt delivery asynchronously (don't block the caller)
|
|
deliverWebhook(delivery.id).catch((err) => {
|
|
console.error(`[Webhook] Background delivery failed for ${delivery.id}:`, err)
|
|
})
|
|
|
|
deliveryCount++
|
|
} catch (error) {
|
|
console.error(`[Webhook] Failed to create delivery for webhook ${webhook.id}:`, error)
|
|
}
|
|
}
|
|
|
|
return deliveryCount
|
|
}
|
|
|
|
/**
|
|
* Attempt to deliver a single webhook.
|
|
*/
|
|
export async function deliverWebhook(deliveryId: string): Promise<void> {
|
|
const delivery = await prisma.webhookDelivery.findUnique({
|
|
where: { id: deliveryId },
|
|
include: { webhook: true },
|
|
})
|
|
|
|
if (!delivery || !delivery.webhook) {
|
|
console.error(`[Webhook] Delivery ${deliveryId} not found`)
|
|
return
|
|
}
|
|
|
|
const { webhook } = delivery
|
|
const payloadStr = JSON.stringify(delivery.payload)
|
|
|
|
// Sign payload with HMAC-SHA256
|
|
const signature = crypto
|
|
.createHmac('sha256', webhook.secret)
|
|
.update(payloadStr)
|
|
.digest('hex')
|
|
|
|
// Build headers
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'X-Webhook-Signature': `sha256=${signature}`,
|
|
'X-Webhook-Event': delivery.event,
|
|
'X-Webhook-Delivery': delivery.id,
|
|
}
|
|
|
|
// Merge custom headers from webhook config
|
|
if (webhook.headers && typeof webhook.headers === 'object') {
|
|
const customHeaders = webhook.headers as Record<string, string>
|
|
for (const [key, value] of Object.entries(customHeaders)) {
|
|
if (typeof value === 'string') {
|
|
headers[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), 30000) // 30s timeout
|
|
|
|
const response = await fetch(webhook.url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: payloadStr,
|
|
signal: controller.signal,
|
|
})
|
|
|
|
clearTimeout(timeout)
|
|
|
|
const responseBody = await response.text().catch(() => '')
|
|
|
|
await prisma.webhookDelivery.update({
|
|
where: { id: deliveryId },
|
|
data: {
|
|
status: response.ok ? 'DELIVERED' : 'FAILED',
|
|
responseStatus: response.status,
|
|
responseBody: responseBody.slice(0, 4000), // Truncate long responses
|
|
attempts: delivery.attempts + 1,
|
|
lastAttemptAt: new Date(),
|
|
},
|
|
})
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
|
|
await prisma.webhookDelivery.update({
|
|
where: { id: deliveryId },
|
|
data: {
|
|
status: 'FAILED',
|
|
responseBody: errorMessage.slice(0, 4000),
|
|
attempts: delivery.attempts + 1,
|
|
lastAttemptAt: new Date(),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retry all failed webhook deliveries that haven't exceeded max retries.
|
|
* Called by cron.
|
|
*/
|
|
export async function retryFailedDeliveries(): Promise<{
|
|
retried: number
|
|
errors: number
|
|
}> {
|
|
let retried = 0
|
|
let errors = 0
|
|
|
|
const failedDeliveries = await prisma.webhookDelivery.findMany({
|
|
where: {
|
|
status: 'FAILED',
|
|
},
|
|
include: {
|
|
webhook: {
|
|
select: { maxRetries: true, isActive: true },
|
|
},
|
|
},
|
|
})
|
|
|
|
for (const delivery of failedDeliveries) {
|
|
// Skip if webhook is inactive or max retries exceeded
|
|
if (!delivery.webhook.isActive) continue
|
|
if (delivery.attempts >= delivery.webhook.maxRetries) continue
|
|
|
|
try {
|
|
await deliverWebhook(delivery.id)
|
|
retried++
|
|
} catch (error) {
|
|
console.error(`[Webhook] Retry failed for delivery ${delivery.id}:`, error)
|
|
errors++
|
|
}
|
|
}
|
|
|
|
return { retried, errors }
|
|
}
|
|
|
|
/**
|
|
* Generate a random HMAC secret for webhook signing.
|
|
*/
|
|
export function generateWebhookSecret(): string {
|
|
return crypto.randomBytes(32).toString('hex')
|
|
}
|