4 Commits

Author SHA1 Message Date
Ryan Hamamura
785f11e52d fix: harden SPA navigation with race protection and correctness fixes
- Add navMu to serialize concurrent navigations on the same context
- Replace url.PathEscape with targeted JS string escaper (PathEscape
  mangles full paths and doesn't escape single quotes)
- Collapse syncWithViewTransition into syncView(bool) to remove duplication
- Simplify popstate ready guard in navigate.js
- Preserve URL hash during SPA navigation
2026-02-12 14:41:50 -10:00
Ryan Hamamura
2f19874c17 feat: add PubSub() accessor to V struct 2026-02-12 14:32:05 -10:00
Ryan Hamamura
27b8540b71 feat: add SPA navigation with view transitions
Swap page content over the existing SSE connection without full page
loads. A persistent Context resets its page-specific state (signals,
actions, intervals, subscriptions) on navigate while preserving the
SSE stream, CSRF token, and session.

- c.Navigate(path) for programmatic SPA navigation from actions
- Injected JS intercepts same-origin <a> clicks (opt out with
  data-via-no-boost) and handles popstate for back/forward
- v.Layout() wraps pages in a shared shell for DRY nav/chrome
- View Transition API integration via WithViewTransitions() on
  PatchElements and h.DataViewTransition() helper
- POST /_navigate endpoint with CSRF validation and rate limiting
- pageStopChan cancels page-level OnInterval goroutines on navigate
- Includes SPA example with layout, counter, and live clock pages
2026-02-12 13:52:47 -10:00
Ryan Hamamura
532651552a refactor: simplify OnInterval API to auto-start and return stop func
Replace the exported OnIntervalRoutine struct (Start/Stop/UpdateInterval)
with a single function that auto-starts the goroutine and returns an
idempotent stop closure. Uses close(channel) instead of send-on-channel,
fixing a potential deadlock when the goroutine exits via context disposal.

Closes #5 item 4.
2026-02-12 12:27:50 -10:00
8 changed files with 326 additions and 88 deletions

View File

@@ -69,7 +69,7 @@ func main() {
- **CSRF protection** — automatic token generation and validation on every action - **CSRF protection** — automatic token generation and validation on every action
- **Rate limiting** — token-bucket algorithm, configurable globally and per-action - **Rate limiting** — token-bucket algorithm, configurable globally and per-action
- **Event handling** — `OnClick`, `OnChange`, `OnSubmit`, `OnInput`, `OnFocus`, `OnBlur`, `OnMouseEnter`, `OnMouseLeave`, `OnScroll`, `OnDblClick`, `OnKeyDown`, and `OnKeyDownMap` for multi-key bindings - **Event handling** — `OnClick`, `OnChange`, `OnSubmit`, `OnInput`, `OnFocus`, `OnBlur`, `OnMouseEnter`, `OnMouseLeave`, `OnScroll`, `OnDblClick`, `OnKeyDown`, and `OnKeyDownMap` for multi-key bindings
- **Timed routines** — `OnInterval` with start/stop/update controls, tied to context lifecycle - **Timed routines** — `OnInterval` auto-starts a ticker goroutine, returns a stop function, tied to context lifecycle
- **Redirects** — `Redirect`, `ReplaceURL`, and format-string variants - **Redirects** — `Redirect`, `ReplaceURL`, and format-string variants
- **Plugin system** — `func(v *V)` hooks for integrating CSS/JS libraries - **Plugin system** — `func(v *V)` hooks for integrating CSS/JS libraries
- **Structured logging** — zerolog with configurable levels; console output in dev, JSON in production - **Structured logging** — zerolog with configurable levels; console output in dev, JSON in production

View File

@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"reflect" "reflect"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -30,7 +31,9 @@ type Context struct {
actionRegistry map[string]actionEntry actionRegistry map[string]actionEntry
signals *sync.Map signals *sync.Map
mu sync.RWMutex mu sync.RWMutex
navMu sync.Mutex
ctxDisposedChan chan struct{} ctxDisposedChan chan struct{}
pageStopChan chan struct{}
reqCtx context.Context reqCtx context.Context
fields []*Field fields []*Field
subscriptions []Subscription subscriptions []Subscription
@@ -48,7 +51,11 @@ func (c *Context) View(f func() h.H) {
if f == nil { if f == nil {
panic("nil viewfn") panic("nil viewfn")
} }
c.view = func() h.H { return h.Div(h.ID(c.id), f()) } if c.app.layout != nil {
c.view = func() h.H { return h.Div(h.ID(c.id), c.app.layout(f)) }
} else {
c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
}
} }
// Component registers a subcontext that has self contained data, actions and signals. // Component registers a subcontext that has self contained data, actions and signals.
@@ -131,17 +138,19 @@ func (c *Context) getAction(id string) (actionEntry, error) {
return actionEntry{}, fmt.Errorf("action '%s' not found", id) return actionEntry{}, fmt.Errorf("action '%s' not found", id)
} }
// OnInterval starts a go routine that sets a time.Ticker with the given duration and executes // OnInterval starts a goroutine that executes handler on every tick of the given duration.
// the given handler func() on every tick. Use *Routine.UpdateInterval to update the interval. // The goroutine is tied to the context lifecycle and will stop when the context is disposed.
func (c *Context) OnInterval(duration time.Duration, handler func()) *OnIntervalRoutine { // Returns a func() that stops the interval when called.
var cn chan struct{} func (c *Context) OnInterval(duration time.Duration, handler func()) func() {
if c.isComponent() { // components use the chan on the parent page ctx var disposeCh, pageCh chan struct{}
cn = c.parentPageCtx.ctxDisposedChan if c.isComponent() {
disposeCh = c.parentPageCtx.ctxDisposedChan
pageCh = c.parentPageCtx.pageStopChan
} else { } else {
cn = c.ctxDisposedChan disposeCh = c.ctxDisposedChan
pageCh = c.pageStopChan
} }
r := newOnIntervalRoutine(cn, duration, handler) return newOnInterval(disposeCh, pageCh, duration, handler)
return r
} }
// Signal creates a reactive signal and initializes it with the given value. // Signal creates a reactive signal and initializes it with the given value.
@@ -264,15 +273,22 @@ func (c *Context) sendPatch(p patch) {
// Sync pushes the current view state and signal changes to the browser immediately // Sync pushes the current view state and signal changes to the browser immediately
// over the live SSE event stream. // over the live SSE event stream.
func (c *Context) Sync() { func (c *Context) Sync() {
c.syncView(false)
}
func (c *Context) syncView(viewTransition bool) {
elemsPatch := new(bytes.Buffer) elemsPatch := new(bytes.Buffer)
if err := c.view().Render(elemsPatch); err != nil { if err := c.view().Render(elemsPatch); err != nil {
c.app.logErr(c, "sync view failed: %v", err) c.app.logErr(c, "sync view failed: %v", err)
return return
} }
c.sendPatch(patch{patchTypeElements, elemsPatch.String()}) typ := patchType(patchTypeElements)
if viewTransition {
typ = patchTypeElementsWithVT
}
c.sendPatch(patch{typ, elemsPatch.String()})
updatedSigs := c.prepareSignalsForPatch() updatedSigs := c.prepareSignalsForPatch()
if len(updatedSigs) != 0 { if len(updatedSigs) != 0 {
outgoingSigs, _ := json.Marshal(updatedSigs) outgoingSigs, _ := json.Marshal(updatedSigs)
c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)}) c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)})
@@ -369,6 +385,46 @@ func (c *Context) ReplaceURLf(format string, a ...any) {
c.ReplaceURL(fmt.Sprintf(format, a...)) c.ReplaceURL(fmt.Sprintf(format, a...))
} }
// resetPageState tears down page-specific state (intervals, subscriptions,
// actions, signals, fields) without disposing the context itself. The SSE
// connection and context lifetime are unaffected.
func (c *Context) resetPageState() {
close(c.pageStopChan)
c.unsubscribeAll()
c.mu.Lock()
c.actionRegistry = make(map[string]actionEntry)
c.signals = new(sync.Map)
c.fields = nil
c.pageStopChan = make(chan struct{})
c.mu.Unlock()
}
// Navigate performs an SPA navigation to the given path. It resets page state,
// runs the target page's init function (with middleware), and pushes the new
// view over the existing SSE connection with a view transition animation.
// If popstate is true, replaceState is used instead of pushState.
func (c *Context) Navigate(path string, popstate bool) {
c.navMu.Lock()
defer c.navMu.Unlock()
route, initFn, params := c.app.matchRoute(path)
if initFn == nil {
c.Redirect(path)
return
}
c.resetPageState()
c.route = route
c.injectRouteParams(params)
initFn(c)
c.syncView(true)
safe := strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(path)
if popstate {
c.ExecScript(fmt.Sprintf("history.replaceState({},'','%s')", safe))
} else {
c.ExecScript(fmt.Sprintf("history.pushState({},'','%s')", safe))
}
}
// dispose idempotently tears down this context: unsubscribes all pubsub // dispose idempotently tears down this context: unsubscribes all pubsub
// subscriptions and closes ctxDisposedChan to stop routines and exit the SSE loop. // subscriptions and closes ctxDisposedChan to stop routines and exit the SSE loop.
func (c *Context) dispose() { func (c *Context) dispose() {
@@ -379,7 +435,7 @@ func (c *Context) dispose() {
} }
// stopAllRoutines closes ctxDisposedChan, broadcasting to all listening // stopAllRoutines closes ctxDisposedChan, broadcasting to all listening
// goroutines (OnIntervalRoutine, SSE loop) that this context is done. // goroutines (OnInterval, SSE loop) that this context is done.
func (c *Context) stopAllRoutines() { func (c *Context) stopAllRoutines() {
select { select {
case <-c.ctxDisposedChan: case <-c.ctxDisposedChan:
@@ -539,8 +595,9 @@ func newContext(id string, route string, v *V) *Context {
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst), actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
actionRegistry: make(map[string]actionEntry), actionRegistry: make(map[string]actionEntry),
signals: new(sync.Map), signals: new(sync.Map),
patchChan: make(chan patch, 1), patchChan: make(chan patch, 8),
ctxDisposedChan: make(chan struct{}, 1), ctxDisposedChan: make(chan struct{}, 1),
pageStopChan: make(chan struct{}),
createdAt: time.Now(), createdAt: time.Now(),
} }
} }

View File

@@ -11,3 +11,11 @@ func DataEffect(expression string) H {
func DataIgnoreMorph() H { func DataIgnoreMorph() H {
return Attr("data-ignore-morph") return Attr("data-ignore-morph")
} }
// DataViewTransition sets the view-transition-name CSS property on an element
// via an inline style. Elements with matching names animate between pages
// during SPA navigation. If the element also needs other inline styles,
// include view-transition-name directly in the Style() call instead.
func DataViewTransition(name string) H {
return Attr("style", "view-transition-name: "+name)
}

View File

@@ -37,29 +37,33 @@ func main() {
return 1000 / time.Duration(refreshRate.Int()) * time.Millisecond return 1000 / time.Duration(refreshRate.Int()) * time.Millisecond
} }
updateData := c.OnInterval(computedTickDuration(), func() { var stopUpdate func()
ts := time.Now().UnixMilli() startInterval := func() {
val := rand.ExpFloat64() * 10 stopUpdate = c.OnInterval(computedTickDuration(), func() {
ts := time.Now().UnixMilli()
val := rand.ExpFloat64() * 10
c.ExecScript(fmt.Sprintf(` c.ExecScript(fmt.Sprintf(`
if (myChart) { if (myChart) {
myChart.appendData({seriesIndex: 0, data: [[%d, %f]]}); myChart.appendData({seriesIndex: 0, data: [[%d, %f]]});
myChart.setOption({},{notMerge:false,lazyUpdate:true}); myChart.setOption({},{notMerge:false,lazyUpdate:true});
}; };
`, ts, val)) `, ts, val))
}) })
updateData.Start() }
startInterval()
updateRefreshRate := c.Action(func() { updateRefreshRate := c.Action(func() {
updateData.UpdateInterval(computedTickDuration()) stopUpdate()
startInterval()
}) })
toggleIsLive := c.Action(func() { toggleIsLive := c.Action(func() {
isLive = isLiveSig.Bool() isLive = isLiveSig.Bool()
if isLive { if isLive {
updateData.Start() startInterval()
} else { } else {
updateData.Stop() stopUpdate()
} }
}) })
c.View(func() h.H { c.View(func() h.H {

View File

@@ -0,0 +1,91 @@
package main
import (
"fmt"
"time"
"github.com/ryanhamamura/via"
. "github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "SPA Navigation",
ServerAddress: ":7331",
})
v.AppendToHead(
Raw(`<link rel="preconnect" href="https://fonts.googleapis.com">`),
Raw(`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`),
Raw(`<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">`),
Raw(`<style>body{font-family:'Inter',sans-serif;margin:0;background:#111;color:#eee}</style>`),
)
v.Layout(func(content func() H) H {
return Div(
Nav(
Style("display:flex;gap:1rem;padding:1rem;background:#222;"),
A(Href("/"), Text("Home"), Style("color:#fff")),
A(Href("/counter"), Text("Counter"), Style("color:#fff")),
A(Href("/clock"), Text("Clock"), Style("color:#fff")),
A(Href("https://github.com"), Text("GitHub (external)"), Style("color:#888")),
A(Href("/"), Text("Full Reload"), Attr("data-via-no-boost"), Style("color:#f88")),
),
Main(Style("padding:1rem"), content()),
)
})
// Home page
v.Page("/", func(c *via.Context) {
goCounter := c.Action(func() { c.Navigate("/counter", false) })
c.View(func() H {
return Div(
H1(Text("Home"), DataViewTransition("page-title")),
P(Text("Click the nav links above — no page reload, no white flash.")),
P(Text("Or navigate programmatically:")),
Button(Text("Go to Counter"), goCounter.OnClick()),
)
})
})
// Counter page — demonstrates signals and actions survive within a page,
// but reset on navigate away and back.
v.Page("/counter", func(c *via.Context) {
count := 0
increment := c.Action(func() { count++; c.Sync() })
goHome := c.Action(func() { c.Navigate("/", false) })
c.View(func() H {
return Div(
H1(Text("Counter"), DataViewTransition("page-title")),
P(Textf("Count: %d", count)),
Button(Text("+1"), increment.OnClick()),
Button(Text("Go Home"), goHome.OnClick(), Style("margin-left:0.5rem")),
)
})
})
// Clock page — demonstrates OnInterval cleanup on navigate.
v.Page("/clock", func(c *via.Context) {
now := time.Now().Format("15:04:05")
c.OnInterval(time.Second, func() {
now = time.Now().Format("15:04:05")
c.Sync()
})
c.View(func() H {
return Div(
H1(Text("Clock"), DataViewTransition("page-title")),
P(Text("This page has an OnInterval that ticks every second.")),
P(Textf("Current time: %s", now)),
P(Text("Navigate away and back — the old interval stops, a new one starts.")),
P(Textf("Proof this is a fresh page init: random = %d", time.Now().UnixNano()%1000)),
)
})
})
fmt.Println("SPA example running at http://localhost:7331")
v.Start()
}

51
navigate.js Normal file
View File

@@ -0,0 +1,51 @@
(function() {
const meta = document.querySelector('meta[data-signals]');
if (!meta) return;
const raw = meta.getAttribute('data-signals');
const parsed = JSON.parse(raw.replace(/'/g, '"'));
const ctxID = parsed['via-ctx'];
const csrf = parsed['via-csrf'];
if (!ctxID || !csrf) return;
function navigate(url, popstate) {
const params = new URLSearchParams({
'via-ctx': ctxID,
'via-csrf': csrf,
'url': url,
});
if (popstate) params.set('popstate', '1');
fetch('/_navigate', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: params.toString()
}).then(function(res) {
if (!res.ok) window.location.href = url;
}).catch(function() {
window.location.href = url;
});
}
document.addEventListener('click', function(e) {
var el = e.target;
while (el && el.tagName !== 'A') el = el.parentElement;
if (!el) return;
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
if (el.hasAttribute('target')) return;
if (el.hasAttribute('data-via-no-boost')) return;
var href = el.getAttribute('href');
if (!href || href.startsWith('#')) return;
try {
var url = new URL(href, window.location.origin);
if (url.origin !== window.location.origin) return;
e.preventDefault();
navigate(url.pathname + url.search + url.hash);
} catch(_) {}
});
var ready = false;
window.addEventListener('popstate', function() {
if (!ready) return;
navigate(window.location.pathname + window.location.search + window.location.hash, true);
});
setTimeout(function() { ready = true; }, 0);
})();

View File

@@ -1,76 +1,34 @@
package via package via
import ( import (
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
) )
// OnIntervalRoutine allows for defining concurrent goroutines safely. Goroutines started by *OnIntervalRoutine func newOnInterval(ctxDisposedChan, pageStopChan chan struct{}, duration time.Duration, handler func()) func() {
// are tied to the *Context lifecycle. localInterrupt := make(chan struct{})
type OnIntervalRoutine struct { var stopped atomic.Bool
mu sync.RWMutex
ctxDisposed chan struct{}
localInterrupt chan struct{}
isRunning atomic.Bool
routineFn func()
tckDuration time.Duration
updateTkrChan chan time.Duration
}
// UpdateInterval sets a new interval duration for the internal *time.Ticker. If the provided go func() {
// duration is equal of less than 0, UpdateInterval does nothing. tkr := time.NewTicker(duration)
func (r *OnIntervalRoutine) UpdateInterval(d time.Duration) { defer tkr.Stop()
r.mu.Lock()
defer r.mu.Unlock()
r.tckDuration = d
r.updateTkrChan <- d
}
// Start executes the predifined goroutine. If no predifined goroutine exists, or it already
// started, Start does nothing.
func (r *OnIntervalRoutine) Start() {
if !r.isRunning.CompareAndSwap(false, true) || r.routineFn == nil {
return
}
go r.routineFn()
}
// Stop interrupts the predifined goroutine. If no predifined goroutine exists, or it already
// ustopped, Stop does nothing.
func (r *OnIntervalRoutine) Stop() {
if !r.isRunning.CompareAndSwap(true, false) || r.routineFn == nil {
return
}
r.localInterrupt <- struct{}{}
}
func newOnIntervalRoutine(ctxDisposedChan chan struct{},
duration time.Duration, handler func()) *OnIntervalRoutine {
r := &OnIntervalRoutine{
ctxDisposed: ctxDisposedChan,
localInterrupt: make(chan struct{}),
updateTkrChan: make(chan time.Duration),
}
r.tckDuration = duration
r.routineFn = func() {
r.mu.RLock()
tkr := time.NewTicker(r.tckDuration)
r.mu.RUnlock()
defer tkr.Stop() // clean up the ticker when routine stops
for { for {
select { select {
case <-r.ctxDisposed: // dispose of the routine when ctx is disposed case <-ctxDisposedChan:
return return
case <-r.localInterrupt: // dispose of the routine on interrupt signal case <-pageStopChan:
return
case <-localInterrupt:
return return
case d := <-r.updateTkrChan:
tkr.Reset(d)
case <-tkr.C: case <-tkr.C:
handler() handler()
} }
} }
}()
return func() {
if stopped.CompareAndSwap(false, true) {
close(localInterrupt)
}
} }
return r
} }

71
via.go
View File

@@ -35,6 +35,9 @@ import (
//go:embed datastar.js //go:embed datastar.js
var datastarJS []byte var datastarJS []byte
//go:embed navigate.js
var navigateJS []byte
// V is the root application. // V is the root application.
// It manages page routing, user sessions, and SSE connections for live updates. // It manages page routing, user sessions, and SSE connections for live updates.
type V struct { type V struct {
@@ -47,6 +50,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)
pageRegistry map[string]func(*Context)
sessionManager *scs.SessionManager sessionManager *scs.SessionManager
pubsub PubSub pubsub PubSub
defaultNATS *defaultNATS defaultNATS *defaultNATS
@@ -56,6 +60,7 @@ type V struct {
datastarOnce sync.Once datastarOnce sync.Once
reaperStop chan struct{} reaperStop chan struct{}
middleware []Middleware middleware []Middleware
layout func(func() h.H) h.H
} }
func (v *V) logEvent(evt *zerolog.Event, c *Context) *zerolog.Event { func (v *V) logEvent(evt *zerolog.Event, c *Context) *zerolog.Event {
@@ -196,6 +201,7 @@ func (v *V) page(route string, raw, wrapped func(*Context)) {
c.stopAllRoutines() c.stopAllRoutines()
}() }()
v.pageRegistry[route] = wrapped
if v.cfg.DevMode { if v.cfg.DevMode {
v.devModePageInitFnMap[route] = wrapped v.devModePageInitFnMap[route] = wrapped
} }
@@ -223,6 +229,8 @@ func (v *V) page(route string, raw, wrapped func(*Context)) {
h.Meta(h.Data("init", "@get('/_sse')")), h.Meta(h.Data("init", "@get('/_sse')")),
h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => { h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => {
navigator.sendBeacon('/_session/close', '%s');});`, c.id))), 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 := []h.H{c.view()}
@@ -411,6 +419,11 @@ func (v *V) HTTPServeMux() *http.ServeMux {
return v.mux 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. // Static serves files from a filesystem directory at the given URL prefix.
// //
// Example: // Example:
@@ -557,6 +570,7 @@ type patchType int
const ( const (
patchTypeElements = iota patchTypeElements = iota
patchTypeElementsWithVT
patchTypeSignals patchTypeSignals
patchTypeScript patchTypeScript
patchTypeRedirect patchTypeRedirect
@@ -577,6 +591,7 @@ func New() *V {
logger: newConsoleLogger(zerolog.InfoLevel), logger: newConsoleLogger(zerolog.InfoLevel),
contextRegistry: make(map[string]*Context), contextRegistry: make(map[string]*Context),
devModePageInitFnMap: make(map[string]func(*Context)), devModePageInitFnMap: make(map[string]func(*Context)),
pageRegistry: make(map[string]func(*Context)),
sessionManager: scs.New(), sessionManager: scs.New(),
datastarPath: "/_datastar.js", datastarPath: "/_datastar.js",
datastarContent: datastarJS, datastarContent: datastarJS,
@@ -627,11 +642,16 @@ func New() *V {
switch patch.typ { switch patch.typ {
case patchTypeElements: case patchTypeElements:
if err := sse.PatchElements(patch.content); err != nil { if err := sse.PatchElements(patch.content); err != nil {
// Only log if connection wasn't closed (avoids noise during shutdown/tests)
if sse.Context().Err() == nil { if sse.Context().Err() == nil {
v.logErr(c, "PatchElements failed: %v", err) 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: case patchTypeSignals:
if err := sse.PatchSignals([]byte(patch.content)); err != nil { if err := sse.PatchSignals([]byte(patch.content)); err != nil {
if sse.Context().Err() == nil { if sse.Context().Err() == nil {
@@ -711,6 +731,39 @@ func New() *V {
} }
}) })
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) { v.mux.HandleFunc("POST /_session/close", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
@@ -769,3 +822,19 @@ func extractParams(pattern, path string) map[string]string {
} }
return params 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
}