Unverified Commit e7953b3b authored by Yegor's avatar Yegor Committed by GitHub

[web] new service worker loading mechanism (#75535)

parent 78ce11d7
......@@ -57,3 +57,57 @@ Future<String> evalTestAppInChrome({
await server?.close();
}
}
typedef ServerRequestListener = void Function(Request);
class AppServer {
AppServer._(this._server, this.chrome, this.onChromeError);
static Future<AppServer> start({
@required String appUrl,
@required String appDirectory,
@required String cacheControl,
int serverPort = 8080,
int browserDebugPort = 8081,
bool headless = true,
List<Handler> additionalRequestHandlers,
}) async {
io.HttpServer server;
Chrome chrome;
server = await io.HttpServer.bind('localhost', serverPort);
final Handler staticHandler = createStaticHandler(appDirectory, defaultDocument: 'index.html');
Cascade cascade = Cascade();
if (additionalRequestHandlers != null) {
for (final Handler handler in additionalRequestHandlers) {
cascade = cascade.add(handler);
}
}
cascade = cascade.add((Request request) async {
final Response response = await staticHandler(request);
return response.change(headers: <String, Object>{
'cache-control': cacheControl,
});
});
shelf_io.serveRequests(server, cascade.handler);
final io.Directory userDataDirectory = io.Directory.systemTemp.createTempSync('chrome_user_data_');
final Completer<String> chromeErrorCompleter = Completer<String>();
chrome = await Chrome.launch(ChromeOptions(
headless: headless,
debugPort: browserDebugPort,
url: appUrl,
userDataDirectory: userDataDirectory.path,
windowHeight: 1024,
windowWidth: 1024,
), onError: chromeErrorCompleter.complete);
return AppServer._(server, chrome, chromeErrorCompleter.future);
}
final Future<String> onChromeError;
final io.HttpServer _server;
final Chrome chrome;
Future<void> stop() async {
chrome?.stop();
await _server?.close();
}
}
......@@ -6,138 +6,262 @@ import 'dart:async';
import 'dart:io';
import 'package:path/path.dart' as path;
import 'package:flutter_devicelab/framework/browser.dart';
import 'package:meta/meta.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_static/shelf_static.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
final String bat = Platform.isWindows ? '.bat' : '';
final String flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script))));
final String flutter = path.join(flutterRoot, 'bin', 'flutter$bat');
import 'browser.dart';
import 'run_command.dart';
import 'test/common.dart';
final String _bat = Platform.isWindows ? '.bat' : '';
final String _flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script))));
final String _flutter = path.join(_flutterRoot, 'bin', 'flutter$_bat');
final String _testAppDirectory = path.join(_flutterRoot, 'dev', 'integration_tests', 'web');
final String _appBuildDirectory = path.join(_testAppDirectory, 'build', 'web');
final String _target = path.join('lib', 'service_worker_test.dart');
final String _targetPath = path.join(_testAppDirectory, _target);
// Run a web service worker test. The expectations are currently stored here
// instead of in the application. This is not run on CI due to the requirement
// of having a headful chrome instance.
// Run a web service worker test as a standalone Dart program.
Future<void> main() async {
await _runWebServiceWorkerTest('lib/service_worker_test.dart');
await runWebServiceWorkerTest(headless: false);
}
Future<void> _runWebServiceWorkerTest(String target, {
List<String> additionalArguments = const<String>[],
}) async {
final String testAppDirectory = path.join(flutterRoot, 'dev', 'integration_tests', 'web');
final String appBuildDirectory = path.join(testAppDirectory, 'build', 'web');
Future<void> _setAppVersion(int version) async {
final File targetFile = File(_targetPath);
await targetFile.writeAsString(
(await targetFile.readAsString()).replaceFirst(
RegExp(r'CLOSE\?version=\d+'),
'CLOSE?version=$version',
)
);
}
// Build the app.
await Process.run(
flutter,
Future<void> _rebuildApp({ @required int version }) async {
await _setAppVersion(version);
await runCommand(
_flutter,
<String>[ 'clean' ],
workingDirectory: testAppDirectory,
workingDirectory: _testAppDirectory,
);
await Process.run(
flutter,
<String>[
'build',
'web',
'--release',
...additionalArguments,
'-t',
target,
],
workingDirectory: testAppDirectory,
await runCommand(
_flutter,
<String>['build', 'web', '--profile', '-t', _target],
workingDirectory: _testAppDirectory,
environment: <String, String>{
'FLUTTER_WEB': 'true',
},
);
final List<Uri> requests = <Uri>[];
final List<Map<String, String>> headers = <Map<String, String>>[];
await runRecordingServer(
appUrl: 'http://localhost:8080/',
appDirectory: appBuildDirectory,
requests: requests,
headers: headers,
browserDebugPort: null,
);
}
Future<void> runWebServiceWorkerTest({
@required bool headless,
}) async {
test('flutter_service_worker.js', () async {
await _rebuildApp(version: 1);
final List<String> requestedPaths = requests.map((Uri uri) => uri.toString()).toList();
final List<String> expectedPaths = <String>[
// Initial page load
'',
'main.dart.js',
'assets/FontManifest.json',
'flutter_service_worker.js',
'manifest.json',
'favicon.ico',
// Service worker install.
'main.dart.js',
'index.html',
'assets/LICENSE',
'assets/AssetManifest.json',
'assets/FontManifest.json',
'',
// Second page load all cached.
];
print('requests: $requestedPaths');
// The exact order isn't important or deterministic.
for (final String path in requestedPaths) {
if (!expectedPaths.remove(path)) {
print('unexpected service worker request: $path');
exit(1);
final Map<String, int> requestedPathCounts = <String, int>{};
void expectRequestCounts(Map<String, int> expectedCounts) {
expect(requestedPathCounts, expectedCounts);
requestedPathCounts.clear();
}
AppServer server;
Future<void> waitForAppToLoad(Map<String, int> waitForCounts) async {
print('Waiting for app to load $waitForCounts');
await Future.any(<Future<void>>[
() async {
while (!waitForCounts.entries.every((MapEntry<String, int> entry) => (requestedPathCounts[entry.key] ?? 0) >= entry.value)) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
if (expectedPaths.isNotEmpty) {
print('Missing service worker requests from expected paths: $expectedPaths');
exit(1);
}(),
server.onChromeError.then((String error) {
throw Exception('Chrome error: $error');
}),
]);
}
}
/// This server runs a release web application and verifies that the service worker
/// caches files correctly, by checking the request resources over HTTP.
///
/// When it receives a request for `CLOSE` the server will be torn down.
///
/// Expects a path to the `build/web` directory produced from `flutter build web`.
Future<void> runRecordingServer({
@required String appUrl,
@required String appDirectory,
@required List<Uri> requests,
@required List<Map<String, String>> headers,
int serverPort = 8080,
int browserDebugPort = 8081,
}) async {
Chrome chrome;
HttpServer server;
final Completer<void> completer = Completer<void>();
Directory userDataDirectory;
try {
server = await HttpServer.bind('localhost', serverPort);
final Cascade cascade = Cascade()
.add((Request request) async {
if (request.url.toString().contains('CLOSE')) {
completer.complete();
return Response.notFound('');
String reportedVersion;
Future<void> startAppServer({
@required String cacheControl,
}) async {
server = await AppServer.start(
headless: headless,
cacheControl: cacheControl,
appUrl: 'http://localhost:8080/index.html',
appDirectory: _appBuildDirectory,
additionalRequestHandlers: <Handler>[
(Request request) {
final String requestedPath = request.url.path;
requestedPathCounts.putIfAbsent(requestedPath, () => 0);
requestedPathCounts[requestedPath] += 1;
if (requestedPath == 'CLOSE') {
reportedVersion = request.url.queryParameters['version'];
return Response.ok('OK');
}
requests.add(request.url);
headers.add(request.headers);
return Response.notFound('');
})
.add(createStaticHandler(appDirectory, defaultDocument: 'index.html'));
shelf_io.serveRequests(server, cascade.handler);
userDataDirectory = Directory.systemTemp.createTempSync('chrome_user_data_');
chrome = await Chrome.launch(ChromeOptions(
headless: false,
debugPort: browserDebugPort,
url: appUrl,
userDataDirectory: userDataDirectory.path,
windowHeight: 500,
windowWidth: 500,
), onError: completer.completeError);
await completer.future;
},
],
);
}
try {
//////////////////////////////////////////////////////
// Caching server
//////////////////////////////////////////////////////
print('With cache: test first page load');
await startAppServer(cacheControl: 'max-age=3600');
await waitForAppToLoad(<String, int>{
'CLOSE': 1,
'flutter_service_worker.js': 1,
});
expectRequestCounts(<String, int>{
'': 1,
// Even though the server is caching index.html is downloaded twice,
// once by the initial page load, and once by the service worker.
// Other resources are loaded once only by the service worker.
'index.html': 2,
'main.dart.js': 1,
'flutter_service_worker.js': 1,
'assets/FontManifest.json': 1,
'assets/NOTICES': 1,
'assets/AssetManifest.json': 1,
'CLOSE': 1,
// In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'.
if (!headless)
...<String, int>{
'manifest.json': 1,
'favicon.ico': 1,
}
});
expect(reportedVersion, '1');
reportedVersion = null;
print('With cache: test page reload');
await server.chrome.reloadPage();
await waitForAppToLoad(<String, int>{
'CLOSE': 1,
'flutter_service_worker.js': 1,
});
expectRequestCounts(<String, int>{
'flutter_service_worker.js': 1,
'CLOSE': 1,
});
expect(reportedVersion, '1');
reportedVersion = null;
print('With cache: test page reload after rebuild');
await _rebuildApp(version: 2);
// Since we're caching, we need to ignore cache when reloading the page.
await server.chrome.reloadPage(ignoreCache: true);
await waitForAppToLoad(<String, int>{
'CLOSE': 1,
'flutter_service_worker.js': 2,
});
expectRequestCounts(<String, int>{
'index.html': 2,
'flutter_service_worker.js': 2,
'': 1,
'main.dart.js': 1,
'assets/NOTICES': 1,
'assets/AssetManifest.json': 1,
'assets/FontManifest.json': 1,
'CLOSE': 1,
if (!headless)
'favicon.ico': 1,
});
expect(reportedVersion, '2');
reportedVersion = null;
await server.stop();
//////////////////////////////////////////////////////
// Non-caching server
//////////////////////////////////////////////////////
print('No cache: test first page load');
await _rebuildApp(version: 3);
await startAppServer(cacheControl: 'max-age=0');
await waitForAppToLoad(<String, int>{
'CLOSE': 1,
'flutter_service_worker.js': 1,
});
expectRequestCounts(<String, int>{
'': 1,
'index.html': 2,
// We still download some resources multiple times if the server is non-caching.
'main.dart.js': 2,
'assets/FontManifest.json': 2,
'flutter_service_worker.js': 1,
'assets/NOTICES': 1,
'assets/AssetManifest.json': 1,
'CLOSE': 1,
// In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'.
if (!headless)
...<String, int>{
'manifest.json': 1,
'favicon.ico': 1,
}
});
expect(reportedVersion, '3');
reportedVersion = null;
print('No cache: test page reload');
await server.chrome.reloadPage();
await waitForAppToLoad(<String, int>{
'CLOSE': 1,
'flutter_service_worker.js': 1,
});
expectRequestCounts(<String, int>{
'flutter_service_worker.js': 1,
'CLOSE': 1,
if (!headless)
'manifest.json': 1,
});
expect(reportedVersion, '3');
reportedVersion = null;
print('No cache: test page reload after rebuild');
await _rebuildApp(version: 4);
// TODO(yjbanov): when running Chrome with DevTools protocol, for some
// reason a hard refresh is still required. This works without a hard
// refresh when running Chrome manually as normal. At the time of writing
// this test I wasn't able to figure out what's wrong with the way we run
// Chrome from tests.
await server.chrome.reloadPage(ignoreCache: true);
await waitForAppToLoad(<String, int>{
'CLOSE': 1,
'flutter_service_worker.js': 1,
});
expectRequestCounts(<String, int>{
'': 1,
'index.html': 2,
'flutter_service_worker.js': 2,
'main.dart.js': 2,
'assets/NOTICES': 1,
'assets/AssetManifest.json': 1,
'assets/FontManifest.json': 2,
'CLOSE': 1,
if (!headless)
...<String, int>{
'manifest.json': 1,
'favicon.ico': 1,
}
});
expect(reportedVersion, '4');
reportedVersion = null;
} finally {
chrome?.stop();
await server?.close();
userDataDirectory.deleteSync(recursive: true);
await _setAppVersion(1);
await server?.stop();
}
// This is a long test. The default 30 seconds is not enough.
}, timeout: const Timeout(Duration(minutes: 10)));
}
......@@ -15,6 +15,7 @@ import 'package:path/path.dart' as path;
import 'browser.dart';
import 'flutter_compact_formatter.dart';
import 'run_command.dart';
import 'service_worker_test.dart';
import 'utils.dart';
typedef ShardRunner = Future<void> Function();
......@@ -811,6 +812,7 @@ Future<void> _runWebLongRunningTests() async {
() => _runGalleryE2eWebTest('profile', canvasKit: true),
() => _runGalleryE2eWebTest('release'),
() => _runGalleryE2eWebTest('release', canvasKit: true),
() => runWebServiceWorkerTest(headless: true),
];
await _ensureChromeDriverIsRunning();
await _runShardRunnerIndexOfTotalSubshard(tests);
......
......@@ -196,6 +196,10 @@ class Chrome {
return data;
}
Future<void> reloadPage({bool ignoreCache = false}) async {
await _debugConnection.page.reload(ignoreCache: ignoreCache);
}
/// Stops the Chrome process.
void stop() {
_isStopped = true;
......
......@@ -23,12 +23,68 @@ found in the LICENSE file. -->
application. For more information, see:
https://developers.google.com/web/fundamentals/primers/service-workers -->
<script>
var serviceWorkerVersion = null;
var scriptLoaded = false;
function loadMainDartJs() {
if (scriptLoaded) {
return;
}
scriptLoaded = true;
var scriptTag = document.createElement('script');
scriptTag.src = 'main.dart.js';
scriptTag.type = 'application/javascript';
document.body.append(scriptTag);
}
if ('serviceWorker' in navigator) {
window.addEventListener('flutter-first-frame', function () {
navigator.serviceWorker.register('flutter_service_worker.js');
// Service workers are supported. Use them.
window.addEventListener('load', function () {
// Wait for registration to finish before dropping the <script> tag.
// Otherwise, the browser will load the script multiple times,
// potentially different versions.
var serviceWorkerUrl = 'flutter_service_worker.js?v=' + serviceWorkerVersion;
navigator.serviceWorker.register(serviceWorkerUrl)
.then((reg) => {
function waitForActivation(serviceWorker) {
serviceWorker.addEventListener('statechange', () => {
if (serviceWorker.state == 'activated') {
console.log('Installed new service worker.');
loadMainDartJs();
}
});
}
if (!reg.active && (reg.installing || reg.waiting)) {
// No active web worker and we have installed or are installing
// one for the first time. Simply wait for it to activate.
waitForActivation(reg.installing ?? reg.waiting);
} else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
// When the app updates the serviceWorkerVersion changes, so we
// need to ask the service worker to update.
console.log('New service worker available.');
reg.update();
waitForActivation(reg.installing);
} else {
// Existing service worker is still good.
console.log('Loading app from service worker.');
loadMainDartJs();
}
});
// If service worker doesn't succeed in a reasonable amount of time,
// fallback to plaint <script> tag.
setTimeout(() => {
if (!scriptLoaded) {
console.warn(
'Failed to load app from service worker. Falling back to plain <script> tag.',
);
loadMainDartJs();
}
}, 4000);
});
} else {
// Service workers not supported. Just drop the <script> tag.
loadMainDartJs();
}
</script>
<script src="main.dart.js" type="application/javascript"></script>
</body>
</html>
......@@ -4,16 +4,8 @@
import 'dart:html' as html;
Future<void> main() async {
final html.ServiceWorkerRegistration worker = await html.window.navigator.serviceWorker.ready;
if (worker.active != null) {
await Future.delayed(const Duration(seconds: 5));
await html.HttpRequest.getString('CLOSE');
return;
}
worker.addEventListener('statechange', (event) async {
if (worker.active != null) {
await Future.delayed(const Duration(seconds: 5));
await html.HttpRequest.getString('CLOSE');
}
});
await html.window.navigator.serviceWorker.ready;
final String response = 'CLOSE?version=1';
await html.HttpRequest.getString(response);
html.document.body.appendHtml(response);
}
......@@ -8,6 +8,10 @@ found in the LICENSE file. -->
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<title>Web Test</title>
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<link rel="manifest" href="manifest.json">
</head>
<body>
......@@ -15,12 +19,68 @@ found in the LICENSE file. -->
application. For more information, see:
https://developers.google.com/web/fundamentals/primers/service-workers -->
<script>
var serviceWorkerVersion = null;
var scriptLoaded = false;
function loadMainDartJs() {
if (scriptLoaded) {
return;
}
scriptLoaded = true;
var scriptTag = document.createElement('script');
scriptTag.src = 'main.dart.js';
scriptTag.type = 'application/javascript';
document.body.append(scriptTag);
}
if ('serviceWorker' in navigator) {
// Service workers are supported. Use them.
window.addEventListener('load', function () {
navigator.serviceWorker.register('flutter_service_worker.js');
// Wait for registration to finish before dropping the <script> tag.
// Otherwise, the browser will load the script multiple times,
// potentially different versions.
var serviceWorkerUrl = 'flutter_service_worker.js?v=' + serviceWorkerVersion;
navigator.serviceWorker.register(serviceWorkerUrl)
.then((reg) => {
function waitForActivation(serviceWorker) {
serviceWorker.addEventListener('statechange', () => {
if (serviceWorker.state == 'activated') {
console.log('Installed new service worker.');
loadMainDartJs();
}
});
}
if (!reg.active && (reg.installing || reg.waiting)) {
// No active web worker and we have installed or are installing
// one for the first time. Simply wait for it to activate.
waitForActivation(reg.installing ?? reg.waiting);
} else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
// When the app updates the serviceWorkerVersion changes, so we
// need to ask the service worker to update.
console.log('New service worker available.');
reg.update();
waitForActivation(reg.installing);
} else {
// Existing service worker is still good.
console.log('Loading app from service worker.');
loadMainDartJs();
}
});
// If service worker doesn't succeed in a reasonable amount of time,
// fallback to plaint <script> tag.
setTimeout(() => {
if (!scriptLoaded) {
console.warn(
'Failed to load app from service worker. Falling back to plain <script> tag.',
);
loadMainDartJs();
}
}, 4000);
});
} else {
// Service workers not supported. Just drop the <script> tag.
loadMainDartJs();
}
</script>
<script src="main.dart.js" type="application/javascript"></script>
</body>
</html>
......@@ -356,6 +356,12 @@ class WebReleaseBundle extends Target {
if (environment.fileSystem.path.basename(inputFile.path) == 'index.html') {
final String randomHash = Random().nextInt(4294967296).toString();
final String resultString = inputFile.readAsStringSync()
.replaceFirst(
'var serviceWorkerVersion = null',
"var serviceWorkerVersion = '$randomHash'",
)
// This is for legacy index.html that still use the old service
// worker loading mechanism.
.replaceFirst(
"navigator.serviceWorker.register('flutter_service_worker.js')",
"navigator.serviceWorker.register('flutter_service_worker.js?v=$randomHash')",
......@@ -492,7 +498,7 @@ self.addEventListener("install", (event) => {
return event.waitUntil(
caches.open(TEMP).then((cache) => {
return cache.addAll(
CORE.map((value) => new Request(value + '?revision=' + RESOURCES[value], {'cache': 'reload'})));
CORE.map((value) => new Request(value, {'cache': 'reload'})));
})
);
});
......
......@@ -23,9 +23,6 @@
<meta name="apple-mobile-web-app-title" content="{{projectName}}">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>{{projectName}}</title>
<link rel="manifest" href="manifest.json">
</head>
......@@ -34,12 +31,68 @@
application. For more information, see:
https://developers.google.com/web/fundamentals/primers/service-workers -->
<script>
var serviceWorkerVersion = null;
var scriptLoaded = false;
function loadMainDartJs() {
if (scriptLoaded) {
return;
}
scriptLoaded = true;
var scriptTag = document.createElement('script');
scriptTag.src = 'main.dart.js';
scriptTag.type = 'application/javascript';
document.body.append(scriptTag);
}
if ('serviceWorker' in navigator) {
window.addEventListener('flutter-first-frame', function () {
navigator.serviceWorker.register('flutter_service_worker.js');
// Service workers are supported. Use them.
window.addEventListener('load', function () {
// Wait for registration to finish before dropping the <script> tag.
// Otherwise, the browser will load the script multiple times,
// potentially different versions.
var serviceWorkerUrl = 'flutter_service_worker.js?v=' + serviceWorkerVersion;
navigator.serviceWorker.register(serviceWorkerUrl)
.then((reg) => {
function waitForActivation(serviceWorker) {
serviceWorker.addEventListener('statechange', () => {
if (serviceWorker.state == 'activated') {
console.log('Installed new service worker.');
loadMainDartJs();
}
});
}
if (!reg.active && (reg.installing || reg.waiting)) {
// No active web worker and we have installed or are installing
// one for the first time. Simply wait for it to activate.
waitForActivation(reg.installing ?? reg.waiting);
} else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
// When the app updates the serviceWorkerVersion changes, so we
// need to ask the service worker to update.
console.log('New service worker available.');
reg.update();
waitForActivation(reg.installing);
} else {
// Existing service worker is still good.
console.log('Loading app from service worker.');
loadMainDartJs();
}
});
// If service worker doesn't succeed in a reasonable amount of time,
// fallback to plaint <script> tag.
setTimeout(() => {
if (!scriptLoaded) {
console.warn(
'Failed to load app from service worker. Falling back to plain <script> tag.',
);
loadMainDartJs();
}
}, 4000);
});
} else {
// Service workers not supported. Just drop the <script> tag.
loadMainDartJs();
}
</script>
<script src="main.dart.js" type="application/javascript"></script>
</body>
</html>
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