From 879f642e959ba28cdf588696893d8d404cf57fa2 Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Tue, 2 Apr 2024 17:46:35 +0100 Subject: [PATCH 01/13] [feat] implement notify proxy to reload browser . . --- cmd/templ/generatecmd/cmd.go | 3 +++ cmd/templ/generatecmd/main.go | 1 + cmd/templ/generatecmd/proxy/proxy.go | 23 +++++++++++++++++++++-- cmd/templ/main.go | 4 ++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index 89ec7662b..b64f95b39 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -49,6 +49,9 @@ type GenerationEvent struct { } func (cmd Generate) Run(ctx context.Context) (err error) { + if cmd.Args.NotifyProxy { + return proxy.NotifyProxy(cmd.Args.ProxyBind, cmd.Args.ProxyPort) + } if cmd.Args.Watch && cmd.Args.FileName != "" { return fmt.Errorf("cannot watch a single file, remove the -f or -watch flag") } diff --git a/cmd/templ/generatecmd/main.go b/cmd/templ/generatecmd/main.go index 0f0b781f6..e4e302d54 100644 --- a/cmd/templ/generatecmd/main.go +++ b/cmd/templ/generatecmd/main.go @@ -21,6 +21,7 @@ type Arguments struct { ProxyBind string ProxyPort int Proxy string + NotifyProxy bool WorkerCount int GenerateSourceMapVisualisations bool IncludeVersion bool diff --git a/cmd/templ/generatecmd/proxy/proxy.go b/cmd/templ/generatecmd/proxy/proxy.go index 27667d722..4073eb176 100644 --- a/cmd/templ/generatecmd/proxy/proxy.go +++ b/cmd/templ/generatecmd/proxy/proxy.go @@ -118,8 +118,17 @@ func (p *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if r.URL.Path == "/_templ/reload/events" { - // Provides a list of messages including a reload message. - p.sse.ServeHTTP(w, r) + switch r.Method { + case http.MethodGet: + // Provides a list of messages including a reload message. + p.sse.ServeHTTP(w, r) + return + case http.MethodPost: + // Send a reload message to all connected clients. + p.sse.Send("message", "reload") + return + } + http.Error(w, "only GET or POST method allowed", http.StatusMethodNotAllowed) return } p.p.ServeHTTP(w, r) @@ -180,3 +189,13 @@ func (rt *roundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return nil, fmt.Errorf("max retries reached") } + +func NotifyProxy(host string, port int) error { + urlStr := fmt.Sprintf("http://%s:%d/_templ/reload/events", host, port) + req, err := http.NewRequest(http.MethodPost, urlStr, nil) + if err != nil { + return err + } + _, err = http.DefaultClient.Do(req) + return err +} diff --git a/cmd/templ/main.go b/cmd/templ/main.go index ff0b0a5fe..0a9cff5d5 100644 --- a/cmd/templ/main.go +++ b/cmd/templ/main.go @@ -91,6 +91,8 @@ Args: The port the proxy will listen on. (default 7331) -proxybind The address the proxy will listen on. (default 127.0.0.1) + -notify-proxy + If present, the command will issue a reload event to the proxy 127.0.0.1:7331, or use proxyport and proxybind to specify a different address. -w Number of workers to use when generating code. (default runtime.NumCPUs) -pprof @@ -134,6 +136,7 @@ func generateCmd(w io.Writer, args []string) (code int) { proxyFlag := cmd.String("proxy", "", "") proxyPortFlag := cmd.Int("proxyport", 7331, "") proxyBindFlag := cmd.String("proxybind", "127.0.0.1", "") + notifyProxyFlag := cmd.Bool("notify-proxy", false, "") workerCountFlag := cmd.Int("w", runtime.NumCPU(), "") pprofPortFlag := cmd.Int("pprof", 0, "") keepOrphanedFilesFlag := cmd.Bool("keep-orphaned-files", false, "") @@ -169,6 +172,7 @@ func generateCmd(w io.Writer, args []string) (code int) { Proxy: *proxyFlag, ProxyPort: *proxyPortFlag, ProxyBind: *proxyBindFlag, + NotifyProxy: *notifyProxyFlag, WorkerCount: *workerCountFlag, GenerateSourceMapVisualisations: *sourceMapVisualisationsFlag, IncludeVersion: *includeVersionFlag, From 5d198f345d690954d37cd9cbf7943fbd9b0aa536 Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Tue, 2 Apr 2024 22:46:41 +0100 Subject: [PATCH 02/13] add proxy notify test --- cmd/templ/generatecmd/proxy/proxy_test.go | 95 +++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go index c952fdd34..bb8515b2a 100644 --- a/cmd/templ/generatecmd/proxy/proxy_test.go +++ b/cmd/templ/generatecmd/proxy/proxy_test.go @@ -1,13 +1,19 @@ package proxy import ( + "bufio" "bytes" "compress/gzip" + "context" "fmt" "io" "net/http" + "net/http/httptest" + "net/url" + "strconv" "strings" "testing" + "time" "github.com/google/go-cmp/cmp" ) @@ -210,4 +216,93 @@ func TestProxy(t *testing.T) { t.Errorf("unexpected response body (-got +want):\n%s", diff) } }) + + t.Run("notify-proxy: sending POST request to /_templ/reload/events should receive reload sse event", func(t *testing.T) { + dummyHandler := func(w http.ResponseWriter, r *http.Request) {} + dummyServer := httptest.NewServer(http.HandlerFunc(dummyHandler)) + defer dummyServer.Close() + + u, err := url.Parse(dummyServer.URL) + if err != nil { + t.Fatalf("unexpected error parsing URL: %v", err) + } + handler := New("0.0.0.0", 0, u) + proxyServer := httptest.NewServer(handler) + defer proxyServer.Close() + + u2, err := url.Parse(proxyServer.URL) + if err != nil { + t.Fatalf("unexpected error parsing URL: %v", err) + } + port, err := strconv.Atoi(u2.Port()) + if err != nil { + t.Fatalf("unexpected error parsing port: %v", err) + } + + // we start an sse request to receive the events. The connection will be closed by the server or timeout. + ctx := context.Background() + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + errChan := make(chan error) + sseRespCh := make(chan string) + sseListening := make(chan bool) // coordination channel to ensure the sse listener is started before notifying the proxy + go func() { + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/_templ/reload/events", proxyServer.URL), nil) + if err != nil { + errChan <- err + return + } + resp, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + errChan <- err + return + } + defer resp.Body.Close() + + sseListening <- true + lines := []string{} + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + if scanner.Text() == "data: reload" { + sseRespCh <- strings.Join(lines, "\n") + return + } + } + err = scanner.Err() + // we expect the connection to be closed by the server: this is the only way to terminate the sse connection + if err != nil { + errChan <- err + return + } + }() + + // either sse is listening or an error occurred + select { + case <-sseListening: + err = NotifyProxy(u2.Hostname(), port) + if err != nil { + t.Fatalf("unexpected error notifying proxy: %v", err) + } + case err := <-errChan: + if err == nil { + t.Fatalf("unexpected sse response: %v", err) + } + } + + // either sse has a expected response or an error or timeout occurred + select { + case resp := <-sseRespCh: + if !strings.Contains(resp, "event: message\ndata: reload") { + t.Errorf("expected sse reload event to be received, got: %q", resp) + } + case err := <-errChan: + if err == nil { + t.Fatalf("unexpected sse response: %v", err) + } + case <-ctx.Done(): + t.Fatalf("timeout waiting for sse response") + } + }) } From 1d4bf6b7f1ecc621727d140f03e5943778454208 Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Tue, 2 Apr 2024 22:56:27 +0100 Subject: [PATCH 03/13] add test comments --- cmd/templ/generatecmd/proxy/proxy_test.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go index bb8515b2a..00fa7fbe7 100644 --- a/cmd/templ/generatecmd/proxy/proxy_test.go +++ b/cmd/templ/generatecmd/proxy/proxy_test.go @@ -218,6 +218,7 @@ func TestProxy(t *testing.T) { }) t.Run("notify-proxy: sending POST request to /_templ/reload/events should receive reload sse event", func(t *testing.T) { + // Arrange 1: create a test proxy server dummyHandler := func(w http.ResponseWriter, r *http.Request) {} dummyServer := httptest.NewServer(http.HandlerFunc(dummyHandler)) defer dummyServer.Close() @@ -239,7 +240,7 @@ func TestProxy(t *testing.T) { t.Fatalf("unexpected error parsing port: %v", err) } - // we start an sse request to receive the events. The connection will be closed by the server or timeout. + // Arrange 2: start a goroutine to listen for sse events ctx := context.Background() ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() @@ -278,8 +279,8 @@ func TestProxy(t *testing.T) { } }() - // either sse is listening or an error occurred - select { + // Act: notify the proxy + select { // either sse is listening or an error occurred case <-sseListening: err = NotifyProxy(u2.Hostname(), port) if err != nil { @@ -291,8 +292,8 @@ func TestProxy(t *testing.T) { } } - // either sse has a expected response or an error or timeout occurred - select { + // Assert + select { // either sse has a expected response or an error or timeout occurred case resp := <-sseRespCh: if !strings.Contains(resp, "event: message\ndata: reload") { t.Errorf("expected sse reload event to be received, got: %q", resp) From 76bf658486b4e49bb5a931d5989122b826e449de Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Tue, 2 Apr 2024 23:00:27 +0100 Subject: [PATCH 04/13] remove redundent contxt wrap --- cmd/templ/generatecmd/proxy/proxy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go index 00fa7fbe7..3efac5a17 100644 --- a/cmd/templ/generatecmd/proxy/proxy_test.go +++ b/cmd/templ/generatecmd/proxy/proxy_test.go @@ -254,7 +254,7 @@ func TestProxy(t *testing.T) { errChan <- err return } - resp, err := http.DefaultClient.Do(req.WithContext(ctx)) + resp, err := http.DefaultClient.Do(req) if err != nil { errChan <- err return From bd375dd9f4efbbd473397806207229d7e4d91412 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 4 Apr 2024 20:37:47 +0100 Subject: [PATCH 05/13] Update cmd/templ/generatecmd/proxy/proxy_test.go --- cmd/templ/generatecmd/proxy/proxy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go index 3efac5a17..3545447b5 100644 --- a/cmd/templ/generatecmd/proxy/proxy_test.go +++ b/cmd/templ/generatecmd/proxy/proxy_test.go @@ -218,7 +218,7 @@ func TestProxy(t *testing.T) { }) t.Run("notify-proxy: sending POST request to /_templ/reload/events should receive reload sse event", func(t *testing.T) { - // Arrange 1: create a test proxy server + // Arrange 1: create a test proxy server. dummyHandler := func(w http.ResponseWriter, r *http.Request) {} dummyServer := httptest.NewServer(http.HandlerFunc(dummyHandler)) defer dummyServer.Close() From 39ba0530685f6a472aa111bd6e98aa532f1258bd Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 4 Apr 2024 20:37:54 +0100 Subject: [PATCH 06/13] Update cmd/templ/generatecmd/proxy/proxy_test.go --- cmd/templ/generatecmd/proxy/proxy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go index 3545447b5..0b7c46a30 100644 --- a/cmd/templ/generatecmd/proxy/proxy_test.go +++ b/cmd/templ/generatecmd/proxy/proxy_test.go @@ -240,7 +240,7 @@ func TestProxy(t *testing.T) { t.Fatalf("unexpected error parsing port: %v", err) } - // Arrange 2: start a goroutine to listen for sse events + // Arrange 2: start a goroutine to listen for sse events. ctx := context.Background() ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() From 32e4fb24315b2255e8d997a1538c3d9edd5aceb8 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 4 Apr 2024 20:38:02 +0100 Subject: [PATCH 07/13] Update cmd/templ/generatecmd/proxy/proxy_test.go --- cmd/templ/generatecmd/proxy/proxy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go index 0b7c46a30..685127951 100644 --- a/cmd/templ/generatecmd/proxy/proxy_test.go +++ b/cmd/templ/generatecmd/proxy/proxy_test.go @@ -292,7 +292,7 @@ func TestProxy(t *testing.T) { } } - // Assert + // Assert. select { // either sse has a expected response or an error or timeout occurred case resp := <-sseRespCh: if !strings.Contains(resp, "event: message\ndata: reload") { From b1f3d94244ebe01ed495a0497a50ef3d54367f5d Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 4 Apr 2024 20:38:07 +0100 Subject: [PATCH 08/13] Update cmd/templ/generatecmd/proxy/proxy_test.go --- cmd/templ/generatecmd/proxy/proxy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go index 685127951..c02a79f06 100644 --- a/cmd/templ/generatecmd/proxy/proxy_test.go +++ b/cmd/templ/generatecmd/proxy/proxy_test.go @@ -272,7 +272,7 @@ func TestProxy(t *testing.T) { } } err = scanner.Err() - // we expect the connection to be closed by the server: this is the only way to terminate the sse connection + // We expect the connection to be closed by the server: this is the only way to terminate the sse connection. if err != nil { errChan <- err return From 1c228112a2a7ec279d89c52b92b96cb273f3f53a Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 4 Apr 2024 20:38:11 +0100 Subject: [PATCH 09/13] Update cmd/templ/generatecmd/proxy/proxy_test.go --- cmd/templ/generatecmd/proxy/proxy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go index c02a79f06..6872f3d34 100644 --- a/cmd/templ/generatecmd/proxy/proxy_test.go +++ b/cmd/templ/generatecmd/proxy/proxy_test.go @@ -279,7 +279,7 @@ func TestProxy(t *testing.T) { } }() - // Act: notify the proxy + // Act: notify the proxy. select { // either sse is listening or an error occurred case <-sseListening: err = NotifyProxy(u2.Hostname(), port) From 2f29fc29741a0c8e8efa1486944c84739d40ef8f Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 4 Apr 2024 20:38:19 +0100 Subject: [PATCH 10/13] Update cmd/templ/generatecmd/proxy/proxy_test.go --- cmd/templ/generatecmd/proxy/proxy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go index 6872f3d34..d9390959c 100644 --- a/cmd/templ/generatecmd/proxy/proxy_test.go +++ b/cmd/templ/generatecmd/proxy/proxy_test.go @@ -293,7 +293,7 @@ func TestProxy(t *testing.T) { } // Assert. - select { // either sse has a expected response or an error or timeout occurred + select { // Either SSE has a expected response or an error or timeout occurred. case resp := <-sseRespCh: if !strings.Contains(resp, "event: message\ndata: reload") { t.Errorf("expected sse reload event to be received, got: %q", resp) From def777b0799fec6a367fe74b90d8b628affda578 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 4 Apr 2024 20:38:25 +0100 Subject: [PATCH 11/13] Update cmd/templ/generatecmd/proxy/proxy_test.go --- cmd/templ/generatecmd/proxy/proxy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go index d9390959c..14f28c8fd 100644 --- a/cmd/templ/generatecmd/proxy/proxy_test.go +++ b/cmd/templ/generatecmd/proxy/proxy_test.go @@ -280,7 +280,7 @@ func TestProxy(t *testing.T) { }() // Act: notify the proxy. - select { // either sse is listening or an error occurred + select { // Either SSE is listening or an error occurred. case <-sseListening: err = NotifyProxy(u2.Hostname(), port) if err != nil { From d7e313bf89a1b855cc6f35a92f2196201198c1b0 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 4 Apr 2024 20:38:32 +0100 Subject: [PATCH 12/13] Update cmd/templ/generatecmd/proxy/proxy_test.go --- cmd/templ/generatecmd/proxy/proxy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go index 14f28c8fd..4afb99b76 100644 --- a/cmd/templ/generatecmd/proxy/proxy_test.go +++ b/cmd/templ/generatecmd/proxy/proxy_test.go @@ -247,7 +247,7 @@ func TestProxy(t *testing.T) { errChan := make(chan error) sseRespCh := make(chan string) - sseListening := make(chan bool) // coordination channel to ensure the sse listener is started before notifying the proxy + sseListening := make(chan bool) // Coordination channel that ensures the SSE listener is started before notifying the proxy. go func() { req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/_templ/reload/events", proxyServer.URL), nil) if err != nil { From a348f33d16ca43c0d81953c4c9baa3b4d43a75e6 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 4 Apr 2024 20:46:36 +0100 Subject: [PATCH 13/13] docs: document --notify-proxy --- docs/docs/09-commands-and-tools/03-hot-reload.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/docs/09-commands-and-tools/03-hot-reload.md b/docs/docs/09-commands-and-tools/03-hot-reload.md index ff0171e54..9f20bc6a2 100644 --- a/docs/docs/09-commands-and-tools/03-hot-reload.md +++ b/docs/docs/09-commands-and-tools/03-hot-reload.md @@ -82,6 +82,20 @@ sequenceDiagram deactivate templ_proxy ``` +### Triggering hot reload from outside `templ generate --watch` + +If you want to trigger a hot reload from outside `templ generate --watch` (e.g. if you're using `air`, `wgo` or another tool to build, but you want to use the templ hot reload proxy), you can use the `--notify-proxy` argument. + +```shell +templ generate --notify-proxy +``` + +This will default to the default templ proxy address of `localhost:7331`, but can be changed with the `--proxybind` and `--proxyport` arguments. + +```shell +templ generate --notify-proxy --proxybind="localhost" --proxyport="8080" +``` + ## Alternative 1: wgo [wgo](https://github.com/bokwoon95/wgo): @@ -96,7 +110,7 @@ To avoid a continous reloading files ending with `_templ.go` should be skipped v ## Alternative 2: air -Air's reload performance is better than templ's built-in feature due to its complex filesystem notification setup, but doesn't ship with a proxy to automatically reload pages, and requires a `toml` configuration file for operation. +Air can handle `*.go` files, but doesn't ship with a proxy to automatically reload pages, and requires a `toml` configuration file for operation. See https://github.com/cosmtrek/air for details.