Support connecting to VM service via MessagePort / postMessage (#9964)

* Support connecting to a VM service over a MessagePort

Adds `?uri=messageport:<targetOrigin>`. On every connect, DevTools posts
a new `MessagePort` to `window.parent`, and the embedding page connects
it to a VM service. This lets pages such as DartPad, whose VM service
runs in a web worker, embed DevTools without a WebSocket.

* Address review feedback: validate targetOrigin, close port with null, and add connect timeout

* Close port on pagehide instead of unload
diff --git a/packages/devtools_app/lib/src/framework/framework_core.dart b/packages/devtools_app/lib/src/framework/framework_core.dart
index 7d48749..08f50bf 100644
--- a/packages/devtools_app/lib/src/framework/framework_core.dart
+++ b/packages/devtools_app/lib/src/framework/framework_core.dart
@@ -18,6 +18,7 @@
 
 import '../extensions/extension_service.dart';
 import '../screens/debugger/breakpoint_manager.dart';
+import '../service/message_port_connection.dart';
 import '../service/service_manager.dart';
 import '../service/vm_service_wrapper.dart';
 import '../shared/analytics/analytics.dart' as ga;
@@ -140,29 +141,34 @@
       final finishedCompleter = Completer<void>();
 
       try {
-        final service = await connect<VmServiceWrapper>(
-          uri: uri,
-          finishedCompleter: finishedCompleter,
-          serviceFactory:
-              ({
-                // ignore: avoid-dynamic, mirrors types of [VmServiceFactory].
-                required Stream<dynamic> /*String|List<int>*/ inStream,
-                required void Function(String message) writeMessage,
-                Log? log,
-                DisposeHandler? disposeHandler,
-                Future? streamClosed,
-                String? wsUri,
-                bool trackFutures = false,
-              }) => VmServiceWrapper.defaultFactory(
-                inStream: inStream,
-                writeMessage: writeMessage,
-                log: log,
-                disposeHandler: disposeHandler,
-                streamClosed: streamClosed,
-                wsUri: wsUri,
-                trackFutures: integrationTestMode,
-              ),
-        );
+        final service = isMessagePortUri(uri)
+            ? await connectWithMessagePort(
+                uri: uri,
+                finishedCompleter: finishedCompleter,
+              )
+            : await connect<VmServiceWrapper>(
+                uri: uri,
+                finishedCompleter: finishedCompleter,
+                serviceFactory:
+                    ({
+                      // ignore: avoid-dynamic, mirrors types of [VmServiceFactory].
+                      required Stream<dynamic> /*String|List<int>*/ inStream,
+                      required void Function(String message) writeMessage,
+                      Log? log,
+                      DisposeHandler? disposeHandler,
+                      Future? streamClosed,
+                      String? wsUri,
+                      bool trackFutures = false,
+                    }) => VmServiceWrapper.defaultFactory(
+                      inStream: inStream,
+                      writeMessage: writeMessage,
+                      log: log,
+                      disposeHandler: disposeHandler,
+                      streamClosed: streamClosed,
+                      wsUri: wsUri,
+                      trackFutures: integrationTestMode,
+                    ),
+              );
 
         await serviceConnection.serviceManager.vmServiceOpened(
           service,
diff --git a/packages/devtools_app/lib/src/service/_message_port_connection_stub.dart b/packages/devtools_app/lib/src/service/_message_port_connection_stub.dart
new file mode 100644
index 0000000..f324258
--- /dev/null
+++ b/packages/devtools_app/lib/src/service/_message_port_connection_stub.dart
@@ -0,0 +1,25 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+// The stub must have the same parameters as the web implementation.
+// ignore_for_file: avoid-unused-parameters
+
+/// @docImport 'message_port_connection.dart';
+library;
+
+import 'dart:async';
+
+import 'vm_service_wrapper.dart';
+
+/// Connects to a VM service over a `MessagePort`, as described in
+/// [messagePortUriScheme].
+///
+/// This is only supported on the web, so this always throws an
+/// [UnsupportedError].
+Future<VmServiceWrapper> connectWithMessagePort({
+  required Uri uri,
+  required Completer<void> finishedCompleter,
+}) => throw UnsupportedError(
+  'Connecting to a VM service over a MessagePort is only supported on the web.',
+);
diff --git a/packages/devtools_app/lib/src/service/_message_port_connection_web.dart b/packages/devtools_app/lib/src/service/_message_port_connection_web.dart
new file mode 100644
index 0000000..c2a968e
--- /dev/null
+++ b/packages/devtools_app/lib/src/service/_message_port_connection_web.dart
@@ -0,0 +1,108 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+/// @docImport 'message_port_connection.dart';
+library;
+
+import 'dart:async';
+import 'dart:js_interop';
+
+import 'package:logging/logging.dart';
+import 'package:web/web.dart';
+
+import '../shared/globals.dart';
+import 'vm_service_wrapper.dart';
+
+final _log = Logger('message_port_connection');
+
+/// How long the embedder has to connect a new port and answer `getVersion`.
+const _connectTimeout = Duration(seconds: 10);
+
+/// Connects to a VM service over a `MessagePort`, as described in
+/// [messagePortUriScheme].
+///
+/// [finishedCompleter] is completed when the returned service is disposed.
+Future<VmServiceWrapper> connectWithMessagePort({
+  required Uri uri,
+  required Completer<void> finishedCompleter,
+}) async {
+  final embedder = window.openerCrossOrigin ?? window.parentCrossOrigin;
+  if (embedder == null) {
+    throw UnsupportedError(
+      'Connecting to a VM service over a MessagePort requires window.opener '
+      'or window.parent.',
+    );
+  }
+  final targetOrigin = uri.path;
+  if (targetOrigin.isEmpty) {
+    throw ArgumentError(
+      'The messageport URI must specify a target origin, e.g., '
+      'messageport:https://example.com',
+    );
+  }
+  // A new channel per connect, so reconnects and reloads just work.
+  final channel = MessageChannel();
+  embedder.postMessage(
+    _ConnectMessage(action: 'connect', port: channel.port2),
+    targetOrigin.toJS,
+    [channel.port2].toJS,
+  );
+
+  final port = channel.port1;
+  final messages = StreamController<Object>();
+  port.onmessage = (MessageEvent event) {
+    final data = event.data;
+    if (data == null) {
+      // The embedder closed the connection. The service disposes itself when
+      // its stream is done.
+      unawaited(messages.close());
+    } else if (data.isA<JSString>()) {
+      messages.add((data as JSString).toDart);
+    } else if (data.isA<JSUint8Array>()) {
+      messages.add((data as JSUint8Array).toDart);
+    } else {
+      _log.warning('Ignoring VM service message of unsupported type: $data');
+    }
+  }.toJS;
+
+  late final StreamSubscription<Event> pageHideSubscription;
+  void closePort() {
+    if (finishedCompleter.isCompleted) return;
+    unawaited(pageHideSubscription.cancel());
+    port
+      ..postMessage(null)
+      ..onmessage = null
+      ..close();
+    unawaited(messages.close());
+    finishedCompleter.complete();
+  }
+
+  // Close the port on pagehide, matching browser WebSocket behavior.
+  pageHideSubscription = const EventStreamProvider<Event>(
+    'pagehide',
+  ).forTarget(window).listen((_) => closePort());
+
+  final service = VmServiceWrapper.defaultFactory(
+    inStream: messages.stream,
+    writeMessage: (message) => port.postMessage(message.toJS),
+    disposeHandler: () async => closePort(),
+    wsUri: uri.toString(),
+    trackFutures: integrationTestMode,
+  );
+
+  // Verify the connection, like `connect` from `package:devtools_shared` does.
+  try {
+    await service.getVersion().timeout(_connectTimeout);
+  } catch (_) {
+    await service.dispose();
+    rethrow;
+  }
+  return service;
+}
+
+/// The message that asks the embedder to connect the transferred `port` to a
+/// VM service.
+extension type _ConnectMessage._(JSObject _) implements JSObject {
+  external factory _ConnectMessage({String action, MessagePort port});
+}
diff --git a/packages/devtools_app/lib/src/service/message_port_connection.dart b/packages/devtools_app/lib/src/service/message_port_connection.dart
new file mode 100644
index 0000000..cfa7e45
--- /dev/null
+++ b/packages/devtools_app/lib/src/service/message_port_connection.dart
@@ -0,0 +1,32 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+export '_message_port_connection_stub.dart'
+    if (dart.library.js_interop) '_message_port_connection_web.dart';
+
+/// URI scheme for connecting to a VM service over a [`MessagePort`][1] from
+/// the page embedding or opening DevTools.
+///
+/// For `?uri=messageport:<targetOrigin>`, DevTools sends a new `port` to its
+/// embedder on every connect, including reconnects and reloads:
+///
+/// ```js
+/// const embedder = window.opener ?? window.parent;
+/// embedder.postMessage({action: 'connect', port}, targetOrigin, [port]);
+/// ```
+///
+/// * `targetOrigin`: the embedder's origin, or `*`. Browsers silently drop the
+///   message on a mismatch.
+/// * `port`: the embedder connects it to a VM service. Messages are:
+///   * [VM service protocol][2] JSON-RPC strings,
+///   * binary frames as `Uint8Array`, or,
+///   * `null` to close the connection.
+/// * Only the latest `port` is used; older ones may be closed.
+///
+/// [1]: https://developer.mozilla.org/en-US/docs/Web/API/MessagePort
+/// [2]: https://github.com/dart-lang/sdk/blob/main/runtime/vm/service/service.md
+const messagePortUriScheme = 'messageport';
+
+/// Whether [uri] has the [messagePortUriScheme].
+bool isMessagePortUri(Uri uri) => uri.isScheme(messagePortUriScheme);
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 1c05daf..993227c 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -18,6 +18,9 @@
 * Fixed unreadable text in the release notes panel, where blockquotes were
   drawn on a hard coded light blue background in the dark theme.
   [#9957](https://github.com/flutter/devtools/pull/9957)
+* Added support for proxying the VM service connection over a `MessagePort`
+  using `?uri=messageport:<origin>`.
+  [#9964](https://github.com/flutter/devtools/pull/9964)
 
 ## Inspector updates
 
diff --git a/packages/devtools_app/test/service/message_port_connection_test.dart b/packages/devtools_app/test/service/message_port_connection_test.dart
new file mode 100644
index 0000000..843fcfc
--- /dev/null
+++ b/packages/devtools_app/test/service/message_port_connection_test.dart
@@ -0,0 +1,50 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'dart:async';
+
+import 'package:devtools_app/src/service/message_port_connection.dart';
+import 'package:devtools_shared/devtools_shared.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  group('isMessagePortUri', () {
+    test('accepts messageport URIs', () {
+      expect(isMessagePortUri(Uri.parse('messageport:*')), isTrue);
+      expect(
+        isMessagePortUri(Uri.parse('messageport:https://example.com')),
+        isTrue,
+      );
+      expect(isMessagePortUri(Uri.parse('MessagePort:*')), isTrue);
+    });
+
+    test('rejects other URIs', () {
+      expect(isMessagePortUri(Uri.parse('ws://127.0.0.1:8181/ws')), isFalse);
+      expect(isMessagePortUri(Uri.parse('http://127.0.0.1:8181/')), isFalse);
+      expect(isMessagePortUri(Uri.parse('sse://127.0.0.1:8181/')), isFalse);
+      expect(isMessagePortUri(Uri.parse('messageport')), isFalse);
+    });
+
+    test('survives normalizeVmServiceUri', () {
+      // `FrameworkCore.initVmService` normalizes the `uri` query parameter
+      // before checking the scheme, so the target origin must survive it.
+      for (final targetOrigin in ['https://example.com', '*']) {
+        final uri = normalizeVmServiceUri('messageport:$targetOrigin')!;
+        expect(isMessagePortUri(uri), isTrue);
+        expect(uri.path, targetOrigin);
+        expect(uri.toString(), 'messageport:$targetOrigin');
+      }
+    });
+  });
+
+  test('connectWithMessagePort is not supported outside the web', () {
+    expect(
+      () => connectWithMessagePort(
+        uri: Uri.parse('messageport:*'),
+        finishedCompleter: Completer<void>(),
+      ),
+      throwsUnsupportedError,
+    );
+  }, testOn: 'vm');
+}
diff --git a/packages/devtools_app/test/service/message_port_connection_web_test.dart b/packages/devtools_app/test/service/message_port_connection_web_test.dart
new file mode 100644
index 0000000..7e21a7a
--- /dev/null
+++ b/packages/devtools_app/test/service/message_port_connection_web_test.dart
@@ -0,0 +1,69 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+@TestOn('browser')
+library;
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:js_interop';
+
+import 'package:devtools_app/src/service/message_port_connection.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:web/web.dart';
+
+void main() {
+  test('connects and closes over a port posted to the parent', () async {
+    // The test runner loads each test suite in a same-origin iframe, so the
+    // test can play the page embedding DevTools.
+    final connectEvent = EventStreamProviders.messageEvent
+        .forTarget(window.parent)
+        .firstWhere((event) => event.source.strictEquals(window).toDart);
+    final finishedCompleter = Completer<void>();
+    final serviceFuture = connectWithMessagePort(
+      uri: Uri.parse('messageport:${window.location.origin}'),
+      finishedCompleter: finishedCompleter,
+    );
+
+    final message = (await connectEvent).data as _ConnectMessage;
+    expect(message.action, 'connect');
+    // Play the VM service. DevTools only calls `getSupportedProtocols` and
+    // `getVersion` here.
+    final port = message.port;
+    final closedByDevTools = Completer<void>();
+    port.onmessage = (MessageEvent event) {
+      final data = event.data;
+      if (data == null) {
+        closedByDevTools.complete();
+        return;
+      }
+      final request =
+          jsonDecode((data as JSString).toDart) as Map<String, Object?>;
+      port.postMessage(
+        jsonEncode({
+          'jsonrpc': '2.0',
+          'id': request['id'],
+          'result': request['method'] == 'getVersion'
+              ? {'type': 'Version', 'major': 4, 'minor': 23}
+              : {'type': 'ProtocolList', 'protocols': <Object?>[]},
+        }).toJS,
+      );
+    }.toJS;
+
+    final service = await serviceFuture;
+    expect((await service.getVersion()).major, 4);
+
+    // Either side posts `null` to close the connection.
+    port.postMessage(null);
+    await closedByDevTools.future;
+    expect(finishedCompleter.isCompleted, isTrue);
+  });
+}
+
+/// The message that DevTools posts to `window.parent`.
+extension type _ConnectMessage._(JSObject _) implements JSObject {
+  external String get action;
+
+  external MessagePort get port;
+}