// ignore_for_file: avoid_init_to_null, prefer_final_fields, deprecated_member_use import 'dart:async'; import 'dart:collection'; import 'dart:html' as html; import 'dart:typed_data'; import 'package:protobuf/protobuf.dart'; import 'base_client.dart'; import 'inbound_gateway_web.dart'; import 'packets/message_common.pb.dart'; import 'transport.dart'; abstract class WsProtobufClient extends BaseClient { final html.WebSocket _ws; late final _WebSocketTransport _transport; /// Plain WebSocket connection. static Future connect(String host, int port, {String path = '/'}) => _open('ws://$host:$port$path'); /// Secure WebSocket (WSS) connection. /// /// Browser handles TLS via the user agent's trust store, so no /// `SecurityContext` is accepted on web. static Future connectSecure(String host, int port, {String path = '/'}) => _open('wss://$host:$port$path'); static Future _open(String url) { final ws = html.WebSocket(url); ws.binaryType = 'arraybuffer'; if (ws.readyState == html.WebSocket.OPEN) { return Future.value(ws); } final completer = Completer(); late StreamSubscription openSub; late StreamSubscription errorSub; void cleanup() { openSub.cancel(); errorSub.cancel(); } openSub = ws.onOpen.listen((_) { if (!completer.isCompleted) { cleanup(); completer.complete(ws); } }); errorSub = ws.onError.listen((event) { if (!completer.isCompleted) { cleanup(); completer.completeError( StateError('WebSocket failed to open: $url'), StackTrace.current); } }); return completer.future; } StreamSubscription? _subscription; final Queue _pending = Queue(); bool _draining = false; WsProtobufClient(this._ws, int heartbeatIntervalTime, int heartbeatWaitTime, Map)> parserMap) { initSelf(this); initHeartbeat(heartbeatIntervalTime, heartbeatWaitTime); _transport = _WebSocketTransport(_ws); isAlive = true; parserMap.addAll({HeartBeat.getDefault().info_.qualifiedMessageName: HeartBeat.fromBuffer}); super.initialize(parserMap, transport: _transport); // web에는 worker isolate가 없으므로 IsolateInboundGateway는 SyncInboundGateway // fallback으로 in-process decode하되 동일한 seq-ordered receive coordinator // contract를 유지한다. final gateway = IsolateInboundGateway(); attachInboundGateway(gateway); unawaited(gateway.start()); _subscription = _ws.onMessage.listen(_onMessage, onError: onError); _ws.onClose.listen((_) => close()); addListener(onHeartBeat); sendHeartBeat(); } void onError(dynamic e) {} // burst 상황에서 pause()는 동기적으로 적용되지 않아 이미 전달된 frame이 남는다. // 도착한 frame을 drop하지 않고 queue에 넣은 뒤, 단일 drain loop가 순서대로 // 변환·dispatch한다. pause/resume은 source backpressure 용도로 drain 단위에서만 건다. void _onMessage(html.MessageEvent event) { _pending.add(event.data); _drain(); } Future _drain() async { if (_draining) return; _draining = true; _subscription?.pause(); try { while (_pending.isNotEmpty) { final data = _pending.removeFirst(); if (data is ByteBuffer) { await _dispatch(data.asUint8List()); } else if (data is Uint8List) { await _dispatch(data); } else if (data is List) { await _dispatch(data); } else if (data is html.Blob) { final reader = html.FileReader(); reader.readAsArrayBuffer(data); await reader.onLoad.first; final result = reader.result; if (result is ByteBuffer) { await _dispatch(result.asUint8List()); } } } } finally { _draining = false; _subscription?.resume(); } } Future _dispatch(List bytes) async { await onReceivedFrame(bytes); sendHeartBeat(); } @override Future closeTransport() async { await _transport.close(); } @override bool get isSourcePaused => _subscription?.isPaused ?? false; } class _WebSocketTransport implements Transport { final html.WebSocket _ws; _WebSocketTransport(this._ws); @override Future writePacket(PacketBase base) async { _ws.send(base.writeToBuffer()); } @override Future close() async { try { _ws.close(); } catch (_) { // already closed by peer } } }