From 557c420028e759400b6b0f4e1cd17b3eca1c4c6d Mon Sep 17 00:00:00 2001 From: Brian Quinlan Date: Mon, 4 Mar 2024 11:25:31 -0800 Subject: [PATCH] Implement WebSocket for the browser (#1142) --- .github/workflows/dart.yml | 172 +++++++++++++++++- pkgs/web_socket/lib/browser_web_socket.dart | 5 + pkgs/web_socket/lib/io_web_socket.dart | 4 + .../lib/src/browser_web_socket.dart | 128 +++++++++++++ pkgs/web_socket/lib/src/io_web_socket.dart | 17 +- pkgs/web_socket/lib/src/utils.dart | 20 ++ pkgs/web_socket/lib/src/web_socket.dart | 4 + pkgs/web_socket/lib/web_socket.dart | 4 + pkgs/web_socket/mono_pkg.yaml | 7 + pkgs/web_socket/pubspec.yaml | 2 + .../browser_web_socket_conformance_test.dart | 14 ++ .../src/disconnect_after_upgrade_tests.dart | 1 + tool/ci.sh | 8 + 13 files changed, 370 insertions(+), 16 deletions(-) create mode 100644 pkgs/web_socket/lib/browser_web_socket.dart create mode 100644 pkgs/web_socket/lib/src/browser_web_socket.dart create mode 100644 pkgs/web_socket/lib/src/utils.dart create mode 100644 pkgs/web_socket/test/browser_web_socket_conformance_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 47b933047e..5f04f84ba7 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -457,6 +457,84 @@ jobs: - job_007 - job_008 job_012: + name: "unit_test; linux; Dart 3.3.0; PKG: pkgs/web_socket; `dart test --test-randomize-ordering-seed=random -p chrome -c dart2js`" + runs-on: ubuntu-latest + steps: + - name: Cache Pub hosted dependencies + uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 + with: + path: "~/.pub-cache/hosted" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0;packages:pkgs/web_socket;commands:test_6" + restore-keys: | + os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0;packages:pkgs/web_socket + os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0 + os:ubuntu-latest;pub-cache-hosted + os:ubuntu-latest + - name: Setup Dart SDK + uses: dart-lang/setup-dart@fedb1266e91cf51be2fdb382869461a434b920a3 + with: + sdk: "3.3.0" + - id: checkout + name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - id: pkgs_web_socket_pub_upgrade + name: pkgs/web_socket; dart pub upgrade + run: dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: pkgs/web_socket + - name: "pkgs/web_socket; dart test --test-randomize-ordering-seed=random -p chrome -c dart2js" + run: "dart test --test-randomize-ordering-seed=random -p chrome -c dart2js" + if: "always() && steps.pkgs_web_socket_pub_upgrade.conclusion == 'success'" + working-directory: pkgs/web_socket + needs: + - job_001 + - job_002 + - job_003 + - job_004 + - job_005 + - job_006 + - job_007 + - job_008 + job_013: + name: "unit_test; linux; Dart 3.3.0; PKG: pkgs/web_socket; `dart test --test-randomize-ordering-seed=random -p vm`" + runs-on: ubuntu-latest + steps: + - name: Cache Pub hosted dependencies + uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 + with: + path: "~/.pub-cache/hosted" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0;packages:pkgs/web_socket;commands:test_5" + restore-keys: | + os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0;packages:pkgs/web_socket + os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0 + os:ubuntu-latest;pub-cache-hosted + os:ubuntu-latest + - name: Setup Dart SDK + uses: dart-lang/setup-dart@fedb1266e91cf51be2fdb382869461a434b920a3 + with: + sdk: "3.3.0" + - id: checkout + name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - id: pkgs_web_socket_pub_upgrade + name: pkgs/web_socket; dart pub upgrade + run: dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: pkgs/web_socket + - name: "pkgs/web_socket; dart test --test-randomize-ordering-seed=random -p vm" + run: "dart test --test-randomize-ordering-seed=random -p vm" + if: "always() && steps.pkgs_web_socket_pub_upgrade.conclusion == 'success'" + working-directory: pkgs/web_socket + needs: + - job_001 + - job_002 + - job_003 + - job_004 + - job_005 + - job_006 + - job_007 + - job_008 + job_014: name: "unit_test; linux; Dart 3.4.0-154.0.dev; PKG: pkgs/http_profile; `dart test --platform vm`" runs-on: ubuntu-latest steps: @@ -495,7 +573,7 @@ jobs: - job_006 - job_007 - job_008 - job_013: + job_015: name: "unit_test; linux; Dart dev; PKG: pkgs/http; `dart run --define=no_default_http_client=true test/no_default_http_client_test.dart`" runs-on: ubuntu-latest steps: @@ -534,7 +612,7 @@ jobs: - job_006 - job_007 - job_008 - job_014: + job_016: name: "unit_test; linux; Dart dev; PKG: pkgs/http; `dart test --platform chrome`" runs-on: ubuntu-latest steps: @@ -573,7 +651,7 @@ jobs: - job_006 - job_007 - job_008 - job_015: + job_017: name: "unit_test; linux; Dart dev; PKGS: pkgs/http, pkgs/http_profile; `dart test --platform vm`" runs-on: ubuntu-latest steps: @@ -621,7 +699,7 @@ jobs: - job_006 - job_007 - job_008 - job_016: + job_018: name: "unit_test; linux; Dart dev; PKG: pkgs/http; `dart test --test-randomize-ordering-seed=random -p chrome -c dart2wasm`" runs-on: ubuntu-latest steps: @@ -660,7 +738,85 @@ jobs: - job_006 - job_007 - job_008 - job_017: + job_019: + name: "unit_test; linux; Dart dev; PKG: pkgs/web_socket; `dart test --test-randomize-ordering-seed=random -p chrome -c dart2js`" + runs-on: ubuntu-latest + steps: + - name: Cache Pub hosted dependencies + uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 + with: + path: "~/.pub-cache/hosted" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/web_socket;commands:test_6" + restore-keys: | + os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/web_socket + os:ubuntu-latest;pub-cache-hosted;sdk:dev + os:ubuntu-latest;pub-cache-hosted + os:ubuntu-latest + - name: Setup Dart SDK + uses: dart-lang/setup-dart@fedb1266e91cf51be2fdb382869461a434b920a3 + with: + sdk: dev + - id: checkout + name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - id: pkgs_web_socket_pub_upgrade + name: pkgs/web_socket; dart pub upgrade + run: dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: pkgs/web_socket + - name: "pkgs/web_socket; dart test --test-randomize-ordering-seed=random -p chrome -c dart2js" + run: "dart test --test-randomize-ordering-seed=random -p chrome -c dart2js" + if: "always() && steps.pkgs_web_socket_pub_upgrade.conclusion == 'success'" + working-directory: pkgs/web_socket + needs: + - job_001 + - job_002 + - job_003 + - job_004 + - job_005 + - job_006 + - job_007 + - job_008 + job_020: + name: "unit_test; linux; Dart dev; PKG: pkgs/web_socket; `dart test --test-randomize-ordering-seed=random -p vm`" + runs-on: ubuntu-latest + steps: + - name: Cache Pub hosted dependencies + uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 + with: + path: "~/.pub-cache/hosted" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/web_socket;commands:test_5" + restore-keys: | + os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/web_socket + os:ubuntu-latest;pub-cache-hosted;sdk:dev + os:ubuntu-latest;pub-cache-hosted + os:ubuntu-latest + - name: Setup Dart SDK + uses: dart-lang/setup-dart@fedb1266e91cf51be2fdb382869461a434b920a3 + with: + sdk: dev + - id: checkout + name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - id: pkgs_web_socket_pub_upgrade + name: pkgs/web_socket; dart pub upgrade + run: dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: pkgs/web_socket + - name: "pkgs/web_socket; dart test --test-randomize-ordering-seed=random -p vm" + run: "dart test --test-randomize-ordering-seed=random -p vm" + if: "always() && steps.pkgs_web_socket_pub_upgrade.conclusion == 'success'" + working-directory: pkgs/web_socket + needs: + - job_001 + - job_002 + - job_003 + - job_004 + - job_005 + - job_006 + - job_007 + - job_008 + job_021: name: "unit_test; linux; Flutter stable; PKG: pkgs/flutter_http_example; `flutter test --platform chrome`" runs-on: ubuntu-latest steps: @@ -699,7 +855,7 @@ jobs: - job_006 - job_007 - job_008 - job_018: + job_022: name: "unit_test; linux; Flutter stable; PKG: pkgs/flutter_http_example; `flutter test`" runs-on: ubuntu-latest steps: @@ -738,7 +894,7 @@ jobs: - job_006 - job_007 - job_008 - job_019: + job_023: name: "unit_test; macos; Flutter stable; PKG: pkgs/flutter_http_example; `flutter test`" runs-on: macos-latest steps: @@ -777,7 +933,7 @@ jobs: - job_006 - job_007 - job_008 - job_020: + job_024: name: "unit_test; windows; Flutter stable; PKG: pkgs/flutter_http_example; `flutter test`" runs-on: windows-latest steps: diff --git a/pkgs/web_socket/lib/browser_web_socket.dart b/pkgs/web_socket/lib/browser_web_socket.dart new file mode 100644 index 0000000000..e418d3d827 --- /dev/null +++ b/pkgs/web_socket/lib/browser_web_socket.dart @@ -0,0 +1,5 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'src/browser_web_socket.dart' show BrowserWebSocket; diff --git a/pkgs/web_socket/lib/io_web_socket.dart b/pkgs/web_socket/lib/io_web_socket.dart index eaea4f06dc..674dda11dc 100644 --- a/pkgs/web_socket/lib/io_web_socket.dart +++ b/pkgs/web_socket/lib/io_web_socket.dart @@ -1 +1,5 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + export 'src/io_web_socket.dart' show IOWebSocket; diff --git a/pkgs/web_socket/lib/src/browser_web_socket.dart b/pkgs/web_socket/lib/src/browser_web_socket.dart new file mode 100644 index 0000000000..eceb86c02c --- /dev/null +++ b/pkgs/web_socket/lib/src/browser_web_socket.dart @@ -0,0 +1,128 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import '../web_socket.dart'; +import 'utils.dart'; + +/// A [WebSocket] using the browser WebSocket API. +/// +/// Usable when targeting the browser using either JavaScript or WASM. +class BrowserWebSocket implements WebSocket { + final web.WebSocket _webSocket; + final _events = StreamController(); + + static Future connect(Uri url) async { + final webSocket = web.WebSocket(url.toString())..binaryType = 'arraybuffer'; + final browserSocket = BrowserWebSocket._(webSocket); + final webSocketConnected = Completer(); + + if (webSocket.readyState == web.WebSocket.OPEN) { + webSocketConnected.complete(browserSocket); + } else { + if (webSocket.readyState == web.WebSocket.CLOSING || + webSocket.readyState == web.WebSocket.CLOSED) { + webSocketConnected.completeError(WebSocketException( + 'Unexpected WebSocket state: ${webSocket.readyState}, ' + 'expected CONNECTING (0) or OPEN (1)')); + } else { + // The socket API guarantees that only a single open event will be + // emitted. + unawaited(webSocket.onOpen.first.then((_) { + webSocketConnected.complete(browserSocket); + })); + } + } + + unawaited(webSocket.onError.first.then((e) { + // Unfortunately, the underlying WebSocket API doesn't expose any + // specific information about the error itself. + if (!webSocketConnected.isCompleted) { + final error = WebSocketException('Failed to connect WebSocket'); + webSocketConnected.completeError(error); + } else { + browserSocket._closed(1006, 'error'); + } + })); + + webSocket.onMessage.listen((e) { + if (browserSocket._events.isClosed) return; + + final eventData = e.data!; + late WebSocketEvent data; + if (eventData.typeofEquals('string')) { + data = TextDataReceived((eventData as JSString).toDart); + } else if (eventData.typeofEquals('object') && + (eventData as JSObject).instanceOfString('ArrayBuffer')) { + data = BinaryDataReceived( + (eventData as JSArrayBuffer).toDart.asUint8List()); + } else { + throw StateError('unexpected message type: ${eventData.runtimeType}'); + } + browserSocket._events.add(data); + }); + + unawaited(webSocket.onClose.first.then((event) { + if (!webSocketConnected.isCompleted) { + webSocketConnected.complete(browserSocket); + } + browserSocket._closed(event.code, event.reason); + })); + + return webSocketConnected.future; + } + + void _closed(int? code, String? reason) { + if (_events.isClosed) return; + _events.add(CloseReceived(code, reason ?? '')); + unawaited(_events.close()); + } + + BrowserWebSocket._(this._webSocket); + + @override + void sendBytes(Uint8List b) { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + // Silently discards the data if the connection is closed. + _webSocket.send(b.jsify()!); + } + + @override + void sendText(String s) { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + // Silently discards the data if the connection is closed. + _webSocket.send(s.jsify()!); + } + + @override + Future close([int? code, String? reason]) async { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + + checkCloseCode(code); + checkCloseReason(reason); + + unawaited(_events.close()); + if ((code, reason) case (final closeCode?, final closeReason?)) { + _webSocket.close(closeCode, closeReason); + } else if (code case final closeCode?) { + _webSocket.close(closeCode); + } else { + _webSocket.close(); + } + } + + @override + Stream get events => _events.stream; +} diff --git a/pkgs/web_socket/lib/src/io_web_socket.dart b/pkgs/web_socket/lib/src/io_web_socket.dart index 4141aaff4a..3b17ccdf58 100644 --- a/pkgs/web_socket/lib/src/io_web_socket.dart +++ b/pkgs/web_socket/lib/src/io_web_socket.dart @@ -1,11 +1,17 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + import 'dart:async'; -import 'dart:convert'; import 'dart:io' as io; import 'dart:typed_data'; import '../web_socket.dart'; +import 'utils.dart'; /// A `dart-io`-based [WebSocket] implementation. +/// +/// Usable when targeting native platforms. class IOWebSocket implements WebSocket { final io.WebSocket _webSocket; final _events = StreamController(); @@ -70,13 +76,8 @@ class IOWebSocket implements WebSocket { throw StateError('WebSocket is closed'); } - if (code != null) { - RangeError.checkValueInInterval(code, 3000, 4999, 'code'); - } - if (reason != null && utf8.encode(reason).length > 123) { - throw ArgumentError.value(reason, 'reason', - 'reason must be <= 123 bytes long when encoded as UTF-8'); - } + checkCloseCode(code); + checkCloseReason(reason); unawaited(_events.close()); try { diff --git a/pkgs/web_socket/lib/src/utils.dart b/pkgs/web_socket/lib/src/utils.dart new file mode 100644 index 0000000000..06a290f711 --- /dev/null +++ b/pkgs/web_socket/lib/src/utils.dart @@ -0,0 +1,20 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +/// Throw if the given close code is not valid. +void checkCloseCode(int? code) { + if (code != null) { + RangeError.checkValueInInterval(code, 3000, 4999, 'code'); + } +} + +/// Throw if the given close reason is not valid. +void checkCloseReason(String? reason) { + if (reason != null && utf8.encode(reason).length > 123) { + throw ArgumentError.value(reason, 'reason', + 'reason must be <= 123 bytes long when encoded as UTF-8'); + } +} diff --git a/pkgs/web_socket/lib/src/web_socket.dart b/pkgs/web_socket/lib/src/web_socket.dart index 4109c37960..dfd3486f00 100644 --- a/pkgs/web_socket/lib/src/web_socket.dart +++ b/pkgs/web_socket/lib/src/web_socket.dart @@ -1,3 +1,7 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + import 'dart:typed_data'; /// An event received from the peer through the [WebSocket]. diff --git a/pkgs/web_socket/lib/web_socket.dart b/pkgs/web_socket/lib/web_socket.dart index b08a48fd61..33c8fec00e 100644 --- a/pkgs/web_socket/lib/web_socket.dart +++ b/pkgs/web_socket/lib/web_socket.dart @@ -1 +1,5 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + export 'src/web_socket.dart'; diff --git a/pkgs/web_socket/mono_pkg.yaml b/pkgs/web_socket/mono_pkg.yaml index 16e4e7a5f3..13baee341e 100644 --- a/pkgs/web_socket/mono_pkg.yaml +++ b/pkgs/web_socket/mono_pkg.yaml @@ -8,3 +8,10 @@ stages: - format: sdk: - dev +- unit_test: + - test: --test-randomize-ordering-seed=random -p vm + os: + - linux + - test: --test-randomize-ordering-seed=random -p chrome -c dart2js + os: + - linux diff --git a/pkgs/web_socket/pubspec.yaml b/pkgs/web_socket/pubspec.yaml index 6ebe7bbed5..f2ad2e24a5 100644 --- a/pkgs/web_socket/pubspec.yaml +++ b/pkgs/web_socket/pubspec.yaml @@ -12,3 +12,5 @@ dev_dependencies: test: ^1.24.0 web_socket_conformance_tests: path: ../web_socket_conformance_tests/ +dependencies: + web: ^0.5.0 diff --git a/pkgs/web_socket/test/browser_web_socket_conformance_test.dart b/pkgs/web_socket/test/browser_web_socket_conformance_test.dart new file mode 100644 index 0000000000..caddff137c --- /dev/null +++ b/pkgs/web_socket/test/browser_web_socket_conformance_test.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('browser') +library; + +import 'package:test/test.dart'; +import 'package:web_socket/browser_web_socket.dart'; +import 'package:web_socket_conformance_tests/web_socket_conformance_tests.dart'; + +void main() { + testAll((uri, {protocols}) => BrowserWebSocket.connect(uri)); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_tests.dart b/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_tests.dart index 16ccd68fa8..b5f52e9503 100644 --- a/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_tests.dart +++ b/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_tests.dart @@ -33,6 +33,7 @@ void testDisconnectAfterUpgrade( expect( (await channel.events.single as CloseReceived).code, anyOf([ + 1002, // protocol error 1005, // closed no status 1006, // closed abnormal ])); diff --git a/tool/ci.sh b/tool/ci.sh index d4cc8d2ee6..864885d1fe 100755 --- a/tool/ci.sh +++ b/tool/ci.sh @@ -99,6 +99,14 @@ for PKG in ${PKGS}; do echo 'dart test --test-randomize-ordering-seed=random -p chrome -c dart2wasm' dart test --test-randomize-ordering-seed=random -p chrome -c dart2wasm || EXIT_CODE=$? ;; + test_5) + echo 'dart test --test-randomize-ordering-seed=random -p vm' + dart test --test-randomize-ordering-seed=random -p vm || EXIT_CODE=$? + ;; + test_6) + echo 'dart test --test-randomize-ordering-seed=random -p chrome -c dart2js' + dart test --test-randomize-ordering-seed=random -p chrome -c dart2js || EXIT_CODE=$? + ;; *) echo -e "\033[31mUnknown TASK '${TASK}' - TERMINATING JOB\033[0m" exit 64