From 9ba3fe73522f5a08266019ca614541b669758d02 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 7 Jul 2026 13:32:24 +0200 Subject: [PATCH 01/11] feat: add StreamResponse and SSE response factories --- system/HTTP/ResponseTrait.php | 24 +++ system/HTTP/SSEResponse.php | 82 +-------- system/HTTP/StreamResponse.php | 159 ++++++++++++++++++ tests/system/HTTP/StreamResponseSendTest.php | 124 ++++++++++++++ tests/system/HTTP/StreamResponseTest.php | 104 ++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 6 +- user_guide_src/source/outgoing/response.rst | 65 ++++++- .../source/outgoing/response/036.php | 2 +- .../source/outgoing/response/037.php | 2 +- .../source/outgoing/response/038.php | 2 +- .../source/outgoing/response/039.php | 14 ++ .../source/outgoing/response/040.php | 10 ++ 12 files changed, 514 insertions(+), 80 deletions(-) create mode 100644 system/HTTP/StreamResponse.php create mode 100644 tests/system/HTTP/StreamResponseSendTest.php create mode 100644 tests/system/HTTP/StreamResponseTest.php create mode 100644 user_guide_src/source/outgoing/response/039.php create mode 100644 user_guide_src/source/outgoing/response/040.php diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index b3c0c88da326..f5221b4addea 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -13,6 +13,7 @@ namespace CodeIgniter\HTTP; +use Closure; use CodeIgniter\Config\Services; use CodeIgniter\Cookie\Cookie; use CodeIgniter\Cookie\CookieStore; @@ -810,6 +811,29 @@ public function download(string $filename = '', $data = '', bool $setMime = fals return $response; } + /** + * Creates a response that streams its body to the client as it is + * generated, instead of buffering the complete body first. + * + * @param (Closure(StreamResponse): void)|iterable $callbackOrChunks A callback that + * streams output via write(), or an iterable of + * string chunks to be written in order + */ + public function stream(Closure|iterable $callbackOrChunks): StreamResponse + { + return new StreamResponse($callbackOrChunks); + } + + /** + * Creates a response for streaming Server-Sent Events (SSE). + * + * @param Closure(SSEResponse): void $callback + */ + public function eventStream(Closure $callback): SSEResponse + { + return new SSEResponse($callback); + } + public function getCSP(): ContentSecurityPolicy { $this->CSP ??= service('csp'); diff --git a/system/HTTP/SSEResponse.php b/system/HTTP/SSEResponse.php index bc86f82e82f8..3a5f412ac2c6 100644 --- a/system/HTTP/SSEResponse.php +++ b/system/HTTP/SSEResponse.php @@ -21,16 +21,16 @@ * * @see \CodeIgniter\HTTP\SSEResponseTest */ -class SSEResponse extends Response implements NonBufferedResponseInterface +class SSEResponse extends StreamResponse { /** * Constructor. * * @param Closure(SSEResponse): void $callback */ - public function __construct(private readonly Closure $callback) + public function __construct(Closure $callback) { - parent::__construct(); + parent::__construct($callback); } /** @@ -42,7 +42,7 @@ public function __construct(private readonly Closure $callback) */ public function event(array|string $data, ?string $event = null, ?string $id = null): bool { - if ($this->isConnectionAborted()) { + if (! $this->isClientConnected()) { return false; } @@ -76,10 +76,6 @@ public function event(array|string $data, ?string $event = null, ?string $id = n */ public function comment(string $text): bool { - if ($this->isConnectionAborted()) { - return false; - } - return $this->write($this->formatMultiline('', $text)); } @@ -90,21 +86,9 @@ public function comment(string $text): bool */ public function retry(int $milliseconds): bool { - if ($this->isConnectionAborted()) { - return false; - } - return $this->write("retry: {$milliseconds}\n\n"); } - /** - * Check if the client connection has been lost. - */ - private function isConnectionAborted(): bool - { - return connection_status() !== CONNECTION_NORMAL || connection_aborted() === 1; - } - /** * Strip newlines from a single-line SSE field (event, id). */ @@ -130,46 +114,14 @@ private function formatMultiline(string $prefix, string $value): string return $output . "\n"; } - /** - * Write raw SSE output and flush. - */ - private function write(string $output): bool - { - echo $output; - - if (! service('environment')->isTesting()) { - if (ob_get_level() > 0) { - ob_flush(); - } - - flush(); - } - - return true; - } - /** * {@inheritDoc} * - * @return $this + * SSE headers are fixed by the protocol, so they override + * anything set on the response. */ - public function send() + protected function prepareStreamHeaders(): void { - // Turn off output buffering completely, even if php.ini output_buffering is not off - if (! service('environment')->isTesting()) { - set_time_limit(0); - ini_set('zlib.output_compression', 'Off'); - - while (ob_get_level() > 0) { - ob_end_clean(); - } - } - - // Close session if active to prevent blocking other requests - if (session_status() === PHP_SESSION_ACTIVE) { - session_write_close(); - } - $this->setContentType('text/event-stream', 'UTF-8'); $this->removeHeader('Cache-Control'); $this->setHeader('Cache-Control', 'no-cache'); @@ -180,25 +132,5 @@ public function send() if (version_compare($this->getProtocolVersion(), '2.0', '<')) { $this->setHeader('Connection', 'keep-alive'); } - - // Intentionally skip CSP finalize: no HTML/JS execution in SSE streams. - $this->sendHeaders(); - $this->sendCookies(); - - ($this->callback)($this); - - return $this; - } - - /** - * {@inheritDoc} - * - * No-op — body is streamed via the callback, not stored. - * - * @return $this - */ - public function sendBody() - { - return $this; } } diff --git a/system/HTTP/StreamResponse.php b/system/HTTP/StreamResponse.php new file mode 100644 index 000000000000..e08782d82892 --- /dev/null +++ b/system/HTTP/StreamResponse.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use Closure; + +/** + * HTTP response that streams its body to the client as it is generated, + * instead of buffering the complete body first. + * + * @see \CodeIgniter\HTTP\StreamResponseTest + */ +class StreamResponse extends Response implements NonBufferedResponseInterface +{ + /** + * @var Closure(static): void + */ + private readonly Closure $callback; + + /** + * @param (Closure(static): void)|iterable $callbackOrChunks A callback that + * streams output via write(), or an iterable of + * string chunks to be written in order + */ + public function __construct(Closure|iterable $callbackOrChunks) + { + parent::__construct(); + + $this->callback = $callbackOrChunks instanceof Closure + ? $callbackOrChunks + : static function (self $response) use ($callbackOrChunks): void { + foreach ($callbackOrChunks as $chunk) { + if (! $response->write($chunk)) { + break; + } + } + }; + } + + /** + * Write a chunk of the streamed body. + * + * @param bool $flush Whether to flush output to the client immediately. + * Pass false when writing many small chunks, then call + * flush() at intervals. + * + * @return bool false if the client has disconnected + */ + public function write(string $chunk, bool $flush = true): bool + { + if (! $this->isClientConnected()) { + return false; + } + + echo $chunk; + + if ($flush) { + $this->flush(); + } + + return true; + } + + /** + * Flush buffered output to the client. + */ + public function flush(): void + { + if (! service('environment')->isTesting()) { + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + } + } + + /** + * Whether the client connection is still open. + * + * Note: PHP only detects a disconnect when attempting to write, + * so this may return true until the next write() call fails. + */ + public function isClientConnected(): bool + { + return connection_status() === CONNECTION_NORMAL && connection_aborted() === 0; + } + + /** + * {@inheritDoc} + * + * @return $this + */ + public function send() + { + // Turn off output buffering completely, even if php.ini output_buffering is not off + if (! service('environment')->isTesting()) { + set_time_limit(0); + ini_set('zlib.output_compression', 'Off'); + + while (ob_get_level() > 0) { + ob_end_clean(); + } + } + + // Close session if active to prevent blocking other requests + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + + $this->prepareStreamHeaders(); + + // Intentionally skip CSP finalize: the body is streamed, not buffered HTML. + $this->sendHeaders(); + $this->sendCookies(); + + ($this->callback)($this); + + return $this; + } + + /** + * Applies headers needed for unbuffered delivery, without overriding + * any the developer has already set. + */ + protected function prepareStreamHeaders(): void + { + if (! $this->hasHeader('X-Accel-Buffering')) { + $this->setHeader('X-Accel-Buffering', 'no'); + } + + if (! $this->hasHeader('Content-Encoding')) { + $this->setHeader('Content-Encoding', 'identity'); + } + } + + /** + * {@inheritDoc} + * + * No-op — body is streamed via the callback, not stored. + * + * @return $this + */ + public function sendBody() + { + return $this; + } +} diff --git a/tests/system/HTTP/StreamResponseSendTest.php b/tests/system/HTTP/StreamResponseSendTest.php new file mode 100644 index 000000000000..b70b30fa6667 --- /dev/null +++ b/tests/system/HTTP/StreamResponseSendTest.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Test\CIUnitTestCase; +use Generator; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; + +/** + * @internal + */ +#[Group('SeparateProcess')] +final class StreamResponseSendTest extends CIUnitTestCase +{ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[WithoutErrorHandler] + public function testSendEmitsHeadersCookiesAndStream(): void + { + $response = new StreamResponse(static function (StreamResponse $stream): void { + $stream->write('Hello '); + $stream->write('World'); + }); + $response->pretend(false); + $response->setCookie('foo', 'bar'); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame('Hello World', $output); + $this->assertHeaderEmitted('X-Accel-Buffering: no'); + $this->assertHeaderEmitted('Content-Encoding: identity'); + $this->assertHeaderEmitted('Set-Cookie: foo=bar;'); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[WithoutErrorHandler] + public function testSendStreamsIterableChunks(): void + { + $response = new StreamResponse(['chunk1', 'chunk2', 'chunk3']); + $response->pretend(false); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame('chunk1chunk2chunk3', $output); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[WithoutErrorHandler] + public function testSendStreamsGeneratorChunks(): void + { + $generator = static function (): Generator { + yield "line1\n"; + + yield "line2\n"; + }; + + $response = new StreamResponse($generator()); + $response->pretend(false); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame("line1\nline2\n", $output); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[WithoutErrorHandler] + public function testSendRespectsCustomHeaders(): void + { + $response = new StreamResponse(static function (StreamResponse $stream): void { + $stream->write("id,email\n"); + }); + $response->pretend(false); + $response->setContentType('text/csv'); + $response->setHeader('X-Accel-Buffering', 'yes'); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame("id,email\n", $output); + $this->assertHeaderEmitted('Content-Type: text/csv; charset=UTF-8'); + $this->assertHeaderEmitted('X-Accel-Buffering: yes'); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[WithoutErrorHandler] + public function testSendEmitsCustomStatusCode(): void + { + $response = new StreamResponse(static function (): void { + }); + $response->pretend(false); + $response->setStatusCode(202); + + ob_start(); + $response->send(); + ob_end_clean(); + + $this->assertSame(202, http_response_code()); + } +} diff --git a/tests/system/HTTP/StreamResponseTest.php b/tests/system/HTTP/StreamResponseTest.php new file mode 100644 index 000000000000..d3673f429b52 --- /dev/null +++ b/tests/system/HTTP/StreamResponseTest.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class StreamResponseTest extends CIUnitTestCase +{ + public function testWriteOutputsChunk(): void + { + $response = new StreamResponse(static function (): void { + }); + + ob_start(); + $result = $response->write('chunk'); + $output = ob_get_clean(); + + $this->assertTrue($result); + $this->assertSame('chunk', $output); + } + + public function testWriteWithoutFlushOutputsChunk(): void + { + $response = new StreamResponse(static function (): void { + }); + + ob_start(); + $result = $response->write('chunk', false); + $output = ob_get_clean(); + + $this->assertTrue($result); + $this->assertSame('chunk', $output); + } + + public function testIsClientConnectedInCli(): void + { + $response = new StreamResponse(static function (): void { + }); + + $this->assertTrue($response->isClientConnected()); + } + + public function testSendBodyIsNoOp(): void + { + $response = new StreamResponse(static function (): void { + }); + + ob_start(); + $result = $response->sendBody(); + $output = ob_get_clean(); + + $this->assertSame($response, $result); + $this->assertSame('', $output); + } + + public function testStatusCodeIsSettable(): void + { + $response = new StreamResponse(static function (): void { + }); + $response->setStatusCode(206); + + $this->assertSame(206, $response->getStatusCode()); + } + + public function testStreamFactoryReturnsStreamResponse(): void + { + $response = (new Response())->stream(static function (): void { + }); + + $this->assertInstanceOf(StreamResponse::class, $response); + $this->assertNotInstanceOf(SSEResponse::class, $response); + } + + public function testStreamFactoryAcceptsIterable(): void + { + $response = (new Response())->stream(['a', 'b']); + + $this->assertInstanceOf(StreamResponse::class, $response); + } + + public function testEventStreamFactoryReturnsSSEResponse(): void + { + $response = (new Response())->eventStream(static function (): void { + }); + + $this->assertInstanceOf(SSEResponse::class, $response); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 031d5e0b3a5a..42bca0b0a794 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -310,7 +310,11 @@ HTTP - Added the ``retry`` option to ``CURLRequest`` for retrying failed responses with configurable delays, retryable status codes, optional transient cURL error retries, and ``Retry-After`` support. See :ref:`curlrequest-request-options-retry`. - Added :ref:`Form Requests ` - a new ``FormRequest`` base class that encapsulates validation rules, custom error messages, and authorization logic for a single HTTP request. - Added ``IncomingRequest::input()`` to read GET, POST, JSON, and raw request data through ``InputData``. -- Added ``SSEResponse`` class for streaming Server-Sent Events (SSE) over HTTP. See :ref:`server-sent-events`. +- Added support for streaming responses. The ``StreamResponse`` class streams a body that is generated + while it is being sent, such as large dataset exports or AI/LLM token streams, and the ``SSEResponse`` + class extends it to stream Server-Sent Events (SSE) over HTTP. Both can be created via the new + ``$this->response->stream()`` and ``$this->response->eventStream()`` factory methods. + See :ref:`streaming-responses` and :ref:`server-sent-events`. - ``Response`` and its child classes no longer require ``Config\App`` passed to their constructors. Consequently, ``CURLRequest``'s ``$config`` parameter is unused and will be removed in a future release. - ``Response`` now lazily loads the ``ContentSecurityPolicy``, ``Cookie``, and ``CookieStore`` classes only when used, diff --git a/user_guide_src/source/outgoing/response.rst b/user_guide_src/source/outgoing/response.rst index b8a0adcbde67..3c949867c5cd 100644 --- a/user_guide_src/source/outgoing/response.rst +++ b/user_guide_src/source/outgoing/response.rst @@ -231,6 +231,61 @@ Some browsers can display files such as PDF. To tell the browser to display the .. literalinclude:: response/033.php +.. _streaming-responses: + +Streaming Responses +=================== + +.. versionadded:: 4.8.0 + +CodeIgniter provides a ``StreamResponse`` class that streams its body to the +client as it is generated, instead of buffering the complete body first. This +is useful for large dataset exports, AI/LLM token streaming, proxying upstream +streams, or any content that is produced while it is being sent. Streaming +keeps memory usage low and improves time-to-first-byte for large responses. + +Use the ``$this->response->stream()`` method with a callback: + +.. literalinclude:: response/039.php + +The callback receives the ``StreamResponse`` instance. ``StreamResponse::write()`` +returns ``false`` once the client has disconnected, so you can stop producing +output early. By default, every ``write()`` flushes output to the client +immediately. When writing many small chunks, pass ``false`` as the second +argument and call ``StreamResponse::flush()`` at intervals instead. + +Alternatively, you may pass an iterable of string chunks (such as a generator), +and each chunk is written and flushed in order: + +.. literalinclude:: response/040.php + +Headers and Status Code +----------------------- + +Set the content type, status code, and any custom headers **before** returning +the response - anything set inside the callback will be too late. Unless you +have already set them, ``StreamResponse`` applies ``X-Accel-Buffering: no`` and +``Content-Encoding: identity`` to discourage intermediaries from buffering or +compressing the stream. + +The response is streamed: output buffering is disabled, the PHP time limit is +removed, and the session is closed to avoid blocking other requests. After +filters still run and may set headers, but they must not rely on the response +body. View rendering and decorators are not applied - stream your output in +the callback. + +Since the body length is unknown in advance, no ``Content-Length`` header is +sent. The client cannot display download progress, and interrupted transfers +cannot be resumed. + +.. note:: To serve an existing file, prefer :ref:`DownloadResponse `, + which sends proper ``Content-Length`` and ``Content-Disposition`` headers. + Use ``StreamResponse`` when the content is generated while it is being sent. + +The :ref:`development server ` and +:ref:`production ` considerations described for SSE below apply +to all streaming responses. + .. _server-sent-events: Server-Sent Events (SSE) @@ -241,7 +296,11 @@ Server-Sent Events (SSE) CodeIgniter provides an ``SSEResponse`` for streaming `Server-Sent Events `_ over HTTP. This is useful for long-lived connections where the server pushes -events to the client. +events to the client. ``SSEResponse`` is a specialized +:ref:`StreamResponse ` that formats output according to +the SSE protocol and forces the appropriate headers. Create it via the +``$this->response->eventStream()`` factory method, or instantiate +``new SSEResponse(...)`` directly. .. literalinclude:: response/036.php @@ -266,6 +325,8 @@ use comments for keep-alive and configure the client retry interval: .. literalinclude:: response/037.php +.. _sse-development-server: + Development Server Limitations ------------------------------ @@ -289,6 +350,8 @@ requests better, such as Apache, nginx with PHP-FPM, or FrankenPHP. This behavior is a limitation of the development server environment, not of ``SSEResponse`` itself. +.. _sse-production: + Production Considerations ------------------------- diff --git a/user_guide_src/source/outgoing/response/036.php b/user_guide_src/source/outgoing/response/036.php index 9308fef80ce2..1d093c9fa849 100644 --- a/user_guide_src/source/outgoing/response/036.php +++ b/user_guide_src/source/outgoing/response/036.php @@ -2,7 +2,7 @@ use CodeIgniter\HTTP\SSEResponse; -return new SSEResponse(static function (SSEResponse $sse) { +return $this->response->eventStream(static function (SSEResponse $sse) { foreach (['Hello', 'World'] as $text) { if (! $sse->event(['text' => $text])) { break; diff --git a/user_guide_src/source/outgoing/response/037.php b/user_guide_src/source/outgoing/response/037.php index 96bc40537ca3..001d968b57cf 100644 --- a/user_guide_src/source/outgoing/response/037.php +++ b/user_guide_src/source/outgoing/response/037.php @@ -2,7 +2,7 @@ use CodeIgniter\HTTP\SSEResponse; -$sse = new SSEResponse(static function (SSEResponse $sse) { +$sse = $this->response->eventStream(static function (SSEResponse $sse) { $sse->comment('keep-alive'); foreach (['one', 'two', 'three', 'four'] as $text) { diff --git a/user_guide_src/source/outgoing/response/038.php b/user_guide_src/source/outgoing/response/038.php index 25f0370d28cf..38934c24eb18 100644 --- a/user_guide_src/source/outgoing/response/038.php +++ b/user_guide_src/source/outgoing/response/038.php @@ -5,7 +5,7 @@ $user_id = session()->get('user_id'); -return new SSEResponse(static function (SSEResponse $sse) use ($user_id) { +return $this->response->eventStream(static function (SSEResponse $sse) use ($user_id) { // Stream live notifications for the current user $notificationModel = model(NotificationModel::class); diff --git a/user_guide_src/source/outgoing/response/039.php b/user_guide_src/source/outgoing/response/039.php new file mode 100644 index 000000000000..ccbb20f4fe2e --- /dev/null +++ b/user_guide_src/source/outgoing/response/039.php @@ -0,0 +1,14 @@ +response->stream(static function (StreamResponse $stream) { + $stream->write("id,email\n"); + + foreach (model('UserModel')->findAll() as $user) { + // write() returns false when the client disconnects + if (! $stream->write("{$user->id},{$user->email}\n")) { + break; + } + } +}); diff --git a/user_guide_src/source/outgoing/response/040.php b/user_guide_src/source/outgoing/response/040.php new file mode 100644 index 000000000000..136e8dee60aa --- /dev/null +++ b/user_guide_src/source/outgoing/response/040.php @@ -0,0 +1,10 @@ +findAll() as $log) { + yield json_encode($log) . "\n"; + } +}; + +return $this->response->stream($chunks()); From 6169a5bb9985797f97584b9297c230b7e97d6531 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 7 Jul 2026 13:59:08 +0200 Subject: [PATCH 02/11] support callable streaming responses --- system/HTTP/ResponseTrait.php | 13 ++++++------- system/HTTP/SSEResponse.php | 5 ++--- system/HTTP/StreamResponse.php | 14 ++++++++------ tests/system/HTTP/StreamResponseTest.php | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index f5221b4addea..0ece333cf8b8 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -13,7 +13,6 @@ namespace CodeIgniter\HTTP; -use Closure; use CodeIgniter\Config\Services; use CodeIgniter\Cookie\Cookie; use CodeIgniter\Cookie\CookieStore; @@ -815,11 +814,11 @@ public function download(string $filename = '', $data = '', bool $setMime = fals * Creates a response that streams its body to the client as it is * generated, instead of buffering the complete body first. * - * @param (Closure(StreamResponse): void)|iterable $callbackOrChunks A callback that - * streams output via write(), or an iterable of - * string chunks to be written in order + * @param (callable(StreamResponse): void)|iterable $callbackOrChunks A callback that + * streams output via write(), or an iterable of + * string chunks to be written in order */ - public function stream(Closure|iterable $callbackOrChunks): StreamResponse + public function stream(callable|iterable $callbackOrChunks): StreamResponse { return new StreamResponse($callbackOrChunks); } @@ -827,9 +826,9 @@ public function stream(Closure|iterable $callbackOrChunks): StreamResponse /** * Creates a response for streaming Server-Sent Events (SSE). * - * @param Closure(SSEResponse): void $callback + * @param callable(SSEResponse): void $callback */ - public function eventStream(Closure $callback): SSEResponse + public function eventStream(callable $callback): SSEResponse { return new SSEResponse($callback); } diff --git a/system/HTTP/SSEResponse.php b/system/HTTP/SSEResponse.php index 3a5f412ac2c6..e2e7e3cb1f9f 100644 --- a/system/HTTP/SSEResponse.php +++ b/system/HTTP/SSEResponse.php @@ -13,7 +13,6 @@ namespace CodeIgniter\HTTP; -use Closure; use JsonException; /** @@ -26,9 +25,9 @@ class SSEResponse extends StreamResponse /** * Constructor. * - * @param Closure(SSEResponse): void $callback + * @param callable(SSEResponse): void $callback */ - public function __construct(Closure $callback) + public function __construct(callable $callback) { parent::__construct($callback); } diff --git a/system/HTTP/StreamResponse.php b/system/HTTP/StreamResponse.php index e08782d82892..22aba76fc771 100644 --- a/system/HTTP/StreamResponse.php +++ b/system/HTTP/StreamResponse.php @@ -29,16 +29,18 @@ class StreamResponse extends Response implements NonBufferedResponseInterface private readonly Closure $callback; /** - * @param (Closure(static): void)|iterable $callbackOrChunks A callback that - * streams output via write(), or an iterable of - * string chunks to be written in order + * @param (callable(static): void)|iterable $callbackOrChunks A callback that + * streams output via write(), or an iterable of + * string chunks to be written in order. A value + * that is both callable and iterable is treated + * as a callback. */ - public function __construct(Closure|iterable $callbackOrChunks) + public function __construct(callable|iterable $callbackOrChunks) { parent::__construct(); - $this->callback = $callbackOrChunks instanceof Closure - ? $callbackOrChunks + $this->callback = is_callable($callbackOrChunks) + ? $callbackOrChunks(...) : static function (self $response) use ($callbackOrChunks): void { foreach ($callbackOrChunks as $chunk) { if (! $response->write($chunk)) { diff --git a/tests/system/HTTP/StreamResponseTest.php b/tests/system/HTTP/StreamResponseTest.php index d3673f429b52..5b3cbcdc79c0 100644 --- a/tests/system/HTTP/StreamResponseTest.php +++ b/tests/system/HTTP/StreamResponseTest.php @@ -78,6 +78,24 @@ public function testStatusCodeIsSettable(): void $this->assertSame(206, $response->getStatusCode()); } + public function testConstructorTreatsCallableIterableAsCallable(): void + { + // An array callable is both callable and iterable; callable must win. + $response = new StreamResponse([$this, 'writeHello']); + $response->pretend(); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame('Hello', $output); + } + + public function writeHello(StreamResponse $stream): void + { + $stream->write('Hello'); + } + public function testStreamFactoryReturnsStreamResponse(): void { $response = (new Response())->stream(static function (): void { From 00ab266d0180214d0287b7b244227547bb137220 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 7 Jul 2026 14:13:53 +0200 Subject: [PATCH 03/11] add streaming methods to ResponseInterface --- system/HTTP/ResponseInterface.php | 17 +++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 1 + user_guide_src/source/outgoing/response.rst | 8 +++++--- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/system/HTTP/ResponseInterface.php b/system/HTTP/ResponseInterface.php index 66053c412939..d016fec338fb 100644 --- a/system/HTTP/ResponseInterface.php +++ b/system/HTTP/ResponseInterface.php @@ -397,6 +397,23 @@ public function redirect(string $uri, string $method = 'auto', ?int $code = null */ public function download(string $filename = '', $data = '', bool $setMime = false); + /** + * Creates a response that streams its body to the client as it is + * generated, instead of buffering the complete body first. + * + * @param (callable(StreamResponse): void)|iterable $callbackOrChunks A callback that + * streams output via write(), or an iterable of + * string chunks to be written in order + */ + public function stream(callable|iterable $callbackOrChunks): StreamResponse; + + /** + * Creates a response for streaming Server-Sent Events (SSE). + * + * @param callable(SSEResponse): void $callback + */ + public function eventStream(callable $callback): SSEResponse; + // -------------------------------------------------------------------- // CSP Methods // -------------------------------------------------------------------- diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 42bca0b0a794..6351cd74e4a5 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -52,6 +52,7 @@ update your implementations to include the new methods or method changes to ensu - **Cache:** ``CodeIgniter\Cache\CacheInterface::remember()`` now accepts a TTL callable. Custom implementations of ``CacheInterface`` must update the ``$ttl`` parameter type from ``int`` to ``callable|int``. - **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, ``inTransaction()``, and ``transaction()`` methods. +- **HTTP:** ``CodeIgniter\HTTP\ResponseInterface`` now requires the ``stream()`` and ``eventStream()`` methods, which create streaming and SSE responses. See :ref:`streaming-responses`. - **Logging:** ``CodeIgniter\Log\Handlers\HandlerInterface::handle()`` now requires a third parameter ``array $context = []``. Any custom log handler that overrides ``handle()`` - whether implementing ``HandlerInterface`` directly or extending a built-in handler class - must add the parameter to its ``handle()`` method signature. - **Security:** The ``SecurityInterface``'s ``verify()`` method now has a native return type of ``static``. - **Validation:** ``CodeIgniter\Validation\ValidationInterface`` now requires the ``getValidatedInput()`` method, which returns a ``CodeIgniter\Input\ValidatedInput`` instance. diff --git a/user_guide_src/source/outgoing/response.rst b/user_guide_src/source/outgoing/response.rst index 3c949867c5cd..217c462f447e 100644 --- a/user_guide_src/source/outgoing/response.rst +++ b/user_guide_src/source/outgoing/response.rst @@ -274,9 +274,11 @@ filters still run and may set headers, but they must not rely on the response body. View rendering and decorators are not applied - stream your output in the callback. -Since the body length is unknown in advance, no ``Content-Length`` header is -sent. The client cannot display download progress, and interrupted transfers -cannot be resumed. +``StreamResponse`` does not set a ``Content-Length`` header by default, since +the body length is typically unknown in advance. Without it, clients cannot +display download progress or resume interrupted transfers. If you do know the +total size (for example, when proxying a stream of known length), you may set +``Content-Length`` yourself before returning the response. .. note:: To serve an existing file, prefer :ref:`DownloadResponse `, which sends proper ``Content-Length`` and ``Content-Disposition`` headers. From dbea7a36d9a16bbf8427fca81ce0617de3b7f6f7 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 7 Jul 2026 14:38:13 +0200 Subject: [PATCH 04/11] add stream proxying example --- user_guide_src/source/outgoing/response.rst | 9 +++++++ .../source/outgoing/response/041.php | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 user_guide_src/source/outgoing/response/041.php diff --git a/user_guide_src/source/outgoing/response.rst b/user_guide_src/source/outgoing/response.rst index 217c462f447e..45d83b08beee 100644 --- a/user_guide_src/source/outgoing/response.rst +++ b/user_guide_src/source/outgoing/response.rst @@ -284,6 +284,15 @@ total size (for example, when proxying a stream of known length), you may set which sends proper ``Content-Length`` and ``Content-Disposition`` headers. Use ``StreamResponse`` when the content is generated while it is being sent. +Example: Proxying an Upstream Stream +------------------------------------ + +``StreamResponse`` can forward a stream from another source - such as object +storage or an internal API - to the client without buffering it in memory or +writing it to disk: + +.. literalinclude:: response/041.php + The :ref:`development server ` and :ref:`production ` considerations described for SSE below apply to all streaming responses. diff --git a/user_guide_src/source/outgoing/response/041.php b/user_guide_src/source/outgoing/response/041.php new file mode 100644 index 000000000000..d9e2f2e831fc --- /dev/null +++ b/user_guide_src/source/outgoing/response/041.php @@ -0,0 +1,24 @@ +response->stream(static function (StreamResponse $stream) { + // Forward the upstream file chunk by chunk, without buffering it in memory + $source = fopen('https://storage.internal/reports/annual.pdf', 'rb'); + + try { + while (! feof($source)) { + $chunk = fread($source, 1_048_576); + + if ($chunk === false || ! $stream->write($chunk)) { + break; + } + } + } finally { + fclose($source); + } +}); + +$stream->setContentType('application/pdf'); + +return $stream; From 25c2ea17455697500faa8f5e98dc1adda9127814 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 7 Jul 2026 14:40:27 +0200 Subject: [PATCH 05/11] fix rector --- tests/system/HTTP/StreamResponseTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/HTTP/StreamResponseTest.php b/tests/system/HTTP/StreamResponseTest.php index 5b3cbcdc79c0..f2f5e15531ed 100644 --- a/tests/system/HTTP/StreamResponseTest.php +++ b/tests/system/HTTP/StreamResponseTest.php @@ -81,7 +81,7 @@ public function testStatusCodeIsSettable(): void public function testConstructorTreatsCallableIterableAsCallable(): void { // An array callable is both callable and iterable; callable must win. - $response = new StreamResponse([$this, 'writeHello']); + $response = new StreamResponse($this->writeHello(...)); $response->pretend(); ob_start(); From c7715c8e7b904392d66716a373ef71df9e678356 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 8 Jul 2026 18:19:47 +0200 Subject: [PATCH 06/11] update structarmed --- structarmed.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/structarmed.php b/structarmed.php index d5bc6b9b07a5..53c371a471e9 100644 --- a/structarmed.php +++ b/structarmed.php @@ -16,6 +16,7 @@ use CodeIgniter\HTTP\Header; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\SSEResponse; +use CodeIgniter\HTTP\StreamResponse; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\DataCaster\DataCaster; use CodeIgniter\Entity\Cast\CastInterface; @@ -155,4 +156,5 @@ ->skipClassViolation(RedirectResponse::class, [PagerInterface::class]) ->skipClassViolation(DownloadResponse::class, [PagerInterface::class]) ->skipClassViolation(SSEResponse::class, [PagerInterface::class]) + ->skipClassViolation(StreamResponse::class, [PagerInterface::class]) ->skipClassViolation(Validation::class, [RendererInterface::class]); From 70042110e55cbfc46425d9fcd2074d94b506585b Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 8 Jul 2026 18:24:51 +0200 Subject: [PATCH 07/11] fix psalm error --- tests/system/HTTP/StreamResponseTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/system/HTTP/StreamResponseTest.php b/tests/system/HTTP/StreamResponseTest.php index f2f5e15531ed..cef26db1bf51 100644 --- a/tests/system/HTTP/StreamResponseTest.php +++ b/tests/system/HTTP/StreamResponseTest.php @@ -107,7 +107,9 @@ public function testStreamFactoryReturnsStreamResponse(): void public function testStreamFactoryAcceptsIterable(): void { - $response = (new Response())->stream(['a', 'b']); + /** @var iterable $chunks */ + $chunks = ['a', 'b']; + $response = (new Response())->stream($chunks); $this->assertInstanceOf(StreamResponse::class, $response); } From 4721eeebfc31621f3710b4bcb492dd43ec5525bb Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 8 Jul 2026 21:51:37 +0200 Subject: [PATCH 08/11] refine streaming response headers and docs --- system/HTTP/ResponseTrait.php | 2 +- system/HTTP/SSEResponse.php | 12 ++++- system/HTTP/StreamResponse.php | 12 +++-- tests/system/HTTP/StreamResponseSendTest.php | 49 ++++++++++++++++++- user_guide_src/source/outgoing/response.rst | 28 ++++++++--- .../source/outgoing/response/039.php | 27 +++++++--- .../source/outgoing/response/041.php | 13 +++-- 7 files changed, 117 insertions(+), 26 deletions(-) diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index 0ece333cf8b8..2cdbf0968f16 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -394,7 +394,7 @@ public function send() * need it avoids the cost of constructing a 1000+ line service on every * request. */ - private function shouldFinalizeCsp(): bool + protected function shouldFinalizeCsp(): bool { // Developer already touched CSP through getCSP(); respect it. if ($this->CSP !== null) { diff --git a/system/HTTP/SSEResponse.php b/system/HTTP/SSEResponse.php index e2e7e3cb1f9f..fd5394ac7f86 100644 --- a/system/HTTP/SSEResponse.php +++ b/system/HTTP/SSEResponse.php @@ -124,7 +124,6 @@ protected function prepareStreamHeaders(): void $this->setContentType('text/event-stream', 'UTF-8'); $this->removeHeader('Cache-Control'); $this->setHeader('Cache-Control', 'no-cache'); - $this->setHeader('Content-Encoding', 'identity'); $this->setHeader('X-Accel-Buffering', 'no'); // Connection: keep-alive is only valid for HTTP/1.x @@ -132,4 +131,15 @@ protected function prepareStreamHeaders(): void $this->setHeader('Connection', 'keep-alive'); } } + + /** + * {@inheritDoc} + * + * CSP is not finalized for SSE responses, as Content Security + * Policy does not apply to event streams. + */ + protected function shouldFinalizeCsp(): bool + { + return false; + } } diff --git a/system/HTTP/StreamResponse.php b/system/HTTP/StreamResponse.php index 22aba76fc771..4b2cdc2cdc65 100644 --- a/system/HTTP/StreamResponse.php +++ b/system/HTTP/StreamResponse.php @@ -123,7 +123,13 @@ public function send() $this->prepareStreamHeaders(); - // Intentionally skip CSP finalize: the body is streamed, not buffered HTML. + // Give CSP a chance to build its headers. Nonce placeholders cannot be + // replaced in a streamed body; call getCSP()->getScriptNonce() or + // getStyleNonce() before returning the response instead. + if ($this->shouldFinalizeCsp()) { + $this->getCSP()->finalize($this); + } + $this->sendHeaders(); $this->sendCookies(); @@ -141,10 +147,6 @@ protected function prepareStreamHeaders(): void if (! $this->hasHeader('X-Accel-Buffering')) { $this->setHeader('X-Accel-Buffering', 'no'); } - - if (! $this->hasHeader('Content-Encoding')) { - $this->setHeader('Content-Encoding', 'identity'); - } } /** diff --git a/tests/system/HTTP/StreamResponseSendTest.php b/tests/system/HTTP/StreamResponseSendTest.php index b70b30fa6667..abccd153b833 100644 --- a/tests/system/HTTP/StreamResponseSendTest.php +++ b/tests/system/HTTP/StreamResponseSendTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Test\CIUnitTestCase; +use Config\App; use Generator; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\PreserveGlobalState; @@ -44,7 +45,7 @@ public function testSendEmitsHeadersCookiesAndStream(): void $this->assertSame('Hello World', $output); $this->assertHeaderEmitted('X-Accel-Buffering: no'); - $this->assertHeaderEmitted('Content-Encoding: identity'); + $this->assertHeaderNotEmitted('Content-Encoding:'); $this->assertHeaderEmitted('Set-Cookie: foo=bar;'); } @@ -121,4 +122,50 @@ public function testSendEmitsCustomStatusCode(): void $this->assertSame(202, http_response_code()); } + + /** + * This test does not test that CSP is handled properly - + * it makes sure that sending gives CSP a chance to do its thing. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[WithoutErrorHandler] + public function testSendEmitsCspHeaderWhenEnabled(): void + { + $this->resetFactories(); + $this->resetServices(); + + config(App::class)->CSPEnabled = true; + + $response = new StreamResponse(static function (): void { + }); + $response->pretend(false); + + ob_start(); + $response->send(); + ob_end_clean(); + + $this->assertHeaderEmitted('Content-Security-Policy:'); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[WithoutErrorHandler] + public function testSendSkipsCspHeaderForSSE(): void + { + $this->resetFactories(); + $this->resetServices(); + + config(App::class)->CSPEnabled = true; + + $response = new SSEResponse(static function (): void { + }); + $response->pretend(false); + + ob_start(); + $response->send(); + ob_end_clean(); + + $this->assertHeaderNotEmitted('Content-Security-Policy:'); + } } diff --git a/user_guide_src/source/outgoing/response.rst b/user_guide_src/source/outgoing/response.rst index 45d83b08beee..f01c3432d2a2 100644 --- a/user_guide_src/source/outgoing/response.rst +++ b/user_guide_src/source/outgoing/response.rst @@ -252,7 +252,12 @@ The callback receives the ``StreamResponse`` instance. ``StreamResponse::write() returns ``false`` once the client has disconnected, so you can stop producing output early. By default, every ``write()`` flushes output to the client immediately. When writing many small chunks, pass ``false`` as the second -argument and call ``StreamResponse::flush()`` at intervals instead. +argument and call ``StreamResponse::flush()`` at intervals instead - the +example above flushes once per batch. + +The callback is not limited to ``write()``: you may also write directly to +``php://output`` (for example, with ``fputcsv()`` for CSV exports), calling +``flush()`` and ``StreamResponse::isClientConnected()`` yourself. Alternatively, you may pass an iterable of string chunks (such as a generator), and each chunk is written and flushed in order: @@ -264,9 +269,11 @@ Headers and Status Code Set the content type, status code, and any custom headers **before** returning the response - anything set inside the callback will be too late. Unless you -have already set them, ``StreamResponse`` applies ``X-Accel-Buffering: no`` and -``Content-Encoding: identity`` to discourage intermediaries from buffering or -compressing the stream. +have already set it, ``StreamResponse`` applies ``X-Accel-Buffering: no`` to +discourage intermediaries from buffering the stream. PHP's zlib output +compression is turned off automatically, but if your web server or CDN +compresses responses (e.g., nginx ``gzip`` or Apache ``mod_deflate``), configure +it to skip your streaming endpoints - compression can delay chunk delivery. The response is streamed: output buffering is disabled, the PHP time limit is removed, and the session is closed to avoid blocking other requests. After @@ -274,6 +281,13 @@ filters still run and may set headers, but they must not rely on the response body. View rendering and decorators are not applied - stream your output in the callback. +If :doc:`Content Security Policy ` is enabled, the +``Content-Security-Policy`` header is sent as usual. However, nonce +placeholders cannot be replaced in a streamed body. When streaming HTML that +needs a nonce, call ``$this->response->getCSP()->getScriptNonce()`` (or +``getStyleNonce()``) **before** returning the response and embed the returned +value in your output yourself. + ``StreamResponse`` does not set a ``Content-Length`` header by default, since the body length is typically unknown in advance. Without it, clients cannot display download progress or resume interrupted transfers. If you do know the @@ -369,9 +383,9 @@ Production Considerations Some server stacks and CDNs buffer or compress responses (e.g., Apache with ``mod_deflate``), which can break real-time SSE delivery. ``SSEResponse`` disables PHP output buffering, turns off zlib output -compression, and sets ``Content-Encoding: identity`` and ``X-Accel-Buffering: no``. -However, intermediaries may still buffer or compress, so configure your web server -or CDN to disable buffering/compression for SSE endpoints. +compression, and sets ``X-Accel-Buffering: no``. However, intermediaries may +still buffer or compress, so configure your web server or CDN to disable +buffering/compression for SSE endpoints. Example: Product-Oriented Use Case ---------------------------------- diff --git a/user_guide_src/source/outgoing/response/039.php b/user_guide_src/source/outgoing/response/039.php index ccbb20f4fe2e..3c5c65f77903 100644 --- a/user_guide_src/source/outgoing/response/039.php +++ b/user_guide_src/source/outgoing/response/039.php @@ -2,13 +2,24 @@ use CodeIgniter\HTTP\StreamResponse; -return $this->response->stream(static function (StreamResponse $stream) { - $stream->write("id,email\n"); +return $this->response + ->stream(static function (StreamResponse $stream) { + $userModel = model('UserModel'); + $offset = 0; - foreach (model('UserModel')->findAll() as $user) { - // write() returns false when the client disconnects - if (! $stream->write("{$user->id},{$user->email}\n")) { - break; + // Fetch rows in batches to keep memory usage low + while ($users = $userModel->findAll(500, $offset)) { + foreach ($users as $user) { + // write() returns false once the client has disconnected + if (! $stream->write(json_encode($user) . "\n", false)) { + return; + } + } + + // Push the whole batch to the client at once + $stream->flush(); + + $offset += 500; } - } -}); + }) + ->setContentType('application/x-ndjson'); diff --git a/user_guide_src/source/outgoing/response/041.php b/user_guide_src/source/outgoing/response/041.php index d9e2f2e831fc..62d7edebcc03 100644 --- a/user_guide_src/source/outgoing/response/041.php +++ b/user_guide_src/source/outgoing/response/041.php @@ -1,11 +1,18 @@ response->stream(static function (StreamResponse $stream) { - // Forward the upstream file chunk by chunk, without buffering it in memory - $source = fopen('https://storage.internal/reports/annual.pdf', 'rb'); +// Open and validate the upstream source before creating the response, +// so a failure can still produce a proper error page +$source = @fopen('https://storage.internal/reports/annual.pdf', 'rb'); + +if ($source === false) { + throw PageNotFoundException::forPageNotFound(); +} +$stream = $this->response->stream(static function (StreamResponse $stream) use ($source) { + // Forward the upstream file chunk by chunk, without buffering it in memory try { while (! feof($source)) { $chunk = fread($source, 1_048_576); From 6d5c62f8ec8e418c4b01ace493d09d0b35fbd956 Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 9 Jul 2026 06:55:56 +0200 Subject: [PATCH 09/11] preserve protocol version for stream factories --- system/HTTP/ResponseTrait.php | 4 ++-- tests/system/HTTP/SSEResponseSendTest.php | 18 ++++++++++++++++++ tests/system/HTTP/StreamResponseTest.php | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index 2cdbf0968f16..e81a80e434ba 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -820,7 +820,7 @@ public function download(string $filename = '', $data = '', bool $setMime = fals */ public function stream(callable|iterable $callbackOrChunks): StreamResponse { - return new StreamResponse($callbackOrChunks); + return (new StreamResponse($callbackOrChunks))->setProtocolVersion($this->getProtocolVersion()); } /** @@ -830,7 +830,7 @@ public function stream(callable|iterable $callbackOrChunks): StreamResponse */ public function eventStream(callable $callback): SSEResponse { - return new SSEResponse($callback); + return (new SSEResponse($callback))->setProtocolVersion($this->getProtocolVersion()); } public function getCSP(): ContentSecurityPolicy diff --git a/tests/system/HTTP/SSEResponseSendTest.php b/tests/system/HTTP/SSEResponseSendTest.php index c89d9148c14a..d96e02ba53c3 100644 --- a/tests/system/HTTP/SSEResponseSendTest.php +++ b/tests/system/HTTP/SSEResponseSendTest.php @@ -52,4 +52,22 @@ public function testSendEmitsHeadersCookiesAndStream(): void $this->assertHeaderNotEmitted('Connection: keep-alive'); } } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[WithoutErrorHandler] + public function testEventStreamFactoryPreservesHttp2ProtocolHeaders(): void + { + $response = (new Response()) + ->setProtocolVersion('2.0') + ->eventStream(static function (): void { + }); + $response->pretend(false); + + ob_start(); + $response->send(); + ob_end_clean(); + + $this->assertHeaderNotEmitted('Connection: keep-alive'); + } } diff --git a/tests/system/HTTP/StreamResponseTest.php b/tests/system/HTTP/StreamResponseTest.php index cef26db1bf51..a0c3512304ea 100644 --- a/tests/system/HTTP/StreamResponseTest.php +++ b/tests/system/HTTP/StreamResponseTest.php @@ -114,6 +114,16 @@ public function testStreamFactoryAcceptsIterable(): void $this->assertInstanceOf(StreamResponse::class, $response); } + public function testStreamFactoryCopiesProtocolVersion(): void + { + $response = (new Response()) + ->setProtocolVersion('2.0') + ->stream(static function (): void { + }); + + $this->assertSame('2.0', $response->getProtocolVersion()); + } + public function testEventStreamFactoryReturnsSSEResponse(): void { $response = (new Response())->eventStream(static function (): void { @@ -121,4 +131,14 @@ public function testEventStreamFactoryReturnsSSEResponse(): void $this->assertInstanceOf(SSEResponse::class, $response); } + + public function testEventStreamFactoryCopiesProtocolVersion(): void + { + $response = (new Response()) + ->setProtocolVersion('2.0') + ->eventStream(static function (): void { + }); + + $this->assertSame('2.0', $response->getProtocolVersion()); + } } From 20ab029fc149edc77da67187dfccea2b3bf6d53c Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 9 Jul 2026 06:58:21 +0200 Subject: [PATCH 10/11] update user guide example --- user_guide_src/source/outgoing/response/040.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/user_guide_src/source/outgoing/response/040.php b/user_guide_src/source/outgoing/response/040.php index 136e8dee60aa..74393e631b48 100644 --- a/user_guide_src/source/outgoing/response/040.php +++ b/user_guide_src/source/outgoing/response/040.php @@ -2,9 +2,8 @@ // Any iterable of string chunks works, including generators. $chunks = static function (): \Generator { - foreach (model('LogModel')->findAll() as $log) { - yield json_encode($log) . "\n"; - } + yield json_encode(['event' => 'started']) . "\n"; + yield json_encode(['event' => 'finished']) . "\n"; }; return $this->response->stream($chunks()); From d47c78aa835874d075c573e7485681d4e7740cc1 Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 9 Jul 2026 06:59:51 +0200 Subject: [PATCH 11/11] cs fix --- user_guide_src/source/outgoing/response/040.php | 1 + 1 file changed, 1 insertion(+) diff --git a/user_guide_src/source/outgoing/response/040.php b/user_guide_src/source/outgoing/response/040.php index 74393e631b48..aa643a6f4236 100644 --- a/user_guide_src/source/outgoing/response/040.php +++ b/user_guide_src/source/outgoing/response/040.php @@ -3,6 +3,7 @@ // Any iterable of string chunks works, including generators. $chunks = static function (): \Generator { yield json_encode(['event' => 'started']) . "\n"; + yield json_encode(['event' => 'finished']) . "\n"; };