@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 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 today; Discord / SMS channels in follow-up slices.
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: Discord, SMS channel drivers. Apps register custom channels via `manager.extend(name, factory)`.
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.28",
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)`. Discord / SMS channels ship in follow-up slices.",
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.28"
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.28",
32
- "@strav/database": "1.0.0-alpha.28",
33
- "@strav/mail": "1.0.0-alpha.28",
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 + four
5
- // channel drivers under subpaths (./mail, ./database, ./log, ./webhook).
6
- // Broadcast / SSE / Discord / SMS channels deferred to future slices.
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,