5 Commits

Author SHA1 Message Date
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
Ryan Hamamura
03b6d7453a feat: add Redirect and ReplaceURL methods for browser navigation
Add SSE-based navigation methods to Context:
- Redirect(url) / Redirectf() - navigate to new page
- ReplaceURL(url) / ReplaceURLf() - update URL without navigation

Update session example to demonstrate full login flow with redirects.
2026-01-12 00:47:52 -10:00
21 changed files with 215 additions and 80 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

@@ -37,4 +37,12 @@ type Options struct {
// with scs LoadAndSave middleware. Configure the session manager before // with scs LoadAndSave middleware. Configure the session manager before
// passing it (lifetime, cookie settings, store, etc). // passing it (lifetime, cookie settings, store, etc).
SessionManager *scs.SessionManager 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" "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.
@@ -318,6 +318,37 @@ func (c *Context) ExecScript(s string) {
c.sendPatch(patch{patchTypeScript, s}) c.sendPatch(patch{patchTypeScript, s})
} }
// Redirect navigates the browser to the given URL.
// This triggers a full page navigation - the current context will be disposed
// and a new context created at the destination URL.
func (c *Context) Redirect(url string) {
if url == "" {
c.app.logWarn(c, "redirect failed: empty url")
return
}
c.sendPatch(patch{patchTypeRedirect, url})
}
// Redirectf navigates the browser to a URL constructed from the format string and arguments.
func (c *Context) Redirectf(format string, a ...any) {
c.Redirect(fmt.Sprintf(format, a...))
}
// ReplaceURL updates the browser's URL and history without triggering navigation.
// Useful for updating query params or path to reflect UI state changes.
func (c *Context) ReplaceURL(url string) {
if url == "" {
c.app.logWarn(c, "replace url failed: empty url")
return
}
c.sendPatch(patch{patchTypeReplaceURL, url})
}
// ReplaceURLf updates the browser's URL using a format string.
func (c *Context) ReplaceURLf(format string, a ...any) {
c.ReplaceURL(fmt.Sprintf(format, a...))
}
// stopAllRoutines stops all go routines tied to this Context preventing goroutine leaks. // stopAllRoutines stops all go routines tied to this Context preventing goroutine leaks.
func (c *Context) stopAllRoutines() { func (c *Context) stopAllRoutines() {
select { select {

6
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,7 @@ 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/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 +14,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
) )

8
go.sum
View File

@@ -17,11 +17,6 @@ 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/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 +40,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=

1
h/h.go
View File

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

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,17 +1,17 @@
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() {
v := via.New() v := via.New()
v.Config(via.Options{ServerAddress: ":7331"})
v.Page("/", func(c *via.Context) { // Login page
username := c.Session().GetString("username") v.Page("/login", func(c *via.Context) {
flash := c.Session().PopString("flash") flash := c.Session().PopString("flash")
usernameInput := c.Signal("") usernameInput := c.Signal("")
login := c.Action(func() { login := c.Action(func() {
@@ -20,38 +20,69 @@ func main() {
c.Session().Set("username", name) c.Session().Set("username", name)
c.Session().Set("flash", "Welcome, "+name+"!") c.Session().Set("flash", "Welcome, "+name+"!")
c.Session().RenewToken() c.Session().RenewToken()
c.Redirect("/dashboard")
} }
c.Sync()
})
logout := c.Action(func() {
c.Session().Set("flash", "Goodbye!")
c.Session().Delete("username")
c.Sync()
}) })
c.View(func() h.H { c.View(func() h.H {
// Already logged in? Redirect to dashboard
if c.Session().GetString("username") != "" {
c.Redirect("/dashboard")
return h.Div()
}
var flashMsg h.H var flashMsg h.H
if flash != "" { if flash != "" {
flashMsg = h.P(h.Text(flash), h.Style("color: green")) flashMsg = h.P(h.Text(flash), h.Style("color: green"))
} }
if username == "" {
return h.Div( return h.Div(
flashMsg, flashMsg,
h.H1(h.Text("Login")), h.H1(h.Text("Login")),
h.Input(h.Type("text"), h.Placeholder("Username"), usernameInput.Bind()), h.Input(h.Type("text"), h.Placeholder("Username"), usernameInput.Bind()),
h.Button(h.Text("Login"), login.OnClick()), h.Button(h.Text("Login"), login.OnClick()),
) )
})
})
// Dashboard page (protected)
v.Page("/dashboard", func(c *via.Context) {
logout := c.Action(func() {
c.Session().Set("flash", "Goodbye!")
c.Session().Delete("username")
c.Redirect("/login")
})
c.View(func() h.H {
username := c.Session().GetString("username")
// Not logged in? Redirect to login
if username == "" {
c.Session().Set("flash", "Please log in first")
c.Redirect("/login")
return h.Div()
}
flash := c.Session().PopString("flash")
var flashMsg h.H
if flash != "" {
flashMsg = h.P(h.Text(flash), h.Style("color: green"))
} }
return h.Div( return h.Div(
flashMsg, flashMsg,
h.H1(h.Textf("Hello, %s!", username)), h.H1(h.Textf("Dashboard - Hello, %s!", username)),
h.P(h.Text("Your session persists across page refreshes.")), h.P(h.Text("Your session persists across page refreshes.")),
h.Button(h.Text("Logout"), logout.OnClick()), h.Button(h.Text("Logout"), logout.OnClick()),
) )
}) })
}) })
// Redirect root to login
v.Page("/", func(c *via.Context) {
c.View(func() h.H {
c.Redirect("/login")
return h.Div()
})
})
v.Start() v.Start()
} }

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

@@ -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"
) )

56
via.go
View File

@@ -15,13 +15,14 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"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,6 +40,9 @@ type V struct {
documentFootIncludes []h.H documentFootIncludes []h.H
devModePageInitFnMap map[string]func(*Context) devModePageInitFnMap map[string]func(*Context)
sessionManager *scs.SessionManager sessionManager *scs.SessionManager
datastarPath string
datastarContent []byte
datastarOnce sync.Once
} }
func (v *V) logFatal(format string, a ...any) { func (v *V) logFatal(format string, a ...any) {
@@ -107,6 +111,12 @@ func (v *V) Config(cfg Options) {
if cfg.SessionManager != nil { if cfg.SessionManager != nil {
v.sessionManager = cfg.SessionManager 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. // AppendToHead appends the given h.H nodes to the head of the base HTML document.
@@ -140,6 +150,7 @@ func (v *V) AppendToFoot(elements ...h.H) {
// }) // })
// }) // })
func (v *V) Page(route string, initContextFn func(c *Context)) { func (v *V) Page(route string, initContextFn func(c *Context)) {
v.ensureDatastarHandler()
// check for panics // check for panics
func() { func() {
defer func() { defer func() {
@@ -175,7 +186,7 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
if v.cfg.DevMode { if v.cfg.DevMode {
v.devModePersist(c) 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, v.documentHeadIncludes...)
headElements = append(headElements, headElements = append(headElements,
h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))), h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))),
@@ -257,6 +268,15 @@ func (v *V) HTTPServeMux() *http.ServeMux {
return v.mux 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) { func (v *V) devModePersist(c *Context) {
p := filepath.Join(".via", "devmode", "ctx.json") p := filepath.Join(".via", "devmode", "ctx.json")
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
@@ -359,6 +379,8 @@ const (
patchTypeElements = iota patchTypeElements = iota
patchTypeSignals patchTypeSignals
patchTypeScript patchTypeScript
patchTypeRedirect
patchTypeReplaceURL
) )
type patch struct { type patch struct {
@@ -375,6 +397,8 @@ func New() *V {
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(), sessionManager: scs.New(),
datastarPath: "/_datastar.js",
datastarContent: datastarJS,
cfg: Options{ cfg: Options{
DevMode: false, DevMode: false,
ServerAddress: ":3000", ServerAddress: ":3000",
@@ -383,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) { 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)
@@ -417,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 {
@@ -453,6 +464,21 @@ func New() *V {
v.logErr(c, "ExecuteScript failed: %v", err) 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)
}
}
} }
} }
} }

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"
) )
@@ -28,6 +28,10 @@ func TestPageRoute(t *testing.T) {
func TestDatastarJS(t *testing.T) { func TestDatastarJS(t *testing.T) {
v := New() v := New()
v.Page("/", func(c *Context) {
c.View(func() h.H { return h.Div() })
})
req := httptest.NewRequest("GET", "/_datastar.js", nil) req := httptest.NewRequest("GET", "/_datastar.js", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req) v.mux.ServeHTTP(w, req)
@@ -37,6 +41,50 @@ func TestDatastarJS(t *testing.T) {
assert.Contains(t, w.Body.String(), "🖕JS_DS🚀") 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) { func TestSignal(t *testing.T) {
var sig *signal var sig *signal
v := New() v := New()