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
This commit is contained in:
toki 2026-05-24 22:36:52 +09:00
parent e0e70d846d
commit 86d704a0bb
13 changed files with 226 additions and 10 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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"

View file

@ -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';

View file

@ -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.

View file

@ -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.

View file

@ -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<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;
}
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).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<int>) {
_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<int> bytes) {
final common = PacketBase.fromBuffer(bytes);
onReceivedData(common.typeName, common.data,
incomingNonce: common.nonce, responseNonce: common.responseNonce);
sendHeartBeat();
}
@override
Future<void> closeTransport() async {
await _transport.close();
}
}
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
}
}
}

View file

@ -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<WsProtobufClient> _clientList = [];

View file

@ -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`.

View file

@ -0,0 +1,8 @@
import 'package:proto_socket/proto_socket.dart';
void main() {
final types = <Type>[WsProtobufClient, PacketBase, Transport];
if (types.isEmpty) {
throw StateError('unreachable');
}
}

View file

@ -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")
}
}

View file

@ -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
}