@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/notify",
3
- "version": "0.1.0",
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, '&amp;')
110
+ .replace(/</g, '&lt;')
111
+ .replace(/>/g, '&gt;')
112
+ .replace(/"/g, '&quot;')
113
+ .replace(/'/g, '&#39;')
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, '&amp;')
72
+ .replace(/</g, '&lt;')
73
+ .replace(/>/g, '&gt;')
74
+ .replace(/"/g, '&quot;')
75
+ .replace(/'/g, '&#39;')
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
+ }
@@ -0,0 +1,9 @@
1
+ declare module 'cloudflare:email' {
2
+ export class EmailMessage {
3
+ from: string
4
+ to: string
5
+ raw: string
6
+
7
+ constructor(from: string, to: string, raw: string)
8
+ }
9
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { notify } from './notify.js'
2
+ export type {
3
+ Channel,
4
+ EmailProvider,
5
+ NotifyEnv,
6
+ NotifyInput,
7
+ NotifyResult,
8
+ SendEmailBinding,
9
+ SentRecord,
10
+ Severity,
11
+ SlackBlock,
12
+ } from './types.js'
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
+ }