baaba_realtime_client

A real-time client for Flutter: SignalR (via signalr_netcore) and raw WebSockets, behind one API that never throws.

  • Every async call returns Either<RealtimeFailure, T> — no try/catch around your hub calls
  • Auto-reconnect with exponential backoff and full jitter
  • Automatic room re-join, dead-connection detection, opt-in offline queueing
  • One errorStream for every failure, including ones no call site could see
  • A full fake client for testing, with no sockets involved

Install

dependencies:
  baaba_realtime_client: ^2.0.0

Quick start

import 'package:baaba_realtime_client/baaba_realtime_client.dart';

RealtimeClient.init(RealtimeConfig(
  uri: Uri.parse('https://api.example.com/chatHub'),
  auth: RealtimeAuthConfig(getToken: () => secureStorage.read('access_token')),
));

final client = RealtimeClient.instance();

await client.connect();

That is the whole setup. Negotiation, transport choice, the handshake, keep-alive and reconnection are handled for you. https:// and wss:// are both fine.

Need two hubs (chat and calls, say)? Use RealtimeClient.create(config) for independent instances instead of the singleton.

Receiving events

// The usual hub shape: Clients.All.SendAsync("ReceiveMessage", dto)
client.on('ReceiveMessage').listen((dto) => print(dto));

// Multi-parameter: Clients.Group(r).SendAsync("UserTyping", userId, isTyping)
client.onArgs('UserTyping').listen((args) => print('${args[0]} ${args[1]}'));

// Typed, with a parser
client.onAs('ReceiveMessage', parser: ChatMessage.fromJson).listen(_append);

// Everything, including targets nothing is subscribed to — good for debugging
client.messageStream.listen((m) => log('${m.target} ${m.arguments}'));

on gives you the first argument; onArgs gives you all of them. That is the only rule. Both are broadcast streams that stay alive across reconnects — cancel your own subscription to stop listening.

There are also optional typed streams for chat, typing, presence and WebRTC signaling (chatStream, typingStream, presenceStream, signalingStream). They assume particular hub method names and payload shapes, so if your hub uses its own names and shapes, stick with on / onAs.

Sending

// Fire-and-forget
await client.send('SendMessage', args: [chatId, payload]);

// Request/response — returns what the hub method returned
final result = await client.invoke('SendMessage', args: [chatId, payload]);
result.fold(
  (failure) => showError(failure.message),
  (data) => messageId = data['id'],
);

// Typed response
final history = await client.invokeAs(
  'GetHistory',
  args: [chatId],
  parser: listParser(ChatMessage.fromJson),
);

Sending while disconnected returns Left(RealtimeFailure.disconnected()) straight away. To buffer messages and flush them on the next connect instead, pass offlineQueue: const OfflineQueuePolicy().

Rooms

await client.joinRoom('general');                        // JoinRoom("general")
await client.joinRoom(42, joinMethod: 'JoinChatGroup');  // JoinChatGroup(int chatId)
await client.leaveRoom('general');

The room is sent exactly as you pass it, so match the hub parameter's type. A string "42" will not bind to an int chatId. activeRooms lists rooms by their string form.

Joined rooms are tracked and re-joined automatically after a reconnect. Joining while disconnected is allowed; it takes effect on connect.

Errors

Nothing throws. Failures arrive in three places, and you generally want all three:

// 1. The call that caused it
final result = await client.invoke('CloseChat', args: [chatId]);
if (result.isLeft()) { /* ... */ }

// 2. Everything, including failures with no call site
client.errorStream.listen((f) {
  Sentry.captureException(f);
  if (f.errorType == RealtimeErrorSource.authentication) promptRelogin();
});

// 3. Connection state, for your UI
client.stateStream.listen((s) => setState(() => _offline = !s.isConnected));

RealtimeFailure carries errorType, a stable numeric code, message, data, details and stackTrace. Switch on errorType (network, timeout, handshake, authentication, protocol, serverError, parseError, offline, queueOverflow, cancelled, disconnected).

A 401 triggers auth.onTokenRefresh and one automatic retry before you ever see a failure.

Connection and reconnection

A failed attempt always schedules another one, including the very first connect():

final result = await client.connect();
// Left means *this attempt* failed. The client is now `reconnecting` and keeps
// trying in the background. Don't wrap connect() in your own retry loop.
  • Retries back off exponentially from 500 ms to a 30 s cap, with jitter and no attempt limit.
  • A dropped connection goes through the same retry loop, and joined rooms are rejoined afterwards.
  • While the device is offline, the client checks again every 5 s. It reconnects the moment the OS reports the network is back, and these attempts don't count toward maxAttempts.
  • A 401 calls auth.onTokenRefresh once and retries straight away.
  • It stops only when you call disconnect() or dispose(), or when maxAttempts, retryIf or ReconnectionPolicy.disabled() says so.

Drive your UI from stateStream, not from what connect() returned.

Behind proxies and firewalls

Before each attempt the client checks whether the device is online. By default that check contacts public hosts (via internet_connection_checker_plus). If a network blocks those but allows your hub, the client decides it is offline and never tries the hub. On restrictive networks, check against your own server instead:

RealtimeConfig(
  uri: Uri.parse('https://api.example.com/chatHub'),
  connectivityProbe: () async {
    try {
      final r = await http.head(Uri.parse('https://api.example.com')).timeout(const Duration(seconds: 5));
      return r.statusCode < 500;
    } catch (_) {
      return false;
    }
  },
)

Or set bypassConnectivityCheck: true to always attempt the hub. You lose the 5 s offline polling, but the instant reconnect when the network returns still works.

A network that blocks WebSockets outright cannot be worked around. The client uses WebSockets only, because the Server-Sent Events fallback in signalr_netcore can crash the app (see SignalROptions). On such a network it keeps retrying and reports network failures on errorStream.

App lifecycle

By default (lifecycleAware: true) the client keeps the connection open in the background, for as long as the OS allows. When the app comes back to the foreground, it:

  • notices a socket the OS closed while the app was away (iOS does this within seconds) and reconnects at once, instead of reporting connected until the server times it out;
  • runs a pending reconnect whose timer the OS suspended, instead of waiting out the rest of the delay.

This needs a Flutter binding. Without one, call notifyAppResumed() yourself.

Going offline in the background (push notifications)

If your server sends push notifications only while the user is offline, it needs to be told when the app goes to the background. The socket also has to stay closed until the app comes back. Own the lifecycle yourself and turn the built-in handling off:

RealtimeConfig(
  uri: Uri.parse('https://api.example.com/chatHub'),
  lifecycleAware: false,
)

// AppLifecycleState.paused
await client.invoke('GoOffline', args: [chatId], timeout: const Duration(seconds: 3));
await client.disconnect();

// AppLifecycleState.resumed
await client.connect(); // rejoins your rooms; keeps retrying if the network is bad
  • Await the offline call before disconnect(). If you don't, the disconnect can win, the server never hears the app went offline, and no pushes arrive.
  • disconnect() guarantees nothing reconnects in the background. It cancels any pending retry and any attempt in flight.
  • Keep the offline queue off (the default). With it on, an offline call that failed while disconnected would be sent right after the next resume, marking the user offline while they are in the app.
  • Rooms survive disconnect(). connect() rejoins them, and so does every automatic reconnect. Call leaveRoom(room, leaveMethod: …) when a chat actually ends, or it will be rejoined next time.
  • The OS can suspend the app before the offline call lands. No client can fully prevent that. Have the server also treat OnDisconnectedAsync as offline, so a dropped socket means the same thing.

Configuration

Defaults are production-shaped; override only what you need.

RealtimeConfig(
  uri: Uri.parse('https://api.example.com/chatHub'),
  reconnect: const ReconnectionPolicy(baseDelay: Duration(seconds: 1)),
  heartbeat: const HeartbeatConfig(pingInterval: Duration(seconds: 15)),
  offlineQueue: const OfflineQueuePolicy(maxQueueSize: 200),  // off unless you pass one
  invokeTimeout: const Duration(seconds: 30),
  signalR: const SignalROptions(),   // negotiation, transport, negotiate headers
  observer: MyObserver(),            // lifecycle hooks for analytics
  interceptors: [MyInterceptor()],   // transform or drop messages
)

Each of ReconnectionPolicy, HeartbeatConfig, OfflineQueuePolicy and RealtimeLogOptions has a .disabled() constructor. Logging masks bearer tokens and trims base64 blobs by default.

If your hub is WebSocket-only with negotiation disabled server-side, use signalR: const SignalROptions.webSocketsOnly(). Otherwise leave the default: a failed /negotiate automatically retries with it skipped, and the working route is remembered.

Testing

import 'package:baaba_realtime_client/testing.dart';

final client = FakeRealtimeClient();

client.simulateState(RealtimeConnectionState.connected);
client.simulateEvent('OrderUpdated', {'id': 1});
client.whenInvoke('GetHistory', [/* ... */]);
client.simulateError(RealtimeFailure.network(message: 'dropped'));

expect(client.recordedSends, hasLength(1));

No sockets, no servers, no timers.

Cleanup

await client.disconnect();  // keeps the instance usable
await client.dispose();     // releases its streams — for create() instances

Call disconnect() when the user logs out. Otherwise the client keeps reconnecting in the background with a token the server will reject.

Known limitations

  • A rejected token is only recognised as one on the negotiate route. When negotiation is skipped (SignalROptions.webSocketsOnly(), or after the negotiate fallback kicks in), a 401 on the WebSocket upgrade surfaces as a network failure, because Dart's WebSocket error does not carry the status code. onTokenRefresh is not called there, but getToken is re-read on every reconnect attempt, so a token refreshed elsewhere in your app is picked up on the next retry.
  • Reconnection does not stop on an authentication failure. With no onTokenRefresh, or after it returns false, the client keeps retrying with backoff. Use ReconnectionPolicy(retryIf: …) to stop earlier, or call disconnect() on logout.
  • connectionId is synthetic when negotiation is skipped. The server only issues one in the negotiate response. Without it, the client fills in a local timestamp, which the server does not know. Don't send it to your backend on that route.

Docs

architecture · public API · testing · known issues

License

MIT. See LICENSE.

Libraries

baaba_realtime_client
A customizable, production-grade Real-Time client for Flutter (SignalR & WebSockets).
testing
Test utilities and test doubles for consumers of baaba_realtime_client.