From 20cb95aba397c5bdac36b3310ba3643ffdb35dbf Mon Sep 17 00:00:00 2001 From: Valery Ivashchanka Date: Tue, 22 Sep 2026 14:51:10 +0300 Subject: [PATCH 1/2] fix: do not corrupt streamed response bodies `LaravelHttpServer` buffers a streamed response and passes the buffer through `mb_trim()`. When the body begins or ends with a whitespace byte, `mb_trim()` decodes the whole buffer as UTF-8 and replaces every invalid byte with `?`, so a binary body reaches the browser mangled. It also changes the body length while the original `Content-Length` is forwarded as is. The buffer is now passed through untouched. Co-Authored-By: Claude Opus 5 (1M context) --- src/Drivers/LaravelHttpServer.php | 5 +- tests/Browser/Visit/StreamedResponseTest.php | 66 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/Browser/Visit/StreamedResponseTest.php diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index fd5a4144..ac251f10 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -369,9 +369,10 @@ private function handleRequest(AmpRequest $request): Response ob_start(); $response->sendContent(); } finally { - // @phpstan-ignore-next-line - $content = mb_trim(ob_get_clean()); + $buffer = ob_get_clean(); } + + $content = $buffer === false ? '' : $buffer; } return new Response( diff --git a/tests/Browser/Visit/StreamedResponseTest.php b/tests/Browser/Visit/StreamedResponseTest.php new file mode 100644 index 00000000..140f7fb9 --- /dev/null +++ b/tests/Browser/Visit/StreamedResponseTest.php @@ -0,0 +1,66 @@ + '
Home
'); + Route::get('/binary', fn (): StreamedResponse => response()->stream( + function () use ($bytes): void { + echo $bytes; + }, + 200, + ['Content-Type' => 'image/jpeg'], + )); + + $page = visit('/'); + + $page->assertScript( + "async () => { + const response = await fetch('/binary'); + const bytes = new Uint8Array(await response.arrayBuffer()); + + return Array.from(bytes).join(','); + }", + implode(',', unpack('C*', $bytes)), + ); +}); + +it('may serve a binary streamed image that the browser is able to decode', function (): void { + $image = file_get_contents(__DIR__.'/../../Fixtures/v4.jpg'); + + Route::get('/', fn (): string => 'Streamed Image'); + Route::get('/image', fn (): StreamedResponse => response()->stream( + function () use ($image): void { + echo $image; + }, + 200, + ['Content-Type' => 'image/jpeg'], + )); + + $page = visit('/'); + + $page->assertScript("document.getElementById('image').complete && document.getElementById('image').naturalWidth > 0"); +}); + +it('may serve a textual streamed response without altering its whitespace', function (): void { + Route::get('/', fn (): string => '
Home
'); + Route::get('/text', fn (): StreamedResponse => response()->stream( + function (): void { + echo "\n Hello World \n"; + }, + 200, + ['Content-Type' => 'text/plain'], + )); + + $page = visit('/'); + + $page->assertScript( + "async () => JSON.stringify(await (await fetch('/text')).text())", + json_encode("\n Hello World \n"), + ); +}); From 6e6ed5540a6a7298d81a00d8f5bf18124b145a15 Mon Sep 17 00:00:00 2001 From: Valery Ivashchanka Date: Tue, 29 Sep 2026 21:54:31 +0300 Subject: [PATCH 2/2] test: cover the server-sent event terminator A `text/event-stream` body ends every event with a blank line, so the trailing "\n\n" is protocol rather than whitespace. `mb_trim()` removed it, and the browser then never dispatched the last event: a page fed "data: first\n\ndata: second\n\n" received `first` only, with nothing reported anywhere. Measured on `5.x` at c98e8a5, where the new test fails with `-'first,second' +'first'` and passes with the two-line change in this branch. Co-Authored-By: Claude Opus 5 (1M context) --- tests/Browser/Visit/StreamedResponseTest.php | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Browser/Visit/StreamedResponseTest.php b/tests/Browser/Visit/StreamedResponseTest.php index 140f7fb9..6ca44f83 100644 --- a/tests/Browser/Visit/StreamedResponseTest.php +++ b/tests/Browser/Visit/StreamedResponseTest.php @@ -64,3 +64,29 @@ function (): void { json_encode("\n Hello World \n"), ); }); + +it('delivers every server-sent event to the browser', function (): void { + Route::get('/', fn (): string => '
-
+ '); + Route::get('/events', fn (): StreamedResponse => response()->stream( + function (): void { + echo "data: first\n\n"; + echo "data: second\n\n"; + }, + 200, + ['Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache'], + )); + + $page = visit('/'); + + $page->assertSee('first'); + + expect($page->text('#received'))->toBe('first,second'); +});