From baeb0cfad77572e09aaf517259916e59a8836aa9 Mon Sep 17 00:00:00 2001 From: Hugo Richard <71938701+HugoRCD@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:44:44 +0000 Subject: [PATCH] fix(hono): assign c.res directly for streaming responses to bypass finalized check Hono's compose skips updating context.res when context.finalized is already true (set by the route handler). The previous code returned the wrapped Response from the middleware, which Hono silently discarded, leaving c.res pointing at the original response whose body had been locked by createObservedBody. The @hono/node-server adapter then crashed trying to call getReader() on the locked stream. Fix: directly assign c.res with the result of finishResponse() instead of returning it, bypassing the finalized guard in Hono's compose. Generated with [Linear](https://linear.app/hrcd/issue/EVL-179/bug-hono-middleware-ai-sdks-createuimessagestreamresponse-crashes#agent-session-e7b0db01) Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> --- packages/evlog/src/hono/index.ts | 7 +-- packages/evlog/test/frameworks/hono.test.ts | 55 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/evlog/src/hono/index.ts b/packages/evlog/src/hono/index.ts index 32f38ee9..bdc9d409 100644 --- a/packages/evlog/src/hono/index.ts +++ b/packages/evlog/src/hono/index.ts @@ -63,11 +63,8 @@ export function evlog(options: EvlogHonoOptions = {}): MiddlewareHandler { try { await next() if (shouldDeferEmitForResponse(c.res)) { - const response = new Response(c.res.body, { - status: c.res.status, - headers: c.res.headers, - }) - return finishResponse(response, { status: response.status }) + c.res = await finishResponse(c.res, { status: c.res.status }) + return } await finish({ status: c.res.status }) } catch (error) { diff --git a/packages/evlog/test/frameworks/hono.test.ts b/packages/evlog/test/frameworks/hono.test.ts index 2c01d024..d0447aa1 100644 --- a/packages/evlog/test/frameworks/hono.test.ts +++ b/packages/evlog/test/frameworks/hono.test.ts @@ -176,6 +176,61 @@ describe('evlog/hono', () => { expect(logValue).toBeUndefined() }) + describe('streaming responses', () => { + it('can consume a streaming response body after middleware wraps it', async () => { + const { drain } = createPipelineSpies() + const app = new Hono() + app.use(evlog({ drain })) + + app.get('/api/stream', () => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('chunk1')) + controller.enqueue(encoder.encode('chunk2')) + controller.close() + }, + }) + return new Response(stream, { + headers: { 'content-type': 'text/event-stream', 'x-vercel-ai-ui-message-stream': 'v1' }, + }) + }) + + const res = await app.request('/api/stream') + expect(res.status).toBe(200) + // Body must not be locked — consuming it should succeed + const text = await res.text() + expect(text).toBe('chunk1chunk2') + + await waitForDrainCalls(drain) + assertHttpEventEmitted(drain, { path: '/api/stream', status: 200 }) + }) + + it('defers drain until stream completes', async () => { + const { drain } = createPipelineSpies() + const app = new Hono() + app.use(evlog({ drain })) + + const { createDeferredStream } = await import('../helpers/stream') + const source = createDeferredStream() + + app.get('/api/defer', () => { + return new Response(source.stream, { + headers: { 'content-type': 'text/event-stream' }, + }) + }) + + const res = await app.request('/api/defer') + expect(res.status).toBe(200) + expect(drain).not.toHaveBeenCalled() + + source.close() + await res.text() + await waitForDrainCalls(drain) + assertHttpEventEmitted(drain, { path: '/api/defer', status: 200 }) + }) + }) + describe('drain / enrich / keep', () => { it('calls drain with emitted event (shared helpers)', async () => { const { drain } = createPipelineSpies()