Real-time event bridge: Gateway to SSE
This is the one-way pipeline inside the discord service that carries what happens in the Discord guild out to a browser in real time. Discord's gateway pushes an event, and moments later an EventSource in the frontend receives a small JSON object describing it. Nothing flows back along this path — it is a read-only feed.
The pipeline has four stages: gateway client → in-process bus → SSE hub → HTTP stream route.
The gateway client
BotClient wraps a single discord.js Client. It logs in with one bot token and declares the gateway intents it needs — guilds, guild messages, message content, members, presences, reactions, typing, and voice states. On top of the raw discord.js callbacks it keeps its own per-event lists of handlers, and registers one gateway listener each for message create / update / delete, reaction add / remove, channel update, member add / remove, presence update, thread create / update, typing start, and channel pins update.
Each gateway callback is normalized before it reaches a handler: the pins-update listener drops anything that is not a text channel, the typing listener flattens Discord's typing object down to { channelId, user, startedTimestamp }, and the rest are passed through with tidy types. Handlers run through a shared runHandlers guard that catches both a synchronous throw and a rejected promise and logs it — one misbehaving handler never takes down the gateway connection.
The event contract
What travels through the rest of the pipeline is not a discord.js object but a plain, serializable DiscordEvent — a discriminated union with fourteen variants, keyed on a type string: message.created / message.updated / message.deleted, reaction.added / reaction.removed, pin.added / pin.removed, typing.started, presence.updated, channel.updated, member.added / member.removed, thread.created / thread.updated. Most variants carry a channelId; the message events carry a full ChatMessage. This union is the single wire format the frontend has to understand.
The in-process bus
MessageBus is deliberately tiny: a Set of listener functions, a publish that calls each one (catching and logging any listener error), and a subscribe that returns an unsubscribe closure. There is no Redis, no cross-pod fan-out. The reason is written into the code as a comment and is a hard constraint, not a shortcut: Discord's gateway allows exactly one WebSocket per bot token, so the service is intentionally single-pod. Because only one process ever holds the gateway connection, an in-memory bus is sufficient — and a distributed pubsub would be solving a problem that cannot occur.
The SSE hub
SseHub subscribes to the bus on startup and holds the set of connected browsers. When a client connects it gets a nanoid id, the SSE response headers (text/event-stream, no-cache, keep-alive, and X-Accel-Buffering: no so a proxy does not buffer the stream), and an opening comment line. A heartbeat comment is written every 25 seconds to keep the connection and any intermediary from timing out the idle stream; when the client closes, the heartbeat is cleared and the client is removed.
Per-channel filtering lives here. Each client optionally carries a set of channel ids it cares about; a null set means "all channels." On fanOut, the hub derives the event's target channel (from the message's channelId, or from a channelId field on the event, or nothing for guild-wide events like presence and membership), serializes the event once as a data: frame, and writes it only to clients whose filter matches — while events with no channel go to everyone. A write that fails drops that client on the spot.
The stream route
A single Fastify route, GET /discord/v1/stream, is the public mouth of the pipeline. It reads an optional comma-separated channelIds query, splits it into the filter set, hands the reply to the hub's connect, and then returns a promise that never resolves — that is what holds the HTTP request open. The hub, not the route, ends the response when the browser disconnects.
Why it is shaped this way
The three seams — gateway client, bus, hub — each do one job and know nothing of the others' internals. The gateway client only knows how to turn Discord callbacks into typed handlers; the bus only knows how to hand an event to whoever subscribed; the hub only knows how to keep browsers connected and filter what each one sees. The single-pod fact is not an accident of scale but a property of Discord's own gateway, and the whole design leans on it.