Files
via/via.go
Ryan Hamamura 73f4e4009b Always sync full state when SSE connects
Previously only called Sync() on SSE reconnect (detected via last-event-id
header). This caused issues when application code registered contexts for
updates before the SSE connection was established - patches sent to
patchChan could be dropped.

Now always call Sync() when SSE connects, ensuring clients receive the
full current state regardless of what happened before the connection
was established.

Fixes #2
2026-01-14 19:02:44 -10:00

561 lines
15 KiB
Go

// Package via provides a reactive, real-time engine for creating Go web
// applications. 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"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"github.com/alexedwards/scs/v2"
"github.com/ryanhamamura/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)
sessionManager *scs.SessionManager
datastarPath string
datastarContent []byte
datastarOnce sync.Once
}
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
}
if cfg.SessionManager != nil {
v.sessionManager = cfg.SessionManager
}
if cfg.DatastarContent != nil {
v.datastarContent = cfg.DatastarContent
}
if cfg.DatastarPath != "" {
v.datastarPath = cfg.DatastarPath
}
}
// 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)) {
v.ensureDatastarHandler()
// 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)
c.reqCtx = r.Context()
routeParams := extractParams(route, r.URL.Path)
c.injectRouteParams(routeParams)
initContextFn(c)
v.registerCtx(c)
if v.cfg.DevMode {
v.devModePersist(c)
}
headElements := []h.H{h.Script(h.Type("module"), h.Src(v.datastarPath))}
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)
}
// Start starts the Via HTTP server on the given address.
func (v *V) Start() {
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
handler := http.Handler(v.mux)
if v.sessionManager != nil {
handler = v.sessionManager.LoadAndSave(v.mux)
}
log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, handler))
}
// HTTPServeMux returns the underlying HTTP request multiplexer to enable user extentions, middleware and
// plugins. It also enables integration with test frameworks like gost-dom/browser for SSE/Datastar testing.
//
// IMPORTANT. The returned *http.ServeMux can only be modified during initialization, before calling via.Start().
// Concurrent handler registration is not safe.
func (v *V) HTTPServeMux() *http.ServeMux {
return v.mux
}
func (v *V) ensureDatastarHandler() {
v.datastarOnce.Do(func() {
v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
_, _ = w.Write(v.datastarContent)
})
})
}
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
patchTypeRedirect
patchTypeReplaceURL
)
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)),
sessionManager: scs.New(),
datastarPath: "/_datastar.js",
datastarContent: datastarJS,
cfg: Options{
DevMode: false,
ServerAddress: ":3000",
LogLvl: LogLevelInfo,
DocumentTitle: "⚡ Via",
},
}
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
}
c.reqCtx = r.Context()
sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5))))
// use last-event-id to tell if request is a sse reconnect
sse.Send(datastar.EventTypePatchElements, []string{}, datastar.WithSSEEventId("via"))
v.logDebug(c, "SSE connection established")
go func() {
c.Sync()
}()
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)
}
}
case patchTypeSignals:
if err := sse.PatchSignals([]byte(patch.content)); err != nil {
if sse.Context().Err() == nil {
v.logErr(c, "PatchSignals failed: %v", err)
}
}
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)
}
}
case patchTypeRedirect:
if err := sse.Redirect(patch.content); err != nil {
if sse.Context().Err() == nil {
v.logErr(c, "Redirect failed: %v", err)
}
}
case patchTypeReplaceURL:
parsedURL, err := url.Parse(patch.content)
if err != nil {
v.logErr(c, "ReplaceURL failed to parse URL: %v", err)
} else if err := sse.ReplaceURL(*parsedURL); err != nil {
if sse.Context().Err() == nil {
v.logErr(c, "ReplaceURL failed: %v", err)
}
}
}
}
}
})
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
}
c.reqCtx = r.Context()
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
}