@meith/runtime 0.16.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,117 @@
1
+ import { deliveryHeaders, nextRetryDelaySeconds, verdictFor, type WebhookTopic } from '@meith/api'
2
+ import { logger } from '@meith/core'
3
+
4
+ export interface ClaimedDelivery {
5
+ readonly id: number
6
+ readonly webhookId: number
7
+ readonly deliveryId: string
8
+ readonly topic: string
9
+ readonly payload: Record<string, unknown>
10
+ readonly attempts: number
11
+ readonly url: string
12
+ readonly secret: string
13
+ }
14
+
15
+ export interface WebhookDeliveryStore {
16
+ claimDue(now: Date, limit: number): Promise<readonly ClaimedDelivery[]>
17
+ markDelivered(id: number, status: number, at: Date): Promise<void>
18
+ scheduleRetry(id: number, at: Date, status: number | null, error: string): Promise<void>
19
+ markDead(id: number, status: number | null, error: string, at: Date): Promise<void>
20
+ }
21
+
22
+ export const REQUEST_TIMEOUT_MS = 10_000
23
+
24
+ export interface DeliverWebhooksResult {
25
+ readonly attempted: number
26
+ readonly delivered: number
27
+ readonly retried: number
28
+ readonly dead: number
29
+ }
30
+
31
+ export interface DeliverWebhooksOptions {
32
+ readonly now?: Date
33
+ readonly random?: () => number
34
+ readonly fetchImpl?: typeof fetch
35
+ }
36
+
37
+ export async function deliverWebhooks(
38
+ store: WebhookDeliveryStore,
39
+ limit: number,
40
+ options: DeliverWebhooksOptions = {},
41
+ ): Promise<DeliverWebhooksResult> {
42
+ const now = options.now ?? new Date()
43
+ const doFetch = options.fetchImpl ?? fetch
44
+ const log = () => logger({ module: 'webhooks' })
45
+
46
+ const claimed = await store.claimDue(now, limit)
47
+ let delivered = 0
48
+ let retried = 0
49
+ let dead = 0
50
+
51
+ for (const row of claimed) {
52
+ const body = JSON.stringify(row.payload)
53
+ const timestampSeconds = Math.floor(now.getTime() / 1000)
54
+
55
+ const headers = deliveryHeaders(
56
+ { id: row.webhookId, url: row.url, secret: row.secret, topics: [], active: true },
57
+ {
58
+ subscriptionId: row.webhookId,
59
+ topic: row.topic as WebhookTopic,
60
+ deliveryId: row.deliveryId,
61
+ payload: row.payload,
62
+ },
63
+ timestampSeconds,
64
+ body,
65
+ )
66
+
67
+ let status: number | null = null
68
+ let failure = ''
69
+
70
+ try {
71
+ const response = await doFetch(row.url, {
72
+ method: 'POST',
73
+ headers,
74
+ body,
75
+ redirect: 'manual',
76
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
77
+ })
78
+ status = response.status
79
+ } catch (err) {
80
+ failure = err instanceof Error ? err.message : String(err)
81
+ }
82
+
83
+ const verdict =
84
+ status === null
85
+ ? nextRetryDelaySeconds(row.attempts, options.random) === null
86
+ ? 'dead'
87
+ : 'retry'
88
+ : verdictFor(status, row.attempts)
89
+
90
+ if (verdict === 'delivered') {
91
+ await store.markDelivered(row.id, status!, now)
92
+ delivered++
93
+ continue
94
+ }
95
+
96
+ if (verdict === 'dead') {
97
+ await store.markDead(row.id, status, failure || `HTTP ${status}`, now)
98
+ dead++
99
+ log().warn(
100
+ { webhookId: row.webhookId, deliveryId: row.deliveryId, status, attempts: row.attempts },
101
+ 'webhook delivery dead-lettered',
102
+ )
103
+ continue
104
+ }
105
+
106
+ const delay = nextRetryDelaySeconds(row.attempts, options.random) ?? 3600
107
+ await store.scheduleRetry(
108
+ row.id,
109
+ new Date(now.getTime() + delay * 1000),
110
+ status,
111
+ failure || `HTTP ${status}`,
112
+ )
113
+ retried++
114
+ }
115
+
116
+ return { attempted: claimed.length, delivered, retried, dead }
117
+ }