<- Documentation - WebSocket Events
The WebSocket gateway uses a shared NATS subscription model. Rather than each connection creating its own NATS subscriptions, a centralized Hub manages one NATS subscription per unique topic and fans messages out to all local connections in-memory.
┌─────────────────────────────────────────────┐
│ WS Server Process │
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │Conn1│ │Conn2│ │Conn3│ ... (clients) │
│ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │
│ ┌──▼────────▼────────▼──┐ │
│ │ Subscriber │ per-connection │
│ │ (key → topic map) │ subscription │
│ └──────────┬────────────┘ tracking │
│ │ │
│ ┌──────────▼────────────┐ │
│ │ Hub │ shared NATS │
│ │ (topic → [conns]) │ subscription │
│ └──────────┬────────────┘ management │
│ │ │
│ ┌──────────▼────────────┐ │
│ │ NATS Client │ │
│ └───────────────────────┘ │
└─────────────────────────────────────────────┘
| Topic Pattern | Subscribed When | Events Delivered |
|---|---|---|
user.{userId} |
Hello (automatic) | Read state, settings, friend events, DMs, direct mentions, joined-thread activity, user updates, VoiceMove |
guild.{guildId} |
Hello (automatic for all guilds) + OP 5 | Guild/channel/role/member/voice events |
channel.{channelId} |
OP 5 Channel Subscription | Messages, typing indicators, channel-specific events |
presence.user.{userId} |
OP 6 Presence Subscription | Presence status changes for watched users |
After authentication, the server subscribes the connection to:
user.{userId}— personal events.guild.{guildId}for every guild the user is a member of.
These are registered using the guild ID as both the key and topic identifier.
Client subscribes to a specific set of channels for real-time typing and message events:
{ "op": 5, "d": { "channels": [123, 124], "guilds": [456] } }Channel subscription semantics:
channelsis the exact channel-subscription set for this connection.- Sending
channels: []unsubscribes the client from all channel topics. - To receive events in both a parent channel and its thread, the client must include both IDs in
channels. - The legacy
channelfield is still accepted as a one-channel fallback for older clients.
Channel permission check order:
- Is it a guild channel? → Check
PermServerViewChannels. - Is it a DM? → Check user is a participant.
- Is it a Group DM? → Check user is a participant.
- None match → subscription rejected (silent, logged server-side).
For guild IDs in the guilds array, the server verifies the user is a member before subscribing.
Important
Thread subscriptions are explicit. Subscribing to a parent guild text channel does not subscribe the client to channel.{threadId} topics. A client must subscribe to the thread channel itself to receive thread message create/update/delete events.
Important
Joining a thread and subscribing to a thread are different things. Joining controls unread / notification behavior for that user. OP 5 channels controls whether this specific connection receives the live thread message stream.
Client manages a set of user IDs to watch for presence changes:
| Operation | Behavior |
|---|---|
clear: true |
Unsubscribe from all presence topics |
set: [ids] |
Replace entire watch list (unsubscribe all, then subscribe to new set) |
add: [ids] |
Add users to watch list (skip if already watching) |
remove: [ids] |
Stop watching specific users |
On every add or set operation, the server immediately sends a presence snapshot (current cached status) for each newly-watched user, so the client doesn't have to wait for the next status change.
Hub.Register(conn, topic):
1. Lock hub
2. If topic exists → add conn to topic's conn set → done
3. If topic is new:
a. Create topicEntry with conn
b. Create shared NATS subscription
c. NATS callback: for each conn in set → conn.Send(msg.Data)
Hub.Unregister(conn, topic):
1. Remove conn from topic's conn set
2. If set is now empty:
a. Delete topic from hub
b. Unsubscribe shared NATS subscription
Hub.UnregisterAll(conn):
1. Scan all topics for this conn
2. Call Unregister for each
- Non-blocking delivery:
conn.Send()uses a buffered channel withselect/default— if the buffer is full, the message is dropped. The heartbeat/ping timeout will eventually evict dead connections. - Deduplication: The Subscriber layer prevents duplicate subscriptions. If
Subscribe("channel.123", "channel.123")is called again with the same key and topic, it's a no-op. - Thread safety: The Hub uses
sync.RWMutex; each topic entry has its ownsync.RWMutexfor the connection set.