gateway-to-sse-bridge

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.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →