From 52083f14c85d4990d136ea7c3510ae34a38be03e Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 16 Jul 2025 15:00:41 +0200 Subject: [PATCH] initial implementation of websocket --- packages/stream_core/README.md | 3 + packages/stream_core/docs/web_socket.md | 67 ++++ .../lib/src/errors/client_exception.dart | 54 ++++ .../lib/src/errors/http_api_error.dart | 31 ++ packages/stream_core/lib/src/utils.dart | 2 + .../lib/src/utils/network_monitor.dart | 12 + .../stream_core/lib/src/utils/result.dart | 170 ++++++++++ .../lib/src/utils/shared_emitter.dart | 92 ++++++ packages/stream_core/lib/src/ws.dart | 0 .../client/connection_recovery_handler.dart | 199 ++++++++++++ .../web_socket_channel_factory.dart | 11 + .../web_socket_channel_factory_html.dart | 31 ++ .../web_socket_channel_factory_io.dart | 22 ++ .../lib/src/ws/client/web_socket_client.dart | 208 ++++++++++++ .../client/web_socket_connection_state.dart | 165 ++++++++++ .../lib/src/ws/client/web_socket_engine.dart | 123 +++++++ .../ws/client/web_socket_ping_controller.dart | 67 ++++ .../lib/src/ws/events/sendable_event.dart | 24 ++ .../lib/src/ws/events/ws_event.dart | 39 +++ packages/stream_core/lib/stream_core.dart | 7 +- packages/stream_core/pubspec.yaml | 13 +- pubspec.lock | 301 ++++++++++++++++++ 22 files changed, 1628 insertions(+), 13 deletions(-) create mode 100644 packages/stream_core/docs/web_socket.md create mode 100644 packages/stream_core/lib/src/errors/client_exception.dart create mode 100644 packages/stream_core/lib/src/errors/http_api_error.dart create mode 100644 packages/stream_core/lib/src/utils.dart create mode 100644 packages/stream_core/lib/src/utils/network_monitor.dart create mode 100644 packages/stream_core/lib/src/utils/result.dart create mode 100644 packages/stream_core/lib/src/utils/shared_emitter.dart create mode 100644 packages/stream_core/lib/src/ws.dart create mode 100644 packages/stream_core/lib/src/ws/client/connection_recovery_handler.dart create mode 100644 packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart create mode 100644 packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart create mode 100644 packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_io.dart create mode 100644 packages/stream_core/lib/src/ws/client/web_socket_client.dart create mode 100644 packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart create mode 100644 packages/stream_core/lib/src/ws/client/web_socket_engine.dart create mode 100644 packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart create mode 100644 packages/stream_core/lib/src/ws/events/sendable_event.dart create mode 100644 packages/stream_core/lib/src/ws/events/ws_event.dart create mode 100644 pubspec.lock diff --git a/packages/stream_core/README.md b/packages/stream_core/README.md index 159aa9a..f447fdd 100644 --- a/packages/stream_core/README.md +++ b/packages/stream_core/README.md @@ -23,3 +23,6 @@ This package is **not designed for direct use by customers**. It acts as the fou ## ⚠️ Versioning Notice This library does follow semantic versioning. Breaking changes may be introduced at any time without warning. We reserve the right to refactor or remove functionality without deprecation periods. However, as all our products need to depend on the same version of the core packages we want to limit breaking changes as much as possible. + +## Detailed docs +* [Websocket](./docs/web_socket.md) \ No newline at end of file diff --git a/packages/stream_core/docs/web_socket.md b/packages/stream_core/docs/web_socket.md new file mode 100644 index 0000000..1cc28eb --- /dev/null +++ b/packages/stream_core/docs/web_socket.md @@ -0,0 +1,67 @@ +# Stream Core Websocket + +## TODO +- [ ] cover with unit tests +- [ ] test implementation +- [ ] reconnect logic +- [ ] improve docs +- [ ] replace print statements with proper logs + +## Overall architecture + +The `WebSocketEngine` is purely responsible for connecting to the websocket and handling events. + +The `WebSocketClient` is the public interface and can be used for apps to connect to a websocket. + +The `WebSocketPingController` keeps track of the timings of the ping/pong for health checks. +It uses the `WebSocketPingClient`, implemented by the `WebSocketClient` to send the ping and listen to pongs. +It also will call the `WebSocketPingClient` if it should disconnect because of a bad connection. + +The `ConnectionRecoveryHandler` manages the reconnection for all cases. + + + +```mermaid +graph TD; + ConnectionRecoveryHandler-->WebSocketClient; + ConnectionRecoveryHandler-->NetworkMonitor; + WebSocketClient-->WebSocketEngine; + WebSocketPingController-->WebSocketClient; +``` + +## WebSocketClient +```dart + WebSocketClient({ + required String url, + required this.eventDecoder, + this.pingReguestBuilder, + this.onConnectionEstablished, + this.onConnected, + }) +``` +The `WebSocketClient` always requires an `eventDecoder`. You should use this to map the websocket message to your own event. +It's important to also map to the `HealthCheckPongEvent` for health check events. + + +When you need to authenticate for the websocket you should sent the authentication event in `onConnectionEstablished`. +The `onConnected` is called when the connection is fully established after (optional) authentication. + +## WebSocketPingController + +The `WebSocketPingController` will use the `WebSocketClient` to send pings to the backend while the websocket is connected. By default it sends a ping every 25 seconds. It expects a pong from the backend within a certain interval, by default 3 seconds. If it doesn't get the pong it will request the `WebSocketClient` to disconnect using `disconnectNoPongReceived`. + +By default the `WebSocketClient` will send a basic health check event for the ping with the connectionId. If you need a different health check event, for example for the SFU, you need to add a `pingReguestBuilder` in the `WebSocketClient`. + +## ConnectionRecoveryHandler + +The `ConnectionRecoveryHandler` manages the reconnection for all cases. Currently implemented are network related reconnection events +and reconnections for websocket errors. The reason in the disconnected state determines if the recovery will reconnect or not. + +When creating a `WebSocketClient` you should also create a `ConnectionRecoveryHandler` yourself like this: + +```dart +final client = WebSocketClient(...); +final recoveryHandler = ConnectionRecoveryHandler(client: client); +``` + +The `WebSocketClient` itself just disconnects when there is an error and the `ConnectionRecoveryHandler` is responsible for reconnecting when needed. \ No newline at end of file diff --git a/packages/stream_core/lib/src/errors/client_exception.dart b/packages/stream_core/lib/src/errors/client_exception.dart new file mode 100644 index 0000000..7247e53 --- /dev/null +++ b/packages/stream_core/lib/src/errors/client_exception.dart @@ -0,0 +1,54 @@ +import 'package:equatable/equatable.dart'; + +import 'http_api_error.dart'; + +class ClientException extends Equatable { + final String? message; + + late final Object? underlyingError; + late final HttpApiError? apiError; + + ClientException({ + this.message, + Object? error, + }) { + underlyingError = error; + if (error is HttpApiError) { + apiError = error; + } + } + + @override + List get props => [message, underlyingError, apiError]; +} + +class WebSocketException extends ClientException { + WebSocketException(this.serverException, {super.error}) + : super( + message: + (serverException ?? WebSocketEngineException.unknown()).reason, + ); + final WebSocketEngineException? serverException; +} + + +class WebSocketEngineException extends ClientException { + static const stopErrorCode = 1000; + + final String reason; + final int code; + final Object? engineError; + + WebSocketEngineException({ + required this.reason, + required this.code, + this.engineError, + }) : super(message: reason); + + WebSocketEngineException.unknown() + : this( + reason: 'Unknown', + code: 0, + engineError: null, + ); +} diff --git a/packages/stream_core/lib/src/errors/http_api_error.dart b/packages/stream_core/lib/src/errors/http_api_error.dart new file mode 100644 index 0000000..447e849 --- /dev/null +++ b/packages/stream_core/lib/src/errors/http_api_error.dart @@ -0,0 +1,31 @@ +abstract interface class HttpApiError { + /// Response HTTP status code + int get statusCode; + + /// API error code + int get code; + + /// Additional error-specific information + List get details; + + /// Request duration + String get duration; + + /// Additional error info + Map get exceptionFields; + + /// Message describing an error + String get message; + + /// URL with additional information + String get moreInfo; + + /// Flag that indicates if the error is unrecoverable, requests that return unrecoverable errors should not be retried, this error only applies to the request that caused it + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? get unrecoverable; +} diff --git a/packages/stream_core/lib/src/utils.dart b/packages/stream_core/lib/src/utils.dart new file mode 100644 index 0000000..5bf66ae --- /dev/null +++ b/packages/stream_core/lib/src/utils.dart @@ -0,0 +1,2 @@ +export 'utils/result.dart'; +export 'utils/shared_emitter.dart'; diff --git a/packages/stream_core/lib/src/utils/network_monitor.dart b/packages/stream_core/lib/src/utils/network_monitor.dart new file mode 100644 index 0000000..0a246e1 --- /dev/null +++ b/packages/stream_core/lib/src/utils/network_monitor.dart @@ -0,0 +1,12 @@ +abstract class NetworkMonitor { + NetworkStatus get currentStatus; + Stream get onStatusChange; +} + +enum NetworkStatus { + /// Internet is available because at least one of the HEAD requests succeeded. + connected, + + /// None of the HEAD requests succeeded. Basically, no internet. + disconnected, +} diff --git a/packages/stream_core/lib/src/utils/result.dart b/packages/stream_core/lib/src/utils/result.dart new file mode 100644 index 0000000..fb421ea --- /dev/null +++ b/packages/stream_core/lib/src/utils/result.dart @@ -0,0 +1,170 @@ +import 'package:equatable/equatable.dart'; + +enum _ResultType { success, failure } + +/// A class which encapsulates a successful outcome with a value of type [T] +/// or a failure with [VideoError]. +abstract class Result extends Equatable { + const Result._(this._type); + + const factory Result.success(T value) = Success._; + + const factory Result.failure(Object error, [StackTrace stackTrace]) = Failure._; + + final _ResultType _type; + + /// Checks if the result is a [Success]. + bool get isSuccess => _type == _ResultType.success; + + /// Check if the result is a [Failure]. + bool get isFailure => _type == _ResultType.failure; +} + +/// Represents successful result. +class Success extends Result { + const Success._(this.data) : super._(_ResultType.success); + + /// The [T] data associated with the result. + final T data; + + @override + List get props => [data]; + + @override + String toString() { + return 'Result.Success{data: $data}'; + } +} + +/// Represents failed result. +class Failure extends Result { + const Failure._(this.error, [this.stackTrace]) : super._(_ResultType.failure); + + /// The [error] associated with the result. + final Object error; + + /// The [stackTrace] associated with the result. + final StackTrace? stackTrace; + + @override + List get props => [error, stackTrace]; + + @override + String toString() { + return 'Result.Failure{error: $error, stackTrace: $stackTrace}'; + } +} + +extension PatternMatching on Result { + /// The [when] method is the equivalent to pattern matching. + /// Its prototype depends on the _Result [_type]s defined. + R when({ + required R Function(T data) success, + required R Function(Object error) failure, + }) { + switch (_type) { + case _ResultType.success: + return success((this as Success).data); + case _ResultType.failure: + return failure((this as Failure).error); + } + } + + /// The [whenOrElse] method is equivalent to [when], but doesn't require + /// all callbacks to be specified. + /// + /// On the other hand, it adds an extra orElse required parameter, + /// for fallback behavior. + R whenOrElse({ + R Function(T data)? success, + R Function(Object error)? failure, + required R Function(Result) orElse, + }) { + switch (_type) { + case _ResultType.success: + return success?.call((this as Success).data) ?? orElse(this); + case _ResultType.failure: + return failure?.call((this as Failure).error) ?? orElse(this); + } + } + + /// The [whenOrNull] method is equivalent to [whenOrElse], + /// but non-exhaustive. + R? whenOrNull({ + R Function(T data)? success, + R Function(Object error)? failure, + }) { + switch (_type) { + case _ResultType.success: + return success?.call((this as Success).data); + case _ResultType.failure: + return failure?.call((this as Failure).error); + } + } + + /// The [map] method is the equivalent to pattern matching. + /// Its prototype depends on the _Result [_type]s defined. + Result map(R Function(T data) convert) { + switch (_type) { + case _ResultType.success: + final origin = this as Success; + return Result.success(convert(origin.data)); + case _ResultType.failure: + return this as Failure; + } + } + + /// The [fold] method is the equivalent to pattern matching. + /// Its prototype depends on the _Result [_type]s defined. + R fold({ + required R Function(Success success) success, + required R Function(Failure failure) failure, + }) { + switch (_type) { + case _ResultType.success: + return success(this as Success); + case _ResultType.failure: + return failure(this as Failure); + } + } + + /// The [foldOrElse] method is equivalent to [fold], but doesn't require + /// all callbacks to be specified. + /// + /// On the other hand, it adds an extra orElse required parameter, + /// for fallback behavior. + R foldOrElse({ + R Function(Success success)? success, + R Function(Failure failure)? failure, + required R Function(Result) orElse, + }) { + switch (_type) { + case _ResultType.success: + return success?.call(this as Success) ?? orElse(this); + case _ResultType.failure: + return failure?.call(this as Failure) ?? orElse(this); + } + } + + /// The [foldOrNull] method is equivalent to [whenOrElse], + /// but non-exhaustive. + R? foldOrNull({ + R Function(Success success)? success, + R Function(Failure failure)? failure, + }) { + switch (_type) { + case _ResultType.success: + return success?.call(this as Success); + case _ResultType.failure: + return failure?.call(this as Failure); + } + } + + /// Returns the encapsulated value if this instance represents success + /// or null of it is failure. + T? getDataOrNull() => whenOrNull(success: _identity); + + Object? getErrorOrNull() => whenOrNull(failure: _identity); +} + +T _identity(T x) => x; diff --git a/packages/stream_core/lib/src/utils/shared_emitter.dart b/packages/stream_core/lib/src/utils/shared_emitter.dart new file mode 100644 index 0000000..cf4acfd --- /dev/null +++ b/packages/stream_core/lib/src/utils/shared_emitter.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:rxdart/rxdart.dart'; + +abstract class SharedEmitter { + Future waitFor({ + required Duration timeLimit, + }); + + StreamSubscription on(void Function(E event) onEvent); + + Future firstWhere( + bool Function(T element) test, { + required Duration timeLimit, + }); + + /// Adds a subscription to this emitter. + StreamSubscription listen( + void Function(T value)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }); + + Stream asStream(); +} + +abstract class MutableSharedEmitter extends SharedEmitter { + void emit(T value); + + Future close(); +} + +/// TODO +class MutableSharedEmitterImpl extends MutableSharedEmitter { + /// Creates a new instance. + MutableSharedEmitterImpl({bool sync = false}) + : _shared = PublishSubject(sync: sync); + + final PublishSubject _shared; + + @override + Future close() { + return _shared.close(); + } + + /// Emit the new value. + @override + void emit(T value) => _shared.add(value); + + @override + Future waitFor({ + required Duration timeLimit, + }) { + return firstWhere( + (it) => it is E, + timeLimit: timeLimit, + ).then((it) => it as E); + } + + @override + StreamSubscription on(void Function(E event) onEvent) { + return _shared.where((it) => it is E).cast().listen(onEvent); + } + + @override + Future firstWhere( + bool Function(T element) test, { + required Duration timeLimit, + }) { + return _shared.firstWhere(test).timeout(timeLimit); + } + + /// Adds a subscription to this emitter. + @override + StreamSubscription listen( + void Function(T value)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return _shared.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + @override + Stream asStream() => _shared; +} diff --git a/packages/stream_core/lib/src/ws.dart b/packages/stream_core/lib/src/ws.dart new file mode 100644 index 0000000..e69de29 diff --git a/packages/stream_core/lib/src/ws/client/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/connection_recovery_handler.dart new file mode 100644 index 0000000..dfda79a --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/connection_recovery_handler.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'dart:math' as math; + +import '../../utils/network_monitor.dart'; +import 'web_socket_client.dart'; +import 'web_socket_connection_state.dart'; + +abstract class AutomaticReconnectionPolicy { + bool canBeReconnected(); +} + +class ConnectionRecoveryHandler { + ConnectionRecoveryHandler({ + RetryStrategy? retryStrategy, + required this.client, + this.networkMonitor, + }) { + this.retryStrategy = retryStrategy ?? DefaultRetryStrategy(); + + policies = [ + WebSocketAutomaticReconnectionPolicy(client: client), + if (networkMonitor case final networkMonitor?) + InternetAvailableReconnectionPolicy( + networkMonitor: networkMonitor, + ), + ]; + + _subscribe(); + } + + Future dispose() async { + await Future.wait( + subscriptions.map((subscription) => subscription.cancel())); + subscriptions.clear(); + _cancelReconnectionTimer(); + } + + final WebSocketClient client; + final NetworkMonitor? networkMonitor; + late final List policies; + List subscriptions = []; + late final RetryStrategy retryStrategy; + Timer? _reconnectionTimer; + + void _reconnectIfNeeded() { + if (!_canReconnectAutomatically()) return; + + client.connect(); + } + + void _disconnectIfNeeded() { + final canBeDisconnected = switch (client.connectionState) { + Connecting() || Connected() || Authenticating() => true, + _ => false, + }; + + if (canBeDisconnected) { + print('Disconnecting automatically'); + client.disconnect(source: DisconnectionSource.systemInitiated()); + } + } + + void _scheduleReconnectionTimerIfNeeded() { + if (!_canReconnectAutomatically()) return; + + final delay = retryStrategy.getDelayAfterFailure(); + print('Scheduling reconnection in ${delay.inSeconds} seconds'); + _reconnectionTimer = Timer(delay, _reconnectIfNeeded); + } + + void _cancelReconnectionTimer() { + if (_reconnectionTimer == null) return; + + print('Cancelling reconnection timer'); + _reconnectionTimer?.cancel(); + _reconnectionTimer = null; + } + + void _subscribe() { + subscriptions.add( + client.connectionStateStream.listen(_websocketConnectionStateChanged)); + if (networkMonitor case final networkMonitor?) { + subscriptions + .add(networkMonitor.onStatusChange.listen(_networkStatusChanged)); + } + } + + void _networkStatusChanged(NetworkStatus status) { + if (status == NetworkStatus.connected) { + _disconnectIfNeeded(); + } else { + _reconnectIfNeeded(); + } + } + + void _websocketConnectionStateChanged(WebSocketConnectionState state) { + switch (state) { + case Connecting(): + _cancelReconnectionTimer(); + case Connected(): + retryStrategy.resetConsecutiveFailures(); + case Disconnected(): + _scheduleReconnectionTimerIfNeeded(); + case Initialized() || Authenticating() || Disconnecting(): + // Don't do anything + break; + } + } + + bool _canReconnectAutomatically() => + policies.every((policy) => policy.canBeReconnected()); +} + +class WebSocketAutomaticReconnectionPolicy + implements AutomaticReconnectionPolicy { + WebSocketClient client; + + WebSocketAutomaticReconnectionPolicy({required this.client}); + + @override + bool canBeReconnected() { + return client.connectionState.isAutomaticReconnectionEnabled; + } +} + +class InternetAvailableReconnectionPolicy + implements AutomaticReconnectionPolicy { + NetworkMonitor networkMonitor; + + InternetAvailableReconnectionPolicy({required this.networkMonitor}); + + @override + bool canBeReconnected() { + return networkMonitor.currentStatus == NetworkStatus.connected; + } +} + +abstract class RetryStrategy { + /// Returns the # of consecutively failed retries. + int get consecutiveFailuresCount; + + /// Increments the # of consecutively failed retries making the next delay longer. + void incrementConsecutiveFailures(); + + /// Resets the # of consecutively failed retries making the next delay be the shortest one. + void resetConsecutiveFailures(); + + /// Calculates and returns the delay for the next retry. + /// + /// Consecutive calls after the same # of failures may return different delays. This randomization is done to + /// make the retry intervals slightly different for different callers to avoid putting the backend down by + /// making all the retries at the same time. + Duration get nextRetryDelay; + + /// Returns the delay and then increments # of consecutively failed retries. + Duration getDelayAfterFailure() { + final delay = nextRetryDelay; + incrementConsecutiveFailures(); + return delay; + } +} + +class DefaultRetryStrategy extends RetryStrategy { + static const maximumReconnectionDelayInSeconds = 25; + + DefaultRetryStrategy(); + + @override + Duration get nextRetryDelay { + /// The first time we get to retry, we do it without any delay. Any subsequent time will + /// be delayed by a random interval. + if (consecutiveFailuresCount == 0) return Duration.zero; + + final maxDelay = math.min( + 0.5 + consecutiveFailuresCount * 2, maximumReconnectionDelayInSeconds); + final minDelay = math.min( + math.max(0.25, (consecutiveFailuresCount - 1) * 2), + maximumReconnectionDelayInSeconds, + ); + + final delayInSeconds = + math.Random().nextDouble() * (maxDelay - minDelay) + minDelay; + + return Duration(milliseconds: (delayInSeconds * 1000).toInt()); + } + + @override + int consecutiveFailuresCount = 0; + + @override + void incrementConsecutiveFailures() { + consecutiveFailuresCount++; + } + + @override + void resetConsecutiveFailures() { + consecutiveFailuresCount = 0; + } +} diff --git a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart new file mode 100644 index 0000000..1bbe589 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart @@ -0,0 +1,11 @@ +import 'dart:async'; + +import 'package:web_socket_channel/web_socket_channel.dart'; + +class WebSocketChannelFactory { + const WebSocketChannelFactory(); + Future connect(Uri uri, + {Iterable? protocols}) async { + throw UnsupportedError('No implementation of the connect api provided'); + } +} diff --git a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart new file mode 100644 index 0000000..a765397 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart @@ -0,0 +1,31 @@ +// ignore_for_file: avoid_web_libraries_in_flutter + +import 'dart:async'; + +import 'package:web/web.dart' as web; +import 'package:web_socket_channel/html.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class WebSocketChannelFactory { + const WebSocketChannelFactory(); + Future connect(Uri uri, + {Iterable? protocols}) async { + final completer = Completer(); + final webSocket = web.WebSocket(uri.toString()) + ..binaryType = BinaryType.list.value; + + unawaited( + webSocket.onOpen.first.then((value) { + completer.complete(HtmlWebSocketChannel(webSocket)); + }), + ); + + unawaited( + webSocket.onError.first.then((err) { + completer.completeError(WebSocketChannelException.from(err)); + }), + ); + + return completer.future; + } +} diff --git a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_io.dart b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_io.dart new file mode 100644 index 0000000..e0ef815 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_io.dart @@ -0,0 +1,22 @@ +import 'dart:io'; + +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class WebSocketChannelFactory { + const WebSocketChannelFactory(); + Future connect( + Uri uri, { + Iterable? protocols, + }) async { + try { + final webSocket = WebSocket.connect( + uri.toString(), + protocols: protocols, + ); + return IOWebSocketChannel(await webSocket); + } on SocketException catch (err) { + throw WebSocketChannelException.from(err); + } + } +} diff --git a/packages/stream_core/lib/src/ws/client/web_socket_client.dart b/packages/stream_core/lib/src/ws/client/web_socket_client.dart new file mode 100644 index 0000000..eccc23e --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/web_socket_client.dart @@ -0,0 +1,208 @@ +import 'dart:convert'; + +import '../../errors/client_exception.dart'; +import '../../utils/shared_emitter.dart'; +import '../events/sendable_event.dart'; +import '../events/ws_event.dart'; +import 'web_socket_connection_state.dart'; +import 'web_socket_engine.dart'; +import 'web_socket_ping_controller.dart'; + +class WebSocketClient implements WebSocketEngineListener, WebSocketPingClient { + late final WebSocketEngine engine; + late final WebSocketPingController pingController; + final PingReguestBuilder? pingReguestBuilder; + final VoidCallback? onConnectionEstablished; + final VoidCallback? onConnected; + final EventDecoder eventDecoder; + + WebSocketConnectionState _connectionStateValue = + WebSocketConnectionState.initialized(); + + set _connectionState(WebSocketConnectionState connectionState) { + if (connectionState == _connectionStateValue) return; + + print('Connection state changed to ${connectionState.runtimeType}'); + pingController.connectionStateChanged(connectionState); + _connectionStateStreamController.emit(connectionState); + _connectionStateValue = connectionState; + } + + WebSocketConnectionState get connectionState => _connectionStateValue; + SharedEmitter get connectionStateStream => + _connectionStateStreamController; + final _connectionStateStreamController = + MutableSharedEmitterImpl(); + + SharedEmitter get events => _events; + final _events = MutableSharedEmitterImpl(); + + String? _connectionId; + + WebSocketClient({ + required String url, + required this.eventDecoder, + this.pingReguestBuilder, + this.onConnectionEstablished, + this.onConnected, + WebSocketEnvironment environment = const WebSocketEnvironment(), + }) { + engine = environment.createEngine( + url: url, + listener: this, + ); + + pingController = environment.createPingController(client: this); + } + + //#region Connection + void connect() { + if (connectionState is Connecting || + connectionState is Authenticating || + connectionState is Connected) { + return; + } + + _connectionState = WebSocketConnectionState.connecting(); + engine.connect(); + } + + void disconnect({ + CloseCode code = CloseCode.normalClosure, + DisconnectionSource source = const UserInitiated(), + }) { + _connectionState = WebSocketConnectionState.disconnecting( + source: source, + ); + + engine.disconnect(code.code, source.toString()); + } + //#endregion + + //#region WebSocketEngineListener + @override + void webSocketDidConnect() { + print('Web socket connection established'); + _connectionState = WebSocketConnectionState.authenticating(); + onConnectionEstablished?.call(); + } + + @override + void webSocketDidDisconnect(WebSocketEngineException? exception) { + switch (connectionState) { + case Connecting() || Authenticating() || Connected(): + _connectionState = WebSocketConnectionState.disconnected( + source: DisconnectionSource.serverInitiated( + error: WebSocketException(exception), + ), + ); + case final Disconnecting disconnecting: + _connectionState = + WebSocketConnectionState.disconnected(source: disconnecting.source); + case Initialized() || Disconnected(): + print( + 'Web socket can not be disconnected when in ${connectionState.runtimeType} state', + ); + } + } + + void _handleHealthCheckEvent(HealthCheckInfo healthCheckInfo) { + final wasAuthenticating = connectionState is Authenticating; + + _connectionState = WebSocketConnectionState.connected( + healthCheckInfo: healthCheckInfo, + ); + _connectionId = healthCheckInfo.connectionId; + pingController.pongReceived(); + + if (wasAuthenticating) { + onConnected?.call(); + } + } + + @override + void webSocketDidReceiveMessage(Object message) { + final event = eventDecoder(message); + if (event == null) { + print('Received message is an unhandled event: $message'); + return; + } + + if (event.healthCheckInfo case final healthCheckInfo?) { + _handleHealthCheckEvent(healthCheckInfo); + } + + if (event.error case final error?) { + _connectionState = WebSocketConnectionState.disconnecting( + source: ServerInitiated(error: ClientException(error: error)), + ); + } + + _events.emit(event); + } + + @override + void webSocketDidReceiveError(Object error, StackTrace stackTrace) { + _connectionState = WebSocketConnectionState.disconnecting( + source: ServerInitiated(error: ClientException(error: error)), + ); + } + //#endregion + + //#region Ping client + @override + void sendPing() { + if (connectionState.isConnected) { + final healthCheckEvent = pingReguestBuilder?.call() ?? + HealthCheckPingEvent(connectionId: _connectionId); + engine.send(message: healthCheckEvent); + } + } + + @override + void disconnectNoPongReceived() { + print('disconnecting from ${engine.url}'); + disconnect( + source: DisconnectionSource.noPongReceived(), + ); + } + //#endregion +} + +class WebSocketEnvironment { + const WebSocketEnvironment(); + + WebSocketEngine createEngine({ + required String url, + required WebSocketEngineListener listener, + }) => + URLSessionWebSocketEngine(url: url, listener: listener); + + WebSocketPingController createPingController({ + required WebSocketPingClient client, + }) => + WebSocketPingController(client: client); +} + +typedef EventDecoder = WsEvent? Function(Object message); +typedef PingReguestBuilder = SendableEvent Function(); +typedef VoidCallback = void Function(); + +enum CloseCode { + invalid(0), + normalClosure(1000), + goingAway(1001), + protocolError(1002), + unsupportedData(1003), + noStatusReceived(1005), + abnormalClosure(1006), + invalidFramePayloadData(1007), + policyViolation(1008), + messageTooBig(1009), + mandatoryExtensionMissing(1010), + internalServerError(1011), + tlsHandshakeFailure(1015); + + const CloseCode(this.code); + final int code; +} diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart new file mode 100644 index 0000000..b6e60f3 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -0,0 +1,165 @@ +import 'package:equatable/equatable.dart'; + +import '../../errors/client_exception.dart'; +import '../events/ws_event.dart'; +import 'web_socket_client.dart'; +import 'web_socket_engine.dart'; +import 'web_socket_ping_controller.dart'; + +/// A web socket connection state. +sealed class WebSocketConnectionState extends Equatable { + const WebSocketConnectionState(); + + factory WebSocketConnectionState.initialized() => const Initialized(); + factory WebSocketConnectionState.connecting() => const Connecting(); + factory WebSocketConnectionState.authenticating() => const Authenticating(); + factory WebSocketConnectionState.connected( + {HealthCheckInfo? healthCheckInfo}) => + Connected(healthCheckInfo: healthCheckInfo); + factory WebSocketConnectionState.disconnecting( + {required DisconnectionSource source}) => + Disconnecting(source: source); + factory WebSocketConnectionState.disconnected( + {required DisconnectionSource source}) => + Disconnected(source: source); + + /// Checks if the connection state is connected. + bool get isConnected => this is Connected; + + /// Returns false if the connection state is in the `notConnected` state. + bool get isActive => this is! Disconnected; + + /// Returns `true` is the state requires and allows automatic reconnection. + bool get isAutomaticReconnectionEnabled { + if (this is! Disconnected) { + return false; + } + + final source = (this as Disconnected).source; + + return switch (source) { + ServerInitiated serverInitiated => + serverInitiated.error != null, //TODO: Implement + UserInitiated() => false, + SystemInitiated() => true, + NoPongReceived() => true, + }; + } + + @override + List get props => []; +} + +/// The initial state meaning that there was no atempt to connect yet. +final class Initialized extends WebSocketConnectionState { + /// The initial state meaning that there was no atempt to connect yet. + const Initialized(); +} + +/// The web socket is connecting. +final class Connecting extends WebSocketConnectionState { + /// The web socket is connecting. + const Connecting(); +} + +/// The web socket is connected, client is authenticating. +final class Authenticating extends WebSocketConnectionState { + /// The web socket is connected, client is authenticating. + const Authenticating(); +} + +/// The web socket was connected. +final class Connected extends WebSocketConnectionState { + /// The web socket was connected. + const Connected({this.healthCheckInfo}); + + /// Health check info on the websocket connection. + final HealthCheckInfo? healthCheckInfo; + + @override + List get props => [healthCheckInfo]; +} + +/// The web socket is disconnecting. +final class Disconnecting extends WebSocketConnectionState { + /// The web socket is disconnecting. [source] contains more info about the source of the event. + const Disconnecting({required this.source}); + + /// Contains more info about the source of the event. + final DisconnectionSource source; + + @override + List get props => [source]; +} + +/// The web socket is not connected. Contains the source/reason why the disconnection has happened. +final class Disconnected extends WebSocketConnectionState { + /// The web socket is not connected. Contains the source/reason why the disconnection has happened. + const Disconnected({required this.source}); + + /// Provides additional information about the source of disconnecting. + final DisconnectionSource source; + + @override + List get props => [source]; +} + +/// Provides additional information about the source of disconnecting. +sealed class DisconnectionSource extends Equatable { + const DisconnectionSource(); + + factory DisconnectionSource.userInitiated() => const UserInitiated(); + factory DisconnectionSource.serverInitiated({ClientException? error}) => + ServerInitiated(error: error); + factory DisconnectionSource.systemInitiated() => const SystemInitiated(); + factory DisconnectionSource.noPongReceived() => const NoPongReceived(); + + /// Returns the underlaying error if connection cut was initiated by the server. + ClientException? get serverError => + this is ServerInitiated ? (this as ServerInitiated).error : null; + + @override + List get props => []; +} + +/// A user initiated web socket disconnecting. +final class UserInitiated extends DisconnectionSource { + /// A user initiated web socket disconnecting. + const UserInitiated(); +} + +/// A server initiated web socket disconnecting, an optional error object is provided. +final class ServerInitiated extends DisconnectionSource { + /// A server initiated web socket disconnecting, an optional error object is provided. + const ServerInitiated({this.error}); + + /// The error that caused the disconnection. + final ClientException? error; + + @override + List get props => [error]; + + bool get isAutomaticReconnectionEnabled { + if (error case final WebSocketEngineException webSocketEngineException) { + if (webSocketEngineException.code == + WebSocketEngineException.stopErrorCode) { + return false; + } + } + + return true; + } +} + +/// The system initiated web socket disconnecting. +final class SystemInitiated extends DisconnectionSource { + /// The system initiated web socket disconnecting. + const SystemInitiated(); +} + +/// [WebSocketPingController] didn't get a pong response. +final class NoPongReceived extends DisconnectionSource { + /// [WebSocketPingController] didn't get a pong response. + const NoPongReceived(); +} + diff --git a/packages/stream_core/lib/src/ws/client/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/web_socket_engine.dart new file mode 100644 index 0000000..3e248c5 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/web_socket_engine.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../../errors/client_exception.dart'; +import '../../utils.dart'; +import '../events/sendable_event.dart'; + +import 'web_socket_channel_factory/web_socket_channel_factory.dart' + if (dart.library.html) 'web_socket_channel_factory/web_socket_channel_factory_html.dart' + if (dart.library.io) 'web_socket_channel_factory/web_socket_channel_factory_io.dart' + as ws_platform; + +abstract interface class WebSocketEngine { + String get url; + Future connect(); + Future disconnect([int? closeCode, String? closeReason]); + void send({required SendableEvent message}); + Future sendPing(); +} + +abstract interface class WebSocketEngineListener { + void webSocketDidConnect(); + void webSocketDidDisconnect(WebSocketEngineException? exception); + void webSocketDidReceiveMessage(Object message); + void webSocketDidReceiveError(Object error, StackTrace stackTrace); +} + +class URLSessionWebSocketEngine implements WebSocketEngine { + URLSessionWebSocketEngine({ + required this.url, + required this.listener, + this.protocols, + this.wsChannelFactory = const ws_platform.WebSocketChannelFactory(), + }); + + /// The URI to connect to. + final String url; + + /// The protocols to use. + final Iterable? protocols; + + final WebSocketEngineListener listener; + final ws_platform.WebSocketChannelFactory wsChannelFactory; + + var _connectRequestInProgress = false; + + WebSocketChannel? _ws; + StreamSubscription? _wsSubscription; + + @override + Future> connect() async { + print( + '[connect] connectRequestInProgress: ' + '$_connectRequestInProgress, url: $url', + ); + try { + if (_connectRequestInProgress) { + print('Connect request already in progress'); + return Result.failure( + WebSocketEngineException( + reason: 'Connect request already in progress', + code: 0, + ), + ); + } + _connectRequestInProgress = true; + + final uri = Uri.parse(url); + _ws = await wsChannelFactory.connect(uri, protocols: protocols); + + listener.webSocketDidConnect(); + + _wsSubscription = _ws!.stream.listen( + (message) => listener.webSocketDidReceiveMessage(message as Object), + onError: listener.webSocketDidReceiveError, + onDone: () => listener.webSocketDidDisconnect( + WebSocketEngineException( + code: _ws?.closeCode ?? 0, + reason: _ws?.closeReason ?? 'Unknown', + ), + ), + ); + return const Result.success(null); + } catch (error, stackTrace) { + print(() => '[connect] failed: $error'); + listener.webSocketDidReceiveError(error, stackTrace); + return Result.failure(error, stackTrace); + } finally { + _connectRequestInProgress = false; + } + } + + @override + Future> disconnect([int? closeCode, String? closeReason]) async { + try { + print( + '[disconnect] connectRequestInProgress: ' + '$_connectRequestInProgress, url: $url', + ); + await _ws?.sink.close(closeCode, closeReason); + _ws = null; + await _wsSubscription?.cancel(); + _wsSubscription = null; + return const Result.success(null); + } catch (error, stackTrace) { + print(() => '[disconnect] failed: $error'); + return Result.failure(error, stackTrace); + } + } + + @override + void send({required SendableEvent message}) { + print('[send] hasWS: ${_ws != null}'); + _ws?.sink.add(message); + } + + @override + Future sendPing() { + // TODO: implement sendPing + throw UnimplementedError(); + } +} diff --git a/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart b/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart new file mode 100644 index 0000000..b3bffd3 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'web_socket_connection_state.dart'; + +/// WebSocketPingController is used to monitor the health of the websocket connection. +/// It will send a ping message to the server every [_pingTimeInterval] and wait for a pong response. +/// If the pong response is not received within [_pongTimeout], the connection is considered disconnected. +/// +/// The controller will automatically resume the ping timer when the connection is resumed. +/// +/// The controller will automatically pause the ping timer when the connection is disconnected. +/// +/// The controller will automatically resume the ping timer when the connection is resumed. +class WebSocketPingController { + final WebSocketPingClient _client; + + final Duration _pingTimeInterval; + final Duration _pongTimeout; + Timer? _pongTimeoutTimer; + Timer? _pingTimer; + + WebSocketPingController({ + required WebSocketPingClient client, + Duration pingTimeInterval = const Duration(seconds: 25), + Duration pongTimeout = const Duration(seconds: 3), + }) : _client = client, + _pingTimeInterval = pingTimeInterval, + _pongTimeout = pongTimeout; + + void connectionStateChanged(WebSocketConnectionState connectionState) { + _pongTimeoutTimer?.cancel(); + + if (connectionState.isConnected) { + print('Resume Websocket Ping timer'); + _pingTimer = Timer.periodic(_pingTimeInterval, (_) { + sendPing(); + }); + } else { + _pingTimer?.cancel(); + } + } + + void sendPing() { + print('WebSocket Ping'); + _schedulePongTimeoutTimer(); + _client.sendPing(); + } + + void pongReceived() { + print('WebSocket Pong'); + _cancelPongTimeoutTimer(); + } + + void _schedulePongTimeoutTimer() { + _pongTimeoutTimer?.cancel(); + _pongTimeoutTimer = Timer(_pongTimeout, _client.disconnectNoPongReceived); + } + + void _cancelPongTimeoutTimer() { + _pongTimeoutTimer?.cancel(); + } +} + +abstract interface class WebSocketPingClient { + void sendPing(); + void disconnectNoPongReceived(); +} diff --git a/packages/stream_core/lib/src/ws/events/sendable_event.dart b/packages/stream_core/lib/src/ws/events/sendable_event.dart new file mode 100644 index 0000000..ab17c41 --- /dev/null +++ b/packages/stream_core/lib/src/ws/events/sendable_event.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; + +abstract interface class SendableEvent { + /// Serialize the object to `String` or `Uint8List`. + Object toSerializedData(); +} + +final class HealthCheckPingEvent implements SendableEvent { + const HealthCheckPingEvent({ + required this.connectionId, + }); + + final String? connectionId; + + @override + Object toSerializedData() => json.encode( + [ + { + 'type': 'health.check', + 'client_id': connectionId, + } + ], + ); +} diff --git a/packages/stream_core/lib/src/ws/events/ws_event.dart b/packages/stream_core/lib/src/ws/events/ws_event.dart new file mode 100644 index 0000000..ab2caba --- /dev/null +++ b/packages/stream_core/lib/src/ws/events/ws_event.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; + +abstract class WsEvent extends Equatable { + const WsEvent(); + + Object? get error => null; + HealthCheckInfo? get healthCheckInfo => null; + + @override + bool? get stringify => true; + + @override + List get props => []; +} + +class HealthCheckPongEvent extends WsEvent { + const HealthCheckPongEvent({ + required this.healthCheckInfo, + }); + + @override + final HealthCheckInfo healthCheckInfo; + + @override + List get props => [healthCheckInfo]; +} + +final class HealthCheckInfo extends Equatable { + const HealthCheckInfo({ + this.connectionId, + this.participantCount, + }); + + final String? connectionId; + final int? participantCount; + + @override + List get props => [connectionId, participantCount]; +} diff --git a/packages/stream_core/lib/stream_core.dart b/packages/stream_core/lib/stream_core.dart index 298576d..b97e124 100644 --- a/packages/stream_core/lib/stream_core.dart +++ b/packages/stream_core/lib/stream_core.dart @@ -1,5 +1,2 @@ -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; -} +export 'src/utils.dart'; +export 'src/ws.dart'; diff --git a/packages/stream_core/pubspec.yaml b/packages/stream_core/pubspec.yaml index c70aa6c..a067e92 100644 --- a/packages/stream_core/pubspec.yaml +++ b/packages/stream_core/pubspec.yaml @@ -5,15 +5,12 @@ repository: https://github.com/GetStream/stream-core-flutter publish_to: none # Delete when ready to publish environment: - sdk: ^3.7.2 - flutter: ">=1.17.0" + sdk: ^3.6.2 dependencies: - flutter: - sdk: flutter + equatable: ^2.0.7 + rxdart: ^0.28.0 + web_socket_channel: ^3.0.1 dev_dependencies: - flutter_test: - sdk: flutter - -flutter: + test: ^1.26.2 diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..6f5a39f --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,301 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + ansi_styles: + dependency: transitive + description: + name: ansi_styles + sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a" + url: "https://pub.dev" + source: hosted + version: "0.3.2+1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cli_launcher: + dependency: transitive + description: + name: cli_launcher + sha256: "5e7e0282b79e8642edd6510ee468ae2976d847a0a29b3916e85f5fa1bfe24005" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + conventional_commit: + dependency: transitive + description: + name: conventional_commit + sha256: fad254feb6fb8eace2be18855176b0a4b97e0d50e416ff0fe590d5ba83735d34 + url: "https://pub.dev" + source: hosted + version: "0.6.1" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + melos: + dependency: "direct dev" + description: + name: melos + sha256: "3f3ab3f902843d1e5a1b1a4dd39a4aca8ba1056f2d32fd8995210fa2843f646f" + url: "https://pub.dev" + source: hosted + version: "6.3.2" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mustache_template: + dependency: transitive + description: + name: mustache_template + sha256: a46e26f91445bfb0b60519be280555b06792460b27b19e2b19ad5b9740df5d1c + url: "https://pub.dev" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "44b4226c0afd4bc3b7c7e67d44c4801abd97103cf0c84609e2654b664ca2798c" + url: "https://pub.dev" + source: hosted + version: "5.0.4" + prompts: + dependency: transitive + description: + name: prompts + sha256: "3773b845e85a849f01e793c4fc18a45d52d7783b4cb6c0569fad19f9d0a774a1" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pub_updater: + dependency: transitive + description: + name: pub_updater + sha256: "54e8dc865349059ebe7f163d6acce7c89eb958b8047e6d6e80ce93b13d7c9e60" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: fb38626579fb345ad00e674e2af3a5c9b0cc4b9bfb8fd7f7ff322c7c9e62aef5 + url: "https://pub.dev" + source: hosted + version: "2.2.2" +sdks: + dart: ">=3.6.2 <4.0.0"