Web : réponses compressées en gzip

Repris d'AJEAN 0.15.7 (web_gzip.go et son test, tels quels).

L'UI (~590 Ko de HTML/JS/CSS en un seul fichier) et les gros JSON
(historique relu, liste des sessions, journal) partaient en clair. gzip
les divise par 4 à 8 — ça se sent sur un téléphone en Wi-Fi ou à travers
le tunnel.

La décision se prend au premier octet, sur le Content-Type posé par le
handler : les flux SSE (chat, complétions /v1 en streaming) et les
binaires passent tels quels — un flux compressé retiendrait ses
événements dans le tampon de gzip. Flush reste transmis pour les réponses
progressives, et Unwrap laisse le WebSocket des postes distants atteindre
le Hijacker d'origine.

Seul l'écouteur local est enveloppé : le tunnel sert le mux nu, comme
avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-02 23:05:34 +02:00
1 parent 38802d9e21
commit f75f3cf27d
3 files changed
+163 -1

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
package loki
import (
"compress/gzip"
"net/http"
"strings"
"sync"
)
// web_gzip.go — compression gzip des réponses du serveur web LOCAL.
//
// L'interface (≈ 850 Ko de HTML/JS/CSS) et les réponses JSON volumineuses (liste
// des sessions, résultats d'outils, journal d'une conversation relue) partaient en
// clair. gzip les divise typiquement par 4 à 8, ce qui se sent sur un téléphone en
// Wi-Fi. La décision se prend au PREMIER octet, d'après le Content-Type choisi par
// le handler : les flux (text/event-stream), les binaires et ce qui est déjà
// compressé passent tels quels — un flux SSE compressé retiendrait ses événements
// dans le tampon de gzip au lieu de les livrer en direct.
var gzPool = sync.Pool{New: func() any { w, _ := gzip.NewWriterLevel(nil, gzip.BestSpeed); return w }}
func gzipHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || r.Method == http.MethodHead {
next.ServeHTTP(w, r)
return
}
gw := &gzipWriter{ResponseWriter: w}
defer gw.finish()
next.ServeHTTP(gw, r)
})
}
type gzipWriter struct {
http.ResponseWriter
decided bool
gz *gzip.Writer
}
// compressible : types textuels uniquement, jamais un flux ni un contenu déjà encodé.
func compressible(h http.Header) bool {
if h.Get("Content-Encoding") != "" {
return false
}
ct := strings.ToLower(h.Get("Content-Type"))
if strings.HasPrefix(ct, "text/event-stream") {
return false
}
return strings.HasPrefix(ct, "text/") || strings.Contains(ct, "json") ||
strings.Contains(ct, "javascript") || strings.Contains(ct, "xml") || strings.Contains(ct, "manifest")
}
func (g *gzipWriter) decide() {
if g.decided {
return
}
g.decided = true
h := g.Header()
h.Add("Vary", "Accept-Encoding")
if !compressible(h) {
return
}
h.Set("Content-Encoding", "gzip")
h.Del("Content-Length")
gz := gzPool.Get().(*gzip.Writer)
gz.Reset(g.ResponseWriter)
g.gz = gz
}
func (g *gzipWriter) WriteHeader(code int) {
// Pas de corps pour ces statuts : ne jamais annoncer un encodage.
if code == http.StatusNoContent || code == http.StatusNotModified || code < 200 {
g.decided = true
}
g.decide()
g.ResponseWriter.WriteHeader(code)
}
func (g *gzipWriter) Write(b []byte) (int, error) {
g.decide()
if g.gz != nil {
return g.gz.Write(b)
}
return g.ResponseWriter.Write(b)
}
// Flush : les handlers qui diffusent (SSE) doivent continuer à pouvoir vider leur
// tampon ; dans ce cas ils ne sont de toute façon pas compressés (voir compressible).
func (g *gzipWriter) Flush() {
if g.gz != nil {
_ = g.gz.Flush()
}
if f, ok := g.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap laisse http.ResponseController atteindre le writer d'origine.
func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.ResponseWriter }
func (g *gzipWriter) finish() {
if g.gz != nil {
_ = g.gz.Close()
g.gz.Reset(nil)
gzPool.Put(g.gz)
g.gz = nil
}
}
+52
View File
@@ -0,0 +1,52 @@
package loki
import (
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// JSON compressé, flux SSE et binaires laissés tels quels.
func TestGzipHandler(t *testing.T) {
h := gzipHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/json":
sendJSON(w, 200, map[string]any{"x": strings.Repeat("a", 5000)})
case "/sse":
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: 1\n\n"))
w.(http.Flusher).Flush()
case "/bin":
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte{1, 2, 3})
}
}))
get := func(p string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", p, nil)
r.Header.Set("Accept-Encoding", "gzip, deflate")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
return rec
}
j := get("/json")
if j.Header().Get("Content-Encoding") != "gzip" {
t.Fatal("JSON non compressé")
}
zr, err := gzip.NewReader(j.Body)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(zr)
if !strings.Contains(string(b), strings.Repeat("a", 5000)) || j.Body.Len() > 500 {
t.Fatalf("décompression/ratio inattendus (%d o compressés)", j.Body.Len())
}
if s := get("/sse"); s.Header().Get("Content-Encoding") != "" || s.Body.String() != "data: 1\n\n" || !s.Flushed {
t.Fatal("le flux SSE ne doit pas être compressé et doit rester vidable")
}
if b := get("/bin"); b.Header().Get("Content-Encoding") != "" {
t.Fatal("binaire compressé à tort")
}
}
+3 -1
View File
@@ -89,7 +89,9 @@ func cmdWeb(args []string) error {
// restent ouverts aussi longtemps que l'utilisateur regarde la page — et
// depuis mountOAI, il couperait aussi les complétions en streaming des
// clients tiers, en plein milieu d'une génération.
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
// gzipHandler : l'UI (~590 Ko) et les gros JSON partent compressés ; les flux
// SSE et les binaires passent tels quels (voir web_gzip.go).
srv := &http.Server{Handler: gzipHandler(mux), ReadHeaderTimeout: 10 * time.Second}
return srv.Serve(ln)
}