// Copyright 2014 The Flutter Authors. 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:typed_data'; import 'package:archive/archive.dart'; import 'package:build_daemon/client.dart'; import 'package:build_daemon/constants.dart' as daemon; import 'package:build_daemon/data/build_status.dart'; import 'package:build_daemon/data/build_target.dart'; import 'package:build_daemon/data/server_log.dart'; import 'package:dwds/asset_handler.dart'; import 'package:dwds/dwds.dart'; import 'package:http_multi_server/http_multi_server.dart'; import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_proxy/shelf_proxy.dart'; import 'package:mime/mime.dart' as mime; import '../artifacts.dart'; import '../asset.dart'; import '../base/common.dart'; import '../base/context.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/net.dart'; import '../build_info.dart'; import '../bundle.dart'; import '../cache.dart'; import '../dart/package_map.dart'; import '../dart/pub.dart'; import '../globals.dart' as globals; import '../platform_plugins.dart'; import '../plugins.dart'; import '../project.dart'; import '../web/chrome.dart'; import '../web/compile.dart'; /// The name of the built web project. const String kBuildTargetName = 'web'; /// A factory for creating a [Dwds] instance. DwdsFactory get dwdsFactory => context.get() ?? Dwds.start; /// The [BuildDaemonCreator] instance. BuildDaemonCreator get buildDaemonCreator => context.get() ?? const BuildDaemonCreator(); /// A factory for creating a [WebFs] instance. WebFsFactory get webFsFactory => context.get() ?? WebFs.start; /// A factory for creating an [HttpMultiServer] instance. HttpMultiServerFactory get httpMultiServerFactory => context.get() ?? HttpMultiServer.bind; /// A function with the same signature as [HttpMultiServer.bind]. typedef HttpMultiServerFactory = Future Function(dynamic address, int port); /// A function with the same signature as [Dwds.start]. typedef DwdsFactory = Future Function({ @required AssetHandler assetHandler, @required Stream buildResults, @required ConnectionProvider chromeConnection, String hostname, ReloadConfiguration reloadConfiguration, bool serveDevTools, LogWriter logWriter, bool verbose, bool enableDebugExtension, UrlEncoder urlEncoder, }); /// A function with the same signature as [WebFs.start]. typedef WebFsFactory = Future Function({ @required String target, @required FlutterProject flutterProject, @required BuildInfo buildInfo, @required bool skipDwds, @required bool initializePlatform, @required String hostname, @required String port, @required UrlTunneller urlTunneller, @required List dartDefines, }); /// The dev filesystem responsible for building and serving web applications. class WebFs { @visibleForTesting WebFs( this._client, this._server, this._dwds, this.uri, this._assetServer, this._useBuildRunner, this._flutterProject, this._target, this._buildInfo, this._initializePlatform, this._dartDefines, ); /// The server URL. final String uri; final HttpServer _server; final Dwds _dwds; final BuildDaemonClient _client; final AssetServer _assetServer; final bool _useBuildRunner; final FlutterProject _flutterProject; final String _target; final BuildInfo _buildInfo; final bool _initializePlatform; final List _dartDefines; StreamSubscription _connectedApps; static const String _kHostName = 'localhost'; Future stop() async { await _client?.close(); await _dwds?.stop(); await _server.close(force: true); await _connectedApps?.cancel(); _assetServer?.dispose(); } Future _cachedExtensionFuture; /// Connect and retrieve the [DebugConnection] for the current application. /// /// Only calls [AppConnection.runMain] on the subsequent connections. Future connect(bool useDebugExtension) { final Completer firstConnection = Completer(); _connectedApps = _dwds.connectedApps.listen((AppConnection appConnection) async { final DebugConnection debugConnection = useDebugExtension ? await (_cachedExtensionFuture ??= _dwds.extensionDebugConnections.stream.first) : await _dwds.debugConnection(appConnection); if (!firstConnection.isCompleted) { firstConnection.complete(ConnectionResult(appConnection, debugConnection)); } else { appConnection.runMain(); } }); return firstConnection.future; } /// Recompile the web application and return whether this was successful. Future recompile() async { if (!_useBuildRunner) { await buildWeb( _flutterProject, _target, _buildInfo, _initializePlatform, _dartDefines, false, ); return true; } _client.startBuild(); await for (final BuildResults results in _client.buildResults) { final BuildResult result = results.results.firstWhere((BuildResult result) { return result.target == kBuildTargetName; }); if (result.status == BuildStatus.failed) { return false; } if (result.status == BuildStatus.succeeded) { return true; } } return true; } /// Start the web compiler and asset server. static Future start({ @required String target, @required FlutterProject flutterProject, @required BuildInfo buildInfo, @required bool skipDwds, @required bool initializePlatform, @required String hostname, @required String port, @required UrlTunneller urlTunneller, @required List dartDefines, }) async { // workaround for https://github.com/flutter/flutter/issues/38290 if (!flutterProject.dartTool.existsSync()) { flutterProject.dartTool.createSync(recursive: true); } // Workaround for https://github.com/flutter/flutter/issues/41681. final String toolPath = globals.fs.path.join(Cache.flutterRoot, 'packages', 'flutter_tools'); if (!globals.fs.isFileSync(globals.fs.path.join(toolPath, '.packages'))) { await pub.get( context: PubContext.pubGet, directory: toolPath, offline: true, skipPubspecYamlCheck: true, checkLastModified: false, ); } final Completer firstBuildCompleter = Completer(); // Initialize the asset bundle. final AssetBundle assetBundle = AssetBundleFactory.instance.createBundle(); await assetBundle.build(); await writeBundle(globals.fs.directory(getAssetBuildDirectory()), assetBundle.entries); final String targetBaseName = globals.fs.path .withoutExtension(target).replaceFirst('lib${globals.fs.path.separator}', ''); final Map mappedUrls = { 'main.dart.js': 'packages/${flutterProject.manifest.appName}/' '${targetBaseName}_web_entrypoint.dart.js', '${targetBaseName}_web_entrypoint.dart.js.map': 'packages/${flutterProject.manifest.appName}/' '${targetBaseName}_web_entrypoint.dart.js.map', '${targetBaseName}_web_entrypoint.dart.bootstrap.js': 'packages/${flutterProject.manifest.appName}/' '${targetBaseName}_web_entrypoint.dart.bootstrap.js', '${targetBaseName}_web_entrypoint.digests': 'packages/${flutterProject.manifest.appName}/' '${targetBaseName}_web_entrypoint.digests', }; // Initialize the dwds server. final String effectiveHostname = hostname ?? _kHostName; final int hostPort = port == null ? await globals.os.findFreePort() : int.tryParse(port); final Pipeline pipeline = const Pipeline().addMiddleware((Handler innerHandler) { return (Request request) async { // Redirect the main.dart.js to the target file we decided to serve. if (mappedUrls.containsKey(request.url.path)) { final String newPath = mappedUrls[request.url.path]; return innerHandler( Request( request.method, Uri.parse(request.requestedUri.toString() .replaceFirst(request.requestedUri.path, '/$newPath')), headers: request.headers, url: Uri.parse(request.url.toString() .replaceFirst(request.url.path, newPath)), ), ); } else { return innerHandler(request); } }; }); Handler handler; Dwds dwds; BuildDaemonClient client; StreamSubscription firstBuild; if (buildInfo.isDebug) { final bool hasWebPlugins = findPlugins(flutterProject) .any((Plugin p) => p.platforms.containsKey(WebPlugin.kConfigKey)); // Start the build daemon and run an initial build. client = await buildDaemonCreator .startBuildDaemon(globals.fs.currentDirectory.path, release: buildInfo.isRelease, profile: buildInfo.isProfile, hasPlugins: hasWebPlugins, initializePlatform: initializePlatform, ); client.startBuild(); // Only provide relevant build results final Stream filteredBuildResults = client.buildResults .asyncMap((BuildResults results) { return results.results .firstWhere((BuildResult result) => result.target == kBuildTargetName); }); // Start the build daemon and run an initial build. firstBuild = client.buildResults.listen((BuildResults buildResults) { if (firstBuildCompleter.isCompleted) { return; } final BuildResult result = buildResults.results.firstWhere((BuildResult result) { return result.target == kBuildTargetName; }); if (result.status == BuildStatus.failed) { firstBuildCompleter.complete(false); } if (result.status == BuildStatus.succeeded) { firstBuildCompleter.complete(true); } }); final int daemonAssetPort = buildDaemonCreator.assetServerPort(globals.fs.currentDirectory); // Initialize the asset bundle. final AssetBundle assetBundle = AssetBundleFactory.instance.createBundle(); await assetBundle.build(); await writeBundle(globals.fs.directory(getAssetBuildDirectory()), assetBundle.entries); if (!skipDwds) { final BuildRunnerAssetHandler assetHandler = BuildRunnerAssetHandler( daemonAssetPort, kBuildTargetName, effectiveHostname, hostPort); dwds = await dwdsFactory( hostname: effectiveHostname, assetHandler: assetHandler, buildResults: filteredBuildResults, chromeConnection: () async { return (await ChromeLauncher.connectedInstance).chromeConnection; }, reloadConfiguration: ReloadConfiguration.none, serveDevTools: false, verbose: false, enableDebugExtension: true, urlEncoder: urlTunneller, logWriter: (dynamic level, String message) => globals.printTrace(message), ); handler = pipeline.addHandler(dwds.handler); } else { handler = pipeline.addHandler(proxyHandler('http://localhost:$daemonAssetPort/web/')); } } else { await buildWeb( flutterProject, target, buildInfo, initializePlatform, dartDefines, false, ); firstBuildCompleter.complete(true); } final AssetServer assetServer = buildInfo.isDebug ? DebugAssetServer(flutterProject, targetBaseName) : ReleaseAssetServer(); Cascade cascade = Cascade(); cascade = cascade.add(handler); cascade = cascade.add(assetServer.handle); final InternetAddress internetAddress = (await InternetAddress.lookup(effectiveHostname)).first; final HttpServer server = await httpMultiServerFactory(internetAddress, hostPort); shelf_io.serveRequests(server, cascade.handler); final WebFs webFS = WebFs( client, server, dwds, 'http://$effectiveHostname:$hostPort', assetServer, buildInfo.isDebug, flutterProject, target, buildInfo, initializePlatform, dartDefines, ); if (!await firstBuildCompleter.future) { throw const BuildException(); } await firstBuild?.cancel(); return webFS; } } /// An exception thrown when build runner fails. /// /// This contains no error information as it will have already been printed to /// the console. class BuildException implements Exception { const BuildException(); } abstract class AssetServer { Future handle(Request request); void dispose() {} } class ReleaseAssetServer extends AssetServer { // Locations where source files, assets, or source maps may be located. final List _searchPaths = [ globals.fs.directory(getWebBuildDirectory()).uri, globals.fs.directory(Cache.flutterRoot).parent.uri, globals.fs.currentDirectory.childDirectory('lib').uri, ]; @override Future handle(Request request) async { Uri fileUri; for (final Uri uri in _searchPaths) { final Uri potential = uri.resolve(request.url.path); if (potential == null || !globals.fs.isFileSync(potential.toFilePath())) { continue; } fileUri = potential; break; } if (fileUri != null) { final File file = globals.fs.file(fileUri); final Uint8List bytes = file.readAsBytesSync(); // Fallback to "application/octet-stream" on null which // makes no claims as to the structure of the data. final String mimeType = mime.lookupMimeType(file.path, headerBytes: bytes) ?? 'application/octet-stream'; return Response.ok(bytes, headers: { 'Content-Type': mimeType, }); } if (request.url.path == '') { final File file = globals.fs.file(globals.fs.path.join(getWebBuildDirectory(), 'index.html')); return Response.ok(file.readAsBytesSync(), headers: { 'Content-Type': 'text/html', }); } return Response.notFound(''); } } class DebugAssetServer extends AssetServer { DebugAssetServer(this.flutterProject, this.targetBaseName); final FlutterProject flutterProject; final String targetBaseName; final PackageMap packageMap = PackageMap(PackageMap.globalPackagesPath); Directory partFiles; @override Future handle(Request request) async { if (request.url.path.endsWith('.html')) { final Uri htmlUri = flutterProject.web.directory.uri.resolveUri(request.url); final File htmlFile = globals.fs.file(htmlUri); if (htmlFile.existsSync()) { return Response.ok(htmlFile.readAsBytesSync(), headers: { 'Content-Type': 'text/html', }); } return Response.notFound(''); } else if (request.url.path.contains('stack_trace_mapper')) { final File file = globals.fs.file(globals.fs.path.join( globals.artifacts.getArtifactPath(Artifact.engineDartSdkPath), 'lib', 'dev_compiler', 'web', 'dart_stack_trace_mapper.js', )); return Response.ok(file.readAsBytesSync(), headers: { 'Content-Type': 'text/javascript', }); } else if (request.url.path.endsWith('part.js')) { // Lazily unpack any deferred imports in release/profile mode. These are // placed into an archive by build_runner, and are named based on the main // entrypoint + a "part" suffix (Though the actual names are arbitrary). // To make this easier to deal with they are copied into a temp directory. if (partFiles == null) { final File dart2jsArchive = globals.fs.file(globals.fs.path.join( flutterProject.dartTool.path, 'build', 'flutter_web', flutterProject.manifest.appName, 'lib', '${targetBaseName}_web_entrypoint.dart.js.tar.gz', )); if (dart2jsArchive.existsSync()) { final Archive archive = TarDecoder().decodeBytes(dart2jsArchive.readAsBytesSync()); partFiles = globals.fs.systemTempDirectory.createTempSync('flutter_tool.') ..createSync(); for (final ArchiveFile file in archive) { partFiles.childFile(file.name).writeAsBytesSync(file.content as List); } } } final String fileName = globals.fs.path.basename(request.url.path); return Response.ok(partFiles.childFile(fileName).readAsBytesSync(), headers: { 'Content-Type': 'text/javascript', }); } else if (request.url.path.contains('require.js')) { final File file = globals.fs.file(globals.fs.path.join( globals.artifacts.getArtifactPath(Artifact.engineDartSdkPath), 'lib', 'dev_compiler', 'kernel', 'amd', 'require.js', )); return Response.ok(file.readAsBytesSync(), headers: { 'Content-Type': 'text/javascript', }); } else if (request.url.path.endsWith('dart_sdk.js.map')) { final File file = globals.fs.file(globals.fs.path.join( globals.artifacts.getArtifactPath(Artifact.flutterWebSdk), 'kernel', 'amd', 'dart_sdk.js.map', )); return Response.ok(file.readAsBytesSync()); } else if (request.url.path.endsWith('dart_sdk.js')) { final File file = globals.fs.file(globals.fs.path.join( globals.artifacts.getArtifactPath(Artifact.flutterWebSdk), 'kernel', 'amd', 'dart_sdk.js', )); return Response.ok(file.readAsBytesSync(), headers: { 'Content-Type': 'text/javascript', }); } else if (request.url.path.endsWith('.dart')) { // This is likely a sourcemap request. The first segment is the // package name, and the rest is the path to the file relative to // the package uri. For example, `foo/bar.dart` would represent a // file at a path like `foo/lib/bar.dart`. If there is no leading // segment, then we assume it is from the current package. String basePath = request.url.path; basePath = basePath.replaceFirst('packages/build_web_compilers/', ''); basePath = basePath.replaceFirst('packages/', ''); // Handle sdk requests that have mangled urls from engine build. if (request.url.path.contains('dart-sdk')) { // Note: the request is a uri and not a file path, so they always use `/`. final String sdkPath = globals.fs.path.joinAll(request.url.path.split('dart-sdk/').last.split('/')); final String dartSdkPath = globals.artifacts.getArtifactPath(Artifact.engineDartSdkPath); final File candidateFile = globals.fs.file(globals.fs.path.join(dartSdkPath, sdkPath)); return Response.ok(candidateFile.readAsBytesSync()); } // See if it is a flutter sdk path. final String webSdkPath = globals.artifacts.getArtifactPath(Artifact.flutterWebSdk); final File candidateFile = globals.fs.file(globals.fs.path.join(webSdkPath, basePath.split('/').join(globals.platform.pathSeparator))); if (candidateFile.existsSync()) { return Response.ok(candidateFile.readAsBytesSync()); } final String packageName = request.url.pathSegments.length == 1 ? flutterProject.manifest.appName : request.url.pathSegments.first; String filePath = globals.fs.path.joinAll(request.url.pathSegments.length == 1 ? request.url.pathSegments : request.url.pathSegments.skip(1)); String packagePath = packageMap.map[packageName]?.toFilePath(windows: globals.platform.isWindows); // If the package isn't found, then we have an issue with relative // paths within the main project. if (packagePath == null) { packagePath = packageMap.map[flutterProject.manifest.appName] .toFilePath(windows: globals.platform.isWindows); filePath = request.url.path; } final File file = globals.fs.file(globals.fs.path.join(packagePath, filePath)); if (file.existsSync()) { return Response.ok(file.readAsBytesSync()); } return Response.notFound(''); } else if (request.url.path.contains('assets')) { final String assetPath = request.url.path.replaceFirst('assets/', ''); final File file = globals.fs.file(globals.fs.path.join(getAssetBuildDirectory(), assetPath)); if (file.existsSync()) { final Uint8List bytes = file.readAsBytesSync(); // Fallback to "application/octet-stream" on null which // makes no claims as to the structure of the data. final String mimeType = mime.lookupMimeType(file.path, headerBytes: bytes) ?? 'application/octet-stream'; return Response.ok(bytes, headers: { 'Content-Type': mimeType, }); } else { return Response.notFound(''); } } return Response.notFound(''); } @override void dispose() { partFiles?.deleteSync(recursive: true); } } class ConnectionResult { ConnectionResult(this.appConnection, this.debugConnection); final AppConnection appConnection; final DebugConnection debugConnection; } class WebTestTargetManifest { WebTestTargetManifest(this.buildFilters); WebTestTargetManifest.all() : buildFilters = null; final List buildFilters; bool get hasBuildFilters => buildFilters != null && buildFilters.isNotEmpty; } /// A testable interface for starting a build daemon. class BuildDaemonCreator { const BuildDaemonCreator(); // TODO(jonahwilliams): find a way to get build checks working for flutter for web. static const String _ignoredLine1 = 'Warning: Interpreting this as package URI'; static const String _ignoredLine2 = 'build_script.dart was not found in the asset graph, incremental builds will not work'; static const String _ignoredLine3 = 'have your dependencies specified fully in your pubspec.yaml'; /// Start a build daemon and register the web targets. /// /// [initializePlatform] controls whether we should invoke [webOnlyInitializePlatform]. Future startBuildDaemon(String workingDirectory, { bool release = false, bool profile = false, bool hasPlugins = false, bool initializePlatform = true, WebTestTargetManifest testTargets, }) async { try { final BuildDaemonClient client = await _connectClient( workingDirectory, release: release, profile: profile, hasPlugins: hasPlugins, initializePlatform: initializePlatform, testTargets: testTargets, ); _registerBuildTargets(client, testTargets); return client; } on OptionsSkew { throwToolExit( 'Incompatible options with current running build daemon.\n\n' 'Please stop other flutter_tool instances running in this directory ' 'before starting a new instance with these options.'); } return null; } void _registerBuildTargets( BuildDaemonClient client, WebTestTargetManifest testTargets, ) { final OutputLocation outputLocation = OutputLocation((OutputLocationBuilder b) => b ..output = '' ..useSymlinks = true ..hoist = false); client.registerBuildTarget(DefaultBuildTarget((DefaultBuildTargetBuilder b) => b ..target = 'web' ..outputLocation = outputLocation?.toBuilder())); if (testTargets != null) { client.registerBuildTarget(DefaultBuildTarget((DefaultBuildTargetBuilder b) { b.target = 'test'; b.outputLocation = outputLocation?.toBuilder(); if (testTargets.hasBuildFilters) { b.buildFilters.addAll(testTargets.buildFilters); } })); } } Future _connectClient( String workingDirectory, { bool release, bool profile, bool hasPlugins, bool initializePlatform, WebTestTargetManifest testTargets, }) { final String flutterToolsPackages = globals.fs.path.join(Cache.flutterRoot, 'packages', 'flutter_tools', '.packages'); final String buildScript = globals.fs.path.join(Cache.flutterRoot, 'packages', 'flutter_tools', 'lib', 'src', 'build_runner', 'build_script.dart'); final String flutterWebSdk = globals.artifacts.getArtifactPath(Artifact.flutterWebSdk); // On Windows we need to call the snapshot directly otherwise // the process will start in a disjoint cmd without access to // STDIO. final List args = [ globals.artifacts.getArtifactPath(Artifact.engineDartBinary), '--packages=$flutterToolsPackages', buildScript, 'daemon', '--skip-build-script-check', '--define', 'flutter_tools:ddc=flutterWebSdk=$flutterWebSdk', '--define', 'flutter_tools:entrypoint=flutterWebSdk=$flutterWebSdk', '--define', 'flutter_tools:entrypoint=release=$release', '--define', 'flutter_tools:entrypoint=profile=$profile', '--define', 'flutter_tools:shell=flutterWebSdk=$flutterWebSdk', '--define', 'flutter_tools:shell=hasPlugins=$hasPlugins', '--define', 'flutter_tools:shell=initializePlatform=$initializePlatform', // The following will cause build runner to only build tests that were requested. if (testTargets != null && testTargets.hasBuildFilters) for (final String buildFilter in testTargets.buildFilters) '--build-filter=$buildFilter', ]; return BuildDaemonClient.connect( workingDirectory, args, logHandler: (ServerLog serverLog) { switch (serverLog.level) { case Level.SEVERE: case Level.SHOUT: // Ignore certain non-actionable messages on startup. if (serverLog.message.contains(_ignoredLine1) || serverLog.message.contains(_ignoredLine2) || serverLog.message.contains(_ignoredLine3)) { return; } globals.printError(serverLog.message); if (serverLog.error != null) { globals.printError(serverLog.error); } if (serverLog.stackTrace != null) { globals.printTrace(serverLog.stackTrace); } break; default: if (serverLog.message.contains('Skipping compiling')) { globals.printError(serverLog.message); } else { globals.printTrace(serverLog.message); } } }, buildMode: daemon.BuildMode.Manual, ); } /// Retrieve the asset server port for the current daemon. int assetServerPort(Directory workingDirectory) { final String portFilePath = globals.fs.path.join(daemon.daemonWorkspace(workingDirectory.path), '.asset_server_port'); return int.tryParse(globals.fs.file(portFilePath).readAsStringSync()); } }