Skip to content

🔥 feat: add lightweight SSE middleware#4239

Open
ReneWerner87 wants to merge 9 commits intomainfrom
codex/sse-middleware
Open

🔥 feat: add lightweight SSE middleware#4239
ReneWerner87 wants to merge 9 commits intomainfrom
codex/sse-middleware

Conversation

@ReneWerner87
Copy link
Copy Markdown
Member

Summary

This PR adds a lightweight middleware/sse package for Fiber v3.

The middleware focuses on the SSE transport primitives that need to be
Fiber-native:

  • SendStreamWriter based streaming
  • SSE response headers
  • event, comment, and retry frame formatting
  • per-write flushing
  • heartbeat comments
  • Last-Event-ID access
  • stream lifecycle cancellation through Stream.Context()
  • disconnect detection through write/flush errors

It deliberately does not include a built-in hub, topic router, replay
store, auth layer, metrics endpoint, or external pub/sub bridge. Those
features are useful, but they are application-specific and better suited
for recipes, contrib packages, or user code built around this primitive.

Background

This follows the discussion and review history from:

The main direction from review was to keep Fiber core focused on the SSE
protocol and streaming lifecycle, rather than shipping a full pub/sub
framework in middleware.

API

app.Get("/events", sse.New(sse.Config{
    Handler: func(c fiber.Ctx, stream *sse.Stream) error {
        return stream.Event(sse.Event{
            Name: "message",
            Data: fiber.Map{"message": "hello"},
        })
    },
}))

Add a small Fiber-native Server-Sent Events middleware focused on the
transport layer: SSE headers, frame formatting, flushing, heartbeat
comments, Last-Event-ID access, stream lifecycle context, and disconnect
detection through write/flush errors.

The implementation intentionally avoids bundling hub, topic routing,
replay storage, auth helpers, metrics, or pub/sub bridges into core. Those
remain application-level concerns and can be covered by recipes or
separate packages.

Also add middleware docs, focused tests, and an implementation plan that
captures the review history and scope decisions.
Copilot AI review requested due to automatic review settings April 26, 2026 17:09
@ReneWerner87 ReneWerner87 requested a review from a team as a code owner April 26, 2026 17:09
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a new SSE middleware package (middleware/sse) implementing Stream and Event primitives with per-write flush semantics and latched errors, heartbeat and retry controls, configuration hooks, docs, tests, minor GoDoc clarifications, and a small limiter test timing adjustment.

Changes

Cohort / File(s) Summary
SSE Middleware
middleware/sse/config.go, middleware/sse/event.go, middleware/sse/sse.go, middleware/sse/sse_test.go
New sse package: Config/Handler types, Stream and methods, Event struct, SSE framing/serialization (CRLF sanitization, multi-line data), per-write flush with error latching and context cancellation, heartbeat ticker, OnClose hook, and comprehensive tests.
Documentation
docs/middleware/sse.md, docs/whats_new.md
Adds SSE middleware documentation and whats-new entry describing New(config ...Config), Stream API, config options (Retry, HeartbeatInterval, DisableHeartbeat, Next, OnClose), and write/flush/error semantics; updates middlewares TOC.
Generated / Interface docs
ctx_interface_gen.go, req_interface_gen.go, res_interface_gen.go
GoDoc clarifications: multipart parsing honors app BodyLimit, minor formatting tweaks for AutoFormat and getLocationFromRoute docs (comments-only).
Other tests
middleware/limiter/limiter_test.go
Minor timing adjustment in limiter test sleep to ensure sliding-window recalculation after handler delay.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Fiber as "Fiber Handler"
    participant Stream
    participant Heartbeat as "Heartbeat Ticker"
    participant OnClose as "OnClose Callback"

    Client->>Fiber: HTTP request (may include Last-Event-ID)
    Fiber->>Fiber: set SSE headers, abandon request
    Fiber->>Stream: create Stream (context tied to request)
    Fiber->>Heartbeat: start ticker if enabled
    Fiber->>Stream: invoke user Handler(c, stream)

    loop Handler writes events/comments
        Stream->>Stream: serialize (id,event,data,retry/comment) sanitize
        Stream->>Client: flush frame
        alt flush succeeds
            Client-->>Stream: receives frame
        else flush fails / client disconnects
            Stream->>Stream: latch error, cancel context, close Done
            Stream-->>Handler: subsequent writes return stream-closed error
        end
    end

    alt handler returns or stream closed
        Stream->>OnClose: invoke OnClose(c, stream.Err())
        Heartbeat->>Heartbeat: stop ticker
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Suggested labels

✏️ Feature, codex

Suggested reviewers

  • gaby
  • sixcolors
  • efectn

Poem

🐰 I nibble lines and stitch each frame,
heartbeats hop to keep the stream tame,
events hop out tidy, one by one,
when flushes fail, I close and shun,
celebrate the quiet run. ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description covers the summary, background, and API example, but lacks detailed coverage of changes, checklist items, and commit formatting guidance per the template. Add sections for Changes Introduced (with checked items for benchmarks, docs, changelog), Type of Change, and a completed Checklist to fully align with the template structure.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change—adding a lightweight SSE middleware to Fiber, which is the core focus of this pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/sse-middleware

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ReneWerner87 ReneWerner87 added this to v3 Apr 26, 2026
@ReneWerner87 ReneWerner87 added this to the v3 milestone Apr 26, 2026
@ReneWerner87 ReneWerner87 linked an issue Apr 26, 2026 that may be closed by this pull request
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 26, 2026

Codecov Report

❌ Patch coverage is 94.81132% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.24%. Comparing base (16d7bc1) to head (8333370).

Files with missing lines Patch % Lines
middleware/sse/sse.go 95.12% 4 Missing and 2 partials ⚠️
middleware/sse/event.go 93.90% 3 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4239      +/-   ##
==========================================
+ Coverage   91.17%   91.24%   +0.06%     
==========================================
  Files         123      126       +3     
  Lines       12084    12296     +212     
==========================================
+ Hits        11018    11219     +201     
- Misses        668      675       +7     
- Partials      398      402       +4     
Flag Coverage Δ
unittests 91.24% <94.81%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new lightweight middleware/sse package to provide Fiber-native Server-Sent Events (SSE) streaming primitives (headers, framing, flushing, heartbeats, disconnect detection) along with documentation and tests.

Changes:

  • Introduces SSE middleware (New(Config)) and Stream API for event/comment/retry writes with per-write flushing and lifecycle cancellation.
  • Adds SSE wire-format helpers (Event, field sanitization, JSON encoding behavior, comment formatting).
  • Documents the new middleware and announces it in the v3 “What’s New” guide.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
middleware/sse/sse.go Adds the Fiber middleware entrypoint and Stream lifecycle/write/heartbeat logic.
middleware/sse/event.go Implements SSE frame formatting, sanitization, and JSON encoding for event data.
middleware/sse/config.go Defines middleware configuration and defaults (retry + heartbeat behavior).
middleware/sse/sse_test.go Adds unit/integration tests for framing, headers, heartbeats, concurrency, and error handling.
docs/whats_new.md Adds SSE to the v3 change overview and introduces the new middleware section.
docs/middleware/sse.md Adds middleware documentation, examples, and config/stream API reference.

Comment thread middleware/sse/sse_test.go Outdated
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new Server-Sent Events (SSE) middleware for Fiber v3, providing essential transport features such as event formatting, heartbeats, and disconnect detection. The implementation includes a Stream object for handling active connections and a configurable middleware handler. Documentation and unit tests are provided. A review comment pointed out a terminology inaccuracy in the documentation where a context was described as 'closed' instead of 'canceled', providing a suggestion to fix it.

Comment thread docs/middleware/sse.md Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
middleware/sse/sse.go (1)

194-220: Optional: harden stopHeartbeat against double-invocation.

The returned closure performs close(stop), which panics if called twice. Today it's only invoked from a single deferred call site, so this is fine — but a sync.Once (or already-closed sentinel) would make the helper resilient against future refactors that might call it from multiple places.

♻️ Proposed refactor
 func (s *Stream) startHeartbeat(interval time.Duration) func() {
     if interval <= 0 {
         return nil
     }

     stop := make(chan struct{})
     go func() {
         ticker := time.NewTicker(interval)
         defer ticker.Stop()
         for {
             select {
             case <-ticker.C:
                 if err := s.Comment(""); err != nil {
                     return
                 }
             case <-stop:
                 return
             case <-s.Done():
                 return
             }
         }
     }()

-    return func() {
-        close(stop)
-    }
+    var once sync.Once
+    return func() {
+        once.Do(func() { close(stop) })
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@middleware/sse/sse.go` around lines 194 - 220, The stop closure returned by
startHeartbeat currently calls close(stop) which will panic if invoked more than
once; modify startHeartbeat to make the stop operation idempotent (e.g., wrap
the close(stop) call with a sync.Once or check an already-closed sentinel) so
calling the returned function multiple times is safe; update the returned
closure in startHeartbeat to use that sync.Once (or atomic/boolean guard) to
ensure close(stop) executes only once while leaving the existing ticker stop and
goroutine exit behavior unchanged.
middleware/sse/sse_test.go (2)

126-147: Potentially flaky timing on slow CI.

HeartbeatInterval: 10 * time.Millisecond with a 30ms handler window leaves only ~2 expected ticks. On a slow/contended CI runner (or a test binary started under -race), the first tick may arrive after the handler returns, leaving the response body without :\n\n and failing the assertion. Consider widening the window (e.g., 5–10× interval) or polling for the heartbeat marker.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@middleware/sse/sse_test.go` around lines 126 - 147, The test
Test_SSE_NewWritesHeartbeat is flaky because HeartbeatInterval is 10ms but the
Handler only waits 30ms, giving very few ticks on slow CI; update the test to
either increase the handler wait to at least 5–10× HeartbeatInterval (e.g., set
the sleep inside Handler to 50–100ms) or implement a small polling loop that
reads resp.Body until the heartbeat marker ":\n\n" appears (retrying for a short
timeout) to reliably detect the heartbeat; reference the
Test_SSE_NewWritesHeartbeat function, New(Config{HeartbeatInterval: ... ,
Handler: ...}) and stream.Done()/stream.Err() when making the change.

268-272: Minor: add a timeout around the <-closed receive to avoid hanging the suite on regression.

If the middleware ever stops invoking OnClose, this test will deadlock indefinitely instead of failing fast. A select with a deadline is consistent with the timeout patterns already used elsewhere in this file (e.g., Test_SSE_StreamContextCanceledOnClose, Test_SSE_StreamWriteError).

♻️ Proposed change
 	resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/events", http.NoBody))
 	require.NoError(t, err)
 	require.Equal(t, fiber.StatusOK, resp.StatusCode)
-	require.ErrorIs(t, <-closed, handlerErr)
+	select {
+	case err := <-closed:
+		require.ErrorIs(t, err, handlerErr)
+	case <-time.After(time.Second):
+		t.Fatal("OnClose was not invoked")
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@middleware/sse/sse_test.go` around lines 268 - 272, Test can hang waiting on
the closed channel; replace the direct receive "require.ErrorIs(t, <-closed,
handlerErr)" with a select that waits for either a value from the "closed"
channel (asserting ErrorIs against "handlerErr") or a time.After timeout
(failing the test fast). Update imports to include "time" and use the same
timeout duration pattern already used in other tests (e.g.,
Test_SSE_StreamContextCanceledOnClose) so the test fails instead of deadlocking.
docs/middleware/sse.md (1)

117-117: Nit: "Context() are closed" — contexts are canceled, not closed.

Suggest tweaking the wording for accuracy with Go's context vocabulary.

📝 Proposed wording
-Every write is flushed. A failed flush closes `Done`, stores the error returned by `Err`, and lets the handler stop without relying on `fasthttp.RequestCtx.Done`, which is not a per-client disconnect signal. After a normal handler return, `Done` and `Context()` are closed while `Err()` remains `nil`; writes after that return `sse: stream closed`.
+Every write is flushed. A failed flush closes `Done`, stores the error returned by `Err`, and lets the handler stop without relying on `fasthttp.RequestCtx.Done`, which is not a per-client disconnect signal. After a normal handler return, `Done` is closed and `Context()` is canceled while `Err()` remains `nil`; writes after that return `sse: stream closed`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/middleware/sse.md` at line 117, Update the sentence that currently says
"After a normal handler return, `Done` and `Context()` are closed while `Err()`
remains `nil`;" to use correct Go context terminology: indicate that `Done` is
closed but `Context()` is canceled (not closed) while `Err()` remains `nil`.
Reference the symbols `Done`, `Context()`, and `Err()` so readers see the exact
change.
middleware/sse/event.go (1)

89-106: Minor: untyped nil Data produces a data: \n line, unlike typed nil.

When Event.Data is untyped nil, eventData returns "", and writeData still emits a single data: \n line (because strings.SplitSeq("", "\n") yields one empty element). For a typed-nil pointer (the case covered by Test_SSE_EventJSONEncodesTypedNilStringer) it falls through to json.Marshal and emits data: null. Consider documenting this asymmetry or treating nil as "no data line" for parity. Not a correctness issue, just surprising for callers who pass Data: nil expecting either data: null or no data line at all.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@middleware/sse/event.go` around lines 89 - 106, eventData currently returns
an empty string for an untyped nil which causes writeData to emit a spurious
empty "data: \n" line; change eventData to signal "no data" (e.g. change
signature to return (string, bool, error) where the bool indicates whether there
is data) and update writeData to skip emitting any data lines when that bool is
false; keep handling for typed nils (they should still be marshaled to "null")
and ensure Event.Data is the referenced symbol to locate callers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@docs/middleware/sse.md`:
- Line 117: Update the sentence that currently says "After a normal handler
return, `Done` and `Context()` are closed while `Err()` remains `nil`;" to use
correct Go context terminology: indicate that `Done` is closed but `Context()`
is canceled (not closed) while `Err()` remains `nil`. Reference the symbols
`Done`, `Context()`, and `Err()` so readers see the exact change.

In `@middleware/sse/event.go`:
- Around line 89-106: eventData currently returns an empty string for an untyped
nil which causes writeData to emit a spurious empty "data: \n" line; change
eventData to signal "no data" (e.g. change signature to return (string, bool,
error) where the bool indicates whether there is data) and update writeData to
skip emitting any data lines when that bool is false; keep handling for typed
nils (they should still be marshaled to "null") and ensure Event.Data is the
referenced symbol to locate callers.

In `@middleware/sse/sse_test.go`:
- Around line 126-147: The test Test_SSE_NewWritesHeartbeat is flaky because
HeartbeatInterval is 10ms but the Handler only waits 30ms, giving very few ticks
on slow CI; update the test to either increase the handler wait to at least
5–10× HeartbeatInterval (e.g., set the sleep inside Handler to 50–100ms) or
implement a small polling loop that reads resp.Body until the heartbeat marker
":\n\n" appears (retrying for a short timeout) to reliably detect the heartbeat;
reference the Test_SSE_NewWritesHeartbeat function,
New(Config{HeartbeatInterval: ... , Handler: ...}) and
stream.Done()/stream.Err() when making the change.
- Around line 268-272: Test can hang waiting on the closed channel; replace the
direct receive "require.ErrorIs(t, <-closed, handlerErr)" with a select that
waits for either a value from the "closed" channel (asserting ErrorIs against
"handlerErr") or a time.After timeout (failing the test fast). Update imports to
include "time" and use the same timeout duration pattern already used in other
tests (e.g., Test_SSE_StreamContextCanceledOnClose) so the test fails instead of
deadlocking.

In `@middleware/sse/sse.go`:
- Around line 194-220: The stop closure returned by startHeartbeat currently
calls close(stop) which will panic if invoked more than once; modify
startHeartbeat to make the stop operation idempotent (e.g., wrap the close(stop)
call with a sync.Once or check an already-closed sentinel) so calling the
returned function multiple times is safe; update the returned closure in
startHeartbeat to use that sync.Once (or atomic/boolean guard) to ensure
close(stop) executes only once while leaving the existing ticker stop and
goroutine exit behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 41cfc69f-76aa-4e51-ac59-c427aeace07c

📥 Commits

Reviewing files that changed from the base of the PR and between bec9ba6 and 5a8a4f1.

📒 Files selected for processing (6)
  • docs/middleware/sse.md
  • docs/whats_new.md
  • middleware/sse/config.go
  • middleware/sse/event.go
  • middleware/sse/sse.go
  • middleware/sse/sse_test.go

@ReneWerner87
Copy link
Copy Markdown
Member Author

Addressed the remaining SSE review feedback in 1aacd75:

  • event frames are now built before writing to the stream, so marshal errors cannot leave a partial frame buffered
  • untyped Event.Data: nil now emits no data: line, while typed nil values still encode as null
  • heartbeat stop is idempotent
  • heartbeat and OnClose tests have wider/timeout-based waits
  • the concurrent writer test uses a conventional indexed loop
  • docs now use the correct context wording

I also checked the failed CI run. middleware/sse passed on macOS and Windows. The macOS 1.26 failure was middleware/limiter Test_Limiter_Sliding_Window_RecalculatesAfterHandlerDelay, which depended on a tight sleep around second-resolution limiter windows; that wait is widened in this commit. The Windows jobs were cancelled after the run was interrupted, not because of an SSE test failure.

Local verification:

  • go test ./middleware/sse ./middleware/limiter
  • go test ./middleware/limiter -run Test_Limiter_Sliding_Window_RecalculatesAfterHandlerDelay -count=10
  • make lint
  • make test
  • go test ./...

make audit still fails on govulncheck findings in the Go 1.25 standard library, after go mod verify and go vet ./... pass. make format hits a local .git/logs/...replace-cookie-lookup-in-router.go path in this checkout, so I formatted the touched Go files directly with gofumpt.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@middleware/sse/sse.go`:
- Around line 46-74: The OnClose hook currently uses the snapshot streamErr
captured before the heartbeat goroutine has fully stopped, so late heartbeat
write errors can be missed; update the OnClose defer (inside the
SendStreamWriter closure that creates newStream and uses cfg.Handler) to
re-check the stream's latched error by calling stream.Err() when invoking
cfg.OnClose — e.g., compute finalErr := streamErr; if finalErr == nil { finalErr
= stream.Err() } then call cfg.OnClose(c, finalErr); keep the existing defer
ordering (closeStream and stopHeartbeat defers remain) and ensure this change
references streamErr, stream.Err(), cfg.OnClose and the existing
startHeartbeat/stopHeartbeat flow so the latched heartbeat error wins over the
earlier snapshot.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 954def49-bbef-4c94-bae5-dfb07f357e0f

📥 Commits

Reviewing files that changed from the base of the PR and between 5a8a4f1 and 1aacd75.

📒 Files selected for processing (8)
  • ctx_interface_gen.go
  • docs/middleware/sse.md
  • middleware/limiter/limiter_test.go
  • middleware/sse/event.go
  • middleware/sse/sse.go
  • middleware/sse/sse_test.go
  • req_interface_gen.go
  • res_interface_gen.go
✅ Files skipped from review due to trivial changes (3)
  • req_interface_gen.go
  • res_interface_gen.go
  • ctx_interface_gen.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • docs/middleware/sse.md
  • middleware/sse/event.go
  • middleware/sse/sse_test.go

Comment thread middleware/sse/sse.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
middleware/sse/sse.go (1)

198-227: Heartbeat goroutine is not joined on stop — residual late-error window remains.

stopHeartbeat only closes the stop channel; it does not wait for the goroutine to exit. With defer order stopHeartbeat → closeStream → OnClose, the heartbeat goroutine can still be mid-s.Comment("") (or about to enter it) while OnClose reads stream.Err() on line 53. If the final tick's write fails and latches s.err after that read, the disconnect error is still missed by OnClose. The mutex serializes writes, so there's no data corruption — only a narrow observability gap.

Joining the goroutine before OnClose runs would close the gap deterministically:

♻️ Optional: join the heartbeat goroutine on stop
 func (s *Stream) startHeartbeat(interval time.Duration) func() {
 	if interval <= 0 {
 		return nil
 	}

 	stop := make(chan struct{})
 	var stopOnce sync.Once
+	done := make(chan struct{})
 	go func() {
+		defer close(done)
 		ticker := time.NewTicker(interval)
 		defer ticker.Stop()
 		for {
 			select {
 			case <-ticker.C:
 				if err := s.Comment(""); err != nil {
 					return
 				}
 			case <-stop:
 				return
 			case <-s.Done():
 				return
 			}
 		}
 	}()

 	return func() {
 		stopOnce.Do(func() {
 			close(stop)
 		})
+		<-done
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@middleware/sse/sse.go` around lines 198 - 227, The heartbeat goroutine
started by Stream.startHeartbeat can still be executing s.Comment("") after the
returned stop function (stopHeartbeat) closes stop, causing a race where OnClose
may read s.Err() before the goroutine finishes and sets the final error; fix
this by making the stop function block until the goroutine has fully exited:
inside startHeartbeat create a done signal (or sync.WaitGroup) that the
goroutine closes/signals on exit and change the returned func() (stopHeartbeat)
to close stop once (using stopOnce) and then wait on that done signal before
returning; keep the existing select cases (ticker.C, stop, s.Done()) and ensure
the done signal is triggered in every goroutine exit path so OnClose sees the
final s.Err() deterministically.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@middleware/sse/sse.go`:
- Around line 198-227: The heartbeat goroutine started by Stream.startHeartbeat
can still be executing s.Comment("") after the returned stop function
(stopHeartbeat) closes stop, causing a race where OnClose may read s.Err()
before the goroutine finishes and sets the final error; fix this by making the
stop function block until the goroutine has fully exited: inside startHeartbeat
create a done signal (or sync.WaitGroup) that the goroutine closes/signals on
exit and change the returned func() (stopHeartbeat) to close stop once (using
stopOnce) and then wait on that done signal before returning; keep the existing
select cases (ticker.C, stop, s.Done()) and ensure the done signal is triggered
in every goroutine exit path so OnClose sees the final s.Err()
deterministically.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a7cda937-3e0e-4e42-978d-c642e89a3051

📥 Commits

Reviewing files that changed from the base of the PR and between 1aacd75 and a039e4a.

📒 Files selected for processing (2)
  • middleware/sse/sse.go
  • middleware/sse/sse_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • middleware/sse/sse_test.go

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.50.

Benchmark suite Current: a039e4a Previous: bec9ba6 Ratio
Benchmark_Compress/Zstd (github.com/gofiber/fiber/v3/middleware/compress) - B/op 1 B/op 0 B/op +∞

This comment was automatically generated by workflow using github-action-benchmark.

@ReneWerner87
Copy link
Copy Markdown
Member Author

Addressed the latest coverage/review feedback in 1ff8f6d:

  • writeEvent validates/marshals Event.Data before building any frame fields, then writes the fully built frame to the stream in one step.
  • the long-running stream docs no longer show all clients reading from one shared channel; the example now uses a per-connection subscription and calls out the fan-out requirement.
  • stopHeartbeat now waits for the heartbeat goroutine to exit, closing the late-error window before OnClose reads the final stream error.
  • local SSE coverage is now 96.4%.

Verified locally:

  • make lint
  • make test
  • go test ./middleware/sse ./middleware/limiter
  • go test ./middleware/sse -coverprofile=/tmp/sse.cover

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@middleware/sse/event.go`:
- Around line 129-134: appendData currently splits normalized data into lines
and will emit an empty "data:" field when input ends with a newline; after
calling normalizeNewlines(data) trim a single trailing "\n" (not all newlines)
before splitting so you don't produce a stray empty line (apply the same
single-trailing-newline trim in writeData if writeData also processes raw data
similarly). Locate appendData and the writeData code path, perform the
single-trailing-newline removal on the normalized string, then continue
splitting and calling appendField("data", ...) as before.
- Around line 42-55: The guards check event.ID/event.Name before sanitization,
so whitespace-only values become empty after sanitizeField and produce empty SSE
fields; update the logic in the block that handles ID and Name (the code using
sanitizeField and appendField) to call sanitizeField (or strings.TrimSpace)
first and then only call appendField if the sanitized value is non-empty,
preserving existing error handling from sanitizeField; ensure you reference
event.ID/event.Name, sanitizeField, and appendField when making the change so
whitespace-only inputs no longer emit empty "id:" or "event:" SSE lines.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0f5a6fbe-3569-4bd7-b1ba-ef43c441579f

📥 Commits

Reviewing files that changed from the base of the PR and between 301ca87 and 1ff8f6d.

📒 Files selected for processing (3)
  • docs/middleware/sse.md
  • middleware/sse/event.go
  • middleware/sse/sse.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/middleware/sse.md
  • middleware/sse/sse.go

Comment thread middleware/sse/event.go
Comment thread middleware/sse/event.go
@ReneWerner87
Copy link
Copy Markdown
Member Author

@grivera64 @vinod-morya @gaby
This is a simpler version that works better with fiber
Could you check that out?

@gaby gaby requested a review from grivera64 April 28, 2026 12:27
@gaby gaby moved this to In Progress in v3 Apr 28, 2026
Comment thread docs/whats_new.md

### SSE

Fiber now includes a small [SSE middleware](./middleware/sse.md) for Server-Sent Events. It handles native
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the word "small"

Comment thread middleware/sse/event.go
case json.RawMessage:
return eventPayload{data: string(value), hasData: true}, nil
default:
encoded, err := json.Marshal(value)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use the JSON lib provided by the user in the app.Config

Comment thread middleware/sse/event.go
}

func trimSingleTrailingNewline(value string) string {
return strings.TrimSuffix(value, "\n")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a utils for this?

Comment thread middleware/sse/sse.go
"github.com/gofiber/fiber/v3"
)

const mimeTextEventStream = "text/event-stream"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldnt this go in constants.go?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Proposal: Add SSE (Server-Sent Events) package

3 participants