Notifications (BYO)
KickJS doesn't ship a first-party notifications package — the previous one was 47 lines of DI glue around a Notifier interface. This guide shows how to wire your channel mix (Slack, Discord, email, webhook, in-app, push…) via a definePlugin factory.
Define the contract
// src/services/notifier.ts
export interface Notification {
channel: 'slack' | 'discord' | 'email' | 'webhook'
to: string
subject?: string
body: string
metadata?: Record<string, unknown>
}
export interface Notifier {
send(notification: Notification): Promise<void>
}
export const NOTIFIER = createToken<Notifier>('app/notifier')Implement channels
Inline whichever channels you actually use; they all wrap a third-party SDK or fetch:
// src/services/channels/slack.channel.ts
import type { Notification } from '../notifier'
export async function sendSlack(webhook: string, n: Notification) {
await fetch(webhook, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: n.body }),
})
}// src/services/channels/email.channel.ts
import type { Notification } from '../notifier'
import { MailerService } from '../mailer.service'
export async function sendEmail(mailer: MailerService, n: Notification) {
await mailer.send({ to: n.to, subject: n.subject ?? '', html: n.body })
}Compose into a Notifier and register
// src/plugins/notifications.plugin.ts
import { definePlugin, type Container } from '@forinda/kickjs'
import { MailerService } from '../services/mailer.service'
import { NOTIFIER, type Notifier } from '../services/notifier'
import { sendSlack } from '../services/channels/slack.channel'
import { sendEmail } from '../services/channels/email.channel'
export interface NotificationsConfig {
slackWebhook?: string
}
export const NotificationsPlugin = definePlugin<NotificationsConfig>({
name: 'NotificationsPlugin',
build: (config) => ({
register(container: Container) {
container.registerFactory(
NOTIFIER,
() => {
const mailer = container.resolve(MailerService)
const notifier: Notifier = {
async send(n) {
switch (n.channel) {
case 'slack':
if (!config.slackWebhook) throw new Error('Slack webhook not configured')
return sendSlack(config.slackWebhook, n)
case 'email':
return sendEmail(mailer, n)
default:
throw new Error(`Channel not implemented: ${n.channel}`)
}
},
}
return notifier
},
)
},
}),
})Usage
@Service()
export class OrderService {
constructor(@Inject(NOTIFIER) private notifier: Notifier) {}
async confirm(order: Order) {
await this.notifier.send({
channel: 'email',
to: order.email,
subject: 'Order confirmed',
body: `<p>Your order #${order.id} is confirmed.</p>`,
})
}
}DevTools integration
Surface delivery counters per channel on the DevTools dashboard via the introspect() slot on a wrapping adapter:
import { defineAdapter } from '@forinda/kickjs'
import type { IntrospectionSnapshot } from '@forinda/kickjs-devtools-kit'
import { NOTIFIER, type Notifier } from '../services/notifier'
export const NotificationsObservabilityAdapter = defineAdapter({
name: 'NotificationsObservabilityAdapter',
build: () => {
const sent: Record<string, number> = {}
const failed: Record<string, number> = {}
return {
beforeStart({ container }) {
const notifier = container.resolve(NOTIFIER)
const original = notifier.send.bind(notifier)
notifier.send = async (n) => {
try {
await original(n)
sent[n.channel] = (sent[n.channel] ?? 0) + 1
} catch (err) {
failed[n.channel] = (failed[n.channel] ?? 0) + 1
throw err
}
}
},
introspect(): IntrospectionSnapshot {
return {
protocolVersion: 1,
name: 'NotificationsObservabilityAdapter',
kind: 'adapter',
state: { sent, failed },
metrics: {
totalSent: Object.values(sent).reduce((s, n) => s + n, 0),
totalFailed: Object.values(failed).reduce((s, n) => s + n, 0),
},
}
},
}
},
})Mount alongside NotificationsPlugin(). The topology view shows per-channel sent / failed counts live.
What you give up by going BYO
The previous @forinda/kickjs-notifications package added a single NotificationsAdapter factory that wired the same DI registration. Everything else (channel implementations, fan-out, retry, dead-letter handling) was up to you. The recipe above is the entire wrapper inlined into your app — pick the channels you actually need and you're done.
Related
- Plugins
- Dependency Injection
- Mailers — paired pattern for the email channel