blob: faeefb2c7cbb0add6da220ba974704fb69b43f73 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: implementation_imports
import 'dart:async';
import 'dart:io' as io; // ignore: dart_io_import
import 'package:build/build.dart';
import 'package:build_config/build_config.dart';
import 'package:build_modules/build_modules.dart';
import 'package:build_modules/builders.dart';
import 'package:build_modules/src/module_builder.dart';
import 'package:build_modules/src/platform.dart';
import 'package:build_modules/src/workers.dart';
import 'package:build_runner_core/build_runner_core.dart' as core;
import 'package:build_runner_core/src/asset_graph/graph.dart';
import 'package:build_runner_core/src/asset_graph/node.dart';
import 'package:build_runner_core/src/generate/build_impl.dart';
import 'package:build_runner_core/src/generate/options.dart';
import 'package:build_test/builder.dart';
import 'package:build_test/src/debug_test_builder.dart';
import 'package:build_web_compilers/build_web_compilers.dart';
import 'package:build_web_compilers/builders.dart';
import 'package:build_web_compilers/src/dev_compiler_bootstrap.dart';
import 'package:crypto/crypto.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
import 'package:scratch_space/scratch_space.dart';
import 'package:test_core/backend.dart';
import 'package:watcher/watcher.dart';
import '../artifacts.dart';
import '../base/file_system.dart';
import '../base/logger.dart';
import '../base/platform.dart';
import '../compile.dart';
import '../convert.dart';
import '../dart/package_map.dart';
import '../globals.dart';
import '../web/compile.dart';
const String ddcBootstrapExtension = '.dart.bootstrap.js';
const String jsEntrypointExtension = '.dart.js';
const String jsEntrypointSourceMapExtension = '.dart.js.map';
const String jsEntrypointArchiveExtension = '.dart.js.tar.gz';
const String digestsEntrypointExtension = '.digests';
const String jsModuleErrorsExtension = '.ddc.js.errors';
const String jsModuleExtension = '.ddc.js';
const String jsSourceMapExtension = '.ddc.js.map';
final DartPlatform flutterWebPlatform =
DartPlatform.register('flutter_web', <String>[
'async',
'collection',
'convert',
'core',
'developer',
'html',
'html_common',
'indexed_db',
'js',
'js_util',
'math',
'svg',
'typed_data',
'web_audio',
'web_gl',
'web_sql',
'_internal',
// Flutter web specific libraries.
'ui',
'_engine',
'io',
'isolate',
]);
/// The build application to compile a flutter application to the web.
final List<core.BuilderApplication> builders = <core.BuilderApplication>[
core.apply(
'flutter_tools|test_bootstrap',
<BuilderFactory>[
(BuilderOptions options) => const DebugTestBuilder(),
(BuilderOptions options) => const FlutterWebTestBootstrapBuilder(),
],
core.toRoot(),
hideOutput: true,
defaultGenerateFor: const InputSet(
include: <String>[
'test/**',
],
),
),
core.apply(
'flutter_tools|shell',
<BuilderFactory>[
(BuilderOptions options) => FlutterWebShellBuilder(
options.config['targets'] ?? <String>['lib/main.dart']
),
],
core.toRoot(),
hideOutput: true,
defaultGenerateFor: const InputSet(
include: <String>[
'lib/**',
'web/**',
],
),
),
core.apply(
'flutter_tools|module_library',
<Builder Function(BuilderOptions)>[moduleLibraryBuilder],
core.toAllPackages(),
isOptional: true,
hideOutput: true,
appliesBuilders: <String>['flutter_tools|module_cleanup']),
core.apply(
'flutter_tools|ddc_modules',
<Builder Function(BuilderOptions)>[
(BuilderOptions options) => MetaModuleBuilder(flutterWebPlatform),
(BuilderOptions options) => MetaModuleCleanBuilder(flutterWebPlatform),
(BuilderOptions options) => ModuleBuilder(flutterWebPlatform),
],
core.toNoneByDefault(),
isOptional: true,
hideOutput: true,
appliesBuilders: <String>['flutter_tools|module_cleanup']),
core.apply(
'flutter_tools|ddc',
<Builder Function(BuilderOptions)>[
(BuilderOptions builderOptions) => KernelBuilder(
platformSdk: artifacts.getArtifactPath(Artifact.flutterWebSdk),
summaryOnly: true,
sdkKernelPath: path.join('kernel', 'flutter_ddc_sdk.dill'),
outputExtension: ddcKernelExtension,
platform: flutterWebPlatform,
librariesPath: 'libraries.json',
),
(BuilderOptions builderOptions) => DevCompilerBuilder(
useIncrementalCompiler: false,
platform: flutterWebPlatform,
platformSdk: artifacts.getArtifactPath(Artifact.flutterWebSdk),
sdkKernelPath: path.join('kernel', 'flutter_ddc_sdk.dill'),
),
],
core.toAllPackages(),
isOptional: true,
hideOutput: true,
appliesBuilders: <String>['flutter_tools|ddc_modules']),
core.apply(
'flutter_tools|entrypoint',
<BuilderFactory>[
(BuilderOptions options) => FlutterWebEntrypointBuilder(
options.config['targets'] ?? <String>['lib/main.dart'],
options.config['release'],
),
],
core.toRoot(),
hideOutput: true,
defaultGenerateFor: const InputSet(
include: <String>[
'lib/**',
],
),
),
core.apply(
'flutter_tools|test_entrypoint',
<BuilderFactory>[
(BuilderOptions options) => FlutterWebTestEntrypointBuilder(
options.config['targets'] ?? const <String>[]
),
],
core.toRoot(),
hideOutput: true,
defaultGenerateFor: const InputSet(
include: <String>[
'test/**_test.dart.browser_test.dart',
],
),
),
core.applyPostProcess('flutter_tools|module_cleanup', moduleCleanup,
defaultGenerateFor: const InputSet())
];
/// A build_runner specific implementation of the [WebCompilationProxy].
class BuildRunnerWebCompilationProxy extends WebCompilationProxy {
BuildRunnerWebCompilationProxy();
core.PackageGraph _packageGraph;
BuildImpl _builder;
PackageUriMapper _packageUriMapper;
@override
Future<bool> initialize({
@required Directory projectDirectory,
@required List<String> targets,
String testOutputDir,
bool release = false,
}) async {
// Create the .dart_tool directory if it doesn't exist.
projectDirectory.childDirectory('.dart_tool').createSync();
// Override the generated output directory so this does not conflict with
// other build_runner output.
core.overrideGeneratedOutputDirectory('flutter_web');
_packageUriMapper = PackageUriMapper(
path.absolute('lib/main.dart'), PackageMap.globalPackagesPath, null, null);
_packageGraph = core.PackageGraph.forPath(projectDirectory.path);
final core.BuildEnvironment buildEnvironment = core.OverrideableEnvironment(
core.IOEnvironment(_packageGraph), onLog: (LogRecord record) {
if (record.level == Level.SEVERE || record.level == Level.SHOUT) {
printError(record.message);
} else {
printTrace(record.message);
}
});
final LogSubscription logSubscription = LogSubscription(
buildEnvironment,
verbose: false,
logLevel: Level.FINE,
);
final BuildOptions buildOptions = await BuildOptions.create(
logSubscription,
packageGraph: _packageGraph,
skipBuildScriptCheck: true,
trackPerformance: false,
deleteFilesByDefault: true,
enableLowResourcesMode: platform.environment['FLUTTER_LOW_RESOURCE_MODE']?.toLowerCase() == 'true',
);
final Set<core.BuildDirectory> buildDirs = <core.BuildDirectory>{
if (testOutputDir != null)
core.BuildDirectory(
'test',
outputLocation: core.OutputLocation(
testOutputDir,
useSymlinks: !platform.isWindows,
),
),
};
final Status status =
logger.startProgress('Compiling ${targets.first} for the Web...', timeout: null);
core.BuildResult result;
try {
result = await _runBuilder(
buildEnvironment,
buildOptions,
targets,
release,
buildDirs,
);
return result.status == core.BuildStatus.success;
} on core.BuildConfigChangedException {
await _cleanAssets(projectDirectory);
result = await _runBuilder(
buildEnvironment,
buildOptions,
targets,
release,
buildDirs,
);
return result.status == core.BuildStatus.success;
} on core.BuildScriptChangedException {
await _cleanAssets(projectDirectory);
result = await _runBuilder(
buildEnvironment,
buildOptions,
targets,
release,
buildDirs,
);
return result.status == core.BuildStatus.success;
} finally {
status.stop();
}
}
@override
Future<bool> invalidate({@required List<Uri> inputs}) async {
final Status status =
logger.startProgress('Recompiling sources...', timeout: null);
final Map<AssetId, ChangeType> updates = <AssetId, ChangeType>{};
for (Uri input in inputs) {
final AssetId assetId = AssetId.resolve(_packageUriMapper.map(input.toFilePath()).toString());
updates[assetId] = ChangeType.MODIFY;
}
core.BuildResult result;
try {
result = await _builder.run(updates);
} finally {
status.cancel();
}
return result.status == core.BuildStatus.success;
}
Future<core.BuildResult> _runBuilder(core.BuildEnvironment buildEnvironment, BuildOptions buildOptions, List<String> targets, bool release, Set<core.BuildDirectory> buildDirs) async {
_builder = await BuildImpl.create(
buildOptions,
buildEnvironment,
builders,
<String, Map<String, dynamic>>{
'flutter_tools|entrypoint': <String, dynamic>{
'targets': targets,
'release': release,
},
'flutter_tools|test_entrypoint': <String, dynamic>{
'targets': targets,
'release': release,
},
'flutter_tools|shell': <String, dynamic>{
'targets': targets,
}
},
isReleaseBuild: false,
);
return _builder.run(
const <AssetId, ChangeType>{},
buildDirs: buildDirs,
);
}
Future<void> _cleanAssets(Directory projectDirectory) async {
final File assetGraphFile = fs.file(core.assetGraphPath);
AssetGraph assetGraph;
try {
assetGraph = AssetGraph.deserialize(await assetGraphFile.readAsBytes());
} catch (_) {
printTrace('Failed to clean up asset graph.');
}
final core.PackageGraph packageGraph = core.PackageGraph.forThisPackage();
await _cleanUpSourceOutputs(assetGraph, packageGraph);
final Directory cacheDirectory = fs.directory(fs.path.join(
projectDirectory.path,
'.dart_tool',
'build',
'flutter_web',
));
if (assetGraphFile.existsSync()) {
assetGraphFile.deleteSync();
}
if (cacheDirectory.existsSync()) {
cacheDirectory.deleteSync(recursive: true);
}
}
Future<void> _cleanUpSourceOutputs(AssetGraph assetGraph, core.PackageGraph packageGraph) async {
final core.FileBasedAssetWriter writer = core.FileBasedAssetWriter(packageGraph);
if (assetGraph?.outputs == null) {
return;
}
for (AssetId id in assetGraph.outputs) {
if (id.package != packageGraph.root.name) {
continue;
}
final GeneratedAssetNode node = assetGraph.get(id);
if (node.wasOutput) {
// Note that this does a file.exists check in the root package and
// only tries to delete the file if it exists. This way we only
// actually delete to_source outputs, without reading in the build
// actions.
await writer.delete(id);
}
}
}
}
/// A ddc-only entrypoint builder that respects the Flutter target flag.
class FlutterWebTestEntrypointBuilder implements Builder {
const FlutterWebTestEntrypointBuilder(this.targets);
final List<String> targets;
@override
Map<String, List<String>> get buildExtensions => const <String, List<String>>{
'.dart': <String>[
ddcBootstrapExtension,
jsEntrypointExtension,
jsEntrypointSourceMapExtension,
jsEntrypointArchiveExtension,
digestsEntrypointExtension,
],
};
@override
Future<void> build(BuildStep buildStep) async {
bool matches = false;
for (String target in targets) {
if (buildStep.inputId.path.contains(target)) {
matches = true;
break;
}
}
if (!matches) {
return;
}
log.info('building for target ${buildStep.inputId.path}');
await bootstrapDdc(buildStep, platform: flutterWebPlatform);
}
}
/// A ddc-only entrypoint builder that respects the Flutter target flag.
class FlutterWebEntrypointBuilder implements Builder {
const FlutterWebEntrypointBuilder(this.targets, this.release);
final List<String> targets;
final bool release;
@override
Map<String, List<String>> get buildExtensions => const <String, List<String>>{
'.dart': <String>[
ddcBootstrapExtension,
jsEntrypointExtension,
jsEntrypointSourceMapExtension,
jsEntrypointArchiveExtension,
digestsEntrypointExtension,
],
};
@override
Future<void> build(BuildStep buildStep) async {
bool matches = false;
for (String target in targets) {
if (buildStep.inputId.path.contains(fs.path.setExtension(target, '_web_entrypoint.dart'))) {
matches = true;
break;
}
}
if (!matches) {
return;
}
log.info('building for target ${buildStep.inputId.path}');
if (release) {
await bootstrapDart2Js(buildStep);
} else {
await bootstrapDdc(buildStep, platform: flutterWebPlatform);
}
}
}
/// Bootstraps the test entrypoint.
class FlutterWebTestBootstrapBuilder implements Builder {
const FlutterWebTestBootstrapBuilder();
@override
Map<String, List<String>> get buildExtensions => const <String, List<String>>{
'_test.dart': <String>[
'_test.dart.browser_test.dart',
]
};
@override
Future<void> build(BuildStep buildStep) async {
final AssetId id = buildStep.inputId;
final String contents = await buildStep.readAsString(id);
final String assetPath = id.pathSegments.first == 'lib'
? path.url.join('packages', id.package, id.path)
: id.path;
final Metadata metadata = parseMetadata(
assetPath, contents, Runtime.builtIn.map((Runtime runtime) => runtime.name).toSet());
if (metadata.testOn.evaluate(SuitePlatform(Runtime.chrome))) {
await buildStep.writeAsString(id.addExtension('.browser_test.dart'), '''
import 'dart:ui' as ui;
import 'dart:html';
import 'dart:js';
import 'package:stream_channel/stream_channel.dart';
import 'package:test_api/src/backend/stack_trace_formatter.dart'; // ignore: implementation_imports
import 'package:test_api/src/util/stack_trace_mapper.dart'; // ignore: implementation_imports
import 'package:test_api/src/remote_listener.dart'; // ignore: implementation_imports
import 'package:test_api/src/suite_channel_manager.dart'; // ignore: implementation_imports
import "${path.url.basename(id.path)}" as test;
Future<void> main() async {
// Extra initialization for flutter_web.
// The following parameters are hard-coded in Flutter's test embedder. Since
// we don't have an embedder yet this is the lowest-most layer we can put
// this stuff in.
await ui.webOnlyInitializeEngine();
// TODO(flutterweb): remove need for dynamic cast.
(ui.window as dynamic).debugOverrideDevicePixelRatio(3.0);
(ui.window as dynamic).webOnlyDebugPhysicalSizeOverride = const ui.Size(2400, 1800);
internalBootstrapBrowserTest(() => test.main);
}
void internalBootstrapBrowserTest(Function getMain()) {
var channel =
serializeSuite(getMain, hidePrints: false, beforeLoad: () async {
var serialized =
await suiteChannel("test.browser.mapper").stream.first as Map;
if (serialized == null) return;
});
postMessageChannel().pipe(channel);
}
StreamChannel serializeSuite(Function getMain(),
{bool hidePrints = true, Future beforeLoad()}) =>
RemoteListener.start(getMain,
hidePrints: hidePrints, beforeLoad: beforeLoad);
StreamChannel suiteChannel(String name) {
var manager = SuiteChannelManager.current;
if (manager == null) {
throw StateError('suiteChannel() may only be called within a test worker.');
}
return manager.connectOut(name);
}
StreamChannel postMessageChannel() {
var controller = StreamChannelController(sync: true);
window.onMessage.firstWhere((message) {
return message.origin == window.location.origin && message.data == "port";
}).then((message) {
var port = message.ports.first;
var portSubscription = port.onMessage.listen((message) {
controller.local.sink.add(message.data);
});
controller.local.stream.listen((data) {
port.postMessage({"data": data});
}, onDone: () {
port.postMessage({"event": "done"});
portSubscription.cancel();
});
});
context['parent'].callMethod('postMessage', [
JsObject.jsify({"href": window.location.href, "ready": true}),
window.location.origin,
]);
return controller.foreign;
}
void setStackTraceMapper(StackTraceMapper mapper) {
var formatter = StackTraceFormatter.current;
if (formatter == null) {
throw StateError(
'setStackTraceMapper() may only be called within a test worker.');
}
formatter.configure(mapper: mapper);
}
''');
}
}
}
/// A shell builder which generates the web specific entrypoint.
class FlutterWebShellBuilder implements Builder {
const FlutterWebShellBuilder(this.targets);
final List<String> targets;
@override
FutureOr<void> build(BuildStep buildStep) async {
bool matches = false;
for (String target in targets) {
if (buildStep.inputId.path.contains(target)) {
matches = true;
break;
}
}
if (!matches) {
return;
}
final AssetId outputId = buildStep.inputId.changeExtension('_web_entrypoint.dart');
await buildStep.writeAsString(outputId, '''
import 'dart:ui' as ui;
import "${path.url.basename(buildStep.inputId.path)}" as entrypoint;
Future<void> main() async {
await ui.webOnlyInitializePlatform();
entrypoint.main();
}
''');
}
@override
Map<String, List<String>> get buildExtensions => const <String, List<String>>{
'.dart': <String>['_web_entrypoint.dart'],
};
}
Future<void> bootstrapDart2Js(BuildStep buildStep) async {
final AssetId dartEntrypointId = buildStep.inputId;
final AssetId moduleId = dartEntrypointId.changeExtension(moduleExtension(flutterWebPlatform));
final Module module = Module.fromJson(json.decode(await buildStep.readAsString(moduleId)));
final List<Module> allDeps = await module.computeTransitiveDependencies(buildStep, throwIfUnsupported: false)..add(module);
final ScratchSpace scratchSpace = await buildStep.fetchResource(scratchSpaceResource);
final Iterable<AssetId> allSrcs = allDeps.expand((Module module) => module.sources);
await scratchSpace.ensureAssets(allSrcs, buildStep);
final String packageFile = await _createPackageFile(allSrcs, buildStep, scratchSpace);
final String dartPath = dartEntrypointId.path.startsWith('lib/')
? 'package:${dartEntrypointId.package}/'
'${dartEntrypointId.path.substring('lib/'.length)}'
: dartEntrypointId.path;
final String jsOutputPath =
'${fs.path.withoutExtension(dartPath.replaceFirst('package:', 'packages/'))}'
'$jsEntrypointExtension';
final String flutterWebSdkPath = artifacts.getArtifactPath(Artifact.flutterWebSdk);
final String librariesPath = fs.path.join(flutterWebSdkPath, 'libraries.json');
final List<String> args = <String>[
'--libraries-spec="$librariesPath"',
'-m',
'-o4',
'-o',
'$jsOutputPath',
'--packages="$packageFile"',
'-Ddart.vm.product=true',
dartPath,
];
final Dart2JsBatchWorkerPool dart2js = await buildStep.fetchResource(dart2JsWorkerResource);
final Dart2JsResult result = await dart2js.compile(args);
final AssetId jsOutputId = dartEntrypointId.changeExtension(jsEntrypointExtension);
final io.File jsOutputFile = scratchSpace.fileFor(jsOutputId);
if (result.succeeded && jsOutputFile.existsSync()) {
log.info(result.output);
// Explicitly write out the original js file and sourcemap.
await scratchSpace.copyOutput(jsOutputId, buildStep);
final AssetId jsSourceMapId =
dartEntrypointId.changeExtension(jsEntrypointSourceMapExtension);
await _copyIfExists(jsSourceMapId, scratchSpace, buildStep);
} else {
log.severe(result.output);
}
}
Future<void> _copyIfExists(
AssetId id, ScratchSpace scratchSpace, AssetWriter writer) async {
final io.File file = scratchSpace.fileFor(id);
if (file.existsSync()) {
await scratchSpace.copyOutput(id, writer);
}
}
/// Creates a `.packages` file unique to this entrypoint at the root of the
/// scratch space and returns it's filename.
///
/// Since mulitple invocations of Dart2Js will share a scratch space and we only
/// know the set of packages involved the current entrypoint we can't construct
/// a `.packages` file that will work for all invocations of Dart2Js so a unique
/// file is created for every entrypoint that is run.
///
/// The filename is based off the MD5 hash of the asset path so that files are
/// unique regarless of situations like `web/foo/bar.dart` vs
/// `web/foo-bar.dart`.
Future<String> _createPackageFile(Iterable<AssetId> inputSources, BuildStep buildStep, ScratchSpace scratchSpace) async {
final Uri inputUri = buildStep.inputId.uri;
final String packageFileName =
'.package-${md5.convert(inputUri.toString().codeUnits)}';
final io.File packagesFile =
scratchSpace.fileFor(AssetId(buildStep.inputId.package, packageFileName));
final Set<String> packageNames = inputSources.map((AssetId s) => s.package).toSet();
final String packagesFileContent =
packageNames.map((String name) => '$name:packages/$name/').join('\n');
await packagesFile
.writeAsString('# Generated for $inputUri\n$packagesFileContent');
return packageFileName;
}