5 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
Ryan Hamamura
ea7b9ad4a1 feat: add custom Datastar.js configuration support
Allow users to provide their own Datastar.js script (e.g., Datastar Pro
or custom builds) via Via's Options configuration. Adds DatastarConfig
struct with Content ([]byte) and Path (string) fields.
2026-01-14 01:47:39 -10:00
22 changed files with 166 additions and 64 deletions

View File

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

View File

@@ -37,4 +37,12 @@ type Options struct {
// with scs LoadAndSave middleware. Configure the session manager before
// passing it (lifetime, cookie settings, store, etc).
SessionManager *scs.SessionManager
// DatastarContent is the Datastar.js script content.
// If nil, the embedded default is used.
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"
"time"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via/h"
)
// 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
@@ -6,6 +6,8 @@ require maragu.dev/gomponents v1.2.0
require (
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/starfederation/datastar-go v1.0.3
github.com/stretchr/testify v1.10.0
@@ -13,13 +15,10 @@ require (
require (
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/davecgh/go-spew v1.1.1 // 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/valyala/bytebufferpool v1.0.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // 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/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/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/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
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/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
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/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.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
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/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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
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 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

1
h/h.go
View File

@@ -79,7 +79,6 @@ func HTML5(p HTML5Props) H {
Body: retype(p.Body),
HTMLAttrs: retype(p.HTMLAttrs),
}
gp.Head = append(gp.Head, Script(Type("module"), Src("/_datastar.js")))
return gc.HTML5(gp)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,33 @@
package main
import (
"github.com/go-via/via"
"github.com/go-via/via/h"
"database/sql"
"log"
_ "github.com/mattn/go-sqlite3"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
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.Config(via.Options{ServerAddress: ":7331"})
v.Config(via.Options{
ServerAddress: ":7331",
SessionManager: sm,
})
// Login page
v.Page("/login", func(c *via.Context) {

View File

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

View File

@@ -2,11 +2,34 @@ package via
import (
"context"
"database/sql"
"time"
"github.com/alexedwards/scs/sqlite3store"
"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 data persists across page views for the same browser.
type Session struct {

View File

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

View File

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

56
via.go
View File

@@ -22,7 +22,7 @@ import (
"sync"
"github.com/alexedwards/scs/v2"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via/h"
"github.com/starfederation/datastar-go/datastar"
)
@@ -32,14 +32,17 @@ 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
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) {
@@ -108,6 +111,12 @@ func (v *V) Config(cfg Options) {
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.
@@ -141,6 +150,7 @@ func (v *V) AppendToFoot(elements ...h.H) {
// })
// })
func (v *V) Page(route string, initContextFn func(c *Context)) {
v.ensureDatastarHandler()
// check for panics
func() {
defer func() {
@@ -176,7 +186,7 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
if v.cfg.DevMode {
v.devModePersist(c)
}
headElements := []h.H{}
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))),
@@ -258,6 +268,15 @@ 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 {
@@ -378,6 +397,8 @@ func New() *V {
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",
@@ -386,16 +407,7 @@ func New() *V {
},
}
v.mux.HandleFunc("GET /_datastar.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
_, _ = w.Write(datastarJS)
})
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
_ = datastar.ReadSignals(r, &sigs)
cID, _ := sigs["via-ctx"].(string)
@@ -420,11 +432,7 @@ func New() *V {
v.logDebug(c, "SSE connection established")
go func() {
if isReconnect || v.cfg.DevMode {
c.Sync()
return
}
c.SyncSignals()
c.Sync()
}()
for {

View File

@@ -5,7 +5,7 @@ import (
"net/http/httptest"
"testing"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
)
@@ -28,6 +28,10 @@ func TestPageRoute(t *testing.T) {
func TestDatastarJS(t *testing.T) {
v := New()
v.Page("/", func(c *Context) {
c.View(func() h.H { return h.Div() })
})
req := httptest.NewRequest("GET", "/_datastar.js", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
@@ -37,6 +41,50 @@ func TestDatastarJS(t *testing.T) {
assert.Contains(t, w.Body.String(), "🖕JS_DS🚀")
}
func TestCustomDatastarContent(t *testing.T) {
customScript := []byte("// Custom Datastar Script")
v := New()
v.Config(Options{
DatastarContent: customScript,
})
v.Page("/", func(c *Context) {
c.View(func() h.H { return h.Div() })
})
req := httptest.NewRequest("GET", "/_datastar.js", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/javascript", w.Header().Get("Content-Type"))
assert.Contains(t, w.Body.String(), "Custom Datastar Script")
}
func TestCustomDatastarPath(t *testing.T) {
v := New()
v.Config(Options{
DatastarPath: "/assets/datastar.js",
})
v.Page("/test", func(c *Context) {
c.View(func() h.H { return h.Div() })
})
// Custom path should serve the script
req := httptest.NewRequest("GET", "/assets/datastar.js", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/javascript", w.Header().Get("Content-Type"))
assert.Contains(t, w.Body.String(), "🖕JS_DS🚀")
// Page should reference the custom path in script tag
req2 := httptest.NewRequest("GET", "/test", nil)
w2 := httptest.NewRecorder()
v.mux.ServeHTTP(w2, req2)
assert.Contains(t, w2.Body.String(), `src="/assets/datastar.js"`)
}
func TestSignal(t *testing.T) {
var sig *signal
v := New()