Repository: valyala/fasthttp
Pull request #1996 (2025-04-16 02:33:27)

early hint functionality
Draft of Early Hints functionality.

Fixes: https://github.com/valyala/fasthttp/issues/1992

pjebs: > The `hints map[string]string` prevents me from generating for example these headers:
> 
> ```
> Link: <https://fonts.google.com>; rel=preconnect
> Link: </main.css>; rel=preload; as=style
> Link: </common.js>; rel=preload; as=script
> Link: </experimental.3eab3290.css>; rel=preload; as=style
> ```
> 
> Can you also add a test case that shows the Early Hints response and makes sure the normal response after it also works fine.

```go
ctx.EarlyHints(map[string]string{
    "https://fonts.google.com": "rel=preconnect",
    "/main.css": "rel=preload; as=style",
    "/common.js": "rel=preload; as=script",
    "/experimental.3eab3290.css": "rel=preload; as=style",
}) 

```

erikdubbelboer: I'm wondering if this is the best API. Another way might be that this function has no arguments and just sends the headers set in the response already and then resets them. Not sure if an Early Hints response can also have other headers?
pjebs: > I'm wondering if this is the best API. Another way might be that this function has no arguments and just sends the headers set in the response already and then resets them. Not sure if an Early Hints response can also have other headers?

The point of early hints is that the Link Headers are sent before the processing of the full response.
In some cases, the full response may take "considerable" time to determine what the headers are and then write them to Response.


pjebs: @erikdubbelboer Is there a sample test that I can use as a template that tests something similar?
erikdubbelboer: The API I was thinking of is just:
```go
ctx.Response.Header.Add("Link", "...")
ctx.EarlyHints()
```
Which then only send the Link header and not any other header that might already have been set.

This design is more in line with what we already have and makes it easier to have no memory allocations.

For an example of a test you can take the 100 continue test: https://github.com/valyala/fasthttp/blob/48f3a2f423f103cd67e504a51f3ec3a1381a5620/server_test.go#L1881
pjebs: > The API I was thinking of is just:
> 
> ```go
> ctx.Response.Header.Add("Link", "...")
> ctx.EarlyHints()
> ```
> 
> Which then only send the Link header and not any other header that might already have been set.
> 
> This design is more in line with what we already have and makes it easier to have no memory allocations.
> 
> For an example of a test you can take the 100 continue test:
> 
> https://github.com/valyala/fasthttp/blob/48f3a2f423f103cd67e504a51f3ec3a1381a5620/server_test.go#L1881

But I still have to iterate over the headers to determine if the header is a `Link` (I.e by doing a comparison). That is slower. 

There is no guarantee the developer will only add Links to header before calling `EarlyHints`.

erikdubbelboer: The amount of headers will be so low that its probably around the same speed as the map. A map makes it really hard not to generate garbage which would make that much slower.
pjebs: @erikdubbelboer Done
pjebs: @erikdubbelboer Done

Patch:
diff --git a/server.go b/server.go
index 64c4c7c..e1ae800 100644
--- a/server.go
+++ b/server.go
@@ -623,6 +623,72 @@ type RequestCtx struct {
 	hijackNoResponse bool
 }
 
+// EarlyHints allows the server to hint to the browser what resources a page would need
+// so the browser can preload them while waiting for the server's full response. Only Link
+// headers already written to the response will be transmitted as Early Hints.
+//
+// This is a HTTP/2+ feature but all browsers will either understand it or safely ignore it.
+//
+// NOTE: Older HTTP/1.1 non-browser clients may face compatibility issues.
+//
+// See: https://developer.chrome.com/docs/web-platform/early-hints and
+// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Link#syntax
+//
+// Example:
+//
+//	func(ctx *fasthttp.RequestCtx) {
+//	   ctx.Response.Header.Add("Link", "<https://fonts.google.com>; rel=preconnect")
+//	   ctx.EarlyHints()
+//	   time.Sleep(5*time.Second) // some time-consuming task
+//	   ctx.SetStatusCode(fasthttp.StatusOK)
+//	   ctx.SetBody([]byte("<html><head></head><body><h1>Hello from Fasthttp</h1></body></html>"))
+//	}
+func (ctx *RequestCtx) EarlyHints() error {
+	links := ctx.Response.Header.PeekAll(b2s(strLink))
+	if len(links) > 0 {
+		c := acquireWriter(ctx)
+		defer releaseWriter(ctx.s, c)
+		_, err := c.Write(strEarlyHints)
+		if err != nil {
+			return err
+		}
+		for _, l := range links {
+			if len(l) == 0 {
+				continue
+			}
+			_, err = c.Write(strLink)
+			if err != nil {
+				return err
+			}
+			_, err = c.Write(strColon)
+			if err != nil {
+				return err
+			}
+			_, err = c.Write(strSpace)
+			if err != nil {
+				return err
+			}
+			_, err = c.Write(l)
+			if err != nil {
+				return err
+			}
+			_, err = c.Write(strCRLF)
+			if err != nil {
+				return err
+			}
+		}
+		_, err = c.Write(strCRLF)
+		if err != nil {
+			return err
+		}
+		err = c.Flush()
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 // HijackHandler must process the hijacked connection c.
 //
 // If KeepHijackedConns is disabled, which is by default,
diff --git a/strings.go b/strings.go
index 36ed029..aa17600 100644
--- a/strings.go
+++ b/strings.go
@@ -25,8 +25,10 @@ var (
 	strColonSpace               = []byte(": ")
 	strCommaSpace               = []byte(", ")
 	strGMT                      = []byte("GMT")
+	strSpace                    = []byte(" ")
 
 	strResponseContinue = []byte("HTTP/1.1 100 Continue\r\n\r\n")
+	strEarlyHints       = []byte("HTTP/1.1 103 Early Hints\r\n")
 
 	strExpect             = []byte(HeaderExpect)
 	strConnection         = []byte(HeaderConnection)
@@ -86,6 +88,7 @@ var (
 	strBoundary            = []byte("boundary")
 	strBytes               = []byte("bytes")
 	strBasicSpace          = []byte("Basic ")
+	strLink                = []byte("Link")
 
 	strApplicationSlash = []byte("application/")
 	strImageSVG         = []byte("image/svg")


Test patch:
diff --git a/server_test.go b/server_test.go
index 4704c9b..9dcc5ad 100644
--- a/server_test.go
+++ b/server_test.go
@@ -1919,6 +1919,49 @@ func TestServerExpect100Continue(t *testing.T) {
 	}
 }
 
+func TestServerExpect103EarlyHints(t *testing.T) {
+	t.Parallel()
+
+	s := &Server{
+		NoDefaultContentType:  true,
+		NoDefaultDate:         true,
+		NoDefaultServerHeader: true,
+		Handler: func(ctx *RequestCtx) {
+			ctx.Response.Header.Add("Link", "<https://cdn.com>; rel=preload; as=script")
+			ctx.EarlyHints() //nolint:errcheck
+		},
+	}
+
+	rw := &readWriter{}
+	rw.r.WriteString("GET /foo HTTP/1.1\r\nContent-Length: 5\r\nContent-Type: a/b\r\n\r\n12345")
+
+	if err := s.ServeConn(rw); err != nil {
+		t.Fatalf("Unexpected error from serveConn: %v", err)
+	}
+
+	scanner := bufio.NewScanner(&rw.w)
+	expected := []string{
+		"HTTP/1.1 103 Early Hints",
+		"Link: <https://cdn.com>; rel=preload; as=script",
+		"",
+		"HTTP/1.1 200 OK",
+		"Content-Length: 0",
+		"Link: <https://cdn.com>; rel=preload; as=script",
+	}
+
+	i := 0
+	for scanner.Scan() {
+		if i >= len(expected) {
+			break
+		}
+		line := scanner.Text()
+		if line != expected[i] {
+			t.Fatalf("unexpected data: %s. Expecting %s", line, expected[i])
+		}
+		i++
+	}
+}
+
 func TestServerContinueHandler(t *testing.T) {
 	t.Parallel()