@growth-labs/notify 0.1.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.
- package/README.md +226 -0
- package/SPEC.md +103 -0
- package/dist/adapters/cf-email.d.ts +12 -0
- package/dist/adapters/cf-email.d.ts.map +1 -0
- package/dist/adapters/cf-email.js +88 -0
- package/dist/adapters/cf-email.js.map +1 -0
- package/dist/adapters/resend.d.ts +5 -0
- package/dist/adapters/resend.d.ts.map +1 -0
- package/dist/adapters/resend.js +52 -0
- package/dist/adapters/resend.js.map +1 -0
- package/dist/adapters/slack.d.ts +5 -0
- package/dist/adapters/slack.d.ts.map +1 -0
- package/dist/adapters/slack.js +41 -0
- package/dist/adapters/slack.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +3 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +124 -0
- package/dist/notify.js.map +1 -0
- package/dist/retry.d.ts +23 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +64 -0
- package/dist/retry.js.map +1 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# @growth-labs/notify
|
|
2
|
+
|
|
3
|
+
Worker-compatible human notification delivery for Slack and email.
|
|
4
|
+
|
|
5
|
+
Use this package when a Worker needs to alert people about operational events:
|
|
6
|
+
daily digest completion/failure, auth monitor incidents, Tail Worker error
|
|
7
|
+
capture, or migration status updates.
|
|
8
|
+
|
|
9
|
+
It is deliberately small:
|
|
10
|
+
|
|
11
|
+
- Slack incoming webhooks
|
|
12
|
+
- Cloudflare Send Email binding
|
|
13
|
+
- Resend REST API fallback
|
|
14
|
+
- fixed severity routing
|
|
15
|
+
- caller-supplied dedup and audit hooks
|
|
16
|
+
- no D1, KV, queues, or background work
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm add @growth-labs/notify
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Basic Usage
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { notify } from '@growth-labs/notify'
|
|
28
|
+
|
|
29
|
+
const result = await notify(env, {
|
|
30
|
+
channels: ['slack', 'email'],
|
|
31
|
+
severity: 'critical',
|
|
32
|
+
title: 'Auth surface down',
|
|
33
|
+
body: '/login/ returning 503 for 12 minutes. Last good: 09:32 UTC.',
|
|
34
|
+
dedupKey: 'auth-monitor:fronts.co:login:down',
|
|
35
|
+
dedupFn: async (key) => {
|
|
36
|
+
// Caller-owned persistence, for example D1 or KV.
|
|
37
|
+
return false
|
|
38
|
+
},
|
|
39
|
+
onSent: async (record) => {
|
|
40
|
+
// Caller-owned audit log.
|
|
41
|
+
console.log(record)
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
if (result.failed.length > 0) {
|
|
46
|
+
console.warn('Notification delivery failed', result.failed)
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Severity Routing
|
|
51
|
+
|
|
52
|
+
The caller always provides the requested channel list. Severity filters that
|
|
53
|
+
list:
|
|
54
|
+
|
|
55
|
+
| Severity | Slack | Email |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `info` | yes | no |
|
|
58
|
+
| `warning` | yes | no |
|
|
59
|
+
| `critical` | yes | yes |
|
|
60
|
+
|
|
61
|
+
Example: `channels: ['email']` with `severity: 'info'` sends nothing and
|
|
62
|
+
returns:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
{
|
|
66
|
+
attempted: ['email'],
|
|
67
|
+
sent: [],
|
|
68
|
+
skipped: [{ channel: 'email', reason: 'severity-routing' }],
|
|
69
|
+
failed: [],
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Routing is not configurable in v0.1. If an event should reach email, it should
|
|
74
|
+
be `critical`.
|
|
75
|
+
|
|
76
|
+
## Environment
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
interface NotifyEnv {
|
|
80
|
+
NOTIFY_SLACK_WEBHOOK?: string
|
|
81
|
+
SEND_EMAIL?: { send(message: unknown): Promise<unknown> }
|
|
82
|
+
RESEND_API_KEY?: string
|
|
83
|
+
NOTIFY_EMAIL_TO?: string
|
|
84
|
+
NOTIFY_EMAIL_FROM?: string
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
| Channel | Required values |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| Slack | `NOTIFY_SLACK_WEBHOOK` |
|
|
91
|
+
| Email via Cloudflare | `SEND_EMAIL`, `NOTIFY_EMAIL_TO`, `NOTIFY_EMAIL_FROM` |
|
|
92
|
+
| Email via Resend | `RESEND_API_KEY`, `NOTIFY_EMAIL_TO`, `NOTIFY_EMAIL_FROM` |
|
|
93
|
+
|
|
94
|
+
If a requested and severity-allowed channel is missing required values,
|
|
95
|
+
`notify()` skips that channel with reason `missing-binding`. It does not throw.
|
|
96
|
+
|
|
97
|
+
## Cloudflare Email
|
|
98
|
+
|
|
99
|
+
Cloudflare is the default email provider:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
await notify(env, {
|
|
103
|
+
channels: ['email'],
|
|
104
|
+
severity: 'critical',
|
|
105
|
+
title: 'Digest failed',
|
|
106
|
+
body: 'The 2026-05-18 digest job failed.',
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Configure the Worker binding:
|
|
111
|
+
|
|
112
|
+
```toml
|
|
113
|
+
[[send_email]]
|
|
114
|
+
name = "SEND_EMAIL"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Cloudflare Email requires the destination address to be verified through Email
|
|
118
|
+
Routing or Email Service setup before sends are accepted. If the binding rejects
|
|
119
|
+
with a destination-verification error, notify records that provider error in
|
|
120
|
+
`result.failed[]`; the package does not attempt verification.
|
|
121
|
+
|
|
122
|
+
## Resend Fallback
|
|
123
|
+
|
|
124
|
+
Use Resend when the incident being reported could affect Cloudflare delivery
|
|
125
|
+
itself:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
await notify(env, {
|
|
129
|
+
channels: ['slack', 'email'],
|
|
130
|
+
severity: 'critical',
|
|
131
|
+
title: 'Cloudflare auth worker unavailable',
|
|
132
|
+
body: 'The monitor cannot reach the auth worker from two regions.',
|
|
133
|
+
emailProvider: 'resend',
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Resend sends a POST to `https://api.resend.com/emails` with bearer auth from
|
|
138
|
+
`RESEND_API_KEY`.
|
|
139
|
+
|
|
140
|
+
## Slack Blocks
|
|
141
|
+
|
|
142
|
+
Slack payloads always include `text: title` as the fallback. If you do not pass
|
|
143
|
+
blocks, notify sends:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
[
|
|
147
|
+
{
|
|
148
|
+
type: 'section',
|
|
149
|
+
text: { type: 'mrkdwn', text: `*${title}*\n${body}` },
|
|
150
|
+
},
|
|
151
|
+
]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Pass `blocks` when the caller owns richer formatting. Email ignores `blocks`.
|
|
155
|
+
|
|
156
|
+
## Dedup And Audit Hooks
|
|
157
|
+
|
|
158
|
+
Dedup is caller-owned:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
await notify(env, {
|
|
162
|
+
channels: ['slack'],
|
|
163
|
+
severity: 'warning',
|
|
164
|
+
title: 'Queue backlog',
|
|
165
|
+
body: 'Email queue has been above threshold for 10 minutes.',
|
|
166
|
+
dedupKey: 'mailer:queue-backlog',
|
|
167
|
+
dedupFn: async (key) => alreadySentRecently(key),
|
|
168
|
+
})
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`dedupFn` is called per channel before dispatch. If it returns `true`, the
|
|
172
|
+
channel is skipped with reason `deduped`. If it throws, notify warns and sends
|
|
173
|
+
anyway. Over-delivery is safer than silently dropping an alert.
|
|
174
|
+
|
|
175
|
+
`onSent` runs once per successful adapter dispatch:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
await notify(env, {
|
|
179
|
+
channels: ['slack', 'email'],
|
|
180
|
+
severity: 'critical',
|
|
181
|
+
title: 'Monitor failed',
|
|
182
|
+
body: 'The auth monitor detected a production outage.',
|
|
183
|
+
onSent: async (record) => writeAuditRecord(record),
|
|
184
|
+
})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Errors from `onSent` are caught and logged with `console.warn`; a successful
|
|
188
|
+
send is not reversed by an audit failure.
|
|
189
|
+
|
|
190
|
+
## Result Shape
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
interface NotifyResult {
|
|
194
|
+
attempted: Channel[]
|
|
195
|
+
sent: Channel[]
|
|
196
|
+
skipped: {
|
|
197
|
+
channel: Channel
|
|
198
|
+
reason: 'deduped' | 'missing-binding' | 'severity-routing'
|
|
199
|
+
}[]
|
|
200
|
+
failed: { channel: Channel; error: string }[]
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Input validation can throw for an invalid shape: empty `channels`, missing
|
|
205
|
+
`title`, missing `body`, unsupported severity, unsupported channel, or
|
|
206
|
+
unsupported email provider.
|
|
207
|
+
|
|
208
|
+
Adapter delivery failures never throw from `notify()`. They are captured in
|
|
209
|
+
`failed[]` and logged with `console.warn`.
|
|
210
|
+
|
|
211
|
+
## Exports
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
export { notify } from '@growth-labs/notify'
|
|
215
|
+
export type {
|
|
216
|
+
Channel,
|
|
217
|
+
EmailProvider,
|
|
218
|
+
NotifyEnv,
|
|
219
|
+
NotifyInput,
|
|
220
|
+
NotifyResult,
|
|
221
|
+
SendEmailBinding,
|
|
222
|
+
SentRecord,
|
|
223
|
+
Severity,
|
|
224
|
+
SlackBlock,
|
|
225
|
+
} from '@growth-labs/notify'
|
|
226
|
+
```
|
package/SPEC.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @growth-labs/notify v0.1.0 Package Spec
|
|
2
|
+
|
|
3
|
+
Status: ready for implementation. Date: 2026-05-14.
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
Ship `@growth-labs/notify@0.1.0`, a leaf package that sends human-facing
|
|
8
|
+
notifications through Slack and email from Cloudflare Workers. It provides one
|
|
9
|
+
API, fixed severity routing, caller-supplied dedup/audit hooks, and retry-aware
|
|
10
|
+
delivery.
|
|
11
|
+
|
|
12
|
+
This package does not store state, classify severity, own D1/KV, or format
|
|
13
|
+
domain-specific messages. Callers pass `severity`, `title`, `body`, optional
|
|
14
|
+
Slack Block Kit `blocks`, optional `dedupFn`, and optional `onSent`.
|
|
15
|
+
|
|
16
|
+
## Public API
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { notify } from '@growth-labs/notify'
|
|
20
|
+
|
|
21
|
+
await notify(env, {
|
|
22
|
+
channels: ['slack', 'email'],
|
|
23
|
+
severity: 'critical',
|
|
24
|
+
title: 'Auth surface down',
|
|
25
|
+
body: '/login/ returning 503 for 12 minutes.',
|
|
26
|
+
blocks: [],
|
|
27
|
+
dedupKey: 'auth-monitor:fronts.co:login:down',
|
|
28
|
+
dedupFn: async (key) => false,
|
|
29
|
+
onSent: async (record) => undefined,
|
|
30
|
+
emailProvider: 'resend',
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The package exports `notify()` and the public types `Severity`, `Channel`,
|
|
35
|
+
`NotifyInput`, `NotifyEnv`, `SlackBlock`, `SentRecord`, and `NotifyResult`.
|
|
36
|
+
|
|
37
|
+
## Severity Routing
|
|
38
|
+
|
|
39
|
+
The caller supplies the requested `channels`; severity filters that list.
|
|
40
|
+
|
|
41
|
+
| Severity | Slack | Email |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| `info` | yes | no |
|
|
44
|
+
| `warning` | yes | no |
|
|
45
|
+
| `critical` | yes | yes |
|
|
46
|
+
|
|
47
|
+
Routing is fixed in v0.1. If `channels: ['email']` is paired with
|
|
48
|
+
`severity: 'info'`, `notify()` returns a skipped email record with reason
|
|
49
|
+
`severity-routing` and sends nothing.
|
|
50
|
+
|
|
51
|
+
## Environment
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
interface NotifyEnv {
|
|
55
|
+
NOTIFY_SLACK_WEBHOOK?: string
|
|
56
|
+
SEND_EMAIL?: SendEmailBinding
|
|
57
|
+
RESEND_API_KEY?: string
|
|
58
|
+
NOTIFY_EMAIL_TO?: string
|
|
59
|
+
NOTIFY_EMAIL_FROM?: string
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Slack requires `NOTIFY_SLACK_WEBHOOK`. Cloudflare email requires `SEND_EMAIL`,
|
|
64
|
+
`NOTIFY_EMAIL_TO`, and `NOTIFY_EMAIL_FROM`. Resend requires `RESEND_API_KEY`,
|
|
65
|
+
`NOTIFY_EMAIL_TO`, and `NOTIFY_EMAIL_FROM`.
|
|
66
|
+
|
|
67
|
+
## Adapter Behavior
|
|
68
|
+
|
|
69
|
+
Slack posts to the incoming webhook with `Content-Type: application/json`.
|
|
70
|
+
Payloads always include `text: input.title`; when no blocks are supplied, the
|
|
71
|
+
adapter sends a section block containing `*title*\nbody`.
|
|
72
|
+
|
|
73
|
+
Cloudflare email uses the Worker Send Email binding and constructs an RFC 822
|
|
74
|
+
message for `EmailMessage`. The HTML body is derived from the plain text body by
|
|
75
|
+
escaping HTML special characters and replacing newlines with `<br>`.
|
|
76
|
+
|
|
77
|
+
Resend posts to `https://api.resend.com/emails` with `Authorization: Bearer
|
|
78
|
+
<RESEND_API_KEY>` and sends `from`, `to: [NOTIFY_EMAIL_TO]`, `subject`, `text`,
|
|
79
|
+
and `html`.
|
|
80
|
+
|
|
81
|
+
All adapters retry up to three attempts. Network errors and 5xx responses retry.
|
|
82
|
+
4xx responses do not retry. Final adapter failures are returned in
|
|
83
|
+
`NotifyResult.failed` and warn to `console.warn`.
|
|
84
|
+
|
|
85
|
+
## Dispatch Invariants
|
|
86
|
+
|
|
87
|
+
1. `notify()` may throw for invalid input, but never throws for adapter delivery
|
|
88
|
+
failure.
|
|
89
|
+
2. `onSent` runs exactly once per successful adapter dispatch.
|
|
90
|
+
3. `dedupFn` failure warns and proceeds with sending.
|
|
91
|
+
4. Every final adapter failure warns through `console.warn`.
|
|
92
|
+
5. The package stores no state and starts no background work.
|
|
93
|
+
6. Channels dispatch in series and in caller-provided order.
|
|
94
|
+
7. Missing bindings skip the channel instead of throwing.
|
|
95
|
+
|
|
96
|
+
## Documentation Requirements
|
|
97
|
+
|
|
98
|
+
README must cover install, basic usage, severity routing, required bindings and
|
|
99
|
+
environment, Cloudflare Email destination verification, Resend fallback use
|
|
100
|
+
cases, dedup hooks, audit hooks, and result shape.
|
|
101
|
+
|
|
102
|
+
`packages-docs/notify.md`, `PACKAGES.md`, and `docs/agent/package-catalog.yaml`
|
|
103
|
+
must be updated with the new package.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type RetryOptions, type RetryResult } from '../retry.js';
|
|
2
|
+
import type { NotifyEnv, NotifyInput } from '../types.js';
|
|
3
|
+
export declare function hasCloudflareEmailBinding(env: NotifyEnv): boolean;
|
|
4
|
+
export declare function dispatchCloudflareEmail(env: NotifyEnv, input: NotifyInput, retryOptions?: Partial<RetryOptions>): Promise<RetryResult<void>>;
|
|
5
|
+
export declare function buildRfc822Message(input: {
|
|
6
|
+
from: string;
|
|
7
|
+
to: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
bodyText: string;
|
|
10
|
+
bodyHtml: string;
|
|
11
|
+
}): string;
|
|
12
|
+
//# sourceMappingURL=cf-email.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cf-email.d.ts","sourceRoot":"","sources":["../../src/adapters/cf-email.ts"],"names":[],"mappings":"AAAA,OAAO,EAGN,KAAK,YAAY,EACjB,KAAK,WAAW,EAGhB,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAIzD,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAMjE;AAED,wBAAsB,uBAAuB,CAC5C,GAAG,EAAE,SAAS,EACd,KAAK,EAAE,WAAW,EAClB,YAAY,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,GAClC,OAAO,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CA2B5B;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE;IACzC,IAAI,EAAE,MAAM,CAAA;IACZ,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;CAChB,GAAG,MAAM,CAsBT"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { DeliveryError, errorMessage, shouldRetryDeliveryError, withRetry, } from '../retry.js';
|
|
2
|
+
export function hasCloudflareEmailBinding(env) {
|
|
3
|
+
return (typeof env.SEND_EMAIL?.send === 'function' &&
|
|
4
|
+
isNonEmptyString(env.NOTIFY_EMAIL_FROM) &&
|
|
5
|
+
isNonEmptyString(env.NOTIFY_EMAIL_TO));
|
|
6
|
+
}
|
|
7
|
+
export async function dispatchCloudflareEmail(env, input, retryOptions) {
|
|
8
|
+
return withRetry(async () => {
|
|
9
|
+
const sender = env.SEND_EMAIL;
|
|
10
|
+
const from = env.NOTIFY_EMAIL_FROM;
|
|
11
|
+
const to = env.NOTIFY_EMAIL_TO;
|
|
12
|
+
if (typeof sender?.send !== 'function' || !isNonEmptyString(from) || !isNonEmptyString(to)) {
|
|
13
|
+
throw new DeliveryError('Cloudflare SEND_EMAIL binding or email addresses are missing', false);
|
|
14
|
+
}
|
|
15
|
+
const EmailMessage = await loadEmailMessage();
|
|
16
|
+
const raw = buildRfc822Message({
|
|
17
|
+
from,
|
|
18
|
+
to,
|
|
19
|
+
subject: input.title,
|
|
20
|
+
bodyText: input.body,
|
|
21
|
+
bodyHtml: buildHtmlBody(input.body),
|
|
22
|
+
});
|
|
23
|
+
await sender.send(new EmailMessage(from, to, raw));
|
|
24
|
+
}, (error) => shouldRetryCloudflareEmail(error), retryOptions);
|
|
25
|
+
}
|
|
26
|
+
export function buildRfc822Message(input) {
|
|
27
|
+
const boundary = 'gl-notify-boundary';
|
|
28
|
+
return [
|
|
29
|
+
`From: ${sanitizeHeader(input.from)}`,
|
|
30
|
+
`To: ${sanitizeHeader(input.to)}`,
|
|
31
|
+
`Subject: ${sanitizeHeader(input.subject)}`,
|
|
32
|
+
'MIME-Version: 1.0',
|
|
33
|
+
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
|
34
|
+
'',
|
|
35
|
+
`--${boundary}`,
|
|
36
|
+
'Content-Type: text/plain; charset=UTF-8',
|
|
37
|
+
'Content-Transfer-Encoding: 8bit',
|
|
38
|
+
'',
|
|
39
|
+
input.bodyText,
|
|
40
|
+
`--${boundary}`,
|
|
41
|
+
'Content-Type: text/html; charset=UTF-8',
|
|
42
|
+
'Content-Transfer-Encoding: 8bit',
|
|
43
|
+
'',
|
|
44
|
+
input.bodyHtml,
|
|
45
|
+
`--${boundary}--`,
|
|
46
|
+
'',
|
|
47
|
+
].join('\r\n');
|
|
48
|
+
}
|
|
49
|
+
function shouldRetryCloudflareEmail(error) {
|
|
50
|
+
const status = statusCode(error);
|
|
51
|
+
if (status && status >= 400 && status < 500) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const message = errorMessage(error).toLowerCase();
|
|
55
|
+
if (message.includes('destination not verified')) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return shouldRetryDeliveryError(error);
|
|
59
|
+
}
|
|
60
|
+
async function loadEmailMessage() {
|
|
61
|
+
const module = await import('cloudflare:email');
|
|
62
|
+
return module.EmailMessage;
|
|
63
|
+
}
|
|
64
|
+
function buildHtmlBody(body) {
|
|
65
|
+
return `<p>${escapeHtml(body).replace(/\n/g, '<br>')}</p>`;
|
|
66
|
+
}
|
|
67
|
+
function escapeHtml(value) {
|
|
68
|
+
return value
|
|
69
|
+
.replace(/&/g, '&')
|
|
70
|
+
.replace(/</g, '<')
|
|
71
|
+
.replace(/>/g, '>')
|
|
72
|
+
.replace(/"/g, '"')
|
|
73
|
+
.replace(/'/g, ''');
|
|
74
|
+
}
|
|
75
|
+
function sanitizeHeader(value) {
|
|
76
|
+
return value.replace(/[\r\n]+/g, ' ').trim();
|
|
77
|
+
}
|
|
78
|
+
function statusCode(error) {
|
|
79
|
+
if (!error || typeof error !== 'object') {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const status = error.status;
|
|
83
|
+
return typeof status === 'number' ? status : undefined;
|
|
84
|
+
}
|
|
85
|
+
function isNonEmptyString(value) {
|
|
86
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=cf-email.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cf-email.js","sourceRoot":"","sources":["../../src/adapters/cf-email.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type RetryOptions, type RetryResult } from '../retry.js';
|
|
2
|
+
import type { NotifyEnv, NotifyInput } from '../types.js';
|
|
3
|
+
export declare function hasResendBinding(env: NotifyEnv): boolean;
|
|
4
|
+
export declare function dispatchResendEmail(env: NotifyEnv, input: NotifyInput, retryOptions?: Partial<RetryOptions>): Promise<RetryResult<void>>;
|
|
5
|
+
//# sourceMappingURL=resend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resend.d.ts","sourceRoot":"","sources":["../../src/adapters/resend.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,KAAK,YAAY,EACjB,KAAK,WAAW,EAGhB,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAIzD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAMxD;AAED,wBAAsB,mBAAmB,CACxC,GAAG,EAAE,SAAS,EACd,KAAK,EAAE,WAAW,EAClB,YAAY,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,GAClC,OAAO,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAgC5B"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { DeliveryError, shouldRetryDeliveryError, withRetry, } from '../retry.js';
|
|
2
|
+
const RESEND_EMAILS_URL = 'https://api.resend.com/emails';
|
|
3
|
+
export function hasResendBinding(env) {
|
|
4
|
+
return (isNonEmptyString(env.RESEND_API_KEY) &&
|
|
5
|
+
isNonEmptyString(env.NOTIFY_EMAIL_FROM) &&
|
|
6
|
+
isNonEmptyString(env.NOTIFY_EMAIL_TO));
|
|
7
|
+
}
|
|
8
|
+
export async function dispatchResendEmail(env, input, retryOptions) {
|
|
9
|
+
return withRetry(async () => {
|
|
10
|
+
if (!hasResendBinding(env)) {
|
|
11
|
+
throw new DeliveryError('Resend API key or email addresses are missing', false);
|
|
12
|
+
}
|
|
13
|
+
const response = await fetch(RESEND_EMAILS_URL, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: {
|
|
16
|
+
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
},
|
|
19
|
+
body: JSON.stringify({
|
|
20
|
+
from: env.NOTIFY_EMAIL_FROM,
|
|
21
|
+
to: [env.NOTIFY_EMAIL_TO],
|
|
22
|
+
subject: input.title,
|
|
23
|
+
text: input.body,
|
|
24
|
+
html: buildHtmlBody(input.body),
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new DeliveryError(await responseErrorMessage('Resend', response), response.status >= 500);
|
|
29
|
+
}
|
|
30
|
+
}, (error) => shouldRetryDeliveryError(error), retryOptions);
|
|
31
|
+
}
|
|
32
|
+
async function responseErrorMessage(label, response) {
|
|
33
|
+
const body = await response.text();
|
|
34
|
+
return body.trim()
|
|
35
|
+
? `${label} responded ${response.status}: ${body}`
|
|
36
|
+
: `${label} responded ${response.status}`;
|
|
37
|
+
}
|
|
38
|
+
function buildHtmlBody(body) {
|
|
39
|
+
return `<p>${escapeHtml(body).replace(/\n/g, '<br>')}</p>`;
|
|
40
|
+
}
|
|
41
|
+
function escapeHtml(value) {
|
|
42
|
+
return value
|
|
43
|
+
.replace(/&/g, '&')
|
|
44
|
+
.replace(/</g, '<')
|
|
45
|
+
.replace(/>/g, '>')
|
|
46
|
+
.replace(/"/g, '"')
|
|
47
|
+
.replace(/'/g, ''');
|
|
48
|
+
}
|
|
49
|
+
function isNonEmptyString(value) {
|
|
50
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=resend.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resend.js","sourceRoot":"","sources":["../../src/adapters/resend.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,aAAa,EAGb,wBAAwB,EACxB,SAAS,GACT,MAAM,aAAa,CAAA;AAGpB,MAAM,iBAAiB,GAAG,+BAA+B,CAAA;AAEzD,MAAM,UAAU,gBAAgB,CAAC,GAAc;IAC9C,OAAO,CACN,gBAAgB,CAAC,GAAG,CAAC,cAAc,CAAC;QACpC,gBAAgB,CAAC,GAAG,CAAC,iBAAiB,CAAC;QACvC,gBAAgB,CAAC,GAAG,CAAC,eAAe,CAAC,CACrC,CAAA;AACF,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACxC,GAAc,EACd,KAAkB,EAClB,YAAoC;IAEpC,OAAO,SAAS,CACf,KAAK,IAAI,EAAE;QACV,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,EAAE,CAAC;YAC5B,MAAM,IAAI,aAAa,CAAC,+CAA+C,EAAE,KAAK,CAAC,CAAA;QAChF,CAAC;QAED,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,iBAAiB,EAAE;YAC/C,MAAM,EAAE,MAAM;YACd,OAAO,EAAE;gBACR,aAAa,EAAE,UAAU,GAAG,CAAC,cAAc,EAAE;gBAC7C,cAAc,EAAE,kBAAkB;aAClC;YACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;gBACpB,IAAI,EAAE,GAAG,CAAC,iBAAiB;gBAC3B,EAAE,EAAE,CAAC,GAAG,CAAC,eAAe,CAAC;gBACzB,OAAO,EAAE,KAAK,CAAC,KAAK;gBACpB,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,IAAI,EAAE,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC;aAC/B,CAAC;SACF,CAAC,CAAA;QAEF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YAClB,MAAM,IAAI,aAAa,CACtB,MAAM,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,CAAC,EAC9C,QAAQ,CAAC,MAAM,IAAI,GAAG,CACtB,CAAA;QACF,CAAC;IACF,CAAC,EACD,CAAC,KAAK,EAAE,EAAE,CAAC,wBAAwB,CAAC,KAAK,CAAC,EAC1C,YAAY,CACZ,CAAA;AACF,CAAC;AAED,KAAK,UAAU,oBAAoB,CAAC,KAAa,EAAE,QAAkB;IACpE,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;IAClC,OAAO,IAAI,CAAC,IAAI,EAAE;QACjB,CAAC,CAAC,GAAG,KAAK,cAAc,QAAQ,CAAC,MAAM,KAAK,IAAI,EAAE;QAClD,CAAC,CAAC,GAAG,KAAK,cAAc,QAAQ,CAAC,MAAM,EAAE,CAAA;AAC3C,CAAC;AAED,SAAS,aAAa,CAAC,IAAY;IAClC,OAAO,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAA;AAC3D,CAAC;AAED,SAAS,UAAU,CAAC,KAAa;IAChC,OAAO,KAAK;SACV,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;AACzB,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAc;IACvC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAA;AACxD,CAAC"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type RetryOptions, type RetryResult } from '../retry.js';
|
|
2
|
+
import type { NotifyEnv, NotifyInput } from '../types.js';
|
|
3
|
+
export declare function hasSlackBinding(env: NotifyEnv): boolean;
|
|
4
|
+
export declare function dispatchSlack(env: NotifyEnv, input: NotifyInput, retryOptions?: Partial<RetryOptions>): Promise<RetryResult<void>>;
|
|
5
|
+
//# sourceMappingURL=slack.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slack.d.ts","sourceRoot":"","sources":["../../src/adapters/slack.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,KAAK,YAAY,EACjB,KAAK,WAAW,EAGhB,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEzD,wBAAgB,eAAe,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAEvD;AAED,wBAAsB,aAAa,CAClC,GAAG,EAAE,SAAS,EACd,KAAK,EAAE,WAAW,EAClB,YAAY,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,GAClC,OAAO,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CA2B5B"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { DeliveryError, shouldRetryDeliveryError, withRetry, } from '../retry.js';
|
|
2
|
+
export function hasSlackBinding(env) {
|
|
3
|
+
return isNonEmptyString(env.NOTIFY_SLACK_WEBHOOK);
|
|
4
|
+
}
|
|
5
|
+
export async function dispatchSlack(env, input, retryOptions) {
|
|
6
|
+
return withRetry(async () => {
|
|
7
|
+
const webhook = env.NOTIFY_SLACK_WEBHOOK;
|
|
8
|
+
if (!isNonEmptyString(webhook)) {
|
|
9
|
+
throw new DeliveryError('NOTIFY_SLACK_WEBHOOK is missing', false);
|
|
10
|
+
}
|
|
11
|
+
const response = await fetch(webhook, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'Content-Type': 'application/json' },
|
|
14
|
+
body: JSON.stringify({
|
|
15
|
+
text: input.title,
|
|
16
|
+
blocks: input.blocks ?? defaultBlocks(input),
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
throw new DeliveryError(await responseErrorMessage('Slack webhook', response), response.status >= 500);
|
|
21
|
+
}
|
|
22
|
+
}, (error) => shouldRetryDeliveryError(error), retryOptions);
|
|
23
|
+
}
|
|
24
|
+
function defaultBlocks(input) {
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
type: 'section',
|
|
28
|
+
text: { type: 'mrkdwn', text: `*${input.title}*\n${input.body}` },
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
async function responseErrorMessage(label, response) {
|
|
33
|
+
const body = await response.text();
|
|
34
|
+
return body.trim()
|
|
35
|
+
? `${label} responded ${response.status}: ${body}`
|
|
36
|
+
: `${label} responded ${response.status}`;
|
|
37
|
+
}
|
|
38
|
+
function isNonEmptyString(value) {
|
|
39
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=slack.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slack.js","sourceRoot":"","sources":["../../src/adapters/slack.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,aAAa,EAGb,wBAAwB,EACxB,SAAS,GACT,MAAM,aAAa,CAAA;AAGpB,MAAM,UAAU,eAAe,CAAC,GAAc;IAC7C,OAAO,gBAAgB,CAAC,GAAG,CAAC,oBAAoB,CAAC,CAAA;AAClD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAClC,GAAc,EACd,KAAkB,EAClB,YAAoC;IAEpC,OAAO,SAAS,CACf,KAAK,IAAI,EAAE;QACV,MAAM,OAAO,GAAG,GAAG,CAAC,oBAAoB,CAAA;QACxC,IAAI,CAAC,gBAAgB,CAAC,OAAO,CAAC,EAAE,CAAC;YAChC,MAAM,IAAI,aAAa,CAAC,iCAAiC,EAAE,KAAK,CAAC,CAAA;QAClE,CAAC;QAED,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,OAAO,EAAE;YACrC,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;gBACpB,IAAI,EAAE,KAAK,CAAC,KAAK;gBACjB,MAAM,EAAE,KAAK,CAAC,MAAM,IAAI,aAAa,CAAC,KAAK,CAAC;aAC5C,CAAC;SACF,CAAC,CAAA;QAEF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YAClB,MAAM,IAAI,aAAa,CACtB,MAAM,oBAAoB,CAAC,eAAe,EAAE,QAAQ,CAAC,EACrD,QAAQ,CAAC,MAAM,IAAI,GAAG,CACtB,CAAA;QACF,CAAC;IACF,CAAC,EACD,CAAC,KAAK,EAAE,EAAE,CAAC,wBAAwB,CAAC,KAAK,CAAC,EAC1C,YAAY,CACZ,CAAA;AACF,CAAC;AAED,SAAS,aAAa,CAAC,KAAkB;IACxC,OAAO;QACN;YACC,IAAI,EAAE,SAAS;YACf,IAAI,EAAE,EAAE,IAAI,EAAE,QAAiB,EAAE,IAAI,EAAE,IAAI,KAAK,CAAC,KAAK,MAAM,KAAK,CAAC,IAAI,EAAE,EAAE;SAC1E;KACD,CAAA;AACF,CAAC;AAED,KAAK,UAAU,oBAAoB,CAAC,KAAa,EAAE,QAAkB;IACpE,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;IAClC,OAAO,IAAI,CAAC,IAAI,EAAE;QACjB,CAAC,CAAC,GAAG,KAAK,cAAc,QAAQ,CAAC,MAAM,KAAK,IAAI,EAAE;QAClD,CAAC,CAAC,GAAG,KAAK,cAAc,QAAQ,CAAC,MAAM,EAAE,CAAA;AAC3C,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAc;IACvC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAA;AACxD,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACpC,YAAY,EACX,OAAO,EACP,aAAa,EACb,SAAS,EACT,WAAW,EACX,YAAY,EACZ,gBAAgB,EAChB,UAAU,EACV,QAAQ,EACR,UAAU,GACV,MAAM,YAAY,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA"}
|
package/dist/notify.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../src/notify.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAA0B,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAa9F,wBAAsB,MAAM,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC,CAmEtF"}
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
const VALID_CHANNELS = new Set(['slack', 'email']);
|
|
5
|
+
const VALID_SEVERITIES = new Set(['info', 'warning', 'critical']);
|
|
6
|
+
const VALID_EMAIL_PROVIDERS = new Set(['cloudflare', 'resend']);
|
|
7
|
+
export async function notify(env, input) {
|
|
8
|
+
validateInput(input);
|
|
9
|
+
const result = {
|
|
10
|
+
attempted: [...input.channels],
|
|
11
|
+
sent: [],
|
|
12
|
+
skipped: [],
|
|
13
|
+
failed: [],
|
|
14
|
+
};
|
|
15
|
+
const allowed = input.channels.filter((channel) => isAllowedBySeverity(channel, input.severity));
|
|
16
|
+
for (const channel of input.channels) {
|
|
17
|
+
if (!allowed.includes(channel)) {
|
|
18
|
+
result.skipped.push({ channel, reason: 'severity-routing' });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
for (const channel of allowed) {
|
|
22
|
+
if (input.dedupKey && input.dedupFn) {
|
|
23
|
+
try {
|
|
24
|
+
const shouldSkip = await input.dedupFn(input.dedupKey);
|
|
25
|
+
if (shouldSkip) {
|
|
26
|
+
result.skipped.push({ channel, reason: 'deduped' });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
console.warn('@growth-labs/notify: dedupFn threw, proceeding with send', {
|
|
32
|
+
dedupKey: input.dedupKey,
|
|
33
|
+
err,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const adapter = selectAdapter(channel, input.emailProvider ?? 'cloudflare');
|
|
38
|
+
if (!adapter.hasRequiredBindings(env)) {
|
|
39
|
+
result.skipped.push({ channel, reason: 'missing-binding' });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const dispatchResult = await adapter.dispatch(env, input);
|
|
43
|
+
if (dispatchResult.ok) {
|
|
44
|
+
result.sent.push(channel);
|
|
45
|
+
if (input.onSent) {
|
|
46
|
+
try {
|
|
47
|
+
await input.onSent({
|
|
48
|
+
channel,
|
|
49
|
+
severity: input.severity,
|
|
50
|
+
title: input.title,
|
|
51
|
+
dedupKey: input.dedupKey,
|
|
52
|
+
attempts: dispatchResult.attempts,
|
|
53
|
+
sentAt: Date.now(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.warn('@growth-labs/notify: onSent hook threw', { channel, err });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
result.failed.push({ channel, error: dispatchResult.error });
|
|
63
|
+
console.warn(`@growth-labs/notify: ${adapter.failureLabel} delivery failed`, {
|
|
64
|
+
title: input.title,
|
|
65
|
+
attempts: dispatchResult.attempts,
|
|
66
|
+
error: dispatchResult.error,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
function selectAdapter(channel, emailProvider) {
|
|
73
|
+
if (channel === 'slack') {
|
|
74
|
+
return {
|
|
75
|
+
channel,
|
|
76
|
+
failureLabel: 'slack',
|
|
77
|
+
hasRequiredBindings: hasSlackBinding,
|
|
78
|
+
dispatch: dispatchSlack,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (emailProvider === 'resend') {
|
|
82
|
+
return {
|
|
83
|
+
channel,
|
|
84
|
+
failureLabel: 'resend',
|
|
85
|
+
hasRequiredBindings: hasResendBinding,
|
|
86
|
+
dispatch: dispatchResendEmail,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
channel,
|
|
91
|
+
failureLabel: 'cf-email',
|
|
92
|
+
hasRequiredBindings: hasCloudflareEmailBinding,
|
|
93
|
+
dispatch: dispatchCloudflareEmail,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function isAllowedBySeverity(channel, severity) {
|
|
97
|
+
if (channel === 'slack') {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return severity === 'critical';
|
|
101
|
+
}
|
|
102
|
+
function validateInput(input) {
|
|
103
|
+
if (!Array.isArray(input.channels) || input.channels.length === 0) {
|
|
104
|
+
throw new Error('@growth-labs/notify: channels must include at least one channel');
|
|
105
|
+
}
|
|
106
|
+
for (const channel of input.channels) {
|
|
107
|
+
if (!VALID_CHANNELS.has(channel)) {
|
|
108
|
+
throw new Error(`@growth-labs/notify: unsupported channel "${String(channel)}"`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!VALID_SEVERITIES.has(input.severity)) {
|
|
112
|
+
throw new Error(`@growth-labs/notify: unsupported severity "${String(input.severity)}"`);
|
|
113
|
+
}
|
|
114
|
+
if (typeof input.title !== 'string' || input.title.trim() === '') {
|
|
115
|
+
throw new Error('@growth-labs/notify: title is required');
|
|
116
|
+
}
|
|
117
|
+
if (typeof input.body !== 'string' || input.body.trim() === '') {
|
|
118
|
+
throw new Error('@growth-labs/notify: body is required');
|
|
119
|
+
}
|
|
120
|
+
if (input.emailProvider !== undefined && !VALID_EMAIL_PROVIDERS.has(input.emailProvider)) {
|
|
121
|
+
throw new Error(`@growth-labs/notify: unsupported emailProvider "${String(input.emailProvider)}"`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=notify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notify.js","sourceRoot":"","sources":["../src/notify.ts"],"names":[],"mappings":"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"}
|
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
maxAttempts: number;
|
|
3
|
+
baseMs: number;
|
|
4
|
+
multiplier: number;
|
|
5
|
+
jitterFactor: number;
|
|
6
|
+
}
|
|
7
|
+
export type RetryResult<T> = {
|
|
8
|
+
ok: true;
|
|
9
|
+
value: T;
|
|
10
|
+
attempts: number;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
error: string;
|
|
14
|
+
attempts: number;
|
|
15
|
+
};
|
|
16
|
+
export declare class DeliveryError extends Error {
|
|
17
|
+
readonly retryable: boolean;
|
|
18
|
+
constructor(message: string, retryable: boolean);
|
|
19
|
+
}
|
|
20
|
+
export declare function withRetry<T>(fn: (attempt: number) => Promise<T>, shouldRetry: (err: unknown, attempt: number) => boolean, options?: Partial<RetryOptions>): Promise<RetryResult<T>>;
|
|
21
|
+
export declare function shouldRetryDeliveryError(error: unknown): boolean;
|
|
22
|
+
export declare function errorMessage(error: unknown): string;
|
|
23
|
+
//# sourceMappingURL=retry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../src/retry.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC5B,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,MAAM,WAAW,CAAC,CAAC,IACtB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACxC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAA;AASjD,qBAAa,aAAc,SAAQ,KAAK;aAGtB,SAAS,EAAE,OAAO;gBADlC,OAAO,EAAE,MAAM,EACC,SAAS,EAAE,OAAO;CAKnC;AAED,wBAAsB,SAAS,CAAC,CAAC,EAChC,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,EACnC,WAAW,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,EACvD,OAAO,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,GAC7B,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAoBzB;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAKhE;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAYnD"}
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const DEFAULT_RETRY_OPTIONS = {
|
|
2
|
+
maxAttempts: 3,
|
|
3
|
+
baseMs: 250,
|
|
4
|
+
multiplier: 4,
|
|
5
|
+
jitterFactor: 0.2,
|
|
6
|
+
};
|
|
7
|
+
export class DeliveryError extends Error {
|
|
8
|
+
retryable;
|
|
9
|
+
constructor(message, retryable) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.retryable = retryable;
|
|
12
|
+
this.name = 'DeliveryError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function withRetry(fn, shouldRetry, options) {
|
|
16
|
+
const resolved = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
|
17
|
+
const maxAttempts = Math.max(1, Math.floor(resolved.maxAttempts));
|
|
18
|
+
let lastError;
|
|
19
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
20
|
+
try {
|
|
21
|
+
const value = await fn(attempt);
|
|
22
|
+
return { ok: true, value, attempts: attempt };
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
lastError = error;
|
|
26
|
+
if (attempt >= maxAttempts || !shouldRetry(error, attempt)) {
|
|
27
|
+
return { ok: false, error: errorMessage(error), attempts: attempt };
|
|
28
|
+
}
|
|
29
|
+
await sleep(delayForAttempt(attempt, resolved));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { ok: false, error: errorMessage(lastError), attempts: maxAttempts };
|
|
33
|
+
}
|
|
34
|
+
export function shouldRetryDeliveryError(error) {
|
|
35
|
+
if (error instanceof DeliveryError) {
|
|
36
|
+
return error.retryable;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
export function errorMessage(error) {
|
|
41
|
+
if (error instanceof Error) {
|
|
42
|
+
return error.message;
|
|
43
|
+
}
|
|
44
|
+
if (typeof error === 'string') {
|
|
45
|
+
return error;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return JSON.stringify(error);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return String(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function delayForAttempt(attempt, options) {
|
|
55
|
+
const baseDelay = options.baseMs * options.multiplier ** (attempt - 1);
|
|
56
|
+
const jitter = 1 + (Math.random() * 2 - 1) * options.jitterFactor;
|
|
57
|
+
return Math.max(0, Math.round(baseDelay * jitter));
|
|
58
|
+
}
|
|
59
|
+
function sleep(ms) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
setTimeout(resolve, ms);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=retry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry.js","sourceRoot":"","sources":["../src/retry.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type Severity = 'info' | 'warning' | 'critical';
|
|
2
|
+
export type Channel = 'slack' | 'email';
|
|
3
|
+
export type EmailProvider = 'cloudflare' | 'resend';
|
|
4
|
+
export interface SlackBlock {
|
|
5
|
+
type: string;
|
|
6
|
+
text?: {
|
|
7
|
+
type: 'mrkdwn' | 'plain_text';
|
|
8
|
+
text: string;
|
|
9
|
+
};
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
export interface SentRecord {
|
|
13
|
+
channel: Channel;
|
|
14
|
+
severity: Severity;
|
|
15
|
+
title: string;
|
|
16
|
+
dedupKey?: string;
|
|
17
|
+
attempts: number;
|
|
18
|
+
sentAt: number;
|
|
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
|
+
export interface SendEmailBinding {
|
|
32
|
+
send(message: unknown): Promise<unknown>;
|
|
33
|
+
}
|
|
34
|
+
export interface NotifyEnv {
|
|
35
|
+
NOTIFY_SLACK_WEBHOOK?: string;
|
|
36
|
+
SEND_EMAIL?: SendEmailBinding;
|
|
37
|
+
RESEND_API_KEY?: string;
|
|
38
|
+
NOTIFY_EMAIL_TO?: string;
|
|
39
|
+
NOTIFY_EMAIL_FROM?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface NotifyResult {
|
|
42
|
+
attempted: Channel[];
|
|
43
|
+
sent: Channel[];
|
|
44
|
+
skipped: {
|
|
45
|
+
channel: Channel;
|
|
46
|
+
reason: 'deduped' | 'missing-binding' | 'severity-routing';
|
|
47
|
+
}[];
|
|
48
|
+
failed: {
|
|
49
|
+
channel: Channel;
|
|
50
|
+
error: string;
|
|
51
|
+
}[];
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,UAAU,CAAA;AACtD,MAAM,MAAM,OAAO,GAAG,OAAO,GAAG,OAAO,CAAA;AACvC,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG,QAAQ,CAAA;AAEnD,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,QAAQ,GAAG,YAAY,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IACtD,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACtB;AAED,MAAM,WAAW,UAAU;IAC1B,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,QAAQ,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,WAAW;IAC3B,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,QAAQ,EAAE,QAAQ,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,UAAU,EAAE,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAChD,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9C,aAAa,CAAC,EAAE,aAAa,CAAA;CAC7B;AAED,MAAM,WAAW,gBAAgB;IAChC,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CACxC;AAED,MAAM,WAAW,SAAS;IACzB,oBAAoB,CAAC,EAAE,MAAM,CAAA;IAC7B,UAAU,CAAC,EAAE,gBAAgB,CAAA;IAC7B,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,YAAY;IAC5B,SAAS,EAAE,OAAO,EAAE,CAAA;IACpB,IAAI,EAAE,OAAO,EAAE,CAAA;IACf,OAAO,EAAE;QACR,OAAO,EAAE,OAAO,CAAA;QAChB,MAAM,EAAE,SAAS,GAAG,iBAAiB,GAAG,kBAAkB,CAAA;KAC1D,EAAE,CAAA;IACH,MAAM,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC7C"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@growth-labs/notify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Worker-compatible human notification delivery for Slack and email with severity routing, retry, and caller-supplied dedup hooks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"SPEC.md"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"registry": "https://registry.npmjs.org/"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@cloudflare/workers-types": "^4.0.0",
|
|
24
|
+
"typescript": "^5.7.0",
|
|
25
|
+
"vitest": "^3.0.0"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc",
|
|
29
|
+
"dev": "tsc --watch",
|
|
30
|
+
"test": "tsc --noEmit -p tsconfig.test.json && vitest run",
|
|
31
|
+
"test:watch": "vitest",
|
|
32
|
+
"check": "biome check src/ __tests__"
|
|
33
|
+
}
|
|
34
|
+
}
|