blob: 17db0721c83b7c81dacc3c2b035586de599cd9e5 [file] [log] [blame]
// 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:math';
import 'package:crypto/crypto.dart';
import 'package:meta/meta.dart';
import 'package:package_config/package_config.dart';
import '../../artifacts.dart';
import '../../base/file_system.dart';
import '../../base/io.dart';
import '../../build_info.dart';
import '../../dart/language_version.dart';
import '../../dart/package_map.dart';
import '../../globals.dart' as globals;
import '../../project.dart';
import '../build_system.dart';
import '../depfile.dart';
import 'assets.dart';
import 'common.dart';
import 'localizations.dart';
/// Whether web builds should call the platform initialization logic.
const String kInitializePlatform = 'InitializePlatform';
/// Whether the application has web plugins.
const String kHasWebPlugins = 'HasWebPlugins';
/// An override for the dart2js build mode.
///
/// Valid values are O1 (lowest, profile default) to O4 (highest, release default).
const String kDart2jsOptimization = 'Dart2jsOptimization';
/// Whether to disable dynamic generation code to satisfy csp policies.
const String kCspMode = 'cspMode';
/// The caching strategy to use for service worker generation.
const String kServiceWorkerStrategy = 'ServiceWorkerStrategy';
/// Whether the dart2js build should output source maps.
const String kSourceMapsEnabled = 'SourceMaps';
/// The caching strategy for the generated service worker.
enum ServiceWorkerStrategy {
/// Download the app shell eagerly and all other assets lazily.
/// Prefer the offline cached version.
offlineFirst,
/// Do not generate a service worker,
none,
}
const String kOfflineFirst = 'offline-first';
const String kNoneWorker = 'none';
/// Convert a [value] into a [ServiceWorkerStrategy].
ServiceWorkerStrategy _serviceWorkerStrategyFromString(String value) {
switch (value) {
case kNoneWorker:
return ServiceWorkerStrategy.none;
// offline-first is the default value for any invalid requests.
default:
return ServiceWorkerStrategy.offlineFirst;
}
}
/// Generates an entry point for a web target.
// Keep this in sync with build_runner/resident_web_runner.dart
class WebEntrypointTarget extends Target {
const WebEntrypointTarget();
@override
String get name => 'web_entrypoint';
@override
List<Target> get dependencies => const <Target>[];
@override
List<Source> get inputs => const <Source>[
Source.pattern('{FLUTTER_ROOT}/packages/flutter_tools/lib/src/build_system/targets/web.dart'),
];
@override
List<Source> get outputs => const <Source>[
Source.pattern('{BUILD_DIR}/main.dart'),
];
@override
Future<void> build(Environment environment) async {
final String targetFile = environment.defines[kTargetFile];
final bool shouldInitializePlatform = environment.defines[kInitializePlatform] == 'true';
final bool hasPlugins = environment.defines[kHasWebPlugins] == 'true';
final Uri importUri = environment.fileSystem.file(targetFile).absolute.uri;
// TODO(jonahwilliams): support configuration of this file.
const String packageFile = '.packages';
final PackageConfig packageConfig = await loadPackageConfigWithLogging(
environment.fileSystem.file(packageFile),
logger: environment.logger,
);
final FlutterProject flutterProject = FlutterProject.current();
final String languageVersion = determineLanguageVersion(
environment.fileSystem.file(targetFile),
packageConfig[flutterProject.manifest.appName],
) ?? '';
// Use the PackageConfig to find the correct package-scheme import path
// for the user application. If the application has a mix of package-scheme
// and relative imports for a library, then importing the entrypoint as a
// file-scheme will cause said library to be recognized as two distinct
// libraries. This can cause surprising behavior as types from that library
// will be considered distinct from each other.
// By construction, this will only be null if the .packages file does not
// have an entry for the user's application or if the main file is
// outside of the lib/ directory.
final String mainImport = packageConfig.toPackageUri(importUri)?.toString()
?? importUri.toString();
String contents;
if (hasPlugins) {
final Uri generatedUri = environment.projectDir
.childDirectory('lib')
.childFile('generated_plugin_registrant.dart')
.absolute
.uri;
final String generatedImport = packageConfig.toPackageUri(generatedUri)?.toString()
?? generatedUri.toString();
contents = '''
$languageVersion
import 'dart:ui' as ui;
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import '$generatedImport';
import '$mainImport' as entrypoint;
Future<void> main() async {
registerPlugins(webPluginRegistry);
if ($shouldInitializePlatform) {
await ui.webOnlyInitializePlatform();
}
entrypoint.main();
}
''';
} else {
contents = '''
$languageVersion
import 'dart:ui' as ui;
import '$mainImport' as entrypoint;
Future<void> main() async {
if ($shouldInitializePlatform) {
await ui.webOnlyInitializePlatform();
}
entrypoint.main();
}
''';
}
environment.buildDir.childFile('main.dart')
.writeAsStringSync(contents);
}
}
/// Compiles a web entry point with dart2js.
class Dart2JSTarget extends Target {
const Dart2JSTarget();
@override
String get name => 'dart2js';
@override
List<Target> get dependencies => const <Target>[
WebEntrypointTarget(),
GenerateLocalizationsTarget(),
];
@override
List<Source> get inputs => const <Source>[
Source.artifact(Artifact.flutterWebSdk),
Source.artifact(Artifact.dart2jsSnapshot),
Source.artifact(Artifact.engineDartBinary),
Source.pattern('{BUILD_DIR}/main.dart'),
Source.pattern('{PROJECT_DIR}/.packages'),
];
@override
List<Source> get outputs => const <Source>[];
@override
List<String> get depfiles => const <String>[
'dart2js.d',
];
@override
Future<void> build(Environment environment) async {
final BuildMode buildMode = getBuildModeForName(environment.defines[kBuildMode]);
final bool sourceMapsEnabled = environment.defines[kSourceMapsEnabled] == 'true';
final List<String> sharedCommandOptions = <String>[
globals.artifacts.getArtifactPath(Artifact.engineDartBinary),
'--disable-dart-dev',
globals.artifacts.getArtifactPath(Artifact.dart2jsSnapshot),
'--libraries-spec=${globals.fs.path.join(globals.artifacts.getArtifactPath(Artifact.flutterWebSdk), 'libraries.json')}',
...?decodeDartDefines(environment.defines, kExtraFrontEndOptions),
if (buildMode == BuildMode.profile)
'-Ddart.vm.profile=true'
else
'-Ddart.vm.product=true',
for (final String dartDefine in decodeDartDefines(environment.defines, kDartDefines))
'-D$dartDefine',
if (!sourceMapsEnabled)
'--no-source-maps',
];
// Run the dart2js compilation in two stages, so that icon tree shaking can
// parse the kernel file for web builds.
final ProcessResult kernelResult = await globals.processManager.run(<String>[
...sharedCommandOptions,
'-o',
environment.buildDir.childFile('app.dill').path,
'--packages=.packages',
'--cfe-only',
environment.buildDir.childFile('main.dart').path, // dartfile
]);
if (kernelResult.exitCode != 0) {
throw Exception(kernelResult.stdout + kernelResult.stderr);
}
final String dart2jsOptimization = environment.defines[kDart2jsOptimization];
final File outputJSFile = environment.buildDir.childFile('main.dart.js');
final bool csp = environment.defines[kCspMode] == 'true';
final ProcessResult javaScriptResult = await environment.processManager.run(<String>[
...sharedCommandOptions,
if (dart2jsOptimization != null) '-$dart2jsOptimization' else '-O4',
if (buildMode == BuildMode.profile) '--no-minify',
if (csp) '--csp',
'-o',
outputJSFile.path,
environment.buildDir.childFile('app.dill').path, // dartfile
]);
if (javaScriptResult.exitCode != 0) {
throw Exception(javaScriptResult.stdout + javaScriptResult.stderr);
}
final File dart2jsDeps = environment.buildDir
.childFile('app.dill.deps');
if (!dart2jsDeps.existsSync()) {
globals.printError('Warning: dart2js did not produced expected deps list at '
'${dart2jsDeps.path}');
return;
}
final DepfileService depfileService = DepfileService(
fileSystem: globals.fs,
logger: globals.logger,
);
final Depfile depfile = depfileService.parseDart2js(
environment.buildDir.childFile('app.dill.deps'),
outputJSFile,
);
depfileService.writeToFile(
depfile,
environment.buildDir.childFile('dart2js.d'),
);
}
}
/// Unpacks the dart2js compilation and resources to a given output directory.
class WebReleaseBundle extends Target {
const WebReleaseBundle();
@override
String get name => 'web_release_bundle';
@override
List<Target> get dependencies => const <Target>[
Dart2JSTarget(),
];
@override
List<Source> get inputs => const <Source>[
Source.pattern('{BUILD_DIR}/main.dart.js'),
Source.pattern('{PROJECT_DIR}/pubspec.yaml'),
];
@override
List<Source> get outputs => const <Source>[
Source.pattern('{OUTPUT_DIR}/main.dart.js'),
];
@override
List<String> get depfiles => const <String>[
'dart2js.d',
'flutter_assets.d',
'web_resources.d',
];
@override
Future<void> build(Environment environment) async {
for (final File outputFile in environment.buildDir.listSync(recursive: true).whereType<File>()) {
final String basename = globals.fs.path.basename(outputFile.path);
if (!basename.contains('main.dart.js')) {
continue;
}
// Do not copy the deps file.
if (basename.endsWith('.deps')) {
continue;
}
outputFile.copySync(
environment.outputDir.childFile(globals.fs.path.basename(outputFile.path)).path
);
}
final String versionInfo = FlutterProject.current().getVersionInfo();
environment.outputDir
.childFile('version.json')
.writeAsStringSync(versionInfo);
final Directory outputDirectory = environment.outputDir.childDirectory('assets');
outputDirectory.createSync(recursive: true);
final Depfile depfile = await copyAssets(
environment,
environment.outputDir.childDirectory('assets'),
targetPlatform: TargetPlatform.web_javascript,
);
final DepfileService depfileService = DepfileService(
fileSystem: globals.fs,
logger: globals.logger,
);
depfileService.writeToFile(
depfile,
environment.buildDir.childFile('flutter_assets.d'),
);
final Directory webResources = environment.projectDir
.childDirectory('web');
final List<File> inputResourceFiles = webResources
.listSync(recursive: true)
.whereType<File>()
.toList();
// Copy other resource files out of web/ directory.
final List<File> outputResourcesFiles = <File>[];
for (final File inputFile in inputResourceFiles) {
final File outputFile = globals.fs.file(globals.fs.path.join(
environment.outputDir.path,
globals.fs.path.relative(inputFile.path, from: webResources.path)));
if (!outputFile.parent.existsSync()) {
outputFile.parent.createSync(recursive: true);
}
outputResourcesFiles.add(outputFile);
// insert a random hash into the requests for service_worker.js. This is not a content hash,
// because it would need to be the hash for the entire bundle and not just the resource
// in question.
if (environment.fileSystem.path.basename(inputFile.path) == 'index.html') {
final String randomHash = Random().nextInt(4294967296).toString();
final String resultString = inputFile.readAsStringSync()
.replaceFirst(
"navigator.serviceWorker.register('flutter_service_worker.js')",
"navigator.serviceWorker.register('flutter_service_worker.js?v=$randomHash')",
);
outputFile.writeAsStringSync(resultString);
continue;
}
inputFile.copySync(outputFile.path);
}
final Depfile resourceFile = Depfile(inputResourceFiles, outputResourcesFiles);
depfileService.writeToFile(
resourceFile,
environment.buildDir.childFile('web_resources.d'),
);
}
}
/// Generate a service worker for a web target.
class WebServiceWorker extends Target {
const WebServiceWorker();
@override
String get name => 'web_service_worker';
@override
List<Target> get dependencies => const <Target>[
Dart2JSTarget(),
WebReleaseBundle(),
];
@override
List<String> get depfiles => const <String>[
'service_worker.d',
];
@override
List<Source> get inputs => const <Source>[];
@override
List<Source> get outputs => const <Source>[];
@override
Future<void> build(Environment environment) async {
final List<File> contents = environment.outputDir
.listSync(recursive: true)
.whereType<File>()
.where((File file) => !file.path.endsWith('flutter_service_worker.js')
&& !globals.fs.path.basename(file.path).startsWith('.'))
.toList();
final Map<String, String> urlToHash = <String, String>{};
for (final File file in contents) {
// Do not force caching of source maps.
if (file.path.endsWith('main.dart.js.map') ||
file.path.endsWith('.part.js.map')) {
continue;
}
final String url = globals.fs.path.toUri(
globals.fs.path.relative(
file.path,
from: environment.outputDir.path),
).toString();
final String hash = md5.convert(await file.readAsBytes()).toString();
urlToHash[url] = hash;
// Add an additional entry for the base URL.
if (globals.fs.path.basename(url) == 'index.html') {
urlToHash['/'] = hash;
}
}
final File serviceWorkerFile = environment.outputDir
.childFile('flutter_service_worker.js');
final Depfile depfile = Depfile(contents, <File>[serviceWorkerFile]);
final ServiceWorkerStrategy serviceWorkerStrategy = _serviceWorkerStrategyFromString(
environment.defines[kServiceWorkerStrategy],
);
final String serviceWorker = generateServiceWorker(
urlToHash,
<String>[
'/',
'main.dart.js',
'index.html',
'assets/NOTICES',
if (urlToHash.containsKey('assets/AssetManifest.json'))
'assets/AssetManifest.json',
if (urlToHash.containsKey('assets/FontManifest.json'))
'assets/FontManifest.json',
],
serviceWorkerStrategy: serviceWorkerStrategy,
);
serviceWorkerFile
.writeAsStringSync(serviceWorker);
final DepfileService depfileService = DepfileService(
fileSystem: globals.fs,
logger: globals.logger,
);
depfileService.writeToFile(
depfile,
environment.buildDir.childFile('service_worker.d'),
);
}
}
/// Generate a service worker with an app-specific cache name a map of
/// resource files.
///
/// The tool embeds file hashes directly into the worker so that the byte for byte
/// invalidation will automatically reactivate workers whenever a new
/// version is deployed.
String generateServiceWorker(
Map<String, String> resources,
List<String> coreBundle, {
@required ServiceWorkerStrategy serviceWorkerStrategy,
}) {
if (serviceWorkerStrategy == ServiceWorkerStrategy.none) {
return '';
}
return '''
'use strict';
const MANIFEST = 'flutter-app-manifest';
const TEMP = 'flutter-temp-cache';
const CACHE_NAME = 'flutter-app-cache';
const RESOURCES = {
${resources.entries.map((MapEntry<String, String> entry) => '"${entry.key}": "${entry.value}"').join(",\n")}
};
// The application shell files that are downloaded before a service worker can
// start.
const CORE = [
${coreBundle.map((String file) => '"$file"').join(',\n')}];
// During install, the TEMP cache is populated with the application shell files.
self.addEventListener("install", (event) => {
self.skipWaiting();
return event.waitUntil(
caches.open(TEMP).then((cache) => {
return cache.addAll(
CORE.map((value) => new Request(value + '?revision=' + RESOURCES[value], {'cache': 'reload'})));
})
);
});
// During activate, the cache is populated with the temp files downloaded in
// install. If this service worker is upgrading from one with a saved
// MANIFEST, then use this to retain unchanged resource files.
self.addEventListener("activate", function(event) {
return event.waitUntil(async function() {
try {
var contentCache = await caches.open(CACHE_NAME);
var tempCache = await caches.open(TEMP);
var manifestCache = await caches.open(MANIFEST);
var manifest = await manifestCache.match('manifest');
// When there is no prior manifest, clear the entire cache.
if (!manifest) {
await caches.delete(CACHE_NAME);
contentCache = await caches.open(CACHE_NAME);
for (var request of await tempCache.keys()) {
var response = await tempCache.match(request);
await contentCache.put(request, response);
}
await caches.delete(TEMP);
// Save the manifest to make future upgrades efficient.
await manifestCache.put('manifest', new Response(JSON.stringify(RESOURCES)));
return;
}
var oldManifest = await manifest.json();
var origin = self.location.origin;
for (var request of await contentCache.keys()) {
var key = request.url.substring(origin.length + 1);
if (key == "") {
key = "/";
}
// If a resource from the old manifest is not in the new cache, or if
// the MD5 sum has changed, delete it. Otherwise the resource is left
// in the cache and can be reused by the new service worker.
if (!RESOURCES[key] || RESOURCES[key] != oldManifest[key]) {
await contentCache.delete(request);
}
}
// Populate the cache with the app shell TEMP files, potentially overwriting
// cache files preserved above.
for (var request of await tempCache.keys()) {
var response = await tempCache.match(request);
await contentCache.put(request, response);
}
await caches.delete(TEMP);
// Save the manifest to make future upgrades efficient.
await manifestCache.put('manifest', new Response(JSON.stringify(RESOURCES)));
return;
} catch (err) {
// On an unhandled exception the state of the cache cannot be guaranteed.
console.error('Failed to upgrade service worker: ' + err);
await caches.delete(CACHE_NAME);
await caches.delete(TEMP);
await caches.delete(MANIFEST);
}
}());
});
// The fetch handler redirects requests for RESOURCE files to the service
// worker cache.
self.addEventListener("fetch", (event) => {
if (event.request.method !== 'GET') {
return;
}
var origin = self.location.origin;
var key = event.request.url.substring(origin.length + 1);
// Redirect URLs to the index.html
if (key.indexOf('?v=') != -1) {
key = key.split('?v=')[0];
}
if (event.request.url == origin || event.request.url.startsWith(origin + '/#') || key == '') {
key = '/';
}
// If the URL is not the RESOURCE list then return to signal that the
// browser should take over.
if (!RESOURCES[key]) {
return;
}
// If the URL is the index.html, perform an online-first request.
if (key == '/') {
return onlineFirst(event);
}
event.respondWith(caches.open(CACHE_NAME)
.then((cache) => {
return cache.match(event.request).then((response) => {
// Either respond with the cached resource, or perform a fetch and
// lazily populate the cache.
return response || fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
})
})
);
});
self.addEventListener('message', (event) => {
// SkipWaiting can be used to immediately activate a waiting service worker.
// This will also require a page refresh triggered by the main worker.
if (event.data === 'skipWaiting') {
self.skipWaiting();
return;
}
if (event.data === 'downloadOffline') {
downloadOffline();
return;
}
});
// Download offline will check the RESOURCES for all files not in the cache
// and populate them.
async function downloadOffline() {
var resources = [];
var contentCache = await caches.open(CACHE_NAME);
var currentContent = {};
for (var request of await contentCache.keys()) {
var key = request.url.substring(origin.length + 1);
if (key == "") {
key = "/";
}
currentContent[key] = true;
}
for (var resourceKey in Object.keys(RESOURCES)) {
if (!currentContent[resourceKey]) {
resources.push(resourceKey);
}
}
return contentCache.addAll(resources);
}
// Attempt to download the resource online before falling back to
// the offline cache.
function onlineFirst(event) {
return event.respondWith(
fetch(event.request).then((response) => {
return caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, response.clone());
return response;
});
}).catch((error) => {
return caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((response) => {
if (response != null) {
return response;
}
throw error;
});
});
})
);
}
''';
}