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

townkrier-core

v1.0.1-alpha.2

Published

Laravel-style notification system for Node.js with multiple channels and providers

Readme

townkrier-core 🚀

A powerful, Laravel-inspired notification system for Node.js. Flexible, provider-agnostic, and built for scalable notification engines.

NPM Version License: MIT


🌟 Overview

Townkrier provides a unified API for sending notifications through multiple channels (Email, SMS, Push, WhatsApp, In-App, etc.). It abstracts the complexity of managing multiple providers, allowing you to focus on your application logic rather than API integrations.

Inspired by the elegant Laravel Notification system, Townkrier brings a familiar, developer-friendly experience to the TypeScript ecosystem.

✨ Features

  • 🔌 Multi-Channel: Email, SMS, Push, WhatsApp, In-App (SSE), and custom channels
  • 🔄 Strategy-Driven Delivery: Built-in support for Priority Fallback, Round Robin, and Weighted Random strategies
  • 🎯 Notifiable Pattern: Attach notification capabilities to any entity (User, Organization, etc.)
  • 🛡️ Production Ready: Robust error handling with BestEffort or AllOrNothing delivery strategies
  • 🔁 Auto-Retry: Automatic retry with exponential backoff for transient failures
  • 📊 Event System: Hook into notification lifecycle for logging, analytics, and monitoring
  • 🏗️ Extensible: Easily build and plug in custom drivers or channels
  • 🦾 Strictly Typed: Native TypeScript support with deep generic integration for compile-time safety
  • 🚀 Framework Agnostic: Works with Express, NestJS, Fastify, or standalone

📦 Installation

pnpm add townkrier-core

# Install channel drivers you need
pnpm add townkrier-resend townkrier-termii townkrier-expo

🚀 Quick Start

1. Initialize the Manager

Use the TownkrierFactory to create your notification manager:

import { TownkrierFactory, DeliveryStrategy } from 'townkrier-core';
import { ResendDriver } from 'townkrier-resend';

const manager = TownkrierFactory.create({
  strategy: DeliveryStrategy.BestEffort, // or AllOrNothing
  channels: {
    email: {
      driver: ResendDriver,
      config: { apiKey: process.env.RESEND_API_KEY },
    },
  },
});

2. Define a Notification

Notifications are classes that define which channels they use and what the message looks like:

import { Notification, Notifiable } from 'townkrier-core';
import { ResendMessage } from 'townkrier-resend';

class WelcomeNotification extends Notification<'email'> {
  constructor(private userName: string) {
    super();
  }

  via(notifiable: Notifiable) {
    return ['email'];
  }

  toEmail(notifiable: Notifiable): ResendMessage {
    return {
      subject: 'Welcome to Our Platform!',
      html: `<h1>Welcome ${this.userName}!</h1><p>We're excited to have you on board.</p>`,
      to: notifiable.routeNotificationFor('email') as string,
      from: '[email protected]',
    };
  }
}

3. Send Notifications

Implement the Notifiable interface on your entities:

const user = {
  id: 'user_123',
  name: 'Jeremiah',
  email: '[email protected]',

  // Required by Notifiable interface
  routeNotificationFor(channel: string) {
    if (channel === 'email') return this.email;
    return undefined;
  },
};

// Send the notification
const result = await manager.send(user, new WelcomeNotification(user.name));
console.log(result.status); // 'success' or 'failed'

🔥 Advanced Usage

Multi-Channel Notifications

Send notifications across multiple channels simultaneously:

import { Notification, Notifiable } from 'townkrier-core';
import { ResendMessage } from 'townkrier-resend';
import { TermiiMessage } from 'townkrier-termii';
import { ExpoMessage } from 'townkrier-expo';

class OrderConfirmation extends Notification<'email' | 'sms' | 'push'> {
  constructor(private orderId: string, private amount: number) {
    super();
  }

  via(notifiable: Notifiable) {
    return ['email', 'sms', 'push'];
  }

  toEmail(notifiable: Notifiable): ResendMessage {
    return {
      subject: `Order #${this.orderId} Confirmed`,
      html: `<p>Your order of $${this.amount} has been confirmed!</p>`,
      to: notifiable.routeNotificationFor('email') as string,
      from: '[email protected]',
    };
  }

  toSms(notifiable: Notifiable): TermiiMessage {
    return {
      to: notifiable.routeNotificationFor('sms') as string,
      sms: `Order #${this.orderId} confirmed! Total: $${this.amount}`,
      type: 'plain',
      channel: 'dnd', // Transactional SMS
    };
  }

  toPush(notifiable: Notifiable): ExpoMessage {
    return {
      to: notifiable.routeNotificationFor('push') as string,
      title: 'Order Confirmed',
      body: `Your order #${this.orderId} has been confirmed!`,
      data: { orderId: this.orderId },
    };
  }
}

Strategic Fallbacks & Load Balancing

Configure multiple drivers per channel with advanced strategies:

import { TownkrierFactory, FallbackStrategy, DeliveryStrategy } from 'townkrier-core';
import { ResendDriver } from 'townkrier-resend';
import { MailtrapDriver } from 'townkrier-mailtrap';
import { SmtpDriver } from 'townkrier-smtp';
import { TermiiDriver } from 'townkrier-termii';

const manager = TownkrierFactory.create({
  strategy: DeliveryStrategy.BestEffort,
  channels: {
    email: {
      strategy: FallbackStrategy.PriorityFallback, // Try highest priority first
      drivers: [
        {
          use: ResendDriver,
          config: { apiKey: process.env.RESEND_API_KEY },
          priority: 10, // Highest priority
        },
        {
          use: MailtrapDriver,
          config: { token: process.env.MAILTRAP_TOKEN },
          priority: 8,
        },
        {
          use: SmtpDriver,
          config: {
            host: process.env.SMTP_HOST,
            port: 587,
            user: process.env.SMTP_USER,
            pass: process.env.SMTP_PASS,
          },
          priority: 5, // Fallback
        },
      ],
    },
    sms: {
      strategy: FallbackStrategy.RoundRobin, // Distribute load evenly
      drivers: [
        {
          use: TermiiDriver,
          config: {
            apiKey: process.env.TERMII_API_KEY,
            from: process.env.TERMII_SENDER_ID,
          },
        },
      ],
    },
  },
});

Event System

Hook into the notification lifecycle:

// Listen to events
manager.events().on('NotificationSending', (event) => {
  console.log(`📤 Sending via: ${event.channels.join(', ')}`);
});

manager.events().on('NotificationSent', (event) => {
  console.log('✅ Notification sent successfully!');
  console.log('Responses:', Object.fromEntries(event.responses));
});

manager.events().on('NotificationFailed', (event) => {
  console.error('❌ Notification failed:', event.error.message);
  // Log to monitoring service
});

Retry Configuration

Customize retry behavior per driver:

const manager = TownkrierFactory.create({
  channels: {
    email: {
      strategy: FallbackStrategy.PriorityFallback,
      drivers: [
        {
          use: ResendDriver,
          config: { apiKey: '...' },
          priority: 10,
          retryConfig: {
            maxRetries: 5, // Try 5 times
            retryDelay: 2000, // Start with 2s delay
            exponentialBackoff: true, // Double delay each retry
            maxRetryDelay: 10000, // Cap at 10s
          },
        },
        {
          use: MailtrapDriver,
          config: { token: '...' },
          priority: 8,
          retryConfig: {
            maxRetries: 1, // No retries, fail immediately
          },
        },
      ],
    },
  },
});

Default Retry Behavior:

  • Retries up to 3 times before falling back
  • Exponential backoff: 1s → 2s → 4s (capped at 5s)
  • Only retries network errors (ETIMEDOUT, ECONNREFUSED, etc.)
  • Does not retry API errors (auth failures, rate limits, etc.)

Disabling Drivers

Temporarily disable drivers without removing them:

const manager = TownkrierFactory.create({
  channels: {
    email: {
      strategy: FallbackStrategy.PriorityFallback,
      drivers: [
        {
          use: ResendDriver,
          config: { apiKey: '...' },
          priority: 10,
          enabled: true, // Active
        },
        {
          use: MailtrapDriver,
          config: { token: '...' },
          priority: 8,
          enabled: false, // Disabled for testing
        },
      ],
    },
  },
});

Message Mappers (Multiple Drivers with Different Interfaces)

When using multiple drivers with different message interfaces, you can define message mappers to transform your unified messages into driver-specific formats. This eliminates type conflicts and keeps your notifications clean.

Why Mappers?

  • Different drivers expect different field names (msg vs body, To vs to, etc.)
  • Without mappers, you'd need to use as any or union types
  • Mappers register once during configuration, keeping notifications simple

How to Use:

  1. Define your unified message interface (in your app code):
// messages/unified-whatsapp.interface.ts
export interface UnifiedWhatsappMessage {
  to: string;
  text: string;
  media?: string;
  caption?: string;
  type?: 'text' | 'image' | 'video' | 'audio' | 'document';
}
  1. Create mappers for each driver (in your app code):
// mappers/whatsapp.mappers.ts
import { MessageMapper } from 'townkrier-core';
import { WhapiMessage } from 'townkrier-whapi';
import { WaSendApiMessage } from 'townkrier-wasender';
import { UnifiedWhatsappMessage } from '../messages/unified-whatsapp.interface';

// Map unified message to Whapi format
export class WhapiMessageMapper implements MessageMapper<UnifiedWhatsappMessage, WhapiMessage> {
  map(message: UnifiedWhatsappMessage): WhapiMessage {
    return {
      to: message.to,
      body: message.text,        // Whapi uses 'body'
      media: message.media,
      caption: message.caption,
      type: message.type,
    };
  }
}

// Map unified message to WaSender format
export class WaSendApiMessageMapper implements MessageMapper<UnifiedWhatsappMessage, WaSendApiMessage> {
  map(message: UnifiedWhatsappMessage): WaSendApiMessage {
    return {
      to: message.to,
      msg: message.text,         // WaSender uses 'msg'
      url: message.media,        // WaSender uses 'url' for media
      type: message.type || 'text',
    };
  }
}
  1. Register mappers when configuring drivers:
import { WhapiDriver } from 'townkrier-whapi';
import { WaSendApiDriver } from 'townkrier-wasender';
import { WhapiMessageMapper, WaSendApiMessageMapper } from './mappers/whatsapp.mappers';

const manager = TownkrierFactory.create({
  channels: {
    whatsapp: {
      strategy: FallbackStrategy.PriorityFallback,
      drivers: [
        {
          use: WhapiDriver,
          config: WhapiDriver.configure({ apiKey: process.env.WHAPI_TOKEN }),
          mapper: WhapiMessageMapper,        // ← Register class (framework instantiates)
          priority: 10,
          enabled: true,
        },
        {
          use: WaSendApiDriver,
          config: WaSendApiDriver.configure({ apiKey: process.env.WASENDER_API_KEY }),
          mapper: WaSendApiMessageMapper,    // ← Register class
          priority: 8,
          enabled: true,
        },
      ],
    },
  },
});
  1. Use unified type in notifications (no as any needed!):
import { Notification, Notifiable } from 'townkrier-core';
import { UnifiedWhatsappMessage } from './messages/unified-whatsapp.interface';

export class WhatsappNotification extends Notification<'whatsapp'> {
  constructor(private userName: string, private orderId: string) {
    super();
  }

  via(notifiable: Notifiable) {
    return ['whatsapp'];
  }

  // Return unified type - mappers handle driver-specific transformation
  toWhatsapp(notifiable: Notifiable): UnifiedWhatsappMessage {
    return {
      to: notifiable.routeNotificationFor('whatsapp') as string,
      text: `Hello *${this.userName}*! Your order *#${this.orderId}* confirmed! 🎉`,
    };
  }
}

Key Benefits:

  • Type-safe: No as any hacks
  • Flexible: Users define their own unified formats
  • Decoupled: Notifications don't know about driver-specific interfaces
  • Reusable: One mapper per driver, configured once
  • Optional: Only needed when using multiple drivers
  • Framework handles instantiation: Register classes, not instances

Toggling Channels and Drivers

Easily enable or disable channels and drivers without removing configuration:

const manager = TownkrierFactory.create({
  channels: {
    // Disable entire channel temporarily
    email: {
      driver: ResendDriver,
      config: ResendDriver.configure({ apiKey: '...' }),
      enabled: false,  // ← Channel disabled
    },
    
    // Disable specific drivers in fallback strategy
    sms: {
      strategy: FallbackStrategy.PriorityFallback,
      drivers: [
        {
          use: TermiiDriver,
          config: TermiiDriver.configure({ apiKey: '...' }),
          priority: 10,
          enabled: true,   // ← Active
        },
        {
          use: TwilioDriver,
          config: TwilioDriver.configure({ accountSid: '...', authToken: '...' }),
          priority: 8,
          enabled: false,  // ← Temporarily disabled
        },
      ],
    },
  },
});

Use Cases:

  • 🎛️ Toggle channels/drivers on/off without code changes
  • 🔧 Perfect for maintenance windows
  • 🧪 A/B testing different providers
  • 🚀 Gradual rollouts
  • 💰 Disable expensive channels in development: enabled: process.env.NODE_ENV === 'production'

🌐 Framework Integrations

Express.js

import express from 'express';
import { TownkrierFactory } from 'townkrier-core';
import { ResendDriver } from 'townkrier-resend';

const app = express();
app.use(express.json());

// Initialize notification manager
const notificationManager = TownkrierFactory.create({
  channels: {
    email: {
      driver: ResendDriver,
      config: { apiKey: process.env.RESEND_API_KEY },
    },
  },
});

// Make it available in requests
app.use((req, res, next) => {
  req.notifications = notificationManager;
  next();
});

// Use in routes
app.post('/api/users/register', async (req, res) => {
  const user = await createUser(req.body);

  // Send welcome email
  await req.notifications.send(user, new WelcomeNotification(user.name));

  res.json({ success: true, user });
});

app.listen(3000);

NestJS

Create a notification module:

// notification.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TownkrierFactory } from 'townkrier-core';
import { ResendDriver } from 'townkrier-resend';
import { TermiiDriver } from 'townkrier-termii';

@Global()
@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: 'NOTIFICATION_MANAGER',
      useFactory: (configService: ConfigService) => {
        return TownkrierFactory.create({
          channels: {
            email: {
              driver: ResendDriver,
              config: { apiKey: configService.get('RESEND_API_KEY') },
            },
            sms: {
              driver: TermiiDriver,
              config: {
                apiKey: configService.get('TERMII_API_KEY'),
                from: configService.get('TERMII_SENDER_ID'),
              },
            },
          },
        });
      },
      inject: [ConfigService],
    },
  ],
  exports: ['NOTIFICATION_MANAGER'],
})
export class NotificationModule {}

// Use in services
import { Injectable, Inject } from '@nestjs/common';
import { NotificationManager } from 'townkrier-core';

@Injectable()
export class UserService {
  constructor(
    @Inject('NOTIFICATION_MANAGER')
    private notifications: NotificationManager,
  ) {}

  async register(data: CreateUserDto) {
    const user = await this.userRepository.create(data);

    // Send welcome notification
    await this.notifications.send(user, new WelcomeNotification(user.name));

    return user;
  }
}

Fastify

import Fastify from 'fastify';
import { TownkrierFactory } from 'townkrier-core';
import { ResendDriver } from 'townkrier-resend';

const fastify = Fastify();

// Create notification manager
const notificationManager = TownkrierFactory.create({
  channels: {
    email: {
      driver: ResendDriver,
      config: { apiKey: process.env.RESEND_API_KEY },
    },
  },
});

// Register as decorator
fastify.decorate('notifications', notificationManager);

// Use in routes
fastify.post('/api/users/register', async (request, reply) => {
  const user = await createUser(request.body);

  await fastify.notifications.send(user, new WelcomeNotification(user.name));

  return { success: true, user };
});

fastify.listen({ port: 3000 });

🛠️ Custom Channels & Drivers

Build custom drivers by implementing the NotificationDriver interface:

import { NotificationDriver, Notifiable, SendResult } from 'townkrier-core';
import axios from 'axios';

interface SlackConfig {
  webhookUrl: string;
}

interface SlackMessage {
  text: string;
  channel?: string;
  username?: string;
}

export class SlackDriver implements NotificationDriver<SlackConfig, SlackMessage> {
  constructor(private config: SlackConfig) {}

  async send(notifiable: Notifiable, message: SlackMessage): Promise<SendResult> {
    try {
      const response = await axios.post(this.config.webhookUrl, {
        text: message.text,
        channel: message.channel,
        username: message.username || 'Notification Bot',
      });

      return {
        id: `slack_${Date.now()}`,
        status: 'success',
        response: response.data,
      };
    } catch (error: any) {
      return {
        id: '',
        status: 'failed',
        error: {
          message: error.message,
          raw: error.response?.data || error,
        },
      };
    }
  }
}

// Register and use
const manager = TownkrierFactory.create({
  channels: {
    slack: {
      driver: SlackDriver,
      config: { webhookUrl: process.env.SLACK_WEBHOOK_URL },
    },
  },
});

class AlertNotification extends Notification<'slack'> {
  via() {
    return ['slack'];
  }

  toSlack(notifiable: Notifiable) {
    return {
      text: '🚨 System Alert: High CPU usage detected!',
      channel: '#alerts',
    };
  }
}

📦 Official Drivers

Email

  • townkrier-resend - Resend email service
  • townkrier-mailtrap - Mailtrap email testing
  • townkrier-smtp - Generic SMTP driver
  • townkrier-postmark - Postmark email service

SMS

  • townkrier-termii - Termii SMS service (Nigeria, Africa)

Push Notifications

  • townkrier-expo - Expo Push Notifications
  • townkrier-fcm - Firebase Cloud Messaging

WhatsApp

  • townkrier-whapi - Whapi.cloud WhatsApp API
  • townkrier-wasender - WaSender WhatsApp API

In-App

  • townkrier-sse - Server-Sent Events for real-time notifications

🎯 Real-World Examples

OTP Verification

class OtpNotification extends Notification<'sms' | 'email'> {
  constructor(private otp: string, private expiresInMinutes: number) {
    super();
  }

  via(notifiable: Notifiable) {
    // Send via SMS if phone exists, otherwise email
    return notifiable.routeNotificationFor('sms') ? ['sms'] : ['email'];
  }

  toSms(notifiable: Notifiable): TermiiMessage {
    return {
      to: notifiable.routeNotificationFor('sms') as string,
      sms: `Your verification code is ${this.otp}. Valid for ${this.expiresInMinutes} minutes.`,
      type: 'plain',
      channel: 'dnd', // Bypass DND for transactional messages
    };
  }

  toEmail(notifiable: Notifiable): ResendMessage {
    return {
      subject: 'Your Verification Code',
      html: `<p>Your verification code is <strong>${this.otp}</strong>. Valid for ${this.expiresInMinutes} minutes.</p>`,
      to: notifiable.routeNotificationFor('email') as string,
      from: '[email protected]',
    };
  }
}

Payment Confirmation

class PaymentConfirmation extends Notification<'email' | 'sms' | 'whatsapp'> {
  constructor(
    private amount: number,
    private currency: string,
    private reference: string,
  ) {
    super();
  }

  via(notifiable: Notifiable) {
    return ['email', 'sms', 'whatsapp'];
  }

  toEmail(notifiable: Notifiable): ResendMessage {
    return {
      subject: 'Payment Received',
      html: `
        <h2>Payment Confirmation</h2>
        <p>We've received your payment of ${this.currency} ${this.amount}</p>
        <p>Reference: ${this.reference}</p>
      `,
      to: notifiable.routeNotificationFor('email') as string,
      from: '[email protected]',
    };
  }

  toSms(notifiable: Notifiable): TermiiMessage {
    return {
      to: notifiable.routeNotificationFor('sms') as string,
      sms: `Payment of ${this.currency}${this.amount} received. Ref: ${this.reference}`,
      channel: 'dnd',
    };
  }

  toWhatsapp(notifiable: Notifiable): WhapiMessage {
    return {
      to: notifiable.routeNotificationFor('whatsapp') as string,
      body: `✅ Payment Confirmed!\n\nAmount: ${this.currency} ${this.amount}\nReference: ${this.reference}`,
    };
  }
}

🧪 Testing

Mock the notification manager in tests:

import { jest } from '@jest/globals';

const mockNotificationManager = {
  send: jest.fn().mockResolvedValue({
    status: 'success',
    results: new Map([['email', { id: 'test_123', status: 'success' }]]),
    errors: new Map(),
  }),
  events: jest.fn().mockReturnValue({
    on: jest.fn(),
  }),
};

// Use in tests
test('should send welcome notification on user registration', async () => {
  const user = await registerUser({ email: '[email protected]' });

  expect(mockNotificationManager.send).toHaveBeenCalledWith(
    user,
    expect.any(WelcomeNotification),
  );
});

📊 Best Practices

  1. Use Environment Variables: Never hardcode API keys
  2. Implement Retry Logic: Use retry configs for production resilience
  3. Monitor Events: Hook into events for logging and analytics
  4. Graceful Degradation: Use BestEffort strategy for non-critical notifications
  5. Type Safety: Always type your notification messages with driver-specific interfaces
  6. Fallback Strategies: Configure multiple drivers per channel for high availability
  7. Test Notifications: Use test/sandbox modes in development

📜 License

MIT © Jeremiah Olisa