baaba_realtime_client 2.0.0
baaba_realtime_client: ^2.0.0 copied to clipboard
A customizable, production-grade Real-Time client for Flutter (SignalR & WebSockets). Features Either-based error handling, auto-reconnection with jitter, dead-connection detection, offline queueing, [...]
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
errorStreamfor 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.onTokenRefreshonce and retries straight away. - It stops only when you call
disconnect()ordispose(), or whenmaxAttempts,retryIforReconnectionPolicy.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
connecteduntil 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. CallleaveRoom(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
OnDisconnectedAsyncas 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.onTokenRefreshis not called there, butgetTokenis 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 returnsfalse, the client keeps retrying with backoff. UseReconnectionPolicy(retryIf: …)to stop earlier, or calldisconnect()on logout. connectionIdis 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.