fix: try solution for race conditions; use brotli included in datastar sdk; small improvements

This commit is contained in:
Joao Goncalves
2025-11-14 17:16:09 -01:00
parent 351bed3ea1
commit 808d4dd0d1
6 changed files with 166 additions and 99 deletions

View File

@@ -2,13 +2,13 @@ package via
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"log" "log"
"reflect" "reflect"
"sync" "sync"
"github.com/go-via/via/h" "github.com/go-via/via/h"
"github.com/starfederation/datastar-go/datastar"
) )
// Context is the living bridge between Go and the browser. // Context is the living bridge between Go and the browser.
@@ -21,10 +21,10 @@ type Context struct {
view func() h.H view func() h.H
componentRegistry map[string]*Context componentRegistry map[string]*Context
parentPageCtx *Context parentPageCtx *Context
sse *datastar.ServerSentEventGenerator patchChan chan patch
actionRegistry map[string]func() actionRegistry map[string]func()
signals map[string]*signal signals *sync.Map
signalsMux sync.Mutex mutex sync.RWMutex
} }
// View defines the UI rendered by this context. // View defines the UI rendered by this context.
@@ -155,9 +155,9 @@ func (c *Context) Signal(v any) *signal {
// components register signals on parent page // components register signals on parent page
if c.isComponent() { if c.isComponent() {
c.parentPageCtx.signals[sigID] = sig c.parentPageCtx.signals.Store(sigID, sig)
} else { } else {
c.signals[sigID] = sig c.signals.Store(sigID, sig)
} }
return sig return sig
@@ -168,56 +168,72 @@ func (c *Context) injectSignals(sigs map[string]any) {
c.app.logErr(c, "signal injection failed: nil signals in ctx") c.app.logErr(c, "signal injection failed: nil signals in ctx")
return return
} }
for k, v := range sigs {
if _, ok := c.signals[k]; !ok { c.mutex.Lock()
c.signals[k] = &signal{ defer c.mutex.Unlock()
id: k,
t: reflect.TypeOf(v), for sigID, val := range sigs {
v: reflect.ValueOf(v), if _, ok := c.signals.Load(sigID); !ok {
} c.signals.Store(sigID, &signal{
id: sigID,
t: reflect.TypeOf(val),
v: reflect.ValueOf(val),
})
continue continue
} }
c.signals[k].v = reflect.ValueOf(v) item, _ := c.signals.Load(sigID)
c.signals[k].changed = false if sig, ok := item.(*signal); ok {
sig.v = reflect.ValueOf(val)
sig.changed = false
}
} }
} }
func (c *Context) getSSE() *datastar.ServerSentEventGenerator { func (c *Context) getSSE() chan patch {
// components use parent page sse stream // components use parent page sse stream
var sse *datastar.ServerSentEventGenerator var patchChan chan patch
if c.isComponent() { if c.isComponent() {
sse = c.parentPageCtx.sse patchChan = c.parentPageCtx.parentPageCtx.patchChan
} else { } else {
sse = c.sse patchChan = c.patchChan
} }
return sse return patchChan
}
func (c *Context) prepareSignalsForPatch() map[string]any {
updatedSigs := make(map[string]any)
c.signals.Range(func(sigID, value any) bool {
if sig, ok := value.(*signal); ok {
if sig.err != nil {
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
return true
}
if sig.changed {
updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.v)
}
}
return true
})
return updatedSigs
} }
// Sync pushes the current view state and signal changes to the browser immediately // Sync pushes the current view state and signal changes to the browser immediately
// over the live SSE event stream. // over the live SSE event stream.
func (c *Context) Sync() { func (c *Context) Sync() {
sse := c.getSSE()
if sse == nil {
c.app.logWarn(c, "view out of sync: no sse stream")
return
}
elemsPatch := bytes.NewBuffer(make([]byte, 0)) elemsPatch := bytes.NewBuffer(make([]byte, 0))
if err := c.view().Render(elemsPatch); err != nil { if err := c.view().Render(elemsPatch); err != nil {
c.app.logErr(c, "sync view failed: %v", err) c.app.logErr(c, "sync view failed: %v", err)
return return
} }
_ = sse.PatchElements(elemsPatch.String()) c.patchChan <- patch{patchTypeElements, elemsPatch.String()}
updatedSigs := make(map[string]any)
for id, sig := range c.signals { c.mutex.RLock()
if sig.err != nil { defer c.mutex.RUnlock()
c.app.logWarn(c, "failed to sync signal '%s': %v", sig.id, sig.err) updatedSigs := c.prepareSignalsForPatch()
}
if sig.changed && sig.err == nil {
updatedSigs[id] = fmt.Sprintf("%v", sig.v)
}
}
if len(updatedSigs) != 0 { if len(updatedSigs) != 0 {
_ = sse.MarshalAndPatchSignals(updatedSigs) outgoingSigs, _ := json.Marshal(updatedSigs)
c.patchChan <- patch{patchTypeSignals, string(outgoingSigs)}
} }
} }
@@ -254,28 +270,19 @@ func (c *Context) SyncElements(elem h.H) {
} }
b := bytes.NewBuffer(make([]byte, 0)) b := bytes.NewBuffer(make([]byte, 0))
_ = elem.Render(b) _ = elem.Render(b)
_ = sse.PatchElements(b.String()) c.patchChan <- patch{patchTypeElements, b.String()}
} }
// SyncSignals pushes the current signal changes to the browser immediately // SyncSignals pushes the current signal changes to the browser immediately
// over the live SSE event stream. // over the live SSE event stream.
func (c *Context) SyncSignals() { func (c *Context) SyncSignals() {
sse := c.getSSE() c.mutex.RLock()
if sse == nil { updatedSigs := c.prepareSignalsForPatch()
c.app.logWarn(c, "signals out of sync: no sse stream") defer c.mutex.RUnlock()
return
}
updatedSigs := make(map[string]any)
for id, sig := range c.signals {
if sig.err != nil {
c.app.logWarn(c, "signal out of sync'%s': %v", sig.id, sig.err)
}
if sig.changed && sig.err == nil {
updatedSigs[id] = fmt.Sprintf("%v", sig.v)
}
}
if len(updatedSigs) != 0 { if len(updatedSigs) != 0 {
_ = sse.MarshalAndPatchSignals(updatedSigs) outgoingSignals, _ := json.Marshal(updatedSigs)
c.patchChan <- patch{patchTypeSignals, string(outgoingSignals)}
} }
} }
@@ -285,7 +292,7 @@ func (c *Context) ExecScript(s string) {
c.app.logWarn(c, "script out of sync: no sse stream") c.app.logWarn(c, "script out of sync: no sse stream")
return return
} }
_ = sse.ExecuteScript(s) c.patchChan <- patch{patchTypeScript, s}
} }
func newContext(id string, route string, app *V) *Context { func newContext(id string, route string, app *V) *Context {
@@ -299,6 +306,7 @@ func newContext(id string, route string, app *V) *Context {
app: app, app: app,
componentRegistry: make(map[string]*Context), componentRegistry: make(map[string]*Context),
actionRegistry: make(map[string]func()), actionRegistry: make(map[string]func()),
signals: make(map[string]*signal), signals: new(sync.Map),
patchChan: make(chan patch, 100),
} }
} }

5
go.mod
View File

@@ -5,20 +5,21 @@ go 1.25.4
require maragu.dev/gomponents v1.2.0 require maragu.dev/gomponents v1.2.0
require ( require (
github.com/CAFxX/httpcompression v0.0.9
github.com/andybalholm/brotli v1.2.0 github.com/andybalholm/brotli v1.2.0
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/go-via/via-plugin-picocss v0.0.0-20251112183909-4485ba2e31d8 github.com/go-via/via-plugin-picocss v0.1.0
github.com/starfederation/datastar-go v1.0.3 github.com/starfederation/datastar-go v1.0.3
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
) )
require ( require (
github.com/CAFxX/httpcompression v0.0.9 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/kr/pretty v0.1.0 // indirect github.com/kr/pretty v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/sys v0.13.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

6
go.sum
View File

@@ -8,8 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-via/via-plugin-picocss v0.0.0-20251112183909-4485ba2e31d8 h1:FRjqaCz+MbG53a8EJOJglSK/dcUx7LwR8+UuJBs5bGU= github.com/go-via/via-plugin-picocss v0.1.0 h1:ytVtBlfYBhidos5ub4a8liYqadz1AkeHhh7e7Paz620=
github.com/go-via/via-plugin-picocss v0.0.0-20251112183909-4485ba2e31d8/go.mod h1:5LEnLE7q8YfYY7jtH/TLPvfquB7Qt9WZ7TbKrskUW+0= github.com/go-via/via-plugin-picocss v0.1.0/go.mod h1:5LEnLE7q8YfYY7jtH/TLPvfquB7Qt9WZ7TbKrskUW+0=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
@@ -42,6 +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/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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -2,29 +2,48 @@ package main
import ( import (
"github.com/go-via/via" "github.com/go-via/via"
"github.com/go-via/via-plugin-picocss/picocss"
"github.com/go-via/via/h" "github.com/go-via/via/h"
) )
type Counter struct{ Count int }
func main() { func main() {
v := via.New() v := via.New()
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"))) v.Config(via.Options{
DocumentTitle: "Via Counter",
// Plugin is placed here. Use picocss.WithOptions(pococss.Options) to add the plugin
// with a different color theme or to enable a classes for a wide range of colors.
Plugins: []via.Plugin{
picocss.Default,
},
})
v.Page("/", func(c *via.Context) { v.Page("/", func(c *via.Context) {
data := Counter{Count: 0}
step := c.Signal(1)
increment := c.Action(func() {
data.Count += step.Int()
c.Sync()
})
c.View(func() h.H { c.View(func() h.H {
return h.Div( return h.Main(h.Class("container"), h.Br(),
h.H1(h.Text("Hello PicoCSS!")), h.H1(h.Text("⚡ Via Counter")), h.Hr(),
h.H2(h.Text("Hello PicoCSS!")), h.Div(
h.H3(h.Text("Hello PicoCSS!")), h.H2(h.Textf("Count - %d", data.Count)),
h.H4(h.Text("Hello PicoCSS!")), h.H5(h.Text("Step - "), step.Text()),
h.H5(h.Text("Hello PicoCSS!")), h.Div(h.Role("group"),
h.H6(h.Text("Hello PicoCSS!")), h.Input(h.Type("number"), step.Bind()),
h.Div(h.Class("grid"), h.Button(h.Text("Increment"), increment.OnClick()),
h.Button(h.Text("Primary")), ),
h.Button(h.Class("secondary"), h.Text("Secondary")),
), ),
) )
}) })
}) })
v.Start() v.Start()
} }

View File

@@ -51,7 +51,7 @@ func (s *signal) Bind() h.H {
// //
// h.Div(mysignal.Text()) // h.Div(mysignal.Text())
func (s *signal) Text() h.H { func (s *signal) Text() h.H {
return h.Data("text", "$"+s.id) return h.Span(h.Data("text", "$"+s.id))
} }
// SetValue updates the signals value and marks it for synchronization with the browser. // SetValue updates the signals value and marks it for synchronization with the browser.

95
via.go
View File

@@ -18,7 +18,6 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/CAFxX/httpcompression"
"github.com/go-via/via/h" "github.com/go-via/via/h"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )
@@ -31,9 +30,8 @@ var datastarJS []byte
type V struct { type V struct {
cfg Options cfg Options
mux *http.ServeMux mux *http.ServeMux
handler http.Handler
contextRegistry map[string]*Context contextRegistry map[string]*Context
contextRegistryMutex sync.Mutex contextRegistryMutex sync.RWMutex
documentHeadIncludes []h.H documentHeadIncludes []h.H
documentFootIncludes []h.H documentFootIncludes []h.H
devModePageInitFnMap map[string]func(*Context) devModePageInitFnMap map[string]func(*Context)
@@ -149,10 +147,13 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
headElements := v.documentHeadIncludes headElements := v.documentHeadIncludes
headElements = append(headElements, h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id)))) headElements = append(headElements, h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))))
headElements = append(headElements, h.Meta(h.Data("init", `window.addEventListener('beforeunload', (evt) => { headElements = append(headElements, h.Meta(h.Data("init", `window.addEventListener('beforeunload', (evt) => {
evt.preventDefault(); evt.returnValue = ''; @post('/_session/close'); return ''; })`))) evt.preventDefault(); evt.returnValue = ''; @get('/_session/close'); return ''; })`)))
headElements = append(headElements, h.Meta(h.Data("init", "@get('/_sse')"))) headElements = append(headElements, h.Meta(h.Data("init", "@get('/_sse')")))
bottomBodyElements := []h.H{c.view()} bottomBodyElements := []h.H{c.view()}
bottomBodyElements = append(bottomBodyElements, v.documentFootIncludes...) bottomBodyElements = append(bottomBodyElements, v.documentFootIncludes...)
if v.cfg.DevMode {
bottomBodyElements = append(bottomBodyElements, h.Script(h.Type("module"), h.Src("https://cdn.jsdelivr.net/gh/dataSPA/dataSPA-inspector@latest/dataspa-inspector.bundled.js")))
}
view := h.HTML5(h.HTML5Props{ view := h.HTML5(h.HTML5Props{
Title: v.cfg.DocumentTitle, Title: v.cfg.DocumentTitle,
Head: headElements, Head: headElements,
@@ -185,8 +186,8 @@ func (v *V) unregisterCtx(id string) {
} }
func (v *V) getCtx(id string) (*Context, error) { func (v *V) getCtx(id string) (*Context, error) {
v.contextRegistryMutex.Lock() v.contextRegistryMutex.RLock()
defer v.contextRegistryMutex.Unlock() defer v.contextRegistryMutex.RUnlock()
if c, ok := v.contextRegistry[id]; ok { if c, ok := v.contextRegistry[id]; ok {
return c, nil return c, nil
} }
@@ -205,7 +206,7 @@ func (v *V) Start() {
v.devModeRestore() v.devModeRestore()
} }
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress) v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.handler)) log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.mux))
} }
func (v *V) devModePersist(c *Context) { func (v *V) devModePersist(c *Context) {
@@ -273,22 +274,25 @@ func (v *V) devModeRestore() {
os.Remove(p) os.Remove(p)
} }
type patchType int
const (
patchTypeElements = iota
patchTypeSignals
patchTypeScript
)
type patch struct {
typ patchType
content string
}
// New creates a new *V application with default configuration. // New creates a new *V application with default configuration.
func New() *V { func New() *V {
mux := http.NewServeMux() mux := http.NewServeMux()
compressionAdapter, err := httpcompression.DefaultAdapter(
httpcompression.MinSize(1024),
httpcompression.BrotliCompressionLevel(5),
httpcompression.ZstandardCompressor(nil),
)
if err != nil {
log.Fatalf("failed to create compression adapter: %v", err)
}
v := &V{ v := &V{
mux: mux, mux: mux,
handler: compressionAdapter(mux),
contextRegistry: make(map[string]*Context), contextRegistry: make(map[string]*Context),
devModePageInitFnMap: make(map[string]func(*Context)), devModePageInitFnMap: make(map[string]func(*Context)),
cfg: Options{ cfg: Options{
@@ -310,20 +314,53 @@ func New() *V {
cID, _ := sigs["via-ctx"].(string) cID, _ := sigs["via-ctx"].(string)
c, err := v.getCtx(cID) c, err := v.getCtx(cID)
if err != nil { if err != nil {
v.logErr(nil, "failed to render page: %v", err) v.logErr(nil, "sse stream failed to start: %v", err)
return return
} }
c.sse = datastar.NewSSE(w, r)
sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5))))
v.logDebug(c, "SSE connection established") v.logDebug(c, "SSE connection established")
if v.cfg.DevMode {
c.Sync() go func() {
} else { if v.cfg.DevMode {
c.SyncSignals() c.Sync()
} else {
c.SyncSignals()
}
}()
for {
select {
case <-sse.Context().Done():
v.logDebug(c, "SSE context done, exiting handler loop")
return
case patch, ok := <-c.patchChan:
if !ok {
v.logDebug(c, "patchChan closed, exiting handler loop")
return
}
switch patch.typ {
case patchTypeElements:
if err := sse.PatchElements(patch.content); err != nil {
v.logErr(c, "PatchElements failed: %v", err)
return
}
case patchTypeSignals:
if err := sse.PatchSignals([]byte(patch.content)); err != nil {
v.logErr(c, "PatchSignals failed: %v", err)
return
}
case patchTypeScript:
if err := sse.ExecuteScript(patch.content, datastar.WithExecuteScriptAutoRemove(true)); err != nil {
v.logErr(c, "ExecuteScript failed: %v", err)
return
}
}
}
} }
<-c.sse.Context().Done()
c.sse = nil
v.logDebug(c, "SSE connection closed")
}) })
v.mux.HandleFunc("GET /_action/{id}", func(w http.ResponseWriter, r *http.Request) { v.mux.HandleFunc("GET /_action/{id}", func(w http.ResponseWriter, r *http.Request) {
actionID := r.PathValue("id") actionID := r.PathValue("id")
var sigs map[string]any var sigs map[string]any
@@ -345,12 +382,12 @@ func New() *V {
v.logErr(c, "action '%s' failed: %v", actionID, r) v.logErr(c, "action '%s' failed: %v", actionID, r)
} }
}() }()
c.signalsMux.Lock()
defer c.signalsMux.Unlock()
c.injectSignals(sigs) c.injectSignals(sigs)
actionFn() actionFn()
}) })
v.mux.HandleFunc("POST /_session/close", func(w http.ResponseWriter, r *http.Request) {
v.mux.HandleFunc("GET /_session/close", func(w http.ResponseWriter, r *http.Request) {
var sigs map[string]any var sigs map[string]any
_ = datastar.ReadSignals(r, &sigs) _ = datastar.ReadSignals(r, &sigs)
cID, _ := sigs["via-ctx"].(string) cID, _ := sigs["via-ctx"].(string)