2 Commits

Author SHA1 Message Date
Ryan Hamamura
f5158b866c feat: add Static and StaticFS helpers for serving static files
One-liner static file serving: v.Static("/assets/", "./public") for
filesystem directories and v.StaticFS("/assets/", fsys) for embed.FS.
Both auto-normalize the URL prefix and disable directory listings.
2026-02-06 13:22:00 -10:00
Ryan Hamamura
2f6c5916ce docs: rewrite README with correct import paths and current feature set 2026-02-06 12:56:31 -10:00
3 changed files with 233 additions and 28 deletions

View File

@@ -1,30 +1,33 @@
# Via
# Via
Real-time engine for building reactive web applications in pure Go.
## 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.
- No JavaScript.
- No transpilation.
- No hydration.
- No front-end fatigue.
- Single SSE stream.
- Full reactivity.
- Built-in Brotli compression.
- Pure Go.
**Philosophy**
- No templates. No JavaScript. No transpilation. No hydration.
- Views are pure Go functions. HTML is composed with a type-safe DSL.
- A single SSE stream carries all reactivity — no WebSocket juggling, no polling.
**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
```go
package main
import (
"github.com/go-via/via"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
type Counter struct{ Count int }
@@ -57,25 +60,43 @@ func main() {
}
```
## What's built in
## 🚧 Experimental
<s>Via is still a newborn.</s> Via is taking its first steps!
- Version `0.1.0` released.
- Expect a little less chaos.
- **Reactive views + signals** — bind state to the DOM; changes push over SSE automatically
- **Components** — self-contained subcontexts with their own data, actions, and signals
- **Sessions** — cookie-based, backed by SQLite via `scs`
- **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
- 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 with things, submit a pull request.
- Fork, branch, build, tinker, submit a pull request.
- Keep every line purposeful.
- Share feedback: open an issue or start a discussion.
## 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.
- 🧩 [Gomponents](https://maragu.dev/gomponents) - The awesome project that gifts Via with Go-native HTML composition superpowers through the `via/h` package.
> Thank you for building something that doesnt just function — it inspires. 🫶
- [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) Go-native HTML composition that powers the `via/h` package.

143
static_test.go Normal file
View 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{}

43
via.go
View File

@@ -9,12 +9,13 @@ package via
import (
"context"
"crypto/rand"
_ "embed"
"crypto/subtle"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
@@ -412,6 +413,46 @@ func (v *V) HTTPServeMux() *http.ServeMux {
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() {
v.datastarOnce.Do(func() {
v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {