diff --git a/src/adapter/utils.ts b/src/adapter/utils.ts index 0d889e6c..94a2ab22 100644 --- a/src/adapter/utils.ts +++ b/src/adapter/utils.ts @@ -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 @@ -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` @@ -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 @@ -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 diff --git a/test/response/sse-double-wrap.test.ts b/test/response/sse-double-wrap.test.ts new file mode 100644 index 00000000..f141fd1d --- /dev/null +++ b/test/response/sse-double-wrap.test.ts @@ -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:') + }) +})