@marcel91200 / caddy Mirror · read-only / commits / df44d6c3
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 08:30 UTC

encode: flush headers immediately for server-sent events responses (#7905)

* encode: flush headers immediately for server-sent events responses The encode middleware withholds the response header until the first body write so it can sniff content-type and apply the minimum_length threshold. For a text/event-stream response the upstream typically writes headers and flushes to establish the event stream before any event body is available, so the client never received the headers and the stream stalled; the same buffering also delayed individual events. When WriteHeader sees a text/event-stream content type, initialize encoding and write the header through immediately. Forcing the header out also marks the response as started, so subsequent event writes bypass the minimum_length buffering and stream to the client as they arrive. Fixes #6293 * encode: add WriteHeader benchmark covering SSE fast path * encode: replace mime.ParseMediaType with bound-checked SSE check WriteHeader runs an SSE Content-Type check on every call once headers haven't been written yet. mime.ParseMediaType parses the full media type, including parameters, even when nothing matches, which shows up on the hot header-write path. Replace it with a bound-checked manual prefix/boundary check (isSSE), skipping parameter parsing for the common non-SSE case. * encode: reject content types with junk after text/event-stream isSSE accepted any suffix after a space, so a value like "text/event-stream nonsense" was treated as an SSE response. After the media type, skip optional whitespace and require either the end of the value or a parameter separator. The check remains allocation-free, so the hot-path motivation for the manual matcher is preserved. --------- Co-authored-by: SillyZir <269283839+SillyZir@users.noreply.github.com> Co-authored-by: Kévin Dunglas <kevin@les-tilleuls.coop>

SillyZir committed Jul 31, 2026 at 10:27 UTC df44d6c3837ee66c1aa5a0ee9e659bf030c91de9
3 files changed +249 -2
modules/caddyhttp/encode/encode.go
+29 -2
@@ -34,6 +34,8 @@ import (
34 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
35 )
36
37 +const sseMediaType = "text/event-stream"
38 +
39 func init() {
40 caddy.RegisterModule(Encode{})
41 }
@@ -262,12 +264,14 @@ func (rw *responseWriter) WriteHeader(status int) {
264 rw.disabled = true // partial representations must not be dynamically re-encoded
265 }
266
267 + h := rw.Header()
268 +
269 // See #5849 and RFC 9110 section 15.4.5 (https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4.5) - 304
270 // Not Modified must have certain headers set as if it was a 200 response, and according to the issue
271 // we would miss the Vary header in this case when compression was also enabled; note that we set this
272 // header in the responseWriter.init() method but that is only called if we are writing a response body
269 - if status == http.StatusNotModified && !hasVaryValue(rw.Header(), "Accept-Encoding") {
270 - rw.Header().Add("Vary", "Accept-Encoding")
273 + if status == http.StatusNotModified && !hasVaryValue(h, "Accept-Encoding") {
274 + h.Add("Vary", "Accept-Encoding")
275 }
276
277 // write status immediately if status is 2xx and the request is CONNECT
@@ -283,6 +287,29 @@ func (rw *responseWriter) WriteHeader(status int) {
287 if 100 <= status && status <= 199 {
288 rw.ResponseWriter.WriteHeader(status)
289 }
290 +
291 + // write header immediately for server-sent events responses, since the
292 + // body may not be written for a while and the client needs the headers
293 + // to establish the event stream; see #6293
294 + if !rw.wroteHeader && (status < 100 || status > 199) && isSSE(h.Get("Content-Type")) {
295 + rw.init()
296 + rw.ResponseWriter.WriteHeader(status)
297 + rw.wroteHeader = true
298 + }
299 +}
300 +
301 +func isSSE(contentType string) bool {
302 + if len(contentType) < len(sseMediaType) || !strings.EqualFold(contentType[:len(sseMediaType)], sseMediaType) {
303 + return false
304 + }
305 +
306 + // After the media type, allow only optional whitespace followed by the
307 + // end of the value or a parameter separator, so a longer type such as
308 + // "text/event-streamfoo" or garbage like "text/event-stream nonsense"
309 + // does not match. TrimLeft on the tail does not allocate.
310 + rest := strings.TrimLeft(contentType[len(sseMediaType):], " \t")
311 +
312 + return rest == "" || rest[0] == ';'
313 }
314
315 // Match determines, if encoding should be done based on the ResponseMatcher.
modules/caddyhttp/encode/encode_test.go
+86
@@ -20,6 +20,92 @@ func BenchmarkOpenResponseWriter(b *testing.B) {
20 }
21 }
22
23 +// discardResponseWriter is a minimal http.ResponseWriter used to isolate
24 +// WriteHeader's own cost from a real transport.
25 +type discardResponseWriter struct {
26 + header http.Header
27 +}
28 +
29 +func (w *discardResponseWriter) Header() http.Header { return w.header }
30 +func (w *discardResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
31 +func (w *discardResponseWriter) WriteHeader(int) {}
32 +
33 +// BenchmarkResponseWriterWriteHeader covers the branches inside WriteHeader:
34 +// the plain/common case, the SSE Content-Type check (both when it doesn't
35 +// match and when it does and rw.init() runs), CONNECT 2xx, informational
36 +// (1xx), and 304 Not Modified (Vary bookkeeping).
37 +func BenchmarkResponseWriterWriteHeader(b *testing.B) {
38 + benchCases := []struct {
39 + name string
40 + encoding string
41 + isConnect bool
42 + status int
43 + contentType string
44 + }{
45 + {name: "plain", encoding: "test", status: http.StatusOK},
46 + {name: "html", encoding: "test", status: http.StatusOK, contentType: "text/html; charset=utf-8"},
47 + {name: "event-stream", encoding: "gzip", status: http.StatusOK, contentType: "text/event-stream"},
48 + {name: "connect", encoding: "test", isConnect: true, status: http.StatusOK},
49 + {name: "informational", encoding: "test", status: http.StatusEarlyHints},
50 + {name: "not-modified", encoding: "test", status: http.StatusNotModified},
51 + }
52 +
53 + for _, bc := range benchCases {
54 + b.Run(bc.name, func(b *testing.B) {
55 + enc := new(Encode)
56 + if bc.name == "event-stream" {
57 + enc.writerPools = map[string]*sync.Pool{
58 + "gzip": {New: func() any { return mockEncoder{} }},
59 + }
60 + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
61 + defer cancel()
62 + if err := enc.Provision(ctx); err != nil {
63 + b.Fatalf("Provision() error = %v", err)
64 + }
65 + }
66 +
67 + w := &discardResponseWriter{header: make(http.Header)}
68 + rw := enc.openResponseWriter(bc.encoding, w, bc.isConnect)
69 +
70 + for b.Loop() {
71 + for k := range rw.Header() {
72 + delete(rw.Header(), k)
73 + }
74 + if bc.contentType != "" {
75 + rw.Header().Set("Content-Type", bc.contentType)
76 + }
77 + rw.wroteHeader = false
78 + rw.statusCode = 0
79 + rw.disabled = false
80 + rw.w = nil
81 + rw.WriteHeader(bc.status)
82 + }
83 + })
84 + }
85 +}
86 +
87 +func TestIsSSE(t *testing.T) {
88 + for _, tc := range []struct {
89 + contentType string
90 + want bool
91 + }{
92 + {"", false},
93 + {"text/plain", false},
94 + {"text/event-stream", true},
95 + {"Text/Event-Stream", true},
96 + {"text/event-stream; charset=utf-8", true},
97 + {"text/event-stream ; charset=utf-8", true},
98 + {"text/event-stream ", true},
99 + {"text/event-streamfoo", false},
100 + {"text/event-stream nonsense", false},
101 + {"text/event-stream x; charset=utf-8", false},
102 + } {
103 + if got := isSSE(tc.contentType); got != tc.want {
104 + t.Errorf("isSSE(%q) = %v, want %v", tc.contentType, got, tc.want)
105 + }
106 + }
107 +}
108 +
109 func TestPreferOrder(t *testing.T) {
110 testCases := []struct {
111 name string
modules/caddyhttp/encode/sse_test.go new
+134
@@ -0,0 +1,134 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package encode_test
16 +
17 +import (
18 + "net/http"
19 + "net/http/httptest"
20 + "testing"
21 +
22 + "github.com/caddyserver/caddy/v2"
23 + "github.com/caddyserver/caddy/v2/caddyconfig"
24 + "github.com/caddyserver/caddy/v2/modules/caddyhttp"
25 + "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
26 + caddygzip "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/gzip"
27 +)
28 +
29 +// recordingWriter records the moment WriteHeader reaches the underlying
30 +// writer, so a test can distinguish "headers flushed to the client" from
31 +// "headers still buffered inside the encoder".
32 +type recordingWriter struct {
33 + http.ResponseWriter
34 + wroteHeader bool
35 + status int
36 +}
37 +
38 +func (rw *recordingWriter) WriteHeader(status int) {
39 + if !rw.wroteHeader {
40 + rw.wroteHeader = true
41 + rw.status = status
42 + }
43 + rw.ResponseWriter.WriteHeader(status)
44 +}
45 +
46 +func (rw *recordingWriter) Flush() {
47 + if f, ok := rw.ResponseWriter.(http.Flusher); ok {
48 + f.Flush()
49 + }
50 +}
51 +
52 +func newSSEEncodeHandler(t *testing.T) *encode.Encode {
53 + t.Helper()
54 + enc := &encode.Encode{
55 + EncodingsRaw: caddy.ModuleMap{
56 + "gzip": caddyconfig.JSON(caddygzip.Gzip{}, nil),
57 + },
58 + Prefer: []string{"gzip"},
59 + // A large minimum_length means a normal small response would be
60 + // buffered (its header withheld) until enough bytes arrive; the SSE
61 + // path must bypass this so the handshake reaches the client.
62 + MinLength: 4096,
63 + }
64 + ctx, cancel := caddy.NewContext(caddy.Context{Context: t.Context()})
65 + t.Cleanup(cancel)
66 + if err := enc.Provision(ctx); err != nil {
67 + t.Fatalf("Provision() error = %v", err)
68 + }
69 + if err := enc.Validate(); err != nil {
70 + t.Fatalf("Validate() error = %v", err)
71 + }
72 + return enc
73 +}
74 +
75 +// An SSE upstream typically writes headers and flushes to establish the
76 +// event stream before any event body is available. The encode middleware
77 +// must let those headers reach the client immediately rather than holding
78 +// them for minimum_length content sniffing. See #6293.
79 +func TestSSEHeadersFlushedBeforeBody(t *testing.T) {
80 + enc := newSSEEncodeHandler(t)
81 + r := httptest.NewRequest(http.MethodGet, "/", nil)
82 + r.Header.Set("Accept-Encoding", "gzip")
83 + baseRec := httptest.NewRecorder()
84 + rec := &recordingWriter{ResponseWriter: baseRec}
85 +
86 + next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error {
87 + w.Header().Set("Content-Type", "text/event-stream")
88 + w.WriteHeader(http.StatusOK)
89 + // Flush the way a real SSE handler does — through the response
90 + // controller (encode's writer implements FlushError, not Flush).
91 + if err := http.NewResponseController(w).Flush(); err != nil {
92 + t.Errorf("flush failed: %v", err)
93 + }
94 + // Before any event body is written, the client must already have the
95 + // headers AND the flush must have reached the underlying writer.
96 + if !rec.wroteHeader {
97 + t.Error("SSE response headers were not written to the client before the body")
98 + }
99 + if rec.status != http.StatusOK {
100 + t.Errorf("underlying status = %d, want 200", rec.status)
101 + }
102 + if !baseRec.Flushed {
103 + t.Error("underlying ResponseRecorder was not flushed for the SSE handshake")
104 + }
105 + return nil
106 + })
107 +
108 + if err := enc.ServeHTTP(rec, r, next); err != nil {
109 + t.Fatalf("ServeHTTP() error = %v", err)
110 + }
111 +}
112 +
113 +// A normal (non-SSE) small response is still allowed to buffer its header
114 +// for content sniffing — the SSE change must not force every response to
115 +// flush its header early. This guards the scope of the fix.
116 +func TestNonSSESmallResponseStillBuffersHeader(t *testing.T) {
117 + enc := newSSEEncodeHandler(t)
118 + r := httptest.NewRequest(http.MethodGet, "/", nil)
119 + r.Header.Set("Accept-Encoding", "gzip")
120 + rec := &recordingWriter{ResponseWriter: httptest.NewRecorder()}
121 +
122 + next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error {
123 + w.Header().Set("Content-Type", "text/plain")
124 + w.WriteHeader(http.StatusOK)
125 + if rec.wroteHeader {
126 + t.Error("non-SSE response flushed its header early; SSE bypass leaked to normal responses")
127 + }
128 + return nil
129 + })
130 +
131 + if err := enc.ServeHTTP(rec, r, next); err != nil {
132 + t.Fatalf("ServeHTTP() error = %v", err)
133 + }
134 +}