[flutter_tools] adds etag/cache control header to debug asset server (#51143)

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 7fdc18a..d56bd97 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 @@
       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 @@
     // Attempt to look up the file by URI.
     if (_files.containsKey(requestPath)) {
       final List<int> 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<int> 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 @@
     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 @@
     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 2f72bc5..325ef26 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 @@
       .handleRequest(Request('GET', Uri.parse('http://foobar/foo.js')));
 
     expect(response.headers, allOf(<Matcher>[
-      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: <Type, Generator>{
@@ -102,12 +103,30 @@
       .handleRequest(Request('GET', Uri.parse('http://foobar/foo.js')));
 
     expect(response.headers, allOf(<Matcher>[
-      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: <String, String>{
+        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 @@
       .handleRequest(Request('GET', Uri.parse('http://localhost/foo.js')));
 
     expect(response.headers, allOf(<Matcher>[
-      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: <Type, Generator>{
@@ -157,8 +178,10 @@
       .handleRequest(Request('GET', Uri.parse('http://foobar/assets/abcd%25E8%25B1%25A1%25E5%25BD%25A2%25E5%25AD%2597.png')));
 
     expect(response.headers, allOf(<Matcher>[
-      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 @@
       .handleRequest(Request('GET', Uri.parse('http://foobar/assets/foo.png')));
 
     expect(response.headers, allOf(<Matcher>[
-      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: <Type,  Generator>{
@@ -187,7 +212,7 @@
     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: <Type,  Generator>{
     Platform: () => linux,
@@ -209,8 +234,8 @@
       .handleRequest(Request('GET', Uri.parse('http://foobar/assets/foo.png')));
 
     expect(response.headers, allOf(<Matcher>[
-      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 @@
       .handleRequest(Request('GET', Uri.parse('http://foobar/assets/foo')));
 
     expect(response.headers, allOf(<Matcher>[
-      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 @@
       .handleRequest(Request('GET', Uri.parse('http://foobar/assets/foo')));
 
     expect(response.headers, allOf(<Matcher>[
-      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 @@
       .handleRequest(Request('GET', Uri.parse('http:///packages/flutter_tools/foo.dart')));
 
     expect(response.headers, allOf(<Matcher>[
-      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());
   }));