Merge package:web_socket_channel into the http monorepo
diff --git a/pkgs/web_socket_channel/.github/dependabot.yml b/pkgs/web_socket_channel/.github/dependabot.yml new file mode 100644 index 0000000..cde02ad --- /dev/null +++ b/pkgs/web_socket_channel/.github/dependabot.yml
@@ -0,0 +1,15 @@ +# Dependabot configuration file. +# See https://docs.github.com/en/code-security/dependabot/dependabot-version-updates +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: + - autosubmit + groups: + github-actions: + patterns: + - "*"
diff --git a/pkgs/web_socket_channel/.github/workflows/no-response.yml b/pkgs/web_socket_channel/.github/workflows/no-response.yml new file mode 100644 index 0000000..ab1ac49 --- /dev/null +++ b/pkgs/web_socket_channel/.github/workflows/no-response.yml
@@ -0,0 +1,37 @@ +# A workflow to close issues where the author hasn't responded to a request for +# more information; see https://github.com/actions/stale. + +name: No Response + +# Run as a daily cron. +on: + schedule: + # Every day at 8am + - cron: '0 8 * * *' + +# All permissions not specified are set to 'none'. +permissions: + issues: write + pull-requests: write + +jobs: + no-response: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'dart-lang' }} + steps: + - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e + with: + # Don't automatically mark inactive issues+PRs as stale. + days-before-stale: -1 + # Close needs-info issues and PRs after 14 days of inactivity. + days-before-close: 14 + stale-issue-label: "needs-info" + close-issue-message: > + Without additional information we're not able to resolve this issue. + Feel free to add more info or respond to any questions above and we + can reopen the case. Thanks for your contribution! + stale-pr-label: "needs-info" + close-pr-message: > + Without additional information we're not able to resolve this PR. + Feel free to add more info or respond to any questions above. + Thanks for your contribution!
diff --git a/pkgs/web_socket_channel/.github/workflows/publish.yaml b/pkgs/web_socket_channel/.github/workflows/publish.yaml new file mode 100644 index 0000000..27157a0 --- /dev/null +++ b/pkgs/web_socket_channel/.github/workflows/publish.yaml
@@ -0,0 +1,17 @@ +# A CI configuration to auto-publish pub packages. + +name: Publish + +on: + pull_request: + branches: [ master ] + push: + tags: [ 'v[0-9]+.[0-9]+.[0-9]+' ] + +jobs: + publish: + if: ${{ github.repository_owner == 'dart-lang' }} + uses: dart-lang/ecosystem/.github/workflows/publish.yaml@main + permissions: + id-token: write # Required for authentication using OIDC + pull-requests: write # Required for writing the pull request note
diff --git a/pkgs/web_socket_channel/.github/workflows/test-package.yml b/pkgs/web_socket_channel/.github/workflows/test-package.yml new file mode 100644 index 0000000..e07c713 --- /dev/null +++ b/pkgs/web_socket_channel/.github/workflows/test-package.yml
@@ -0,0 +1,71 @@ +name: CI + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ master ] + pull_request: + branches: [ master ] + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze + if: always() && steps.install.outcome == 'success' + + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + sdk: [3.3, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' + - name: Run Chrome tests + run: dart test --platform chrome + if: always() && steps.install.outcome == 'success' + - name: Run Chrome tests - wasm + run: dart test --platform chrome --compiler dart2wasm + if: always() && steps.install.outcome == 'success' && matrix.sdk == 'dev' + + # Run analysis against the oldest supported pub constraints. + downgrade: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + - run: dart pub downgrade + - run: dart analyze
diff --git a/pkgs/web_socket_channel/.gitignore b/pkgs/web_socket_channel/.gitignore new file mode 100644 index 0000000..49ce72d --- /dev/null +++ b/pkgs/web_socket_channel/.gitignore
@@ -0,0 +1,3 @@ +.dart_tool/ +.packages +pubspec.lock
diff --git a/pkgs/web_socket_channel/CHANGELOG.md b/pkgs/web_socket_channel/CHANGELOG.md new file mode 100644 index 0000000..aff1de9 --- /dev/null +++ b/pkgs/web_socket_channel/CHANGELOG.md
@@ -0,0 +1,156 @@ +## 3.0.1 + +- Remove unnecessary `dependency_overrides`. +- Remove obsolete documentation for `WebSocketChannel.new`. +- Update package `web: '>=0.5.0 <2.0.0'`. + +## 3.0.0 + +- Provide an adapter around `package:web_socket` `WebSocket`s and make it the + default implementation for `WebSocketChannel.connect`. +- **BREAKING**: Remove `WebSocketChannel` constructor. +- **BREAKING**: Make `WebSocketChannel` an `abstract interface`. +- **BREAKING**: `IOWebSocketChannel.ready` will throw + `WebSocketChannelException` instead of `WebSocketException`. + +## 2.4.5 + +- use secure random number generator for frame masking. + +## 2.4.4 + +- Require Dart `^3.3` +- Require `package:web` `^0.5.0`. + +## 2.4.3 + +- `HtmlWebSocketChannel`: Relax the type of the websocket parameter to the + constructor in order to mitigate a breaking change introduced in `2.4.1`. + +## 2.4.2 (retracted) + +- Allow `web: '>=0.3.0 <0.5.0'` + +## 2.4.1 + +- Update the examples to use `WebSocketChannel.ready` and clarify that + `WebSocketChannel.ready` should be awaited before sending data over the + `WebSocketChannel`. +- Mention `ready` in the docs for `connect`. +- Bump minimum Dart version to 3.2.0 +- Move to `pkg:web` to support WebAssembly compilation. + +## 2.4.0 + +- Add a `customClient` parameter to the `IOWebSocketChannel.connect` factory, + which allows the user to provide a custom `HttpClient` instance to use for the + WebSocket connection +- Bump minimum Dart version to 2.15.0 + +## 2.3.0 + +- Added a Future `ready` property to `WebSocketChannel`, which completes when + the connection is established +- Added a `connectTimeout` parameter to the `IOWebSocketChannel.connect` factory, + which controls the timeout of the WebSocket Future. +- Use platform agnostic code in README example. + +## 2.2.0 + +- Add `HtmlWebSocketChannel.innerWebSocket` getter to access features not exposed + through the shared `WebSocketChannel` interface. + +## 2.1.0 + +- Add `IOWebSocketChannel.innerWebSocket` getter to access features not exposed + through the shared `WebSocketChannel` interface. + +## 2.0.0 + +- Support null safety. +- Require Dart 2.12. + +## 1.2.0 + +* Add `protocols` argument to `WebSocketChannel.connect`. See the docs for + `WebSocket.connet`. +* Allow the latest crypto release (`3.x`). + +## 1.1.0 + +* Add `WebSocketChannel.connect` factory constructor supporting platform + independent creation of WebSockets providing the lowest common denominator + of support on dart:io and dart:html. + +## 1.0.15 + +* bug fix don't pass protocols parameter to WebSocket. + +## 1.0.14 + +* Updates to handle `Socket implements Stream<Uint8List>` + +## 1.0.13 + +* Internal changes for consistency with the Dart SDK. + +## 1.0.12 + +* Allow `stream_channel` version 2.x + +## 1.0.11 + +* Fixed description in pubspec. + +* Fixed lints in README.md. + +## 1.0.10 + +* Fixed links in README.md. + +* Added an example. + +* Fixed analysis lints that affected package score. + +## 1.0.9 + +* Set max SDK version to `<3.0.0`. + +## 1.0.8 + +* Remove use of deprecated constant name. + +## 1.0.7 + +* Support the latest dev SDK. + +## 1.0.6 + +* Declare support for `async` 2.0.0. + +## 1.0.5 + +* Increase the SDK version constraint to `<2.0.0-dev.infinity`. + +## 1.0.4 + +* Support `crypto` 2.0.0. + +## 1.0.3 + +* Fix all strong-mode errors and warnings. + +* Fix a bug where `HtmlWebSocketChannel.close()` would crash on non-Dartium + browsers if the close code and reason weren't provided explicitly. + +## 1.0.2 + +* Properly use `BASE64` from `dart:convert` rather than `crypto`. + +## 1.0.1 + +* Add support for `crypto` 1.0.0. + +## 1.0.0 + +* Initial version
diff --git a/pkgs/web_socket_channel/CONTRIBUTING.md b/pkgs/web_socket_channel/CONTRIBUTING.md new file mode 100644 index 0000000..d7111dd --- /dev/null +++ b/pkgs/web_socket_channel/CONTRIBUTING.md
@@ -0,0 +1,33 @@ +Want to contribute? Great! First, read this page (including the small print at +the end). + +### Before you contribute +Before we can use your code, you must sign the +[Google Individual Contributor License Agreement](https://cla.developers.google.com/about/google-individual) +(CLA), which you can do online. The CLA is necessary mainly because you own the +copyright to your changes, even after your contribution becomes part of our +codebase, so we need your permission to use and distribute your code. We also +need to be sure of various other things—for instance that you'll tell us if you +know that your code infringes on other people's patents. You don't have to sign +the CLA until after you've submitted your code for review and a member has +approved it, but you must do it before we can put your code into our codebase. + +Before you start working on a larger contribution, you should get in touch with +us first through the issue tracker with your idea so that we can help out and +possibly guide you. Coordinating up front makes it much easier to avoid +frustration later on. + +### Code reviews +All submissions, including submissions by project members, require review. + +### File headers +All files in the project must start with the following header. + + // Copyright (c) 2018, 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. + +### The small print +Contributions made by corporations are covered by a different agreement than the +one above, the +[Software Grant and Corporate Contributor License Agreement](https://developers.google.com/open-source/cla/corporate).
diff --git a/pkgs/web_socket_channel/LICENSE b/pkgs/web_socket_channel/LICENSE new file mode 100644 index 0000000..2372431 --- /dev/null +++ b/pkgs/web_socket_channel/LICENSE
@@ -0,0 +1,27 @@ +Copyright 2016, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/pkgs/web_socket_channel/README.md b/pkgs/web_socket_channel/README.md new file mode 100644 index 0000000..3ff0637 --- /dev/null +++ b/pkgs/web_socket_channel/README.md
@@ -0,0 +1,73 @@ +[](https://github.com/dart-lang/web_socket_channel/actions/workflows/test-package.yml) +[](https://pub.dev/packages/web_socket_channel) +[](https://pub.dev/packages/web_socket_channel/publisher) + +`package:web_socket_channel` provides cross-platform +[`StreamChannel`][stream_channel] wrappers for WebSocket connections. + +## Docs and Usage + +It provides a cross-platform +[`WebSocketChannel`][WebSocketChannel] API, a cross-platform implementation of +that API that communicates over an underlying [`StreamChannel`][stream_channel], +[an implementation][IOWebSocketChannel] that wraps `dart:io`'s `WebSocket` +class, and [a similar implementation][HtmlWebSocketChannel] that wraps +`dart:html`'s. + +[stream_channel]: https://pub.dev/packages/stream_channel +[WebSocketChannel]: https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel/WebSocketChannel-class.html +[IOWebSocketChannel]: https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel.io/IOWebSocketChannel-class.html +[HtmlWebSocketChannel]: https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel.html/HtmlWebSocketChannel-class.html + +It also provides constants for the WebSocket protocol's pre-defined status codes +in the [`status.dart` library][status]. It's strongly recommended that users +import this library with the prefix `status`. + +[status]: https://pub.dev/documentation/web_socket_channel/latest/status/status-library.html + +```dart +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; + +main() async { + final wsUrl = Uri.parse('ws://example.com'); + final channel = WebSocketChannel.connect(wsUrl); + + await channel.ready; + + channel.stream.listen((message) { + channel.sink.add('received!'); + channel.sink.close(status.goingAway); + }); +} +``` + +## `WebSocketChannel` + +The [`WebSocketChannel`][WebSocketChannel] class's most important role is as the +interface for WebSocket stream channels across all implementations and all +platforms. In addition to the base `StreamChannel` interface, it adds a +[`protocol`][protocol] getter that returns the negotiated protocol for the +socket, as well as [`closeCode`][closeCode] and [`closeReason`][closeReason] +getters that provide information about why the socket closed. + +[protocol]: https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel/WebSocketChannel/protocol.html +[closeCode]: https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel/WebSocketChannel/closeCode.html +[closeReason]: https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel/WebSocketChannel/closeReason.html + +The channel's [`sink` property][sink] is also special. It returns a +[`WebSocketSink`][WebSocketSink], which is just like a `StreamSink` except that +its [`close()`][sink.close] method supports optional `closeCode` and +`closeReason` parameters. These parameters allow the caller to signal to the +other socket exactly why they're closing the connection. + +[sink]: https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel/WebSocketChannel/sink.html +[WebSocketSink]: https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel/WebSocketSink-class.html +[sink.close]: https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel/WebSocketSink/close.html + +`WebSocketChannel` also works as a cross-platform implementation of the +WebSocket protocol. The [`WebSocketChannel.connect` constructor][connect] +connects to a listening server using the appropriate implementation for the +platform. + +[connect]: https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel/WebSocketChannel/WebSocketChannel.connect.html \ No newline at end of file
diff --git a/pkgs/web_socket_channel/analysis_options.yaml b/pkgs/web_socket_channel/analysis_options.yaml new file mode 100644 index 0000000..b7c530a --- /dev/null +++ b/pkgs/web_socket_channel/analysis_options.yaml
@@ -0,0 +1,27 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + +linter: + rules: + - avoid_bool_literals_in_conditional_expressions + - avoid_classes_with_only_static_members + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_returning_this + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - join_return_with_assignment + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_runtimeType_toString + - prefer_const_declarations + - prefer_expression_function_bodies + - prefer_final_locals + - prefer_void_to_null + - unnecessary_await_in_return + - use_string_buffers
diff --git a/pkgs/web_socket_channel/example/example.dart b/pkgs/web_socket_channel/example/example.dart new file mode 100644 index 0000000..b09b798 --- /dev/null +++ b/pkgs/web_socket_channel/example/example.dart
@@ -0,0 +1,18 @@ +// Copyright (c) 2018, 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 'package:web_socket_channel/status.dart' as status; +import 'package:web_socket_channel/web_socket_channel.dart'; + +void main() async { + final wsUrl = Uri.parse('ws://example.com'); + final channel = WebSocketChannel.connect(wsUrl); + + await channel.ready; + + channel.stream.listen((message) { + channel.sink.add('received!'); + channel.sink.close(status.goingAway); + }); +}
diff --git a/pkgs/web_socket_channel/lib/adapter_web_socket_channel.dart b/pkgs/web_socket_channel/lib/adapter_web_socket_channel.dart new file mode 100644 index 0000000..0271f44 --- /dev/null +++ b/pkgs/web_socket_channel/lib/adapter_web_socket_channel.dart
@@ -0,0 +1,149 @@ +// 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:typed_data'; + +import 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:web_socket/web_socket.dart'; + +import 'src/channel.dart'; +import 'src/exception.dart'; + +/// A [WebSocketChannel] implemented using [WebSocket]. +class AdapterWebSocketChannel extends StreamChannelMixin + implements WebSocketChannel { + @override + String? get protocol => _protocol; + String? _protocol; + + @override + int? get closeCode => _closeCode; + int? _closeCode; + + @override + String? get closeReason => _closeReason; + String? _closeReason; + + /// The close code set by the local user. + /// + /// To ensure proper ordering, this is stored until we get a done event on + /// [StreamChannelController.local]`.stream`. + int? _localCloseCode; + + /// The close reason set by the local user. + /// + /// To ensure proper ordering, this is stored until we get a done event on + /// [StreamChannelController.local]`.stream`. + String? _localCloseReason; + + /// Completer for [ready]. + final _readyCompleter = Completer<void>(); + + @override + Future<void> get ready => _readyCompleter.future; + + @override + Stream get stream => _controller.foreign.stream; + + final _controller = + StreamChannelController<Object?>(sync: true, allowForeignErrors: false); + + @override + late final WebSocketSink sink = _WebSocketSink(this); + + /// Creates a new WebSocket connection. + /// + /// If provided, the [protocols] argument indicates that subprotocols that + /// the peer is able to select. See + /// [RFC-6455 1.9](https://datatracker.ietf.org/doc/html/rfc6455#section-1.9). + /// + /// After construction, the [AdapterWebSocketChannel] may not be + /// connected to the peer. The [ready] future will complete after the channel + /// is connected. If there are errors creating the connection the [ready] + /// future will complete with an error. + factory AdapterWebSocketChannel.connect(Uri url, + {Iterable<String>? protocols}) => + AdapterWebSocketChannel(WebSocket.connect(url, protocols: protocols)); + + // Construct a [WebSocketWebSocketChannelAdapter] from an existing + // [WebSocket]. + AdapterWebSocketChannel(FutureOr<WebSocket> webSocket) { + Future<WebSocket> webSocketFuture; + if (webSocket is WebSocket) { + webSocketFuture = Future.value(webSocket); + } else { + webSocketFuture = webSocket; + } + + webSocketFuture.then((webSocket) { + webSocket.events.listen((event) { + switch (event) { + case TextDataReceived(text: final text): + _controller.local.sink.add(text); + case BinaryDataReceived(data: final data): + _controller.local.sink.add(data); + case CloseReceived(code: final code, reason: final reason): + _closeCode = code; + _closeReason = reason; + _controller.local.sink.close(); + } + }); + _controller.local.stream.listen((obj) { + try { + switch (obj) { + case final String s: + webSocket.sendText(s); + case final Uint8List b: + webSocket.sendBytes(b); + case final List<int> b: + webSocket.sendBytes(Uint8List.fromList(b)); + default: + throw UnsupportedError('Cannot send ${obj.runtimeType}'); + } + } on WebSocketConnectionClosed { + // There is nowhere to surface this error; `_controller.local.sink` + // has already been closed. + } + }, onDone: () async { + try { + await webSocket.close(_localCloseCode, _localCloseReason); + } on WebSocketConnectionClosed { + // It is not an error to close an already-closed `WebSocketChannel`. + } + }); + _protocol = webSocket.protocol; + _readyCompleter.complete(); + }, onError: (Object e) { + Exception error; + if (e is TimeoutException) { + // Required for backwards compatibility with `IOWebSocketChannel`. + error = e; + } else { + error = WebSocketChannelException.from(e); + } + _readyCompleter.completeError(error); + _controller.local.sink.addError(error); + _controller.local.sink.close(); + }); + } +} + +/// A [WebSocketSink] that tracks the close code and reason passed to [close]. +class _WebSocketSink extends DelegatingStreamSink implements WebSocketSink { + /// The channel to which this sink belongs. + final AdapterWebSocketChannel _channel; + + _WebSocketSink(AdapterWebSocketChannel channel) + : _channel = channel, + super(channel._controller.foreign.sink); + + @override + Future close([int? closeCode, String? closeReason]) { + _channel._localCloseCode = closeCode; + _channel._localCloseReason = closeReason; + return super.close(); + } +}
diff --git a/pkgs/web_socket_channel/lib/html.dart b/pkgs/web_socket_channel/lib/html.dart new file mode 100644 index 0000000..bc99a63 --- /dev/null +++ b/pkgs/web_socket_channel/lib/html.dart
@@ -0,0 +1,203 @@ +// Copyright (c) 2016, 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:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:web/web.dart'; + +import 'src/channel.dart'; +import 'src/exception.dart'; + +/// A [WebSocketChannel] that communicates using a `dart:html` [WebSocket]. +class HtmlWebSocketChannel extends StreamChannelMixin + implements WebSocketChannel { + /// The underlying `dart:html` [WebSocket]. + final WebSocket innerWebSocket; + + @override + String? get protocol => innerWebSocket.protocol; + + @override + int? get closeCode => _closeCode; + int? _closeCode; + + @override + String? get closeReason => _closeReason; + String? _closeReason; + + /// The number of bytes of data that have been queued but not yet transmitted + /// to the network. + int? get bufferedAmount => innerWebSocket.bufferedAmount; + + /// The close code set by the local user. + /// + /// To ensure proper ordering, this is stored until we get a done event on + /// [StreamChannelController.local]`.stream`. + int? _localCloseCode; + + /// The close reason set by the local user. + /// + /// To ensure proper ordering, this is stored until we get a done event on + /// [StreamChannelController.local]`.stream`. + String? _localCloseReason; + + /// Completer for [ready]. + late Completer<void> _readyCompleter; + + @override + Future<void> get ready => _readyCompleter.future; + + @override + Stream get stream => _controller.foreign.stream; + + final _controller = + StreamChannelController<Object?>(sync: true, allowForeignErrors: false); + + @override + late final WebSocketSink sink = _HtmlWebSocketSink(this); + + /// Creates a new WebSocket connection. + /// + /// Connects to [url] using [WebSocket.new] and returns a channel that can be + /// used to communicate over the resulting socket. The [url] may be either a + /// [String] or a [Uri]. The [protocols] parameter is the same as for + /// [WebSocket.new]. + /// + /// The [binaryType] parameter controls what type is used for binary messages + /// received by this socket. It defaults to [BinaryType.list], which causes + /// binary messages to be delivered as [Uint8List]s. If it's + /// [BinaryType.blob], they're delivered as [Blob]s instead. + HtmlWebSocketChannel.connect(Object url, + {Iterable<String>? protocols, BinaryType? binaryType}) + : this( + WebSocket( + url.toString(), + protocols?.map((e) => e.toJS).toList().toJS ?? JSArray(), + )..binaryType = (binaryType ?? BinaryType.list).value, + ); + + /// Creates a channel wrapping [webSocket]. + /// + /// The parameter [webSocket] should be either a dart:html `WebSocket` + /// instance or a package:web [WebSocket] instance. + HtmlWebSocketChannel(Object /*WebSocket*/ webSocket) + : innerWebSocket = webSocket as WebSocket { + _readyCompleter = Completer(); + if (innerWebSocket.readyState == WebSocket.OPEN) { + _readyCompleter.complete(); + _listen(); + } else { + if (innerWebSocket.readyState == WebSocket.CLOSING || + innerWebSocket.readyState == WebSocket.CLOSED) { + _readyCompleter.completeError(WebSocketChannelException( + 'WebSocket state error: ${innerWebSocket.readyState}')); + } + // The socket API guarantees that only a single open event will be + // emitted. + innerWebSocket.onOpen.first.then((_) { + _readyCompleter.complete(); + _listen(); + }); + } + + // The socket API guarantees that only a single error event will be emitted, + // and that once it is no open or message events will be emitted. + innerWebSocket.onError.first.then((_) { + // Unfortunately, the underlying WebSocket API doesn't expose any + // specific information about the error itself. + final error = WebSocketChannelException('WebSocket connection failed.'); + if (!_readyCompleter.isCompleted) { + _readyCompleter.completeError(error); + } + _controller.local.sink.addError(error); + _controller.local.sink.close(); + }); + + innerWebSocket.onMessage.listen(_innerListen); + + // The socket API guarantees that only a single error event will be emitted, + // and that once it is no other events will be emitted. + innerWebSocket.onClose.first.then((event) { + _closeCode = event.code; + _closeReason = event.reason; + _controller.local.sink.close(); + }); + } + + void _innerListen(MessageEvent event) { + // Event data will be ArrayBuffer, Blob, or String. + final eventData = event.data; + final Object? data; + if (eventData.typeofEquals('string')) { + data = (eventData as JSString).toDart; + } else if (eventData.typeofEquals('object') && + (eventData as JSObject).instanceOfString('ArrayBuffer')) { + data = (eventData as JSArrayBuffer).toDart.asUint8List(); + } else { + // Blobs are passed directly. + data = eventData; + } + _controller.local.sink.add(data); + } + + /// Pipes user events to [innerWebSocket]. + void _listen() { + _controller.local.stream.listen((obj) => innerWebSocket.send(obj!.jsify()!), + onDone: () { + // On Chrome and possibly other browsers, `null` can't be passed as the + // default here. The actual arity of the function call must be correct or + // it will fail. + if ((_localCloseCode, _localCloseReason) + case (final closeCode?, final closeReason?)) { + innerWebSocket.close(closeCode, closeReason); + } else if (_localCloseCode case final closeCode?) { + innerWebSocket.close(closeCode); + } else { + innerWebSocket.close(); + } + }); + } +} + +/// A [WebSocketSink] that tracks the close code and reason passed to [close]. +class _HtmlWebSocketSink extends DelegatingStreamSink implements WebSocketSink { + /// The channel to which this sink belongs. + final HtmlWebSocketChannel _channel; + + _HtmlWebSocketSink(HtmlWebSocketChannel channel) + : _channel = channel, + super(channel._controller.foreign.sink); + + @override + Future close([int? closeCode, String? closeReason]) { + _channel._localCloseCode = closeCode; + _channel._localCloseReason = closeReason; + return super.close(); + } +} + +/// An enum for choosing what type [HtmlWebSocketChannel] emits for binary +/// messages. +class BinaryType { + /// Tells the channel to emit binary messages as [Blob]s. + static const blob = BinaryType._('blob', 'blob'); + + /// Tells the channel to emit binary messages as [Uint8List]s. + static const list = BinaryType._('list', 'arraybuffer'); + + /// The name of the binary type, which matches its variable name. + final String name; + + /// The value as understood by the underlying [WebSocket] API. + final String value; + + const BinaryType._(this.name, this.value); + + @override + String toString() => name; +}
diff --git a/pkgs/web_socket_channel/lib/io.dart b/pkgs/web_socket_channel/lib/io.dart new file mode 100644 index 0000000..ff10d1a --- /dev/null +++ b/pkgs/web_socket_channel/lib/io.dart
@@ -0,0 +1,63 @@ +// Copyright (c) 2016, 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:io' show HttpClient, WebSocket; + +import 'package:web_socket/io_web_socket.dart' show IOWebSocket; + +import 'adapter_web_socket_channel.dart'; +import 'src/channel.dart'; +import 'src/exception.dart'; + +/// A [WebSocketChannel] that communicates using a `dart:io` [WebSocket]. +class IOWebSocketChannel extends AdapterWebSocketChannel { + /// Creates a new WebSocket connection. + /// + /// Connects to [url] using [WebSocket.connect] and returns a channel that can + /// be used to communicate over the resulting socket. The [url] may be either + /// a [String] or a [Uri]. The [protocols] and [headers] parameters are the + /// same as [WebSocket.connect]. + /// + /// [pingInterval] controls the interval for sending ping signals. If a ping + /// message is not answered by a pong message from the peer, the WebSocket is + /// assumed disconnected and the connection is closed with a `goingAway` code. + /// When a ping signal is sent, the pong message must be received within + /// [pingInterval]. It defaults to `null`, indicating that ping messages are + /// disabled. + /// + /// [connectTimeout] determines how long to wait for [WebSocket.connect] + /// before throwing a [TimeoutException]. If connectTimeout is null then the + /// connection process will never time-out. + /// + /// If there's an error connecting, the channel's stream emits a + /// [WebSocketChannelException] wrapping that error and then closes. + factory IOWebSocketChannel.connect( + Object url, { + Iterable<String>? protocols, + Map<String, dynamic>? headers, + Duration? pingInterval, + Duration? connectTimeout, + HttpClient? customClient, + }) { + var webSocketFuture = WebSocket.connect( + url.toString(), + headers: headers, + protocols: protocols, + customClient: customClient, + ).then((webSocket) => webSocket..pingInterval = pingInterval); + + if (connectTimeout != null) { + webSocketFuture = webSocketFuture.timeout(connectTimeout); + } + + return IOWebSocketChannel(webSocketFuture); + } + + /// Creates a channel wrapping [webSocket]. + IOWebSocketChannel(FutureOr<WebSocket> webSocket) + : super(webSocket is Future<WebSocket> + ? webSocket.then(IOWebSocket.fromWebSocket) as FutureOr<IOWebSocket> + : IOWebSocket.fromWebSocket(webSocket)); +}
diff --git a/pkgs/web_socket_channel/lib/src/channel.dart b/pkgs/web_socket_channel/lib/src/channel.dart new file mode 100644 index 0000000..f8a560e --- /dev/null +++ b/pkgs/web_socket_channel/lib/src/channel.dart
@@ -0,0 +1,126 @@ +// Copyright (c) 2016, 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' as convert; + +import 'package:async/async.dart'; +import 'package:crypto/crypto.dart'; +import 'package:stream_channel/stream_channel.dart'; + +import '../adapter_web_socket_channel.dart'; +import 'exception.dart'; + +const String _webSocketGUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +/// A [StreamChannel] that communicates over a WebSocket. +/// +/// This is implemented by classes that use `dart:io` and `dart:html`. +/// The [WebSocketChannel.new] constructor can also be used on any platform to +/// connect to use the WebSocket protocol over a pre-existing channel. +/// +/// All implementations emit [WebSocketChannelException]s. These exceptions wrap +/// the native exception types where possible. +abstract interface class WebSocketChannel extends StreamChannelMixin { + /// The subprotocol selected by the server. + /// + /// For a client socket, this is initially `null`. After the WebSocket + /// connection is established the value is set to the subprotocol selected by + /// the server. If no subprotocol is negotiated the value will remain `null`. + String? get protocol; + + /// The [close code][] set when the WebSocket connection is closed. + /// + /// [close code]: https://tools.ietf.org/html/rfc6455#section-7.1.5 + /// + /// Before the connection has been closed, this will be `null`. + int? get closeCode; + + /// The [close reason][] set when the WebSocket connection is closed. + /// + /// [close reason]: https://tools.ietf.org/html/rfc6455#section-7.1.6 + /// + /// Before the connection has been closed, this will be `null`. + String? get closeReason; + + /// A future that will complete when the WebSocket connection has been + /// established. + /// + /// This future must be complete before before data can be sent using + /// [WebSocketChannel.sink]. + /// + /// If a connection could not be established (e.g. because of a network + /// issue), then this future will complete with an error. + /// + /// For example: + /// ``` + /// final channel = WebSocketChannel.connect(Uri.parse('ws://example.com')); + /// + /// try { + /// await channel.ready; + /// } on SocketException catch (e) { + /// // Handle the exception. + /// } on WebSocketChannelException catch (e) { + /// // Handle the exception. + /// } + /// + /// // If `ready` completes without an error then the channel is ready to + /// // send data. + /// channel.sink.add('Hello World'); + /// ``` + Future<void> get ready; + + /// The sink for sending values to the other endpoint. + /// + /// This supports additional arguments to [WebSocketSink.close] that provide + /// the remote endpoint reasons for closing the connection. + @override + WebSocketSink get sink; + + /// Signs a `Sec-WebSocket-Key` header sent by a WebSocket client as part of + /// the [initial handshake][]. + /// + /// The return value should be sent back to the client in a + /// `Sec-WebSocket-Accept` header. + /// + /// [initial handshake]: https://tools.ietf.org/html/rfc6455#section-4.2.2 + static String signKey(String key) + // We use [codeUnits] here rather than UTF-8-decoding the string because + // [key] is expected to be base64 encoded, and so will be pure ASCII. + => + convert.base64 + .encode(sha1.convert((key + _webSocketGUID).codeUnits).bytes); + + /// Creates a new WebSocket connection. + /// + /// Connects to [uri] using and returns a channel that can be used to + /// communicate over the resulting socket. + /// + /// The optional [protocols] parameter is the same as `WebSocket.connect`. + /// + /// A WebSocketChannel is returned synchronously, however the connection is + /// not established synchronously. + /// The [ready] future will complete after the channel is connected. + /// If there are errors creating the connection the [ready] future will + /// complete with an error. + static WebSocketChannel connect(Uri uri, {Iterable<String>? protocols}) => + AdapterWebSocketChannel.connect(uri, protocols: protocols); +} + +/// The sink exposed by a [WebSocketChannel]. +/// +/// This is like a normal [StreamSink], except that it supports extra arguments +/// to [close]. +abstract interface class WebSocketSink implements DelegatingStreamSink { + /// Closes the web socket connection. + /// + /// [closeCode] and [closeReason] are the [close code][] and [reason][] sent + /// to the remote peer, respectively. If they are omitted, the peer will see + /// a "no status received" code with no reason. + /// + /// [close code]: https://tools.ietf.org/html/rfc6455#section-7.1.5 + /// [reason]: https://tools.ietf.org/html/rfc6455#section-7.1.6 + @override + Future close([int? closeCode, String? closeReason]); +}
diff --git a/pkgs/web_socket_channel/lib/src/exception.dart b/pkgs/web_socket_channel/lib/src/exception.dart new file mode 100644 index 0000000..11de8ab --- /dev/null +++ b/pkgs/web_socket_channel/lib/src/exception.dart
@@ -0,0 +1,22 @@ +// Copyright (c) 2016, 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 'channel.dart'; + +/// An exception thrown by a [WebSocketChannel]. +class WebSocketChannelException implements Exception { + final String? message; + + /// The exception that caused this one, if available. + final Object? inner; + + WebSocketChannelException([this.message]) : inner = null; + + WebSocketChannelException.from(this.inner) : message = inner.toString(); + + @override + String toString() => message == null + ? 'WebSocketChannelException' + : 'WebSocketChannelException: $message'; +}
diff --git a/pkgs/web_socket_channel/lib/src/sink_completer.dart b/pkgs/web_socket_channel/lib/src/sink_completer.dart new file mode 100644 index 0000000..cea8c17 --- /dev/null +++ b/pkgs/web_socket_channel/lib/src/sink_completer.dart
@@ -0,0 +1,154 @@ +// Copyright (c) 2016, 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 'channel.dart'; + +/// A [WebSocketSink] where the destination is provided later. +/// +/// This is like a `StreamSinkCompleter`, except that it properly forwards +/// parameters to [WebSocketSink.close]. +class WebSocketSinkCompleter { + /// The sink for this completer. + /// + /// When a destination sink is provided, events that have been passed to the + /// sink will be forwarded to the destination. + /// + /// Events can be added to the sink either before or after a destination sink + /// is set. + final WebSocketSink sink = _CompleterSink(); + + /// Returns [sink] typed as a [_CompleterSink]. + _CompleterSink get _sink => sink as _CompleterSink; + + /// Sets a sink as the destination for events from the + /// [WebSocketSinkCompleter]'s [sink]. + /// + /// The completer's [sink] will act exactly as [destinationSink]. + /// + /// If the destination sink is set before events are added to [sink], further + /// events are forwarded directly to [destinationSink]. + /// + /// If events are added to [sink] before setting the destination sink, they're + /// buffered until the destination is available. + /// + /// A destination sink may be set at most once. + void setDestinationSink(WebSocketSink destinationSink) { + if (_sink._destinationSink != null) { + throw StateError('Destination sink already set'); + } + _sink._setDestinationSink(destinationSink); + } +} + +/// [WebSocketSink] completed by [WebSocketSinkCompleter]. +class _CompleterSink implements WebSocketSink { + /// Controller for an intermediate sink. + /// + /// Created if the user adds events to this sink before the destination sink + /// is set. + StreamController? _controller; + + /// Completer for [done]. + /// + /// Created if the user requests the [done] future before the destination sink + /// is set. + Completer? _doneCompleter; + + /// Destination sink for the events added to this sink. + /// + /// Set when [WebSocketSinkCompleter.setDestinationSink] is called. + WebSocketSink? _destinationSink; + + /// The close code passed to [close]. + int? _closeCode; + + /// The close reason passed to [close]. + String? _closeReason; + + /// Whether events should be sent directly to [_destinationSink], as opposed + /// to going through [_controller]. + bool get _canSendDirectly => _controller == null && _destinationSink != null; + + @override + Future get done { + if (_doneCompleter != null) return _doneCompleter!.future; + if (_destinationSink == null) { + _doneCompleter = Completer.sync(); + return _doneCompleter!.future; + } + return _destinationSink!.done; + } + + @override + void add(Object? event) { + if (_canSendDirectly) { + _destinationSink!.add(event); + } else { + _ensureController().add(event); + } + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + if (_canSendDirectly) { + _destinationSink!.addError(error, stackTrace); + } else { + _ensureController().addError(error, stackTrace); + } + } + + @override + Future addStream(Stream stream) { + if (_canSendDirectly) return _destinationSink!.addStream(stream); + + final controller = _ensureController(); + return controller.addStream(stream, cancelOnError: false); + } + + @override + Future close([int? closeCode, String? closeReason]) { + if (_canSendDirectly) { + _destinationSink!.close(closeCode, closeReason); + } else { + _closeCode = closeCode; + _closeReason = closeReason; + _ensureController().close(); + } + return done; + } + + /// Create [_controller] if it doesn't yet exist. + StreamController _ensureController() => + _controller ??= StreamController(sync: true); + + /// Sets the destination sink to which events from this sink will be provided. + /// + /// If set before the user adds events, events will be added directly to the + /// destination sink. If the user adds events earlier, an intermediate sink is + /// created using a stream controller, and the destination sink is linked to + /// it later. + void _setDestinationSink(WebSocketSink sink) { + assert(_destinationSink == null); + _destinationSink = sink; + + // If the user has already added data, it's buffered in the controller, so + // we add it to the sink. + if (_controller != null) { + // Catch any error that may come from [addStream] or [sink.close]. They'll + // be reported through [done] anyway. + sink + .addStream(_controller!.stream) + .whenComplete(() => sink.close(_closeCode, _closeReason)) + .catchError((_) {}); + } + + // If the user has already asked when the sink is done, connect the sink's + // done callback to that completer. + if (_doneCompleter != null) { + _doneCompleter!.complete(sink.done); + } + } +}
diff --git a/pkgs/web_socket_channel/lib/status.dart b/pkgs/web_socket_channel/lib/status.dart new file mode 100644 index 0000000..178e7d3 --- /dev/null +++ b/pkgs/web_socket_channel/lib/status.dart
@@ -0,0 +1,90 @@ +// Copyright (c) 2016, 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. + +/// Status codes that are defined in the WebSocket spec. +/// +/// This library is intended to be imported with a prefix. +/// +/// ```dart +/// import 'package:web_socket_channel/web_socket_channel.dart'; +/// import 'package:web_socket_channel/status.dart' as status; +/// +/// void main() async { +/// var channel = WebSocketChannel.connect(Uri.parse('ws://localhost:1234')); +/// // ... +/// channel.close(status.goingAway); +/// } +/// ``` +library; + +import 'dart:core'; + +/// The purpose for which the connection was established has been fulfilled. +const normalClosure = 1000; + +/// An endpoint is "going away", such as a server going down or a browser having +/// navigated away from a page. +const goingAway = 1001; + +/// An endpoint is terminating the connection due to a protocol error. +const protocolError = 1002; + +/// An endpoint is terminating the connection because it has received a type of +/// data it cannot accept. +/// +/// For example, an endpoint that understands only text data MAY send this if it +/// receives a binary message). +const unsupportedData = 1003; + +/// No status code was present. +/// +/// This **must not** be set explicitly by an endpoint. +const noStatusReceived = 1005; + +/// The connection was closed abnormally. +/// +/// For example, this is used if the connection was closed without sending or +/// receiving a Close control frame. +/// +/// This **must not** be set explicitly by an endpoint. +const abnormalClosure = 1006; + +/// An endpoint is terminating the connection because it has received data +/// within a message that was not consistent with the type of the message. +/// +/// For example, the endpoint may have receieved non-UTF-8 data within a text +/// message. +const invalidFramePayloadData = 1007; + +/// An endpoint is terminating the connection because it has received a message +/// that violates its policy. +/// +/// This is a generic status code that can be returned when there is no other +/// more suitable status code (such as [unsupportedData] or [messageTooBig]), or +/// if there is a need to hide specific details about the policy. +const policyViolation = 1008; + +/// An endpoint is terminating the connection because it has received a message +/// that is too big for it to process. +const messageTooBig = 1009; + +/// The client is terminating the connection because it expected the server to +/// negotiate one or more extensions, but the server didn't return them in the +/// response message of the WebSocket handshake. +/// +/// The list of extensions that are needed should appear in the close reason. +/// Note that this status code is not used by the server, because it can fail +/// the WebSocket handshake instead. +const missingMandatoryExtension = 1010; + +/// The server is terminating the connection because it encountered an +/// unexpected condition that prevented it from fulfilling the request. +const internalServerError = 1011; + +/// The connection was closed due to a failure to perform a TLS handshake. +/// +/// For example, the server certificate may not have been verified. +/// +/// This **must not** be set explicitly by an endpoint. +const tlsHandshakeFailed = 1015;
diff --git a/pkgs/web_socket_channel/lib/web_socket_channel.dart b/pkgs/web_socket_channel/lib/web_socket_channel.dart new file mode 100644 index 0000000..54c05a0 --- /dev/null +++ b/pkgs/web_socket_channel/lib/web_socket_channel.dart
@@ -0,0 +1,6 @@ +// Copyright (c) 2016, 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/channel.dart'; +export 'src/exception.dart';
diff --git a/pkgs/web_socket_channel/pubspec.yaml b/pkgs/web_socket_channel/pubspec.yaml new file mode 100644 index 0000000..54f57f9 --- /dev/null +++ b/pkgs/web_socket_channel/pubspec.yaml
@@ -0,0 +1,25 @@ +name: web_socket_channel +version: 3.0.1 +description: >- + StreamChannel wrappers for WebSockets. Provides a cross-platform + WebSocketChannel API, a cross-platform implementation of that API that + communicates over an underlying StreamChannel. +repository: https://github.com/dart-lang/web_socket_channel + +environment: + sdk: ^3.3.0 + +dependencies: + async: ^2.5.0 + crypto: ^3.0.0 + stream_channel: ^2.1.0 + web: '>=0.5.0 <2.0.0' + web_socket: ^0.1.5 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.25.2 + +topics: + - web + - http
diff --git a/pkgs/web_socket_channel/test/adapter_web_socket_channel_test.dart b/pkgs/web_socket_channel/test/adapter_web_socket_channel_test.dart new file mode 100644 index 0000000..44ed7a9 --- /dev/null +++ b/pkgs/web_socket_channel/test/adapter_web_socket_channel_test.dart
@@ -0,0 +1,150 @@ +// 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'; + +import 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:web_socket/web_socket.dart'; +import 'package:web_socket_channel/adapter_web_socket_channel.dart'; +import 'package:web_socket_channel/src/exception.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'echo_server_vm.dart' + if (dart.library.js_interop) 'echo_server_web.dart'; + +void main() { + group('AdapterWebSocketChannel', () { + late Uri uri; + late StreamChannel<Object?> httpServerChannel; + late StreamQueue<Object?> httpServerQueue; + + setUp(() async { + httpServerChannel = await startServer(); + httpServerQueue = StreamQueue(httpServerChannel.stream); + + // When run under dart2wasm, JSON numbers are always returned as [double]. + final port = ((await httpServerQueue.next) as num).toInt(); + uri = Uri.parse('ws://localhost:$port'); + }); + tearDown(() async { + httpServerChannel.sink.add(null); + }); + + test('failed connect', () async { + final channel = + AdapterWebSocketChannel.connect(Uri.parse('ws://notahost')); + + await expectLater( + channel.ready, throwsA(isA<WebSocketChannelException>())); + }); + + test('good connect', () async { + final channel = AdapterWebSocketChannel.connect(uri); + await expectLater(channel.ready, completes); + await channel.sink.close(); + }); + + test('echo empty text', () async { + final channel = AdapterWebSocketChannel.connect(uri); + await expectLater(channel.ready, completes); + channel.sink.add(''); + expect(await channel.stream.first, ''); + await channel.sink.close(); + }); + + test('echo empty binary', () async { + final channel = AdapterWebSocketChannel.connect(uri); + await expectLater(channel.ready, completes); + channel.sink.add(Uint8List.fromList(<int>[])); + expect(await channel.stream.first, isEmpty); + await channel.sink.close(); + }); + + test('echo hello', () async { + final channel = AdapterWebSocketChannel.connect(uri); + await expectLater(channel.ready, completes); + channel.sink.add('hello'); + expect(await channel.stream.first, 'hello'); + await channel.sink.close(); + }); + + test('echo [1,2,3]', () async { + final channel = AdapterWebSocketChannel.connect(uri); + await expectLater(channel.ready, completes); + channel.sink.add([1, 2, 3]); + expect(await channel.stream.first, [1, 2, 3]); + await channel.sink.close(); + }); + + test('alternative string and binary request and response', () async { + final channel = AdapterWebSocketChannel.connect(uri); + await expectLater(channel.ready, completes); + channel.sink.add('This count says:'); + channel.sink.add([1, 2, 3]); + channel.sink.add('And then:'); + channel.sink.add([4, 5, 6]); + expect(await channel.stream.take(4).toList(), [ + 'This count says:', + [1, 2, 3], + 'And then:', + [4, 5, 6] + ]); + }); + + test('remote close', () async { + final channel = AdapterWebSocketChannel.connect(uri); + await expectLater(channel.ready, completes); + channel.sink.add('close'); // Asks the peer to close. + // Give the server time to send a close frame. + await Future<void>.delayed(const Duration(seconds: 1)); + expect(channel.closeCode, 3001); + expect(channel.closeReason, 'you asked me to'); + await channel.sink.close(); + }); + + test('local close', () async { + final channel = AdapterWebSocketChannel.connect(uri); + await expectLater(channel.ready, completes); + await channel.sink.close(3005, 'please close'); + expect(channel.closeCode, null); + expect(channel.closeReason, null); + }); + + test('constructor with WebSocket', () async { + final webSocket = await WebSocket.connect(uri); + final channel = AdapterWebSocketChannel(webSocket); + + await expectLater(channel.ready, completes); + channel.sink.add('This count says:'); + channel.sink.add([1, 2, 3]); + channel.sink.add('And then:'); + channel.sink.add([4, 5, 6]); + expect(await channel.stream.take(4).toList(), [ + 'This count says:', + [1, 2, 3], + 'And then:', + [4, 5, 6] + ]); + }); + + test('constructor with Future<WebSocket>', () async { + final webSocketFuture = WebSocket.connect(uri); + final channel = AdapterWebSocketChannel(webSocketFuture); + + await expectLater(channel.ready, completes); + channel.sink.add('This count says:'); + channel.sink.add([1, 2, 3]); + channel.sink.add('And then:'); + channel.sink.add([4, 5, 6]); + expect(await channel.stream.take(4).toList(), [ + 'This count says:', + [1, 2, 3], + 'And then:', + [4, 5, 6] + ]); + }); + }); +}
diff --git a/pkgs/web_socket_channel/test/echo_server_vm.dart b/pkgs/web_socket_channel/test/echo_server_vm.dart new file mode 100644 index 0000000..99ef2a2 --- /dev/null +++ b/pkgs/web_socket_channel/test/echo_server_vm.dart
@@ -0,0 +1,34 @@ +// 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:io'; + +import 'package:stream_channel/stream_channel.dart'; + +Future<void> hybridMain(StreamChannel<Object?> channel) async { + late HttpServer server; + + server = (await HttpServer.bind('localhost', 0)) + ..transform(WebSocketTransformer()) + .listen((WebSocket webSocket) => webSocket.listen((data) { + if (data == 'close') { + webSocket.close(3001, 'you asked me to'); + } else { + webSocket.add(data); + } + })); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} + +/// Starts an WebSocket server that echos the payload of the request. +Future<StreamChannel<Object?>> startServer() async { + final controller = StreamChannelController<Object?>(sync: true); + unawaited(hybridMain(controller.foreign)); + return controller.local; +}
diff --git a/pkgs/web_socket_channel/test/echo_server_web.dart b/pkgs/web_socket_channel/test/echo_server_web.dart new file mode 100644 index 0000000..030b702 --- /dev/null +++ b/pkgs/web_socket_channel/test/echo_server_web.dart
@@ -0,0 +1,35 @@ +// 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 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +/// Starts an WebSocket server that echos the payload of the request. +/// Copied from `echo_server_vm.dart`. +Future<StreamChannel<Object?>> startServer() async => spawnHybridCode(r''' +import 'dart:async'; +import 'dart:io'; + +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an WebSocket server that echos the payload of the request. +Future<void> hybridMain(StreamChannel<Object?> channel) async { + late HttpServer server; + + server = (await HttpServer.bind('localhost', 0)) + ..transform(WebSocketTransformer()) + .listen((WebSocket webSocket) => webSocket.listen((data) { + if (data == 'close') { + webSocket.close(3001, 'you asked me to'); + } else { + webSocket.add(data); + } + })); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} +''');
diff --git a/pkgs/web_socket_channel/test/html_test.dart b/pkgs/web_socket_channel/test/html_test.dart new file mode 100644 index 0000000..98fe43f --- /dev/null +++ b/pkgs/web_socket_channel/test/html_test.dart
@@ -0,0 +1,189 @@ +// Copyright (c) 2016, 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 'dart:async'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:web/web.dart' hide BinaryType; +import 'package:web_socket_channel/html.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +extension on StreamChannel { + /// Handles the Wasm case where the runtime type is actually [double] instead + /// of the JS case where its [int]. + Future<int> get firstAsInt async => ((await stream.first) as num).toInt(); +} + +void main() { + late int port; + setUpAll(() async { + final channel = spawnHybridCode(r''' + import 'dart:io'; + + import 'package:stream_channel/stream_channel.dart'; + + hybridMain(StreamChannel channel) async { + var server = await HttpServer.bind('localhost', 0); + server.transform(WebSocketTransformer()).listen((webSocket) { + webSocket.listen((request) { + webSocket.add(request); + }); + }); + channel.sink.add(server.port); + } + ''', stayAlive: true); + + port = await channel.firstAsInt; + }); + + test('communicates using an existing WebSocket', () async { + final webSocket = WebSocket('ws://localhost:$port'); + final channel = HtmlWebSocketChannel(webSocket); + + expect(channel.ready, completes); + + addTearDown(channel.sink.close); + + final queue = StreamQueue(channel.stream); + channel.sink.add('foo'); + expect(await queue.next, equals('foo')); + + channel.sink.add(Uint8List.fromList([1, 2, 3, 4, 5])); + expect( + await _decodeBlob(await queue.next as Blob), + equals([1, 2, 3, 4, 5]), + ); + + webSocket.binaryType = 'arraybuffer'; + channel.sink.add(Uint8List.fromList([1, 2, 3, 4, 5])); + expect(await queue.next, equals([1, 2, 3, 4, 5])); + }); + + test('communicates using an existing open WebSocket', () async { + final webSocket = WebSocket('ws://localhost:$port'); + await webSocket.onOpen.first; + + final channel = HtmlWebSocketChannel(webSocket); + + expect(channel.ready, completes); + + addTearDown(channel.sink.close); + + final queue = StreamQueue(channel.stream); + channel.sink.add('foo'); + expect(await queue.next, equals('foo')); + }); + + test('communicates using an connecting WebSocket', () async { + final webSocket = WebSocket('ws://localhost:$port'); + + final channel = HtmlWebSocketChannel(webSocket); + + expect(channel.ready, completes); + + addTearDown(channel.sink.close); + }); + + test('communicates using an existing closed WebSocket', () async { + final webSocket = WebSocket('ws://localhost:$port'); + webSocket.close(); + + final channel = HtmlWebSocketChannel(webSocket); + await expectLater( + channel.ready, + throwsA( + isA<WebSocketChannelException>() + .having((p0) => p0.message, 'message', 'WebSocket state error: 2') + .having((p0) => p0.inner, 'inner', isNull), + ), + ); + }); + + test('.connect defaults to binary lists', () async { + final channel = HtmlWebSocketChannel.connect('ws://localhost:$port'); + + expect(channel.ready, completes); + + addTearDown(channel.sink.close); + + final queue = StreamQueue(channel.stream); + channel.sink.add('foo'); + expect(await queue.next, equals('foo')); + + channel.sink.add(Uint8List.fromList([1, 2, 3, 4, 5])); + expect(await queue.next, equals([1, 2, 3, 4, 5])); + }); + + test('.connect defaults to binary lists using platform independent api', + () async { + final channel = WebSocketChannel.connect(Uri.parse('ws://localhost:$port')); + + expect(channel.ready, completes); + + addTearDown(channel.sink.close); + + final queue = StreamQueue(channel.stream); + channel.sink.add('foo'); + expect(await queue.next, equals('foo')); + + channel.sink.add(Uint8List.fromList([1, 2, 3, 4, 5])); + expect(await queue.next, equals([1, 2, 3, 4, 5])); + }); + + test('.connect can use blobs', () async { + final channel = HtmlWebSocketChannel.connect('ws://localhost:$port', + binaryType: BinaryType.blob); + + expect(channel.ready, completes); + + addTearDown(channel.sink.close); + + final queue = StreamQueue(channel.stream); + channel.sink.add('foo'); + expect(await queue.next, equals('foo')); + + channel.sink.add(Uint8List.fromList([1, 2, 3, 4, 5])); + expect( + await _decodeBlob(await queue.next as Blob), equals([1, 2, 3, 4, 5])); + }); + + test('.connect wraps a connection error in WebSocketChannelException', + () async { + // Spawn a server that will immediately reject the connection. + final serverChannel = spawnHybridCode(r''' + import 'dart:io'; + + import 'package:stream_channel/stream_channel.dart'; + + hybridMain(StreamChannel channel) async { + var server = await ServerSocket.bind('localhost', 0); + server.listen((socket) { + socket.close(); + }); + channel.sink.add(server.port); + } + '''); + + // TODO(nweiz): Make this channel use a port number that's guaranteed to be + // invalid. + final channel = HtmlWebSocketChannel.connect( + 'ws://localhost:${await serverChannel.firstAsInt}'); + expect(channel.ready, throwsA(isA<WebSocketChannelException>())); + expect(channel.stream.toList(), throwsA(isA<WebSocketChannelException>())); + }); +} + +Future<List<int>> _decodeBlob(Blob blob) async { + final reader = FileReader(); + reader.readAsArrayBuffer(blob); + await reader.onLoadEnd.first; + return (reader.result as JSArrayBuffer).toDart.asUint8List(); +}
diff --git a/pkgs/web_socket_channel/test/io_test.dart b/pkgs/web_socket_channel/test/io_test.dart new file mode 100644 index 0000000..2d5d578 --- /dev/null +++ b/pkgs/web_socket_channel/test/io_test.dart
@@ -0,0 +1,249 @@ +// Copyright (c) 2016, 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('vm') +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +void main() { + HttpServer server; + + test('communicates using existing WebSockets', () async { + server = await HttpServer.bind('localhost', 0); + addTearDown(server.close); + server.transform(WebSocketTransformer()).listen((WebSocket webSocket) { + final channel = IOWebSocketChannel(webSocket); + channel.sink.add('hello!'); + channel.stream.listen((request) { + expect(request, equals('ping')); + channel.sink.add('pong'); + channel.sink.close(3678, 'raisin'); + }); + }); + + final webSocket = await WebSocket.connect('ws://localhost:${server.port}'); + final channel = IOWebSocketChannel(webSocket); + + expect(channel.ready, completes); + + var n = 0; + channel.stream.listen((message) { + if (n == 0) { + expect(message, equals('hello!')); + channel.sink.add('ping'); + } else if (n == 1) { + expect(message, equals('pong')); + } else { + fail('Only expected two messages.'); + } + n++; + }, onDone: expectAsync0(() { + expect(channel.closeCode, equals(3678)); + expect(channel.closeReason, equals('raisin')); + })); + }); + + test('.connect communicates immediately', () async { + server = await HttpServer.bind('localhost', 0); + addTearDown(server.close); + server.transform(WebSocketTransformer()).listen((WebSocket webSocket) { + final channel = IOWebSocketChannel(webSocket); + channel.stream.listen((request) { + expect(request, equals('ping')); + channel.sink.add('pong'); + }); + }); + + final channel = IOWebSocketChannel.connect('ws://localhost:${server.port}'); + + expect(channel.ready, completes); + + channel.sink.add('ping'); + + channel.stream.listen(expectAsync1((message) { + expect(message, equals('pong')); + channel.sink.close(3678, 'raisin'); + }), onDone: expectAsync0(() {})); + }); + + test('.connect communicates immediately using platform independent api', + () async { + server = await HttpServer.bind('localhost', 0); + addTearDown(server.close); + server.transform(WebSocketTransformer()).listen((WebSocket webSocket) { + final channel = IOWebSocketChannel(webSocket); + channel.stream.listen((request) { + expect(request, equals('ping')); + channel.sink.add('pong'); + }); + }); + + final channel = + WebSocketChannel.connect(Uri.parse('ws://localhost:${server.port}')); + + expect(channel.ready, completes); + + channel.sink.add('ping'); + + channel.stream.listen(expectAsync1((message) { + expect(message, equals('pong')); + channel.sink.close(3678, 'raisin'); + }), onDone: expectAsync0(() {})); + }); + + test('.connect with an immediate call to close', () async { + server = await HttpServer.bind('localhost', 0); + addTearDown(server.close); + server.transform(WebSocketTransformer()).listen((WebSocket webSocket) { + expect(() async { + final channel = IOWebSocketChannel(webSocket); + await channel.stream.drain<void>(); + expect(channel.closeCode, equals(3678)); + expect(channel.closeReason, equals('raisin')); + }(), completes); + }); + + final channel = IOWebSocketChannel.connect('ws://localhost:${server.port}'); + + expect(channel.ready, completes); + + await channel.sink.close(3678, 'raisin'); + }); + + test('.connect wraps a connection error in WebSocketChannelException', + () async { + server = await HttpServer.bind('localhost', 0); + addTearDown(server.close); + server.listen((request) { + request.response.statusCode = 404; + request.response.close(); + }); + + final channel = IOWebSocketChannel.connect('ws://localhost:${server.port}'); + expect(channel.ready, throwsA(isA<WebSocketChannelException>())); + expect(channel.stream.drain<void>(), + throwsA(isA<WebSocketChannelException>())); + }); + + test('.protocols fail', () async { + const passedProtocol = 'passed-protocol'; + const failedProtocol = 'failed-protocol'; + String selector(List<String> receivedProtocols) => passedProtocol; + + server = await HttpServer.bind('localhost', 0); + addTearDown(server.close); + server.listen((HttpRequest request) { + expect( + WebSocketTransformer.upgrade(request, protocolSelector: selector), + throwsException, + ); + }); + + final channel = IOWebSocketChannel.connect( + 'ws://localhost:${server.port}', + protocols: [failedProtocol], + ); + expect(channel.ready, throwsA(isA<WebSocketChannelException>())); + expect( + channel.stream.drain<void>(), + throwsA(isA<WebSocketChannelException>()), + ); + }); + + test('.protocols pass', () async { + const passedProtocol = 'passed-protocol'; + String selector(List<String> receivedProtocols) => passedProtocol; + + server = await HttpServer.bind('localhost', 0); + addTearDown(server.close); + server.listen((HttpRequest request) async { + final webSocket = await WebSocketTransformer.upgrade( + request, + protocolSelector: selector, + ); + expect(webSocket.protocol, passedProtocol); + await webSocket.close(); + }); + + final channel = IOWebSocketChannel.connect('ws://localhost:${server.port}', + protocols: [passedProtocol]); + + expect(channel.ready, completes); + + await channel.stream.drain<void>(); + expect(channel.protocol, passedProtocol); + }); + + test('.connects with a timeout parameters specified', () async { + server = await HttpServer.bind('localhost', 0); + addTearDown(server.close); + server.transform(WebSocketTransformer()).listen((webSocket) { + expect(() async { + final channel = IOWebSocketChannel(webSocket); + await channel.stream.drain<void>(); + expect(channel.closeCode, equals(3678)); + expect(channel.closeReason, equals('raisin')); + }(), completes); + }); + + final channel = IOWebSocketChannel.connect( + 'ws://localhost:${server.port}', + connectTimeout: const Duration(milliseconds: 1000), + ); + expect(channel.ready, completes); + await channel.sink.close(3678, 'raisin'); + }); + + test('.respects timeout parameter when trying to connect', () async { + server = await HttpServer.bind('localhost', 0); + addTearDown(server.close); + server + .transform(StreamTransformer<HttpRequest, HttpRequest>.fromHandlers( + handleData: (data, sink) { + // Wait before we handle this request, to give the timeout a chance to + // kick in. We still want to make sure that we handle the request + // afterwards to not have false positives with the timeout + Timer(const Duration(milliseconds: 800), () { + sink.add(data); + }); + })) + .transform(WebSocketTransformer()) + .listen((webSocket) { + final channel = IOWebSocketChannel(webSocket); + channel.stream.drain<void>(); + }); + + final channel = IOWebSocketChannel.connect( + 'ws://localhost:${server.port}', + connectTimeout: const Duration(milliseconds: 500), + ); + + expect(channel.ready, throwsA(isA<TimeoutException>())); + expect(channel.stream.drain<void>(), throwsA(anything)); + }); + + test('.custom client is passed through', () async { + server = await HttpServer.bind('localhost', 0); + addTearDown(server.close); + server.transform(StreamTransformer<HttpRequest, HttpRequest>.fromHandlers( + handleData: (data, sink) { + expect(data.headers['user-agent'], ['custom']); + sink.add(data); + }, + )).transform(WebSocketTransformer()); + + final channel = IOWebSocketChannel.connect( + 'ws://localhost:${server.port}', + customClient: HttpClient()..userAgent = 'custom', + ); + + expect(channel.ready, completes); + }); +}