Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 deptrac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -281,5 +281,7 @@ deptrac:
- CodeIgniter\Pager\PagerInterface
CodeIgniter\HTTP\DownloadResponse:
- CodeIgniter\Pager\PagerInterface
CodeIgniter\HTTP\SSEResponse:
- CodeIgniter\Pager\PagerInterface
CodeIgniter\Validation\Validation:
- CodeIgniter\View\RendererInterface
8 changes: 4 additions & 4 deletions system/CodeIgniter.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\Filters\Filters;
use CodeIgniter\HTTP\CLIRequest;
use CodeIgniter\HTTP\DownloadResponse;
use CodeIgniter\HTTP\Exceptions\RedirectException;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Method;
use CodeIgniter\HTTP\NonBufferedResponseInterface;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\Request;
use CodeIgniter\HTTP\RequestInterface;
Expand Down Expand Up @@ -529,7 +529,7 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache

// Skip unnecessary processing for special Responses.
if (
! $this->response instanceof DownloadResponse
! $this->response instanceof NonBufferedResponseInterface
&& ! $this->response instanceof RedirectResponse
) {
// Save our current URI as the previous URI in the session
Expand Down Expand Up @@ -1018,7 +1018,7 @@ protected function gatherOutput(?Cache $cacheConfig = null, $returned = null)
{
$this->output = $this->outputBufferingEnd();

if ($returned instanceof DownloadResponse) {
if ($returned instanceof NonBufferedResponseInterface) {
$this->response = $returned;

return;
Expand Down Expand Up @@ -1064,7 +1064,7 @@ public function storePreviousURL($uri)
}

// Ignore unroutable responses
if ($this->response instanceof DownloadResponse || $this->response instanceof RedirectResponse) {
if ($this->response instanceof NonBufferedResponseInterface || $this->response instanceof RedirectResponse) {
return;
}

Expand Down
6 changes: 3 additions & 3 deletions system/Debug/Toolbar.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
use CodeIgniter\Debug\Toolbar\Collectors\History;
use CodeIgniter\Format\JSONFormatter;
use CodeIgniter\Format\XMLFormatter;
use CodeIgniter\HTTP\DownloadResponse;
use CodeIgniter\HTTP\Header;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\NonBufferedResponseInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\I18n\Time;
Expand Down Expand Up @@ -382,8 +382,8 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r
/** @var ResponseInterface $response */
$response ??= service('response');

// Disable the toolbar for downloads
if ($response instanceof DownloadResponse) {
// Disable the toolbar for non-buffered responses (downloads, SSE)
if ($response instanceof NonBufferedResponseInterface) {
return;
}

Expand Down
4 changes: 2 additions & 2 deletions system/Filters/PageCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@

use CodeIgniter\Cache\ResponseCache;
use CodeIgniter\HTTP\CLIRequest;
use CodeIgniter\HTTP\DownloadResponse;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\NonBufferedResponseInterface;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
Expand Down Expand Up @@ -68,7 +68,7 @@ public function after(RequestInterface $request, ResponseInterface $response, $a
assert($request instanceof CLIRequest || $request instanceof IncomingRequest);

if (
! $response instanceof DownloadResponse
! $response instanceof NonBufferedResponseInterface
&& ! $response instanceof RedirectResponse
&& ($this->cacheStatusCodes === [] || in_array($response->getStatusCode(), $this->cacheStatusCodes, true))
) {
Expand Down
2 changes: 1 addition & 1 deletion system/HTTP/DownloadResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
*
* @see \CodeIgniter\HTTP\DownloadResponseTest
*/
class DownloadResponse extends Response
class DownloadResponse extends Response implements NonBufferedResponseInterface
{
/**
* Download file name
Expand Down
22 changes: 22 additions & 0 deletions system/HTTP/NonBufferedResponseInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <[email protected]>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\HTTP;

/**
* Marker interface for responses that bypass output buffering
* and send their body directly to the client (e.g. downloads, SSE streams).
*/
interface NonBufferedResponseInterface
{
}
205 changes: 205 additions & 0 deletions system/HTTP/SSEResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <[email protected]>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\HTTP;

use Closure;
use Config\App;
use JsonException;

/**
* HTTP response for Server-Sent Events (SSE) streaming.
*
* @see \CodeIgniter\HTTP\SSEResponseTest
*/
class SSEResponse extends Response implements NonBufferedResponseInterface
{
/**
* Constructor.
*
* @param Closure(SSEResponse): void $callback
*/
public function __construct(private readonly Closure $callback)
{
parent::__construct(config(App::class));
}

/**
* Send an SSE event to the client.
*
* @param array<string, mixed>|string $data Event data (arrays are JSON-encoded)
* @param string|null $event Event type
* @param string|null $id Event ID
*/
public function event(array|string $data, ?string $event = null, ?string $id = null): bool
{
if ($this->isConnectionAborted()) {
return false;
}

$output = '';

if ($event !== null) {
$output .= 'event: ' . $this->sanitizeLine($event) . "\n";
}

if ($id !== null) {
$output .= 'id: ' . $this->sanitizeLine($id) . "\n";
}

if (is_array($data)) {
try {
$data = json_encode($data, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
log_message('error', 'SSE JSON encode failed: {message}', ['message' => $e->getMessage()]);

return false;
}
}

$output .= $this->formatMultiline('data', $data);

return $this->write($output);
}

/**
* Send an SSE comment (useful for keep-alive).
*/
public function comment(string $text): bool
{
if ($this->isConnectionAborted()) {
return false;
}

return $this->write($this->formatMultiline('', $text));
}

/**
* Set the client reconnection interval.
*
* @param int $milliseconds Retry interval in milliseconds
*/
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).
*/
private function sanitizeLine(string $value): string
{
return str_replace(["\r\n", "\r", "\n"], '', $value);
}

/**
* Format a value as prefixed SSE lines, normalizing line endings.
*
* Each line becomes "{prefix}: {line}\n", terminated by an extra "\n".
*/
private function formatMultiline(string $prefix, string $value): string
Comment thread
paulbalandan marked this conversation as resolved.
{
$value = str_replace(["\r\n", "\r"], "\n", $value);
$output = '';

foreach (explode("\n", $value) as $line) {
$output .= ($prefix !== '' ? "{$prefix}: " : ': ') . $line . "\n";
Comment thread
michalsn marked this conversation as resolved.
Outdated
}

return $output . "\n";
}

/**
* Write raw SSE output and flush.
*/
private function write(string $output): bool
Comment thread
paulbalandan marked this conversation as resolved.
{
echo $output;

if (ENVIRONMENT !== 'testing') {
if (ob_get_level() > 0) {
ob_flush();
}

flush();
}

return true;
}

/**
* {@inheritDoc}
*
* @return $this
*/
public function send()
{
// Turn off output buffering completely, even if php.ini output_buffering is not off
if (ENVIRONMENT !== 'testing') {
set_time_limit(0);
Comment thread
paulbalandan marked this conversation as resolved.
ini_set('zlib.output_compression', 'Off');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (ini_get('zlib.output_compression')) {

Is it not disabled by default?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that app/Config/Events.php is user-configurable, and someone could completely remove thezlib.output_compression check. That's why it's safer to keep it here. The cost is negligible.


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');
Comment thread
michalsn marked this conversation as resolved.

// 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
*/
public function sendBody()
{
return $this;
}
}
55 changes: 55 additions & 0 deletions tests/system/HTTP/SSEResponseSendTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <[email protected]>
*
* 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;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\Attributes\WithoutErrorHandler;

/**
* @internal
*/
#[Group('SeparateProcess')]
final class SSEResponseSendTest extends CIUnitTestCase
{
#[PreserveGlobalState(false)]
#[RunInSeparateProcess]
#[WithoutErrorHandler]
public function testSendEmitsHeadersCookiesAndStream(): void
{
$response = new SSEResponse(static function (SSEResponse $sse): void {
$sse->event('hello');
});
$response->pretend(false);
$response->setCookie('foo', 'bar');

ob_start();
$response->send();
$output = ob_get_clean();

$this->assertSame("data: hello\n\n", $output);
$this->assertHeaderEmitted('Content-Type: text/event-stream; charset=UTF-8');
$this->assertHeaderEmitted('Cache-Control: no-cache');
$this->assertHeaderEmitted('X-Accel-Buffering: no');
$this->assertHeaderEmitted('Set-Cookie: foo=bar;');

if (version_compare($response->getProtocolVersion(), '2.0', '<')) {
$this->assertHeaderEmitted('Connection: keep-alive');
} else {
$this->assertHeaderNotEmitted('Connection: keep-alive');
}
}
}
Loading
Loading