better-notify-best-practices
Quick reference for Better Notify configuration, patterns, and common gotchas
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
Quick reference for Better Notify configuration, patterns, and common gotchas
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Interactive setup wizard for adding Better Notify to a TypeScript/JavaScript project
Context and API guidance for Better Notify — end-to-end typed notification infrastructure for Node.js
Seamlessly use AI SDK inside your oRPC projects without any extra overhead.
Use oRPC inside an Astro project.
Functions to encode and decode base64url strings (URL-safe variant of base64).
A plugin for oRPC to batch requests and responses to reduce overhead.
| name | better-notify/best-practices |
| description | Quick reference for Better Notify configuration, patterns, and common gotchas |
| license | MIT |
| metadata | {"author":"Ali Torki","homepage":"https://github.com/ali-master","version":"1.0.0"} |
Always consult better-notify.com/docs for latest API.
npm install @betternotify/core @betternotify/email (+ channel/transport packages)lib/notify.tslib/notify-client.tsmail.<route>.send({ to, input })| Function | Import | Purpose |
|---|---|---|
createNotify({ channels }) | @betternotify/core | Root builder with channel map |
createClient({ catalog, transportsByChannel }) | @betternotify/core | Type-safe send client |
createCatalog(map) | @betternotify/core | Standalone catalog (without builder) |
defineChannel({ name, slots, validateArgs, render }) | @betternotify/core | Custom channel definition |
slot.resolver<T>() / slot.value<T>() | @betternotify/core | Template slot declarations |
consoleLogger({ level }) | @betternotify/core | Built-in console logger |
handlePromise(promise) | @betternotify/core | Tuple-returning async wrapper [error, result] |
Each channel has typed slots set via the builder:
@betternotify/email)| Slot | Type | Required |
|---|---|---|
.input(schema) | Standard Schema (Zod, Valibot, ArkType) | Yes |
.subject(resolver) | string or (args) => string | Yes |
.template(adapter) | TemplateAdapter or { render } | Yes |
.from(resolver) | Address or (args) => Address | No |
.replyTo(address) | Address | No |
.tags(tags) | Record<string, string | number | boolean> | No |
.priority(level) | 'low' | 'normal' | 'high' | No |
Send args: { to, input, cc?, bcc?, replyTo?, from?, headers?, attachments? }
@betternotify/sms)| Slot | Type | Required |
|---|---|---|
.input(schema) | Standard Schema | Yes |
.body(resolver) | string or (args) => string | Yes |
Send args: { to: string, input } (phone number)
@betternotify/push)| Slot | Type | Required |
|---|---|---|
.input(schema) | Standard Schema | Yes |
.title(resolver) | string or (args) => string | Yes |
.body(resolver) | string or (args) => string | Yes |
.data(resolver) | Record<string, unknown> | No |
.badge(resolver) | number | No |
Send args: { to: string \| string[], input } (device tokens)
@betternotify/slack)| Slot | Type | Required |
|---|---|---|
.input(schema) | Standard Schema | Yes |
.text(resolver) | string or (args) => string | Yes |
.blocks(resolver) | SlackBlock[] | No |
Send args: { input, to?: string, threadTs?: string } (channel ID)
@betternotify/discord)| Slot | Type | Required |
|---|---|---|
.input(schema) | Standard Schema | Yes |
.body(resolver) | string or (args) => string | Yes |
.embeds(resolver) | DiscordEmbed[] | No |
.username(value) | string | No |
.avatarUrl(value) | string | No |
Send args: { input }
@betternotify/telegram)| Slot | Type | Required |
|---|---|---|
.input(schema) | Standard Schema | Yes |
.body(resolver) | string or (args) => string | Yes |
.parseMode(mode) | 'HTML' | 'Markdown' | 'MarkdownV2' | No |
.attachment(resolver) | TelegramAttachment | No |
Send args: { to: string \| number, input } (chat ID)
Import optional features from subpaths, not the root barrel:
// Correct
import { withRateLimit } from '@betternotify/core/middlewares';
import { inMemoryRateLimitStore } from '@betternotify/core/stores';
import { consoleEventSink } from '@betternotify/core/sinks';
import { inMemoryTracer } from '@betternotify/core/tracers';
// Wrong — do not import these from '@betternotify/core'
| Subpath | Exports |
|---|---|
/middlewares | withDryRun, withTagInject, withEventLogger, withRateLimit, withIdempotency, withTracing, createMiddleware |
/stores | inMemorySuppressionList, inMemoryRateLimitStore, inMemoryIdempotencyStore, createSuppressionList, createRateLimitStore, createIdempotencyStore |
/sinks | inMemoryEventSink, consoleEventSink, createEventSink |
/tracers | inMemoryTracer |
/transports | createHttpClient, createTransport, multiTransport, mapTransport |
/logger | consoleLogger, fromPino |
/plugins | createPlugin |
Middleware mutates context or short-circuits the pipeline. Named with with prefix.
const rpc = createNotify({ channels: { email: ch } })
.use(withRateLimit({ store, key, max, window }))
.use(withIdempotency({ store, key, ttl }))
.use(withDryRun());
Custom middleware:
import { createMiddleware } from '@betternotify/core/middlewares';
const withLogging = createMiddleware(async ({ next, route, messageId }) => {
console.log(`Sending ${route} (${messageId})`);
return next();
});
Hooks observe but don't mutate. Set on createClient:
const mail = createClient({
catalog,
transportsByChannel: { email: transport },
hooks: {
onBeforeSend: ({ route, messageId, args }) => { ... },
onExecute: ({ rendered }) => { ... },
onAfterSend: ({ result, timing }) => { ... },
onError: ({ error, phase }) => { ... },
},
})
Error phases: 'validate' | 'middleware' | 'render' | 'send' | 'hook'
Rule: If removing it would change whether the notification goes out, it must be middleware, not a hook.
// Single send
const result = await mail.welcome.send({ to, input })
// result: { messageId, data, envelope?, timing: { renderMs, sendMs } }
// Batch send
const batch = await mail.welcome.batch([{ to, input }, ...], { interval: 250 })
// batch: { okCount, errorCount, results: [{ status, result?, error? }] }
// Render only (no send)
const rendered = await mail.welcome.render(input)
// Cleanup
await mail.close()
// String shorthand
{ to: 'user@example.com' }
// Object with name
{ to: { name: 'Alice', email: 'alice@example.com' } }
// Multiple recipients
{ to: ['alice@example.com', { name: 'Bob', email: 'bob@example.com' }] }
// From — both fields optional (merges with defaults)
{ from: { name: 'Support' } } // uses default email
{ from: { email: 'no-reply@...' } } // uses default name
All errors subclass NotifyRpcError and are JSON-serializable.
| Error | When |
|---|---|
NotifyRpcValidationError | Input fails schema validation |
NotifyRpcRateLimitedError | Rate limit exceeded (has retryAfterMs) |
NotifyRpcNotImplementedError | Feature not yet available |
NotifyRpcProviderError | Transport delivery failure |
import { multiTransport } from '@betternotify/email';
multiTransport({
strategy: 'failover', // try next on failure
transports: [{ transport: primary }, { transport: fallback }],
});
| Strategy | Behavior |
|---|---|
failover | Try transports in order until one succeeds |
round-robin | Rotate between transports |
random | Pick randomly |
race | Send via all, return first success |
parallel | Send via all, wait for all |
mirrored | Send via all, return primary result |
@betternotify/core/middlewares, not @betternotify/core. Root barrel only exports core primitives..input() accepts Zod, Valibot, or ArkType schemas. Don't assume Zod..subject('Hello') and .subject(({ input }) => input.title) are both valid.from merges per-field — Per-email from and defaults.from shallow-merge. { from: { name: 'Support' } } keeps the default email.mockTransport() (email), mockSmsTransport() (SMS), etc. Check .sent or .messages array.{ transactional: rpc.catalog({ welcome: ... }) } creates route transactional.welcome, accessed as mail.transactional.welcome.send().handlePromise over try/catch — Use const [err, result] = await handlePromise(promise) for async error handling.