diff --git a/examples/flutter_gallery/web/index.html b/examples/flutter_gallery/web/index.html index 69b9962cac4..9e8e09b79b2 100644 --- a/examples/flutter_gallery/web/index.html +++ b/examples/flutter_gallery/web/index.html @@ -18,6 +18,16 @@ found in the LICENSE file. --> + + diff --git a/packages/flutter_tools/lib/src/build_system/targets/web.dart b/packages/flutter_tools/lib/src/build_system/targets/web.dart index 5b5f2b81fc2..57e27e08a3e 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/web.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/web.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:crypto/crypto.dart'; + import '../../artifacts.dart'; import '../../base/file_system.dart'; import '../../base/io.dart'; @@ -231,7 +233,12 @@ class WebReleaseBundle extends Target { @override Future build(Environment environment) async { for (final File outputFile in environment.buildDir.listSync(recursive: true).whereType()) { - if (!globals.fs.path.basename(outputFile.path).contains('main.dart.js')) { + 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( @@ -267,3 +274,96 @@ class WebReleaseBundle extends Target { } } + +/// Generate a service worker for a web target. +class WebServiceWorker extends Target { + const WebServiceWorker(); + + @override + String get name => 'web_service_worker'; + + @override + List get dependencies => const [ + Dart2JSTarget(), + WebReleaseBundle(), + ]; + + @override + List get depfiles => const [ + 'service_worker.d', + ]; + + @override + List get inputs => const []; + + @override + List get outputs => const []; + + @override + Future build(Environment environment) async { + final List contents = environment.outputDir + .listSync(recursive: true) + .whereType() + .where((File file) => !file.path.endsWith('flutter_service_worker.js') + && !globals.fs.path.basename(file.path).startsWith('.')) + .toList(); + // TODO(jonahwilliams): determine whether this needs to be made more efficient. + final Map uriToHash = { + for (File file in contents) + // Do not force caching of source maps. + if (!file.path.endsWith('main.dart.js.map')) + '/${globals.fs.path.relative(file.path, from: environment.outputDir.path)}': + md5.convert(await file.readAsBytes()).toString(), + }; + final File serviceWorkerFile = environment.outputDir + .childFile('flutter_service_worker.js'); + final Depfile depfile = Depfile(contents, [serviceWorkerFile]); + final String serviceWorker = generateServiceWorker(uriToHash); + serviceWorkerFile + .writeAsStringSync(serviceWorker); + depfile.writeToFile(environment.buildDir.childFile('service_worker.d')); + } +} + +/// Generate a service worker with an app-specific cache name a map of +/// resource files. +/// +/// We embed file hashes directly into the worker so that the byte for byte +/// invalidation will automatically reactivate workers whenever a new +/// version is deployed. +// TODO(jonahwilliams): on re-activate, only evict stale assets. +String generateServiceWorker(Map resources) { + return ''' +'use strict'; +const CACHE_NAME = 'flutter-app-cache'; +const RESOURCES = { + ${resources.entries.map((MapEntry entry) => '"${entry.key}": "${entry.value}"').join(",\n")} +}; + +self.addEventListener('activate', function (event) { + event.waitUntil( + caches.keys().then(function (cacheName) { + return caches.delete(cacheName); + }).then(function (_) { + return caches.open(CACHE_NAME); + }).then(function (cache) { + return cache.addAll(Object.keys(RESOURCES)); + }) + ); +}); + +self.addEventListener('fetch', function (event) { + event.respondWith( + caches.match(event.request) + .then(function (response) { + if (response) { + return response; + } + return fetch(event.request, { + credentials: 'include' + }); + }) + ); +}); +'''; +} diff --git a/packages/flutter_tools/lib/src/commands/assemble.dart b/packages/flutter_tools/lib/src/commands/assemble.dart index cafbdd122ae..330832c1158 100644 --- a/packages/flutter_tools/lib/src/commands/assemble.dart +++ b/packages/flutter_tools/lib/src/commands/assemble.dart @@ -35,7 +35,7 @@ const List _kDefaultTargets = [ ProfileMacOSBundleFlutterAssets(), ReleaseMacOSBundleFlutterAssets(), DebugBundleLinuxAssets(), - WebReleaseBundle(), + WebServiceWorker(), DebugAndroidApplication(), FastStartAndroidApplication(), ProfileAndroidApplication(), diff --git a/packages/flutter_tools/lib/src/web/compile.dart b/packages/flutter_tools/lib/src/web/compile.dart index 266ecc483d5..b7cf8a6d2c1 100644 --- a/packages/flutter_tools/lib/src/web/compile.dart +++ b/packages/flutter_tools/lib/src/web/compile.dart @@ -39,7 +39,7 @@ Future buildWeb( final Status status = globals.logger.startProgress('Compiling $target for the Web...', timeout: null); final Stopwatch sw = Stopwatch()..start(); try { - final BuildResult result = await buildSystem.build(const WebReleaseBundle(), Environment( + final BuildResult result = await buildSystem.build(const WebServiceWorker(), Environment( outputDir: globals.fs.directory(getWebBuildDirectory()), projectDir: globals.fs.currentDirectory, buildDir: flutterProject.directory diff --git a/packages/flutter_tools/templates/app/web/index.html.tmpl b/packages/flutter_tools/templates/app/web/index.html.tmpl index dc50861a9fb..060558c2185 100644 --- a/packages/flutter_tools/templates/app/web/index.html.tmpl +++ b/packages/flutter_tools/templates/app/web/index.html.tmpl @@ -15,6 +15,16 @@ + + diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart index f3458b19dae..2a59794b9f1 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.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:file_testing/file_testing.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/build_system/build_system.dart'; @@ -80,6 +81,7 @@ void main() { })); test('WebReleaseBundle copies dart2js output and resource files to output directory', () => testbed.run(() async { + environment.defines[kBuildMode] = 'release'; final Directory webResources = environment.projectDir.childDirectory('web'); webResources.childFile('index.html') ..createSync(recursive: true); @@ -383,6 +385,27 @@ void main() { }, overrides: { ProcessManager: () => MockProcessManager(), })); + + test('Generated service worker correctly inlines file hashes', () { + final String result = generateServiceWorker({'/foo': 'abcd'}); + + expect(result, contains('{\n "/foo": "abcd"\n};')); + }); + + test('WebServiceWorker generates a service_worker for a web resource folder', () => testbed.run(() async { + environment.outputDir.childFile('a.txt') + ..createSync(recursive: true) + ..writeAsStringSync('A'); + await const WebServiceWorker().build(environment); + + expect(environment.outputDir.childFile('flutter_service_worker.js'), exists); + // Contains file hash. + expect(environment.outputDir.childFile('flutter_service_worker.js').readAsStringSync(), + contains('"/a.txt": "7fc56270e7a70fa81a5935b72eacbe29"')); + expect(environment.buildDir.childFile('service_worker.d'), exists); + // Depends on resource file. + expect(environment.buildDir.childFile('service_worker.d').readAsStringSync(), contains('a.txt')); + })); } class MockProcessManager extends Mock implements ProcessManager {}