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

@msgly/core

v0.2.3

Published

Core engine for the messaging hub — unified message model, adapter contract, event bus

Readme

@msgly/core

Core engine for Msgly — unified message model, the createHub factory, retry, idempotency, capability checks, and the Adapter contract every channel package implements. Zero classes, runs in Node 18+, Next.js (Node + Edge), and the browser.

@msgly/core is the runtime every channel adapter plugs into. You won't usually depend on it directly for application code — install it alongside one or more adapters:

Chat / messaging: @msgly/telegram, @msgly/whatsapp, @msgly/line, @msgly/messenger, @msgly/instagram, @msgly/discord, @msgly/msteams

Email: @msgly/gmail, @msgly/outlook

Install

npm install @msgly/core

Quick start

import express from 'express';
import { createHub } from '@msgly/core';
import { createTelegramAdapter } from '@msgly/telegram';
import { createWhatsAppAdapter } from '@msgly/whatsapp';

const hub = createHub();

hub.register(
  createTelegramAdapter({
    botToken: process.env.TELEGRAM_BOT_TOKEN!,
    webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET!,
  }),
);

hub.register(
  createWhatsAppAdapter({
    phoneNumberId: process.env.WA_PHONE_ID!,
    accessToken: process.env.WA_TOKEN!,
    appSecret: process.env.META_APP_SECRET!,
    verifyToken: process.env.META_VERIFY_TOKEN!,
  }),
);

// Verify credentials at startup — fail fast on bad tokens
await hub.connect({ throwOnFailure: true });

hub.on('message', async (msg) => {
  if (msg.content.type === 'text') {
    await hub.send({
      channel: msg.channel,
      account: msg.account,
      contact: msg.contact,
      content: { type: 'text', text: `You said: ${msg.content.text}` },
    });
  }
});

const app = express();
app.use(express.json({ verify: (req, _r, buf) => ((req as any).rawBody = new Uint8Array(buf)) }));

const handlers = hub.createWebhookHandler();
app.get('/webhook/:channel', handlers.get);
app.post('/webhook/:channel', handlers.post);

app.listen(3000);

Concepts

The unified message

Every inbound and outbound message conforms to the same shape, regardless of channel:

interface UnifiedMessage {
  id: string;                  // library-generated UUID, stable across retries
  externalId?: string;         // the platform's own message id
  channel: ChannelName;        // 'telegram' | 'whatsapp' | 'line' | 'messenger' | 'instagram'
                               //   | 'discord' | 'msteams' | 'gmail' | 'outlook'
  account: AccountRef;         // your business identity on that channel
  contact: ContactRef;         // the end user
  content: MessageContent;     // discriminated union, see below
  timestamp: string;           // ISO 8601
  direction: 'inbound' | 'outbound';
  metadata?: Record<string, unknown>;
  raw?: unknown;               // present on inbound — original platform payload
}

Content types

MessageContent is a discriminated union:

type MessageContent =
  | { type: 'text'; text: string }
  | { type: 'image' | 'video' | 'audio' | 'file';
      mediaRef: MediaReference; caption?: string }
  | { type: 'location'; latitude: number; longitude: number; name?: string; address?: string }
  | { type: 'interactive'; text: string; buttons: { id: string; label: string }[] }
  | { type: 'template'; templateName: string; language: string;
      variables?: Record<string, string> };

AccountRef and ContactRef

interface AccountRef {
  channel: ChannelName;
  channelAccountId: string;    // bot id / phone_number_id / page id
}

interface ContactRef {
  channel: ChannelName;
  channelUserId: string;       // chat_id / phone number / page-scoped user id
  displayName?: string;
  globalContactId?: string;    // your cross-channel identity if you have one
}

createHub(options?)

function createHub(options?: HubOptions): Hub;

interface HubOptions {
  store?: MessageStore;             // default: in-memory
  logger?: Logger;                  // default: console-based (warn + error only)
  retry?: Partial<RetryOptions>;    // default: 3 attempts, 500ms base, 8000ms cap
}

hub.register(adapter)

Registers a channel adapter. Throws MsglyError with code: 'AdapterAlreadyRegistered' on duplicate registration. Returns hub for chaining.

hub.send(message)

Send a partial OutboundMessageid, direction, and timestamp are filled in for you.

await hub.send({
  channel: 'whatsapp',
  account: { channel: 'whatsapp', channelAccountId: '...' },
  contact: { channel: 'whatsapp', channelUserId: '919999999999' },
  content: { type: 'text', text: 'hi' },
});

Sends are wrapped in retry (see Retry) and validated against the target adapter's capabilities (see Capability checks).

hub.on(event, handler) — returns unsubscribe

const off = hub.on('message',  (msg) => { /* handle inbound */ });
hub.on('delivery', (receipt) => { /* status updates */ });
hub.on('error',    (err, ctx) => { /* observe failures */ });

// Later:
off();

Unlike traditional EventEmitter-based libraries, hub.on() returns an unsubscribe function — no need to track handler references for cleanup.

hub.connect({ throwOnFailure? })

Calls every registered adapter's verifyCredentials() in parallel. Returns Record<ChannelName, CredentialsCheckResult>. Pass throwOnFailure: true to throw an aggregated error if any adapter fails — useful in boot scripts.

const report = await hub.connect();
// { telegram: { ok: true,  accountInfo: '@my_bot' },
//   whatsapp: { ok: false, reason: 'unauthorized', hint: '...' } }

The hint is an actionable string explaining exactly which env var to fix and where to find the value.

hub.createWebhookHandler()

Returns { get, post } for use with any Express-like framework:

  • GET /webhook/:channel — handles the Meta-family subscription handshake (hub.verify_token check)
  • POST /webhook/:channel — verifies the channel's signature (HMAC / Ed25519 / RS256 JWT / shared-secret depending on adapter), optionally short-circuits with a platform-specific ack body (Discord PONG, Graph validationToken echo), dispatches to the right adapter, deduplicates via externalId, emits message events
const handlers = hub.createWebhookHandler();
app.get('/webhook/:channel', handlers.get);
app.post('/webhook/:channel', handlers.post);

Raw body is required. Signature verification needs the byte-exact request body as a Uint8Array. Configure your body parser to expose it on req.rawBody. For Express, capture across all content-types so platform handshakes that arrive as text/plain (e.g. Microsoft Graph's validationToken) aren't dropped:

const captureRaw = (req, _res, buf) => { req.rawBody = new Uint8Array(buf); };
app.use(express.json({ verify: captureRaw }));
app.use(express.urlencoded({ extended: true, verify: captureRaw }));
app.use(express.raw({ type: '*/*', verify: captureRaw })); // fallback for text/plain etc.

hub.handleWebhook(channel, req)

The lower-level entry point used by createWebhookHandler. Useful when wiring webhooks into a framework that doesn't fit the Express shape, or directly inside Next.js Route Handlers / Server Actions.

hub.channels / hub.getAdapter(channel)

channels returns the list of registered channel names. getAdapter returns the registered adapter, or throws a MsglyError with code: 'AdapterNotRegistered'.

hub.start() / hub.stop()

Calls the optional start()/stop() lifecycle hooks on every registered adapter.

Retry

Sends are wrapped in exponential backoff with equal jitter:

  • maxAttempts: 3 (configurable)
  • initialDelayMs: 500
  • maxDelayMs: 8000
  • Backoff: min(initial * 2^(attempt-1), maxDelay) then delay/2 + random(delay/2)

Auth errors (401/403/404) and "unauthorized" codes are never retried — the token is bad, retrying just wastes API calls. Network errors and 5xx are retried.

const hub = createHub({
  retry: {
    maxAttempts: 5,
    initialDelayMs: 200,
    maxDelayMs: 4000,
    shouldRetry: (err, attempt) => attempt < 3,
  },
});

Capability checks

Every adapter advertises an AdapterCapabilities object:

interface AdapterCapabilities {
  text: boolean;
  media: { image: boolean; video: boolean; audio: boolean; file: boolean };
  interactive: { buttons: boolean; quickReplies: boolean };
  templates: boolean;
  reactions: boolean;
  typing: boolean;
}

The hub checks content.type against these before dispatching. Unsupported sends throw a MsglyError with code: 'UnsupportedFeature':

import { isMsglyError } from '@msgly/core';

try {
  await hub.send({ channel: 'instagram', /* ... */ content: { type: 'audio', mediaRef } });
} catch (err) {
  if (isMsglyError(err, 'UnsupportedFeature')) {
    // Instagram does not support audio
  }
}

Cross-channel matrix:

| Feature | Telegram | WhatsApp | LINE | Messenger | Instagram | Discord | Teams | Gmail | Outlook | | -------------- | -------- | -------- | ---- | --------- | --------- | ------- | ----- | ----- | ------- | | text | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | image | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | | video | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | — | | audio | ✓ | ✓ | ✓ | ✓ | — | ✓ | — | — | — | | file | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | — | — | | buttons | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | — | | quick replies | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | — | — | | templates | — | ✓ | — | — | — | — | — | — | — | | reactions | ✓ | ✓ | — | — | ✓ | — | — | — | — | | typing | ✓ | — | — | ✓ | — | — | ✓ | — | — |

Email adapters (Gmail, Outlook) are text-only in v1 — inbound attachments come through as best-effort plain-text body extraction, and outbound media is not yet supported.

Idempotency and storage

The hub uses a MessageStore for two things:

  1. Saving inbound and outbound messages.
  2. Deduplicating webhook deliveries by externalId (platforms retry on 5xx, so the same message can arrive twice).

The default in-memory store is fine for development and tests but loses state on restart. Provide your own implementation for production:

interface MessageStore {
  saveMessage(message: UnifiedMessage): Promise<void>;
  getMessage(id: string): Promise<UnifiedMessage | null>;
  hasExternalId(channel: string, externalId: string): Promise<boolean>;
}

const hub = createHub({ store: makePostgresStore(db) });

Errors

All errors thrown by msgly are plain Error instances tagged with name: 'MsglyError' plus a machine-readable code. Detect them with isMsglyError:

import { isMsglyError, type MsglyErrorCode } from '@msgly/core';

try {
  await hub.send(/* ... */);
} catch (err) {
  if (isMsglyError(err, 'SendFailed')) {
    console.log('channel:', err.channel);
    console.log('receipt:', err.receipt);
  }
}

Possible codes:

| Code | Thrown when | | ------------------------- | ---------------------------------------------------- | | AdapterNotRegistered | hub.send() to a channel with no adapter | | AdapterAlreadyRegistered| hub.register() called twice for the same channel | | UnsupportedFeature | content.type is not in adapter capabilities | | InvalidSignature | Webhook HMAC mismatch | | SendFailed | adapter.send() failed after retries |

Constructors are also exported for adapter authors: adapterNotRegistered, unsupportedFeature, invalidSignature, sendFailed.

Writing a custom adapter

Implement the Adapter interface:

import type {
  Adapter,
  AdapterCapabilities,
  CredentialsCheckResult,
  WebhookRequest,
  OutboundMessage,
  InboundMessage,
  DeliveryReceipt,
  MediaFile,
  MediaReference,
} from '@msgly/core';

interface MyConfig { apiToken: string; }

export function createMyAdapter(config: MyConfig): Adapter {
  return {
    channel: 'mychannel' as const,
    capabilities: { /* ... */ },
    async send(message: OutboundMessage): Promise<DeliveryReceipt> { /* ... */ },
    async handleWebhook(req: WebhookRequest): Promise<InboundMessage[]> { /* ... */ },
    async verifySignature(req: WebhookRequest): Promise<boolean> { /* HMAC check */ },
    async uploadMedia(file: MediaFile): Promise<MediaReference> { /* ... */ },
    async downloadMedia(ref: MediaReference): Promise<MediaFile> { /* ... */ },
    async verifyCredentials(): Promise<CredentialsCheckResult> { /* ... */ },

    // Optional — for Meta-style GET handshake (Messenger / Instagram / WhatsApp):
    verifyWebhookChallenge(query) { /* ... */ return null; },

    // Optional — for platforms whose POST webhook must reply with a
    // specific body (Discord PING/PONG, Graph validationToken echo):
    getInteractionAck(req) {
      // return null to fall through
      // return a string → sent as application/json
      // return { body, contentType } for non-JSON responses (text/plain etc.)
      return null;
    },

    // Optional lifecycle hooks:
    async start() { /* ... */ },
    async stop() { /* ... */ },
  };
}

Adding a new ChannelName requires extending the union in core/src/types.ts'mychannel' won't compile until you do.

Runtime compatibility

@msgly/core and every adapter use only Web Standard APIs:

  • fetch (no undici, no node-fetch)
  • Web Crypto (globalThis.crypto.subtle) for HMAC signatures
  • TextEncoder / Uint8Array instead of Buffer
  • globalThis.crypto.randomUUID() for ids (with a Math.random fallback)

This means msgly runs everywhere modern JS does:

| Runtime | Supported | | ----------------------------- | --------- | | Node 18+ | ✓ | | Next.js Node runtime | ✓ | | Next.js Edge runtime | ✓ | | Bun / Deno | ✓ | | Modern browsers (server-only adapters; not for client sends) | ✓ |

Server-side webhook handling needs the raw request bytes — most frameworks expose them; for Next.js Route Handlers, use await req.arrayBuffer() and pass new Uint8Array(...).

Adapters

| Channel | Package | Inbound auth | Setup notes | | ---------------- | ------------------ | --------------------------------------------- | -------------------------------------------- | | Telegram | @msgly/telegram | X-Telegram-Bot-Api-Secret-Token header | Easiest — @BotFather, no business approval | | LINE | @msgly/line | HMAC-SHA256, constant-time | LINE Developers console | | Messenger | @msgly/messenger | X-Hub-Signature-256 HMAC | Needs Meta App + Facebook Page | | Instagram | @msgly/instagram | X-Hub-Signature-256 HMAC | IG Business linked to Page | | WhatsApp | @msgly/whatsapp | X-Hub-Signature-256 HMAC | Meta WhatsApp Cloud API | | Discord | @msgly/discord | Ed25519 over timestamp + rawBody | HTTP Interactions (slash commands + buttons) | | Microsoft Teams | @msgly/msteams | RS256 JWT against Bot Framework JWKS | Azure Bot resource + Teams channel | | Gmail | @msgly/gmail | RS256 OIDC JWT (or shared token) | Pub/Sub push subscription, OAuth refresh token | | Outlook / M365 | @msgly/outlook | clientState shared secret, constant-time | Graph change-notification subscription |

Email adapters (gmail, outlook) are text-only in v1. Each is single-mailbox (one OAuth refresh token in config = one inbox). See the per-package READMEs for setup walkthroughs.

Documentation

Full quickstart, connection guides, and architecture overview: https://github.com/AyushJain070401/msgly

License

MIT