[vm/ffi] dartdev CLI native-assets

This CL introduces native assets suport for `dart run` and introduces
`dart build` which is similar to `dart compile` but outputs a folder
instead to that native assets can be bundled with an executable.

Change-Id: Ib6cfb95539f0adee46c99e531e440928c3f72f2b
Cq-Include-Trybots: luci.dart.try:pkg-linux-debug-try,pkg-linux-release-try,pkg-mac-release-arm64-try,pkg-mac-release-try,pkg-win-release-try
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/267340
Reviewed-by: Martin Kustermann <kustermann@google.com>
Reviewed-by: Ben Konyi <bkonyi@google.com>
Commit-Queue: Daco Harkes <dacoharkes@google.com>
diff --git a/pkg/_fe_analyzer_shared/lib/src/experiments/flags.dart b/pkg/_fe_analyzer_shared/lib/src/experiments/flags.dart
index 13e5da4..5bdc64c 100644
--- a/pkg/_fe_analyzer_shared/lib/src/experiments/flags.dart
+++ b/pkg/_fe_analyzer_shared/lib/src/experiments/flags.dart
@@ -107,6 +107,13 @@
       experimentEnabledVersion: const Version(2, 17),
       experimentReleasedVersion: const Version(2, 17)),
 
+  nativeAssets(
+      name: 'native-assets',
+      isEnabledByDefault: false,
+      isExpired: false,
+      experimentEnabledVersion: const Version(3, 1),
+      experimentReleasedVersion: const Version(3, 1)),
+
   nonNullable(
       name: 'non-nullable',
       isEnabledByDefault: true,
diff --git a/pkg/analyzer/lib/src/dart/analysis/experiments.g.dart b/pkg/analyzer/lib/src/dart/analysis/experiments.g.dart
index dc6c8064..9ca8dc6 100644
--- a/pkg/analyzer/lib/src/dart/analysis/experiments.g.dart
+++ b/pkg/analyzer/lib/src/dart/analysis/experiments.g.dart
@@ -32,6 +32,7 @@
   EnableString.macros: ExperimentalFeatures.macros,
   EnableString.named_arguments_anywhere:
       ExperimentalFeatures.named_arguments_anywhere,
+  EnableString.native_assets: ExperimentalFeatures.native_assets,
   EnableString.non_nullable: ExperimentalFeatures.non_nullable,
   EnableString.nonfunction_type_aliases:
       ExperimentalFeatures.nonfunction_type_aliases,
@@ -93,6 +94,9 @@
   /// String to enable the experiment "named-arguments-anywhere"
   static const String named_arguments_anywhere = 'named-arguments-anywhere';
 
+  /// String to enable the experiment "native-assets"
+  static const String native_assets = 'native-assets';
+
   /// String to enable the experiment "non-nullable"
   static const String non_nullable = 'non-nullable';
 
@@ -278,8 +282,18 @@
     releaseVersion: Version.parse('2.17.0'),
   );
 
-  static final non_nullable = ExperimentalFeature(
+  static final native_assets = ExperimentalFeature(
     index: 14,
+    enableString: EnableString.native_assets,
+    isEnabledByDefault: IsEnabledByDefault.native_assets,
+    isExpired: IsExpired.native_assets,
+    documentation: 'Compile and bundle native assets.',
+    experimentalReleaseVersion: null,
+    releaseVersion: null,
+  );
+
+  static final non_nullable = ExperimentalFeature(
+    index: 15,
     enableString: EnableString.non_nullable,
     isEnabledByDefault: IsEnabledByDefault.non_nullable,
     isExpired: IsExpired.non_nullable,
@@ -289,7 +303,7 @@
   );
 
   static final nonfunction_type_aliases = ExperimentalFeature(
-    index: 15,
+    index: 16,
     enableString: EnableString.nonfunction_type_aliases,
     isEnabledByDefault: IsEnabledByDefault.nonfunction_type_aliases,
     isExpired: IsExpired.nonfunction_type_aliases,
@@ -299,7 +313,7 @@
   );
 
   static final patterns = ExperimentalFeature(
-    index: 16,
+    index: 17,
     enableString: EnableString.patterns,
     isEnabledByDefault: IsEnabledByDefault.patterns,
     isExpired: IsExpired.patterns,
@@ -309,7 +323,7 @@
   );
 
   static final records = ExperimentalFeature(
-    index: 17,
+    index: 18,
     enableString: EnableString.records,
     isEnabledByDefault: IsEnabledByDefault.records,
     isExpired: IsExpired.records,
@@ -319,7 +333,7 @@
   );
 
   static final sealed_class = ExperimentalFeature(
-    index: 18,
+    index: 19,
     enableString: EnableString.sealed_class,
     isEnabledByDefault: IsEnabledByDefault.sealed_class,
     isExpired: IsExpired.sealed_class,
@@ -329,7 +343,7 @@
   );
 
   static final set_literals = ExperimentalFeature(
-    index: 19,
+    index: 20,
     enableString: EnableString.set_literals,
     isEnabledByDefault: IsEnabledByDefault.set_literals,
     isExpired: IsExpired.set_literals,
@@ -339,7 +353,7 @@
   );
 
   static final spread_collections = ExperimentalFeature(
-    index: 20,
+    index: 21,
     enableString: EnableString.spread_collections,
     isEnabledByDefault: IsEnabledByDefault.spread_collections,
     isExpired: IsExpired.spread_collections,
@@ -349,7 +363,7 @@
   );
 
   static final super_parameters = ExperimentalFeature(
-    index: 21,
+    index: 22,
     enableString: EnableString.super_parameters,
     isEnabledByDefault: IsEnabledByDefault.super_parameters,
     isExpired: IsExpired.super_parameters,
@@ -359,7 +373,7 @@
   );
 
   static final test_experiment = ExperimentalFeature(
-    index: 22,
+    index: 23,
     enableString: EnableString.test_experiment,
     isEnabledByDefault: IsEnabledByDefault.test_experiment,
     isExpired: IsExpired.test_experiment,
@@ -370,7 +384,7 @@
   );
 
   static final triple_shift = ExperimentalFeature(
-    index: 23,
+    index: 24,
     enableString: EnableString.triple_shift,
     isEnabledByDefault: IsEnabledByDefault.triple_shift,
     isExpired: IsExpired.triple_shift,
@@ -380,7 +394,7 @@
   );
 
   static final unnamed_libraries = ExperimentalFeature(
-    index: 24,
+    index: 25,
     enableString: EnableString.unnamed_libraries,
     isEnabledByDefault: IsEnabledByDefault.unnamed_libraries,
     isExpired: IsExpired.unnamed_libraries,
@@ -390,7 +404,7 @@
   );
 
   static final value_class = ExperimentalFeature(
-    index: 25,
+    index: 26,
     enableString: EnableString.value_class,
     isEnabledByDefault: IsEnabledByDefault.value_class,
     isExpired: IsExpired.value_class,
@@ -400,7 +414,7 @@
   );
 
   static final variance = ExperimentalFeature(
-    index: 26,
+    index: 27,
     enableString: EnableString.variance,
     isEnabledByDefault: IsEnabledByDefault.variance,
     isExpired: IsExpired.variance,
@@ -455,6 +469,9 @@
   /// Default state of the experiment "named-arguments-anywhere"
   static const bool named_arguments_anywhere = true;
 
+  /// Default state of the experiment "native-assets"
+  static const bool native_assets = false;
+
   /// Default state of the experiment "non-nullable"
   static const bool non_nullable = true;
 
@@ -541,6 +558,9 @@
   /// Expiration status of the experiment "named-arguments-anywhere"
   static const bool named_arguments_anywhere = true;
 
+  /// Expiration status of the experiment "native-assets"
+  static const bool native_assets = false;
+
   /// Expiration status of the experiment "non-nullable"
   static const bool non_nullable = true;
 
@@ -631,6 +651,9 @@
   bool get named_arguments_anywhere =>
       isEnabled(ExperimentalFeatures.named_arguments_anywhere);
 
+  /// Current state for the flag "native-assets"
+  bool get native_assets => isEnabled(ExperimentalFeatures.native_assets);
+
   /// Current state for the flag "non-nullable"
   bool get non_nullable => isEnabled(ExperimentalFeatures.non_nullable);
 
diff --git a/pkg/dart2native/lib/dart2native.dart b/pkg/dart2native/lib/dart2native.dart
index 75b0859..17f110d 100644
--- a/pkg/dart2native/lib/dart2native.dart
+++ b/pkg/dart2native/lib/dart2native.dart
@@ -50,16 +50,18 @@
 }
 
 Future<ProcessResult> generateAotKernel(
-    String dart,
-    String genKernel,
-    String platformDill,
-    String sourceFile,
-    String kernelFile,
-    String? packages,
-    List<String> defines,
-    {String enableExperiment = '',
-    String? targetOS,
-    List<String> extraGenKernelOptions = const []}) {
+  String dart,
+  String genKernel,
+  String platformDill,
+  String sourceFile,
+  String kernelFile,
+  String? packages,
+  List<String> defines, {
+  String enableExperiment = '',
+  String? targetOS,
+  List<String> extraGenKernelOptions = const [],
+  String? nativeAssets,
+}) {
   return Process.run(dart, [
     genKernel,
     '--platform',
@@ -73,6 +75,7 @@
     '-o',
     kernelFile,
     ...extraGenKernelOptions,
+    if (nativeAssets != null) ...['--native-assets', nativeAssets],
     sourceFile
   ]);
 }
diff --git a/pkg/dart2native/lib/generate.dart b/pkg/dart2native/lib/generate.dart
index 0c6a9e0..d354237 100644
--- a/pkg/dart2native/lib/generate.dart
+++ b/pkg/dart2native/lib/generate.dart
@@ -33,6 +33,7 @@
   bool verbose = false,
   String verbosity = 'all',
   List<String> extraOptions = const [],
+  String? nativeAssets,
 }) async {
   final Directory tempDir = Directory.systemTemp.createTempSync();
   try {
@@ -62,15 +63,23 @@
     }
 
     final String kernelFile = path.join(tempDir.path, 'kernel.dill');
-    final kernelResult = await generateAotKernel(Platform.executable, genKernel,
-        productPlatformDill, sourcePath, kernelFile, packages, defines,
-        enableExperiment: enableExperiment,
-        targetOS: targetOS,
-        extraGenKernelOptions: [
-          '--invocation-modes=compile',
-          '--verbosity=$verbosity',
-          '--${soundNullSafety ? '' : 'no-'}sound-null-safety',
-        ]);
+    final kernelResult = await generateAotKernel(
+      Platform.executable,
+      genKernel,
+      productPlatformDill,
+      sourcePath,
+      kernelFile,
+      packages,
+      defines,
+      enableExperiment: enableExperiment,
+      targetOS: targetOS,
+      extraGenKernelOptions: [
+        '--invocation-modes=compile',
+        '--verbosity=$verbosity',
+        '--${soundNullSafety ? '' : 'no-'}sound-null-safety',
+      ],
+      nativeAssets: nativeAssets,
+    );
     await _forwardOutput(kernelResult);
     if (kernelResult.exitCode != 0) {
       throw 'Generating AOT kernel dill failed!';
diff --git a/pkg/dartdev/lib/dartdev.dart b/pkg/dartdev/lib/dartdev.dart
index dba71b9..59ddfc5 100644
--- a/pkg/dartdev/lib/dartdev.dart
+++ b/pkg/dartdev/lib/dartdev.dart
@@ -17,6 +17,7 @@
 
 import 'src/analytics.dart';
 import 'src/commands/analyze.dart';
+import 'src/commands/build.dart';
 import 'src/commands/compilation_server.dart';
 import 'src/commands/compile.dart';
 import 'src/commands/create.dart';
@@ -54,7 +55,7 @@
     }
 
     // Finally, call the runner to execute the command; see DartdevRunner.
-    final runner = DartdevRunner(args);
+    final runner = DartdevRunner(args, io.Platform.executableArguments);
     exitCode = await runner.run(args);
   } on UsageException catch (e) {
     // TODO(sigurdm): It is unclear when a UsageException gets to here, and
@@ -86,15 +87,23 @@
 
   final bool verbose;
 
+  final List<String> vmEnabledExperiments;
+
   late Analytics _analytics;
 
-  DartdevRunner(List<String> args)
+  DartdevRunner(List<String> args, [List<String> vmArgs = const []])
       : verbose = args.contains('-v') || args.contains('--verbose'),
         argParser = globalDartdevOptionsParser(
             verbose: args.contains('-v') || args.contains('--verbose')),
+        vmEnabledExperiments = parseVmEnabledExperiments(vmArgs),
         super('dart', '$dartdevDescription.') {
     addCommand(AnalyzeCommand(verbose: verbose));
     addCommand(CompilationServerCommand(verbose: verbose));
+    final nativeAssetsExperimentEnabled =
+        nativeAssetsEnabled(vmEnabledExperiments);
+    if (nativeAssetsExperimentEnabled) {
+      addCommand(BuildCommand(verbose: verbose));
+    }
     addCommand(CompileCommand(verbose: verbose));
     addCommand(CreateCommand(verbose: verbose));
     addCommand(DebugAdapterCommand(verbose: verbose));
@@ -113,8 +122,13 @@
         isVerbose: () => verbose,
       ),
     );
-    addCommand(RunCommand(verbose: verbose));
-    addCommand(TestCommand());
+    addCommand(RunCommand(
+      verbose: verbose,
+      nativeAssetsExperimentEnabled: nativeAssetsExperimentEnabled,
+    ));
+    addCommand(TestCommand(
+      nativeAssetsExperimentEnabled: nativeAssetsExperimentEnabled,
+    ));
   }
 
   @visibleForTesting
diff --git a/pkg/dartdev/lib/src/commands/build.dart b/pkg/dartdev/lib/src/commands/build.dart
new file mode 100644
index 0000000..798dce3
--- /dev/null
+++ b/pkg/dartdev/lib/src/commands/build.dart
@@ -0,0 +1,207 @@
+// Copyright (c) 2023, 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:io';
+
+import 'package:dart2native/generate.dart';
+import 'package:dartdev/src/commands/compile.dart';
+import 'package:dartdev/src/experiments.dart';
+import 'package:dartdev/src/sdk.dart';
+import 'package:front_end/src/api_prototype/compiler_options.dart'
+    show Verbosity;
+import 'package:native_assets_builder/native_assets_builder.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:path/path.dart' as path;
+
+import '../core.dart';
+import '../native_assets.dart';
+
+class BuildCommand extends DartdevCommand {
+  static const String cmdName = 'build';
+  static const String outputOptionName = 'output';
+  static const String formatOptionName = 'format';
+
+  BuildCommand({bool verbose = false})
+      : super(cmdName, 'Build a Dart application including native assets.',
+            verbose) {
+    argParser
+      ..addOption(
+        outputOptionName,
+        abbr: 'o',
+        help: '''
+          Write the output to <folder name>.
+          This can be an absolute or relative path.
+          ''',
+      )
+      ..addOption(
+        formatOptionName,
+        abbr: 'f',
+        allowed: ['exe', 'aot'],
+        defaultsTo: 'exe',
+      )
+      ..addOption(
+        'verbosity',
+        help: 'Sets the verbosity level of the compilation.',
+        defaultsTo: Verbosity.defaultValue,
+        allowed: Verbosity.allowedValues,
+        allowedHelp: Verbosity.allowedValuesHelp,
+      )
+      ..addExperimentalFlags(verbose: verbose);
+  }
+
+  @override
+  String get invocation => '${super.invocation} <dart entry point>';
+
+  @override
+  Future<int> run() async {
+    if (!Sdk.checkArtifactExists(genKernel) ||
+        !Sdk.checkArtifactExists(genSnapshot) ||
+        !Sdk.checkArtifactExists(sdk.dart)) {
+      return 255;
+    }
+    // AOT compilation isn't supported on ia32. Currently, generating an
+    // executable only supports AOT runtimes, so these commands are disabled.
+    if (Platform.version.contains('ia32')) {
+      stderr.write("'dart build' is not supported on x86 architectures");
+      return 64;
+    }
+    final args = argResults!;
+
+    // We expect a single rest argument; the dart entry point.
+    if (args.rest.length != 1) {
+      // This throws.
+      usageException('Missing Dart entry point.');
+    }
+
+    // TODO(https://dartbug.com/52458): Support `dart build <pkg>:<bin-script>`.
+    // Similar to Dart run. Possibly also in `dart compile`.
+    final sourceUri = Uri(path: args.rest[0].normalizeCanonicalizePath());
+    if (!checkFile(sourceUri.toFilePath())) {
+      return -1;
+    }
+
+    final outputUri = Uri.directory(
+      (args[outputOptionName] as String?)
+              ?.normalizeCanonicalizePath()
+              .makeFolder() ??
+          sourceUri.toFilePath().removeDotDart().makeFolder(),
+    );
+
+    final format = args[formatOptionName] as String;
+    final outputExeUri = outputUri
+        .resolve('${sourceUri.pathSegments.last.split('.').first}.$format');
+
+    final outputDir = Directory.fromUri(outputUri);
+    if (await outputDir.exists()) {
+      stdout.writeln('Deleting output directory: ${outputUri.toFilePath()}.');
+      await outputDir.delete(recursive: true);
+    }
+    await outputDir.create(recursive: true);
+
+    stdout.writeln('Building native assets.');
+    final workingDirectory = Directory.current.uri;
+    final target = Target.current;
+    final nativeAssets = await NativeAssetsBuildRunner(
+      dartExecutable: Uri.file(sdk.dart),
+      logger: logger,
+    ).build(
+      workingDirectory: workingDirectory,
+      target: target,
+      linkModePreference: LinkModePreference.dynamic,
+      includeParentEnvironment: true,
+    );
+    final staticAssets = nativeAssets.whereLinkMode(LinkMode.static);
+    if (staticAssets.isNotEmpty) {
+      stderr.write(
+          """'dart build' does not yet support native artifacts packaged as ${LinkMode.static.name}.
+Use linkMode as dynamic library instead.""");
+      return 255;
+    }
+
+    Uri? tempUri;
+    Uri? nativeAssetsDartUri;
+    if (nativeAssets.isNotEmpty) {
+      stdout.writeln('Copying native assets.');
+      Asset targetLocation(Asset asset) {
+        final path = asset.path;
+        switch (path.runtimeType) {
+          case AssetSystemPath:
+          case AssetInExecutable:
+          case AssetInProcess:
+            return asset;
+          case AssetAbsolutePath:
+            return asset.copyWith(
+              path: AssetRelativePath(
+                Uri(
+                  path: (path as AssetAbsolutePath).uri.pathSegments.last,
+                ),
+              ),
+            );
+        }
+        throw 'Unsupported asset path type ${path.runtimeType} in asset $asset';
+      }
+
+      final assetTargetLocations = {
+        for (final asset in nativeAssets) asset: targetLocation(asset),
+      };
+      await Future.wait([
+        for (final assetMapping in assetTargetLocations.entries)
+          if (assetMapping.key != assetMapping.value)
+            File.fromUri((assetMapping.key.path as AssetAbsolutePath).uri).copy(
+                outputUri
+                    .resolveUri(
+                        (assetMapping.value.path as AssetRelativePath).uri)
+                    .toFilePath())
+      ]);
+
+      tempUri = (await Directory.systemTemp.createTemp()).uri;
+      nativeAssetsDartUri = tempUri.resolve('native_assets.yaml');
+      final assetsContent =
+          assetTargetLocations.values.toList().toNativeAssetsFile();
+      await Directory.fromUri(nativeAssetsDartUri.resolve('.')).create();
+      await File(nativeAssetsDartUri.toFilePath()).writeAsString(assetsContent);
+    }
+    final packageConfig = await packageConfigUri(sourceUri);
+
+    await generateNative(
+      kind: format,
+      sourceFile: sourceUri.toFilePath(),
+      outputFile: outputExeUri.toFilePath(),
+      verbose: verbose,
+      verbosity: args['verbosity'],
+      defines: [],
+      nativeAssets: nativeAssetsDartUri?.toFilePath(),
+      packages: packageConfig?.toFilePath(),
+    );
+
+    if (tempUri != null) {
+      await Directory.fromUri(tempUri).delete(recursive: true);
+    }
+
+    return 0;
+  }
+}
+
+extension on String {
+  String normalizeCanonicalizePath() => path.canonicalize(path.normalize(this));
+  String makeFolder() => endsWith('\\') || endsWith('/') ? this : '$this/';
+  String removeDotDart() => replaceFirst(RegExp(r'\.dart$'), '');
+}
+
+// TODO(https://github.com/dart-lang/package_config/issues/126): Expose this
+// logic in package:package_config.
+Future<Uri?> packageConfigUri(Uri uri) async {
+  while (true) {
+    final candidate = uri.resolve('.dart_tool/package_config.json');
+    if (await File.fromUri(candidate).exists()) {
+      return candidate;
+    }
+    final parent = uri.resolve('..');
+    if (parent == uri) {
+      return null;
+    }
+    uri = parent;
+  }
+}
diff --git a/pkg/dartdev/lib/src/commands/compile.dart b/pkg/dartdev/lib/src/commands/compile.dart
index 691f924..f532d61 100644
--- a/pkg/dartdev/lib/src/commands/compile.dart
+++ b/pkg/dartdev/lib/src/commands/compile.dart
@@ -14,6 +14,7 @@
 
 import '../core.dart';
 import '../experiments.dart';
+import '../native_assets.dart';
 import '../sdk.dart';
 import '../utils.dart';
 import '../vm_interop_handler.dart';
@@ -236,12 +237,14 @@
   final String commandName;
   final String format;
   final String help;
+  final bool nativeAssetsExperimentEnabled;
 
   CompileNativeCommand({
     required this.commandName,
     required this.format,
     required this.help,
     bool verbose = false,
+    this.nativeAssetsExperimentEnabled = false,
   }) : super(commandName, 'Compile Dart $help', verbose) {
     argParser
       ..addOption(
@@ -326,6 +329,15 @@
       return compileErrorExitCode;
     }
 
+    if (nativeAssetsExperimentEnabled) {
+      final assets = await compileNativeAssetsJit();
+      if (assets?.isNotEmpty ?? false) {
+        stderr.writeln(
+            "'dart compile' does currently not support native assets.");
+        return 255;
+      }
+    }
+
     try {
       await generateNative(
         kind: format,
@@ -422,8 +434,10 @@
 
 class CompileCommand extends DartdevCommand {
   static const String cmdName = 'compile';
-  CompileCommand({bool verbose = false})
-      : super(cmdName, 'Compile Dart to various formats.', verbose) {
+  CompileCommand({
+    bool verbose = false,
+    bool nativeAssetsExperimentEnabled = false,
+  }) : super(cmdName, 'Compile Dart to various formats.', verbose) {
     addSubcommand(CompileJSCommand(verbose: verbose));
     addSubcommand(CompileSnapshotCommand(
       commandName: CompileSnapshotCommand.jitSnapshotCmdName,
@@ -446,6 +460,7 @@
       help: 'to a self-contained executable.',
       format: 'exe',
       verbose: verbose,
+      nativeAssetsExperimentEnabled: nativeAssetsExperimentEnabled,
     ));
     addSubcommand(CompileNativeCommand(
       commandName: CompileNativeCommand.aotSnapshotCmdName,
@@ -453,6 +468,7 @@
           'To run the snapshot use: dartaotruntime <AOT snapshot file>',
       format: 'aot',
       verbose: verbose,
+      nativeAssetsExperimentEnabled: nativeAssetsExperimentEnabled,
     ));
   }
 }
diff --git a/pkg/dartdev/lib/src/commands/run.dart b/pkg/dartdev/lib/src/commands/run.dart
index 21d7de4..c0f53b8 100644
--- a/pkg/dartdev/lib/src/commands/run.dart
+++ b/pkg/dartdev/lib/src/commands/run.dart
@@ -16,6 +16,7 @@
 import '../core.dart';
 import '../experiments.dart';
 import '../generate_kernel.dart';
+import '../native_assets.dart';
 import '../resident_frontend_constants.dart';
 import '../resident_frontend_utils.dart';
 import '../sdk.dart';
@@ -42,8 +43,12 @@
     );
   }
 
-  RunCommand({bool verbose = false})
-      : super(
+  final bool nativeAssetsExperimentEnabled;
+
+  RunCommand({
+    bool verbose = false,
+    this.nativeAssetsExperimentEnabled = false,
+  }) : super(
           cmdName,
           'Run a Dart program.',
           verbose,
@@ -295,6 +300,18 @@
       }
     }
 
+    String? nativeAssets;
+    if (nativeAssetsExperimentEnabled) {
+      try {
+        nativeAssets = (await compileNativeAssetsJitYamlFile())?.toFilePath();
+      } on Exception catch (e, stacktrace) {
+        log.stderr('Error: Compiling native assets failed.');
+        log.stderr(e.toString());
+        log.stderr(stacktrace.toString());
+        return errorExitCode;
+      }
+    }
+
     final hasServerInfoOption = args.wasParsed(serverInfoOption);
     final useResidentServer =
         args.wasParsed(residentOption) || hasServerInfoOption;
@@ -304,6 +321,7 @@
       executable = await getExecutableForCommand(
         mainCommand,
         allowSnapshot: !(useResidentServer || hasExperiments),
+        nativeAssets: nativeAssets,
       );
     } on CommandResolutionFailedException catch (e) {
       log.stderr(e.message);
diff --git a/pkg/dartdev/lib/src/commands/test.dart b/pkg/dartdev/lib/src/commands/test.dart
index 0d594fc..d75007d 100644
--- a/pkg/dartdev/lib/src/commands/test.dart
+++ b/pkg/dartdev/lib/src/commands/test.dart
@@ -5,9 +5,11 @@
 import 'dart:async';
 
 import 'package:args/args.dart';
+import 'package:dartdev/src/experiments.dart';
 import 'package:pub/pub.dart';
 
 import '../core.dart';
+import '../native_assets.dart';
 import '../vm_interop_handler.dart';
 
 /// Implement `dart test`.
@@ -16,7 +18,10 @@
 class TestCommand extends DartdevCommand {
   static const String cmdName = 'test';
 
-  TestCommand() : super(cmdName, 'Run tests for a project.', false);
+  final bool nativeAssetsExperimentEnabled;
+
+  TestCommand({this.nativeAssetsExperimentEnabled = false})
+      : super(cmdName, 'Run tests for a project.', false);
 
   // This argument parser is here solely to ensure that VM specific flags are
   // provided before any command and to provide a more consistent help message
@@ -39,10 +44,27 @@
   @override
   FutureOr<int> run() async {
     final args = argResults!;
+
+    String? nativeAssets;
+    if (nativeAssetsExperimentEnabled) {
+      try {
+        nativeAssets = (await compileNativeAssetsJitYamlFile())?.toFilePath();
+      } on Exception catch (e, stacktrace) {
+        log.stderr('Error: Compiling native assets failed.');
+        log.stderr(e.toString());
+        log.stderr(stacktrace.toString());
+        return DartdevCommand.errorExitCode;
+      }
+    }
+
     try {
-      final testExecutable = await getExecutableForCommand('test:test');
-      log.trace('dart $testExecutable ${args.rest.join(' ')}');
-      VmInteropHandler.run(testExecutable.executable, args.rest,
+      final testExecutable = await getExecutableForCommand('test:test',
+          nativeAssets: nativeAssets);
+      final argsRestNoExperiment = args.rest
+          .where((e) => !e.startsWith('--$experimentFlagName='))
+          .toList();
+      log.trace('dart $testExecutable ${argsRestNoExperiment.join(' ')}');
+      VmInteropHandler.run(testExecutable.executable, argsRestNoExperiment,
           packageConfigOverride: testExecutable.packageConfig!);
       return 0;
     } on CommandResolutionFailedException catch (e) {
diff --git a/pkg/dartdev/lib/src/experiments.dart b/pkg/dartdev/lib/src/experiments.dart
index 5a49e07..b31c2af 100644
--- a/pkg/dartdev/lib/src/experiments.dart
+++ b/pkg/dartdev/lib/src/experiments.dart
@@ -76,3 +76,25 @@
     return enabledExperiments;
   }
 }
+
+List<String> parseVmEnabledExperiments(List<String> vmArgs) {
+  var experiments = <String>[];
+  var itr = vmArgs.iterator;
+  while (itr.moveNext()) {
+    var arg = itr.current;
+    if (arg == '--$experimentFlagName') {
+      if (!itr.moveNext()) break;
+      experiments.add(itr.current);
+    } else if (arg.startsWith('--$experimentFlagName=')) {
+      var parts = arg.split('=');
+      if (parts.length == 2) {
+        experiments.addAll(parts[1].split(','));
+      }
+    }
+  }
+  return experiments;
+}
+
+bool nativeAssetsEnabled(List<String> vmEnabledExperiments) =>
+    vmEnabledExperiments
+        .contains(ExperimentalFeatures.native_assets.enableString);
diff --git a/pkg/dartdev/lib/src/native_assets.dart b/pkg/dartdev/lib/src/native_assets.dart
new file mode 100644
index 0000000..1a3d874
--- /dev/null
+++ b/pkg/dartdev/lib/src/native_assets.dart
@@ -0,0 +1,72 @@
+// Copyright (c) 2023, 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:io';
+
+import 'package:dartdev/src/sdk.dart';
+import 'package:logging/logging.dart';
+import 'package:native_assets_builder/native_assets_builder.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+
+import 'core.dart';
+
+final logger = Logger('')
+  ..onRecord.listen((LogRecord record) {
+    final levelValue = record.level.value;
+    if (levelValue >= Level.SEVERE.value) {
+      log.stderr(record.message);
+    } else if (levelValue >= Level.INFO.value) {
+      log.stdout(record.message);
+    } else {
+      log.trace(record.message);
+    }
+  });
+
+/// Compiles all native assets for host OS in JIT mode.
+///
+/// Returns `null` on missing package_config.json, failing gracefully.
+Future<List<Asset>?> compileNativeAssetsJit() async {
+  final workingDirectory = Directory.current.uri;
+  // TODO(https://github.com/dart-lang/package_config/issues/126): Use
+  // package config resolution from package:package_config.
+  if (!await File.fromUri(
+          workingDirectory.resolve('.dart_tool/package_config.json'))
+      .exists()) {
+    return null;
+  }
+  final assets = await NativeAssetsBuildRunner(
+    // This always runs in JIT mode.
+    dartExecutable: Uri.file(sdk.dart),
+    logger: logger,
+  ).build(
+    workingDirectory: workingDirectory,
+    // When running in JIT mode, only the host OS needs to be build.
+    target: Target.current,
+    // When running in JIT mode, only dynamic libraries are supported.
+    linkModePreference: LinkModePreference.dynamic,
+    includeParentEnvironment: true,
+  );
+  return assets;
+}
+
+/// Compiles all native assets for host OS in JIT mode, and creates the
+/// native assets yaml file.
+///
+/// Used in `dart run` and `dart test`.
+///
+/// Returns `null` on missing package_config.json, failing gracefully.
+Future<Uri?> compileNativeAssetsJitYamlFile() async {
+  final assets = await compileNativeAssetsJit();
+  if (assets == null) return null;
+
+  final workingDirectory = Directory.current.uri;
+  final assetsUri = workingDirectory.resolve('.dart_tool/native_assets.yaml');
+  final nativeAssetsYaml = '''# Native assets mapping for host OS in JIT mode.
+# Generated by dartdev and package:native.
+${assets.toNativeAssetsFile()}''';
+  final assetFile = File(assetsUri.toFilePath());
+  await assetFile.writeAsString(nativeAssetsYaml);
+  return assetsUri;
+}
diff --git a/pkg/dartdev/lib/src/vm_interop_handler.dart b/pkg/dartdev/lib/src/vm_interop_handler.dart
index 388d1bd..ab6f189 100644
--- a/pkg/dartdev/lib/src/vm_interop_handler.dart
+++ b/pkg/dartdev/lib/src/vm_interop_handler.dart
@@ -5,6 +5,8 @@
 import 'dart:isolate';
 
 /// Contains methods used to communicate DartDev results back to the VM.
+///
+/// Messages are received in runtime/bin/dartdev_isolate.cc.
 abstract class VmInteropHandler {
   /// Initializes [VmInteropHandler] to utilize [port] to communicate with the
   /// VM.
diff --git a/pkg/dartdev/pubspec.yaml b/pkg/dartdev/pubspec.yaml
index 0b5636b..25f5eeb 100644
--- a/pkg/dartdev/pubspec.yaml
+++ b/pkg/dartdev/pubspec.yaml
@@ -19,7 +19,10 @@
   dartdoc: any
   dds: any
   front_end: any
+  logging: any
   meta: any
+  native_assets_builder: any
+  native_assets_cli: any
   package_config: any
   path: any
   pub: any
diff --git a/pkg/dartdev/test/native_assets/build_test.dart b/pkg/dartdev/test/native_assets/build_test.dart
new file mode 100644
index 0000000..dc8ce07
--- /dev/null
+++ b/pkg/dartdev/test/native_assets/build_test.dart
@@ -0,0 +1,42 @@
+// Copyright (c) 2023, 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.
+
+// @dart=2.18
+
+import 'dart:io';
+
+import 'package:test/test.dart';
+
+import '../utils.dart';
+import 'helpers.dart';
+
+void main(List<String> args) async {
+  final bool fromDartdevSource = args.contains('--source');
+
+  test('dart build', timeout: longTimeout, () async {
+    await nativeAssetsTest('dart_app', (dartAppUri) async {
+      await runDart(
+        arguments: [
+          '--enable-experiment=native-assets',
+          if (fromDartdevSource)
+            Platform.script.resolve('../../bin/dartdev.dart').toFilePath(),
+          'build',
+          'bin/dart_app.dart',
+        ],
+        workingDirectory: dartAppUri,
+        logger: logger,
+      );
+
+      final exeUri = dartAppUri.resolve('bin/dart_app/dart_app.exe');
+      expect(await File.fromUri(exeUri).exists(), true);
+      final result = await runProcess(
+        executable: exeUri,
+        arguments: [],
+        workingDirectory: dartAppUri,
+        logger: logger,
+      );
+      expectDartAppStdout(result.stdout);
+    });
+  });
+}
diff --git a/pkg/dartdev/test/native_assets/helpers.dart b/pkg/dartdev/test/native_assets/helpers.dart
new file mode 100644
index 0000000..36636e0
--- /dev/null
+++ b/pkg/dartdev/test/native_assets/helpers.dart
@@ -0,0 +1,179 @@
+// Copyright (c) 2023, 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:io';
+
+import 'package:logging/logging.dart';
+import 'package:native_assets_builder/src/utils/run_process.dart'
+    as run_process;
+import 'package:test/test.dart';
+import 'package:yaml/yaml.dart';
+
+extension UriExtension on Uri {
+  Uri get parent {
+    return File(toFilePath()).parent.uri;
+  }
+}
+
+const keepTempKey = 'KEEP_TEMPORARY_DIRECTORIES';
+
+Future<void> inTempDir(Future<void> Function(Uri tempUri) fun) async {
+  final tempDir = await Directory.systemTemp.createTemp();
+  try {
+    await fun(tempDir.uri);
+  } finally {
+    if (!Platform.environment.containsKey(keepTempKey) ||
+        Platform.environment[keepTempKey]!.isEmpty) {
+      await tempDir.delete(recursive: true);
+    }
+  }
+}
+
+/// Runs a [Process].
+///
+/// If [logger] is provided, stream stdout and stderr to it.
+///
+/// If [captureOutput], captures stdout and stderr.
+Future<run_process.RunProcessResult> runProcess({
+  required Uri executable,
+  List<String> arguments = const [],
+  Uri? workingDirectory,
+  Map<String, String>? environment,
+  bool includeParentEnvironment = true,
+  required Logger? logger,
+  bool captureOutput = true,
+  int expectedExitCode = 0,
+  bool throwOnUnexpectedExitCode = false,
+}) =>
+    run_process.runProcess(
+      executable: executable,
+      arguments: arguments,
+      workingDirectory: workingDirectory,
+      environment: environment,
+      includeParentEnvironment: includeParentEnvironment,
+      logger: logger,
+      captureOutput: captureOutput,
+      expectedExitCode: expectedExitCode,
+      throwOnUnexpectedExitCode: throwOnUnexpectedExitCode,
+    );
+
+Future<void> copyTestProjects(Uri copyTargetUri, Logger logger) async {
+  final pkgNativeAssetsBuilderUri =
+      Platform.script.resolve('../../../native_assets_builder/');
+  // Reuse the test projects from `pkg:native`.
+  final testProjectsUri =
+      pkgNativeAssetsBuilderUri.resolve('test/test_projects/');
+  final manifestUri = testProjectsUri.resolve('manifest.yaml');
+  final manifestFile = File.fromUri(manifestUri);
+  final manifestString = await manifestFile.readAsString();
+  final manifestYaml = loadYamlDocument(manifestString);
+  final manifest = [
+    for (final path in manifestYaml.contents as YamlList) Uri(path: path)
+  ];
+  final filesToCopy =
+      manifest.where((e) => e.pathSegments.last != 'pubspec.yaml').toList();
+  final filesToModify =
+      manifest.where((e) => e.pathSegments.last == 'pubspec.yaml').toList();
+
+  for (final pathToCopy in filesToCopy) {
+    final sourceFile = File.fromUri(testProjectsUri.resolveUri(pathToCopy));
+    final targetUri = copyTargetUri.resolveUri(pathToCopy);
+    final targetDirUri = targetUri.parent;
+    final targetDir = Directory.fromUri(targetDirUri);
+    if (!(await targetDir.exists())) {
+      await targetDir.create(recursive: true);
+    }
+    await sourceFile.copy(targetUri.toFilePath());
+  }
+  for (final pathToModify in filesToModify) {
+    final sourceFile = File.fromUri(testProjectsUri.resolveUri(pathToModify));
+    final targetUri = copyTargetUri.resolveUri(pathToModify);
+    final sourceString = await sourceFile.readAsString();
+    final modifiedString = sourceString.replaceAll(
+      'path: ../../../',
+      'path: ${pkgNativeAssetsBuilderUri.toFilePath().replaceAll('\\', '/')}',
+    );
+    await File.fromUri(targetUri).writeAsString(modifiedString);
+  }
+
+  // If we're copying `my_native_library/` we need to simulate that its
+  // native assets are pre-built
+  final myNativeLibraryUri = copyTargetUri.resolve('my_native_library/');
+  if (await Directory(myNativeLibraryUri.toFilePath()).exists()) {
+    await runPubGet(
+      workingDirectory: myNativeLibraryUri,
+      logger: logger,
+    );
+    await runDart(
+      arguments: ['tool/native.dart', 'build'],
+      workingDirectory: myNativeLibraryUri,
+      logger: logger,
+    );
+  }
+}
+
+Future<void> runPubGet({
+  required Uri workingDirectory,
+  required Logger logger,
+}) async {
+  final result = await runDart(
+    arguments: ['pub', 'get'],
+    workingDirectory: workingDirectory,
+    logger: logger,
+  );
+  expect(result.exitCode, 0);
+}
+
+void expectDartAppStdout(String stdout) {
+  expect(
+    stdout,
+    stringContainsInOrder(
+      [
+        'add(5, 6) = 11',
+        'subtract(5, 6) = -1',
+      ],
+    ),
+  );
+}
+
+/// Logger that outputs the full trace when a test fails.
+final logger = Logger('')
+  ..level = Level.ALL
+  ..onRecord.listen((record) {
+    printOnFailure('${record.level.name}: ${record.time}: ${record.message}');
+  });
+
+final dartExecutable = Uri.file(Platform.resolvedExecutable);
+
+Future<void> nativeAssetsTest(
+  String packageUnderTest,
+  Future<void> Function(Uri) fun,
+) async {
+  assert(const [
+    'dart_app',
+    'native_add',
+  ].contains(packageUnderTest));
+  return await inTempDir((tempUri) async {
+    await copyTestProjects(tempUri, logger);
+    final packageUri = tempUri.resolve('$packageUnderTest/');
+    await runPubGet(workingDirectory: packageUri, logger: logger);
+    return await fun(packageUri);
+  });
+}
+
+Future<run_process.RunProcessResult> runDart({
+  required List<String> arguments,
+  Uri? workingDirectory,
+  required Logger? logger,
+}) async {
+  final result = await runProcess(
+    executable: dartExecutable,
+    arguments: arguments,
+    workingDirectory: workingDirectory,
+    logger: logger,
+  );
+  expect(result.exitCode, 0);
+  return result;
+}
diff --git a/pkg/dartdev/test/native_assets/run_test.dart b/pkg/dartdev/test/native_assets/run_test.dart
new file mode 100644
index 0000000..beb3284
--- /dev/null
+++ b/pkg/dartdev/test/native_assets/run_test.dart
@@ -0,0 +1,52 @@
+// Copyright (c) 2023, 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.
+
+// @dart=2.18
+
+import 'package:test/test.dart';
+
+import '../utils.dart';
+import 'helpers.dart';
+
+void main(List<String> args) async {
+  // No --source option, `dart run` from source does not output target program
+  // stdout.
+
+  test('dart run ', timeout: longTimeout, () async {
+    await nativeAssetsTest('dart_app', (dartAppUri) async {
+      final result = await runDart(
+        arguments: [
+          '--enable-experiment=native-assets',
+          'run',
+        ],
+        workingDirectory: dartAppUri,
+        logger: logger,
+      );
+      expectDartAppStdout(result.stdout);
+    });
+  });
+
+  test('dart run test/xxx_test.dart', timeout: longTimeout, () async {
+    await nativeAssetsTest('native_add', (packageUri) async {
+      final result = await runDart(
+        arguments: [
+          '--enable-experiment=native-assets',
+          'run',
+          'test/native_add_test.dart',
+        ],
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+      expect(
+        result.stdout,
+        stringContainsInOrder(
+          [
+            'native add test',
+            'All tests passed!',
+          ],
+        ),
+      );
+    });
+  });
+}
diff --git a/pkg/dartdev/test/native_assets/test_test.dart b/pkg/dartdev/test/native_assets/test_test.dart
new file mode 100644
index 0000000..23848d6
--- /dev/null
+++ b/pkg/dartdev/test/native_assets/test_test.dart
@@ -0,0 +1,60 @@
+// Copyright (c) 2023, 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.
+
+// @dart=2.18
+
+import 'package:test/test.dart';
+
+import '../utils.dart';
+import 'helpers.dart';
+
+void main(List<String> args) async {
+  // No --source option, `dart run` from source does not output target program
+  // stdout.
+
+  test('dart test', timeout: longTimeout, () async {
+    await nativeAssetsTest('native_add', (packageUri) async {
+      final result = await runDart(
+        arguments: [
+          '--enable-experiment=native-assets',
+          'test',
+        ],
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+      expect(
+        result.stdout,
+        stringContainsInOrder(
+          [
+            'native add test',
+            'All tests passed!',
+          ],
+        ),
+      );
+    });
+  });
+
+  test('dart run test:test', timeout: longTimeout, () async {
+    await nativeAssetsTest('native_add', (packageUri) async {
+      final result = await runDart(
+        arguments: [
+          '--enable-experiment=native-assets',
+          'run',
+          'test:test',
+        ],
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+      expect(
+        result.stdout,
+        stringContainsInOrder(
+          [
+            'native add test',
+            'All tests passed!',
+          ],
+        ),
+      );
+    });
+  });
+}
diff --git a/pkg/dartdev/test/utils.dart b/pkg/dartdev/test/utils.dart
index d562234..0c44883 100644
--- a/pkg/dartdev/test/utils.dart
+++ b/pkg/dartdev/test/utils.dart
@@ -223,7 +223,7 @@
 ///
 /// Many of this package tests rely on having the SDK folder layout.
 void ensureRunFromSdkBinDart() {
-  final uri = Uri(path: Platform.resolvedExecutable);
+  final uri = Uri.file(Platform.resolvedExecutable);
   final pathReversed = uri.pathSegments.reversed.toList();
   if (!pathReversed[0].startsWith('dart')) {
     throw StateError('Main executable is not Dart: ${uri.toFilePath()}.');
diff --git a/pkg/front_end/lib/src/api_prototype/experimental_flags_generated.dart b/pkg/front_end/lib/src/api_prototype/experimental_flags_generated.dart
index 5b660a1..28a2d1a1 100644
--- a/pkg/front_end/lib/src/api_prototype/experimental_flags_generated.dart
+++ b/pkg/front_end/lib/src/api_prototype/experimental_flags_generated.dart
@@ -171,6 +171,14 @@
       experimentEnabledVersion: const Version(2, 17),
       experimentReleasedVersion: const Version(2, 17));
 
+  static const ExperimentalFlag nativeAssets = const ExperimentalFlag(
+      name: 'native-assets',
+      isEnabledByDefault: false,
+      isExpired: false,
+      enabledVersion: const Version(3, 1),
+      experimentEnabledVersion: const Version(3, 1),
+      experimentReleasedVersion: const Version(3, 1));
+
   static const ExperimentalFlag nonNullable = const ExperimentalFlag(
       name: 'non-nullable',
       isEnabledByDefault: true,
@@ -382,6 +390,10 @@
   GlobalFeature get namedArgumentsAnywhere => _namedArgumentsAnywhere ??=
       _computeGlobalFeature(ExperimentalFlag.namedArgumentsAnywhere);
 
+  GlobalFeature? _nativeAssets;
+  GlobalFeature get nativeAssets =>
+      _nativeAssets ??= _computeGlobalFeature(ExperimentalFlag.nativeAssets);
+
   GlobalFeature? _nonNullable;
   GlobalFeature get nonNullable =>
       _nonNullable ??= _computeGlobalFeature(ExperimentalFlag.nonNullable);
@@ -526,6 +538,11 @@
           canonicalUri,
           libraryVersion);
 
+  LibraryFeature? _nativeAssets;
+  LibraryFeature get nativeAssets =>
+      _nativeAssets ??= globalFeatures._computeLibraryFeature(
+          ExperimentalFlag.nativeAssets, canonicalUri, libraryVersion);
+
   LibraryFeature? _nonNullable;
   LibraryFeature get nonNullable =>
       _nonNullable ??= globalFeatures._computeLibraryFeature(
@@ -625,6 +642,8 @@
         return macros;
       case shared.ExperimentalFlag.namedArgumentsAnywhere:
         return namedArgumentsAnywhere;
+      case shared.ExperimentalFlag.nativeAssets:
+        return nativeAssets;
       case shared.ExperimentalFlag.nonNullable:
         return nonNullable;
       case shared.ExperimentalFlag.nonfunctionTypeAliases:
@@ -690,6 +709,8 @@
       return ExperimentalFlag.macros;
     case "named-arguments-anywhere":
       return ExperimentalFlag.namedArgumentsAnywhere;
+    case "native-assets":
+      return ExperimentalFlag.nativeAssets;
     case "non-nullable":
       return ExperimentalFlag.nonNullable;
     case "nonfunction-type-aliases":
@@ -749,6 +770,8 @@
   ExperimentalFlag.macros: ExperimentalFlag.macros.isEnabledByDefault,
   ExperimentalFlag.namedArgumentsAnywhere:
       ExperimentalFlag.namedArgumentsAnywhere.isEnabledByDefault,
+  ExperimentalFlag.nativeAssets:
+      ExperimentalFlag.nativeAssets.isEnabledByDefault,
   ExperimentalFlag.nonNullable: ExperimentalFlag.nonNullable.isEnabledByDefault,
   ExperimentalFlag.nonfunctionTypeAliases:
       ExperimentalFlag.nonfunctionTypeAliases.isEnabledByDefault,
@@ -974,6 +997,7 @@
   shared.ExperimentalFlag.macros: ExperimentalFlag.macros,
   shared.ExperimentalFlag.namedArgumentsAnywhere:
       ExperimentalFlag.namedArgumentsAnywhere,
+  shared.ExperimentalFlag.nativeAssets: ExperimentalFlag.nativeAssets,
   shared.ExperimentalFlag.nonNullable: ExperimentalFlag.nonNullable,
   shared.ExperimentalFlag.nonfunctionTypeAliases:
       ExperimentalFlag.nonfunctionTypeAliases,
diff --git a/pkg/native_assets_builder/lib/src/build_runner/build_runner.dart b/pkg/native_assets_builder/lib/src/build_runner/build_runner.dart
index 6e979c8..6388d00 100644
--- a/pkg/native_assets_builder/lib/src/build_runner/build_runner.dart
+++ b/pkg/native_assets_builder/lib/src/build_runner/build_runner.dart
@@ -137,7 +137,7 @@
     }
     await runProcess(
       workingDirectory: workingDirectory,
-      executable: dartExecutable.toFilePath(),
+      executable: dartExecutable,
       arguments: [
         '--packages=${packageConfigUri.toFilePath()}',
         buildDotDart.toFilePath(),
@@ -145,6 +145,8 @@
       ],
       logger: logger,
       includeParentEnvironment: includeParentEnvironment,
+      expectedExitCode: 0,
+      throwOnUnexpectedExitCode: true,
     );
     final buildOutput = await BuildOutput.readFromFile(outDir: outDir);
     setMetadata(config.target, config.packageName, buildOutput?.metadata);
diff --git a/pkg/native_assets_builder/lib/src/utils/run_process.dart b/pkg/native_assets_builder/lib/src/utils/run_process.dart
index b557bb5..6a34b3ff 100644
--- a/pkg/native_assets_builder/lib/src/utils/run_process.dart
+++ b/pkg/native_assets_builder/lib/src/utils/run_process.dart
@@ -8,17 +8,22 @@
 
 import 'package:logging/logging.dart';
 
-/// Runs a process async and captures the exit code and standard out.
+/// Runs a [Process].
 ///
-/// Supports streaming output using a [Logger].
+/// If [logger] is provided, stream stdout and stderr to it.
+///
+/// If [captureOutput], captures stdout and stderr.
+// TODO(dacoharkes): Share between package:c_compiler and here.
 Future<RunProcessResult> runProcess({
-  required String executable,
-  required List<String> arguments,
+  required Uri executable,
+  List<String> arguments = const [],
   Uri? workingDirectory,
   Map<String, String>? environment,
   bool includeParentEnvironment = true,
-  bool throwOnFailure = true,
-  required Logger logger,
+  required Logger? logger,
+  bool captureOutput = true,
+  int expectedExitCode = 0,
+  bool throwOnUnexpectedExitCode = false,
 }) async {
   if (Platform.isWindows && !includeParentEnvironment) {
     const winEnvKeys = [
@@ -39,18 +44,18 @@
   final commandString = [
     if (printWorkingDir) '(cd ${workingDirectory.toFilePath()};',
     ...?environment?.entries.map((entry) => '${entry.key}=${entry.value}'),
-    executable,
+    executable.toFilePath(),
     ...arguments.map((a) => a.contains(' ') ? "'$a'" : a),
     if (printWorkingDir) ')',
   ].join(' ');
-  logger.info('Running `$commandString`.');
+  logger?.info('Running `$commandString`.');
 
-  final stdoutBuffer = <String>[];
-  final stderrBuffer = <String>[];
+  final stdoutBuffer = StringBuffer();
+  final stderrBuffer = StringBuffer();
   final stdoutCompleter = Completer<Object?>();
   final stderrCompleter = Completer<Object?>();
-  final Process process = await Process.start(
-    executable,
+  final process = await Process.start(
+    executable.toFilePath(),
     arguments,
     workingDirectory: workingDirectory?.toFilePath(),
     environment: environment,
@@ -60,66 +65,60 @@
 
   process.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(
     (s) {
-      logger.fine(s);
-      stdoutBuffer.add(s);
+      logger?.fine(s);
+      if (captureOutput) stdoutBuffer.writeln(s);
     },
     onDone: stdoutCompleter.complete,
   );
   process.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(
     (s) {
-      logger.severe(s);
-      stderrBuffer.add(s);
+      logger?.severe(s);
+      if (captureOutput) stderrBuffer.writeln(s);
     },
     onDone: stderrCompleter.complete,
   );
 
-  final int exitCode = await process.exitCode;
+  final exitCode = await process.exitCode;
   await stdoutCompleter.future;
-  final String stdout = stdoutBuffer.join();
   await stderrCompleter.future;
-  final String stderr = stderrBuffer.join();
   final result = RunProcessResult(
     pid: process.pid,
-    command: '$executable ${arguments.join(' ')}',
+    command: commandString,
     exitCode: exitCode,
-    stdout: stdout,
-    stderr: stderr,
+    stdout: stdoutBuffer.toString(),
+    stderr: stderrBuffer.toString(),
   );
-  if (throwOnFailure && result.exitCode != 0) {
-    throw ProcessInvocationException(result);
+  if (throwOnUnexpectedExitCode && expectedExitCode != exitCode) {
+    throw ProcessException(
+      executable.toFilePath(),
+      arguments,
+      "Full command string: '$commandString'.\n"
+      "Exit code: '$exitCode'.\n"
+      'For the output of the process check the logger output.',
+    );
   }
   return result;
 }
 
-class RunProcessResult extends ProcessResult {
+/// Drop in replacement of [ProcessResult].
+class RunProcessResult {
+  final int pid;
+
   final String command;
 
-  final int _exitCode;
+  final int exitCode;
 
-  // For some reason super.exitCode returns 0.
-  @override
-  int get exitCode => _exitCode;
+  final String stderr;
 
-  final String _stderrString;
-
-  @override
-  String get stderr => _stderrString;
-
-  final String _stdoutString;
-
-  @override
-  String get stdout => _stdoutString;
+  final String stdout;
 
   RunProcessResult({
-    required int pid,
+    required this.pid,
     required this.command,
-    required int exitCode,
-    required String stderr,
-    required String stdout,
-  })  : _exitCode = exitCode,
-        _stderrString = stderr,
-        _stdoutString = stdout,
-        super(pid, exitCode, stdout, stderr);
+    required this.exitCode,
+    required this.stderr,
+    required this.stdout,
+  });
 
   @override
   String toString() => '''command: $command
@@ -127,15 +126,3 @@
 stdout: $stdout
 stderr: $stderr''';
 }
-
-class ProcessInvocationException implements Exception {
-  final RunProcessResult runProcessResult;
-
-  ProcessInvocationException(this.runProcessResult);
-
-  String get message => '''A process run failed.
-$runProcessResult''';
-
-  @override
-  String toString() => message;
-}
diff --git a/pkg/native_assets_builder/pubspec.yaml b/pkg/native_assets_builder/pubspec.yaml
index eb130dd..449f602 100644
--- a/pkg/native_assets_builder/pubspec.yaml
+++ b/pkg/native_assets_builder/pubspec.yaml
@@ -5,7 +5,7 @@
 publish_to: none
 
 environment:
-  sdk: ">=2.17.0 <3.0.0"
+  sdk: ">=3.0.0 <4.0.0"
 
 # Use 'any' constraints here; we get our versions from the DEPS file.
 dependencies:
diff --git a/pkg/native_assets_builder/test/build_runner/build_planner_test.dart b/pkg/native_assets_builder/test/build_runner/build_planner_test.dart
index 2af502a..b9b920d 100644
--- a/pkg/native_assets_builder/test/build_runner/build_planner_test.dart
+++ b/pkg/native_assets_builder/test/build_runner/build_planner_test.dart
@@ -21,7 +21,7 @@
       await runPubGet(workingDirectory: nativeAddUri, logger: logger);
 
       final result = await runProcess(
-        executable: Platform.resolvedExecutable,
+        executable: Uri.file(Platform.resolvedExecutable),
         arguments: [
           'pub',
           'deps',
diff --git a/pkg/native_assets_builder/test/build_runner/helpers.dart b/pkg/native_assets_builder/test/build_runner/helpers.dart
index 443966a..b0b2c03 100644
--- a/pkg/native_assets_builder/test/build_runner/helpers.dart
+++ b/pkg/native_assets_builder/test/build_runner/helpers.dart
@@ -17,7 +17,7 @@
   required Logger logger,
 }) async {
   final result = await runProcess(
-    executable: Platform.resolvedExecutable,
+    executable: Uri.file(Platform.resolvedExecutable),
     arguments: ['pub', 'get'],
     workingDirectory: workingDirectory,
     logger: logger,
@@ -78,7 +78,7 @@
   if (Platform.isLinux) {
     final assetUri = (asset.path as AssetAbsolutePath).uri;
     final nmResult = await runProcess(
-      executable: 'nm',
+      executable: Uri(path: 'nm'),
       arguments: [
         '-D',
         assetUri.toFilePath(),
diff --git a/pkg/native_assets_builder/test/helpers.dart b/pkg/native_assets_builder/test/helpers.dart
index 342bc4b..4168590 100644
--- a/pkg/native_assets_builder/test/helpers.dart
+++ b/pkg/native_assets_builder/test/helpers.dart
@@ -3,10 +3,11 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:async';
-import 'dart:convert';
 import 'dart:io';
 
 import 'package:logging/logging.dart';
+import 'package:native_assets_builder/src/utils/run_process.dart'
+    as run_process;
 import 'package:test/test.dart';
 import 'package:yaml/yaml.dart';
 
@@ -36,106 +37,33 @@
   }
 }
 
-/// Runs a process async and captures the exit code and standard out.
-Future<RunProcessResult> runProcess({
-  required String executable,
-  required List<String> arguments,
+/// Runs a [Process].
+///
+/// If [logger] is provided, stream stdout and stderr to it.
+///
+/// If [captureOutput], captures stdout and stderr.
+Future<run_process.RunProcessResult> runProcess({
+  required Uri executable,
+  List<String> arguments = const [],
   Uri? workingDirectory,
   Map<String, String>? environment,
-  bool throwOnFailure = true,
-  required Logger logger,
-}) async {
-  final printWorkingDir =
-      workingDirectory != null && workingDirectory != Directory.current.uri;
-  final commandString = [
-    if (printWorkingDir) '(cd ${workingDirectory.toFilePath()};',
-    ...?environment?.entries.map((entry) => '${entry.key}=${entry.value}'),
-    executable,
-    ...arguments.map((a) => a.contains(' ') ? "'$a'" : a),
-    if (printWorkingDir) ')',
-  ].join(' ');
-
-  logger.info('Running `$commandString`.');
-
-  final stdoutBuffer = <String>[];
-  final stderrBuffer = <String>[];
-  final stdoutCompleter = Completer<Object?>();
-  final stderrCompleter = Completer<Object?>();
-  final Process process = await Process.start(
-    executable,
-    arguments,
-    workingDirectory: workingDirectory?.toFilePath(),
-    environment: environment,
-  );
-
-  process.stdout.transform(utf8.decoder).listen(
-    (s) {
-      logger.fine('  $s');
-      stdoutBuffer.add(s);
-    },
-    onDone: stdoutCompleter.complete,
-  );
-  process.stderr.transform(utf8.decoder).listen(
-    (s) {
-      logger.shout('  $s');
-      stderrBuffer.add(s);
-    },
-    onDone: stderrCompleter.complete,
-  );
-
-  final int exitCode = await process.exitCode;
-  await stdoutCompleter.future;
-  final String stdout = stdoutBuffer.join();
-  await stderrCompleter.future;
-  final String stderr = stderrBuffer.join();
-  final result = RunProcessResult(
-    pid: process.pid,
-    command: '$executable ${arguments.join(' ')}',
-    exitCode: exitCode,
-    stdout: stdout,
-    stderr: stderr,
-  );
-  if (throwOnFailure && result.exitCode != 0) {
-    throw result;
-  }
-  return result;
-}
-
-class RunProcessResult extends ProcessResult {
-  final String command;
-
-  final int _exitCode;
-
-  @override
-  int get exitCode => _exitCode;
-
-  final String _stderrString;
-
-  @override
-  String get stderr => _stderrString;
-
-  final String _stdoutString;
-
-  @override
-  String get stdout => _stdoutString;
-
-  RunProcessResult({
-    required int pid,
-    required this.command,
-    required int exitCode,
-    required String stderr,
-    required String stdout,
-  })  : _exitCode = exitCode,
-        _stderrString = stderr,
-        _stdoutString = stdout,
-        super(pid, exitCode, stdout, stderr);
-
-  @override
-  String toString() => '''command: $command
-exitCode: $exitCode
-stdout: $stdout
-stderr: $stderr''';
-}
+  bool includeParentEnvironment = true,
+  required Logger? logger,
+  bool captureOutput = true,
+  int expectedExitCode = 0,
+  bool throwOnUnexpectedExitCode = false,
+}) =>
+    run_process.runProcess(
+      executable: executable,
+      arguments: arguments,
+      workingDirectory: workingDirectory,
+      environment: environment,
+      includeParentEnvironment: includeParentEnvironment,
+      logger: logger,
+      captureOutput: captureOutput,
+      expectedExitCode: expectedExitCode,
+      throwOnUnexpectedExitCode: throwOnUnexpectedExitCode,
+    );
 
 final pkgNativeAssetsBuilderUri = Platform.script.resolve('../../');
 final testProjectsUri =
diff --git a/tools/experimental_features.yaml b/tools/experimental_features.yaml
index a27cac0..d7c5fad 100644
--- a/tools/experimental_features.yaml
+++ b/tools/experimental_features.yaml
@@ -133,6 +133,9 @@
   inline-class:
     help: "Inline class"
 
+  native-assets:
+    help: "Compile and bundle native assets."
+
 # Experiment flag only used for testing.
   test-experiment:
     help: >-