blob: d04e3a2209c3a01a09357ca95f4493484b4e38fc [file]
// 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 'dart:convert';
import 'dart:math' show Random;
import 'package:analyzer/file_system/file_system.dart';
import 'package:async/async.dart';
import 'package:frontend_server/starter.dart';
import '../resource_provider/resource_provider_io_overrides.dart';
import '../util/string_sink_ext.dart';
/// Parsed output frame from a `frontend_server` `compile` or `recompile`
/// command.
typedef FrontendServerOutput = ({
/// Path to the emitted `.dill`, e.g. `app.dill.incremental.dill`.
String dillPath,
int errorCount,
/// Formatted compiler diagnostics emitted during the compilation.
String diagnostics,
/// Source file URIs added (`+<uri>`) since the previous compilation.
List<Uri> addedSources,
/// Source file URIs removed (`-<uri>`) since the previous compilation.
List<Uri> removedSources,
});
/// In-process client for `pkg/frontend_server` driven via [starter] and its
/// standard stdin/stdout boundary-key protocol.
///
/// All `dart:io` file system operations performed by `frontend_server` are
/// redirected to the provided [ResourceProvider] via
/// [runWithResourceProviderIO].
///
/// Lifecycle:
/// 1. Start an incremental compiler session with [FrontendServerClient.start].
/// 2. Run the initial compilation with [compile], then call [accept] on
/// success (or [quit] on fatal failure).
/// 3. Run subsequent incremental compilations with [recompile], calling
/// [accept] when `errorCount == 0` or [reject] when `errorCount > 0`.
/// 4. Terminate the session and release stream resources with [quit].
///
/// The protocol has no request identifiers: each instruction is answered by the
/// next frame written to the output. Hence a client must not have more than one
/// outstanding instruction, and callers must not call the methods below
/// concurrently.
final class FrontendServerClient {
static final _random = Random();
final ResourceProvider _resourceProvider;
final StreamController<List<int>> _input;
final StreamController<String> _output;
final StreamQueue<String> _lines;
/// Completes with an error as soon as `frontend_server` stops, whether it
/// crashed or exited in response to [quit].
///
/// See [_nextLine] for why this is necessary.
final Completer<Never> _stopped = Completer();
late final Future<int> _serverFuture;
FrontendServerClient._(
this._resourceProvider,
this._input,
this._output,
this._lines,
) {
// Only read when something is waiting for output, so keep stopping from
// surfacing as an unhandled asynchronous error.
_stopped.future.ignore();
}
/// Starts `frontend_server` in interactive mode with [args] (which must
/// include `--incremental` and no positional entrypoint argument),
/// redirecting `dart:io` access to [resourceProvider].
static FrontendServerClient start(
ResourceProvider resourceProvider,
List<String> args,
) => runWithResourceProviderIO(resourceProvider, () {
final input = StreamController<List<int>>();
final output = StreamController<String>();
final client = FrontendServerClient._(
resourceProvider,
input,
output,
StreamQueue(output.stream.transform(const LineSplitter())),
);
// `listenAndCompile` handles instructions in an `async` listener callback
// with no error handling, so a throw while compiling lands here as an
// uncaught asynchronous error. The `!` is safe because an `async` body
// never throws synchronously.
client._serverFuture = runZonedGuarded(
() async => await starter(
args,
input: input.stream,
output: output.sink.asStringSink(),
),
client._stop,
)!;
// `starter()` only completes its future once it has been sent `quit`; any
// other completion means the server stopped without answering.
client._serverFuture.then(
(exitCode) => client._stop(
StateError('frontend_server exited with code $exitCode'),
StackTrace.empty,
),
onError: client._stop,
);
return client;
});
/// Sends a `compile <entrypoint>` instruction for the initial compilation and
/// returns the parsed [FrontendServerOutput].
Future<FrontendServerOutput> compile(String entrypoint) =>
runWithResourceProviderIO(_resourceProvider, () {
_send('compile $entrypoint');
return _readCompileOutput();
});
/// Sends a `recompile` (or `recompile-restart` when [restart] is `true`)
/// instruction with [invalidatedUris] and returns the parsed
/// [FrontendServerOutput].
///
/// Every entry in [invalidatedUris] should be a full URI with a scheme
/// (e.g. `file:///...` or `workspace:///...`) so `Uri.base.resolve` inside
/// `frontend_server` preserves it when running in a Web Worker.
Future<FrontendServerOutput> recompile(
String entrypoint,
List<String> invalidatedUris, {
required bool restart,
}) => runWithResourceProviderIO(_resourceProvider, () {
final instruction = restart ? 'recompile-restart' : 'recompile';
final boundaryKey = _boundaryKey();
_send(
[
'$instruction $entrypoint $boundaryKey',
...invalidatedUris,
boundaryKey,
].join('\n'),
);
return _readCompileOutput();
});
/// Accepts the last compilation delta (`accept`).
///
/// `frontend_server` writes no output in response; the next instruction is
/// what orders this against subsequent compilations.
void accept() =>
runWithResourceProviderIO(_resourceProvider, () => _send('accept'));
/// Rejects the last compilation delta (`reject`) and waits for
/// `frontend_server` to acknowledge and roll back to the last accepted state.
Future<void> reject() =>
runWithResourceProviderIO(_resourceProvider, () async {
_send('reject');
final boundaryKey = await _readBoundaryKey();
while (await _nextLine != boundaryKey) {}
});
/// Sends `quit` to `frontend_server`, waits for [starter] to exit, and closes
/// the underlying input/output streams.
///
/// Also used to clean up after a crashed server, in which case there is
/// nobody left to answer `quit`.
Future<void> quit() => runWithResourceProviderIO(_resourceProvider, () async {
if (!_stopped.isCompleted) {
_send('quit');
// Racing [_stopped] for the reason given on [_nextLine]: a throw while
// handling `quit` leaves [_serverFuture] pending forever.
await Future.any<void>([
_serverFuture,
_stopped.future,
]).onError((_, _) {});
}
await _input.close();
// Immediate, because a read abandoned by [_nextLine] is still queued: a
// queued cancel would wait behind it for output that never arrives.
await _lines.cancel(immediate: true);
await _output.close();
});
void _send(String command) {
_input.add(utf8.encode('$command\n'));
}
/// Signals that `frontend_server` will not write any more output.
void _stop(Object error, StackTrace stackTrace) {
if (!_stopped.isCompleted) {
_stopped.completeError(error, stackTrace);
}
}
/// The next line of output from `frontend_server`, or an error if the server
/// stopped before writing it.
///
/// Racing [_stopped] is what keeps a crash inside `frontend_server` from
/// hanging the caller forever: such a crash writes no output _and_ never
/// completes the future returned by [starter], which only ever completes in
/// response to `quit`.
///
/// The abandoned read stays queued in [_lines], so a client that has stopped
/// must be discarded rather than reused.
Future<String> get _nextLine => Future.any([_lines.next, _stopped.future]);
/// Reads the `result <boundary-key>` line that opens every output frame.
Future<String> _readBoundaryKey() async {
final line = await _nextLine;
if (!line.startsWith('result ')) {
throw _protocolError('a `result <boundary-key>` line', line);
}
return line.substring('result '.length);
}
Future<FrontendServerOutput> _readCompileOutput() async {
final boundaryKey = await _readBoundaryKey();
final diagnostics = <String>[];
while (true) {
final line = await _nextLine;
if (line == boundaryKey) break;
diagnostics.add(line);
}
final addedSources = <Uri>[];
final removedSources = <Uri>[];
while (true) {
final line = await _nextLine;
if (line.startsWith('$boundaryKey ')) {
// '<boundary-key> <dill-path> <error-count>', where only the error
// count is guaranteed not to contain spaces.
final rest = line.substring(boundaryKey.length + 1);
final splitIndex = rest.lastIndexOf(' ');
final errorCount = splitIndex < 0
? null
: int.tryParse(rest.substring(splitIndex + 1));
if (errorCount == null) {
throw _protocolError(
'a `<boundary-key> <dill-path> <error-count>` line',
line,
);
}
return (
dillPath: rest.substring(0, splitIndex),
errorCount: errorCount,
diagnostics: diagnostics.isEmpty ? '' : '${diagnostics.join('\n')}\n',
addedSources: addedSources,
removedSources: removedSources,
);
} else if (line.startsWith('+')) {
addedSources.add(Uri.parse(line.substring(1)));
} else if (line.startsWith('-')) {
removedSources.add(Uri.parse(line.substring(1)));
}
}
}
StateError _protocolError(String expected, String line) => StateError(
'Unexpected frontend_server output, expected $expected, got: "$line"',
);
/// A key for framing a `recompile` instruction and the output it produces.
///
/// Only needs to be free of spaces and unlikely to occur in the invalidated
/// URIs or the diagnostics it delimits.
static String _boundaryKey() =>
'dartpad-boundary-${_random.nextInt(1 << 32).toRadixString(16)}';
}