blob: 482908ab6671e3dcb629600c2aa6f7e99564bfea [file] [edit]
// 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;
}