diff --git a/README.md b/README.md index f322098..91ee405 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Via takes a radical stance: - No front-end fatigue. - Single SSE stream. - Full reactivity. +- Built-in Brotli compression. - Pure Go. diff --git a/go.mod b/go.mod index a35921e..f012656 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.25.4 require maragu.dev/gomponents v1.2.0 require ( + github.com/CAFxX/httpcompression v0.0.9 + github.com/andybalholm/brotli v1.2.0 github.com/fsnotify/fsnotify v1.9.0 github.com/go-via/via-plugin-picocss v0.0.0-20251112183909-4485ba2e31d8 github.com/starfederation/datastar-go v1.0.3 @@ -12,12 +14,11 @@ require ( ) require ( - github.com/CAFxX/httpcompression v0.0.9 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/kr/pretty v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/sys v0.13.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 69e7967..78396f1 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,11 @@ github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQs github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -37,10 +42,9 @@ github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/via.go b/via.go index 8ea2b99..50f0047 100644 --- a/via.go +++ b/via.go @@ -18,6 +18,7 @@ import ( "strings" "sync" + "github.com/CAFxX/httpcompression" "github.com/go-via/via/h" "github.com/starfederation/datastar-go/datastar" ) @@ -30,6 +31,7 @@ var datastarJS []byte type V struct { cfg Options mux *http.ServeMux + handler http.Handler contextRegistry map[string]*Context contextRegistryMutex sync.Mutex documentHeadIncludes []h.H @@ -203,7 +205,7 @@ func (v *V) Start() { v.devModeRestore() } v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress) - log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.mux)) + log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.handler)) } func (v *V) devModePersist(c *Context) { @@ -274,8 +276,19 @@ func (v *V) devModeRestore() { // New creates a new *V application with default configuration. func New() *V { mux := http.NewServeMux() + + compressionAdapter, err := httpcompression.DefaultAdapter( + httpcompression.MinSize(1024), + httpcompression.BrotliCompressionLevel(5), + httpcompression.ZstandardCompressor(nil), + ) + if err != nil { + log.Fatalf("failed to create compression adapter: %v", err) + } + v := &V{ mux: mux, + handler: compressionAdapter(mux), contextRegistry: make(map[string]*Context), devModePageInitFnMap: make(map[string]func(*Context)), cfg: Options{ diff --git a/via_test.go b/via_test.go new file mode 100644 index 0000000..ad0cc25 --- /dev/null +++ b/via_test.go @@ -0,0 +1,165 @@ +package via + +import ( + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/andybalholm/brotli" + "github.com/go-via/via/h" + "github.com/stretchr/testify/assert" +) + +func TestCompressionBrotli(t *testing.T) { + v := New() + v.Page("/", func(c *Context) { + c.View(func() h.H { + return h.Div(h.Text(strings.Repeat("Hello Via! ", 200))) + }) + }) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Encoding", "br") + w := httptest.NewRecorder() + + v.handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "br", w.Header().Get("Content-Encoding")) + + reader := brotli.NewReader(w.Body) + decompressed, err := io.ReadAll(reader) + assert.NoError(t, err) + assert.Contains(t, string(decompressed), "Hello Via!") +} + +func TestCompressionGzip(t *testing.T) { + v := New() + v.Page("/", func(c *Context) { + c.View(func() h.H { + return h.Div(h.Text(strings.Repeat("Hello Via! ", 200))) + }) + }) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + + v.handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "gzip", w.Header().Get("Content-Encoding")) + + reader, err := gzip.NewReader(w.Body) + assert.NoError(t, err) + decompressed, err := io.ReadAll(reader) + assert.NoError(t, err) + assert.Contains(t, string(decompressed), "Hello Via!") +} + +func TestCompressionNone(t *testing.T) { + v := New() + v.Page("/", func(c *Context) { + c.View(func() h.H { + return h.Div(h.Text(strings.Repeat("Hello Via! ", 200))) + }) + }) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + v.handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Empty(t, w.Header().Get("Content-Encoding")) + assert.Contains(t, w.Body.String(), "Hello Via!") +} + +func TestCompressionMinSize(t *testing.T) { + v := New() + v.Page("/", func(c *Context) { + c.View(func() h.H { + return h.Div(h.Text("Small")) + }) + }) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Encoding", "br") + w := httptest.NewRecorder() + + v.handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Empty(t, w.Header().Get("Content-Encoding")) +} + +func TestCompressionLargeResponse(t *testing.T) { + v := New() + v.Page("/", func(c *Context) { + c.View(func() h.H { + return h.Div(h.Text(strings.Repeat("A", 2000))) + }) + }) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Encoding", "br") + w := httptest.NewRecorder() + + v.handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "br", w.Header().Get("Content-Encoding")) +} + +func TestCompressionDatastarJS(t *testing.T) { + v := New() + + req := httptest.NewRequest("GET", "/_datastar.js", nil) + req.Header.Set("Accept-Encoding", "br") + w := httptest.NewRecorder() + + v.handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "br", w.Header().Get("Content-Encoding")) +} + +func TestCompressionBrotliPreferred(t *testing.T) { + v := New() + v.Page("/", func(c *Context) { + c.View(func() h.H { + return h.Div(h.Text(strings.Repeat("Hello Via! ", 200))) + }) + }) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Encoding", "gzip, br") + w := httptest.NewRecorder() + + v.handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "br", w.Header().Get("Content-Encoding")) +} + +func TestCompressionZstdDisabled(t *testing.T) { + v := New() + v.Page("/", func(c *Context) { + c.View(func() h.H { + return h.Div(h.Text(strings.Repeat("Hello Via! ", 200))) + }) + }) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Encoding", "zstd, br, gzip") + w := httptest.NewRecorder() + + v.handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "br", w.Header().Get("Content-Encoding"), "Should use Brotli, not zstd") + assert.NotEqual(t, "zstd", w.Header().Get("Content-Encoding")) +}