Skip to content
Merged
2 changes: 2 additions & 0 deletions structarmed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
17 changes: 17 additions & 0 deletions system/HTTP/ResponseInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> $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
// --------------------------------------------------------------------
Expand Down
25 changes: 24 additions & 1 deletion system/HTTP/ResponseTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string> $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());
}
Comment thread
michalsn marked this conversation as resolved.

public function getCSP(): ContentSecurityPolicy
{
$this->CSP ??= service('csp');
Expand Down
83 changes: 12 additions & 71 deletions system/HTTP/SSEResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,23 @@

namespace CodeIgniter\HTTP;

use Closure;
use JsonException;

/**
* HTTP response for Server-Sent Events (SSE) streaming.
*
* @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);
}

/**
Expand All @@ -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;
}

Expand Down Expand Up @@ -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));
}

Expand All @@ -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).
*/
Expand All @@ -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;
}
}
163 changes: 163 additions & 0 deletions system/HTTP/StreamResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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<string> $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;
}
}
Loading
Loading