Some checks failed
CI / Build and Test (push) Has been cancelled
Streams listed in Options.Streams are created by Start() when the embedded NATS server initializes, replacing manual EnsureStream calls during setup. Migrates nats-chatroom and pubsub-crud examples.
913 lines
24 KiB
Go
913 lines
24 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 (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
_ "embed"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
ossignal "os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/alexedwards/scs/v2"
|
|
"github.com/rs/zerolog"
|
|
"github.com/ryanhamamura/via/h"
|
|
"github.com/starfederation/datastar-go/datastar"
|
|
)
|
|
|
|
//go:embed datastar.js
|
|
var datastarJS []byte
|
|
|
|
//go:embed navigate.js
|
|
var navigateJS []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
|
|
server *http.Server
|
|
logger zerolog.Logger
|
|
contextRegistry map[string]*Context
|
|
contextRegistryMutex sync.RWMutex
|
|
documentHeadIncludes []h.H
|
|
documentFootIncludes []h.H
|
|
devModePageInitFnMap map[string]func(*Context)
|
|
pageRegistry map[string]func(*Context)
|
|
sessionManager *scs.SessionManager
|
|
pubsub PubSub
|
|
defaultNATS *defaultNATS
|
|
actionRateLimit RateLimitConfig
|
|
datastarPath string
|
|
datastarContent []byte
|
|
datastarOnce sync.Once
|
|
reaperStop chan struct{}
|
|
middleware []Middleware
|
|
layout func(func() h.H) h.H
|
|
}
|
|
|
|
func (v *V) logEvent(evt *zerolog.Event, c *Context) *zerolog.Event {
|
|
if c != nil && c.id != "" {
|
|
evt = evt.Str("via-ctx", c.id)
|
|
}
|
|
return evt
|
|
}
|
|
|
|
func (v *V) logFatal(format string, a ...any) {
|
|
v.logEvent(v.logger.WithLevel(zerolog.FatalLevel), nil).Msgf(format, a...)
|
|
}
|
|
|
|
func (v *V) logErr(c *Context, format string, a ...any) {
|
|
v.logEvent(v.logger.Error(), c).Msgf(format, a...)
|
|
}
|
|
|
|
func (v *V) logWarn(c *Context, format string, a ...any) {
|
|
v.logEvent(v.logger.Warn(), c).Msgf(format, a...)
|
|
}
|
|
|
|
func (v *V) logInfo(c *Context, format string, a ...any) {
|
|
v.logEvent(v.logger.Info(), c).Msgf(format, a...)
|
|
}
|
|
|
|
func (v *V) logDebug(c *Context, format string, a ...any) {
|
|
v.logEvent(v.logger.Debug(), c).Msgf(format, a...)
|
|
}
|
|
|
|
func newConsoleLogger(level zerolog.Level) zerolog.Logger {
|
|
return zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}).
|
|
With().Timestamp().Logger().Level(level)
|
|
}
|
|
|
|
// Config overrides the default configuration with the given options.
|
|
func (v *V) Config(cfg Options) {
|
|
if cfg.Logger != nil {
|
|
v.logger = *cfg.Logger
|
|
} else if cfg.LogLevel != nil || cfg.DevMode != v.cfg.DevMode {
|
|
level := zerolog.InfoLevel
|
|
if cfg.LogLevel != nil {
|
|
level = *cfg.LogLevel
|
|
}
|
|
if cfg.DevMode {
|
|
v.logger = newConsoleLogger(level)
|
|
} else {
|
|
v.logger = zerolog.New(os.Stderr).With().Timestamp().Logger().Level(level)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
if cfg.PubSub != nil {
|
|
v.defaultNATS = nil
|
|
v.pubsub = cfg.PubSub
|
|
}
|
|
if cfg.ContextSuspendAfter != 0 {
|
|
v.cfg.ContextSuspendAfter = cfg.ContextSuspendAfter
|
|
}
|
|
if cfg.ContextTTL != 0 {
|
|
v.cfg.ContextTTL = cfg.ContextTTL
|
|
}
|
|
if cfg.Streams != nil {
|
|
v.cfg.Streams = cfg.Streams
|
|
}
|
|
if cfg.ActionRateLimit.Rate != 0 || cfg.ActionRateLimit.Burst != 0 {
|
|
v.actionRateLimit = cfg.ActionRateLimit
|
|
}
|
|
}
|
|
|
|
// 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)) {
|
|
wrapped := chainMiddleware(v.middleware, initContextFn)
|
|
v.page(route, initContextFn, wrapped)
|
|
}
|
|
|
|
// page registers a route with separate raw and wrapped init functions.
|
|
// raw is used for the panic-check at registration time; wrapped includes
|
|
// any middleware and is used as the live handler.
|
|
func (v *V) page(route string, raw, wrapped func(*Context)) {
|
|
v.ensureDatastarHandler()
|
|
// check for panics using the raw handler (no middleware)
|
|
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)
|
|
raw(c)
|
|
c.view()
|
|
c.stopAllRoutines()
|
|
}()
|
|
|
|
v.pageRegistry[route] = wrapped
|
|
if v.cfg.DevMode {
|
|
v.devModePageInitFnMap[route] = wrapped
|
|
}
|
|
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)
|
|
wrapped(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','via-csrf':'%s'}", id, c.csrfToken))),
|
|
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))),
|
|
h.Meta(h.Attr("name", "view-transition"), h.Attr("content", "same-origin")),
|
|
h.Script(h.Raw(string(navigateJS))),
|
|
)
|
|
|
|
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,
|
|
})
|
|
_ = view.Render(w)
|
|
}))
|
|
}
|
|
|
|
func (v *V) registerCtx(c *Context) {
|
|
v.contextRegistryMutex.Lock()
|
|
defer v.contextRegistryMutex.Unlock()
|
|
v.contextRegistry[c.id] = c
|
|
v.logDebug(c, "new context added to registry")
|
|
v.logDebug(nil, "number of sessions in registry: %d", len(v.contextRegistry))
|
|
}
|
|
|
|
func (v *V) cleanupCtx(c *Context) {
|
|
c.dispose()
|
|
if v.cfg.DevMode {
|
|
v.devModeRemovePersisted(c)
|
|
}
|
|
v.unregisterCtx(c)
|
|
}
|
|
|
|
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", len(v.contextRegistry))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (v *V) startReaper() {
|
|
ttl := v.cfg.ContextTTL
|
|
if ttl < 0 {
|
|
return
|
|
}
|
|
if ttl == 0 {
|
|
ttl = time.Hour
|
|
}
|
|
suspendAfter := v.cfg.ContextSuspendAfter
|
|
if suspendAfter == 0 {
|
|
suspendAfter = 15 * time.Minute
|
|
}
|
|
if suspendAfter > ttl {
|
|
suspendAfter = ttl
|
|
}
|
|
interval := suspendAfter / 3
|
|
if interval < 5*time.Second {
|
|
interval = 5 * time.Second
|
|
}
|
|
v.reaperStop = make(chan struct{})
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-v.reaperStop:
|
|
return
|
|
case <-ticker.C:
|
|
v.reapOrphanedContexts(suspendAfter, ttl)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (v *V) reapOrphanedContexts(suspendAfter, ttl time.Duration) {
|
|
now := time.Now()
|
|
v.contextRegistryMutex.RLock()
|
|
var toSuspend, toReap []*Context
|
|
for _, c := range v.contextRegistry {
|
|
if c.sseConnected.Load() {
|
|
continue
|
|
}
|
|
// Use the most recent liveness signal
|
|
lastAlive := c.createdAt
|
|
if dc := c.sseDisconnectedAt.Load(); dc != nil && dc.After(lastAlive) {
|
|
lastAlive = *dc
|
|
}
|
|
if seen := c.lastSeenAt.Load(); seen != nil && seen.After(lastAlive) {
|
|
lastAlive = *seen
|
|
}
|
|
silentFor := now.Sub(lastAlive)
|
|
if silentFor > ttl {
|
|
toReap = append(toReap, c)
|
|
} else if silentFor > suspendAfter && !c.suspended.Load() {
|
|
toSuspend = append(toSuspend, c)
|
|
}
|
|
}
|
|
v.contextRegistryMutex.RUnlock()
|
|
|
|
for _, c := range toSuspend {
|
|
v.logInfo(c, "suspending context (no SSE connection after %s)", suspendAfter)
|
|
c.suspend()
|
|
}
|
|
for _, c := range toReap {
|
|
v.logInfo(c, "reaping orphaned context (no SSE connection after %s)", ttl)
|
|
v.cleanupCtx(c)
|
|
}
|
|
}
|
|
|
|
// Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM
|
|
// signal is received, then performs a graceful shutdown.
|
|
func (v *V) Start() {
|
|
if v.pubsub == nil {
|
|
dn, err := getSharedNATS()
|
|
if err != nil {
|
|
v.logWarn(nil, "embedded NATS unavailable: %v", err)
|
|
} else {
|
|
v.defaultNATS = dn
|
|
v.pubsub = &natsRef{dn: dn}
|
|
}
|
|
}
|
|
|
|
for _, sc := range v.cfg.Streams {
|
|
if err := EnsureStream(v, sc); err != nil {
|
|
v.logger.Fatal().Err(err).Msgf("failed to create stream %q", sc.Name)
|
|
}
|
|
}
|
|
|
|
handler := http.Handler(v.mux)
|
|
if v.sessionManager != nil {
|
|
handler = v.sessionManager.LoadAndSave(v.mux)
|
|
}
|
|
v.server = &http.Server{
|
|
Addr: v.cfg.ServerAddress,
|
|
Handler: handler,
|
|
}
|
|
|
|
v.startReaper()
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- v.server.ListenAndServe()
|
|
}()
|
|
|
|
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
|
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
ossignal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
select {
|
|
case sig := <-sigCh:
|
|
v.logInfo(nil, "received signal %v, shutting down", sig)
|
|
case err := <-errCh:
|
|
if err != nil && err != http.ErrServerClosed {
|
|
v.logger.Fatal().Err(err).Msg("http server failed")
|
|
}
|
|
return
|
|
}
|
|
|
|
v.Shutdown()
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the server and all contexts.
|
|
// Safe for programmatic or test use.
|
|
func (v *V) Shutdown() {
|
|
if v.reaperStop != nil {
|
|
close(v.reaperStop)
|
|
}
|
|
v.logInfo(nil, "draining all contexts")
|
|
v.drainAllContexts()
|
|
|
|
if v.server != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := v.server.Shutdown(ctx); err != nil {
|
|
v.logErr(nil, "http server shutdown error: %v", err)
|
|
}
|
|
}
|
|
|
|
if v.pubsub != nil {
|
|
if err := v.pubsub.Close(); err != nil {
|
|
v.logErr(nil, "pubsub close error: %v", err)
|
|
}
|
|
}
|
|
v.defaultNATS = nil
|
|
|
|
v.logInfo(nil, "shutdown complete")
|
|
}
|
|
|
|
func (v *V) drainAllContexts() {
|
|
v.contextRegistryMutex.Lock()
|
|
contexts := make([]*Context, 0, len(v.contextRegistry))
|
|
for _, c := range v.contextRegistry {
|
|
contexts = append(contexts, c)
|
|
}
|
|
v.contextRegistry = make(map[string]*Context)
|
|
v.contextRegistryMutex.Unlock()
|
|
|
|
for _, c := range contexts {
|
|
v.logDebug(c, "disposing context")
|
|
c.dispose()
|
|
}
|
|
v.logInfo(nil, "drained %d context(s)", len(contexts))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// PubSub returns the configured PubSub backend, or nil if none is set.
|
|
func (v *V) PubSub() PubSub {
|
|
return v.pubsub
|
|
}
|
|
|
|
// Static serves files from a filesystem directory at the given URL prefix.
|
|
//
|
|
// Example:
|
|
//
|
|
// v.Static("/assets/", "./public")
|
|
func (v *V) Static(urlPrefix, dir string) {
|
|
if !strings.HasSuffix(urlPrefix, "/") {
|
|
urlPrefix += "/"
|
|
}
|
|
fileServer := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir)))
|
|
v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer))
|
|
}
|
|
|
|
// StaticFS serves files from an [fs.FS] at the given URL prefix.
|
|
// This is useful with //go:embed filesystems.
|
|
//
|
|
// Example:
|
|
//
|
|
// //go:embed static
|
|
// var staticFiles embed.FS
|
|
// v.StaticFS("/assets/", staticFiles)
|
|
func (v *V) StaticFS(urlPrefix string, fsys fs.FS) {
|
|
if !strings.HasSuffix(urlPrefix, "/") {
|
|
urlPrefix += "/"
|
|
}
|
|
fileServer := http.StripPrefix(urlPrefix, http.FileServerFS(fsys))
|
|
v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer))
|
|
}
|
|
|
|
// noDirListing wraps a file server handler to return 404 for directory requests.
|
|
func noDirListing(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasSuffix(r.URL.Path, "/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
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 {
|
|
v.logFatal("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()
|
|
|
|
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
|
|
patchTypeElementsWithVT
|
|
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,
|
|
logger: newConsoleLogger(zerolog.InfoLevel),
|
|
contextRegistry: make(map[string]*Context),
|
|
devModePageInitFnMap: make(map[string]func(*Context)),
|
|
pageRegistry: make(map[string]func(*Context)),
|
|
sessionManager: scs.New(),
|
|
datastarPath: "/_datastar.js",
|
|
datastarContent: datastarJS,
|
|
cfg: Options{
|
|
DevMode: false,
|
|
ServerAddress: ":3000",
|
|
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.logInfo(nil, "context expired, reloading client: %s", cID)
|
|
sse := datastar.NewSSE(w, r)
|
|
sse.ExecuteScript("window.location.reload()")
|
|
return
|
|
}
|
|
c.reqCtx = r.Context()
|
|
now := time.Now()
|
|
c.lastSeenAt.Store(&now)
|
|
|
|
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"))
|
|
|
|
// Drain stale patches on reconnect so the client gets fresh state
|
|
if c.sseDisconnectedAt.Load() != nil {
|
|
for {
|
|
select {
|
|
case <-c.patchChan:
|
|
default:
|
|
goto drained
|
|
}
|
|
}
|
|
drained:
|
|
}
|
|
c.sseConnected.Store(true)
|
|
c.sseDisconnectedAt.Store(nil)
|
|
v.logDebug(c, "SSE connection established")
|
|
|
|
if c.suspended.Load() {
|
|
c.navMu.Lock()
|
|
c.suspended.Store(false)
|
|
if initFn := v.pageRegistry[c.route]; initFn != nil {
|
|
v.logInfo(c, "resuming suspended context")
|
|
initFn(c)
|
|
}
|
|
c.navMu.Unlock()
|
|
}
|
|
|
|
go c.Sync()
|
|
|
|
keepalive := time.NewTicker(30 * time.Second)
|
|
defer keepalive.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-sse.Context().Done():
|
|
v.logDebug(c, "SSE connection ended")
|
|
c.sseConnected.Store(false)
|
|
dcNow := time.Now()
|
|
c.sseDisconnectedAt.Store(&dcNow)
|
|
return
|
|
case <-c.ctxDisposedChan:
|
|
v.logDebug(c, "context disposed, closing SSE")
|
|
return
|
|
case <-keepalive.C:
|
|
sse.PatchSignals([]byte("{}"))
|
|
case patch := <-c.patchChan:
|
|
switch patch.typ {
|
|
case patchTypeElements:
|
|
if err := sse.PatchElements(patch.content); err != nil {
|
|
if sse.Context().Err() == nil {
|
|
v.logErr(c, "PatchElements failed: %v", err)
|
|
}
|
|
}
|
|
case patchTypeElementsWithVT:
|
|
if err := sse.PatchElements(patch.content, datastar.WithViewTransitions()); err != nil {
|
|
if sse.Context().Err() == nil {
|
|
v.logErr(c, "PatchElements (view transition) 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
|
|
}
|
|
csrfToken, _ := sigs["via-csrf"].(string)
|
|
if subtle.ConstantTimeCompare([]byte(csrfToken), []byte(c.csrfToken)) != 1 {
|
|
v.logWarn(c, "action '%s' rejected: invalid CSRF token", actionID)
|
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
if c.actionLimiter != nil && !c.actionLimiter.Allow() {
|
|
v.logWarn(c, "action '%s' rate limited", actionID)
|
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
c.reqCtx = r.Context()
|
|
entry, err := c.getAction(actionID)
|
|
if err != nil {
|
|
v.logDebug(c, "action '%s' failed: %v", actionID, err)
|
|
return
|
|
}
|
|
if entry.limiter != nil && !entry.limiter.Allow() {
|
|
v.logWarn(c, "action '%s' rate limited (per-action)", actionID)
|
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
// log err if action panics
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
v.logErr(c, "action '%s' failed: %v", actionID, r)
|
|
}
|
|
}()
|
|
|
|
c.injectSignals(sigs)
|
|
if len(entry.middleware) > 0 {
|
|
chainMiddleware(entry.middleware, func(_ *Context) { entry.fn() })(c)
|
|
} else {
|
|
entry.fn()
|
|
}
|
|
})
|
|
|
|
v.mux.HandleFunc("POST /_navigate", func(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
cID := r.FormValue("via-ctx")
|
|
csrfToken := r.FormValue("via-csrf")
|
|
navURL := r.FormValue("url")
|
|
popstate := r.FormValue("popstate") == "1"
|
|
|
|
if cID == "" || navURL == "" || !strings.HasPrefix(navURL, "/") {
|
|
http.Error(w, "missing or invalid parameters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
c, err := v.getCtx(cID)
|
|
if err != nil {
|
|
v.logErr(nil, "navigate failed: %v", err)
|
|
http.Error(w, "context not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(csrfToken), []byte(c.csrfToken)) != 1 {
|
|
v.logWarn(c, "navigate rejected: invalid CSRF token")
|
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
if c.actionLimiter != nil && !c.actionLimiter.Allow() {
|
|
v.logWarn(c, "navigate rate limited")
|
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
c.reqCtx = r.Context()
|
|
v.logDebug(c, "SPA navigate to %s", navURL)
|
|
c.Navigate(navURL, popstate)
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
v.mux.HandleFunc("POST /_session/close", func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
v.logErr(nil, "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
|
|
}
|
|
v.logDebug(c, "session close event triggered")
|
|
v.cleanupCtx(c)
|
|
})
|
|
|
|
return v
|
|
}
|
|
|
|
func genRandID() string {
|
|
b := make([]byte, 4)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func genCSRFToken() string {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
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] {
|
|
return nil
|
|
}
|
|
}
|
|
return params
|
|
}
|
|
|
|
// matchRoute finds the registered page init function and extracted params for the given path.
|
|
func (v *V) matchRoute(path string) (route string, initFn func(*Context), params map[string]string) {
|
|
for pattern, fn := range v.pageRegistry {
|
|
if p := extractParams(pattern, path); p != nil {
|
|
return pattern, fn, p
|
|
}
|
|
}
|
|
return "", nil, nil
|
|
}
|
|
|
|
// Layout sets a layout function that wraps every page's view.
|
|
// The layout receives the page content as a function and returns the full view.
|
|
func (v *V) Layout(f func(func() h.H) h.H) {
|
|
v.layout = f
|
|
}
|