Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b8540b71 | ||
|
|
532651552a |
@@ -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
|
||||||
|
|||||||
85
context.go
85
context.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -31,6 +32,7 @@ type Context struct {
|
|||||||
signals *sync.Map
|
signals *sync.Map
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ctxDisposedChan chan struct{}
|
ctxDisposedChan chan struct{}
|
||||||
|
pageStopChan chan struct{}
|
||||||
reqCtx context.Context
|
reqCtx context.Context
|
||||||
fields []*Field
|
fields []*Field
|
||||||
subscriptions []Subscription
|
subscriptions []Subscription
|
||||||
@@ -48,8 +50,12 @@ func (c *Context) View(f func() h.H) {
|
|||||||
if f == nil {
|
if f == nil {
|
||||||
panic("nil viewfn")
|
panic("nil viewfn")
|
||||||
}
|
}
|
||||||
|
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()) }
|
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.
|
||||||
// It returns the component's view as a DOM node fn that can be placed in the view
|
// It returns the component's view as a DOM node fn that can be placed in the view
|
||||||
@@ -131,17 +137,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.
|
||||||
@@ -369,6 +377,60 @@ 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) {
|
||||||
|
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.syncWithViewTransition()
|
||||||
|
escaped := url.PathEscape(path)
|
||||||
|
if popstate {
|
||||||
|
c.ExecScript(fmt.Sprintf("history.replaceState({},'',decodeURIComponent('%s'))", escaped))
|
||||||
|
} else {
|
||||||
|
c.ExecScript(fmt.Sprintf("history.pushState({},'',decodeURIComponent('%s'))", escaped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncWithViewTransition renders the view and sends it as a PatchElements
|
||||||
|
// with the view transition flag, plus any changed signals.
|
||||||
|
func (c *Context) syncWithViewTransition() {
|
||||||
|
elemsPatch := new(bytes.Buffer)
|
||||||
|
if err := c.view().Render(elemsPatch); err != nil {
|
||||||
|
c.app.logErr(c, "sync view failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.sendPatch(patch{patchTypeElementsWithVT, elemsPatch.String()})
|
||||||
|
|
||||||
|
updatedSigs := c.prepareSignalsForPatch()
|
||||||
|
if len(updatedSigs) != 0 {
|
||||||
|
outgoingSigs, _ := json.Marshal(updatedSigs)
|
||||||
|
c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +441,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 +601,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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,10 @@ 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.
|
||||||
|
func DataViewTransition(name string) H {
|
||||||
|
return Attr("style", "view-transition-name: "+name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ 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()
|
||||||
|
startInterval := func() {
|
||||||
|
stopUpdate = c.OnInterval(computedTickDuration(), func() {
|
||||||
ts := time.Now().UnixMilli()
|
ts := time.Now().UnixMilli()
|
||||||
val := rand.ExpFloat64() * 10
|
val := rand.ExpFloat64() * 10
|
||||||
|
|
||||||
@@ -48,18 +50,20 @@ func main() {
|
|||||||
};
|
};
|
||||||
`, 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 {
|
||||||
|
|||||||
91
internal/examples/spa/main.go
Normal file
91
internal/examples/spa/main.go
Normal 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()
|
||||||
|
}
|
||||||
52
navigate.js
Normal file
52
navigate.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
(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);
|
||||||
|
} catch(_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var ready = false;
|
||||||
|
window.addEventListener('popstate', function() {
|
||||||
|
if (!ready) { ready = true; return; }
|
||||||
|
navigate(window.location.pathname + window.location.search, true);
|
||||||
|
});
|
||||||
|
// Mark as ready after initial load completes
|
||||||
|
setTimeout(function() { ready = true; }, 0);
|
||||||
|
})();
|
||||||
74
routine.go
74
routine.go
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
66
via.go
66
via.go
@@ -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()}
|
||||||
@@ -557,6 +565,7 @@ type patchType int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
patchTypeElements = iota
|
patchTypeElements = iota
|
||||||
|
patchTypeElementsWithVT
|
||||||
patchTypeSignals
|
patchTypeSignals
|
||||||
patchTypeScript
|
patchTypeScript
|
||||||
patchTypeRedirect
|
patchTypeRedirect
|
||||||
@@ -577,6 +586,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 +637,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 +726,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 +817,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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user