@zerotal/notifications 1.0.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/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/package.json +58 -0
- package/src/BroadcastChannel.ts +80 -0
- package/src/BroadcastMessage.ts +32 -0
- package/src/BroadcastNotificationJob.ts +51 -0
- package/src/DatabaseChannel.ts +295 -0
- package/src/MailChannel.ts +93 -0
- package/src/Notifiable.ts +95 -0
- package/src/Notification.ts +114 -0
- package/src/NotificationFake.ts +282 -0
- package/src/NotificationManager.ts +269 -0
- package/src/NotificationRegistry.ts +37 -0
- package/src/OnDemandNotifiable.ts +46 -0
- package/src/SendNotificationJob.ts +67 -0
- package/src/SlackChannel.ts +76 -0
- package/src/SmsChannel.ts +151 -0
- package/src/admin.ts +219 -0
- package/src/commands/NotificationsPruneCommand.ts +54 -0
- package/src/commands/NotificationsTestCommand.ts +63 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +122 -0
- package/src/drivers/LogDriver.ts +41 -0
- package/src/drivers/MailDriver.ts +54 -0
- package/src/drivers/ResendDriver.ts +54 -0
- package/src/drivers/SmtpDriver.ts +510 -0
- package/src/errors.ts +163 -0
- package/src/events.ts +70 -0
- package/src/facades/Notify.ts +3 -0
- package/src/global.d.ts +31 -0
- package/src/index.ts +68 -0
- package/src/messages/MailMessage.ts +342 -0
- package/src/observability.ts +146 -0
- package/src/provider/NotificationProvider.ts +62 -0
- package/src/serialization.ts +188 -0
- package/src/stats.ts +93 -0
- package/src/types.ts +129 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Changelog — @zerotal/notifications
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `beta`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Queued notifications survive a persistent queue driver. `SendNotificationJob`
|
|
14
|
+
serializes its notifiable and notification, and rebuilds both on the worker
|
|
15
|
+
side; previously only the sync driver worked, and SQLite/Redis delivered a job
|
|
16
|
+
whose recipient and notification were `undefined`.
|
|
17
|
+
- The SMTP driver honours `secure`. It opens a TLS connection for `secure: true`,
|
|
18
|
+
upgrades via STARTTLS when a server offers it, and refuses to send credentials
|
|
19
|
+
over an unencrypted connection unless `allowInsecureAuth` says otherwise.
|
|
20
|
+
- SMTP replies are parsed and their status codes checked, so a rejected
|
|
21
|
+
recipient or failed authentication raises instead of reporting a successful
|
|
22
|
+
send. The reader frames multi-line replies rather than assuming one per packet.
|
|
23
|
+
- Header values are stripped of CR/LF before being written, closing a header
|
|
24
|
+
injection through a notification's subject or a display name. Non-ASCII header
|
|
25
|
+
values are encoded per RFC 2047, and body lines beginning with `.` are
|
|
26
|
+
dot-stuffed so a message is not truncated at its first such line.
|
|
27
|
+
- The database channel records the recipient's own class as `notifiable_type` and
|
|
28
|
+
scopes every read by type and id together, so models sharing an id no longer
|
|
29
|
+
share an inbox.
|
|
30
|
+
- A failing channel no longer cancels the others. Every declared channel is
|
|
31
|
+
attempted and the failures are reported together as `NotificationDispatchError`.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- Custom channels via `NotificationManager.extend(name, factory)`, resolved
|
|
36
|
+
lazily and able to replace a built-in.
|
|
37
|
+
- `channels(notifiable)` receives the recipient, so one notification can route
|
|
38
|
+
per person's preferences, and `routeNotificationFor(channel)` lets a notifiable
|
|
39
|
+
redirect an individual channel.
|
|
40
|
+
- `sendMany` / `queueMany` for many recipients, and `route()` for a destination
|
|
41
|
+
with no model behind it.
|
|
42
|
+
- Mail attachments — `MailMessage.attach()`, `attachFile()`, and `embed()` for
|
|
43
|
+
inline images — carried by the SMTP, Resend, and log drivers.
|
|
44
|
+
- `to*()` methods may return a promise, so building a message can do I/O.
|
|
45
|
+
- Inbox operations: paging on `all()`/`unread()`, `unreadCount()`,
|
|
46
|
+
`markAsUnread()`, `delete()`, `clear()`, `recent()`, and `prune()`. The table
|
|
47
|
+
gains indexes on the recipient lookups.
|
|
48
|
+
- `notifications:prune` and `notifications:test` console commands.
|
|
49
|
+
- An admin panel console showing recent deliveries, per-channel totals, and the
|
|
50
|
+
stored inbox, gated on the `notifications.view` ability.
|
|
51
|
+
- Config validation at boot through `NotificationConfig()` /
|
|
52
|
+
`validateNotificationConfig()`.
|
|
53
|
+
- `NotificationFake` gains `assertSentOn`, `assertQueued`, `assertSentTimes`,
|
|
54
|
+
`sentTo`, an inert `database` accessor, and failure messages listing what was
|
|
55
|
+
actually captured.
|
|
56
|
+
|
|
57
|
+
- `SlackMessage.webhookUrl` is optional; the URL resolves from the message, then
|
|
58
|
+
the notifiable's route, then `slack.webhook` in config.
|
|
59
|
+
- `SmsMessage.to` is optional and defaults to the notifiable's `phone`.
|
|
60
|
+
- `BroadcastMessage.onQueue()` delivers through the queue. `onConnection()` is
|
|
61
|
+
removed — the queue has no connection concept for it to name.
|
|
62
|
+
|
|
63
|
+
## [1.0.0] — 2026-08-05
|
|
64
|
+
|
|
65
|
+
_First public release._
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
|
|
69
|
+
- Added a typed error vocabulary (`NotificationError` + `E_NOTIFICATION_*` codes).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# @zerotal/notifications
|
|
2
|
+
|
|
3
|
+
> Multi-channel notifications — mail, database, broadcast, Slack, and SMS — from a single class.
|
|
4
|
+
|
|
5
|
+
Send the same notification across multiple delivery channels from one class: a `Notification` describes _what_ to send and which `channels()` to deliver on, while the `NotificationManager` routes it to each channel. Includes a `Notifiable` mixin for an object-oriented API and a `NotificationFake` for tests.
|
|
6
|
+
|
|
7
|
+
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @zerotal/notifications
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
Register the provider in `bootstrap/providers.ts`:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { NotificationProvider } from "@zerotal/notifications";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Write a notification by extending `Notification`, declaring `channels()`, then implementing a `to*()` method per channel:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { Notification, MailMessage } from "@zerotal/notifications";
|
|
29
|
+
import type { Notifiable } from "@zerotal/notifications";
|
|
30
|
+
|
|
31
|
+
export class OrderShippedNotification extends Notification {
|
|
32
|
+
constructor(private order: Order) {
|
|
33
|
+
super();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
channels() {
|
|
37
|
+
return ["mail", "database", "slack"];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
toMail(_notifiable: Notifiable) {
|
|
41
|
+
return new MailMessage()
|
|
42
|
+
.subject(`Order #${this.order.id} shipped`)
|
|
43
|
+
.line("Your order is on its way.")
|
|
44
|
+
.action("Track package", `https://app.test/orders/${this.order.id}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
toDatabase(_notifiable: Notifiable) {
|
|
48
|
+
return { orderId: this.order.id, status: "shipped" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
toSlack(_notifiable: Notifiable) {
|
|
52
|
+
return { text: `Order #${this.order.id} shipped` };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The recipient is passed to `channels()`, so one notification can follow each person's preferences:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
channels(user: Notifiable) {
|
|
61
|
+
return user.wantsSms ? ["database", "sms"] : ["database", "mail"];
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Send via the `Notify` facade or the `Notifiable` mixin:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { Notify } from "@zerotal/notifications";
|
|
69
|
+
|
|
70
|
+
await Notify.send(user, new OrderShippedNotification(order));
|
|
71
|
+
await Notify.sendMany(admins, new LowStockNotification(product));
|
|
72
|
+
await Notify.route({ mail: "ops@acme.test" }).notify(new DeployFinished(build));
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { Notifiable } from "@zerotal/notifications";
|
|
77
|
+
import { AuthUser } from "@zerotal/auth";
|
|
78
|
+
|
|
79
|
+
export class User extends Notifiable(AuthUser) {}
|
|
80
|
+
|
|
81
|
+
await user.notify(new OrderShippedNotification(order)); // send now
|
|
82
|
+
await user.notifyLater(new OrderShippedNotification(order)); // queue
|
|
83
|
+
const unread = await user.unreadNotifications();
|
|
84
|
+
const badge = await user.unreadNotificationCount();
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Every declared channel is attempted even if one fails, so a broken Slack webhook never costs the recipient their email. The failures are reported together afterwards as a `NotificationDispatchError`.
|
|
88
|
+
|
|
89
|
+
Real-time delivery: add `"broadcast"` to `channels()` and return a `BroadcastMessage` from `toBroadcast()` (requires `BroadcastProvider`).
|
|
90
|
+
|
|
91
|
+
Add a channel of your own with `extend()`:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const notifications = app.container.makeSync("notifications");
|
|
95
|
+
notifications.extend("discord", () => new DiscordChannel(config));
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Testing with `NotificationFake`:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { NotificationFake } from "@zerotal/notifications";
|
|
102
|
+
|
|
103
|
+
const notify = NotificationFake.install();
|
|
104
|
+
await triggerShipment(order);
|
|
105
|
+
notify.assertSentTo(user, OrderShippedNotification);
|
|
106
|
+
notify.assertSentOn(user, OrderShippedNotification, "mail");
|
|
107
|
+
notify.assertSentCount(1);
|
|
108
|
+
notify.restore();
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Console commands
|
|
112
|
+
|
|
113
|
+
- `bun zt notifications:prune --days 30` — delete stored notifications that have been read and are older than the threshold. Pass `--all` to include unread.
|
|
114
|
+
- `bun zt notifications:test you@example.com` — send one real email through the configured mail driver, to check a transport end to end.
|
|
115
|
+
|
|
116
|
+
## Exports
|
|
117
|
+
|
|
118
|
+
- `Notification` — base class for notifications; declare `channels()` and `to*()` methods.
|
|
119
|
+
- `Notify` — facade for `send` / `sendMany` / `queue` / `route` / `extend`.
|
|
120
|
+
- `Notifiable` — mixin adding `notify`, `notifyLater`, and database-inbox helpers to a model (also the recipient contract type).
|
|
121
|
+
- `NotificationManager` — the manager that routes notifications to channels.
|
|
122
|
+
- `NotificationFake` — in-memory test double with `assertSentTo` / `assertSentOn` / `assertQueued` / `assertSentTimes` / `assertSentCount` / `assertNothingSent`.
|
|
123
|
+
- `NotificationProvider` — service provider registering the manager.
|
|
124
|
+
- `NotificationConfig` / `validateNotificationConfig` — config factory and its boot-time checks.
|
|
125
|
+
- `NotificationRegistry` — registers a notification class for queue rebuilding when it lives outside `app/notifications/`.
|
|
126
|
+
- Channels: `MailChannel`, `DatabaseChannel`, `SlackChannel`, `SmsChannel`, `BroadcastChannel`, plus `BROADCAST_NOTIFICATION_EVENT`.
|
|
127
|
+
- `MailMessage` — fluent email builder with styled lines, a call-to-action, and attachments. `RichLine` builds mixed-style lines.
|
|
128
|
+
- `BroadcastMessage` — payload wrapper for the broadcast channel (supports `.onQueue()`).
|
|
129
|
+
- `OnDemandNotifiable` — recipient addressed directly rather than looked up.
|
|
130
|
+
- Mail drivers: `LogDriver`, `SmtpDriver`, `ResendDriver`, and the `MailDriver` contract.
|
|
131
|
+
- `SendNotificationJob` / `BroadcastNotificationJob` — queued jobs backing `notifyLater` and `onQueue`.
|
|
132
|
+
- `recentDeliveries` / `channelStats` — in-process delivery counters behind the admin console.
|
|
133
|
+
- Types: `Notifiable`, `NotificationChannel`, `NotificationConfigShape`, `SmsConfigShape`, `TwilioConfigShape`, `VonageConfigShape`, `NotificationRecord`, `InboxQuery`, `SlackMessage`, `SmsMessage`, `MailAttachment`, `TextStyle`.
|
|
134
|
+
- Typed error vocabulary re-exported from `./errors`.
|
|
135
|
+
|
|
136
|
+
## Documentation
|
|
137
|
+
|
|
138
|
+
- [Notifications](../../docs/notifications.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/notifications",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "beta",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"src",
|
|
16
|
+
"!src/**/*.test.ts",
|
|
17
|
+
"!src/**/*.test.tsx",
|
|
18
|
+
"!src/**/*.spec.ts",
|
|
19
|
+
"!src/**/__fixtures__/**"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.3.14"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@zerotal/core": "1.0.0",
|
|
33
|
+
"@zerotal/orm": "1.0.0",
|
|
34
|
+
"@zerotal/queue": "1.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@zerotal/broadcasting": "1.0.0",
|
|
38
|
+
"typescript": "^5.8.0"
|
|
39
|
+
},
|
|
40
|
+
"description": "Multi-channel notifications for Zerotal — mail (SMTP/Resend), database, broadcast, Slack, and SMS.",
|
|
41
|
+
"keywords": [
|
|
42
|
+
"zerotal",
|
|
43
|
+
"bun",
|
|
44
|
+
"typescript",
|
|
45
|
+
"framework",
|
|
46
|
+
"notifications",
|
|
47
|
+
"mail",
|
|
48
|
+
"slack",
|
|
49
|
+
"sms"
|
|
50
|
+
],
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
54
|
+
"directory": "packages/notifications"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/notifications#readme",
|
|
57
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
58
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Notifiable } from "./types.ts";
|
|
2
|
+
import type { Notification } from "./Notification.ts";
|
|
3
|
+
import { BroadcastMessage } from "./BroadcastMessage.ts";
|
|
4
|
+
import { NotificationChannelUnavailableError } from "./errors.ts";
|
|
5
|
+
|
|
6
|
+
/** The wire event name used for every broadcast notification; the payload `type` distinguishes them. */
|
|
7
|
+
export const BROADCAST_NOTIFICATION_EVENT = "notification";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Notification channel that pushes a notification to a connected client in real time via
|
|
11
|
+
* `@zerotal/broadcasting`. Broadcasts on the notifiable's private channel
|
|
12
|
+
* (`private-notifications.{id}` by default) with the event name `"notification"`; the payload
|
|
13
|
+
* carries `{ ...data, id, type, readAt, createdAt }`.
|
|
14
|
+
*/
|
|
15
|
+
export class BroadcastChannel {
|
|
16
|
+
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
17
|
+
// Lazy import so @zerotal/notifications has no hard dependency on @zerotal/broadcasting.
|
|
18
|
+
interface BroadcastApi {
|
|
19
|
+
Broadcast: {
|
|
20
|
+
to(channel: string, event: string, data: unknown): void;
|
|
21
|
+
later?(
|
|
22
|
+
channel: string,
|
|
23
|
+
event: string,
|
|
24
|
+
data: unknown,
|
|
25
|
+
options?: { queue?: string; connection?: string },
|
|
26
|
+
): Promise<void> | void;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
let broadcasting: BroadcastApi;
|
|
30
|
+
try {
|
|
31
|
+
broadcasting = (await import("@zerotal/broadcasting" as string)) as BroadcastApi;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// Distinguish "not installed" from "installed but threw on import" — the
|
|
34
|
+
// second reported as the first sends people to fix the wrong thing.
|
|
35
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
36
|
+
const missing = /cannot find (module|package)|module not found/i.test(detail);
|
|
37
|
+
throw new NotificationChannelUnavailableError(
|
|
38
|
+
missing
|
|
39
|
+
? "[Zerotal] Notifications broadcast channel requires @zerotal/broadcasting. " +
|
|
40
|
+
"Register BroadcastProvider in bootstrap/providers.ts."
|
|
41
|
+
: `[Zerotal] Notifications broadcast channel could not load @zerotal/broadcasting — ${detail}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const result = await notification.toBroadcast(notifiable);
|
|
46
|
+
const message = result instanceof BroadcastMessage ? result : new BroadcastMessage(result);
|
|
47
|
+
|
|
48
|
+
const channel = this.channelFor(notifiable);
|
|
49
|
+
const type = notification.broadcastType();
|
|
50
|
+
const payload = {
|
|
51
|
+
...message.data,
|
|
52
|
+
id: crypto.randomUUID(),
|
|
53
|
+
type,
|
|
54
|
+
readAt: null,
|
|
55
|
+
createdAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// A message routed with onQueue() is handed to the queue; anything else goes
|
|
59
|
+
// out inline, which is the point of the channel.
|
|
60
|
+
if (message.queue !== undefined) {
|
|
61
|
+
const { BroadcastNotificationJob } = await import("./BroadcastNotificationJob.ts");
|
|
62
|
+
const { Queue } = await import("@zerotal/queue");
|
|
63
|
+
await Queue.dispatch(
|
|
64
|
+
new BroadcastNotificationJob(channel, BROADCAST_NOTIFICATION_EVENT, payload, message.queue),
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
broadcasting.Broadcast.to(channel, BROADCAST_NOTIFICATION_EVENT, payload);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The (prefixed) private channel a notifiable receives broadcast notifications on. */
|
|
73
|
+
channelFor(notifiable: Notifiable): string {
|
|
74
|
+
const custom = (
|
|
75
|
+
notifiable as { receivesBroadcastNotificationsOn?(): string }
|
|
76
|
+
).receivesBroadcastNotificationsOn?.();
|
|
77
|
+
const name = custom ?? `notifications.${notifiable.id}`;
|
|
78
|
+
return name.startsWith("private-") || name.startsWith("presence-") ? name : `private-${name}`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The broadcastable representation of a notification, returned from `Notification.toBroadcast()`.
|
|
3
|
+
* Wraps the data payload plus optional queue routing.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* toBroadcast() {
|
|
7
|
+
* return new BroadcastMessage({ invoiceId: this.invoice.id, amount: this.invoice.amount });
|
|
8
|
+
* }
|
|
9
|
+
*/
|
|
10
|
+
export class BroadcastMessage {
|
|
11
|
+
/** Queue to deliver this broadcast on. Unset means deliver inline. */
|
|
12
|
+
queue: string | undefined = undefined;
|
|
13
|
+
|
|
14
|
+
constructor(public readonly data: Record<string, unknown>) {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Deliver this broadcast from a queue rather than inline.
|
|
18
|
+
*
|
|
19
|
+
* Worth doing when the broadcast fans out to many connections and the
|
|
20
|
+
* request should not wait for it. The trade is latency: a queued broadcast
|
|
21
|
+
* arrives whenever a worker picks it up.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* toBroadcast() {
|
|
25
|
+
* return new BroadcastMessage({ id: this.report.id }).onQueue("broadcasts");
|
|
26
|
+
* }
|
|
27
|
+
*/
|
|
28
|
+
onQueue(queue: string): this {
|
|
29
|
+
this.queue = queue;
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Job, JobRegistry } from "@zerotal/queue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Delivers one broadcast notification from a queue.
|
|
5
|
+
*
|
|
6
|
+
* Dispatched only when a `BroadcastMessage` names a queue or connection via
|
|
7
|
+
* `onQueue()` / `onConnection()`; an unrouted broadcast goes out inline, since
|
|
8
|
+
* the whole point of the channel is immediacy. Its payload is the resolved wire
|
|
9
|
+
* data, so it round-trips through a persistent driver without needing the
|
|
10
|
+
* notification class.
|
|
11
|
+
*/
|
|
12
|
+
export class BroadcastNotificationJob extends Job {
|
|
13
|
+
override readonly queue: string;
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly _channel: string = "",
|
|
17
|
+
private readonly _event: string = "",
|
|
18
|
+
private readonly _data: Record<string, unknown> = {},
|
|
19
|
+
queue = "broadcast",
|
|
20
|
+
) {
|
|
21
|
+
super();
|
|
22
|
+
this.queue = queue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override payload(): Record<string, unknown> {
|
|
26
|
+
return {
|
|
27
|
+
channel: this._channel,
|
|
28
|
+
event: this._event,
|
|
29
|
+
data: this._data,
|
|
30
|
+
queue: this.queue,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static fromPayload(data: Record<string, unknown>): BroadcastNotificationJob {
|
|
35
|
+
return new BroadcastNotificationJob(
|
|
36
|
+
data["channel"] as string,
|
|
37
|
+
data["event"] as string,
|
|
38
|
+
data["data"] as Record<string, unknown>,
|
|
39
|
+
(data["queue"] as string) ?? "broadcast",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async handle(): Promise<void> {
|
|
44
|
+
const { Broadcast } = (await import("@zerotal/broadcasting" as string)) as {
|
|
45
|
+
Broadcast: { to(channel: string, event: string, data: unknown): void };
|
|
46
|
+
};
|
|
47
|
+
Broadcast.to(this._channel, this._event, this._data);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
JobRegistry.register(BroadcastNotificationJob);
|