From f26de3e5a6ed88e2a139df885ae35656a0d45fd5 Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 6 Feb 2026 20:40:24 +0100 Subject: [PATCH 1/4] feat: SSEResponse class for streaming Server-Side Events --- system/CodeIgniter.php | 8 +- system/Debug/Toolbar.php | 6 +- system/Filters/PageCache.php | 4 +- system/HTTP/DownloadResponse.php | 2 +- system/HTTP/NonBufferedResponseInterface.php | 22 ++ system/HTTP/SSEResponse.php | 204 ++++++++++++++++++ tests/system/HTTP/SSEResponseSendTest.php | 55 +++++ tests/system/HTTP/SSEResponseTest.php | 178 +++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 2 + user_guide_src/source/outgoing/response.rst | 35 +++ .../source/outgoing/response/036.php | 15 ++ .../source/outgoing/response/037.php | 21 ++ 12 files changed, 542 insertions(+), 10 deletions(-) create mode 100644 system/HTTP/NonBufferedResponseInterface.php create mode 100644 system/HTTP/SSEResponse.php create mode 100644 tests/system/HTTP/SSEResponseSendTest.php create mode 100644 tests/system/HTTP/SSEResponseTest.php create mode 100644 user_guide_src/source/outgoing/response/036.php create mode 100644 user_guide_src/source/outgoing/response/037.php diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index d59ebd61c5c8..39bd5f42843f 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -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; @@ -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 @@ -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; @@ -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; } diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index c35003129764..fff9662d3e68 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -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; @@ -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; } diff --git a/system/Filters/PageCache.php b/system/Filters/PageCache.php index c170e0d46cac..f41569ccd6be 100644 --- a/system/Filters/PageCache.php +++ b/system/Filters/PageCache.php @@ -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; @@ -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)) ) { diff --git a/system/HTTP/DownloadResponse.php b/system/HTTP/DownloadResponse.php index 5e1816e3afe1..4d0850ecc4d1 100644 --- a/system/HTTP/DownloadResponse.php +++ b/system/HTTP/DownloadResponse.php @@ -23,7 +23,7 @@ * * @see \CodeIgniter\HTTP\DownloadResponseTest */ -class DownloadResponse extends Response +class DownloadResponse extends Response implements NonBufferedResponseInterface { /** * Download file name diff --git a/system/HTTP/NonBufferedResponseInterface.php b/system/HTTP/NonBufferedResponseInterface.php new file mode 100644 index 000000000000..06f70ce4dd2a --- /dev/null +++ b/system/HTTP/NonBufferedResponseInterface.php @@ -0,0 +1,22 @@ + + * + * 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 +{ +} diff --git a/system/HTTP/SSEResponse.php b/system/HTTP/SSEResponse.php new file mode 100644 index 000000000000..31f5c8175c7d --- /dev/null +++ b/system/HTTP/SSEResponse.php @@ -0,0 +1,204 @@ + + * + * 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 $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 + { + $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 + { + 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'); + + // 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; + } +} diff --git a/tests/system/HTTP/SSEResponseSendTest.php b/tests/system/HTTP/SSEResponseSendTest.php new file mode 100644 index 000000000000..c89d9148c14a --- /dev/null +++ b/tests/system/HTTP/SSEResponseSendTest.php @@ -0,0 +1,55 @@ + + * + * 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'); + } + } +} diff --git a/tests/system/HTTP/SSEResponseTest.php b/tests/system/HTTP/SSEResponseTest.php new file mode 100644 index 000000000000..b35eea3c4cd2 --- /dev/null +++ b/tests/system/HTTP/SSEResponseTest.php @@ -0,0 +1,178 @@ + + * + * 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; + +/** + * @internal + */ +final class SSEResponseTest extends CIUnitTestCase +{ + public function testEventFormatsLinesAndSanitizesFields(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $result = $response->event("line1\nline2", "up\ndate", "1\n2"); + $output = ob_get_clean(); + + $this->assertTrue($result); + $this->assertSame( + "event: update\nid: 12\ndata: line1\ndata: line2\n\n", + $output, + ); + } + + public function testCommentFormatsAsSseCommentLines(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $result = $response->comment("keep\nalive"); + $output = ob_get_clean(); + + $this->assertTrue($result); + $this->assertSame(": keep\n: alive\n\n", $output); + } + + public function testRetryFormatsRetryField(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $result = $response->retry(1500); + $output = ob_get_clean(); + + $this->assertTrue($result); + $this->assertSame("retry: 1500\n\n", $output); + } + + public function testEventReturnsFalseOnJsonEncodeFailure(): void + { + $response = new SSEResponse(static function (): void { + }); + + $data = [ + 'bad' => "\xB1\x31", + ]; + + ob_start(); + $result = $response->event($data); + $output = ob_get_clean(); + + $this->assertFalse($result); + $this->assertSame('', $output); + } + + public function testEventWithStringDataOnly(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event('hello'); + $output = ob_get_clean(); + + $this->assertSame("data: hello\n\n", $output); + } + + public function testEventWithArrayDataJsonEncodes(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event(['key' => 'value']); + $output = ob_get_clean(); + + $this->assertSame("data: {\"key\":\"value\"}\n\n", $output); + } + + public function testEventWithEventNameOnly(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event('data', 'update'); + $output = ob_get_clean(); + + $this->assertSame("event: update\ndata: data\n\n", $output); + } + + public function testEventWithIdOnly(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event('data', null, '42'); + $output = ob_get_clean(); + + $this->assertSame("id: 42\ndata: data\n\n", $output); + } + + public function testEventNormalizesCarriageReturnLineFeed(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event("a\r\nb"); + $output = ob_get_clean(); + + $this->assertSame("data: a\ndata: b\n\n", $output); + } + + public function testEventNormalizesCarriageReturn(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event("a\rb"); + $output = ob_get_clean(); + + $this->assertSame("data: a\ndata: b\n\n", $output); + } + + public function testCommentSingleLine(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->comment('hello'); + $output = ob_get_clean(); + + $this->assertSame(": hello\n\n", $output); + } + + public function testSendBodyIsNoOp(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $result = $response->sendBody(); + $output = ob_get_clean(); + + $this->assertSame($response, $result); + $this->assertSame('', $output); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 3237a4a2116f..026744fbe8f1 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -65,6 +65,8 @@ Helpers and Functions Others ====== +- Added ``SSEResponse`` class for streaming Server-Sent Events (SSE) over HTTP. See :ref:`server-sent-events`. + *************** Message Changes *************** diff --git a/user_guide_src/source/outgoing/response.rst b/user_guide_src/source/outgoing/response.rst index 034eca9289b6..6174514dfead 100644 --- a/user_guide_src/source/outgoing/response.rst +++ b/user_guide_src/source/outgoing/response.rst @@ -231,6 +231,41 @@ Some browsers can display files such as PDF. To tell the browser to display the .. literalinclude:: response/033.php +.. _server-sent-events: + +Server-Sent Events (SSE) +======================== + +.. versionadded:: 4.8.0 + +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. + +.. literalinclude:: response/036.php + +The callback receives the ``SSEResponse`` instance. Use ``event()``, +``comment()``, or ``retry()`` to send SSE fields. If you pass an array to +``event()``, it will be JSON-encoded. If encoding fails or the client +disconnects, ``event()`` returns ``false``. + +The response is streamed: output buffering is disabled, and the session is closed +to avoid blocking other requests. Headers must be set **before** returning the +response because anything set inside the callback will be too late. + +After filters still run. Any headers or cookies they set will be sent, but they +must not rely on the response body. View rendering, and decorators are not applied +because the response body is not built - stream your output in the callback. + +Custom Headers and Keep-Alive +----------------------------- + +If you need custom headers, set them before returning the response. You can also +use comments for keep-alive and configure the client retry interval: + +.. literalinclude:: response/037.php + HTTP Caching ============ diff --git a/user_guide_src/source/outgoing/response/036.php b/user_guide_src/source/outgoing/response/036.php new file mode 100644 index 000000000000..9308fef80ce2 --- /dev/null +++ b/user_guide_src/source/outgoing/response/036.php @@ -0,0 +1,15 @@ +event(['text' => $text])) { + break; + } + + sleep(1); + } + + $sse->event('[DONE]'); +}); diff --git a/user_guide_src/source/outgoing/response/037.php b/user_guide_src/source/outgoing/response/037.php new file mode 100644 index 000000000000..96bc40537ca3 --- /dev/null +++ b/user_guide_src/source/outgoing/response/037.php @@ -0,0 +1,21 @@ +comment('keep-alive'); + + foreach (['one', 'two', 'three', 'four'] as $text) { + if (! $sse->event(['text' => $text])) { + break; + } + } + + sleep(1); + + $sse->retry(5000); +}); + +$sse->setHeader('X-Stream-Name', 'demo'); + +return $sse; From 4a4f9c8dab57754e95ca1a7da724a201f120fd1b Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 6 Feb 2026 21:06:01 +0100 Subject: [PATCH 2/4] update deptrac --- deptrac.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deptrac.yaml b/deptrac.yaml index 4f7d8367bae6..a5bb70811e4c 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -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 From 9aea7ded0467ca3754567939464a34615b99525f Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 6 Feb 2026 21:10:02 +0100 Subject: [PATCH 3/4] update test --- tests/system/HTTP/SSEResponseTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/system/HTTP/SSEResponseTest.php b/tests/system/HTTP/SSEResponseTest.php index b35eea3c4cd2..193cf6ffae57 100644 --- a/tests/system/HTTP/SSEResponseTest.php +++ b/tests/system/HTTP/SSEResponseTest.php @@ -14,10 +14,12 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; /** * @internal */ +#[Group('SeparateProcess')] final class SSEResponseTest extends CIUnitTestCase { public function testEventFormatsLinesAndSanitizesFields(): void From d7085f40facd1f3bae725a64d3c64cf709d597d6 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 7 Feb 2026 19:49:35 +0100 Subject: [PATCH 4/4] apply code suggestions Co-authored-by: Pooya Parsa --- system/HTTP/SSEResponse.php | 1 + user_guide_src/source/outgoing/response.rst | 18 ++++++++ .../source/outgoing/response/038.php | 44 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 user_guide_src/source/outgoing/response/038.php diff --git a/system/HTTP/SSEResponse.php b/system/HTTP/SSEResponse.php index 31f5c8175c7d..ec8ea5974d26 100644 --- a/system/HTTP/SSEResponse.php +++ b/system/HTTP/SSEResponse.php @@ -174,6 +174,7 @@ public function send() $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 diff --git a/user_guide_src/source/outgoing/response.rst b/user_guide_src/source/outgoing/response.rst index 6174514dfead..042ca9c4c9f9 100644 --- a/user_guide_src/source/outgoing/response.rst +++ b/user_guide_src/source/outgoing/response.rst @@ -266,6 +266,24 @@ use comments for keep-alive and configure the client retry interval: .. literalinclude:: response/037.php +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. + +Example: Product-Oriented Use Case +---------------------------------- + +The following example simulates a small notification stream to illustrate a more +product-focused use case: + +.. literalinclude:: response/038.php + HTTP Caching ============ diff --git a/user_guide_src/source/outgoing/response/038.php b/user_guide_src/source/outgoing/response/038.php new file mode 100644 index 000000000000..25f0370d28cf --- /dev/null +++ b/user_guide_src/source/outgoing/response/038.php @@ -0,0 +1,44 @@ +get('user_id'); + +return new SSEResponse(static function (SSEResponse $sse) use ($user_id) { + // Stream live notifications for the current user + $notificationModel = model(NotificationModel::class); + + $lastId = 0; + + // In a real app, you would typically keep the connection open indefinitely + for ($i = 0; $i < 6; $i++) { + $order = $lastId === 0 ? 'desc' : 'asc'; + + // On the first pass, pick the newest notification + // After that, stream any newer ones in order + $notification = $notificationModel->where('user_id', $user_id) + ->where('id >', $lastId) + ->orderBy('id', $order) + ->first(); + + if ($notification !== null) { + $lastId = (int) $notification['id']; + + if (! $sse->event($notification, 'notification', (string) $lastId)) { + break; + } + } else { + // No new notifications yet: send a keep-alive comment + if (! $sse->comment('keep-alive')) { + break; + } + } + + // Poll every 10 seconds + sleep(10); + } + + // Ask the browser to retry in 60 seconds if the connection closes + $sse->retry(60000); +});