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 <zhujingdi1998@gmail.com>
This commit is contained in:
+39
-7
@@ -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
|
||||
}
|
||||
|
||||
// run cmd option in .air.toml
|
||||
func (e *Engine) building() error {
|
||||
e.buildLog("building...")
|
||||
err := e.runCommand(e.config.Build.Cmd)
|
||||
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
|
||||
return "", err
|
||||
}
|
||||
return nil
|
||||
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() (string, error) {
|
||||
e.buildLog("building...")
|
||||
output, err := e.runCommandCopyOutput(e.config.Build.Cmd)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// run pre_cmd option in .air.toml
|
||||
|
||||
+15
-6
@@ -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>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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
})();
|
||||
+51
-5
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+37
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user