@growth-labs/notify 0.1.0 → 0.1.1
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.
- package/package.json +6 -1
- package/src/adapters/cf-email.ts +130 -0
- package/src/adapters/resend.ts +80 -0
- package/src/adapters/slack.ts +65 -0
- package/src/cloudflare-email.d.ts +9 -0
- package/src/index.ts +12 -0
- package/src/notify.ts +149 -0
- package/src/retry.ts +86 -0
- package/src/types.ts +52 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growth-labs/notify",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Worker-compatible human notification delivery for Slack and email with severity routing, retry, and caller-supplied dedup hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"dist",
|
|
15
|
+
"src/**/*.ts",
|
|
16
|
+
"src/**/*.astro",
|
|
17
|
+
"src/**/*.css",
|
|
18
|
+
"src/**/*.md",
|
|
19
|
+
"src/**/*.sql",
|
|
15
20
|
"README.md",
|
|
16
21
|
"SPEC.md"
|
|
17
22
|
],
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeliveryError,
|
|
3
|
+
errorMessage,
|
|
4
|
+
type RetryOptions,
|
|
5
|
+
type RetryResult,
|
|
6
|
+
shouldRetryDeliveryError,
|
|
7
|
+
withRetry,
|
|
8
|
+
} from '../retry.js'
|
|
9
|
+
import type { NotifyEnv, NotifyInput } from '../types.js'
|
|
10
|
+
|
|
11
|
+
type EmailMessageConstructor = typeof import('cloudflare:email').EmailMessage
|
|
12
|
+
|
|
13
|
+
export function hasCloudflareEmailBinding(env: NotifyEnv): boolean {
|
|
14
|
+
return (
|
|
15
|
+
typeof env.SEND_EMAIL?.send === 'function' &&
|
|
16
|
+
isNonEmptyString(env.NOTIFY_EMAIL_FROM) &&
|
|
17
|
+
isNonEmptyString(env.NOTIFY_EMAIL_TO)
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function dispatchCloudflareEmail(
|
|
22
|
+
env: NotifyEnv,
|
|
23
|
+
input: NotifyInput,
|
|
24
|
+
retryOptions?: Partial<RetryOptions>,
|
|
25
|
+
): Promise<RetryResult<void>> {
|
|
26
|
+
return withRetry(
|
|
27
|
+
async () => {
|
|
28
|
+
const sender = env.SEND_EMAIL
|
|
29
|
+
const from = env.NOTIFY_EMAIL_FROM
|
|
30
|
+
const to = env.NOTIFY_EMAIL_TO
|
|
31
|
+
if (typeof sender?.send !== 'function' || !isNonEmptyString(from) || !isNonEmptyString(to)) {
|
|
32
|
+
throw new DeliveryError(
|
|
33
|
+
'Cloudflare SEND_EMAIL binding or email addresses are missing',
|
|
34
|
+
false,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const EmailMessage = await loadEmailMessage()
|
|
39
|
+
const raw = buildRfc822Message({
|
|
40
|
+
from,
|
|
41
|
+
to,
|
|
42
|
+
subject: input.title,
|
|
43
|
+
bodyText: input.body,
|
|
44
|
+
bodyHtml: buildHtmlBody(input.body),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
await sender.send(new EmailMessage(from, to, raw))
|
|
48
|
+
},
|
|
49
|
+
(error) => shouldRetryCloudflareEmail(error),
|
|
50
|
+
retryOptions,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildRfc822Message(input: {
|
|
55
|
+
from: string
|
|
56
|
+
to: string
|
|
57
|
+
subject: string
|
|
58
|
+
bodyText: string
|
|
59
|
+
bodyHtml: string
|
|
60
|
+
}): string {
|
|
61
|
+
const boundary = 'gl-notify-boundary'
|
|
62
|
+
return [
|
|
63
|
+
`From: ${sanitizeHeader(input.from)}`,
|
|
64
|
+
`To: ${sanitizeHeader(input.to)}`,
|
|
65
|
+
`Subject: ${sanitizeHeader(input.subject)}`,
|
|
66
|
+
'MIME-Version: 1.0',
|
|
67
|
+
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
|
68
|
+
'',
|
|
69
|
+
`--${boundary}`,
|
|
70
|
+
'Content-Type: text/plain; charset=UTF-8',
|
|
71
|
+
'Content-Transfer-Encoding: 8bit',
|
|
72
|
+
'',
|
|
73
|
+
input.bodyText,
|
|
74
|
+
`--${boundary}`,
|
|
75
|
+
'Content-Type: text/html; charset=UTF-8',
|
|
76
|
+
'Content-Transfer-Encoding: 8bit',
|
|
77
|
+
'',
|
|
78
|
+
input.bodyHtml,
|
|
79
|
+
`--${boundary}--`,
|
|
80
|
+
'',
|
|
81
|
+
].join('\r\n')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function shouldRetryCloudflareEmail(error: unknown): boolean {
|
|
85
|
+
const status = statusCode(error)
|
|
86
|
+
if (status && status >= 400 && status < 500) {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const message = errorMessage(error).toLowerCase()
|
|
91
|
+
if (message.includes('destination not verified')) {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return shouldRetryDeliveryError(error)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function loadEmailMessage(): Promise<EmailMessageConstructor> {
|
|
99
|
+
const module = await import('cloudflare:email')
|
|
100
|
+
return module.EmailMessage
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildHtmlBody(body: string): string {
|
|
104
|
+
return `<p>${escapeHtml(body).replace(/\n/g, '<br>')}</p>`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function escapeHtml(value: string): string {
|
|
108
|
+
return value
|
|
109
|
+
.replace(/&/g, '&')
|
|
110
|
+
.replace(/</g, '<')
|
|
111
|
+
.replace(/>/g, '>')
|
|
112
|
+
.replace(/"/g, '"')
|
|
113
|
+
.replace(/'/g, ''')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sanitizeHeader(value: string): string {
|
|
117
|
+
return value.replace(/[\r\n]+/g, ' ').trim()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function statusCode(error: unknown): number | undefined {
|
|
121
|
+
if (!error || typeof error !== 'object') {
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
const status = (error as { status?: unknown }).status
|
|
125
|
+
return typeof status === 'number' ? status : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
129
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
130
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeliveryError,
|
|
3
|
+
type RetryOptions,
|
|
4
|
+
type RetryResult,
|
|
5
|
+
shouldRetryDeliveryError,
|
|
6
|
+
withRetry,
|
|
7
|
+
} from '../retry.js'
|
|
8
|
+
import type { NotifyEnv, NotifyInput } from '../types.js'
|
|
9
|
+
|
|
10
|
+
const RESEND_EMAILS_URL = 'https://api.resend.com/emails'
|
|
11
|
+
|
|
12
|
+
export function hasResendBinding(env: NotifyEnv): boolean {
|
|
13
|
+
return (
|
|
14
|
+
isNonEmptyString(env.RESEND_API_KEY) &&
|
|
15
|
+
isNonEmptyString(env.NOTIFY_EMAIL_FROM) &&
|
|
16
|
+
isNonEmptyString(env.NOTIFY_EMAIL_TO)
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function dispatchResendEmail(
|
|
21
|
+
env: NotifyEnv,
|
|
22
|
+
input: NotifyInput,
|
|
23
|
+
retryOptions?: Partial<RetryOptions>,
|
|
24
|
+
): Promise<RetryResult<void>> {
|
|
25
|
+
return withRetry(
|
|
26
|
+
async () => {
|
|
27
|
+
if (!hasResendBinding(env)) {
|
|
28
|
+
throw new DeliveryError('Resend API key or email addresses are missing', false)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const response = await fetch(RESEND_EMAILS_URL, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: {
|
|
34
|
+
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify({
|
|
38
|
+
from: env.NOTIFY_EMAIL_FROM,
|
|
39
|
+
to: [env.NOTIFY_EMAIL_TO],
|
|
40
|
+
subject: input.title,
|
|
41
|
+
text: input.body,
|
|
42
|
+
html: buildHtmlBody(input.body),
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new DeliveryError(
|
|
48
|
+
await responseErrorMessage('Resend', response),
|
|
49
|
+
response.status >= 500,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
(error) => shouldRetryDeliveryError(error),
|
|
54
|
+
retryOptions,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function responseErrorMessage(label: string, response: Response): Promise<string> {
|
|
59
|
+
const body = await response.text()
|
|
60
|
+
return body.trim()
|
|
61
|
+
? `${label} responded ${response.status}: ${body}`
|
|
62
|
+
: `${label} responded ${response.status}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildHtmlBody(body: string): string {
|
|
66
|
+
return `<p>${escapeHtml(body).replace(/\n/g, '<br>')}</p>`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function escapeHtml(value: string): string {
|
|
70
|
+
return value
|
|
71
|
+
.replace(/&/g, '&')
|
|
72
|
+
.replace(/</g, '<')
|
|
73
|
+
.replace(/>/g, '>')
|
|
74
|
+
.replace(/"/g, '"')
|
|
75
|
+
.replace(/'/g, ''')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
79
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
80
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeliveryError,
|
|
3
|
+
type RetryOptions,
|
|
4
|
+
type RetryResult,
|
|
5
|
+
shouldRetryDeliveryError,
|
|
6
|
+
withRetry,
|
|
7
|
+
} from '../retry.js'
|
|
8
|
+
import type { NotifyEnv, NotifyInput } from '../types.js'
|
|
9
|
+
|
|
10
|
+
export function hasSlackBinding(env: NotifyEnv): boolean {
|
|
11
|
+
return isNonEmptyString(env.NOTIFY_SLACK_WEBHOOK)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function dispatchSlack(
|
|
15
|
+
env: NotifyEnv,
|
|
16
|
+
input: NotifyInput,
|
|
17
|
+
retryOptions?: Partial<RetryOptions>,
|
|
18
|
+
): Promise<RetryResult<void>> {
|
|
19
|
+
return withRetry(
|
|
20
|
+
async () => {
|
|
21
|
+
const webhook = env.NOTIFY_SLACK_WEBHOOK
|
|
22
|
+
if (!isNonEmptyString(webhook)) {
|
|
23
|
+
throw new DeliveryError('NOTIFY_SLACK_WEBHOOK is missing', false)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const response = await fetch(webhook, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({
|
|
30
|
+
text: input.title,
|
|
31
|
+
blocks: input.blocks ?? defaultBlocks(input),
|
|
32
|
+
}),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new DeliveryError(
|
|
37
|
+
await responseErrorMessage('Slack webhook', response),
|
|
38
|
+
response.status >= 500,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
(error) => shouldRetryDeliveryError(error),
|
|
43
|
+
retryOptions,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function defaultBlocks(input: NotifyInput) {
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
type: 'section',
|
|
51
|
+
text: { type: 'mrkdwn' as const, text: `*${input.title}*\n${input.body}` },
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function responseErrorMessage(label: string, response: Response): Promise<string> {
|
|
57
|
+
const body = await response.text()
|
|
58
|
+
return body.trim()
|
|
59
|
+
? `${label} responded ${response.status}: ${body}`
|
|
60
|
+
: `${label} responded ${response.status}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
64
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
65
|
+
}
|
package/src/index.ts
ADDED
package/src/notify.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { dispatchCloudflareEmail, hasCloudflareEmailBinding } from './adapters/cf-email.js'
|
|
2
|
+
import { dispatchResendEmail, hasResendBinding } from './adapters/resend.js'
|
|
3
|
+
import { dispatchSlack, hasSlackBinding } from './adapters/slack.js'
|
|
4
|
+
import type { RetryResult } from './retry.js'
|
|
5
|
+
import type { Channel, EmailProvider, NotifyEnv, NotifyInput, NotifyResult } from './types.js'
|
|
6
|
+
|
|
7
|
+
type Adapter = {
|
|
8
|
+
channel: Channel
|
|
9
|
+
failureLabel: string
|
|
10
|
+
hasRequiredBindings: (env: NotifyEnv) => boolean
|
|
11
|
+
dispatch: (env: NotifyEnv, input: NotifyInput) => Promise<RetryResult<void>>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const VALID_CHANNELS = new Set<Channel>(['slack', 'email'])
|
|
15
|
+
const VALID_SEVERITIES = new Set(['info', 'warning', 'critical'])
|
|
16
|
+
const VALID_EMAIL_PROVIDERS = new Set<EmailProvider>(['cloudflare', 'resend'])
|
|
17
|
+
|
|
18
|
+
export async function notify(env: NotifyEnv, input: NotifyInput): Promise<NotifyResult> {
|
|
19
|
+
validateInput(input)
|
|
20
|
+
|
|
21
|
+
const result: NotifyResult = {
|
|
22
|
+
attempted: [...input.channels],
|
|
23
|
+
sent: [],
|
|
24
|
+
skipped: [],
|
|
25
|
+
failed: [],
|
|
26
|
+
}
|
|
27
|
+
const allowed = input.channels.filter((channel) => isAllowedBySeverity(channel, input.severity))
|
|
28
|
+
|
|
29
|
+
for (const channel of input.channels) {
|
|
30
|
+
if (!allowed.includes(channel)) {
|
|
31
|
+
result.skipped.push({ channel, reason: 'severity-routing' })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const channel of allowed) {
|
|
36
|
+
if (input.dedupKey && input.dedupFn) {
|
|
37
|
+
try {
|
|
38
|
+
const shouldSkip = await input.dedupFn(input.dedupKey)
|
|
39
|
+
if (shouldSkip) {
|
|
40
|
+
result.skipped.push({ channel, reason: 'deduped' })
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.warn('@growth-labs/notify: dedupFn threw, proceeding with send', {
|
|
45
|
+
dedupKey: input.dedupKey,
|
|
46
|
+
err,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const adapter = selectAdapter(channel, input.emailProvider ?? 'cloudflare')
|
|
52
|
+
if (!adapter.hasRequiredBindings(env)) {
|
|
53
|
+
result.skipped.push({ channel, reason: 'missing-binding' })
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const dispatchResult = await adapter.dispatch(env, input)
|
|
58
|
+
if (dispatchResult.ok) {
|
|
59
|
+
result.sent.push(channel)
|
|
60
|
+
if (input.onSent) {
|
|
61
|
+
try {
|
|
62
|
+
await input.onSent({
|
|
63
|
+
channel,
|
|
64
|
+
severity: input.severity,
|
|
65
|
+
title: input.title,
|
|
66
|
+
dedupKey: input.dedupKey,
|
|
67
|
+
attempts: dispatchResult.attempts,
|
|
68
|
+
sentAt: Date.now(),
|
|
69
|
+
})
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.warn('@growth-labs/notify: onSent hook threw', { channel, err })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
result.failed.push({ channel, error: dispatchResult.error })
|
|
76
|
+
console.warn(`@growth-labs/notify: ${adapter.failureLabel} delivery failed`, {
|
|
77
|
+
title: input.title,
|
|
78
|
+
attempts: dispatchResult.attempts,
|
|
79
|
+
error: dispatchResult.error,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function selectAdapter(channel: Channel, emailProvider: EmailProvider): Adapter {
|
|
88
|
+
if (channel === 'slack') {
|
|
89
|
+
return {
|
|
90
|
+
channel,
|
|
91
|
+
failureLabel: 'slack',
|
|
92
|
+
hasRequiredBindings: hasSlackBinding,
|
|
93
|
+
dispatch: dispatchSlack,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (emailProvider === 'resend') {
|
|
98
|
+
return {
|
|
99
|
+
channel,
|
|
100
|
+
failureLabel: 'resend',
|
|
101
|
+
hasRequiredBindings: hasResendBinding,
|
|
102
|
+
dispatch: dispatchResendEmail,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
channel,
|
|
108
|
+
failureLabel: 'cf-email',
|
|
109
|
+
hasRequiredBindings: hasCloudflareEmailBinding,
|
|
110
|
+
dispatch: dispatchCloudflareEmail,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isAllowedBySeverity(channel: Channel, severity: NotifyInput['severity']): boolean {
|
|
115
|
+
if (channel === 'slack') {
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
return severity === 'critical'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validateInput(input: NotifyInput): void {
|
|
122
|
+
if (!Array.isArray(input.channels) || input.channels.length === 0) {
|
|
123
|
+
throw new Error('@growth-labs/notify: channels must include at least one channel')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const channel of input.channels) {
|
|
127
|
+
if (!VALID_CHANNELS.has(channel)) {
|
|
128
|
+
throw new Error(`@growth-labs/notify: unsupported channel "${String(channel)}"`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!VALID_SEVERITIES.has(input.severity)) {
|
|
133
|
+
throw new Error(`@growth-labs/notify: unsupported severity "${String(input.severity)}"`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof input.title !== 'string' || input.title.trim() === '') {
|
|
137
|
+
throw new Error('@growth-labs/notify: title is required')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (typeof input.body !== 'string' || input.body.trim() === '') {
|
|
141
|
+
throw new Error('@growth-labs/notify: body is required')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (input.emailProvider !== undefined && !VALID_EMAIL_PROVIDERS.has(input.emailProvider)) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`@growth-labs/notify: unsupported emailProvider "${String(input.emailProvider)}"`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
maxAttempts: number
|
|
3
|
+
baseMs: number
|
|
4
|
+
multiplier: number
|
|
5
|
+
jitterFactor: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type RetryResult<T> =
|
|
9
|
+
| { ok: true; value: T; attempts: number }
|
|
10
|
+
| { ok: false; error: string; attempts: number }
|
|
11
|
+
|
|
12
|
+
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
|
|
13
|
+
maxAttempts: 3,
|
|
14
|
+
baseMs: 250,
|
|
15
|
+
multiplier: 4,
|
|
16
|
+
jitterFactor: 0.2,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class DeliveryError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
message: string,
|
|
22
|
+
public readonly retryable: boolean,
|
|
23
|
+
) {
|
|
24
|
+
super(message)
|
|
25
|
+
this.name = 'DeliveryError'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function withRetry<T>(
|
|
30
|
+
fn: (attempt: number) => Promise<T>,
|
|
31
|
+
shouldRetry: (err: unknown, attempt: number) => boolean,
|
|
32
|
+
options?: Partial<RetryOptions>,
|
|
33
|
+
): Promise<RetryResult<T>> {
|
|
34
|
+
const resolved = { ...DEFAULT_RETRY_OPTIONS, ...options }
|
|
35
|
+
const maxAttempts = Math.max(1, Math.floor(resolved.maxAttempts))
|
|
36
|
+
let lastError: unknown
|
|
37
|
+
|
|
38
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
39
|
+
try {
|
|
40
|
+
const value = await fn(attempt)
|
|
41
|
+
return { ok: true, value, attempts: attempt }
|
|
42
|
+
} catch (error) {
|
|
43
|
+
lastError = error
|
|
44
|
+
if (attempt >= maxAttempts || !shouldRetry(error, attempt)) {
|
|
45
|
+
return { ok: false, error: errorMessage(error), attempts: attempt }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await sleep(delayForAttempt(attempt, resolved))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { ok: false, error: errorMessage(lastError), attempts: maxAttempts }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function shouldRetryDeliveryError(error: unknown): boolean {
|
|
56
|
+
if (error instanceof DeliveryError) {
|
|
57
|
+
return error.retryable
|
|
58
|
+
}
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function errorMessage(error: unknown): string {
|
|
63
|
+
if (error instanceof Error) {
|
|
64
|
+
return error.message
|
|
65
|
+
}
|
|
66
|
+
if (typeof error === 'string') {
|
|
67
|
+
return error
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return JSON.stringify(error)
|
|
71
|
+
} catch {
|
|
72
|
+
return String(error)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function delayForAttempt(attempt: number, options: RetryOptions): number {
|
|
77
|
+
const baseDelay = options.baseMs * options.multiplier ** (attempt - 1)
|
|
78
|
+
const jitter = 1 + (Math.random() * 2 - 1) * options.jitterFactor
|
|
79
|
+
return Math.max(0, Math.round(baseDelay * jitter))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sleep(ms: number): Promise<void> {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
setTimeout(resolve, ms)
|
|
85
|
+
})
|
|
86
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type Severity = 'info' | 'warning' | 'critical'
|
|
2
|
+
export type Channel = 'slack' | 'email'
|
|
3
|
+
export type EmailProvider = 'cloudflare' | 'resend'
|
|
4
|
+
|
|
5
|
+
export interface SlackBlock {
|
|
6
|
+
type: string
|
|
7
|
+
text?: { type: 'mrkdwn' | 'plain_text'; text: string }
|
|
8
|
+
[key: string]: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SentRecord {
|
|
12
|
+
channel: Channel
|
|
13
|
+
severity: Severity
|
|
14
|
+
title: string
|
|
15
|
+
dedupKey?: string
|
|
16
|
+
attempts: number
|
|
17
|
+
sentAt: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NotifyInput {
|
|
21
|
+
channels: Channel[]
|
|
22
|
+
severity: Severity
|
|
23
|
+
title: string
|
|
24
|
+
body: string
|
|
25
|
+
blocks?: SlackBlock[]
|
|
26
|
+
dedupKey?: string
|
|
27
|
+
dedupFn?: (dedupKey: string) => Promise<boolean>
|
|
28
|
+
onSent?: (record: SentRecord) => Promise<void>
|
|
29
|
+
emailProvider?: EmailProvider
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SendEmailBinding {
|
|
33
|
+
send(message: unknown): Promise<unknown>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NotifyEnv {
|
|
37
|
+
NOTIFY_SLACK_WEBHOOK?: string
|
|
38
|
+
SEND_EMAIL?: SendEmailBinding
|
|
39
|
+
RESEND_API_KEY?: string
|
|
40
|
+
NOTIFY_EMAIL_TO?: string
|
|
41
|
+
NOTIFY_EMAIL_FROM?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface NotifyResult {
|
|
45
|
+
attempted: Channel[]
|
|
46
|
+
sent: Channel[]
|
|
47
|
+
skipped: {
|
|
48
|
+
channel: Channel
|
|
49
|
+
reason: 'deduped' | 'missing-binding' | 'severity-routing'
|
|
50
|
+
}[]
|
|
51
|
+
failed: { channel: Channel; error: string }[]
|
|
52
|
+
}
|