Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] implement notify proxy to reload browser #661

Merged
merged 13 commits into from
Apr 4, 2024
3 changes: 3 additions & 0 deletions cmd/templ/generatecmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
1 change: 1 addition & 0 deletions cmd/templ/generatecmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Arguments struct {
ProxyBind string
ProxyPort int
Proxy string
NotifyProxy bool
WorkerCount int
GenerateSourceMapVisualisations bool
IncludeVersion bool
Expand Down
23 changes: 21 additions & 2 deletions cmd/templ/generatecmd/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
96 changes: 96 additions & 0 deletions cmd/templ/generatecmd/proxy/proxy_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -210,4 +216,94 @@ 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) {
// Arrange 1: create a test proxy server.
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)
}

// Arrange 2: start a goroutine to listen for sse events.
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 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 {
errChan <- err
return
}
resp, err := http.DefaultClient.Do(req)
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
}
}()

// Act: notify the proxy.
select { // Either SSE is listening or an error occurred.
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)
}
}

// 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)
}
case err := <-errChan:
if err == nil {
t.Fatalf("unexpected sse response: %v", err)
}
case <-ctx.Done():
t.Fatalf("timeout waiting for sse response")
}
})
}
4 changes: 4 additions & 0 deletions cmd/templ/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, "")
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion docs/docs/09-commands-and-tools/03-hot-reload.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```

Comment on lines +85 to +98
Copy link

@leandergangso leandergangso Apr 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, this is a nice addition to the templ CLI, will be using this once it's released.
Maybe even replace --watch?

I was thinking that a change to the explanation, to make it more clear, could help this feature be more utilized when it's released.

By specifying that --notify-proxy takes priority over other flags, and will be exec just once, can help developers better understand the usage for this cmd.
Also, the first time I read this text, it felt like I should add --notify-proxy as a flag to the templ generate --watch cmd, which would end in an unexpected outcome. (perhaps just my thinking tho :P )

Could also mention that --notify-proxy only works if --proxy is used. You kinda already does this and it's somewhat self explanatory, but one extra sentence could clear up any misunderstandings?

PS: some of this can maybe also be applied to the --notify-proxy CLI help text.

just some thoughts on my part.

## Alternative 1: wgo

[wgo](https://github.com/bokwoon95/wgo):
Expand All @@ -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.

Expand Down