From 2a2f95c7cae085caf0012024c0fe1eb2ae53bb95 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 7 Jul 2026 13:32:24 +0200 Subject: [PATCH 01/14] 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 b03c3c4f3b23..db00bbeb52c9 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -314,7 +314,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 5a368d5ae9d6c78606ba1b5162fc7eab1e7d551b Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 7 Jul 2026 13:59:08 +0200 Subject: [PATCH 02/14] 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 1ed65fd407e866ab9287fee06f42e8d6b8ae29e5 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 7 Jul 2026 14:13:53 +0200 Subject: [PATCH 03/14] 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 db00bbeb52c9..013625054260 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -53,6 +53,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 27de4c0a77d5281268f49530b5d097606758f87b Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 7 Jul 2026 14:38:13 +0200 Subject: [PATCH 04/14] 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 3a1a7634717c11348bd770b8a35b038a01bafe0a Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 7 Jul 2026 14:40:27 +0200 Subject: [PATCH 05/14] 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 cc79e0a39131d410c979b11470f26db0c6a92a7b Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 8 Jul 2026 18:19:47 +0200 Subject: [PATCH 06/14] 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 9f7be73c3f6756cdb7166faa5c399b1e9ec84801 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 8 Jul 2026 18:24:51 +0200 Subject: [PATCH 07/14] 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 7252df2a1bffd8b415c419ac528e37678a3a5950 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 8 Jul 2026 21:51:37 +0200 Subject: [PATCH 08/14] 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 4bb577cc6eaa029caeabc301280ccf993a1a0f60 Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 9 Jul 2026 06:55:56 +0200 Subject: [PATCH 09/14] 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 539faf80c77e33fb9d62498d006189bcd9f5f029 Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 9 Jul 2026 06:58:21 +0200 Subject: [PATCH 10/14] 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 365f16caa0045c342af83c3219c711f18bf5fe1a Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 9 Jul 2026 06:59:51 +0200 Subject: [PATCH 11/14] 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"; }; From d32f25a110af1d4da8cf69ee9346fc8912ce8366 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 19 Jul 2026 19:26:53 +0200 Subject: [PATCH 12/14] fix test for callable-iterable --- tests/system/HTTP/StreamResponseTest.php | 25 +++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/system/HTTP/StreamResponseTest.php b/tests/system/HTTP/StreamResponseTest.php index a0c3512304ea..50c0b78c32df 100644 --- a/tests/system/HTTP/StreamResponseTest.php +++ b/tests/system/HTTP/StreamResponseTest.php @@ -14,6 +14,8 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Test\CIUnitTestCase; +use Generator; +use IteratorAggregate; use PHPUnit\Framework\Attributes\Group; /** @@ -80,8 +82,22 @@ public function testStatusCodeIsSettable(): void public function testConstructorTreatsCallableIterableAsCallable(): void { - // An array callable is both callable and iterable; callable must win. - $response = new StreamResponse($this->writeHello(...)); + $callbackOrChunks = new class () implements IteratorAggregate { + public function __invoke(StreamResponse $stream): void + { + $stream->write('Hello'); + } + + /** + * @return Generator + */ + public function getIterator(): Generator + { + yield 'iterable chunk'; + } + }; + + $response = new StreamResponse($callbackOrChunks); $response->pretend(); ob_start(); @@ -91,11 +107,6 @@ public function testConstructorTreatsCallableIterableAsCallable(): void $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 4f6b1aa4d46fa6bf0262a6f663ed19add5eb8da5 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 19 Jul 2026 19:32:16 +0200 Subject: [PATCH 13/14] add note to the user guide about stream() and eventStream() --- user_guide_src/source/outgoing/response.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/user_guide_src/source/outgoing/response.rst b/user_guide_src/source/outgoing/response.rst index f01c3432d2a2..f68091a30021 100644 --- a/user_guide_src/source/outgoing/response.rst +++ b/user_guide_src/source/outgoing/response.rst @@ -275,6 +275,11 @@ 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. +.. note:: ``stream()`` and ``eventStream()`` create a new response instance. + Headers, cookies, and status codes already set on the main response are not + copied. Set them on the returned ``StreamResponse`` or ``SSEResponse`` + instance before returning it. + 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 From 7d83b33898568e31f5468b328cd683eec72d593a Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 19 Jul 2026 19:40:51 +0200 Subject: [PATCH 14/14] fix Psalm --- tests/system/HTTP/StreamResponseTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/system/HTTP/StreamResponseTest.php b/tests/system/HTTP/StreamResponseTest.php index 50c0b78c32df..2135259202b6 100644 --- a/tests/system/HTTP/StreamResponseTest.php +++ b/tests/system/HTTP/StreamResponseTest.php @@ -82,7 +82,11 @@ public function testStatusCodeIsSettable(): void public function testConstructorTreatsCallableIterableAsCallable(): void { - $callbackOrChunks = new class () implements IteratorAggregate { + $callbackOrChunks = new + /** + * @implements IteratorAggregate + */ + class () implements IteratorAggregate { public function __invoke(StreamResponse $stream): void { $stream->write('Hello');