In the browser
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:
export default defineNuxtConfig({
modules: ['nuxt-pigeon'],
nuxtPigeon: {
channels: {
telegram: { receive: true },
},
},
})
Use it
<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:
| Field | What it is |
|---|---|
channel | telegram, slack, webhook, … |
at | ISO timestamp of when it reached the server |
text | The readable part, where the channel has one |
from | Sender, { id, name }, where the channel says |
type | The event name in the channel's own words |
body | The parsed payload, untouched |
conversation | The chat, channel or thread it belongs to |
headers | Every header, untouched |
raw | The 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
localhostand keep the tunnel for the incoming side. The tunnel page has the details and the quotes. connectedbeing 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 plainEventSourceonmessagehandler catches everything.
Read more
Development with a tunnel
Telegram and Slack cannot reach localhost. Nuxt dev --tunnel fixes that, and one surprise about Cloudflare quick tunnels will cost you an evening otherwise.
Telegram
Send and receive Telegram messages in Nuxt: bot token, chat id, photos, inline keyboards, editing, deleting, and updates by webhook instead of long polling.