Skip to content

Commit

Permalink
Add a WebSocket implementation to package:cupertino_http (#1153)
Browse files Browse the repository at this point in the history
  • Loading branch information
brianquinlan committed Mar 19, 2024
1 parent 5dfea72 commit cfbc191
Show file tree
Hide file tree
Showing 10 changed files with 313 additions and 13 deletions.
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) {
// `onWebSocketTaskOpened should have been called and completed
// `readyCompleter`. So either there was a error creating the connection
// or a logic error.
if (error == null) {
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(utf8.encode(reason)));
} else {
_task.cancel();
}
}
}

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

@override
String get protocol => _protocol;
}
Loading

0 comments on commit cfbc191

Please sign in to comment.