* feat: add Handler() method for testing and custom server integration Exposes the underlying http.Handler to enable: - Integration testing with gost-dom/browser for SSE/Datastar testing - Custom server setups (e.g., embedding Via in existing applications) - Standard Go httptest patterns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Suppress closed pipe errors when SSE connection closes Don't log error when SSE fails to send patches if the connection was already closed. This reduces noise in logs during shutdown and testing, where browsers/clients close connections before server handlers finish. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: joeblew999 <joeblew999@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
520 lines
13 KiB
Go
520 lines
13 KiB
Go
// 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"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"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
|
|
documentHeadIncludes []h.H
|
|
documentFootIncludes []h.H
|
|
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 != "" {
|
|
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 options.
|
|
func (v *V) Config(cfg Options) {
|
|
if cfg.LogLvl != undefined {
|
|
v.cfg.LogLvl = cfg.LogLvl
|
|
}
|
|
if cfg.DocumentTitle != "" {
|
|
v.cfg.DocumentTitle = cfg.DocumentTitle
|
|
}
|
|
if cfg.Plugins != nil {
|
|
for _, plugin := range cfg.Plugins {
|
|
if plugin != nil {
|
|
plugin(v)
|
|
}
|
|
}
|
|
}
|
|
if cfg.DevMode != v.cfg.DevMode {
|
|
v.cfg.DevMode = cfg.DevMode
|
|
}
|
|
if cfg.ServerAddress != "" {
|
|
v.cfg.ServerAddress = cfg.ServerAddress
|
|
}
|
|
}
|
|
|
|
// AppendToHead appends the given h.H nodes to the head of the base HTML document.
|
|
// Useful for including css stylesheets and JS scripts.
|
|
func (v *V) AppendToHead(elements ...h.H) {
|
|
for _, el := range elements {
|
|
if el != nil {
|
|
v.documentHeadIncludes = append(v.documentHeadIncludes, el)
|
|
}
|
|
}
|
|
}
|
|
|
|
// AppendToFoot appends the given h.H nodes to the end of the base HTML document body.
|
|
// Useful for including JS scripts.
|
|
func (v *V) AppendToFoot(elements ...h.H) {
|
|
for _, el := range elements {
|
|
if el != nil {
|
|
v.documentFootIncludes = append(v.documentFootIncludes, el)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Page registers a route and its associated page handler. The handler receives a *Context
|
|
// that defines state, 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, 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()
|
|
c.stopAllRoutines()
|
|
}()
|
|
|
|
// save page init function allows devmode to restore persisted ctx later
|
|
if v.cfg.DevMode {
|
|
v.devModePageInitFnMap[route] = initContextFn
|
|
}
|
|
v.mux.HandleFunc("GET "+route, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
v.logDebug(nil, "GET %s", r.URL.String())
|
|
if strings.Contains(r.URL.Path, "favicon") ||
|
|
strings.Contains(r.URL.Path, ".well-known") ||
|
|
strings.Contains(r.URL.Path, "js.map") {
|
|
return
|
|
}
|
|
id := fmt.Sprintf("%s_/%s", route, genRandID())
|
|
c := newContext(id, route, v)
|
|
routeParams := extractParams(route, r.URL.Path)
|
|
c.injectRouteParams(routeParams)
|
|
initContextFn(c)
|
|
v.registerCtx(c)
|
|
if v.cfg.DevMode {
|
|
v.devModePersist(c)
|
|
}
|
|
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 {
|
|
bodyElements = append(bodyElements, h.Script(h.Type("module"),
|
|
h.Src("https://cdn.jsdelivr.net/gh/dataSPA/dataSPA-inspector@latest/dataspa-inspector.bundled.js")))
|
|
bodyElements = append(bodyElements, h.Raw("<dataspa-inspector/>"))
|
|
}
|
|
view := h.HTML5(h.HTML5Props{
|
|
Title: v.cfg.DocumentTitle,
|
|
Head: headElements,
|
|
Body: bodyElements,
|
|
HTMLAttrs: []h.H{},
|
|
})
|
|
_ = view.Render(w)
|
|
}))
|
|
}
|
|
|
|
func (v *V) registerCtx(c *Context) {
|
|
v.contextRegistryMutex.Lock()
|
|
defer v.contextRegistryMutex.Unlock()
|
|
if c == nil {
|
|
v.logErr(c, "failed to add nil context to registry")
|
|
return
|
|
}
|
|
v.contextRegistry[c.id] = c
|
|
v.logDebug(c, "new context added to registry")
|
|
v.logDebug(nil, "number of sessions in registry: %d", v.currSessionNum())
|
|
}
|
|
|
|
func (v *V) currSessionNum() int {
|
|
return len(v.contextRegistry)
|
|
}
|
|
|
|
func (v *V) unregisterCtx(c *Context) {
|
|
if c.id == "" {
|
|
v.logErr(c, "unregister ctx failed: ctx contains empty id")
|
|
return
|
|
}
|
|
v.contextRegistryMutex.Lock()
|
|
defer v.contextRegistryMutex.Unlock()
|
|
v.logDebug(c, "ctx removed from registry")
|
|
delete(v.contextRegistry, c.id)
|
|
v.logDebug(nil, "number of sessions in registry: %d", v.currSessionNum())
|
|
}
|
|
|
|
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() {
|
|
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
|
|
log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.mux))
|
|
}
|
|
|
|
// Handler returns the underlying http.Handler for use with custom servers or testing.
|
|
// This enables integration with test frameworks like gost-dom/browser for SSE/Datastar testing.
|
|
func (v *V) Handler() http.Handler {
|
|
return v.mux
|
|
}
|
|
|
|
func (v *V) devModePersist(c *Context) {
|
|
p := filepath.Join(".via", "devmode", "ctx.json")
|
|
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
|
log.Fatalf("failed to create directory for devmode files: %v", err)
|
|
}
|
|
|
|
// 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()
|
|
|
|
// add ctx to persisted list
|
|
if _, ok := ctxRegMap[c.id]; !ok {
|
|
ctxRegMap[c.id] = c.route
|
|
}
|
|
|
|
// write persisted list to file
|
|
file, err = os.Create(p)
|
|
if err != nil {
|
|
v.logErr(c, "devmode failed to percist ctx: %v", err)
|
|
|
|
}
|
|
defer file.Close()
|
|
|
|
encoder := json.NewEncoder(file)
|
|
if err := encoder.Encode(ctxRegMap); err != nil {
|
|
v.logErr(c, "devmode failed to persist ctx")
|
|
}
|
|
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(cID string) {
|
|
p := filepath.Join(".via", "devmode", "ctx.json")
|
|
file, err := os.Open(p)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return
|
|
}
|
|
v.logErr(nil, "devmode could not restore ctx from file: %v", err)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
var ctxRegMap map[string]string
|
|
if err := json.NewDecoder(file).Decode(&ctxRegMap); err != nil {
|
|
v.logWarn(nil, "devmode could not restore ctx from file: %v", err)
|
|
return
|
|
}
|
|
for ctxID, pageRoute := range ctxRegMap {
|
|
if ctxID == cID {
|
|
pageInitFn, ok := v.devModePageInitFnMap[pageRoute]
|
|
if !ok {
|
|
v.logWarn(nil, "devmode could not restore ctx from file: page init fn for route '%s' not found", pageRoute)
|
|
continue
|
|
}
|
|
c := newContext(ctxID, pageRoute, v)
|
|
pageInitFn(c)
|
|
v.registerCtx(c)
|
|
v.logDebug(c, "devmode restored ctx")
|
|
}
|
|
}
|
|
}
|
|
|
|
type patchType int
|
|
|
|
const (
|
|
patchTypeElements = iota
|
|
patchTypeSignals
|
|
patchTypeScript
|
|
)
|
|
|
|
type patch struct {
|
|
typ patchType
|
|
content string
|
|
}
|
|
|
|
// New creates a new *V application with default configuration.
|
|
func New() *V {
|
|
mux := http.NewServeMux()
|
|
|
|
v := &V{
|
|
mux: mux,
|
|
contextRegistry: make(map[string]*Context),
|
|
devModePageInitFnMap: make(map[string]func(*Context)),
|
|
cfg: Options{
|
|
DevMode: false,
|
|
ServerAddress: ":3000",
|
|
LogLvl: LogLevelInfo,
|
|
DocumentTitle: "⚡ Via",
|
|
},
|
|
}
|
|
|
|
v.mux.HandleFunc("GET /_datastar.js", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
_, _ = w.Write(datastarJS)
|
|
})
|
|
|
|
v.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)
|
|
|
|
if v.cfg.DevMode {
|
|
if _, err := v.getCtx(cID); err != nil {
|
|
v.devModeRestore(cID)
|
|
}
|
|
}
|
|
c, err := v.getCtx(cID)
|
|
if err != nil {
|
|
v.logErr(nil, "sse stream failed to start: %v", err)
|
|
return
|
|
}
|
|
|
|
sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5))))
|
|
|
|
v.logDebug(c, "SSE connection established")
|
|
|
|
go func() {
|
|
if v.cfg.DevMode {
|
|
c.Sync()
|
|
return
|
|
}
|
|
c.SyncSignals()
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-sse.Context().Done():
|
|
v.logDebug(c, "SSE connection ended")
|
|
return
|
|
case patch, ok := <-c.patchChan:
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch patch.typ {
|
|
case patchTypeElements:
|
|
if err := sse.PatchElements(patch.content); err != nil {
|
|
// Only log if connection wasn't closed (avoids noise during shutdown/tests)
|
|
if sse.Context().Err() == nil {
|
|
v.logErr(c, "PatchElements failed: %v", err)
|
|
}
|
|
continue
|
|
}
|
|
case patchTypeSignals:
|
|
if err := sse.PatchSignals([]byte(patch.content)); err != nil {
|
|
if sse.Context().Err() == nil {
|
|
v.logErr(c, "PatchSignals failed: %v", err)
|
|
}
|
|
continue
|
|
}
|
|
case patchTypeScript:
|
|
if err := sse.ExecuteScript(patch.content, datastar.WithExecuteScriptAutoRemove(true)); err != nil {
|
|
if sse.Context().Err() == nil {
|
|
v.logErr(c, "ExecuteScript failed: %v", err)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
v.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)
|
|
c, err := v.getCtx(cID)
|
|
if err != nil {
|
|
v.logErr(nil, "action '%s' failed: %v", actionID, err)
|
|
return
|
|
}
|
|
actionFn, err := c.getActionFn(actionID)
|
|
if err != nil {
|
|
v.logDebug(c, "action '%s' failed: %v", actionID, err)
|
|
return
|
|
}
|
|
// log err if actionFn panics
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
v.logErr(c, "action '%s' failed: %v", actionID, r)
|
|
}
|
|
}()
|
|
|
|
c.injectSignals(sigs)
|
|
actionFn()
|
|
})
|
|
|
|
v.mux.HandleFunc("POST /_session/close", func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Printf("Error reading body: %v", err)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
cID := string(body)
|
|
c, err := v.getCtx(cID)
|
|
if err != nil {
|
|
v.logErr(c, "failed to handle session close: %v", err)
|
|
return
|
|
}
|
|
c.stopAllRoutines()
|
|
v.logDebug(c, "session close event triggered")
|
|
if v.cfg.DevMode {
|
|
v.devModeRemovePersisted(c)
|
|
}
|
|
v.unregisterCtx(c)
|
|
})
|
|
return v
|
|
}
|
|
|
|
func genRandID() string {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)[:8]
|
|
}
|
|
|
|
func extractParams(pattern, path string) map[string]string {
|
|
p := strings.Split(strings.Trim(pattern, "/"), "/")
|
|
u := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(p) != len(u) {
|
|
return nil
|
|
}
|
|
params := make(map[string]string)
|
|
for i := range p {
|
|
if strings.HasPrefix(p[i], "{") && strings.HasSuffix(p[i], "}") {
|
|
key := p[i][1 : len(p[i])-1] // remove {}
|
|
params[key] = u[i]
|
|
} else if p[i] != u[i] {
|
|
continue
|
|
}
|
|
}
|
|
return params
|
|
}
|