[ package:dds ] Add DartDevelopmentServiceLauncher class

Provides shared logic for launching DDS using `dart
development-service`.

Change-Id: I934c777ada23206b498731ffb0611ed3bd383f1c
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/378320
Commit-Queue: Ben Konyi <bkonyi@google.com>
Reviewed-by: Derek Xu <derekx@google.com>
diff --git a/pkg/dds/CHANGELOG.md b/pkg/dds/CHANGELOG.md
index 499429d..cd0d884 100644
--- a/pkg/dds/CHANGELOG.md
+++ b/pkg/dds/CHANGELOG.md
@@ -1,6 +1,7 @@
 # 4.2.5
 - Fixed DevTools URI not including a trailing '/' before the query parameters, which could prevent DevTools from loading properly.
 - [DAP] Fixed an issue where format specifiers and `format.hex` in `variablesRequest` would not apply to values from lists such as `Uint8List` from `dart:typed_data`.
+- Added `package:dds/dds_launcher.dart`, a library which can be used to launch DDS instances using `dart development-service`.
 
 # 4.2.4+1
 - Added missing type to `Event` in `postEvent`.
diff --git a/pkg/dds/lib/dds.dart b/pkg/dds/lib/dds.dart
index 8b22313..7a6ed14 100644
--- a/pkg/dds/lib/dds.dart
+++ b/pkg/dds/lib/dds.dart
@@ -169,6 +169,8 @@
   static const String protocolVersion = '2.0';
 }
 
+/// Thrown by DDS during initialization failures, unexpected connection issues,
+/// and when attempting to spawn DDS when an existing DDS instance exists.
 class DartDevelopmentServiceException implements Exception {
   /// Set when `DartDeveloperService.startDartDevelopmentService` is called and
   /// the target VM service already has a Dart Developer Service instance
@@ -182,6 +184,33 @@
   /// Set when a connection error has occurred after startup.
   static const int connectionError = 3;
 
+  factory DartDevelopmentServiceException.fromJson(Map<String, Object?> json) {
+    if (json
+        case {
+          'error_code': final int errorCode,
+          'message': final String message,
+          'uri': final String? uri
+        }) {
+      return switch (errorCode) {
+        existingDdsInstanceError =>
+          DartDevelopmentServiceException.existingDdsInstance(
+            message,
+            ddsUri: Uri.parse(uri!),
+          ),
+        failedToStartError => DartDevelopmentServiceException.failedToStart(),
+        connectionError =>
+          DartDevelopmentServiceException.connectionIssue(message),
+        _ => throw StateError(
+            'Invalid DartDevelopmentServiceException error_code: $errorCode',
+          ),
+      };
+    }
+    throw StateError('Invalid DartDevelopmentServiceException JSON: $json');
+  }
+
+  /// Thrown when `DartDeveloperService.startDartDevelopmentService` is called
+  /// and the target VM service already has a Dart Developer Service instance
+  /// connected.
   factory DartDevelopmentServiceException.existingDdsInstance(
     String message, {
     Uri? ddsUri,
@@ -192,11 +221,14 @@
     );
   }
 
+  /// Thrown when the connection to the remote VM service terminates unexpectedly
+  /// during Dart Development Service startup.
   factory DartDevelopmentServiceException.failedToStart() {
     return DartDevelopmentServiceException._(
         failedToStartError, 'Failed to start Dart Development Service');
   }
 
+  /// Thrown when a connection error has occurred after startup.
   factory DartDevelopmentServiceException.connectionIssue(String message) {
     return DartDevelopmentServiceException._(connectionError, message);
   }
@@ -215,6 +247,7 @@
   final String message;
 }
 
+/// Thrown when attempting to start a new DDS instance when one already exists.
 class ExistingDartDevelopmentServiceException
     extends DartDevelopmentServiceException {
   ExistingDartDevelopmentServiceException._(
diff --git a/pkg/dds/lib/dds_launcher.dart b/pkg/dds/lib/dds_launcher.dart
new file mode 100644
index 0000000..2e8ad80
--- /dev/null
+++ b/pkg/dds/lib/dds_launcher.dart
@@ -0,0 +1,198 @@
+// 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.
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'dds.dart' hide DartDevelopmentService;
+import 'src/arg_parser.dart';
+import 'src/dds_impl.dart';
+
+/// Spawns a Dart Development Service instance which will communicate with a
+/// VM service. Requires the target VM service to have no other connected
+/// clients.
+///
+/// [remoteVmServiceUri] is the address of the VM service that this
+/// development service will communicate with.
+///
+/// If provided, [serviceUri] will determine the address and port of the
+/// spawned Dart Development Service.
+///
+/// [enableAuthCodes] controls whether or not an authentication code must
+/// be provided by clients when communicating with this instance of
+/// DDS. Authentication codes take the form of a base64
+/// encoded string provided as the first element of the DDS path and is meant
+/// to make it more difficult for unintended clients to connect to this
+/// service. Authentication codes are enabled by default.
+///
+/// If [serveDevTools] is enabled, DDS will serve a DevTools instance and act
+/// as a DevTools Server. If not specified, [devToolsServerAddress] is ignored.
+///
+/// If provided, DDS will redirect DevTools requests to an existing DevTools
+/// server hosted at [devToolsServerAddress]. Ignored if [serveDevTools] is not
+/// true.
+///
+/// If [enableServicePortFallback] is enabled, DDS will attempt to bind to any
+/// available port if the specified port is unavailable.
+///
+/// If set, the set of [cachedUserTags] will be used to determine which CPU
+/// samples should be cached by DDS.
+///
+/// If provided, [dartExecutable] is the path to the 'dart' executable that
+/// should be used to spawn the DDS instance. By default, `Platform.executable`
+/// is used.
+class DartDevelopmentServiceLauncher {
+  static Future<DartDevelopmentServiceLauncher> start({
+    required Uri remoteVmServiceUri,
+    Uri? serviceUri,
+    bool enableAuthCodes = true,
+    bool serveDevTools = false,
+    Uri? devToolsServerAddress,
+    bool enableServicePortFallback = false,
+    List<String> cachedUserTags = const <String>[],
+    String? dartExecutable,
+  }) async {
+    final process = await Process.start(
+      dartExecutable ?? Platform.executable,
+      <String>[
+        'development-service',
+        '--${DartDevelopmentServiceOptions.vmServiceUriOption}=$remoteVmServiceUri',
+        if (serviceUri != null) ...<String>[
+          '--${DartDevelopmentServiceOptions.bindAddressOption}=${serviceUri.host}',
+          '--${DartDevelopmentServiceOptions.bindPortOption}=${serviceUri.port}',
+        ],
+        if (!enableAuthCodes)
+          '--${DartDevelopmentServiceOptions.disableServiceAuthCodesFlag}',
+        if (serveDevTools)
+          '--${DartDevelopmentServiceOptions.serveDevToolsFlag}',
+        if (devToolsServerAddress != null)
+          '--${DartDevelopmentServiceOptions.devToolsServerAddressOption}=$devToolsServerAddress',
+        if (enableServicePortFallback)
+          '--${DartDevelopmentServiceOptions.enableServicePortFallbackFlag}',
+        for (final String tag in cachedUserTags)
+          '--${DartDevelopmentServiceOptions.cachedUserTagsOption}=$tag',
+      ],
+    );
+    final completer = Completer<DartDevelopmentServiceLauncher>();
+    late StreamSubscription<Object?> stderrSub;
+    stderrSub = process.stderr
+        .transform(utf8.decoder)
+        .transform(json.decoder)
+        .listen((Object? result) {
+      if (result
+          case {
+            'state': 'started',
+            'ddsUri': final String ddsUriStr,
+          }) {
+        final ddsUri = Uri.parse(ddsUriStr);
+        final devToolsUriStr = result['devToolsUri'] as String?;
+        final devToolsUri =
+            devToolsUriStr == null ? null : Uri.parse(devToolsUriStr);
+        final dtdUriStr =
+            (result['dtd'] as Map<String, Object?>?)?['uri'] as String?;
+        final dtdUri = dtdUriStr == null ? null : Uri.parse(dtdUriStr);
+
+        completer.complete(
+          DartDevelopmentServiceLauncher._(
+            process: process,
+            uri: ddsUri,
+            devToolsUri: devToolsUri,
+            dtdUri: dtdUri,
+          ),
+        );
+      } else if (result
+          case {
+            'state': 'error',
+            'error': final String error,
+          }) {
+        final Map<String, Object?>? exceptionDetails =
+            result['ddsExceptionDetails'] as Map<String, Object?>?;
+        completer.completeError(
+          exceptionDetails != null
+              ? DartDevelopmentServiceException.fromJson(exceptionDetails)
+              : StateError(error),
+        );
+      } else {
+        throw StateError('Unexpected result from DDS: $result');
+      }
+      stderrSub.cancel();
+    });
+    return completer.future;
+  }
+
+  DartDevelopmentServiceLauncher._({
+    required Process process,
+    required this.uri,
+    required this.devToolsUri,
+    required this.dtdUri,
+  }) : _ddsInstance = process;
+
+  final Process _ddsInstance;
+
+  /// The [Uri] VM service clients can use to communicate with this
+  /// DDS instance via HTTP.
+  final Uri uri;
+
+  /// The HTTP [Uri] of the hosted DevTools instance.
+  ///
+  /// Returns `null` if DevTools is not running.
+  final Uri? devToolsUri;
+
+  /// The [Uri] of the Dart Tooling Daemon instance that is hosted by DevTools.
+  ///
+  /// This will be null if DTD was not started by the DevTools server. For
+  /// example, it may have been started by an IDE.
+  final Uri? dtdUri;
+
+  /// The [Uri] VM service clients can use to communicate with this
+  /// DDS instance via server-sent events (SSE).
+  Uri get sseUri => _toSse(uri)!;
+
+  /// The [Uri] VM service clients can use to communicate with this
+  /// DDS instance via a [WebSocket].
+  Uri get wsUri => _toWebSocket(uri)!;
+
+  List<String> _cleanupPathSegments(Uri uri) {
+    final pathSegments = <String>[];
+    if (uri.pathSegments.isNotEmpty) {
+      pathSegments.addAll(
+        uri.pathSegments.where(
+          // Strip out the empty string that appears at the end of path segments.
+          // Empty string elements will result in an extra '/' being added to the
+          // URI.
+          (s) => s.isNotEmpty,
+        ),
+      );
+    }
+    return pathSegments;
+  }
+
+  Uri? _toWebSocket(Uri? uri) {
+    if (uri == null) {
+      return null;
+    }
+    final pathSegments = _cleanupPathSegments(uri);
+    pathSegments.add('ws');
+    return uri.replace(scheme: 'ws', pathSegments: pathSegments);
+  }
+
+  Uri? _toSse(Uri? uri) {
+    if (uri == null) {
+      return null;
+    }
+    final pathSegments = _cleanupPathSegments(uri);
+    pathSegments.add(DartDevelopmentServiceImpl.kSseHandlerPath);
+    return uri.replace(scheme: 'sse', pathSegments: pathSegments);
+  }
+
+  /// Completes when the DDS instance has shutdown.
+  Future<void> get done => _ddsInstance.exitCode;
+
+  /// Shutdown the DDS instance.
+  Future<void> shutdown() {
+    _ddsInstance.kill();
+    return _ddsInstance.exitCode;
+  }
+}
diff --git a/pkg/dds/lib/src/dds_impl.dart b/pkg/dds/lib/src/dds_impl.dart
index 376855e..69dcfd4 100644
--- a/pkg/dds/lib/src/dds_impl.dart
+++ b/pkg/dds/lib/src/dds_impl.dart
@@ -347,8 +347,8 @@
   Handler _sseHandler() {
     final handler = SseHandler(
       authCodesEnabled
-          ? Uri.parse('/$authCode/$_kSseHandlerPath')
-          : Uri.parse('/$_kSseHandlerPath'),
+          ? Uri.parse('/$authCode/$kSseHandlerPath')
+          : Uri.parse('/$kSseHandlerPath'),
       keepAlive: sseKeepAlive,
     );
 
@@ -441,7 +441,7 @@
       return null;
     }
     final pathSegments = _cleanupPathSegments(uri);
-    pathSegments.add(_kSseHandlerPath);
+    pathSegments.add(kSseHandlerPath);
     return uri.replace(scheme: 'sse', pathSegments: pathSegments);
   }
 
@@ -552,7 +552,7 @@
   StreamManager get streamManager => _streamManager;
   late StreamManager _streamManager;
 
-  static const _kSseHandlerPath = '\$debugHandler';
+  static const kSseHandlerPath = '\$debugHandler';
 
   late json_rpc.Peer vmServiceClient;
   late WebSocketChannel _vmServiceSocket;
diff --git a/pkg/dds/test/launcher_smoke_test.dart b/pkg/dds/test/launcher_smoke_test.dart
new file mode 100644
index 0000000..80fc54b
--- /dev/null
+++ b/pkg/dds/test/launcher_smoke_test.dart
@@ -0,0 +1,72 @@
+// 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.
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:dds/dds_launcher.dart';
+import 'package:test/test.dart';
+import 'package:vm_service/vm_service_io.dart';
+
+import 'common/test_helper.dart';
+
+void main() {
+  group('DDS', () {
+    late Process process;
+    late DartDevelopmentServiceLauncher dds;
+
+    setUp(() async {
+      process = await spawnDartProcess('smoke.dart');
+    });
+
+    tearDown(() async {
+      await dds.shutdown();
+      process.kill();
+    });
+
+    void createSmokeTest(bool useAuthCodes) {
+      test(
+        'Launcher Smoke Test with ${useAuthCodes ? "" : "no"} authentication codes',
+        () async {
+          dds = await DartDevelopmentServiceLauncher.start(
+            remoteVmServiceUri: remoteVmServiceUri,
+            enableAuthCodes: useAuthCodes,
+          );
+
+          // Ensure basic websocket requests are forwarded correctly to the VM service.
+          final service = await vmServiceConnectUri(dds.wsUri.toString());
+          final version = await service.getVersion();
+          expect(version.major! > 0, true);
+          expect(version.minor! >= 0, true);
+
+          expect(
+            dds.uri.pathSegments,
+            useAuthCodes ? isNotEmpty : isEmpty,
+          );
+
+          // Ensure we can still make requests of the VM service via HTTP.
+          HttpClient client = HttpClient();
+          final request = await client.getUrl(remoteVmServiceUri.replace(
+            pathSegments: [
+              if (remoteVmServiceUri.pathSegments.isNotEmpty)
+                remoteVmServiceUri.pathSegments.first,
+              'getVersion',
+            ],
+          ));
+          final response = await request.close();
+          final Map<String, dynamic> jsonResponse = (await response
+              .transform(utf8.decoder)
+              .transform(json.decoder)
+              .single) as Map<String, dynamic>;
+          expect(jsonResponse['result']['type'], 'Version');
+          expect(jsonResponse['result']['major'] > 0, true);
+          expect(jsonResponse['result']['minor'] >= 0, true);
+        },
+      );
+    }
+
+    createSmokeTest(true);
+    createSmokeTest(false);
+  });
+}