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:
parent
e0e70d846d
commit
86d704a0bb
13 changed files with 226 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
2
dart/lib/src/protobuf_client_web.dart
Normal file
2
dart/lib/src/protobuf_client_web.dart
Normal 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.
|
||||
2
dart/lib/src/protobuf_server_web.dart
Normal file
2
dart/lib/src/protobuf_server_web.dart
Normal 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.
|
||||
126
dart/lib/src/ws_protobuf_client_web.dart
Normal file
126
dart/lib/src/ws_protobuf_client_web.dart
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [];
|
||||
|
|
|
|||
3
dart/lib/src/ws_protobuf_server_web.dart
Normal file
3
dart/lib/src/ws_protobuf_server_web.dart
Normal 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`.
|
||||
8
dart/test/browser_ws_import_compile.dart
Normal file
8
dart/test/browser_ws_import_compile.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue