Files
via/via.go
Gerard Webb 6da518d990 feat: Add Handler() method and fix SSE closed pipe errors (#24)
* 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>
2025-12-11 08:51:16 -01:00

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
}