import 'package:socket_io_client/socket_io_client.dart' as io; import '../models/server_config.dart'; import '../utils/debug_logger.dart'; void debugPrint(String? message, {int? wrapWidth}) { if (message == null) return; DebugLogger.fromLegacy(message, scope: 'socket'); } class SocketService { final ServerConfig serverConfig; final bool websocketOnly; io.Socket? _socket; String? _authToken; SocketService({ required this.serverConfig, String? authToken, this.websocketOnly = false, }) : _authToken = authToken; String? get sessionId => _socket?.id; io.Socket? get socket => _socket; String? get authToken => _authToken; bool get isConnected => _socket?.connected == true; Future connect({bool force = false}) async { if (_socket != null && _socket!.connected && !force) return; try { _socket?.dispose(); } catch (_) {} String base = serverConfig.url.replaceFirst(RegExp(r'/+$'), ''); // Normalize accidental ":0" ports or invalid port values in stored URL try { final u = Uri.parse(base); if (u.hasPort && u.port == 0) { // Drop the explicit :0 to fall back to scheme default (80/443) base = '${u.scheme}://${u.host}${u.path.isEmpty ? '' : u.path}'; } } catch (_) {} final path = '/ws/socket.io'; final builder = io.OptionBuilder() // Transport selection .setTransports(websocketOnly ? ['websocket'] : ['polling', 'websocket']) .setRememberUpgrade(!websocketOnly) .setUpgrade(!websocketOnly) // Tune reconnect/backoff and timeouts .setReconnectionAttempts(0) // 0/Infinity semantics: unlimited attempts .setReconnectionDelay(1000) .setReconnectionDelayMax(5000) .setRandomizationFactor(0.5) .setTimeout(20000) .setPath(path); // Merge Authorization (if any) with user-defined custom headers for the // Socket.IO handshake. Avoid overriding reserved headers. final Map extraHeaders = {}; if (_authToken != null && _authToken!.isNotEmpty) { extraHeaders['Authorization'] = 'Bearer $_authToken'; builder.setAuth({'token': _authToken}); } if (serverConfig.customHeaders.isNotEmpty) { final reserved = { 'authorization', 'content-type', 'accept', // Socket/WebSocket reserved or managed by client/runtime 'host', 'origin', 'connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol', }; serverConfig.customHeaders.forEach((key, value) { final lower = key.toLowerCase(); if (!reserved.contains(lower) && value.isNotEmpty) { // Do not overwrite Authorization we already set from authToken if (lower == 'authorization' && extraHeaders.containsKey('Authorization')) { return; } extraHeaders[key] = value; } }); } if (extraHeaders.isNotEmpty) { builder.setExtraHeaders(extraHeaders); } _socket = io.io(base, builder.build()); _socket!.on('connect', (_) { debugPrint('Socket connected: ${_socket!.id}'); if (_authToken != null && _authToken!.isNotEmpty) { _socket!.emit('user-join', { 'auth': {'token': _authToken}, }); } }); _socket!.on('connect_error', (err) { debugPrint('Socket connect_error: $err'); }); _socket!.on('reconnect_attempt', (attempt) { debugPrint('Socket reconnect_attempt: $attempt'); }); _socket!.on('reconnect', (attempt) { debugPrint('Socket reconnected after $attempt attempts'); if (_authToken != null && _authToken!.isNotEmpty) { // Best-effort rejoin _socket!.emit('user-join', { 'auth': {'token': _authToken}, }); } }); _socket!.on('reconnect_failed', (_) { debugPrint('Socket reconnect_failed'); }); _socket!.on('disconnect', (reason) { debugPrint('Socket disconnected: $reason'); }); } /// Update the auth token used by the socket service. /// If connected, emits a best-effort rejoin with the new token. void updateAuthToken(String? token) { _authToken = token; if (_socket?.connected == true && _authToken != null && _authToken!.isNotEmpty) { try { _socket!.emit('user-join', { 'auth': {'token': _authToken}, }); } catch (_) {} } } void onChatEvents( void Function( Map event, void Function(dynamic response)? ack, ) handler, ) { _socket?.on('chat-events', (dynamic data, [dynamic ack]) { try { Map? map; if (data is Map) { map = data; } else if (data is Map) { map = Map.from(data); } if (map == null) return; final ackFn = ack is Function ? (dynamic payload) { if (payload is List) { Function.apply(ack, payload); } else if (payload == null) { Function.apply(ack, const []); } else { Function.apply(ack, [payload]); } } : null; handler(map, ackFn); } catch (_) {} }); } // Subscribe to general channel events (server-broadcasted channel updates) void onChannelEvents( void Function( Map event, void Function(dynamic response)? ack, ) handler, ) { _socket?.on('channel-events', (dynamic data, [dynamic ack]) { try { Map? map; if (data is Map) { map = data; } else if (data is Map) { map = Map.from(data); } if (map == null) return; final ackFn = ack is Function ? (dynamic payload) { if (payload is List) { Function.apply(ack, payload); } else if (payload == null) { Function.apply(ack, const []); } else { Function.apply(ack, [payload]); } } : null; handler(map, ackFn); } catch (_) {} }); } void offChatEvents() { _socket?.off('chat-events'); } void offChannelEvents() { _socket?.off('channel-events'); } // Subscribe to an arbitrary socket.io event (used for dynamic tool channels) void onEvent(String eventName, void Function(dynamic data) handler) { _socket?.on(eventName, handler); } void offEvent(String eventName) { _socket?.off(eventName); } void dispose() { try { _socket?.dispose(); } catch (_) {} _socket = null; } // Best-effort: ensure there is an active connection and wait briefly. // Returns true if connected by the end of the timeout. Future ensureConnected({ Duration timeout = const Duration(seconds: 2), }) async { if (isConnected) return true; try { await connect(); } catch (_) {} final start = DateTime.now(); while (!isConnected && DateTime.now().difference(start) < timeout) { await Future.delayed(const Duration(milliseconds: 50)); } return isConnected; } }