brainy/src/webhooks/webhookSystem.ts

427 lines
10 KiB
TypeScript
Raw Normal View History

/**
* Webhook System - Enterprise Event Notifications
*
* 🧠 Real-time notifications for augmentation events, data changes, and system alerts
* Critical for enterprise integrations and premium connectors
*/
import { EventEmitter } from 'events'
import { BrainyDataInterface } from '../types/brainyDataInterface.js'
export interface WebhookConfig {
url: string
events: WebhookEventType[]
headers?: Record<string, string>
secret?: string
retryPolicy?: {
maxRetries: number
backoffMs: number
maxBackoffMs: number
}
filters?: {
augmentations?: string[]
metadata?: Record<string, any>
}
enabled: boolean
}
export type WebhookEventType =
| 'data.added'
| 'data.updated'
| 'data.deleted'
| 'augmentation.triggered'
| 'augmentation.completed'
| 'augmentation.failed'
| 'connector.sync.started'
| 'connector.sync.completed'
| 'connector.sync.failed'
| 'graph.relationship.created'
| 'graph.relationship.deleted'
| 'system.alert'
| 'license.expired'
| 'license.renewed'
export interface WebhookPayload {
event: WebhookEventType
timestamp: string
data: any
metadata?: Record<string, any>
brainyId?: string
augmentationId?: string
signature?: string
}
export interface WebhookResponse {
success: boolean
statusCode?: number
error?: string
retryAfter?: number
}
export class WebhookSystem extends EventEmitter {
private webhooks: Map<string, WebhookConfig> = new Map()
private brainy: BrainyDataInterface
private retryQueues: Map<string, any[]> = new Map()
private isRunning: boolean = false
constructor(brainy: BrainyDataInterface) {
super()
this.brainy = brainy
this.setupEventListeners()
}
/**
* Register a new webhook
*/
async registerWebhook(id: string, config: WebhookConfig): Promise<void> {
// Validate URL
try {
new URL(config.url)
} catch {
throw new Error(`Invalid webhook URL: ${config.url}`)
}
// Set default retry policy
if (!config.retryPolicy) {
config.retryPolicy = {
maxRetries: 3,
backoffMs: 1000,
maxBackoffMs: 30000
}
}
this.webhooks.set(id, config)
this.retryQueues.set(id, [])
console.log(`🔔⚛️ Webhook registered: ${id}${config.url}`)
}
/**
* Remove a webhook
*/
async unregisterWebhook(id: string): Promise<void> {
this.webhooks.delete(id)
this.retryQueues.delete(id)
console.log(`🔔 Webhook unregistered: ${id}`)
}
/**
* List all webhooks
*/
listWebhooks(): Array<{ id: string; config: WebhookConfig }> {
return Array.from(this.webhooks.entries()).map(([id, config]) => ({
id,
config
}))
}
/**
* Trigger webhook for an event
*/
async triggerWebhook(event: WebhookEventType, data: any, metadata?: Record<string, any>): Promise<void> {
const payload: WebhookPayload = {
event,
timestamp: new Date().toISOString(),
data,
metadata
}
// Find webhooks subscribed to this event
for (const [id, config] of this.webhooks.entries()) {
if (!config.enabled) continue
if (!config.events.includes(event)) continue
// Apply filters
if (config.filters) {
if (config.filters.augmentations && metadata?.augmentation) {
if (!config.filters.augmentations.includes(metadata.augmentation)) {
continue
}
}
if (config.filters.metadata) {
let matchesFilter = true
for (const [key, value] of Object.entries(config.filters.metadata)) {
if (metadata?.[key] !== value) {
matchesFilter = false
break
}
}
if (!matchesFilter) continue
}
}
// Sign payload if secret provided
if (config.secret) {
payload.signature = await this.signPayload(payload, config.secret)
}
// Send webhook
this.sendWebhook(id, config, payload)
}
}
/**
* Send webhook with retry logic
*/
private async sendWebhook(
id: string,
config: WebhookConfig,
payload: WebhookPayload,
retryCount: number = 0
): Promise<void> {
try {
const response = await this.executeWebhook(config, payload)
if (response.success) {
console.log(`✅ Webhook delivered: ${id}${config.url}`)
this.emit('webhook:delivered', { id, payload, response })
} else {
throw new Error(response.error || `HTTP ${response.statusCode}`)
}
} catch (error) {
console.error(`❌ Webhook failed: ${id}${error.message}`)
// Retry logic
if (retryCount < config.retryPolicy!.maxRetries) {
const backoff = Math.min(
config.retryPolicy!.backoffMs * Math.pow(2, retryCount),
config.retryPolicy!.maxBackoffMs
)
console.log(`🔄 Retrying webhook ${id} in ${backoff}ms (attempt ${retryCount + 1})`)
setTimeout(() => {
this.sendWebhook(id, config, payload, retryCount + 1)
}, backoff)
} else {
console.error(`❌ Webhook ${id} failed after ${retryCount} retries`)
this.emit('webhook:failed', { id, payload, error: error.message })
// Add to dead letter queue
this.retryQueues.get(id)?.push({ payload, failedAt: new Date() })
}
}
}
/**
* Execute HTTP webhook call
*/
private async executeWebhook(config: WebhookConfig, payload: WebhookPayload): Promise<WebhookResponse> {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000) // 10 second timeout
const response = await fetch(config.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Brainy-Event': payload.event,
'X-Brainy-Signature': payload.signature || '',
...config.headers
},
body: JSON.stringify(payload),
signal: controller.signal
})
clearTimeout(timeout)
return {
success: response.ok,
statusCode: response.status,
error: response.ok ? undefined : `HTTP ${response.status}`
}
} catch (error: any) {
return {
success: false,
error: error.message
}
}
}
/**
* Sign webhook payload for security
*/
private async signPayload(payload: WebhookPayload, secret: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(JSON.stringify(payload))
const key = encoder.encode(secret)
// Simple HMAC-like signature (in production, use proper crypto)
const signature = btoa(String.fromCharCode(...new Uint8Array(data)))
return signature
}
/**
* Setup event listeners on Brainy
*/
private setupEventListeners(): void {
// Data events
this.brainy.on?.('data:added', (data) => {
this.triggerWebhook('data.added', data)
})
this.brainy.on?.('data:updated', (data) => {
this.triggerWebhook('data.updated', data)
})
this.brainy.on?.('data:deleted', (data) => {
this.triggerWebhook('data.deleted', data)
})
// Augmentation events
this.brainy.on?.('augmentation:triggered', (data) => {
this.triggerWebhook('augmentation.triggered', data, {
augmentation: data.augmentationId
})
})
this.brainy.on?.('augmentation:completed', (data) => {
this.triggerWebhook('augmentation.completed', data, {
augmentation: data.augmentationId
})
})
this.brainy.on?.('augmentation:failed', (data) => {
this.triggerWebhook('augmentation.failed', data, {
augmentation: data.augmentationId
})
})
// Graph events
this.brainy.on?.('graph:relationship:created', (data) => {
this.triggerWebhook('graph.relationship.created', data)
})
this.brainy.on?.('graph:relationship:deleted', (data) => {
this.triggerWebhook('graph.relationship.deleted', data)
})
}
/**
* Test webhook configuration
*/
async testWebhook(id: string): Promise<boolean> {
const config = this.webhooks.get(id)
if (!config) {
throw new Error(`Webhook ${id} not found`)
}
const testPayload: WebhookPayload = {
event: 'system.alert',
timestamp: new Date().toISOString(),
data: {
message: 'Test webhook from Brainy',
test: true
},
metadata: {
webhookId: id
}
}
if (config.secret) {
testPayload.signature = await this.signPayload(testPayload, config.secret)
}
const response = await this.executeWebhook(config, testPayload)
return response.success
}
/**
* Retry failed webhooks
*/
async retryFailed(id: string): Promise<number> {
const queue = this.retryQueues.get(id)
const config = this.webhooks.get(id)
if (!queue || !config) {
return 0
}
const failed = [...queue]
this.retryQueues.set(id, [])
let retried = 0
for (const item of failed) {
await this.sendWebhook(id, config, item.payload)
retried++
}
return retried
}
/**
* Get webhook statistics
*/
getStatistics(): {
total: number
enabled: number
failedQueues: Array<{ id: string; count: number }>
} {
const enabled = Array.from(this.webhooks.values()).filter(w => w.enabled).length
const failedQueues = Array.from(this.retryQueues.entries()).map(([id, queue]) => ({
id,
count: queue.length
}))
return {
total: this.webhooks.size,
enabled,
failedQueues
}
}
}
/**
* Webhook builder for easy configuration
*/
export class WebhookBuilder {
private config: Partial<WebhookConfig> = {
events: [],
enabled: true
}
url(url: string): this {
this.config.url = url
return this
}
events(...events: WebhookEventType[]): this {
this.config.events = events
return this
}
headers(headers: Record<string, string>): this {
this.config.headers = headers
return this
}
secret(secret: string): this {
this.config.secret = secret
return this
}
retry(maxRetries: number, backoffMs: number = 1000): this {
this.config.retryPolicy = {
maxRetries,
backoffMs,
maxBackoffMs: backoffMs * 10
}
return this
}
filter(filters: WebhookConfig['filters']): this {
this.config.filters = filters
return this
}
build(): WebhookConfig {
if (!this.config.url) {
throw new Error('Webhook URL is required')
}
if (!this.config.events || this.config.events.length === 0) {
throw new Error('At least one event type is required')
}
return this.config as WebhookConfig
}
}