From 740f57bdd64bc8208b3db28958caaed7916821c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederic=20G=2E=20=C3=98stby?= Date: Thu, 19 Feb 2026 00:31:34 +0100 Subject: [PATCH 1/4] WIP --- .../http/response/senders/EventStream.php | 113 ++++++++++++++++++ .../response/senders/stream/event/Event.php | 29 +++++ .../response/senders/stream/event/Field.php | 23 ++++ .../response/senders/stream/event/Type.php | 20 ++++ 4 files changed, 185 insertions(+) create mode 100644 src/mako/http/response/senders/EventStream.php create mode 100644 src/mako/http/response/senders/stream/event/Event.php create mode 100644 src/mako/http/response/senders/stream/event/Field.php create mode 100644 src/mako/http/response/senders/stream/event/Type.php diff --git a/src/mako/http/response/senders/EventStream.php b/src/mako/http/response/senders/EventStream.php new file mode 100644 index 000000000..f95758245 --- /dev/null +++ b/src/mako/http/response/senders/EventStream.php @@ -0,0 +1,113 @@ +fields as $field) { + $output .= "{$field->type->value}: {$this->stringifyValue($field->value)}\n"; + } + + $output .= "\n"; + + echo $output; + + flush(); + } + + /** + * Sends the stream to the client. + */ + protected function sendStream(): void + { + foreach ((fn (): Generator => ($this->stream)())() as $event) { + if (connection_aborted()) { + break; + } + + $this->sendEvent($event); + } + } + + /** + * {@inheritDoc} + */ + #[Override] + public function send(Request $request, Response $response): void + { + $response->setType('text/event-stream', 'UTF-8'); + + $response->headers->add('Connection', 'keep-alive'); + $response->headers->add('Cache-Control', 'no-cache'); + $response->headers->add('X-Accel-Buffering', 'no'); + + // Erase output buffers and disable output buffering + + while (ob_get_level() > 0) { + ob_end_clean(); + } + + // Send headers + + $response->sendHeaders(); + + // Send the stream + + $this->sendStream(); + } +} diff --git a/src/mako/http/response/senders/stream/event/Event.php b/src/mako/http/response/senders/stream/event/Event.php new file mode 100644 index 000000000..b27fdbb99 --- /dev/null +++ b/src/mako/http/response/senders/stream/event/Event.php @@ -0,0 +1,29 @@ +fields = $fields; + } +} diff --git a/src/mako/http/response/senders/stream/event/Field.php b/src/mako/http/response/senders/stream/event/Field.php new file mode 100644 index 000000000..d9edc1703 --- /dev/null +++ b/src/mako/http/response/senders/stream/event/Field.php @@ -0,0 +1,23 @@ + Date: Thu, 19 Feb 2026 00:47:57 +0100 Subject: [PATCH 2/4] Update EventStream.php --- .../http/response/senders/EventStream.php | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/mako/http/response/senders/EventStream.php b/src/mako/http/response/senders/EventStream.php index f95758245..2c0ffe454 100644 --- a/src/mako/http/response/senders/EventStream.php +++ b/src/mako/http/response/senders/EventStream.php @@ -38,6 +38,16 @@ public function __construct( ) { } + /** + * Erases and disables output buffers. + */ + protected function eraseAndDisableOutputBuffers(): void + { + while (ob_get_level() > 0) { + ob_end_clean(); + } + } + /** * Stringifies the value. */ @@ -53,9 +63,9 @@ protected function stringifyValue(null|float|int|JsonSerializable|string|Stringa } /** - * Sends the event to the client. + * Prepares the event for sending. */ - protected function sendEvent(Event $event): void + protected function prepareEvent(Event $event): string { $output = ''; @@ -65,7 +75,15 @@ protected function sendEvent(Event $event): void $output .= "\n"; - echo $output; + return $output; + } + + /** + * Sends the event to the client. + */ + protected function sendEvent(string $event): void + { + echo $event; flush(); } @@ -80,7 +98,7 @@ protected function sendStream(): void break; } - $this->sendEvent($event); + $this->sendEvent($this->prepareEvent($event)); } } @@ -98,9 +116,7 @@ public function send(Request $request, Response $response): void // Erase output buffers and disable output buffering - while (ob_get_level() > 0) { - ob_end_clean(); - } + $this->eraseAndDisableOutputBuffers(); // Send headers From e29ee3c24094d21921eefe335e765e9a14f0cd09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederic=20G=2E=20=C3=98stby?= Date: Thu, 19 Feb 2026 00:52:44 +0100 Subject: [PATCH 3/4] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93bb4d293..53fab8d77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - `MySQL` - `Postgres` * Now possible to define custom input/output value objects for the query builder. +* Added a `EventStream` response sender to simplify sending [sever-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events). -------------------------------------------------------- From c788bb7abac98c9cbba3fcb5a7240dd3301c1e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederic=20G=2E=20=C3=98stby?= Date: Thu, 19 Feb 2026 10:39:39 +0100 Subject: [PATCH 4/4] Added tests --- .../http/response/senders/EventStreamTest.php | 174 ++++++++++++++++++ .../senders/stream/event/EventTest.php | 35 ++++ .../senders/stream/event/FieldTest.php | 28 +++ 3 files changed, 237 insertions(+) create mode 100644 tests/unit/http/response/senders/EventStreamTest.php create mode 100644 tests/unit/http/response/senders/stream/event/EventTest.php create mode 100644 tests/unit/http/response/senders/stream/event/FieldTest.php diff --git a/tests/unit/http/response/senders/EventStreamTest.php b/tests/unit/http/response/senders/EventStreamTest.php new file mode 100644 index 000000000..d9f2653fa --- /dev/null +++ b/tests/unit/http/response/senders/EventStreamTest.php @@ -0,0 +1,174 @@ +shouldReceive('add')->once()->with('Connection', 'keep-alive'); + $headers->shouldReceive('add')->once()->with('Cache-Control', 'no-cache'); + $headers->shouldReceive('add')->once()->with('X-Accel-Buffering', 'no'); + + $response = Mockery::mock(Response::class); + + $response->shouldReceive('setType')->once()->with('text/event-stream', 'UTF-8'); + $response->shouldReceive('sendHeaders')->once(); + + (function () use ($headers): void { + $this->headers = $headers; + })->bindTo($response, Response::class)(); + + return $response; + } + + /** + * + */ + public function testBasicEventStream(): void + { + $eventStream = Mockery::mock(EventStream::class, [function () { + yield new Event( + new Field(Type::DATA, 'hello, world!') + ); + }]); + + $eventStream->makePartial()->shouldAllowMockingProtectedMethods(); + + $eventStream->shouldReceive('eraseAndDisableOutputBuffers')->once(); + + $eventStream->shouldReceive('sendEvent')->once()->with("data: hello, world!\n\n"); + + $eventStream->send($this->getRequest(), $this->getResponse()); + } + + /** + * + */ + public function testEventStreamWithMultipleFields(): void + { + $eventStream = Mockery::mock(EventStream::class, [function () { + yield new Event( + new Field(Type::EVENT, 'greeting'), + new Field(Type::DATA, 'hello, world!') + ); + }]); + + $eventStream->makePartial()->shouldAllowMockingProtectedMethods(); + + $eventStream->shouldReceive('eraseAndDisableOutputBuffers')->once(); + + $eventStream->shouldReceive('sendEvent')->once()->with("event: greeting\ndata: hello, world!\n\n"); + + $eventStream->send($this->getRequest(), $this->getResponse()); + } + + /** + * + */ + public function testEventStreamWithMultipleEvents(): void + { + $eventStream = Mockery::mock(EventStream::class, [function () { + yield new Event( + new Field(Type::EVENT, 'greeting'), + new Field(Type::DATA, 'first hello') + ); + yield new Event( + new Field(Type::EVENT, 'greeting'), + new Field(Type::DATA, 'second hello') + ); + }]); + + $eventStream->makePartial()->shouldAllowMockingProtectedMethods(); + + $eventStream->shouldReceive('eraseAndDisableOutputBuffers')->once(); + + $eventStream->shouldReceive('sendEvent')->once()->with("event: greeting\ndata: first hello\n\n"); + $eventStream->shouldReceive('sendEvent')->once()->with("event: greeting\ndata: second hello\n\n"); + + $eventStream->send($this->getRequest(), $this->getResponse()); + } + + /** + * + */ + public function testEventStreamWithStringable(): void + { + $eventStream = Mockery::mock(EventStream::class, [function () { + yield new Event( + new Field(Type::DATA, new class implements Stringable { + public function __toString(): string + { + return 'this is a string'; + } + }) + ); + }]); + + $eventStream->makePartial()->shouldAllowMockingProtectedMethods(); + + $eventStream->shouldReceive('eraseAndDisableOutputBuffers')->once(); + + $eventStream->shouldReceive('sendEvent')->once()->with("data: this is a string\n\n"); + + $eventStream->send($this->getRequest(), $this->getResponse()); + } + + /** + * + */ + public function testEventStreamWithJsonSerializable(): void + { + $eventStream = Mockery::mock(EventStream::class, [function () { + yield new Event( + new Field(Type::DATA, new class implements JsonSerializable { + public function jsonSerialize(): mixed + { + return [1, 2, 3]; + } + }) + ); + }]); + + $eventStream->makePartial()->shouldAllowMockingProtectedMethods(); + + $eventStream->shouldReceive('eraseAndDisableOutputBuffers')->once(); + + $eventStream->shouldReceive('sendEvent')->once()->with("data: [1,2,3]\n\n"); + + $eventStream->send($this->getRequest(), $this->getResponse()); + } +} diff --git a/tests/unit/http/response/senders/stream/event/EventTest.php b/tests/unit/http/response/senders/stream/event/EventTest.php new file mode 100644 index 000000000..cb406125b --- /dev/null +++ b/tests/unit/http/response/senders/stream/event/EventTest.php @@ -0,0 +1,35 @@ +assertCount(2, $event->fields); + + foreach ($event->fields as $field) { + $this->assertInstanceOf(Field::class, $field); + } + } +} diff --git a/tests/unit/http/response/senders/stream/event/FieldTest.php b/tests/unit/http/response/senders/stream/event/FieldTest.php new file mode 100644 index 000000000..b71646f5a --- /dev/null +++ b/tests/unit/http/response/senders/stream/event/FieldTest.php @@ -0,0 +1,28 @@ +assertSame(Type::DATA, $field->type); + $this->assertSame('foobar', $field->value); + } +}