Support deferred imports on profile/release builds of Flutter Web (#41222)
diff --git a/packages/flutter_tools/lib/src/build_runner/build_script.dart b/packages/flutter_tools/lib/src/build_runner/build_script.dart index 6adb7a0..c5b8543 100644 --- a/packages/flutter_tools/lib/src/build_runner/build_script.dart +++ b/packages/flutter_tools/lib/src/build_runner/build_script.dart
@@ -11,6 +11,7 @@ import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; +import 'package:archive/archive.dart'; import 'package:build/build.dart'; import 'package:build_config/build_config.dart'; import 'package:build_modules/build_modules.dart'; @@ -26,6 +27,7 @@ import 'package:build_web_compilers/builders.dart'; import 'package:build_web_compilers/src/dev_compiler_bootstrap.dart'; import 'package:crypto/crypto.dart'; +import 'package:glob/glob.dart'; import 'package:path/path.dart' as path; // ignore: package_path_import import 'package:scratch_space/scratch_space.dart'; import 'package:test_core/backend.dart'; @@ -461,8 +463,32 @@ final AssetId jsOutputId = dartEntrypointId.changeExtension(jsEntrypointExtension); final File jsOutputFile = scratchSpace.fileFor(jsOutputId); if (result.succeeded && jsOutputFile.existsSync()) { - log.info(result.output); - // Explicitly write out the original js file and sourcemap. + final String rootDir = path.dirname(jsOutputFile.path); + final String dartFile = path.basename(dartEntrypointId.path); + final Glob fileGlob = Glob('$dartFile.js*'); + final Archive archive = Archive(); + await for (FileSystemEntity jsFile in fileGlob.list(root: rootDir)) { + if (jsFile.path.endsWith(jsEntrypointExtension) || + jsFile.path.endsWith(jsEntrypointSourceMapExtension)) { + // These are explicitly output, and are not part of the archive. + continue; + } + if (jsFile is File) { + final String fileName = path.relative(jsFile.path, from: rootDir); + final FileStat fileStats = jsFile.statSync(); + archive.addFile( + ArchiveFile(fileName, fileStats.size, await jsFile.readAsBytes()) + ..mode = fileStats.mode + ..lastModTime = fileStats.modified.millisecondsSinceEpoch); + } + } + if (archive.isNotEmpty) { + final AssetId archiveId = dartEntrypointId.changeExtension(jsEntrypointArchiveExtension); + await buildStep.writeAsBytes(archiveId, TarEncoder().encode(archive)); + } + + // Explicitly write out the original js file and sourcemap - we can't output + // these as part of the archive because they already have asset nodes. await scratchSpace.copyOutput(jsOutputId, buildStep); final AssetId jsSourceMapId = dartEntrypointId.changeExtension(jsEntrypointSourceMapExtension);
diff --git a/packages/flutter_tools/lib/src/build_runner/web_fs.dart b/packages/flutter_tools/lib/src/build_runner/web_fs.dart index e8fb3ae..bc082e8 100644 --- a/packages/flutter_tools/lib/src/build_runner/web_fs.dart +++ b/packages/flutter_tools/lib/src/build_runner/web_fs.dart
@@ -4,10 +4,9 @@ import 'dart:async'; +import 'package:archive/archive.dart'; import 'package:build_daemon/client.dart'; -import 'package:build_daemon/constants.dart'; -import 'package:build_daemon/constants.dart' hide BuildMode; -import 'package:build_daemon/constants.dart' as daemon show BuildMode; +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'; @@ -89,6 +88,7 @@ this._server, this._dwds, this.uri, + this._assetServer, ); /// The server uri. @@ -97,6 +97,7 @@ final HttpServer _server; final Dwds _dwds; final BuildDaemonClient _client; + final AssetServer _assetServer; StreamSubscription<void> _connectedApps; static const String _kHostName = 'localhost'; @@ -106,6 +107,7 @@ await _dwds?.stop(); await _server.close(force: true); await _connectedApps?.cancel(); + _assetServer?.dispose(); } Future<DebugConnection> _cachedExtensionFuture; @@ -180,21 +182,22 @@ await assetBundle.build(); await writeBundle(fs.directory(getAssetBuildDirectory()), assetBundle.entries); - // Initialize the dwds server. - final int hostPort = port == null ? await os.findFreePort() : int.tryParse(port); - // Map the bootstrap files to the correct package directory. final String targetBaseName = fs.path - .withoutExtension(target).replaceFirst('lib${fs.path.separator}', ''); + .withoutExtension(target).replaceFirst('lib${fs.path.separator}', ''); final Map<String, String> mappedUrls = <String, String>{ 'main.dart.js': 'packages/${flutterProject.manifest.appName}/' - '${targetBaseName}_web_entrypoint.dart.js', + '${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.js.map', '${targetBaseName}_web_entrypoint.dart.bootstrap.js': 'packages/${flutterProject.manifest.appName}/' - '${targetBaseName}_web_entrypoint.dart.bootstrap.js', + '${targetBaseName}_web_entrypoint.dart.bootstrap.js', '${targetBaseName}_web_entrypoint.digests': 'packages/${flutterProject.manifest.appName}/' - '${targetBaseName}_web_entrypoint.digests', + '${targetBaseName}_web_entrypoint.digests', }; + + // Initialize the dwds server. + final int hostPort = port == null ? await 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. @@ -237,9 +240,10 @@ } else { handler = pipeline.addHandler(proxyHandler('http://localhost:$daemonAssetPort/web/')); } + final AssetServer assetServer = AssetServer(flutterProject, targetBaseName); Cascade cascade = Cascade(); cascade = cascade.add(handler); - cascade = cascade.add(_assetHandler(flutterProject)); + cascade = cascade.add(assetServer.handle); final HttpServer server = await httpMultiServerFactory(hostname ?? _kHostName, hostPort); shelf_io.serveRequests(server, cascade.handler); return WebFs( @@ -247,98 +251,136 @@ server, dwds, 'http://$_kHostName:$hostPort/', + assetServer, ); } +} - static Future<Response> Function(Request request) _assetHandler(FlutterProject flutterProject) { - final PackageMap packageMap = PackageMap(PackageMap.globalPackagesPath); - return (Request request) async { - if (request.url.path.contains('stack_trace_mapper')) { - final File file = fs.file(fs.path.join( - artifacts.getArtifactPath(Artifact.engineDartSdkPath), +class AssetServer { + AssetServer(this.flutterProject, this.targetBaseName); + + final FlutterProject flutterProject; + final String targetBaseName; + final PackageMap packageMap = PackageMap(PackageMap.globalPackagesPath); + Directory partFiles; + + Future<Response> handle(Request request) async { + if (request.url.path.contains('stack_trace_mapper')) { + final File file = fs.file(fs.path.join( + artifacts.getArtifactPath(Artifact.engineDartSdkPath), + 'lib', + 'dev_compiler', + 'web', + 'dart_stack_trace_mapper.js', + )); + return Response.ok(file.readAsBytesSync(), headers: <String, String>{ + '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 = fs.file(fs.path.join( + flutterProject.dartTool.path, + 'build', + 'flutter_web', + '${flutterProject.manifest.appName}', 'lib', - 'dev_compiler', - 'web', - 'dart_stack_trace_mapper.js', + '${targetBaseName}_web_entrypoint.dart.js.tar.gz', )); - return Response.ok(file.readAsBytesSync(), headers: <String, String>{ - 'Content-Type': 'text/javascript', - }); - } else if (request.url.path.contains('require.js')) { - final File file = fs.file(fs.path.join( - artifacts.getArtifactPath(Artifact.engineDartSdkPath), - 'lib', - 'dev_compiler', - 'kernel', - 'amd', - 'require.js', - )); - return Response.ok(file.readAsBytesSync(), headers: <String, String>{ - 'Content-Type': 'text/javascript', - }); - } else if (request.url.path.endsWith('dart_sdk.js')) { - final File file = fs.file(fs.path.join( - artifacts.getArtifactPath(Artifact.flutterWebSdk), - 'kernel', - 'amd', - 'dart_sdk.js', - )); - return Response.ok(file.readAsBytesSync(), headers: <String, String>{ - 'Content-Type': 'text/javascript', - }); - } else if (request.url.path.endsWith('dart_sdk.js.map')) { - final File file = fs.file(fs.path.join( - artifacts.getArtifactPath(Artifact.flutterWebSdk), - 'kernel', - 'amd', - 'dart_sdk.js.map', - )); - return Response.ok(file.readAsBytesSync()); - } 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. - - // Handle sdk requests that have mangled urls from engine build. - if (request.url.path.contains('flutter_web_sdk')) { - // Note: the request is a uri and not a file path, so they always use `/`. - final String sdkPath = fs.path.joinAll(request.url.path.split('flutter_web_sdk/').last.split('/')); - final String webSdkPath = artifacts.getArtifactPath(Artifact.flutterWebSdk); - return Response.ok(fs.file(fs.path.join(webSdkPath, sdkPath)).readAsBytesSync()); - } - - final String packageName = request.url.pathSegments.length == 1 - ? flutterProject.manifest.appName - : request.url.pathSegments.first; - String filePath = fs.path.joinAll(request.url.pathSegments.length == 1 - ? request.url.pathSegments - : request.url.pathSegments.skip(1)); - String packagePath = packageMap.map[packageName]?.toFilePath(windows: 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: platform.isWindows); - filePath = request.url.path; - } - final File file = fs.file(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 = fs.file(fs.path.join(getAssetBuildDirectory(), assetPath)); - if (file.existsSync()) { - return Response.ok(file.readAsBytesSync()); - } else { - return Response.notFound(''); + if (dart2jsArchive.existsSync()) { + final Archive archive = TarDecoder().decodeBytes(dart2jsArchive.readAsBytesSync()); + partFiles = fs.systemTempDirectory.createTempSync('_flutter_tool') + ..createSync(); + for (ArchiveFile file in archive) { + partFiles.childFile(file.name).writeAsBytesSync(file.content); + } } } + final String fileName = fs.path.basename(request.url.path); + return Response.ok(partFiles.childFile(fileName).readAsBytesSync(), headers: <String, String>{ + 'Content-Type': 'text/javascript', + }); + } else if (request.url.path.contains('require.js')) { + final File file = fs.file(fs.path.join( + artifacts.getArtifactPath(Artifact.engineDartSdkPath), + 'lib', + 'dev_compiler', + 'kernel', + 'amd', + 'require.js', + )); + return Response.ok(file.readAsBytesSync(), headers: <String, String>{ + 'Content-Type': 'text/javascript', + }); + } else if (request.url.path.endsWith('dart_sdk.js')) { + final File file = fs.file(fs.path.join( + artifacts.getArtifactPath(Artifact.flutterWebSdk), + 'kernel', + 'amd', + 'dart_sdk.js', + )); + return Response.ok(file.readAsBytesSync(), headers: <String, String>{ + 'Content-Type': 'text/javascript', + }); + } else if (request.url.path.endsWith('dart_sdk.js.map')) { + final File file = fs.file(fs.path.join( + artifacts.getArtifactPath(Artifact.flutterWebSdk), + 'kernel', + 'amd', + 'dart_sdk.js.map', + )); + return Response.ok(file.readAsBytesSync()); + } 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. + + // Handle sdk requests that have mangled urls from engine build. + if (request.url.path.contains('flutter_web_sdk')) { + // Note: the request is a uri and not a file path, so they always use `/`. + final String sdkPath = fs.path.joinAll(request.url.path.split('flutter_web_sdk/').last.split('/')); + final String webSdkPath = artifacts.getArtifactPath(Artifact.flutterWebSdk); + return Response.ok(fs.file(fs.path.join(webSdkPath, sdkPath)).readAsBytesSync()); + } + + final String packageName = request.url.pathSegments.length == 1 + ? flutterProject.manifest.appName + : request.url.pathSegments.first; + String filePath = fs.path.joinAll(request.url.pathSegments.length == 1 + ? request.url.pathSegments + : request.url.pathSegments.skip(1)); + String packagePath = packageMap.map[packageName]?.toFilePath(windows: 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: platform.isWindows); + filePath = request.url.path; + } + final File file = fs.file(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 = fs.file(fs.path.join(getAssetBuildDirectory(), assetPath)); + if (file.existsSync()) { + return Response.ok(file.readAsBytesSync()); + } else { + return Response.notFound(''); + } + } + return Response.notFound(''); + } + + void dispose() { + partFiles?.deleteSync(recursive: true); } } @@ -459,7 +501,7 @@ /// Retrieve the asset server port for the current daemon. int assetServerPort(Directory workingDirectory) { - final String portFilePath = fs.path.join(daemonWorkspace(workingDirectory.path), '.asset_server_port'); + final String portFilePath = fs.path.join(daemon.daemonWorkspace(workingDirectory.path), '.asset_server_port'); return int.tryParse(fs.file(portFilePath).readAsStringSync()); } }
diff --git a/packages/flutter_tools/lib/src/web/compile.dart b/packages/flutter_tools/lib/src/web/compile.dart index 1e73e1b..a682b6b 100644 --- a/packages/flutter_tools/lib/src/web/compile.dart +++ b/packages/flutter_tools/lib/src/web/compile.dart
@@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:archive/archive.dart'; import 'package:meta/meta.dart'; import '../asset.dart'; @@ -48,9 +49,23 @@ flutterProject.manifest.appName, '${fs.path.withoutExtension(target)}_web_entrypoint.dart.js', ); + // Check for deferred import outputs. + final File dart2jsArchive = fs.file(fs.path.join( + flutterProject.dartTool.path, + 'build', + 'flutter_web', + '${flutterProject.manifest.appName}', + '${fs.path.withoutExtension(target)}_web_entrypoint.dart.js.tar.gz'), + ); fs.file(outputPath).copySync(fs.path.join(outputDir.path, 'main.dart.js')); fs.file('$outputPath.map').copySync(fs.path.join(outputDir.path, 'main.dart.js.map')); flutterProject.web.indexFile.copySync(fs.path.join(outputDir.path, 'index.html')); + if (dart2jsArchive.existsSync()) { + final Archive archive = TarDecoder().decodeBytes(dart2jsArchive.readAsBytesSync()); + for (ArchiveFile file in archive.files) { + outputDir.childFile(file.name).writeAsBytesSync(file.content); + } + } } } catch (err) { printError(err.toString());
diff --git a/packages/flutter_tools/test/general.shard/commands/build_web_test.dart b/packages/flutter_tools/test/general.shard/commands/build_web_test.dart index f8c7d1d..a2bdeff 100644 --- a/packages/flutter_tools/test/general.shard/commands/build_web_test.dart +++ b/packages/flutter_tools/test/general.shard/commands/build_web_test.dart
@@ -2,7 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + +import 'package:archive/archive.dart'; import 'package:args/command_runner.dart'; +import 'package:file_testing/file_testing.dart'; import 'package:flutter_tools/src/base/common.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/platform.dart'; @@ -25,6 +29,7 @@ MockWebCompilationProxy mockWebCompilationProxy; Testbed testbed; MockPlatform mockPlatform; + bool addArchive = false; setUpAll(() { Cache.flutterRoot = ''; @@ -32,6 +37,7 @@ }); setUp(() { + addArchive = false; mockWebCompilationProxy = MockWebCompilationProxy(); testbed = Testbed(setup: () { fs.file('pubspec.yaml') @@ -46,9 +52,18 @@ mode: anyNamed('mode'), initializePlatform: anyNamed('initializePlatform'), )).thenAnswer((Invocation invocation) { - final String path = fs.path.join('.dart_tool', 'build', 'flutter_web', 'foo', 'lib', 'main_web_entrypoint.dart.js'); + final String prefix = fs.path.join('.dart_tool', 'build', 'flutter_web', 'foo', 'lib'); + final String path = fs.path.join(prefix, 'main_web_entrypoint.dart.js'); fs.file(path).createSync(recursive: true); fs.file('$path.map').createSync(); + if (addArchive) { + final List<int> bytes = utf8.encode('void main() {}'); + final TarEncoder encoder = TarEncoder(); + final Archive archive = Archive() + ..addFile(ArchiveFile.noCompress('main_web_entrypoint.1.dart.js', bytes.length, bytes)); + fs.file(fs.path.join(prefix, 'main_web_entrypoint.dart.js.tar.gz')) + ..writeAsBytes(encoder.encode(archive)); + } return Future<bool>.value(true); }); }, overrides: <Type, Generator>{ @@ -59,6 +74,19 @@ }); }); + test('Copies generated part files out of build directory', () => testbed.run(() async { + addArchive = true; + await buildWeb( + FlutterProject.current(), + fs.path.join('lib', 'main.dart'), + BuildInfo.release, + false, + ); + + expect(fs.file(fs.path.join('build', 'web', 'main_web_entrypoint.1.dart.js')), exists); + expect(fs.file(fs.path.join('build', 'web', 'main.dart.js')), exists); + })); + test('Refuses to build for web when missing index.html', () => testbed.run(() async { fs.file(fs.path.join('web', 'index.html')).deleteSync(); @@ -82,16 +110,6 @@ expect(await runner.run(), 1); })); - test('Can build for web', () => testbed.run(() async { - - await buildWeb( - FlutterProject.current(), - fs.path.join('lib', 'main.dart'), - BuildInfo.debug, - false, - ); - })); - test('Refuses to build a debug build for web', () => testbed.run(() async { final CommandRunner<void> runner = createTestCommandRunner(BuildCommand());