summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEd Tanous <ed@tanous.net>2024-04-06 18:39:18 +0300
committerEd Tanous <ed@tanous.net>2024-04-27 19:48:39 +0300
commit499b5b4dcbf3a936e65694de24219e65e3268e4d (patch)
tree6325d78bf2636be832c15a0790b58d921f7d9c63
parent3bfa3b29c0515a9e77c7c69fe072b7ff2e0fc302 (diff)
downloadbmcweb-499b5b4dcbf3a936e65694de24219e65e3268e4d.tar.xz
Add static webpack etag support
Webpack (which is what vue uses to compress its HTML) is capable of generating hashes of files when it produces the dist files[1]. This gets generated in the form of <filename>.<hash>.<extension> This commit attempts to detect these patterns, and enable etag caching to speed up webui load times. It detects these patterns, grabs the hash for the file, and returns it in the Etag header[2]. The behavior is implemented such that: If the file has an etag, the etag header is returned. If the request has an If-None-Match header, and that header matches, only 304 is returned. Tested: Tests were run on qemu S7106 bmcweb with default error logging level, and HTTP/2 enabled, along with svg optimization patches. Run scripts/generate_auth_certificate.py to set up TLS certificates. (valid TLS certs are required for HTTP caching to work properly in some browsers). Load the webui. Note that DOM load takes 1.10 seconds, Load takes 1.10 seconds, and all requests return 200 OK. Refresh the GUI. Note that most resources now return 304, and DOM time is reduced to 279 milliseconds and load is reduced to 280 milliseconds. DOM load (which is what the BMC has control over) is decreased by a factor of 3-4X. Setting chrome to "Fast 5g" throttling in the network tab shows a more pronounced difference, 1.28S load time vs 3.96S. BMC also shows 477KB transferred on the wire, versus 2.3KB transferred on the wire. This has the potential to significantly reduce the load on the BMC when the webui refreshes. [1] https://webpack.js.org/guides/caching/ [2] https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag Change-Id: I68aa7ef75533506d98e8fce10bb04a494dc49669 Signed-off-by: Ed Tanous <ed@tanous.net>
-rw-r--r--http/http2_connection.hpp28
-rw-r--r--http/http_response.hpp6
-rw-r--r--include/security_headers.hpp5
-rw-r--r--include/webassets.hpp66
4 files changed, 90 insertions, 15 deletions
diff --git a/http/http2_connection.hpp b/http/http2_connection.hpp
index 4b2d186f07..2c60c1268e 100644
--- a/http/http2_connection.hpp
+++ b/http/http2_connection.hpp
@@ -167,17 +167,18 @@ class HTTP2Connection :
close();
return -1;
}
- Response& thisRes = it->second.res;
- thisRes = std::move(completedRes);
- crow::Request& thisReq = it->second.req;
- std::vector<nghttp2_nv> hdr;
+ Http2StreamData& stream = it->second;
+ Response& res = stream.res;
+ res = std::move(completedRes);
+ crow::Request& thisReq = stream.req;
- completeResponseFields(thisReq, thisRes);
- thisRes.addHeader(boost::beast::http::field::date, getCachedDateStr());
- thisRes.preparePayload();
+ completeResponseFields(thisReq, res);
+ res.addHeader(boost::beast::http::field::date, getCachedDateStr());
+ res.preparePayload();
- boost::beast::http::fields& fields = thisRes.fields();
- std::string code = std::to_string(thisRes.resultInt());
+ boost::beast::http::fields& fields = res.fields();
+ std::string code = std::to_string(res.resultInt());
+ std::vector<nghttp2_nv> hdr;
hdr.emplace_back(
headerFromStringViews(":status", code, NGHTTP2_NV_FLAG_NONE));
for (const boost::beast::http::fields::value_type& header : fields)
@@ -185,8 +186,6 @@ class HTTP2Connection :
hdr.emplace_back(headerFromStringViews(
header.name_string(), header.value(), NGHTTP2_NV_FLAG_NONE));
}
- Http2StreamData& stream = it->second;
- crow::Response& res = stream.res;
http::response<bmcweb::HttpBody>& fbody = res.response;
stream.writer.emplace(fbody.base(), fbody.body());
@@ -279,6 +278,13 @@ class HTTP2Connection :
else
#endif // BMCWEB_INSECURE_DISABLE_AUTHX
{
+ std::string_view expected = thisReq.getHeaderValue(
+ boost::beast::http::field::if_none_match);
+ BMCWEB_LOG_DEBUG("Setting expected hash {}", expected);
+ if (!expected.empty())
+ {
+ asyncResp->res.setExpectedHash(expected);
+ }
handler->handle(thisReq, asyncResp);
}
return 0;
diff --git a/http/http_response.hpp b/http/http_response.hpp
index c93d60d685..18266ec93d 100644
--- a/http/http_response.hpp
+++ b/http/http_response.hpp
@@ -80,6 +80,7 @@ struct Response
}
response = std::move(r.response);
jsonValue = std::move(r.jsonValue);
+ expectedHash = std::move(r.expectedHash);
// Only need to move completion handler if not already completed
// Note, there are cases where we might move out of a Response object
@@ -144,6 +145,11 @@ struct Response
return fields()[key];
}
+ std::string_view getHeaderValue(boost::beast::http::field key) const
+ {
+ return fields()[key];
+ }
+
void keepAlive(bool k)
{
response.keep_alive(k);
diff --git a/include/security_headers.hpp b/include/security_headers.hpp
index c0855f439d..2a2eb40d7d 100644
--- a/include/security_headers.hpp
+++ b/include/security_headers.hpp
@@ -16,8 +16,11 @@ inline void addSecurityHeaders(const crow::Request& req [[maybe_unused]],
"includeSubdomains");
res.addHeader(bf::pragma, "no-cache");
- res.addHeader(bf::cache_control, "no-store, max-age=0");
+ if (res.getHeaderValue(bf::cache_control).empty())
+ {
+ res.addHeader(bf::cache_control, "no-store, max-age=0");
+ }
res.addHeader("X-Content-Type-Options", "nosniff");
std::string_view contentType = res.getHeaderValue("Content-Type");
diff --git a/include/webassets.hpp b/include/webassets.hpp
index c5c7228ede..4bcc8cb69b 100644
--- a/include/webassets.hpp
+++ b/include/webassets.hpp
@@ -25,6 +25,37 @@ struct CmpStr
}
};
+inline std::string getStaticEtag(const std::filesystem::path& webpath)
+{
+ // webpack outputs production chunks in the form:
+ // <filename>.<hash>.<extension>
+ // For example app.63e2c453.css
+ // Try to detect this, so we can use the hash as the ETAG
+ std::vector<std::string> split;
+ bmcweb::split(split, webpath.filename().string(), '.');
+ BMCWEB_LOG_DEBUG("Checking {} split.size() {}", webpath.filename().string(),
+ split.size());
+ if (split.size() < 3)
+ {
+ return "";
+ }
+
+ // get the second to last element
+ std::string hash = split.rbegin()[1];
+
+ // Webpack hashes are 8 characters long
+ if (hash.size() != 8)
+ {
+ return "";
+ }
+ // Webpack hashes only include hex printable characters
+ if (hash.find_first_not_of("0123456789abcdefABCDEF") != std::string::npos)
+ {
+ return "";
+ }
+ return std::format("\"{}\"", hash);
+}
+
inline void requestRoutes(App& app)
{
constexpr static std::array<std::pair<const char*, const char*>, 17>
@@ -99,6 +130,9 @@ inline void requestRoutes(App& app)
contentEncoding = "gzip";
}
+ std::string etag = getStaticEtag(webpath);
+
+ bool renamed = false;
if (webpath.filename().string().starts_with("index."))
{
webpath = webpath.parent_path();
@@ -107,6 +141,7 @@ inline void requestRoutes(App& app)
// insert the non-directory version of this path
webroutes::routes.insert(webpath);
webpath += "/";
+ renamed = true;
}
}
@@ -147,9 +182,9 @@ inline void requestRoutes(App& app)
}
app.routeDynamic(webpath)(
- [absolutePath, contentType, contentEncoding](
- const crow::Request&,
- const std::shared_ptr<bmcweb::AsyncResp>& asyncResp) {
+ [absolutePath, contentType, contentEncoding, etag,
+ renamed](const crow::Request& req,
+ const std::shared_ptr<bmcweb::AsyncResp>& asyncResp) {
if (contentType != nullptr)
{
asyncResp->res.addHeader(
@@ -163,6 +198,31 @@ inline void requestRoutes(App& app)
contentEncoding);
}
+ if (!etag.empty())
+ {
+ asyncResp->res.addHeader(boost::beast::http::field::etag,
+ etag);
+ // Don't cache paths that don't have the etag in them, like
+ // index, which gets transformed to /
+ if (!renamed)
+ {
+ // Anything with a hash can be cached forever and is
+ // immutable
+ asyncResp->res.addHeader(
+ boost::beast::http::field::cache_control,
+ "max-age=31556926, immutable");
+ }
+
+ std::string_view cachedEtag = req.getHeaderValue(
+ boost::beast::http::field::if_none_match);
+ if (cachedEtag == etag)
+ {
+ asyncResp->res.result(
+ boost::beast::http::status::not_modified);
+ return;
+ }
+ }
+
// res.set_header("Cache-Control", "public, max-age=86400");
if (!asyncResp->res.openFile(absolutePath))
{