| // Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'dart:async'; |
| |
| import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; |
| import 'package:meta/meta.dart'; |
| import 'package:sse/server/sse_handler.dart'; |
| import 'package:stream_channel/stream_channel.dart'; |
| |
| /// A [StreamSink] wrapper that logs events and errors. |
| class LoggingMiddlewareSink<S> implements StreamSink<S> { |
| LoggingMiddlewareSink(this.sink); |
| |
| @override |
| void add(S event) { |
| print('DevTools SSE response: $event'); |
| sink.add(event); |
| } |
| |
| @override |
| void addError(Object error, [StackTrace? stackTrace]) { |
| print('DevTools SSE error response: $error'); |
| sink.addError(error, stackTrace); |
| } |
| |
| @override |
| Future<void> addStream(Stream<S> stream) { |
| return sink.addStream(stream); |
| } |
| |
| @override |
| Future<void> close() => sink.close(); |
| |
| @override |
| Future<void> get done => sink.done; |
| |
| final StreamSink<dynamic> sink; |
| } |
| |
| /// A connection between a DevTools front-end app and the DevTools server. |
| class DevToolsClientManager { |
| DevToolsClientManager({required this.requestNotificationPermissions}); |
| |
| /// The timeout to wait for a ping when verifying a client is still |
| /// responsive. |
| static const _clientResponsivenessTimeout = Duration(milliseconds: 500); |
| |
| /// Whether to immediately request notification permissions when a client |
| /// connects. |
| final bool requestNotificationPermissions; |
| final List<DevToolsClient> _clients = []; |
| |
| void acceptClient(SseConnection connection, {bool enableLogging = false}) { |
| final client = DevToolsClient.fromSSEConnection(connection, enableLogging); |
| if (requestNotificationPermissions) { |
| client.enableNotifications(); |
| } |
| _clients.add(client); |
| connection.sink.done.then((_) => _clients.remove(client)); |
| } |
| |
| /// Finds a DevTools client that is connected to the given VM service URI, or |
| /// returns a reusable client if no client is currently connected. |
| Future<DevToolsClient?> findReusableClient() { |
| final availableClients = _clients.where((c) => c.reusable).toList(); |
| return _firstResponsiveClient(availableClients); |
| } |
| |
| /// Finds an existing DevTools client connected to the given VM service. |
| Future<DevToolsClient?> findExistingConnectedReusableClient( |
| Uri vmServiceUri, |
| ) { |
| final matchingClients = _clients |
| .where( |
| (c) => c.reusable && _areSameVmServices(c.vmServiceUri, vmServiceUri), |
| ) |
| .toList(); |
| return _firstResponsiveClient(matchingClients); |
| } |
| |
| Future<DevToolsClient?> _firstResponsiveClient( |
| List<DevToolsClient> candidateClients, |
| ) async { |
| for (final client in candidateClients) { |
| try { |
| await client.ping().timeout(_clientResponsivenessTimeout); |
| return client; |
| } on TimeoutException { |
| _clients.remove(client); |
| } |
| } |
| return null; |
| } |
| |
| /// Returns whether [uri1] and [uri2] point to the same VM service instance. |
| /// |
| /// If either URI is null (e.g., when a DevTools client is connected to |
| /// DevTools but has not yet connected to a VM service), returns false since |
| /// an unconnected client cannot be reused for a specific VM service. |
| static bool _areSameVmServices(Uri? uri1, Uri? uri2) { |
| if (uri1 == null || uri2 == null) return false; |
| return uri1.host == uri2.host && |
| uri1.port == uri2.port && |
| uri1.pathSegments.isNotEmpty && |
| uri2.pathSegments.isNotEmpty && |
| uri1.pathSegments[0] == uri2.pathSegments[0]; |
| } |
| |
| @override |
| String toString() { |
| return _clients |
| .map((c) { |
| return '${c.hasConnection.toString().padRight(5)} ' |
| '${c.currentPage?.padRight(12)} ${c.vmServiceUri.toString()}'; |
| }) |
| .join('\n'); |
| } |
| |
| Map<String, dynamic> toJson(dynamic id) => { |
| 'id': id, |
| 'result': {'clients': _clients.map((e) => e.toJson()).toList()}, |
| }; |
| } |
| |
| /// Represents a DevTools client connection to the DevTools server API. |
| class DevToolsClient { |
| @visibleForTesting |
| DevToolsClient({ |
| required Stream<String> stream, |
| required StreamSink<dynamic> sink, |
| bool loggingEnabled = false, |
| }) { |
| var outputSink = sink; |
| if (loggingEnabled) { |
| stream = stream.map<String>((String e) { |
| print('DevTools SSE request: $e'); |
| return e; |
| }); |
| outputSink = LoggingMiddlewareSink<dynamic>(sink); |
| } |
| |
| final controller = StreamController<String>(); |
| controller.stream.listen((data) => outputSink.add(data)); |
| |
| _devToolsPeer = json_rpc.Peer( |
| StreamChannel(stream, controller.sink), |
| strictProtocolChecks: false, |
| ); |
| _registerJsonRpcMethods(); |
| _devToolsPeer.listen(); |
| } |
| |
| factory DevToolsClient.fromSSEConnection( |
| SseConnection sse, |
| bool loggingEnabled, |
| ) { |
| final stream = sse.stream; |
| final StreamSink<dynamic> sink = sse.sink; |
| return DevToolsClient( |
| stream: stream, |
| sink: sink, |
| loggingEnabled: loggingEnabled, |
| ); |
| } |
| |
| void _registerJsonRpcMethods() { |
| _devToolsPeer.registerMethod('connected', (json_rpc.Parameters parameters) { |
| _vmServiceUri = Uri.parse(parameters['uri'].asString); |
| }); |
| |
| _devToolsPeer.registerMethod('disconnected', ( |
| json_rpc.Parameters parameters, |
| ) { |
| _vmServiceUri = null; |
| }); |
| |
| _devToolsPeer.registerMethod('currentPage', ( |
| json_rpc.Parameters parameters, |
| ) { |
| _initialized = true; |
| _currentPage = parameters['id'].asString; |
| _embedded = parameters['embedded'].asBool; |
| }); |
| |
| _devToolsPeer.registerMethod('pingResponse', ( |
| json_rpc.Parameters parameters, |
| ) { |
| _nextPingResponse.complete(); |
| _nextPingResponse = Completer(); |
| }); |
| } |
| |
| Completer<void> _nextPingResponse = Completer(); |
| |
| void connectToVmService(Uri uri, bool notifyUser) { |
| _devToolsPeer.sendNotification('connectToVm', { |
| 'uri': uri.toString(), |
| 'notify': notifyUser, |
| }); |
| } |
| |
| void notify() => _devToolsPeer.sendNotification('notify'); |
| |
| Future<void> ping() { |
| _devToolsPeer.sendNotification('ping'); |
| return _nextPingResponse.future; |
| } |
| |
| void enableNotifications() => |
| _devToolsPeer.sendNotification('enableNotifications'); |
| |
| void showPage(String pageId) { |
| _devToolsPeer.sendNotification('showPage', {'page': pageId}); |
| } |
| |
| Map<String, dynamic> toJson() => { |
| 'hasConnection': hasConnection, |
| 'currentPage': currentPage, |
| 'embedded': embedded, |
| 'vmServiceUri': vmServiceUri?.toString(), |
| }; |
| |
| bool get initialized => _initialized; |
| bool _initialized = false; |
| |
| String? get currentPage => _currentPage; |
| String? _currentPage; |
| |
| bool get embedded => _embedded; |
| bool _embedded = false; |
| |
| Uri? get vmServiceUri => _vmServiceUri; |
| Uri? _vmServiceUri; |
| |
| bool get reusable => initialized && !embedded; |
| |
| bool get hasConnection => _vmServiceUri != null; |
| |
| late json_rpc.Peer _devToolsPeer; |
| } |