Skip to main content

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.