Repository: cloudwego/hertz
Pull request #1335 (2025-04-17 09:46:17)

feat(protocol): new sse pkg
#### 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:
-->
- [x] This PR title match the format: \<type\>(optional scope): \<description\>
- [x] 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.


#### (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: new `sse` pkg for reading and writing server-sent events
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/1335?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 `96.92308%` with `6 lines` in your changes missing coverage. Please review.
> Project coverage is 73.38%. Comparing base [(`af864f0`)](https://app.codecov.io/gh/cloudwego/hertz/commit/af864f0f7aa4784b84948b948e130c3c0213ce3c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) to head [(`9af932a`)](https://app.codecov.io/gh/cloudwego/hertz/commit/9af932a48aed4bdde7331ba2699c129cc93a7c2b?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/1335?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/protocol/sse/reader.go](https://app.codecov.io/gh/cloudwego/hertz/pull/1335?src=pr&el=tree&filepath=pkg%2Fprotocol%2Fsse%2Freader.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL3Byb3RvY29sL3NzZS9yZWFkZXIuZ28=) | 91.66% | [5 Missing and 1 partial :warning: ](https://app.codecov.io/gh/cloudwego/hertz/pull/1335?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    #1335      +/-   ##
===========================================
+ Coverage    73.10%   73.38%   +0.28%     
===========================================
  Files          139      144       +5     
  Lines        16315    16510     +195     
===========================================
+ Hits         11927    12116     +189     
- Misses        3803     3808       +5     
- Partials       585      586       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/cloudwego/hertz/pull/1335?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/protocol/sse/event.go b/pkg/protocol/sse/event.go
new file mode 100644
index 0000000..1696b2d
--- /dev/null
+++ b/pkg/protocol/sse/event.go
@@ -0,0 +1,159 @@
+/*
+ * 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 sse
+
+import (
+	"fmt"
+	"sync"
+	"time"
+)
+
+const (
+	fieldID = 1 << iota
+	fieldType
+	fieldData
+	fieldRetry
+)
+
+// Event represents a Server-Sent Event (SSE).
+type Event struct {
+	ID   string
+	Type string
+	Data []byte
+
+	// hertz only supports reading and writing the field,
+	// and will not take care of retry policy, please implement on your own.
+	Retry time.Duration
+
+	bitset uint8
+}
+
+var poolEvent = sync.Pool{}
+
+// NewEvent creates a new event.
+//
+// Call `Release` when you're done with the event.
+func NewEvent() *Event {
+	if v := poolEvent.Get(); v != nil {
+		ret := v.(*Event)
+		ret.Reset()
+		return ret
+	}
+	return &Event{}
+}
+
+// Release releases the event back to the pool.
+func (e *Event) Release() {
+	poolEvent.Put(e)
+}
+
+// String returns a string representation of the event.
+func (e *Event) String() string {
+	return fmt.Sprintf("Event{ID:%q, Type:%q, Retry:%s, Data:%q}", e.ID, e.Type, e.Retry, e.Data)
+}
+
+// Reset resets the event fields.
+func (e *Event) Reset() {
+	e.ID = ""
+	e.Type = ""
+	e.Retry = time.Duration(0)
+	e.Data = e.Data[:0]
+	e.bitset = 0
+}
+
+// Clone creates a copy of the event.
+//
+// When it's no longer needed, call `Release` to return it to the pool.
+func (e *Event) Clone() *Event {
+	p := NewEvent()
+	p.ID = e.ID
+	p.Type = e.Type
+	p.Retry = e.Retry
+	p.Data = append(p.Data[:0], e.Data...)
+	p.bitset = e.bitset
+	return p
+}
+
+// IsSetID returns true if the event ID is set.
+//
+// Please use SetID to set the event ID for differentiating notset or empty
+func (e *Event) IsSetID() bool {
+	return e.bitset&fieldID != 0 || len(e.ID) > 0
+}
+
+// IsSetType returns true if the event type is set.
+//
+// Please use SetEvent to set the event type for differentiating notset or empty
+func (e *Event) IsSetType() bool {
+	return e.bitset&fieldType != 0 || len(e.Type) > 0
+}
+
+// IsSetRetry returns true if the retry duration is set.
+//
+// Please use SetRetry to set the event retry duration for differentiating notset or empty
+func (e *Event) IsSetRetry() bool {
+	return e.bitset&fieldRetry != 0 || e.Retry != 0
+}
+
+// IsSetData returns true if the event data is set.
+//
+// Please use SetData to set or AppendData to append the event data for differentiating notset or empty
+func (e *Event) IsSetData() bool {
+	return e.bitset&fieldData != 0 || len(e.Data) > 0
+}
+
+// SetID sets the event ID.
+func (e *Event) SetID(id string) {
+	e.ID = id
+	e.bitset |= fieldID
+}
+
+// SetEvent sets the event type.
+func (e *Event) SetEvent(eventType string) {
+	e.Type = eventType
+	e.bitset |= fieldType
+}
+
+// SetData sets the event data.
+func (e *Event) SetData(data []byte) {
+	e.Data = append(e.Data[:0], data...)
+	e.bitset |= fieldData
+}
+
+// SetDataString sets the event data from a string.
+func (e *Event) SetDataString(data string) {
+	e.Data = append(e.Data[:0], data...)
+	e.bitset |= fieldData
+}
+
+// AppendData appends data to the event data.
+func (e *Event) AppendData(data []byte) {
+	e.Data = append(e.Data, data...)
+	e.bitset |= fieldData
+}
+
+// AppendDataString appends string data to the event data.
+func (e *Event) AppendDataString(data string) {
+	e.Data = append(e.Data, data...)
+	e.bitset |= fieldData
+}
+
+// SetRetry sets the retry duration.
+func (e *Event) SetRetry(retry time.Duration) {
+	e.Retry = retry
+	e.bitset |= fieldRetry
+}
diff --git a/pkg/protocol/sse/reader.go b/pkg/protocol/sse/reader.go
new file mode 100644
index 0000000..ef089b4
--- /dev/null
+++ b/pkg/protocol/sse/reader.go
@@ -0,0 +1,166 @@
+/*
+ * 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 sse
+
+import (
+	"bufio"
+	"bytes"
+	"errors"
+	"io"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/cloudwego/hertz/internal/bytestr"
+	"github.com/cloudwego/hertz/pkg/protocol"
+)
+
+// errNotSSEContentType is returned when the response's content type is not text/event-stream.
+var errNotSSEContentType = errors.New("Content-Type returned by server is NOT text/event-stream")
+
+// Reader represents a reader for Server-Sent Events (SSE).
+//
+// It is used to parse the response body and extract individual events.
+type Reader struct {
+	resp   *protocol.Response
+	s      *bufio.Scanner
+	events int32
+}
+
+// NewReader creates a new SSE reader from the given response.
+//
+// It returns an error if the response's content type is not text/event-stream.
+func NewReader(resp *protocol.Response) (*Reader, error) {
+	if !bytes.HasPrefix(resp.Header.ContentType(), bytestr.MIMETextEventStream) {
+		return nil, errNotSSEContentType
+	}
+	r := &Reader{resp: resp}
+	if resp.IsBodyStream() {
+		r.s = bufio.NewScanner(resp.BodyStream())
+	} else {
+		r.s = bufio.NewScanner(bytes.NewReader(resp.Body()))
+	}
+	return r, nil
+}
+
+// ForEach iterates over all SSE events in the response body,
+// invoking the provided handler function for each event.
+//
+// `f` MUST NOT keep the Event reference after the func returns.
+// Use (*Event).Clone to create a copy instead.
+//
+// Iteration stops if the handler returns an error or if reading fails.
+// It returns nil if all events are processed successfully.
+func (r *Reader) ForEach(f func(e *Event) error) error {
+	e := NewEvent()
+	defer e.Release()
+	for {
+		if err := r.Read(e); err != nil {
+			if err == io.EOF {
+				return nil
+			}
+			return err
+		}
+		if err := f(e); err != nil {
+			return err
+		}
+	}
+}
+
+// Read reads a single SSE event from the response body.
+//
+// It populates the provided Event struct with the parsed data.
+// Returns nil if an event was successfully read, or an error otherwise.
+func (r *Reader) Read(e *Event) error {
+	e.Reset()
+	for i := 0; r.s.Scan(); i++ {
+		line := r.s.Bytes()
+
+		// Trim UTF8 BOM
+		if i == 0 && r.events == 0 && bytes.HasPrefix(line, []byte{0xEF, 0xBB, 0xBF}) {
+			line = line[3:]
+		}
+
+		if len(line) == 0 {
+			// Empty line marks the end of an event
+			if e.bitset != 0 {
+				r.events++
+				return nil
+			}
+			continue // Skip empty lines at the beginning
+		}
+
+		if line[0] == ':' {
+			// Comment which starts with colon
+			continue
+		}
+
+		// Parse field
+		var f, v []byte
+		i := bytes.IndexByte(line, ':')
+		if i < 0 {
+			// No colon, the entire line is the field name with an empty value
+			f = line
+		} else {
+			f = line[:i]
+			// If the colon is followed by a space, remove it
+			if i+1 < len(line) && line[i+1] == ' ' {
+				v = line[i+2:]
+			} else {
+				v = line[i+1:]
+			}
+		}
+
+		// Process the field
+		switch string(f) {
+		case "event":
+			e.SetEvent(sseEventType(v))
+		case "data":
+			if len(e.Data) > 0 {
+				// If we already have data, append a newline before the new data
+				e.Data = append(e.Data, '\n')
+			}
+			e.AppendData(v)
+		case "id":
+			id := string(v)
+			// Ignore if it contains Null
+			if !strings.Contains(id, "\u0000") {
+				e.SetID(id)
+			}
+		case "retry":
+			if retry, err := strconv.ParseInt(string(v), 10, 64); err == nil {
+				e.SetRetry(time.Duration(retry) * time.Millisecond)
+			}
+		default:
+			// As per spec, ignore if it's not defined.
+		}
+	}
+	// Check if scanner encountered an error
+	if err := r.s.Err(); err != nil {
+		return err
+	}
+	if e.bitset == 0 {
+		return io.EOF
+	}
+	r.events++
+	return nil
+}
+
+// Close closes the underlying response body.
+func (r *Reader) Close() error {
+	return r.resp.CloseBodyStream()
+}
diff --git a/pkg/protocol/sse/request.go b/pkg/protocol/sse/request.go
new file mode 100644
index 0000000..84a5ac8
--- /dev/null
+++ b/pkg/protocol/sse/request.go
@@ -0,0 +1,42 @@
+/*
+ * 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 sse
+
+import (
+	"bytes"
+
+	"github.com/cloudwego/hertz/internal/bytestr"
+	"github.com/cloudwego/hertz/pkg/protocol"
+)
+
+// AddAcceptMIME adds `text/event-stream` to http `Accept` header.
+//
+// This is NOT required as per spec:
+// * User agents MAY set (`Accept`, `text/event-stream`) in request's header list.
+func AddAcceptMIME(req *protocol.Request) {
+	v := req.Header.Peek("Accept")
+	if len(v) > 0 {
+		if bytes.Contains(v, bytestr.MIMETextEventStream) {
+			return
+		}
+		// for better compatibility, only use one Accept header value
+		// append `text/event-stream` to the end of the value
+		req.Header.Set("Accept", string(v)+", "+string(bytestr.MIMETextEventStream))
+	} else {
+		req.Header.Set("Accept", string(bytestr.MIMETextEventStream))
+	}
+}
diff --git a/pkg/protocol/sse/utils.go b/pkg/protocol/sse/utils.go
new file mode 100644
index 0000000..3c44ed5
--- /dev/null
+++ b/pkg/protocol/sse/utils.go
@@ -0,0 +1,35 @@
+/*
+ * 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 sse
+
+func sseEventType(v []byte) string {
+	switch string(v) {
+	case "message":
+		return "message"
+	}
+	return string(v)
+}
+
+func hasCRLF(s string) bool {
+	for i := len(s) - 1; i >= 0; i-- {
+		switch s[i] {
+		case '\r', '\n':
+			return true
+		}
+	}
+	return false
+}
diff --git a/pkg/protocol/sse/writer.go b/pkg/protocol/sse/writer.go
new file mode 100644
index 0000000..9e6b8ba
--- /dev/null
+++ b/pkg/protocol/sse/writer.go
@@ -0,0 +1,169 @@
+/*
+ * 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 sse
+
+import (
+	"bytes"
+	"errors"
+	"io"
+	"strconv"
+	"strings"
+	"sync"
+
+	"github.com/cloudwego/hertz/internal/bytestr"
+	"github.com/cloudwego/hertz/pkg/app"
+	"github.com/cloudwego/hertz/pkg/common/bytebufferpool"
+	"github.com/cloudwego/hertz/pkg/protocol/http1/resp"
+)
+
+type writeFlusher interface {
+	io.Writer
+	Flush() error
+}
+
+// Writer represents a writer for Server-Sent Events (SSE).
+//
+// It is used to write individual events to the response body.
+type Writer struct {
+	w writeFlusher
+
+	mu sync.Mutex
+}
+
+// NewWriter creates a new SSE writer.
+func NewWriter(c *app.RequestContext) *Writer {
+	c.Response.Header.Set("Cache-Control", "no-cache")
+	c.Response.Header.SetContentType(string(bytestr.MIMETextEventStream))
+	w := c.Response.GetHijackWriter()
+	if w == nil {
+		w = resp.NewChunkedBodyWriter(&c.Response, c.GetWriter())
+		c.Response.HijackWriter(w)
+	}
+	return &Writer{w: w}
+}
+
+var (
+	errIDContainsCRLR   = errors.New(`id field contains '\r' or '\n'`)
+	errTypeContainsCRLR = errors.New(`event field contains '\r' or '\n'`)
+)
+
+// WriteEvent writes a single SSE event to the response body.
+//
+// If id, eventType, or data are zero-length, they will be ignored.
+// It returns an error if the event contains invalid characters or if the underlying writer fails.
+func (w *Writer) WriteEvent(id, eventType string, data []byte) error {
+	return w.Write(&Event{
+		ID:   id,
+		Type: eventType,
+		Data: data,
+	})
+}
+
+// WriteKeepAlive writes a comment line with "keep-alive" to the response body.
+//
+// It keeps the underlying connection alive, which is useful when using proxy servers.
+func (w *Writer) WriteKeepAlive() error {
+	return w.WriteComment("keep-alive")
+}
+
+// WriteComment writes comment lines to the response body.
+//
+// Client-side will ignore lines starting with a U+003A COLON character (:)
+// see: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
+func (w *Writer) WriteComment(s string) error {
+	p := bytebufferpool.Get()
+	defer bytebufferpool.Put(p)
+
+	buf := p.B[:0]
+	for len(s) > 0 {
+		i := strings.IndexByte(s, '\n')
+		if i >= 0 {
+			buf = append(buf, ':')
+			buf = append(buf, s[:i+1]...) // it contains '\n' already
+			s = s[i+1:]
+		} else {
+			buf = append(append(append(buf, ':'), s...), '\n')
+			s = ""
+		}
+	}
+	if len(buf) == 0 {
+		buf = append(buf, ':')
+	}
+	p.B = buf
+	w.mu.Lock()
+	defer w.mu.Unlock()
+	if _, err := w.w.Write(p.B); err != nil {
+		return err
+	}
+	return w.w.Flush()
+}
+
+// Write writes a single SSE event to the response body.
+//
+// It returns an error if the event contains invalid characters or underlying writer fails.
+func (w *Writer) Write(e *Event) error {
+	p := bytebufferpool.Get()
+	defer bytebufferpool.Put(p)
+
+	buf := p.B[:0]
+
+	if e.IsSetID() {
+		if hasCRLF(e.ID) {
+			return errIDContainsCRLR
+		}
+		buf = append(append(append(buf, "id: "...), e.ID...), '\n')
+	}
+
+	if e.IsSetType() {
+		if e.Type == "message" {
+			buf = append(buf, "event: message\n"...) // fast path for message
+		} else {
+			if hasCRLF(e.Type) {
+				return errTypeContainsCRLR
+			}
+			buf = append(append(append(buf, "event: "...), e.Type...), '\n')
+		}
+	}
+
+	if e.IsSetRetry() {
+		buf = append(buf, "retry: "...)
+		buf = strconv.AppendInt(buf, e.Retry.Milliseconds(), 10)
+		buf = append(buf, '\n')
+	}
+
+	if e.IsSetData() {
+		data := e.Data
+		for len(data) > 0 {
+			i := bytes.IndexByte(data, '\n')
+			if i >= 0 {
+				buf = append(buf, "data: "...)
+				buf = append(buf, data[:i+1]...) // it contains '\n' already
+				data = data[i+1:]
+			} else {
+				buf = append(append(append(buf, "data: "...), data...), '\n')
+				data = nil
+			}
+		}
+	}
+	p.B = append(buf, '\n') // end of event
+	w.mu.Lock()
+	defer w.mu.Unlock()
+	if _, err := w.w.Write(p.B); err != nil {
+		return err
+	}
+	return w.w.Flush()
+}


Test patch:
diff --git a/pkg/protocol/sse/event_test.go b/pkg/protocol/sse/event_test.go
new file mode 100644
index 0000000..a375599
--- /dev/null
+++ b/pkg/protocol/sse/event_test.go
@@ -0,0 +1,135 @@
+/*
+ * 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 sse
+
+import (
+	"testing"
+	"time"
+
+	"github.com/cloudwego/hertz/pkg/common/test/assert"
+)
+
+func TestEvent_SetAndIsSet(t *testing.T) {
+	e := NewEvent()
+	defer e.Release()
+
+	// Test initial state
+	assert.Assert(t, !e.IsSetID())
+	assert.Assert(t, !e.IsSetType())
+	assert.Assert(t, !e.IsSetRetry())
+	assert.Assert(t, !e.IsSetData())
+
+	// Test SetID and IsSetID
+	e.SetID("test-id")
+	assert.Assert(t, e.IsSetID())
+	assert.DeepEqual(t, "test-id", e.ID)
+
+	// Test SetEvent and IsSetType
+	e.SetEvent("test-event")
+	assert.Assert(t, e.IsSetType())
+	assert.DeepEqual(t, "test-event", e.Type)
+
+	// Test SetRetry and IsSetRetry
+	r := 3 * time.Second
+	e.SetRetry(r)
+	assert.Assert(t, e.IsSetRetry())
+	assert.DeepEqual(t, r, e.Retry)
+
+	// Test SetData and IsSetData
+	d := []byte("test-data")
+	e.SetData(d)
+	assert.Assert(t, e.IsSetData())
+	assert.DeepEqual(t, d, e.Data)
+	e.Reset()
+	assert.Assert(t, e.IsSetData() == false)
+	e.SetDataString(string(d))
+	assert.Assert(t, e.IsSetData())
+	assert.DeepEqual(t, d, e.Data)
+}
+
+func TestEvent_AppendData(t *testing.T) {
+	e := NewEvent()
+	defer e.Release()
+
+	// Test AppendData
+	e.AppendData([]byte("first"))
+	assert.Assert(t, e.IsSetData())
+	assert.DeepEqual(t, []byte("first"), e.Data)
+
+	// Append more data
+	e.AppendDataString("second")
+	assert.DeepEqual(t, []byte("firstsecond"), e.Data)
+}
+
+func TestEvent_Reset(t *testing.T) {
+	e := NewEvent()
+	defer e.Release()
+
+	// Set all fields
+	e.SetID("test-id")
+	e.SetEvent("test-event")
+	e.SetRetry(3 * time.Second)
+	e.SetData([]byte("test-data"))
+
+	// Verify all fields are set
+	assert.Assert(t, e.IsSetID())
+	assert.Assert(t, e.IsSetType())
+	assert.Assert(t, e.IsSetRetry())
+	assert.Assert(t, e.IsSetData())
+
+	// Reset and verify all fields are cleared
+	e.Reset()
+	assert.Assert(t, !e.IsSetID())
+	assert.Assert(t, !e.IsSetType())
+	assert.Assert(t, !e.IsSetRetry())
+	assert.Assert(t, !e.IsSetData())
+	assert.DeepEqual(t, "", e.ID)
+	assert.DeepEqual(t, "", e.Type)
+	assert.DeepEqual(t, time.Duration(0), e.Retry)
+	assert.DeepEqual(t, 0, len(e.Data))
+}
+
+func TestEvent_Clone(t *testing.T) {
+	e1 := NewEvent()
+	e1.SetID("test-id")
+	e1.SetEvent("test-event")
+	e1.SetRetry(3 * time.Second)
+	e1.SetData([]byte("test-data"))
+	e2 := e1.Clone()
+	assert.Assert(t, e2.IsSetID())
+	assert.Assert(t, e2.IsSetType())
+	assert.Assert(t, e2.IsSetRetry())
+	assert.Assert(t, e2.IsSetData())
+	assert.DeepEqual(t, "test-id", e2.ID)
+	assert.DeepEqual(t, "test-event", e2.Type)
+	assert.DeepEqual(t, 3*time.Second, e2.Retry)
+	assert.DeepEqual(t, []byte("test-data"), e2.Data)
+	e1.Release()
+	e2.Release()
+}
+
+func TestEvent_PoolAndRelease(t *testing.T) {
+	e1 := NewEvent()
+	e1.SetID("test-id")
+	e1.Release()
+
+	// Get another event from the pool, should be the same instance but reset
+	e2 := NewEvent()
+	assert.Assert(t, !e2.IsSetID())
+	assert.DeepEqual(t, "", e2.ID)
+	e2.Release()
+}
diff --git a/pkg/protocol/sse/example_test.go b/pkg/protocol/sse/example_test.go
new file mode 100644
index 0000000..d1faafa
--- /dev/null
+++ b/pkg/protocol/sse/example_test.go
@@ -0,0 +1,73 @@
+/*
+ * 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 sse
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/cloudwego/hertz/internal/testutils"
+	"github.com/cloudwego/hertz/pkg/app"
+	"github.com/cloudwego/hertz/pkg/app/client"
+	"github.com/cloudwego/hertz/pkg/common/config"
+	"github.com/cloudwego/hertz/pkg/protocol"
+	"github.com/cloudwego/hertz/pkg/route"
+)
+
+// Example demonstrates a simple SSE server and client interaction.
+func Example() {
+	// --- SSE Server ---
+	opt := config.NewOptions([]config.Option{})
+	opt.Addr = "127.0.0.1:0"
+	engine := route.NewEngine(opt)
+	engine.GET("/", func(ctx context.Context, c *app.RequestContext) {
+		w := NewWriter(c)
+		for i := 0; i < 5; i++ {
+			w.WriteEvent(fmt.Sprintf("id-%d", i), "message", []byte("hello\n\nworld"))
+			time.Sleep(10 * time.Millisecond)
+		}
+	})
+	go engine.Run()
+	defer engine.Close()
+	// Wait for server to start
+	time.Sleep(20 * time.Millisecond)
+	opt.Addr = testutils.GetListenerAddr(engine)
+
+	// --- SSE Client ---
+	c, _ := client.NewClient()
+	req, resp := protocol.AcquireRequest(), protocol.AcquireResponse()
+	req.SetRequestURI("http://" + opt.Addr + "/")
+	req.SetMethod("GET")
+	AddAcceptMIME(req) // optional for most SSE servers
+	if err := c.Do(context.Background(), req, resp); err != nil {
+		panic(err)
+	}
+	r, err := NewReader(resp)
+	if err != nil {
+		panic(err)
+	}
+	err = r.ForEach(func(e *Event) error {
+		println("Event:", e.String())
+		return nil
+	})
+	if err != nil {
+		panic(err)
+	}
+	// Output:
+	//
+}
diff --git a/pkg/protocol/sse/reader_test.go b/pkg/protocol/sse/reader_test.go
new file mode 100644
index 0000000..16a3acd
--- /dev/null
+++ b/pkg/protocol/sse/reader_test.go
@@ -0,0 +1,288 @@
+/*
+ * 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 sse
+
+import (
+	"bytes"
+	"io"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/cloudwego/hertz/internal/bytestr"
+	"github.com/cloudwego/hertz/pkg/common/test/assert"
+	"github.com/cloudwego/hertz/pkg/protocol"
+)
+
+type mockBodyStream struct {
+	reader io.Reader
+	closed bool
+}
+
+func (m *mockBodyStream) Read(p []byte) (n int, err error) {
+	return m.reader.Read(p)
+}
+
+func (m *mockBodyStream) Close() error {
+	m.closed = true
+	return nil
+}
+
+func TestNewReader(t *testing.T) {
+	tests := []struct {
+		name        string
+		contentType []byte
+		body        []byte
+		wantErr     bool
+	}{
+		{
+			name:        "Valid content type",
+			contentType: bytestr.MIMETextEventStream,
+			body:        []byte("event: message\ndata: test\n\n"),
+			wantErr:     false,
+		},
+		{
+			name:        "Invalid content type",
+			contentType: []byte("text/plain"),
+			body:        []byte("event: message\ndata: test\n\n"),
+			wantErr:     true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			resp := &protocol.Response{}
+			resp.Header.SetContentType(string(tt.contentType))
+			resp.SetBody(tt.body)
+
+			r, err := NewReader(resp)
+			if tt.wantErr {
+				assert.Assert(t, err != nil)
+				assert.Assert(t, r == nil)
+			} else {
+				assert.Assert(t, err == nil)
+				assert.Assert(t, r != nil)
+			}
+		})
+	}
+}
+
+func TestReader_ReadEvent(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected *Event
+		wantErr  bool
+	}{
+		{
+			name:  "Basic event",
+			input: "id: 123\nevent: update\ndata: test data\n\n",
+			expected: func() *Event {
+				e := NewEvent()
+				e.SetID("123")
+				e.SetEvent("update")
+				e.SetData([]byte("test data"))
+				return e
+			}(),
+			wantErr: false,
+		},
+		{
+			name:  "Event with retry",
+			input: "id: 123\nevent: update\nretry: 3000\ndata: test data\n\n",
+			expected: func() *Event {
+				e := NewEvent()
+				e.SetID("123")
+				e.SetEvent("update")
+				e.SetRetry(3000 * time.Millisecond)
+				e.SetData([]byte("test data"))
+				return e
+			}(),
+			wantErr: false,
+		},
+		{
+			name:  "Event with multiline data",
+			input: "id: 123\nevent: update\ndata: line1\ndata: line2\ndata: line3\n\n",
+			expected: func() *Event {
+				e := NewEvent()
+				e.SetID("123")
+				e.SetEvent("update")
+				e.SetData([]byte("line1\nline2\nline3"))
+				return e
+			}(),
+			wantErr: false,
+		},
+		{
+			name:  "Event with BOM",
+			input: "\xEF\xBB\xBFid: 123\nevent: update\ndata: test data\n\n",
+			expected: func() *Event {
+				e := NewEvent()
+				e.SetID("123")
+				e.SetEvent("update")
+				e.SetData([]byte("test data"))
+				return e
+			}(),
+			wantErr: false,
+		},
+		{
+			name:  "Event with comments",
+			input: ": this is a comment\nid: 123\n: another comment\nevent: update\ndata: test data\n\n",
+			expected: func() *Event {
+				e := NewEvent()
+				e.SetID("123")
+				e.SetEvent("update")
+				e.SetData([]byte("test data"))
+				return e
+			}(),
+			wantErr: false,
+		},
+		{
+			name:  "Event with no colon in field",
+			input: "id\nevent: update\ndata: test data\n\n",
+			expected: func() *Event {
+				e := NewEvent()
+				e.SetID("")
+				e.SetEvent("update")
+				e.SetData([]byte("test data"))
+				return e
+			}(),
+			wantErr: false,
+		},
+		{
+			name:  "Event with no space after colon",
+			input: "id:123\nevent:update\ndata:test data\n\n",
+			expected: func() *Event {
+				e := NewEvent()
+				e.SetID("123")
+				e.SetEvent("update")
+				e.SetData([]byte("test data"))
+				return e
+			}(),
+			wantErr: false,
+		},
+		{
+			name:  "Event with ID containing null character (should be ignored)",
+			input: "id: test\u0000id\nevent: update\ndata: test data\n\n",
+			expected: func() *Event {
+				e := NewEvent()
+				e.SetEvent("update")
+				e.SetData([]byte("test data"))
+				return e
+			}(),
+			wantErr: false,
+		},
+		{
+			name:  "Event with invalid retry value",
+			input: "id: 123\nevent: update\nretry: invalid\ndata: test data\n\n",
+			expected: func() *Event {
+				e := NewEvent()
+				e.SetID("123")
+				e.SetEvent("update")
+				e.SetData([]byte("test data"))
+				return e
+			}(),
+			wantErr: false,
+		},
+		{
+			name:  "Empty event",
+			input: "\n\n",
+			expected: func() *Event {
+				e := NewEvent()
+				// Empty event doesn't set any fields, so bitset remains 0
+				return e
+			}(),
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			resp := &protocol.Response{}
+			resp.Header.SetContentType(string(bytestr.MIMETextEventStream))
+			resp.SetBody([]byte(tt.input))
+
+			r, err := NewReader(resp)
+			assert.Assert(t, err == nil)
+
+			e := NewEvent()
+			err = r.Read(e)
+
+			if tt.wantErr {
+				assert.Assert(t, err != nil)
+			} else {
+				assert.Assert(t, err == nil)
+				assert.DeepEqual(t, tt.expected.ID, e.ID)
+				assert.DeepEqual(t, tt.expected.Type, e.Type)
+				assert.DeepEqual(t, tt.expected.Retry, e.Retry)
+				assert.DeepEqual(t, tt.expected.Data, e.Data)
+			}
+
+			e.Release()
+		})
+	}
+}
+
+func TestReader_ReadEvent_WithBodyStream(t *testing.T) {
+	input := "id: 123\nevent: update\ndata: test data\n\n"
+
+	resp := &protocol.Response{}
+	resp.Header.SetContentType(string(bytestr.MIMETextEventStream))
+
+	// Create a mock body stream
+	ms := &mockBodyStream{
+		reader: strings.NewReader(input),
+	}
+	resp.SetBodyStream(ms, -1)
+
+	r, err := NewReader(resp)
+	assert.Assert(t, err == nil)
+
+	e := NewEvent()
+	err = r.Read(e)
+	assert.Assert(t, err == nil)
+
+	// Verify event data
+	assert.DeepEqual(t, "123", e.ID)
+	assert.DeepEqual(t, "update", e.Type)
+	assert.DeepEqual(t, []byte("test data"), e.Data)
+
+	// Test Close
+	err = r.Close()
+	assert.Assert(t, err == nil)
+	assert.Assert(t, ms.closed)
+
+	e.Release()
+}
+
+func TestReader_ReadEvent_Error(t *testing.T) {
+	// Create a reader that will return an error
+	errReader := &bytes.Reader{}
+
+	resp := &protocol.Response{}
+	resp.Header.SetContentType(string(bytestr.MIMETextEventStream))
+	resp.SetBodyStream(errReader, -1)
+
+	r, err := NewReader(resp)
+	assert.Assert(t, err == nil)
+
+	e := NewEvent()
+	err = r.Read(e)
+
+	// The error from bytes.Reader will be io.EOF
+	assert.Assert(t, err == io.EOF)
+
+	e.Release()
+}
diff --git a/pkg/protocol/sse/request_test.go b/pkg/protocol/sse/request_test.go
new file mode 100644
index 0000000..c04d231
--- /dev/null
+++ b/pkg/protocol/sse/request_test.go
@@ -0,0 +1,54 @@
+/*
+ * 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 sse
+
+import (
+	"testing"
+
+	"github.com/cloudwego/hertz/internal/bytestr"
+	"github.com/cloudwego/hertz/pkg/common/test/assert"
+	"github.com/cloudwego/hertz/pkg/protocol"
+)
+
+func TestAddAcceptMIME(t *testing.T) {
+	// Test case 1: Empty Accept header
+	req := protocol.AcquireRequest()
+	defer protocol.ReleaseRequest(req)
+
+	AddAcceptMIME(req)
+
+	acceptHeader := req.Header.Peek("Accept")
+	assert.DeepEqual(t, string(bytestr.MIMETextEventStream), string(acceptHeader))
+
+	// Test case 2: Existing Accept header without text/event-stream
+	req.Reset()
+	req.Header.Set("Accept", "text/html, application/json")
+
+	AddAcceptMIME(req)
+
+	acceptHeader = req.Header.Peek("Accept")
+	assert.DeepEqual(t, "text/html, application/json, text/event-stream", string(acceptHeader))
+
+	// Test case 3: Existing Accept header already containing text/event-stream
+	req.Reset()
+	req.Header.Set("Accept", "text/html, text/event-stream, application/json")
+
+	AddAcceptMIME(req)
+
+	acceptHeader = req.Header.Peek("Accept")
+	assert.DeepEqual(t, "text/html, text/event-stream, application/json", string(acceptHeader))
+}
diff --git a/pkg/protocol/sse/utils_test.go b/pkg/protocol/sse/utils_test.go
new file mode 100644
index 0000000..e9169f3
--- /dev/null
+++ b/pkg/protocol/sse/utils_test.go
@@ -0,0 +1,38 @@
+/*
+ * 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 sse
+
+import (
+	"testing"
+
+	"github.com/cloudwego/hertz/pkg/common/test/assert"
+)
+
+func TestHasCRLF(t *testing.T) {
+	assert.Assert(t, hasCRLF("\nThis is a test string"))
+	assert.Assert(t, hasCRLF("This is \na test string"))
+	assert.Assert(t, hasCRLF("This is a test string\n"))
+	assert.Assert(t, hasCRLF("\rThis is a test string"))
+	assert.Assert(t, hasCRLF("This is \rna test string"))
+	assert.Assert(t, hasCRLF("This is a test string\r"))
+	assert.Assert(t, hasCRLF("This is a test string") == false)
+}
+
+func TestSseEventType(t *testing.T) {
+	assert.DeepEqual(t, "message", sseEventType([]byte("message")))
+	assert.DeepEqual(t, "custom", sseEventType([]byte("custom")))
+}
diff --git a/pkg/protocol/sse/writer_test.go b/pkg/protocol/sse/writer_test.go
new file mode 100644
index 0000000..24e2aa8
--- /dev/null
+++ b/pkg/protocol/sse/writer_test.go
@@ -0,0 +1,222 @@
+/*
+ * 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 sse
+
+import (
+	"bytes"
+	"errors"
+	"testing"
+	"time"
+
+	"github.com/cloudwego/hertz/pkg/app"
+	"github.com/cloudwego/hertz/pkg/common/test/assert"
+)
+
+// mockWriteFlusher implements the writeFlusher interface for testing
+type mw struct {
+	buf         bytes.Buffer
+	flushCalled bool
+	writeErr    error
+	flushErr    error
+}
+
+func (m *mw) Write(p []byte) (n int, err error) {
+	if m.writeErr != nil {
+		return 0, m.writeErr
+	}
+	return m.buf.Write(p)
+}
+
+func (m *mw) Flush() error {
+	m.flushCalled = true
+	return m.flushErr
+}
+
+func (m *mw) String() string {
+	return m.buf.String()
+}
+
+func TestWriter_WriteEvent(t *testing.T) {
+	tests := []struct {
+		name      string
+		id        string
+		eventType string
+		data      []byte
+		wantErr   bool
+		expected  string
+	}{
+		{
+			name:      "Basic event",
+			id:        "123",
+			eventType: "message",
+			data:      []byte("test data"),
+			wantErr:   false,
+			expected:  "id: 123\nevent: message\ndata: test data\n\n",
+		},
+		{
+			name:      "Empty fields",
+			id:        "",
+			eventType: "",
+			data:      nil,
+			wantErr:   false,
+			expected:  "\n",
+		},
+		{
+			name:      "ID with CRLF",
+			id:        "test\nid",
+			eventType: "update",
+			data:      []byte("test data"),
+			wantErr:   true,
+			expected:  "",
+		},
+		{
+			name:      "Event type with CRLF",
+			id:        "123",
+			eventType: "up\ndate",
+			data:      []byte("test data"),
+			wantErr:   true,
+			expected:  "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m := &mw{}
+			w := &Writer{w: m}
+
+			err := w.WriteEvent(tt.id, tt.eventType, tt.data)
+
+			if tt.wantErr {
+				assert.Assert(t, err != nil)
+			} else {
+				assert.Assert(t, err == nil)
+				assert.DeepEqual(t, tt.expected, m.String())
+				assert.Assert(t, m.flushCalled)
+			}
+		})
+	}
+}
+
+func TestWriter_Write(t *testing.T) {
+	tests := []struct {
+		name     string
+		event    *Event
+		writeErr error
+		flushErr error
+		wantErr  bool
+		expected string
+	}{
+		{
+			name: "Complete event",
+			event: func() *Event {
+				e := NewEvent()
+				e.SetID("123")
+				e.SetEvent("update")
+				e.SetRetry(3 * time.Second)
+				e.SetData([]byte("test data"))
+				return e
+			}(),
+			wantErr:  false,
+			expected: "id: 123\nevent: update\nretry: 3000\ndata: test data\n\n",
+		},
+		{
+			name: "Multiline data",
+			event: func() *Event {
+				e := NewEvent()
+				e.SetData([]byte("line1\nline2\nline3"))
+				return e
+			}(),
+			wantErr:  false,
+			expected: "data: line1\ndata: line2\ndata: line3\n\n",
+		},
+		{
+			name: "Write error",
+			event: func() *Event {
+				e := NewEvent()
+				e.SetData([]byte("test data"))
+				return e
+			}(),
+			writeErr: errors.New("write error"),
+			wantErr:  true,
+			expected: "",
+		},
+		{
+			name: "Flush error",
+			event: func() *Event {
+				e := NewEvent()
+				e.SetData([]byte("test data"))
+				return e
+			}(),
+			flushErr: errors.New("flush error"),
+			wantErr:  true,
+			expected: "data: test data\n\n",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m := &mw{
+				writeErr: tt.writeErr,
+				flushErr: tt.flushErr,
+			}
+			w := &Writer{w: m}
+
+			err := w.Write(tt.event)
+
+			if tt.wantErr {
+				assert.Assert(t, err != nil)
+			} else {
+				assert.Assert(t, err == nil)
+				assert.DeepEqual(t, tt.expected, m.String())
+				assert.Assert(t, m.flushCalled)
+			}
+		})
+	}
+}
+
+func TestNewWriter(t *testing.T) {
+	c := app.NewContext(0)
+	w := NewWriter(c)
+	assert.Assert(t, w != nil)
+	assert.DeepEqual(t, "no-cache", string(c.Response.Header.Peek("Cache-Control")))
+	assert.DeepEqual(t, "text/event-stream", string(c.Response.Header.Peek("Content-Type")))
+}
+
+func TestWriter_WriteComment(t *testing.T) {
+	m := &mw{}
+	w := &Writer{w: m}
+
+	err := w.WriteComment("test\ncomment")
+
+	assert.Assert(t, err == nil)
+	assert.DeepEqual(t, ":test\n:comment\n", m.String())
+	assert.Assert(t, m.flushCalled)
+
+	// empty string
+	m = &mw{}
+	w = &Writer{w: m}
+	err = w.WriteComment("")
+	assert.Assert(t, err == nil)
+	assert.DeepEqual(t, ":", m.String())
+
+	// keep-alive
+	m = &mw{}
+	w = &Writer{w: m}
+	err = w.WriteKeepAlive()
+	assert.Assert(t, err == nil)
+	assert.DeepEqual(t, ":keep-alive\n", m.String())
+}