4 Commits

Author SHA1 Message Date
Ryan Hamamura
82a3314089 feat: add SQLite session store support
Add NewSQLiteSessionManager helper that creates an SCS session manager
backed by SQLite, allowing sessions to persist across server restarts.
The function handles table creation automatically.
2026-01-15 08:44:27 -10:00
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
Ryan Hamamura
c77ccc0796 chore: rename module to github.com/ryanhamamura/via
Update module path and all internal imports to use the new repository location.
2026-01-14 10:47:11 -10:00
Ryan Hamamura
d4b831492e refactor: simplify Datastar configuration API
Flatten DatastarConfig struct into Options (DatastarContent, DatastarPath)
and replace datastarHandlerRegistered bool with sync.Once for thread safety.
2026-01-14 02:01:18 -10:00
21 changed files with 103 additions and 87 deletions

View File

@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
) )
// actionTrigger represents a trigger to an event handler fn // actionTrigger represents a trigger to an event handler fn

View File

@@ -2,17 +2,6 @@ package via
import "github.com/alexedwards/scs/v2" import "github.com/alexedwards/scs/v2"
// DatastarConfig configures a custom Datastar.js script.
type DatastarConfig struct {
// Content is the Datastar.js script content.
// If nil, the embedded default is used.
Content []byte
// Path is the URL path where the script is served.
// Defaults to "/_datastar.js" if empty.
Path string
}
type LogLevel int type LogLevel int
const ( const (
@@ -49,7 +38,11 @@ type Options struct {
// passing it (lifetime, cookie settings, store, etc). // passing it (lifetime, cookie settings, store, etc).
SessionManager *scs.SessionManager SessionManager *scs.SessionManager
// Datastar configures a custom Datastar.js script. // DatastarContent is the Datastar.js script content.
// If nil, Via uses its embedded default. // If nil, the embedded default is used.
Datastar *DatastarConfig DatastarContent []byte
// DatastarPath is the URL path where the script is served.
// Defaults to "/_datastar.js" if empty.
DatastarPath string
} }

View File

@@ -11,7 +11,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
) )
// Context is the living bridge between Go and the browser. // Context is the living bridge between Go and the browser.

7
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/go-via/via module github.com/ryanhamamura/via
go 1.25.4 go 1.25.4
@@ -6,6 +6,8 @@ require maragu.dev/gomponents v1.2.0
require ( require (
github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de
github.com/alexedwards/scs/v2 v2.9.0
github.com/mattn/go-sqlite3 v1.14.32 github.com/mattn/go-sqlite3 v1.14.32
github.com/starfederation/datastar-go v1.0.3 github.com/starfederation/datastar-go v1.0.3
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
@@ -13,13 +15,10 @@ 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
github.com/kr/pretty v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

11
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/sqlite3store v0.0.0-20251002162104-209de6e426de h1:c72K9HLu6K442et0j3BUL/9HEYaUJouLkkVANdmqTOo=
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= 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/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=
@@ -17,11 +19,7 @@ github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQs
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
@@ -45,9 +43,8 @@ github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -3,8 +3,8 @@ package main
import ( import (
"math/rand" "math/rand"
"github.com/go-via/via" "github.com/ryanhamamura/via"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
) )
var ( var (

View File

@@ -1,8 +1,8 @@
package main package main
import ( import (
"github.com/go-via/via" "github.com/ryanhamamura/via"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
) )
type Counter struct{ Count int } type Counter struct{ Count int }

View File

@@ -1,8 +1,8 @@
package main package main
import ( import (
"github.com/go-via/via" "github.com/ryanhamamura/via"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
) )
func main() { func main() {

View File

@@ -1,8 +1,8 @@
package main package main
import ( import (
"github.com/go-via/via" "github.com/ryanhamamura/via"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
) )
func main() { func main() {

View File

@@ -1,9 +1,9 @@
package main package main
import ( import (
"github.com/go-via/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss" // "github.com/go-via/via-plugin-picocss/picocss"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
) )
type Counter struct{ Count int } type Counter struct{ Count int }

View File

@@ -3,9 +3,9 @@ package main
import ( import (
"strconv" "strconv"
"github.com/go-via/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss" // "github.com/go-via/via-plugin-picocss/picocss"
. "github.com/go-via/via/h" . "github.com/ryanhamamura/via/h"
) )
func main() { func main() {

View File

@@ -1,9 +1,9 @@
package main package main
import ( import (
"github.com/go-via/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss" // "github.com/go-via/via-plugin-picocss/picocss"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
) )
type Counter struct{ Count int } type Counter struct{ Count int }

View File

@@ -4,8 +4,8 @@ import (
_ "embed" _ "embed"
"net/http" "net/http"
"github.com/go-via/via" "github.com/ryanhamamura/via"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
) )
// Example of a Via application with a plugin that adds PicoCSS. The plugin // Example of a Via application with a plugin that adds PicoCSS. The plugin

View File

@@ -5,9 +5,9 @@ import (
"math/rand" "math/rand"
"time" "time"
"github.com/go-via/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss" // "github.com/go-via/via-plugin-picocss/picocss"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
) )
func main() { func main() {

View File

@@ -1,13 +1,33 @@
package main package main
import ( import (
"github.com/go-via/via" "database/sql"
"github.com/go-via/via/h" "log"
_ "github.com/mattn/go-sqlite3"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
) )
func main() { func main() {
// Open SQLite database for persistent sessions
db, err := sql.Open("sqlite3", "sessions.db")
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
defer db.Close()
// Create session manager with SQLite store
sm, err := via.NewSQLiteSessionManager(db)
if err != nil {
log.Fatalf("failed to create session manager: %v", err)
}
v := via.New() v := via.New()
v.Config(via.Options{ServerAddress: ":7331"}) v.Config(via.Options{
ServerAddress: ":7331",
SessionManager: sm,
})
// Login page // Login page
v.Page("/login", func(c *via.Context) { v.Page("/login", func(c *via.Context) {

View File

@@ -6,8 +6,8 @@ import (
"log" "log"
"time" "time"
"github.com/go-via/via" "github.com/ryanhamamura/via"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )

View File

@@ -2,11 +2,34 @@ package via
import ( import (
"context" "context"
"database/sql"
"time" "time"
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
) )
// NewSQLiteSessionManager creates a session manager using SQLite for persistence.
// Creates the sessions table if it doesn't exist.
// The returned manager can be configured further (Lifetime, Cookie settings, etc.)
// before passing to Options.SessionManager.
func NewSQLiteSessionManager(db *sql.DB) (*scs.SessionManager, error) {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BLOB NOT NULL,
expiry REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(expiry);
`)
if err != nil {
return nil, err
}
sm := scs.New()
sm.Store = sqlite3store.New(db)
return sm, nil
}
// Session provides access to the user's session data. // Session provides access to the user's session data.
// Session data persists across page views for the same browser. // Session data persists across page views for the same browser.
type Session struct { type Session struct {

View File

@@ -5,7 +5,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
) )
// Signal represents a value that is reactive in the browser. Signals // Signal represents a value that is reactive in the browser. Signals

View File

@@ -4,7 +4,7 @@ import (
// "net/http/httptest" // "net/http/httptest"
"testing" "testing"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )

44
via.go
View File

@@ -22,7 +22,7 @@ import (
"sync" "sync"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )
@@ -39,10 +39,10 @@ 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 sessionManager *scs.SessionManager
datastarPath string datastarPath string
datastarContent []byte datastarContent []byte
datastarHandlerRegistered bool datastarOnce sync.Once
} }
func (v *V) logFatal(format string, a ...any) { func (v *V) logFatal(format string, a ...any) {
@@ -111,13 +111,11 @@ func (v *V) Config(cfg Options) {
if cfg.SessionManager != nil { if cfg.SessionManager != nil {
v.sessionManager = cfg.SessionManager v.sessionManager = cfg.SessionManager
} }
if cfg.Datastar != nil { if cfg.DatastarContent != nil {
if cfg.Datastar.Content != nil { v.datastarContent = cfg.DatastarContent
v.datastarContent = cfg.Datastar.Content }
} if cfg.DatastarPath != "" {
if cfg.Datastar.Path != "" { v.datastarPath = cfg.DatastarPath
v.datastarPath = cfg.Datastar.Path
}
} }
} }
@@ -271,13 +269,11 @@ func (v *V) HTTPServeMux() *http.ServeMux {
} }
func (v *V) ensureDatastarHandler() { func (v *V) ensureDatastarHandler() {
if v.datastarHandlerRegistered { v.datastarOnce.Do(func() {
return v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {
} w.Header().Set("Content-Type", "application/javascript")
v.datastarHandlerRegistered = true _, _ = w.Write(v.datastarContent)
v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) { })
w.Header().Set("Content-Type", "application/javascript")
_, _ = w.Write(v.datastarContent)
}) })
} }
@@ -412,10 +408,6 @@ func New() *V {
} }
v.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) { v.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) {
isReconnect := false
if r.Header.Get("last-event-id") == "via" {
isReconnect = true
}
var sigs map[string]any var sigs map[string]any
_ = datastar.ReadSignals(r, &sigs) _ = datastar.ReadSignals(r, &sigs)
cID, _ := sigs["via-ctx"].(string) cID, _ := sigs["via-ctx"].(string)
@@ -440,11 +432,7 @@ func New() *V {
v.logDebug(c, "SSE connection established") v.logDebug(c, "SSE connection established")
go func() { go func() {
if isReconnect || v.cfg.DevMode { c.Sync()
c.Sync()
return
}
c.SyncSignals()
}() }()
for { for {

View File

@@ -5,7 +5,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/go-via/via/h" "github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -45,9 +45,7 @@ func TestCustomDatastarContent(t *testing.T) {
customScript := []byte("// Custom Datastar Script") customScript := []byte("// Custom Datastar Script")
v := New() v := New()
v.Config(Options{ v.Config(Options{
Datastar: &DatastarConfig{ DatastarContent: customScript,
Content: customScript,
},
}) })
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
c.View(func() h.H { return h.Div() }) c.View(func() h.H { return h.Div() })
@@ -65,9 +63,7 @@ func TestCustomDatastarContent(t *testing.T) {
func TestCustomDatastarPath(t *testing.T) { func TestCustomDatastarPath(t *testing.T) {
v := New() v := New()
v.Config(Options{ v.Config(Options{
Datastar: &DatastarConfig{ DatastarPath: "/assets/datastar.js",
Path: "/assets/datastar.js",
},
}) })
v.Page("/test", func(c *Context) { v.Page("/test", func(c *Context) {
c.View(func() h.H { return h.Div() }) c.View(func() h.H { return h.Div() })