Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5158b866c | ||
|
|
2f6c5916ce | ||
|
|
0762ddbbc2 | ||
|
|
b7acfa6302 |
75
README.md
75
README.md
@@ -1,30 +1,33 @@
|
|||||||
# ⚡Via
|
# Via
|
||||||
|
|
||||||
Real-time engine for building reactive web applications in pure Go.
|
Real-time engine for building reactive web applications in pure Go.
|
||||||
|
|
||||||
|
|
||||||
## Why Via?
|
## Why Via?
|
||||||
Somewhere along the way, the web became tangled in layers of JavaScript, build chains, and frameworks stacked on frameworks.
|
|
||||||
|
|
||||||
Via takes a radical stance:
|
The web became tangled in layers of JavaScript, build chains, and frameworks stacked on frameworks. Via takes a different path.
|
||||||
|
|
||||||
- No templates.
|
**Philosophy**
|
||||||
- No JavaScript.
|
- No templates. No JavaScript. No transpilation. No hydration.
|
||||||
- No transpilation.
|
- Views are pure Go functions. HTML is composed with a type-safe DSL.
|
||||||
- No hydration.
|
- A single SSE stream carries all reactivity — no WebSocket juggling, no polling.
|
||||||
- No front-end fatigue.
|
|
||||||
- Single SSE stream.
|
|
||||||
- Full reactivity.
|
|
||||||
- Built-in Brotli compression.
|
|
||||||
- Pure Go.
|
|
||||||
|
|
||||||
|
**Batteries included**
|
||||||
|
- Automatic CSRF protection on every action call
|
||||||
|
- Token-bucket rate limiting (global defaults + per-action overrides)
|
||||||
|
- Cookie-based sessions backed by SQLite
|
||||||
|
- Pub/sub messaging with an embedded NATS backend
|
||||||
|
- Structured logging via zerolog
|
||||||
|
- Graceful shutdown with context draining
|
||||||
|
- Brotli compression out of the box
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
```go
|
```go
|
||||||
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 }
|
||||||
@@ -57,25 +60,43 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## What's built in
|
||||||
|
|
||||||
## 🚧 Experimental
|
- **Reactive views + signals** — bind state to the DOM; changes push over SSE automatically
|
||||||
<s>Via is still a newborn.</s> Via is taking its first steps!
|
- **Components** — self-contained subcontexts with their own data, actions, and signals
|
||||||
- Version `0.1.0` released.
|
- **Sessions** — cookie-based, backed by SQLite via `scs`
|
||||||
- Expect a little less chaos.
|
- **Pub/sub** — embedded NATS server with JetStream; generic `Publish[T]` / `Subscribe[T]` helpers
|
||||||
|
- **CSRF protection** — automatic token generation and validation on every 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
|
||||||
|
- **Timed routines** — `OnInterval` with start/stop/update controls, tied to context lifecycle
|
||||||
|
- **Redirects** — `Redirect`, `ReplaceURL`, and format-string variants
|
||||||
|
- **Plugin system** — `func(v *V)` hooks for integrating CSS/JS libraries
|
||||||
|
- **Structured logging** — zerolog with configurable levels; console output in dev, JSON in production
|
||||||
|
- **Graceful shutdown** — listens for SIGINT/SIGTERM, drains contexts, closes pub/sub
|
||||||
|
- **Context lifecycle** — background reaper cleans up disconnected contexts; configurable TTL
|
||||||
|
- **HTML DSL** — the `h` package provides type-safe Go-native HTML composition
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
The `internal/examples/` directory contains 14 runnable examples:
|
||||||
|
|
||||||
|
`chatroom` · `counter` · `countercomp` · `greeter` · `keyboard` · `livereload` · `nats-chatroom` · `pathparams` · `picocss` · `plugins` · `pubsub-crud` · `realtimechart` · `session` · `shakespeare`
|
||||||
|
|
||||||
|
## Experimental
|
||||||
|
|
||||||
|
Via is maturing — sessions, CSRF, rate limiting, pub/sub, and graceful shutdown are in place — but the API is still evolving. Expect breaking changes before `v1`.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
- Via is intentionally minimal and opinionated — and so is contributing.
|
- Via is intentionally minimal and opinionated — and so is contributing.
|
||||||
- If you love Go, simplicity, and meaningful abstractions — Come along for the ride!
|
- Fork, branch, build, tinker, submit a pull request.
|
||||||
- Fork, branch, build, tinker with things, submit a pull request.
|
|
||||||
- Keep every line purposeful.
|
- Keep every line purposeful.
|
||||||
- Share feedback: open an issue or start a discussion.
|
- Share feedback: open an issue or start a discussion.
|
||||||
|
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
Via builds upon the work of these amazing projects:
|
Via builds upon the work of these projects:
|
||||||
|
|
||||||
- 🚀 [Datastar](https://data-star.dev) - The hypermedia powerhouse at the core of Via. It powers browser reactivity through Signals and enables real-time HTML/Signal patches over an always-on SSE event stream.
|
- [Datastar](https://data-star.dev) — the hypermedia framework powering browser reactivity through signals and real-time HTML patches over SSE.
|
||||||
- 🧩 [Gomponents](https://maragu.dev/gomponents) - The awesome project that gifts Via with Go-native HTML composition superpowers through the `via/h` package.
|
- [Gomponents](https://maragu.dev/gomponents) — Go-native HTML composition that powers the `via/h` package.
|
||||||
|
|
||||||
> Thank you for building something that doesn’t just function — it inspires. 🫶
|
|
||||||
|
|||||||
@@ -61,4 +61,9 @@ type Options struct {
|
|||||||
// connection before the background reaper disposes it.
|
// connection before the background reaper disposes it.
|
||||||
// Default: 30s. Negative value disables the reaper.
|
// Default: 30s. Negative value disables the reaper.
|
||||||
ContextTTL time.Duration
|
ContextTTL time.Duration
|
||||||
|
|
||||||
|
// ActionRateLimit configures the default token-bucket rate limiter for
|
||||||
|
// action endpoints. Zero values use built-in defaults (10 req/s, burst 20).
|
||||||
|
// Set Rate to -1 to disable rate limiting entirely.
|
||||||
|
ActionRateLimit RateLimitConfig
|
||||||
}
|
}
|
||||||
|
|||||||
28
context.go
28
context.go
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Context is the living bridge between Go and the browser.
|
// Context is the living bridge between Go and the browser.
|
||||||
@@ -20,13 +21,15 @@ import (
|
|||||||
type Context struct {
|
type Context struct {
|
||||||
id string
|
id string
|
||||||
route string
|
route string
|
||||||
|
csrfToken string
|
||||||
app *V
|
app *V
|
||||||
view func() h.H
|
view func() h.H
|
||||||
routeParams map[string]string
|
routeParams map[string]string
|
||||||
componentRegistry map[string]*Context
|
componentRegistry map[string]*Context
|
||||||
parentPageCtx *Context
|
parentPageCtx *Context
|
||||||
patchChan chan patch
|
patchChan chan patch
|
||||||
actionRegistry map[string]func()
|
actionLimiter *rate.Limiter
|
||||||
|
actionRegistry map[string]actionEntry
|
||||||
signals *sync.Map
|
signals *sync.Map
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ctxDisposedChan chan struct{}
|
ctxDisposedChan chan struct{}
|
||||||
@@ -103,26 +106,31 @@ func (c *Context) isComponent() bool {
|
|||||||
// h.Button(h.Text("Increment n"), increment.OnClick()),
|
// h.Button(h.Text("Increment n"), increment.OnClick()),
|
||||||
// )
|
// )
|
||||||
// })
|
// })
|
||||||
func (c *Context) Action(f func()) *actionTrigger {
|
func (c *Context) Action(f func(), opts ...ActionOption) *actionTrigger {
|
||||||
id := genRandID()
|
id := genRandID()
|
||||||
if f == nil {
|
if f == nil {
|
||||||
c.app.logErr(c, "failed to bind action '%s' to context: nil func", id)
|
c.app.logErr(c, "failed to bind action '%s' to context: nil func", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entry := actionEntry{fn: f}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&entry)
|
||||||
|
}
|
||||||
|
|
||||||
if c.isComponent() {
|
if c.isComponent() {
|
||||||
c.parentPageCtx.actionRegistry[id] = f
|
c.parentPageCtx.actionRegistry[id] = entry
|
||||||
} else {
|
} else {
|
||||||
c.actionRegistry[id] = f
|
c.actionRegistry[id] = entry
|
||||||
}
|
}
|
||||||
return &actionTrigger{id}
|
return &actionTrigger{id}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Context) getActionFn(id string) (func(), error) {
|
func (c *Context) getAction(id string) (actionEntry, error) {
|
||||||
if f, ok := c.actionRegistry[id]; ok {
|
if e, ok := c.actionRegistry[id]; ok {
|
||||||
return f, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
return nil, 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 go routine that sets a time.Ticker with the given duration and executes
|
||||||
@@ -477,10 +485,12 @@ func newContext(id string, route string, v *V) *Context {
|
|||||||
return &Context{
|
return &Context{
|
||||||
id: id,
|
id: id,
|
||||||
route: route,
|
route: route,
|
||||||
|
csrfToken: genCSRFToken(),
|
||||||
routeParams: make(map[string]string),
|
routeParams: make(map[string]string),
|
||||||
app: v,
|
app: v,
|
||||||
componentRegistry: make(map[string]*Context),
|
componentRegistry: make(map[string]*Context),
|
||||||
actionRegistry: make(map[string]func()),
|
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
|
||||||
|
actionRegistry: make(map[string]actionEntry),
|
||||||
signals: new(sync.Map),
|
signals: new(sync.Map),
|
||||||
patchChan: make(chan patch, 1),
|
patchChan: make(chan patch, 1),
|
||||||
ctxDisposedChan: make(chan struct{}, 1),
|
ctxDisposedChan: make(chan struct{}, 1),
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -14,6 +14,7 @@ require (
|
|||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/starfederation/datastar-go v1.0.3
|
github.com/starfederation/datastar-go v1.0.3
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
golang.org/x/time v0.14.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -37,6 +38,6 @@ require (
|
|||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.45.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
48
ratelimit.go
Normal file
48
ratelimit.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package via
|
||||||
|
|
||||||
|
import "golang.org/x/time/rate"
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultActionRate float64 = 10.0
|
||||||
|
defaultActionBurst int = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimitConfig configures token-bucket rate limiting for actions.
|
||||||
|
// Zero values fall back to defaults. Rate of -1 disables limiting entirely.
|
||||||
|
type RateLimitConfig struct {
|
||||||
|
Rate float64
|
||||||
|
Burst int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionOption configures per-action behaviour when passed to Context.Action.
|
||||||
|
type ActionOption func(*actionEntry)
|
||||||
|
|
||||||
|
type actionEntry struct {
|
||||||
|
fn func()
|
||||||
|
limiter *rate.Limiter // nil = use context default
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRateLimit returns an ActionOption that gives this action its own
|
||||||
|
// token-bucket limiter, overriding the context-level default.
|
||||||
|
func WithRateLimit(r float64, burst int) ActionOption {
|
||||||
|
return func(e *actionEntry) {
|
||||||
|
e.limiter = newLimiter(RateLimitConfig{Rate: r, Burst: burst}, defaultActionRate, defaultActionBurst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLimiter creates a *rate.Limiter from cfg, substituting defaults for zero
|
||||||
|
// values. A Rate of -1 disables limiting (returns nil).
|
||||||
|
func newLimiter(cfg RateLimitConfig, defaultRate float64, defaultBurst int) *rate.Limiter {
|
||||||
|
r := cfg.Rate
|
||||||
|
b := cfg.Burst
|
||||||
|
if r == -1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if r == 0 {
|
||||||
|
r = defaultRate
|
||||||
|
}
|
||||||
|
if b == 0 {
|
||||||
|
b = defaultBurst
|
||||||
|
}
|
||||||
|
return rate.NewLimiter(rate.Limit(r), b)
|
||||||
|
}
|
||||||
101
ratelimit_test.go
Normal file
101
ratelimit_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package via
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewLimiter_Defaults(t *testing.T) {
|
||||||
|
l := newLimiter(RateLimitConfig{}, defaultActionRate, defaultActionBurst)
|
||||||
|
require.NotNil(t, l)
|
||||||
|
assert.InDelta(t, defaultActionRate, float64(l.Limit()), 0.001)
|
||||||
|
assert.Equal(t, defaultActionBurst, l.Burst())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLimiter_CustomValues(t *testing.T) {
|
||||||
|
l := newLimiter(RateLimitConfig{Rate: 5, Burst: 10}, defaultActionRate, defaultActionBurst)
|
||||||
|
require.NotNil(t, l)
|
||||||
|
assert.InDelta(t, 5.0, float64(l.Limit()), 0.001)
|
||||||
|
assert.Equal(t, 10, l.Burst())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLimiter_DisabledWithNegativeRate(t *testing.T) {
|
||||||
|
l := newLimiter(RateLimitConfig{Rate: -1}, defaultActionRate, defaultActionBurst)
|
||||||
|
assert.Nil(t, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenBucket_AllowsBurstThenRejects(t *testing.T) {
|
||||||
|
l := newLimiter(RateLimitConfig{Rate: 1, Burst: 3}, 1, 3)
|
||||||
|
require.NotNil(t, l)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
assert.True(t, l.Allow(), "request %d should be allowed within burst", i)
|
||||||
|
}
|
||||||
|
assert.False(t, l.Allow(), "request beyond burst should be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithRateLimit_CreatesLimiter(t *testing.T) {
|
||||||
|
entry := actionEntry{fn: func() {}}
|
||||||
|
opt := WithRateLimit(2, 4)
|
||||||
|
opt(&entry)
|
||||||
|
|
||||||
|
require.NotNil(t, entry.limiter)
|
||||||
|
assert.InDelta(t, 2.0, float64(entry.limiter.Limit()), 0.001)
|
||||||
|
assert.Equal(t, 4, entry.limiter.Burst())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextAction_WithRateLimit(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
c := newContext("test-rl", "/", v)
|
||||||
|
|
||||||
|
called := false
|
||||||
|
c.Action(func() { called = true }, WithRateLimit(1, 2))
|
||||||
|
|
||||||
|
// Verify the entry has its own limiter
|
||||||
|
for _, entry := range c.actionRegistry {
|
||||||
|
require.NotNil(t, entry.limiter)
|
||||||
|
assert.InDelta(t, 1.0, float64(entry.limiter.Limit()), 0.001)
|
||||||
|
assert.Equal(t, 2, entry.limiter.Burst())
|
||||||
|
}
|
||||||
|
assert.False(t, called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextAction_DefaultNoPerActionLimiter(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
c := newContext("test-no-rl", "/", v)
|
||||||
|
|
||||||
|
c.Action(func() {})
|
||||||
|
|
||||||
|
for _, entry := range c.actionRegistry {
|
||||||
|
assert.Nil(t, entry.limiter, "entry without WithRateLimit should have nil limiter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextLimiter_DefaultsApplied(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
c := newContext("test-ctx-limiter", "/", v)
|
||||||
|
|
||||||
|
require.NotNil(t, c.actionLimiter)
|
||||||
|
assert.InDelta(t, defaultActionRate, float64(c.actionLimiter.Limit()), 0.001)
|
||||||
|
assert.Equal(t, defaultActionBurst, c.actionLimiter.Burst())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextLimiter_DisabledViaConfig(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
v.actionRateLimit = RateLimitConfig{Rate: -1}
|
||||||
|
c := newContext("test-disabled", "/", v)
|
||||||
|
|
||||||
|
assert.Nil(t, c.actionLimiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextLimiter_CustomConfig(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
v.Config(Options{ActionRateLimit: RateLimitConfig{Rate: 50, Burst: 100}})
|
||||||
|
c := newContext("test-custom", "/", v)
|
||||||
|
|
||||||
|
require.NotNil(t, c.actionLimiter)
|
||||||
|
assert.InDelta(t, 50.0, float64(c.actionLimiter.Limit()), 0.001)
|
||||||
|
assert.Equal(t, 100, c.actionLimiter.Burst())
|
||||||
|
}
|
||||||
143
static_test.go
Normal file
143
static_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package via
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatic(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(dir, "sub"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello world"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(dir, "sub", "nested.txt"), []byte("nested"), 0644)
|
||||||
|
|
||||||
|
v := New()
|
||||||
|
v.Static("/assets/", dir)
|
||||||
|
|
||||||
|
t.Run("serves file", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/assets/hello.txt", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "hello world", w.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("serves nested file", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/assets/sub/nested.txt", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "nested", w.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("directory listing returns 404", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/assets/", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("subdirectory listing returns 404", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/assets/sub/", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file returns 404", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/assets/nope.txt", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticAutoSlash(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(dir, "ok.txt"), []byte("ok"), 0644)
|
||||||
|
|
||||||
|
v := New()
|
||||||
|
v.Static("/files", dir) // no trailing slash
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/files/ok.txt", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "ok", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticFS(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"style.css": {Data: []byte("body{}")},
|
||||||
|
"js/app.js": {Data: []byte("console.log('hi')")},
|
||||||
|
}
|
||||||
|
|
||||||
|
v := New()
|
||||||
|
v.StaticFS("/static/", fsys)
|
||||||
|
|
||||||
|
t.Run("serves file", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/static/style.css", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "body{}", w.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("serves nested file", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/static/js/app.js", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "console.log('hi')", w.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("directory listing returns 404", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/static/", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file returns 404", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/static/nope.css", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticFSAutoSlash(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"ok.txt": {Data: []byte("ok")},
|
||||||
|
}
|
||||||
|
|
||||||
|
v := New()
|
||||||
|
v.StaticFS("/embed", fsys) // no trailing slash
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/embed/ok.txt", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "ok", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify StaticFS accepts the fs.FS interface (compile-time check).
|
||||||
|
var _ fs.FS = fstest.MapFS{}
|
||||||
76
via.go
76
via.go
@@ -9,11 +9,13 @@ package via
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -47,6 +49,7 @@ type V struct {
|
|||||||
devModePageInitFnMap map[string]func(*Context)
|
devModePageInitFnMap map[string]func(*Context)
|
||||||
sessionManager *scs.SessionManager
|
sessionManager *scs.SessionManager
|
||||||
pubsub PubSub
|
pubsub PubSub
|
||||||
|
actionRateLimit RateLimitConfig
|
||||||
datastarPath string
|
datastarPath string
|
||||||
datastarContent []byte
|
datastarContent []byte
|
||||||
datastarOnce sync.Once
|
datastarOnce sync.Once
|
||||||
@@ -131,6 +134,9 @@ func (v *V) Config(cfg Options) {
|
|||||||
if cfg.ContextTTL != 0 {
|
if cfg.ContextTTL != 0 {
|
||||||
v.cfg.ContextTTL = cfg.ContextTTL
|
v.cfg.ContextTTL = cfg.ContextTTL
|
||||||
}
|
}
|
||||||
|
if cfg.ActionRateLimit.Rate != 0 || cfg.ActionRateLimit.Burst != 0 {
|
||||||
|
v.actionRateLimit = cfg.ActionRateLimit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -203,7 +209,7 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
|
|||||||
headElements := []h.H{h.Script(h.Type("module"), h.Src(v.datastarPath))}
|
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','via-csrf':'%s'}", id, c.csrfToken))),
|
||||||
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))),
|
||||||
@@ -407,6 +413,46 @@ func (v *V) HTTPServeMux() *http.ServeMux {
|
|||||||
return v.mux
|
return v.mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static serves files from a filesystem directory at the given URL prefix.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// v.Static("/assets/", "./public")
|
||||||
|
func (v *V) Static(urlPrefix, dir string) {
|
||||||
|
if !strings.HasSuffix(urlPrefix, "/") {
|
||||||
|
urlPrefix += "/"
|
||||||
|
}
|
||||||
|
fileServer := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir)))
|
||||||
|
v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaticFS serves files from an [fs.FS] at the given URL prefix.
|
||||||
|
// This is useful with //go:embed filesystems.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// //go:embed static
|
||||||
|
// var staticFiles embed.FS
|
||||||
|
// v.StaticFS("/assets/", staticFiles)
|
||||||
|
func (v *V) StaticFS(urlPrefix string, fsys fs.FS) {
|
||||||
|
if !strings.HasSuffix(urlPrefix, "/") {
|
||||||
|
urlPrefix += "/"
|
||||||
|
}
|
||||||
|
fileServer := http.StripPrefix(urlPrefix, http.FileServerFS(fsys))
|
||||||
|
v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// noDirListing wraps a file server handler to return 404 for directory requests.
|
||||||
|
func noDirListing(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (v *V) ensureDatastarHandler() {
|
func (v *V) ensureDatastarHandler() {
|
||||||
v.datastarOnce.Do(func() {
|
v.datastarOnce.Do(func() {
|
||||||
v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {
|
v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -632,13 +678,29 @@ func New() *V {
|
|||||||
v.logErr(nil, "action '%s' failed: %v", actionID, err)
|
v.logErr(nil, "action '%s' failed: %v", actionID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
csrfToken, _ := sigs["via-csrf"].(string)
|
||||||
|
if subtle.ConstantTimeCompare([]byte(csrfToken), []byte(c.csrfToken)) != 1 {
|
||||||
|
v.logWarn(c, "action '%s' rejected: invalid CSRF token", actionID)
|
||||||
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.actionLimiter != nil && !c.actionLimiter.Allow() {
|
||||||
|
v.logWarn(c, "action '%s' rate limited", actionID)
|
||||||
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
c.reqCtx = r.Context()
|
c.reqCtx = r.Context()
|
||||||
actionFn, err := c.getActionFn(actionID)
|
entry, err := c.getAction(actionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.logDebug(c, "action '%s' failed: %v", actionID, err)
|
v.logDebug(c, "action '%s' failed: %v", actionID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// log err if actionFn panics
|
if entry.limiter != nil && !entry.limiter.Allow() {
|
||||||
|
v.logWarn(c, "action '%s' rate limited (per-action)", actionID)
|
||||||
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// log err if action panics
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
v.logErr(c, "action '%s' failed: %v", actionID, r)
|
v.logErr(c, "action '%s' failed: %v", actionID, r)
|
||||||
@@ -646,7 +708,7 @@ func New() *V {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
c.injectSignals(sigs)
|
c.injectSignals(sigs)
|
||||||
actionFn()
|
entry.fn()
|
||||||
})
|
})
|
||||||
|
|
||||||
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) {
|
||||||
@@ -675,6 +737,12 @@ func genRandID() string {
|
|||||||
return hex.EncodeToString(b)[:8]
|
return hex.EncodeToString(b)[:8]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func genCSRFToken() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
func extractParams(pattern, path string) map[string]string {
|
func extractParams(pattern, path string) map[string]string {
|
||||||
p := strings.Split(strings.Trim(pattern, "/"), "/")
|
p := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||||
u := strings.Split(strings.Trim(path, "/"), "/")
|
u := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
|||||||
Reference in New Issue
Block a user