npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@classytic/notifications

v1.2.0

Published

Multi-channel notification system with pluggable providers, templates, and retry - zero required dependencies

Readme

@classytic/notifications

Multi-channel notification system for TypeScript/Node.js

Pluggable channels, templates, retry with backoff, and user preferences. Zero required dependencies — bring your own providers.

Features

  • Multi-Channel — Email (Nodemailer — Gmail, SES, SMTP, any transport), Webhook, Console, or build your own
  • Zero Required Deps — Nodemailer is an optional peer dependency, loaded lazily
  • Templates — Plug any template engine (React Email, MJML, Handlebars, etc.)
  • Retry + Backoff — Exponential, linear, or fixed backoff with jitter. Per-channel overrides
  • User Preferences — Per-user, per-event, per-channel opt-in/out with quiet hours
  • Quiet Hours — Timezone-aware quiet period enforcement (no external deps)
  • Idempotency — Built-in deduplication with pluggable stores (memory, Redis, DB)
  • Lifecycle Eventsbefore:send, after:send, send:success, send:failed, send:retry
  • Hook Factories — Generate event handlers for EventEmitter, MongoKit, or any hook system
  • Webhook Signing — HMAC-SHA256 payload signing out of the box
  • TypeScript — Full type definitions, ESM-only

Installation

npm install @classytic/notifications

For EmailChannel (optional — only if you use email):

npm install nodemailer

Quick Start

import { NotificationService } from '@classytic/notifications';
import { EmailChannel, WebhookChannel, ConsoleChannel } from '@classytic/notifications/channels';

const notifications = new NotificationService({
  channels: [
    new EmailChannel({
      from: 'App <[email protected]>',
      transport: { host: 'smtp.gmail.com', port: 587, auth: { user, pass } },
    }),
    new WebhookChannel({
      url: 'https://hooks.slack.com/services/...',
      events: ['order.*'],
    }),
    new ConsoleChannel(), // dev/testing
  ],
  templates: async (id, data) => ({
    subject: `Notification: ${id}`,
    html: `<p>${JSON.stringify(data)}</p>`,
  }),
  retry: { maxAttempts: 3, backoff: 'exponential' },
});

await notifications.send({
  event: 'user.created',
  recipient: { email: '[email protected]', name: 'John' },
  data: { name: 'John' },
  template: 'welcome',
});

Channels

Built-in Channels

EmailChannel (Nodemailer)

Requires: npm install nodemailer

import { EmailChannel } from '@classytic/notifications/channels';

// SMTP
const email = new EmailChannel({
  from: 'App <[email protected]>',
  transport: { host: 'smtp.gmail.com', port: 587, auth: { user, pass } },
});

// Gmail shorthand
const gmail = new EmailChannel({
  from: '[email protected]',
  transport: { service: 'gmail', auth: { user, pass } },
});

// Pre-created transporter (SES, custom)
import nodemailer from 'nodemailer';
const email = new EmailChannel({
  from: '[email protected]',
  transporter: nodemailer.createTransport({ /* SES config */ }),
});

WebhookChannel

Zero dependencies — uses native fetch.

import { WebhookChannel } from '@classytic/notifications/channels';

const slack = new WebhookChannel({
  url: 'https://hooks.slack.com/services/...',
  events: ['order.completed', 'user.created'],
});

// With HMAC-SHA256 signing
const signed = new WebhookChannel({
  url: 'https://api.partner.com/webhooks',
  secret: process.env.WEBHOOK_SECRET!,
  headers: { 'X-API-Key': process.env.PARTNER_KEY! },
  timeout: 5000,
});

ConsoleChannel

Logs to console. Useful for development and testing.

import { ConsoleChannel } from '@classytic/notifications/channels';

const dev = new ConsoleChannel();
const scoped = new ConsoleChannel({ events: ['user.*'] });

Custom Channels

Extend BaseChannel or implement the Channel interface directly:

import { BaseChannel } from '@classytic/notifications/channels';
import type { NotificationPayload, SendResult, ChannelConfig } from '@classytic/notifications';

interface SlackConfig extends ChannelConfig {
  webhookUrl: string;
}

class SlackChannel extends BaseChannel<SlackConfig> {
  constructor(config: SlackConfig) {
    super({ name: 'slack', ...config });
  }

  async send(payload: NotificationPayload): Promise<SendResult> {
    const res = await fetch(this.config.webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: String(payload.data.message) }),
    });
    if (!res.ok) return { status: 'failed', channel: this.name, error: res.statusText };
    return { status: 'sent', channel: this.name };
  }
}

Event Filtering

Channels only receive events matching their events whitelist. Supports wildcards:

new WebhookChannel({
  url: '...',
  events: ['order.*'],        // matches order.created, order.completed, etc.
});

new ConsoleChannel({
  events: [],                  // empty = all events (default)
});

Per-Channel Retry Override

Channels can override the global retry config, including disabling retry:

const notifications = new NotificationService({
  retry: { maxAttempts: 3, backoff: 'exponential' }, // global default
  channels: [
    new EmailChannel({
      from: '[email protected]',
      transport: { ... },
      retry: { maxAttempts: 5 },              // more retries for email
    }),
    new WebhookChannel({
      url: '...',
      retry: { maxAttempts: 1 },              // disable retry for webhooks
    }),
  ],
});

Templates

Plug any template engine via a resolver function:

const notifications = new NotificationService({
  templates: async (templateId, data) => {
    const templates: Record<string, { subject: string; html: string }> = {
      welcome: {
        subject: `Welcome ${data.name}!`,
        html: `<h1>Hello ${data.name}</h1>`,
      },
    };
    return templates[templateId] ?? { subject: templateId, text: JSON.stringify(data) };
  },
});

await notifications.send({
  event: 'user.created',
  recipient: { email: '[email protected]' },
  data: { name: 'John' },
  template: 'welcome', // resolved before sending
});

Template values are merged into payload.data, with template values taking precedence.

User Preferences

Filter channels per-user with a preference resolver:

const notifications = new NotificationService({
  preferences: async (recipientId, event) => {
    const prefs = await db.getUserPrefs(recipientId);
    return {
      channels: { email: true, sms: false },        // opt-out of SMS
      events: { 'marketing.promo': false },          // opt-out of promos
      quiet: {                                        // quiet hours
        start: '22:00',
        end: '07:00',
        timezone: 'America/New_York',
      },
    };
  },
});

Quiet Hours

Notifications are automatically skipped when the recipient is in their quiet period. Times use HH:MM format, timezone uses IANA names. Overnight ranges (e.g. 22:00–07:00) are supported.

// Quiet hours are returned from the preference resolver
preferences: async (recipientId) => ({
  quiet: {
    start: '22:00',        // inclusive
    end: '07:00',          // exclusive
    timezone: 'Asia/Dhaka', // IANA timezone (defaults to UTC)
  },
});

// You can also use the utility directly
import { isQuietHours } from '@classytic/notifications/utils';

if (isQuietHours({ start: '22:00', end: '07:00', timezone: 'Asia/Dhaka' })) {
  console.log('Shhh!');
}

Idempotency / Deduplication

Prevent duplicate notifications by providing an idempotencyKey on the payload. The key is only recorded after at least one channel succeeds.

const notifications = new NotificationService({
  channels: [new EmailChannel({ ... })],
  idempotency: {},                             // uses MemoryIdempotencyStore, 24h TTL
});

await notifications.send({
  event: 'order.completed',
  recipient: { email: '[email protected]' },
  data: { orderId: '123' },
  idempotencyKey: 'order-completed-123',       // duplicate sends are skipped
});

// Second send with same key → skipped (sent: 0, skipped: 1)
await notifications.send({
  event: 'order.completed',
  recipient: { email: '[email protected]' },
  data: { orderId: '123' },
  idempotencyKey: 'order-completed-123',
});

Custom Store + TTL

import { MemoryIdempotencyStore } from '@classytic/notifications/utils';

// Custom TTL (1 hour)
const notifications = new NotificationService({
  idempotency: {
    ttl: 60 * 60 * 1000,  // 1 hour in ms (default: 24h)
  },
});

// Custom store (e.g. Redis for distributed systems)
import type { IdempotencyStore } from '@classytic/notifications/utils';

class RedisIdempotencyStore implements IdempotencyStore {
  async has(key: string): Promise<boolean> {
    return !!(await redis.exists(`idemp:${key}`));
  }
  async set(key: string, ttlMs: number): Promise<void> {
    await redis.set(`idemp:${key}`, '1', 'PX', ttlMs);
  }
}

const notifications = new NotificationService({
  idempotency: { store: new RedisIdempotencyStore() },
});

Batch Sending

Send thousands of notifications with controlled concurrency using a worker-pool pattern:

const payloads = students.map(s => ({
  event: 'birthday',
  recipient: { id: s.id, email: s.email },
  data: { name: s.name },
  template: 'birthday',
  idempotencyKey: `birthday-${s.id}-2024`,
}));

const batch = await notifications.sendBatch(payloads, {
  concurrency: 20,                          // max parallel sends (default: 10)
  onProgress: ({ completed, total }) => {
    console.log(`${completed}/${total}`);
  },
});

console.log(`Sent: ${batch.sent}, Failed: ${batch.failed}, Skipped: ${batch.skipped}`);

Each notification goes through the full send() pipeline (lifecycle events, templates, preferences, retry). Errors in individual notifications are caught and reported — they never abort the batch.

Concurrency Utility

The underlying pMap concurrency pool is exported for reuse:

import { pMap } from '@classytic/notifications/utils';

const results = await pMap(
  urls,
  async (url) => fetch(url).then(r => r.json()),
  { concurrency: 5 },
);

Retry + Backoff

const notifications = new NotificationService({
  retry: {
    maxAttempts: 3,           // default: 1 (no retry)
    backoff: 'exponential',   // 'exponential' | 'linear' | 'fixed'
    initialDelay: 500,        // ms, default: 500
    maxDelay: 30_000,         // ms, default: 30000
  },
});

Jitter (+-25%) is applied automatically to prevent thundering herd.

Lifecycle Events

notifications.on('before:send', (payload) => {
  console.log('Sending:', payload.event);
});

notifications.on('after:send', (result) => {
  console.log(`Sent ${result.sent}/${result.results.length}`);
});

notifications.on('send:success', (result) => { /* ... */ });
notifications.on('send:failed', (result) => { /* ... */ });
notifications.on('send:retry', ({ channel, attempt, error }) => { /* ... */ });

// Remove listener
notifications.off('send:failed', handler);

Lifecycle contract:

  • before:send is fail-fast — a throwing listener aborts the send and propagates the error. Use this for validation or rate limiting.
  • after:send, send:success, send:failed are safe — listener errors are caught and logged, never masking the dispatch result.
  • send:retry errors are caught and logged.

Hook Factories

Generate event handlers for any hook/event system:

const hooks = notifications.createHooks([
  {
    event: 'user.created',
    getRecipient: (user) => ({ email: user.email, name: user.name }),
    getData: (user) => ({ name: user.name }),
    template: 'welcome',
  },
  {
    event: 'order.completed',
    getRecipient: (order) => ({ email: order.customer.email }),
    getData: (order) => ({ orderId: order.id, total: order.total }),
    template: 'order-confirmation',
    channels: ['email'],
  },
]);

// With EventEmitter
emitter.on('user.created', hooks['user.created'][0]);

// With MongoKit
repo.on('after:create', hooks['user.created'][0]);

Hooks are fire-and-forget: errors are logged but never thrown to avoid breaking the caller's flow.

Merging Hooks

Combine hooks from multiple sources:

import { mergeHooks } from '@classytic/notifications/utils';

const combined = mergeHooks(
  notifications.createHooks(userHookConfigs),
  notifications.createHooks(orderHookConfigs),
);

Channel Management

// Add/remove channels at runtime
notifications.addChannel(new ConsoleChannel());
notifications.removeChannel('console');

// Inspect registered channels
notifications.getChannel('email');      // Channel | undefined
notifications.getChannelNames();        // ['email', 'webhook']

API Reference

NotificationService

| Method | Description | |--------|-------------| | send(payload) | Send notification to all matching channels | | sendBatch(payloads, options?) | Send multiple notifications with concurrency control | | addChannel(channel) | Register a channel at runtime | | removeChannel(name) | Remove a channel by name | | getChannel(name) | Get a channel by name | | getChannelNames() | List all registered channel names | | createHooks(configs) | Create event-specific hook handlers | | on(event, handler) | Listen to lifecycle events | | off(event, handler) | Remove a lifecycle listener |

BaseChannel<TConfig>

Abstract base class for channels. Provides shouldHandle(event) with wildcard support.

Exports

// Core
import { NotificationService } from '@classytic/notifications';

// Channels
import { BaseChannel, EmailChannel, WebhookChannel, ConsoleChannel } from '@classytic/notifications/channels';

// Utilities
import { mergeHooks, withRetry, resolveRetryConfig, calculateDelay, Emitter } from '@classytic/notifications/utils';
import { NotificationError, ChannelError, ProviderNotInstalledError } from '@classytic/notifications/utils';
import { isQuietHours, MemoryIdempotencyStore, pMap } from '@classytic/notifications/utils';
import type { IdempotencyStore, PMapOptions, QuietHoursConfig } from '@classytic/notifications/utils';

License

MIT