@novu/stateless 无状态通知框架实战:Provider 注册、模板编排与 trigger 触发全解析
【免费下载链接】novuThe open-source communication infrastructure for agents and products项目地址: https://gitcode.com/GitHub_Trending/no/novu
本文基于 Novu 仓库中 packages/stateless/README.md 展开。
@novu/stateless是 Novu 通知基础设施中一个轻量、无状态(stateless)的通知管理框架包:它不依赖数据库与后台服务,只凭内存中的 Provider、Template 与 Theme 三大存储,即可在任意 Node.js 应用中完成多通道(邮件/SMS/聊天/推送)消息的模板渲染与发送。读完本文,你将掌握该包从安装、Provider 注册、模板注册到事件触发的完整链路,并理解其底层引擎与源码实现。
一、包概览:它解决什么问题
@novu/stateless的定位是"Notification Management Framework"(通知管理框架),当前仓库中版本为2.6.6(见 package.json),采用 MIT 协议,支持 CommonJS 与 ESM 双构建产物(main指向dist/cjs/index.js,module指向dist/esm/index.js),对 Node.js 的引擎要求为>=10。
其核心设计思想可以用一句话概括:用一套统一 API 抽象所有通知渠道。开发者不再需要针对每家邮件/SMS/IM 服务商分别接入 SDK,而是面向 Novu 的统一 Provider 接口编程,之后可以随时在底层切换或混用多家服务商,而不改动业务代码。
整个包非常轻量,运行时依赖只有三个(见 package.json):
handlebars:负责消息模板的编译与渲染;lodash.get:用于按路径安全地取出触发载荷中的变量;lodash.merge:用于合并默认配置与用户传入配置。
从源码结构看(packages/stateless/src/lib),包被清晰拆分为六大内部模块:
| 模块 | 源码路径 | 职责 |
|---|---|---|
NovuStateless主类 | novu.ts | 对外 API 门面,持有各 Store 并对外暴露注册/触发方法 |
| Provider 存储 | provider/provider.store.ts | 按 id 或 channel 管理已注册的渠道 Provider |
| Template 存储 | template/template.store.ts | 保存模板,并按触发载荷筛选激活消息 |
| Theme 存储 | theme/theme.store.ts | 管理邮件主题(品牌化布局)及默认主题 |
| 内容引擎 | content/content.engine.ts | 基于 Handlebars 的模板编译与变量提取 |
| 触发引擎 | trigger/trigger.engine.ts | 触发一次事件时串联上述全部组件完成发送 |
"无状态"的含义在于:模板、Provider 与主题全部存放在进程内的内存存储中,通过register*系列方法写入,无需任何数据库或外部服务。这使得它非常适合嵌入到已有 Node 服务中作为"通知发送函数库"使用,同时它也是 Novu 完整平台中"代码优先"(code-first)场景的基石。
二、安装与导入
2.1 包管理器安装
原 README 提供了 npm 与 yarn 两种安装方式:
npm install @novu/statelessyarn add @novu/stateless由于项目本身基于 pnpm workspace 管理(见仓库根目录 pnpm-workspace.yaml),在该仓库内也可以使用 pnpm 安装或直接引用本地包:
pnpm add @novu/stateless2.2 按需导入
包通过 src/index.ts 统一导出,主要导出项包括:
import { NovuStateless, ChannelTypeEnum } from '@novu/stateless'; import type { ITemplate, IMessage, ITriggerPayload, IEmailProvider, ISmsProvider, IChatProvider, IPushProvider, } from '@novu/stateless';其中NovuStateless是核心类,ChannelTypeEnum是渠道类型枚举(email/sms/chat/push/tool,见 template.interface.ts)。
Provider 实现类则从@novu/providers导入,例如 README 示例中的SendgridEmailProvider。该包位于 packages/providers/src,聚合了全部渠道服务商的实现。
三、五分钟快速上手
以下代码完整复刻 README 的 Usage 示例,并补充了必要的类型与注释,是一个可以直接复制运行的最小闭环:
import { NovuStateless, ChannelTypeEnum } from '@novu/stateless'; import { SendgridEmailProvider } from '@novu/providers'; // 1. 创建无状态实例 const novu = new NovuStateless(); // 2. 注册邮件渠道 Provider await novu.registerProvider( new SendgridEmailProvider({ apiKey: process.env.SENDGRID_API_KEY, from: 'sender@mail.com', }), ); // 3. 注册模板(一条密码重置通知) const passwordResetTemplate = await novu.registerTemplate({ id: 'password-reset', messages: [ { subject: 'Your password reset request', channel: ChannelTypeEnum.EMAIL, template: ` Hi {{firstName}}! To reset your password click <a href="{{resetLink}}">here.</a> {{#if organization}} <img src="{{organization.logo}}" /> {{/if}} `, }, ], }); // 4. 触发事件 await novu.trigger('password-reset', { $user_id: '<USER IDENTIFIER>', $email: 'test@email.com', firstName: 'John', lastName: 'Doe', organization: { logo: 'https://evilcorp.com/logo.png', }, });执行流程梳理:
new NovuStateless()创建实例,内部自动初始化三个内存存储(TemplateStore / ProviderStore / ThemeStore)与默认的 Handlebars 内容引擎;registerProvider把 Sendgrid 提供商注册进 ProviderStore;registerTemplate将id: 'password-reset'的模板写入 TemplateStore;trigger('password-reset', data)触发事件:TriggerEngine 按 id 找到模板 → 渲染 Handlebars 模板 → 将结果通过 EmailHandler 交给 Sendgrid 发送。
模板中的{{firstName}}、{{resetLink}}等占位符会被触发载荷中的同名键替换;{{#if organization}}是 Handlebars 的条件块,当载荷中存在organization对象时才渲染其中的图片标签。README 示例在触发时传入的organization.logo正是为了让该条件块生效。
四、核心 API 详解
4.1new NovuStateless(config?)
构造函数接收可选的 INovuConfig,用于注入自定义实现:
interface INovuConfig { channels?: { email?: { from?: { name: string; email: string }; }; }; variableProtection?: boolean; // 是否开启变量缺失保护,默认 true templateStore?: TemplateStore; // 自定义模板存储 providerStore?: ProviderStore; // 自定义 Provider 存储 themeStore?: ThemeStore; // 自定义主题存储 contentEngine?: IContentEngine; // 自定义内容引擎 }从 novu.ts 的构造逻辑可以看到,默认配置中variableProtection被置为true,且用户配置会通过lodash.merge与默认配置深度合并。这意味着变量缺失保护默认开启:如果模板中引用了载荷中不存在的变量,触发时会直接抛出Missing variables passed. ...错误(见下文 5.2 节)。
4.2registerProvider(provider)与registerProvider(providerId, provider)
方法有两种重载形式(novu.ts):
// 形式一:只传 Provider 实例,providerId 取 provider.id await novu.registerProvider(new SendgridEmailProvider({ ... })); // 形式二:显式指定注册 id await novu.registerProvider('my-sendgrid', new SendgridEmailProvider({ ... }));注册后的 Provider 存入 ProviderStore。底层 ProviderStore 支持四种查询方式:
getProviderById(providerId):按注册时使用的 key 精确获取;getProviderByInternalId(providerId):按 Provider 自身暴露的id属性查找;getProviderByChannel(channel):按渠道类型查找(若同一渠道注册多个,取第一个);getProviders():列出全部已注册 Provider。
4.3registerTemplate(template)
参数是 ITemplate:
interface ITemplate { id: string; // 模板唯一标识,触发时用 eventId 对应它 themeId?: string; // 可选:绑定的主题 id messages: IMessage[];// 一条模板可包含多条消息(如同时发邮件+短信) }注册后返回该模板在 Store 中的实例。模板内部可以声明多条不同渠道的消息,从而一次触发同时向用户发送多通道通知,例如邮件 + 短信:
await novu.registerTemplate({ id: 'welcome', messages: [ { channel: ChannelTypeEnum.EMAIL, subject: 'Welcome!', template: 'Hi {{name}}' }, { channel: ChannelTypeEnum.SMS, template: 'Welcome {{name}}' }, ], });4.4trigger(eventId, data)
await novu.trigger('<EVENT_NAME>', { $user_id: '<USER IDENTIFIER>', // 必填 $email: 'test@email.com', // 邮件渠道必填 firstName: 'John', // ... 任意自定义变量 });eventId必须与某个已注册模板的id一致,否则会抛出Template on event: xxx was not found in the template store。第二个参数是 ITriggerPayload,其中$前缀的键为框架保留字段:
| 字段 | 说明 |
|---|---|
$user_id | 必填,用户唯一标识 |
$email | 邮件渠道发送地址,邮件消息发送时必填 |
$phone | 已废弃(deprecated),SMS 场景请使用$channelData |
$theme_id | 可选,指定本次触发的主题 |
$webhookUrl | 已废弃,请使用$channelData |
$channelData | 渠道级数据(如聊天渠道的 webhook 地址等) |
$attachments | 附件列表,可声明channels限定发送渠道 |
$branding | 品牌信息(在邮件模板 payload 中注入$branding) |
除保留字段外,其余键值会被原样注入 Handlebars 模板供渲染使用。
五、底层原理:TriggerEngine 的完整调用链
trigger的实质是新建一个 TriggerEngine 并把模板、Provider、主题、内容引擎与配置一并传入。其内部按如下顺序执行:
5.1 模板查找与消息筛选
const template = await this.templateStore.getTemplateById(eventId); const activeMessages: IMessage[] = await this.templateStore.getActiveMessages(template, data);getActiveMessages(见 template.store.ts)会对模板内每条消息执行"激活判定":active字段为undefined(未设置)时消息默认激活;为布尔值时取其本身;为函数时用触发载荷调用该函数异步求值。这给了开发者极强的灵活性——同一模板可以根据业务条件动态决定本次触发发不发某条消息:
messages: [ { channel: ChannelTypeEnum.EMAIL, template: '...', active: (payload) => payload.user.hasEmail, // 按载荷动态开关 }, ],5.2 变量缺失保护(variableProtection)
TriggerEngine 在发送前会先做变量体检(trigger.engine.ts):
- 用内容引擎的
extractMessageVariables从模板字符串与 subject 中提取所有 Handlebars 变量; - 用
lodash.get逐一检查这些变量在载荷中是否存在; - 若存在缺失且
config.variableProtection为true,抛出Missing variables passed. <缺失变量列表>错误。
注意这里使用的是_get(data, variable),即支持点路径:模板中的{{organization.logo}}会被提取为organization.logo,检查的正是载荷中data['organization']['logo']是否存在。若不需要此保护,可在构造实例时传入variableProtection: false。
5.3 自定义校验器
每条消息可以携带validator(template.interface.ts):
validator?: { validate(payload: ITriggerPayload): Promise<boolean> | boolean; };TriggerEngine 在发送前会调用message.validator.validate(data),返回false时抛出Payload for ${channel} is invalid,用于在发送前拦截非法载荷。
5.4 事件钩子:pre:send 与 post:send
NovuStateless继承自 Node 原生EventEmitter,TriggerEngine 在真正发送前后各发出一个事件([trigger.engine.ts](https://link.gitcode.com/i/41885d96e76a4fa24948f1d22c944222#L53-L58, L81-L86)):
novu.on('pre:send', ({ id, channel, message, triggerPayload }) => { // 发送前的钩子:可在此埋点、审计、做限流 }); novu.on('post:send', ({ id, channel, message, triggerPayload }) => { // 发送后的钩子:可在此记录发送结果 });5.5 主题解析与渠道分发
TriggerEngine 按"载荷$theme_id> 模板themeId> 默认主题"的优先级解析主题(trigger.engine.ts),随后根据 Provider 的channelType将消息分发给对应的 Handler:
EMAIL→ EmailHandlerSMS→ SmsHandlerCHAT→ ChatHandler
每个 Handler 负责把IMessage转换为该渠道 Provider 要求的IEmailOptions/ISmsOptions/IChatOptions并调用provider.sendMessage(...)。
5.6 EmailHandler 细节:模板、主题与附件
以邮件为例,EmailHandler.send 完成的工作包括:
- 从载荷中过滤出适用于邮件渠道的附件(
$attachments中channels未声明或包含email的项); - 将
$branding注入渲染上下文(templatePayload = { $branding, ...data }); - 模板渲染:
template与textTemplate均支持字符串或异步函数两种形式,subject支持字符串或同步函数(否则抛错); - 主题包装:若配置了主题,用
getEmailLayout()取得外层 HTML 布局,把渲染后的body作为变量嵌入,同时合并getTemplateVariables()返回的主题变量; - 发送前校验
$email必填(缺失时抛出$email on the trigger payload is missing...)。
六、主题(Theme)机制
除了 README 中直接展示的 API,主题是 stateless 框架中较容易被忽略但很实用的能力。通过 ThemeStore 与 theme.interface.ts,可以为邮件提供品牌化的统一 HTML 外壳:
const theme = { branding: { mainColor: '#ff3519', logo: 'https://example.com/logo.png' }, emailTemplate: { getEmailLayout() { return `<div style="..."><h1>{{branding.mainColor}}</h1>{{{body}}}</div>`; }, getTemplateVariables() { return { branding: { mainColor: '#ff3519' } }; }, }, }; await novu.registerTheme('branded', theme); await novu.setDefaultTheme('branded');之后每次触发邮件都会自动套用该主题布局;也可在单次触发时通过载荷$theme_id覆盖,或在模板上声明themeId绑定指定主题。主题解析的优先级链为:载荷$theme_id> 模板themeId> 默认主题(见 trigger.engine.ts)。
七、Provider 生态与渠道支持
原 README 用清单形式列出了各渠道的 Provider 支持情况。在仓库当前状态下,packages/providers/src 聚合了这些实现,各渠道代表性 Provider 如下:
邮件(Email)已支持:Sendgrid、Netcore、Mailgun、SES、Postmark、自定义 SMTP(Nodemailer)、Mailjet、Mandrill、SendinBlue; 待支持:SparkPost。
短信(SMS)已支持:Twilio、Plivo、SNS、Nexmo(Vonage)、Sms77、Telnyx、Termii、Gupshup; 待支持:Bandwidth、RingCentral。
推送(Push)已支持:FCM、Expo; 待支持:SNS、Pushwoosh。
聊天(Chat)已支持:Slack、Discord; 待支持:MS Teams、Mattermost。
应用内(In-App)已支持:Novu 自研通知中心(即本仓库 packages/novu 所提供的 Inbox / 通知中心能力)。
其他(规划中):PagerDuty。
说明:以上清单忠实反映 README 写作时的状态,"已支持/待支持"以勾选标记为准;其中标为未勾选的项不代表仓库中完全没有代码,仅代表该清单当时未将其列为完成状态,请以实际源码为准。
从 Provider 接口(provider.interface.ts)可以看到,所有 Provider 都遵循统一的契约:必须实现id、channelType与sendMessage(options, bridgeProviderData)。发送成功统一返回ISendMessageSuccessResponse(可包含id/ids/date/channel),回调解析则通过可选的getMessageId与parseEventBody实现。这套统一接口正是"一套 API 管所有渠道"的根基——切换服务商时,只需替换 registerProvider 传入的实例,业务代码零改动。
八、测试验证与可观测入口
仓库为该包提供了完整的单元测试,可作为理解各组件行为的活文档:
- novu.spec.ts:主类 API 行为测试;
- trigger.engine.spec.ts:触发引擎全链路测试(模板查找、Provider 解析、变量保护等);
- content.engine.spec.ts:Handlebars 渲染与变量提取测试;
- template.store.spec.ts 与 provider.store.spec.ts:两个内存存储的行为测试;
- email.handler.spec.ts、sms.handler.spec.ts、chat.handler.spec.ts:各渠道 Handler 的发送逻辑测试。
若要在本仓库内运行这些测试,可在packages/stateless目录下执行(package.json 中定义了test:unit脚本):
pnpm --filter @novu/stateless test:unit九、与其他 Novu 包的关系与适用场景
@novu/stateless是 Novu 生态中的"轻量内核",与平台其他组件互补:
@novu/providers(packages/providers):Provider 实现库,stateless 的 Provider 实例通常从这里取;@novu/framework(packages/framework):面向代码优先工作流的完整框架,提供 step、control 等更高层抽象;@novu/js/@novu/react(packages/js、packages/react):Inbox 前端 SDK,对应 README 中 In-App 渠道的能力;apps/api/apps/worker等(apps/api、apps/worker):Novu 托管/自托管平台的完整后端,stateless 的逻辑在平台内作为执行内核被复用。
因此,@novu/stateless最适合以下场景:
- 已有后端服务、不想引入数据库:只想在代码里声明式地注册模板与 Provider,直接调用发送;
- 多通道统一抽象:邮件、短信、聊天、推送一接口切换,降低多服务商集成成本;
- 高度定制:通过
INovuConfig注入自定义templateStore、providerStore、themeStore或contentEngine,把存储与渲染逻辑替换为任意实现; - 事件驱动的通知流:借助
pre:send/post:send钩子与模板级active函数,构建可观测、可动态开关的通知链路。
简而言之,@novu/stateless用约千行源码实现了一个"注册 Provider → 注册模板 → 触发事件 → 自动渲染并分发"的完整通知内核。无论你是在评估 Novu 的架构设计,还是准备在业务中快速落地多通道通知,它都是一个低依赖、易上手、可深度定制的起点。
【免费下载链接】novuThe open-source communication infrastructure for agents and products项目地址: https://gitcode.com/GitHub_Trending/no/novu
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考