Realtime Layer

Realtime Layer

Meww.me uses two WebSocket-based systems for real-time communication between the bot and the dashboard frontend.

Architecture

 Frontend (React)         Fastify (port 2555)              Bot Core
┌──────────────┐         ┌───────────────────┐
│              │  WS/    │   Socket.IO       │
│  useSocket() │ � - �────── │   SocketBridge    │
│  usePlayer() │ ──────► │                   │
│              │         │    ┌──────────┐   │
└──────────────┘         │    │ BotWs    │   │
                         │    │ Service  │   │
                         │    └────┬─────┘   │
                         └─────────┼─────────┘
                                   │ internal WS
                         ┌─────────▼─────────┐
                         │  websocket.ts      │
                         │  (Fastify WS)      │
                         │  /v1/ws/:guildId   │
                         └───────────────────┘

There are two distinct WebSocket layers:

  1. Internal WebSocket - A Fastify WebSocket route (websocket.ts) that connects the bot core to the BotWsService. This is an internal channel.
  2. Socket.IO - A Socket.IO server attached to the Fastify HTTP server. The SocketBridge connects Socket.IO clients (the frontend) to the internal WebSocket.

Socket.IO (Frontend ↔ Dashboard)

Connection

The frontend connects to the Socket.IO server at the VITE_BACKEND_URL with the following configuration:

Transport: polling → websocket (upgrade)
Ping interval: 10,000ms
Ping timeout: 8,000ms
Credentials: true (sends cookies for session auth)

CORS

The Socket.IO server applies the same CORS policy as the Fastify REST API. Allowed origins are determined by FRONTEND_URL in config. In development, localhost:3000 and localhost:3001 are automatically allowed.

Event Flow

Subscribe to a Guild

Client → Server:  subscribe(guildId)
Server → Client:  subscribed({ guildId })
Server → Client:  playerCreate(playerState)  // initial state, if player exists

The client joins a Socket.IO room named guild:{guildId}. The SocketBridge also subscribes to the internal WebSocket for that guild and forwards all events.

Request State Refresh

Client → Server:  requestState()
Server → Client:  playerState({ ...state, _guild: guildId })

Used when the frontend needs to reconcile its local state with the bot's actual state.

Unsubscribe

Client → Server:  unsubscribe()
// or:
Client disconnects → automatic cleanup

The SocketBridge cleans up the internal WebSocket subscription and removes the client from the Socket.IO room.

Events Emitted by the Server

EventPayloadDescription
subscribed{ guildId }Confirmation that the client joined the guild room
playerCreatePlayer state objectInitial state when subscribing (if a player exists)
playerStatePlayer state objectFull state snapshot (response to requestState)
trackStartTrack + player dataA new track has started playing
trackEndTrack dataThe current track has ended
queueUpdateQueue dataThe queue has been modified
playerUpdatePlayer state diffPlayer properties changed (volume, loop, pause, etc.)
playerDestroy{ guildId }The player has been destroyed

All server-emitted events include a _guild field with the guild ID for routing.

Frontend Integration

The useSocket hook manages the Socket.IO connection:

// Simplified usage in usePlayer.ts
const socket = useSocket();
 
useEffect(() => {
  if (!socket || !guildId) return;
  socket.emit("subscribe", guildId);
 
  socket.on("playerState", (data) => {
    // Update local player state
  });
 
  socket.on("trackStart", (data) => {
    // Update current track
  });
 
  return () => {
    socket.emit("unsubscribe");
    socket.off("playerState");
    socket.off("trackStart");
  };
}, [socket, guildId]);

Internal WebSocket (websocket.ts)

The internal WebSocket route is available at /v1/ws/:guildId on the Fastify server. It requires the same Authorization header as the REST API.

This WebSocket is used exclusively by BotWsService to receive real-time events from the bot core. The bot emits events using manager.dashboardEmit(guildId, event, data), which internally emits to the Socket.IO room for that guild.

SocketBridge (SocketBridge.ts)

The SocketBridge class connects the two WebSocket layers:

  1. When a Socket.IO client subscribes to a guild, the bridge subscribes the internal BotWsService to that guild's events.
  2. Internal events are forwarded to the Socket.IO room.
  3. On disconnect, the bridge cleans up both the Socket.IO room membership and the internal subscription.
class SocketBridge {
  private subscriptions = new Map<string, { guildId: string; handler: Function }>();
 
  constructor(
    private io: SocketIOServer,
    private botWs: BotWsService,
    private botApi: AxiosInstance
  ) {
    this.setup();
  }
 
  // subscribe: join room, subscribe to internal WS, fetch initial state
  // unsubscribe: leave room, unsubscribe from internal WS
  // disconnect: automatic cleanup
}

Each Socket.IO socket can only be subscribed to one guild at a time. Subscribing to a new guild automatically unsubscribes from the previous one.

Dashboard Emit

The bot core can push events to dashboard clients at any time via:

manager.dashboardEmit(guildId, eventName, data);

This function is set by the DashboardPlugin during the onReady hook:

client.dashboardEmit = (guildId, event, data) =>
  io.to('guild:' + guildId).emit(event, data);

Events are emitted from various locations:

  • Player events (trackStart.ts, trackEnd.ts) - push track changes
  • Queue modifications - push queue updates
  • Settings changes - push configuration updates
  • Player state changes - push pause, volume, loop changes

Player State Object

The player state object emitted in real-time events contains:

{
  "guildId": "123456789012345678",
  "current": {
    "title": "Song Title",
    "uri": "https://...",
    "length": 240000,
    "thumbnail": "https://...",
    "author": "Artist Name"
  },
  "queue": [
    { "title": "...", "uri": "...", "length": 0, "thumbnail": "...", "author": "..." }
  ],
  "pause": false,
  "position": 45000,
  "volume": 80,
  "loop": "none",
  "autoplay": false,
  "updatedAt": 1714000000000
}

The updatedAt timestamp is used by the frontend for state reconciliation when events arrive out of order.