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

Add a WebSocket implementation to package:cupertino_http #1153

Merged
merged 11 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/cupertino.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
matrix:
# Test on the minimum supported flutter version and the latest
# version.
flutter-version: ["3.16.0", "any"]
flutter-version: ["3.19.0", "any"]
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225
Expand Down Expand Up @@ -61,4 +61,4 @@ jobs:
run: |
cd example
flutter pub get
flutter test --timeout=1200s integration_test/
flutter test --timeout=1200s integration_test/main.dart
2 changes: 2 additions & 0 deletions pkgs/cupertino_http/example/integration_test/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'url_session_delegate_test.dart' as url_session_delegate_test;
import 'url_session_task_test.dart' as url_session_task_test;
import 'url_session_test.dart' as url_session_test;
import 'utils_test.dart' as utils_test;
import 'web_socket_conformance_test.dart' as web_socket_conformance_test;

/// Execute all the tests in this directory.
///
Expand All @@ -43,4 +44,5 @@ void main() {
url_session_task_test.main();
url_session_test.main();
utils_test.main();
web_socket_conformance_test.main();
}
31 changes: 27 additions & 4 deletions pkgs/cupertino_http/example/integration_test/utils_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@ void main() {
});
});

group('stringDictToMap', () {
group('stringNSDictionaryToMap', () {
test('empty input', () {
final d = ncb.NSMutableDictionary.new1(linkedLibs);

expect(stringDictToMap(d), <String, String>{});
expect(stringNSDictionaryToMap(d), <String, String>{});
});

test('single string input', () {
final d = ncb.NSMutableDictionary.new1(linkedLibs)
..setObject_forKey_(
'value'.toNSString(linkedLibs), 'key'.toNSString(linkedLibs));

expect(stringDictToMap(d), {'key': 'value'});
expect(stringNSDictionaryToMap(d), {'key': 'value'});
});

test('multiple string input', () {
Expand All @@ -43,8 +43,31 @@ void main() {
'value2'.toNSString(linkedLibs), 'key2'.toNSString(linkedLibs))
..setObject_forKey_(
'value3'.toNSString(linkedLibs), 'key3'.toNSString(linkedLibs));
expect(stringDictToMap(d),
expect(stringNSDictionaryToMap(d),
{'key1': 'value1', 'key2': 'value2', 'key3': 'value3'});
});
});

group('stringIterableToNSArray', () {
test('empty input', () {
final array = stringIterableToNSArray([]);
expect(array.count, 0);
});

test('single string input', () {
final array = stringIterableToNSArray(['apple']);
expect(array.count, 1);
expect(
ncb.NSString.castFrom(array.objectAtIndex_(0)).toString(), 'apple');
});

test('multiple string input', () {
final array = stringIterableToNSArray(['apple', 'banana']);
expect(array.count, 2);
expect(
ncb.NSString.castFrom(array.objectAtIndex_(0)).toString(), 'apple');
expect(
ncb.NSString.castFrom(array.objectAtIndex_(1)).toString(), 'banana');
});
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// 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:cupertino_http/cupertino_http.dart';
import 'package:test/test.dart';
import 'package:web_socket_conformance_tests/web_socket_conformance_tests.dart';

void main() {
testAll(CupertinoWebSocket.connect);

group('defaultSessionConfiguration', () {
testAll(
CupertinoWebSocket.connect,
);
});
group('fromSessionConfiguration', () {
final config = URLSessionConfiguration.ephemeralSessionConfiguration();
testAll((uri, {protocols}) =>
CupertinoWebSocket.connect(uri, protocols: protocols, config: config));
});
}
2 changes: 2 additions & 0 deletions pkgs/cupertino_http/example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ dev_dependencies:
integration_test:
sdk: flutter
test: ^1.21.1
web_socket_conformance_tests:
path: ../../web_socket_conformance_tests/

flutter:
uses-material-design: true
1 change: 1 addition & 0 deletions pkgs/cupertino_http/lib/cupertino_http.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@ import 'src/cupertino_client.dart';

export 'src/cupertino_api.dart';
export 'src/cupertino_client.dart';
export 'src/cupertino_web_socket.dart';
37 changes: 34 additions & 3 deletions pkgs/cupertino_http/lib/src/cupertino_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ class URLSessionConfiguration
Map<String, String>? get httpAdditionalHeaders {
if (_nsObject.HTTPAdditionalHeaders case var additionalHeaders?) {
final headers = ncb.NSDictionary.castFrom(additionalHeaders);
return stringDictToMap(headers);
return stringNSDictionaryToMap(headers);
}
return null;
}
Expand Down Expand Up @@ -628,7 +628,7 @@ class HTTPURLResponse extends URLResponse {
Map<String, String> get allHeaderFields {
final headers =
ncb.NSDictionary.castFrom(_httpUrlResponse.allHeaderFields!);
return stringDictToMap(headers);
return stringNSDictionaryToMap(headers);
}

@override
Expand Down Expand Up @@ -992,7 +992,7 @@ class URLRequest extends _ObjectHolder<ncb.NSURLRequest> {
return null;
} else {
final headers = ncb.NSDictionary.castFrom(_nsObject.allHTTPHeaderFields!);
return stringDictToMap(headers);
return stringNSDictionaryToMap(headers);
}
}

Expand Down Expand Up @@ -1584,4 +1584,35 @@ class URLSession extends _ObjectHolder<ncb.NSURLSession> {
onWebSocketTaskClosed: _onWebSocketTaskClosed);
return task;
}

/// Creates a [URLSessionWebSocketTask] that represents a connection to a
/// WebSocket endpoint.
///
/// See [NSURLSession webSocketTaskWithURL:protocols:](https://developer.apple.com/documentation/foundation/nsurlsession/3181172-websockettaskwithurl)
URLSessionWebSocketTask webSocketTaskWithURL(Uri uri,
{Iterable<String>? protocols}) {
if (_isBackground) {
throw UnsupportedError(
'WebSocket tasks are not supported in background sessions');
}

final URLSessionWebSocketTask task;
if (protocols == null) {
task = URLSessionWebSocketTask._(
_nsObject.webSocketTaskWithURL_(uriToNSURL(uri)));
} else {
task = URLSessionWebSocketTask._(
_nsObject.webSocketTaskWithURL_protocols_(
uriToNSURL(uri), stringIterableToNSArray(protocols)));
}
_setupDelegation(_delegate, this, task,
onComplete: _onComplete,
onData: _onData,
onFinishedDownloading: _onFinishedDownloading,
onRedirect: _onRedirect,
onResponse: _onResponse,
onWebSocketTaskOpened: _onWebSocketTaskOpened,
onWebSocketTaskClosed: _onWebSocketTaskClosed);
return task;
}
}
204 changes: 204 additions & 0 deletions pkgs/cupertino_http/lib/src/cupertino_web_socket.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// 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:typed_data';

import 'package:web_socket/web_socket.dart';

import 'cupertino_api.dart';

/// An error occurred while connecting to the peer.
class ConnectionException extends WebSocketException {
final Error error;

ConnectionException(super.message, this.error);

@override
String toString() => 'CupertinoErrorWebSocketException: $message $error';
}

/// A [WebSocket] implemented using the
/// [NSURLSessionWebSocketTask API](https://developer.apple.com/documentation/foundation/nsurlsessionwebsockettask).
class CupertinoWebSocket implements WebSocket {
/// Create a new WebSocket connection using the
/// [NSURLSessionWebSocketTask API](https://developer.apple.com/documentation/foundation/nsurlsessionwebsockettask).
///
/// The URL supplied in [url] must use the scheme ws or wss.
///
/// 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).
static Future<CupertinoWebSocket> connect(Uri url,
{Iterable<String>? protocols, URLSessionConfiguration? config}) async {
if (!url.isScheme('ws') && !url.isScheme('wss')) {
throw ArgumentError.value(
url, 'url', 'only ws: and wss: schemes are supported');
}

final readyCompleter = Completer<CupertinoWebSocket>();
late CupertinoWebSocket webSocket;

final session = URLSession.sessionWithConfiguration(
config ?? URLSessionConfiguration.defaultSessionConfiguration(),
// In a successful flow, the callbacks will be made in this order:
// onWebSocketTaskOpened(...) // Good connect.
// <receive/send messages to the peer>
// onWebSocketTaskClosed(...) // Optional: peer sent Close frame.
// onComplete(..., error=null) // Disconnected.
//
// In a failure to connect to the peer, the flow will be:
// onComplete(session, task, error=error):
//
// `onComplete` can also be called at any point if the peer is
// disconnected without Close frames being exchanged.
onWebSocketTaskOpened: (session, task, protocol) {
webSocket = CupertinoWebSocket._(task, protocol ?? '');
readyCompleter.complete(webSocket);
}, onWebSocketTaskClosed: (session, task, closeCode, reason) {
assert(readyCompleter.isCompleted);
webSocket._connectionClosed(closeCode, reason);
}, onComplete: (session, task, error) {
if (!readyCompleter.isCompleted) {

Choose a reason for hiding this comment

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

This flow is kinda confusing. Can you add some comments?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added some comments and reordered the callbacks into their expected order.

// `onWebSocketTaskOpened should have been called and completed
// `readyCompleter`. So either there was a error creating the connection
// or a logic error.
if (error == null) {

Choose a reason for hiding this comment

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

This could be an assert. You can pass a string message as the second arg:
assert(error != null, 'expected an error or "onWebSocketTaskOpened" to be called first');

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 left it this way because assert() isn't a null guard i.e. I'd have to error! on line 74.

Let me know if you'd prefer that.

Choose a reason for hiding this comment

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

Oh, right. That makes sense.

throw AssertionError(
'expected an error or "onWebSocketTaskOpened" to be called '
'first');
}
readyCompleter.completeError(
ConnectionException('connection ended unexpectedly', error));
} else {
// There are three possibilities here:
// 1. the peer sent a close Frame, `onWebSocketTaskClosed` was already
// called and `_connectionClosed` is a no-op.
// 2. we sent a close Frame (through `close()`) and `_connectionClosed`
// is a no-op.
// 3. an error occured (e.g. network failure) and `_connectionClosed`
// will signal that and close `event`.
webSocket._connectionClosed(
1006, Data.fromList('abnormal close'.codeUnits));
}
});

session.webSocketTaskWithURL(url, protocols: protocols).resume();
return readyCompleter.future;
}

final URLSessionWebSocketTask _task;
final String _protocol;
final _events = StreamController<WebSocketEvent>();

CupertinoWebSocket._(this._task, this._protocol) {
_scheduleReceive();
}

/// Handle an incoming message from the peer and schedule receiving the next
/// message.
void _handleMessage(URLSessionWebSocketMessage value) {
late WebSocketEvent event;
switch (value.type) {
case URLSessionWebSocketMessageType.urlSessionWebSocketMessageTypeString:
event = TextDataReceived(value.string!);
break;
case URLSessionWebSocketMessageType.urlSessionWebSocketMessageTypeData:
event = BinaryDataReceived(value.data!.bytes);
break;
}
_events.add(event);
_scheduleReceive();
}

void _scheduleReceive() {
unawaited(_task
.receiveMessage()
.then(_handleMessage, onError: _closeConnectionWithError));
}

/// Close the WebSocket connection due to an error and send the
/// [CloseReceived] event.
void _closeConnectionWithError(Object e) {
if (e is Error) {
if (e.domain == 'NSPOSIXErrorDomain' && e.code == 57) {
// Socket is not connected.
// onWebSocketTaskClosed/onComplete will be invoked and may indicate a
// close code.
return;
}
var (int code, String? reason) = switch ([e.domain, e.code]) {
['NSPOSIXErrorDomain', 100] => (1002, e.localizedDescription),
_ => (1006, e.localizedDescription)
};
_task.cancel();
_connectionClosed(
code, reason == null ? null : Data.fromList(reason.codeUnits));
} else {
throw StateError('unexpected error: $e');
}
}

void _connectionClosed(int? closeCode, Data? reason) {
if (!_events.isClosed) {
final closeReason = reason == null ? '' : utf8.decode(reason.bytes);

_events
..add(CloseReceived(closeCode, closeReason))
..close();
}
}

@override
void sendBytes(Uint8List b) {
if (_events.isClosed) {
throw StateError('WebSocket is closed');
}
_task
.sendMessage(URLSessionWebSocketMessage.fromData(Data.fromList(b)))
.then((_) => _, onError: _closeConnectionWithError);
}

@override
void sendText(String s) {
if (_events.isClosed) {
throw StateError('WebSocket is closed');
}
_task
.sendMessage(URLSessionWebSocketMessage.fromString(s))
.then((_) => _, onError: _closeConnectionWithError);
}

@override
Future<void> close([int? code, String? reason]) async {
if (_events.isClosed) {
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');
}

if (!_events.isClosed) {
unawaited(_events.close());
if (code != null) {
reason = reason ?? '';
_task.cancelWithCloseCode(code, Data.fromList(reason.codeUnits));
} else {
_task.cancel();
}
}
}

@override
Stream<WebSocketEvent> get events => _events.stream;

@override
String get protocol => _protocol;
}
Loading
Loading