From 2d26cbb2bfe95199f766dd05337bbd433e085a22 Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Thu, 7 May 2020 17:59:02 -0700 Subject: [PATCH] [flutter_tools] reduce initial cache size on web (#56103) --- .../lib/src/build_system/targets/web.dart | 122 +++++++++++++++--- .../build_system/targets/web_test.dart | 25 +++- 2 files changed, 126 insertions(+), 21 deletions(-) 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 bc867a80e95..e640ed2b463 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/web.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/web.dart @@ -357,7 +357,8 @@ class WebServiceWorker extends Target { final Map urlToHash = {}; for (final File file in contents) { // Do not force caching of source maps. - if (file.path.endsWith('main.dart.js.map')) { + if (file.path.endsWith('main.dart.js.map') || + file.path.endsWith('.part.js.map')) { continue; } final String url = globals.fs.path.toUri( @@ -376,7 +377,15 @@ class WebServiceWorker extends Target { final File serviceWorkerFile = environment.outputDir .childFile('flutter_service_worker.js'); final Depfile depfile = Depfile(contents, [serviceWorkerFile]); - final String serviceWorker = generateServiceWorker(urlToHash); + final String serviceWorker = generateServiceWorker(urlToHash, [ + 'main.dart.js', + '/', + 'index.html', + 'assets/LICENSE', + 'assets/AssetManifest.json', + if (urlToHash.containsKey('assets/FontManifest.json')) + 'assets/FontManifest.json', + ]); serviceWorkerFile .writeAsStringSync(serviceWorker); final DepfileService depfileService = DepfileService( @@ -393,40 +402,113 @@ class WebServiceWorker extends Target { /// 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 +/// 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. -// TODO(jonahwilliams): on re-activate, only evict stale assets. -String generateServiceWorker(Map resources) { +String generateServiceWorker(Map resources, List coreBundle) { 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 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)); +// 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) => { + return event.waitUntil( + caches.open(TEMP).then((cache) => { + return cache.addAll(CORE); }) ); }); -self.addEventListener('fetch', function (event) { - event.respondWith( - caches.match(event.request) - .then(function (response) { - if (response) { - return response; +// 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); + for (var request of await tempCache.keys()) { + var response = await tempCache.match(request); + await contentCache.put(request, response); } - return fetch(event.request); + 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) => { + var origin = self.location.origin; + var key = event.request.url.substring(origin.length + 1); + // If the URL is not the the RESOURCE list, skip the cache. + if (!RESOURCES[key]) { + return event.respondWith(fetch(event.request)); + } + 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; + }); }) + }) ); }); + '''; } 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 60b955eb3a1..8752d2b6b8c 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 @@ -463,11 +463,17 @@ void main() { })); test('Generated service worker correctly inlines file hashes', () { - final String result = generateServiceWorker({'/foo': 'abcd'}); + final String result = generateServiceWorker({'/foo': 'abcd'}, []); expect(result, contains('{\n "/foo": "abcd"\n};')); }); + test('Generated service worker includes core files', () { + final String result = generateServiceWorker({'/foo': 'abcd'}, ['foo', 'bar']); + + expect(result, contains('"foo",\n"bar"')); + }); + test('WebServiceWorker generates a service_worker for a web resource folder', () => testbed.run(() async { environment.outputDir.childDirectory('a').childFile('a.txt') ..createSync(recursive: true) @@ -497,6 +503,23 @@ void main() { contains('"index.html": "d41d8cd98f00b204e9800998ecf8427e"')); expect(environment.buildDir.childFile('service_worker.d'), exists); })); + + test('WebServiceWorker does not cache source maps', () => testbed.run(() async { + environment.outputDir + .childFile('main.dart.js') + .createSync(recursive: true); + environment.outputDir + .childFile('main.dart.js.map') + .createSync(recursive: true); + await const WebServiceWorker().build(environment); + + // No caching of source maps. + expect(environment.outputDir.childFile('flutter_service_worker.js').readAsStringSync(), + isNot(contains('"main.dart.js.map"'))); + // Expected twice, once for RESOURCES and once for CORE. + expect(environment.outputDir.childFile('flutter_service_worker.js').readAsStringSync(), + contains('"main.dart.js"')); + })); } class MockProcessManager extends Mock implements ProcessManager {}