diff --git a/packages/flutter_tools/lib/src/build_runner/devfs_web.dart b/packages/flutter_tools/lib/src/build_runner/devfs_web.dart index 7fdc18a7a28..d56bd979996 100644 --- a/packages/flutter_tools/lib/src/build_runner/devfs_web.dart +++ b/packages/flutter_tools/lib/src/build_runner/devfs_web.dart @@ -137,6 +137,10 @@ class WebAssetServer implements AssetReader { return shelf.Response.notFound(''); } + // Track etag headers for better caching of resources. + final String ifNoneMatch = request.headers[HttpHeaders.ifNoneMatchHeader]; + headers[HttpHeaders.cacheControlHeader] = 'max-age=0, must-revalidate'; + // NOTE: shelf removes leading `/` for some reason. final String requestPath = request.url.path.startsWith('/') ? request.url.path @@ -146,16 +150,29 @@ class WebAssetServer implements AssetReader { // Attempt to look up the file by URI. if (_files.containsKey(requestPath)) { final List bytes = getFile(requestPath); + // Use the underlying buffer hashCode as a revision string. This buffer is + // replaced whenever the frontend_server produces new output files, which + // will also change the hashCode. + final String etag = bytes.hashCode.toString(); + if (ifNoneMatch == etag) { + return shelf.Response.notModified(); + } headers[HttpHeaders.contentLengthHeader] = bytes.length.toString(); headers[HttpHeaders.contentTypeHeader] = 'application/javascript'; + headers[HttpHeaders.etagHeader] = etag; return shelf.Response.ok(bytes, headers: headers); } // If this is a sourcemap file, then it might be in the in-memory cache. // Attempt to lookup the file by URI. if (_sourcemaps.containsKey(requestPath)) { final List bytes = getSourceMap(requestPath); + final String etag = bytes.hashCode.toString(); + if (ifNoneMatch == etag) { + return shelf.Response.notModified(); + } headers[HttpHeaders.contentLengthHeader] = bytes.length.toString(); headers[HttpHeaders.contentTypeHeader] = 'application/json'; + headers[HttpHeaders.etagHeader] = etag; return shelf.Response.ok(bytes, headers: headers); } @@ -172,6 +189,13 @@ class WebAssetServer implements AssetReader { if (!file.existsSync()) { return shelf.Response.notFound(''); } + + // For real files, use a serialized file stat as a revision + final String etag = file.lastModifiedSync().toIso8601String(); + if (ifNoneMatch == etag) { + return shelf.Response.notModified(); + } + final int length = file.lengthSync(); // Attempt to determine the file's mime type. if this is not provided some // browsers will refuse to render images/show video et cetera. If the tool @@ -186,6 +210,7 @@ class WebAssetServer implements AssetReader { mimeType ??= _kDefaultMimeType; headers[HttpHeaders.contentLengthHeader] = length.toString(); headers[HttpHeaders.contentTypeHeader] = mimeType; + headers[HttpHeaders.etagHeader] = etag; return shelf.Response.ok(file.openRead(), headers: headers); } diff --git a/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart b/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart index 2f72bc5fa8f..325ef26b746 100644 --- a/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart +++ b/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart @@ -87,8 +87,9 @@ void main() { .handleRequest(Request('GET', Uri.parse('http://foobar/foo.js'))); expect(response.headers, allOf([ - containsPair('content-length', source.lengthSync().toString()), - containsPair('content-type', 'application/javascript'), + containsPair(HttpHeaders.contentLengthHeader, source.lengthSync().toString()), + containsPair(HttpHeaders.contentTypeHeader, 'application/javascript'), + containsPair(HttpHeaders.etagHeader, isNotNull) ])); expect((await response.read().toList()).first, source.readAsBytesSync()); }, overrides: { @@ -102,12 +103,30 @@ void main() { .handleRequest(Request('GET', Uri.parse('http://foobar/foo.js'))); expect(response.headers, allOf([ - containsPair('content-length', '9'), - containsPair('content-type', 'application/javascript'), + containsPair(HttpHeaders.contentLengthHeader, '9'), + containsPair(HttpHeaders.contentTypeHeader, 'application/javascript'), + containsPair(HttpHeaders.etagHeader, isNotNull), + containsPair(HttpHeaders.cacheControlHeader, 'max-age=0, must-revalidate') ])); expect((await response.read().toList()).first, utf8.encode('main() {}')); })); + test('Returns notModified when the ifNoneMatch header matches the etag', () => testbed.run(() async { + webAssetServer.writeFile('/foo.js', 'main() {}'); + + final Response response = await webAssetServer + .handleRequest(Request('GET', Uri.parse('http://foobar/foo.js'))); + final String etag = response.headers[HttpHeaders.etagHeader]; + + final Response cachedResponse = await webAssetServer + .handleRequest(Request('GET', Uri.parse('http://foobar/foo.js'), headers: { + HttpHeaders.ifNoneMatchHeader: etag + })); + + expect(cachedResponse.statusCode, HttpStatus.notModified); + expect(await cachedResponse.read().toList(), isEmpty); + })); + test('handles missing JavaScript files from in memory cache', () => testbed.run(() async { final File source = globals.fs.file('source') ..writeAsStringSync('main() {}'); @@ -141,8 +160,10 @@ void main() { .handleRequest(Request('GET', Uri.parse('http://localhost/foo.js'))); expect(response.headers, allOf([ - containsPair('content-length', source.lengthSync().toString()), - containsPair('content-type', 'application/javascript'), + containsPair(HttpHeaders.contentLengthHeader, source.lengthSync().toString()), + containsPair(HttpHeaders.contentTypeHeader, 'application/javascript'), + containsPair(HttpHeaders.etagHeader, isNotNull), + containsPair(HttpHeaders.cacheControlHeader, 'max-age=0, must-revalidate') ])); expect((await response.read().toList()).first, source.readAsBytesSync()); }, overrides: { @@ -157,8 +178,10 @@ void main() { .handleRequest(Request('GET', Uri.parse('http://foobar/assets/abcd%25E8%25B1%25A1%25E5%25BD%25A2%25E5%25AD%2597.png'))); expect(response.headers, allOf([ - containsPair('content-length', source.lengthSync().toString()), - containsPair('content-type', 'image/png'), + containsPair(HttpHeaders.contentLengthHeader, source.lengthSync().toString()), + containsPair(HttpHeaders.contentTypeHeader, 'image/png'), + containsPair(HttpHeaders.etagHeader, isNotNull), + containsPair(HttpHeaders.cacheControlHeader, 'max-age=0, must-revalidate') ])); expect((await response.read().toList()).first, source.readAsBytesSync()); })); @@ -171,8 +194,10 @@ void main() { .handleRequest(Request('GET', Uri.parse('http://foobar/assets/foo.png'))); expect(response.headers, allOf([ - containsPair('content-length', source.lengthSync().toString()), - containsPair('content-type', 'image/png'), + containsPair(HttpHeaders.contentLengthHeader, source.lengthSync().toString()), + containsPair(HttpHeaders.contentTypeHeader, 'image/png'), + containsPair(HttpHeaders.etagHeader, isNotNull), + containsPair(HttpHeaders.cacheControlHeader, 'max-age=0, must-revalidate') ])); expect((await response.read().toList()).first, source.readAsBytesSync()); }, overrides: { @@ -187,7 +212,7 @@ void main() { final Response response = await webAssetServer .handleRequest(Request('GET', Uri.parse('http://foobar/foo.dart'))); - expect(response.headers, containsPair('content-length', source.lengthSync().toString())); + expect(response.headers, containsPair(HttpHeaders.contentLengthHeader, source.lengthSync().toString())); expect((await response.read().toList()).first, source.readAsBytesSync()); }, overrides: { Platform: () => linux, @@ -209,8 +234,8 @@ void main() { .handleRequest(Request('GET', Uri.parse('http://foobar/assets/foo.png'))); expect(response.headers, allOf([ - containsPair('content-length', source.lengthSync().toString()), - containsPair('content-type', 'image/png'), + containsPair(HttpHeaders.contentLengthHeader, source.lengthSync().toString()), + containsPair(HttpHeaders.contentTypeHeader, 'image/png'), ])); expect((await response.read().toList()).first, source.readAsBytesSync()); })); @@ -224,8 +249,8 @@ void main() { .handleRequest(Request('GET', Uri.parse('http://foobar/assets/foo'))); expect(response.headers, allOf([ - containsPair('content-length', '100'), - containsPair('content-type', 'application/octet-stream'), + containsPair(HttpHeaders.contentLengthHeader, '100'), + containsPair(HttpHeaders.contentTypeHeader, 'application/octet-stream'), ])); expect((await response.read().toList()).first, source.readAsBytesSync()); })); @@ -239,8 +264,8 @@ void main() { .handleRequest(Request('GET', Uri.parse('http://foobar/assets/foo'))); expect(response.headers, allOf([ - containsPair('content-length', '3'), - containsPair('content-type', 'application/octet-stream'), + containsPair(HttpHeaders.contentLengthHeader, '3'), + containsPair(HttpHeaders.contentTypeHeader, 'application/octet-stream'), ])); expect((await response.read().toList()).first, source.readAsBytesSync()); })); @@ -264,8 +289,8 @@ void main() { .handleRequest(Request('GET', Uri.parse('http:///packages/flutter_tools/foo.dart'))); expect(response.headers, allOf([ - containsPair('content-length', '3'), - containsPair('content-type', 'application/octet-stream'), + containsPair(HttpHeaders.contentLengthHeader, '3'), + containsPair(HttpHeaders.contentTypeHeader, 'application/octet-stream'), ])); expect((await response.read().toList()).first, source.readAsBytesSync()); }));