@better-forge/notify 0.0.0-stage → 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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -2
  3. package/dist/alertPolicy.d.ts +41 -0
  4. package/dist/alertPolicy.d.ts.map +1 -0
  5. package/dist/alertPolicy.js +137 -0
  6. package/dist/alertPolicy.js.map +1 -0
  7. package/dist/channelInput.d.ts +34 -0
  8. package/dist/channelInput.d.ts.map +1 -0
  9. package/dist/channelInput.js +156 -0
  10. package/dist/channelInput.js.map +1 -0
  11. package/dist/channelSecrets.d.ts +8 -0
  12. package/dist/channelSecrets.d.ts.map +1 -0
  13. package/dist/channelSecrets.js +17 -0
  14. package/dist/channelSecrets.js.map +1 -0
  15. package/dist/engine.d.ts +68 -0
  16. package/dist/engine.d.ts.map +1 -0
  17. package/dist/engine.js +314 -0
  18. package/dist/engine.js.map +1 -0
  19. package/dist/index.d.ts +12 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +12 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/migrations.d.ts +907 -0
  24. package/dist/migrations.d.ts.map +1 -0
  25. package/dist/migrations.js +86 -0
  26. package/dist/migrations.js.map +1 -0
  27. package/dist/notice.d.ts +55 -0
  28. package/dist/notice.d.ts.map +1 -0
  29. package/dist/notice.js +25 -0
  30. package/dist/notice.js.map +1 -0
  31. package/dist/routes.d.ts +74 -0
  32. package/dist/routes.d.ts.map +1 -0
  33. package/dist/routes.js +302 -0
  34. package/dist/routes.js.map +1 -0
  35. package/dist/tables.d.ts +919 -0
  36. package/dist/tables.d.ts.map +1 -0
  37. package/dist/tables.js +68 -0
  38. package/dist/tables.js.map +1 -0
  39. package/dist/template.d.ts +35 -0
  40. package/dist/template.d.ts.map +1 -0
  41. package/dist/template.js +72 -0
  42. package/dist/template.js.map +1 -0
  43. package/dist/transports.d.ts +50 -0
  44. package/dist/transports.d.ts.map +1 -0
  45. package/dist/transports.js +274 -0
  46. package/dist/transports.js.map +1 -0
  47. package/dist/worker.d.ts +25 -0
  48. package/dist/worker.d.ts.map +1 -0
  49. package/dist/worker.js +38 -0
  50. package/dist/worker.js.map +1 -0
  51. package/package.json +57 -4
  52. package/src/alertPolicy.ts +149 -0
  53. package/src/channelInput.ts +153 -0
  54. package/src/channelSecrets.ts +20 -0
  55. package/src/engine.ts +423 -0
  56. package/src/index.ts +16 -0
  57. package/src/migrations.ts +88 -0
  58. package/src/notice.ts +68 -0
  59. package/src/routes.ts +362 -0
  60. package/src/tables.ts +96 -0
  61. package/src/template.ts +76 -0
  62. package/src/transports.ts +351 -0
  63. package/src/worker.ts +58 -0
@@ -0,0 +1,351 @@
1
+ import type { VaultRef } from '@better-forge/contracts'
2
+ import { plainSegments, readableTimestamp, renderSegments, type Notice, type NoticeSeverity } from './notice.js'
3
+ import type { SmtpMessage } from './engine.js'
4
+ import { configForEvent, type EventWording } from './channelInput.js'
5
+ import { buildWebhookRequest, encodeUrlPart, substituteVars } from './template.js'
6
+
7
+ export interface TransportOptions {
8
+ /** Resolves a vault reference to its secret fields (channel credentials may be kept in a vault). */
9
+ resolveSecret: (ref: VaultRef) => Promise<Record<string, string>>
10
+ /** Sends one mail through the application's SMTP settings. */
11
+ sendMail: (message: SmtpMessage) => Promise<void>
12
+ /** Discord only accepts a public image URL for a webhook avatar; it cannot be uploaded as a file. */
13
+ discordAvatarUrl: string
14
+ /**
15
+ * The subject and body a new email channel starts with. A channel that still has them is written from the notice,
16
+ * so every kind of event gets wording of its own; a template the channel's owner edited is used as it is.
17
+ */
18
+ standardEmail?: { subject: string; body: string }
19
+ /** Which kinds of event the channel's own message fields word, and the wording of the others when the channel has none. */
20
+ wording?: EventWording
21
+ /**
22
+ * Opens the requests to an address the channel's owner typed in (a webhook, a Slack, Discord or Teams URL, an ntfy
23
+ * server of the channel's own), so that a product which must keep them away from some networks passes a fetch that refuses them. The
24
+ * requests to the fixed hosts of Telegram, Pushover and ntfy.sh use the global fetch. Defaults to the global fetch.
25
+ */
26
+ fetch?: typeof fetch
27
+ }
28
+
29
+ /** The part of a stored channel that picks and configures its transport. */
30
+ export interface TransportChannel {
31
+ type: string
32
+ /** JSON configuration of the channel. */
33
+ config: string
34
+ }
35
+
36
+ const OUTBOUND_TIMEOUT_MS = 15_000
37
+ const EMAIL_MAX_BODY = 10_000
38
+
39
+ const SEVERITY_EMOJI: Record<NoticeSeverity, string> = { critical: '🔴', warning: '🟡', ok: '🟢' }
40
+
41
+ /** Telegram HTML and Slack mrkdwn both need exactly `&`, `<` and `>` escaped; Slack reads `<...>` as a link or a mention. */
42
+ const escapeHtml = (value: string): string => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
43
+
44
+ /** Neutralises markdown (links, emphasis, quotes) in text that a sender, not the channel's owner, controls. */
45
+ const escapeMarkdown = (value: string): string => value.replace(/[\\*_~`|>[\]()<#]/g, '\\$&')
46
+
47
+ /** Discord also pings for `@everyone`, `@here` and `<@id>` in a message body; an invisible space breaks each of them. */
48
+ const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b)
49
+ const escapeDiscordText = (value: string): string => escapeMarkdown(value).replace(/@/g, `@${ZERO_WIDTH_SPACE}`)
50
+
51
+ /**
52
+ * A MessageCard understands only a small part of markdown and may not honour a backslash, so instead of escaping, the
53
+ * two things that make a link or a tag are broken with an invisible space: `](` and `<`.
54
+ */
55
+ const escapeTeamsText = (value: string): string => value.replace(/\]\(/g, `]${ZERO_WIDTH_SPACE}(`).replace(/</g, `<${ZERO_WIDTH_SPACE}`)
56
+
57
+ /**
58
+ * `escapeMarkdown`, cut so the result is at most `max` characters: whole escaped characters only, so no backslash is left
59
+ * at the end without the character it escapes. A cut text ends in an ellipsis.
60
+ */
61
+ function escapeWithin(value: string, max: number): string {
62
+ const whole = escapeMarkdown(value)
63
+ if (whole.length <= max) return whole
64
+ let out = ''
65
+ for (const char of value) {
66
+ const escaped = escapeMarkdown(char)
67
+ if (out.length + escaped.length > max - 1) break
68
+ out += escaped
69
+ }
70
+ return `${out}…`
71
+ }
72
+
73
+ /** The variables with the error text of the check, which a sender can write, run through `escape`. */
74
+ const withEscapedError = (vars: Vars, escape: (value: string) => string): Vars =>
75
+ vars['error_message'] === undefined ? vars : { ...vars, error_message: escape(vars['error_message']) }
76
+
77
+ /** Cuts `value` to `max` characters without splitting a surrogate pair. */
78
+ function clip(value: string, max: number): string {
79
+ const chars = Array.from(value)
80
+ return chars.length <= max ? value : `${chars.slice(0, max - 1).join('')}…`
81
+ }
82
+
83
+ /** Cuts `value` to `maxBytes` of UTF-8 without splitting a character. */
84
+ function clipBytes(value: string, maxBytes: number): string {
85
+ if (Buffer.byteLength(value) <= maxBytes) return value
86
+ let out = ''
87
+ for (const char of value) {
88
+ if (Buffer.byteLength(out + char + '…') > maxBytes) break
89
+ out += char
90
+ }
91
+ return `${out}…`
92
+ }
93
+
94
+ /** One outbound notification request; a non-2xx answer fails the delivery as `<failurePrefix> <status>`. */
95
+ async function sendHttp(
96
+ doFetch: typeof fetch,
97
+ url: string,
98
+ init: { method: string; headers: Record<string, string>; body?: string },
99
+ failurePrefix: string,
100
+ ): Promise<void> {
101
+ const res = await doFetch(url, { ...init, signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS) })
102
+ if (!res.ok) throw new Error(`${failurePrefix} ${res.status}`)
103
+ }
104
+
105
+ function postWebhookJson(doFetch: typeof fetch, url: string, payload: unknown, label: string): Promise<void> {
106
+ return sendHttp(doFetch, url, {
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'application/json' },
109
+ body: JSON.stringify(payload),
110
+ }, `${label} webhook returned HTTP`)
111
+ }
112
+
113
+ /** Throws `<service> API returned HTTP <status>[: <reason>]`; the reason is the service's own error text, never a credential. */
114
+ async function failOnHttpError(res: Response, service: string, reason: (json: Record<string, unknown>) => string): Promise<void> {
115
+ if (res.ok) return
116
+ const text = await res.json().then((j: unknown) => (j && typeof j === 'object' && !Array.isArray(j) ? reason(j as Record<string, unknown>) : '')).catch(() => '')
117
+ throw new Error(`${service} API returned HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`)
118
+ }
119
+
120
+ const TELEGRAM_MAX_LENGTH = 4096
121
+ /** Keeps the optional Message Text from crowding out the facts; the detail field is what gets cut to fit. */
122
+ const TELEGRAM_MAX_TEXT_LINE = 1000
123
+
124
+ const NTFY_DEFAULT_SERVER = 'https://ntfy.sh'
125
+ /** ntfy's default message limit is 4096 bytes, not characters. */
126
+ const NTFY_MAX_MESSAGE_BYTES = 4000
127
+ const HEADER_SAFE_TOKEN = /^[\x21-\x7e]+$/
128
+ const NTFY_PRIORITY: Record<NoticeSeverity, number> = { critical: 4, warning: 3, ok: 3 }
129
+ const NTFY_TAGS: Record<NoticeSeverity, string> = { critical: 'rotating_light', warning: 'warning', ok: 'white_check_mark' }
130
+
131
+ const PUSHOVER_URL = 'https://api.pushover.net/1/messages.json'
132
+ const PUSHOVER_MAX_MESSAGE = 1024
133
+ /** Pushover priority 1 is high: it bypasses the recipient's quiet hours. Only a critical event gets it. */
134
+ const PUSHOVER_PRIORITY: Record<NoticeSeverity, number> = { critical: 1, warning: 0, ok: 0 }
135
+
136
+ /** Discord refuses an embed field value of more than 1024 characters. */
137
+ const DISCORD_MAX_FIELD_VALUE = 1024
138
+ const DISCORD_COLORS: Record<NoticeSeverity, number> = { critical: 0xe53935, warning: 0xfb8c00, ok: 0x43a047 }
139
+ const TEAMS_COLORS: Record<NoticeSeverity, string> = { critical: 'E53935', warning: 'FB8C00', ok: '43A047' }
140
+ const SLACK_COLORS: Record<NoticeSeverity, string> = { critical: '#E53935', warning: '#FB8C00', ok: '#43A047' }
141
+
142
+ interface TelegramConfig { botToken?: string; vault?: VaultRef; chatId: string; text?: string }
143
+ interface NtfyConfig { serverUrl?: string; topic: string; accessToken?: string; vault?: VaultRef; text?: string }
144
+ interface PushoverConfig { appToken?: string; vault?: VaultRef; userKey: string; text?: string }
145
+
146
+ type Vars = Record<string, string>
147
+
148
+ /**
149
+ * The channel senders. An email and a webhook are written by the user as templates over `vars`; the other channels
150
+ * format the `notice` themselves and only read `vars` to fill in the free text the user typed into the channel.
151
+ */
152
+ export function createTransports(options: TransportOptions) {
153
+ const doFetch = options.fetch ?? fetch
154
+
155
+ /** A credential typed into the channel, or the `value` secret of its vault reference. */
156
+ async function resolveToken(direct: string | undefined, vault: VaultRef | undefined, vaultProblem: string): Promise<string> {
157
+ if (!vault) return direct ?? ''
158
+ const secret = await options.resolveSecret(vault)
159
+ const token = (secret['value'] ?? '').trim()
160
+ if (!token) throw new Error(vaultProblem)
161
+ return token
162
+ }
163
+
164
+ async function sendEmail(config: { to: string; subject?: string; body?: string }, vars: Vars, notice?: Notice) {
165
+ const standard = options.standardEmail
166
+ const isStandard = !!standard && config.subject === standard.subject && config.body === standard.body
167
+ // A part without wording of its own, or one that still has the stock template, is written from the notice.
168
+ const written = notice && (isStandard || !config.subject || !config.body) ? plainAlert(notice, vars, undefined, EMAIL_MAX_BODY) : undefined
169
+ await options.sendMail({
170
+ to: substituteVars(config.to, vars),
171
+ subject: written && (isStandard || !config.subject) ? written.title : substituteVars(config.subject ?? '', vars),
172
+ text: written && (isStandard || !config.body) ? written.body : substituteVars(config.body ?? '', vars),
173
+ })
174
+ }
175
+
176
+ async function sendWebhook(config: { url: string; method: string; headers?: Record<string, string>; body?: string }, vars: Vars) {
177
+ const { url, init } = buildWebhookRequest(config, vars)
178
+ await sendHttp(doFetch, url, init, 'HTTP')
179
+ }
180
+
181
+ async function sendDiscord(
182
+ config: { webhookUrl: string; username?: string; avatarUrl?: string; content?: string },
183
+ vars: Vars,
184
+ notice: Notice,
185
+ ) {
186
+ const embed = {
187
+ title: renderSegments(notice.headline, { subject: (v) => `\`${v}\``, state: (v) => `**${v}**` }),
188
+ color: DISCORD_COLORS[notice.severity],
189
+ fields: [
190
+ ...notice.facts.map(({ label, value }) => ({ name: label, value, inline: true })),
191
+ ...(notice.detail ? [{ name: notice.detail.label, value: escapeWithin(notice.detail.value, DISCORD_MAX_FIELD_VALUE), inline: false }] : []),
192
+ ],
193
+ footer: { text: `${notice.atLabel} ${notice.at}` },
194
+ timestamp: new Date().toISOString(),
195
+ }
196
+
197
+ const payload: Record<string, unknown> = { embeds: [embed] }
198
+ if (config.username) payload['username'] = config.username
199
+ payload['avatar_url'] = config.avatarUrl ? substituteVars(config.avatarUrl, vars, encodeUrlPart) : options.discordAvatarUrl
200
+ if (config.content) payload['content'] = substituteVars(config.content, withEscapedError(vars, escapeDiscordText))
201
+
202
+ await postWebhookJson(doFetch, config.webhookUrl, payload, 'Discord')
203
+ }
204
+
205
+ async function sendTeams(config: { webhookUrl: string; summary?: string }, vars: Vars, notice: Notice) {
206
+ // The card title is led by the emoji and the bold subject; the kind of subject is left to the summary.
207
+ const bold = { kind: () => '', subject: (v: string) => `**${v}**`, state: (v: string) => `**${v}**` }
208
+ const summary = config.summary ? substituteVars(config.summary, vars) : plainSegments(notice.headline)
209
+
210
+ const facts: { name: string; value: string }[] = [
211
+ ...notice.facts.map((fact) => ({ name: fact.fullLabel ?? fact.label, value: fact.value })),
212
+ ...(notice.detail ? [{ name: notice.detail.label, value: escapeTeamsText(notice.detail.value) }] : []),
213
+ { name: notice.atLabel, value: notice.at },
214
+ ]
215
+
216
+ const payload = {
217
+ '@type': 'MessageCard',
218
+ '@context': 'https://schema.org/extensions',
219
+ themeColor: TEAMS_COLORS[notice.severity],
220
+ summary,
221
+ sections: [{
222
+ activityTitle: `${SEVERITY_EMOJI[notice.severity]} ${renderSegments(notice.headline, bold)}`,
223
+ activitySubtitle: renderSegments(notice.subtitle, bold),
224
+ facts,
225
+ markdown: true,
226
+ }],
227
+ }
228
+
229
+ await postWebhookJson(doFetch, config.webhookUrl, payload, 'Teams')
230
+ }
231
+
232
+ async function sendSlack(config: { webhookUrl: string; text?: string }, vars: Vars, notice: Notice) {
233
+ const headline = renderSegments(notice.headline, { subject: (v) => `*${v}*`, state: (v) => `*${v}*` })
234
+ const fallbackText = `${SEVERITY_EMOJI[notice.severity]} ${headline}`
235
+
236
+ const fields = [
237
+ ...notice.facts.map(({ label, value }) => ({ type: 'mrkdwn', text: `*${label}:*\n${value}` })),
238
+ ...(notice.detail ? [{ type: 'mrkdwn', text: `*${notice.detail.label}:*\n${escapeHtml(notice.detail.value)}` }] : []),
239
+ ]
240
+
241
+ const blocks = [
242
+ { type: 'section', text: { type: 'mrkdwn', text: fallbackText } },
243
+ { type: 'section', fields },
244
+ { type: 'context', elements: [{ type: 'mrkdwn', text: `${notice.atLabel} ${notice.at}` }] },
245
+ ]
246
+
247
+ const payload: Record<string, unknown> = {
248
+ text: fallbackText,
249
+ attachments: [{ color: SLACK_COLORS[notice.severity], blocks }],
250
+ }
251
+ if (config.text) payload['text'] = substituteVars(config.text, vars, escapeHtml) + '\n' + fallbackText
252
+
253
+ await postWebhookJson(doFetch, config.webhookUrl, payload, 'Slack')
254
+ }
255
+
256
+ async function sendTelegram(config: TelegramConfig, vars: Vars, notice: Notice) {
257
+ const botToken = await resolveToken(config.botToken, config.vault, 'Telegram vault secret has no bot token: use a value secret')
258
+ const headline = renderSegments(notice.headline, { text: escapeHtml, subject: (v) => `<b>${v}</b>`, state: (v) => `<b>${v}</b>` })
259
+
260
+ const detail = notice.detail ? escapeHtml(notice.detail.value) : ''
261
+ const build = (detailText: string) => [
262
+ ...(config.text ? [escapeHtml(Array.from(substituteVars(config.text, vars)).slice(0, TELEGRAM_MAX_TEXT_LINE).join(''))] : []),
263
+ `${SEVERITY_EMOJI[notice.severity]} ${headline}`,
264
+ '',
265
+ ...notice.facts.map(({ label, value }) => `<b>${label}:</b> ${escapeHtml(value)}`),
266
+ ...(detailText ? [`<b>${notice.detail!.label}:</b> ${detailText}`] : []),
267
+ `<i>${notice.atLabel} ${escapeHtml(readableTimestamp(notice.at))}</i>`,
268
+ ].join('\n')
269
+
270
+ // Cutting the finished message could split an HTML tag, so an oversized one loses the tail of the details field only.
271
+ let text = build(detail)
272
+ if (text.length > TELEGRAM_MAX_LENGTH && detail) {
273
+ const keep = Math.max(0, detail.length - (text.length - TELEGRAM_MAX_LENGTH) - 1)
274
+ text = build(`${detail.slice(0, keep).replace(/&[a-z]*$/, '')}…`)
275
+ }
276
+
277
+ // The bot token is part of the URL path. Only the status code and Telegram's own description are reported
278
+ // (the Bot API never echoes the token), so the token never reaches the delivery history.
279
+ const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
280
+ method: 'POST',
281
+ headers: { 'Content-Type': 'application/json' },
282
+ body: JSON.stringify({ chat_id: config.chatId.trim().replace(/^#(?=-?\d)/, ''), text, parse_mode: 'HTML', link_preview_options: { is_disabled: true } }),
283
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
284
+ })
285
+ await failOnHttpError(res, 'Telegram', (j) => typeof j['description'] === 'string' ? j['description'] : '')
286
+ }
287
+
288
+ /** The plain-text alert the push channels (ntfy, Pushover) show: a headline and the facts under it. */
289
+ function plainAlert(notice: Notice, vars: Vars, customText: string | undefined, maxBody: number): { title: string; body: string } {
290
+ const body = [
291
+ ...(customText ? [clip(substituteVars(customText, vars), TELEGRAM_MAX_TEXT_LINE)] : []),
292
+ ...notice.facts.map(({ label, value }) => `${label}: ${value}`),
293
+ ...(notice.detail ? [`${notice.detail.label}: ${notice.detail.value}`] : []),
294
+ `${notice.atLabel} ${readableTimestamp(notice.at)}`,
295
+ ].join('\n')
296
+ return { title: clip(plainSegments(notice.headline), 250), body: clip(body, maxBody) }
297
+ }
298
+
299
+ async function sendNtfy(config: NtfyConfig, vars: Vars, notice: Notice) {
300
+ const token = await resolveToken(config.accessToken, config.vault, 'ntfy vault secret has no access token: use a value secret')
301
+ // fetch quotes an invalid header value in its error, which would put the token into the delivery history.
302
+ if (token && !HEADER_SAFE_TOKEN.test(token)) throw new Error('ntfy access token contains characters that cannot be sent in a header')
303
+ const { title, body } = plainAlert(notice, vars, config.text, NTFY_MAX_MESSAGE_BYTES)
304
+ // A server behind a reverse proxy may live under a path prefix, which publishing keeps.
305
+ const customServer = config.serverUrl?.trim()
306
+ const target = new URL(customServer || NTFY_DEFAULT_SERVER)
307
+ if (!target.pathname.endsWith('/')) target.pathname += '/'
308
+ // Publishing as JSON to the server root keeps non-ASCII titles intact, which a header-based publish would not.
309
+ const res = await (customServer ? doFetch : fetch)(target.href, {
310
+ method: 'POST',
311
+ headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
312
+ body: JSON.stringify({ topic: config.topic.trim(), title, message: clipBytes(body, NTFY_MAX_MESSAGE_BYTES), priority: NTFY_PRIORITY[notice.severity], tags: [NTFY_TAGS[notice.severity]] }),
313
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
314
+ redirect: 'error',
315
+ })
316
+ await failOnHttpError(res, 'ntfy', (j) => typeof j['error'] === 'string' ? j['error'] : '')
317
+ }
318
+
319
+ async function sendPushover(config: PushoverConfig, vars: Vars, notice: Notice) {
320
+ const token = await resolveToken(config.appToken, config.vault, 'Pushover vault secret has no application token: use a value secret')
321
+ const { title, body } = plainAlert(notice, vars, config.text, PUSHOVER_MAX_MESSAGE)
322
+ const res = await fetch(PUSHOVER_URL, {
323
+ method: 'POST',
324
+ headers: { 'Content-Type': 'application/json' },
325
+ body: JSON.stringify({ token, user: config.userKey.trim(), title, message: body, priority: PUSHOVER_PRIORITY[notice.severity] }),
326
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
327
+ redirect: 'error',
328
+ })
329
+ await failOnHttpError(res, 'Pushover', (j) => Array.isArray(j['errors']) ? j['errors'].filter((e) => typeof e === 'string').join('; ') : '')
330
+ }
331
+
332
+ /** Sends one event through the channel; throws when the channel type is unknown or the send fails. */
333
+ async function deliver(channel: TransportChannel, vars: Vars, notice: Notice): Promise<void> {
334
+ const config = configForEvent(channel.type, JSON.parse(channel.config) as Record<string, unknown>, vars['event_type'], options.wording)
335
+ switch (channel.type) {
336
+ case 'email': return sendEmail(config as { to: string; subject: string; body: string }, vars, notice)
337
+ case 'webhook': return sendWebhook(config as { url: string; method: string; headers?: Record<string, string>; body?: string }, vars)
338
+ case 'discord': return sendDiscord(config as { webhookUrl: string; username?: string; avatarUrl?: string; content?: string }, vars, notice)
339
+ case 'teams': return sendTeams(config as { webhookUrl: string; summary?: string }, vars, notice)
340
+ case 'slack': return sendSlack(config as { webhookUrl: string; text?: string }, vars, notice)
341
+ case 'telegram': return sendTelegram(config as unknown as TelegramConfig, vars, notice)
342
+ case 'ntfy': return sendNtfy(config as unknown as NtfyConfig, vars, notice)
343
+ case 'pushover': return sendPushover(config as unknown as PushoverConfig, vars, notice)
344
+ default: throw new Error(`Unsupported notification channel type: ${channel.type}`)
345
+ }
346
+ }
347
+
348
+ return { deliver, sendEmail }
349
+ }
350
+
351
+ export type Transports = ReturnType<typeof createTransports>
package/src/worker.ts ADDED
@@ -0,0 +1,58 @@
1
+ import cron, { type ScheduledTask } from 'node-cron'
2
+ import type { NotifyEngine } from './engine.js'
3
+
4
+ /** Work the application runs on the same ticks as the deliveries. A rejection is logged under `failure`, never thrown. */
5
+ export interface WorkerTask {
6
+ /** The line logged when the task fails (the error follows it). */
7
+ failure: string
8
+ run: () => Promise<unknown>
9
+ }
10
+
11
+ export interface DeliveryWorkerOptions {
12
+ engine: Pick<NotifyEngine, 'processDue' | 'purgeOldDeliveries'>
13
+ /** Run every 30 seconds beside the sending of due deliveries (retries, digests that came due). */
14
+ retryTasks?: readonly WorkerTask[]
15
+ /** Run daily at 03:00 beside the purge of old delivery history. */
16
+ purgeTasks?: readonly WorkerTask[]
17
+ /** Prefix of the line logged when the worker starts. */
18
+ logPrefix?: string
19
+ }
20
+
21
+ /**
22
+ * Sends the deliveries that came due (retries and digests) every 30 seconds and purges old history daily. Does not
23
+ * wait for a pass to finish before the next one is due; the engine takes care of overlapping passes.
24
+ */
25
+ export function createDeliveryWorker(options: DeliveryWorkerOptions) {
26
+ let retryTask: ScheduledTask | null = null
27
+ let purgeTask: ScheduledTask | null = null
28
+
29
+ const tasks = (list: readonly WorkerTask[]) => {
30
+ for (const task of list) task.run().catch((error) => console.error(task.failure, error))
31
+ }
32
+
33
+ function start(): void {
34
+ if (retryTask || purgeTask) return
35
+ retryTask = cron.schedule('*/30 * * * * *', () => {
36
+ tasks([
37
+ { failure: '[notifier] Retry worker failed:', run: () => options.engine.processDue() },
38
+ ...(options.retryTasks ?? []),
39
+ ])
40
+ })
41
+ purgeTask = cron.schedule('0 3 * * *', () => {
42
+ tasks([
43
+ { failure: '[notifier] Delivery history purge failed:', run: () => options.engine.purgeOldDeliveries() },
44
+ ...(options.purgeTasks ?? []),
45
+ ])
46
+ })
47
+ console.log(`${options.logPrefix ?? '[notifier]'} Retry worker started`)
48
+ }
49
+
50
+ function stop(): void {
51
+ retryTask?.destroy()
52
+ purgeTask?.destroy()
53
+ retryTask = null
54
+ purgeTask = null
55
+ }
56
+
57
+ return { start, stop }
58
+ }