feat: add cookie-based session support using alexedwards/scs (#1)

- Add Session wrapper with typed getters (GetString, GetInt, GetBool, etc.)
- Add flash message support via Pop methods (PopString, PopInt, etc.)
- Add session utilities: Exists, Keys, ID, Clear, Destroy, RenewToken
- Create default session manager in New() for zero-config usage
- Allow custom session manager via Options.SessionManager
- Wrap mux with scs LoadAndSave middleware in Start()
- Add session example demonstrating login/logout with flash messages
This commit is contained in:
ryanhamamura
2026-01-09 06:59:26 -10:00
committed by GitHub
parent 43495ccada
commit 9a23188973
7 changed files with 284 additions and 1 deletions

View File

@@ -1,5 +1,7 @@
package via package via
import "github.com/alexedwards/scs/v2"
type LogLevel int type LogLevel int
const ( const (
@@ -30,4 +32,9 @@ type Options struct {
// Plugins to extend the capabilities of the `Via` application. // Plugins to extend the capabilities of the `Via` application.
Plugins []Plugin Plugins []Plugin
// SessionManager enables cookie-based sessions. If set, Via wraps handlers
// with scs LoadAndSave middleware. Configure the session manager before
// passing it (lifetime, cookie settings, store, etc).
SessionManager *scs.SessionManager
} }

View File

@@ -2,6 +2,7 @@ package via
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -29,6 +30,7 @@ type Context struct {
signals *sync.Map signals *sync.Map
mu sync.RWMutex mu sync.RWMutex
ctxDisposedChan chan struct{} ctxDisposedChan chan struct{}
reqCtx context.Context
} }
// View defines the UI rendered by this context. // View defines the UI rendered by this context.
@@ -360,6 +362,16 @@ func (c *Context) GetPathParam(param string) string {
return "" return ""
} }
// Session returns the session for this context.
// Session data persists across page views for the same browser.
// Returns a no-op session if no SessionManager is configured.
func (c *Context) Session() *Session {
return &Session{
ctx: c.reqCtx,
manager: c.app.sessionManager,
}
}
func newContext(id string, route string, v *V) *Context { func newContext(id string, route string, v *V) *Context {
if v == nil { if v == nil {
log.Fatal("create context failed: app pointer is nil") log.Fatal("create context failed: app pointer is nil")

1
go.mod
View File

@@ -13,6 +13,7 @@ require (
require ( require (
github.com/CAFxX/httpcompression v0.0.9 // indirect github.com/CAFxX/httpcompression v0.0.9 // indirect
github.com/alexedwards/scs/v2 v2.9.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect

2
go.sum
View File

@@ -2,6 +2,8 @@ github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWG
github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM= github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=

View File

@@ -0,0 +1,57 @@
package main
import (
"github.com/go-via/via"
"github.com/go-via/via/h"
)
func main() {
v := via.New()
v.Page("/", func(c *via.Context) {
username := c.Session().GetString("username")
flash := c.Session().PopString("flash")
usernameInput := c.Signal("")
login := c.Action(func() {
name := usernameInput.String()
if name != "" {
c.Session().Set("username", name)
c.Session().Set("flash", "Welcome, "+name+"!")
c.Session().RenewToken()
}
c.Sync()
})
logout := c.Action(func() {
c.Session().Set("flash", "Goodbye!")
c.Session().Delete("username")
c.Sync()
})
c.View(func() h.H {
var flashMsg h.H
if flash != "" {
flashMsg = h.P(h.Text(flash), h.Style("color: green"))
}
if username == "" {
return h.Div(
flashMsg,
h.H1(h.Text("Login")),
h.Input(h.Type("text"), h.Placeholder("Username"), usernameInput.Bind()),
h.Button(h.Text("Login"), login.OnClick()),
)
}
return h.Div(
flashMsg,
h.H1(h.Textf("Hello, %s!", username)),
h.P(h.Text("Your session persists across page refreshes.")),
h.Button(h.Text("Logout"), logout.OnClick()),
)
})
})
v.Start()
}

191
session.go Normal file
View File

@@ -0,0 +1,191 @@
package via
import (
"context"
"time"
"github.com/alexedwards/scs/v2"
)
// Session provides access to the user's session data.
// Session data persists across page views for the same browser.
type Session struct {
ctx context.Context
manager *scs.SessionManager
}
// Get retrieves a value from the session.
func (s *Session) Get(key string) any {
if s.manager == nil || s.ctx == nil {
return nil
}
return s.manager.Get(s.ctx, key)
}
// GetString retrieves a string value from the session.
func (s *Session) GetString(key string) string {
if s.manager == nil || s.ctx == nil {
return ""
}
return s.manager.GetString(s.ctx, key)
}
// GetInt retrieves an int value from the session.
func (s *Session) GetInt(key string) int {
if s.manager == nil || s.ctx == nil {
return 0
}
return s.manager.GetInt(s.ctx, key)
}
// GetBool retrieves a bool value from the session.
func (s *Session) GetBool(key string) bool {
if s.manager == nil || s.ctx == nil {
return false
}
return s.manager.GetBool(s.ctx, key)
}
// Set stores a value in the session.
func (s *Session) Set(key string, val any) {
if s.manager == nil || s.ctx == nil {
return
}
s.manager.Put(s.ctx, key, val)
}
// Delete removes a value from the session.
func (s *Session) Delete(key string) {
if s.manager == nil || s.ctx == nil {
return
}
s.manager.Remove(s.ctx, key)
}
// Clear removes all data from the session.
func (s *Session) Clear() error {
if s.manager == nil || s.ctx == nil {
return nil
}
return s.manager.Clear(s.ctx)
}
// Destroy destroys the session entirely (use for logout).
func (s *Session) Destroy() error {
if s.manager == nil || s.ctx == nil {
return nil
}
return s.manager.Destroy(s.ctx)
}
// RenewToken regenerates the session token (use after login to prevent session fixation).
func (s *Session) RenewToken() error {
if s.manager == nil || s.ctx == nil {
return nil
}
return s.manager.RenewToken(s.ctx)
}
// Exists returns true if the key exists in the session.
func (s *Session) Exists(key string) bool {
if s.manager == nil || s.ctx == nil {
return false
}
return s.manager.Exists(s.ctx, key)
}
// Keys returns all keys in the session.
func (s *Session) Keys() []string {
if s.manager == nil || s.ctx == nil {
return nil
}
return s.manager.Keys(s.ctx)
}
// ID returns the session token (cookie value).
func (s *Session) ID() string {
if s.manager == nil || s.ctx == nil {
return ""
}
return s.manager.Token(s.ctx)
}
// Pop retrieves a value and deletes it from the session (flash message pattern).
func (s *Session) Pop(key string) any {
if s.manager == nil || s.ctx == nil {
return nil
}
return s.manager.Pop(s.ctx, key)
}
// PopString retrieves a string value and deletes it from the session.
func (s *Session) PopString(key string) string {
if s.manager == nil || s.ctx == nil {
return ""
}
return s.manager.PopString(s.ctx, key)
}
// PopInt retrieves an int value and deletes it from the session.
func (s *Session) PopInt(key string) int {
if s.manager == nil || s.ctx == nil {
return 0
}
return s.manager.PopInt(s.ctx, key)
}
// PopBool retrieves a bool value and deletes it from the session.
func (s *Session) PopBool(key string) bool {
if s.manager == nil || s.ctx == nil {
return false
}
return s.manager.PopBool(s.ctx, key)
}
// GetFloat64 retrieves a float64 value from the session.
func (s *Session) GetFloat64(key string) float64 {
if s.manager == nil || s.ctx == nil {
return 0
}
return s.manager.GetFloat(s.ctx, key)
}
// PopFloat64 retrieves a float64 value and deletes it from the session.
func (s *Session) PopFloat64(key string) float64 {
if s.manager == nil || s.ctx == nil {
return 0
}
return s.manager.PopFloat(s.ctx, key)
}
// GetTime retrieves a time.Time value from the session.
func (s *Session) GetTime(key string) time.Time {
if s.manager == nil || s.ctx == nil {
return time.Time{}
}
return s.manager.GetTime(s.ctx, key)
}
// PopTime retrieves a time.Time value and deletes it from the session.
func (s *Session) PopTime(key string) time.Time {
if s.manager == nil || s.ctx == nil {
return time.Time{}
}
return s.manager.PopTime(s.ctx, key)
}
// GetBytes retrieves a []byte value from the session.
func (s *Session) GetBytes(key string) []byte {
if s.manager == nil || s.ctx == nil {
return nil
}
return s.manager.GetBytes(s.ctx, key)
}
// PopBytes retrieves a []byte value and deletes it from the session.
func (s *Session) PopBytes(key string) []byte {
if s.manager == nil || s.ctx == nil {
return nil
}
return s.manager.PopBytes(s.ctx, key)
}

15
via.go
View File

@@ -20,6 +20,7 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/alexedwards/scs/v2"
"github.com/go-via/via/h" "github.com/go-via/via/h"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )
@@ -37,6 +38,7 @@ type V struct {
documentHeadIncludes []h.H documentHeadIncludes []h.H
documentFootIncludes []h.H documentFootIncludes []h.H
devModePageInitFnMap map[string]func(*Context) devModePageInitFnMap map[string]func(*Context)
sessionManager *scs.SessionManager
} }
func (v *V) logFatal(format string, a ...any) { func (v *V) logFatal(format string, a ...any) {
@@ -102,6 +104,9 @@ func (v *V) Config(cfg Options) {
if cfg.ServerAddress != "" { if cfg.ServerAddress != "" {
v.cfg.ServerAddress = cfg.ServerAddress v.cfg.ServerAddress = cfg.ServerAddress
} }
if cfg.SessionManager != nil {
v.sessionManager = cfg.SessionManager
}
} }
// AppendToHead appends the given h.H nodes to the head of the base HTML document. // AppendToHead appends the given h.H nodes to the head of the base HTML document.
@@ -162,6 +167,7 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
} }
id := fmt.Sprintf("%s_/%s", route, genRandID()) id := fmt.Sprintf("%s_/%s", route, genRandID())
c := newContext(id, route, v) c := newContext(id, route, v)
c.reqCtx = r.Context()
routeParams := extractParams(route, r.URL.Path) routeParams := extractParams(route, r.URL.Path)
c.injectRouteParams(routeParams) c.injectRouteParams(routeParams)
initContextFn(c) initContextFn(c)
@@ -235,7 +241,11 @@ func (v *V) getCtx(id string) (*Context, error) {
// Start starts the Via HTTP server on the given address. // Start starts the Via HTTP server on the given address.
func (v *V) Start() { func (v *V) Start() {
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress) v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.mux)) 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 // HTTPServeMux returns the underlying HTTP request multiplexer to enable user extentions, middleware and
@@ -364,6 +374,7 @@ func New() *V {
mux: mux, mux: mux,
contextRegistry: make(map[string]*Context), contextRegistry: make(map[string]*Context),
devModePageInitFnMap: make(map[string]func(*Context)), devModePageInitFnMap: make(map[string]func(*Context)),
sessionManager: scs.New(),
cfg: Options{ cfg: Options{
DevMode: false, DevMode: false,
ServerAddress: ":3000", ServerAddress: ":3000",
@@ -396,6 +407,7 @@ func New() *V {
v.logErr(nil, "sse stream failed to start: %v", err) v.logErr(nil, "sse stream failed to start: %v", err)
return return
} }
c.reqCtx = r.Context()
sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5)))) sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5))))
@@ -456,6 +468,7 @@ func New() *V {
v.logErr(nil, "action '%s' failed: %v", actionID, err) v.logErr(nil, "action '%s' failed: %v", actionID, err)
return return
} }
c.reqCtx = r.Context()
actionFn, err := c.getActionFn(actionID) actionFn, err := c.getActionFn(actionID)
if err != nil { if err != nil {
v.logDebug(c, "action '%s' failed: %v", actionID, err) v.logDebug(c, "action '%s' failed: %v", actionID, err)