[ package:dds ] Add support for registering external DevTools servers with DDS

External DevTools instances can be registered with DDS at runtime, allowing for DDS
to redirect DevTools requests to the external DevTools server.

TEST=pkg/dds/test/external_devtools_instance_test.dart

Change-Id: I0bed34029b6ea7d935f77a031ff99b73b986b068
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/278663
Reviewed-by: Kenzie Davisson <kenzieschmoll@google.com>
Commit-Queue: Ben Konyi <bkonyi@google.com>
diff --git a/pkg/dds/CHANGELOG.md b/pkg/dds/CHANGELOG.md
index 62f235b..282dd5c 100644
--- a/pkg/dds/CHANGELOG.md
+++ b/pkg/dds/CHANGELOG.md
@@ -1,3 +1,6 @@
+# 2.7.0
+- Added `DartDevelopmentService.setExternalDevToolsUri(Uri uri)`, adding support for registering an external DevTools server with DDS.
+
 # 2.6.1
 - [DAP] Fix a crash handling errors when fetching full strings in evaluation and logging events.
 
diff --git a/pkg/dds/lib/dds.dart b/pkg/dds/lib/dds.dart
index 177a926..03cbbf4 100644
--- a/pkg/dds/lib/dds.dart
+++ b/pkg/dds/lib/dds.dart
@@ -103,6 +103,13 @@
   /// Stop accepting requests after gracefully handling existing requests.
   Future<void> shutdown();
 
+  /// Registers an external DevTools server with this instance of
+  /// [DartDevelopmentService] to allow for DDS to redirect DevTools requests
+  /// to the DevTools server.
+  ///
+  /// Throws a [StateError] if DevTools is already being served by DDS.
+  void setExternalDevToolsUri(Uri uri);
+
   /// Set to `true` if this instance of [DartDevelopmentService] requires an
   /// authentication code to connect.
   bool get authCodesEnabled;
diff --git a/pkg/dds/lib/src/dds_impl.dart b/pkg/dds/lib/src/dds_impl.dart
index 33be98f..c6d6067 100644
--- a/pkg/dds/lib/src/dds_impl.dart
+++ b/pkg/dds/lib/src/dds_impl.dart
@@ -320,18 +320,41 @@
   }
 
   Handler _httpHandler() {
+    final notFoundHandler = proxyHandler(remoteVmServiceUri);
+
+    // If DDS is serving DevTools, install the DevTools handlers and forward
+    // any unhandled HTTP requests to the VM service.
     if (_devToolsConfiguration != null && _devToolsConfiguration!.enable) {
-      // Install the DevTools handlers and forward any unhandled HTTP requests to
-      // the VM service.
       final String buildDir =
           _devToolsConfiguration!.customBuildDirectoryPath.toFilePath();
       return defaultHandler(
         dds: this,
         buildDir: buildDir,
-        notFoundHandler: proxyHandler(remoteVmServiceUri),
+        notFoundHandler: notFoundHandler,
       ) as FutureOr<Response> Function(Request);
     }
-    return proxyHandler(remoteVmServiceUri);
+
+    // Otherwise, DevTools may be served externally, or not at all.
+    return (Request request) {
+      final pathSegments = request.url.pathSegments;
+      if (pathSegments.isEmpty || pathSegments.first != 'devtools') {
+        // Not a DevTools request, forward to the VM service.
+        return notFoundHandler(request);
+      } else {
+        if (_devToolsUri == null) {
+          // DevTools is not being served externally.
+          return Response.notFound(
+            'No DevTools instance is registered with the Dart Development Service (DDS).',
+          );
+        }
+        // Redirect to the external DevTools server.
+        return Response.seeOther(
+          _devToolsUri!.replace(
+            queryParameters: request.requestedUri.queryParameters,
+          ),
+        );
+      }
+    };
   }
 
   List<String> _cleanupPathSegments(Uri uri) {
@@ -365,7 +388,8 @@
     return uri.replace(scheme: 'sse', pathSegments: pathSegments);
   }
 
-  Uri? _toDevTools(Uri? uri) {
+  @visibleForTesting
+  Uri? toDevTools(Uri? uri) {
     // The DevTools URI is a bit strange as the query parameters appear after
     // the fragment. There's no nice way to encode the query parameters
     // properly, so we create another Uri just to grab the formatted query.
@@ -419,8 +443,20 @@
   Uri? get wsUri => _toWebSocket(_uri);
 
   @override
-  Uri? get devToolsUri =>
-      _devToolsConfiguration?.enable ?? false ? _toDevTools(_uri) : null;
+  Uri? get devToolsUri {
+    _devToolsUri ??=
+        _devToolsConfiguration?.enable ?? false ? toDevTools(_uri) : null;
+    return _devToolsUri;
+  }
+
+  void setExternalDevToolsUri(Uri uri) {
+    if (_devToolsConfiguration?.enable ?? false) {
+      throw StateError('A hosted DevTools instance is already being served.');
+    }
+    _devToolsUri = uri;
+  }
+
+  Uri? _devToolsUri;
 
   final bool _ipv6;
 
diff --git a/pkg/dds/pubspec.yaml b/pkg/dds/pubspec.yaml
index ffdec1d..ff75532 100644
--- a/pkg/dds/pubspec.yaml
+++ b/pkg/dds/pubspec.yaml
@@ -1,5 +1,5 @@
 name: dds
-version: 2.6.1
+version: 2.7.0
 description: >-
   A library used to spawn the Dart Developer Service, used to communicate with
   a Dart VM Service instance.
diff --git a/pkg/dds/test/common/test_helper.dart b/pkg/dds/test/common/test_helper.dart
index 7325d97..35dc0cf 100644
--- a/pkg/dds/test/common/test_helper.dart
+++ b/pkg/dds/test/common/test_helper.dart
@@ -12,6 +12,7 @@
 
 Future<Process> spawnDartProcess(
   String script, {
+  bool serveObservatory = true,
   bool pauseOnStart = true,
   bool disableServiceAuthCodes = false,
 }) async {
@@ -23,6 +24,7 @@
   final arguments = [
     '--disable-dart-dev',
     '--observe=0',
+    if (!serveObservatory) '--no-serve-observatory',
     if (pauseOnStart) '--pause-isolates-on-start',
     if (disableServiceAuthCodes) '--disable-service-auth-codes',
     '--write-service-info=$serviceInfoUri',
diff --git a/pkg/dds/test/external_devtools_instance_test.dart b/pkg/dds/test/external_devtools_instance_test.dart
new file mode 100644
index 0000000..3756f28
--- /dev/null
+++ b/pkg/dds/test/external_devtools_instance_test.dart
@@ -0,0 +1,89 @@
+// 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:convert';
+import 'dart:io';
+
+import 'package:dds/dds.dart';
+import 'package:dds/devtools_server.dart';
+import 'package:dds/src/dds_impl.dart';
+
+import 'package:test/test.dart';
+import 'common/test_helper.dart';
+
+void main() {
+  Process? process;
+  DartDevelopmentService? ddService;
+  HttpServer? devToolsServer;
+
+  setUp(() async {
+    // We don't care what's actually running in the target process for this
+    // test, so we're just using an existing one that invokes `debugger()` so
+    // we know it won't exit before we can connect.
+    process = await spawnDartProcess(
+      'get_stream_history_script.dart',
+      serveObservatory: false,
+      pauseOnStart: false,
+    );
+
+    devToolsServer = (await DevToolsServer().serveDevTools(
+      customDevToolsPath: devtoolsAppUri(prefix: '../../../').toFilePath(),
+    ))!;
+  });
+
+  tearDown(() async {
+    devToolsServer?.close(force: true);
+    await ddService?.shutdown();
+    process?.kill();
+    ddService = null;
+    process = null;
+    devToolsServer = null;
+  });
+
+  defineTest({required bool authCodesEnabled}) {
+    test(
+        'Ensure external DevTools assets are available with '
+        '${authCodesEnabled ? '' : 'no'} auth codes', () async {
+      ddService = await DartDevelopmentService.startDartDevelopmentService(
+        remoteVmServiceUri,
+      );
+      final dds = ddService!;
+      expect(dds.isRunning, true);
+      expect(dds.devToolsUri, isNull);
+
+      final client = HttpClient();
+      final ddsDevToolsUri =
+          (dds as DartDevelopmentServiceImpl).toDevTools(dds.uri!)!;
+
+      // Check that DevTools assets are not accessible before registering the
+      // DevTools server URI with DDS.
+      final badRequest = await client.getUrl(ddsDevToolsUri);
+      final badResponse = await badRequest.close();
+      expect(badResponse.statusCode, HttpStatus.notFound);
+      badResponse.drain();
+
+      // Register the external DevTools server URI with DDS.
+      dds.setExternalDevToolsUri(
+        Uri.parse(
+          'http://${devToolsServer!.address.host}:${devToolsServer!.port}',
+        ),
+      );
+
+      // Check that DevTools assets are accessible via the DDS DevTools URI.
+      final devtoolsRequest = await client.getUrl(ddsDevToolsUri);
+      final devtoolsResponse = await devtoolsRequest.close();
+
+      // DevTools should be served from the DevTools server port, not the DDS port.
+      expect(devtoolsResponse.connectionInfo!.remotePort, devToolsServer!.port);
+      expect(devtoolsResponse.statusCode, 200);
+      final devtoolsContent =
+          await devtoolsResponse.transform(utf8.decoder).join();
+      expect(devtoolsContent, startsWith('<!DOCTYPE html>'));
+      client.close();
+    });
+  }
+
+  defineTest(authCodesEnabled: true);
+  defineTest(authCodesEnabled: false);
+}