proto-socket/dart/lib/src/ws_protobuf_client_web.dart
toki 6cdd0c3581 feat: protocol evolution compatibility implementation
- Add legacy alias support for backward compatibility
- Update ProtocolBuffer message definitions with new fields
- Implement fullname-based message routing
- Add alias fallback logic in all language clients (Dart, Go, Kotlin, Python, TypeScript)
- Update test suites for alias and fullname validation
- Add protocol sync verification tools
- Update documentation (PORTING_GUIDE, PROTOCOL, VERSIONING)
- Add agent task documentation for protocol evolution
2026-06-16 06:56:15 +09:00

156 lines
4.7 KiB
Dart

// 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<WsProtobufClient> {
final html.WebSocket _ws;
late final _WebSocketTransport _transport;
/// Plain WebSocket connection.
static Future<html.WebSocket> 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<html.WebSocket> connectSecure(String host, int port,
{String path = '/'}) =>
_open('wss://$host:$port$path');
static Future<html.WebSocket> _open(String url) {
final ws = html.WebSocket(url);
ws.binaryType = 'arraybuffer';
if (ws.readyState == html.WebSocket.OPEN) {
return Future.value(ws);
}
final completer = Completer<html.WebSocket>();
late StreamSubscription<html.Event> openSub;
late StreamSubscription<html.Event> 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<html.MessageEvent>? _subscription;
final Queue<dynamic> _pending = Queue<dynamic>();
bool _draining = false;
WsProtobufClient(this._ws, int heartbeatIntervalTime, int heartbeatWaitTime,
Map<String, GeneratedMessage Function(List<int>)> 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<void> _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<int>) {
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<void> _dispatch(List<int> bytes) async {
await onReceivedFrame(bytes);
sendHeartBeat();
}
@override
Future<void> 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<void> writePacket(PacketBase base) async {
_ws.send(base.writeToBuffer());
}
@override
Future<void> close() async {
try {
_ws.close();
} catch (_) {
// already closed by peer
}
}
}