blob: 3cf6b380bcc5c3ec0cad6d7d874028746d5eea04 [file] [log] [blame]
// 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 'package:build_daemon/data/build_status.dart';
import 'package:build_daemon/data/serializers.dart' as build_daemon;
import 'package:dwds/data/error_response.dart';
import 'package:dwds/data/run_request.dart';
import 'package:dwds/src/connections/debug_connection.dart';
import 'package:dwds/src/debugging/remote_debugger.dart';
import 'package:dwds/src/debugging/webkit_debugger.dart';
import 'package:dwds/src/servers/extension_backend.dart';
import 'package:logging/logging.dart';
import 'package:pedantic/pedantic.dart';
import 'package:shelf/shelf.dart';
import 'package:sse/server/sse_handler.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';
import '../../asset_handler.dart';
import '../../data/connect_request.dart';
import '../../data/devtools_request.dart';
import '../../data/isolate_events.dart';
import '../../data/serializers.dart';
import '../connections/app_connection.dart';
import '../dwds_vm_client.dart';
import '../servers/devtools.dart';
import '../services/app_debug_services.dart';
import '../services/debug_service.dart';
/// SSE handler to enable development features like hot reload and
/// opening DevTools.
class DevHandler {
StreamSubscription _sub;
final SseHandler _sseHandler = SseHandler(Uri.parse(r'/$sseHandler'));
final _injectedConnections = Set<SseConnection>();
final DevTools _devTools;
final AssetHandler _assetHandler;
final String _hostname;
final _connectedApps = StreamController<AppConnection>.broadcast();
final _servicesByAppId = <String, Future<AppDebugServices>>{};
final _appConnectionByAppId = <String, AppConnection>{};
final Stream<BuildResult> buildResults;
final bool _verbose;
final void Function(Level, String) _logWriter;
final Future<ChromeConnection> Function() _chromeConnection;
final ExtensionBackend _extensionBackend;
final StreamController<DebugConnection> extensionDebugConnections =
StreamController<DebugConnection>();
/// Null until [close] is called.
///
/// All subsequent calls to [close] will return this future.
Future<void> _closed;
Stream<AppConnection> get connectedApps => _connectedApps.stream;
DevHandler(
this._chromeConnection,
this.buildResults,
this._devTools,
this._assetHandler,
this._hostname,
this._verbose,
this._logWriter,
this._extensionBackend,
) {
_sub = buildResults.listen(_emitBuildResults);
_listen();
if (_extensionBackend != null) {
_listenForDebugExtension();
}
}
Handler get handler => _sseHandler.handler;
Future<void> close() => _closed ??= () async {
await _sub.cancel();
// We listen for connections to close and remove them from the connections
// set. Therefore we shouldn't asynchronously iterate through the
// connections.
await Future.wait(_injectedConnections
.map((injectedConnection) => injectedConnection.sink.close()));
await Future.wait(_servicesByAppId.values.map((futureServices) async {
await (await futureServices).close();
}));
_servicesByAppId.clear();
}();
void _emitBuildResults(BuildResult result) {
if (result.status != BuildStatus.succeeded) return;
for (var injectedConnection in _injectedConnections) {
injectedConnection.sink
.add(jsonEncode(build_daemon.serializers.serialize(result)));
}
}
// TODO(https://github.com/dart-lang/webdev/issues/202) - Refactor so this is
// a getter and is created immediately.
Future<DebugService> startDebugService(
ChromeConnection chromeConnection, AppConnection appConnection) async {
ChromeTab appTab;
WipConnection tabConnection;
var appInstanceId = appConnection.request.instanceId;
for (var tab in await chromeConnection.getTabs()) {
if (tab.url.startsWith('chrome-extensions:')) continue;
tabConnection = await tab.connect();
var result = await tabConnection.runtime
.evaluate(r'window["$dartAppInstanceId"];');
if (result.value == appInstanceId) {
appTab = tab;
break;
}
unawaited(tabConnection.close());
}
if (appTab == null) {
throw StateError('Could not connect to application with appInstanceId: '
'$appInstanceId');
}
await tabConnection.runtime.enable();
var webkitDebugger = WebkitDebugger(WipDebugger(tabConnection));
return DebugService.start(
_hostname,
webkitDebugger,
appTab.url,
_assetHandler,
appConnection,
_logWriter,
onResponse: _verbose
? (response) {
if (response['error'] == null) return;
_logWriter(Level.WARNING,
'VmService proxy responded with an error:\n$response');
}
: null,
useSse: false,
);
}
Future<AppDebugServices> loadAppServices(AppConnection appConnection) =>
_servicesByAppId.putIfAbsent(appConnection.request.appId, () async {
var debugService =
await startDebugService(await _chromeConnection(), appConnection);
var appServices = await _createAppDebugServices(
appConnection.request.appId, debugService);
if (appConnection.isStarted) {
await appServices.chromeProxyService.resumeFromStart();
}
unawaited(appServices.chromeProxyService.remoteDebugger.onClose.first
.whenComplete(() {
appServices.close();
_servicesByAppId.remove(appConnection.request.appId);
_logWriter(
Level.INFO,
'Stopped debug service on '
'ws://${debugService.hostname}:${debugService.port}\n');
}));
return appServices;
});
void _handleConnection(SseConnection injectedConnection) {
_injectedConnections.add(injectedConnection);
AppConnection appConnection;
injectedConnection.stream.listen((data) async {
try {
var message = serializers.deserialize(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 =
await _handleConnectRequest(message, injectedConnection);
} else {
if (appConnection == null) {
throw StateError('Not connected to an application.');
}
if (message is DevToolsRequest) {
await _handleDebugRequest(appConnection, injectedConnection);
} else if (message is IsolateExit) {
await _handleIsolateExit(appConnection);
} else if (message is IsolateStart) {
await _handleIsolateStart(appConnection, injectedConnection);
} else if (message is RunResponse) {
await _handleRunResponse(appConnection);
}
}
} 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(serializers.serialize(ErrorResponse((b) => b
..error = '$e'
..stackTrace = '$s'))));
} on StateError catch (_) {
// The sink has already closed (app is disconnected), swallow the
// error.
}
}
});
unawaited(injectedConnection.sink.done.then((_) async {
_injectedConnections.remove(injectedConnection);
if (appConnection != null) {
_appConnectionByAppId.remove(appConnection.request.appId);
var services = await _servicesByAppId[appConnection.request.appId];
if (services != null) {
if (services.connectedInstanceId == null ||
services.connectedInstanceId ==
appConnection.request.instanceId) {
services.connectedInstanceId = null;
services.chromeProxyService?.destroyIsolate();
}
}
}
}));
}
Future<void> _handleDebugRequest(
AppConnection appConnection, SseConnection sseConnection) async {
if (_devTools == null) {
sseConnection.sink
.add(jsonEncode(serializers.serialize(DevToolsResponse((b) => b
..success = 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;
}
AppDebugServices appServices;
try {
appServices = await loadAppServices(appConnection);
} catch (_) {
sseConnection.sink
.add(jsonEncode(serializers.serialize(DevToolsResponse((b) => b
..success = false
..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.'))));
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(serializers.serialize(DevToolsResponse((b) => b
..success = false
..error = 'This app is already being debugged in a different tab. '
'Please close that tab or switch to it.'))));
return;
}
// If you load the same app in a different tab then we need to throw
// away our old services and start new ones.
if (!(await _isCorrectTab(appConnection.request.instanceId,
appServices.chromeProxyService.remoteDebugger))) {
unawaited(appServices.close());
unawaited(_servicesByAppId.remove(appConnection.request.appId));
appServices = await loadAppServices(appConnection);
}
sseConnection.sink.add(jsonEncode(
serializers.serialize(DevToolsResponse((b) => b..success = true))));
appServices.connectedInstanceId = appConnection.request.instanceId;
await appServices.chromeProxyService.remoteDebugger
.sendCommand('Target.createTarget', params: {
'newWindow': true,
'url': Uri(
scheme: 'http',
host: _devTools.hostname,
port: _devTools.port,
queryParameters: {'uri': appServices.debugService.uri}).toString(),
});
}
Future<AppConnection> _handleConnectRequest(
ConnectRequest message, SseConnection sseConnection) async {
// After a page refresh, reconnect to the same app services if they
// were previously launched and create the new isolate.
var services = await _servicesByAppId[message.appId];
var connection = AppConnection(message, sseConnection);
if (services != null && services.connectedInstanceId == null) {
// Re-connect to the previous instance if its in the same tab,
// otherwise do nothing for now.
if (await _isCorrectTab(
message.instanceId, services.chromeProxyService.remoteDebugger)) {
services.connectedInstanceId = message.instanceId;
await services.chromeProxyService.createIsolate(connection);
}
}
_appConnectionByAppId[message.appId] = connection;
_connectedApps.add(connection);
return connection;
}
Future<void> _handleIsolateExit(AppConnection appConnection) async {
(await _servicesByAppId[appConnection.request.appId])
?.chromeProxyService
?.destroyIsolate();
}
Future<void> _handleIsolateStart(
AppConnection appConnection, SseConnection sseConnection) async {
await (await _servicesByAppId[appConnection.request.appId])
?.chromeProxyService
?.createIsolate(appConnection);
// [IsolateStart] events are the result of a Hot Restart.
// Run the application after the Isolate has been created.
sseConnection.sink.add(jsonEncode(serializers.serialize(RunRequest())));
}
Future<void> _handleRunResponse(AppConnection appConnection) async {
await (await _servicesByAppId[appConnection.request.appId])
?.chromeProxyService
?.resumeFromStart();
}
void _listen() async {
var injectedConnections = _sseHandler.connections;
while (await injectedConnections.hasNext) {
_handleConnection(await injectedConnections.next);
}
}
Future<AppDebugServices> _createAppDebugServices(
String appId, DebugService debugService) async {
_logWriter(
Level.INFO,
'Debug service listening on '
'${debugService.uri}\n');
var webdevClient = await DwdsVmClient.create(debugService);
return AppDebugServices(debugService, webdevClient);
}
void _listenForDebugExtension() async {
while (await _extensionBackend.connections.hasNext) {
_startExtensionDebugService();
}
}
/// Starts a [DebugService] for Dart Debug Extension.
void _startExtensionDebugService() async {
var _extensionDebugger = await _extensionBackend.extensionDebugger;
// Waits for a `DevToolsRequest` to be sent from the extension background
// when the extension is clicked.
_extensionDebugger.devToolsRequestStream.listen((devToolsRequest) async {
var connection = _appConnectionByAppId[devToolsRequest.appId];
if (connection == null) {
throw StateError(
'Not connected to an app with id: ${devToolsRequest.appId}');
}
var appServices =
await _servicesByAppId.putIfAbsent(devToolsRequest.appId, () async {
var debugService = await DebugService.start(
_hostname,
_extensionDebugger,
devToolsRequest.tabUrl,
_assetHandler,
connection,
_logWriter,
onResponse: _verbose
? (response) {
if (response['error'] == null) return;
_logWriter(Level.WARNING,
'VmService proxy responded with an error:\n$response');
}
: null,
useSse: true,
);
var appServices =
await _createAppDebugServices(devToolsRequest.appId, debugService);
unawaited(appServices.chromeProxyService.remoteDebugger.onClose.first
.whenComplete(() {
appServices.chromeProxyService.destroyIsolate();
appServices.close();
_servicesByAppId.remove(devToolsRequest.appId);
_logWriter(
Level.INFO,
'Stopped debug service on '
'${appServices.debugService.uri}\n');
}));
extensionDebugConnections.add(DebugConnection(appServices));
return appServices;
});
await _extensionDebugger.sendCommand('Target.createTarget', params: {
'newWindow': true,
'url': Uri(
scheme: 'http',
host: _devTools.hostname,
port: _devTools.port,
queryParameters: {'uri': appServices.debugService.uri}).toString(),
});
});
}
}
/// Checks if connection of [remoteDebugger] is running the app with [instanceId].
Future<bool> _isCorrectTab(
String instanceId, RemoteDebugger remoteDebugger) async {
var result = await remoteDebugger.evaluate(r'window["$dartAppInstanceId"];');
return result.value == instanceId;
}