- 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
117 lines
3.5 KiB
Dart
117 lines
3.5 KiB
Dart
// ignore_for_file: avoid_init_to_null, prefer_final_fields
|
|
|
|
import 'dart:io';
|
|
import 'dart:async';
|
|
import 'dart:collection';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:protobuf/protobuf.dart';
|
|
import 'base_client.dart';
|
|
import 'inbound_gateway_io.dart';
|
|
import 'packets/message_common.pb.dart';
|
|
import 'transport.dart';
|
|
|
|
abstract class WsProtobufClient extends BaseClient<WsProtobufClient> {
|
|
final WebSocket _ws;
|
|
late final _WebSocketTransport _transport;
|
|
|
|
/// Plain WebSocket connection.
|
|
static Future<WebSocket> connect(String host, int port,
|
|
{String path = '/'}) =>
|
|
WebSocket.connect('ws://$host:$port$path');
|
|
|
|
/// Secure WebSocket (WSS) connection.
|
|
static Future<WebSocket> connectSecure(String host, int port,
|
|
{String path = '/',
|
|
SecurityContext? context,
|
|
bool Function(X509Certificate certificate)? onBadCertificate}) {
|
|
final client =
|
|
HttpClient(context: context ?? SecurityContext.defaultContext);
|
|
if (onBadCertificate != null) {
|
|
client.badCertificateCallback = (certificate, _, __) {
|
|
return onBadCertificate(certificate);
|
|
};
|
|
}
|
|
return WebSocket.connect('wss://$host:$port$path', customClient: client);
|
|
}
|
|
|
|
StreamSubscription? _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);
|
|
// TCP 경로와 동일한 gateway-backed receive coordinator를 사용한다. start()는
|
|
// _ready completer를 동기 구간에서 설정하므로 spawn 전 첫 프레임도 drop되지 않는다.
|
|
final gateway = IsolateInboundGateway();
|
|
attachInboundGateway(gateway);
|
|
unawaited(gateway.start());
|
|
_subscription = _ws.listen(_onMessage, onError: onError, onDone: close);
|
|
addListener(onHeartBeat);
|
|
sendHeartBeat();
|
|
}
|
|
|
|
void onError(dynamic e) {}
|
|
|
|
// burst 상황에서 pause()는 동기적으로 적용되지 않아 이미 전달된 frame이 남는다.
|
|
// 도착한 frame을 drop하지 않고 queue에 넣은 뒤, 단일 drain loop가 순서대로
|
|
// dispatch한다. pause/resume은 source backpressure 용도로 drain 단위에서만 건다.
|
|
void _onMessage(dynamic data) {
|
|
_pending.add(data);
|
|
_drain();
|
|
}
|
|
|
|
Future<void> _drain() async {
|
|
if (_draining) return;
|
|
_draining = true;
|
|
_subscription?.pause();
|
|
try {
|
|
while (_pending.isNotEmpty) {
|
|
await _dispatch(_pending.removeFirst());
|
|
}
|
|
} finally {
|
|
_draining = false;
|
|
_subscription?.resume();
|
|
}
|
|
}
|
|
|
|
Future<void> _dispatch(dynamic data) async {
|
|
final bytes = data is List<int> ? data : (data as Uint8List).toList();
|
|
await onReceivedFrame(bytes);
|
|
sendHeartBeat();
|
|
}
|
|
|
|
@override
|
|
Future<void> closeTransport() async {
|
|
await _transport.close();
|
|
}
|
|
|
|
@override
|
|
bool get isSourcePaused => _subscription?.isPaused ?? false;
|
|
}
|
|
|
|
class _WebSocketTransport implements Transport {
|
|
final WebSocket _ws;
|
|
|
|
_WebSocketTransport(this._ws);
|
|
|
|
@override
|
|
Future<void> writePacket(PacketBase base) async {
|
|
_ws.add(base.writeToBuffer());
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
try {
|
|
await _ws.close();
|
|
} catch (_) {
|
|
// already closed by peer
|
|
}
|
|
}
|
|
}
|