feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core

This commit is contained in:
2026-08-24 21:37:43 +02:00
parent 676496b77d
commit 0894c40f07
113 changed files with 12413 additions and 3613 deletions
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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;
}
@@ -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);
}
}
}
@@ -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);
}
}
}
}
@@ -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'));
@@ -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));
@@ -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);
}
@@ -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);
}
}
}
}
+130
View File
@@ -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 '';
}
+20
View File
@@ -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.'));
});
});
});
}