-
Notifications
You must be signed in to change notification settings - Fork 224
fix: Cloudflare streaming response backpressure #1186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 4 commits
d2a3438
beecf15
63f6a8c
0f7dfd9
a5188ac
3cdee23
fc546c2
961a03b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@opennextjs/aws": patch | ||
| --- | ||
|
|
||
| Bound Cloudflare Node response streaming with backpressure and stop retaining an unused duplicate response body. |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add more comment on your change please, this is quite hard to review and follow what's going on
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've tried to better explain what's going on with 961a03b |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| import { Writable } from "node:stream"; | ||
| import { ReadableStream } from "node:stream/web"; | ||
|
|
||
| import { openNextHandler } from "@opennextjs/aws/core/requestHandler.js"; | ||
| import routingHandler from "@opennextjs/aws/core/routingHandler.js"; | ||
| import type { | ||
| InternalEvent, | ||
| InternalResult, | ||
| StreamCreator, | ||
| } from "@opennextjs/aws/types/open-next.js"; | ||
| import { beforeEach, vi } from "vitest"; | ||
|
|
||
| vi.mock("@opennextjs/aws/adapters/config/index.js", () => ({ | ||
| BuildId: "build-id", | ||
| HtmlPages: [], | ||
| NextConfig: {}, | ||
| })); | ||
|
|
||
| vi.mock("@opennextjs/aws/core/routingHandler.js", () => ({ | ||
| default: vi.fn(), | ||
| INTERNAL_EVENT_REQUEST_ID: "x-opennext-request-id", | ||
| INTERNAL_HEADER_INITIAL_URL: "x-opennext-initial-url", | ||
| INTERNAL_HEADER_RESOLVED_ROUTES: "x-opennext-resolved-routes", | ||
| INTERNAL_HEADER_REWRITE_STATUS_CODE: "x-opennext-rewrite-status-code", | ||
| MIDDLEWARE_HEADER_PREFIX: "x-middleware-response-", | ||
| MIDDLEWARE_HEADER_PREFIX_LEN: "x-middleware-response-".length, | ||
| })); | ||
|
|
||
| vi.mock("@opennextjs/aws/core/util.js", () => ({ | ||
| requestHandler: vi.fn(), | ||
| setNextjsPrebundledReact: vi.fn(), | ||
| })); | ||
|
|
||
| const mockedRoutingHandler = vi.mocked(routingHandler); | ||
| const event: InternalEvent = { | ||
| type: "core", | ||
| method: "GET", | ||
| rawPath: "/", | ||
| url: "https://example.com/", | ||
| body: Buffer.alloc(0), | ||
| headers: {}, | ||
| query: {}, | ||
| cookies: {}, | ||
| remoteAddress: "::1", | ||
| }; | ||
|
|
||
| function nextTurn() { | ||
| return new Promise<void>((resolve) => setImmediate(resolve)); | ||
| } | ||
|
|
||
| async function waitFor(predicate: () => boolean) { | ||
| for (let attempt = 0; attempt < 100; attempt++) { | ||
| if (predicate()) { | ||
| return; | ||
| } | ||
| await nextTurn(); | ||
| } | ||
| throw new Error("Timed out waiting for condition"); | ||
| } | ||
|
|
||
| function createBody(total: number) { | ||
| let produced = 0; | ||
| const body = new ReadableStream<Uint8Array>({ | ||
| pull(controller) { | ||
| if (produced >= total) { | ||
| controller.close(); | ||
| return; | ||
| } | ||
| const chunk = new Uint8Array(64 * 1024); | ||
| chunk[0] = produced++; | ||
| controller.enqueue(chunk); | ||
| }, | ||
| }); | ||
|
|
||
| return { | ||
| body, | ||
| get produced() { | ||
| return produced; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function createResult(body: ReadableStream<Uint8Array>): InternalResult { | ||
| return { | ||
| type: "core", | ||
| statusCode: 200, | ||
| headers: {}, | ||
| body, | ||
| isBase64Encoded: false, | ||
| }; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| globalThis.openNextConfig = {}; | ||
| globalThis.__next_route_preloader = vi.fn(async () => {}); | ||
| }); | ||
|
|
||
| describe("requestHandler streaming", () => { | ||
| it("skips tee and remains bounded when chunks do not need retaining", async () => { | ||
| const source = createBody(20); | ||
| const tee = vi.spyOn(source.body, "tee"); | ||
| const result = createResult(source.body); | ||
| mockedRoutingHandler.mockResolvedValue(result); | ||
|
|
||
| const writeCallbacks: Array<() => void> = []; | ||
| const received: number[] = []; | ||
| const streamCreator: StreamCreator = { | ||
| retainChunks: false, | ||
| writeHeaders: () => | ||
| new Writable({ | ||
| highWaterMark: 1, | ||
| write(chunk: Buffer, _encoding, callback) { | ||
| received.push(chunk[0]); | ||
| writeCallbacks.push(callback); | ||
| }, | ||
| }), | ||
| }; | ||
|
|
||
| const handlerPromise = openNextHandler(event, { streamCreator }); | ||
| await waitFor(() => writeCallbacks.length > 0); | ||
| await nextTurn(); | ||
| const producedWhileStalled = source.produced; | ||
| await new Promise((resolve) => setTimeout(resolve, 20)); | ||
|
Cooperrr marked this conversation as resolved.
Outdated
|
||
|
|
||
| expect(source.produced).toBe(producedWhileStalled); | ||
| expect(source.produced).toBeLessThan(20); | ||
| expect(tee).not.toHaveBeenCalled(); | ||
|
|
||
| while (received.length < 20) { | ||
| await waitFor(() => writeCallbacks.length > 0); | ||
| writeCallbacks.shift()!(); | ||
| } | ||
|
|
||
| await expect(handlerPromise).resolves.toBe(result); | ||
| expect(received).toEqual(Array.from({ length: 20 }, (_, index) => index)); | ||
| expect(source.body.locked).toBe(true); | ||
| }); | ||
|
|
||
| it("retains a tee branch by default", async () => { | ||
| const source = createBody(3); | ||
| const tee = vi.spyOn(source.body, "tee"); | ||
| const result = createResult(source.body); | ||
| mockedRoutingHandler.mockResolvedValue(result); | ||
|
|
||
| const streamed: Buffer[] = []; | ||
| const streamCreator: StreamCreator = { | ||
| writeHeaders: () => | ||
| new Writable({ | ||
| write(chunk, _encoding, callback) { | ||
| streamed.push(chunk); | ||
| callback(); | ||
| }, | ||
| }), | ||
| }; | ||
|
|
||
| const returned = await openNextHandler(event, { streamCreator }); | ||
|
|
||
| expect(tee).toHaveBeenCalledOnce(); | ||
| expect(returned.body).not.toBe(source.body); | ||
| expect(Buffer.concat(streamed).length).toBe(3 * 64 * 1024); | ||
| expect((await new Response(returned.body).arrayBuffer()).byteLength).toBe( | ||
| 3 * 64 * 1024, | ||
| ); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a breaking change, this PR cannot just be a patch.
It will also likely break some converters that relies on the body being readable
We could gate that under an env variable that we'll remove on a future major
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for taking the time to review.
I've reverted back to keeping a copy of the body to avoid breaking changes (would be nice to remove down the line as you mentioned in a future major) but not essential for this PR.