From fcab510ec95f749d8e9b8734efc3752e3f3caa90 Mon Sep 17 00:00:00 2001 From: Pavel Piuro Date: Tue, 24 Mar 2026 15:39:36 +0400 Subject: [PATCH 1/2] fix: pass through plain ReadableStream directly instead of re-wrapping When a handler returns a ReadableStream, Elysia was wrapping it in a new ReadableStream with per-chunk async iteration, causing ~100x latency for large streams (#1741) and preventing the cancel callback from firing on client disconnect (#1768). Plain ReadableStreams (non-SSE, non-generator) are now piped through a lightweight TransformStream that only normalizes non-standard chunk types (DataView, Blob) while passing Uint8Array/string/ArrayBuffer chunks through with minimal overhead. Fixes #1741, fixes #1768 --- src/adapter/utils.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/adapter/utils.ts b/src/adapter/utils.ts index 4310ece0..8d5aaa0f 100644 --- a/src/adapter/utils.ts +++ b/src/adapter/utils.ts @@ -306,6 +306,52 @@ export const createStreamHandler = } } + // Plain ReadableStream (not SSE, not a generator) can be passed through + // directly. Re-wrapping it would add per-chunk async overhead (#1741) + // and break the native cancel callback (#1768). + if ( + !isSSE && + generator instanceof ReadableStream && + typeof (generator as any).next !== 'function' + ) + return new Response( + generator.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if ( + chunk instanceof Uint8Array || + chunk instanceof ArrayBuffer || + typeof chunk === 'string' + ) + controller.enqueue(chunk) + else if (ArrayBuffer.isView(chunk)) + controller.enqueue( + new Uint8Array( + chunk.buffer, + chunk.byteOffset, + chunk.byteLength + ) + ) + else if (chunk instanceof Blob) + return chunk + .arrayBuffer() + .then((buf) => + controller.enqueue( + new Uint8Array(buf) + ) + ) + else + controller.enqueue( + typeof chunk === 'object' + ? JSON.stringify(chunk) + : String(chunk) + ) + } + }) + ), + set as any + ) + // Get an explicit async iterator so pull() can advance one step at a time. // Generators already implement the iterator protocol directly (.next()), // while ReadableStream (which generator may be reassigned to above) needs From 59ce75041ac832e4443f18aa5d42204bcb9a98ca Mon Sep 17 00:00:00 2001 From: Pavel Piuro Date: Tue, 24 Mar 2026 16:02:17 +0400 Subject: [PATCH 2/2] fix: add try/catch around JSON.stringify in stream transform Matches the existing fallback pattern in the pull handler for edge cases like circular references or BigInt values. --- src/adapter/utils.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/adapter/utils.ts b/src/adapter/utils.ts index 8d5aaa0f..f531fd25 100644 --- a/src/adapter/utils.ts +++ b/src/adapter/utils.ts @@ -341,11 +341,15 @@ export const createStreamHandler = ) ) else - controller.enqueue( - typeof chunk === 'object' - ? JSON.stringify(chunk) - : String(chunk) - ) + try { + controller.enqueue( + typeof chunk === 'object' + ? JSON.stringify(chunk) + : String(chunk) + ) + } catch { + controller.enqueue(String(chunk)) + } } }) ),