Add an HTML implementation of WebSocketChannel.
R=kevmoo@google.com
Review URL: https://codereview.chromium.org//1747113003 .
diff --git a/README.md b/README.md
index 7e39a79..3e984aa 100644
--- a/README.md
+++ b/README.md
@@ -2,12 +2,14 @@
wrappers for WebSocket connections. It provides a cross-platform
[`WebSocketChannel`][WebSocketChannel] API, a cross-platform implementation of
that API that communicates over an underlying [`StreamChannel`][stream_channel],
-and [an implementation][IOWebSocketChannel] that wraps `dart:io`'s `WebSocket`
-class.
+[an implementation][IOWebSocketChannel] that wraps `dart:io`'s `WebSocket`
+class, and [a similar implementation][HtmlWebSocketChannel] that wrap's
+`dart:html`'s.
[stream_channel]: https://pub.dartlang.org/packages/stream_channel
[WebSocketChannel]: https://www.dartdocs.org/documentation/web_socket_channel/latest/web_socket_channel/WebSocketChannel-class.html
[IOWebSocketChannel]: https://www.dartdocs.org/documentation/web_socket_channel/latest/io/IOWebSocketChannel-class.html
+[HtmlWebSocketChannel]: https://www.dartdocs.org/documentation/web_socket_channel/latest/html/HtmlWebSocketChannel-class.html
## `WebSocketChannel`
@@ -74,3 +76,32 @@
});
}
```
+
+## `HtmlWebSocketChannel`
+
+The [`HtmlWebSocketChannel`][HtmlWebSocketChannel] class wraps
+[`dart:html`'s `WebSocket` class][html.WebSocket]. Because it imports
+`dart:html`, it has its own library, `package:web_socket_channel/html.dart`.
+This allows the main `WebSocketChannel` class to be available on all platforms.
+
+[html.WebSocket]: https://api.dartlang.org/latest/dart-html/WebSocket-class.html
+
+An `HtmlWebSocketChannel` can be created by passing a `dart:html` WebSocket to
+[its constructor][new HtmlWebSocketChannel]. It's more common to want to connect
+directly to a `ws://` or `wss://` URL, in which case
+[`new HtmlWebSocketChannel.connect()`][HtmlWebSocketChannel.connect] should be used.
+
+[new HtmlWebSocketChannel]: https://www.dartdocs.org/documentation/web_socket_channel/latest/html/HtmlWebSocketChannel/HtmlWebSocketChannel.html
+[HtmlWebSocketChannel.connect]: https://www.dartdocs.org/documentation/web_socket_channel/latest/html/HtmlWebSocketChannel/HtmlWebSocketChannel.connect.html
+
+```dart
+import 'package:web_socket_channel/html.dart';
+
+main() async {
+ var channel = new HtmlWebSocketChannel.connect("ws://localhost:8181");
+ channel.sink.add("connected!");
+ channel.sink.listen((message) {
+ // ...
+ });
+}
+```
diff --git a/lib/html.dart b/lib/html.dart
new file mode 100644
index 0000000..9d0b070
--- /dev/null
+++ b/lib/html.dart
@@ -0,0 +1,146 @@
+// 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:html';
+import 'dart:typed_data';
+
+import 'package:async/async.dart';
+import 'package:stream_channel/stream_channel.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 _webSocket;
+
+ String get protocol => _webSocket.protocol;
+
+ int get closeCode => _closeCode;
+ int _closeCode;
+
+ 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 => _webSocket.bufferedAmount;
+
+ /// The close code set by the local user.
+ ///
+ /// To ensure proper ordering, this is stored until we get a done event on
+ /// [_controller.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
+ /// [_controller.local.stream].
+ String _localCloseReason;
+
+ Stream get stream => _controller.foreign.stream;
+ final _controller = new StreamChannelController(
+ sync: true, allowForeignErrors: false);
+
+ WebSocketSink get sink => _sink;
+ WebSocketSink _sink;
+
+ /// Creates a new WebSocket connection.
+ ///
+ /// Connects to [url] using [new WebSocket] 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
+ /// [new WebSocket].
+ ///
+ /// 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(url, {Iterable<String> protocols,
+ BinaryType binaryType})
+ : this(new WebSocket(url.toString(), protocols)
+ ..binaryType = (binaryType ?? BinaryType.list).value);
+
+ /// Creates a channel wrapping [webSocket].
+ HtmlWebSocketChannel(this._webSocket){
+ _sink = new _HtmlWebSocketSink(this);
+
+ if (_webSocket.readyState == WebSocket.OPEN) {
+ _listen();
+ } else {
+ // The socket API guarantees that only a single open event will be
+ // emitted.
+ _webSocket.onOpen.first.then((_) {
+ _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.
+ _webSocket.onError.first.then((_) {
+ _controller.local.sink.addError(
+ new WebSocketChannelException("WebSocket connection failed."));
+ _controller.local.sink.close();
+ });
+
+ _webSocket.onMessage.listen((event) {
+ var data = event.data;
+ if (data is ByteBuffer) data = data.asUint8List();
+ _controller.local.sink.add(data);
+ });
+
+ // The socket API guarantees that only a single error event will be emitted,
+ // and that once it is no other events will be emitted.
+ _webSocket.onClose.first.then((event) {
+ _closeCode = event.code;
+ _closeReason = event.reason;
+ _controller.local.sink.close();
+ });
+ }
+
+ /// Pipes user events to [_webSocket].
+ void _listen() {
+ _controller.local.stream.listen((message) => _webSocket.send(message),
+ onDone: () => _webSocket.close(_localCloseCode, _localCloseReason));
+ }
+}
+
+/// 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)
+ : super(channel._controller.foreign.sink),
+ _channel = channel;
+
+ 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 = const BinaryType._("blob", "blob");
+
+ /// Tells the channel to emit binary messages as [Uint8List]s.
+ static const list = const 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);
+
+ String toString() => name;
+}
diff --git a/test/html_test.dart b/test/html_test.dart
new file mode 100644
index 0000000..f5c3555
--- /dev/null
+++ b/test/html_test.dart
@@ -0,0 +1,92 @@
+// 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')
+@Skip(
+ "This suite requires a WebSocket server, which is currently unsupported\n"
+ "by the test package (dart-lang/test#330). It's currently set up to talk\n"
+ "to a hard-coded server on localhost:1234 that is spawned in \n"
+ "html_test_server.dart.")
+
+import 'dart:async';
+import 'dart:html';
+import 'dart:typed_data';
+
+import 'package:async/async.dart';
+import 'package:test/test.dart';
+
+import 'package:web_socket_channel/html.dart';
+
+void main() {
+ var channel;
+ tearDown(() {
+ if (channel != null) channel.sink.close();
+ });
+
+ test("communicates using an existing WebSocket", () async {
+ var webSocket = new WebSocket("ws://localhost:1234");
+ channel = new HtmlWebSocketChannel(webSocket);
+
+ var queue = new StreamQueue(channel.stream);
+ channel.sink.add("foo");
+ expect(await queue.next, equals("foo"));
+
+ channel.sink.add(new Uint8List.fromList([1, 2, 3, 4, 5]));
+ expect(await _decodeBlob(await queue.next), equals([1, 2, 3, 4, 5]));
+
+ webSocket.binaryType = "arraybuffer";
+ channel.sink.add(new 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 {
+ var webSocket = new WebSocket("ws://localhost:1234");
+ await webSocket.onOpen.first;
+
+ channel = new HtmlWebSocketChannel(webSocket);
+
+ var queue = new StreamQueue(channel.stream);
+ channel.sink.add("foo");
+ expect(await queue.next, equals("foo"));
+ });
+
+ test(".connect defaults to binary lists", () async {
+ channel = new HtmlWebSocketChannel.connect("ws://localhost:1234");
+
+ var queue = new StreamQueue(channel.stream);
+ channel.sink.add("foo");
+ expect(await queue.next, equals("foo"));
+
+ channel.sink.add(new Uint8List.fromList([1, 2, 3, 4, 5]));
+ expect(await queue.next, equals([1, 2, 3, 4, 5]));
+ });
+
+ test(".connect can use blobs", () async {
+ channel = new HtmlWebSocketChannel.connect(
+ "ws://localhost:1234", binaryType: BinaryType.blob);
+
+ var queue = new StreamQueue(channel.stream);
+ channel.sink.add("foo");
+ expect(await queue.next, equals("foo"));
+
+ channel.sink.add(new Uint8List.fromList([1, 2, 3, 4, 5]));
+ expect(await _decodeBlob(await queue.next), equals([1, 2, 3, 4, 5]));
+ });
+
+ test(".connect wraps a connection error in WebSocketChannelException",
+ () async {
+ // TODO(nweiz): Make this channel use a port number that's guaranteed to be
+ // invalid.
+ var channel = new HtmlWebSocketChannel.connect("ws://localhost:1235");
+ expect(channel.stream.toList(),
+ throwsA(new isInstanceOf<WebSocketChannelException>()));
+ });
+}
+
+Future<List<int>> _decodeBlob(Blob blob) async {
+ var reader = new FileReader();
+ reader.readAsArrayBuffer(blob);
+ await reader.onLoad.first;
+ return reader.result;
+}
diff --git a/test/html_test_server.dart b/test/html_test_server.dart
new file mode 100644
index 0000000..e356695
--- /dev/null
+++ b/test/html_test_server.dart
@@ -0,0 +1,16 @@
+// 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:io';
+
+main() async {
+ var server = await HttpServer.bind("localhost", 1234);
+ server.transform(new WebSocketTransformer()).listen((webSocket) {
+ print("connected");
+ webSocket.listen((request) {
+ print("got $request");
+ webSocket.add(request);
+ });
+ });
+}