[beta] [dart2wasm] Generate source maps

This implements generating source maps for the generated Wasm files.

Copying dart2js's command line interface, a source map file with the
name `<program name>.wasm.map` is generated unless `--no-source-maps` is
passed.

When a source map is generated, the generated .wasm file gets a new
section `sourceMappingURL` with the contents `<program name>.wasm.map`.

This section seems to be undocumented, but Chrome and binaryen recognize
it as the URI to the source map file. Chrome is then loads it
automatically in the DevTools.

- `wasm_builder` package is updated with the new `source_map` library,
  which describes the source mapping entries.

- `wasm_builder`'s `InstructionsBuilder` is updated with the new public
  members:

  - `startSourceMapping`: starts mapping the instructions generated to
    the given source code.

  - `stopSourceMapping`: stops mapping the instructions generated to a
    source code. These instructions won't have a mapping in the source
    map.

- `CodeGenerator` sets the source file URI and location in the file
  when:

  - Starting compiling a new member
  - Compiling an expression and statement

Bug: https://github.com/dart-lang/sdk/issues/55763
Change-Id: Ieb24796b4b17a735b846793617664a453f1061ce
Cherry-pick: https://dart-review.googlesource.com/c/sdk/+/370500
Cherry-pick-request: https://github.com/dart-lang/sdk/issues/56239
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/375660
Reviewed-by: Ömer Ağacan <omersa@google.com>
Commit-Queue: Martin Kustermann <kustermann@google.com>
diff --git a/pkg/dart2wasm/lib/closures.dart b/pkg/dart2wasm/lib/closures.dart
index 6b96135..c565f2c 100644
--- a/pkg/dart2wasm/lib/closures.dart
+++ b/pkg/dart2wasm/lib/closures.dart
@@ -947,8 +947,9 @@
 class Lambda {
   final FunctionNode functionNode;
   final w.FunctionBuilder function;
+  final Source functionNodeSource;
 
-  Lambda(this.functionNode, this.function);
+  Lambda(this.functionNode, this.function, this.functionNodeSource);
 }
 
 /// The context for one or more closures, containing their captured variables.
@@ -1137,12 +1138,22 @@
 
   int get depth => functionIsSyncStarOrAsync.length - 1;
 
-  CaptureFinder(this.closures, this.member);
+  CaptureFinder(this.closures, this.member)
+      : _currentSource =
+            member.enclosingComponent!.uriToSource[member.fileUri]!;
 
   Translator get translator => closures.translator;
 
   w.ModuleBuilder get m => translator.m;
 
+  Source _currentSource;
+
+  @override
+  void visitFileUriExpression(FileUriExpression node) {
+    _currentSource = node.enclosingComponent!.uriToSource[node.fileUri]!;
+    super.visitFileUriExpression(node);
+  }
+
   @override
   void visitFunctionNode(FunctionNode node) {
     assert(depth == 0); // Nested function nodes are skipped by [_visitLambda].
@@ -1275,7 +1286,7 @@
       functionName = "$member closure $functionNodeName at ${node.location}";
     }
     final function = m.functions.define(type, functionName);
-    closures.lambdas[node] = Lambda(node, function);
+    closures.lambdas[node] = Lambda(node, function, _currentSource);
 
     functionIsSyncStarOrAsync.add(node.asyncMarker == AsyncMarker.SyncStar ||
         node.asyncMarker == AsyncMarker.Async);
diff --git a/pkg/dart2wasm/lib/code_generator.dart b/pkg/dart2wasm/lib/code_generator.dart
index 58352c5..5bfa29a 100644
--- a/pkg/dart2wasm/lib/code_generator.dart
+++ b/pkg/dart2wasm/lib/code_generator.dart
@@ -159,6 +159,67 @@
     return expectedType;
   }
 
+  Source? _sourceMapSource;
+  int _sourceMapFileOffset = TreeNode.noOffset;
+
+  /// Update the [Source] for the AST nodes being compiled.
+  ///
+  /// The [Source] is used to resolve [TreeNode.fileOffset]s to file URI, line,
+  /// and column numbers, to be able to generate source mappings, in
+  /// [setSourceMapFileOffset].
+  ///
+  /// Setting this `null` disables source mapping for the instructions being
+  /// generated.
+  ///
+  /// This should be called before [setSourceMapFileOffset] as the file offset
+  /// passed to that function is resolved using the [Source].
+  ///
+  /// Returns the old [Source], which can be used to restore the source mapping
+  /// after visiting a sub-tree.
+  Source? setSourceMapSource(Source? source) {
+    final old = _sourceMapSource;
+    _sourceMapSource = source;
+    return old;
+  }
+
+  /// Update the source location of the AST nodes being compiled in the source
+  /// map.
+  ///
+  /// When the offset is [TreeNode.noOffset], this disables mapping the
+  /// generated instructions.
+  ///
+  /// Returns the old file offset, which can be used to restore the source
+  /// mapping after vising a sub-tree.
+  int setSourceMapFileOffset(int fileOffset) {
+    if (!b.recordSourceMaps) {
+      final old = _sourceMapFileOffset;
+      _sourceMapFileOffset = fileOffset;
+      return old;
+    }
+    if (fileOffset == TreeNode.noOffset) {
+      b.stopSourceMapping();
+      final old = _sourceMapFileOffset;
+      _sourceMapFileOffset = fileOffset;
+      return old;
+    }
+    final source = _sourceMapSource!;
+    final fileUri = source.fileUri!;
+    final location = source.getLocation(fileUri, fileOffset);
+    final old = _sourceMapFileOffset;
+    _sourceMapFileOffset = fileOffset;
+    b.startSourceMapping(
+        fileUri, location.line - 1, location.column - 1, member.name.text);
+    return old;
+  }
+
+  /// Calls [setSourceMapSource] and [setSourceMapFileOffset].
+  (Source?, int) setSourceMapSourceAndFileOffset(
+      Source? source, int fileOffset) {
+    final oldSource = setSourceMapSource(source);
+    final oldFileOffset = setSourceMapFileOffset(fileOffset);
+    return (oldSource, oldFileOffset);
+  }
+
   /// Generate code for the member.
   void generate() {
     Member member = this.member;
@@ -208,6 +269,9 @@
       return;
     }
 
+    final source = member.enclosingComponent!.uriToSource[member.fileUri]!;
+    setSourceMapSourceAndFileOffset(source, member.fileOffset);
+
     if (member is Constructor) {
       translator.membersBeingGenerated.add(member);
       if (reference.isConstructorBodyReference) {
@@ -484,12 +548,18 @@
 
     for (Field field in info.cls!.fields) {
       if (field.isInstanceMember && field.initializer != null) {
+        final source = field.enclosingComponent!.uriToSource[field.fileUri]!;
+        final (oldSource, oldFileOffset) =
+            setSourceMapSourceAndFileOffset(source, field.fileOffset);
+
         int fieldIndex = translator.fieldIndex[field]!;
         w.Local local = addLocal(info.struct.fields[fieldIndex].type.unpacked);
 
         wrap(field.initializer!, info.struct.fields[fieldIndex].type.unpacked);
         b.local_set(local);
         fieldLocals[field] = local;
+
+        setSourceMapSourceAndFileOffset(oldSource, oldFileOffset);
       }
     }
   }
@@ -717,6 +787,8 @@
     // Initialize closure information from enclosing member.
     this.closures = closures;
 
+    setSourceMapSource(lambda.functionNodeSource);
+
     assert(lambda.functionNode.asyncMarker != AsyncMarker.Async);
 
     setupLambdaParametersAndContexts(lambda);
@@ -877,6 +949,15 @@
   /// result to the expected type if needed. All expression code generation goes
   /// through this method.
   w.ValueType wrap(Expression node, w.ValueType expectedType) {
+    var sourceUpdated = false;
+    Source? oldSource;
+    if (node is FileUriNode) {
+      final source =
+          node.enclosingComponent!.uriToSource[(node as FileUriNode).fileUri]!;
+      oldSource = setSourceMapSource(source);
+      sourceUpdated = true;
+    }
+    final oldFileOffset = setSourceMapFileOffset(node.fileOffset);
     try {
       w.ValueType resultType = node.accept1(this, expectedType);
       translator.convertType(function, resultType, expectedType);
@@ -884,15 +965,23 @@
     } catch (_) {
       _printLocation(node);
       rethrow;
+    } finally {
+      if (sourceUpdated) {
+        setSourceMapSource(oldSource);
+      }
+      setSourceMapFileOffset(oldFileOffset);
     }
   }
 
   void visitStatement(Statement node) {
+    final oldFileOffset = setSourceMapFileOffset(node.fileOffset);
     try {
       node.accept(this);
     } catch (_) {
       _printLocation(node);
       rethrow;
+    } finally {
+      setSourceMapFileOffset(oldFileOffset);
     }
   }
 
diff --git a/pkg/dart2wasm/lib/compile.dart b/pkg/dart2wasm/lib/compile.dart
index 0d2051d..a9652ad 100644
--- a/pkg/dart2wasm/lib/compile.dart
+++ b/pkg/dart2wasm/lib/compile.dart
@@ -33,7 +33,7 @@
     show transformComponent;
 import 'package:vm/transformations/unreachable_code_elimination.dart'
     as unreachable_code_elimination;
-import 'package:wasm_builder/wasm_builder.dart' show Module, Serializer;
+import 'package:wasm_builder/wasm_builder.dart' show Serializer;
 
 import 'compiler_options.dart' as compiler;
 import 'constant_evaluator.dart';
@@ -45,18 +45,11 @@
 import 'translator.dart';
 
 class CompilerOutput {
-  final Module _wasmModule;
+  final Uint8List wasmModule;
   final String jsRuntime;
+  final String? sourceMap;
 
-  late final Uint8List wasmModule = _serializeWasmModule();
-
-  Uint8List _serializeWasmModule() {
-    final s = Serializer();
-    _wasmModule.serialize(s);
-    return s.data;
-  }
-
-  CompilerOutput(this._wasmModule, this.jsRuntime);
+  CompilerOutput(this.wasmModule, this.jsRuntime, this.sourceMap);
 }
 
 /// Compile a Dart file into a Wasm module.
@@ -64,7 +57,14 @@
 /// Returns `null` if an error occurred during compilation. The
 /// [handleDiagnosticMessage] callback will have received an error message
 /// describing the error.
-Future<CompilerOutput?> compileToModule(compiler.WasmCompilerOptions options,
+///
+/// When generating a source map, `sourceMapUrl` argument should be provided
+/// with the URL of the source map. This value will be added to the Wasm module
+/// in `sourceMappingURL` section. When this argument is null the code
+/// generator does not generate source mappings.
+Future<CompilerOutput?> compileToModule(
+    compiler.WasmCompilerOptions options,
+    Uri? sourceMapUrl,
     void Function(DiagnosticMessage) handleDiagnosticMessage) async {
   var succeeded = true;
   void diagnosticMessageHandler(DiagnosticMessage message) {
@@ -205,10 +205,19 @@
         depFile);
   }
 
-  final wasmModule = translator.translate();
+  final generateSourceMaps = options.translatorOptions.generateSourceMaps;
+  final wasmModule = translator.translate(sourceMapUrl);
+  final serializer = Serializer();
+  wasmModule.serialize(serializer);
+  final wasmModuleSerialized = serializer.data;
+
+  final sourceMap =
+      generateSourceMaps ? serializer.sourceMapSerializer.serialize() : null;
+
   String jsRuntime = jsRuntimeFinalizer.generate(
       translator.functions.translatedProcedures,
       translator.internalizedStringsForJSRuntime,
       mode);
-  return CompilerOutput(wasmModule, jsRuntime);
+
+  return CompilerOutput(wasmModuleSerialized, jsRuntime, sourceMap);
 }
diff --git a/pkg/dart2wasm/lib/dart2wasm.dart b/pkg/dart2wasm/lib/dart2wasm.dart
index d106088..92a2886 100644
--- a/pkg/dart2wasm/lib/dart2wasm.dart
+++ b/pkg/dart2wasm/lib/dart2wasm.dart
@@ -94,6 +94,10 @@
   Flag("enable-experimental-ffi",
       (o, value) => o.translatorOptions.enableExperimentalFfi = value,
       defaultsTo: _d.translatorOptions.enableExperimentalFfi),
+  // Use same flag with dart2js for disabling source maps.
+  Flag("no-source-maps",
+      (o, value) => o.translatorOptions.generateSourceMaps = !value,
+      defaultsTo: !_d.translatorOptions.generateSourceMaps),
 ];
 
 Map<fe.ExperimentalFlag, bool> processFeExperimentalFlags(
diff --git a/pkg/dart2wasm/lib/generate_wasm.dart b/pkg/dart2wasm/lib/generate_wasm.dart
index c0e2315..174fd7a 100644
--- a/pkg/dart2wasm/lib/generate_wasm.dart
+++ b/pkg/dart2wasm/lib/generate_wasm.dart
@@ -1,6 +1,7 @@
 import 'dart:io';
 
 import 'package:front_end/src/api_unstable/vm.dart' show printDiagnosticMessage;
+import 'package:path/path.dart' as path;
 
 import 'compile.dart';
 import 'compiler_options.dart';
@@ -18,10 +19,16 @@
     print('  - librariesSpecPath = ${options.librariesSpecPath}');
     print('  - packagesPath file = ${options.packagesPath}');
     print('  - platformPath file = ${options.platformPath}');
+    print(
+        '  - generate source maps = ${options.translatorOptions.generateSourceMaps}');
   }
 
-  CompilerOutput? output = await compileToModule(
-      options, (message) => printDiagnosticMessage(message, errorPrinter));
+  final relativeSourceMapUrl = options.translatorOptions.generateSourceMaps
+      ? Uri.file('${path.basename(options.outputFile)}.map')
+      : null;
+
+  CompilerOutput? output = await compileToModule(options, relativeSourceMapUrl,
+      (message) => printDiagnosticMessage(message, errorPrinter));
 
   if (output == null) {
     return 1;
@@ -32,8 +39,13 @@
   await outFile.writeAsBytes(output.wasmModule);
 
   final jsFile = options.outputJSRuntimeFile ??
-      '${options.outputFile.substring(0, options.outputFile.lastIndexOf('.'))}.mjs';
+      path.setExtension(options.outputFile, '.mjs');
   await File(jsFile).writeAsString(output.jsRuntime);
 
+  final sourceMap = output.sourceMap;
+  if (sourceMap != null) {
+    await File('${options.outputFile}.map').writeAsString(sourceMap);
+  }
+
   return 0;
 }
diff --git a/pkg/dart2wasm/lib/state_machine.dart b/pkg/dart2wasm/lib/state_machine.dart
index 8602bbd..017d2ad 100644
--- a/pkg/dart2wasm/lib/state_machine.dart
+++ b/pkg/dart2wasm/lib/state_machine.dart
@@ -606,6 +606,9 @@
 
   @override
   void generate() {
+    final source = member.enclosingComponent!.uriToSource[member.fileUri]!;
+    setSourceMapSource(source);
+    setSourceMapFileOffset(member.fileOffset);
     closures = Closures(translator, member);
     setupParametersAndContexts(member.reference);
     _generateBodies(member.function!);
@@ -614,6 +617,7 @@
   @override
   w.BaseFunction generateLambda(Lambda lambda, Closures closures) {
     this.closures = closures;
+    setSourceMapSource(lambda.functionNodeSource);
     setupLambdaParametersAndContexts(lambda);
     _generateBodies(lambda.functionNode);
     return function;
diff --git a/pkg/dart2wasm/lib/translator.dart b/pkg/dart2wasm/lib/translator.dart
index 525b1d1..75450bd 100644
--- a/pkg/dart2wasm/lib/translator.dart
+++ b/pkg/dart2wasm/lib/translator.dart
@@ -46,6 +46,7 @@
   bool verbose = false;
   bool enableExperimentalFfi = false;
   bool enableExperimentalWasmInterop = false;
+  bool generateSourceMaps = true;
   int inliningLimit = 0;
   int? sharedMemoryMaxPages;
   List<int> watchPoints = [];
@@ -292,8 +293,8 @@
     dynamicForwarders = DynamicForwarders(this);
   }
 
-  w.Module translate() {
-    m = w.ModuleBuilder(watchPoints: options.watchPoints);
+  w.Module translate(Uri? sourceMapUrl) {
+    m = w.ModuleBuilder(sourceMapUrl, watchPoints: options.watchPoints);
     voidMarker = w.RefType.def(w.StructType("void"), nullable: true);
 
     // Collect imports and exports as the very first thing so the function types
diff --git a/pkg/dart2wasm/pubspec.yaml b/pkg/dart2wasm/pubspec.yaml
index dfe6f29..8bcbddf 100644
--- a/pkg/dart2wasm/pubspec.yaml
+++ b/pkg/dart2wasm/pubspec.yaml
@@ -15,6 +15,7 @@
   kernel: any
   vm: any
   wasm_builder: any
+  path: any
 
 # Use 'any' constraints here; we get our versions from the DEPS file.
 dev_dependencies:
diff --git a/pkg/test_runner/lib/src/test_suite.dart b/pkg/test_runner/lib/src/test_suite.dart
index a2729d0..04229a6 100644
--- a/pkg/test_runner/lib/src/test_suite.dart
+++ b/pkg/test_runner/lib/src/test_suite.dart
@@ -838,6 +838,10 @@
         for (var opt in vmOptions)
           opt.replaceAll(r'$TEST_COMPILATION_DIR', tempDir)
       ];
+      for (var i = 0; i < testFile.dart2wasmOptions.length; i += 1) {
+        testFile.dart2wasmOptions[i] = testFile.dart2wasmOptions[i]
+            .replaceAll(r'$TEST_COMPILATION_DIR', tempDir);
+      }
       environment['TEST_COMPILATION_DIR'] = tempDir;
 
       compileTimeArguments = compilerConfiguration.computeCompilerArguments(
diff --git a/pkg/wasm_builder/lib/source_map.dart b/pkg/wasm_builder/lib/source_map.dart
new file mode 100644
index 0000000..3eac612
--- /dev/null
+++ b/pkg/wasm_builder/lib/source_map.dart
@@ -0,0 +1,188 @@
+// Copyright (c) 2024, 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.
+
+/// Represents a mapping from a range of generated instructions to some source
+/// code.
+class SourceMapping {
+  /// Start offset of mapped instructions.
+  final int instructionOffset;
+
+  /// Source info for the mapped instructions starting at [instructionOffset].
+  ///
+  /// When `null`, the mapping effectively makes the code unmapped. This is
+  /// useful for compiler-generated code that doesn't correstpond to any lines
+  /// in the source.
+  final SourceInfo? sourceInfo;
+
+  SourceMapping._(this.instructionOffset, this.sourceInfo);
+
+  SourceMapping(
+      this.instructionOffset, Uri fileUri, int line, int col, String? name)
+      : sourceInfo = SourceInfo(fileUri, line, col, name);
+
+  SourceMapping.unmapped(this.instructionOffset) : sourceInfo = null;
+
+  @override
+  String toString() => '$instructionOffset -> $sourceInfo';
+}
+
+class SourceInfo {
+  /// URI of the compiled code's file.
+  final Uri fileUri;
+
+  /// 0-based line number of the compiled code.
+  final int line;
+
+  /// 0-based column number of the compiled code.
+  final int col;
+
+  /// Name of the mapped code. This is usually the name of the function that
+  /// contains the code.
+  final String? name;
+
+  SourceInfo(this.fileUri, this.line, this.col, this.name);
+
+  @override
+  String toString() => '$fileUri:$line:$col ($name)';
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) {
+      return true;
+    }
+
+    if (other is! SourceInfo) {
+      return false;
+    }
+
+    return fileUri == other.fileUri &&
+        line == other.line &&
+        col == other.col &&
+        name == other.name;
+  }
+
+  @override
+  int get hashCode => Object.hash(fileUri, line, col, name);
+}
+
+class SourceMapSerializer {
+  final List<SourceMapping> mappings = [];
+
+  void addMapping(int instructionOffset, SourceInfo? sourceInfo) {
+    final mapping = SourceMapping._(instructionOffset, sourceInfo);
+    mappings.add(mapping);
+  }
+
+  void copyMappings(SourceMapSerializer other, int offset) {
+    for (final mapping in other.mappings) {
+      mappings.add(SourceMapping._(
+        mapping.instructionOffset + offset,
+        mapping.sourceInfo,
+      ));
+    }
+  }
+
+  String serialize() => _serializeSourceMap(mappings);
+}
+
+String _serializeSourceMap(List<SourceMapping> mappings) {
+  final Set<Uri> sourcesSet = {};
+  for (final mapping in mappings) {
+    if (mapping.sourceInfo?.fileUri != null) {
+      sourcesSet.add(mapping.sourceInfo!.fileUri);
+    }
+  }
+
+  final List<Uri> sourcesList = sourcesSet.toList();
+
+  // Maps sources to their indices in the 'sources' list.
+  final Map<Uri, int> sourceIndices = {};
+  for (Uri source in sourcesList) {
+    sourceIndices[source] = sourceIndices.length;
+  }
+
+  final Set<String> namesSet = {};
+  for (final mapping in mappings) {
+    if (mapping.sourceInfo?.name != null) {
+      namesSet.add(mapping.sourceInfo!.name!);
+    }
+  }
+
+  final List<String> namesList = namesSet.toList();
+
+  // Maps names to their index in the 'names' list.
+  final Map<String, int> nameIndices = {};
+  for (String name in namesList) {
+    nameIndices[name] = nameIndices.length;
+  }
+
+  // Generate the 'mappings' field.
+  final StringBuffer mappingsStr = StringBuffer();
+
+  int lastTargetColumn = 0;
+  int lastSourceIndex = 0;
+  int lastSourceLine = 0;
+  int lastSourceColumn = 0;
+  int lastNameIndex = 0;
+
+  for (int i = 0; i < mappings.length; ++i) {
+    final mapping = mappings[i];
+
+    lastTargetColumn =
+        _encodeVLQ(mappingsStr, mapping.instructionOffset, lastTargetColumn);
+
+    final sourceInfo = mapping.sourceInfo;
+    if (sourceInfo != null) {
+      final sourceIndex = sourceIndices[sourceInfo.fileUri]!;
+
+      lastSourceIndex = _encodeVLQ(mappingsStr, sourceIndex, lastSourceIndex);
+      lastSourceLine = _encodeVLQ(mappingsStr, sourceInfo.line, lastSourceLine);
+      lastSourceColumn =
+          _encodeVLQ(mappingsStr, sourceInfo.col, lastSourceColumn);
+
+      if (sourceInfo.name != null) {
+        final nameIndex = nameIndices[sourceInfo.name!]!;
+        lastNameIndex = _encodeVLQ(mappingsStr, nameIndex, lastNameIndex);
+      }
+    }
+
+    if (i != mappings.length - 1) {
+      mappingsStr.write(',');
+    }
+  }
+
+  return """{
+      "version": 3,
+      "sources": [${sourcesList.map((source) => '"$source"').join(",")}],
+      "names": [${namesList.map((name) => '"$name"').join(",")}],
+      "mappings": "$mappingsStr"
+  }""";
+}
+
+/// Writes the VLQ of delta between [value] and [offset] into [output] and
+/// return [value].
+int _encodeVLQ(StringSink output, int value, int offset) {
+  int delta = value - offset;
+  int signBit = 0;
+  if (delta < 0) {
+    signBit = 1;
+    delta = -delta;
+  }
+  delta = (delta << 1) | signBit;
+  do {
+    int digit = delta & _vlqBaseMask;
+    delta >>= _vlqBaseShift;
+    if (delta > 0) {
+      digit |= _vlqContinuationBit;
+    }
+    output.write(_base64Digits[digit]);
+  } while (delta > 0);
+  return value;
+}
+
+const int _vlqBaseShift = 5;
+const int _vlqBaseMask = (1 << 5) - 1;
+const int _vlqContinuationBit = 1 << 5;
+const String _base64Digits = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn'
+    'opqrstuvwxyz0123456789+/';
diff --git a/pkg/wasm_builder/lib/src/builder/instructions.dart b/pkg/wasm_builder/lib/src/builder/instructions.dart
index abe326e..ba4cd00 100644
--- a/pkg/wasm_builder/lib/src/builder/instructions.dart
+++ b/pkg/wasm_builder/lib/src/builder/instructions.dart
@@ -6,6 +6,7 @@
 
 import '../ir/ir.dart' as ir;
 import 'builder.dart';
+import '../../source_map.dart';
 
 // TODO(joshualitt): Suggested further optimizations:
 //   1) Add size estimates to `_Instruction`, and then remove logic where we
@@ -119,6 +120,12 @@
   /// middle of the stack are left out.
   int maxStackShown = 10;
 
+  /// Mappings for the instructions in [_instructions] to their source code.
+  ///
+  /// Since we add mappings as we generate instructions, this will be sorted
+  /// based on [SourceMapping.instructionOffset].
+  final List<SourceMapping>? _sourceMappings;
+
   int _indent = 1;
   final List<String> _traceLines = [];
 
@@ -141,7 +148,8 @@
 
   /// Create a new instruction sequence.
   InstructionsBuilder(this.module, List<ir.ValueType> outputs)
-      : _stackTraces = module.watchPoints.isNotEmpty ? {} : null {
+      : _stackTraces = module.watchPoints.isNotEmpty ? {} : null,
+        _sourceMappings = module.sourceMapUrl == null ? null : [] {
     _labelStack.add(Expression(const [], outputs));
   }
 
@@ -151,9 +159,11 @@
   /// Textual trace of the instructions.
   String get trace => _traceLines.join();
 
+  bool get recordSourceMaps => _sourceMappings != null;
+
   @override
-  ir.Instructions forceBuild() =>
-      ir.Instructions(locals, _instructions, _stackTraces, _traceLines);
+  ir.Instructions forceBuild() => ir.Instructions(
+      locals, _instructions, _stackTraces, _traceLines, _sourceMappings);
 
   void _add(ir.Instruction i) {
     if (!_reachable) return;
@@ -344,6 +354,55 @@
         indentAfter: reindent ? 1 : 0);
   }
 
+  // Source maps
+
+  /// Start mapping added instructions to the source location given in
+  /// arguments.
+  ///
+  /// This assumes [recordSourceMaps] is `true`.
+  void startSourceMapping(Uri fileUri, int line, int col, String? name) {
+    _addSourceMapping(
+        SourceMapping(_instructions.length, fileUri, line, col, name));
+  }
+
+  /// Stop mapping added instructions to the last source location given in
+  /// [startSourceMapping].
+  ///
+  /// The instructions added after this won't have a mapping in the source map.
+  ///
+  /// This assumes [recordSourceMaps] is `true`.
+  void stopSourceMapping() {
+    _addSourceMapping(SourceMapping.unmapped(_instructions.length));
+  }
+
+  void _addSourceMapping(SourceMapping mapping) {
+    final sourceMappings = _sourceMappings!;
+
+    if (sourceMappings.isNotEmpty) {
+      final lastMapping = sourceMappings.last;
+
+      // Check if we are overriding the current source location. This can
+      // happen when we restore the source location after a compiling a
+      // sub-tree, and the next node in the AST immediately updates the source
+      // location. The restored location is then never used.
+      if (lastMapping.instructionOffset == mapping.instructionOffset) {
+        sourceMappings.removeLast();
+        sourceMappings.add(mapping);
+        return;
+      }
+
+      // Check if we the new mapping maps to the same source as the old
+      // mapping. This happens when we have e.g. an instance field get like
+      // `length`, which gets transformed by the front-end as `this.length`. In
+      // this case `this` and `length` will have the same source location.
+      if (lastMapping.sourceInfo == mapping.sourceInfo) {
+        return;
+      }
+    }
+
+    sourceMappings.add(mapping);
+  }
+
   // Meta
 
   /// Emit a comment.
diff --git a/pkg/wasm_builder/lib/src/builder/module.dart b/pkg/wasm_builder/lib/src/builder/module.dart
index ff96ace..aeffc84 100644
--- a/pkg/wasm_builder/lib/src/builder/module.dart
+++ b/pkg/wasm_builder/lib/src/builder/module.dart
@@ -8,6 +8,7 @@
 // TODO(joshualitt): Get rid of cycles in the builder graph.
 /// A Wasm module builder.
 class ModuleBuilder with Builder<ir.Module> {
+  final Uri? sourceMapUrl;
   final List<int> watchPoints;
   final types = TypesBuilder();
   late final functions = FunctionsBuilder(this);
@@ -24,7 +25,7 @@
   /// bytes to watch. When the module is serialized, the stack traces leading
   /// to the production of all watched bytes are printed. This can be used to
   /// debug runtime errors happening at specific offsets within the module.
-  ModuleBuilder({this.watchPoints = const []});
+  ModuleBuilder(this.sourceMapUrl, {this.watchPoints = const []});
 
   @override
   ir.Module forceBuild() {
@@ -33,6 +34,7 @@
     final finalMemories = memories.build();
     final finalGlobals = globals.build();
     return ir.Module(
+        sourceMapUrl,
         finalFunctions,
         finalTables,
         tags.build(),
diff --git a/pkg/wasm_builder/lib/src/ir/function.dart b/pkg/wasm_builder/lib/src/ir/function.dart
index b0fce07..e39f23a 100644
--- a/pkg/wasm_builder/lib/src/ir/function.dart
+++ b/pkg/wasm_builder/lib/src/ir/function.dart
@@ -70,6 +70,7 @@
     // Bundle locals and body
     localS.write(body);
     s.writeUnsigned(localS.data.length);
+    s.sourceMapSerializer.copyMappings(localS.sourceMapSerializer, s.offset);
     s.writeData(localS);
   }
 
diff --git a/pkg/wasm_builder/lib/src/ir/instructions.dart b/pkg/wasm_builder/lib/src/ir/instructions.dart
index 6eec752..98658cc 100644
--- a/pkg/wasm_builder/lib/src/ir/instructions.dart
+++ b/pkg/wasm_builder/lib/src/ir/instructions.dart
@@ -2,6 +2,7 @@
 // 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 '../../source_map.dart';
 import '../serialize/serialize.dart';
 import 'ir.dart';
 
@@ -21,15 +22,46 @@
   /// A string trace.
   late final trace = _traceLines.join();
 
+  /// Mappings for the instructions in [_instructions] to their source code.
+  ///
+  /// Since we add mappings as we generate instructions, this will be sorted
+  /// based on [SourceMapping.instructionOffset].
+  final List<SourceMapping>? _sourceMappings;
+
   /// Create a new instruction sequence.
-  Instructions(
-      this.locals, this.instructions, this._stackTraces, this._traceLines);
+  Instructions(this.locals, this.instructions, this._stackTraces,
+      this._traceLines, this._sourceMappings);
 
   @override
   void serialize(Serializer s) {
-    for (final i in instructions) {
+    final sourceMappings = _sourceMappings;
+    int sourceMappingIdx = 0;
+    for (int instructionIdx = 0;
+        instructionIdx < instructions.length;
+        instructionIdx += 1) {
+      final i = instructions[instructionIdx];
       if (_stackTraces != null) s.debugTrace(_stackTraces![i]!);
+
+      if (sourceMappings != null) {
+        // Skip to the mapping that covers the current instruction.
+        while (sourceMappingIdx < sourceMappings.length - 1 &&
+            sourceMappings[sourceMappingIdx + 1].instructionOffset <=
+                instructionIdx) {
+          sourceMappingIdx += 1;
+        }
+
+        if (sourceMappingIdx < sourceMappings.length) {
+          final mapping = sourceMappings[sourceMappingIdx];
+          if (mapping.instructionOffset <= instructionIdx) {
+            s.sourceMapSerializer.addMapping(s.offset, mapping.sourceInfo);
+            sourceMappingIdx += 1;
+          }
+        }
+      }
+
       i.serialize(s);
     }
+
+    s.sourceMapSerializer.addMapping(s.offset, null);
   }
 }
diff --git a/pkg/wasm_builder/lib/src/ir/module.dart b/pkg/wasm_builder/lib/src/ir/module.dart
index 945a985..6ab8585 100644
--- a/pkg/wasm_builder/lib/src/ir/module.dart
+++ b/pkg/wasm_builder/lib/src/ir/module.dart
@@ -17,8 +17,10 @@
   final DataSegments dataSegments;
   final List<Import> imports;
   final List<int> watchPoints;
+  final Uri? sourceMapUrl;
 
   Module(
+      this.sourceMapUrl,
       this.functions,
       this.tables,
       this.tags,
@@ -51,6 +53,10 @@
     DataCountSection(dataSegments.defined, watchPoints).serialize(s);
     CodeSection(functions.defined, watchPoints).serialize(s);
     DataSection(dataSegments.defined, watchPoints).serialize(s);
+    if (sourceMapUrl != null) {
+      SourceMapSection(sourceMapUrl.toString()).serialize(s);
+    }
+
     if (functions.namedCount > 0 ||
         types.namedCount > 0 ||
         globals.namedCount > 0) {
diff --git a/pkg/wasm_builder/lib/src/serialize/sections.dart b/pkg/wasm_builder/lib/src/serialize/sections.dart
index df3a9e6..4c71841 100644
--- a/pkg/wasm_builder/lib/src/serialize/sections.dart
+++ b/pkg/wasm_builder/lib/src/serialize/sections.dart
@@ -17,6 +17,8 @@
       serializeContents(contents);
       s.writeByte(id);
       s.writeUnsigned(contents.data.length);
+      s.sourceMapSerializer
+          .copyMappings(contents.sourceMapSerializer, s.offset);
       s.writeData(contents, watchPoints);
     }
   }
@@ -390,3 +392,18 @@
     s.writeData(globalNameSubsection);
   }
 }
+
+class SourceMapSection extends CustomSection {
+  final String url;
+
+  SourceMapSection(this.url) : super([]);
+
+  @override
+  bool get isNotEmpty => true;
+
+  @override
+  void serializeContents(Serializer s) {
+    s.writeName("sourceMappingURL");
+    s.writeName(url);
+  }
+}
diff --git a/pkg/wasm_builder/lib/src/serialize/serialize.dart b/pkg/wasm_builder/lib/src/serialize/serialize.dart
index 5643c15..c617ff1 100644
--- a/pkg/wasm_builder/lib/src/serialize/serialize.dart
+++ b/pkg/wasm_builder/lib/src/serialize/serialize.dart
@@ -11,11 +11,12 @@
         ElementSection,
         ExportSection,
         FunctionSection,
-        ImportSection,
         GlobalSection,
+        ImportSection,
         MemorySection,
         NameSection,
+        SourceMapSection,
         StartSection,
-        TagSection,
         TableSection,
+        TagSection,
         TypeSection;
diff --git a/pkg/wasm_builder/lib/src/serialize/serializer.dart b/pkg/wasm_builder/lib/src/serialize/serializer.dart
index ae2fc7b..9ca3f52 100644
--- a/pkg/wasm_builder/lib/src/serialize/serializer.dart
+++ b/pkg/wasm_builder/lib/src/serialize/serializer.dart
@@ -6,6 +6,8 @@
 import 'dart:convert';
 import 'dart:typed_data';
 
+import '../../source_map.dart';
+
 abstract class Serializable {
   void serialize(Serializer s);
 }
@@ -23,6 +25,11 @@
   // chunk of data produced by this serializer.
   late final SplayTreeMap<int, Object> _traces = SplayTreeMap();
 
+  /// Get the current offset in the serialized data.
+  int get offset => _index;
+
+  final SourceMapSerializer sourceMapSerializer = SourceMapSerializer();
+
   void _ensure(int size) {
     // Ensure space for at least `size` additional bytes.
     if (_data.length < _index + size) {
diff --git a/tests/web/wasm/source_map_simple_test.dart b/tests/web/wasm/source_map_simple_test.dart
new file mode 100644
index 0000000..2197ae6
--- /dev/null
+++ b/tests/web/wasm/source_map_simple_test.dart
@@ -0,0 +1,119 @@
+// Copyright (c) 2024, 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.
+
+// dart2wasmOptions=--extra-compiler-option=-DTEST_COMPILATION_DIR=$TEST_COMPILATION_DIR
+
+import 'dart:js_interop';
+import 'dart:typed_data';
+import 'dart:convert';
+
+import 'package:source_maps/parser.dart';
+
+void f() {
+  g();
+}
+
+void g() {
+  throw 'hi';
+}
+
+void main() {
+  // Read source map of the current program.
+  final compilationDir = const String.fromEnvironment("TEST_COMPILATION_DIR");
+  final sourceMapFileContents =
+      readfile('$compilationDir/source_map_simple_test.wasm.map');
+  final mapping = parse(utf8.decode(sourceMapFileContents)) as SingleMapping;
+
+  // Get some simple stack trace.
+  String? stackTraceString;
+  try {
+    f();
+  } catch (e, st) {
+    stackTraceString = st.toString();
+  }
+
+  // Print the stack trace to make it easy to update the test.
+  print("-----");
+  print(stackTraceString);
+  print("-----");
+
+  final stackTraceLines = stackTraceString!.split('\n');
+
+  // Stack trace should look like:
+  //
+  //   at Error._throwWithCurrentStackTrace (wasm://wasm/00118b26:wasm-function[144]:0x163e0)
+  //   at f (wasm://wasm/00118b26:wasm-function[996]:0x243dd)
+  //   at main (wasm://wasm/00118b26:wasm-function[988]:0x241dc)
+  //   at main tear-off trampoline (wasm://wasm/00118b26:wasm-function[990]:0x24340)
+  //   at _invokeMain (wasm://wasm/00118b26:wasm-function[104]:0x15327)
+  //   at Module.invoke (/usr/local/google/home/omersa/dart/sdk/test/test.mjs:317:26)
+  //   at main (/usr/local/google/home/omersa/dart/sdk/sdk/pkg/dart2wasm/bin/run_wasm.js:421:21)
+  //   at async action (/usr/local/google/home/omersa/dart/sdk/sdk/pkg/dart2wasm/bin/run_wasm.js:350:37)
+  //
+  // The first 5 frames are in Wasm, but "main tear-off trampoline" shouldn't
+  // have a source mapping as it's compiler generated.
+
+  // (function name, line, column) of the frames we check.
+  //
+  // Information we don't check are "null": we don't want to check line/column
+  // of standard library functions to avoid breaking the test with unrelated
+  // changes to the standard library.
+  const List<(String, int?, int?)?> frameDetails = [
+    ("_throwWithCurrentStackTrace", null, null),
+    ("g", 18, 3),
+    ("f", 14, 3),
+    ("main", 31, 5),
+    null, // compiler generated, not mapped
+    ("_invokeMain", null, null),
+  ];
+
+  for (int frameIdx = 0; frameIdx < frameDetails.length; frameIdx += 1) {
+    final line = stackTraceLines[frameIdx];
+    final hexOffsetMatch = stackTraceHexOffsetRegExp.firstMatch(line);
+    if (hexOffsetMatch == null) {
+      throw "Unable to parse hex offset from stack frame $frameIdx";
+    }
+    final hexOffsetStr = hexOffsetMatch.group(1)!; // includes '0x'
+    final offset = int.tryParse(hexOffsetStr);
+    if (offset == null) {
+      throw "Unable to parse hex number in frame $frameIdx: $hexOffsetStr";
+    }
+    final span = mapping.spanFor(0, offset);
+    final frameInfo = frameDetails[frameIdx];
+    if (frameInfo == null) {
+      if (span != null) {
+        throw "Stack frame $frameIdx should not have a source span, but it is mapped: $span";
+      }
+      continue;
+    }
+    if (span == null) {
+      throw "Stack frame $frameIdx does not have source mapping";
+    }
+    final funName = span.text;
+    if (frameInfo.$1 != funName) {
+      throw "Stack frame $frameIdx does not have expected name: expected ${frameInfo.$1}, found $funName";
+    }
+    if (frameInfo.$2 != null) {
+      if (span.start.line + 1 != frameInfo.$2) {
+        throw "Stack frame $frameIdx is expected to have line ${frameInfo.$2}, but it has line ${span.start.line + 1}";
+      }
+    }
+    if (frameInfo.$3 != null) {
+      if (span.start.column + 1 != frameInfo.$3) {
+        throw "Stack frame $frameIdx is expected to have column ${frameInfo.$3}, but it has column ${span.start.column + 1}";
+      }
+    }
+  }
+}
+
+/// Read the file at the given [path].
+///
+/// This relies on the `readbuffer` function provided by d8.
+@JS()
+external JSArrayBuffer readbuffer(JSString path);
+
+/// Read the file at the given [path].
+Uint8List readfile(String path) => Uint8List.view(readbuffer(path.toJS).toDart);
+
+final stackTraceHexOffsetRegExp = RegExp(r'wasm-function.*(0x[0-9a-fA-F]+)\)$');
diff --git a/tests/web/web.status b/tests/web/web.status
index f09a47a..55cc136 100644
--- a/tests/web/web.status
+++ b/tests/web/web.status
@@ -8,6 +8,9 @@
 [ $compiler == dart2wasm ]
 (?!wasm)*: SkipByDesign
 
+[ $compiler == dart2wasm && $runtime != d8 ]
+wasm/source_map_simple_test: SkipByDesign
+
 [ $compiler != dart2wasm ]
 wasm/*: SkipByDesign