// Package via provides a reactive web framework for Go. // It lets you build live, type-safe web interfaces without JavaScript. // // Via unifies routing, state, and UI reactivity through a simple mental model: // Go on the server — HTML in the browser — updated in real time via Datastar. package via import ( "crypto/rand" _ "embed" "encoding/hex" "fmt" "log" "net/http" "strings" "sync" "github.com/go-via/via/h" "github.com/starfederation/datastar-go/datastar" ) //go:embed datastar.js var datastarJS []byte // V is the root application. // It manages page routing, user sessions, and SSE connections for live updates. type V struct { cfg Options mux *http.ServeMux contextRegistry map[string]*Context contextRegistryMutex sync.RWMutex baseLayout func(h.HTML5Props) h.H } func (v *V) logErr(c *Context, format string, a ...any) { cRef := "" if c != nil && c.id != "" { cRef = fmt.Sprintf("via-ctx=%q ", c.id) } log.Printf("[error] %smsg=%q", cRef, fmt.Sprintf(format, a...)) } func (v *V) logWarn(c *Context, format string, a ...any) { cRef := "" if c != nil && c.id != "" { cRef = fmt.Sprintf("via-ctx=%q ", c.id) } if v.cfg.LogLvl <= LogLevelWarn { log.Printf("[warn] %smsg=%q", cRef, fmt.Sprintf(format, a...)) } } func (v *V) logInfo(c *Context, format string, a ...any) { cRef := "" if c != nil && c.id != "" { cRef = fmt.Sprintf("via-ctx=%q ", c.id) } if v.cfg.LogLvl <= LogLevelInfo { log.Printf("[info] %smsg=%q", cRef, fmt.Sprintf(format, a...)) } } func (v *V) logDebug(c *Context, format string, a ...any) { cRef := "" if c != nil && c.id != "" { cRef = fmt.Sprintf("via-ctx=%q ", c.id) } if v.cfg.LogLvl == LogLevelDebug { log.Printf("[debug] %smsg=%q", cRef, fmt.Sprintf(format, a...)) } } // Config overrides the default configuration with the given configuration options. func (v *V) Config(cfg Options) { if cfg.LogLvl != v.cfg.LogLvl { v.cfg.LogLvl = cfg.LogLvl } if cfg.DocumentTitle != "" { v.cfg.DocumentTitle = cfg.DocumentTitle } if cfg.DocumentHeadIncludes != nil { v.cfg.DocumentHeadIncludes = cfg.DocumentHeadIncludes } if cfg.DocumentBodyIncludes != nil { v.cfg.DocumentBodyIncludes = cfg.DocumentBodyIncludes } if cfg.Plugins != nil { for _, plugin := range cfg.Plugins { if plugin != nil { plugin(v) } } } } // Page registers a route and its associated page handler. // The handler receives a *Context to define UI, signals, and actions. // // Example: // // v.Page("/", func(c *via.Context) { // c.View(func() h.H { // return h.H1(h.Text("Hello, Via!")) // }) // }) func (v *V) Page(route string, composeContext func(c *Context)) { v.mux.HandleFunc("GET "+route, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "favicon") { return } id := fmt.Sprintf("%s_/%s", route, genRandID()) c := newContext(id, v) v.logDebug(c, "GET %s", route) composeContext(c) v.registerCtx(c.id, c) headElements := v.cfg.DocumentHeadIncludes headElements = append(headElements, h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id)))) headElements = append(headElements, h.Meta(h.Data("init", "@get('/_sse')"))) bottomBodyElements := []h.H{c.view()} for _, el := range v.cfg.DocumentBodyIncludes { bottomBodyElements = append(bottomBodyElements, el) } view := h.HTML5(h.HTML5Props{ Title: v.cfg.DocumentTitle, Head: headElements, Body: bottomBodyElements, }) _ = view.Render(w) })) } func (v *V) registerCtx(id string, c *Context) { v.contextRegistryMutex.Lock() defer v.contextRegistryMutex.Unlock() v.contextRegistry[id] = c v.logDebug(c, "new context added to registry") } // func (a *App) unregisterCtx(id string) { // if _, ok := a.contextRegistry[id]; ok { // a.contextRegistryMutex.Lock() // defer a.contextRegistryMutex.Unlock() // delete(a.contextRegistry, id) // } // } func (v *V) getCtx(id string) (*Context, error) { v.contextRegistryMutex.RLock() defer v.contextRegistryMutex.RUnlock() if c, ok := v.contextRegistry[id]; ok { return c, nil } return nil, fmt.Errorf("ctx '%s' not found", id) } // HandleFunc registers the HTTP handler function for a given pattern. The handler function panics if // in conflict with another registered handler with the same pattern. func (v *V) HandleFunc(pattern string, f http.HandlerFunc) { v.mux.HandleFunc(pattern, f) } // Start starts the Via HTTP server on the given address. func (v *V) Start(addr string) { v.logInfo(nil, "via started") log.Fatalf("via failed: %v", http.ListenAndServe(addr, v.mux)) } // New creates a new Via application with default configuration. func New() *V { mux := http.NewServeMux() app := &V{ mux: mux, contextRegistry: make(map[string]*Context), cfg: Options{ LogLvl: LogLevelDebug, DocumentTitle: "Via Application", DocumentHeadIncludes: make([]h.H, 0), DocumentBodyIncludes: make([]h.H, 0), }, } app.mux.HandleFunc("GET /_datastar.js", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript") _, _ = w.Write(datastarJS) }) app.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) { var sigs map[string]any _ = datastar.ReadSignals(r, &sigs) cID, _ := sigs["via-ctx"].(string) c, err := app.getCtx(cID) if err != nil { app.logErr(nil, "failed to render page: %v", err) return } c.sse = datastar.NewSSE(w, r) app.logDebug(c, "SSE connection established") c.SyncSignals() <-c.sse.Context().Done() c.sse = nil app.logDebug(c, "SSE connection closed") }) app.mux.HandleFunc("GET /_action/{id}", func(w http.ResponseWriter, r *http.Request) { actionID := r.PathValue("id") var sigs map[string]any _ = datastar.ReadSignals(r, &sigs) cID, _ := sigs["via-ctx"].(string) active_ctx_count := 0 inactive_ctx_count := 0 for _, c := range app.contextRegistry { if c.sse != nil { active_ctx_count++ continue } inactive_ctx_count++ } app.logDebug(nil, "active_ctx_count=%d inactive_ctx_count=%d", active_ctx_count, inactive_ctx_count) c, err := app.getCtx(cID) if err != nil { app.logErr(nil, "action '%s' failed: %v", actionID, err) return } actionFn, err := c.getActionFn(actionID) if err != nil { app.logDebug(c, "action '%s' failed: %v", actionID, err) return } // log err if actionFn panics defer func() { if r := recover(); r != nil { app.logErr(c, "action '%s' failed: %v", actionID, r) } }() c.signalsMux.Lock() defer c.signalsMux.Unlock() app.logDebug(c, "signals=%v", sigs) c.injectSignals(sigs) actionFn() }) return app } func genRandID() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b)[:8] }