Client Development
Setting Up the Hypermodern Client
The Hypermodern client provides a unified interface for communicating with servers across multiple protocols. Whether you're building a mobile app, web application, or desktop client, the API remains consistent.
Basic Client Setup
import 'package:hypermodern/hypermodern.dart';
// Automatic protocol selection based on URL
final client = HypermodernClient('ws://localhost:8082');
// Or specify protocol explicitly
final httpClient = HypermodernClient.http('http://localhost:8080');
final wsClient = HypermodernClient.websocket('ws://localhost:8082');
final tcpClient = HypermodernClient.tcp('localhost:8081');
Client Configuration
Configure client behavior with detailed options:
final client = HypermodernClient(
'ws://localhost:8082',
config: ClientConfig(
// Connection settings
connectionTimeout: Duration(seconds: 10),
requestTimeout: Duration(seconds: 30),
// Retry configuration
maxRetries: 3,
retryDelay: Duration(seconds: 1),
retryBackoffMultiplier: 2.0,
// Reconnection (WebSocket/TCP)
autoReconnect: true,
reconnectAttempts: 5,
reconnectDelay: Duration(seconds: 2),
// Performance tuning
binaryMode: true,
compressionEnabled: true,
keepAlive: true,
// Security
validateCertificates: true,
customHeaders: {
'User-Agent': 'MyApp/1.0.0',
'X-API-Version': '2.0',
},
),
);
Authentication Setup
Configure authentication for all requests:
// JWT Authentication
client.setAuthToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');
// Custom authentication
client.setAuthProvider(CustomAuthProvider());
// Per-request authentication
final response = await client.request<User>(
'get_user',
request,
auth: BearerAuth('custom-token'),
);
Making Requests Across Protocols
The beauty of Hypermodern is that request patterns remain identical regardless of the underlying protocol.
Basic Request Pattern
import 'generated/models.dart';
import 'generated/requests.dart';
Future<void> basicRequests() async {
await client.connect();
try {
// Create a user
final createRequest = CreateUserRequest(
username: 'alice',
email: 'alice@example.com',
password: 'secure123',
);
final newUser = await client.request<User>('create_user', createRequest);
print('Created user: ${newUser.username} (ID: ${newUser.id})');
// Get the user back
final getRequest = GetUserRequest(id: newUser.id);
final fetchedUser = await client.request<User>('get_user', getRequest);
print('Fetched user: ${fetchedUser.username}');
// Update the user
final updateRequest = UpdateUserRequest(
id: newUser.id,
username: 'alice_updated',
);
final updatedUser = await client.request<User>('update_user', updateRequest);
print('Updated user: ${updatedUser.username}');
} finally {
await client.disconnect();
}
}
Batch Requests
Execute multiple requests efficiently:
Future<void> batchRequests() async {
// Parallel execution
final futures = [
client.request<User>('get_user', GetUserRequest(id: 1)),
client.request<User>('get_user', GetUserRequest(id: 2)),
client.request<User>('get_user', GetUserRequest(id: 3)),
];
final users = await Future.wait(futures);
print('Fetched ${users.length} users in parallel');
// Sequential with error handling
final userIds = [1, 2, 3, 4, 5];
final fetchedUsers = <User>[];
for (final id in userIds) {
try {
final user = await client.request<User>('get_user', GetUserRequest(id: id));
fetchedUsers.add(user);
} on NotFoundException {
print('User $id not found, skipping');
}
}
print('Successfully fetched ${fetchedUsers.length} users');
}
Request Cancellation
Cancel long-running requests:
Future<void> cancellableRequests() async {
final cancellationToken = CancellationToken();
// Start a long-running request
final future = client.request<SearchResult>(
'search_users',
SearchUsersRequest(query: 'complex search'),
cancellationToken: cancellationToken,
);
// Cancel after 5 seconds
Timer(Duration(seconds: 5), () {
cancellationToken.cancel();
});
try {
final result = await future;
print('Search completed: ${result.totalCount} results');
} on CancellationException {
print('Search was cancelled');
}
}
Handling Real-time Streams
Streaming is where Hypermodern really shines, providing real-time communication with the same ease as regular requests.
Basic Streaming
Future<void> basicStreaming() async {
await client.connect();
// Start streaming user updates
final request = WatchUsersRequest(userIds: [1, 2, 3]);
await for (final update in client.stream<UserUpdate>('watch_users', request)) {
switch (update.type) {
case UpdateType.created:
print('User created: ${update.user.username}');
break;
case UpdateType.updated:
print('User updated: ${update.user.username}');
break;
case UpdateType.deleted:
print('User deleted: ${update.userId}');
break;
}
}
}
Stream Management
Control stream lifecycle and handle errors:
Future<void> managedStreaming() async {
StreamSubscription<UserUpdate>? subscription;
try {
final stream = client.stream<UserUpdate>(
'watch_users',
WatchUsersRequest(userIds: [1, 2, 3]),
);
subscription = stream.listen(
(update) {
print('Received update: ${update.type}');
},
onError: (error) {
print('Stream error: $error');
// Implement retry logic
},
onDone: () {
print('Stream completed');
},
);
// Let it run for 30 seconds
await Future.delayed(Duration(seconds: 30));
} finally {
await subscription?.cancel();
}
}
Bidirectional Streaming
Handle two-way communication:
Future<void> bidirectionalChat() async {
final chatRoom = 'general';
// Set up incoming message stream
final incomingMessages = client.stream<ChatMessage>(
'chat_messages',
JoinChatRequest(roomId: chatRoom),
);
// Listen for incoming messages
final subscription = incomingMessages.listen((message) {
print('${message.username}: ${message.content}');
});
// Send messages
final messageController = client.getStreamController<ChatMessage>('chat_messages');
// Send a message
messageController.add(ChatMessage(
roomId: chatRoom,
content: 'Hello everyone!',
username: 'alice',
));
// Send more messages based on user input
stdin.transform(utf8.decoder).transform(LineSplitter()).listen((line) {
if (line.isNotEmpty) {
messageController.add(ChatMessage(
roomId: chatRoom,
content: line,
username: 'alice',
));
}
});
// Cleanup
await Future.delayed(Duration(minutes: 5));
await subscription.cancel();
await messageController.close();
}
Stream Filtering and Transformation
Process streams with Dart's powerful stream operations:
Future<void> streamProcessing() async {
final userUpdates = client.stream<UserUpdate>('watch_users', WatchUsersRequest());
// Filter only user creations
final newUsers = userUpdates
.where((update) => update.type == UpdateType.created)
.map((update) => update.user);
// Buffer updates and process in batches
final batchedUpdates = userUpdates
.bufferTime(Duration(seconds: 5))
.where((batch) => batch.isNotEmpty);
await for (final batch in batchedUpdates) {
print('Processing batch of ${batch.length} updates');
await processBatch(batch);
}
}
Future<void> processBatch(List<UserUpdate> updates) async {
// Process multiple updates efficiently
final creations = updates.where((u) => u.type == UpdateType.created).length;
final updates_count = updates.where((u) => u.type == UpdateType.updated).length;
final deletions = updates.where((u) => u.type == UpdateType.deleted).length;
print('Batch summary: $creations created, $updates_count updated, $deletions deleted');
}
Connection Management and Lifecycle
Proper connection management ensures reliable communication and optimal resource usage.
Connection Lifecycle
class ClientManager {
late HypermodernClient _client;
StreamSubscription? _connectionSubscription;
Future<void> initialize() async {
_client = HypermodernClient(
'ws://localhost:8082',
config: ClientConfig(
autoReconnect: true,
reconnectAttempts: 5,
reconnectDelay: Duration(seconds: 2),
),
);
// Monitor connection state
_connectionSubscription = _client.connectionState.listen((state) {
switch (state) {
case ConnectionState.connecting:
print('🔄 Connecting to server...');
break;
case ConnectionState.connected:
print('✅ Connected to server');
_onConnected();
break;
case ConnectionState.disconnected:
print('❌ Disconnected from server');
_onDisconnected();
break;
case ConnectionState.reconnecting:
print('🔄 Reconnecting to server...');
break;
case ConnectionState.failed:
print('💥 Connection failed');
_onConnectionFailed();
break;
}
});
await _client.connect();
}
void _onConnected() {
// Resume any paused operations
// Sync local state with server
// Start real-time subscriptions
}
void _onDisconnected() {
// Pause operations that require connectivity
// Cache operations for later retry
}
void _onConnectionFailed() {
// Implement fallback strategies
// Switch to offline mode
// Notify user of connectivity issues
}
Future<void> dispose() async {
await _connectionSubscription?.cancel();
await _client.disconnect();
}
}
Connection Pooling (HTTP)
For HTTP clients, manage connection pools efficiently:
class HttpClientPool {
final Map<String, HypermodernClient> _clients = {};
HypermodernClient getClient(String baseUrl) {
return _clients.putIfAbsent(baseUrl, () {
return HypermodernClient.http(
baseUrl,
config: ClientConfig(
connectionPoolSize: 10,
keepAlive: true,
keepAliveTimeout: Duration(seconds: 30),
maxRequestsPerConnection: 100,
),
);
});
}
Future<void> closeAll() async {
for (final client in _clients.values) {
await client.disconnect();
}
_clients.clear();
}
}
Heartbeat and Keep-Alive
Maintain persistent connections:
class HeartbeatManager {
final HypermodernClient client;
Timer? _heartbeatTimer;
HeartbeatManager(this.client);
void startHeartbeat() {
_heartbeatTimer = Timer.periodic(Duration(seconds: 30), (timer) async {
try {
await client.request<PingResponse>('ping', PingRequest());
} catch (e) {
print('Heartbeat failed: $e');
// Connection might be dead, trigger reconnection
await client.reconnect();
}
});
}
void stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
}
Error Handling Strategies
Robust error handling is crucial for production applications.
Error Types and Handling
Future<User?> safeGetUser(int userId) async {
try {
final request = GetUserRequest(id: userId);
return await client.request<User>('get_user', request);
} on NotFoundException catch (e) {
// User doesn't exist - this might be expected
print('User $userId not found');
return null;
} on ValidationException catch (e) {
// Invalid request data
print('Invalid request: ${e.message}');
print('Field errors: ${e.fieldErrors}');
throw UserInputException('Please check your input');
} on UnauthorizedException catch (e) {
// Authentication required
print('Authentication required');
await _handleAuthenticationRequired();
throw AuthenticationRequiredException();
} on RateLimitException catch (e) {
// Rate limit exceeded
print('Rate limit exceeded, retry after ${e.retryAfter} seconds');
await Future.delayed(Duration(seconds: e.retryAfter));
return await safeGetUser(userId); // Retry
} on NetworkException catch (e) {
// Network connectivity issues
print('Network error: ${e.message}');
if (await _isOnline()) {
// Retry with exponential backoff
return await _retryWithBackoff(() => safeGetUser(userId));
} else {
throw OfflineException('No internet connection');
}
} on TimeoutException catch (e) {
// Request timed out
print('Request timed out after ${e.timeout}');
throw TimeoutException('Server is taking too long to respond');
} on ServerException catch (e) {
// Server-side error
print('Server error: ${e.message} (${e.statusCode})');
if (e.statusCode >= 500) {
// Server error, might be temporary
return await _retryWithBackoff(() => safeGetUser(userId));
} else {
// Client error, don't retry
throw ServerException('Server rejected the request');
}
} catch (e) {
// Unexpected error
print('Unexpected error: $e');
throw UnexpectedException('Something went wrong');
}
}
Retry Strategies
Implement sophisticated retry logic:
class RetryManager {
static Future<T> retryWithExponentialBackoff<T>(
Future<T> Function() operation, {
int maxAttempts = 3,
Duration initialDelay = const Duration(seconds: 1),
double backoffMultiplier = 2.0,
Duration maxDelay = const Duration(seconds: 30),
bool Function(Exception)? shouldRetry,
}) async {
int attempt = 0;
Duration delay = initialDelay;
while (attempt < maxAttempts) {
try {
return await operation();
} catch (e) {
attempt++;
if (attempt >= maxAttempts) {
rethrow;
}
if (shouldRetry != null && e is Exception && !shouldRetry(e)) {
rethrow;
}
print('Attempt $attempt failed: $e. Retrying in ${delay.inSeconds}s...');
await Future.delayed(delay);
delay = Duration(
milliseconds: (delay.inMilliseconds * backoffMultiplier).round(),
);
if (delay > maxDelay) {
delay = maxDelay;
}
}
}
throw StateError('This should never be reached');
}
static bool shouldRetryException(Exception e) {
return e is NetworkException ||
e is TimeoutException ||
(e is ServerException && e.statusCode >= 500) ||
e is RateLimitException;
}
}
// Usage
final user = await RetryManager.retryWithExponentialBackoff(
() => client.request<User>('get_user', GetUserRequest(id: 123)),
shouldRetry: RetryManager.shouldRetryException,
);
Circuit Breaker Pattern
Prevent cascading failures:
class CircuitBreaker {
final int failureThreshold;
final Duration timeout;
final Duration resetTimeout;
int _failureCount = 0;
DateTime? _lastFailureTime;
CircuitState _state = CircuitState.closed;
CircuitBreaker({
this.failureThreshold = 5,
this.timeout = const Duration(seconds: 30),
this.resetTimeout = const Duration(minutes: 1),
});
Future<T> execute<T>(Future<T> Function() operation) async {
if (_state == CircuitState.open) {
if (_shouldAttemptReset()) {
_state = CircuitState.halfOpen;
} else {
throw CircuitBreakerOpenException('Circuit breaker is open');
}
}
try {
final result = await operation();
_onSuccess();
return result;
} catch (e) {
_onFailure();
rethrow;
}
}
void _onSuccess() {
_failureCount = 0;
_state = CircuitState.closed;
}
void _onFailure() {
_failureCount++;
_lastFailureTime = DateTime.now();
if (_failureCount >= failureThreshold) {
_state = CircuitState.open;
}
}
bool _shouldAttemptReset() {
return _lastFailureTime != null &&
DateTime.now().difference(_lastFailureTime!) > resetTimeout;
}
}
enum CircuitState { closed, open, halfOpen }
Performance Optimization
Optimize client performance for different scenarios.
Request Batching
Batch multiple requests for efficiency:
class RequestBatcher {
final HypermodernClient client;
final Duration batchWindow;
final int maxBatchSize;
final Map<String, List<BatchedRequest>> _pendingRequests = {};
final Map<String, Timer> _batchTimers = {};
RequestBatcher(
this.client, {
this.batchWindow = const Duration(milliseconds: 100),
this.maxBatchSize = 10,
});
Future<T> request<T>(String endpoint, dynamic request) async {
final completer = Completer<T>();
final batchedRequest = BatchedRequest<T>(request, completer);
_pendingRequests.putIfAbsent(endpoint, () => []).add(batchedRequest);
// Start batch timer if not already running
if (!_batchTimers.containsKey(endpoint)) {
_batchTimers[endpoint] = Timer(batchWindow, () => _executeBatch(endpoint));
}
// Execute immediately if batch is full
if (_pendingRequests[endpoint]!.length >= maxBatchSize) {
_batchTimers[endpoint]?.cancel();
_executeBatch(endpoint);
}
return completer.future;
}
Future<void> _executeBatch(String endpoint) async {
final requests = _pendingRequests.remove(endpoint) ?? [];
_batchTimers.remove(endpoint);
if (requests.isEmpty) return;
try {
// Execute batch request
final batchRequest = BatchRequest(
endpoint: endpoint,
requests: requests.map((r) => r.request).toList(),
);
final batchResponse = await client.request<BatchResponse>(
'batch_execute',
batchRequest,
);
// Distribute responses
for (int i = 0; i < requests.length; i++) {
if (i < batchResponse.responses.length) {
requests[i].completer.complete(batchResponse.responses[i]);
} else {
requests[i].completer.completeError(
Exception('Missing response for batched request'),
);
}
}
} catch (e) {
// Fail all requests in batch
for (final request in requests) {
request.completer.completeError(e);
}
}
}
}
Caching Strategies
Implement intelligent caching:
class CachedClient {
final HypermodernClient client;
final Map<String, CacheEntry> _cache = {};
final Duration defaultTtl;
CachedClient(this.client, {this.defaultTtl = const Duration(minutes: 5)});
Future<T> request<T>(
String endpoint,
dynamic request, {
Duration? ttl,
bool forceRefresh = false,
}) async {
final cacheKey = _generateCacheKey(endpoint, request);
final entry = _cache[cacheKey];
// Return cached result if valid
if (!forceRefresh && entry != null && !entry.isExpired) {
return entry.data as T;
}
// Fetch fresh data
final response = await client.request<T>(endpoint, request);
// Cache the response
_cache[cacheKey] = CacheEntry(
data: response,
expiresAt: DateTime.now().add(ttl ?? defaultTtl),
);
return response;
}
String _generateCacheKey(String endpoint, dynamic request) {
// Generate deterministic cache key
return '$endpoint:${request.hashCode}';
}
void clearCache() {
_cache.clear();
}
void evictExpired() {
_cache.removeWhere((key, entry) => entry.isExpired);
}
}
class CacheEntry {
final dynamic data;
final DateTime expiresAt;
CacheEntry({required this.data, required this.expiresAt});
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
What's Next
You now have comprehensive knowledge of client development with Hypermodern. In the next chapter, we'll shift focus to server development, exploring how to implement robust server applications that can handle multiple protocols, process requests efficiently, and provide real-time streaming capabilities.