blob: 5639e8a2744504cbf3d2335278a4e38c4582f973 [file] [log] [blame] [edit]
// Copyright (c) 2019, 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 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:dds/dds_launcher.dart';
import 'package:dwds/data/build_result.dart';
import 'package:dwds/data/connect_request.dart';
import 'package:dwds/data/debug_event.dart';
import 'package:dwds/data/devtools_request.dart';
import 'package:dwds/data/error_response.dart';
import 'package:dwds/data/hot_reload_request.dart';
import 'package:dwds/data/hot_reload_response.dart';
import 'package:dwds/data/hot_restart_request.dart';
import 'package:dwds/data/hot_restart_response.dart';
import 'package:dwds/data/isolate_events.dart';
import 'package:dwds/data/register_event.dart';
import 'package:dwds/data/run_request.dart';
import 'package:dwds/data/service_extension_request.dart';
import 'package:dwds/data/service_extension_response.dart';
import 'package:dwds/src/config/tool_configuration.dart';
import 'package:dwds/src/connections/app_connection.dart';
import 'package:dwds/src/connections/debug_connection.dart';
import 'package:dwds/src/debugging/execution_context.dart';
import 'package:dwds/src/debugging/remote_debugger.dart';
import 'package:dwds/src/debugging/webkit_debugger.dart';
import 'package:dwds/src/dwds_vm_client.dart';
import 'package:dwds/src/events.dart';
import 'package:dwds/src/handlers/injector.dart';
import 'package:dwds/src/handlers/socket_connections.dart';
import 'package:dwds/src/readers/asset_reader.dart';
import 'package:dwds/src/servers/devtools.dart';
import 'package:dwds/src/servers/extension_backend.dart';
import 'package:dwds/src/servers/extension_debugger.dart';
import 'package:dwds/src/services/app_debug_services.dart';
import 'package:dwds/src/services/chrome/chrome_debug_service.dart';
import 'package:dwds/src/services/chrome/chrome_proxy_service.dart';
import 'package:dwds/src/services/expression_compiler.dart';
import 'package:dwds/src/services/web_socket/web_socket_debug_service.dart';
import 'package:dwds/src/services/web_socket/web_socket_proxy_service.dart';
import 'package:dwds/src/utilities/shared.dart';
import 'package:logging/logging.dart';
import 'package:shelf/shelf.dart';
import 'package:sse/server/sse_handler.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';
/// When enabled, this logs VM service protocol and Chrome debug protocol
/// traffic to disk.
///
/// Note: this should not be checked in enabled.
const _enableLogging = false;
final _logger = Logger('DevHandler');
/// SSE handler to enable development features like hot reload and
/// opening DevTools.
class DevHandler {
final _subs = <StreamSubscription>[];
final _sseHandlers = <String, SocketHandler>{};
final _injectedConnections = <SocketConnection>{};
final DevTools? _devTools;
final AssetReader _assetReader;
final String _hostname;
final _connectedApps = StreamController<AppConnection>.broadcast();
final _servicesByAppId = <String, AppDebugServices>{};
final _appConnectionByAppId = <String, AppConnection>{};
final Stream<BuildResult> buildResults;
final Future<ChromeConnection> Function() _chromeConnection;
final ExtensionBackend? _extensionBackend;
final StreamController<DebugConnection> extensionDebugConnections =
StreamController<DebugConnection>();
final UrlEncoder? _urlEncoder;
final bool _useSseForDebugProxy;
final bool _useSseForInjectedClient;
final DartDevelopmentServiceConfiguration _ddsConfig;
final bool _launchDevToolsInNewWindow;
final ExpressionCompiler? _expressionCompiler;
final DwdsInjector _injected;
/// Null until [close] is called.
///
/// All subsequent calls to [close] will return this future.
Future<void>? _closed;
Stream<AppConnection> get connectedApps => _connectedApps.stream;
final bool useWebSocketConnection;
DevHandler(
this._chromeConnection,
this.buildResults,
this._devTools,
this._assetReader,
this._hostname,
this._extensionBackend,
this._urlEncoder,
this._useSseForDebugProxy,
this._useSseForInjectedClient,
this._expressionCompiler,
this._injected,
this._ddsConfig,
this._launchDevToolsInNewWindow, {
this.useWebSocketConnection = false,
}) {
_subs.add(buildResults.listen(_emitBuildResults));
_listen();
if (_extensionBackend != null) {
_listenForDebugExtension();
}
}
Handler get handler => (request) async {
final path = request.requestedUri.path;
final sseHandler = _sseHandlers[path];
if (sseHandler != null) {
return sseHandler.handler(request);
}
return Response.notFound('');
};
Future<void> close() => _closed ??= () async {
await Future.wait([for (final sub in _subs) sub.cancel()]);
for (final handler in _sseHandlers.values) {
handler.shutdown();
}
await Future.wait(
_servicesByAppId.values.map((service) => service.close()),
);
_servicesByAppId.clear();
}();
void _emitBuildResults(BuildResult result) {
if (result.status != BuildStatus.succeeded) return;
for (final injectedConnection in _injectedConnections) {
injectedConnection.sink.add(jsonEncode(_serializeMessage(result)));
}
}
/// Serializes an outgoing request to the injected client.
/// For plain Dart types (converted away from built_value), emit the
/// wire format `[TypeName, json]`.
Object? _serializeMessage(Object request) {
return switch (request) {
ConnectRequest() => ['ConnectRequest', request.toJson()],
RunRequest() => ['RunRequest', request.toJson()],
HotReloadRequest() => ['HotReloadRequest', request.toJson()],
HotRestartRequest() => ['HotRestartRequest', request.toJson()],
ServiceExtensionRequest() => [
'ServiceExtensionRequest',
request.toJson(),
],
BuildResult() => ['BuildResult', request.toJson()],
DebugEvent() => ['DebugEvent', request.toJson()],
BatchedDebugEvents() => ['BatchedDebugEvents', request.toJson()],
DevToolsResponse() => request,
IsolateStart() => ['IsolateStart', request.toJson()],
IsolateExit() => ['IsolateExit', request.toJson()],
ErrorResponse() => ['ErrorResponse', request.toJson()],
RegisterEvent() => ['RegisterEvent', request.toJson()],
Map() => request, // Already a raw message (e.g., ping)
_ => throw UnsupportedError('Unknown request type: $request'),
};
}
/// Deserializes a message from JSON, handling custom fromJson() implementations.
Object? _deserializeMessage(dynamic decoded) {
if (decoded case [final String typeName, ...]) {
// For Map-based RPC data types, the second element is the JSON map.
final jsonData = switch (decoded) {
[_, final Map<String, dynamic> map] => map,
_ => const <String, dynamic>{},
};
return switch (typeName) {
// List-based RPC data types:
'DevToolsRequest' => DevToolsRequest.fromJson(decoded),
// Map-based RPC data types:
'ConnectRequest' => ConnectRequest.fromJson(jsonData),
'RunRequest' => RunRequest.fromJson(jsonData),
'HotRestartRequest' => HotRestartRequest.fromJson(jsonData),
'HotReloadResponse' => HotReloadResponse.fromJson(jsonData),
'HotRestartResponse' => HotRestartResponse.fromJson(jsonData),
'ServiceExtensionResponse' => ServiceExtensionResponse.fromJson(
jsonData,
),
'ErrorResponse' => ErrorResponse.fromJson(jsonData),
'DebugEvent' => DebugEvent.fromJson(jsonData),
'BatchedDebugEvents' => BatchedDebugEvents.fromJson(jsonData),
'IsolateStart' => IsolateStart.fromJson(jsonData),
'IsolateExit' => IsolateExit.fromJson(jsonData),
'RegisterEvent' => RegisterEvent.fromJson(jsonData),
_ => throw FormatException('Unrecognized event type: $typeName'),
};
}
throw FormatException('Unrecognized event type: $decoded');
}
/// Sends the provided [request] to all connected injected clients.
/// Returns the number of clients the request was successfully sent to.
int _sendRequestToClients(Object request) {
_logger.finest('Sending request to injected clients: $request');
var successfulSends = 0;
for (final injectedConnection in _injectedConnections) {
try {
injectedConnection.sink.add(jsonEncode(_serializeMessage(request)));
successfulSends++;
} on StateError catch (e) {
// The sink has already closed (app is disconnected), or another StateError occurred.
_logger.warning(
'Failed to send request to client, connection likely closed. Error: $e',
);
} catch (e, s) {
// Catch any other potential errors during sending.
_logger.severe('Error sending request to client: $e', e, s);
}
}
_logger.fine(
'Sent request to $successfulSends clients out of ${_injectedConnections.length} total connections',
);
return successfulSends;
}
/// Starts a [ChromeDebugService] for local debugging.
Future<ChromeDebugService> _startLocalDebugService(
ChromeConnection chromeConnection,
AppConnection appConnection,
) async {
ChromeTab? appTab;
ExecutionContext? executionContext;
WipConnection? connection;
late final debugger = WebkitDebugger(WipDebugger(connection!));
final appInstanceId = appConnection.request.instanceId;
for (final tab in await chromeConnection.getTabs()) {
if (tab.isChromeExtension || tab.isBackgroundPage) continue;
connection = await tab.connect();
if (_enableLogging) {
connection.onSend.listen((message) {
_log(' wip', '==> $message');
});
connection.onReceive.listen((message) {
_log(' wip', '<== $message');
});
connection.onNotification.listen((message) {
_log(' wip', '<== $message');
});
}
final contextIds = connection.runtime.onExecutionContextCreated
.map((context) => context.id)
// There is no way to calculate the number of existing execution
// contexts so keep receiving them until there is a 50ms gap after
// receiving the last one.
.takeUntilGap(const Duration(milliseconds: 50));
// We enqueue this work as we need to begin listening (`.hasNext`)
// before events are received.
safeUnawaited(Future.microtask(connection.runtime.enable));
await for (final contextId in contextIds) {
final result = await connection.sendCommand('Runtime.evaluate', {
'expression': r'window["$dartAppInstanceId"];',
'contextId': contextId,
});
final evaluatedAppId = result.result?['result']?['value'];
if (evaluatedAppId == appInstanceId) {
appTab = tab;
executionContext = RemoteDebuggerExecutionContext(
contextId,
debugger,
);
break;
}
}
if (appTab != null) break;
safeUnawaited(connection.close());
}
if (appTab == null || connection == null || executionContext == null) {
throw AppConnectionException(
'Could not connect to application with appInstanceId: '
'$appInstanceId',
);
}
return ChromeDebugService.start(
// We assume the user will connect to the debug service on the same
// machine. This allows consumers of DWDS to provide a `hostname` for
// debugging through the Dart Debug Extension without impacting the local
// debug workflow.
hostname: 'localhost',
remoteDebugger: debugger,
executionContext: executionContext,
assetReader: _assetReader,
appConnection: appConnection,
urlEncoder: _urlEncoder,
onResponse: (response) {
if (response['error'] == null) return;
_logger.finest('VmService proxy responded with an error:\n$response');
if (_enableLogging) {
_log('vm', '<== ${response.toString().replaceAll('\n', ' ')}');
}
},
onRequest: (request) {
if (_enableLogging) {
_log('vm', '==> ${request.toString().replaceAll('\n', ' ')}');
}
},
// This will provide a websocket based service.
useSse: false,
expressionCompiler: _expressionCompiler,
ddsConfig: _ddsConfig,
);
}
void _log(String type, String message) {
final logFile = File('${Platform.environment['HOME']}/dwds_log.txt');
final time = (DateTime.now().millisecondsSinceEpoch % 1000000) / 1000.0;
logFile.writeAsStringSync(
'[${time.toStringAsFixed(3).padLeft(7)}s] $type $message\n',
mode: FileMode.append,
);
}
Future<AppDebugServices> loadAppServices(AppConnection appConnection) async {
final appId = appConnection.request.appId;
var appServices = _servicesByAppId[appId];
if (appServices == null) {
if (useWebSocketConnection) {
appServices = await _createWebSocketAppServices(appConnection);
} else {
appServices = await _createChromeAppServices(appConnection);
_setupChromeServiceCleanup(appServices, appConnection);
}
_servicesByAppId[appId] = appServices;
}
return appServices;
}
/// Creates WebSocket-based app services for hot reload functionality.
Future<AppDebugServices> _createWebSocketAppServices(
AppConnection appConnection,
) async {
final webSocketDebugService = await WebSocketDebugService.start(
hostname: 'localhost',
appConnection: appConnection,
assetReader: _assetReader,
sendClientRequest: _sendRequestToClients,
ddsConfig: _ddsConfig,
);
return _createAppDebugServicesWebSocketMode(
webSocketDebugService,
appConnection,
);
}
/// Creates Chrome-based app services for full debugging capabilities.
Future<AppDebugServices> _createChromeAppServices(
AppConnection appConnection,
) async {
final debugService = await _startLocalDebugService(
await _chromeConnection(),
appConnection,
);
return _createAppDebugServices(debugService);
}
/// Sets up cleanup handling for Chrome-based services.
void _setupChromeServiceCleanup(
AppDebugServices appServices,
AppConnection appConnection,
) {
final chromeProxy = appServices.proxyService;
if (chromeProxy is ChromeProxyService) {
safeUnawaited(
chromeProxy.remoteDebugger.onClose.first.whenComplete(() async {
await appServices.close();
_servicesByAppId.remove(appConnection.request.appId);
_logger.info(
'Stopped debug service on '
'ws://${appServices.debugService.hostname}:${appServices.debugService.port}\n',
);
}),
);
}
}
void _handleConnection(SocketConnection injectedConnection) {
_injectedConnections.add(injectedConnection);
AppConnection? appConnection;
injectedConnection.stream.listen((data) async {
try {
final message = _deserializeMessage(jsonDecode(data));
if (message is ConnectRequest) {
if (appConnection != null) {
throw StateError(
'Duplicate connection request from the same app. '
'Please file an issue at '
'https://github.com/dart-lang/webdev/issues/new.',
);
}
appConnection = useWebSocketConnection
? await _handleWebSocketConnectRequest(
message,
injectedConnection,
)
: await _handleConnectRequest(message, injectedConnection);
} else {
final connection = appConnection;
if (connection == null) {
throw StateError('Not connected to an application.');
}
if (message is DevToolsRequest) {
await _handleDebugRequest(connection, injectedConnection);
} else {
// Handle messages for both WebSocket and Chrome proxy services
await _handleMessage(connection, message);
}
}
} catch (e, s) {
// Most likely the app disconnected in the middle of us responding,
// but we will try and send an error response back to the page just in
// case it is still running.
try {
injectedConnection.sink.add(
jsonEncode(
_serializeMessage(ErrorResponse(error: '$e', stackTrace: '$s')),
),
);
} on StateError catch (_) {
// The sink has already closed (app is disconnected), swallow the
// error.
}
}
});
safeUnawaited(
injectedConnection.sink.done.then((_) {
_injectedConnections.remove(injectedConnection);
final connection = appConnection;
if (connection != null) {
final appId = connection.request.appId;
final services = _servicesByAppId[appId];
// WebSocket mode doesn't need this because WebSocketProxyService handles connection tracking and cleanup
if (!useWebSocketConnection) {
_appConnectionByAppId.remove(appId);
}
if (services != null) {
if (services.connectedInstanceId == null ||
services.connectedInstanceId == connection.request.instanceId) {
services.connectedInstanceId = null;
_handleIsolateExit(connection);
}
}
}
}),
);
}
/// Handles messages for both WebSocket and Chrome proxy services.
Future<void> _handleMessage(AppConnection connection, Object? message) async {
if (message == null) return;
final appId = connection.request.appId;
final proxyService = _servicesByAppId[appId]?.proxyService;
if (proxyService == null) {
if (message is HotRestartRequest) {
_logger.finest('No debugger, let the app handle the restart itself.');
connection.hotRestart(message);
return;
}
_logger.warning(
'No proxy service found for appId: $appId to process $message',
);
return;
}
// Handle messages that are specific to certain proxy service types
if (message is HotReloadResponse) {
proxyService.completeHotReload(message);
} else if (message is HotRestartResponse) {
proxyService.completeHotRestart(message);
} else if (message is ServiceExtensionResponse) {
proxyService.completeServiceExtension(message);
} else if (message is IsolateExit) {
_handleIsolateExit(connection);
} else if (message is IsolateStart) {
await _handleIsolateStart(connection);
} else if (message is BatchedDebugEvents) {
proxyService.parseBatchedDebugEvents(message);
} else if (message is DebugEvent) {
proxyService.parseDebugEvent(message);
} else if (message is RegisterEvent) {
proxyService.parseRegisterEvent(message);
} else if (message is HotRestartRequest) {
final services = _servicesByAppId[appId]!;
if (services.dwdsVmClient is! ChromeDwdsVmClient ||
services.proxyService is! ChromeProxyService) {
throw UnsupportedError(
'In-app restart requests are only supported by '
'ChromeDwdsVmClient and ChromeProxyService',
);
}
final chromeClient = services.dwdsVmClient as ChromeDwdsVmClient;
final chromeProxyService = services.proxyService as ChromeProxyService;
unawaited(
chromeClient.hotRestart(chromeProxyService, chromeClient.client),
);
} else {
final serviceType = proxyService is WebSocketProxyService
? 'WebSocket'
: 'Chrome';
throw UnsupportedError(
'Message type ${message.runtimeType} is not supported in $serviceType mode',
);
}
}
Future<void> _handleDebugRequest(
AppConnection appConnection,
SocketConnection sseConnection,
) async {
if (_devTools == null && !_ddsConfig.serveDevTools) {
sseConnection.sink.add(
jsonEncode(
_serializeMessage(
DevToolsResponse(
success: false,
promptExtension: false,
error:
'Debugging is not enabled.\n\n'
'If you are using webdev please pass the --debug flag.\n'
'Otherwise check the docs for the tool you are using.',
),
),
),
);
return;
}
final debuggerStart = DateTime.now();
AppDebugServices appServices;
try {
appServices = await loadAppServices(appConnection);
} catch (_) {
final error =
'Unable to connect debug services to your '
'application. Most likely this means you are trying to '
'load in a different Chrome window than was launched by '
'your development tool.';
var response = DevToolsResponse(
success: false,
promptExtension: false,
error: error,
);
if (_extensionBackend != null) {
response = DevToolsResponse(
success: false,
promptExtension: true,
error:
'$error\n\n'
'Your workflow alternatively supports debugging through the '
'Dart Debug Extension.\n\n'
'Would you like to install the extension?',
);
}
sseConnection.sink.add(jsonEncode(_serializeMessage(response)));
return;
}
// Check if we are already running debug services for a different
// instance of this app.
if (appServices.connectedInstanceId != null &&
appServices.connectedInstanceId != appConnection.request.instanceId) {
sseConnection.sink.add(
jsonEncode(
_serializeMessage(
DevToolsResponse(
success: false,
promptExtension: false,
error:
'This app is already being debugged in a different tab. '
'Please close that tab or switch to it.',
),
),
),
);
return;
}
sseConnection.sink.add(
jsonEncode(
_serializeMessage(
DevToolsResponse(success: true, promptExtension: false),
),
),
);
appServices.connectedInstanceId = appConnection.request.instanceId;
appServices.dwdsStats!.updateLoadTime(
debuggerStart: debuggerStart,
devToolsStart: DateTime.now(),
);
final chromeProxy = appServices.proxyService;
if (chromeProxy is ChromeProxyService) {
await _launchDevTools(
chromeProxy.remoteDebugger,
_constructDevToolsUri(
appServices,
appServices.debugService.uri,
ideQueryParam: 'Dwds',
),
);
}
}
/// Creates a debug connection for WebSocket mode.
Future<DebugConnection> createDebugConnectionForWebSocket(
AppConnection appConnection,
) async {
final appDebugServices = await loadAppServices(appConnection);
// Initialize WebSocket proxy service
try {
final proxyService = appDebugServices.proxyService;
if (proxyService is! WebSocketProxyService) {
throw StateError(
'Expected WebSocketProxyService but got ${proxyService.runtimeType}. ',
);
}
await proxyService.isInitialized;
_logger.fine('WebSocket proxy service initialized successfully');
} catch (e) {
_logger.severe('Failed to initialize WebSocket proxy service: $e');
rethrow;
}
return DebugConnection(appDebugServices);
}
/// Creates a debug connection for Chrome mode.
Future<DebugConnection> createDebugConnectionForChrome(
AppConnection appConnection,
) async {
final appDebugServices = await loadAppServices(appConnection);
// Initialize Chrome proxy service
try {
final proxyService = appDebugServices.proxyService;
if (proxyService is! ChromeProxyService) {
throw StateError(
'Expected ChromeProxyService but got ${proxyService.runtimeType}. ',
);
}
await proxyService.isInitialized;
_logger.fine('Chrome proxy service initialized successfully');
} catch (e) {
_logger.severe('Failed to initialize Chrome proxy service: $e');
rethrow;
}
return DebugConnection(appDebugServices);
}
Future<AppConnection> _handleConnectRequest(
ConnectRequest message,
SocketConnection sseConnection,
) async {
// After a page refresh, reconnect to the same app services if they
// were previously launched and create the new isolate.
final services = _servicesByAppId[message.appId];
final existingConnection = _appConnectionByAppId[message.appId];
// Completer to indicate when the app's main() method is ready to be run.
// Its future is passed to the AppConnection so that it can be awaited on
// before running the app's main() method:
final readyToRunMainCompleter = Completer<void>();
final connection = AppConnection(
message,
sseConnection,
readyToRunMainCompleter.future,
);
if (_canReconnect(existingConnection, message)) {
// Disconnect any old connection (eg. those in the keep-alive waiting
// state when reloading the page).
existingConnection?.shutDown();
final chromeProxy = services!.proxyService;
if (chromeProxy is ChromeProxyService) {
chromeProxy.destroyIsolate();
}
// Reconnect to existing service.
services.connectedInstanceId = message.instanceId;
final chromeService = services.proxyService;
if (chromeService is ChromeProxyService) {
if (chromeService.pauseIsolatesOnStart) {
// If the pause-isolates-on-start flag is set, we need to wait for
// the resume event to run the app's main() method.
_waitForResumeEventToRunMain(
chromeService.resumeAfterRestartEventsStream,
readyToRunMainCompleter,
);
} else {
// Otherwise, we can run the app's main() method immediately.
readyToRunMainCompleter.complete();
}
await chromeService.createIsolate(connection);
}
} else {
// If this is the initial app connection, we can run the app's main()
// method immediately.
readyToRunMainCompleter.complete();
}
_appConnectionByAppId[message.appId] = connection;
_connectedApps.add(connection);
return connection;
}
/// Handles WebSocket mode connection requests with multi-window support.
Future<AppConnection> _handleWebSocketConnectRequest(
ConnectRequest message,
SocketConnection sseConnection,
) async {
// After a page refresh, reconnect to the same app services if they
// were previously launched and create the new isolate.
final services = _servicesByAppId[message.appId];
final existingConnection = _appConnectionByAppId[message.appId];
// Completer to indicate when the app's main() method is ready to be run.
// Its future is passed to the AppConnection so that it can be awaited on
// before running the app's main() method:
final readyToRunMainCompleter = Completer<void>();
final connection = AppConnection(
message,
sseConnection,
readyToRunMainCompleter.future,
);
if (_canReconnect(existingConnection, message)) {
// Reconnect to existing service.
await _reconnectToService(
services!,
existingConnection,
connection,
message,
readyToRunMainCompleter,
);
} else {
// New browser window or initial connection: run main() immediately
readyToRunMainCompleter.complete();
// For WebSocket mode, we need to proactively create and emit a debug connection
try {
if (services != null) {
// If we are reconnecting to an existing app but not the same instance,
// ensure the isolate is started for the new connection before creating
// the debug connection, otherwise it will hang waiting for initialization.
await _handleIsolateStart(connection);
}
// Initialize the WebSocket service and create debug connection
final debugConnection = await createDebugConnectionForWebSocket(
connection,
);
// Emit the debug connection through the extension stream
extensionDebugConnections.add(debugConnection);
} catch (e, s) {
_logger.warning('Failed to create WebSocket debug connection: $e\n$s');
}
}
_appConnectionByAppId[message.appId] = connection;
_connectedApps.add(connection);
_logger.fine('App connection established for: ${message.appId}');
return connection;
}
/// Allow connection reuse for page refreshes and same instance reconnections
bool _canReconnect(
AppConnection? existingConnection,
ConnectRequest message,
) {
final services = _servicesByAppId[message.appId];
if (services == null) {
return false;
}
if (existingConnection?.request.instanceId == message.instanceId) {
return true;
}
final isKeepAliveReconnect =
existingConnection?.isInKeepAlivePeriod == true;
final hasNoActiveConnection = services.connectedInstanceId == null;
final noExistingConnection = existingConnection == null;
return (isKeepAliveReconnect && hasNoActiveConnection) ||
(noExistingConnection && hasNoActiveConnection);
}
/// Handles reconnection to existing services for web-socket mode.
Future<void> _reconnectToService(
AppDebugServices services,
AppConnection? existingConnection,
AppConnection newConnection,
ConnectRequest message,
Completer<void> readyToRunMainCompleter,
) async {
// Disconnect old connection
existingConnection?.shutDown();
services.connectedInstanceId = message.instanceId;
_logger.finest('WebSocket service reconnected for app: ${message.appId}');
final wsService = services.proxyService;
if (wsService is WebSocketProxyService) {
_setupMainExecution(
wsService.pauseIsolatesOnStart,
wsService.resumeAfterRestartEventsStream,
readyToRunMainCompleter,
);
} else {
readyToRunMainCompleter.complete();
}
await _handleIsolateStart(newConnection);
}
/// Sets up main() execution timing based on pause settings.
void _setupMainExecution(
bool pauseIsolatesOnStart,
Stream<String>? resumeEventsStream,
Completer<void> readyToRunMainCompleter,
) {
if (pauseIsolatesOnStart && resumeEventsStream != null) {
_waitForResumeEventToRunMain(resumeEventsStream, readyToRunMainCompleter);
} else {
readyToRunMainCompleter.complete();
}
}
/// Waits for a resume event to trigger the app's main() method.
///
/// The [readyToRunMainCompleter]'s future will be passed to the
/// [AppConnection] so that it can be awaited on before running the app's
/// main() method.
void _waitForResumeEventToRunMain(
Stream<String> resumeEventsStream,
Completer<void> readyToRunMainCompleter,
) {
StreamSubscription<String>? resumeEventsSubscription;
resumeEventsSubscription = resumeEventsStream.listen((_) async {
await resumeEventsSubscription!.cancel();
readyToRunMainCompleter.complete();
});
}
/// Handles isolate exit events for both WebSocket and Chrome-based debugging.
void _handleIsolateExit(AppConnection appConnection) {
final appId = appConnection.request.appId;
if (useWebSocketConnection) {
_logger.fine(
'Isolate exit handled by WebSocket proxy service for app: $appId',
);
} else {
final proxyService = _servicesByAppId[appId]?.proxyService;
if (proxyService is ChromeProxyService) {
proxyService.destroyIsolate();
}
}
}
/// Handles isolate start events for both WebSocket and Chrome-based debugging.
Future<void> _handleIsolateStart(AppConnection appConnection) async {
final appId = appConnection.request.appId;
if (useWebSocketConnection) {
final proxyService = _servicesByAppId[appId]?.proxyService;
if (proxyService is WebSocketProxyService) {
await proxyService.createIsolate(appConnection);
}
} else {
final proxyService = _servicesByAppId[appId]?.proxyService;
if (proxyService is ChromeProxyService) {
await proxyService.createIsolate(appConnection);
}
}
}
void _listen() {
_subs.add(
_injected.devHandlerPaths.listen((devHandlerPath) async {
final uri = Uri.parse(devHandlerPath);
if (!_sseHandlers.containsKey(uri.path)) {
final handler = _useSseForInjectedClient
? SseSocketHandler(
// We provide an essentially indefinite keep alive duration because
// the underlying connection could be lost while the application
// is paused. The connection will get re-established after a resume
// or cleaned up on a full page refresh.
SseHandler(uri, keepAlive: const Duration(days: 3000)),
)
: WebSocketSocketHandler();
_sseHandlers[uri.path] = handler;
final injectedConnections = handler.connections;
while (await injectedConnections.hasNext) {
_handleConnection(await injectedConnections.next);
}
}
}),
);
}
Future<AppDebugServices> _createAppDebugServices(
ChromeDebugService debugService,
) async {
final dwdsStats = DwdsStats();
DartDevelopmentServiceLauncher? dds;
if (_ddsConfig.enable) {
dds = await debugService.startDartDevelopmentService();
}
final vmClient = await ChromeDwdsVmClient.create(debugService, dds?.wsUri);
final appDebugService = AppDebugServices(
debugService: debugService,
dwdsVmClient: vmClient,
dwdsStats: dwdsStats,
dds: dds,
);
final encodedUri = await debugService.encodedUri;
_logger.info('Debug service listening on $encodedUri\n');
final chromeProxy = appDebugService.proxyService;
if (chromeProxy is ChromeProxyService) {
await chromeProxy.remoteDebugger.sendCommand(
'Runtime.evaluate',
params: {
'expression':
'console.log('
'"This app is linked to the debug service: $encodedUri"'
');',
},
);
}
// Notify that DWDS has been launched and a debug connection has been made:
_maybeEmitDwdsLaunchEvent();
return appDebugService;
}
Future<AppDebugServices> _createAppDebugServicesWebSocketMode(
WebSocketDebugService webSocketDebugService,
AppConnection appConnection,
) async {
DartDevelopmentServiceLauncher? dds;
if (_ddsConfig.enable) {
dds = await webSocketDebugService.startDartDevelopmentService();
}
final wsVmClient = await WebSocketDwdsVmClient.create(
webSocketDebugService,
dds?.wsUri,
);
final wsAppDebugService = AppDebugServices(
debugService: webSocketDebugService,
dwdsVmClient: wsVmClient,
dds: dds,
);
safeUnawaited(_handleIsolateStart(appConnection));
final debugServiceUri = webSocketDebugService.uri;
_logger.info('Debug service listening on $debugServiceUri\n');
// Notify that DWDS has been launched and a debug connection has been made:
_maybeEmitDwdsLaunchEvent();
return wsAppDebugService;
}
Future<void> _listenForDebugExtension() async {
final extensionBackend = _extensionBackend;
if (extensionBackend == null) {
_logger.severe('No debug extension backend. Debugging will not work.');
return;
}
while (await extensionBackend.connections.hasNext) {
final extensionDebugger = await extensionBackend.extensionDebugger;
_startExtensionDebugService(extensionDebugger);
}
}
/// Starts a [ChromeProxyService] for Dart Debug Extension.
void _startExtensionDebugService(ExtensionDebugger extensionDebugger) {
// Waits for a `DevToolsRequest` to be sent from the extension background
// when the extension is clicked.
extensionDebugger.devToolsRequestStream.listen((devToolsRequest) async {
try {
await _handleDevToolsRequest(extensionDebugger, devToolsRequest);
_logger.finest('DevTools request processed successfully');
} catch (error) {
_logger.severe('Encountered error handling DevTools request.');
extensionDebugger.closeWithError(error);
}
});
}
Future<void> _handleDevToolsRequest(
ExtensionDebugger extensionDebugger,
DevToolsRequest devToolsRequest,
) async {
// TODO(grouma) - Ideally we surface those warnings to the extension so
// that it can be displayed to the user through an alert.
final tabUrl = devToolsRequest.tabUrl;
final appId = devToolsRequest.appId;
if (tabUrl == null) {
throw StateError(
'Failed to start extension debug service. '
'Missing tab url in DevTools request for app with id: $appId',
);
}
final connection = _appConnectionByAppId[appId];
if (connection == null) {
throw StateError(
'Failed to start extension debug service. '
'Not connected to an app with id: $appId',
);
}
final executionContext = extensionDebugger.executionContext;
if (executionContext == null) {
throw StateError(
'Failed to start extension debug service. '
'No execution context for app with id: $appId',
);
}
final debuggerStart = DateTime.now();
var appServices = _servicesByAppId[appId];
if (appServices == null) {
final debugService = await ChromeDebugService.start(
hostname: _hostname,
remoteDebugger: extensionDebugger,
executionContext: executionContext,
assetReader: _assetReader,
appConnection: connection,
urlEncoder: _urlEncoder,
onResponse: (response) {
if (response['error'] == null) return;
_logger.finest('VmService proxy responded with an error:\n$response');
},
useSse: _useSseForDebugProxy,
expressionCompiler: _expressionCompiler,
ddsConfig: _ddsConfig,
);
appServices = await _createAppDebugServices(debugService);
extensionDebugger.sendEvent('dwds.debugUri', debugService.uri);
final encodedUri = await debugService.encodedUri;
extensionDebugger.sendEvent('dwds.encodedUri', encodedUri);
final chromeProxy = appServices.proxyService;
if (chromeProxy is ChromeProxyService) {
safeUnawaited(
chromeProxy.remoteDebugger.onClose.first.whenComplete(() async {
chromeProxy.destroyIsolate();
await appServices?.close();
_servicesByAppId.remove(devToolsRequest.appId);
_logger.info(
'Stopped debug service on '
'${await appServices?.debugService.encodedUri}\n',
);
}),
);
}
extensionDebugConnections.add(DebugConnection(appServices));
_servicesByAppId[appId] = appServices;
}
_maybeEmitDwdsAttachEvent(devToolsRequest);
// If we don't have a DevTools instance, then are connecting to an IDE.
// Therefore return early instead of opening DevTools:
if (_devTools == null) return;
final encodedUri = await appServices.debugService.encodedUri;
appServices.dwdsStats!.updateLoadTime(
debuggerStart: debuggerStart,
devToolsStart: DateTime.now(),
);
emitEvent(DwdsEvent.devtoolsLaunch());
// Send the DevTools URI to the Dart Debug Extension so that it can open it:
final devToolsUri = _constructDevToolsUri(
appServices,
encodedUri,
ideQueryParam: 'ChromeDevTools',
);
return extensionDebugger.sendEvent('dwds.devtoolsUri', devToolsUri);
}
Future<void> _launchDevTools(
RemoteDebugger remoteDebugger,
String devToolsUri,
) async {
// TODO(grouma) - We may want to log the debugServiceUri if we don't launch
// DevTools so that users can manually connect.
emitEvent(DwdsEvent.devtoolsLaunch());
await remoteDebugger.sendCommand(
'Target.createTarget',
params: {'newWindow': _launchDevToolsInNewWindow, 'url': devToolsUri},
);
}
String _constructDevToolsUri(
AppDebugServices appDebugServices,
String serviceUri, {
String ideQueryParam = '',
}) {
final devToolsUri = _devTools?.uri ?? appDebugServices.devToolsUri;
if (devToolsUri == null) {
throw StateError('DevHandler: DevTools is not available');
}
return devToolsUri
.replace(
pathSegments: [
// Strips any trailing slashes from the original path
...devToolsUri.pathSegments.whereNot((e) => e.isEmpty),
'debugger',
],
queryParameters: {
...devToolsUri.queryParameters,
'uri': serviceUri,
if (ideQueryParam.isNotEmpty) 'ide': ideQueryParam,
},
)
.toString();
}
static void _maybeEmitDwdsAttachEvent(DevToolsRequest request) {
if (request.client != null) {
emitEvent(
DwdsEvent.dwdsAttach(
client: request.client!,
isFlutterApp:
globalToolConfiguration.loadStrategy.buildSettings.isFlutterApp,
),
);
}
}
static void _maybeEmitDwdsLaunchEvent() {
if (globalToolConfiguration.appMetadata.codeRunner != null) {
emitEvent(
DwdsEvent.dwdsLaunch(
codeRunner: globalToolConfiguration.appMetadata.codeRunner!,
isFlutterApp:
globalToolConfiguration.loadStrategy.buildSettings.isFlutterApp,
),
);
}
}
}
class AppConnectionException implements Exception {
final String details;
AppConnectionException(this.details);
}
extension<T> on Stream<T> {
/// Forwards events from the original stream until a period of at least [gap]
/// occurs in between events, in which case the returned stream will end.
Stream<T> takeUntilGap(Duration gap) {
final controller = isBroadcast
? StreamController<T>.broadcast(sync: true)
: StreamController<T>(sync: true);
late StreamSubscription<T> subscription;
Timer? gapTimer;
controller.onListen = () {
subscription = listen(
(e) {
controller.add(e);
gapTimer?.cancel();
gapTimer = Timer(gap, () {
subscription.cancel();
controller.close();
});
},
onError: controller.addError,
onDone: controller.close,
);
};
// Not handling pause/resume
return controller.stream;
}
}