| // 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); |
| } |
| } |