dart_eval

repository·master·Indexed 19 days ago

https://github.com/ethanblake4/dart_eval

An extensible bytecode compiler and interpreter for the Dart language that enables dynamic execution and code-push capabilities for Flutter and Dart AOT applications. It provides an eval() shorthand for runtime execution, a Compiler for generating EVC bytecode, and a Runtime for execution. Features include a sandboxed environment with configurable permissions, wrapper and bridge interop for native Dart classes, and a runtime override system for dynamically swapping function implementations.

Tokens
18.2K
Snippets
48
Records
64
Agent score
64%

What's inside dart_eval

  1. Understand return values and boxing

    master

    When executing code, dart_eval returns a $Value wrapper for most types to provide metadata.

    Exceptions: int, double, bool, and List are returned unboxed.

    Tip: If you want consistent behavior where everything is boxed, define your function's return type as dynamic in the Dart source.

    Note: The eval() shorthand method automatically unboxes all return values for you.

  2. Configure entrypoints and tree-shaking

    master

    To optimize compilation size, dart_eval uses tree-shaking. By default, any file named main.dart or containing runtime overrides is treated as an entrypoint and compiled entirely.

    To ensure other specific files are not tree-shaken away, add their URIs to the Compiler.entrypoints list.

    final compiler = Compiler();
    compiler.entrypoints.add('package:my_package/some_file.dart');
    compiler.compile(...);
  3. Pre-compile Dart code to EVC bytecode

    master

    To avoid runtime compilation overhead, pre-compile your Dart code into EVC (eval bytecode) files. This is more efficient than compiling at runtime.

    Workflow:

    1. Use Compiler.compile() to generate a program.
    2. Call program.write() to get the bytecode.
    3. Save the bytecode to a file.
    4. Load the file using Runtime(bytecode) for execution.
    // --- Compiling to a file ---
    import 'dart:io';
    import 'package:dart_eval/dart_eval.dart';
    
    void main() {
      final compiler = Compiler();
      final program = compiler.compile({'my_package': {
        'main.dart': 'int main() => 42;' 
      }});
      
      final bytecode = program.write();
      final file = File('program.evc');
      file.writeAsBytesSync(bytecode);
    }
    
    // --- Loading and executing later ---
    void runLater() {
      final file = File('program.evc');
      final bytecode = file.readAsBytesSync().buffer.asByteData();
      
      final runtime = Runtime(bytecode);
      print(runtime.executeLib('package:my_package/main.dart', 'main')); // prints '42'
    }
  4. Extend native Dart classes in dart_eval using Bridge Interop

    master

    Bridge interop is used when you need to extend a native Dart class or use it as an interface within dart_eval.

    Note: Unlike wrapper interop, bridge interop does not allow you to wrap an existing instance of the class; you must instantiate the bridge class itself.

    1. Annotate your class with @Bind(bridge: true).
    2. Run dart_eval bind to generate bindings and the plugin.
    3. Add the plugin to the Compiler and Runtime.

    To use classes from other packages without cloning them, you can create a subclass and use the @Bind(implicitSupers: true) annotation to create bindings for all inherited methods and properties.

    import 'package:eval_annotation/eval_annotation.dart';
    
    @Bind(bridge: true)
    class Book {
      final List<String> pages;
      Book(this.pages);
      String getPage(int index) => pages[index];
    }
    
    // Usage in dart_eval:
    import 'package:dart_eval/dart_eval.dart';
    import 'package:my_app/book.dart';
    
    final compiler = Compiler();
    compiler.addPlugin(MyAppPlugin());
    final program = compiler.compile({'my_package': {
      'main.dart': '''
        import 'package:my_app/book.dart';
    
        class MyBook extends Book {
          MyBook(super.pages);
    
          @override
          String getPage(int index) {
            return 'MyBook: ${super.getPage(index)}';
          }
        }
    
        MyBook main() {
          final book = MyBook(['Page 1', 'Page 2']);
          return book;
        }
      '''
    }});
    
    final runtime = Runtime.ofProgram(program);
    runtime.addPlugin(MyAppPlugin());
    
    final book = runtime.executeLib('package:my_package/main.dart', 'main') as Book;
    print(book.getPage(0)); // prints 'MyBook: Page 1'
  5. Configure JSON bindings for external packages

    master

    If you need to bind classes from another Dart package without cloning the entire package, you can use JSON binding files.

    1. Create a folder named .dart_eval in your project root.
    2. Add a bindings subfolder inside .dart_eval.
    3. Place your JSON binding files in that subfolder.

    Currently, the binding generator does not support direct creation of JSON bindings. To create them, you must first generate Dart bindings and then use a script to convert them to JSON using a BridgeSerializer.

  6. Pass arguments to `dart_eval`

    master

    When passing arguments to eval() or other execution methods, use $Value wrappers (boxed types) like $String or $Map to provide type information and mutability.

    Exceptions: int, double, bool, and List are treated as primitives and should be passed without wrapping if the function signature specifies these exact types.

    Important Rules:

    • When calling functions or constructors externally, you must specify all arguments in order (including optional and named ones).
    • Use null to indicate the absence of an argument.
    • Use $null() to indicate a literal null value.
    final program = '''
      int main(int count, String str) {
        return count + str.length;
      }
    ''';
    
    // Passing 1 (primitive) and $String (boxed)
    print(eval(program, function: 'main', args: [1, $String('Hi!')])); // -> 4
  7. Enable native Dart classes in dart_eval using Wrapper Interop

    master

    Wrapper interop allows you to use native Dart classes, pass them as arguments, and call their methods within dart_eval.

    1. Annotate your class with @Bind() from the eval_annotation package.
    2. Run dart_eval bind in your project directory to generate bindings and a plugin.
    3. Add the generated plugin to both the Compiler and the Runtime.

    Core Dart types are backed by native values. You can access the backing native value using the $value property of a $Value object. This approach also exposes a $ClassName wrapper class to wrap existing instances for use in dart_eval.

    import 'package:eval_annotation/eval_annotation.dart';
    
    @Bind()
    class Book {
      final List<String> pages;
    
      Book(this.pages);
      String getPage(int index) => pages[index];
    }
    
    // After running `dart_eval bind` and using the generated plugin:
    import 'package:dart_eval/dart_eval.dart';
    
    final compiler = Compiler();
    compiler.addPlugin(MyAppPlugin());
    final program = compiler.compile({'my_package': {
      'main.dart': '''
        import 'package:my_app/book.dart';
        
        Book main() {
          final book = Book(['Page 1', 'Page 2']);
          return book;
        }
      '''
    }});
    
    final runtime = Runtime.ofProgram(program);
    runtime.addPlugin(MyAppPlugin());
    
    final book = runtime.executeLib('package:my_package/main.dart', 'main').$value as Book;
    print(book.getPage(0)); // prints 'Page 1'
  8. Versioned Runtime Overrides

    master

    You can version overrides to roll out updates to functions immediately and revert to native implementations after an official update.

    1. Add a semver version constraint to the @RuntimeOverride annotation in your eval code.
    2. Set the runtimeOverrideVersion global property in your native Dart code to specify the current app version.

    The runtime will only apply the override if the runtimeOverrideVersion matches the version constraint.

    // In eval code:
    @RuntimeOverride('#login_page_get_data', version: '<1.4.0')
    
    // In native Dart code:
    runtimeOverrideVersion = Version.parse('1.3.0');
  9. Implement Runtime Overrides to swap function implementations

    master

    The runtime overrides system allows you to dynamically swap function or constructor implementations at runtime.

    1. Prepare the code for overriding

    In your Dart code, wrap the call site with a null-coalescing call to runtimeOverride() using a unique ID:

    final result = runtimeOverride('#myFunction') ?? myFunction();

    Note: You may need to cast the return value of runtimeOverride as the compiler cannot specify generic parameters to the Dart type system.

    2. Mark the override in eval code

    In the code being evaluated, mark the target function with the @RuntimeOverride annotation:

    @RuntimeOverride('#myFunction')
    String myFunction() => 'Updated version of string';

    3. Load overrides

    Call loadGlobalOverrides() on the Runtime instance. This sets the runtime as the single global runtime and loads overrides for use by hot wrappers.

    void main() {
      // Give the override a unique ID
      final result = runtimeOverride('#myFunction') ?? myFunction();
      print(result);
    }
    
    String myFunction() => 'Original version of string';
    
    // In the eval code:
    @RuntimeOverride('#myFunction')
    String myFunction() => 'Updated version of string';
  10. Manage security and permissions

    master

    The dart_eval runtime is sandboxed. By default, programs cannot access the file system, network, or other system resources. You must explicitly grant permissions using runtime.grant or the permissions parameter in eval().

    Available Permission Domains:

    • FilesystemPermission: Control file access (e.g., FilesystemPermission.any, FilesystemReadPermission.directory(path)).
    • NetworkPermission: Control network access (e.g., NetworkPermission.any, NetworkPermission.url(url)).
    • ProcessPermission: Control process execution (e.g., ProcessRunPermission(RegExp(pattern))).

    Usage:

    • Use runtime.grant(permission) to enable access.
    • Use runtime.revoke(permission) to remove access.
    • Use the @AssertPermission annotation when writing bindings to ensure required permissions are present.
    // Using runtime.grant
    final runtime = Runtime(bytecode);
    runtime.grant(FilesystemPermission.any);
    runtime.grant(NetworkPermission.url('example.com'));
    
    // Using eval() with permissions
    eval(source, permissions: [
      NetworkPermission.any,
      FilesystemReadPermission.directory('/home/user/mydata'), 
      ProcessRunPermission(RegExp(r'^ls$'))
    ]);
  11. Tree-shaking in dart_eval

    master

    The compiler performs tree-shaking to minimize the size of the generated bytecode. It starts from a set of defined entrypoints and recursively identifies used identifiers.

    The Process:

    1. Identify Entrypoints: The process begins with the libraries designated as entrypoints.
    2. Trace Usage: The compiler tracks which identifiers (functions, classes, etc.) are actually used within the entrypoints and their dependencies.
    3. Prune Unused Declarations: Any declaration in a library that is not a 'bridge' (internal compiler mechanism) and is not reachable through the usage graph is removed from the final compilation unit.
    4. Reachability: The compiler uses a worklist-based approach to traverse the dependency graph, ensuring that if a declaration is used, its own dependencies are also marked as used.
  12. Library discovery and reachability

    master

    The compiler discovers which libraries need to be included in the compilation by traversing the graph of imports and exports starting from the provided entrypoints.

    It automatically includes core Dart libraries such as:

    • dart:core
    • dart:async
    • dart:io

    It also filters out libraries belonging to package:eval_annotation to avoid including annotation-only code in the runtime bytecode.