Skip to content

Conversation

@raunak-rpm
Copy link

@raunak-rpm raunak-rpm commented Jan 7, 2026

Description

Fixes #1661 - SSE double-wrapping bug when returning pre-formatted Response with chunked transfer encoding.

Problem

When users return a Response with pre-formatted SSE data (content-type: text/event-stream and transfer-encoding: chunked) while using set.headers, the response gets double-wrapped with the data: prefix:

Expected:

data: hello

data: world

Got:

data: data: hello

data: world

This only occurs when all three conditions are met:

  1. Using set.headers (causes routing through handleResponse)
  2. Response has transfer-encoding: chunked
  3. Response has content-type: text/event-stream

Root Cause

The bug chain:

  1. handleResponse merges set.headers into the Response
  2. Detects transfer-encoding: chunked and routes through handleStream(streamResponse(response))
  3. handleStream checks content-type and detects SSE
  4. Auto-wraps all chunks with data: ${chunk}\n\n format
  5. But the Response body is ALREADY formatted as SSE by the user
  6. Result: Double-wrapping → data: data: hello

Solution

Production-level architectural fix (not a workaround):

Added skipFormat parameter to createStreamHandler to distinguish between:

  • Generator functions → Need SSE formatting applied
  • Pre-formatted Response bodies → Stream as-is, no formatting

When handleResponse calls handleStream for a Response body, it passes skipFormat=true to prevent auto-formatting of already-formatted content.

Changes Made

src/adapter/utils.ts

  • Added skipFormat?: boolean parameter to createStreamHandler
  • Check skipFormat before applying SSE formatting
  • Pass skipFormat=true when streaming Response bodies (2 locations in createResponseHandler)

test/response/sse-double-wrap.test.ts

  • Test case 1: Pre-formatted Response with set.headers
  • Test case 2: Pre-formatted Response with content-type in set.headers
  • Test case 3: Generator functions still get formatted correctly

Testing

All 1433 existing tests pass
3 new regression tests added
No breaking changes
Generator SSE formatting still works
Pre-formatted Response streaming works

Impact

  • Severity: High - Breaks SSE functionality completely
  • Affected: Users returning pre-formatted SSE Response with set.headers
  • Risk: None - Clean architectural fix, all tests pass
  • Backward Compatibility: 100% - No API changes

Related Issues

Introduced in v1.3.16 per user investigation in issue comments.


This is a production-ready architectural fix, not a workaround. The solution properly distinguishes between content that needs formatting vs pre-formatted content.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented Server-Sent Events (SSE) responses from being double-wrapped so pre-formatted SSE is preserved and SSE output is correctly formatted.
  • Tests

    • Added tests validating pre-formatted SSE, SSE via custom headers, generator-based routes, and explicit SSE formatting to ensure correct SSE behavior.

✏️ Tip: You can customize this high-level summary in your review settings.

…h chunked transfer

Fixes elysiajs#1661

Problem:
When returning a Response with pre-formatted SSE data (content-type: text/event-stream
and transfer-encoding: chunked) while using set.headers, the response would be double-
wrapped with 'data:' prefix, resulting in 'data: data: hello' instead of 'data: hello'.

Root Cause:
- handleResponse merges set.headers into the Response
- Sees transfer-encoding: chunked and routes through handleStream(streamResponse(response))
- handleStream detects SSE via content-type and auto-wraps chunks with 'data: ' format
- But the Response body is ALREADY formatted as SSE by the user
- Result: double-wrapping

Solution:
Added skipFormat parameter to createStreamHandler to prevent SSE auto-formatting when
streaming pre-formatted Response bodies. The skipFormat flag is set to true when:
- Response has transfer-encoding: chunked
- Stream comes from streamResponse(response) (already formatted by user)

This ensures:
- Generator functions still get SSE formatting applied
- Pre-formatted Response bodies are streamed as-is
- No behavioral changes for existing working code

Changes:
- src/adapter/utils.ts: Added skipFormat parameter to createStreamHandler
- src/adapter/utils.ts: Pass skipFormat=true for Response body streams
- test/response/sse-double-wrap.test.ts: Added comprehensive regression tests

Testing:
- All 1434 existing tests pass
- 3 new tests cover the bug scenarios
- Verified generator functions still work correctly
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 7, 2026

Walkthrough

Adds an optional skipFormat?: boolean to createStreamHandler and propagates it through the streaming pipeline so pre-formatted SSE responses bypass automatic SSE formatting and avoid double-wrapping.

Changes

Cohort / File(s) Summary
SSE Double-Wrap Fix
src/adapter/utils.ts
Added optional skipFormat?: boolean parameter to createStreamHandler. isSSE detection now respects skipFormat to avoid applying SSE auto-formatting when true, and the flag is propagated when reinvoking the stream pipeline for pre-formatted responses.
Test Coverage
test/response/sse-double-wrap.test.ts
New test file with four tests validating SSE wrapping: pre-formatted Response and pre-formatted body with set.headers are not double-wrapped; generator routes yield plain text or correctly formatted SSE blocks as configured.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Router as Router
    participant StreamHandler as createStreamHandler
    participant RespHandler as createResponseHandler
    participant Downstream as DownstreamStream

    Client->>Router: HTTP request
    Router->>StreamHandler: handle route result (passes skipFormat?)
    alt skipFormat = false
        StreamHandler->>RespHandler: detect SSE -> apply auto-formatting
        RespHandler->>Downstream: stream formatted SSE
    else skipFormat = true
        StreamHandler->>Downstream: pass through pre-formatted SSE (no extra formatting)
    end
    Downstream->>Client: stream chunks
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • fix: missing cookies in sse #1435: Modifies the same createStreamHandler in src/adapter/utils.ts (adjusts streaming/header handling), likely related to streaming pipeline changes.

Poem

🐰 I hop where event streams gently prance,
SkipFormat keeps the bytes from double dance.
No extra "data:" to clutter the tune,
Chunks sail steady beneath the moon. 🎶

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: fixing an SSE double-wrapping bug when returning pre-formatted Response objects, matching the core objective.
Linked Issues check ✅ Passed The PR addresses issue #1661 by adding skipFormat parameter to createStreamHandler to prevent double-wrapping of pre-formatted SSE Response bodies while preserving auto-formatting for generators.
Out of Scope Changes check ✅ Passed All changes in src/adapter/utils.ts and test/response/sse-double-wrap.test.ts are directly scoped to fixing the SSE double-wrapping issue described in #1661 with no unrelated modifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing touches
  • 📝 Generate docstrings

📜 Recent review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 426f98c and b2ad171.

📒 Files selected for processing (1)
  • test/response/sse-double-wrap.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/response/sse-double-wrap.test.ts

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.

Copy link
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: 0

🧹 Nitpick comments (1)
test/response/sse-double-wrap.test.ts (1)

43-62: Enhance test coverage for SSE-enabled generators.

While this test verifies that plain generators still work, it doesn't validate that generators with explicit SSE markers are still formatted correctly. Consider adding a test case that uses SSE markers (e.g., setting content-type: text/event-stream in set.headers or using .sse property) and verifies the output contains proper SSE formatting with data: prefix.

Also note: the content-type header on line 53 is set on the request, not the response, so it doesn't affect SSE formatting behavior.

📝 Example test to add
it('should properly format SSE for generators with SSE markers', async () => {
    const app = new Elysia().get('/', ({ set }) => {
        set.headers['content-type'] = 'text/event-stream'
        
        return (function* () {
            yield 'hello'
            yield 'world'
        })()
    })

    const response = await app
        .handle(new Request('http://localhost/'))
        .then((r) => r.text())

    // Should be formatted with SSE data prefix
    expect(response).toContain('data: hello')
    expect(response).toContain('data: world')
})
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 969fc76 and c29f61e.

📒 Files selected for processing (2)
  • src/adapter/utils.ts
  • test/response/sse-double-wrap.test.ts
🔇 Additional comments (6)
src/adapter/utils.ts (4)

155-156: LGTM! Clean parameter addition.

The optional skipFormat parameter is well-placed and maintains backward compatibility. The naming clearly conveys its purpose.


175-185: LGTM! Correct logic to bypass SSE formatting.

The !skipFormat check correctly short-circuits SSE detection when skipFormat is true, ensuring pre-formatted Response bodies are not reformatted. The existing SSE detection logic is preserved for normal use cases.


393-394: LGTM! Correct propagation of skipFormat flag.

The inline comment clearly explains that pre-formatted Response bodies should not be auto-formatted. This correctly prevents double-wrapping in the status update path.


408-409: LGTM! Correct propagation in the main streaming path.

This correctly prevents SSE auto-formatting for pre-formatted Response bodies in the primary streaming path. The inline comment is helpful.

test/response/sse-double-wrap.test.ts (2)

5-23: LGTM! Comprehensive test for the primary bug scenario.

This test effectively validates the main fix by reproducing all three conditions from issue #1661: set.headers, transfer-encoding: chunked, and content-type: text/event-stream. The assertions correctly verify both the expected output and the absence of double-wrapping.


25-41: LGTM! Tests important header-merging variation.

This test covers the scenario where content-type is set via set.headers rather than directly on the Response, ensuring the fix works correctly in both header-setting patterns.

- Tests that generators using sse() helper get proper SSE formatting
- Verifies no double-wrapping occurs with explicit SSE markers
- Addresses CodeRabbit review suggestion for comprehensive SSE test coverage
Copy link
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

🤖 Fix all issues with AI agents
In @test/response/sse-double-wrap.test.ts:
- Around line 43-62: The test sets 'content-type: text/event-stream' on the
Request which doesn't influence response formatting for a GET; update the test
"should properly format SSE for generator functions" by removing the request
header in the Request(...) call (or alternatively change the test
purpose/comment to assert response-driven SSE behavior) so that the generator
route defined with Elysia().get('/', function* () { yield 'hello'; yield 'world'
}) is validated as plain text output; adjust the comment to state that request
Content-Type is irrelevant for GET responses if you keep the header.
🧹 Nitpick comments (2)
test/response/sse-double-wrap.test.ts (2)

64-66: Consider moving dynamic import to top-level.

The sse helper is dynamically imported on line 65, which is less conventional than importing at the file level. While functionally correct, moving it to the top-level imports (line 2) would improve consistency and readability.

♻️ Proposed refactor

Move the import to the top of the file:

-import { describe, it, expect } from 'bun:test'
-import { Elysia } from '../../src'
+import { describe, it, expect } from 'bun:test'
+import { Elysia, sse } from '../../src'

Then remove the dynamic import from the test:

 	it('should format SSE correctly for generators with explicit SSE configuration', async () => {
-		const { sse } = await import('../../src')
-		
 		const app = new Elysia().get('/', ({ set }) => {

1-86: Consider adding response header verification for completeness.

The tests thoroughly verify response body content, but don't validate that response headers are correctly set (e.g., content-type: text/event-stream, transfer-encoding: chunked). Adding header assertions would make the tests more comprehensive and catch potential header-related regressions.

💡 Example header verification for test 1
-	const response = await app.handle(new Request('http://localhost/')).then(r => r.text())
+	const response = await app.handle(new Request('http://localhost/'))
+	
+	// Verify headers
+	expect(response.headers.get('content-type')).toBe('text/event-stream')
+	expect(response.headers.get('hello')).toBe('world')
+	
+	const text = await response.text()
 
 	// Should NOT double-wrap with "data: data:"
-	expect(response).toBe('data: hello\n\ndata: world\n\n')
-	expect(response).not.toContain('data: data:')
+	expect(text).toBe('data: hello\n\ndata: world\n\n')
+	expect(text).not.toContain('data: data:')
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c29f61e and ec1dfb5.

📒 Files selected for processing (1)
  • test/response/sse-double-wrap.test.ts

- Removed content-type header from GET request (doesn't affect response)
- Request Content-Type is irrelevant for GET responses
- Addresses CodeRabbit review feedback
Copy link
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: 0

🧹 Nitpick comments (1)
test/response/sse-double-wrap.test.ts (1)

43-56: Strengthen assertions to verify output format.

The comment states "Generator without explicit SSE should format as plain text," but the assertions only check substring presence, not the actual format. This test would pass even if the output were SSE-formatted (e.g., "data: hello\n\n").

Consider adding assertions that verify the output is truly plain text (e.g., no "data:" prefix, no double newlines) or document why checking substring presence is sufficient.

📝 Suggested assertion improvements
 	it('should properly format SSE for generator functions', async () => {
 		const app = new Elysia().get('/', function* () {
 			yield 'hello'
 			yield 'world'
 		})
 
 		const response = await app
 			.handle(new Request('http://localhost/'))
 			.then((r) => r.text())
 
 		// Generator without explicit SSE should format as plain text
 		expect(response).toContain('hello')
 		expect(response).toContain('world')
+		// Verify it's NOT SSE formatted
+		expect(response).not.toContain('data: hello')
+		expect(response).not.toContain('data: world')
 	})

Alternatively, if generators should auto-format to SSE even without explicit sse() calls, update the comment and verify SSE format instead.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ec1dfb5 and 426f98c.

📒 Files selected for processing (1)
  • test/response/sse-double-wrap.test.ts
🔇 Additional comments (2)
test/response/sse-double-wrap.test.ts (2)

5-41: LGTM! Solid regression tests for the double-wrapping fix.

Both tests correctly validate the core issue: pre-formatted SSE responses with set.headers should not be double-wrapped. The first test uses headers in the Response constructor, while the second sets content-type via set.headers — good coverage of different configuration patterns.


58-79: Test logic is sound; dynamic import is acceptable.

The async generator with explicit sse() helper correctly tests that formatted SSE data doesn't get double-wrapped. The dynamic import at line 59 isolates the dependency, which is fine, though importing at the top would be more conventional.

…tted

- Added negative assertions to confirm no 'data:' prefix
- Ensures generator without sse() helper outputs plain text
- Test now explicitly verifies what comment claims
- Addresses CodeRabbit review feedback
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants