Version: Spring Boot 2.3.4.RELEASE
When using a content resource chain to cache-bust .css
and .js
files, with server compression enabled, and Thymeleaf as the template engine (not sure if it's relevant or not), the css file is compressed but the js file is not.
If the JS file is requested directly (without going through the resource chain), then it's compressed.
Here's a complete minimal repro: https://github.com/jnizet/compressiondemo
Here's a screenshot of the Chrome network panel when loading the page:
As you can see, the bundle.js
files (which did not go through the resource chain) has a transfer size that is less than the file size. But the bundle.c525abb....js
file, which is the same file, but obtained through the resource chain, is not compressed.
What's weird is that the css file did go through the resource chain, but was compressed as expected.
Comment From: jnizet
I did some more digging to understand why that happens and how to circumvent it.
Circumventing the issue
It can be relatively easily circumvented by gzipping (and/or brotliing) the file at build time and adding the .gzip
and/or .br
file(s) to the resources in addition to the original .js
file, and then setting the property spring.resources.chain.compressed
to true
.
I'll do that for the project where this was noticed. It will enable a slightly better brotli compression for browsers that support brotli, and avoid compressing the files at runtime.
Why the issue exists, and why it doesn't for CSS files
Inside org.apache.coyote.CompressionConfig#useCompression
, the compression is bypassed for the JS file due to these lines of code:
String eTag = responseHeaders.getHeader("ETag");
if (eTag != null && !eTag.trim().startsWith("W/")) {
// Has an ETag that doesn't start with "W/..." so it must be a
// strong ETag
return false;
}
So, Tomcat won't compress files if the response has a strong ETag response header. After some more searching, it does that to be spec-compliant: see https://tools.ietf.org/html/rfc7232#section-2.3.3:
a content-encoded representation has to be distinct from the entity tag of an unencoded representation to prevent potential conflicts during cache updates and range requests
Where does this ETag come from?
It comes from org.springframework.web.servlet.resource.VersionResourceResolver.FileNameVersionedResource#getResponseHeaders
, which unconditionally add an ETag header to the response.
I think this ETag should be avoided, at least when compression is enabled, because IMHO, compressing large JS files is more beneficial to users than having an ETag, especially given that the response also has a Cache-control: max-age
header.
Why doesn't this happen for CSS files?
Because CSS files go through an additional transforming step, implemented by CssLinkResourceTransformer
, consisting in rewriting the links located inside the CSS file. This has the effect of returning a TransformedResource
instead of a FileNameVersionedResource
, and since this resource is not an instance of HttpResource
, the method org.springframework.web.servlet.resource.ResourceHttpRequestHandler#setHeaders
does not copy the headers of the returned resource like it would have done for the original FileNameVersionedResource
.
So, effectively, the ETag that should probably has been added to the CSS response is lost after this transformation which, in this case, is a good thing, since that's what allows the file to be compressed.
In the end, we end up with
- CSS: ETag absent (maybe by accident) so the file is compressed by Tomcat
- JS (and other non-transformed resources): ETag present, so the file is not compressed by Tomcat
- precompressed file: ETag present and the file is (pre-)compressed: this is the best situation.
I think an easy simple fix would be to allow disabling the generation of an ETag header, and to advise disabling it for resources that should be compressed by Tomcat, since the ETag (correctly) disables compression by Tomcat in order to stay spec-compliant.
Comment From: bclozel
Thanks for the analysis @jnizet, I didn't have time to take a look yet but this reminds me of a change we recently applied for Spring Framework 5.3: spring-projects/spring-framework#24898
Comment From: jnizet
Thanks @bclozel
Great. That change will indeed fix the issue. Maybe this issue should be reworded in order to add the missing (weak) ETag for CSS resources then. Its absence doesn't bother me much, but I guess it would make sense to add an ETag for CSS resources too since there is one for other resources.
Comment From: ttwd80
I think we can close this. I cloned https://github.com/jnizet/compressiondemo
- I ran with Java 11 and the existing Spring Boot version - 2.3.4.RELEASE
- and confirmed that the bug exists
- I ran with Java 11 and changed the Spring Boot version to - 2.3.12.RELEASE
- and confirmed that the bug still exists
- I ran with Java 11 and changed the Spring Boot version to - 2.4.0
- and confirmed that the bug is no longer there
Bug exist is when <script src="/bundle.js"></script>
and <script th:src="@{/bundle.js}"></script>
produce different file size.
Bug no longer there is when they both produce the same file size (smaller) and the one with Thymeleaf has an ETag value of W/"c525abb80dc9c078dce19f0c5422994a"
Comment From: sbrannen
@ttwd80, thanks for confirming that the originally reported bug has been fixed.
However, please note that this issue was repurposed to focus on the "Missing ETag response header for CSS TransformedResources".
In light of that, I think this issue should not be closed; however, @bclozel can likely better assess that.