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.