| // Copyright 2020 The Dart Authors. 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:async/async.dart'; | |
| import 'package:path/path.dart' as p; | |
| import 'shared.dart'; | |
| /// Wrapper around the incremental frontend server compiler. | |
| class FrontendServerClient { | |
| final String _entrypoint; | |
| final Process _feServer; | |
| final StreamQueue<String> _feServerStdoutLines; | |
| final bool _verbose; | |
| _ClientState _state; | |
| /// Completes the next time this client becomes idle, see [_isIdle]. | |
| Completer<void>? _idleCompleter; | |
| FrontendServerClient._( | |
| this._entrypoint, | |
| this._feServer, | |
| this._feServerStdoutLines, { | |
| bool? verbose, | |
| }) : _verbose = verbose ?? false, | |
| _state = _ClientState.waitingForFirstCompile { | |
| _feServer.stderr.transform(utf8.decoder).listen(stderr.write); | |
| } | |
| /// Starts the frontend server. | |
| /// | |
| /// Most arguments directly mirror the command line arguments for the | |
| /// frontend_server (see `pkg/frontend_server/lib/frontend_server.dart` in | |
| /// the sdk). Options are exposed on an as-needed basis. | |
| /// | |
| /// The [entrypoint] and [packagesJson] may be a relative path or any uri | |
| /// supported by the frontend server. | |
| /// | |
| /// The [librariesSpec] is a path or uri to the libraries specification JSON | |
| /// (e.g. `libraries.json`). | |
| /// | |
| /// The [outputDillPath] determines where the primary output should be, and | |
| /// some targets may output additional files based on that file name (by | |
| /// adding file extensions for instance). | |
| /// | |
| /// When the [frontendServerPath] argument is provided, the frontend server | |
| /// will be started from the specified file. The specified file can either be | |
| /// a Dart source file or an AppJIT snapshot. | |
| /// | |
| /// When the [frontendServerPath] argument is provided, setting [debug] to | |
| /// true permits debuggers to attach to the frontend server. When the | |
| /// [frontendServerPath] argument is omitted, setting [debug] to true will | |
| /// cause an [ArgumentError] to be thrown. | |
| /// | |
| /// [additionalArgs] are passed to the frontend server after all other args. | |
| static Future<FrontendServerClient> start( | |
| String entrypoint, | |
| String outputDillPath, | |
| String platformKernel, { | |
| String dartdevcModuleFormat = 'amd', | |
| bool debug = false, | |
| List<String>? enabledExperiments, | |
| bool enableHttpUris = false, | |
| List<String> fileSystemRoots = const [], // For `fileSystemScheme` uris, | |
| String fileSystemScheme = | |
| 'org-dartlang-root', // Custom scheme for virtual `fileSystemRoots`. | |
| String? frontendServerPath, // Defaults to the snapshot in the sdk. | |
| String? librariesSpec, | |
| String packagesJson = '.dart_tool/package_config.json', | |
| String? sdkRoot, // Defaults to the current SDK root. | |
| String target = 'vm', // The kernel target type. | |
| bool verbose = false, // Verbose logs, including server/client messages | |
| bool printIncrementalDependencies = true, | |
| List<String> additionalSources = const [], | |
| String? nativeAssets, | |
| List<String> additionalArgs = const [], | |
| }) async { | |
| final sdk = sdkRoot ?? sdkDir; | |
| final commonArguments = <String>[ | |
| '--sdk-root', | |
| sdk, | |
| '--platform=$platformKernel', | |
| '--target=$target', | |
| if (target == 'dartdevc') | |
| '--dartdevc-module-format=$dartdevcModuleFormat', | |
| for (final root in fileSystemRoots) '--filesystem-root=$root', | |
| '--filesystem-scheme', | |
| fileSystemScheme, | |
| '--output-dill', | |
| outputDillPath, | |
| '--packages=$packagesJson', | |
| if (librariesSpec != null) ...['--libraries-spec', librariesSpec], | |
| if (enableHttpUris) '--enable-http-uris', | |
| '--incremental', | |
| if (verbose) '--verbose', | |
| if (!printIncrementalDependencies) '--no-print-incremental-dependencies', | |
| if (enabledExperiments != null) | |
| for (final experiment in enabledExperiments) | |
| '--enable-experiment=$experiment', | |
| for (final source in additionalSources) ...['--source', source], | |
| if (nativeAssets != null) ...['--native-assets', nativeAssets], | |
| ...additionalArgs, | |
| ]; | |
| final dartExecutable = p.join( | |
| sdk, | |
| 'bin', | |
| Platform.isWindows ? 'dart.exe' : 'dart', | |
| ); | |
| final dartAotRuntimePath = p.join( | |
| sdk, | |
| 'bin', | |
| Platform.isWindows ? 'dartaotruntime.exe' : 'dartaotruntime', | |
| ); | |
| final feServerAotSnapshotPath = p.join( | |
| sdk, | |
| 'bin', | |
| 'snapshots', | |
| 'frontend_server_aot.dart.snapshot', | |
| ); | |
| late final Process feServer; | |
| if (frontendServerPath != null) { | |
| if (!File(frontendServerPath).existsSync()) { | |
| throw ArgumentError.value( | |
| frontendServerPath, | |
| 'frontendServerPath', | |
| 'Frontend server snapshot does not exist', | |
| ); | |
| } | |
| feServer = await Process.start(dartExecutable, <String>[ | |
| if (debug) '--observe', | |
| frontendServerPath, | |
| ...commonArguments, | |
| ]); | |
| } else { | |
| if (debug) { | |
| throw ArgumentError( | |
| 'The debug argument cannot be set to true when the ' | |
| 'frontendServerPath argument is omitted.', | |
| ); | |
| } | |
| if (!File(feServerAotSnapshotPath).existsSync()) { | |
| throw ArgumentError.value( | |
| sdk, | |
| 'sdkRoot', | |
| 'Frontend server AOT snapshot does not exist at ' | |
| '"$feServerAotSnapshotPath"', | |
| ); | |
| } | |
| if (!File(dartAotRuntimePath).existsSync()) { | |
| throw ArgumentError.value( | |
| sdk, | |
| 'sdkRoot', | |
| 'Dart AOT runtime does not exist at "$dartAotRuntimePath"', | |
| ); | |
| } | |
| feServer = await Process.start(dartAotRuntimePath, <String>[ | |
| feServerAotSnapshotPath, | |
| ...commonArguments, | |
| ]); | |
| } | |
| final feServerStdoutLines = StreamQueue( | |
| feServer.stdout.transform(utf8.decoder).transform(const LineSplitter()), | |
| ); | |
| // The frontend_server doesn't appear to recursively create files, so we | |
| // need to make sure the output dir already exists. | |
| final outputDir = Directory(p.dirname(outputDillPath)); | |
| if (!await outputDir.exists()) await outputDir.create(); | |
| return FrontendServerClient._( | |
| entrypoint, | |
| feServer, | |
| feServerStdoutLines, | |
| verbose: verbose, | |
| ); | |
| } | |
| /// Compiles [_entrypoint], using an incremental recompile if possible. | |
| /// | |
| /// [invalidatedUris] must not be null for all but the very first compile. | |
| /// | |
| /// The frontend server _does not_ do any of its own invalidation. | |
| Future<CompileResult> compile([List<Uri>? invalidatedUris]) => | |
| _compile(invalidatedUris, restart: false); | |
| /// Like [compile] but for a hot restart. | |
| /// | |
| /// Must not be called before the first [compile]. | |
| Future<CompileResult> recompileRestart(List<Uri> invalidatedUris) => | |
| _compile(invalidatedUris, restart: true); | |
| Future<CompileResult> _compile( | |
| List<Uri>? invalidatedUris, { | |
| required bool restart, | |
| }) async { | |
| while (_state == _ClientState.compilingExpression) { | |
| await _nextIdle; | |
| } | |
| String action; | |
| switch (_state) { | |
| case _ClientState.waitingForFirstCompile: | |
| if (restart) { | |
| throw StateError( | |
| '`recompileRestart` must not be called before the first `compile`.', | |
| ); | |
| } | |
| action = 'compile'; | |
| break; | |
| case _ClientState.waitingForRecompile: | |
| if (invalidatedUris == null || invalidatedUris.isEmpty) { | |
| throw StateError( | |
| '`invalidatedUris` must be non-empty after the first `compile`.', | |
| ); | |
| } | |
| action = restart ? 'recompile-restart' : 'recompile'; | |
| break; | |
| case _ClientState.waitingForAcceptOrReject: | |
| throw StateError( | |
| 'Previous `CompileResult` must be accepted or rejected by ' | |
| 'calling `accept` or `reject`.', | |
| ); | |
| case _ClientState.compiling: | |
| throw StateError( | |
| 'App is already being compiled, you must wait for that to ' | |
| 'complete and `accept` or `reject` the result before compiling ' | |
| 'again.', | |
| ); | |
| case _ClientState.rejecting: | |
| throw StateError( | |
| 'Still waiting for previous `reject` call to finish. ' | |
| 'You must await that before compiling again.', | |
| ); | |
| case _ClientState.compilingExpression: | |
| throw StateError('Unreachable'); | |
| } | |
| _state = _ClientState.compiling; | |
| try { | |
| final command = StringBuffer('$action $_entrypoint'); | |
| if (action != 'compile') { | |
| final boundaryKey = generateUuidV4(); | |
| command.writeln(' $boundaryKey'); | |
| for (final uri in invalidatedUris!) { | |
| command.writeln('$uri'); | |
| } | |
| command.write(boundaryKey); | |
| } | |
| _sendCommand(command.toString()); | |
| var state = _CompileState.started; | |
| late String feBoundaryKey; | |
| final newSources = <Uri>{}; | |
| final removedSources = <Uri>{}; | |
| final compilerOutputLines = <String>[]; | |
| var errorCount = 0; | |
| String? outputDillPath; | |
| while (state != _CompileState.done && | |
| await _feServerStdoutLines.hasNext) { | |
| final line = await _nextInputLine(); | |
| switch (state) { | |
| case _CompileState.started: | |
| assert(line.startsWith('result')); | |
| feBoundaryKey = line.substring(line.indexOf(' ') + 1); | |
| state = _CompileState.waitingForKey; | |
| continue; | |
| case _CompileState.waitingForKey: | |
| if (line == feBoundaryKey) { | |
| state = _CompileState.gettingSourceDiffs; | |
| } else { | |
| compilerOutputLines.add(line); | |
| } | |
| continue; | |
| case _CompileState.gettingSourceDiffs: | |
| if (line.startsWith(feBoundaryKey)) { | |
| state = _CompileState.done; | |
| final parts = line.split(' '); | |
| outputDillPath = parts.getRange(1, parts.length - 1).join(' '); | |
| errorCount = int.parse(parts.last); | |
| continue; | |
| } | |
| final diffUri = Uri.parse(line.substring(1)); | |
| if (line.startsWith('+')) { | |
| newSources.add(diffUri); | |
| } else if (line.startsWith('-')) { | |
| removedSources.add(diffUri); | |
| } else { | |
| throw StateError( | |
| 'unrecognized diff line, should start with a + or - ' | |
| 'but got: $line', | |
| ); | |
| } | |
| continue; | |
| case _CompileState.done: | |
| throw StateError('Unreachable'); | |
| } | |
| } | |
| return CompileResult._( | |
| dillOutput: outputDillPath, | |
| errorCount: errorCount, | |
| newSources: newSources, | |
| removedSources: removedSources, | |
| compilerOutputLines: compilerOutputLines, | |
| ); | |
| } finally { | |
| _state = _ClientState.waitingForAcceptOrReject; | |
| } | |
| } | |
| /// TODO: Document | |
| Future<CompileResult> compileExpression({ | |
| required String expression, | |
| required List<String> definitions, | |
| required bool isStatic, | |
| required String klass, | |
| required String libraryUri, | |
| required List<String> typeDefinitions, | |
| }) => throw UnimplementedError(); | |
| /// Compiles Dart [expression] to JS in the scope of [line] and [column] | |
| /// of [libraryUri], or of its part [scriptUri]. | |
| /// | |
| /// [jsFrameValues] maps variables in scope to their JS values. | |
| /// Can only be called after the first [compile] and automatically awaits | |
| /// an [accept] or [reject]. | |
| /// | |
| /// On success, stores the emitted JS in [CompileResult.compiledExpression]. | |
| /// On failure, errors are stored in [CompileResult.compilerOutputLines]. | |
| Future<CompileResult> compileExpressionToJs({ | |
| required String expression, | |
| required int column, | |
| required Map<String, String> jsFrameValues, | |
| required Map<String, String> jsModules, | |
| required String libraryUri, | |
| required int line, | |
| required String moduleName, | |
| String? scriptUri, | |
| }) async { | |
| while (!_isIdle) { | |
| await _nextIdle; | |
| } | |
| // See Frontend Server state machine here: | |
| // pkg/frontend_server/lib/frontend_server.dart | |
| final previousState = _state; | |
| _state = _ClientState.compilingExpression; | |
| try { | |
| _sendCommand('JSON_INPUT'); | |
| _sendCommand( | |
| jsonEncode({ | |
| 'type': 'COMPILE_EXPRESSION_JS', | |
| 'data': { | |
| 'expression': expression, | |
| 'libraryUri': libraryUri, | |
| 'scriptUri': ?scriptUri, | |
| 'line': line, | |
| 'column': column, | |
| 'jsModules': jsModules, | |
| 'jsFrameValues': jsFrameValues, | |
| 'moduleName': moduleName, | |
| }, | |
| }), | |
| ); | |
| final compilerOutputLines = <String>[]; | |
| String? feBoundaryKey; | |
| while (await _feServerStdoutLines.hasNext) { | |
| final line = await _nextInputLine(); | |
| if (feBoundaryKey == null) { | |
| if (line.startsWith('result ')) { | |
| feBoundaryKey = line.substring('result '.length); | |
| } else { | |
| compilerOutputLines.add(line); | |
| } | |
| } else if (line == feBoundaryKey) { | |
| return CompileResult._( | |
| dillOutput: null, | |
| compilerOutputLines: compilerOutputLines, | |
| errorCount: 1, | |
| newSources: const [], | |
| removedSources: const [], | |
| ); | |
| } else if (line.startsWith('$feBoundaryKey ')) { | |
| final parts = line.split(' '); | |
| final outputPath = parts.getRange(1, parts.length - 1).join(' '); | |
| final errorCount = int.parse(parts.last); | |
| // Read [outputPath] before it's overridden by another compile. | |
| final output = await File(outputPath).readAsString(); | |
| return CompileResult._( | |
| dillOutput: null, | |
| compilerOutputLines: [ | |
| ...compilerOutputLines, | |
| if (errorCount > 0) output, | |
| ], | |
| errorCount: errorCount, | |
| newSources: const [], | |
| removedSources: const [], | |
| compiledExpression: errorCount == 0 ? output : null, | |
| ); | |
| } else { | |
| compilerOutputLines.add(line); | |
| } | |
| } | |
| throw StateError( | |
| 'The frontend server exited while compiling an expression.', | |
| ); | |
| } finally { | |
| _setState(previousState); | |
| } | |
| } | |
| /// Should be invoked when results of compilation are accepted by the client. | |
| /// | |
| /// Either [accept] or [reject] should be called after every [compile] call. | |
| void accept() { | |
| if (_state != _ClientState.waitingForAcceptOrReject) { | |
| throw StateError( | |
| 'Called `accept` but there was no previous compile to accept.', | |
| ); | |
| } | |
| _sendCommand('accept'); | |
| _setState(_ClientState.waitingForRecompile); | |
| } | |
| /// Should be invoked when results of compilation are rejected by the client. | |
| /// | |
| /// Either [accept] or [reject] should be called after every [compile] call. | |
| /// | |
| /// The result of this call must be awaited before a new [compile] can be | |
| /// done. | |
| Future<void> reject() async { | |
| if (_state != _ClientState.waitingForAcceptOrReject) { | |
| throw StateError( | |
| 'Called `reject` but there was no previous compile to reject.', | |
| ); | |
| } | |
| _state = _ClientState.rejecting; | |
| _sendCommand('reject'); | |
| late String boundaryKey; | |
| var rejectState = _RejectState.started; | |
| while (rejectState != _RejectState.done && | |
| await _feServerStdoutLines.hasNext) { | |
| final line = await _nextInputLine(); | |
| switch (rejectState) { | |
| case _RejectState.started: | |
| if (!line.startsWith('result')) { | |
| throw StateError( | |
| 'Expected a line like `result <boundary-key>` after a `reject` ' | |
| 'command, but got:\n$line', | |
| ); | |
| } | |
| boundaryKey = line.split(' ').last; | |
| rejectState = _RejectState.waitingForKey; | |
| continue; | |
| case _RejectState.waitingForKey: | |
| if (line != boundaryKey) { | |
| throw StateError('Expected exactly `$boundaryKey` but got:\n$line'); | |
| } | |
| rejectState = _RejectState.done; | |
| continue; | |
| case _RejectState.done: | |
| throw StateError('Unreachable'); | |
| } | |
| } | |
| _setState(_ClientState.waitingForRecompile); | |
| } | |
| /// Should be invoked when frontend server compiler should forget what was | |
| /// accepted previously so that next call to [compile] produces complete | |
| /// kernel file. | |
| void reset() { | |
| if (_state == _ClientState.compiling || | |
| _state == _ClientState.compilingExpression) { | |
| throw StateError( | |
| 'Called `reset` during an active compile, you must wait for that to ' | |
| 'complete first.', | |
| ); | |
| } | |
| _sendCommand('reset'); | |
| _setState(_ClientState.waitingForRecompile); | |
| } | |
| /// Stop the service gracefully (using the shutdown command) | |
| Future<int> shutdown() async { | |
| _sendCommand('quit'); | |
| final timer = Timer(const Duration(seconds: 1), _feServer.kill); | |
| final exitCode = await _feServer.exitCode; | |
| timer.cancel(); | |
| await _feServerStdoutLines.cancel(); | |
| return exitCode; | |
| } | |
| /// Kills the server forcefully by calling `kill` on the process, and | |
| /// returns the result. | |
| bool kill({ProcessSignal processSignal = ProcessSignal.sigterm}) { | |
| _feServerStdoutLines.cancel(); | |
| return _feServer.kill(processSignal); | |
| } | |
| /// Sends [command] to the [_feServer] via stdin, and logs it if [_verbose]. | |
| void _sendCommand(String command) { | |
| if (_verbose) { | |
| final lines = const LineSplitter().convert(command); | |
| for (final line in lines) { | |
| print('>> $line'); | |
| } | |
| } | |
| _feServer.stdin.writeln(command); | |
| } | |
| /// Reads a line from [_feServerStdoutLines] and logs it if [_verbose]. | |
| Future<String> _nextInputLine() async { | |
| final line = await _feServerStdoutLines.next; | |
| if (_verbose) print('<< $line'); | |
| return line; | |
| } | |
| /// `true` if no request is in progress or waiting to be accepted/rejected. | |
| bool get _isIdle => | |
| _state == _ClientState.waitingForFirstCompile || | |
| _state == _ClientState.waitingForRecompile; | |
| /// Completes the next time this client is idle. | |
| Future<void> get _nextIdle => (_idleCompleter ??= Completer<void>()).future; | |
| /// Sets [_state] and updates _idleCompleter. | |
| void _setState(_ClientState state) { | |
| _state = state; | |
| if (_isIdle) { | |
| _idleCompleter?.complete(); | |
| _idleCompleter = null; | |
| } | |
| } | |
| } | |
| /// The result of a compile call. | |
| class CompileResult { | |
| const CompileResult._({ | |
| required this.dillOutput, | |
| required this.compilerOutputLines, | |
| required this.errorCount, | |
| required this.newSources, | |
| required this.removedSources, | |
| this.compiledExpression, | |
| }); | |
| /// The produced dill output file, this will either be a full dill file, an | |
| /// incremental dill file, or `null` if no file was produced. | |
| final String? dillOutput; | |
| /// All output from the compiler, typically this would contain errors or | |
| /// warnings. | |
| final Iterable<String> compilerOutputLines; | |
| /// The total count of errors, details should appear in | |
| /// [compilerOutputLines]. | |
| final int errorCount; | |
| /// A single file containing all source maps for all JS outputs. | |
| /// | |
| /// Read [jsManifestOutput] for file offsets for each sourcemap. | |
| String? get jsSourceMapsOutput => | |
| dillOutput == null ? null : '$dillOutput.map'; | |
| /// A single file containing all JS outputs. | |
| /// | |
| /// Read [jsManifestOutput] for file offsets for each source. | |
| String? get jsSourcesOutput => | |
| dillOutput == null ? null : '$dillOutput.sources'; | |
| /// A single file containing the debugger metadata for all JS outputs. | |
| /// | |
| /// [jsManifestOutput] holds file offsets for each module's metadata. Only | |
| /// written with `--experimental-emit-debug-metadata`. | |
| String? get jsMetadataOutput => | |
| dillOutput == null ? null : '$dillOutput.metadata'; | |
| /// A JSON manifest containing offsets for the sources and source maps in | |
| /// the [jsSourcesOutput] and [jsSourceMapsOutput] files. | |
| String? get jsManifestOutput => | |
| dillOutput == null ? null : '$dillOutput.json'; | |
| /// All the transitive source dependencies that were added as a part of this | |
| /// compile. | |
| final Iterable<Uri> newSources; | |
| /// All the transitive source dependencies that were removed as a part of | |
| /// this compile. | |
| final Iterable<Uri> removedSources; | |
| /// JS from a valid expression compile, or `null`. | |
| final String? compiledExpression; | |
| } | |
| /// Internal states for the client. | |
| enum _ClientState { | |
| compiling, | |
| compilingExpression, | |
| rejecting, | |
| waitingForAcceptOrReject, | |
| waitingForFirstCompile, | |
| waitingForRecompile, | |
| } | |
| /// Frontend server interaction states for a `compile` call. | |
| enum _CompileState { started, waitingForKey, gettingSourceDiffs, done } | |
| /// Frontend server interaction states for a `reject` call. | |
| enum _RejectState { started, waitingForKey, done } |