blob: 81a04ef83bf374440622c4e0366855ed8b1208b4 [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/loaders/strategy.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';
/// Handles injecting the DWDS client and embedding debugging related
/// information.
class DwdsInjector {
final LoadStrategy _loadStrategy;
final Future<String>? _extensionUri;
final _devHandlerPaths = StreamController<String>();
final _logger = Logger('DwdsInjector');
final bool _enableDevtoolsLaunch;
final bool _useSseForInjectedClient;
final bool _emitDebugEvents;
final bool _isInternalBuild;
final Future<bool> Function() _isFlutterApp;
DwdsInjector(
this._loadStrategy, {
required bool enableDevtoolsLaunch,
required bool useSseForInjectedClient,
required bool emitDebugEvents,
required bool isInternalBuild,
required Future<bool> Function() isFlutterApp,
Future<String>? extensionUri,
}) : _extensionUri = extensionUri,
_enableDevtoolsLaunch = enableDevtoolsLaunch,
_useSseForInjectedClient = useSseForInjectedClient,
_emitDebugEvents = emitDebugEvents,
_isInternalBuild = isInternalBuild,
_isFlutterApp = isFlutterApp;
/// 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 hander 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 = base64
.encode(md5.convert(utf8.encode('$requestedUri')).bytes);
var scheme = request.requestedUri.scheme;
if (!_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;
_loadStrategy.trackEntrypoint(entrypoint);
body = _injectClientAndHoistMain(
body,
appId,
devHandlerPath,
entrypoint,
await _extensionUri,
_loadStrategy,
_enableDevtoolsLaunch,
_emitDebugEvents,
_isInternalBuild,
await _isFlutterApp(),
);
body += await _loadStrategy.bootstrapFor(entrypoint);
_logger.info('Injected debugging metadata for '
'entrypoint at $requestedUri');
etag = base64.encode(md5.convert(body.codeUnits).bytes);
newHeaders[HttpHeaders.etagHeader] = etag;
}
if (ifNoneMatch == etag) {
return Response.notModified(headers: newHeaders);
}
return response.change(body: body, headers: newHeaders);
} else {
final loadResponse = await _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.
String _injectClientAndHoistMain(
String body,
String appId,
String devHandlerPath,
String entrypointPath,
String? extensionUri,
LoadStrategy loadStrategy,
bool enableDevtoolsLaunch,
bool emitDebugEvents,
bool isInternalBuild,
bool isFlutterApp) {
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 = _injectedClientSnippet(
appId,
devHandlerPath,
entrypointPath,
extensionUri,
loadStrategy,
enableDevtoolsLaunch,
emitDebugEvents,
isInternalBuild,
isFlutterApp);
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.
String _injectedClientSnippet(
String appId,
String devHandlerPath,
String entrypointPath,
String? extensionUri,
LoadStrategy loadStrategy,
bool enableDevtoolsLaunch,
bool emitDebugEvents,
bool isInternalBuild,
bool isFlutterApp,
) {
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 = $enableDevtoolsLaunch;\n'
'window.\$dartEntrypointPath = "$entrypointPath";\n'
'window.\$dartEmitDebugEvents = $emitDebugEvents;\n'
'window.\$isInternalBuild = $isInternalBuild;\n'
'window.\$isFlutterApp = $isFlutterApp;\n'
'${loadStrategy.loadClientSnippet(_clientScript)}';
if (extensionUri != null) {
injectedBody += 'window.\$dartExtensionUri = "$extensionUri";\n';
}
return injectedBody;
}