@strav/notification 1.0.0-alpha.28 → 1.0.0-alpha.29
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 +4 -2
- package/package.json +19 -8
- package/src/drivers/discord/discord_config.ts +51 -0
- package/src/drivers/discord/discord_notification_driver.ts +246 -0
- package/src/drivers/discord/discord_notification_provider.ts +37 -0
- package/src/drivers/discord/index.ts +7 -0
- package/src/drivers/sse/index.ts +7 -0
- package/src/drivers/sse/sse_config.ts +28 -0
- package/src/drivers/sse/sse_notification_driver.ts +234 -0
- package/src/drivers/sse/sse_notification_provider.ts +29 -0
- package/src/index.ts +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @strav/notification
|
|
2
2
|
|
|
3
|
-
Multi-channel notifications for Strav 1.0. One fluent surface (`notifications.send(notifiable, notification)`) that fan-outs to ≥1 channel drivers — mail / database / log / webhook / broadcast
|
|
3
|
+
Multi-channel notifications for Strav 1.0. One fluent surface (`notifications.send(notifiable, notification)`) that fan-outs to ≥1 channel drivers — mail / database / log / webhook / broadcast / discord / sse today; SMS channel in a follow-up slice.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import { BaseNotification, type Notifiable, NotificationManager } from '@strav/notification'
|
|
@@ -32,5 +32,7 @@ Canonical docs live in [`docs/notification/README.md`](../../docs/notification/R
|
|
|
32
32
|
| Log | `@strav/notification/log` | Routes through `@strav/kernel`'s `Logger`. Useful for dev + tests. |
|
|
33
33
|
| Webhook | `@strav/notification/webhook` | POSTs a signed JSON envelope (`x-strav-signature: sha256=...` over `${timestamp}.${body}`) to a configured endpoint. Exports `verifyWebhookSignature` for receiver-side validation. |
|
|
34
34
|
| Broadcast | `@strav/notification/broadcast` | Publishes a `BroadcastEvent` via `@strav/broadcast`'s `Broadcaster`. Pairs with `router.sse(...)` so live UI clients receive the same dispatch. |
|
|
35
|
+
| Discord | `@strav/notification/discord` | POSTs `notification.toDiscord(notifiable)` to a Discord webhook URL. Returns a string (shorthand for `{ content }`) or a `DiscordMessage` with `embeds` / `components` / per-message `webhookUrl` override. Per-recipient URLs via `notifiable.discordWebhookUrl`. |
|
|
36
|
+
| SSE | `@strav/notification/sse` | In-process pub/sub. Reads `notification.toSSE(notifiable)` and pushes to every active subscriber for that notifiable. HTTP handlers consume subscriptions via `driver.subscribe(id, { notifiableType? })` + `sseResponse()` from `@strav/http`. |
|
|
35
37
|
|
|
36
|
-
Deferred:
|
|
38
|
+
Deferred: SMS channel driver. Apps register custom channels via `manager.extend(name, factory)`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/notification",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
4
|
-
"description": "Strav multi-channel notifications — NotificationManager fan-out across channel drivers (mail / database / log / webhook / broadcast). Manager+drivers shape; new channels register via `manager.extend(name, factory)`.
|
|
3
|
+
"version": "1.0.0-alpha.29",
|
|
4
|
+
"description": "Strav multi-channel notifications — NotificationManager fan-out across channel drivers (mail / database / log / webhook / broadcast / discord / sse). Manager+drivers shape; new channels register via `manager.extend(name, factory)`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"types": "./src/index.ts",
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
"./log": "./src/drivers/log/index.ts",
|
|
13
13
|
"./webhook": "./src/drivers/webhook/index.ts",
|
|
14
14
|
"./broadcast": "./src/drivers/broadcast/index.ts",
|
|
15
|
+
"./discord": "./src/drivers/discord/index.ts",
|
|
16
|
+
"./sse": "./src/drivers/sse/index.ts",
|
|
15
17
|
"./tenanted": "./src/drivers/database/tenanted/index.ts"
|
|
16
18
|
},
|
|
17
19
|
"files": [
|
|
@@ -25,12 +27,19 @@
|
|
|
25
27
|
"access": "public"
|
|
26
28
|
},
|
|
27
29
|
"dependencies": {
|
|
28
|
-
"@strav/kernel": "1.0.0-alpha.
|
|
30
|
+
"@strav/kernel": "1.0.0-alpha.29"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@strav/broadcast": "1.0.0-alpha.29",
|
|
34
|
+
"@strav/database": "1.0.0-alpha.29",
|
|
35
|
+
"@strav/http": "1.0.0-alpha.29",
|
|
36
|
+
"@strav/mail": "1.0.0-alpha.29"
|
|
29
37
|
},
|
|
30
38
|
"peerDependencies": {
|
|
31
|
-
"@strav/broadcast": "1.0.0-alpha.
|
|
32
|
-
"@strav/database": "1.0.0-alpha.
|
|
33
|
-
"@strav/
|
|
39
|
+
"@strav/broadcast": "1.0.0-alpha.29",
|
|
40
|
+
"@strav/database": "1.0.0-alpha.29",
|
|
41
|
+
"@strav/http": "1.0.0-alpha.29",
|
|
42
|
+
"@strav/mail": "1.0.0-alpha.29",
|
|
34
43
|
"@types/bun": ">=1.3.14"
|
|
35
44
|
},
|
|
36
45
|
"peerDependenciesMeta": {
|
|
@@ -40,9 +49,11 @@
|
|
|
40
49
|
"@strav/database": {
|
|
41
50
|
"optional": true
|
|
42
51
|
},
|
|
52
|
+
"@strav/http": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
43
55
|
"@strav/mail": {
|
|
44
56
|
"optional": true
|
|
45
57
|
}
|
|
46
|
-
}
|
|
47
|
-
"devDependencies": null
|
|
58
|
+
}
|
|
48
59
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor-specific config shape for the Discord channel. The
|
|
3
|
+
* discriminator `driver: 'discord'` selects this factory at
|
|
4
|
+
* `manager.use(...)` time.
|
|
5
|
+
*
|
|
6
|
+
* The Discord channel ships as a *webhook* driver — apps configure
|
|
7
|
+
* one Discord webhook URL per channel and POST against it. Per-
|
|
8
|
+
* recipient routing happens two ways:
|
|
9
|
+
*
|
|
10
|
+
* 1. Notifiables expose their own `discordWebhookUrl` field, and the
|
|
11
|
+
* driver uses it instead of the channel default.
|
|
12
|
+
* 2. The notification's `toDiscord(notifiable)` hook returns a
|
|
13
|
+
* `{ webhookUrl, ... }` envelope that overrides both.
|
|
14
|
+
*
|
|
15
|
+
* Bot tokens / interaction-aware messaging is out of scope for this
|
|
16
|
+
* slice — apps that need it bring their own integration and dispatch
|
|
17
|
+
* through `manager.extend(name, factory)`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ChannelConfig } from '../../notification_config.ts'
|
|
21
|
+
|
|
22
|
+
export interface DiscordChannelConfig extends ChannelConfig {
|
|
23
|
+
driver: 'discord'
|
|
24
|
+
/**
|
|
25
|
+
* Default webhook URL. Optional — apps that route every dispatch
|
|
26
|
+
* via per-recipient or per-notification URLs omit it. The driver
|
|
27
|
+
* fails the dispatch (returns `delivered: false`, no error) when
|
|
28
|
+
* neither the notification, the notifiable, nor the config supplies
|
|
29
|
+
* a URL — same opt-out semantics as the mail / webhook channels.
|
|
30
|
+
*/
|
|
31
|
+
webhookUrl?: string
|
|
32
|
+
/**
|
|
33
|
+
* Default username shown for messages sent via this channel. Apps
|
|
34
|
+
* commonly set this to their product name. Per-message overrides
|
|
35
|
+
* win — `toDiscord` can return `{ username: '...' }`.
|
|
36
|
+
*/
|
|
37
|
+
username?: string
|
|
38
|
+
/**
|
|
39
|
+
* Default avatar URL. Same override rules as `username`.
|
|
40
|
+
*/
|
|
41
|
+
avatarUrl?: string
|
|
42
|
+
/**
|
|
43
|
+
* When `true`, the driver appends `?wait=true` to the webhook URL.
|
|
44
|
+
* Discord then responds 200 with the created message JSON instead
|
|
45
|
+
* of 204 with an empty body — useful if downstream wants the
|
|
46
|
+
* message ID via the dispatch result's `reference`. Default `false`.
|
|
47
|
+
*/
|
|
48
|
+
wait?: boolean
|
|
49
|
+
/** Request timeout in ms. Default `5000`. */
|
|
50
|
+
timeoutMs?: number
|
|
51
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DiscordNotificationDriver` — POSTs notifications to a Discord
|
|
3
|
+
* webhook URL.
|
|
4
|
+
*
|
|
5
|
+
* The wire shape matches Discord's
|
|
6
|
+
* [Execute Webhook](https://discord.com/developers/docs/resources/webhook#execute-webhook)
|
|
7
|
+
* endpoint:
|
|
8
|
+
*
|
|
9
|
+
* POST {webhookUrl}[?wait=true]
|
|
10
|
+
* content-type: application/json
|
|
11
|
+
*
|
|
12
|
+
* {
|
|
13
|
+
* "content": "Hi from Strav", // ≤ 2000 chars
|
|
14
|
+
* "username": "Strav", // overrides webhook default
|
|
15
|
+
* "avatar_url": "https://...",
|
|
16
|
+
* "embeds": [ { "title": "...", ... } ],
|
|
17
|
+
* "components": [ ... ],
|
|
18
|
+
* "allowed_mentions": { "parse": [] }
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* Reads `notification.toDiscord(notifiable)` for the body. The hook
|
|
22
|
+
* can return either:
|
|
23
|
+
*
|
|
24
|
+
* - A string — shorthand for `{ content: <string> }`.
|
|
25
|
+
* - A `DiscordMessage` — full envelope, including an optional
|
|
26
|
+
* `webhookUrl` field that overrides the channel default.
|
|
27
|
+
*
|
|
28
|
+
* Webhook URL resolution order: hook return → `notifiable.discordWebhookUrl`
|
|
29
|
+
* → `config.webhookUrl`. When none resolve, the dispatch is skipped
|
|
30
|
+
* (`{ delivered: false }` with no error) — same intentional opt-out
|
|
31
|
+
* the mail + webhook channels use.
|
|
32
|
+
*
|
|
33
|
+
* Wire normalisation:
|
|
34
|
+
* - `username` / `avatar_url`: per-message > channel default. The
|
|
35
|
+
* hook sees the channel default as a `defaults` argument so it
|
|
36
|
+
* can branch on it if needed.
|
|
37
|
+
* - Camel-case keys on the JS side (`avatarUrl`, `allowedMentions`,
|
|
38
|
+
* `threadName`) are translated to Discord's snake_case wire form
|
|
39
|
+
* before send. This keeps apps' notification code idiomatic.
|
|
40
|
+
*
|
|
41
|
+
* On 2xx the driver returns `{ delivered: true }`. With `wait: true`,
|
|
42
|
+
* Discord echoes the created message JSON — the driver returns its
|
|
43
|
+
* `id` as the dispatch `reference`. On 4xx / 5xx / network failure
|
|
44
|
+
* the driver throws `NotificationDeliveryError`; 429 + 5xx flag
|
|
45
|
+
* `retryable: true`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import type { Notifiable } from '../../notifiable.ts'
|
|
49
|
+
import type { BaseNotification } from '../../notification.ts'
|
|
50
|
+
import type { NotificationDriver } from '../../notification_driver.ts'
|
|
51
|
+
import { NotificationDeliveryError } from '../../notification_error.ts'
|
|
52
|
+
import type { NotificationContext, NotificationDeliveryResult } from '../../types.ts'
|
|
53
|
+
|
|
54
|
+
/** Shape returned by `notification.toDiscord(notifiable)`. */
|
|
55
|
+
export interface DiscordMessage {
|
|
56
|
+
/** Message text body. Up to 2000 chars (Discord limit — not enforced here). */
|
|
57
|
+
content?: string
|
|
58
|
+
/** Override the webhook's configured display name for this message. */
|
|
59
|
+
username?: string
|
|
60
|
+
/** Override the webhook's avatar for this message. */
|
|
61
|
+
avatarUrl?: string
|
|
62
|
+
/** Embeds — up to 10. Apps build them per the Discord embed object spec. */
|
|
63
|
+
embeds?: ReadonlyArray<Record<string, unknown>>
|
|
64
|
+
/** Message-component arrays (buttons, selects). */
|
|
65
|
+
components?: ReadonlyArray<Record<string, unknown>>
|
|
66
|
+
/**
|
|
67
|
+
* Allowed-mentions control. Default behaviour at Discord is to
|
|
68
|
+
* resolve every mention in `content` — set
|
|
69
|
+
* `{ parse: [] }` to suppress all `@mentions`.
|
|
70
|
+
*/
|
|
71
|
+
allowedMentions?: Record<string, unknown>
|
|
72
|
+
/** Render the message as text-to-speech. */
|
|
73
|
+
tts?: boolean
|
|
74
|
+
/** When the webhook targets a forum, name the new thread. */
|
|
75
|
+
threadName?: string
|
|
76
|
+
/** Message flags bitfield (e.g. SUPPRESS_EMBEDS = 1 << 2). */
|
|
77
|
+
flags?: number
|
|
78
|
+
/**
|
|
79
|
+
* Override the webhook URL for this dispatch only. Useful when the
|
|
80
|
+
* notification carries its own routing decision; takes priority
|
|
81
|
+
* over `notifiable.discordWebhookUrl` and `config.webhookUrl`.
|
|
82
|
+
*/
|
|
83
|
+
webhookUrl?: string
|
|
84
|
+
/**
|
|
85
|
+
* Pass-through escape hatch — keys placed here are added to the
|
|
86
|
+
* Discord payload verbatim (snake_case expected). Use for fields
|
|
87
|
+
* the typed envelope hasn't grown to cover yet.
|
|
88
|
+
*/
|
|
89
|
+
extra?: Record<string, unknown>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Hook surface — apps add `toDiscord(notifiable, defaults)` on their notification. */
|
|
93
|
+
interface DiscordCapableNotification extends BaseNotification {
|
|
94
|
+
toDiscord?(
|
|
95
|
+
notifiable: Notifiable,
|
|
96
|
+
defaults: { username?: string; avatarUrl?: string },
|
|
97
|
+
): string | DiscordMessage | Promise<string | DiscordMessage>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface NotifiableWithDiscordWebhook extends Notifiable {
|
|
101
|
+
discordWebhookUrl?: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface DiscordNotificationDriverOptions {
|
|
105
|
+
name: string
|
|
106
|
+
webhookUrl?: string
|
|
107
|
+
username?: string
|
|
108
|
+
avatarUrl?: string
|
|
109
|
+
wait?: boolean
|
|
110
|
+
timeoutMs?: number
|
|
111
|
+
/** Custom `fetch` for tests. */
|
|
112
|
+
fetch?: typeof fetch
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class DiscordNotificationDriver implements NotificationDriver {
|
|
116
|
+
readonly name: string
|
|
117
|
+
private readonly defaultWebhookUrl: string | undefined
|
|
118
|
+
private readonly username: string | undefined
|
|
119
|
+
private readonly avatarUrl: string | undefined
|
|
120
|
+
private readonly wait: boolean
|
|
121
|
+
private readonly timeoutMs: number
|
|
122
|
+
private readonly fetchFn: typeof fetch
|
|
123
|
+
|
|
124
|
+
constructor(options: DiscordNotificationDriverOptions) {
|
|
125
|
+
this.name = options.name
|
|
126
|
+
this.defaultWebhookUrl = options.webhookUrl
|
|
127
|
+
this.username = options.username
|
|
128
|
+
this.avatarUrl = options.avatarUrl
|
|
129
|
+
this.wait = options.wait ?? false
|
|
130
|
+
this.timeoutMs = options.timeoutMs ?? 5000
|
|
131
|
+
this.fetchFn = options.fetch ?? fetch
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async send(
|
|
135
|
+
notifiable: Notifiable,
|
|
136
|
+
notification: BaseNotification,
|
|
137
|
+
context: NotificationContext,
|
|
138
|
+
): Promise<NotificationDeliveryResult> {
|
|
139
|
+
const hook = (notification as DiscordCapableNotification).toDiscord
|
|
140
|
+
if (typeof hook !== 'function') {
|
|
141
|
+
return { channel: this.name, delivered: false }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const defaults = {
|
|
145
|
+
...(this.username !== undefined ? { username: this.username } : {}),
|
|
146
|
+
...(this.avatarUrl !== undefined ? { avatarUrl: this.avatarUrl } : {}),
|
|
147
|
+
}
|
|
148
|
+
const raw = await hook.call(notification, notifiable, defaults)
|
|
149
|
+
const message: DiscordMessage = typeof raw === 'string' ? { content: raw } : raw
|
|
150
|
+
|
|
151
|
+
const webhookUrl =
|
|
152
|
+
message.webhookUrl ??
|
|
153
|
+
(notifiable as NotifiableWithDiscordWebhook).discordWebhookUrl ??
|
|
154
|
+
this.defaultWebhookUrl
|
|
155
|
+
if (webhookUrl === undefined || webhookUrl === '') {
|
|
156
|
+
return { channel: this.name, delivered: false }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const body = JSON.stringify(serialise(message, defaults))
|
|
160
|
+
const endpoint = this.wait ? appendWait(webhookUrl) : webhookUrl
|
|
161
|
+
|
|
162
|
+
let response: Response
|
|
163
|
+
try {
|
|
164
|
+
response = await this.fetchFn(endpoint, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'content-type': 'application/json' },
|
|
167
|
+
body,
|
|
168
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
169
|
+
})
|
|
170
|
+
} catch (cause) {
|
|
171
|
+
throw new NotificationDeliveryError(
|
|
172
|
+
`DiscordNotificationDriver: network failure for channel "${this.name}".`,
|
|
173
|
+
{
|
|
174
|
+
context: {
|
|
175
|
+
channel: this.name,
|
|
176
|
+
notifiableId: notifiable.id,
|
|
177
|
+
notification: notification.constructor.name,
|
|
178
|
+
retryable: true,
|
|
179
|
+
},
|
|
180
|
+
cause,
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (response.ok) {
|
|
186
|
+
// When `wait: true`, Discord returns 200 with the created message
|
|
187
|
+
// JSON; we expose its id as the dispatch reference. Without
|
|
188
|
+
// `wait`, Discord returns 204 — no reference available, fall
|
|
189
|
+
// back to the notification context id for correlation.
|
|
190
|
+
let reference: string = context.id
|
|
191
|
+
if (this.wait && response.status === 200) {
|
|
192
|
+
try {
|
|
193
|
+
const created = (await response.json()) as { id?: string }
|
|
194
|
+
if (typeof created.id === 'string') reference = created.id
|
|
195
|
+
} catch {
|
|
196
|
+
// Discord drifted — keep the context id as the reference.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { channel: this.name, delivered: true, reference }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const responseBody = await response.text().catch(() => '')
|
|
203
|
+
throw new NotificationDeliveryError(
|
|
204
|
+
`DiscordNotificationDriver: Discord responded HTTP ${response.status} ${response.statusText}.`,
|
|
205
|
+
{
|
|
206
|
+
context: {
|
|
207
|
+
channel: this.name,
|
|
208
|
+
notifiableId: notifiable.id,
|
|
209
|
+
notification: notification.constructor.name,
|
|
210
|
+
status: response.status,
|
|
211
|
+
retryable: response.status >= 500 || response.status === 429,
|
|
212
|
+
responseBody: responseBody.slice(0, 1024),
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Map our camel-case `DiscordMessage` shape onto Discord's wire JSON.
|
|
221
|
+
* Per-message values win over channel defaults; `extra` is spread
|
|
222
|
+
* verbatim so apps can reach fields the typed envelope hasn't grown
|
|
223
|
+
* to yet (e.g. `poll`, `applied_tags` for forum posts).
|
|
224
|
+
*/
|
|
225
|
+
function serialise(
|
|
226
|
+
m: DiscordMessage,
|
|
227
|
+
defaults: { username?: string; avatarUrl?: string },
|
|
228
|
+
): Record<string, unknown> {
|
|
229
|
+
const wire: Record<string, unknown> = { ...m.extra }
|
|
230
|
+
if (m.content !== undefined) wire.content = m.content
|
|
231
|
+
const username = m.username ?? defaults.username
|
|
232
|
+
if (username !== undefined) wire.username = username
|
|
233
|
+
const avatar = m.avatarUrl ?? defaults.avatarUrl
|
|
234
|
+
if (avatar !== undefined) wire.avatar_url = avatar
|
|
235
|
+
if (m.embeds !== undefined) wire.embeds = m.embeds
|
|
236
|
+
if (m.components !== undefined) wire.components = m.components
|
|
237
|
+
if (m.allowedMentions !== undefined) wire.allowed_mentions = m.allowedMentions
|
|
238
|
+
if (m.tts !== undefined) wire.tts = m.tts
|
|
239
|
+
if (m.threadName !== undefined) wire.thread_name = m.threadName
|
|
240
|
+
if (m.flags !== undefined) wire.flags = m.flags
|
|
241
|
+
return wire
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function appendWait(url: string): string {
|
|
245
|
+
return url.includes('?') ? `${url}&wait=true` : `${url}?wait=true`
|
|
246
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServiceProvider that registers the discord-channel factory on the
|
|
3
|
+
* `NotificationManager`. Apps include this in their provider list
|
|
4
|
+
* AFTER `NotificationProvider`; the factory resolves whenever
|
|
5
|
+
* `config.notification.channels.<name>.driver === 'discord'`.
|
|
6
|
+
*
|
|
7
|
+
* Unlike the webhook channel, the Discord factory does NOT validate
|
|
8
|
+
* `webhookUrl` upfront — apps can intentionally omit it and route
|
|
9
|
+
* every dispatch via per-recipient (`notifiable.discordWebhookUrl`)
|
|
10
|
+
* or per-message (`toDiscord` returning `{ webhookUrl }`) URLs. The
|
|
11
|
+
* driver fails the dispatch (`delivered: false`) when none resolve.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
15
|
+
import { NotificationManager } from '../../notification_manager.ts'
|
|
16
|
+
import type { DiscordChannelConfig } from './discord_config.ts'
|
|
17
|
+
import { DiscordNotificationDriver } from './discord_notification_driver.ts'
|
|
18
|
+
|
|
19
|
+
export class DiscordNotificationProvider extends ServiceProvider {
|
|
20
|
+
override readonly name = 'notification.discord'
|
|
21
|
+
override readonly dependencies = ['notification']
|
|
22
|
+
|
|
23
|
+
override async boot(app: Application): Promise<void> {
|
|
24
|
+
const manager = app.resolve(NotificationManager)
|
|
25
|
+
manager.extend('discord', ({ instanceName, config }) => {
|
|
26
|
+
const cfg = config as DiscordChannelConfig
|
|
27
|
+
return new DiscordNotificationDriver({
|
|
28
|
+
name: instanceName,
|
|
29
|
+
...(cfg.webhookUrl !== undefined ? { webhookUrl: cfg.webhookUrl } : {}),
|
|
30
|
+
...(cfg.username !== undefined ? { username: cfg.username } : {}),
|
|
31
|
+
...(cfg.avatarUrl !== undefined ? { avatarUrl: cfg.avatarUrl } : {}),
|
|
32
|
+
...(cfg.wait !== undefined ? { wait: cfg.wait } : {}),
|
|
33
|
+
...(cfg.timeoutMs !== undefined ? { timeoutMs: cfg.timeoutMs } : {}),
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { DiscordChannelConfig } from './discord_config.ts'
|
|
2
|
+
export {
|
|
3
|
+
type DiscordMessage,
|
|
4
|
+
DiscordNotificationDriver,
|
|
5
|
+
type DiscordNotificationDriverOptions,
|
|
6
|
+
} from './discord_notification_driver.ts'
|
|
7
|
+
export { DiscordNotificationProvider } from './discord_notification_provider.ts'
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { SSEChannelConfig } from './sse_config.ts'
|
|
2
|
+
export {
|
|
3
|
+
SSENotificationDriver,
|
|
4
|
+
type SSENotificationDriverOptions,
|
|
5
|
+
type SSESubscribeOptions,
|
|
6
|
+
} from './sse_notification_driver.ts'
|
|
7
|
+
export { SSENotificationProvider } from './sse_notification_provider.ts'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor-specific config shape for the SSE channel. The
|
|
3
|
+
* discriminator `driver: 'sse'` selects this factory at
|
|
4
|
+
* `manager.use(...)` time.
|
|
5
|
+
*
|
|
6
|
+
* Unlike the broadcast channel, the SSE channel is a *pure in-process*
|
|
7
|
+
* pub/sub registry — no `Broadcaster` peer, no Postgres LISTEN/NOTIFY.
|
|
8
|
+
* One process, one registry; subscribers live on the same Bun instance
|
|
9
|
+
* that dispatches the notification. Apps that need cross-process
|
|
10
|
+
* fan-out wire the broadcast channel instead.
|
|
11
|
+
*
|
|
12
|
+
* When neither is appropriate (single-process apps that don't want a
|
|
13
|
+
* pub/sub backplane at all), this is the simplest way to push a live
|
|
14
|
+
* notification into a `router.sse(...)` handler.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ChannelConfig } from '../../notification_config.ts'
|
|
18
|
+
|
|
19
|
+
export interface SSEChannelConfig extends ChannelConfig {
|
|
20
|
+
driver: 'sse'
|
|
21
|
+
/**
|
|
22
|
+
* Per-subscriber queue size. When a subscriber falls behind by
|
|
23
|
+
* more than `queueSize` events, the oldest events are dropped
|
|
24
|
+
* (best-effort delivery — the SSE contract anyway; clients
|
|
25
|
+
* recover via `Last-Event-ID` on reconnect). Default `64`.
|
|
26
|
+
*/
|
|
27
|
+
queueSize?: number
|
|
28
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SSENotificationDriver` — in-process pub/sub channel for live
|
|
3
|
+
* notifications.
|
|
4
|
+
*
|
|
5
|
+
* The driver maintains a `Map<key, Set<Subscriber>>` keyed by the
|
|
6
|
+
* notifiable's identity. `send(notifiable, ...)` reads
|
|
7
|
+
* `notification.toSSE(notifiable)` for the event body and pushes it
|
|
8
|
+
* to every subscriber for that notifiable; HTTP handlers consume
|
|
9
|
+
* subscriptions via `subscribe(id, { notifiableType? })` and pipe the
|
|
10
|
+
* iterable into `sseResponse(...)` from `@strav/http`.
|
|
11
|
+
*
|
|
12
|
+
* const route = router.get('/notifications/stream', async (ctx) => {
|
|
13
|
+
* const user = ctx.auth.user!
|
|
14
|
+
* const driver = notifications.use('sse') as SSENotificationDriver
|
|
15
|
+
* const stream = driver.subscribe(user.id, { notifiableType: 'User' })
|
|
16
|
+
* return sseResponse(stream, { signal: ctx.request.raw.signal })
|
|
17
|
+
* })
|
|
18
|
+
*
|
|
19
|
+
* Why this exists alongside the broadcast channel:
|
|
20
|
+
*
|
|
21
|
+
* - **Broadcast** (`./broadcast`) routes through `@strav/broadcast`'s
|
|
22
|
+
* `Broadcaster` — pluggable backplane (memory / postgres), supports
|
|
23
|
+
* multi-process fan-out via LISTEN/NOTIFY.
|
|
24
|
+
* - **SSE** (this driver) is a single-process registry with no
|
|
25
|
+
* peer dependency. Right answer for the common "I just want my
|
|
26
|
+
* user to see the notification in their open tab" case without
|
|
27
|
+
* pulling in a broadcast driver.
|
|
28
|
+
*
|
|
29
|
+
* Backpressure — each subscriber holds a bounded queue (default 64).
|
|
30
|
+
* When a slow consumer falls behind, the oldest events are dropped
|
|
31
|
+
* to make room (and `droppedEvents` increments). Lost events are
|
|
32
|
+
* the SSE contract anyway: clients recover by reading
|
|
33
|
+
* `Last-Event-ID` on reconnect and asking the app to backfill from
|
|
34
|
+
* the database channel.
|
|
35
|
+
*
|
|
36
|
+
* Skips delivery (`{ delivered: false }`, no error) when the hook is
|
|
37
|
+
* absent OR no subscribers exist for the notifiable. Apps inspect
|
|
38
|
+
* `result.delivered === false && result.error === undefined` to
|
|
39
|
+
* branch on "user is offline" vs a real failure.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { SSEEvent } from '@strav/http'
|
|
43
|
+
import type { Notifiable } from '../../notifiable.ts'
|
|
44
|
+
import type { BaseNotification } from '../../notification.ts'
|
|
45
|
+
import type { NotificationDriver } from '../../notification_driver.ts'
|
|
46
|
+
import { NotificationDeliveryError } from '../../notification_error.ts'
|
|
47
|
+
import type { NotificationContext, NotificationDeliveryResult } from '../../types.ts'
|
|
48
|
+
|
|
49
|
+
/** Hook surface — apps add `toSSE(notifiable)` on their notification. */
|
|
50
|
+
interface SSECapableNotification extends BaseNotification {
|
|
51
|
+
toSSE?(notifiable: Notifiable): SSEEvent | string | Promise<SSEEvent | string>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SSESubscribeOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Constrain the subscription to a specific notifiable type. When
|
|
57
|
+
* both subscriber and dispatch provide a `notifiableType`, they
|
|
58
|
+
* must match for the event to land. When either omits it, the id
|
|
59
|
+
* alone is used (looser routing — useful for tests).
|
|
60
|
+
*/
|
|
61
|
+
notifiableType?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SSENotificationDriverOptions {
|
|
65
|
+
name: string
|
|
66
|
+
/** Per-subscriber bounded queue size. Default 64. */
|
|
67
|
+
queueSize?: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Subscriber {
|
|
71
|
+
push(event: SSEEvent): void
|
|
72
|
+
close(): void
|
|
73
|
+
droppedEvents: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class SSENotificationDriver implements NotificationDriver {
|
|
77
|
+
readonly name: string
|
|
78
|
+
private readonly queueSize: number
|
|
79
|
+
private readonly subscribers = new Map<string, Set<Subscriber>>()
|
|
80
|
+
|
|
81
|
+
constructor(options: SSENotificationDriverOptions) {
|
|
82
|
+
this.name = options.name
|
|
83
|
+
this.queueSize = options.queueSize ?? 64
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Open a subscription for `id` (+ optional `notifiableType`). The
|
|
88
|
+
* returned iterable yields one `SSEEvent` per matched dispatch and
|
|
89
|
+
* runs cleanly when the consumer breaks out of the `for await`
|
|
90
|
+
* loop or the iterator's `return()` is called (which
|
|
91
|
+
* `sseResponse()` does on client disconnect).
|
|
92
|
+
*/
|
|
93
|
+
subscribe(id: string | number, options: SSESubscribeOptions = {}): AsyncIterable<SSEEvent> {
|
|
94
|
+
const key = subscriberKey(id, options.notifiableType)
|
|
95
|
+
return makeSubscription(this.subscribers, key, this.queueSize)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How many active subscribers exist for `(id, notifiableType?)`.
|
|
100
|
+
* Useful for the database channel pairing — apps may want to skip
|
|
101
|
+
* persisting a "live" event when the user already has an SSE tab
|
|
102
|
+
* open and consumed it.
|
|
103
|
+
*/
|
|
104
|
+
subscriberCount(id: string | number, options: SSESubscribeOptions = {}): number {
|
|
105
|
+
const key = subscriberKey(id, options.notifiableType)
|
|
106
|
+
return this.subscribers.get(key)?.size ?? 0
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async send(
|
|
110
|
+
notifiable: Notifiable,
|
|
111
|
+
notification: BaseNotification,
|
|
112
|
+
context: NotificationContext,
|
|
113
|
+
): Promise<NotificationDeliveryResult> {
|
|
114
|
+
const hook = (notification as SSECapableNotification).toSSE
|
|
115
|
+
if (typeof hook !== 'function') {
|
|
116
|
+
return { channel: this.name, delivered: false }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const key = subscriberKey(notifiable.id, notifiable.notifiableType)
|
|
120
|
+
const targets = this.subscribers.get(key)
|
|
121
|
+
if (targets === undefined || targets.size === 0) {
|
|
122
|
+
return { channel: this.name, delivered: false }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let raw: SSEEvent | string
|
|
126
|
+
try {
|
|
127
|
+
raw = await hook.call(notification, notifiable)
|
|
128
|
+
} catch (cause) {
|
|
129
|
+
throw new NotificationDeliveryError(
|
|
130
|
+
`SSENotificationDriver: toSSE() threw for channel "${this.name}".`,
|
|
131
|
+
{
|
|
132
|
+
context: {
|
|
133
|
+
channel: this.name,
|
|
134
|
+
notifiableId: notifiable.id,
|
|
135
|
+
notification: notification.constructor.name,
|
|
136
|
+
},
|
|
137
|
+
cause,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Default `event` to the notification class and `id` to the
|
|
143
|
+
// dispatch context id — both can be overridden by the hook.
|
|
144
|
+
const base: SSEEvent = typeof raw === 'string' ? { data: raw } : { ...raw }
|
|
145
|
+
if (base.id === undefined) base.id = context.id
|
|
146
|
+
if (base.event === undefined) base.event = notification.constructor.name
|
|
147
|
+
|
|
148
|
+
// Snapshot the subscriber set before iterating — handlers may
|
|
149
|
+
// close + remove themselves mid-broadcast (e.g. heartbeat detects
|
|
150
|
+
// a dead connection).
|
|
151
|
+
for (const sub of Array.from(targets)) sub.push(base)
|
|
152
|
+
|
|
153
|
+
return { channel: this.name, delivered: true, reference: context.id }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function subscriberKey(id: string | number, notifiableType: string | undefined): string {
|
|
158
|
+
return `${notifiableType ?? ''}|${id}`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One subscriber = one bounded queue + a wake/sleep gate. The
|
|
163
|
+
* generator's `finally` block deregisters the subscriber from the
|
|
164
|
+
* shared map — so closing the response (or breaking the loop) tears
|
|
165
|
+
* down the slot cleanly.
|
|
166
|
+
*/
|
|
167
|
+
function makeSubscription(
|
|
168
|
+
registry: Map<string, Set<Subscriber>>,
|
|
169
|
+
key: string,
|
|
170
|
+
capacity: number,
|
|
171
|
+
): AsyncIterable<SSEEvent> {
|
|
172
|
+
return {
|
|
173
|
+
[Symbol.asyncIterator]() {
|
|
174
|
+
const queue: SSEEvent[] = []
|
|
175
|
+
let closed = false
|
|
176
|
+
let waker: (() => void) | undefined
|
|
177
|
+
const subscriber: Subscriber = {
|
|
178
|
+
droppedEvents: 0,
|
|
179
|
+
push(event) {
|
|
180
|
+
if (closed) return
|
|
181
|
+
if (queue.length >= capacity) {
|
|
182
|
+
queue.shift()
|
|
183
|
+
subscriber.droppedEvents += 1
|
|
184
|
+
}
|
|
185
|
+
queue.push(event)
|
|
186
|
+
waker?.()
|
|
187
|
+
},
|
|
188
|
+
close() {
|
|
189
|
+
if (closed) return
|
|
190
|
+
closed = true
|
|
191
|
+
waker?.()
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let bucket = registry.get(key)
|
|
196
|
+
if (bucket === undefined) {
|
|
197
|
+
bucket = new Set()
|
|
198
|
+
registry.set(key, bucket)
|
|
199
|
+
}
|
|
200
|
+
bucket.add(subscriber)
|
|
201
|
+
|
|
202
|
+
const detach = (): void => {
|
|
203
|
+
subscriber.close()
|
|
204
|
+
const set = registry.get(key)
|
|
205
|
+
if (set !== undefined) {
|
|
206
|
+
set.delete(subscriber)
|
|
207
|
+
if (set.size === 0) registry.delete(key)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
async next(): Promise<IteratorResult<SSEEvent>> {
|
|
213
|
+
while (true) {
|
|
214
|
+
const event = queue.shift()
|
|
215
|
+
if (event !== undefined) return { value: event, done: false }
|
|
216
|
+
if (closed) return { value: undefined, done: true }
|
|
217
|
+
await new Promise<void>((resolve) => {
|
|
218
|
+
waker = resolve
|
|
219
|
+
})
|
|
220
|
+
waker = undefined
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
async return(): Promise<IteratorResult<SSEEvent>> {
|
|
224
|
+
detach()
|
|
225
|
+
return { value: undefined, done: true }
|
|
226
|
+
},
|
|
227
|
+
async throw(err): Promise<IteratorResult<SSEEvent>> {
|
|
228
|
+
detach()
|
|
229
|
+
throw err
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServiceProvider that registers the SSE-channel factory on the
|
|
3
|
+
* `NotificationManager`. Apps include this in their provider list
|
|
4
|
+
* AFTER `NotificationProvider`; the factory resolves whenever
|
|
5
|
+
* `config.notification.channels.<name>.driver === 'sse'`.
|
|
6
|
+
*
|
|
7
|
+
* No peer dependencies — the driver is pure in-process.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
11
|
+
import { NotificationManager } from '../../notification_manager.ts'
|
|
12
|
+
import type { SSEChannelConfig } from './sse_config.ts'
|
|
13
|
+
import { SSENotificationDriver } from './sse_notification_driver.ts'
|
|
14
|
+
|
|
15
|
+
export class SSENotificationProvider extends ServiceProvider {
|
|
16
|
+
override readonly name = 'notification.sse'
|
|
17
|
+
override readonly dependencies = ['notification']
|
|
18
|
+
|
|
19
|
+
override async boot(app: Application): Promise<void> {
|
|
20
|
+
const manager = app.resolve(NotificationManager)
|
|
21
|
+
manager.extend('sse', ({ instanceName, config }) => {
|
|
22
|
+
const cfg = config as SSEChannelConfig
|
|
23
|
+
return new SSENotificationDriver({
|
|
24
|
+
name: instanceName,
|
|
25
|
+
...(cfg.queueSize !== undefined ? { queueSize: cfg.queueSize } : {}),
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Public API of @strav/notification.
|
|
2
2
|
//
|
|
3
3
|
// V1: NotificationManager facade + NotificationDriver interface +
|
|
4
|
-
// BaseNotification abstract class + Notifiable interface +
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// BaseNotification abstract class + Notifiable interface + channel
|
|
5
|
+
// drivers under subpaths (./mail, ./database, ./log, ./webhook,
|
|
6
|
+
// ./broadcast, ./discord, ./sse). SMS channel follows in a later slice.
|
|
7
7
|
|
|
8
8
|
export {
|
|
9
9
|
MockNotificationDriver,
|