Commit 9f145f6c authored by Jonah Williams's avatar Jonah Williams Committed by Flutter GitHub Bot

[flutter_tools][web] Add basic service worker generation support to web applications (#48344)

parent 0a600e1d
......@@ -18,6 +18,16 @@ found in the LICENSE file. -->
<link rel="manifest" href="/manifest.json">
</head>
<body>
<!-- This script installs service_worker.js to provide PWA functionality to
application. For more information, see:
https://developers.google.com/web/fundamentals/primers/service-workers -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/flutter_service_worker.js');
});
}
</script>
<script src="main.dart.js" type="application/javascript"></script>
</body>
</html>
......@@ -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<void> build(Environment environment) async {
for (final File outputFile in environment.buildDir.listSync(recursive: true).whereType<File>()) {
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<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();
// TODO(jonahwilliams): determine whether this needs to be made more efficient.
final Map<String, String> uriToHash = <String, String>{
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, <File>[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<String, String> resources) {
return '''
'use strict';
const CACHE_NAME = 'flutter-app-cache';
const RESOURCES = {
${resources.entries.map((MapEntry<String, String> 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'
});
})
);
});
''';
}
......@@ -35,7 +35,7 @@ const List<Target> _kDefaultTargets = <Target>[
ProfileMacOSBundleFlutterAssets(),
ReleaseMacOSBundleFlutterAssets(),
DebugBundleLinuxAssets(),
WebReleaseBundle(),
WebServiceWorker(),
DebugAndroidApplication(),
FastStartAndroidApplication(),
ProfileAndroidApplication(),
......
......@@ -39,7 +39,7 @@ Future<void> 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
......
......@@ -15,6 +15,16 @@
<link rel="manifest" href="/manifest.json">
</head>
<body>
<!-- This script installs service_worker.js to provide PWA functionality to
application. For more information, see:
https://developers.google.com/web/fundamentals/primers/service-workers -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/service_worker.js');
});
}
</script>
<script src="main.dart.js" type="application/javascript"></script>
</body>
</html>
......@@ -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: <Type, Generator>{
ProcessManager: () => MockProcessManager(),
}));
test('Generated service worker correctly inlines file hashes', () {
final String result = generateServiceWorker(<String, String>{'/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 {}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment