WebSocket

WebSocket allows the server and browser to maintain a persistent, two-way connection. Unlike regular HTTP where the browser always initiates requests, WebSocket lets the server push messages to clients at any time.

Common use cases: real-time notifications, live chat, dashboards that update automatically, multiplayer games.

Finch's WebSocket support is built around three classes:

  • SocketManager — manages all active WebSocket connections and dispatches messages to the correct route handler.
  • SocketEvent — defines callbacks for onConnect, onMessage, onDisconnect, and onError on a specific path.
  • SocketClient — represents one connected client; passed into every callback, it's what you call .send() on.

A regular Finch Controller is what upgrades the incoming HTTP request to a WebSocket connection — there is no separate SocketController base class to extend.

Setup

1. Define a SocketManager

Create the SocketManager in app.dart. It takes the app instance, a root SocketEvent (for lifecycle events), and a map of named route handlers:

final socketManager = SocketManager(
  app,
  event: SocketEvent(
    onConnect: (socket) {
      // Called when a client connects
      // Notify all other clients about the new connection
      app.socketManager?.sendToAll(
        'A user connected. Total: ${app.socketManager?.countClients}',
        path: 'output',
      );

      // Send a confirmation to the newly connected client
      socket.send(
        {'message': 'Successfully connected to socket!'},
        path: 'connected',
      );
    },
    onMessage: (socket, data) {
      // Called for messages not matched by any named route
    },
    onDisconnect: (socket) {
      // Called when a client disconnects
      var count = app.socketManager?.countClients ?? 0;
      app.socketManager?.sendToAll(
        'A user disconnected. Total: ${count - 1}',
        path: 'output',
      );
    },
    onError: (socket, data) {
      // Called when the incoming message can't be decoded as JSON,
      // or another error occurs while handling it
    },
  ),
  routes: _getSocketRoutes(),
);

Only the root event gets onConnect/onDisconnect/onError calls — see the note under "Define Named Routes" below.

2. Define Named Routes

Socket routes are a Map<String, SocketEvent>. Each key is a "path" (a logical channel name). When a client sends a message to that path, the corresponding onMessage callback fires:

Map<String, SocketEvent> _getSocketRoutes() {
  return {
    // Client sends to path 'test' — server replies with request headers
    'test': SocketEvent(
      onMessage: (socket, data) {
        socket.send([socket.rq.headers], path: 'test');
      },
    ),

    // Client sends to path 'time' — server replies with current time
    'time': SocketEvent(
      onMessage: (socket, data) {
        socket.send(DateTime.now().toString(), path: 'output');
      },
    ),
  };
}

Only onMessage is used from a named route's SocketEventonConnect/onDisconnect/onError set on a route entry are never called; those only fire from the root event passed to SocketManager. A message whose path doesn't match any named route falls through to the root event.onMessage instead.

3. Upgrade the Request in a Controller

The controller's job is to hand the HTTP request over to socketManager so the upgrade can happen — it's a plain Controller, nothing special:

class WebSocketController extends Controller {
  Future<String> socket() async {
    // Transfer the request to the SocketManager for WebSocket upgrade
    await socketManager.requestHandle(rq);
    return rq.renderSocket(); // returns 'Socket is requested!'
  }
}

4. Register the WebSocket Route

The WebSocket route must accept Methods.ALL because the upgrade handshake uses a GET request with special headers:

FinchRoute(
  key: 'root.ws',
  path: '/ws',
  methods: Methods.ALL,
  index: webSocketController.socket,
),

Sending Messages from the Server

Every callback receives a SocketClient (referred to as socket in the examples above). It — and SocketManager itself — expose methods to send messages:

// Send to the specific client that triggered the event
socket.send(data, path: 'channelName');

// Send to all connected clients
app.socketManager?.sendToAll(data, path: 'channelName');

// Send to one specific client by connection ID
// (note the method's real name has a typo: "Clinet", not "Client")
app.socketManager?.sendToClinet(clientId, data, path: 'channelName');

// Send to every client associated with a given user ID
// (requires that userId was passed to requestHandle(rq, userId: ...) when they connected)
app.socketManager?.sendToUser(userId, data, path: 'channelName');

There is no built-in "send to all except this one" method — if you need that, filter app.socketManager?.getAllClientsKeys() yourself and call sendToClinet for each remaining ID.

The path in a send call determines which handler on the client side receives it. On the JavaScript side, listen for messages on the same path name.

Client-Side JavaScript Example

const ws = new WebSocket('ws://localhost:8080/ws');

ws.onopen = () => console.log('Connected');

// Listen for messages on the 'connected' path
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.path === 'connected') {
    console.log('Server says:', msg.data.message);
  }
  if (msg.path === 'output') {
    console.log('Output:', msg.data);
  }
};

// Send a message to the 'time' route on the server
ws.send(JSON.stringify({ path: 'time', data: {} }));

How Many Clients Are Connected?

int count = app.socketManager?.countClients ?? 0;
int users = app.socketManager?.countUsers ?? 0; // unique users, if you pass userId on connect