feat: add devmode flag; introduce live reload support; update examples
This commit is contained in:
165
via.go
165
via.go
@@ -9,9 +9,13 @@ import (
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -25,12 +29,14 @@ 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
|
||||
cfg Options
|
||||
mux *http.ServeMux
|
||||
contextRegistry map[string]*Context
|
||||
contextRegistryMutex sync.RWMutex
|
||||
documentHeadIncludes []h.H
|
||||
documentFootIncludes []h.H
|
||||
devModePageInitFnMap map[string]func(*Context)
|
||||
devModePageInitFnMapMutex sync.Mutex
|
||||
}
|
||||
|
||||
func (v *V) logErr(c *Context, format string, a ...any) {
|
||||
@@ -46,7 +52,7 @@ func (v *V) logWarn(c *Context, format string, a ...any) {
|
||||
if c != nil && c.id != "" {
|
||||
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
|
||||
}
|
||||
if v.cfg.LogLvl <= LogLevelWarn {
|
||||
if v.cfg.LogLvl >= LogLevelWarn {
|
||||
log.Printf("[warn] %smsg=%q", cRef, fmt.Sprintf(format, a...))
|
||||
}
|
||||
}
|
||||
@@ -56,7 +62,7 @@ func (v *V) logInfo(c *Context, format string, a ...any) {
|
||||
if c != nil && c.id != "" {
|
||||
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
|
||||
}
|
||||
if v.cfg.LogLvl <= LogLevelInfo {
|
||||
if v.cfg.LogLvl >= LogLevelInfo {
|
||||
log.Printf("[info] %smsg=%q", cRef, fmt.Sprintf(format, a...))
|
||||
}
|
||||
}
|
||||
@@ -86,6 +92,12 @@ func (v *V) Config(cfg Options) {
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -106,7 +118,6 @@ func (v *V) AppendToFoot(elements ...h.H) {
|
||||
v.documentFootIncludes = append(v.documentFootIncludes, el)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Page registers a route and its associated page handler.
|
||||
@@ -119,7 +130,12 @@ func (v *V) AppendToFoot(elements ...h.H) {
|
||||
// return h.H1(h.Text("Hello, Via!"))
|
||||
// })
|
||||
// })
|
||||
func (v *V) Page(route string, composeContext func(c *Context)) {
|
||||
func (v *V) Page(route string, initContextFn func(c *Context)) {
|
||||
if v.cfg.DevMode {
|
||||
v.devModePageInitFnMapMutex.Lock()
|
||||
defer v.devModePageInitFnMapMutex.Unlock()
|
||||
v.devModePageInitFnMap[route] = initContextFn
|
||||
}
|
||||
v.mux.HandleFunc("GET "+route, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "favicon") {
|
||||
return
|
||||
@@ -127,7 +143,7 @@ func (v *V) Page(route string, composeContext func(c *Context)) {
|
||||
id := fmt.Sprintf("%s_/%s", route, genRandID())
|
||||
c := newContext(id, v)
|
||||
v.logDebug(c, "GET %s", route)
|
||||
composeContext(c)
|
||||
initContextFn(c)
|
||||
v.registerCtx(c.id, c)
|
||||
headElements := v.documentHeadIncludes
|
||||
headElements = append(headElements, h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))))
|
||||
@@ -146,6 +162,10 @@ func (v *V) Page(route string, composeContext func(c *Context)) {
|
||||
func (v *V) registerCtx(id string, 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[id] = c
|
||||
v.logDebug(c, "new context added to registry")
|
||||
}
|
||||
@@ -174,83 +194,158 @@ func (v *V) HandleFunc(pattern string, f http.HandlerFunc) {
|
||||
}
|
||||
|
||||
// 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))
|
||||
func (v *V) Start() {
|
||||
v.logInfo(nil, "via started on address: %s", v.cfg.ServerAddress)
|
||||
log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.mux))
|
||||
}
|
||||
|
||||
func (v *V) persistCtx(c *Context) error {
|
||||
idsplit := strings.Split(c.id, "_")
|
||||
if len(idsplit) < 2 {
|
||||
return fmt.Errorf("failed to identify ctx page route")
|
||||
}
|
||||
route := idsplit[0]
|
||||
ctxmap := map[string]any{"id": c.id, "route": route}
|
||||
|
||||
p := path.Join(".via", "devmode", "ctx.json")
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory for devmode files: %v", err)
|
||||
}
|
||||
|
||||
file, err := os.Create(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file in devmode directory: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
if err := encoder.Encode(ctxmap); err != nil {
|
||||
return fmt.Errorf("failed to encode ctx: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *V) restoreCtx() *Context {
|
||||
p := path.Join(".via", "devmode", "ctx.json")
|
||||
file, err := os.Open(p)
|
||||
if err != nil {
|
||||
fmt.Println("Error opening file:", err)
|
||||
return nil
|
||||
}
|
||||
defer file.Close()
|
||||
var ctxmap map[string]any
|
||||
if err := json.NewDecoder(file).Decode(&ctxmap); err != nil {
|
||||
fmt.Println("Error restoring ctx:", err)
|
||||
return nil
|
||||
}
|
||||
ctxId, ok := ctxmap["id"].(string)
|
||||
if !ok {
|
||||
fmt.Println("Error restoring ctx")
|
||||
return nil
|
||||
}
|
||||
pageRoute, ok := ctxmap["route"].(string)
|
||||
if !ok {
|
||||
fmt.Println("Error restoring ctx")
|
||||
return nil
|
||||
}
|
||||
pageInitFn, ok := v.devModePageInitFnMap[pageRoute]
|
||||
if !ok {
|
||||
fmt.Println("devmode failed to restore ctx: ")
|
||||
return nil
|
||||
}
|
||||
|
||||
c := newContext(ctxId, v)
|
||||
pageInitFn(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// New creates a new Via application with default configuration.
|
||||
func New() *V {
|
||||
mux := http.NewServeMux()
|
||||
app := &V{
|
||||
mux: mux,
|
||||
contextRegistry: make(map[string]*Context),
|
||||
v := &V{
|
||||
mux: mux,
|
||||
contextRegistry: make(map[string]*Context),
|
||||
devModePageInitFnMap: make(map[string]func(*Context)),
|
||||
cfg: Options{
|
||||
LogLvl: LogLevelDebug,
|
||||
DocumentTitle: "Via Application",
|
||||
DevMode: false,
|
||||
ServerAddress: ":3000",
|
||||
LogLvl: LogLevelInfo,
|
||||
DocumentTitle: "⚡ Via",
|
||||
},
|
||||
}
|
||||
|
||||
app.mux.HandleFunc("GET /_datastar.js", func(w http.ResponseWriter, r *http.Request) {
|
||||
v.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) {
|
||||
v.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) {
|
||||
var sigs map[string]any
|
||||
_ = datastar.ReadSignals(r, &sigs)
|
||||
if v.cfg.DevMode && len(v.contextRegistry) == 0 {
|
||||
restoredC := v.restoreCtx()
|
||||
if restoredC != nil {
|
||||
restoredC.injectSignals(sigs)
|
||||
v.registerCtx(restoredC.id, restoredC)
|
||||
}
|
||||
}
|
||||
cID, _ := sigs["via-ctx"].(string)
|
||||
c, err := app.getCtx(cID)
|
||||
c, err := v.getCtx(cID)
|
||||
if err != nil {
|
||||
app.logErr(nil, "failed to render page: %v", err)
|
||||
v.logErr(nil, "failed to render page: %v", err)
|
||||
return
|
||||
}
|
||||
c.sse = datastar.NewSSE(w, r)
|
||||
app.logDebug(c, "SSE connection established")
|
||||
c.SyncSignals()
|
||||
v.logDebug(c, "SSE connection established")
|
||||
if v.cfg.DevMode {
|
||||
c.Sync()
|
||||
v.persistCtx(c)
|
||||
} else {
|
||||
c.SyncSignals()
|
||||
}
|
||||
<-c.sse.Context().Done()
|
||||
c.sse = nil
|
||||
app.logDebug(c, "SSE connection closed")
|
||||
v.logDebug(c, "SSE connection closed")
|
||||
})
|
||||
app.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")
|
||||
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 {
|
||||
for _, c := range v.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)
|
||||
v.logDebug(nil, "active_ctx_count=%d inactive_ctx_count=%d", active_ctx_count, inactive_ctx_count)
|
||||
c, err := v.getCtx(cID)
|
||||
if err != nil {
|
||||
app.logErr(nil, "action '%s' failed: %v", actionID, err)
|
||||
v.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)
|
||||
v.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)
|
||||
v.logErr(c, "action '%s' failed: %v", actionID, r)
|
||||
}
|
||||
}()
|
||||
c.signalsMux.Lock()
|
||||
defer c.signalsMux.Unlock()
|
||||
app.logDebug(c, "signals=%v", sigs)
|
||||
v.logDebug(c, "signals=%v", sigs)
|
||||
c.injectSignals(sigs)
|
||||
actionFn()
|
||||
|
||||
})
|
||||
return app
|
||||
return v
|
||||
}
|
||||
|
||||
func genRandID() string {
|
||||
|
||||
Reference in New Issue
Block a user