blob: 4ad0a97eea07f18cc302fffef245cebe832382c2 [file] [edit]
// 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:js_interop';
import 'package:json_rpc_2/json_rpc_2.dart';
import 'package:web/web.dart' as web;
import 'util/message_port_channel.dart';
const _defaultHeadHtml = '''
<style>
body { margin: 0; overflow: hidden; background: transparent; }
</style>
''';
String _iframeHtml({
required Uri ddcModuleLoaderJs,
required Uri sdkJs,
required Uri sandboxJs,
required String headHtml,
required String bodyHtml,
}) =>
'''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="$ddcModuleLoaderJs" defer></script>
<script src="$sdkJs" defer></script>
<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();
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));
});
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;
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()),
);
}
/// Disposes of the sandbox and its resources.
void dispose() {
_iframe?.remove();
unawaited(_peer.close());
_consoleController.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`.
/// The [sdkLocation] should point to the directory containing the `sdk.js`
/// (and `sdk.tar` which we won't use here).
static Future<Sandbox> createIFrame(
web.Node container, {
required Uri assetBaseUrl,
required Uri sdkLocation,
String headHtml = _defaultHeadHtml,
String bodyHtml = '',
}) async {
if (!assetBaseUrl.path.endsWith('/')) {
assetBaseUrl = assetBaseUrl.replace(path: '${assetBaseUrl.path}/');
}
sdkLocation = assetBaseUrl.resolveUri(sdkLocation);
if (!sdkLocation.path.endsWith('/')) {
sdkLocation = sdkLocation.replace(path: '${sdkLocation.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'),
ddcModuleLoaderJs: assetBaseUrl.resolve('ddc_module_loader.js'),
sdkJs: sdkLocation.resolve('sdk.js'),
headHtml: headHtml,
bodyHtml: bodyHtml,
).toJS;
container.appendChild(iframe);
try {
// TODO: Consider loading sandbox.js first, then setup error handlers and
// have it proxy out loading errors, and have it setup the ports
// and send out a port with a ready message when everything is
// loaded. That could be a bit a faster! And provide better error
// reporting, if something goes wrong inside the iframe.
await iframe.onLoad.first.timeout(const Duration(seconds: 120));
} on TimeoutException {
throw Exception('Timeout (120s) while loading sandboxed iframe');
}
final web.MessageChannel(:port1, :port2) = web.MessageChannel();
final contentWindow = iframe.contentWindow;
if (contentWindow == null) {
// This should never happen, unless there some obscure security policy
// at play -- but even this is unlikely with srcdoc!
throw AssertionError('iframe.contentWindow is null after "load" event');
}
contentWindow.postMessage(
{'action': 'connect'}.jsify(),
'*'.toJS,
[port2].toJS,
);
return Sandbox._(Peer(messagePortChannel(port1).cast()), iframe);
}
}