diff --git a/packages/devtools_app/lib/src/framework/framework_core.dart b/packages/devtools_app/lib/src/framework/framework_core.dart index 7d48749b1a9..08f50bf419c 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 'package:vm_service/vm_service.dart'; 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 @@ extension FrameworkCore on Never { final finishedCompleter = Completer(); try { - final service = await connect( - uri: uri, - finishedCompleter: finishedCompleter, - serviceFactory: - ({ - // ignore: avoid-dynamic, mirrors types of [VmServiceFactory]. - required Stream /*String|List*/ 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( + uri: uri, + finishedCompleter: finishedCompleter, + serviceFactory: + ({ + // ignore: avoid-dynamic, mirrors types of [VmServiceFactory]. + required Stream /*String|List*/ 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 00000000000..f324258255d --- /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 connectWithMessagePort({ + required Uri uri, + required Completer 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 00000000000..c2a968ea57a --- /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 connectWithMessagePort({ + required Uri uri, + required Completer 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(); + 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()) { + messages.add((data as JSString).toDart); + } else if (data.isA()) { + messages.add((data as JSUint8Array).toDart); + } else { + _log.warning('Ignoring VM service message of unsupported type: $data'); + } + }.toJS; + + late final StreamSubscription 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( + '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 00000000000..cfa7e452ada --- /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:`, 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 22f639d72d2..1b3ffbfb692 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 @@ To learn more about DevTools, check out the * 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:`. + [#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 00000000000..843fcfc7ffc --- /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(), + ), + 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 00000000000..7e21a7aed32 --- /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(); + 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(); + port.onmessage = (MessageEvent event) { + final data = event.data; + if (data == null) { + closedByDevTools.complete(); + return; + } + final request = + jsonDecode((data as JSString).toDart) as Map; + port.postMessage( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': request['id'], + 'result': request['method'] == 'getVersion' + ? {'type': 'Version', 'major': 4, 'minor': 23} + : {'type': 'ProtocolList', 'protocols': []}, + }).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; +}