Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create a simple WebSocket interface #1128

Merged
merged 12 commits into from
Feb 20, 2024
102 changes: 81 additions & 21 deletions .github/workflows/dart.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pkgs/web_socket/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 0.0.1
brianquinlan marked this conversation as resolved.
Show resolved Hide resolved

- Abstract interface definition.
2 changes: 2 additions & 0 deletions pkgs/web_socket/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
TODO: Put a short description of the package here that helps potential users
know whether this package might be useful for them.
3 changes: 3 additions & 0 deletions pkgs/web_socket/example/web_socket_example.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
void main() {
// TODO: add an example.
}
135 changes: 135 additions & 0 deletions pkgs/web_socket/lib/src/web_socket.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import 'dart:typed_data';

/// An event received from the peer through the [WebSocket].
sealed class WebSocketEvent {}

/// Text data received from the peer through the [WebSocket].
///
/// See [WebSocket.events].
final class TextDataReceived extends WebSocketEvent {
final String text;
TextDataReceived(this.text);

@override
bool operator ==(Object other) =>
other is TextDataReceived && other.text == text;

@override
int get hashCode => text.hashCode;
}

/// Binary data received from the peer through the [WebSocket].
///
/// See [WebSocket.events].
final class BinaryDataReceived extends WebSocketEvent {
final Uint8List data;
BinaryDataReceived(this.data);

@override
bool operator ==(Object other) {
if (other is BinaryDataReceived && other.data.length == data.length) {
for (var i = 0; i < data.length; ++i) {
if (other.data[i] != data[i]) return false;
}
return true;
}
return false;
}

@override
int get hashCode => data.hashCode;

@override
String toString() => 'BinaryDataReceived($data)';
}

/// A close notification (Close frame) received from the peer through the
/// [WebSocket] or a failure indication.
///
/// See [WebSocket.events].
final class CloseReceived extends WebSocketEvent {
/// A numerical code indicating the reason why the WebSocket was closed.
///
/// See [RFC-6455 7.4](https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4)
/// for guidance on how to interpret these codes.
final int? code;

/// A textual explanation of the reason why the WebSocket was closed.
///
/// Will be empty if the peer did not specify a reason.
final String reason;

CloseReceived([this.code, this.reason = '']);

@override
bool operator ==(Object other) =>
other is CloseReceived && other.code == code && other.reason == reason;

@override
int get hashCode => [code, reason].hashCode;

@override
String toString() => 'CloseReceived($code, $reason)';
}

class WebSocketException implements Exception {
final String message;
WebSocketException([this.message = '']);
}

/// Thrown if [WebSocket.sendText], [WebSocket.sendBytes], or
/// [WebSocket.close] is called when the [WebSocket] is closed.
class WebSocketConnectionClosed extends WebSocketException {
WebSocketConnectionClosed([super.message = 'Connection Closed']);
}

/// The interface for WebSocket connections.
///
/// TODO: insert a usage example.
abstract interface class WebSocket {
/// Sends text data to the connected peer.
///
/// Throws [WebSocketConnectionClosed] if the [WebSocket] is
/// closed (either through [close] or by the peer).
void sendText(String s);

/// Sends binary data to the connected peer.
///
/// Throws [WebSocketConnectionClosed] if the [WebSocket] is
/// closed (either through [close] or by the peer).
void sendBytes(Uint8List b);

/// Closes the WebSocket connection and the [events] `Stream`.
///
/// Sends a Close frame to the peer. If the optional [code] and [reason]
/// arguments are given, they will be included in the Close frame. If no
/// [code] is set then the peer will see a 1005 status code. If no [reason]
/// is set then the peer will not receive a reason string.
///
/// Throws a [RangeError] if [code] is not in the range 3000-4999.
///
/// Throws an [ArgumentError] if [reason] is longer than 123 bytes when
/// encoded as UTF-8
///
/// Throws [WebSocketConnectionClosed] if the connection is already
/// closed (including by the peer).
Future<void> close([int? code, String? reason]);

/// A [Stream] of [WebSocketEvent] received from the peer.
///
/// Data received by the peer will be delivered as a [TextDataReceived] or
/// [BinaryDataReceived].
///
/// If a [CloseReceived] event is received then the [Stream] will be closed. A
/// [CloseReceived] event indicates either that:
///
/// - A close frame was received from the peer. [CloseReceived.code] and
/// [CloseReceived.reason] will be set by the peer.
/// - A failure occured (e.g. the peer disconnected). [CloseReceived.code] and
/// [CloseReceived.reason] will be a failure code defined by
/// (RFC-6455)[https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1]
/// (e.g. 1006).
///
/// Errors will never appear in this [Stream].
Stream<WebSocketEvent> get events;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Stream and its relationship with the sendText/sendBytes/close methods is my biggest concern with this API.

As documented now, sendText, etc. throw if the WebSocket is closed. But the ClosedReceived event is delivered asynchronously so you can't rely on it being delivered in time to prevent those calls. For example:

timer = Timer.periodic(const Duration(seconds: 2), (timer) {
  websocket.sendText('<stock>GOOG</stock>');
});

await for (final event in websocket.events) {
  switch (event) {
  // Should probably do something with the stock quote.
  case CloseReceived:
    // This is not sufficient - the implementation may add `CloseReceived` to the `StreamController` and then
    // close the `StreamController` before this event is received. This probably means that, in practice, every
    // call to `sendText`/`sendBytes` will need to be guarded in a `try`/`catch`
    timer.cancel();
  }
}

Some alternatives:

  1. just throw away sendText/sendBytes after the WebSocket is closed - this is what JavaScript does
  2. use SyncStreamController (will that work?)
  3. make a synchronous callback for events instead of using a Stream.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think even if we synchronously surface a ClosedReceived there is still the possibility of a sendText failing to write because of a closed socket, though it may be significantly less less likely.

I would probably start with throwing, and we can consider an option to silently discard if that has a clear use case.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think even if we synchronously surface a ClosedReceived there is still the possibility of a sendText failing to write because of a closed socket, though it may be significantly less less likely.

My plan was for sendText/sendBytes to not throw in that case. Any other behavior would be difficult on the web, which only allows send to throw if the connection isn't established.

I have a conformance test to verify that behavior (the tests are still very rough at this point).

I've updated the docs to make a note of that.

I would probably start with throwing, and we can consider an option to silently discard if that has a clear use case.

SGTM!

}
4 changes: 4 additions & 0 deletions pkgs/web_socket/lib/web_socket.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/// TODO: write this doc string.
library;

export 'src/web_socket.dart';
10 changes: 10 additions & 0 deletions pkgs/web_socket/mono_pkg.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
sdk:
- pubspec
- dev

stages:
- analyze_and_format:
- analyze: --fatal-infos
- format:
sdk:
- dev
Loading