Repository: cloudwego/hertz
Pull request #1327 (2025-04-09 10:10:56)

feat(adaptor): new HertzHandler for http.Handler
#### What type of PR is this?
feat

<!--
Add one of the following kinds:

build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
docs: Documentation only changes
feat: A new feature
optimize: A new optimization
fix: A bug fix
perf: A code change that improves performance
refactor: A code change that neither fixes a bug nor adds a feature
style: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc)
test: Adding missing tests or correcting existing tests
chore: Changes to the build process or auxiliary tools and libraries such as documentation generation
-->

#### Check the PR title.
<!--
The description of the title will be attached in Release Notes, 
so please describe it from user-oriented, what this PR does / why we need it.
Please check your PR title with the below requirements:
-->
- [ ] This PR title match the format: \<type\>(optional scope): \<description\>
- [ ] The description of this PR title is user-oriented and clear enough for others to understand.
- [ ] Attach the PR updating the user documentation if the current PR requires user awareness at the usage level. [User docs repo](https://github.com/cloudwego/cloudwego.github.io)


#### (Optional) Translate the PR title into Chinese.
新的 HertzHandler 用于兼容 net/http.Handler

#### (Optional) More detailed description for this PR(en: English/zh: Chinese).
<!--
Provide more detailed info for review(e.g., it's recommended to provide perf data if this is a perf type PR).
-->
en:
zh(optional):

#### (Optional) Which issue(s) this PR fixes:
<!--
Automatically closes linked issue when PR is merged.
Eg: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->

#### (Optional) The PR that updates user documentation:
<!--
If the current PR requires user awareness at the usage level, please submit a PR to update user docs. [User docs repo](https://github.com/cloudwego/cloudwego.github.io)
-->

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/cloudwego/hertz/pull/1327?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) Report
Attention: Patch coverage is `21.39037%` with `147 lines` in your changes missing coverage. Please review.
> Project coverage is 72.19%. Comparing base [(`133717f`)](https://app.codecov.io/gh/cloudwego/hertz/commit/133717f1d4d299b9bac106d2e3a3a69c8a3aaf98?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) to head [(`2ca9a4f`)](https://app.codecov.io/gh/cloudwego/hertz/commit/2ca9a4fa94390a1c1b0b06ce34855647f37ac355?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego).

| [Files with missing lines](https://app.codecov.io/gh/cloudwego/hertz/pull/1327?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) | Patch % | Lines |
|---|---|---|
| [pkg/common/adaptor/utils.go](https://app.codecov.io/gh/cloudwego/hertz/pull/1327?src=pr&el=tree&filepath=pkg%2Fcommon%2Fadaptor%2Futils.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL2NvbW1vbi9hZGFwdG9yL3V0aWxzLmdv) | 6.81% | [82 Missing :warning: ](https://app.codecov.io/gh/cloudwego/hertz/pull/1327?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) |
| [pkg/common/adaptor/handler.go](https://app.codecov.io/gh/cloudwego/hertz/pull/1327?src=pr&el=tree&filepath=pkg%2Fcommon%2Fadaptor%2Fhandler.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL2NvbW1vbi9hZGFwdG9yL2hhbmRsZXIuZ28=) | 36.95% | [50 Missing and 8 partials :warning: ](https://app.codecov.io/gh/cloudwego/hertz/pull/1327?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) |
| [internal/testutils/testutils.go](https://app.codecov.io/gh/cloudwego/hertz/pull/1327?src=pr&el=tree&filepath=internal%2Ftestutils%2Ftestutils.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-aW50ZXJuYWwvdGVzdHV0aWxzL3Rlc3R1dGlscy5nbw==) | 0.00% | [7 Missing :warning: ](https://app.codecov.io/gh/cloudwego/hertz/pull/1327?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           develop    #1327      +/-   ##
===========================================
- Coverage    72.80%   72.19%   -0.61%     
===========================================
  Files          139      141       +2     
  Lines        16244    16431     +187     
===========================================
+ Hits         11826    11863      +37     
- Misses        3823     3964     +141     
- Partials       595      604       +9     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/cloudwego/hertz/pull/1327?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/pkg/common/adaptor/handler.go b/pkg/common/adaptor/handler.go
new file mode 100644
index 0000000..2af3c4f
--- /dev/null
+++ b/pkg/common/adaptor/handler.go
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package adaptor
+
+import (
+	"bufio"
+	"context"
+	"errors"
+	"net"
+	"net/http"
+
+	"github.com/cloudwego/hertz/internal/bytestr"
+	"github.com/cloudwego/hertz/pkg/app"
+	"github.com/cloudwego/hertz/pkg/network"
+	"github.com/cloudwego/hertz/pkg/protocol/consts"
+	"github.com/cloudwego/hertz/pkg/protocol/http1/resp"
+)
+
+// HertzHandler converts a http.Handler to an app.HandlerFunc.
+func HertzHandler(h http.Handler) app.HandlerFunc {
+	return func(ctx context.Context, rc *app.RequestContext) {
+		// creating http.Request
+		r := &rc.Request
+		req, err := http.NewRequestWithContext(ctx, methodstr(r.Method()), r.URI().String(), nil)
+		if err != nil {
+			rc.SetStatusCode(consts.StatusInternalServerError)
+			rc.SetBodyString(err.Error())
+			return
+		}
+
+		// request Body & ContentLength
+		stream := r.IsBodyStream()
+		if stream && r.HasMultipartForm() && len(r.BodyBytes()) > 0 {
+			// in this case, r.MultipartForm() called before this handler then we can not rely on stream body.
+			// coz both r.Body() and r.BodyStream() will return nothing due to EOF of stream body.
+			// we fix it by calling r.CloseBodyStream(), then r.Body() correctly returns bytes.
+			// FIXME: This should be taken care of by `protocol.Request`
+			r.CloseBodyStream()
+			stream = false
+		}
+		if stream {
+			// use BodyStream if possible to avoid OOM issue.
+			req.Body = reader2closer(r.BodyStream())
+			req.ContentLength = int64(r.Header.ContentLength())
+		} else {
+			// fast path for default cases with StreamRequestBody=false
+			b := r.Body()
+			req.Body = newBytesRWCloser(b)
+			req.ContentLength = int64(len(b))
+		}
+
+		// request Header
+		r.Header.VisitAll(func(k, v []byte) {
+			req.Header[string(k)] = append(req.Header[string(k)], string(v))
+		})
+
+		// request other properties
+		if s := r.Header.GetProtocol(); s != "" {
+			req.Proto = s
+			req.ProtoMajor, req.ProtoMinor, _ = parseHTTPVersion(s)
+		}
+		req.Close = r.ConnectionClose()
+		req.RemoteAddr = rc.RemoteAddr().String()
+		req.RequestURI = string(r.RequestURI())
+		if tlsconn, ok := rc.GetConn().(network.ConnTLSer); ok {
+			state := tlsconn.ConnectionState()
+			req.TLS = &state
+		}
+
+		// creating http.ResponseWriter
+		//
+		// coz it's server response
+		// no need to copy anything from hertz Response
+		w := &httpResponseWriter{rc: rc}
+		h.ServeHTTP(w, req)
+	}
+}
+
+type httpResponseWriter struct {
+	rc     *app.RequestContext
+	header http.Header
+
+	err error
+
+	wroteHeader bool
+	hijacked    bool
+	skipBody    bool
+}
+
+var errConnHijacked = errors.New("hertz net/http adaptor: conn hijacked")
+
+func (p *httpResponseWriter) Header() http.Header {
+	if p.header != nil {
+		return p.header
+	}
+	p.header = make(map[string][]string)
+	return p.header
+}
+
+func (p *httpResponseWriter) Write(b []byte) (n int, _ error) {
+	if p.hijacked {
+		return 0, errConnHijacked
+	}
+	if !p.wroteHeader {
+		p.WriteHeader(consts.StatusOK)
+	}
+	if p.err != nil {
+		return 0, p.err
+	}
+	if p.skipBody {
+		return len(b), nil
+	}
+	n, p.err = p.rc.Response.GetHijackWriter().Write(b)
+	return n, p.err
+}
+
+func (p *httpResponseWriter) WriteHeader(statusCode int) {
+	if p.wroteHeader || p.hijacked {
+		return
+	}
+	p.wroteHeader = true
+	r := &p.rc.Response
+	// reset and check if user updates Content-Length
+	// if we have no Content-Length, can only use chunked Transfer-Encoding
+	r.Header.InitContentLengthWithValue(-1)
+	for k, vv := range p.header {
+		for _, v := range vv {
+			r.Header.Add(k, v)
+		}
+	}
+	w := p.rc.GetWriter()
+	r.Header.SetStatusCode(statusCode)
+	p.skipBody = r.Header.MustSkipContentLength() ||
+		string(p.rc.Request.Method()) == consts.MethodHead
+	if p.skipBody {
+		// set Content-Length: 0
+		r.Header.SetCanonical(bytestr.StrContentLength, []byte("0"))
+		// skip all further writes,
+		// must be set for hertz request loop or it would write header and body after handler returns
+		r.HijackWriter(noopWriter{})
+		p.err = resp.WriteHeader(&r.Header, w)
+		return
+	}
+	if r.Header.ContentLength() < 0 {
+		r.HijackWriter(resp.NewChunkedBodyWriter(r, w))
+	} else {
+		p.err = resp.WriteHeader(&r.Header, w)
+		// use Writer directly instead of keep buffering data in resp.BodyBuffer()
+		// you never know how much data would be written to response
+		r.HijackWriter(writer2writerExt(w))
+	}
+}
+
+var _ http.Flusher = (*httpResponseWriter)(nil)
+
+// Flush implements the [http.Flusher]
+func (p *httpResponseWriter) Flush() {
+	_ = p.rc.GetWriter().Flush()
+}
+
+var _ http.Hijacker = (*httpResponseWriter)(nil)
+
+// Hijack implements the [net/http.Hijacker]
+func (p *httpResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+	if p.hijacked {
+		return nil, nil, errConnHijacked
+	}
+	p.hijacked = true
+
+	conn := p.rc.GetConn()
+
+	// reset timeout if any
+	_ = conn.SetReadTimeout(0)
+	_ = conn.SetWriteTimeout(0)
+
+	// make sure after handler returns:
+	// * hertz won't reuse the conn
+	// * hertz won't write any extra bytes to underlying conn
+	p.rc.Response.Header.SetConnectionClose(true)
+	p.rc.Response.HijackWriter(noopHijackWriter{})
+
+	return conn, bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)), nil
+}
+
+type noopHijackWriter struct{}
+
+var _ network.ExtWriter = noopHijackWriter{}
+
+func (noopHijackWriter) Write(b []byte) (int, error) {
+	return 0, errConnHijacked
+}
+func (noopHijackWriter) Flush() error    { return errConnHijacked }
+func (noopHijackWriter) Finalize() error { return nil }
+
+type noopWriter struct{}
+
+var _ network.ExtWriter = noopWriter{}
+
+func (noopWriter) Write(b []byte) (int, error) { return len(b), nil }
+func (noopWriter) Flush() error                { return nil }
+func (noopWriter) Finalize() error             { return nil }
diff --git a/pkg/common/adaptor/request.go b/pkg/common/adaptor/request.go
index ef3d787..7213ef6 100644
--- a/pkg/common/adaptor/request.go
+++ b/pkg/common/adaptor/request.go
@@ -24,6 +24,8 @@ import (
 )
 
 // GetCompatRequest only support basic function of Request, not for all.
+//
+// Deprecated: use HertzHandler instead
 func GetCompatRequest(req *protocol.Request) (*http.Request, error) {
 	r, err := http.NewRequest(string(req.Method()), req.URI().String(), bytes.NewReader(req.Body()))
 	if err != nil {
diff --git a/pkg/common/adaptor/response.go b/pkg/common/adaptor/response.go
index 4dbc0fa..2c0364d 100644
--- a/pkg/common/adaptor/response.go
+++ b/pkg/common/adaptor/response.go
@@ -68,6 +68,8 @@ func (c *compatResponse) WriteHeader(statusCode int) {
 }
 
 // GetCompatResponseWriter only support basic function of ResponseWriter, not for all.
+//
+// Deprecated: use HertzHandler instead
 func GetCompatResponseWriter(resp *protocol.Response) http.ResponseWriter {
 	c := &compatResponse{
 		h: resp,
diff --git a/pkg/common/adaptor/utils.go b/pkg/common/adaptor/utils.go
new file mode 100644
index 0000000..a47b888
--- /dev/null
+++ b/pkg/common/adaptor/utils.go
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package adaptor
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+	"unsafe"
+
+	"github.com/cloudwego/hertz/pkg/network"
+	"github.com/cloudwego/hertz/pkg/protocol/consts"
+)
+
+// methodstr tries to return consts without allocation
+func methodstr(m []byte) string {
+	if len(m) == 0 {
+		return "GET"
+	}
+	switch m[0] {
+	case 'G':
+		if string(m) == consts.MethodGet {
+			return consts.MethodGet
+		}
+	case 'P':
+		switch string(m) {
+		case consts.MethodPost:
+			return consts.MethodPost
+
+		case consts.MethodPut:
+			return consts.MethodPut
+
+		case consts.MethodPatch:
+			return consts.MethodPatch
+		}
+	case 'H':
+		if string(m) == consts.MethodHead {
+			return consts.MethodHead
+		}
+	case 'D':
+		if string(m) == consts.MethodDelete {
+			return consts.MethodDelete
+		}
+	}
+	return string(m)
+}
+
+type bytesRWCloser struct {
+	bytes.Reader
+}
+
+// newBytesRWCloser adds noop Close method to bytes.Reader without allocation
+func newBytesRWCloser(b []byte) io.ReadCloser {
+	rd := bytes.NewReader(b)
+	return (*bytesRWCloser)(unsafe.Pointer(rd))
+}
+
+// Close implements the [io.Closer] interface.
+func (bytesRWCloser) Close() error { return nil }
+
+func writer2writerExt(w network.Writer) network.ExtWriter {
+	return extWriter{w}
+}
+
+type extWriter struct {
+	network.Writer
+}
+
+func (w extWriter) Write(b []byte) (int, error) {
+	buf, err := w.Writer.Malloc(len(b))
+	if err != nil {
+		return 0, err
+	}
+	return copy(buf, b), nil
+}
+
+func (w extWriter) Finalize() error {
+	return w.Flush()
+}
+
+func reader2closer(r io.Reader) io.ReadCloser {
+	rc, ok := r.(io.ReadCloser)
+	if ok {
+		return rc
+	}
+	return io.NopCloser(r)
+}
+
+func parseHTTPVersion(s string) (major, minor int, _ error) {
+	v := strings.TrimPrefix(s, "HTTP/")
+	if len(v) == len(s) {
+		return 1, 1, fmt.Errorf("invalid http version: %q", s)
+	}
+	switch v {
+	case "1.0":
+		return 1, 0, nil
+
+	case "1.1":
+		return 1, 1, nil
+
+	default:
+		a, b, ok := strings.Cut(v, ".")
+		if ok {
+			major, err1 := strconv.Atoi(a)
+			minor, err2 := strconv.Atoi(b)
+			if err1 == nil && err2 == nil {
+				return major, minor, nil
+			}
+		}
+	}
+	return 1, 1, fmt.Errorf("invalid http version: %q", s)
+}


Test patch:
diff --git a/internal/testutils/testutils.go b/internal/testutils/testutils.go
index 4de5835..917d34b 100644
--- a/internal/testutils/testutils.go
+++ b/internal/testutils/testutils.go
@@ -20,6 +20,7 @@ import (
 	"net"
 	"path"
 	"reflect"
+	"time"
 	"unsafe"
 
 	"github.com/cloudwego/hertz/pkg/network"
@@ -95,3 +96,17 @@ func GetListenerAddr(v interface{}) string {
 func GetURL(v interface{}, p string) string {
 	return "http://" + path.Join(GetListenerAddr(v), p)
 }
+
+type RouteEngine interface {
+	IsRunning() bool
+}
+
+func WaitEngineRunning(e RouteEngine) {
+	for i := 0; i < 100; i++ {
+		if e.IsRunning() {
+			return
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	panic("not running")
+}
diff --git a/internal/testutils/testutils_test.go b/internal/testutils/testutils_test.go
index 756eded..8317d6b 100644
--- a/internal/testutils/testutils_test.go
+++ b/internal/testutils/testutils_test.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"io"
 	"net/http"
+	"sync/atomic"
 	"testing"
 	"time"
 
@@ -61,3 +62,20 @@ func TestGetListener(t *testing.T) {
 		t.Fatal(s, "!=", msg)
 	}
 }
+
+type routeEngine struct {
+	Running atomic.Bool
+}
+
+func (e *routeEngine) IsRunning() bool {
+	return e.Running.Load()
+}
+
+func TestWaitEngineRunning(t *testing.T) {
+	e := &routeEngine{}
+	go func() {
+		time.Sleep(30 * time.Millisecond)
+		e.Running.Store(true)
+	}()
+	WaitEngineRunning(e)
+}
diff --git a/pkg/app/client/client_test.go b/pkg/app/client/client_test.go
index 5e182f6..9e4ffde 100644
--- a/pkg/app/client/client_test.go
+++ b/pkg/app/client/client_test.go
@@ -111,13 +111,7 @@ func nextUnixSock() string {
 }
 
 func waitEngineRunning(e *route.Engine) {
-	for i := 0; i < 100; i++ {
-		if e.IsRunning() {
-			return
-		}
-		time.Sleep(10 * time.Millisecond)
-	}
-	panic("not running")
+	testutils.WaitEngineRunning(e)
 }
 
 func TestCloseIdleConnections(t *testing.T) {
diff --git a/pkg/app/server/hertz_test.go b/pkg/app/server/hertz_test.go
index e1aadb3..f86357b 100644
--- a/pkg/app/server/hertz_test.go
+++ b/pkg/app/server/hertz_test.go
@@ -57,13 +57,7 @@ type routeEngine interface {
 }
 
 func waitEngineRunning(e routeEngine) {
-	for i := 0; i < 100; i++ {
-		if e.IsRunning() {
-			return
-		}
-		time.Sleep(10 * time.Millisecond)
-	}
-	panic("not running")
+	testutils.WaitEngineRunning(e)
 }
 
 func TestHertz_Run(t *testing.T) {
diff --git a/pkg/common/adaptor/handler_test.go b/pkg/common/adaptor/handler_test.go
new file mode 100644
index 0000000..0facc2b
--- /dev/null
+++ b/pkg/common/adaptor/handler_test.go
@@ -0,0 +1,255 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package adaptor
+
+import (
+	"bytes"
+	"context"
+	"embed"
+	"fmt"
+	"io"
+	"mime/multipart"
+	"net"
+	"net/http"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/cloudwego/hertz/internal/testutils"
+	"github.com/cloudwego/hertz/pkg/app"
+	"github.com/cloudwego/hertz/pkg/common/config"
+	"github.com/cloudwego/hertz/pkg/common/test/assert"
+	"github.com/cloudwego/hertz/pkg/route"
+)
+
+//go:embed *
+var adaptorFiles embed.FS
+
+func runEngine(onCreate func(*route.Engine)) (string, *route.Engine) {
+	opt := config.NewOptions(nil)
+	opt.Addr = "127.0.0.1:0"
+	engine := route.NewEngine(opt)
+	onCreate(engine)
+	go engine.Run()
+	testutils.WaitEngineRunning(engine)
+	return testutils.GetListenerAddr(engine), engine
+}
+
+func TestHertzHandler_BodyStream(t *testing.T) {
+	var wg sync.WaitGroup
+	wg.Add(1)
+	h := HertzHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		defer wg.Done()
+		b := make([]byte, 100)
+		for i := 0; i < 3; i++ { // reading chunked data
+			n, err := r.Body.Read(b)
+			assert.Nil(t, err)
+			assert.Assert(t, n == 5, n)
+			assert.Assert(t, string(b[:n]) == "hello")
+		}
+		n, err := r.Body.Read(b)
+		assert.Assert(t, err == io.EOF)
+		assert.Assert(t, n == 0)
+	}))
+	addr, e := runEngine(func(e *route.Engine) {
+		e.GetOptions().StreamRequestBody = true
+		e.POST("/test", h)
+	})
+	defer e.Close()
+
+	r, w := io.Pipe() // for sending chunked data
+	req, err := http.NewRequest("POST", "http://"+addr+"/test", r)
+	assert.Nil(t, err)
+	cli := &http.Client{}
+	go cli.Do(req)
+	for i := 0; i < 3; i++ {
+		w.Write([]byte("hello"))
+		time.Sleep(50 * time.Millisecond)
+	}
+	w.Close()
+	wg.Wait()
+}
+
+func TestHertzHandler_Chunked(t *testing.T) {
+	h := HertzHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		f := w.(http.Flusher)
+		w.Header().Set("Transfer-Encoding", "chunked")
+		for i := 0; i < 5; i++ {
+			chunk := fmt.Sprintf("data:%d", i)
+			_, err := w.Write([]byte(chunk))
+			assert.Nil(t, err)
+			f.Flush()
+			time.Sleep(20 * time.Millisecond)
+		}
+	}))
+	addr, e := runEngine(func(e *route.Engine) {
+		e.GET("/test", h)
+	})
+	defer e.Close()
+
+	resp, err := http.Get("http://" + addr + "/test")
+	assert.Nil(t, err)
+	defer resp.Body.Close()
+	assert.Assert(t, len(resp.TransferEncoding) == 1 && resp.TransferEncoding[0] == "chunked")
+	for i := 0; i < 5; i++ {
+		b := make([]byte, 10)
+		n, err := resp.Body.Read(b)
+		assert.Nil(t, err)
+		assert.Assert(t, string(b[:n]) == fmt.Sprintf("data:%d", i))
+	}
+}
+
+func TestHertzHandler_Hijack(t *testing.T) {
+	h := HertzHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, rw, err := w.(http.Hijacker).Hijack()
+		assert.Nil(t, err)
+		_, _, err = w.(http.Hijacker).Hijack() // hijacked
+		assert.NotNil(t, err)
+
+		w.WriteHeader(500) // hijacked, noop
+		_, err = w.Write([]byte("hello"))
+		assert.Assert(t, err == errConnHijacked)
+
+		rw.Write([]byte("hello"))
+		rw.Flush()
+		b := make([]byte, 10)
+		n, err := rw.Read(b)
+		assert.Nil(t, err)
+		assert.Assert(t, string(b[:n]) == "world")
+	}))
+	addr, e := runEngine(func(e *route.Engine) {
+		e.GET("/test", h)
+	})
+	defer e.Close()
+
+	conn, err := net.Dial("tcp", addr)
+	assert.Nil(t, err)
+	defer conn.Close()
+	conn.Write([]byte("GET /test HTTP/1.1\r\nHost: example.com\r\n\r\n"))
+	b := make([]byte, 100)
+	n, err := conn.Read(b)
+	assert.Nil(t, err)
+	assert.Assert(t, string(b[:n]) == "hello", string(b[:n]))
+	_, err = conn.Write([]byte("world"))
+	assert.Nil(t, err)
+
+	n, err = conn.Read(b) // Keep-Alive will not work if hijacked
+	assert.Assert(t, err == io.EOF)
+	assert.Assert(t, n == 0)
+}
+
+func TestHertzHandler_FSEmbed(t *testing.T) {
+	addr, e := runEngine(func(e *route.Engine) {
+		h := HertzHandler(http.FileServer(http.FS(adaptorFiles)))
+		e.GET("/*filepath", h)
+		e.HEAD("/*filepath", h)
+	})
+	defer e.Close()
+
+	resp, err := http.Get("http://" + addr + "/handler_test.go")
+	assert.Nil(t, err)
+
+	expect := "hello, I'm handler_test.go"
+
+	b, err := io.ReadAll(resp.Body)
+	s := string(b)
+	assert.Nil(t, err)
+	assert.Assert(t, strings.Contains(s, expect), s)
+}
+
+func TestHertzHandler_Multipart(t *testing.T) {
+	kvs := map[string]string{
+		"name":  "Alice",
+		"email": "alice@example.com",
+	}
+	h := HertzHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		for k, expectv := range kvs {
+			v := r.FormValue(k)
+			assert.Assert(t, v == expectv, v)
+		}
+		w.WriteHeader(204)
+		w.Write([]byte("hello"))
+	}))
+	addr, e := runEngine(func(e *route.Engine) {
+		opts := e.GetOptions()
+		opts.StreamRequestBody = true
+		opts.DisablePreParseMultipartForm = true
+		e.POST("/test", func(ctx context.Context, rc *app.RequestContext) {
+			_, err := rc.MultipartForm() // call rc.MultipartForm before HertzHandler
+			assert.Nil(t, err)
+			h(ctx, rc)
+		})
+	})
+	defer e.Close()
+
+	body, ct := createMultipartBody(kvs)
+	req, err := http.NewRequest("POST", "http://"+addr+"/test", bytes.NewReader(body.Bytes()))
+	assert.Nil(t, err)
+	req.Header.Set("Content-Type", ct)
+
+	client := &http.Client{}
+	resp, err := client.Do(req)
+	assert.Nil(t, err)
+	assert.Assert(t, resp.StatusCode == 204, resp.StatusCode)
+	resp.Body.Close()
+}
+
+func createMultipartBody(kvs map[string]string) (*bytes.Buffer, string) {
+	buf := &bytes.Buffer{}
+	w := multipart.NewWriter(buf)
+	for k, v := range kvs {
+		_ = w.WriteField(k, v)
+	}
+	_ = w.Close()
+	return buf, w.FormDataContentType()
+}
+
+func TestNoopHijackWriter(t *testing.T) {
+	writer := noopHijackWriter{}
+
+	// Test Write method
+	n, err := writer.Write([]byte("test"))
+	assert.Assert(t, n == 0, n)
+	assert.Assert(t, err == errConnHijacked, err)
+
+	// Test Flush method
+	err = writer.Flush()
+	assert.Assert(t, err == errConnHijacked, err)
+
+	// Test Finalize method
+	err = writer.Finalize()
+	assert.Nil(t, err)
+}
+
+func TestNoopWriter(t *testing.T) {
+	writer := noopWriter{}
+
+	// Test Write method
+	testData := []byte("test data")
+	n, err := writer.Write(testData)
+	assert.Assert(t, n == len(testData), n)
+	assert.Nil(t, err)
+
+	// Test Flush method
+	err = writer.Flush()
+	assert.Nil(t, err)
+
+	// Test Finalize method
+	err = writer.Finalize()
+	assert.Nil(t, err)
+}
diff --git a/pkg/common/adaptor/utils_test.go b/pkg/common/adaptor/utils_test.go
new file mode 100644
index 0000000..827f39d
--- /dev/null
+++ b/pkg/common/adaptor/utils_test.go
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2025 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package adaptor
+
+import (
+	"runtime"
+	"testing"
+
+	"github.com/cloudwego/hertz/internal/bytestr"
+	"github.com/cloudwego/hertz/pkg/common/test/assert"
+)
+
+func TestMethodStr(t *testing.T) {
+	var m0, m1 runtime.MemStats
+	assertEqual := func(got, expect string) {
+		if got != expect {
+			t.Helper()
+			t.Fatal(got)
+		}
+	}
+	runtime.ReadMemStats(&m0)
+	for i := 0; i < 1000; i++ {
+		assertEqual(methodstr([]byte(nil)), "GET")
+		assertEqual(methodstr(bytestr.StrGet), "GET")
+		assertEqual(methodstr(bytestr.StrHead), "HEAD")
+		assertEqual(methodstr(bytestr.StrPost), "POST")
+		assertEqual(methodstr(bytestr.StrPut), "PUT")
+		assertEqual(methodstr(bytestr.StrDelete), "DELETE")
+		assertEqual(methodstr(bytestr.StrPatch), "PATCH")
+	}
+	runtime.ReadMemStats(&m1)
+
+	// should be zero, but in case of other background task running
+	diff := m1.Mallocs - m0.Mallocs
+	assert.Assert(t, diff < 50, diff)
+}
+
+func TestBytesRWCloser(t *testing.T) {
+	// Test data
+	testData := []byte("test bytesRWCloser")
+
+	// Create a new bytesRWCloser
+	rc := newBytesRWCloser(testData)
+
+	// Read from the reader
+	buf := make([]byte, len(testData))
+	n, err := rc.Read(buf)
+
+	// Verify read was successful
+	assert.Nil(t, err)
+	assert.DeepEqual(t, n, len(testData))
+	assert.DeepEqual(t, buf, testData)
+
+	// Test that Close returns nil
+	err = rc.Close()
+	assert.Nil(t, err)
+}
+
+func TestParseHTTPVersion(t *testing.T) {
+	// Test HTTP/1.0
+	major, minor, err := parseHTTPVersion("HTTP/1.0")
+	assert.Nil(t, err)
+	assert.DeepEqual(t, major, 1)
+	assert.DeepEqual(t, minor, 0)
+
+	// Test HTTP/1.1
+	major, minor, err = parseHTTPVersion("HTTP/1.1")
+	assert.Nil(t, err)
+	assert.DeepEqual(t, major, 1)
+	assert.DeepEqual(t, minor, 1)
+
+	// Test HTTP/2.0
+	major, minor, err = parseHTTPVersion("HTTP/2.0")
+	assert.Nil(t, err)
+	assert.DeepEqual(t, major, 2)
+	assert.DeepEqual(t, minor, 0)
+
+	// Test HTTP/3.1
+	major, minor, err = parseHTTPVersion("HTTP/3.1")
+	assert.Nil(t, err)
+	assert.DeepEqual(t, major, 3)
+	assert.DeepEqual(t, minor, 1)
+
+	// Test missing HTTP prefix
+	major, minor, err = parseHTTPVersion("1.1")
+	assert.NotNil(t, err)
+	assert.DeepEqual(t, major, 1)
+	assert.DeepEqual(t, minor, 1)
+
+	// Test missing dot separator
+	major, minor, err = parseHTTPVersion("HTTP/11")
+	assert.NotNil(t, err)
+	assert.DeepEqual(t, major, 1)
+	assert.DeepEqual(t, minor, 1)
+
+	// Test empty string
+	major, minor, err = parseHTTPVersion("")
+	assert.NotNil(t, err)
+	assert.DeepEqual(t, major, 1)
+	assert.DeepEqual(t, minor, 1)
+}