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

nuxt-pigeon

v1.0.0-beta.1

Published

Send and receive messages across Telegram, Slack, Discord and any webhook.

Downloads

32

Readme

// server/api/contact.post.ts
await telegram.send('New contact request from Flo')

That is the whole setup. No client to construct, no token to pass around, no import to write.

What you get

Four verbs, seven services. send, listen, edit, delete. They look the same everywhere, even though one service wants multipart, the next wants an upload first, and the third wants a blob reference.

Receiving does not care how. Telegram delivers by webhook, Bluesky has to be polled, Slack has a three second deadline to answer. You write one handler and get the same shape back.

What arrives in the frontend, arrives. One composable, no polling loop of your own.

Nothing is swallowed. Every send hands back the service's own answer, body and headers included. Where a service cannot do something, you get a sentence that says why, not silence.

Setup

npx nuxt module add nuxt-pigeon
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-pigeon'],
  nuxtPigeon: {
    channels: {
      telegram: { receive: true },
      discord: true,
    },
  },
})
# .env
PIGEON_TELEGRAM_BOT_TOKEN=123456:ABC…
PIGEON_TELEGRAM_CHAT_ID=987654321
PIGEON_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/…

Secrets live in .env, never in nuxt.config.ts. Every value can also be set in the config if it is not a secret, and every call can override it.

Sending

Each channel is auto imported on the server. No import, no setup.

// server/api/deploy.post.ts
export default defineEventHandler(async () => {
  await discord.send('Deploy failed on `main`', {
    embeds: [{ title: 'Build #42', color: 0xd4262a }],
    media: [{ url: 'https://example.com/log.png', alt: 'The failing step' }],
  })
})

Everything the service accepts is reachable. Discord embeds, Slack blocks, Telegram keyboards, ntfy priorities and actions, Mastodon content warnings, Bluesky link cards. We add the parts that are easy to get wrong: character limits counted the way the service counts them, escaping, byte offsets, media fetched and uploaded for the services that take no url.

Images

The same option everywhere, either a url or bytes:

await mastodon.post('Release 1.0', { media: [{ url: 'https://…/shot.png', alt: 'The new panel' }] })
await telegram.send('Look', { media: [{ data: pngBytes, filename: 'shot.png' }] })

Underneath these are four different mechanisms. Telegram is handed the url and fetches it itself, Discord gets multipart, Mastodon uploads first and waits for processing, Bluesky wants raw bytes, Slack needs three requests. You do not have to know that.

Changing and removing

Sending returns a handle. Pass it back:

const msg = await discord.send('Deploy running…')
await discord.edit(msg, 'Deploy finished')
await discord.delete(msg)

The handle is a small plain object (id, and whatever else the service needs), so you can store it in your database and rebuild it tomorrow.

Receiving

Register once at startup, from a Nitro plugin:

// server/plugins/pigeon.ts
export default defineNitroPlugin((nitro) => {
  const stop = telegram.listen(async (update) => {
    if (update.message?.text === '/status') {
      await telegram.send('running', { chatId: update.message.chat.id })
    }
  })

  nitro.hooks.hook('close', stop)
})

The route, the signature check and the transport are handled. Telegram verifies its secret token, Slack its signing secret and answers the challenge within its deadline, Mastodon and Bluesky are polled by a hot reload safe poller.

Your handler gets the service's own payload, fully typed and with nothing removed.

In the browser

<script setup>
const { messages, connected } = usePigeon()
</script>

<template>
  <p v-for="m in messages" :key="m.at">{{ m.channel }}: {{ m.text }}</p>
</template>

A server sent event stream, one connection, closed automatically when the component goes away. Normalised fields (text, from, conversation) sit next to the untouched raw and body.

What each channel can do

| | send | media | edit | delete | receive | | -------- | ---- | ----- | ---- | ------ | ------------- | | Telegram | ✅ | ✅ | ✅ | ✅ | ✅ webhook | | Discord | ✅ | ✅ | ✅ | ✅ | — | | Slack | ✅ | ✅ ¹ | ✅ ¹ | ✅ ¹ | ✅ Events API | | ntfy | ✅ | ✅ | ✅ ² | ✅ ² | — | | Mastodon | ✅ | ✅ | ✅ | ✅ | ✅ polling | | Bluesky | ✅ | ✅ | ❌ ³ | ✅ | ✅ polling | | Webhook | ✅ | — | — ⁴ | — ⁴ | ✅ route |

¹ needs a bot token. With only an incoming webhook Slack answers ok and no message id, so there is nothing to point at afterwards. ² needs an ntfy server of 2.16.0 or newer. ³ Bluesky has no post editing. putRecord answers with a 200 and the appview ignores the change, so an edit would look like it worked and do nothing. It is not offered rather than offered and broken. ⁴ you decide what the receiver is, so send carries method and url and a PATCH or DELETE is one call.

Where a channel cannot do something, the method is missing from its type, so you find out while typing rather than in production. Calling it anyway gets a sentence naming the reason.

Generic webhooks

Point it at anything and it sends what you give it, adding nothing:

nuxtPigeon: {
  webhook: {
    endpoints: {
      n8n: { headers: { 'X-Source': 'nuxt' } },   // url from PIGEON_WEBHOOK_N8N_URL
    },
  },
}
await webhook.send({ event: 'deploy', ok: true }, { to: 'n8n' })

With a secret it signs to Standard Webhooks, so the other side can verify with an existing library instead of reading our docs. Incoming requests are verified the same way.

Reliability

Retry with growing delays for 5xx, Retry-After respected where the service sends one, and read from the body where a service puts it there instead (Telegram does). Network errors are not retried by default: without an answer there is no way to know whether the message already arrived, and sending it twice is worse than not knowing.

Errors never carry a token. For Discord and Slack the url itself is the credential, so errors name the target instead of the address.

Things that will cost you an hour if nobody tells you

  • Mentions are written differently everywhere. Discord @here, Slack <!here>. A @here copied from Discord into Slack sits there as dead text and notifies nobody, without an error.
  • Telegram lowers its own limit with an image. 4096 characters for text, 1024 for a caption. The same string can fit before an edit and be too long after one.
  • Bluesky counts graphemes and bytes. 300 and 3000, and neither is String.length. A blob is limited to about 1 MB, which a normal screenshot exceeds.
  • Discord drops attachments on an edit that does not name them. We send them again unless you say media: [].
  • Server sent events do not arrive through a Cloudflare quick tunnel. The connection opens and stays empty, which looks exactly like a broken server. Use the tunnel for the incoming side and open the page on localhost.

Nuxt compatibility

Nuxt 4, Node 22 or newer. The server side runs on Nitro, the composable is the only part in the browser.

Contributing

pnpm install
pnpm dev            # the playground, with every channel to click through
pnpm test           # vitest, no network
pnpm test:types
pnpm lint

License

MIT