@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 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, '&amp;')
70
+ .replace(/</g, '&lt;')
71
+ .replace(/>/g, '&gt;')
72
+ .replace(/"/g, '&quot;')
73
+ .replace(/'/g, '&#39;');
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":"AAAA,OAAO,EACN,aAAa,EACb,YAAY,EAGZ,wBAAwB,EACxB,SAAS,GACT,MAAM,aAAa,CAAA;AAKpB,MAAM,UAAU,yBAAyB,CAAC,GAAc;IACvD,OAAO,CACN,OAAO,GAAG,CAAC,UAAU,EAAE,IAAI,KAAK,UAAU;QAC1C,gBAAgB,CAAC,GAAG,CAAC,iBAAiB,CAAC;QACvC,gBAAgB,CAAC,GAAG,CAAC,eAAe,CAAC,CACrC,CAAA;AACF,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC5C,GAAc,EACd,KAAkB,EAClB,YAAoC;IAEpC,OAAO,SAAS,CACf,KAAK,IAAI,EAAE;QACV,MAAM,MAAM,GAAG,GAAG,CAAC,UAAU,CAAA;QAC7B,MAAM,IAAI,GAAG,GAAG,CAAC,iBAAiB,CAAA;QAClC,MAAM,EAAE,GAAG,GAAG,CAAC,eAAe,CAAA;QAC9B,IAAI,OAAO,MAAM,EAAE,IAAI,KAAK,UAAU,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,EAAE,CAAC,EAAE,CAAC;YAC5F,MAAM,IAAI,aAAa,CACtB,8DAA8D,EAC9D,KAAK,CACL,CAAA;QACF,CAAC;QAED,MAAM,YAAY,GAAG,MAAM,gBAAgB,EAAE,CAAA;QAC7C,MAAM,GAAG,GAAG,kBAAkB,CAAC;YAC9B,IAAI;YACJ,EAAE;YACF,OAAO,EAAE,KAAK,CAAC,KAAK;YACpB,QAAQ,EAAE,KAAK,CAAC,IAAI;YACpB,QAAQ,EAAE,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC;SACnC,CAAC,CAAA;QAEF,MAAM,MAAM,CAAC,IAAI,CAAC,IAAI,YAAY,CAAC,IAAI,EAAE,EAAE,EAAE,GAAG,CAAC,CAAC,CAAA;IACnD,CAAC,EACD,CAAC,KAAK,EAAE,EAAE,CAAC,0BAA0B,CAAC,KAAK,CAAC,EAC5C,YAAY,CACZ,CAAA;AACF,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,KAMlC;IACA,MAAM,QAAQ,GAAG,oBAAoB,CAAA;IACrC,OAAO;QACN,SAAS,cAAc,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE;QACrC,OAAO,cAAc,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE;QACjC,YAAY,cAAc,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE;QAC3C,mBAAmB;QACnB,kDAAkD,QAAQ,GAAG;QAC7D,EAAE;QACF,KAAK,QAAQ,EAAE;QACf,yCAAyC;QACzC,iCAAiC;QACjC,EAAE;QACF,KAAK,CAAC,QAAQ;QACd,KAAK,QAAQ,EAAE;QACf,wCAAwC;QACxC,iCAAiC;QACjC,EAAE;QACF,KAAK,CAAC,QAAQ;QACd,KAAK,QAAQ,IAAI;QACjB,EAAE;KACF,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;AACf,CAAC;AAED,SAAS,0BAA0B,CAAC,KAAc;IACjD,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAAC,CAAA;IAChC,IAAI,MAAM,IAAI,MAAM,IAAI,GAAG,IAAI,MAAM,GAAG,GAAG,EAAE,CAAC;QAC7C,OAAO,KAAK,CAAA;IACb,CAAC;IAED,MAAM,OAAO,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,CAAA;IACjD,IAAI,OAAO,CAAC,QAAQ,CAAC,0BAA0B,CAAC,EAAE,CAAC;QAClD,OAAO,KAAK,CAAA;IACb,CAAC;IAED,OAAO,wBAAwB,CAAC,KAAK,CAAC,CAAA;AACvC,CAAC;AAED,KAAK,UAAU,gBAAgB;IAC9B,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,kBAAkB,CAAC,CAAA;IAC/C,OAAO,MAAM,CAAC,YAAY,CAAA;AAC3B,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,cAAc,CAAC,KAAa;IACpC,OAAO,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAA;AAC7C,CAAC;AAED,SAAS,UAAU,CAAC,KAAc;IACjC,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACzC,OAAO,SAAS,CAAA;IACjB,CAAC;IACD,MAAM,MAAM,GAAI,KAA8B,CAAC,MAAM,CAAA;IACrD,OAAO,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAA;AACvD,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 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, '&amp;')
44
+ .replace(/</g, '&lt;')
45
+ .replace(/>/g, '&gt;')
46
+ .replace(/"/g, '&quot;')
47
+ .replace(/'/g, '&#39;');
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"}
@@ -0,0 +1,3 @@
1
+ export { notify } from './notify.js';
2
+ export type { Channel, EmailProvider, NotifyEnv, NotifyInput, NotifyResult, SendEmailBinding, SentRecord, Severity, SlackBlock, } from './types.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -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,2 @@
1
+ export { notify } from './notify.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA"}
@@ -0,0 +1,3 @@
1
+ import type { NotifyEnv, NotifyInput, NotifyResult } from './types.js';
2
+ export declare function notify(env: NotifyEnv, input: NotifyInput): Promise<NotifyResult>;
3
+ //# sourceMappingURL=notify.d.ts.map
@@ -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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"}
@@ -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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"}
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -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
+ }