Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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());
}

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