From ff98bd4610bf1774481393b493becd2f3e7544e7 Mon Sep 17 00:00:00 2001 From: Einherjar1632 Date: Thu, 9 Jul 2026 22:18:51 +0900 Subject: [PATCH] fix(cloudflare-node): use native IdentityTransformStream for response body The JS-backed ReadableStream with a manually captured controller intermittently stalls mid-stream on deployed Workers: the final flushes are never delivered to the client and the terminating chunk is never sent, leaving connections open indefinitely. Writes were acknowledged without backpressure, so the stall was invisible to the worker. Switch to the Workers-native IdentityTransformStream and await writer.write() so the runtime pumps the stream with real backpressure. --- .changeset/cloudflare-node-native-stream.md | 18 +++++ .../src/overrides/wrappers/cloudflare-node.ts | 70 ++++++++++++------- 2 files changed, 64 insertions(+), 24 deletions(-) create mode 100644 .changeset/cloudflare-node-native-stream.md diff --git a/.changeset/cloudflare-node-native-stream.md b/.changeset/cloudflare-node-native-stream.md new file mode 100644 index 000000000..d97059b6c --- /dev/null +++ b/.changeset/cloudflare-node-native-stream.md @@ -0,0 +1,18 @@ +--- +"@opennextjs/aws": patch +--- + +fix(cloudflare-node): use native IdentityTransformStream for the response body + +The `cloudflare-node` wrapper built the streamed response body from a JS-backed +`ReadableStream` with a manually captured controller, acknowledging writes +without backpressure. On deployed Workers this intermittently (20-35% of +requests in our reproduction) stalled mid-stream: the final flush(es) of +SSR/RSC responses were never delivered, the terminating chunk was never sent, +and the client connection stayed open indefinitely — browsers eventually +exhaust their per-origin connection pool and the whole site appears frozen. + +Switching to the Workers-native `IdentityTransformStream` and awaiting +`writer.write()` provides runtime-managed pumping with real backpressure and +eliminates the stall (0 hangs in 80 requests after the change, measured on the +same production deployment). diff --git a/packages/open-next/src/overrides/wrappers/cloudflare-node.ts b/packages/open-next/src/overrides/wrappers/cloudflare-node.ts index d706e91f1..c230be9e5 100644 --- a/packages/open-next/src/overrides/wrappers/cloudflare-node.ts +++ b/packages/open-next/src/overrides/wrappers/cloudflare-node.ts @@ -7,6 +7,14 @@ import type { Wrapper, WrapperHandler } from "types/overrides"; import { Writable } from "node:stream"; +// `IdentityTransformStream` is a Cloudflare Workers specific, C++-backed +// identity TransformStream optimized for byte streams. +// https://developers.cloudflare.com/workers/runtime-apis/streams/transformstream/#identitytransformstream +declare class IdentityTransformStream extends TransformStream< + Uint8Array, + Uint8Array +> {} + // Response with null body status (101, 204, 205, or 304) cannot have a body. const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]); @@ -68,12 +76,21 @@ const handler: WrapperHandler = }); } - let controller: ReadableStreamDefaultController; - const readable = new ReadableStream({ - start(c) { - controller = c; - }, - }); + // Use the native (C++-backed) `IdentityTransformStream` instead of a + // JS-backed `ReadableStream` with a manually captured controller. + // + // With the JS-backed stream, the runtime's pump of the response body + // has been observed to intermittently stall mid-stream on deployed + // Workers: the last enqueued flush(es) are never delivered and the + // terminating chunk is never sent, leaving the client connection open + // indefinitely. Because `Writable.write` acknowledged chunks without + // waiting for the consumer, nothing in the worker ever noticed the + // stall (the invocation simply never completed). + // + // The native stream is pumped by the runtime itself and + // `writer.write()` resolves only once the chunk is accepted, giving + // real backpressure end-to-end and avoiding the stall entirely. + const { readable, writable } = new IdentityTransformStream(); const response = new Response(readable, { status: statusCode, @@ -81,30 +98,35 @@ const handler: WrapperHandler = }); resolveResponse(response); + const writer = writable.getWriter(); + return new Writable({ write(chunk, encoding, callback) { - try { - controller.enqueue(chunk); - } catch (e: any) { - return callback(e); - } - callback(); + const bytes = + chunk instanceof Uint8Array + ? chunk + : Buffer.from(chunk, encoding); + writer.write(bytes).then( + () => callback(), + (e) => callback(e), + ); }, final(callback) { - controller.close(); - callback(); + writer.close().then( + () => callback(), + (e) => callback(e), + ); }, destroy(error, callback) { - if (error) { - controller.error(error); - } else { - try { - controller.close(); - } catch { - // Ignore "This ReadableStream is closed" error - } - } - callback(error); + const done = error + ? writer.abort(error) + : writer.close().catch(() => { + // Ignore "already closed" errors + }); + done.then( + () => callback(error), + () => callback(error), + ); }, }); },