@open-mercato/webhooks 0.4.9-canary-8c762104f0

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.
Files changed (60) hide show
  1. package/CLAUDE.md +1 -0
  2. package/build.mjs +69 -0
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +7 -0
  5. package/dist/modules/webhooks/acl.js +14 -0
  6. package/dist/modules/webhooks/acl.js.map +7 -0
  7. package/dist/modules/webhooks/api/openapi.js +8 -0
  8. package/dist/modules/webhooks/api/openapi.js.map +7 -0
  9. package/dist/modules/webhooks/api/webhook-deliveries/route.js +118 -0
  10. package/dist/modules/webhooks/api/webhook-deliveries/route.js.map +7 -0
  11. package/dist/modules/webhooks/api/webhooks/route.js +268 -0
  12. package/dist/modules/webhooks/api/webhooks/route.js.map +7 -0
  13. package/dist/modules/webhooks/backend/webhooks/[id]/page.meta.js +17 -0
  14. package/dist/modules/webhooks/backend/webhooks/[id]/page.meta.js.map +7 -0
  15. package/dist/modules/webhooks/backend/webhooks/create/page.meta.js +17 -0
  16. package/dist/modules/webhooks/backend/webhooks/create/page.meta.js.map +7 -0
  17. package/dist/modules/webhooks/backend/webhooks/page.meta.js +24 -0
  18. package/dist/modules/webhooks/backend/webhooks/page.meta.js.map +7 -0
  19. package/dist/modules/webhooks/data/entities.js +196 -0
  20. package/dist/modules/webhooks/data/entities.js.map +7 -0
  21. package/dist/modules/webhooks/data/validators.js +39 -0
  22. package/dist/modules/webhooks/data/validators.js.map +7 -0
  23. package/dist/modules/webhooks/events.js +18 -0
  24. package/dist/modules/webhooks/events.js.map +7 -0
  25. package/dist/modules/webhooks/index.js +14 -0
  26. package/dist/modules/webhooks/index.js.map +7 -0
  27. package/dist/modules/webhooks/setup.js +12 -0
  28. package/dist/modules/webhooks/setup.js.map +7 -0
  29. package/dist/modules/webhooks/subscribers/outbound-dispatch.js +67 -0
  30. package/dist/modules/webhooks/subscribers/outbound-dispatch.js.map +7 -0
  31. package/dist/modules/webhooks/workers/webhook-delivery.js +129 -0
  32. package/dist/modules/webhooks/workers/webhook-delivery.js.map +7 -0
  33. package/generated/entities/webhook_delivery_entity/index.ts +22 -0
  34. package/generated/entities/webhook_entity/index.ts +26 -0
  35. package/generated/entities.ids.generated.ts +12 -0
  36. package/generated/entity-fields-registry.ts +13 -0
  37. package/jest.config.cjs +20 -0
  38. package/package.json +77 -0
  39. package/src/index.ts +1 -0
  40. package/src/modules/webhooks/acl.ts +10 -0
  41. package/src/modules/webhooks/api/openapi.ts +5 -0
  42. package/src/modules/webhooks/api/webhook-deliveries/route.ts +131 -0
  43. package/src/modules/webhooks/api/webhooks/route.ts +288 -0
  44. package/src/modules/webhooks/backend/webhooks/[id]/page.meta.ts +13 -0
  45. package/src/modules/webhooks/backend/webhooks/[id]/page.tsx +262 -0
  46. package/src/modules/webhooks/backend/webhooks/create/page.meta.ts +13 -0
  47. package/src/modules/webhooks/backend/webhooks/create/page.tsx +75 -0
  48. package/src/modules/webhooks/backend/webhooks/page.meta.ts +20 -0
  49. package/src/modules/webhooks/backend/webhooks/page.tsx +206 -0
  50. package/src/modules/webhooks/data/entities.ts +157 -0
  51. package/src/modules/webhooks/data/validators.ts +40 -0
  52. package/src/modules/webhooks/events.ts +15 -0
  53. package/src/modules/webhooks/i18n/en.json +73 -0
  54. package/src/modules/webhooks/index.ts +12 -0
  55. package/src/modules/webhooks/setup.ts +10 -0
  56. package/src/modules/webhooks/subscribers/outbound-dispatch.ts +79 -0
  57. package/src/modules/webhooks/workers/webhook-delivery.ts +158 -0
  58. package/tsconfig.build.json +13 -0
  59. package/tsconfig.json +9 -0
  60. package/watch.mjs +6 -0
@@ -0,0 +1,157 @@
1
+ import { Entity, PrimaryKey, Property, Index } from '@mikro-orm/core'
2
+
3
+ @Entity({ tableName: 'webhooks' })
4
+ @Index({ properties: ['organizationId', 'tenantId', 'isActive'] })
5
+ @Index({ properties: ['organizationId', 'tenantId', 'deletedAt'] })
6
+ export class WebhookEntity {
7
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
8
+ id!: string
9
+
10
+ @Property({ type: 'text' })
11
+ name!: string
12
+
13
+ @Property({ name: 'description', type: 'text', nullable: true })
14
+ description?: string | null
15
+
16
+ @Property({ name: 'url', type: 'text' })
17
+ url!: string
18
+
19
+ @Property({ name: 'secret', type: 'text' })
20
+ secret!: string
21
+
22
+ @Property({ name: 'previous_secret', type: 'text', nullable: true })
23
+ previousSecret?: string | null
24
+
25
+ @Property({ name: 'previous_secret_set_at', type: Date, nullable: true })
26
+ previousSecretSetAt?: Date | null
27
+
28
+ @Property({ name: 'subscribed_events', type: 'json' })
29
+ subscribedEvents!: string[]
30
+
31
+ @Property({ name: 'http_method', type: 'text', default: "'POST'" })
32
+ httpMethod: string = 'POST'
33
+
34
+ @Property({ name: 'custom_headers', type: 'json', nullable: true })
35
+ customHeaders?: Record<string, string> | null
36
+
37
+ @Property({ name: 'is_active', type: 'boolean', default: true })
38
+ isActive: boolean = true
39
+
40
+ @Property({ name: 'delivery_strategy', type: 'text', default: "'http'" })
41
+ deliveryStrategy: string = 'http'
42
+
43
+ @Property({ name: 'strategy_config', type: 'json', nullable: true })
44
+ strategyConfig?: Record<string, unknown> | null
45
+
46
+ @Property({ name: 'max_retries', type: 'int', default: 10 })
47
+ maxRetries: number = 10
48
+
49
+ @Property({ name: 'timeout_ms', type: 'int', default: 15000 })
50
+ timeoutMs: number = 15000
51
+
52
+ @Property({ name: 'rate_limit_per_minute', type: 'int', default: 0 })
53
+ rateLimitPerMinute: number = 0
54
+
55
+ @Property({ name: 'consecutive_failures', type: 'int', default: 0 })
56
+ consecutiveFailures: number = 0
57
+
58
+ @Property({ name: 'auto_disable_threshold', type: 'int', default: 100 })
59
+ autoDisableThreshold: number = 100
60
+
61
+ @Property({ name: 'last_success_at', type: Date, nullable: true })
62
+ lastSuccessAt?: Date | null
63
+
64
+ @Property({ name: 'last_failure_at', type: Date, nullable: true })
65
+ lastFailureAt?: Date | null
66
+
67
+ @Property({ name: 'integration_id', type: 'text', nullable: true })
68
+ integrationId?: string | null
69
+
70
+ @Property({ name: 'organization_id', type: 'uuid' })
71
+ organizationId!: string
72
+
73
+ @Property({ name: 'tenant_id', type: 'uuid' })
74
+ tenantId!: string
75
+
76
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
77
+ createdAt: Date = new Date()
78
+
79
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
80
+ updatedAt: Date = new Date()
81
+
82
+ @Property({ name: 'deleted_at', type: Date, nullable: true })
83
+ deletedAt?: Date | null
84
+ }
85
+
86
+ @Entity({ tableName: 'webhook_deliveries' })
87
+ @Index({ properties: ['webhookId', 'status'] })
88
+ @Index({ properties: ['organizationId', 'tenantId', 'createdAt'] })
89
+ @Index({ properties: ['webhookId', 'createdAt'] })
90
+ @Index({ properties: ['eventType', 'organizationId'] })
91
+ export class WebhookDeliveryEntity {
92
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
93
+ id!: string
94
+
95
+ @Property({ name: 'webhook_id', type: 'uuid' })
96
+ webhookId!: string
97
+
98
+ @Property({ name: 'event_type', type: 'text' })
99
+ eventType!: string
100
+
101
+ @Property({ name: 'message_id', type: 'text' })
102
+ messageId!: string
103
+
104
+ @Property({ name: 'payload', type: 'json' })
105
+ payload!: Record<string, unknown>
106
+
107
+ @Property({ name: 'status', type: 'text', default: "'pending'" })
108
+ status: string = 'pending'
109
+
110
+ @Property({ name: 'response_status', type: 'int', nullable: true })
111
+ responseStatus?: number | null
112
+
113
+ @Property({ name: 'response_body', type: 'text', nullable: true })
114
+ responseBody?: string | null
115
+
116
+ @Property({ name: 'response_headers', type: 'json', nullable: true })
117
+ responseHeaders?: Record<string, string> | null
118
+
119
+ @Property({ name: 'error_message', type: 'text', nullable: true })
120
+ errorMessage?: string | null
121
+
122
+ @Property({ name: 'attempt_number', type: 'int', default: 0 })
123
+ attemptNumber: number = 0
124
+
125
+ @Property({ name: 'max_attempts', type: 'int', default: 10 })
126
+ maxAttempts: number = 10
127
+
128
+ @Property({ name: 'next_retry_at', type: Date, nullable: true })
129
+ nextRetryAt?: Date | null
130
+
131
+ @Property({ name: 'duration_ms', type: 'int', nullable: true })
132
+ durationMs?: number | null
133
+
134
+ @Property({ name: 'target_url', type: 'text' })
135
+ targetUrl!: string
136
+
137
+ @Property({ name: 'enqueued_at', type: Date })
138
+ enqueuedAt: Date = new Date()
139
+
140
+ @Property({ name: 'last_attempt_at', type: Date, nullable: true })
141
+ lastAttemptAt?: Date | null
142
+
143
+ @Property({ name: 'delivered_at', type: Date, nullable: true })
144
+ deliveredAt?: Date | null
145
+
146
+ @Property({ name: 'organization_id', type: 'uuid' })
147
+ organizationId!: string
148
+
149
+ @Property({ name: 'tenant_id', type: 'uuid' })
150
+ tenantId!: string
151
+
152
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
153
+ createdAt: Date = new Date()
154
+
155
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
156
+ updatedAt: Date = new Date()
157
+ }
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod'
2
+
3
+ export const webhookCreateSchema = z.object({
4
+ name: z.string().min(1).max(255),
5
+ description: z.string().max(1000).optional().nullable(),
6
+ url: z.string().url(),
7
+ subscribedEvents: z.array(z.string().min(1)).min(1),
8
+ httpMethod: z.enum(['POST', 'PUT', 'PATCH'] as const).default('POST'),
9
+ customHeaders: z.record(z.string(), z.string()).optional().nullable(),
10
+ deliveryStrategy: z.literal('http').default('http'),
11
+ strategyConfig: z.record(z.string(), z.unknown()).optional().nullable(),
12
+ maxRetries: z.number().int().min(0).max(30).default(10),
13
+ timeoutMs: z.number().int().min(1000).max(60000).default(15000),
14
+ rateLimitPerMinute: z.number().int().min(0).max(10000).default(0),
15
+ autoDisableThreshold: z.number().int().min(0).max(1000).default(100),
16
+ integrationId: z.string().optional().nullable(),
17
+ })
18
+
19
+ export type WebhookCreateInput = z.infer<typeof webhookCreateSchema>
20
+
21
+ export const webhookUpdateSchema = webhookCreateSchema.partial().extend({
22
+ isActive: z.boolean().optional(),
23
+ })
24
+
25
+ export type WebhookUpdateInput = z.infer<typeof webhookUpdateSchema>
26
+
27
+ export const webhookListQuerySchema = z.object({
28
+ page: z.string().optional(),
29
+ pageSize: z.string().optional(),
30
+ search: z.string().optional(),
31
+ isActive: z.string().optional(),
32
+ })
33
+
34
+ export const webhookDeliveryQuerySchema = z.object({
35
+ page: z.coerce.number().min(1).default(1),
36
+ pageSize: z.coerce.number().min(1).max(100).default(50),
37
+ webhookId: z.string().uuid().optional(),
38
+ eventType: z.string().optional(),
39
+ status: z.enum(['pending', 'sending', 'delivered', 'failed', 'expired'] as const).optional(),
40
+ })
@@ -0,0 +1,15 @@
1
+ import { createModuleEvents } from '@open-mercato/shared/modules/events'
2
+
3
+ const events = [
4
+ { id: 'webhooks.webhook.created', label: 'Webhook Created', entity: 'webhook', category: 'crud' as const },
5
+ { id: 'webhooks.webhook.updated', label: 'Webhook Updated', entity: 'webhook', category: 'crud' as const },
6
+ { id: 'webhooks.webhook.deleted', label: 'Webhook Deleted', entity: 'webhook', category: 'crud' as const },
7
+ { id: 'webhooks.delivery.succeeded', label: 'Webhook Delivery Succeeded', entity: 'delivery', category: 'lifecycle' as const },
8
+ { id: 'webhooks.delivery.failed', label: 'Webhook Delivery Failed', entity: 'delivery', category: 'lifecycle' as const },
9
+ { id: 'webhooks.webhook.disabled', label: 'Webhook Auto-Disabled', entity: 'webhook', category: 'lifecycle' as const },
10
+ ] as const
11
+
12
+ export const eventsConfig = createModuleEvents({ moduleId: 'webhooks', events })
13
+ export const emitWebhooksEvent = eventsConfig.emit
14
+ export type WebhooksEventId = typeof events[number]['id']
15
+ export default eventsConfig
@@ -0,0 +1,73 @@
1
+ {
2
+ "webhooks.nav.title": "Webhooks",
3
+ "webhooks.nav.group": "Integrations",
4
+ "webhooks.nav.create": "Create Webhook",
5
+
6
+ "webhooks.list.title": "Webhooks",
7
+ "webhooks.list.description": "Manage webhook endpoints for receiving platform events.",
8
+ "webhooks.list.empty": "No webhooks configured yet.",
9
+ "webhooks.list.createFirst": "Create your first webhook to start receiving event notifications.",
10
+ "webhooks.list.columns.name": "Name",
11
+ "webhooks.list.columns.url": "URL",
12
+ "webhooks.list.columns.events": "Events",
13
+ "webhooks.list.columns.status": "Status",
14
+ "webhooks.list.columns.lastTriggered": "Last Triggered",
15
+ "webhooks.list.columns.successRate": "Health",
16
+ "webhooks.list.columns.createdAt": "Created",
17
+ "webhooks.list.status.active": "Active",
18
+ "webhooks.list.status.inactive": "Inactive",
19
+ "webhooks.list.actions.edit": "Edit",
20
+ "webhooks.list.actions.delete": "Delete",
21
+ "webhooks.list.actions.test": "Test",
22
+ "webhooks.list.confirmDelete": "Are you sure you want to delete this webhook? This action cannot be undone.",
23
+ "webhooks.list.deleteSuccess": "Webhook deleted successfully.",
24
+ "webhooks.list.deleteError": "Failed to delete webhook.",
25
+
26
+ "webhooks.form.title.create": "Create Webhook",
27
+ "webhooks.form.title.edit": "Edit Webhook",
28
+ "webhooks.form.name": "Name",
29
+ "webhooks.form.namePlaceholder": "My Webhook",
30
+ "webhooks.form.description": "Description",
31
+ "webhooks.form.descriptionPlaceholder": "Optional description for this webhook",
32
+ "webhooks.form.url": "Endpoint URL",
33
+ "webhooks.form.urlPlaceholder": "https://example.com/webhook",
34
+ "webhooks.form.urlHint": "The URL that will receive webhook payloads via HTTP POST.",
35
+ "webhooks.form.events": "Subscribed Events",
36
+ "webhooks.form.eventsHint": "Select which events should trigger this webhook.",
37
+ "webhooks.form.eventsPlaceholder": "Select events...",
38
+ "webhooks.form.httpMethod": "HTTP Method",
39
+ "webhooks.form.isActive": "Active",
40
+ "webhooks.form.maxRetries": "Max Retries",
41
+ "webhooks.form.maxRetriesHint": "Maximum number of delivery retry attempts (0-30).",
42
+ "webhooks.form.timeoutMs": "Timeout (ms)",
43
+ "webhooks.form.timeoutMsHint": "Request timeout in milliseconds (1000-60000).",
44
+ "webhooks.form.secret": "Signing Secret",
45
+ "webhooks.form.secretHint": "This secret is used to sign webhook payloads. Keep it safe.",
46
+ "webhooks.form.secretCopied": "Secret copied to clipboard.",
47
+ "webhooks.form.group.general": "General",
48
+ "webhooks.form.group.events": "Event Subscription",
49
+ "webhooks.form.group.delivery": "Delivery Settings",
50
+ "webhooks.form.createSuccess": "Webhook created successfully.",
51
+ "webhooks.form.createError": "Failed to create webhook.",
52
+ "webhooks.form.updateSuccess": "Webhook updated successfully.",
53
+ "webhooks.form.updateError": "Failed to update webhook.",
54
+
55
+ "webhooks.deliveries.title": "Delivery Log",
56
+ "webhooks.deliveries.empty": "No delivery attempts yet.",
57
+ "webhooks.deliveries.columns.event": "Event",
58
+ "webhooks.deliveries.columns.status": "Status",
59
+ "webhooks.deliveries.columns.responseStatus": "HTTP Status",
60
+ "webhooks.deliveries.columns.attempts": "Attempts",
61
+ "webhooks.deliveries.columns.duration": "Duration",
62
+ "webhooks.deliveries.columns.createdAt": "Time",
63
+ "webhooks.deliveries.status.pending": "Pending",
64
+ "webhooks.deliveries.status.sending": "Sending",
65
+ "webhooks.deliveries.status.delivered": "Delivered",
66
+ "webhooks.deliveries.status.failed": "Failed",
67
+ "webhooks.deliveries.status.expired": "Expired",
68
+
69
+ "webhooks.errors.tenantRequired": "Tenant context required.",
70
+ "webhooks.errors.orgRequired": "Organization context required.",
71
+ "webhooks.errors.notFound": "Webhook not found.",
72
+ "webhooks.errors.forbidden": "You do not have permission to perform this action."
73
+ }
@@ -0,0 +1,12 @@
1
+ import type { ModuleInfo } from '@open-mercato/shared/modules/registry'
2
+
3
+ export const metadata: ModuleInfo = {
4
+ name: 'webhooks',
5
+ title: 'Webhooks',
6
+ version: '0.1.0',
7
+ description: 'Standard Webhooks compliant outbound webhook delivery for platform events.',
8
+ author: 'Open Mercato Team',
9
+ license: 'Proprietary',
10
+ }
11
+
12
+ export { features } from './acl'
@@ -0,0 +1,10 @@
1
+ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
+
3
+ export const setup: ModuleSetupConfig = {
4
+ defaultRoleFeatures: {
5
+ admin: ['webhooks.*'],
6
+ employee: ['webhooks.view', 'webhooks.deliveries.view'],
7
+ },
8
+ }
9
+
10
+ export default setup
@@ -0,0 +1,79 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { WebhookEntity } from '../data/entities'
3
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+
5
+ export const metadata = {
6
+ event: '*',
7
+ persistent: true,
8
+ id: 'webhooks:outbound-dispatch',
9
+ }
10
+
11
+ export default async function handler(
12
+ payload: Record<string, unknown>,
13
+ ctx: { container: { resolve: <T = unknown>(name: string) => T }; eventId?: string },
14
+ ) {
15
+ const eventId = ctx.eventId ?? (payload.eventId as string) ?? (payload.type as string)
16
+ if (!eventId) return
17
+
18
+ const tenantId = payload.tenantId as string | undefined
19
+ const organizationId = payload.organizationId as string | undefined
20
+ if (!tenantId) return
21
+
22
+ if (eventId.startsWith('webhooks.')) return
23
+
24
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
25
+
26
+ const webhooks = await findWithDecryption(
27
+ em,
28
+ WebhookEntity,
29
+ {
30
+ isActive: true,
31
+ deletedAt: null,
32
+ tenantId,
33
+ ...(organizationId ? { organizationId } : {}),
34
+ },
35
+ {},
36
+ { tenantId, organizationId: organizationId ?? '' },
37
+ )
38
+
39
+ if (!webhooks.length) return
40
+
41
+ const matchingWebhooks = webhooks.filter((webhook) =>
42
+ webhook.subscribedEvents.some((pattern) => eventMatchesPattern(eventId, pattern)),
43
+ )
44
+
45
+ if (!matchingWebhooks.length) return
46
+
47
+ const queue = ctx.container.resolve<{ enqueueJob: (data: unknown) => Promise<unknown> }>('queueService')
48
+
49
+ for (const webhook of matchingWebhooks) {
50
+ try {
51
+ await queue.enqueueJob({
52
+ queue: 'webhook-deliveries',
53
+ data: {
54
+ webhookId: webhook.id,
55
+ eventId,
56
+ payload,
57
+ tenantId,
58
+ organizationId: organizationId ?? webhook.organizationId,
59
+ },
60
+ })
61
+ } catch {
62
+ // Don't fail the event pipeline if queue is unavailable
63
+ }
64
+ }
65
+ }
66
+
67
+ function eventMatchesPattern(eventId: string, pattern: string): boolean {
68
+ if (pattern === '*') return true
69
+ if (pattern === eventId) return true
70
+ if (pattern.endsWith('.*')) {
71
+ const prefix = pattern.slice(0, -2)
72
+ return eventId.startsWith(prefix + '.')
73
+ }
74
+ if (pattern.endsWith('*')) {
75
+ const prefix = pattern.slice(0, -1)
76
+ return eventId.startsWith(prefix)
77
+ }
78
+ return false
79
+ }
@@ -0,0 +1,158 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { WebhookEntity, WebhookDeliveryEntity } from '../data/entities'
3
+ import { buildWebhookHeaders, generateMessageId } from '@open-mercato/shared/lib/webhooks'
4
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+
6
+ export const metadata = {
7
+ queue: 'webhook-deliveries',
8
+ id: 'webhooks:delivery-worker',
9
+ concurrency: 10,
10
+ }
11
+
12
+ interface WebhookDeliveryJob {
13
+ webhookId: string
14
+ eventId: string
15
+ payload: Record<string, unknown>
16
+ tenantId: string
17
+ organizationId: string
18
+ }
19
+
20
+ export default async function handler(
21
+ job: { data: WebhookDeliveryJob },
22
+ ctx: { resolve: <T = unknown>(name: string) => T },
23
+ ) {
24
+ const { webhookId, eventId, payload, tenantId, organizationId } = job.data
25
+ const em = (ctx.resolve('em') as EntityManager).fork()
26
+
27
+ const webhook = await findOneWithDecryption(
28
+ em,
29
+ WebhookEntity,
30
+ { id: webhookId, isActive: true, deletedAt: null },
31
+ {},
32
+ { tenantId, organizationId },
33
+ )
34
+
35
+ if (!webhook) return
36
+
37
+ const messageId = generateMessageId()
38
+ const timestamp = Math.floor(Date.now() / 1000)
39
+ const body = JSON.stringify({
40
+ type: eventId,
41
+ timestamp: new Date().toISOString(),
42
+ data: payload,
43
+ })
44
+
45
+ const now = new Date()
46
+ const delivery = em.create(WebhookDeliveryEntity, {
47
+ webhookId: webhook.id,
48
+ eventType: eventId,
49
+ messageId,
50
+ payload: JSON.parse(body),
51
+ status: 'sending',
52
+ attemptNumber: 0,
53
+ maxAttempts: webhook.maxRetries,
54
+ targetUrl: webhook.url,
55
+ organizationId: webhook.organizationId,
56
+ tenantId: webhook.tenantId,
57
+ enqueuedAt: now,
58
+ createdAt: now,
59
+ updatedAt: now,
60
+ })
61
+
62
+ await em.flush()
63
+
64
+ const headers = buildWebhookHeaders(
65
+ messageId,
66
+ timestamp,
67
+ body,
68
+ webhook.secret,
69
+ webhook.previousSecret,
70
+ )
71
+
72
+ const startTime = Date.now()
73
+
74
+ try {
75
+ const controller = new AbortController()
76
+ const timeoutId = setTimeout(() => controller.abort(), webhook.timeoutMs)
77
+
78
+ const response = await fetch(webhook.url, {
79
+ method: webhook.httpMethod,
80
+ headers: {
81
+ 'content-type': 'application/json',
82
+ ...headers,
83
+ ...(webhook.customHeaders ?? {}),
84
+ },
85
+ body,
86
+ signal: controller.signal,
87
+ })
88
+
89
+ clearTimeout(timeoutId)
90
+
91
+ const durationMs = Date.now() - startTime
92
+ const responseBody = await response.text().catch(() => '')
93
+
94
+ delivery.responseStatus = response.status
95
+ delivery.responseBody = responseBody.slice(0, 4096)
96
+ delivery.durationMs = durationMs
97
+ delivery.lastAttemptAt = new Date()
98
+ delivery.attemptNumber += 1
99
+
100
+ if (response.ok) {
101
+ delivery.status = 'delivered'
102
+ delivery.deliveredAt = new Date()
103
+ webhook.consecutiveFailures = 0
104
+ webhook.lastSuccessAt = new Date()
105
+ } else {
106
+ const shouldRetry = shouldRetryStatus(response.status) && delivery.attemptNumber < delivery.maxAttempts
107
+ if (shouldRetry) {
108
+ delivery.status = 'pending'
109
+ delivery.nextRetryAt = calculateNextRetry(delivery.attemptNumber)
110
+ } else {
111
+ delivery.status = 'failed'
112
+ }
113
+ webhook.consecutiveFailures += 1
114
+ webhook.lastFailureAt = new Date()
115
+
116
+ if (webhook.autoDisableThreshold > 0 && webhook.consecutiveFailures >= webhook.autoDisableThreshold) {
117
+ webhook.isActive = false
118
+ }
119
+ }
120
+ } catch (error) {
121
+ const durationMs = Date.now() - startTime
122
+ delivery.durationMs = durationMs
123
+ delivery.lastAttemptAt = new Date()
124
+ delivery.attemptNumber += 1
125
+ delivery.errorMessage = error instanceof Error ? error.message : 'Unknown error'
126
+
127
+ const shouldRetry = delivery.attemptNumber < delivery.maxAttempts
128
+ if (shouldRetry) {
129
+ delivery.status = 'pending'
130
+ delivery.nextRetryAt = calculateNextRetry(delivery.attemptNumber)
131
+ } else {
132
+ delivery.status = 'failed'
133
+ }
134
+
135
+ webhook.consecutiveFailures += 1
136
+ webhook.lastFailureAt = new Date()
137
+
138
+ if (webhook.autoDisableThreshold > 0 && webhook.consecutiveFailures >= webhook.autoDisableThreshold) {
139
+ webhook.isActive = false
140
+ }
141
+ }
142
+
143
+ await em.flush()
144
+ }
145
+
146
+ function shouldRetryStatus(status: number): boolean {
147
+ if (status >= 200 && status < 300) return false
148
+ if (status === 408 || status === 429) return true
149
+ if (status >= 500) return true
150
+ return false
151
+ }
152
+
153
+ function calculateNextRetry(attemptNumber: number): Date {
154
+ const baseDelay = 1000
155
+ const delayMs = baseDelay * Math.pow(2, attemptNumber - 1)
156
+ const jitter = Math.random() * 1000
157
+ return new Date(Date.now() + delayMs + jitter)
158
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.json",
4
+ "compilerOptions": {
5
+ "noEmit": false,
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "outDir": "./dist",
9
+ "rootDir": "./src"
10
+ },
11
+ "include": ["src/**/*"],
12
+ "exclude": ["src/**/__tests__/**/*", "src/**/*.test.ts"]
13
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "noEmit": true
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
9
+ }
package/watch.mjs ADDED
@@ -0,0 +1,6 @@
1
+ import { watch } from '../../scripts/watch.mjs'
2
+ import { dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+ watch(__dirname)