Repository: cloudwego/hertz
Pull request #1339 (2025-04-24 08:49:16)

feat(sse): add LastEventID helpers
#### 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: add LastEventID helper funcs
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/1339?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 `90.90909%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 73.43%. Comparing base [(`5a39ca9`)](https://app.codecov.io/gh/cloudwego/hertz/commit/5a39ca94ec1e4f2d82ccae15f96b332b6ac5fa33?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) to head [(`842a545`)](https://app.codecov.io/gh/cloudwego/hertz/commit/842a545113599c966e41bb19a6d01494a0e33ec8?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/1339?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/1339?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=) | 88.88% | [1 Missing :warning: ](https://app.codecov.io/gh/cloudwego/hertz/pull/1339?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    #1339      +/-   ##
===========================================
+ Coverage    73.42%   73.43%   +0.01%     
===========================================
  Files          144      144              
  Lines        16560    16569       +9     
===========================================
+ Hits         12159    12168       +9     
  Misses        3813     3813              
  Partials       588      588              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/cloudwego/hertz/pull/1339?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
index 1696b2d..3989ca6 100644
--- a/pkg/protocol/sse/event.go
+++ b/pkg/protocol/sse/event.go
@@ -32,7 +32,7 @@ const (
 // Event represents a Server-Sent Event (SSE).
 type Event struct {
 	ID   string
-	Type string
+	Type string // aka `event` field, which means event type
 	Data []byte
 
 	// hertz only supports reading and writing the field,
diff --git a/pkg/protocol/sse/reader.go b/pkg/protocol/sse/reader.go
index ef089b4..f44e734 100644
--- a/pkg/protocol/sse/reader.go
+++ b/pkg/protocol/sse/reader.go
@@ -39,6 +39,8 @@ type Reader struct {
 	resp   *protocol.Response
 	s      *bufio.Scanner
 	events int32
+
+	lastEventID string
 }
 
 // NewReader creates a new SSE reader from the given response.
@@ -81,6 +83,18 @@ func (r *Reader) ForEach(f func(e *Event) error) error {
 	}
 }
 
+// LastEventID returns the last event ID read by the reader.
+func (r *Reader) LastEventID() string {
+	return r.lastEventID
+}
+
+func (r *Reader) onEventRead(e *Event) {
+	r.events++
+	if e.IsSetID() {
+		r.lastEventID = e.ID
+	}
+}
+
 // Read reads a single SSE event from the response body.
 //
 // It populates the provided Event struct with the parsed data.
@@ -98,7 +112,7 @@ func (r *Reader) Read(e *Event) error {
 		if len(line) == 0 {
 			// Empty line marks the end of an event
 			if e.bitset != 0 {
-				r.events++
+				r.onEventRead(e)
 				return nil
 			}
 			continue // Skip empty lines at the beginning
@@ -156,7 +170,7 @@ func (r *Reader) Read(e *Event) error {
 	if e.bitset == 0 {
 		return io.EOF
 	}
-	r.events++
+	r.onEventRead(e)
 	return nil
 }
 
diff --git a/pkg/protocol/sse/utils.go b/pkg/protocol/sse/utils.go
index 3c44ed5..5d69751 100644
--- a/pkg/protocol/sse/utils.go
+++ b/pkg/protocol/sse/utils.go
@@ -16,6 +16,17 @@
 
 package sse
 
+import (
+	"github.com/cloudwego/hertz/pkg/app"
+)
+
+const LastEventIDHeader = "Last-Event-ID"
+
+// GetLastEventID returns the value of the Last-Event-ID header.
+func GetLastEventID(c *app.RequestContext) string {
+	return string(c.GetHeader(LastEventIDHeader))
+}
+
 func sseEventType(v []byte) string {
 	switch string(v) {
 	case "message":


Test patch:
diff --git a/pkg/protocol/sse/example_test.go b/pkg/protocol/sse/example_test.go
index d1faafa..7f83e76 100644
--- a/pkg/protocol/sse/example_test.go
+++ b/pkg/protocol/sse/example_test.go
@@ -36,6 +36,7 @@ func Example() {
 	opt.Addr = "127.0.0.1:0"
 	engine := route.NewEngine(opt)
 	engine.GET("/", func(ctx context.Context, c *app.RequestContext) {
+		println("Server Got LastEventID", GetLastEventID(c))
 		w := NewWriter(c)
 		for i := 0; i < 5; i++ {
 			w.WriteEvent(fmt.Sprintf("id-%d", i), "message", []byte("hello\n\nworld"))
@@ -53,6 +54,7 @@ func Example() {
 	req, resp := protocol.AcquireRequest(), protocol.AcquireResponse()
 	req.SetRequestURI("http://" + opt.Addr + "/")
 	req.SetMethod("GET")
+	req.SetHeader(LastEventIDHeader, "id-0")
 	AddAcceptMIME(req) // optional for most SSE servers
 	if err := c.Do(context.Background(), req, resp); err != nil {
 		panic(err)
@@ -68,6 +70,7 @@ func Example() {
 	if err != nil {
 		panic(err)
 	}
+	println("Client LastEventID", r.LastEventID())
 	// Output:
 	//
 }
diff --git a/pkg/protocol/sse/reader_test.go b/pkg/protocol/sse/reader_test.go
index 16a3acd..9b62496 100644
--- a/pkg/protocol/sse/reader_test.go
+++ b/pkg/protocol/sse/reader_test.go
@@ -228,6 +228,11 @@ func TestReader_ReadEvent(t *testing.T) {
 				assert.DeepEqual(t, tt.expected.Type, e.Type)
 				assert.DeepEqual(t, tt.expected.Retry, e.Retry)
 				assert.DeepEqual(t, tt.expected.Data, e.Data)
+
+				// LastEventID check
+				if e.ID != "" {
+					assert.DeepEqual(t, r.LastEventID(), e.ID)
+				}
 			}
 
 			e.Release()
@@ -259,6 +264,11 @@ func TestReader_ReadEvent_WithBodyStream(t *testing.T) {
 	assert.DeepEqual(t, "update", e.Type)
 	assert.DeepEqual(t, []byte("test data"), e.Data)
 
+	// LastEventID check
+	if e.ID != "" {
+		assert.DeepEqual(t, r.LastEventID(), e.ID)
+	}
+
 	// Test Close
 	err = r.Close()
 	assert.Assert(t, err == nil)