diff --git a/transport/http/server.go b/transport/http/server.go index d26ac75..3f3e8c0 100644 --- a/transport/http/server.go +++ b/transport/http/server.go @@ -3,13 +3,17 @@ package http import ( "context" "fmt" + "io" "net" "net/http" "net/http/pprof" "net/url" "os" "path" + "path/filepath" + "slices" "strconv" + "strings" "sync" "time" @@ -106,14 +110,61 @@ func (s *Server) Webroot(prefix string, fs http.FileSystem) { s.fs.SetIndexFile("/index.html") } +func (s *Server) shouldCompress(req *http.Request) bool { + if !strings.Contains(req.Header.Get(headerAcceptEncoding), "gzip") || + strings.Contains(req.Header.Get("Connection"), "Upgrade") { + return false + } + + // Check if the request path is excluded from compression + extension := filepath.Ext(req.URL.Path) + if slices.Contains(assetsExtensions, extension) { + return true + } + return false +} + +func (s *Server) staticHandle(ctx *gin.Context, fp http.File) { + uri := path.Clean(ctx.Request.URL.Path) + fi, err := fp.Stat() + if err != nil { + return + } + if !fi.IsDir() { + //https://github.com/gin-contrib/gzip + if s.shouldCompress(ctx.Request) && fi.Size() > 8192 { + gzWriter := newGzipWriter() + gzWriter.Reset(ctx.Writer) + ctx.Header(headerContentEncoding, "gzip") + ctx.Writer.Header().Add(headerVary, headerAcceptEncoding) + originalEtag := ctx.GetHeader("ETag") + if originalEtag != "" && !strings.HasPrefix(originalEtag, "W/") { + ctx.Header("ETag", "W/"+originalEtag) + } + ctx.Writer = &gzipWriter{ctx.Writer, gzWriter} + defer func() { + if ctx.Writer.Size() < 0 { + gzWriter.Reset(io.Discard) + } + gzWriter.Close() + if ctx.Writer.Size() > -1 { + ctx.Header("Content-Length", strconv.Itoa(ctx.Writer.Size())) + } + putGzipWriter(gzWriter) + }() + } + } + http.ServeContent(ctx.Writer, ctx.Request, path.Base(uri), s.fs.modtime, fp) + ctx.Abort() + return +} + func (s *Server) notFoundHandle(ctx *gin.Context) { if s.fs != nil && ctx.Request.Method == http.MethodGet { uri := path.Clean(ctx.Request.URL.Path) if fp, err := s.fs.Open(uri); err == nil { - http.ServeContent(ctx.Writer, ctx.Request, path.Base(uri), s.fs.modtime, fp) + s.staticHandle(ctx, fp) fp.Close() - ctx.Abort() - return } } ctx.JSON(http.StatusNotFound, newResponse(errors.NotFound, "Not Found", nil)) diff --git a/transport/http/types.go b/transport/http/types.go index c915494..1a4ba05 100644 --- a/transport/http/types.go +++ b/transport/http/types.go @@ -1,8 +1,14 @@ package http import ( + "bufio" + "compress/gzip" "context" + "errors" + "io" + "net" "net/http" + "sync" "git.nobla.cn/golang/aeus/pkg/logger" "github.com/gin-gonic/gin" @@ -41,6 +47,77 @@ type ( } ) +const ( + headerAcceptEncoding = "Accept-Encoding" + headerContentEncoding = "Content-Encoding" + headerVary = "Vary" +) + +var ( + gzPool sync.Pool + assetsExtensions = []string{".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".otf"} +) + +type gzipWriter struct { + gin.ResponseWriter + writer *gzip.Writer +} + +func (g *gzipWriter) WriteString(s string) (int, error) { + g.Header().Del("Content-Length") + return g.writer.Write([]byte(s)) +} + +func (g *gzipWriter) Write(data []byte) (int, error) { + g.Header().Del("Content-Length") + return g.writer.Write(data) +} + +func (g *gzipWriter) Flush() { + _ = g.writer.Flush() + g.ResponseWriter.Flush() +} + +// Fix: https://github.com/mholt/caddy/issues/38 +func (g *gzipWriter) WriteHeader(code int) { + g.Header().Del("Content-Length") + g.ResponseWriter.WriteHeader(code) +} + +var _ http.Hijacker = (*gzipWriter)(nil) + +// Hijack allows the caller to take over the connection from the HTTP server. +// After a call to Hijack, the HTTP server library will not do anything else with the connection. +// It becomes the caller's responsibility to manage and close the connection. +// +// It returns the underlying net.Conn, a buffered reader/writer for the connection, and an error +// if the ResponseWriter does not support the Hijacker interface. +func (g *gzipWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := g.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, errors.New("the ResponseWriter doesn't support the Hijacker interface") + } + return hijacker.Hijack() +} + +func newGzipWriter() (writer *gzip.Writer) { + v := gzPool.Get() + if v == nil { + writer, _ = gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression) + } else { + if w, ok := v.(*gzip.Writer); ok { + return w + } else { + writer, _ = gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression) + } + } + return +} + +func putGzipWriter(writer *gzip.Writer) { + gzPool.Put(writer) +} + func WithNetwork(network string) Option { return func(o *options) { o.network = network