Fix caching

This CL makes fidlbolt use cache-busting for bundle.js and style.cc, and
no-cache (i.e. require revalidation of caches) for index.html. This
should make deployments take effect immediately without requiring anyone
to force refresh.

Tested manually and confirmed the server issues 304 based on
If-Modified-Since, and then 200 after modifying index.html. Also
confirmed that changing js/css changes the hashes and they don't get
served from cache in that case.

Change-Id: Ib4f678e518f337b7d50a64ebc3e2dd234261c8df
Reviewed-on: https://fuchsia-review.googlesource.com/c/fidlbolt/+/761602
Reviewed-by: Yifei Teng <yifeit@google.com>
diff --git a/backend/main.go b/backend/main.go
index 76d79d3..6098f55 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -10,7 +10,9 @@
 	"fmt"
 	"log"
 	"net/http"
+	"net/url"
 	"os"
+	"path"
 	"path/filepath"
 	"time"
 )
@@ -67,13 +69,13 @@
 	}
 
 	mux := http.NewServeMux()
-	mux.Handle("/", http.FileServer(http.Dir(static)))
+	mux.Handle("/", withCacheControl(http.FileServer(http.Dir(static))))
 	mux.Handle("/convert", &postHandler{server, *verboseFlag})
 
 	timeout := 10 * time.Second
 	handler := http.TimeoutHandler(
 		mux, timeout, fmt.Sprintf("Request exceeded the %v time limit", timeout))
-	handler = logging(handler)
+	handler = withLogging(handler)
 
 	port := *portFlag
 	if port == "" {
@@ -143,6 +145,47 @@
 	}
 }
 
+type cachingStrategy int
+
+const (
+	// Disallow browser caching so changes are picked up immediately.
+	noCaching cachingStrategy = iota
+	// Use an extremely long TTL because cache busting ensures the URL changes
+	// when content changes. We achieve this by passing "hash: true" to
+	// HtmlWebpackPlugin in frontend/webpack.config.js.
+	cacheBusting
+)
+
+func getCachingStrategy(url *url.URL) cachingStrategy {
+	switch path.Ext(url.Path) {
+	case ".js", ".css":
+		// Cache busting should put a hash in the query string.
+		if url.RawQuery != "" {
+			return cacheBusting
+		}
+	}
+	// Default to not caching. In particular, this applies to the "/" request
+	// for index.html. We always want to serve that fresh so that new
+	// deployments take effect right away.
+	return noCaching
+}
+
+func withCacheControl(handler http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch getCachingStrategy(r.URL) {
+		case noCaching:
+			w.Header().Add("Cache-Control", "no-cache")
+		case cacheBusting:
+			// It's traditional to use a TTL of 1 year for cache busting:
+			// https://web.dev/uses-long-cache-ttl/
+			ttl := 365 * 24 * time.Hour
+			w.Header().Add("Cache-Control",
+				fmt.Sprintf("max-age=%d, public", int(ttl.Seconds())))
+		}
+		handler.ServeHTTP(w, r)
+	})
+}
+
 type statusWriter struct {
 	http.ResponseWriter
 	status int
@@ -153,7 +196,7 @@
 	sw.ResponseWriter.WriteHeader(status)
 }
 
-func logging(handler http.Handler) http.Handler {
+func withLogging(handler http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		log.Printf("[%s] %s %s", r.RemoteAddr, r.Method, r.URL)
 		sw := statusWriter{ResponseWriter: w}
diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js
index 831541e..bed63dc 100644
--- a/frontend/webpack.config.js
+++ b/frontend/webpack.config.js
@@ -31,6 +31,7 @@
     new HtmlWebpackPlugin({
       template: 'src/index.html',
       favicon: 'src/favicon.ico',
+      hash: true,
     }),
     new MiniCssExtractPlugin({
       filename: 'style.css',