blob: aad12ec0614b2904ae688179b9a3da69e415b106 [file] [log] [blame]
// Copyright (c) 2019, 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 'dart:isolate';
import 'package:crypto/crypto.dart';
import 'package:dwds/src/config/tool_configuration.dart';
import 'package:dwds/src/loaders/ddc_library_bundle.dart';
import 'package:dwds/src/version.dart';
import 'package:logging/logging.dart';
import 'package:shelf/shelf.dart';
/// File extension that build_web_compilers will place the
/// [entrypointExtensionMarker] in.
const bootstrapJsExtension = '.bootstrap.js';
/// Marker placed by build_web_compilers for where to put injected JS code.
const entrypointExtensionMarker = '/* ENTRYPOINT_EXTENTION_MARKER */';
/// Marker placed by build_web_compilers for where to put injected JS code.
const mainExtensionMarker = '/* MAIN_EXTENSION_MARKER */';
const _clientScript = 'dwds/src/injected/client';
/// This class is responsible for modifying the served JavaScript files
/// to include the injected DWDS client, enabling debugging capabilities
/// and source mapping when running in a browser environment.
///
/// The `_injectDebuggingSupportCode` flag determines whether debugging-related
/// functionality should be included:
/// - When `true`, the DWDS client is injected, enabling debugging features.
/// - When `false`, debugging support is disabled, meaning the application will
/// run without debugging.
///
/// This separation allows for scenarios where debugging is not needed or
/// should be explicitly avoided.
class DwdsInjector {
final Future<String>? _extensionUri;
final _devHandlerPaths = StreamController<String>();
final _logger = Logger('DwdsInjector');
final bool _injectDebuggingSupportCode;
DwdsInjector({
Future<String>? extensionUri,
bool injectDebuggingSupportCode = true,
}) : _extensionUri = extensionUri,
_injectDebuggingSupportCode = injectDebuggingSupportCode;
/// Returns the embedded dev handler paths.
///
/// This will be next to the requested entrypoints.
Stream<String> get devHandlerPaths => _devHandlerPaths.stream;
Middleware get middleware => (innerHandler) {
return (Request request) async {
if (request.url.path.endsWith('$_clientScript.js')) {
final uri = await Isolate.resolvePackageUri(
Uri.parse('package:$_clientScript.js'),
);
if (uri == null) {
throw StateError('Cannot resolve "package:$_clientScript.js"');
}
final result = await File(uri.toFilePath()).readAsString();
return Response.ok(
result,
headers: {HttpHeaders.contentTypeHeader: 'application/javascript'},
);
} else if (request.url.path.endsWith(bootstrapJsExtension)) {
final ifNoneMatch = request.headers[HttpHeaders.ifNoneMatchHeader];
if (ifNoneMatch != null) {
// Disable caching of the inner handler by manually modifying the
// if-none-match header before forwarding the request.
request = request.change(
headers: {HttpHeaders.ifNoneMatchHeader: '$ifNoneMatch\$injected'},
);
}
final response = await innerHandler(request);
if (response.statusCode == HttpStatus.notFound) return response;
var body = await response.readAsString();
var etag = response.headers[HttpHeaders.etagHeader];
final newHeaders = Map.of(response.headers);
if (body.startsWith(entrypointExtensionMarker)) {
// The requestedUri contains the hostname and port which guarantees
// uniqueness.
final requestedUri = request.requestedUri;
final appId = _base64Md5('$requestedUri');
var scheme = request.requestedUri.scheme;
if (!globalToolConfiguration.debugSettings.useSseForInjectedClient) {
// Switch http->ws and https->wss.
scheme = scheme.replaceFirst('http', 'ws');
}
final requestedUriBase =
'$scheme'
'://${request.requestedUri.authority}';
var devHandlerPath = '\$dwdsSseHandler';
final subPath = request.url.pathSegments.toList()..removeLast();
if (subPath.isNotEmpty) {
devHandlerPath = '${subPath.join('/')}/$devHandlerPath';
}
_logger.info('Received request for entrypoint at $requestedUri');
devHandlerPath = '$requestedUriBase/$devHandlerPath';
_devHandlerPaths.add(devHandlerPath);
final entrypoint = request.url.path;
await globalToolConfiguration.loadStrategy.trackEntrypoint(
entrypoint,
);
// If true, inject the debugging client and hoist the main function
// to enable debugging support.
if (_injectDebuggingSupportCode) {
body = await _injectClientAndHoistMain(
body,
appId,
devHandlerPath,
entrypoint,
await _extensionUri,
);
}
body += await globalToolConfiguration.loadStrategy.bootstrapFor(
entrypoint,
);
_logger.info(
'Injected debugging metadata for '
'entrypoint at $requestedUri',
);
etag = _base64Md5(body);
newHeaders[HttpHeaders.etagHeader] = etag;
}
if (ifNoneMatch == etag) {
return Response.notModified(headers: newHeaders);
}
return response.change(body: body, headers: newHeaders);
} else {
final loadResponse = await globalToolConfiguration.loadStrategy.handler(
request,
);
if (loadResponse.statusCode != HttpStatus.notFound) {
return loadResponse;
}
return innerHandler(request);
}
};
};
}
/// Returns the provided body with the main function hoisted into a global
/// variable and a snippet of JS that loads the injected client.
Future<String> _injectClientAndHoistMain(
String body,
String appId,
String devHandlerPath,
String entrypointPath,
String? extensionUri,
) async {
final bodyLines = body.split('\n');
final extensionIndex = bodyLines.indexWhere(
(line) => line.contains(mainExtensionMarker),
);
var result = bodyLines.sublist(0, extensionIndex).join('\n');
// The line after the marker calls `main`. We prevent `main` from
// being called and make it runnable through a global variable.
final mainFunction =
bodyLines[extensionIndex + 1].replaceAll('main();', 'main').trim();
// We inject the client in the entry point module as the client expects the
// application to be in a ready state, that is the main function is hoisted
// and the Dart SDK is loaded.
final injectedClientSnippet = await _injectedClientSnippet(
appId,
devHandlerPath,
entrypointPath,
extensionUri,
);
result += '''
// Injected by dwds for debugging support.
if(!window.\$dwdsInitialized) {
window.\$dwdsInitialized = true;
window.\$dartMainTearOffs = [$mainFunction];
window.\$dartRunMain = function() {
window.\$dartMainExecuted = true;
window.\$dartMainTearOffs.forEach(function(main){
main();
});
}
$injectedClientSnippet
} else {
if(window.\$dartMainExecuted){
$mainFunction();
}else {
window.\$dartMainTearOffs.push($mainFunction);
}
}
''';
result += bodyLines.sublist(extensionIndex + 2).join('\n');
return result;
}
/// JS snippet which includes global variables required for debugging.
Future<String> _injectedClientSnippet(
String appId,
String devHandlerPath,
String entrypointPath,
String? extensionUri,
) async {
final loadStrategy = globalToolConfiguration.loadStrategy;
final buildSettings = loadStrategy.buildSettings;
final appMetadata = globalToolConfiguration.appMetadata;
final debugSettings = globalToolConfiguration.debugSettings;
var injectedBody =
'window.\$dartAppId = "$appId";\n'
'window.\$dartReloadConfiguration = "${loadStrategy.reloadConfiguration}";\n'
'window.\$dartModuleStrategy = "${loadStrategy.id}";\n'
'window.\$loadModuleConfig = ${loadStrategy.loadModuleSnippet};\n'
'window.\$dwdsVersion = "$packageVersion";\n'
'window.\$dwdsDevHandlerPath = "$devHandlerPath";\n'
'window.\$dwdsEnableDevToolsLaunch = ${debugSettings.enableDevToolsLaunch};\n'
'window.\$dartEntrypointPath = "$entrypointPath";\n'
'window.\$dartEmitDebugEvents = ${debugSettings.emitDebugEvents};\n'
'window.\$isInternalBuild = ${appMetadata.isInternalBuild};\n'
'window.\$isFlutterApp = ${buildSettings.isFlutterApp};\n'
'${loadStrategy is DdcLibraryBundleStrategy ? 'window.\$hotReloadSourcesPath = "${loadStrategy.hotReloadSourcesUri.toString()}";\n' : ''}'
'${loadStrategy.loadClientSnippet(_clientScript)}';
if (extensionUri != null) {
injectedBody += 'window.\$dartExtensionUri = "$extensionUri";\n';
}
final workspaceName = appMetadata.workspaceName;
if (workspaceName != null) {
injectedBody += 'window.\$dartWorkspaceName = "$workspaceName";\n';
}
return injectedBody;
}
final _utf8FusedConverter = utf8.encoder.fuse(md5);
String _base64Md5(String input) {
final bytes = _utf8FusedConverter.convert(input).bytes;
return base64.encode(bytes);
}