blob: 7b222da5686f429bbad3c0204822a148217ca22d [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.
/// @docImport '../dartpad.dart';
library;
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:json_rpc_2/json_rpc_2.dart' as rpc;
import 'package:stream_channel/stream_channel.dart';
import 'package:vm_service/vm_service.dart' show VmService;
import 'exceptions.dart' show rethrowAsDartPadException;
import 'message_port/message_port.dart';
import 'shared.dart';
export 'exceptions.dart' hide rethrowAsDartPadException;
export 'version_info.dart' show VersionInfo;
/// Client for talking to `shared_worker.dart`.
base class WorkerClient {
static const _pingInterval = Duration(seconds: 30);
final rpc.Peer _peer;
Timer? _pingTimer;
final _languageServers = <int, LanguageServer>{};
final _sandboxes = <int, Sandbox>{};
final _watchers = <int, WorkspaceWatcher>{};
/// Creates a client that communicates over [channel].
///
/// The [channel] usually connects to a `Worker` instance (in tests) or a
/// `MessagePort` (in the browser).
///
/// When [keepAlive] is `true` (the default), [WorkerClient] sends a
/// periodic [ping] every 30 seconds to keep the worker session alive.
WorkerClient(StreamChannel<Object?> channel, {bool keepAlive = true})
: _peer = rpc.Peer.withoutJson(channel) {
_peer.registerMethod('workspace/languageServer/message', _handleLsMessage);
_peer.registerMethod('workspace/languageServer/exited', _handleLsExited);
_peer.registerMethod('workspace/watcher/events', _handleWatchEvent);
_peer.registerMethod('workspace/sandbox/console', _handleSandboxConsole);
if (keepAlive) {
_pingTimer = Timer.periodic(_pingInterval, (_) {
if (!_peer.isClosed) {
ping().ignore();
}
});
}
_peer.listen().whenComplete(() {
_pingTimer?.cancel();
for (final w in _watchers.values.toList()) {
w._cleanup();
}
for (final ls in _languageServers.values.toList()) {
ls._cleanup();
}
for (final s in _sandboxes.values.toList()) {
s._cleanup();
}
}).ignore();
}
/// A [Future] that completes when the connection to the worker is closed.
Future<void> get done => _peer.done;
/// Sends a keep-alive `ping` request to the worker.
///
/// [WorkerClient] automatically sends a `ping` every 30 seconds while
/// connected so that the worker does not close the session for inactivity.
Future<void> ping() async {
await _peer.request<Map>('ping', {});
}
/// Closes the connection to the worker.
Future<void> close() async {
_pingTimer?.cancel();
await _peer.close();
}
/// Version and capability metadata for the worker.
Future<VersionInfo> version() async {
final result = await _peer.request<Map>('version', {});
return VersionInfo.fromJson(result.cast<String, Object?>());
}
/// Creates a workspace in the worker.
///
/// A [Workspace] is allocated a unique folder [Workspace.workspaceFolder].
/// Closing a workspace using [Workspace.close] deletes the
/// _workspace folder_ and any [LanguageServer] and [Sandbox]
/// started within said workspace.
///
/// Workspaces are not isolated, and file operations may interfere with other
/// _workspace folders_. This may change in the future.
Future<Workspace> createWorkspace() async {
final result = await _peer.request<Map>('createWorkspace', {});
return Workspace._(
this,
(result['workspaceId'] as num).toInt(),
result['workspaceFolder'] as String,
);
}
void _handleSandboxConsole(rpc.Parameters params) {
final id = (params['sandboxId'].value as num).toInt();
final level = ConsoleLevel._fromName(params['level'].asString);
final message = params['message'].asString;
_sandboxes[id]?._consoleController.add((level: level, message: message));
}
void _handleLsMessage(rpc.Parameters params) {
final id = (params['languageServerId'].value as num).toInt();
final message = params['message'].asMap;
_languageServers[id]?._incomingMessages.add(message);
}
void _handleLsExited(rpc.Parameters params) {
final id = (params['languageServerId'].value as num).toInt();
_languageServers[id]?._handleExited();
}
void _handleWatchEvent(rpc.Parameters params) {
final watcherId = (params['watcherId'].value as num).toInt();
final events = params['events'].asList;
final watcher = _watchers[watcherId];
if (watcher != null) {
for (final e in events) {
final map = e as Map;
final type = map['type'] as String;
final path = map['path'] as String;
watcher._controller.add(switch (type) {
'add' => FileAddedEvent(path),
'modify' => FileModifiedEvent(path),
'remove' => FileRemovedEvent(path),
_ => FileModifiedEvent(path),
});
}
}
}
}
/// Representation of a _workspace_ inside the worker with methods wrapping
/// the RPC interface.
///
/// All paths passed to methods will be resolved against [workspaceFolder].
final class Workspace {
final WorkerClient _client;
final int id;
/// Folder owned by this workspace.
///
/// All paths given to methods on this class will be resolved against
/// [workspaceFolder].
final String workspaceFolder;
Workspace._(this._client, this.id, this.workspaceFolder);
/// Helper to attach the workspaceId to every request.
Future<T> _request<T>(String method, [Map<String, Object?>? params]) async {
return await _client._peer.request<T>(method, {
...?params,
'workspaceId': id,
});
}
/// Write [text] to file at [path] in this workspace.
Future<void> writeFileFromText(String path, String text) =>
_request('workspace/writeFileFromText', {'path': path, 'text': text});
/// Write [bytes] to file at [path] in this workspace.
Future<void> writeFileFromBytes(String path, Uint8List bytes) =>
_request('workspace/writeFileFromBytes', {'path': path, 'bytes': bytes});
/// Read file at [path] in this workspace as UTF-8 string.
Future<String> readFileAsText(String path) async {
final result = await _request<Map>('workspace/readFileAsText', {
'path': path,
});
return result['text'] as String;
}
/// Read file at [path] in this workspace as bytes.
Future<Uint8List> readFileAsBytes(String path) async {
final result = await _request<Map>('workspace/readFileAsBytes', {
'path': path,
});
return result['bytes'] as Uint8List;
}
/// Extract [tarArchive] into folder at [path] in this workspace.
Future<void> importTarArchive(String path, Uint8List tarArchive) => _request(
'workspace/importTarArchive',
{'path': path, 'bytes': tarArchive},
);
/// Export files from [path] in this workspace to a tar-archive.
Future<Uint8List> exportTarArchive(String path) async {
final result = await _request<Map>('workspace/exportTarArchive', {
'path': path,
});
return result['bytes'] as Uint8List;
}
/// Delete file or folder at [path] in this workspace.
Future<void> deleteFileSystemEntity(String path) =>
_request('workspace/deleteFileSystemEntity', {'path': path});
/// Get information about a file or folder in this workspace.
Future<({String type, int? size})> stat(String path) async {
final result = await _request<Map>('workspace/stat', {'path': path});
return (
type: result['type'] as String,
size: (result['size'] as num?)?.toInt(),
);
}
/// Returns true if a file exists at [path] in this workspace.
Future<bool> fileExist(String path) async {
try {
final s = await stat(path);
return s.type == 'file';
} on FileNotFoundException {
return false;
}
}
/// Returns true if a folder exists at [path] in this workspace.
Future<bool> folderExist(String path) async {
try {
final s = await stat(path);
return s.type == 'folder';
} on FileNotFoundException {
return false;
}
}
/// Create a folder at [path] in this workspace.
Future<void> createFolder(String path) =>
_request('workspace/createFolder', {'path': path});
/// List folder at [path] in this workspace.
///
/// Returns a list of entries on the form:
/// * `path`, `path/to/file` relative to [path] given.
/// * `type`, `'file'` or `'folder'`.
Future<List<({String path, String type})>> listDirectory(
String path, {
bool recursive = false,
bool ignoreHidden = false,
}) async {
final result = await _request<Map>('workspace/listDirectory', {
'path': path,
'recursive': recursive,
'ignoreHidden': ignoreHidden,
});
return (result['entries'] as List).map((e) {
final map = e as Map;
return (path: map['path'] as String, type: map['type'] as String);
}).toList();
}
/// Watch a file or directory for changes.
WorkspaceWatcher watch(String path) => WorkspaceWatcher._(this, path);
/// Invoke a `dart pub` [command] with [args].
///
/// The following commands are supported:
/// * `get`,
/// * `add`,
/// * `downgrade`,
/// * `outdated`,
/// * `upgrade`,
/// * `remove`, and,
/// * `unpack`.
///
/// Throws [PubCommandFailedException], if the command exited non-zero.
///
/// Returns a `log` containing lines from stdout.
Future<({String log})> pub({
String path = '.',
required String command,
List<String> args = const <String>[],
}) async {
final result = await _request<Map>('workspace/pub', {
'path': path,
'command': command,
'args': args,
});
return (log: result['log'] as String);
}
/// Start a language server talking the [LSP] protocol.
///
/// [LSP]: https://microsoft.github.io/language-server-protocol/
Future<LanguageServer> startLanguageServer() async {
final result = await _request<Map>('workspace/startLanguageServer');
final lsId = (result['languageServerId'] as num).toInt();
final ls = LanguageServer._(_client, this, lsId);
_client._languageServers[lsId] = ls;
return ls;
}
/// Connect to a [SandboxedIframe] using a [MessagePort].
///
/// A [SandboxedIframe] can only be connected to one [Workspace].
///
/// Once connected, you get a [Sandbox] object for controlling compilation
/// and execution within the sandboxed iframe.
///
/// You may pass [SandboxedIframe.port] directly, or use
/// [MessagePort.asBinaryChannel] / [MessagePort.fromBinaryChannel] to proxy
/// the message port over a different transport layer.
Future<Sandbox> connectSandboxedIframe(MessagePort port) async {
final result = await _request<Map>('workspace/connectSandbox', {
'port': port,
});
final id = result['sandboxId'] as int;
final modes = (result['modes'] as List).cast<String>();
return _client._sandboxes[id] = Sandbox._(this, id, modes);
}
/// Destroy this workspace and all resources held by it.
///
/// While sandboxes are controlled through the worker, the [SandboxedIframe]
/// will have to be removed using [SandboxedIframe.close].
Future<void> close() async {
try {
await _client._peer.request<void>('workspace/close', {'workspaceId': id});
} finally {
final watchers = _client._watchers.values
.where((w) => w.workspace == this)
.toList();
for (final w in watchers) {
try {
w._cleanup();
} catch (_) {
// ignore
}
}
final languageServers = _client._languageServers.values
.where((ls) => ls.workspace == this)
.toList();
for (final ls in languageServers) {
try {
ls._cleanup();
} catch (_) {
// ignore
}
}
final sandboxes = _client._sandboxes.values
.where((s) => s._workspace == this)
.toList();
for (final s in sandboxes) {
try {
s._cleanup();
} catch (_) {
// ignore
}
}
}
}
}
/// A client for the language server running within a workspace.
final class LanguageServer {
final WorkerClient _client;
final Workspace workspace;
final int id;
final _incomingMessages = StreamController<Object?>();
final _outgoingMessages = StreamController<Object?>();
late final StreamChannel<Object?> _channel;
LanguageServer._(this._client, this.workspace, this.id) {
_channel = StreamChannel(_incomingMessages.stream, _outgoingMessages.sink);
// Forward outgoing LSP messages to the worker tunnel
_outgoingMessages.stream.listen((message) {
if (_client._peer.isClosed) {
return;
}
_client._peer.sendNotification('workspace/languageServer/message', {
'workspaceId': workspace.id,
'languageServerId': id,
'message': message,
});
});
}
/// Communication channel over which standard LSP JSON-RPC 2.0 messages
/// travel.
///
/// These are not encoded as JSON Strings, but instead travels as the kind of
/// JSON values returned by [json] codec from `dart:convert`.
StreamChannel<Object?> get languageServerChannel => _channel;
/// Closes the language server.
Future<void> close() async {
try {
await _client._peer.request<void>('workspace/languageServer/close', {
'workspaceId': workspace.id,
'languageServerId': id,
});
} finally {
_cleanup();
}
}
void _handleExited() {
_cleanup();
}
void _cleanup() {
_client._languageServers.remove(id);
_incomingMessages.close().ignore();
_outgoingMessages.close().ignore();
}
}
/// A client object for watching for file changes inside a [Workspace].
///
/// A [WorkspaceWatcher] object does not listen for events until someone
/// subscribes to [changes] for events. Events are not garenteed until [ready]
/// is resolved, and [ready] will not resolve until someone subscribes to
/// [changes].
final class WorkspaceWatcher {
final Workspace workspace;
/// Folder or file to be watched.
final String path;
var _watcherId = Completer<int>();
late final StreamController<FileChangeEvent> _controller;
WorkspaceWatcher._(this.workspace, this.path) {
_controller = StreamController<FileChangeEvent>.broadcast(
onListen: _onListen,
onCancel: _onCancel,
);
}
/// Broadcast stream with [FileChangeEvent] for [path].
///
/// File changes will only be reported while this stream subscribers.
/// When a subscription is made, events prior to [ready] being resolved may
/// not be reported.
///
/// Generally, you should subscribe to the [changes] stream, and wait for
/// [ready] before assuming that events for file changes will arrive.
Stream<FileChangeEvent> get changes => _controller.stream;
/// A [Future] that completes when the watcher is initialized and reporting
/// events in [changes].
///
/// This future will not complete until a subscription to [changes] has been
/// made. This future will change when all subscriptions to [changes] are
/// cancelled.
Future<void> get ready => _watcherId.future;
/// True, if watcher is initialized and reporting events in [changes].
bool get isReady => _watcherId.isCompleted;
void _onListen() {
assert(!_watcherId.isCompleted);
_watcherId.complete(
Future(() async {
final result = await workspace._request<Map>('workspace/startWatcher', {
'path': path,
});
final watcherId = (result['watcherId'] as num).toInt();
workspace._client._watchers[watcherId] = this;
return watcherId;
}),
);
}
void _onCancel() {
if (_controller.isClosed) return;
_watcherId.future.then((watcherId) async {
try {
await workspace._request<Map>('workspace/watcher/close', {
'watcherId': watcherId,
});
} finally {
workspace._client._watchers.remove(watcherId);
}
}).ignore();
_watcherId = Completer();
}
void _cleanup() {
workspace._client._watchers.removeWhere((_, w) => w == this);
_controller.close().ignore();
}
}
/// Represents a change to a file or directory in the workspace.
sealed class FileChangeEvent {
/// Absolute path of the file or folder.
final String path;
const FileChangeEvent(this.path);
}
/// An event fired when a file or directory is added to the workspace.
final class FileAddedEvent extends FileChangeEvent {
const FileAddedEvent(super.path);
}
/// An event fired when a file or directory in the workspace is modified.
final class FileModifiedEvent extends FileChangeEvent {
const FileModifiedEvent(super.path);
}
/// An event fired when a file or directory is removed from the workspace.
final class FileRemovedEvent extends FileChangeEvent {
const FileRemovedEvent(super.path);
}
extension on rpc.Peer {
/// Wrap [sendRequest] with casting the return to [T]
Future<T> request<T>(String method, [Object? parameters]) async {
try {
return await sendRequest(method, parameters) as T;
} on rpc.RpcException catch (e) {
rethrowAsDartPadException(e);
}
}
}
/// Severity level for messages emitted on [Sandbox.console].
enum ConsoleLevel implements Comparable<ConsoleLevel> {
/// Output from [`console.debug`][1].
///
/// [1]: https://developer.mozilla.org/en-US/docs/Web/API/console/debug_static
debug('debug'),
/// Output from [`console.log`][1], includes `print`.
///
/// [1]: https://developer.mozilla.org/en-US/docs/Web/API/console/log_static
log('log'),
/// Output from [`console.info`][1].
///
/// [1]: https://developer.mozilla.org/en-US/docs/Web/API/console/info_static
info('info'),
/// Output from [`console.warn`][1].
///
/// [1]: https://developer.mozilla.org/en-US/docs/Web/API/console/warn_static
warn('warn'),
/// Output from [`console.error`][1], includes `unhandledrejection` and
/// `error` events from `window`.
///
/// [1]: https://developer.mozilla.org/en-US/docs/Web/API/console/error_static
error('error');
/// Name of this level in the worker wire protocol.
final String protocolName;
const ConsoleLevel(this.protocolName);
@override
int compareTo(ConsoleLevel other) => index.compareTo(other.index);
bool operator <(ConsoleLevel other) => index < other.index;
bool operator <=(ConsoleLevel other) => index <= other.index;
bool operator >(ConsoleLevel other) => index > other.index;
bool operator >=(ConsoleLevel other) => index >= other.index;
static final _byProtocolName = {
for (final level in ConsoleLevel.values) level.protocolName: level,
};
static ConsoleLevel _fromName(String name) =>
_byProtocolName[name] ?? ConsoleLevel.log;
}
/// A client for running Dart code from a [Workspace] inside a
/// [SandboxedIframe].
///
/// The [Sandbox] client object controls what is going on inside the `<iframe>`,
/// communication is proxied by the [Workspace] it is connected to, and methods
/// like [run] resolve paths against the connected [Workspace.workspaceFolder].
final class Sandbox {
final Workspace _workspace;
final int _id;
/// The available _run modes_ for this sandbox.
///
/// {@template run_modes}
/// A [DartPadSdk] defines one or more _modes_ that code a run using.
///
/// The [DartPadSdk] for **Dart** defines _run modes_:
/// * `mode: 'console'` for running `main()` as a console app.
///
/// The [DartPadSdk] for **Flutter** defines _run modes_:
/// * `mode: 'console'` for running `main()` as a console app.
/// * `mode: 'flutter'` for wrapping a `main()` that calls `runApp()` in a
/// manner that configures the flutter engine.
/// {@endtemplate}
final List<String> modes;
Sandbox._(this._workspace, this._id, this.modes);
final _consoleController =
StreamController<({ConsoleLevel level, String message})>.broadcast();
/// A broadcast stream of console messages produced by the running
/// application.
///
/// Subscribe to this stream before calling [run] to ensure initial console
/// output from `main()` is not missed.
Stream<({ConsoleLevel level, String message})> get console =>
_consoleController.stream;
/// Compiles and runs a Dart entrypoint in the sandbox.
///
/// The [path] is resolved against [Workspace.workspaceFolder] (e.g.,
/// `'bin/main.dart'` or `'lib/main.dart'`).
///
/// The [mode] must be one of the supported [modes].
///
/// This may only be called **once** per [Sandbox]. Use [hotReload] or
/// [hotRestart] to run modified code, and create a new [Sandbox] (with a new
/// [SandboxedIframe]) to run a different program. Calling [run] again throws
/// [InvalidSandboxStateException].
///
/// {@macro run_modes}
Future<({String log})> run(String path, {required String mode}) async {
final result = await _workspace._request<Map>('workspace/sandbox/run', {
'sandboxId': _id,
'path': path,
'mode': mode,
});
return (log: result['log'] as String);
}
/// Hot restarts the currently running application in the sandbox.
///
/// This recompiles the entrypoint and fully reloads the application state.
Future<({String log})> hotRestart() async {
final result = await _workspace._request<Map>(
'workspace/sandbox/hotRestart',
{'sandboxId': _id},
);
return (log: result['log'] as String);
}
/// Hot reloads the currently running application in the sandbox.
///
/// This recompiles the application incrementally, preserving its state.
Future<({String log})> hotReload() async {
final result = await _workspace._request<Map>(
'workspace/sandbox/hotReload',
{'sandboxId': _id},
);
return (log: result['log'] as String);
}
/// Connects [port] to the VM Service protocol server for this sandbox.
///
/// Messages sent over [port] are Dart VM Service JSON-RPC 2.0 strings,
/// binary frames (`Uint8Array` / [Uint8List]), or `null` to close the
/// connection. Multiple clients can be connected to the same [Sandbox]
/// simultaneously (for example, an in-Dart [VmService] client and an embedded
/// DevTools `<iframe>`).
Future<void> connectServiceProtocol(MessagePort port) async {
await _workspace._request<void>(
'workspace/sandbox/connectServiceProtocol',
{'sandboxId': _id, 'port': port},
);
}
/// Starts a VM Service protocol connection to this sandbox and returns a
/// connected [VmService] client.
Future<VmService> startServiceProtocol() async {
final (clientPort, workerPort) = MessagePortExt.createChannel();
try {
await connectServiceProtocol(workerPort);
} catch (_) {
clientPort.close();
rethrow;
}
final channel = clientPort.vmServiceChannel();
return VmService(
channel.stream,
channel.sink.add,
disposeHandler: channel.sink.close,
);
}
/// Release resources associated with this [Sandbox].
///
/// This does not remove the `<iframe>`.
Future<void> close() async {
try {
await _workspace._request<Map>('workspace/sandbox/close', {
'sandboxId': _id,
});
} finally {
_cleanup();
}
}
void _cleanup() {
_consoleController.close().ignore();
_workspace._client._sandboxes.remove(_id);
}
}