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 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 <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;

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

Choose a reason for hiding this comment

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

formatMultiline() and write() appear to be natural extension
boundaries (I/O and formatting).
Would making them protected help avoid duplication or framework
forking when custom SSE behavior is needed?

Suggested change
private function formatMultiline(string $prefix, string $value): string
protected function formatMultiline(string $prefix, string $value): string

{
$value = str_replace(["\r\n", "\r"], "\n", $value);
$output = '';

foreach (explode("\n", $value) as $line) {
$output .= ($prefix !== '' ? "{$prefix}: " : ': ') . $line . "\n";
}

return $output . "\n";
}

/**
* Write raw SSE output and flush.
*/
private function write(string $output): bool
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
private function write(string $output): bool
protected function write(string $output): bool

{
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);
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('X-Accel-Buffering', 'no');
Copy link
Contributor

Choose a reason for hiding this comment

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

In some environments (e.g. Apache + mod_deflate or CDN), automatic gzip compression can break real-time SSE delivery.
Would it make sense to explicitly disable compression via Content-Encoding: identity?

Suggested change
$this->setHeader('X-Accel-Buffering', 'no');
$this->setHeader('X-Accel-Buffering', 'no');
$this->setHeader('Content-Encoding', 'identity');


// 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 <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 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