feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
## 1.0.0
|
||||
|
||||
- Initial version, created by Stagehand
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- Formatting and other updates.
|
||||
|
||||
## 1.0.2
|
||||
|
||||
- Bug fix.
|
||||
|
||||
## 1.0.3
|
||||
|
||||
- Fixed web socket implementation. Other bugs.
|
||||
|
||||
## 1.0.4
|
||||
|
||||
- Fixed invoke method.
|
||||
|
||||
## 1.0.5
|
||||
|
||||
- Fixed typo in negotiate response - [f28f4bf](https://github.com/jamiewest/signalr_core/commit/f28f4bfa6f174dc24b2614ece057fcaaf3d121d1)
|
||||
|
||||
## 1.0.6
|
||||
|
||||
- Bug fixes - [359e1ae](https://github.com/dart-lang/http/commit/469ea67d412d4e90ade81bc672b601d4d663e685), [ab4a902](https://github.com/jamiewest/signalr_core/commit/ab4a9020c72c5ba1f9560940fade536516cb1292), [9cfeea4](https://github.com/jamiewest/signalr_core/commit/9cfeea4a86a78ce32f29a3147cf58532415ea814)
|
||||
|
||||
## 1.0.7
|
||||
|
||||
- Bug fixes - [51cba40](https://github.com/jamiewest/signalr_core/commit/51cba400e52640b7f2fdf0dd4154061d8117e778)
|
||||
|
||||
## 1.0.8
|
||||
|
||||
- Bug Fixes - [dc767b9](https://github.com/jamiewest/signalr_core/commit/dc767b943a5cf9dee6d464cae5c7e015c99deb50)
|
||||
|
||||
## 1.1.0
|
||||
|
||||
- Null safety contributed by Mol0ko [#44](https://github.com/jamiewest/signalr_core/commit/7e8eaaf7a7940c60385a076625b75f126a1292d7)
|
||||
|
||||
## 1.1.1
|
||||
|
||||
- Bug fixes contributed by janjoosse [#49](https://github.com/jamiewest/signalr_core/pull/49)
|
||||
|
||||
## 1.1.2
|
||||
|
||||
- Updated packages
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Jamie West
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
## Introduction
|
||||
|
||||
ASP.NET Core **SignalR** is an open-source library that simplifies adding real-time web functionality to apps. Real-time web functionality enables server-side code to push content to clients instantly. It's platform-independent, and can be used on both the command-line and the browser.
|
||||
|
||||
## Example
|
||||
```dart
|
||||
final connection = HubConnectionBuilder().withUrl('http://localhost:5000/chatHub',
|
||||
HttpConnectionOptions(
|
||||
logging: (level, message) => print(message),
|
||||
)).build();
|
||||
|
||||
await connection.start();
|
||||
|
||||
connection.on('ReceiveMessage', (message) {
|
||||
print(message.toString());
|
||||
});
|
||||
|
||||
await connection.invoke('SendMessage', args: ['Bob', 'Says hi!']);
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
include: package:lints/recommended.yaml
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:signalr_core/signalr_core.dart';
|
||||
|
||||
Future<void> main(List<String> arguments) async {
|
||||
final connection = HubConnectionBuilder()
|
||||
.withUrl(
|
||||
'http://localhost:5115/chatHub',
|
||||
HttpConnectionOptions(
|
||||
client: IOClient(
|
||||
HttpClient()..badCertificateCallback = (x, y, z) => true),
|
||||
logging: (level, message) => print(message),
|
||||
))
|
||||
.build();
|
||||
|
||||
await connection.start();
|
||||
|
||||
connection.on('ReceiveMessage', (message) {
|
||||
print(message.toString());
|
||||
});
|
||||
|
||||
await connection.invoke('SendMessage', args: ['Bob', 'Says hi!']);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// An ASP.NET Core SignalR Dart Client
|
||||
///
|
||||
/// ASP.NET Core SignalR is an open-source library that simplifies adding real-time web functionality to apps.
|
||||
/// Real-time web functionality enables server-side code to push content to clients instantly.
|
||||
library signalr_core;
|
||||
|
||||
export 'src/connection.dart';
|
||||
export 'src/default_reconnect_policy.dart';
|
||||
export 'src/handshake_protocol.dart';
|
||||
export 'src/http_connection.dart';
|
||||
export 'src/http_connection_options.dart';
|
||||
export 'src/hub_connection.dart';
|
||||
export 'src/hub_connection_builder.dart';
|
||||
export 'src/hub_protocol.dart';
|
||||
export 'src/json_hub_protocol.dart';
|
||||
export 'src/logger.dart';
|
||||
export 'src/retry_policy.dart';
|
||||
export 'src/text_message_format.dart';
|
||||
export 'src/transport.dart';
|
||||
export 'src/utils.dart';
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:signalr_core/src/transport.dart' as transfer;
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
|
||||
abstract class Connection {
|
||||
Connection({
|
||||
this.features,
|
||||
this.connectionId,
|
||||
});
|
||||
|
||||
final dynamic features;
|
||||
|
||||
final String? connectionId;
|
||||
|
||||
String? baseUrl;
|
||||
|
||||
OnReceive? onreceive;
|
||||
|
||||
OnClose? onclose;
|
||||
|
||||
Future<void> start({
|
||||
transfer.TransferFormat? transferFormat = transfer.TransferFormat.binary,
|
||||
});
|
||||
|
||||
Future<void> send(dynamic data);
|
||||
|
||||
Future<void> stop({Exception? exception});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:signalr_core/src/retry_policy.dart';
|
||||
|
||||
const defaultRetryDelaysInMilliseconds = [0, 2000, 10000, 30000, null];
|
||||
|
||||
class DefaultReconnectPolicy implements RetryPolicy {
|
||||
DefaultReconnectPolicy({
|
||||
this.retryDelays = defaultRetryDelaysInMilliseconds,
|
||||
});
|
||||
|
||||
final List<int?> retryDelays;
|
||||
|
||||
@override
|
||||
int? nextRetryDelayInMilliseconds(RetryContext retryContext) {
|
||||
return retryDelays[retryContext.previousRetryCount!];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:signalr_core/src/text_message_format.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class HandshakeRequestMessage {
|
||||
HandshakeRequestMessage({
|
||||
this.protocol,
|
||||
this.version,
|
||||
});
|
||||
|
||||
final String? protocol;
|
||||
final int? version;
|
||||
}
|
||||
|
||||
class HandshakeResponseMessage {
|
||||
HandshakeResponseMessage({
|
||||
this.error,
|
||||
this.minorVersion,
|
||||
});
|
||||
|
||||
final String? error;
|
||||
final int? minorVersion;
|
||||
}
|
||||
|
||||
extension on HandshakeRequestMessage {
|
||||
Map<String, dynamic> toJson() => {
|
||||
'protocol': protocol,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
extension HandshakeResponseMessageExtensions on HandshakeResponseMessage {
|
||||
static HandshakeResponseMessage fromJson(Map<String, dynamic> json) {
|
||||
return HandshakeResponseMessage(
|
||||
error: json['error'] as String?,
|
||||
minorVersion: json['minorVersion'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HandshakeProtocol {
|
||||
String writeHandshakeRequest(HandshakeRequestMessage handshakeRequest) {
|
||||
return TextMessageFormat.write(json.encode(handshakeRequest.toJson()));
|
||||
}
|
||||
|
||||
Tuple2<dynamic, HandshakeResponseMessage> parseHandshakeResponse(
|
||||
dynamic data) {
|
||||
HandshakeResponseMessage _responseMessage;
|
||||
String _messageData;
|
||||
dynamic _remainingData;
|
||||
|
||||
if (data is Uint8List) {
|
||||
// Format is binary but still need to read JSON text from handshake response
|
||||
var separatorIndex = data.indexOf(TextMessageFormat.RecordSeparatorCode);
|
||||
if (separatorIndex == -1) {
|
||||
throw Exception('Message is incomplete.');
|
||||
}
|
||||
|
||||
// content before separator is handshake response
|
||||
// optional content after is additional messages
|
||||
final responseLength = separatorIndex + 1;
|
||||
_messageData = utf8.decode(data.sublist(0, responseLength));
|
||||
_remainingData = (data.length > responseLength)
|
||||
? data.sublist(responseLength, data.length)
|
||||
: null;
|
||||
} else {
|
||||
final textData = data as String;
|
||||
final separatorIndex =
|
||||
textData.indexOf(TextMessageFormat.recordSeparator);
|
||||
if (separatorIndex == -1) {
|
||||
throw Exception('Message is incomplete.');
|
||||
}
|
||||
|
||||
// content before separator is handshake response
|
||||
// optional content after is additional messages
|
||||
final responseLength = separatorIndex + 1;
|
||||
_messageData = textData.substring(0, responseLength);
|
||||
_remainingData = (textData.length > responseLength)
|
||||
? textData.substring(responseLength)
|
||||
: null;
|
||||
}
|
||||
|
||||
// At this point we should have just the single handshake message
|
||||
final messages = TextMessageFormat.parse(_messageData);
|
||||
final response = HandshakeResponseMessageExtensions.fromJson(
|
||||
json.decode(messages[0]) as Map<String, dynamic>);
|
||||
|
||||
// if (response.type) {
|
||||
// throw new Error("Expected a handshake response from the server.");
|
||||
// }
|
||||
|
||||
_responseMessage = response;
|
||||
|
||||
return Tuple2<dynamic, HandshakeResponseMessage>(
|
||||
_remainingData,
|
||||
_responseMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:signalr_core/src/connection.dart';
|
||||
import 'package:signalr_core/src/http_connection_options.dart';
|
||||
import 'package:signalr_core/src/logger.dart';
|
||||
import 'package:signalr_core/src/transport.dart';
|
||||
import 'package:signalr_core/src/transports/long_polling_transport.dart';
|
||||
import 'package:signalr_core/src/transports/server_sent_events_transport.dart';
|
||||
import 'package:signalr_core/src/transports/web_socket_transport.dart';
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
|
||||
enum ConnectionState {
|
||||
connecting,
|
||||
connected,
|
||||
disconnected,
|
||||
disconnecting,
|
||||
}
|
||||
|
||||
class NegotiateResponse {
|
||||
NegotiateResponse({
|
||||
this.connectionId,
|
||||
this.connectionToken,
|
||||
this.negotiateVersion,
|
||||
this.availableTransports,
|
||||
this.url,
|
||||
this.accessToken,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final String? connectionId;
|
||||
String? connectionToken;
|
||||
final int? negotiateVersion;
|
||||
final List<AvailableTransport>? availableTransports;
|
||||
final String? url;
|
||||
final String? accessToken;
|
||||
final String? error;
|
||||
}
|
||||
|
||||
extension NegotiateResponseExtensions on NegotiateResponse {
|
||||
static NegotiateResponse fromJson(Map<String, dynamic> json) {
|
||||
return NegotiateResponse(
|
||||
connectionId: json['connectionId'] as String?,
|
||||
connectionToken: json['connectionToken'] as String?,
|
||||
negotiateVersion: json['negotiateVersion'] as int?,
|
||||
availableTransports: AvailableTransportExtensions.listFromJson(
|
||||
json['availableTransports'] as List<dynamic>?,
|
||||
),
|
||||
url: json['url'] as String?,
|
||||
accessToken: json['accessToken'] as String?,
|
||||
error: json['error'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AvailableTransport {
|
||||
AvailableTransport({
|
||||
this.transport,
|
||||
this.transferFormats,
|
||||
});
|
||||
|
||||
final HttpTransportType? transport;
|
||||
final List<TransferFormat>? transferFormats;
|
||||
}
|
||||
|
||||
extension AvailableTransportExtensions on AvailableTransport {
|
||||
static AvailableTransport fromJson(Map<String, dynamic> json) {
|
||||
return AvailableTransport(
|
||||
transport:
|
||||
HttpTransportTypeExtensions.fromName(json['transport'] as String?),
|
||||
transferFormats: List<dynamic>.from(
|
||||
json['transferFormats'] as Iterable<dynamic>)
|
||||
.map((value) => TransferFormatExtensions.fromName(value as String))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static List<AvailableTransport> listFromJson(List<dynamic>? json) {
|
||||
return json == null
|
||||
? <AvailableTransport>[]
|
||||
: json
|
||||
.map((value) => AvailableTransportExtensions.fromJson(
|
||||
value as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
const maxRedirects = 100;
|
||||
|
||||
class HttpConnection implements Connection {
|
||||
ConnectionState? _connectionState;
|
||||
late bool _connectionStarted;
|
||||
final http.BaseClient? _client;
|
||||
Logging? _logging;
|
||||
final HttpConnectionOptions _options;
|
||||
Transport? _transport;
|
||||
Future<void>? _startInternalFuture;
|
||||
Future<void>? _stopFuture;
|
||||
late Completer _stopCompleter;
|
||||
Exception? _stopException;
|
||||
AccessTokenFactory? _accessTokenFactory;
|
||||
TransportSendQueue? _sendQueue;
|
||||
|
||||
@override
|
||||
final dynamic features = {};
|
||||
@override
|
||||
String? baseUrl;
|
||||
@override
|
||||
String? connectionId;
|
||||
@override
|
||||
OnReceive? onreceive;
|
||||
@override
|
||||
OnClose? onclose;
|
||||
|
||||
final int negotiateVersion = 1;
|
||||
|
||||
HttpConnection({
|
||||
required String? url,
|
||||
required HttpConnectionOptions options,
|
||||
}) : baseUrl = url,
|
||||
_client = (options.client != null)
|
||||
? options.client
|
||||
: http.Client() as http.BaseClient,
|
||||
_options = options {
|
||||
_logging = (options.logging != null) ? options.logging : (l, m) => {};
|
||||
_connectionState = ConnectionState.disconnected;
|
||||
_connectionStarted = false;
|
||||
|
||||
onreceive = null;
|
||||
onclose = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> start({
|
||||
TransferFormat? transferFormat = TransferFormat.binary,
|
||||
}) async {
|
||||
_logging!(LogLevel.debug,
|
||||
'Starting connection with transfer format \'${transferFormat.toString()}\'.');
|
||||
|
||||
if (_connectionState != ConnectionState.disconnected) {
|
||||
return Future.error(
|
||||
Exception(
|
||||
'Cannot start an HttpConnection that is not in the \'Disconnected\' state.'),
|
||||
);
|
||||
}
|
||||
|
||||
_connectionState = ConnectionState.connecting;
|
||||
|
||||
_startInternalFuture = _startInternal(transferFormat: transferFormat);
|
||||
await _startInternalFuture;
|
||||
|
||||
if (_connectionState == ConnectionState.disconnecting) {
|
||||
// stop() was called and transitioned the client into the Disconnecting state.
|
||||
const message =
|
||||
'Failed to start the HttpConnection before stop() was called.';
|
||||
_logging!(LogLevel.error, message);
|
||||
|
||||
// We cannot await stopPromise inside startInternal since stopInternal awaits the startInternalPromise.
|
||||
await _stopFuture;
|
||||
|
||||
return Future.error(Exception(message));
|
||||
} else if (_connectionState as dynamic != ConnectionState.connected) {
|
||||
// stop() was called and transitioned the client into the Disconnecting state.
|
||||
const message =
|
||||
'HttpConnection.startInternal completed gracefully but didn\'t enter the connection into the connected state!';
|
||||
_logging!(LogLevel.error, message);
|
||||
return Future.error(Exception(message));
|
||||
}
|
||||
|
||||
_connectionStarted = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> send(dynamic data) {
|
||||
if (_connectionState != ConnectionState.connected) {
|
||||
return Future.error(Exception(
|
||||
'Cannot send data if the connection is not in the \'Connected\' State.'));
|
||||
}
|
||||
|
||||
_sendQueue ??= TransportSendQueue(transport: _transport);
|
||||
|
||||
// Transport will not be null if state is connected
|
||||
return _sendQueue!.send(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop({Exception? exception}) async {
|
||||
if (_connectionState == ConnectionState.disconnected) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Call to HttpConnection.stop(${exception.toString()}) ignored because the connection is already in the disconnected state.');
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
if (_connectionState == ConnectionState.disconnecting) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Call to HttpConnection.stop(${exception.toString()}) ignored because the connection is already in the disconnecting state.');
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
_connectionState = ConnectionState.disconnecting;
|
||||
|
||||
_stopCompleter = Completer();
|
||||
|
||||
_stopFuture = _stopCompleter.future;
|
||||
|
||||
await _stopInternal(exception: exception);
|
||||
await _stopFuture;
|
||||
}
|
||||
|
||||
Future<void> _stopInternal({Exception? exception}) async {
|
||||
// Set exception as soon as possible otherwise there is a race between
|
||||
// the transport closing and providing an exception and the exception from a close message
|
||||
// We would prefer the close message exception.
|
||||
_stopException = exception;
|
||||
|
||||
try {
|
||||
await _startInternalFuture;
|
||||
} catch (e) {
|
||||
// This exception is returned to the user as a rejected Future from the start method.
|
||||
}
|
||||
|
||||
// if (_sendQueue != null) {
|
||||
// try {
|
||||
// await _sendQueue.stop();
|
||||
// } catch (e) {
|
||||
// _logging(LogLevel.error,
|
||||
// 'TransportSendQueue.stop() threw error \'${e.toString()}\'.');
|
||||
// }
|
||||
// _sendQueue = null;
|
||||
// }
|
||||
|
||||
// The transport's onclose will trigger stopConnection which will run our onclose event.
|
||||
// The transport should always be set if currently connected. If it wasn't set, it's likely because
|
||||
// stop was called during start() and start() failed.
|
||||
if (_transport != null) {
|
||||
try {
|
||||
await _transport!.stop();
|
||||
} catch (e) {
|
||||
_logging!(LogLevel.error,
|
||||
'HttpConnection.transport.stop() threw error \'${e.toString()}\'.');
|
||||
_stopConnection();
|
||||
}
|
||||
|
||||
_transport = null;
|
||||
} else {
|
||||
_logging!(LogLevel.debug,
|
||||
'HttpConnection.transport is undefined in HttpConnection.stop() because start() failed.');
|
||||
_stopConnection();
|
||||
}
|
||||
}
|
||||
|
||||
void _stopConnection({Exception? exception}) {
|
||||
_logging!(LogLevel.debug,
|
||||
'HttpConnection.stopConnection(${exception.toString()}) called while in state ${_connectionState.toString()}.');
|
||||
|
||||
_transport = null;
|
||||
|
||||
// If we have a stopError, it takes precedence over the error from the transport
|
||||
var _exception = (_stopException == null) ? exception : _stopException;
|
||||
_stopException = null;
|
||||
|
||||
if (_connectionState == ConnectionState.disconnected) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Call to HttpConnection.stopConnection(${_exception.toString()}) was ignored because the connection is already in the disconnected state.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_connectionState == ConnectionState.connecting) {
|
||||
_logging!(LogLevel.warning,
|
||||
'Call to HttpConnection.stopConnection(${_exception.toString()}) was ignored because the connection is still in the connecting state.');
|
||||
throw Exception(
|
||||
'HttpConnection.stopConnection(${_exception.toString()}) was called while the connection is still in the connecting state.');
|
||||
}
|
||||
|
||||
if (_connectionState == ConnectionState.disconnecting) {
|
||||
// A call to stop() induced this call to stopConnection and needs to be completed.
|
||||
// Any stop() awaiters will be scheduled to continue after the onclose callback fires.
|
||||
_stopCompleter.complete();
|
||||
}
|
||||
|
||||
if (_exception != null) {
|
||||
_logging!(LogLevel.error,
|
||||
'Connection disconnected with error \'${_exception.toString()}\'.');
|
||||
} else {
|
||||
_logging!(LogLevel.information, 'Connection disconnected.');
|
||||
}
|
||||
|
||||
if (_sendQueue != null) {
|
||||
_sendQueue!.stop()!.catchError((e) => _logging!(LogLevel.error,
|
||||
'TransportSendQueue.stop() threw error \'${e.toString()}\'.'));
|
||||
_sendQueue = null;
|
||||
}
|
||||
|
||||
connectionId = null;
|
||||
_connectionState = ConnectionState.disconnected;
|
||||
|
||||
if (_connectionStarted) {
|
||||
_connectionStarted = false;
|
||||
try {
|
||||
if (onclose != null) {
|
||||
onclose!(_exception);
|
||||
}
|
||||
} catch (e) {
|
||||
_logging!(LogLevel.error,
|
||||
'HttpConnection.onclose(${_exception.toString()}) threw error \'${e.toString()}\'.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startInternal({required TransferFormat? transferFormat}) async {
|
||||
// Store the original base url and the access token factory since they may change
|
||||
// as part of negotiating
|
||||
var url = baseUrl;
|
||||
_accessTokenFactory = _options.accessTokenFactory;
|
||||
|
||||
try {
|
||||
if (_options.skipNegotiation) {
|
||||
if (_options.transport == HttpTransportType.webSockets) {
|
||||
// No need to add a connection ID in this case
|
||||
_transport = _constructTransport(HttpTransportType.webSockets);
|
||||
// We should just call connect directly in this case.
|
||||
// No fallback or negotiate in this case.
|
||||
await _startTransport(url: url, transferFormat: transferFormat);
|
||||
} else {
|
||||
throw Exception(
|
||||
'Negotiation can only be skipped when using the WebSocket transport directly.');
|
||||
}
|
||||
} else {
|
||||
NegotiateResponse negotiateResponse;
|
||||
var redirects = 0;
|
||||
|
||||
do {
|
||||
negotiateResponse = await _getNegotiationResponse(url!);
|
||||
// the user tries to stop the connection when it is being started
|
||||
if (_connectionState == ConnectionState.disconnecting ||
|
||||
_connectionState == ConnectionState.disconnected) {
|
||||
throw Exception('The connection was stopped during negotiation.');
|
||||
}
|
||||
|
||||
if (negotiateResponse.error != null) {
|
||||
throw Exception(negotiateResponse.error);
|
||||
}
|
||||
|
||||
// if ((negotiateResponse as dynamic).protocolVersion) {
|
||||
// throw Exception('Detected a connection attempt to an ASP.NET SignalR Server. This client only supports connecting to an ASP.NET Core SignalR Server. See https://aka.ms/signalr-core-differences for details.');
|
||||
// }
|
||||
|
||||
if (negotiateResponse.url != null) {
|
||||
url = negotiateResponse.url;
|
||||
}
|
||||
|
||||
if (negotiateResponse.accessToken != null) {
|
||||
// Replace the current access token factory with one that uses
|
||||
// the returned access token
|
||||
final accessToken = negotiateResponse.accessToken;
|
||||
_accessTokenFactory = () async => accessToken;
|
||||
}
|
||||
|
||||
redirects++;
|
||||
} while ((negotiateResponse.url != null) && redirects < maxRedirects);
|
||||
|
||||
if ((redirects == maxRedirects) && (negotiateResponse.url != null)) {
|
||||
throw Exception('Negotiate redirection limit exceeded.');
|
||||
}
|
||||
|
||||
await _createTransport(
|
||||
url, _options.transport, negotiateResponse, transferFormat);
|
||||
}
|
||||
|
||||
// TODO: Figure out how to check for dynamic properties.
|
||||
// if (_transport is LongPollingTransport) {
|
||||
// features.inherentKeepAlive = true;
|
||||
// }
|
||||
|
||||
if (_connectionState == ConnectionState.connecting) {
|
||||
// Ensure the connection transitions to the connected state prior to completing this.startInternalPromise.
|
||||
// start() will handle the case when stop was called and startInternal exits still in the disconnecting state.
|
||||
_logging!(LogLevel.debug, 'The HttpConnection connected successfully.');
|
||||
_connectionState = ConnectionState.connected;
|
||||
}
|
||||
|
||||
// stop() is waiting on us via this.startInternalPromise so keep this.transport around so it can clean up.
|
||||
// This is the only case startInternal can exit in neither the connected nor disconnected state because stopConnection()
|
||||
// will transition to the disconnected state. start() will wait for the transition using the stopPromise.
|
||||
} catch (e) {
|
||||
_logging!(
|
||||
LogLevel.error, 'Failed to start the connection: ' + e.toString());
|
||||
_connectionState = ConnectionState.disconnected;
|
||||
_transport = null;
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<NegotiateResponse> _getNegotiationResponse(String url) async {
|
||||
final headers = {};
|
||||
|
||||
if (_accessTokenFactory != null) {
|
||||
final token = await _accessTokenFactory!();
|
||||
if (token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.customHeaders != null) {
|
||||
headers.addAll(_options.customHeaders!);
|
||||
}
|
||||
|
||||
final negotiateUrl = _resolveNegotiateUrl(url);
|
||||
_logging!(LogLevel.debug, 'Sending negotiation request: $negotiateUrl.');
|
||||
|
||||
// TODO: Fix user agent header...
|
||||
//headers['X-SignalR-User-Agent'] = 'Microsoft SignalR/';
|
||||
headers['Content-Type'] = 'text/plain;charset=UTF-8';
|
||||
|
||||
try {
|
||||
final response = await _client!.post(Uri.parse(negotiateUrl),
|
||||
headers: Map<String, String>.from(headers));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Future.error(Exception(
|
||||
'Unexpected status code returned from negotiate \'${response.statusCode}\''));
|
||||
}
|
||||
|
||||
final negotiateResponse = NegotiateResponseExtensions.fromJson(
|
||||
json.decode(response.body) as Map<String, dynamic>);
|
||||
|
||||
if ((negotiateResponse.negotiateVersion != null) &&
|
||||
negotiateResponse.negotiateVersion! < 1) {
|
||||
negotiateResponse.connectionToken = negotiateResponse.connectionId;
|
||||
}
|
||||
|
||||
if (negotiateResponse.negotiateVersion == null) {
|
||||
negotiateResponse.connectionToken = negotiateResponse.connectionId;
|
||||
}
|
||||
|
||||
return negotiateResponse;
|
||||
} catch (e) {
|
||||
_logging!(LogLevel.error,
|
||||
'Failed to complete negotiation with the server: ' + e.toString());
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
static String _resolveNegotiateUrl(String url) {
|
||||
final index = url.indexOf('?');
|
||||
var negotiateUrl = url.substring(0, index == -1 ? url.length : index);
|
||||
if (negotiateUrl[negotiateUrl.length - 1] != '/') {
|
||||
negotiateUrl += '/';
|
||||
}
|
||||
negotiateUrl += 'negotiate';
|
||||
negotiateUrl += index == -1 ? '' : url.substring(index);
|
||||
return negotiateUrl;
|
||||
}
|
||||
|
||||
Future<void> _startTransport({String? url, TransferFormat? transferFormat}) {
|
||||
if (_transport != null) {
|
||||
_transport!
|
||||
..onreceive = onreceive
|
||||
..onclose = (e) => _stopConnection(exception: e);
|
||||
return _transport!.connect(url, transferFormat);
|
||||
} else {
|
||||
return Future.value();
|
||||
}
|
||||
}
|
||||
|
||||
static String? _createConnectUrl(String? url, String? connectionToken) {
|
||||
if (connectionToken == null) {
|
||||
return url;
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(url!);
|
||||
if (uri == null) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return Uri(
|
||||
scheme: uri.scheme,
|
||||
host: uri.host,
|
||||
port: uri.port,
|
||||
path: uri.path,
|
||||
fragment: uri.fragment.isNotEmpty ? uri.fragment : null,
|
||||
queryParameters: <String, dynamic>{
|
||||
...uri.queryParameters,
|
||||
...{'id': connectionToken},
|
||||
},
|
||||
).toString();
|
||||
}
|
||||
|
||||
Future<void> _createTransport(
|
||||
String? url,
|
||||
dynamic requestedTransport,
|
||||
NegotiateResponse negotiateResponse,
|
||||
TransferFormat? requestedTransferFormat) async {
|
||||
var connectUrl = _createConnectUrl(url, negotiateResponse.connectionToken);
|
||||
if (requestedTransport is Transport) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Connection was provided an instance of Transport, using that directly.');
|
||||
_transport = requestedTransport;
|
||||
await _startTransport(
|
||||
url: connectUrl, transferFormat: requestedTransferFormat);
|
||||
|
||||
connectionId = negotiateResponse.connectionId;
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
final transportExceptions = [];
|
||||
final transports = negotiateResponse.availableTransports!;
|
||||
NegotiateResponse? negotiate = negotiateResponse;
|
||||
|
||||
for (var endpoint in transports) {
|
||||
_connectionState = ConnectionState.connecting;
|
||||
final transportOrError = _resolveTransportOrError(
|
||||
endpoint,
|
||||
requestedTransport as HttpTransportType?,
|
||||
requestedTransferFormat,
|
||||
);
|
||||
|
||||
if (transportOrError is Exception) {
|
||||
transportExceptions.add(transportOrError);
|
||||
} else {
|
||||
if (transportOrError is Transport) {
|
||||
_transport = transportOrError;
|
||||
if (negotiate == null) {
|
||||
try {
|
||||
negotiate = await _getNegotiationResponse(url!);
|
||||
} catch (ex) {
|
||||
return Future.error(ex);
|
||||
}
|
||||
connectUrl = _createConnectUrl(url, negotiate.connectionToken);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await _startTransport(
|
||||
url: connectUrl,
|
||||
transferFormat: requestedTransferFormat,
|
||||
);
|
||||
connectionId = negotiate!.connectionId;
|
||||
return Future.value(null);
|
||||
} catch (e) {
|
||||
_logging!(LogLevel.error,
|
||||
'Failed to start the transport \'${endpoint.transport}\': ${e.toString()}');
|
||||
negotiate = null;
|
||||
transportExceptions
|
||||
.add(Exception('${endpoint.transport} failed: ${e.toString()}'));
|
||||
|
||||
if (_connectionState != ConnectionState.connecting) {
|
||||
const message =
|
||||
'Failed to select transport before stop() was called.';
|
||||
_logging!(LogLevel.debug, message);
|
||||
return Future.error(Exception(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _resolveTransportOrError(
|
||||
AvailableTransport endpoint,
|
||||
HttpTransportType? requestedTransport,
|
||||
TransferFormat? requestedTransferFormat,
|
||||
) {
|
||||
final transport = endpoint.transport;
|
||||
if (transport == null) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Skipping transport \'${endpoint.transport.toString()}\' because it is not supported by this client.');
|
||||
return Exception(
|
||||
'Skipping transport \'${endpoint.transport.toString()}\' because it is not supported by this client.');
|
||||
} else {
|
||||
if (_transportMatches(requestedTransport, transport)) {
|
||||
final transferFormats = endpoint.transferFormats!;
|
||||
if (transferFormats.contains(requestedTransferFormat)) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Selecting transport \'${transport.toString()}\'.');
|
||||
try {
|
||||
return _constructTransport(transport);
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
} else {
|
||||
_logging!(LogLevel.debug,
|
||||
'Skipping transport \'${transport.toString()}\' because it does not support the requested transfer format \'${requestedTransferFormat.toString()}\'.');
|
||||
return Exception(
|
||||
'\'${transport.toString()}\' does not support ${requestedTransferFormat.toString()}');
|
||||
}
|
||||
} else {
|
||||
_logging!(LogLevel.debug,
|
||||
'Skipping transport \'${transport.toString()}\' because it was disabled by the client.');
|
||||
return Exception(
|
||||
'\'${transport.toString()}\' is disabled by the client.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _transportMatches(
|
||||
HttpTransportType? requestedTransport,
|
||||
HttpTransportType actualTransport,
|
||||
) {
|
||||
if (requestedTransport == null) {
|
||||
return true;
|
||||
} else {
|
||||
return requestedTransport.index == actualTransport.index;
|
||||
}
|
||||
}
|
||||
|
||||
Transport? _constructTransport(HttpTransportType transport) {
|
||||
switch (transport) {
|
||||
case HttpTransportType.none:
|
||||
break;
|
||||
case HttpTransportType.webSockets:
|
||||
return WebSocketTransport(
|
||||
accessTokenFactory: _accessTokenFactory,
|
||||
logging: _logging,
|
||||
logMessageContent: _options.logMessageContent,
|
||||
client: _client);
|
||||
case HttpTransportType.serverSentEvents:
|
||||
return ServerSentEventsTransport(
|
||||
accessTokenFactory: _accessTokenFactory,
|
||||
logMessageContent: _options.logMessageContent,
|
||||
logging: _logging,
|
||||
client: _client);
|
||||
case HttpTransportType.longPolling:
|
||||
return LongPollingTransport(
|
||||
accessTokenFactory: _accessTokenFactory,
|
||||
logMessageContent: _options.logMessageContent,
|
||||
log: _logging,
|
||||
client: _client);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class TransportSendQueue {
|
||||
final List<dynamic> _buffer = [];
|
||||
late Completer _sendBufferedData;
|
||||
bool _executing = true;
|
||||
Completer? _transportResult;
|
||||
Future<void>? _sendLoopPromise;
|
||||
|
||||
final Transport? transport;
|
||||
|
||||
TransportSendQueue({this.transport}) {
|
||||
_sendBufferedData = Completer();
|
||||
_transportResult = Completer();
|
||||
|
||||
_sendLoopPromise = sendLoop();
|
||||
}
|
||||
|
||||
Future<void> send(dynamic data) {
|
||||
_bufferData(data);
|
||||
_transportResult ??= Completer();
|
||||
return _transportResult!.future;
|
||||
}
|
||||
|
||||
Future<void>? stop() {
|
||||
_executing = false;
|
||||
_sendBufferedData.complete();
|
||||
return _sendLoopPromise;
|
||||
}
|
||||
|
||||
void _bufferData(dynamic data) {
|
||||
// TODO: I believe this is checking that the buffer contains already similar data, if not throw error.
|
||||
// fix this.
|
||||
if (_buffer.isNotEmpty) {
|
||||
//throw Exception('Expected data to be of type ${_buffer.toString()} but was of type ${data.toString()}');
|
||||
}
|
||||
|
||||
_buffer.add(data);
|
||||
|
||||
if (!_sendBufferedData.isCompleted) {
|
||||
_sendBufferedData.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendLoop() async {
|
||||
while (true) {
|
||||
await _sendBufferedData.future;
|
||||
|
||||
if (!_executing) {
|
||||
if (_transportResult != null) {
|
||||
_transportResult!.completeError(Exception('Connection stopped.'));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
_sendBufferedData = Completer();
|
||||
|
||||
final transportResult = _transportResult;
|
||||
_transportResult = null;
|
||||
|
||||
if (_buffer.isNotEmpty) {
|
||||
final data = (_buffer[0] is String)
|
||||
? _buffer.join('')
|
||||
: TransportSendQueue._concatBuffers(_buffer as List<ByteBuffer?>);
|
||||
|
||||
_buffer.clear();
|
||||
|
||||
try {
|
||||
await transport!.send(data);
|
||||
transportResult!.complete();
|
||||
} catch (error) {
|
||||
transportResult!.completeError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static ByteBuffer _concatBuffers(List<ByteBuffer?> byteBuffers) {
|
||||
final totalLength =
|
||||
byteBuffers.map((b) => b!.lengthInBytes).reduce((a, b) => a + b);
|
||||
final result = Uint8List(totalLength);
|
||||
|
||||
var offset = 0;
|
||||
for (final item in byteBuffers) {
|
||||
result.setAll(offset, item!.asUint8List());
|
||||
offset += item.lengthInBytes;
|
||||
}
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:http/http.dart';
|
||||
import 'package:signalr_core/src/transport.dart';
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
|
||||
/// Options provided to the 'withUrl' factory constructor on [HubConnection] to configure options for the HTTP-based transports.
|
||||
class HttpConnectionOptions {
|
||||
HttpConnectionOptions({
|
||||
this.client,
|
||||
this.transport,
|
||||
this.logging,
|
||||
this.accessTokenFactory,
|
||||
this.logMessageContent = false,
|
||||
this.skipNegotiation = false,
|
||||
this.withCredentials = true,
|
||||
this.customHeaders
|
||||
});
|
||||
|
||||
/// An [BaseClient] that will be used to make HTTP requests.
|
||||
final BaseClient? client;
|
||||
|
||||
/// An [HttpTransportType] or [Transport] value specifying the transport to use for the connection.
|
||||
final dynamic transport;
|
||||
|
||||
/// Configures the logger used for logging.
|
||||
///
|
||||
/// Provide an [Logger] instance, and log messages will be logged via that instance.
|
||||
final Logging? logging;
|
||||
|
||||
// custom headers sent with the negotiating HTTP request
|
||||
final Map<String, String>? customHeaders;
|
||||
|
||||
/// A function that provides an access token required for HTTP Bearer authentication.
|
||||
///
|
||||
/// A string containing the access token, or a Future that resolves to a string containing the access token.
|
||||
final AccessTokenFactory? accessTokenFactory;
|
||||
|
||||
/// A boolean indicating if message content should be logged.
|
||||
///
|
||||
/// Message content can contain sensitive user data, so this is disabled by default.
|
||||
final bool logMessageContent;
|
||||
|
||||
/// A boolean indicating if negotiation should be skipped.
|
||||
///
|
||||
/// Negotiation can only be skipped when the [transport] property is set to 'HttpTransportType.WebSockets'.
|
||||
final bool skipNegotiation;
|
||||
|
||||
/// This controls whether credentials such as cookies are sent in cross-site requests.
|
||||
///
|
||||
/// Cookies are used by many load-balancers for sticky sessions which is required when your app is deployed with multiple servers.
|
||||
final bool withCredentials;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
import 'package:signalr_core/signalr_core.dart';
|
||||
|
||||
/// A builder for configuring [HubConnection] instances.
|
||||
class HubConnectionBuilder {
|
||||
HubProtocol? _protocol;
|
||||
HttpConnectionOptions? _httpConnectionOptions;
|
||||
HttpTransportType? _httpTransportType;
|
||||
String? _url;
|
||||
RetryPolicy? reconnectPolicy;
|
||||
|
||||
/// Configures the [HubConnection] to use HTTP-based transports to connect to the specified URL.
|
||||
// ignore: avoid_returning_this
|
||||
HubConnectionBuilder withUrl(String url, [dynamic transportTypeOrOptions]) {
|
||||
_url = url;
|
||||
|
||||
if (transportTypeOrOptions != null) {
|
||||
if (transportTypeOrOptions is HttpConnectionOptions) {
|
||||
_httpConnectionOptions = transportTypeOrOptions;
|
||||
} else if (transportTypeOrOptions is HttpTransportType) {
|
||||
_httpTransportType = transportTypeOrOptions;
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Configures the [HubConnection] to use the specified Hub Protocol.
|
||||
// ignore: avoid_returning_this
|
||||
HubConnectionBuilder withHubProtocol(HubProtocol protocol) {
|
||||
_protocol = protocol;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Configures the [HubConnection] to automatically attempt to reconnect if the connection is lost.
|
||||
// ignore: avoid_returning_this
|
||||
HubConnectionBuilder withAutomaticReconnect(
|
||||
[dynamic retryDelaysOrReconnectPolicy]) {
|
||||
if (reconnectPolicy != null) {
|
||||
throw Exception('A reconnectPolicy has already been set.');
|
||||
}
|
||||
|
||||
if (retryDelaysOrReconnectPolicy == null) {
|
||||
reconnectPolicy = DefaultReconnectPolicy();
|
||||
} else if (retryDelaysOrReconnectPolicy is List) {
|
||||
reconnectPolicy = DefaultReconnectPolicy(
|
||||
retryDelays: retryDelaysOrReconnectPolicy as List<int>,
|
||||
);
|
||||
} else if (retryDelaysOrReconnectPolicy is RetryPolicy) {
|
||||
reconnectPolicy = retryDelaysOrReconnectPolicy;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Creates a [HubConnection] from the configuration options specified in this builder.
|
||||
HubConnection build() {
|
||||
// Now create the connection
|
||||
if (_url == null) {
|
||||
throw Exception(
|
||||
'The \'HubConnectionBuilder.withUrl\' method must be called before building the connection.');
|
||||
}
|
||||
|
||||
_httpConnectionOptions ??=
|
||||
HttpConnectionOptions(transport: _httpTransportType);
|
||||
|
||||
final connection =
|
||||
HttpConnection(url: _url, options: _httpConnectionOptions!);
|
||||
|
||||
return HubConnection(
|
||||
connection: connection,
|
||||
logging: (_httpConnectionOptions!.logging != null)
|
||||
? _httpConnectionOptions!.logging
|
||||
: (l, m) => {},
|
||||
protocol: (_protocol == null) ? JsonHubProtocol() : _protocol!,
|
||||
reconnectPolicy: reconnectPolicy,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:signalr_core/src/transport.dart';
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
|
||||
/// Defines the type of a Hub Message.
|
||||
enum MessageType {
|
||||
/// MessageType is not defined.
|
||||
undefined, // = 0,
|
||||
/// Indicates the message is an Invocation message and implements the [InvocationMessage] interface.
|
||||
invocation, // = 1,
|
||||
/// Indicates the message is a StreamItem message and implements the [StreamItemMessage] interface.
|
||||
streamItem, // = 2,
|
||||
/// Indicates the message is a Completion message and implements the [CompletionMessage] interface.
|
||||
completion, // = 3,
|
||||
/// Indicates the message is a Stream Invocation message and implements the [StreamInvocationMessage] interface.
|
||||
streamInvocation, // = 4,
|
||||
/// Indicates the message is a Cancel Invocation message and implements the [CancelInvocationMessage] interface.
|
||||
cancelInvocation, // = 5,
|
||||
/// Indicates the message is a Ping message and implements the [PingMessage] interface.
|
||||
ping, // = 6,
|
||||
/// Indicates the message is a Close message and implements the [CloseMessage] interface.
|
||||
close, // = 7,
|
||||
}
|
||||
|
||||
extension MessageTypeExtensions on MessageType? {
|
||||
int get value {
|
||||
switch (this) {
|
||||
case MessageType.undefined:
|
||||
return 0;
|
||||
case MessageType.invocation:
|
||||
return 1;
|
||||
case MessageType.streamItem:
|
||||
return 2;
|
||||
case MessageType.completion:
|
||||
return 3;
|
||||
case MessageType.streamInvocation:
|
||||
return 4;
|
||||
case MessageType.cancelInvocation:
|
||||
return 5;
|
||||
case MessageType.ping:
|
||||
return 6;
|
||||
case MessageType.close:
|
||||
return 7;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
String get name {
|
||||
switch (this) {
|
||||
case MessageType.undefined:
|
||||
return '0';
|
||||
case MessageType.invocation:
|
||||
return 'invocation';
|
||||
case MessageType.streamItem:
|
||||
return 'streamItem';
|
||||
case MessageType.completion:
|
||||
return 'completion';
|
||||
case MessageType.streamInvocation:
|
||||
return 'streamInvocation';
|
||||
case MessageType.cancelInvocation:
|
||||
return 'cancelInvocation';
|
||||
case MessageType.ping:
|
||||
return 'ping';
|
||||
case MessageType.close:
|
||||
return 'close';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines properties common to all Hub messages.
|
||||
abstract class HubMessage {
|
||||
const HubMessage({this.type});
|
||||
|
||||
/// A [MessageType] value indicating the type of this message.
|
||||
final MessageType? type;
|
||||
}
|
||||
|
||||
/// Defines properties common to all Hub messages relating to a specific invocation.
|
||||
abstract class HubInvocationMessage extends HubMessage {
|
||||
HubInvocationMessage({MessageType? type, this.headers, this.invocationId})
|
||||
: super(type: type);
|
||||
|
||||
/// A [MessageHeaders] dictionary containing headers attached to the message.
|
||||
final Map<String, String>? headers;
|
||||
|
||||
///The ID of the invocation relating to this message.
|
||||
///
|
||||
///This is expected to be present for StreamInvocationMessage and CompletionMessage. It may
|
||||
///be 'undefined' for an InvocationMessage if the sender does not expect a response.
|
||||
final String? invocationId;
|
||||
}
|
||||
|
||||
/// A hub message representing a non-streaming invocation.
|
||||
class InvocationMessage extends HubInvocationMessage {
|
||||
InvocationMessage(
|
||||
{this.target,
|
||||
this.arguments,
|
||||
this.streamIds,
|
||||
Map<String, String>? headers,
|
||||
String? invocationId})
|
||||
: super(
|
||||
type: MessageType.invocation,
|
||||
headers: headers,
|
||||
invocationId: invocationId);
|
||||
|
||||
/// The target method name.
|
||||
final String? target;
|
||||
|
||||
/// The target method arguments.
|
||||
final List<dynamic>? arguments;
|
||||
|
||||
/// The target method stream IDs.
|
||||
final List<String>? streamIds;
|
||||
}
|
||||
|
||||
/// A hub message representing a streaming invocation.
|
||||
class StreamInvocationMessage extends HubInvocationMessage {
|
||||
StreamInvocationMessage(
|
||||
{this.target,
|
||||
this.arguments,
|
||||
this.streamIds,
|
||||
Map<String, String>? headers,
|
||||
String? invocationId})
|
||||
: super(
|
||||
type: MessageType.streamInvocation,
|
||||
headers: headers,
|
||||
invocationId: invocationId);
|
||||
|
||||
/// The target method name.
|
||||
final String? target;
|
||||
|
||||
/// The target method arguments.
|
||||
final List<dynamic>? arguments;
|
||||
|
||||
/// The target method stream IDs.
|
||||
final List<String>? streamIds;
|
||||
}
|
||||
|
||||
/// A hub message representing a single item produced as part of a result stream.
|
||||
class StreamItemMessage extends HubInvocationMessage {
|
||||
StreamItemMessage(
|
||||
{this.item, Map<String, String>? headers, String? invocationId})
|
||||
: super(
|
||||
type: MessageType.streamItem,
|
||||
headers: headers,
|
||||
invocationId: invocationId);
|
||||
|
||||
/// The item produced by the server.
|
||||
final dynamic item;
|
||||
}
|
||||
|
||||
/// A hub message representing the result of an invocation.
|
||||
class CompletionMessage extends HubInvocationMessage with EquatableMixin {
|
||||
CompletionMessage(
|
||||
{this.error,
|
||||
this.result,
|
||||
Map<String, String>? headers,
|
||||
String? invocationId})
|
||||
: super(
|
||||
type: MessageType.completion,
|
||||
headers: headers,
|
||||
invocationId: invocationId);
|
||||
|
||||
/// The error produced by the invocation, if any.
|
||||
///
|
||||
/// Either CompletionMessage.error CompletionMessage.result must be defined, but not both.
|
||||
final String? error;
|
||||
|
||||
/// The result produced by the invocation, if any.
|
||||
///
|
||||
/// Either {@link @aspnet/signalr.CompletionMessage.error} or {@link @aspnet/signalr.CompletionMessage.result} must be defined, but not both.
|
||||
final dynamic result;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [error, result, headers, invocationId];
|
||||
}
|
||||
|
||||
/// A hub message indicating that the sender is still active.
|
||||
class PingMessage extends HubMessage with EquatableMixin {
|
||||
PingMessage() : super(type: MessageType.ping);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [type];
|
||||
}
|
||||
|
||||
/// A hub message indicating that the sender is closing the connection.
|
||||
///
|
||||
/// If {@link @aspnet/signalr.CloseMessage.error} is defined, the sender is closing the connection due to an error.
|
||||
///
|
||||
class CloseMessage extends HubMessage {
|
||||
CloseMessage({this.error, this.allowReconnect})
|
||||
: super(type: MessageType.close);
|
||||
|
||||
/// The error that triggered the close, if any.
|
||||
///
|
||||
/// If this property is undefined, the connection was closed normally and without error.
|
||||
final String? error;
|
||||
|
||||
/// If true, clients with automatic reconnects enabled should attempt to reconnect after receiving the CloseMessage.
|
||||
/// Otherwise, they should not.
|
||||
final bool? allowReconnect;
|
||||
}
|
||||
|
||||
/// A hub message sent to request that a streaming invocation be canceled.
|
||||
class CancelInvocationMessage extends HubInvocationMessage {
|
||||
CancelInvocationMessage({Map<String, String>? headers, String? invocationId})
|
||||
: super(
|
||||
type: MessageType.cancelInvocation,
|
||||
headers: headers,
|
||||
invocationId: invocationId);
|
||||
}
|
||||
|
||||
/// A protocol abstraction for communicating with SignalR Hubs.
|
||||
abstract class HubProtocol {
|
||||
HubProtocol({this.name, this.version, this.transferFormat});
|
||||
|
||||
/// The name of the protocol. This is used by SignalR to resolve the protocol between the client and server.
|
||||
final String? name;
|
||||
|
||||
/// The version of the protocol.
|
||||
final int? version;
|
||||
|
||||
/// The TransferFormat of the protocol. */
|
||||
final TransferFormat? transferFormat;
|
||||
|
||||
/// Creates an array of [HubMessage] objects from the specified serialized representation.
|
||||
///
|
||||
/// If transferFormat is 'Text', the `input` parameter must be a string, otherwise it must be an ArrayBuffer.
|
||||
///
|
||||
/// [input] A string (json), or Uint8List (binary) containing the serialized representation.
|
||||
/// [Logger] logger A logger that will be used to log messages that occur during parsing.
|
||||
|
||||
List<HubMessage?> parseMessages(Object input, Logging? logging);
|
||||
|
||||
/// Writes the specified HubMessage to a string or ArrayBuffer and returns it.
|
||||
///
|
||||
/// If transferFormat is 'Text', the result of this method will be a string, otherwise it will be an ArrayBuffer.
|
||||
///
|
||||
/// [message] The message to write.
|
||||
/// returns A string or ArrayBuffer containing the serialized representation of the message.
|
||||
|
||||
dynamic writeMessage(HubMessage message);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:signalr_core/src/hub_protocol.dart';
|
||||
import 'package:signalr_core/src/logger.dart';
|
||||
import 'package:signalr_core/src/text_message_format.dart';
|
||||
import 'package:signalr_core/src/transport.dart';
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
|
||||
const String jsonHubProtocolName = 'json';
|
||||
|
||||
/// Implements the JSON Hub Protocol.
|
||||
class JsonHubProtocol implements HubProtocol {
|
||||
@override
|
||||
String get name => jsonHubProtocolName;
|
||||
|
||||
@override
|
||||
int get version => 1;
|
||||
|
||||
@override
|
||||
TransferFormat get transferFormat => TransferFormat.text;
|
||||
|
||||
/// Creates an array of [HubMessage] objects from the specified serialized representation.
|
||||
@override
|
||||
List<HubMessage?> parseMessages(dynamic input, Logging? logging) {
|
||||
// Only JsonContent is allowed.
|
||||
if (!(input is String)) {
|
||||
throw Exception(
|
||||
'Invalid input for JSON hub protocol. Expected a string.');
|
||||
}
|
||||
|
||||
final jsonInput = input;
|
||||
final hubMessages = <HubMessage?>[];
|
||||
|
||||
// ignore: unnecessary_null_comparison
|
||||
if (input == null) {
|
||||
return hubMessages;
|
||||
}
|
||||
|
||||
// Parse the messages
|
||||
final messages = TextMessageFormat.parse(jsonInput);
|
||||
for (var message in messages) {
|
||||
final jsonData = json.decode(message);
|
||||
final messageType =
|
||||
_getMessageTypeFromJson(jsonData as Map<String, dynamic>);
|
||||
HubMessage? parsedMessage;
|
||||
|
||||
switch (messageType) {
|
||||
case MessageType.invocation:
|
||||
parsedMessage = InvocationMessageExtensions.fromJson(
|
||||
jsonData);
|
||||
_isInvocationMessage(parsedMessage as InvocationMessage);
|
||||
break;
|
||||
case MessageType.streamItem:
|
||||
parsedMessage = StreamItemMessageExtensions.fromJson(
|
||||
jsonData);
|
||||
_isStreamItemMessage(parsedMessage as StreamItemMessage);
|
||||
break;
|
||||
case MessageType.completion:
|
||||
parsedMessage = CompletionMessageExtensions.fromJson(
|
||||
jsonData);
|
||||
_isCompletionMessage(parsedMessage as CompletionMessage);
|
||||
break;
|
||||
case MessageType.ping:
|
||||
parsedMessage =
|
||||
PingMessageExtensions.fromJson(jsonData);
|
||||
// Single value, no need to validate
|
||||
break;
|
||||
case MessageType.close:
|
||||
parsedMessage =
|
||||
CloseMessageExtensions.fromJson(jsonData);
|
||||
// All optional values, no need to validate
|
||||
break;
|
||||
default:
|
||||
// Future protocol changes can add message types, old clients can ignore them
|
||||
logging!(
|
||||
LogLevel.information,
|
||||
'Unknown message type \'' +
|
||||
messageType.toString() +
|
||||
'\' ignored.');
|
||||
continue;
|
||||
}
|
||||
hubMessages.add(parsedMessage);
|
||||
}
|
||||
|
||||
return hubMessages;
|
||||
}
|
||||
|
||||
/// Writes the specified [HubMessage] to a string and returns it.
|
||||
@override
|
||||
String? writeMessage(HubMessage message) {
|
||||
switch (message.type) {
|
||||
case MessageType.undefined:
|
||||
break;
|
||||
case MessageType.invocation:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as InvocationMessage).toJson()));
|
||||
case MessageType.streamItem:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as StreamItemMessage).toJson()));
|
||||
case MessageType.completion:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as CompletionMessage).toJson()));
|
||||
case MessageType.streamInvocation:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as StreamInvocationMessage).toJson()));
|
||||
case MessageType.cancelInvocation:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as CancelInvocationMessage).toJson()));
|
||||
case MessageType.ping:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as PingMessage).toJson()));
|
||||
case MessageType.close:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as CloseMessage).toJson()));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static MessageType _getMessageTypeFromJson(Map<String, dynamic> json) {
|
||||
switch (json['type'] as int?) {
|
||||
case 0:
|
||||
return MessageType.undefined;
|
||||
case 1:
|
||||
return MessageType.invocation;
|
||||
case 2:
|
||||
return MessageType.streamItem;
|
||||
case 3:
|
||||
return MessageType.completion;
|
||||
case 4:
|
||||
return MessageType.streamInvocation;
|
||||
case 5:
|
||||
return MessageType.cancelInvocation;
|
||||
case 6:
|
||||
return MessageType.ping;
|
||||
case 7:
|
||||
return MessageType.close;
|
||||
default:
|
||||
return MessageType.undefined;
|
||||
}
|
||||
}
|
||||
|
||||
void _isInvocationMessage(InvocationMessage message) {
|
||||
_assertNotEmptyString(
|
||||
message.target, 'Invalid payload for Invocation message.');
|
||||
|
||||
if (message.invocationId != null) {
|
||||
_assertNotEmptyString(
|
||||
message.target, 'Invalid payload for Invocation message.');
|
||||
}
|
||||
}
|
||||
|
||||
void _isStreamItemMessage(StreamItemMessage message) {
|
||||
_assertNotEmptyString(
|
||||
message.invocationId, 'Invalid payload for StreamItem message.');
|
||||
|
||||
if (message.item == null) {
|
||||
throw Exception('Invalid payload for StreamItem message.');
|
||||
}
|
||||
}
|
||||
|
||||
void _isCompletionMessage(CompletionMessage message) {
|
||||
if ((message.result == null) && (message.error != null)) {
|
||||
_assertNotEmptyString(
|
||||
message.error, 'Invalid payload for Completion message.');
|
||||
}
|
||||
|
||||
_assertNotEmptyString(
|
||||
message.invocationId, 'Invalid payload for Completion message.');
|
||||
}
|
||||
|
||||
void _assertNotEmptyString(dynamic value, String errorMessage) {
|
||||
if ((value is String == false) || (value as String).isEmpty) {
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension InvocationMessageExtensions on InvocationMessage {
|
||||
static InvocationMessage fromJson(Map<String, dynamic> json) {
|
||||
return InvocationMessage(
|
||||
target: json['target'] as String?,
|
||||
arguments: json['arguments'] as List?,
|
||||
headers: json['headers'] as Map<String, String>?,
|
||||
invocationId: json['invocationId'] as String?,
|
||||
streamIds: json['streamIds'] as List<String>?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
if (invocationId != null) 'invocationId': invocationId,
|
||||
'target': target,
|
||||
'arguments': arguments ?? [],
|
||||
if (streamIds != null) 'streamIds': streamIds
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension StreamInvocationMessageExtensions on StreamInvocationMessage {
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
'invocationId': invocationId,
|
||||
'target': target,
|
||||
'arguments': arguments,
|
||||
'streamIds': streamIds
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension StreamItemMessageExtensions on StreamItemMessage {
|
||||
static StreamItemMessage fromJson(Map<String, dynamic> json) {
|
||||
return StreamItemMessage(
|
||||
item: json['item'] as dynamic,
|
||||
headers: json['headers'] as Map<String, String>?,
|
||||
invocationId: json['invocationId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
'item': item,
|
||||
'invocationId': invocationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension CancelInvocationMessageExtensions on CancelInvocationMessage {
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
'invocationId': invocationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension CompletionMessageExtensions on CompletionMessage {
|
||||
static CompletionMessage fromJson(Map<String, dynamic> json) {
|
||||
return CompletionMessage(
|
||||
result: json['result'],
|
||||
error: json['error'] as String?,
|
||||
headers: json['headers'] as Map<String, String>?,
|
||||
invocationId: json['invocationId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
'invocationId': invocationId,
|
||||
'result': result,
|
||||
'error': error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension PingMessageExtensions on PingMessage {
|
||||
static PingMessage fromJson(Map<String, dynamic> json) {
|
||||
return PingMessage();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension CloseMessageExtensions on CloseMessage {
|
||||
static CloseMessage fromJson(Map<String, dynamic> json) {
|
||||
return CloseMessage(error: json['error'] as String?);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
'error': error,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// Log Levels are ordered in increasing severity. So `Debug` is more severe than `Trace`, etc.
|
||||
enum LogLevel {
|
||||
/// Log level for very low severity diagnostic messages.
|
||||
trace,
|
||||
|
||||
/// Log level for low severity diagnostic messages.
|
||||
debug,
|
||||
|
||||
/// Log level for informational diagnostic messages.
|
||||
information,
|
||||
|
||||
/// Log level for diagnostic messages that indicate a non-fatal problem.
|
||||
warning,
|
||||
|
||||
/// Log level for diagnostic messages that indicate a failure in the current operation.
|
||||
error,
|
||||
|
||||
/// Log level for diagnostic messages that indicate a failure that will terminate the entire application.
|
||||
critical,
|
||||
|
||||
/// The highest possible log level. Used when configuring logging to indicate that no log messages should be emitted.
|
||||
none,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// An abstraction that controls when the client attempts to reconnect and how many attempts to do so.
|
||||
abstract class RetryPolicy {
|
||||
/// Called after the transport loses the connection.
|
||||
int? nextRetryDelayInMilliseconds(RetryContext retryContext);
|
||||
}
|
||||
|
||||
class RetryContext {
|
||||
const RetryContext({
|
||||
this.previousRetryCount,
|
||||
this.elapsedMilliseconds,
|
||||
this.retryReason,
|
||||
});
|
||||
|
||||
/// The number of consecutive failed tries so far.
|
||||
final int? previousRetryCount;
|
||||
|
||||
/// The amount of time in milliseconds spent retrying so far.
|
||||
final int? elapsedMilliseconds;
|
||||
|
||||
/// The error that forced the upcoming retry.
|
||||
final Exception? retryReason;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
mixin TextMessageFormat {
|
||||
static const RecordSeparatorCode = 0x1e;
|
||||
|
||||
static String recordSeparator =
|
||||
String.fromCharCode(TextMessageFormat.RecordSeparatorCode);
|
||||
|
||||
static String write(String output) {
|
||||
return '$output${TextMessageFormat.recordSeparator}';
|
||||
}
|
||||
|
||||
static List<String> parse(String input) {
|
||||
if (input.isEmpty) {
|
||||
throw Exception('Message is incomplete.');
|
||||
}
|
||||
|
||||
if (input[input.length - 1] != TextMessageFormat.recordSeparator) {
|
||||
throw Exception('Message is incomplete.');
|
||||
}
|
||||
|
||||
var messages = input.split(TextMessageFormat.recordSeparator)..removeLast();
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:signalr_core/signalr_core.dart';
|
||||
|
||||
/// Specifies a specific HTTP transport type.
|
||||
///
|
||||
/// This will be treated as a bit flag in the future, so we keep it using power-of-two values.
|
||||
enum HttpTransportType {
|
||||
/// Specifies no transport preference.
|
||||
none, // 0
|
||||
/// Specifies the WebSockets transport.
|
||||
webSockets, // 1
|
||||
/// Specifies the Server-Sent Events transport.
|
||||
serverSentEvents, // 2
|
||||
/// Specifies the Long Polling transport.
|
||||
longPolling // 4
|
||||
}
|
||||
|
||||
extension HttpTransportTypeExtensions on HttpTransportType {
|
||||
static HttpTransportType fromName(String? name) {
|
||||
switch (name) {
|
||||
case 'none':
|
||||
{
|
||||
return HttpTransportType.none;
|
||||
}
|
||||
case 'WebSockets':
|
||||
{
|
||||
return HttpTransportType.webSockets;
|
||||
}
|
||||
case 'ServerSentEvents':
|
||||
{
|
||||
return HttpTransportType.serverSentEvents;
|
||||
}
|
||||
case 'LongPolling':
|
||||
{
|
||||
return HttpTransportType.longPolling;
|
||||
}
|
||||
default:
|
||||
return HttpTransportType.none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Specifies the transfer format for a connection.
|
||||
enum TransferFormat {
|
||||
/// Specifies that only text data will be transmitted over the connection.
|
||||
text, // = 1,
|
||||
/// Specifies that binary data will be transmitted over the connection.
|
||||
binary, // = 2,
|
||||
}
|
||||
|
||||
extension TransferFormatExtensions on TransferFormat {
|
||||
static TransferFormat fromName(String name) {
|
||||
switch (name) {
|
||||
case 'Text':
|
||||
{
|
||||
return TransferFormat.text;
|
||||
}
|
||||
case 'Binary':
|
||||
{
|
||||
return TransferFormat.binary;
|
||||
}
|
||||
default:
|
||||
return TransferFormat.binary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An abstraction over the behavior of transports.
|
||||
///
|
||||
/// This is designed to support the framework and not intended for use by applications.
|
||||
abstract class Transport {
|
||||
Future<void> connect(String? url, TransferFormat? transferFormat);
|
||||
Future<void> send(dynamic data);
|
||||
Future<void> stop();
|
||||
OnReceive? onreceive;
|
||||
OnClose? onclose;
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:signalr_core/signalr_core.dart';
|
||||
|
||||
class LongPollingTransport implements Transport {
|
||||
final BaseClient? _client;
|
||||
final AccessTokenFactory? _accessTokenFactory;
|
||||
final Logging? _log;
|
||||
final bool? _logMessageContent;
|
||||
final bool? _withCredentials;
|
||||
|
||||
String? _url;
|
||||
late bool _running;
|
||||
Future<void>? _receiving;
|
||||
Exception? _closeError;
|
||||
|
||||
LongPollingTransport({
|
||||
BaseClient? client,
|
||||
AccessTokenFactory? accessTokenFactory,
|
||||
Logging? log,
|
||||
bool? logMessageContent,
|
||||
bool? withCredentials,
|
||||
}) : _client = client,
|
||||
_accessTokenFactory = accessTokenFactory,
|
||||
_log = log,
|
||||
_logMessageContent = logMessageContent,
|
||||
_withCredentials = withCredentials {
|
||||
_running = false;
|
||||
onreceive = null;
|
||||
onclose = null;
|
||||
}
|
||||
|
||||
@override
|
||||
OnClose? onclose;
|
||||
|
||||
@override
|
||||
OnReceive? onreceive;
|
||||
|
||||
@override
|
||||
Future<void> connect(String? url, TransferFormat? transferFormat) async {
|
||||
_url = url;
|
||||
|
||||
_log?.call(LogLevel.trace, '(LongPolling transport) Connecting.');
|
||||
|
||||
final headers = <String, String>{};
|
||||
final userAgentHeader = getUserAgentHeader();
|
||||
headers[userAgentHeader.item1] = userAgentHeader.item2;
|
||||
|
||||
final token = await _getAccessToken();
|
||||
if (token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
// Make initial long polling request
|
||||
// Server uses first long polling request to finish initializing connection and it returns without data
|
||||
final pollUrl = '$url&_=${DateTime.now().millisecondsSinceEpoch}';
|
||||
_log?.call(LogLevel.trace, '(LongPolling transport) polling: $pollUrl.');
|
||||
final response = await _client!.get(Uri.parse(pollUrl), headers: headers);
|
||||
if (response.statusCode != 200) {
|
||||
_log?.call(LogLevel.error,
|
||||
'(LongPolling transport) Unexpected response code: ${response.statusCode}.');
|
||||
|
||||
// Mark running as false so that the poll immediately ends and runs the close logic
|
||||
_closeError = Exception(response.statusCode);
|
||||
_running = false;
|
||||
} else {
|
||||
_running = true;
|
||||
}
|
||||
|
||||
_receiving = _poll(_url, headers);
|
||||
}
|
||||
|
||||
Future<String?> _getAccessToken() async {
|
||||
if (_accessTokenFactory != null) {
|
||||
return await _accessTokenFactory();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _poll(String? url, Map<String, String> headers) async {
|
||||
try {
|
||||
while (_running) {
|
||||
// We have to get the access token on each poll, in case it changes
|
||||
final token = await _getAccessToken();
|
||||
if (token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
final pollUrl = '$url&_=${DateTime.now().millisecondsSinceEpoch}';
|
||||
_log?.call(
|
||||
LogLevel.trace, '(LongPolling transport) polling: $pollUrl.');
|
||||
final response =
|
||||
await _client!.get(Uri.parse(pollUrl), headers: headers).timeout(
|
||||
const Duration(milliseconds: 100000),
|
||||
onTimeout: () {
|
||||
_log?.call(LogLevel.warning, 'Timeout from HTTP request.');
|
||||
throw TimeoutException('A timeout occurred.');
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 204) {
|
||||
_log?.call(LogLevel.information,
|
||||
'(LongPolling transport) Poll terminated by server.');
|
||||
|
||||
_running = false;
|
||||
} else if (response.statusCode != 200) {
|
||||
_log?.call(LogLevel.error,
|
||||
'(LongPolling transport) Unexpected response code: ${response.statusCode}.');
|
||||
|
||||
// Unexpected status code
|
||||
_closeError = Exception(response.statusCode);
|
||||
_running = false;
|
||||
} else {
|
||||
// Process the response
|
||||
if (response.body.isNotEmpty) {
|
||||
_log?.call(LogLevel.trace,
|
||||
'(LongPolling transport) data received. ${getDataDetail(response.body, _logMessageContent)}.');
|
||||
|
||||
if (onreceive != null) {
|
||||
onreceive!(response.body);
|
||||
}
|
||||
} else {
|
||||
// This is another way timeout manifest.
|
||||
_log?.call(LogLevel.trace,
|
||||
'(LongPolling transport) Poll timed out, reissuing.');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!_running) {
|
||||
// Log but disregard errors that occur after stopping
|
||||
_log?.call(LogLevel.trace,
|
||||
'(LongPolling transport) Poll errored after shutdown: $e');
|
||||
} else {
|
||||
if (e is TimeoutException) {
|
||||
// Ignore timeouts and reissue the poll.
|
||||
_log?.call(LogLevel.trace,
|
||||
'(LongPolling transport) Poll timed out, reissuing.');
|
||||
} else {
|
||||
// Close the connection with the error as the result.
|
||||
_closeError = e as Exception;
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_log?.call(LogLevel.trace, '(LongPolling transport) Polling complete.');
|
||||
|
||||
// We will reach here with pollAborted==false when the server returned a response causing the transport to stop.
|
||||
// If pollAborted==true then client initiated the stop and the stop method will raise the close event after DELETE is sent.
|
||||
// if (_pollAborted) {
|
||||
// _raiseOnClose();
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> send(data) async {
|
||||
if (!_running) {
|
||||
return Future.error(
|
||||
Exception('Cannot send until the transport is connected'));
|
||||
}
|
||||
return sendMessage(
|
||||
_log,
|
||||
'LongPolling',
|
||||
_client,
|
||||
_url,
|
||||
_accessTokenFactory,
|
||||
data,
|
||||
_logMessageContent,
|
||||
_withCredentials,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_log?.call(LogLevel.trace, '(LongPolling transport) Stopping polling.');
|
||||
|
||||
// Tell receiving loop to stop, abort any current request, and then wait for it to finish
|
||||
_running = false;
|
||||
//_pollAbort.abort();
|
||||
|
||||
try {
|
||||
await _receiving;
|
||||
|
||||
// Send DELETE to clean up long polling on the server
|
||||
_log?.call(LogLevel.trace,
|
||||
'(LongPolling transport) sending DELETE request to $_url.');
|
||||
|
||||
final headers = <String, String>{};
|
||||
final userAgentHeader = getUserAgentHeader();
|
||||
headers[userAgentHeader.item1] = userAgentHeader.item2;
|
||||
|
||||
final token = await _getAccessToken();
|
||||
if (token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
await _client!.delete(Uri.parse(_url!), headers: headers);
|
||||
|
||||
_log?.call(
|
||||
LogLevel.trace, '(LongPolling transport) DELETE request sent.');
|
||||
} finally {
|
||||
_log?.call(LogLevel.trace, '(LongPolling transport) Stop finished.');
|
||||
|
||||
// Raise close event here instead of in polling
|
||||
// It needs to happen after the DELETE request is sent
|
||||
_raiseOnClose();
|
||||
}
|
||||
}
|
||||
|
||||
void _raiseOnClose() {
|
||||
if (onclose != null) {
|
||||
var logMessage = '(LongPolling transport) Firing onclose event.';
|
||||
if (_closeError != null) {
|
||||
logMessage += ' Error: ' + _closeError.toString();
|
||||
}
|
||||
_log?.call(LogLevel.trace, logMessage);
|
||||
onclose!(_closeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:signalr_core/src/logger.dart';
|
||||
import 'package:signalr_core/src/transport.dart';
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
import 'package:sse_channel/sse_channel.dart';
|
||||
|
||||
class ServerSentEventsTransport implements Transport {
|
||||
final BaseClient? _client;
|
||||
final AccessTokenFactory? _accessTokenFactory;
|
||||
final Logging? _log;
|
||||
final bool? _logMessageContent;
|
||||
final bool? _withCredentials;
|
||||
String? _url;
|
||||
SseChannel? _sseChannel;
|
||||
|
||||
ServerSentEventsTransport({
|
||||
BaseClient? client,
|
||||
AccessTokenFactory? accessTokenFactory,
|
||||
Logging? logging,
|
||||
bool? logMessageContent,
|
||||
bool? withCredentials,
|
||||
}) : _client = client,
|
||||
_accessTokenFactory = accessTokenFactory,
|
||||
_log = logging,
|
||||
_logMessageContent = logMessageContent,
|
||||
_withCredentials = withCredentials {
|
||||
onclose = null;
|
||||
onreceive = null;
|
||||
}
|
||||
|
||||
@override
|
||||
OnClose? onclose;
|
||||
|
||||
@override
|
||||
OnReceive? onreceive;
|
||||
|
||||
@override
|
||||
Future<void> connect(String? url, TransferFormat? transferFormat) async {
|
||||
_log!(LogLevel.trace, '(SSE transport) Connecting.');
|
||||
|
||||
// set url before accessTokenFactory because this.url is only for send and we set the auth header instead of the query string for send
|
||||
_url = url;
|
||||
|
||||
if (_accessTokenFactory != null) {
|
||||
final token = await _accessTokenFactory();
|
||||
if (token != null && _url != null) {
|
||||
_url = _url! +
|
||||
(!url!.contains('?') ? '?' : '&') +
|
||||
'access_token=${Uri.encodeComponent(token)}';
|
||||
}
|
||||
}
|
||||
|
||||
var completer = Completer<void>();
|
||||
|
||||
var opened = false;
|
||||
if (transferFormat != TransferFormat.text) {
|
||||
return completer.completeError(
|
||||
Exception(
|
||||
'The Server-Sent Events transport only supports the \'Text\' transfer format'),
|
||||
);
|
||||
}
|
||||
|
||||
SseChannel channel;
|
||||
try {
|
||||
channel = SseChannel.connect(Uri.parse(url!));
|
||||
_log(LogLevel.information, 'SSE connected to $_url');
|
||||
opened = true;
|
||||
_sseChannel = channel;
|
||||
completer.complete();
|
||||
} catch (e) {
|
||||
return completer.completeError(e);
|
||||
}
|
||||
|
||||
_sseChannel!.stream.listen((data) {
|
||||
_log(LogLevel.trace,
|
||||
'(SSE transport) data received. ${getDataDetail(data, _logMessageContent)}');
|
||||
onreceive!(data);
|
||||
}, onError: (e) {
|
||||
if (opened) {
|
||||
_close(exception: e as Exception);
|
||||
} else if (e is Object) {
|
||||
completer.completeError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> send(data) async {
|
||||
if (_sseChannel == null) {
|
||||
return Future.error(
|
||||
Exception('Cannot send until the transport is connected'));
|
||||
}
|
||||
return sendMessage(
|
||||
_log!,
|
||||
'SSE',
|
||||
_client!,
|
||||
_url!,
|
||||
_accessTokenFactory,
|
||||
data,
|
||||
_logMessageContent,
|
||||
_withCredentials,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() {
|
||||
_close();
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
void _close({Exception? exception}) {
|
||||
if (_sseChannel != null) {
|
||||
_sseChannel = null;
|
||||
|
||||
if (onclose != null) {
|
||||
onclose!(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import 'package:http/http.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
Future<WebSocketChannel> connect(Uri uri, {BaseClient? client}) => Future.error(
|
||||
UnsupportedError('No implementation of the connect api provided'));
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import 'package:http/http.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
Future<WebSocketChannel> connect(Uri uri, {BaseClient? client}) async =>
|
||||
Future.value(WebSocketChannel.connect(uri));
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
Future<WebSocketChannel> connect(
|
||||
Uri uri, {
|
||||
required BaseClient client,
|
||||
}) async {
|
||||
var random = Random();
|
||||
|
||||
var nonceData = Uint8List(16);
|
||||
for (var i = 0; i < 16; i++) {
|
||||
nonceData[i] = random.nextInt(256);
|
||||
}
|
||||
var nonce = base64.encode(nonceData);
|
||||
|
||||
WebSocket ws;
|
||||
|
||||
var wsUri = Uri(
|
||||
scheme: uri.scheme == 'wss' ? 'https' : 'http',
|
||||
userInfo: uri.userInfo,
|
||||
host: uri.host,
|
||||
port: uri.port,
|
||||
path: uri.path,
|
||||
query: uri.query,
|
||||
fragment: uri.fragment);
|
||||
|
||||
var request = Request('GET', wsUri)
|
||||
..headers.addAll({
|
||||
'Connection': 'Upgrade',
|
||||
'Upgrade': 'websocket',
|
||||
'Sec-WebSocket-Key': nonce,
|
||||
'Cache-Control': 'no-cache',
|
||||
'Sec-WebSocket-Version': '13',
|
||||
});
|
||||
|
||||
var response = await client.send(request);
|
||||
var socket = await (response as IOStreamedResponse).detachSocket();
|
||||
|
||||
ws = WebSocket.fromUpgradedSocket(
|
||||
socket,
|
||||
serverSide: false,
|
||||
);
|
||||
|
||||
return IOWebSocketChannel(ws);
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:signalr_core/src/logger.dart';
|
||||
import 'package:signalr_core/src/transport.dart';
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import 'web_socket_channel_api.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.io) 'web_socket_channel_io.dart'
|
||||
// dart.library.html is only true under dart2js; dart.library.js_interop is true
|
||||
// under both dart2js and dart2wasm, so this also selects the working browser
|
||||
// implementation when compiled with `flutter build web --wasm`.
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.js_interop) 'web_socket_channel_html.dart' as platform;
|
||||
|
||||
class WebSocketTransport implements Transport {
|
||||
final Logging? _logging;
|
||||
final AccessTokenFactory? _accessTokenFactory;
|
||||
final bool? _logMessageContent;
|
||||
final BaseClient? _client;
|
||||
|
||||
StreamSubscription<dynamic>? _streamSubscription;
|
||||
WebSocketChannel? _channel;
|
||||
|
||||
WebSocketTransport({
|
||||
BaseClient? client,
|
||||
AccessTokenFactory? accessTokenFactory,
|
||||
Logging? logging,
|
||||
bool? logMessageContent,
|
||||
}) : _logging = logging,
|
||||
_accessTokenFactory = accessTokenFactory,
|
||||
_logMessageContent = logMessageContent,
|
||||
_client = client {
|
||||
onreceive = null;
|
||||
onclose = null;
|
||||
}
|
||||
|
||||
@override
|
||||
OnClose? onclose;
|
||||
|
||||
@override
|
||||
OnReceive? onreceive;
|
||||
|
||||
@override
|
||||
Future<void> connect(String? url, TransferFormat? transferFormat) async {
|
||||
assert(url != null);
|
||||
assert(transferFormat != null);
|
||||
|
||||
_logging!(LogLevel.trace, '(WebSockets transport) Connecting.');
|
||||
|
||||
if (_accessTokenFactory != null) {
|
||||
final token = await _accessTokenFactory();
|
||||
if (token!.isNotEmpty) {
|
||||
final encodedToken = Uri.encodeComponent(token);
|
||||
url = url! +
|
||||
(url.contains('?') ? '&' : '?') +
|
||||
'access_token=$encodedToken';
|
||||
}
|
||||
}
|
||||
|
||||
final connectFuture = Completer<void>();
|
||||
var opened = false;
|
||||
|
||||
url = url!.replaceFirst(RegExp(r'^http'), 'ws');
|
||||
|
||||
_channel = await platform.connect(Uri.parse(url), client: _client!);
|
||||
|
||||
_logging(LogLevel.information, 'WebSocket connected to $url.');
|
||||
opened = true;
|
||||
|
||||
_streamSubscription = _channel?.stream.listen((data) {
|
||||
var dataDetail = getDataDetail(data, _logMessageContent);
|
||||
_logging(
|
||||
LogLevel.trace, '(WebSockets transport) data received. $dataDetail');
|
||||
if (onreceive != null) {
|
||||
try {
|
||||
onreceive!(data);
|
||||
} on Exception catch (e1) {
|
||||
_close(e1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, onError: (e) {
|
||||
_logging(LogLevel.error,
|
||||
'(WebSockets transport) socket error: ${e.toString()}}');
|
||||
}, onDone: () {
|
||||
if (opened == true) {
|
||||
_close(null);
|
||||
} else {}
|
||||
}, cancelOnError: false);
|
||||
|
||||
return connectFuture.complete();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> send(dynamic data) {
|
||||
if ((_channel == null) || (_channel?.closeCode != null)) {
|
||||
return Future.error(Exception('WebSocket is not in the OPEN state'));
|
||||
}
|
||||
|
||||
_logging!(LogLevel.trace,
|
||||
'(WebSockets transport) sending data. ${getDataDetail(data, _logMessageContent)}.');
|
||||
_channel!.sink.add(data);
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() {
|
||||
if (_channel != null) {
|
||||
_close(null);
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
void _close(Exception? error) {
|
||||
var closeCode = 0;
|
||||
String? closeReason;
|
||||
if (_channel != null) {
|
||||
closeCode = _channel!.closeCode ?? 0;
|
||||
closeReason = _channel!.closeReason;
|
||||
_streamSubscription!.cancel();
|
||||
_streamSubscription = null;
|
||||
_channel!.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
_logging!(LogLevel.trace, '(WebSockets transport) socket closed.');
|
||||
if (onclose != null) {
|
||||
if (error != null) {
|
||||
onclose!(error);
|
||||
} else {
|
||||
if (closeCode != 0 && closeCode != 1000) {
|
||||
onclose!(
|
||||
Exception(
|
||||
'WebSocket closed with status code: $closeCode ($closeReason).'),
|
||||
);
|
||||
}
|
||||
onclose!(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:signalr_core/src/logger.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
typedef OnReceive = void Function(dynamic data);
|
||||
typedef OnClose = void Function(Exception? error);
|
||||
typedef AccessTokenFactory = Future<String?> Function();
|
||||
typedef Logging = void Function(LogLevel level, String message);
|
||||
|
||||
const String version = '0.0.0-DEV_BUILD';
|
||||
|
||||
String getDataDetail(dynamic data, bool? includeContent) {
|
||||
var detail = '';
|
||||
|
||||
if (data is ByteBuffer) {
|
||||
detail = 'Binary data of length ${data.lengthInBytes}';
|
||||
if (includeContent!) {
|
||||
detail += '. Content: \'${formatByteBuffer(data)}\'';
|
||||
}
|
||||
} else if (data is String) {
|
||||
detail = 'String data of length \'${data.length}\'';
|
||||
if (includeContent!) {
|
||||
detail += '. Content: \'$data\'';
|
||||
}
|
||||
}
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
String formatByteBuffer(ByteBuffer data) {
|
||||
final view = data.asUint8List();
|
||||
|
||||
var str = '';
|
||||
for (var n in view) {
|
||||
final pad = n < 16 ? '0' : '';
|
||||
str += '0x$pad${n.toStringAsFixed(16)} ';
|
||||
}
|
||||
|
||||
return str.substring(0, str.length - 1);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(
|
||||
Logging? log,
|
||||
String transportName,
|
||||
BaseClient? client,
|
||||
String? url,
|
||||
AccessTokenFactory? accessTokenFactory,
|
||||
dynamic content,
|
||||
bool? logMessageContent,
|
||||
bool? withCredentials) async {
|
||||
var headers = <String, String>{};
|
||||
if (accessTokenFactory != null) {
|
||||
final token = await accessTokenFactory();
|
||||
if (token != null) {
|
||||
headers = {
|
||||
'Authorization': 'Bearer $token',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final userAgentHeader = getUserAgentHeader();
|
||||
headers[userAgentHeader.item1] = userAgentHeader.item2;
|
||||
|
||||
log?.call(LogLevel.trace,
|
||||
'($transportName transport) sending data. ${getDataDetail(content, logMessageContent)}.');
|
||||
|
||||
final encoding = (content is ByteBuffer)
|
||||
? Encoding.getByName('')
|
||||
: Encoding.getByName('UTF-8');
|
||||
final response = await client?.post(Uri.parse(url ?? ''),
|
||||
headers: headers, body: content, encoding: encoding);
|
||||
|
||||
log?.call(LogLevel.trace,
|
||||
'($transportName transport) request complete. Response status: ${response?.statusCode}.');
|
||||
}
|
||||
|
||||
Tuple2<String, String> getUserAgentHeader() {
|
||||
var userAgentHeaderName = 'X-SignalR-User-Agent';
|
||||
return Tuple2<String, String>(
|
||||
userAgentHeaderName,
|
||||
_constructUserAgent(
|
||||
version, getOsName(), getRuntime(), getRuntimeVersion()));
|
||||
}
|
||||
|
||||
String _constructUserAgent(
|
||||
String version,
|
||||
String os,
|
||||
String runtime,
|
||||
String runtimeVersion,
|
||||
) {
|
||||
// Microsoft SignalR/[Version] ([Detailed Version]; [Operating System]; [Runtime]; [Runtime Version])
|
||||
var userAgent = 'Microsoft SignalR/';
|
||||
|
||||
final majorAndMinor = version.split('.');
|
||||
userAgent += '${majorAndMinor[0]}.${majorAndMinor[1]}';
|
||||
userAgent += ' ($version; ';
|
||||
|
||||
if (os.isNotEmpty) {
|
||||
userAgent += '$os; ';
|
||||
} else {
|
||||
userAgent += 'Unknown OS; ';
|
||||
}
|
||||
|
||||
userAgent += '$runtime';
|
||||
|
||||
if (runtimeVersion.isNotEmpty) {
|
||||
userAgent += '; $runtimeVersion';
|
||||
} else {
|
||||
userAgent += '; Unknown Runtime Version';
|
||||
}
|
||||
|
||||
userAgent += ')';
|
||||
return userAgent;
|
||||
}
|
||||
|
||||
String getOsName() {
|
||||
// TODO: Figure out to determine the platform without using dart:io
|
||||
return '';
|
||||
}
|
||||
|
||||
String getRuntimeVersion() {
|
||||
return '';
|
||||
}
|
||||
|
||||
String getRuntime() {
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
name: signalr_core
|
||||
description: ASP.NET Core SignalR Client written for Dart that is platform-independent, and can be used on both the command-line and the browser.
|
||||
version: 1.1.2
|
||||
homepage: https://github.com/jamiewest/signalr_core
|
||||
|
||||
environment:
|
||||
sdk: ^3.2.6
|
||||
|
||||
dependencies:
|
||||
http: ^1.2.0
|
||||
logging: ^1.2.0
|
||||
meta: ^1.9.1
|
||||
tuple: ^2.0.2
|
||||
web_socket_channel: ^2.4.3
|
||||
sse_channel: ^0.1.1
|
||||
equatable: ^2.0.5
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^3.0.0
|
||||
test: ^1.24.4
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:signalr_core/signalr_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'withAutomaticReconnect uses default retryDelays when called with no arguments',
|
||||
() {
|
||||
// From DefaultReconnectPolicy.dart
|
||||
final defaultRetryDelaysInMilliseconds = [0, 2000, 10000, 30000, null];
|
||||
final builder = HubConnectionBuilder().withAutomaticReconnect();
|
||||
|
||||
var retryCount = 0;
|
||||
for (var delay in defaultRetryDelaysInMilliseconds) {
|
||||
final retryContext = RetryContext(
|
||||
previousRetryCount: retryCount++,
|
||||
elapsedMilliseconds: 0,
|
||||
retryReason: Exception(),
|
||||
);
|
||||
|
||||
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryContext),
|
||||
delay);
|
||||
}
|
||||
});
|
||||
|
||||
test('withAutomaticReconnect uses custom retryDelays when provided', () {
|
||||
final customRetryDelays = [3, 1, 4, 1, 5, 9];
|
||||
final builder =
|
||||
HubConnectionBuilder().withAutomaticReconnect(customRetryDelays);
|
||||
|
||||
var retryCount = 0;
|
||||
for (var delay in customRetryDelays) {
|
||||
final retryContext = RetryContext(
|
||||
previousRetryCount: retryCount++,
|
||||
elapsedMilliseconds: 0,
|
||||
retryReason: Exception(),
|
||||
);
|
||||
|
||||
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryContext),
|
||||
delay);
|
||||
}
|
||||
|
||||
// TODO: This test fails in Dart but looks like works using Typescript's testing framework
|
||||
// final retryContextFinal = RetryContext(
|
||||
// previousRetryCount: retryCount++,
|
||||
// elapsedMilliseconds: 0,
|
||||
// retryReason: Exception());
|
||||
|
||||
// expect(
|
||||
// builder.reconnectPolicy.nextRetryDelayInMilliseconds(retryContextFinal),
|
||||
// null);
|
||||
});
|
||||
|
||||
test('withAutomaticReconnect uses a custom RetryPolicy when provided', () {
|
||||
final customRetryDelays = [127, 0, 0, 1];
|
||||
final builder = HubConnectionBuilder().withAutomaticReconnect(
|
||||
DefaultReconnectPolicy(retryDelays: customRetryDelays));
|
||||
|
||||
var retryCount = 0;
|
||||
for (var delay in customRetryDelays) {
|
||||
final retryContext = RetryContext(
|
||||
previousRetryCount: retryCount++,
|
||||
elapsedMilliseconds: 0,
|
||||
retryReason: Exception(),
|
||||
);
|
||||
|
||||
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryContext),
|
||||
delay);
|
||||
}
|
||||
|
||||
// TODO: This test fails in Dart but looks like works using Typescript's testing framework
|
||||
// final retryContextFinal = RetryContext(
|
||||
// previousRetryCount: retryCount++,
|
||||
// elapsedMilliseconds: 0,
|
||||
// retryReason: Exception());
|
||||
|
||||
// expect(
|
||||
// builder.reconnectPolicy.nextRetryDelayInMilliseconds(retryContextFinal),
|
||||
// null);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:signalr_core/src/hub_protocol.dart';
|
||||
import 'package:signalr_core/src/json_hub_protocol.dart';
|
||||
import 'package:signalr_core/src/text_message_format.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('can read ping message', () {
|
||||
final payload = '{"type":6}${TextMessageFormat.recordSeparator}';
|
||||
final messages = JsonHubProtocol()
|
||||
.parseMessages(payload, (level, message) => print(message));
|
||||
expect(messages, equals([PingMessage()]));
|
||||
});
|
||||
|
||||
test('can read completion message', () {
|
||||
final payload = '{"type":3,"invocationId":"0","result":{"data":"123"}}'
|
||||
'${TextMessageFormat.recordSeparator}';
|
||||
final messages = JsonHubProtocol()
|
||||
.parseMessages(payload, (level, message) => print(message));
|
||||
expect(
|
||||
messages,
|
||||
equals([
|
||||
CompletionMessage(invocationId: '0', result: {'data': '123'})
|
||||
]));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:signalr_core/src/text_message_format.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const Map<String, List<String>> messages = {
|
||||
'\u001e': [''],
|
||||
'\u001e\u001e': ['', ''],
|
||||
'Hello\u001e': ['Hello'],
|
||||
'Hello,\u001eWorld!\u001e': ['Hello,', 'World!']
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('TextMessageFormat', () {
|
||||
Map<String, List<String>>.from({
|
||||
'\u001e': [''],
|
||||
'\u001e\u001e': ['', ''],
|
||||
'Hello\u001e': ['Hello'],
|
||||
'Hello,\u001eWorld!\u001e': ['Hello,', 'World!']
|
||||
}).forEach((payload, expectedMessages) {
|
||||
test('should parse \'${Uri.encodeComponent(payload)}\' correctly', () {
|
||||
final messages = TextMessageFormat.parse(payload);
|
||||
expect(messages, expectedMessages);
|
||||
});
|
||||
});
|
||||
|
||||
Map<String, String>.from({
|
||||
'': 'Message is incomplete.',
|
||||
'ABC': 'Message is incomplete.',
|
||||
'ABC\u001eXYZ': 'Message is incomplete.',
|
||||
}).forEach((payload, expectedError) {
|
||||
test('should fail to parse \'${Uri.encodeComponent(payload)}\'', () {
|
||||
//expect(() => TextMessageFormat.parse(payload), predicate((Exception e) => e is Exception && e.message == 'Message is incomplete.'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user