diff --git a/webdev/lib/src/command/daemon_command.dart b/webdev/lib/src/command/daemon_command.dart new file mode 100644 index 000000000..e2df6e2a8 --- /dev/null +++ b/webdev/lib/src/command/daemon_command.dart @@ -0,0 +1,49 @@ +// 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:args/command_runner.dart'; + +import '../daemon/daemon.dart'; + +Stream> get _stdinCommandStream => stdin + .transform(utf8.decoder) + .transform(const LineSplitter()) + .where((String line) => line.startsWith('[{') && line.endsWith('}]')) + .map>((String line) { + line = line.substring(1, line.length - 1); + return json.decode(line) as Map; + }); + +void _stdoutCommandResponse(Map command) { + stdout.writeln('[${json.encode(command)}]'); +} + +/// A mode for running WebDev from command-line tools. +/// +/// Communication happens over STDIO using JSON-RPC. +/// +/// This supports a subset of: +/// https://github.com/flutter/flutter/blob/master/packages/flutter_tools/doc/daemon.md +class DaemonCommand extends Command { + @override + final name = 'daemon'; + + @override + final hidden = true; + + @override + String get description => + 'A mode for running WebDev from command-line tools.'; + + @override + Future run() async { + var daemon = Daemon(_stdinCommandStream, _stdoutCommandResponse); + await daemon.onExit; + return 0; + } +} diff --git a/webdev/lib/src/daemon/daemon.dart b/webdev/lib/src/daemon/daemon.dart new file mode 100644 index 000000000..6bf05b86a --- /dev/null +++ b/webdev/lib/src/daemon/daemon.dart @@ -0,0 +1,93 @@ +// 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:io'; + +import 'daemon_domain.dart'; +import 'domain.dart'; +import 'utilites.dart'; + +/// A collection of domains. +/// +/// Listens for commands, routes them to the corresponding domain and provides +/// the result. +class Daemon { + Daemon(Stream> commandStream, this._sendCommand) { + _registerDomain(DaemonDomain(this)); + + // TODO(grouma) - complete these other domains. + //_registerDomain(appDomain = AppDomain(this)); + //_registerDomain(deviceDomain = DeviceDomain(this)); + //_registerDomain(emulatorDomain = EmulatorDomain(this)); + + _commandSubscription = commandStream.listen( + _handleRequest, + onDone: () { + if (!_onExitCompleter.isCompleted) _onExitCompleter.complete(0); + }, + ); + } + + StreamSubscription> _commandSubscription; + + final void Function(Map) _sendCommand; + + final Completer _onExitCompleter = Completer(); + final Map _domainMap = {}; + + void _registerDomain(Domain domain) { + _domainMap[domain.name] = domain; + } + + Future get onExit => _onExitCompleter.future; + + void _handleRequest(Map request) { + // {id, method, params} + + // [id] is an opaque type to us. + var id = request['id']; + + if (id == null) { + stderr.writeln('no id for request: $request'); + return; + } + + try { + var method = request['method'] as String ?? ''; + if (!method.contains('.')) { + throw ArgumentError('method not understood: $method'); + } + + var domain = method.substring(0, method.indexOf('.')); + var name = method.substring(method.indexOf('.') + 1); + if (_domainMap[domain] == null) { + throw ArgumentError('no domain for method: $method'); + } + + _domainMap[domain].handleCommand( + name, id, request['params'] as Map ?? {}); + } catch (error, trace) { + send({ + 'id': id, + 'error': toJsonable(error), + 'trace': '$trace', + }); + } + } + + void send(Map map) => _sendCommand(map); + + void shutdown({dynamic error}) { + _commandSubscription?.cancel(); + for (var domain in _domainMap.values) domain.dispose(); + if (!_onExitCompleter.isCompleted) { + if (error == null) { + _onExitCompleter.complete(0); + } else { + _onExitCompleter.completeError(error); + } + } + } +} diff --git a/webdev/lib/src/daemon/daemon_domain.dart b/webdev/lib/src/daemon/daemon_domain.dart new file mode 100644 index 000000000..df1bd8675 --- /dev/null +++ b/webdev/lib/src/daemon/daemon_domain.dart @@ -0,0 +1,37 @@ +// 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:io'; + +import 'daemon.dart'; +import 'domain.dart'; + +const String protocolVersion = '0.4.2'; + +/// A collection of method and events relevant to the daemon command. +class DaemonDomain extends Domain { + DaemonDomain(Daemon daemon) : super(daemon, 'daemon') { + registerHandler('version', _version); + registerHandler('shutdown', _shutdown); + + sendEvent( + 'daemon.connected', + { + 'version': protocolVersion, + 'pid': pid, + }, + ); + } + + Future _version(Map args) { + return Future.value(protocolVersion); + } + + Future _shutdown(Map args) { + // Schedule shutdown after we return the result. + Timer.run(daemon.shutdown); + return Future.value(); + } +} diff --git a/webdev/lib/src/daemon/domain.dart b/webdev/lib/src/daemon/domain.dart new file mode 100644 index 000000000..6e7ec0765 --- /dev/null +++ b/webdev/lib/src/daemon/domain.dart @@ -0,0 +1,55 @@ +// 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 'daemon.dart'; +import 'utilites.dart'; + +/// Hosts a set of commands and events to be used with the Daemon. +abstract class Domain { + Domain(this.daemon, this.name); + + final Daemon daemon; + final String name; + final Map _handlers = {}; + + void registerHandler(String name, CommandHandler handler) { + _handlers[name] = handler; + } + + @override + String toString() => name; + + void handleCommand(String command, dynamic id, Map args) { + Future.sync(() { + if (_handlers.containsKey(command)) return _handlers[command](args); + throw ArgumentError('command not understood: $name.$command'); + }).then((dynamic result) { + if (result == null) { + _send({'id': id}); + } else { + _send({'id': id, 'result': toJsonable(result)}); + } + }).catchError((dynamic error, dynamic trace) { + _send({ + 'id': id, + 'error': toJsonable(error), + 'trace': '$trace', + }); + }); + } + + void sendEvent(String name, [dynamic args]) { + var map = {'event': name}; + if (args != null) map['params'] = toJsonable(args); + _send(map); + } + + void _send(Map map) => daemon.send(map); + + void dispose() {} +} + +typedef CommandHandler = Future Function(Map args); diff --git a/webdev/lib/src/daemon/utilites.dart b/webdev/lib/src/daemon/utilites.dart new file mode 100644 index 000000000..b13b4591d --- /dev/null +++ b/webdev/lib/src/daemon/utilites.dart @@ -0,0 +1,42 @@ +// 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. + +dynamic toJsonable(dynamic obj) { + if (obj is String || + obj is int || + obj is bool || + obj is Map || + obj is List || + obj == null) return obj; + return '$obj'; +} + +String getStringArg(Map args, String name, + {bool required = false}) { + if (required && !args.containsKey(name)) + throw ArgumentError('$name is required'); + var val = args[name]; + if (val != null && val is! String) { + throw ArgumentError('$name is not a String'); + } + return val as String; +} + +bool getBoolArg(Map args, String name, + {bool required = false}) { + if (required && !args.containsKey(name)) { + throw ArgumentError('$name is required'); + } + var val = args[name]; + if (val != null && val is! bool) throw ArgumentError('$name is not a bool'); + return val as bool; +} + +int getIntArg(Map args, String name, {bool required = false}) { + if (required && !args.containsKey(name)) + throw ArgumentError('$name is required'); + var val = args[name]; + if (val != null && val is! int) throw ArgumentError('$name is not an int'); + return val as int; +} diff --git a/webdev/lib/src/webdev_command_runner.dart b/webdev/lib/src/webdev_command_runner.dart index 3ba157cad..4e36951e0 100644 --- a/webdev/lib/src/webdev_command_runner.dart +++ b/webdev/lib/src/webdev_command_runner.dart @@ -8,6 +8,7 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'command/build_command.dart'; +import 'command/daemon_command.dart'; import 'command/serve_command.dart'; import 'util.dart'; import 'version.dart'; @@ -23,6 +24,7 @@ class _CommandRunner extends CommandRunner { negatable: false, help: 'Prints the version of webdev.'); addCommand(BuildCommand()); addCommand(ServeCommand()); + addCommand(DaemonCommand()); } @override diff --git a/webdev/test/daemon/daemon_domain_test.dart b/webdev/test/daemon/daemon_domain_test.dart new file mode 100644 index 000000000..9cc35c26b --- /dev/null +++ b/webdev/test/daemon/daemon_domain_test.dart @@ -0,0 +1,54 @@ +// 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:convert'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_process/test_process.dart'; +import 'package:webdev/src/util.dart'; + +import '../test_utils.dart'; + +void main() { + String exampleDirectory; + + setUpAll(() async { + exampleDirectory = p.absolute(p.join(p.current, '..', 'example')); + + var process = await TestProcess.start(pubPath, ['upgrade'], + workingDirectory: exampleDirectory, environment: getPubEnvironment()); + + await process.shouldExit(0); + }); + + group('Daemon', () { + group('Events', () { + test('.connected', () async { + var webdev = + await runWebDev(['daemon'], workingDirectory: exampleDirectory); + await expectLater( + webdev.stdout, emits(startsWith('[{"event":"daemon.connected"'))); + await webdev.kill(); + }); + }); + + test('.version', () async { + var webdev = + await runWebDev(['daemon'], workingDirectory: exampleDirectory); + webdev.stdin.add(utf8.encode('[{"method":"daemon.version","id":0}]\n')); + await expectLater( + webdev.stdout, emitsThrough(equals('[{"id":0,"result":"0.4.2"}]'))); + await webdev.kill(); + }); + + test('.shutdown', () async { + var webdev = + await runWebDev(['daemon'], workingDirectory: exampleDirectory); + webdev.stdin.add(utf8.encode('[{"method":"daemon.shutdown","id":0}]\n')); + await expectLater(webdev.stdout, emitsThrough(equals('[{"id":0}]'))); + expect(await webdev.exitCode, equals(0)); + }); + }); +}