From 86d704a0bb248730470748419bcd3705bca6d0d9 Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 24 May 2026 22:36:52 +0900 Subject: [PATCH] feat: dart web e2e support and ws protobuf fixes - Add browser WebSocket import compatibility test - Add platform-specific WebSocket clients (io/web) - Add protobuf client/server web variants - Fix WS protobuf server for browser compatibility - Remove deprecated ws_protobuf_client.dart (consolidated into platform-specific files) - Update test and server files for websocket functionality --- README.md | 3 +- .../run-proto-socket-test-matrix/SKILL.md | 4 +- .../scripts/run_matrix.sh | 2 +- dart/lib/proto_socket.dart | 12 +- dart/lib/src/protobuf_client_web.dart | 2 + dart/lib/src/protobuf_server_web.dart | 2 + ...client.dart => ws_protobuf_client_io.dart} | 0 dart/lib/src/ws_protobuf_client_web.dart | 126 ++++++++++++++++++ dart/lib/src/ws_protobuf_server.dart | 2 +- dart/lib/src/ws_protobuf_server_web.dart | 3 + dart/test/browser_ws_import_compile.dart | 8 ++ go/test/ws_test.go | 59 ++++++++ go/ws_server.go | 13 +- 13 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 dart/lib/src/protobuf_client_web.dart create mode 100644 dart/lib/src/protobuf_server_web.dart rename dart/lib/src/{ws_protobuf_client.dart => ws_protobuf_client_io.dart} (100%) create mode 100644 dart/lib/src/ws_protobuf_client_web.dart create mode 100644 dart/lib/src/ws_protobuf_server_web.dart create mode 100644 dart/test/browser_ws_import_compile.dart diff --git a/README.md b/README.md index 46d94be..0e7d19e 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Keep implementation status here as the public snapshot. Current work context is | Full local validation | `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all` | Proto sync, same-language tests, and all available cross-language checks | | Proto schema sync check | `tools/check_proto_sync.sh` | Verifies language proto copies match `proto/message_common.proto` except allowed language options | | Regenerate protobuf bindings | `tools/generate_proto.sh` | Run after changing the canonical proto schema | -| Dart tests | `cd dart && dart pub get && dart test` | Dart same-language checks | +| Dart tests | `cd dart && dart pub get && dart test && dart compile js test/browser_ws_import_compile.dart -o /tmp/proto_socket_browser_ws_import_compile.js` | Dart VM/IO tests and web import compile check | | Go tests | `cd go && go test ./...` | Go same-language checks | | Kotlin tests | `cd kotlin && ./gradlew test` | Kotlin same-language checks | | Python tests | `cd python && python3 -m pytest -q` | Python same-language checks | @@ -241,6 +241,7 @@ Same-language checks: cd dart dart pub get dart test +dart compile js test/browser_ws_import_compile.dart -o /tmp/proto_socket_browser_ws_import_compile.js ``` ```bash diff --git a/agent-ops/skills/project/run-proto-socket-test-matrix/SKILL.md b/agent-ops/skills/project/run-proto-socket-test-matrix/SKILL.md index 65d2460..01fbffd 100644 --- a/agent-ops/skills/project/run-proto-socket-test-matrix/SKILL.md +++ b/agent-ops/skills/project/run-proto-socket-test-matrix/SKILL.md @@ -57,7 +57,7 @@ tools/check_proto_sync.sh - 동일 언어 테스트: ```bash -(cd dart && dart pub get && dart test) +(cd dart && dart pub get && dart test && dart compile js test/browser_ws_import_compile.dart -o /tmp/proto_socket_browser_ws_import_compile.js) (cd go && go test ./...) (cd kotlin && ./gradlew test) (cd python && python3 -m pytest -q) @@ -119,7 +119,7 @@ tools/check_proto_sync.sh **동일언어** | 언어 | 명령 | 결과 | |---|---|---| -| Dart | `dart pub get && dart test` | PASS | +| Dart | `dart pub get && dart test && dart compile js test/browser_ws_import_compile.dart -o /tmp/proto_socket_browser_ws_import_compile.js` | PASS | **언어간 통신** | 서버 \ 클라이언트 | Dart | Go | Kotlin | Python | TypeScript | diff --git a/agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh b/agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh index 8e768a3..bc11318 100755 --- a/agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh +++ b/agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh @@ -28,7 +28,7 @@ log_dir="$(mktemp -d "${TMPDIR:-/tmp}/proto-socket-matrix.XXXXXX")" languages=("Dart" "Go" "Kotlin" "Python" "TypeScript") declare -A unit_cmds=( - ["Dart"]="dart pub get && dart test" + ["Dart"]="dart pub get && dart test && dart compile js test/browser_ws_import_compile.dart -o /tmp/proto_socket_browser_ws_import_compile.js" ["Go"]="go test ./..." ["Kotlin"]="./gradlew test" ["Python"]="python3 -m pytest -q" diff --git a/dart/lib/proto_socket.dart b/dart/lib/proto_socket.dart index b55ceff..45703a5 100644 --- a/dart/lib/proto_socket.dart +++ b/dart/lib/proto_socket.dart @@ -3,9 +3,13 @@ library proto_socket; export 'src/communicator.dart'; export 'src/transport.dart'; export 'src/base_client.dart'; -export 'src/protobuf_client.dart'; -export 'src/protobuf_server.dart'; +export 'src/protobuf_client.dart' + if (dart.library.html) 'src/protobuf_client_web.dart'; +export 'src/protobuf_server.dart' + if (dart.library.html) 'src/protobuf_server_web.dart'; export 'src/response_checker.dart'; -export 'src/ws_protobuf_client.dart'; -export 'src/ws_protobuf_server.dart'; +export 'src/ws_protobuf_client_io.dart' + if (dart.library.html) 'src/ws_protobuf_client_web.dart'; +export 'src/ws_protobuf_server.dart' + if (dart.library.html) 'src/ws_protobuf_server_web.dart'; export 'src/packets/message_common.pb.dart'; diff --git a/dart/lib/src/protobuf_client_web.dart b/dart/lib/src/protobuf_client_web.dart new file mode 100644 index 0000000..218a21b --- /dev/null +++ b/dart/lib/src/protobuf_client_web.dart @@ -0,0 +1,2 @@ +// Browser stub: TCP `ProtobufClient` is not available on web targets because +// it depends on `dart:io` sockets. Use `WsProtobufClient` for Flutter Web. diff --git a/dart/lib/src/protobuf_server_web.dart b/dart/lib/src/protobuf_server_web.dart new file mode 100644 index 0000000..726bd16 --- /dev/null +++ b/dart/lib/src/protobuf_server_web.dart @@ -0,0 +1,2 @@ +// Browser stub: TCP `ProtobufServer` is not available on web targets because +// it depends on `dart:io` ServerSocket. Server implementations remain IO-only. diff --git a/dart/lib/src/ws_protobuf_client.dart b/dart/lib/src/ws_protobuf_client_io.dart similarity index 100% rename from dart/lib/src/ws_protobuf_client.dart rename to dart/lib/src/ws_protobuf_client_io.dart diff --git a/dart/lib/src/ws_protobuf_client_web.dart b/dart/lib/src/ws_protobuf_client_web.dart new file mode 100644 index 0000000..57fd05e --- /dev/null +++ b/dart/lib/src/ws_protobuf_client_web.dart @@ -0,0 +1,126 @@ +// ignore_for_file: avoid_init_to_null, prefer_final_fields, deprecated_member_use + +import 'dart:async'; +import 'dart:html' as html; +import 'dart:typed_data'; + +import 'package:protobuf/protobuf.dart'; +import 'base_client.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; + } + + WsProtobufClient(this._ws, int heartbeatIntervalTime, int heartbeatWaitTime, + Map)> parserMap) { + initSelf(this); + initHeartbeat(heartbeatIntervalTime, heartbeatWaitTime); + _transport = _WebSocketTransport(_ws); + isAlive = true; + parserMap.addAll({(HeartBeat).toString(): HeartBeat.fromBuffer}); + super.initialize(parserMap, transport: _transport); + _ws.onMessage.listen(_onMessage, onError: onError); + _ws.onClose.listen((_) => close()); + addListener(onHeartBeat); + sendHeartBeat(); + } + + void onError(dynamic e) {} + + void _onMessage(html.MessageEvent event) { + final data = event.data; + if (data is ByteBuffer) { + _dispatch(data.asUint8List()); + } else if (data is Uint8List) { + _dispatch(data); + } else if (data is List) { + _dispatch(data); + } else if (data is html.Blob) { + final reader = html.FileReader(); + reader.readAsArrayBuffer(data); + reader.onLoad.first.then((_) { + final result = reader.result; + if (result is ByteBuffer) { + _dispatch(result.asUint8List()); + } + }); + } + } + + void _dispatch(List bytes) { + final common = PacketBase.fromBuffer(bytes); + onReceivedData(common.typeName, common.data, + incomingNonce: common.nonce, responseNonce: common.responseNonce); + sendHeartBeat(); + } + + @override + Future closeTransport() async { + await _transport.close(); + } +} + +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 + } + } +} diff --git a/dart/lib/src/ws_protobuf_server.dart b/dart/lib/src/ws_protobuf_server.dart index d6cd8c2..e38d344 100644 --- a/dart/lib/src/ws_protobuf_server.dart +++ b/dart/lib/src/ws_protobuf_server.dart @@ -4,7 +4,7 @@ import 'dart:async'; import 'dart:io'; import 'package:protobuf/protobuf.dart'; import 'packets/message_common.pb.dart'; -import 'ws_protobuf_client.dart'; +import 'ws_protobuf_client_io.dart'; abstract class WsProtobufServer { List _clientList = []; diff --git a/dart/lib/src/ws_protobuf_server_web.dart b/dart/lib/src/ws_protobuf_server_web.dart new file mode 100644 index 0000000..932fde4 --- /dev/null +++ b/dart/lib/src/ws_protobuf_server_web.dart @@ -0,0 +1,3 @@ +// Browser stub: `WsProtobufServer` is not available on web targets because it +// depends on `dart:io` HttpServer. WebSocket server implementations remain +// IO-only; browsers can only act as clients via `WsProtobufClient`. diff --git a/dart/test/browser_ws_import_compile.dart b/dart/test/browser_ws_import_compile.dart new file mode 100644 index 0000000..7ec0911 --- /dev/null +++ b/dart/test/browser_ws_import_compile.dart @@ -0,0 +1,8 @@ +import 'package:proto_socket/proto_socket.dart'; + +void main() { + final types = [WsProtobufClient, PacketBase, Transport]; + if (types.isEmpty) { + throw StateError('unreachable'); + } +} diff --git a/go/test/ws_test.go b/go/test/ws_test.go index b0625fb..3f52e46 100644 --- a/go/test/ws_test.go +++ b/go/test/ws_test.go @@ -244,3 +244,62 @@ func TestWsServerStopDisconnectsClients(t *testing.T) { t.Fatal("client is still marked alive after server stop") } } + +func TestWsOriginPatterns(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 1. Server with Allowed OriginPatterns + portAllowed := freePort(t) + optsAllowed := toki.WsServerOptions{ + AcceptOptions: &websocket.AcceptOptions{ + OriginPatterns: []string{"localhost:3000"}, + }, + } + serverAllowed := toki.NewWsServerWithOptions("127.0.0.1", portAllowed, "/", optsAllowed, func(conn *websocket.Conn) *toki.WsClient { + return toki.NewWsClient(conn, 0, 0, testParserMap()) + }) + if err := serverAllowed.Start(ctx); err != nil { + t.Fatal(err) + } + defer serverAllowed.Stop() + + // Attempt dial with allowed origin + dialOpts := &websocket.DialOptions{ + HTTPHeader: map[string][]string{ + "Origin": {"http://localhost:3000"}, + }, + } + connAllowed, _, err := websocket.Dial(ctx, fmt.Sprintf("ws://127.0.0.1:%d/", portAllowed), dialOpts) + if err != nil { + t.Fatalf("expected allowed origin to succeed, got: %v", err) + } + _ = connAllowed.Close(websocket.StatusNormalClosure, "") + + // Attempt dial with disallowed origin (should fail) + dialOptsBad := &websocket.DialOptions{ + HTTPHeader: map[string][]string{ + "Origin": {"http://malicious.com"}, + }, + } + _, _, err = websocket.Dial(ctx, fmt.Sprintf("ws://127.0.0.1:%d/", portAllowed), dialOptsBad) + if err == nil { + t.Fatal("expected disallowed origin to be rejected, but it succeeded") + } + + // 2. Default Server (no options, same-origin only) + portDefault := freePort(t) + serverDefault := toki.NewWsServer("127.0.0.1", portDefault, "/", func(conn *websocket.Conn) *toki.WsClient { + return toki.NewWsClient(conn, 0, 0, testParserMap()) + }) + if err := serverDefault.Start(ctx); err != nil { + t.Fatal(err) + } + defer serverDefault.Stop() + + // Default server should reject local cross-origin + _, _, err = websocket.Dial(ctx, fmt.Sprintf("ws://127.0.0.1:%d/", portDefault), dialOpts) + if err == nil { + t.Fatal("expected default server to reject cross-origin, but it succeeded") + } +} diff --git a/go/ws_server.go b/go/ws_server.go index 9373508..07e9661 100644 --- a/go/ws_server.go +++ b/go/ws_server.go @@ -25,6 +25,7 @@ type WsServer struct { listener net.Listener started bool OnClientConnected func(*WsClient) + acceptOptions *websocket.AcceptOptions } func NewWsServer(host string, port int, path string, newClient func(*websocket.Conn) *WsClient) *WsServer { @@ -43,6 +44,16 @@ func NewWsServerTLS(host string, port int, path string, tlsCfg *tls.Config, newC return s } +type WsServerOptions struct { + AcceptOptions *websocket.AcceptOptions +} + +func NewWsServerWithOptions(host string, port int, path string, options WsServerOptions, newClient func(*websocket.Conn) *WsClient) *WsServer { + s := NewWsServer(host, port, path, newClient) + s.acceptOptions = options.AcceptOptions + return s +} + func (s *WsServer) Started() bool { s.mu.Lock() defer s.mu.Unlock() @@ -128,7 +139,7 @@ func (s *WsServer) Broadcast(m proto.Message) error { } func (s *WsServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { - conn, err := websocket.Accept(w, r, nil) + conn, err := websocket.Accept(w, r, s.acceptOptions) if err != nil { return }