From 0811477d4f34d0fa1743e5402341c46a2fa57fbf Mon Sep 17 00:00:00 2001 From: Polo123456789 <57022222+Polo123456789@users.noreply.github.com> Date: Sun, 19 Jan 2025 02:28:51 -0600 Subject: [PATCH] Feature: Show build errors when using proxy (#725) * proxy: stream reload and error messages * proxy: Console log on build failure * proxy: show build errors in a modal --------- Co-authored-by: xiantang --- runner/engine.go | 42 +++++++++++++++--- runner/proxy.go | 21 ++++++--- runner/proxy.js | 86 +++++++++++++++++++++++++++++++++++++ runner/proxy_stream.go | 56 +++++++++++++++++++++--- runner/proxy_stream_test.go | 21 ++++++++- runner/proxy_test.go | 50 +++++++++++++++------ 6 files changed, 246 insertions(+), 30 deletions(-) create mode 100644 runner/proxy.js diff --git a/runner/engine.go b/runner/engine.go index f1a40cf7..46c3c88c 100644 --- a/runner/engine.go +++ b/runner/engine.go @@ -391,10 +391,20 @@ func (e *Engine) buildRun() { return } } - if err = e.building(); err != nil { + if output, err := e.building(); err != nil { e.buildLog("failed to build, error: %s", err.Error()) _ = e.writeBuildErrorLog(err.Error()) if e.config.Build.StopOnError { + // It only makes sense to run it if we stop on error. Otherwise when + // running the binary again the error modal will be overwritten by + // the reload. + if e.config.Proxy.Enabled { + e.proxy.BuildFailed(BuildFailedMsg{ + Error: err.Error(), + Command: e.config.Build.Cmd, + Output: output, + }) + } return } } @@ -443,14 +453,36 @@ func (e *Engine) runCommand(command string) error { return nil } +func (e *Engine) runCommandCopyOutput(command string) (string, error) { + // both stdout and stderr are piped to the same buffer, so ignore the second + // one + cmd, stdout, _, err := e.startCmd(command) + if err != nil { + return "", err + } + defer func() { + stdout.Close() + }() + + stdoutBytes, _ := io.ReadAll(stdout) + _, _ = io.Copy(os.Stdout, strings.NewReader(string(stdoutBytes))) + + // wait for command to finish + err = cmd.Wait() + if err != nil { + return string(stdoutBytes), err + } + return string(stdoutBytes), nil +} + // run cmd option in .air.toml -func (e *Engine) building() error { +func (e *Engine) building() (string, error) { e.buildLog("building...") - err := e.runCommand(e.config.Build.Cmd) + output, err := e.runCommandCopyOutput(e.config.Build.Cmd) if err != nil { - return err + return output, err } - return nil + return output, nil } // run pre_cmd option in .air.toml diff --git a/runner/proxy.go b/runner/proxy.go index d6db86f0..09121ef3 100644 --- a/runner/proxy.go +++ b/runner/proxy.go @@ -2,6 +2,7 @@ package runner import ( "bytes" + _ "embed" "fmt" "io" "log" @@ -11,10 +12,14 @@ import ( "time" ) -type Reloader interface { +//go:embed proxy.js +var ProxyScript string + +type Streamer interface { AddSubscriber() *Subscriber RemoveSubscriber(id int32) Reload() + BuildFailed(msg BuildFailedMsg) Stop() } @@ -22,7 +27,7 @@ type Proxy struct { server *http.Server client *http.Client config *cfgProxy - stream Reloader + stream Streamer } func NewProxy(cfg *cfgProxy) *Proxy { @@ -43,7 +48,7 @@ func NewProxy(cfg *cfgProxy) *Proxy { func (p *Proxy) Run() { http.HandleFunc("/", p.proxyHandler) - http.HandleFunc("/internal/reload", p.reloadHandler) + http.HandleFunc("/__air_internal/sse", p.reloadHandler) if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(p.Stop()) } @@ -53,6 +58,10 @@ func (p *Proxy) Reload() { p.stream.Reload() } +func (p *Proxy) BuildFailed(msg BuildFailedMsg) { + p.stream.BuildFailed(msg) +} + func (p *Proxy) injectLiveReload(resp *http.Response) (string, error) { buf := new(bytes.Buffer) if _, err := buf.ReadFrom(resp.Body); err != nil { @@ -66,7 +75,7 @@ func (p *Proxy) injectLiveReload(resp *http.Response) (string, error) { return page, nil } - script := `` + script := "" return page[:body] + script + page[body:], nil } @@ -174,8 +183,8 @@ func (p *Proxy) reloadHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) flusher.Flush() - for range sub.reloadCh { - fmt.Fprintf(w, "data: reload\n\n") + for msg := range sub.msgCh { + fmt.Fprint(w, msg.AsSSE()) flusher.Flush() } } diff --git a/runner/proxy.js b/runner/proxy.js new file mode 100644 index 00000000..c9d0233d --- /dev/null +++ b/runner/proxy.js @@ -0,0 +1,86 @@ +(() => { + const eventSource = new EventSource("/__air_internal/sse"); + + eventSource.addEventListener('reload', () => { + location.reload(); + }); + + eventSource.addEventListener('build-failed', (event) => { + const data = JSON.parse(event.data); + showErrorInModal(data); + }); + + function showErrorInModal(data) { + document.body.insertAdjacentHTML(`beforeend`, ` + +
+
+
Build Error
+
+ +
+
+ `); + const modal = document.getElementById('air__modal'); + const modalBody = document.getElementById('air__modal-body'); + const modalClose = document.getElementById('air__modal-close'); + modalBody.innerHTML = ` + Build Cmd:
${data.command}

+ Output:
${data.output}

+ Error:
${data.error}
+ `; + modal.style.display = 'flex'; + + modalClose.addEventListener('click', () => { + modal.style.display = 'none'; + }); + } +})(); diff --git a/runner/proxy_stream.go b/runner/proxy_stream.go index f3a4bd47..6d1048e6 100644 --- a/runner/proxy_stream.go +++ b/runner/proxy_stream.go @@ -1,6 +1,8 @@ package runner import ( + "encoding/json" + "fmt" "sync" "sync/atomic" ) @@ -11,9 +13,27 @@ type ProxyStream struct { count atomic.Int32 } +type StreamMessageType string + +const ( + StreamMessageReload StreamMessageType = "reload" + StreamMessageBuildFailed StreamMessageType = "build-failed" +) + +type StreamMessage struct { + Type StreamMessageType + Data interface{} +} + +type BuildFailedMsg struct { + Error string `json:"error"` + Command string `json:"command"` + Output string `json:"output"` +} + type Subscriber struct { - id int32 - reloadCh chan struct{} + id int32 + msgCh chan StreamMessage } func NewProxyStream() *ProxyStream { @@ -32,7 +52,7 @@ func (stream *ProxyStream) AddSubscriber() *Subscriber { defer stream.mu.Unlock() stream.count.Add(1) - sub := &Subscriber{id: stream.count.Load(), reloadCh: make(chan struct{})} + sub := &Subscriber{id: stream.count.Load(), msgCh: make(chan StreamMessage)} stream.subscribers[stream.count.Load()] = sub return sub } @@ -42,13 +62,39 @@ func (stream *ProxyStream) RemoveSubscriber(id int32) { defer stream.mu.Unlock() if _, ok := stream.subscribers[id]; ok { - close(stream.subscribers[id].reloadCh) + close(stream.subscribers[id].msgCh) delete(stream.subscribers, id) } } func (stream *ProxyStream) Reload() { for _, sub := range stream.subscribers { - sub.reloadCh <- struct{}{} + sub.msgCh <- StreamMessage{ + Type: StreamMessageReload, + Data: nil, + } + } +} + +func (stream *ProxyStream) BuildFailed(err BuildFailedMsg) { + for _, sub := range stream.subscribers { + sub.msgCh <- StreamMessage{ + Type: StreamMessageBuildFailed, + Data: err, + } + } +} + +func (m StreamMessage) AsSSE() string { + s := "event: " + string(m.Type) + "\n" + s += "data: " + stringify(m.Data) + "\n" + return s + "\n" +} + +func stringify(v any) string { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("{\"error\":\"Failed to marshal message: %s\"}", err) } + return string(b) } diff --git a/runner/proxy_stream_test.go b/runner/proxy_stream_test.go index ca1e78cb..bf4a0978 100644 --- a/runner/proxy_stream_test.go +++ b/runner/proxy_stream_test.go @@ -4,6 +4,8 @@ import ( "sync" "sync/atomic" "testing" + + "github.com/stretchr/testify/assert" ) func find(s map[int32]*Subscriber, id int32) bool { @@ -43,7 +45,7 @@ func TestProxyStream(t *testing.T) { wg.Add(1) go func(sub *Subscriber) { defer wg.Done() - <-sub.reloadCh + <-sub.msgCh reloadCount.Add(1) }(sub) } @@ -69,3 +71,20 @@ func TestProxyStream(t *testing.T) { t.Errorf("expected subscribers count to be %d, got %d", exp, got) } } + +func TestBuildFailureMessage(t *testing.T) { + stream := NewProxyStream() + sub := stream.AddSubscriber() + + msg := BuildFailedMsg{ + Error: "build failed", + Command: "go build", + Output: "error output", + } + + go stream.BuildFailed(msg) + + received := <-sub.msgCh + assert.Equal(t, StreamMessageBuildFailed, received.Type) + assert.Equal(t, msg, received.Data) +} diff --git a/runner/proxy_test.go b/runner/proxy_test.go index 62e0a0f4..69f86631 100644 --- a/runner/proxy_test.go +++ b/runner/proxy_test.go @@ -20,20 +20,21 @@ import ( type reloader struct { subCh chan struct{} - reloadCh chan struct{} + reloadCh chan StreamMessage } func (r *reloader) AddSubscriber() *Subscriber { r.subCh <- struct{}{} - return &Subscriber{reloadCh: r.reloadCh} + return &Subscriber{msgCh: r.reloadCh} } func (r *reloader) RemoveSubscriber(_ int32) { close(r.subCh) } -func (r *reloader) Reload() {} -func (r *reloader) Stop() {} +func (r *reloader) Reload() {} +func (r *reloader) BuildFailed(BuildFailedMsg) {} +func (r *reloader) Stop() {} var proxyPort = 8090 @@ -201,7 +202,7 @@ func TestProxy_injectLiveReload(t *testing.T) { }, Body: io.NopCloser(strings.NewReader(`

test

`)), }, - expect: `

test

`, + expect: fmt.Sprintf(`

test

`, ProxyScript), }, } for _, tt := range tests { @@ -211,8 +212,15 @@ func TestProxy_injectLiveReload(t *testing.T) { ProxyPort: 1111, AppPort: 2222, }) - if got, _ := proxy.injectLiveReload(tt.given); got != tt.expect { - t.Errorf("expected page %+v, got %v", tt.expect, got) + got, _ := proxy.injectLiveReload(tt.given) + if got != tt.expect { + // Use a more descriptive error message + if len(got) > 100 || len(tt.expect) > 100 { + t.Errorf("Script injection mismatch.\nGot length: %d\nExpected length: %d", + len(got), len(tt.expect)) + } else { + t.Errorf("expected page %+v, got %v", tt.expect, got) + } } }) } @@ -225,7 +233,7 @@ func TestProxy_reloadHandler(t *testing.T) { srvPort := getServerPort(t, srv) defer srv.Close() - reloader := &reloader{subCh: make(chan struct{}), reloadCh: make(chan struct{})} + reloader := &reloader{subCh: make(chan struct{}), reloadCh: make(chan StreamMessage)} cfg := &cfgProxy{ Enabled: true, ProxyPort: proxyPort, @@ -248,11 +256,12 @@ func TestProxy_reloadHandler(t *testing.T) { proxy.reloadHandler(rec, req) }() - // wait for subscriber to be added <-reloader.subCh - // send a reload event and wait for http response - reloader.reloadCh <- struct{}{} + reloader.reloadCh <- StreamMessage{ + Type: StreamMessageReload, + Data: nil, + } close(reloader.reloadCh) wg.Wait() @@ -265,7 +274,22 @@ func TestProxy_reloadHandler(t *testing.T) { if err != nil { t.Errorf("reading body: %v", err) } - if got, exp := string(bodyBytes), "data: reload\n\n"; got != exp { - t.Errorf("expected %q but got %q", exp, got) + + expected := "event: reload\ndata: null\n\n" + if got := string(bodyBytes); got != expected { + t.Errorf("expected %q but got %q", expected, got) + } + + expectedHeaders := map[string]string{ + "Access-Control-Allow-Origin": "*", + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + } + + for key, value := range expectedHeaders { + if got := resp.Header.Get(key); got != value { + t.Errorf("expected header %s to be %q but got %q", key, value, got) + } } }