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>
  • Loading branch information
Polo123456789 and xiantang authored Jan 19, 2025
1 parent ad99709 commit 0811477
Show file tree
Hide file tree
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
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions runner/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package runner

import (
"bytes"
_ "embed"
"fmt"
"io"
"log"
Expand All @@ -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 {
Expand All @@ -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())
}
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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()
}
}
Expand Down
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"
)
Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
Expand Up @@ -4,6 +4,8 @@ import (
"sync"
"sync/atomic"
"testing"

"github.com/stretchr/testify/assert"
)

func find(s map[int32]*Subscriber, id int32) bool {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Loading

0 comments on commit 0811477

Please sign in to comment.