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

@thinwrap/notifications

v1.2.0

Published

Unified TypeScript facade for 35 notification providers across email, SMS, push, and chat. Stateless, BYO fetch, zero vendor SDKs.

Readme

@thinwrap/notifications

Unified TypeScript facade for 35 notification providers across email, SMS, push, and chat. Stateless. Zero vendor SDKs. Bring your own fetch.

Install

npm install @thinwrap/notifications

Requires Node.js ≥18 (uses native fetch).

End-to-end example

import { Email, ConnectorError } from '@thinwrap/notifications';

const sendgrid = new Email('sendgrid', {
  apiKey: process.env.SENDGRID_KEY!,
  from: '[email protected]',
});

try {
  const result = await sendgrid.send({
    to: '[email protected]',
    subject: 'Hello from @thinwrap/notifications',
    html: '<p>It works.</p>',
  });
  console.log(result.success, result.providerMessageId);
} catch (e) {
  if (e instanceof ConnectorError) {
    console.error(e.providerCode, e.providerMessage);
  } else throw e;
}

Switching providers

Change the provider ID and config; everything else stays.

const mailgun = new Email('mailgun', {
  apiKey: process.env.MAILGUN_KEY!,
  domain: 'mg.example.com',
  from: '[email protected]',
});

await mailgun.send({ to: '[email protected]', subject: 'Hi', text: 'It works.' });

Provider IDs

Provider IDs are typed string literals — typos fail to compile. Prefer-enum codebases can use the equivalent EmailProviderIdEnum / SmsProviderIdEnum / PushProviderIdEnum / ChatProviderIdEnum exports interchangeably (new Email(EmailProviderIdEnum.Sendgrid, …)). Each connector also exposes connector.id for runtime introspection. Per-channel construction and the config shape each provider takes are documented in its per-connector README.

Bring your own fetch

import { Email } from '@thinwrap/notifications';
import undici from 'undici';

const email = new Email('sendgrid', {
  apiKey: process.env.SENDGRID_KEY!,
  from: '[email protected]',
  fetch: undici.fetch,           // any fetch-compatible function
});

The injected fetch does not isolate you from the host

Contract: a non-2xx must be RETURNED as a Response, not thrown — that is plain fetch semantics, and it is what lets each connector map the status to a providerCode (429 → rate_limited, 401 → auth_failed, …).

On Node, both globalThis.fetch and undici.fetch dispatch through undici's process-global dispatcher, so whatever the application installed applies to your calls too — including a responseError() interceptor, under which a non-2xx rejects instead of resolving. The library detects that and rebuilds the Response, so classification still works. It cannot repair one thing: if the provider gzipped the error body, that interceptor buffers it below fetch's content-decoding and the bytes are destroyed before any library sees them, so the vendor's message is lost.

To make your calls genuinely independent of the host's configuration, pass an explicit dispatcher instead of relying on the global one:

import { Agent, fetch as undiciFetch } from 'undici';

const isolated = new Agent();                      // no inherited interceptors
const fetchImpl = ((url, init) =>
  undiciFetch(url as string, { ...init, dispatcher: isolated })) as typeof fetch;

The wrapper holds no state — no token cache, no connection pool, no retry buffer. FCM and APNs sign tokens fresh on every send by default; supply tokenCache?: TokenCacheHook in config to reuse them. For FCM it amortizes the OAuth exchange. For APNs it is required in production: APNs rejects a provider token that changes more than once every 20 minutes (429 TooManyProviderTokenUpdates). See the FCM and APNs READMEs for hook-shape detail.

Error handling

import { ConnectorError } from '@thinwrap/notifications';

try {
  await sendgrid.send(input);
} catch (e) {
  if (e instanceof ConnectorError) {
    switch (e.providerCode) {
      case 'rate_limited':         /* respect Retry-After in e.cause     */ break;
      case 'auth_failed':          /* rotate credentials                  */ break;
      case 'invalid_request':      /* fix payload                         */ break;
      case 'invalid_recipient':    /* clean address                       */ break;
      case 'provider_unavailable': /* transient 5xx — your retry strategy */ break;
      case 'unknown':              /* fallback                            */ break;
    }
  } else throw e;
}

What is a ConnectorError. Every vendor error response, every transport failure, and every request the connector rejects before sending — bad request data, and facade/config misuse such as a missing config, an unknown provider id, an unknown Twilio region/edge, or an FCM/APNs privateKey that cannot sign (auth_failed). Errors thrown by code you inject — a tokenCache hook, or a connector class constructed directly with null config — propagate unchanged.

The wrapper performs no automatic retry. Compose your own retry strategy from providerCode and the raw vendor response carried on e.cause (including the Retry-After header where the vendor sets one).

Transport failures carry no message detail, by design

When the request never reaches the provider, e.message is a fixed Network error (or Request cancelled), not the underlying error's text. For this package the request URL is frequently the credential itself — Telegram's /bot<token>/… path, and every webhook provider (Slack, Discord, Mattermost, MS Teams, Google Chat, Rocket.Chat) — and Node embeds that URL in the error message it raises, so any logger recording e.message would capture the secret (CWE-532).

The diagnostic you actually want is preserved: read e.cause.raw.code for the failure identifier (ECONNRESET, UND_ERR_SOCKET, …), which names the failure more precisely than the prose ever did. e.name is preserved too, so cancellation detection still works.

_passthrough escape valve

When the normalized input doesn't expose a vendor-specific field, forward arbitrary keys via _passthrough:

await sendgrid.send({
  to: '[email protected]',
  subject: 'Hi',
  html: '<p>fallback</p>',
  _passthrough: {
    body: { dynamic_template_data: { name: 'Alice', orderId: '12345' } },
    headers: { 'X-Custom-Header': 'value' },
  },
});

Keys are forwarded verbatim — no casing transformation. See each per-connector README for vendor-specific _passthrough examples.

Bring your own connector

When _passthrough isn't enough — the provider isn't shipped at all — implement the channel's exported connector interface (IEmailConnector / ISmsConnector / IPushConnector / IChatConnector) and pass the instance to the facade constructor. The contract is id, channelType, and send(). You keep the normalized input/result shapes and the uniform error-handling path; only the wire call is yours.

import { Push, ChannelTypeEnum } from '@thinwrap/notifications';
import type { IPushConnector, PushSendInput, PushSendResult } from '@thinwrap/notifications';

class NtfyPushConnector implements IPushConnector {
  readonly id = 'ntfy';
  readonly channelType = ChannelTypeEnum.PUSH;

  async send(input: PushSendInput): Promise<PushSendResult> {
    const res = await fetch(`https://ntfy.sh/${input.to}`, {
      method: 'POST',
      headers: input.title ? { Title: input.title } : undefined,
      body: input.body ?? '',
    });
    return {
      success: res.ok,
      status: res.ok ? 'sent' : 'rejected',
      providerMessageId: null,
      raw: await res.json(),
    };
  }
}

const push = new Push(new NtfyPushConnector());
await push.send({ to: 'deploys', title: 'Deploy', body: 'v1.0 is live' });

Throw ConnectorError from send() for hard failures so consumers keep a single error-handling path; return success: false for HTTP-2xx-but-rejected soft-rejects, matching the built-in connectors.

Language constraints

  • Node.js ≥18 required (uses native fetch).
  • Node 18, 19, and 20 emit an ExperimentalWarning: The Fetch API is an experimental feature on first fetch use. This is an upstream Node disclosure, not a @thinwrap/notifications warning — fetch became stable (warning removed) in Node 21.0. fetch is functionally identical across Node 18–26 for Thinwrap's GET/POST/JSON usage. Set NODE_NO_WARNINGS=1 or use --no-warnings to suppress on 18/19/20, or upgrade to Node 21+.
  • Ships dual-build: ESM (import) and CJS (require). Full TypeScript types.
  • Zero runtime dependencies. AWS Signature V4 (SES + SNS connectors) is hand-rolled against node:crypto. No vendor SDKs.
  • Server-only. Browser support is not in v1.0 — most providers require server-only secrets.

Public API surface (locked at v1.0)

| Category | Exports | |---|---| | Facades | Email, Sms, Push, Chat | | Errors | ConnectorError, type ProviderCode | | Provider-ID + channel enums | EmailProviderIdEnum, SmsProviderIdEnum, PushProviderIdEnum, ChatProviderIdEnum, ChannelTypeEnum | | Status enums | EmailEventStatusEnum, SmsEventStatusEnum, PushEventStatusEnum, CheckIntegrationResponseEnum | | Connector interfaces (BYO) | IEmailConnector, ISmsConnector, IPushConnector, IChatConnector | | Input / result types | EmailSendInput/EmailSendResult, SmsSendInput/SmsSendResult, PushSendInput/PushSendResult, ChatSendInput/ChatSendResult, EmailAttachment, TokenCacheHook | | Base + passthrough | BaseConnector, CasingEnum, transformKeys, mergePassthrough; types ProviderConfigMap, EmailProvider, SmsProvider, PushProvider, ChatProvider | | Connectors & config types | one <Provider><Channel>Connector class + <Provider>Config per provider — see the index below |

Per-connector documentation

Each per-connector README documents auth method, regional/sandbox endpoints, narrowed input augmentations, outlier translations, error-code mappings, and _passthrough examples.

Email (10)

| Provider | README | |---|---| | ses | src/providers/ses/README.md | | resend | src/providers/resend/README.md | | mailgun | src/providers/mailgun/README.md | | sendgrid | src/providers/sendgrid/README.md | | postmark | src/providers/postmark/README.md | | mailersend | src/providers/mailersend/README.md | | mailtrap | src/providers/mailtrap/README.md | | brevo | src/providers/brevo/README.md | | sparkpost | src/providers/sparkpost/README.md | | scaleway | src/providers/scaleway/README.md |

SMS (10)

| Provider | README | |---|---| | nexmo (Vonage) | src/providers/vonage/README.md | | twilio | src/providers/twilio/README.md | | plivo | src/providers/plivo/README.md | | sns | src/providers/sns/README.md | | sinch | src/providers/sinch/README.md | | telnyx | src/providers/telnyx/README.md | | infobip | src/providers/infobip/README.md | | messagebird | src/providers/messagebird/README.md | | textmagic | src/providers/textmagic/README.md | | d7networks | src/providers/d7networks/README.md |

Push (6)

| Provider | README | |---|---| | fcm | src/providers/fcm/README.md | | expo | src/providers/expo/README.md | | apns | src/providers/apns/README.md | | one-signal | src/providers/one-signal/README.md | | pusher-beams | src/providers/pusher-beams/README.md | | wonderpush | src/providers/wonderpush/README.md |

Chat (9)

| Provider | README | |---|---| | telegram | src/providers/telegram/README.md | | slack | src/providers/slack/README.md | | whatsapp-business | src/providers/whatsapp-business/README.md | | discord | src/providers/discord/README.md | | ms-teams | src/providers/ms-teams/README.md | | google-chat | src/providers/google-chat/README.md | | mattermost | src/providers/mattermost/README.md | | rocket-chat | src/providers/rocket-chat/README.md | | line | src/providers/line/README.md |

Migrating

From a vendor SDK

Replace SDK construction with the facade; replace SDK method calls with .send(...):

// Before — @sendgrid/mail
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_KEY!);
await sgMail.send({ to, from, subject, html });

// After
import { Email } from '@thinwrap/notifications';
const email = new Email('sendgrid', { apiKey: process.env.SENDGRID_KEY!, from });
await email.send({ to, subject, html });

From @novu/providers

The connector classes are shape-compatible with Novu's provider interfaces (IEmailProvider, ISmsProvider, IPushProvider, IChatProvider). You can use the facade or instantiate the connector class directly:

// Before
import { SendgridEmailProvider } from '@novu/providers';
const sg = new SendgridEmailProvider({ apiKey, from });

// After
import { Email } from '@thinwrap/notifications';
const sg = new Email('sendgrid', { apiKey, from });

See MIGRATION.md for the full recipe (sed-across-codebase, return-shape delta, edge cases).

From raw HTTP

If you've been hand-rolling vendor HTTP calls, the facade collapses the boilerplate to one line per send. Error handling and retry composition stay yours.

For AI agents and contributors

Security

Report vulnerabilities privately — please do not open a public issue. Preferred: a private security advisory on this repository. Alternatively, email [email protected]. Include the affected versions and a minimal reproduction if you have one.

A vulnerability in a provider's own API or service belongs to that vendor rather than to this wrapper — please report those upstream.

Supply chain: releases are published to npm via GitHub Actions OIDC trusted-publishing with Sigstore provenance attestation — verify an installed copy with npm audit signatures @thinwrap/notifications@<version>. No long-lived npm publish token exists in this repository or its CI secrets, the publishing account requires two-factor authentication (TOTP, with npm's auth-and-writes setting), and CI consumes no external reusable workflows.

License

MIT — see LICENSE.