Skip to content

Commit

Permalink
Feature: Show build errors when using proxy (#725)
Browse files Browse the repository at this point in the history
* proxy: stream reload and error messages

* proxy: Console log on build failure

* proxy: show build errors in a modal

---------

Co-authored-by: xiantang <zhujingdi1998@gmail.com>
Polo123456789 and xiantang authored Jan 19, 2025
1 parent ad99709 commit 0811477
Showing 6 changed files with 246 additions and 30 deletions.
42 changes: 37 additions & 5 deletions runner/engine.go
Original file line number Diff line number Diff line change
@@ -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
21 changes: 15 additions & 6 deletions runner/proxy.go
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ package runner

import (
"bytes"
_ "embed"
"fmt"
"io"
"log"
@@ -11,18 +12,22 @@ 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()
}

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>new EventSource("/internal/reload").onmessage = () => { location.reload() }</script>`
script := "<script>" + ProxyScript + "</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()
}
}
86 changes: 86 additions & 0 deletions runner/proxy.js
Original file line number Diff line number Diff line change
@@ -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`, `
<style>
.air__modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
}
.air__modal-content {
background-color: white;
color: black;
padding: 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
width: 80%;
}
.air__modal-header {
font-size: 1.5em;
margin-bottom: 10px;
}
.air__modal-body {
margin-bottom: 20px;
overflow-x: auto;
}
.air__modal-close {
background-color: #007bff;
color: white;
border: none;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
}
.air__modal pre {
background-color: #1e1e1e;
color: #f8f8f2;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
white-space: pre;
}
.air__modal code {
font-family: 'Courier New', Courier, monospace;
}
</style>
<div class="air__modal" id="air__modal">
<div class="air__modal-content">
<div class="air__modal-header">Build Error</div>
<div class="air__modal-body" id="air__modal-body"></div>
<button class="air__modal-close" id="air__modal-close">Close</button>
</div>
</div>
`);
const modal = document.getElementById('air__modal');
const modalBody = document.getElementById('air__modal-body');
const modalClose = document.getElementById('air__modal-close');
modalBody.innerHTML = `
<strong>Build Cmd:</strong> <pre><code>${data.command}</code></pre><br>
<strong>Output:</strong> <pre><code>${data.output}</code></pre><br>
<strong>Error:</strong> <pre><code>${data.error}</code></pre>
`;
modal.style.display = 'flex';

modalClose.addEventListener('click', () => {
modal.style.display = 'none';
});
}
})();
56 changes: 51 additions & 5 deletions runner/proxy_stream.go
Original file line number Diff line number Diff line change
@@ -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)
}
21 changes: 20 additions & 1 deletion runner/proxy_stream_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
50 changes: 37 additions & 13 deletions runner/proxy_test.go
Original file line number Diff line number Diff line change
@@ -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(`<body><h1>test</h1></body>`)),
},
expect: `<body><h1>test</h1><script>new EventSource("/internal/reload").onmessage = () => { location.reload() }</script></body>`,
expect: fmt.Sprintf(`<body><h1>test</h1><script>%s</script></body>`, 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)
}
}
}

0 comments on commit 0811477

Please sign in to comment.