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]); 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/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index b3c0c88da326..e81a80e434ba 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) { @@ -810,6 +810,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 (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 + { + return (new StreamResponse($callbackOrChunks))->setProtocolVersion($this->getProtocolVersion()); + } + + /** + * Creates a response for streaming Server-Sent Events (SSE). + * + * @param callable(SSEResponse): void $callback + */ + public function eventStream(callable $callback): SSEResponse + { + return (new SSEResponse($callback))->setProtocolVersion($this->getProtocolVersion()); + } + public function getCSP(): ContentSecurityPolicy { $this->CSP ??= service('csp'); diff --git a/system/HTTP/SSEResponse.php b/system/HTTP/SSEResponse.php index bc86f82e82f8..fd5394ac7f86 100644 --- a/system/HTTP/SSEResponse.php +++ b/system/HTTP/SSEResponse.php @@ -13,7 +13,6 @@ namespace CodeIgniter\HTTP; -use Closure; use JsonException; /** @@ -21,16 +20,16 @@ * * @see \CodeIgniter\HTTP\SSEResponseTest */ -class SSEResponse extends Response implements NonBufferedResponseInterface +class SSEResponse extends StreamResponse { /** * Constructor. * - * @param Closure(SSEResponse): void $callback + * @param callable(SSEResponse): void $callback */ - public function __construct(private readonly Closure $callback) + public function __construct(callable $callback) { - parent::__construct(); + parent::__construct($callback); } /** @@ -42,7 +41,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 +75,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 +85,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,75 +113,33 @@ 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'); - $this->setHeader('Content-Encoding', 'identity'); $this->setHeader('X-Accel-Buffering', 'no'); // Connection: keep-alive is only valid for HTTP/1.x 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 + * CSP is not finalized for SSE responses, as Content Security + * Policy does not apply to event streams. */ - public function sendBody() + protected function shouldFinalizeCsp(): bool { - return $this; + return false; } } diff --git a/system/HTTP/StreamResponse.php b/system/HTTP/StreamResponse.php new file mode 100644 index 000000000000..4b2cdc2cdc65 --- /dev/null +++ b/system/HTTP/StreamResponse.php @@ -0,0 +1,163 @@ + + * + * 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 (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(callable|iterable $callbackOrChunks) + { + parent::__construct(); + + $this->callback = is_callable($callbackOrChunks) + ? $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(); + + // 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(); + + ($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'); + } + } + + /** + * {@inheritDoc} + * + * No-op — body is streamed via the callback, not stored. + * + * @return $this + */ + public function sendBody() + { + return $this; + } +} 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/StreamResponseSendTest.php b/tests/system/HTTP/StreamResponseSendTest.php new file mode 100644 index 000000000000..abccd153b833 --- /dev/null +++ b/tests/system/HTTP/StreamResponseSendTest.php @@ -0,0 +1,171 @@ + + * + * 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 Config\App; +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->assertHeaderNotEmitted('Content-Encoding:'); + $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()); + } + + /** + * 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/tests/system/HTTP/StreamResponseTest.php b/tests/system/HTTP/StreamResponseTest.php new file mode 100644 index 000000000000..a0c3512304ea --- /dev/null +++ b/tests/system/HTTP/StreamResponseTest.php @@ -0,0 +1,144 @@ + + * + * 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 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 { + }); + + $this->assertInstanceOf(StreamResponse::class, $response); + $this->assertNotInstanceOf(SSEResponse::class, $response); + } + + public function testStreamFactoryAcceptsIterable(): void + { + /** @var iterable $chunks */ + $chunks = ['a', 'b']; + $response = (new Response())->stream($chunks); + + $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 { + }); + + $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()); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 031d5e0b3a5a..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. @@ -310,7 +311,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..f01c3432d2a2 100644 --- a/user_guide_src/source/outgoing/response.rst +++ b/user_guide_src/source/outgoing/response.rst @@ -231,6 +231,86 @@ 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 - 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: + +.. 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 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 +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 +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. + 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. + .. _server-sent-events: Server-Sent Events (SSE) @@ -241,7 +321,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 +350,8 @@ use comments for keep-alive and configure the client retry interval: .. literalinclude:: response/037.php +.. _sse-development-server: + Development Server Limitations ------------------------------ @@ -289,15 +375,17 @@ 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 ------------------------- 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/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..3c5c65f77903 --- /dev/null +++ b/user_guide_src/source/outgoing/response/039.php @@ -0,0 +1,25 @@ +response + ->stream(static function (StreamResponse $stream) { + $userModel = model('UserModel'); + $offset = 0; + + // 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/040.php b/user_guide_src/source/outgoing/response/040.php new file mode 100644 index 000000000000..aa643a6f4236 --- /dev/null +++ b/user_guide_src/source/outgoing/response/040.php @@ -0,0 +1,10 @@ + 'started']) . "\n"; + + yield json_encode(['event' => 'finished']) . "\n"; +}; + +return $this->response->stream($chunks()); 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..62d7edebcc03 --- /dev/null +++ b/user_guide_src/source/outgoing/response/041.php @@ -0,0 +1,31 @@ +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); + + if ($chunk === false || ! $stream->write($chunk)) { + break; + } + } + } finally { + fclose($source); + } +}); + +$stream->setContentType('application/pdf'); + +return $stream;