blob: 67a8358a3067b427c3ab376c6f446505da677d43 [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:js_interop';
import 'package:json_rpc_2/json_rpc_2.dart';
import 'package:web/web.dart' as web;
import 'util/json_rpc_message_port_channel.dart';
const _defaultHeadHtml = '''
<style>
body { margin: 0; overflow: hidden; background: transparent; }
</style>
''';
String _iframeHtml({
required Uri sandboxJs,
required String headHtml,
required String bodyHtml,
}) =>
'''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="$sandboxJs" defer></script>
$headHtml
</head>
<body>$bodyHtml</body>
</html>
''';
/// The severity level of a message printed to the sandbox console.
///
/// These closely map to the standard browser `console` methods.
enum ConsoleLevel {
/// A standard log message (e.g., generated by Dart's `print()`).
log,
/// An informational message.
info,
/// A warning message indicating a potential issue.
warn,
/// An error message indicating a failure or exception.
error,
}
/// A structured console message intercepted from the sandboxed environment.
///
/// Contains the `level` of severity and the fully formatted string `message`
/// ready for display in a UI.
typedef ConsoleMessage = ({ConsoleLevel level, String message});
/// Client for interacting with a sandboxed DDC execution environment.
class Sandbox {
final web.HTMLIFrameElement? _iframe;
final Peer _peer;
final _consoleController = StreamController<ConsoleMessage>.broadcast();
final _unhandledRejectionController =
StreamController<({String message})>.broadcast();
final _errorController = StreamController<({String message})>.broadcast();
final _extensionEventController =
StreamController<({String kind, Map<String, Object?> data})>.broadcast();
Sandbox._(this._peer, this._iframe) {
_peer.registerMethod('console', (Parameters params) {
final levelStr = params['level'].asString;
final message = params['message'].asString;
final level = ConsoleLevel.values.firstWhere(
(e) => e.name == levelStr,
orElse: () => ConsoleLevel.log,
);
_consoleController.add((level: level, message: message));
});
_peer.registerMethod('unhandledRejection', (Parameters params) {
final message = params['message'].asString;
_unhandledRejectionController.add((message: message));
});
_peer.registerMethod('error', (Parameters params) {
final message = params['message'].asString;
_errorController.add((message: message));
});
_peer.registerMethod('extensionEvent', (Parameters params) {
final kind = params['kind'].asString;
final data = jsonDecode(params['data'].asString);
_extensionEventController.add((kind: kind, data: data));
});
unawaited(_peer.listen());
}
/// Stream of console output from the sandbox.
///
/// This is a _broadcast stream_, you must subcribe immediately after creating
/// the sandbox if you want to be certain to get all messages.
Stream<ConsoleMessage> get onConsole => _consoleController.stream;
/// Stream of unhandled rejections from the sandbox.
///
/// This is a _broadcast stream_, you must subscribe immediately after
/// creating the sandbox if you want to be certain to get all messages.
Stream<({String message})> get onUnhandledRejection =>
_unhandledRejectionController.stream;
/// Stream of runtime errors from the sandbox.
///
/// This is a _broadcast stream_, you must subscribe immediately after
/// creating the sandbox if you want to be certain to get all messages.
Stream<({String message})> get onError => _errorController.stream;
/// Stream of custom extension events from the sandbox.
///
/// These events are fired by `dart:developer`'s `postEvent` method.
///
/// This is a _broadcast stream_ - you must subscribe immediately after
/// creating the sandbox if you want to be certain to get all events.
Stream<({String kind, Map<String, Object?> data})> get onExtensionEvent =>
_extensionEventController.stream;
Future<T> _sendRequest<T>(
String method, [
Map<String, Object?> params = const {},
]) async {
return await _peer.sendRequest(method, params) as T;
}
/// Injects compiled Dart-to-JS code into the sandbox.
Future<void> loadModule({
required String code,
String moduleName = 'main',
}) async {
await _sendRequest<void>('loadModule', {
'code': code,
'moduleName': moduleName,
});
}
/// Runs the application by calling `main()` in the target library.
Future<void> runMain(Uri libraryUri) async {
await _sendRequest<void>('runMain', {'libraryUri': libraryUri.toString()});
}
/// Run flutter app by calling `main()` from [libraryUri].
///
/// This requires `flutter.js`, which is loaded when running with Flutter SDK.
/// You may expect [hotReload] to work after this, but [hotRestart] will not.
Future<void> runApp(Uri libraryUri) async {
await _sendRequest<void>('runApp', {'libraryUri': libraryUri.toString()});
}
/// Triggers a hot restart, resetting global state.
///
/// Returns the current hot restart generation number from the embedder.
Future<({int generation})> hotRestart({
String? code,
String? moduleName = 'main',
}) async {
final r = await _sendRequest<Map>('hotRestart', {
'code': ?code,
'moduleName': ?moduleName,
});
return (generation: (r['generation'] as num).toInt());
}
/// Triggers a stateful hot reload.
///
/// [librariesToReload] should contain the URIs of the libraries that changed.
///
/// Returns the current hot reload generation number from the embedder.
Future<({int generation})> hotReload({
String? code,
String? moduleName = 'main',
List<Uri> librariesToReload = const [],
}) async {
final r = await _sendRequest<Map>('hotReload', {
'code': ?code,
'moduleName': ?moduleName,
'librariesToReload': librariesToReload.map((u) => u.toString()).toList(),
});
return (generation: (r['generation'] as num).toInt());
}
/// Fetches the current hot restart generation counter.
Future<({int generation})> getHotRestartGeneration() async {
final r = await _sendRequest<Map>('getHotRestartGeneration');
return (generation: (r['generation'] as num).toInt());
}
/// Fetches the current hot reload generation counter.
Future<({int generation})> getHotReloadGeneration() async {
final r = await _sendRequest<Map>('getHotReloadGeneration');
return (generation: (r['generation'] as num).toInt());
}
/// Get application metrics from the sandbox.
Future<
({
int dartSize,
int jsSize,
int sourceMapSize,
int evaluatedModules,
Duration loadTime,
})
>
appMetrics() async {
final r = await _sendRequest<Map>('appMetrics');
return (
dartSize: (r['dartSize'] as num).toInt(),
jsSize: (r['jsSize'] as num).toInt(),
sourceMapSize: (r['sourceMapSize'] as num).toInt(),
evaluatedModules: (r['evaluatedModules'] as num).toInt(),
loadTime: Duration(milliseconds: (r['loadTimeMs'] as num).toInt()),
);
}
/// Invokes a developer service extension inside the running app.
Future<String> invokeExtension(
String method,
Map<String, String> args,
) async {
return await _sendRequest<String>('invokeExtension', {
'method': method,
'args': args,
});
}
/// Disposes of the sandbox and its resources.
void dispose() {
_iframe?.remove();
unawaited(_peer.close());
_consoleController.close();
_unhandledRejectionController.close();
_errorController.close();
_extensionEventController.close();
}
/// Creates a [Sandbox] by injecting an iframe into [container].
///
/// The [assetBaseUrl] should point to the directory containing `sandbox.js`
/// and `ddc_module_loader.js`.
static Future<Sandbox> createIFrame(
web.Node container, {
required Uri assetBaseUrl,
String headHtml = _defaultHeadHtml,
String bodyHtml = '',
}) async {
if (!assetBaseUrl.path.endsWith('/')) {
assetBaseUrl = assetBaseUrl.replace(path: '${assetBaseUrl.path}/');
}
final iframe = web.HTMLIFrameElement();
// TODO: Consider if we want allow-same-origin, it might needed for
// source maps, but it decidedly breaks the security sandbox!
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
iframe.srcdoc = _iframeHtml(
sandboxJs: assetBaseUrl.resolve('sandbox.js'),
headHtml: headHtml,
bodyHtml: bodyHtml,
).toJS;
container.appendChild(iframe);
return await Future(() async {
await for (final event in web.window.onMessage) {
if (event.source != iframe.contentWindow ||
!event.data.isA<JSObject>()) {
continue;
}
final m = event.data as _SandboxMessage;
switch (m.action) {
case 'connect':
return Sandbox._(
Peer.withoutJson(jsonRpcMessagePortChannel(m.port)),
iframe,
);
case 'error':
throw Exception('Failed to load sandboxed iframe: ${m.message}');
}
}
throw AssertionError('unreachable');
}).timeout(const Duration(seconds: 120));
}
}
extension type _SandboxMessage._(JSObject _) implements JSObject {
external String? get action;
external web.MessagePort get port;
external String get message;
}