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
@@ -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 '';
}