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

@agentoria/notifykit

v0.3.1

Published

Notification delivery for TypeScript apps — bot webhooks, email, web push, SMS and an inbox behind one contract, with a per-recipient category × channel matrix.

Readme

@agentoria/notifykit

Notification delivery for TypeScript apps. Bot webhooks, email, browser push, SMS and an in-app inbox behind one contract — plus the per-recipient category × channel matrix that decides which of them a message may use.

Runs on Node, Cloudflare Workers, Deno and the browser: fetch and WebCrypto only, no Buffer, no node:crypto, no dependencies.

npm i @agentoria/notifykit

Why the obvious contract isn't the contract

The tempting model is "a channel is a pure function from a message to one HTTP request". It's a good model — a provider becomes ~20 lines and is testable without a network — and it fits exactly one of the five channel families:

| Family | Destination | Payload | Writes back? | |---|---|---|---| | Bot webhook (Feishu, Telegram, Slack, …) | one URL the user pasted | free-form JSON | no | | Email | N addresses, chunked (50/call) | subject + text + html | no | | Browser push | N device subscriptions your app stores | encrypted, VAPID-signed | yes — 404/410 means delete it | | SMS | one phone number | a pre-approved template code — free text is not sendable in CN | no | | Inbox | your own database | a row | it is a write |

Two capabilities break the single-request model: fan-out over destinations the host owns, and write-back. So plan takes the destinations and returns requests, plural, and dead endpoints come back as stale for you to delete — a transport never learns what a database is.

Three layers

import { render, deliver, feishu } from "@agentoria/notifykit";

const rendered = render(
  { category: "failed", title: "Build failed", body: "3 tests red", url: "https://app/run/42" },
  feishu.capabilities,
);
const result = await deliver(feishu, { webhookUrl: process.env.FEISHU_HOOK! }, rendered);

if (result.stale?.length) await dropSubscriptions(result.stale);
if (!result.ok && result.retryable) await myQueue.retryLater();
  • render — pure and capability-aware. Truncation and escaping are decided once per transport instead of re-derived by every provider: Server酱's 32-character title is a declared capability, not a surprise from the API. A transport with a URL field gets the link raw and a body that doesn't repeat it; an inline one gets it appended, with the body trimmed to make room — a verbose body must not be what costs the reader the actionable part.

  • plan — pure, returns requests. Feishu and DingTalk HMAC here; that's still not I/O, so every transport is unit-testable without a fetch mock. The clock, the nonce source and the phone normaliser arrive as a PlanContext parameter rather than being read from globals, so a fixed input has a fixed output and asserting on a signature needs no fake timers:

    await dingtalk.plan(cfg, rendered, [], planContext({ now: () => 1_767_322_000_000 }));
  • deliver — the only layer that touches the network. It never throws, and it classifies without scheduling: retryable says whether another attempt could work; when to retry is yours, because you know whether you have a durable queue or a cron loop.

When a channel fans out, any destination succeeding is success. Reaching four of five devices delivered the notification; reporting failure would make you retry and duplicate on the four that worked.

Severity

severity says how loudly, where category says what happened — they vary independently, since an app can have four categories and only two worth waking someone for.

render({ category: "failed", title: "Build failed", body: "…", severity: "urgent" }, ntfy.capabilities);

Transports with a native notion of it map it — Feishu card colour, Discord embed colour, ntfy priority, Gotify priority, Bark interruption level — and the rest ignore it. urgent is the only level above the default on purpose: push services grade priority coarsely, and a scale with five names invites callers to pick one at random. The question is "should this interrupt someone", which is a yes or a no. The mappings are the conservative reading of each API: ntfy urgent is 4 rather than 5, because 5 bypasses Do Not Disturb and that is the user's setting to make, not yours.

Channels

feishu · telegram · slack · discord · dingtalk · wecom · ntfy · bark · serverchan · gotify · email (Resend) · webpush (RFC 8291 aes128gcm + VAPID) · sms (Aliyun templates) · sms_twilio

Each declares its config fields, so your settings UI renders itself:

import { TRANSPORTS, getTransport, validateConfig, redactConfig } from "@agentoria/notifykit";

const fields = getTransport("telegram")!.fields;      // → drives the form
validateConfig(telegram, config);                      // → a message, or null
redactConfig(telegram.fields, stored);                 // → botToken: "••••CRET"

Redaction is an allowlist over the field declarations, not a denylist of key names — a field added later is masked by default, and an undeclared key is dropped rather than echoed.

Adding one

const myBot: Transport = {
  id: "mybot",
  title: "My bot",
  capabilities: { richText: "markdown", link: "inline" },
  fields: [{ key: "url", label: "Webhook URL", secret: true, required: true, url: true }],
  async plan(cfg, r, dests) {
    return [{ destinationId: dests[0]?.id ?? "self", url: cfg.url, method: "POST",
              headers: { "content-type": "application/json" }, body: JSON.stringify({ text: r.body }) }];
  },
};

Two optional hooks exist for what a pure plan can't express:

  • validate(cfg) — cross-field checks, run before anything is stored.
  • interpret(status, body) — for a provider whose success envelope looks like an error. Bark answers {"code":200}; rather than bend the shared rule around one vendor, that transport judges its own responses.

The matrix

Which categories a recipient accepts on which channels — "marketing may go to SMS and email, nothing else".

import { route, applyPreference, type Category } from "@agentoria/notifykit";

const failures: Category = { id: "failed", title: "Failures", policy: "transactional" };
const offers:   Category = { id: "promo",  title: "Offers",   policy: "marketing" };

route(matrix, failures, { available: ["inbox", "email", "feishu"] });  // → ["inbox"] by default
route(matrix, offers,   { available: ["inbox", "email", "sms"] });     // → [] until consented

Three things about it are not obvious, and each is a bug the naive version ships with:

  1. Defaults are a function of (category, channel), not a constant. Account events are opt-in — don't email someone who never asked. Operator broadcasts are opt-out — a maintenance notice reaches everyone unless they left. One global default cannot express both.
  2. Storage is sparse. Only cells the recipient explicitly toggled are stored (toRows / fromRows); the rest resolve through the defaults, so defaults can change without a migration.
  3. Marketing is not just another category. Consent for marketing over SMS and email is a legal requirement in several jurisdictions, so a marketing category is refused on any channel without an explicit stored opt-in — whatever the defaults say — while an explicit false is kept rather than pruned, because silence and refusal differ when the question is legal rather than a preference. This is the one rule here deliberately not configurable.

UI: headless first

@agentoria/notifykit/headless has the logic with no framework and no markup — which fields to show, when a secret may be left blank (editing keeps the stored value, creating cannot), whether a cell is explicitly set or merely defaulted, which cells are consent rather than preference. Those are the rules that have bugs in them.

import { initChannelForm, channelFormStatus, buildMatrixGrid, toggleCell } from "@agentoria/notifykit/headless";

@agentoria/notifykit/react is ~200 lines of unstyled markup over it, taking every class name from props, so it drops into your design system without a fork:

import { ChannelForm, PreferenceMatrix } from "@agentoria/notifykit/react";

<ChannelForm transports={TRANSPORTS} onSubmit={save} classes={{ input: "my-input", submit: "my-btn" }} />
<PreferenceMatrix matrix={matrix} categories={categories} channels={channels} onChange={setMatrix} />

What it doesn't own

Storage · tenancy (a recipient is an opaque id — an org in one app, a user in another) · scheduling and retry timing · translation catalogs · the inbox implementation (an InboxSink interface, since the table is yours).

Its whole surface is: given a message, a category, some destinations and a matrix — which channels, and what HTTP requests.

Browser push

The subscriptions live in your tables; hand them in as destinations.

import { generateVapidKeys, subscriptionDestination, webpush, deliver, render } from "@agentoria/notifykit";

const { publicKey, privateKey } = await generateVapidKeys();  // once, then store

const result = await deliver(
  webpush,
  { vapidPublicKey: publicKey, vapidPrivateKey: privateKey, vapidSubject: "mailto:[email protected]" },
  render(message, webpush.capabilities),
  rows.map(subscriptionDestination),
);
await Promise.all((result.stale ?? []).map(deleteSubscription));   // 404/410 → gone

Endpoints are checked against the known push services before anything is sent: a stored subscription endpoint is attacker-influenced data, and refusing anything else keeps a row from becoming an SSRF primitive years later.

SMS

Chinese carriers only deliver pre-approved templates, so the transport maps a category to a template code and fills it from message.data:

{ signName: "Acme", templates: "promo=SMS_123456\nsecurity=SMS_654321", … }

A category with no mapped template sends nothing rather than something the carrier would reject. Twilio takes free text and is a separate transport rather than a flag on this one.

Numbers, and which carrier gets them

Numbers are canonicalised to E.164 before anything is sent — otherwise 13800138000 and +8613800138000 are one person counted as two destinations, and every stale/sent tally keyed on the id is quietly wrong. Aliyun still receives the bare domestic number it requires; the destinationId stays canonical, because you track people rather than one carrier's URL quirk.

The built-in normaliser is a shape check, not libphonenumber: it cannot tell you a well-formed number is unassigned or a landline. If that matters, inject the real thing rather than have this package grow an 82 KB metadata table for two of its fourteen transports:

import parsePhoneNumber from "libphonenumber-js";
deliver(sms, cfg, rendered, dests, {
  context: { normalizePhone: (s) => parsePhoneNumber(s, "CN")?.number ?? null },
});

Routing between carriers is a separate function on purpose — no library can decide it, because it depends on the contracts you signed:

import { partitionPhones } from "@agentoria/notifykit";

const { sms, sms_twilio, unroutable } = partitionPhones(phones, { aliyun: true, twilio: true });

+86 prefers Aliyun, everything else prefers Twilio, and each falls back to the other — except into mainland China, which is refused unless you pass allowTwilioToChina. Delivery there over an international carrier needs registered templates and sender ids, so the usual result is a silent non-delivery you still pay for. Those numbers come back in unroutable instead, where you can see them.

License

MIT