Repository: caddyserver/caddy (language: go)

Issue:
Originate HTTP 103 Early Hints
HTTP 103 Early Hints ([draft RFC 8297](https://datatracker.ietf.org/doc/rfc8297/)) is a mechanism for servers to immediately send `Link` headers to clients so the clients can immediately start requests for those resources.

It is similar to HTTP/2 server push which immediately pushes resources to the client without an extra round trip, but instead of pushing the whole resource right away, the server tells the client, "Hey I'm still getting you the final response, but while you're waiting here's some resources you can download right now."

Enabled clients will read the `Link` headers from the HTTP 103 response and download those resources. Despite the extra round trip, this has a couple notable advantages over HTTP/2:

- The client can choose to ignore the download if the resource is already cached locally
- The server can suggest resources that it does not originate

In practice, HTTP/2 server push is difficult to get right because the server has to predict what the client needs to download. Pushing unnecessary resources actually _worsened_ performance, [according to Chrome data](https://developer.chrome.com/blog/early-hints/), but there was no reliable, efficient way of knowing what the client actually needed ahead of time.

103 responses are small, so despite the extra round trip, letting the client choose whether to initiate other requests to potentially multiple origins is actually a net performance benefit. This gain is amplified the longer the server takes to generate the final response.

I would suggest that Server Push and Early Hints have different purposes:

- **Push** when you know the client _WILL_ have to _REQUEST_ a resource _FROM YOU_
- **Hint** when you know the client _MAY_ have to _USE_ a resource

(Notice the differences: only push when the client "WILL REQUEST FROM YOU", whereas you can hint when the client "MAY USE" a resource.)

Since HTTP/2 server push and HTTP 103 have different purposes, we should probably have both in Caddy.

Early Hints is still technically expeirmental, so we'll document this feature in Caddy as experimental as well. That means it is subject to change and/or removal.

According to @dunglas at https://dunglas.fr/2021/02/using-the-103-early-hints-status-code-in-go-applications/, it's very simple to perform Early Hints in Go (Go 1.19+ required):

```
w.Header().Add("Link", "</style.css>; rel=preload; as=style")
w.Header().Add("Link", "</script.js>; rel=preload; as=script")
w.WriteHeader(http.StatusEarlyHints)
```

then you proceed to write your full response.

The question is, how do we best expose this for the user to configure?

Essentially it's just one or more HTTP headers, so the `header` handler seems fitting. The only trick is that we have to immediately write the response with a 103 status. `header` can't flush a response, but the `static_response` handler can:

```
{
    "handler": "static_response",
    "status_code": 103,
    "headers": {
        "Link": [
            "</style.css>; rel=preload; as=style",
            "</script.js>; rel=preload; as=script"
        ]
    }
}
```

All we have to do is tweak the `static_response` handler to not terminate the handler chain after it writes a 103 response.

Technically, HTTP/1.1 clients can support 103 Early Hints, but it could also break them if they're not expecting a 1xx response without an Expect header. The RFC recommends only sending 103s for HTTP/2 (or HTTP/3) connections, as those protocols do not depend on headers to determine the end of the response.

Fastly has a page for testing your client's HTTP Early Hints support: https://early-hints.fastlylabs.com/

I will upgrade the `protocol` matcher to support specific HTTP versions, such as `http/2` or `http/2+` to match only HTTP/2 or HTTP/2 and newer, respectively.

Apache's implementation of Early Hints has the option of also pushing the hinted resources, but as I said earlier, I think the two have different purposes and times to use them, so I don't think I'll combine push and hint, unless we see convincing evidence that it is actually beneficial.

As for the Caddyfile, the `respond` directive should be able to work fine in conjunction with the `header` directive. (The `respond` directive does not currently support setting headers, because that is what the `header` directive does, and it amounts to the exact same number of lines of config -- actually more if used in `respond` since you have the closing brace on a separate line.)


Discussion / hints:

Fix patch:
diff --git a/modules/caddyhttp/matchers.go b/modules/caddyhttp/matchers.go
index 5056c9aa43c..f86ce0a4547 100644
--- a/modules/caddyhttp/matchers.go
+++ b/modules/caddyhttp/matchers.go
@@ -1121,6 +1121,22 @@ func (m MatchProtocol) Match(r *http.Request) bool {
 		return r.TLS != nil
 	case "http":
 		return r.TLS == nil
+	case "http/1.0":
+		return r.ProtoMajor == 1 && r.ProtoMinor == 0
+	case "http/1.0+":
+		return r.ProtoAtLeast(1, 0)
+	case "http/1.1":
+		return r.ProtoMajor == 1 && r.ProtoMinor == 1
+	case "http/1.1+":
+		return r.ProtoAtLeast(1, 1)
+	case "http/2":
+		return r.ProtoMajor == 2
+	case "http/2+":
+		return r.ProtoAtLeast(2, 0)
+	case "http/3":
+		return r.ProtoMajor == 3
+	case "http/3+":
+		return r.ProtoAtLeast(3, 0)
 	}
 	return false
 }
diff --git a/modules/caddyhttp/push/handler.go b/modules/caddyhttp/push/handler.go
index 75442be134f..27652ef0881 100644
--- a/modules/caddyhttp/push/handler.go
+++ b/modules/caddyhttp/push/handler.go
@@ -29,10 +29,24 @@ func init() {
 	caddy.RegisterModule(Handler{})
 }
 
-// Handler is a middleware for manipulating the request body.
+// Handler is a middleware for HTTP/2 server push. Note that
+// HTTP/2 server push has been deprecated by some clients and
+// its use is discouraged unless you can accurately predict
+// which resources actually need to be pushed to the client;
+// it can be difficult to know what the client already has
+// cached. Pushing unnecessary resources results in worse
+// performance. Consider using HTTP 103 Early Hints instead.
+//
+// This handler supports pushing from Link headers; in other
+// words, if the eventual response has Link headers, this
+// handler will push the resources indicated by those headers,
+// even without specifying any resources in its config.
 type Handler struct {
-	Resources []Resource    `json:"resources,omitempty"`
-	Headers   *HeaderConfig `json:"headers,omitempty"`
+	// The resources to push.
+	Resources []Resource `json:"resources,omitempty"`
+
+	// Headers to modify for the push requests.
+	Headers *HeaderConfig `json:"headers,omitempty"`
 
 	logger *zap.Logger
 }
diff --git a/modules/caddyhttp/responsewriter.go b/modules/caddyhttp/responsewriter.go
index 0ffb9320356..374bbfbac68 100644
--- a/modules/caddyhttp/responsewriter.go
+++ b/modules/caddyhttp/responsewriter.go
@@ -111,15 +111,15 @@ type responseRecorder struct {
 //
 // Proper usage of a recorder looks like this:
 //
-//     rec := caddyhttp.NewResponseRecorder(w, buf, shouldBuffer)
-//     err := next.ServeHTTP(rec, req)
-//     if err != nil {
-//         return err
-//     }
-//     if !rec.Buffered() {
-//         return nil
-//     }
-//     // process the buffered response here
+//	rec := caddyhttp.NewResponseRecorder(w, buf, shouldBuffer)
+//	err := next.ServeHTTP(rec, req)
+//	if err != nil {
+//	    return err
+//	}
+//	if !rec.Buffered() {
+//	    return nil
+//	}
+//	// process the buffered response here
 //
 // The header map is not buffered; i.e. the ResponseRecorder's Header()
 // method returns the same header map of the underlying ResponseWriter.
@@ -129,7 +129,7 @@ type responseRecorder struct {
 // Once you are ready to write the response, there are two ways you can
 // do it. The easier way is to have the recorder do it:
 //
-//     rec.WriteResponse()
+//	rec.WriteResponse()
 //
 // This writes the recorded response headers as well as the buffered body.
 // Or, you may wish to do it yourself, especially if you manipulated the
@@ -138,9 +138,12 @@ type responseRecorder struct {
 // recorder's body buffer, but you might have your own body to write
 // instead):
 //
-//     w.WriteHeader(rec.Status())
-//     io.Copy(w, rec.Buffer())
+//	w.WriteHeader(rec.Status())
+//	io.Copy(w, rec.Buffer())
 //
+// As a special case, 1xx responses are not buffered nor recorded
+// because they are not the final response; they are passed through
+// directly to the underlying ResponseWriter.
 func NewResponseRecorder(w http.ResponseWriter, buf *bytes.Buffer, shouldBuffer ShouldBufferFunc) ResponseRecorder {
 	return &responseRecorder{
 		ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: w},
@@ -149,22 +152,29 @@ func NewResponseRecorder(w http.ResponseWriter, buf *bytes.Buffer, shouldBuffer
 	}
 }
 
+// WriteHeader writes the headers with statusCode to the wrapped
+// ResponseWriter unless the response is to be buffered instead.
+// 1xx responses are never buffered.
 func (rr *responseRecorder) WriteHeader(statusCode int) {
 	if rr.wroteHeader {
 		return
 	}
-	rr.statusCode = statusCode
-	rr.wroteHeader = true
 
-	// decide whether we should buffer the response
-	if rr.shouldBuffer == nil {
-		rr.stream = true
-	} else {
-		rr.stream = !rr.shouldBuffer(rr.statusCode, rr.ResponseWriterWrapper.Header())
+	// 1xx responses aren't final; just informational
+	if statusCode < 100 || statusCode > 199 {
+		rr.statusCode = statusCode
+		rr.wroteHeader = true
+
+		// decide whether we should buffer the response
+		if rr.shouldBuffer == nil {
+			rr.stream = true
+		} else {
+			rr.stream = !rr.shouldBuffer(rr.statusCode, rr.ResponseWriterWrapper.Header())
+		}
 	}
 
-	// if not buffered, immediately write header
-	if rr.stream {
+	// if informational or not buffered, immediately write header
+	if rr.stream || (100 <= statusCode && statusCode <= 199) {
 		rr.ResponseWriterWrapper.WriteHeader(rr.statusCode)
 	}
 }
diff --git a/modules/caddyhttp/staticresp.go b/modules/caddyhttp/staticresp.go
index f4296924cb4..ccc70e2da5c 100644
--- a/modules/caddyhttp/staticresp.go
+++ b/modules/caddyhttp/staticresp.go
@@ -86,6 +86,12 @@ Response headers may be added using the --header flag for each header field.
 type StaticResponse struct {
 	// The HTTP status code to respond with. Can be an integer or,
 	// if needing to use a placeholder, a string.
+	//
+	// If the status code is 103 (Early Hints), the response headers
+	// will be written to the client immediately, the body will be
+	// ignored, and the next handler will be invoked. This behavior
+	// is EXPERIMENTAL while RFC 8297 is a draft, and may be changed
+	// or removed.
 	StatusCode WeakString `json:"status_code,omitempty"`
 
 	// Header fields to set on the response; overwrites any existing
@@ -170,7 +176,7 @@ func (s *StaticResponse) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
 	return nil
 }
 
-func (s StaticResponse) ServeHTTP(w http.ResponseWriter, r *http.Request, _ Handler) error {
+func (s StaticResponse) ServeHTTP(w http.ResponseWriter, r *http.Request, next Handler) error {
 	// close the connection immediately
 	if s.Abort {
 		panic(http.ErrAbortHandler)
@@ -237,10 +243,15 @@ func (s StaticResponse) ServeHTTP(w http.ResponseWriter, r *http.Request, _ Hand
 	w.WriteHeader(statusCode)
 
 	// write response body
-	if body != "" {
+	if statusCode != http.StatusEarlyHints && body != "" {
 		fmt.Fprint(w, body)
 	}
 
+	// continue handling after Early Hints as they are not the final response
+	if statusCode == http.StatusEarlyHints {
+		return next.ServeHTTP(w, r)
+	}
+
 	return nil
 }
 


Test patch: