Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions src/adapter/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ export const createStreamHandler =
async (
generator: Generator | AsyncGenerator | ReadableStream,
set?: Context['set'],
request?: Request
request?: Request,
skipFormat?: boolean
) => {
// Since ReadableStream doesn't have next, init might be undefined
let init = (generator as Generator).next?.() as
Expand All @@ -171,13 +172,17 @@ export const createStreamHandler =
return mapCompactResponse(init.value, request)
}

// Check if stream is from a pre-formatted Response body
const isSSE =
// @ts-ignore First SSE result is wrapped with sse()
init?.value?.sse ??
// @ts-ignore ReadableStream is wrapped with sse()
generator?.sse ??
// User explicitly set content-type to SSE
set?.headers['content-type']?.startsWith('text/event-stream')
!skipFormat &&
(
// @ts-ignore First SSE result is wrapped with sse()
init?.value?.sse ??
// @ts-ignore ReadableStream is wrapped with sse()
generator?.sse ??
// User explicitly set content-type to SSE
set?.headers['content-type']?.startsWith('text/event-stream')
)

const format = isSSE
? (data: string) => `data: ${data}\n\n`
Expand Down Expand Up @@ -385,7 +390,8 @@ export const createResponseHandler = (handler: CreateHandlerParameter) => {
return handleStream(
streamResponse(newResponse as Response),
responseToSetHeaders(newResponse as Response, set),
request
request,
true // skipFormat: don't auto-format SSE for pre-formatted Response
) as any

return newResponse
Expand All @@ -399,7 +405,8 @@ export const createResponseHandler = (handler: CreateHandlerParameter) => {
return handleStream(
streamResponse(response as Response),
responseToSetHeaders(response as Response, set),
request
request,
true // skipFormat: don't auto-format SSE for pre-formatted Response
) as any

return response
Expand Down
83 changes: 83 additions & 0 deletions test/response/sse-double-wrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'bun:test'
import { Elysia } from '../../src'

describe('SSE - Response Double Wrapping', () => {
it('should not double-wrap SSE data when returning pre-formatted Response', async () => {
const app = new Elysia().get('/', ({ set }) => {
set.headers.hello = 'world'

return new Response('data: hello\n\ndata: world\n\n', {
headers: {
'content-type': 'text/event-stream',
'transfer-encoding': 'chunked'
},
status: 200
})
})

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

// Should NOT double-wrap with "data: data:"
expect(response).toBe('data: hello\n\ndata: world\n\n')
expect(response).not.toContain('data: data:')
})

it('should not double-wrap SSE when using set.headers with pre-formatted content', async () => {
const app = new Elysia().get('/', ({ set }) => {
set.headers['x-custom'] = 'test'
set.headers['content-type'] = 'text/event-stream'

return new Response('data: message1\n\ndata: message2\n\n', {
headers: {
'transfer-encoding': 'chunked'
}
})
})

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

expect(response).toBe('data: message1\n\ndata: message2\n\n')
expect(response).not.toContain('data: data:')
})

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')
})

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

const app = new Elysia().get('/', ({ set }) => {
set.headers['content-type'] = 'text/event-stream'

return (async function* () {
yield sse({ data: 'first message' })
yield sse({ data: 'second message' })
})()
})

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

// Generator WITH explicit SSE markers should get properly formatted
expect(response).toContain('data: first message\n\n')
expect(response).toContain('data: second message\n\n')
// Should NOT double-wrap
expect(response).not.toContain('data: data:')
})
})