Repository: a-h/templ (Go)

Issue:
Proxy: injection of `script.js` with Brotli compression is silently failing
I encountered an issue where `script.js` was inconsistently injected into pages across different browsers. Specifically, both Safari and cURL successfully had the JavaScript script injected, whereas Firefox did not.

Upon further investigation by exporting requests from Safari and Firefox as cURL commands, I pinpointed the cause to the differences in the `Accept-Encoding` header. The request from Firefox uses `Accept-Encoding: br`, unlike Safari or a basic cURL command.

Here are the results demonstrating this discrepancy:
```
$ curl -s 'http://localhost:7331/' -H 'Accept-Encoding: gzip' | gunzip - | grep -Eo '</main>.*'
</main><script src="/_templ/reload/script.js"></script></body></html>
```

```
$ curl -s 'http://localhost:7331/' -H 'Accept-Encoding: br' | brotli -d | grep -Eo '</main>.*'
</main></body></html>
```

I don't think I would be capable of submitting a PR, but I'll give a try if I got time. Maybe warning the user that only `gzip` compression is supported today could be easily doable, while a proper fix could come later?


Discussion: Great find, thanks for raising the issue. I agree that it shouldn't fail silently.

I don't think it would be hard to add the brotli support alongside gzip.

Are there any others we should add proactively?
Oh, cool if it's not too hard to add support for brotli!

[MDN lists others format](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding#directives), but I'm not sure if any browser is using something other than gzip or brotli. 
OK, PR https://github.com/a-h/templ/pull/695 added.

I've added brotli support, and logged a warning if an unsupported encoding was used, along with tests.

Could you please test it out to make sure it works for you before I merge it?



Fix (patch):
diff --git a/.version b/.version
index 22ee752c8..f279e70ea 100644
--- a/.version
+++ b/.version
@@ -1,1 +1,1 @@
-0.2.668
\ No newline at end of file
+0.2.668
diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go
index 76a543e2f..2d5122361 100644
--- a/cmd/templ/generatecmd/cmd.go
+++ b/cmd/templ/generatecmd/cmd.go
@@ -343,7 +343,7 @@ func (cmd *Generate) StartProxy(ctx context.Context) (p *proxy.Handler, err erro
 	if cmd.Args.ProxyBind == "" {
 		cmd.Args.ProxyBind = "127.0.0.1"
 	}
-	p = proxy.New(cmd.Args.ProxyBind, cmd.Args.ProxyPort, target)
+	p = proxy.New(cmd.Log, cmd.Args.ProxyBind, cmd.Args.ProxyPort, target)
 	go func() {
 		cmd.Log.Info("Proxying", slog.String("from", p.URL), slog.String("to", p.Target.String()))
 		if err := http.ListenAndServe(fmt.Sprintf("%s:%d", cmd.Args.ProxyBind, cmd.Args.ProxyPort), p); err != nil {
diff --git a/cmd/templ/generatecmd/proxy/proxy.go b/cmd/templ/generatecmd/proxy/proxy.go
index 4073eb176..6c098c904 100644
--- a/cmd/templ/generatecmd/proxy/proxy.go
+++ b/cmd/templ/generatecmd/proxy/proxy.go
@@ -5,7 +5,8 @@ import (
 	"compress/gzip"
 	"fmt"
 	"io"
-	"log"
+	stdlog "log"
+	"log/slog"
 	"math"
 	"net/http"
 	"net/http/httputil"
@@ -16,6 +17,7 @@ import (
 	"time"
 
 	"github.com/a-h/templ/cmd/templ/generatecmd/sse"
+	"github.com/andybalholm/brotli"
 
 	_ "embed"
 )
@@ -26,85 +28,113 @@ var script string
 const scriptTag = `<script src="/_templ/reload/script.js"></script>`
 
 type Handler struct {
+	log    *slog.Logger
 	URL    string
 	Target *url.URL
 	p      *httputil.ReverseProxy
 	sse    *sse.Handler
 }
 
-func updateGzipResponse(r *http.Response) error {
-	plainr, err := gzip.NewReader(r.Body)
+func insertScriptTagIntoBody(body string) (updated string) {
+	return strings.Replace(body, "</body>", scriptTag+"</body>", -1)
+}
+
+type passthroughWriteCloser struct {
+	io.Writer
+}
+
+func (pwc passthroughWriteCloser) Close() error {
+	return nil
+}
+
+const unsupportedContentEncoding = "Unsupported content encoding, hot reload script not inserted."
+
+func (h *Handler) modifyResponse(r *http.Response) error {
+	if r.Header.Get("templ-skip-modify") == "true" {
+		return nil
+	}
+	if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "text/html") {
+		return nil
+	}
+
+	// Set up readers and writers.
+	newReader := func(in io.Reader) (out io.Reader, err error) {
+		return in, nil
+	}
+	newWriter := func(out io.Writer) io.WriteCloser {
+		return passthroughWriteCloser{out}
+	}
+	switch r.Header.Get("Content-Encoding") {
+	case "gzip":
+		newReader = func(in io.Reader) (out io.Reader, err error) {
+			return gzip.NewReader(in)
+		}
+		newWriter = func(out io.Writer) io.WriteCloser {
+			return gzip.NewWriter(out)
+		}
+	case "br":
+		newReader = func(in io.Reader) (out io.Reader, err error) {
+			return brotli.NewReader(in), nil
+		}
+		newWriter = func(out io.Writer) io.WriteCloser {
+			return brotli.NewWriter(out)
+		}
+	case "":
+		// No content encoding.
+	default:
+		h.log.Warn(unsupportedContentEncoding, slog.String("encoding", r.Header.Get("Content-Encoding")))
+	}
+
+	// Read the encoded body.
+	encr, err := newReader(r.Body)
 	if err != nil {
 		return err
 	}
-	defer plainr.Close()
-	body, err := io.ReadAll(plainr)
+	defer r.Body.Close()
+	body, err := io.ReadAll(encr)
 	if err != nil {
 		return err
 	}
+
+	// Update it.
 	updated := insertScriptTagIntoBody(string(body))
+
+	// Encode the response.
 	var buf bytes.Buffer
-	gzw := gzip.NewWriter(&buf)
-	defer gzw.Close()
-	_, err = gzw.Write([]byte(updated))
+	encw := newWriter(&buf)
+	_, err = encw.Write([]byte(updated))
 	if err != nil {
 		return err
 	}
-	err = gzw.Close()
+	err = encw.Close()
 	if err != nil {
 		return err
 	}
+
+	// Update the response.
 	r.Body = io.NopCloser(&buf)
 	r.ContentLength = int64(buf.Len())
 	r.Header.Set("Content-Length", strconv.Itoa(buf.Len()))
 	return nil
 }
 
-func updatePlainResponse(r *http.Response) error {
-	body, err := io.ReadAll(r.Body)
-	if err != nil {
-		return err
-	}
-	updated := insertScriptTagIntoBody(string(body))
-	r.Body = io.NopCloser(strings.NewReader(updated))
-	r.ContentLength = int64(len(updated))
-	r.Header.Set("Content-Length", strconv.Itoa(len(updated)))
-	return nil
-}
-
-func insertScriptTagIntoBody(body string) (updated string) {
-	return strings.Replace(body, "</body>", scriptTag+"</body>", -1)
-}
-
-func modifyResponse(r *http.Response) error {
-	if r.Header.Get("templ-skip-modify") == "true" {
-		return nil
-	}
-	if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "text/html") {
-		return nil
-	}
-	modifier := updatePlainResponse
-	if r.Header.Get("Content-Encoding") == "gzip" {
-		modifier = updateGzipResponse
-	}
-	return modifier(r)
-}
-
-func New(bind string, port int, target *url.URL) *Handler {
+func New(log *slog.Logger, bind string, port int, target *url.URL) (h *Handler) {
 	p := httputil.NewSingleHostReverseProxy(target)
-	p.ErrorLog = log.New(os.Stderr, "Proxy to target error: ", 0)
+	p.ErrorLog = stdlog.New(os.Stderr, "Proxy to target error: ", 0)
 	p.Transport = &roundTripper{
 		maxRetries:      10,
 		initialDelay:    100 * time.Millisecond,
 		backoffExponent: 1.5,
 	}
-	p.ModifyResponse = modifyResponse
-	return &Handler{
+	h = &Handler{
+		log:    log,
 		URL:    fmt.Sprintf("http://%s:%d", bind, port),
 		Target: target,
 		p:      p,
 		sse:    sse.New(),
 	}
+	p.ModifyResponse = h.modifyResponse
+	return h
 }
 
 func (p *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
diff --git a/go.mod b/go.mod
index ce847d337..3253ce13a 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
 	github.com/a-h/parse v0.0.0-20240121214402-3caf7543159a
 	github.com/a-h/pathvars v0.0.12
 	github.com/a-h/protocol v0.0.0-20230224160810-b4eec67c1c22
+	github.com/andybalholm/brotli v1.1.0
 	github.com/cenkalti/backoff/v4 v4.2.1
 	github.com/cli/browser v1.2.0
 	github.com/fatih/color v1.16.0
diff --git a/go.sum b/go.sum
index e50315e5d..1684b1171 100644
--- a/go.sum
+++ b/go.sum
@@ -10,14 +10,18 @@ github.com/a-h/pathvars v0.0.12 h1:B4JaZGvHKNgNNlw8LMayPM/Hc0f3xZ2PXivu8YIl/X0=
 github.com/a-h/pathvars v0.0.12/go.mod h1:7rLTtvDVyKneR/N65hC0lh2sZ2KRyAmWFaOvv00uxb0=
 github.com/a-h/protocol v0.0.0-20230224160810-b4eec67c1c22 h1:ehNdbGOAR8KTrLY/S90/9RJ4p/cgeNdt1sRt0DSiRWs=
 github.com/a-h/protocol v0.0.0-20230224160810-b4eec67c1c22/go.mod h1:Gm0KywveHnkiIhqFSMZglXwWZRQICg3KDWLYdglv/d8=
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
 github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
 github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
 github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
 github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
 github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 github.com/cli/browser v1.2.0 h1:yvU7e9qf97kZqGFX6n2zJPHsmSObY9ske+iCvKelvXg=
 github.com/cli/browser v1.2.0/go.mod h1:xFFnXLVcAyW9ni0cuo6NnrbCP75JxJ0RO7VtCBiH/oI=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -35,6 +39,7 @@ github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjH
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rs/cors v1.8.3 h1:O+qNyWn7Z+F9M0ILBHgMVPuB1xTOucVd5gtaYyXBpRo=
 github.com/rs/cors v1.8.3/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
 github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
@@ -54,6 +59,7 @@ go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I=
 go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
 go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
 go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
+go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
 go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
 go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
 go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
@@ -101,3 +107,4 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/gomod2nix.toml b/gomod2nix.toml
index 7f94691f3..1c37d6bc2 100644
--- a/gomod2nix.toml
+++ b/gomod2nix.toml
@@ -19,6 +19,9 @@ schema = 3
   [mod."github.com/a-h/protocol"]
     version = "v0.0.0-20230224160810-b4eec67c1c22"
     hash = "sha256-aoxNdsVfyXtiKv3mkEqF63bBEHd6thX7tC/7a1c8rcQ="
+  [mod."github.com/andybalholm/brotli"]
+    version = "v1.1.0"
+    hash = "sha256-njLViV4v++ZdgOWGWzlvkefuFvA/nkugl3Ta/h1nu/0="
   [mod."github.com/andybalholm/cascadia"]
     version = "v1.3.1"
     hash = "sha256-M0u22DXSeXUaYtl1KoW1qWL46niFpycFkraCEQ/luYA="