diff --git a/context.go b/context.go index 195d54c..8ff2ca4 100644 --- a/context.go +++ b/context.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "reflect" "sync" "github.com/go-via/via/h" @@ -32,8 +33,7 @@ type Context struct { // Changes to signals or state can be pushed live with Sync(). func (c *Context) View(f func() h.H) { if f == nil { - c.app.logErr(c, "failed to bind view to context: nil func") - return + panic("nil viewfn") } c.view = func() h.H { return h.Div(h.ID(c.id), f()) } } @@ -143,6 +143,12 @@ func (c *Context) Signal(v any) *signal { err: fmt.Errorf("context '%s' failed to bind signal '%s': nil signal value", c.id, sigID), } } + switch reflect.TypeOf(v).Kind() { + case reflect.Slice, reflect.Struct: + if j, err := json.Marshal(v); err == nil { + v = string(j) + } + } sig := &signal{ id: sigID, val: v, @@ -251,13 +257,13 @@ func (c *Context) Sync() { // Then, the merge will only occur if the ID of the top level element in the patch // matches 'my-element'. func (c *Context) SyncElements(elem h.H) { - patchChan := c.getPatchChan() - if c.view == nil { - c.app.logErr(c, "sync element failed: viewfn is nil") + if elem == nil { + c.app.logErr(c, "sync elements failed: view func is nil") return } - if elem == nil { - c.app.logErr(c, "sync element failed: view func is nil") + patchChan := c.getPatchChan() + if patchChan == nil { + c.app.logWarn(c, "sync elements failed: no sse stream") return } b := bytes.NewBuffer(make([]byte, 0)) @@ -302,15 +308,15 @@ func (c *Context) ExecScript(s string) { patchChan <- patch{patchTypeScript, s} } -func newContext(id string, route string, app *V) *Context { - if app == nil { - log.Fatalf("create context failed: app pointer is nil") +func newContext(id string, route string, v *V) *Context { + if v == nil { + log.Fatal("create context failed: app pointer is nil") } return &Context{ id: id, route: route, - app: app, + app: v, componentRegistry: make(map[string]*Context), actionRegistry: make(map[string]func()), signals: new(sync.Map), diff --git a/go.mod b/go.mod index 8e5b175..c6a334b 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/kr/pretty v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/sys v0.13.0 // indirect + golang.org/x/sys v0.38.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f5737bc..5f0bef5 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,8 @@ github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/signal_test.go b/signal_test.go new file mode 100644 index 0000000..6abec3a --- /dev/null +++ b/signal_test.go @@ -0,0 +1,64 @@ +package via + +import ( + // "net/http/httptest" + "testing" + + "github.com/go-via/via/h" + "github.com/stretchr/testify/assert" +) + +func TestSignalReturnAsString(t *testing.T) { + testcases := []struct { + given any + expected string + }{ + {"test", "test"}, + {"another", "another"}, + {1, "1"}, + {-99, "-99"}, + {1.1, "1.1"}, + {-34.345, "-34.345"}, + {true, "true"}, + {false, "false"}, + } + + for _, testcase := range testcases { + var sig *signal + v := New() + v.Page("/", func(c *Context) { + c.View(func() h.H { return nil }) + sig = c.Signal(testcase.given) + }) + + assert.Equal(t, testcase.expected, sig.String()) + + } +} + +func TestSignalReturnAsStringComplexTypes(t *testing.T) { + testcases := []struct { + given any + expected string + }{ + {[]string{"test"}, `["test"]`}, + {[]int{1, 2}, "[1, 2]"}, + {struct{ Val string }{"test"}, `{"Val": "test"}`}, + {struct { + Num int + IsPositive bool + }{1, true}, `{"Num": 1, "IsPositive": true}`}, + } + + for _, testcase := range testcases { + var sig *signal + v := New() + v.Page("/", func(c *Context) { + c.View(func() h.H { return nil }) + sig = c.Signal(testcase.given) + }) + + assert.JSONEq(t, testcase.expected, sig.String()) + + } +} diff --git a/via.go b/via.go index 1d1deed..294f905 100644 --- a/via.go +++ b/via.go @@ -18,6 +18,7 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/go-via/via/h" "github.com/starfederation/datastar-go/datastar" @@ -38,6 +39,10 @@ type V struct { devModePageInitFnMap map[string]func(*Context) } +func (v *V) logFatal(format string, a ...any) { + log.Printf("[fatal] msg=%q", fmt.Sprintf(format, a...)) +} + func (v *V) logErr(c *Context, format string, a ...any) { cRef := "" if c != nil && c.id != "" { @@ -119,8 +124,8 @@ func (v *V) AppendToFoot(elements ...h.H) { } } -// Page registers a route and its associated page handler. -// The handler receives a *Context to define UI, signals, and actions. +// Page registers a route and its associated page handler. The handler receives a *Context +// that defines state, UI, signals, and actions. // // Example: // @@ -130,6 +135,20 @@ func (v *V) AppendToFoot(elements ...h.H) { // }) // }) func (v *V) Page(route string, initContextFn func(c *Context)) { + // check for panics + func() { + defer func() { + if err := recover(); err != nil { + v.logFatal("failed to register page with init func that panics: %v", err) + panic(err) + } + }() + c := newContext("", "", v) + initContextFn(c) + c.view() + }() + + // save page init function allows devmode to restore persisted ctx later if v.cfg.DevMode { v.devModePageInitFnMap[route] = initContextFn } @@ -145,10 +164,15 @@ func (v *V) Page(route string, initContextFn func(c *Context)) { if v.cfg.DevMode { v.devModePersist(c) } - v.AppendToHead(h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id)))) - v.AppendToHead(h.Meta(h.Data("init", "@get('/_sse')"))) - v.AppendToHead(h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => { - navigator.sendBeacon('/_session/close', '%s');});`, c.id)))) + headElements := []h.H{} + headElements = append(headElements, v.documentHeadIncludes...) + headElements = append(headElements, + h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))), + h.Meta(h.Data("init", "@get('/_sse')")), + h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => { + navigator.sendBeacon('/_session/close', '%s');});`, c.id))), + ) + bodyElements := []h.H{c.view()} bodyElements = append(bodyElements, v.documentFootIncludes...) if v.cfg.DevMode { @@ -158,7 +182,7 @@ func (v *V) Page(route string, initContextFn func(c *Context)) { } view := h.HTML5(h.HTML5Props{ Title: v.cfg.DocumentTitle, - Head: v.documentHeadIncludes, + Head: headElements, Body: bodyElements, HTMLAttrs: []h.H{}, }) @@ -175,11 +199,11 @@ func (v *V) registerCtx(c *Context) { } v.contextRegistry[c.id] = c v.logDebug(c, "new context added to registry") - v.summarize() + v.logDebug(nil, "number of sessions in registry: %d", v.currSessionNum()) } -func (v *V) summarize() { - fmt.Println("Have", len(v.contextRegistry), "sessions") +func (v *V) currSessionNum() int { + return len(v.contextRegistry) } func (v *V) unregisterCtx(id string) { @@ -190,7 +214,7 @@ func (v *V) unregisterCtx(id string) { } v.logDebug(nil, "ctx '%s' removed from registry", id) delete(v.contextRegistry, id) - v.summarize() + v.currSessionNum() } func (v *V) getCtx(id string) (*Context, error) { @@ -251,6 +275,37 @@ func (v *V) devModePersist(c *Context) { v.logDebug(c, "devmode persisted ctx to file") } +func (v *V) devModeRemovePersisted(c *Context) { + p := filepath.Join(".via", "devmode", "ctx.json") + + // load persisted list from file, or empty list if file not found + file, err := os.Open(p) + ctxRegMap := make(map[string]string) + if err == nil { + json.NewDecoder(file).Decode(&ctxRegMap) + } + file.Close() + + // remove ctx to persisted list + if _, ok := ctxRegMap[c.id]; !ok { + delete(ctxRegMap, c.id) + } + + // write persisted list to file + file, err = os.Create(p) + if err != nil { + v.logErr(c, "devmode failed to remove percisted ctx: %v", err) + + } + defer file.Close() + + encoder := json.NewEncoder(file) + if err := encoder.Encode(ctxRegMap); err != nil { + v.logErr(c, "devmode failed to remove persisted ctx") + } + v.logDebug(c, "devmode removed persisted ctx from file") +} + func (v *V) devModeRestore() { p := filepath.Join(".via", "devmode", "ctx.json") file, err := os.Open(p) @@ -330,18 +385,16 @@ func New() *V { v.logDebug(c, "SSE connection established") - go func() { - if v.cfg.DevMode { - c.Sync() - } else { - c.SyncSignals() - } - }() + if v.cfg.DevMode { + c.Sync() + } else { + c.SyncSignals() + } for { select { case <-sse.Context().Done(): - v.logDebug(c, "SSE context done, exiting handler loop") + v.logDebug(c, "SSE connection ended") return case patch, ok := <-c.patchChan: if !ok { @@ -365,6 +418,8 @@ func New() *V { return } } + default: + time.Sleep(100 * time.Microsecond) } } }) @@ -410,6 +465,7 @@ func New() *V { return } v.logDebug(c, "session close event triggered") + v.devModeRemovePersisted(c) v.unregisterCtx(c.id) }) diff --git a/via_test.go b/via_test.go index ed816ed..fce8d84 100644 --- a/via_test.go +++ b/via_test.go @@ -35,6 +35,7 @@ func TestDatastarJS(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "application/javascript", w.Header().Get("Content-Type")) + assert.Contains(t, w.Body.String(), "🖕JS_DS🚀") } func TestSignal(t *testing.T) { @@ -106,3 +107,10 @@ func TestSyncSignals(t *testing.T) { patch := <-ctx.patchChan assert.Equal(t, patch.content, fmt.Sprintf(`{"%s":"updated"}`, sig.ID())) } + +func TestPage_PanicsOnNoView(t *testing.T) { + assert.Panics(t, func() { + v := New() + v.Page("/", func(c *Context) {}) + }) +}