In the browser

Show incoming Telegram, Slack and webhook messages live in your Nuxt frontend with one composable. Server sent events, no polling and no refresh button.

A message reaches your server. Getting it from there onto a page is usually the part nobody wants to build: an endpoint, an interval, a list, a way to stop it again.

It is one composable.

Copy this

Nothing to turn on

In development the stream is already running. You only touch this to switch it on for production, which is a decision rather than a default:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-pigeon'],
  nuxtPigeon: {
    channels: {
      telegram: { receive: true },
    },
  },
})

Use it

app/pages/inbox.vue
<script setup lang="ts">
const { messages, connected } = usePigeon()
</script>

<template>
  <p>Stream is {{ connected ? 'open' : 'closed' }}</p>

  <ul>
    <li v-for="message in messages" :key="message.at">
      <strong>{{ message.channel }}</strong>
      {{ message.text }}
    </li>
  </ul>
</template>

Write to your bot

The list grows while you watch it. Nothing is fetched, nothing polls.

What just happened

  • Server sent events, not a websocket. One direction is all this needs, and it reconnects on its own when the connection drops.
  • One connection per page, however many components ask. Ten components calling usePigeon() share a single stream.
  • The connection closes itself when the component goes away, and survives a hot reload without leaving a second one behind.

Only one channel

const { messages } = usePigeon({ channel: 'telegram', limit: 20 })

The server sends every channel down the same stream and the filtering happens in the browser. That is deliberate: a browser allows about six connections per host over HTTP/1.1, and nuxt dev speaks HTTP/1.1. A page with one stream per channel would open eight, and the last two would hang with nothing to see and nothing in the log.

What you get

Every message has the same shape, whichever channel it came from:

FieldWhat it is
channeltelegram, slack, webhook, …
atISO timestamp of when it reached the server
textThe readable part, where the channel has one
fromSender, { id, name }, where the channel says
typeThe event name in the channel's own words
bodyThe parsed payload, untouched
conversationThe chat, channel or thread it belongs to
headersEvery header, untouched
rawThe body as it arrived, before parsing

The first six are ours and are the same everywhere. The last three are the service's, handed over unchanged, because we cannot know what you need from them.

text is filled where a channel knows what its text is. A generic webhook has no such concept, so there it stays empty and body is where you look.

What will cost you an hour

  • Nothing arrives through a development tunnel. A Cloudflare quick tunnel throttles server sent events, and it looks exactly like a broken server. Open the page on localhost and keep the tunnel for the incoming side. The tunnel page has the details and the quotes.
  • connected being true is not enough. A poller on the server and the stream to the page fail separately. Both have to work, and the playground shows both for that reason.
  • In production the stream is off. On purpose: every open tab would otherwise get every message, and messages can be personal data. Switching it on means guarding the route yourself.
    nuxt.config.ts
    nuxtPigeon: {
      stream: { enabled: true },
    }
    
  • A named event would never fire onmessage. If you build your own client against the route rather than using the composable: the channel travels inside the payload, not as an event name, so a plain EventSource onmessage handler catches everything.

Read more