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). -------------------------------------------------------- diff --git a/src/mako/http/response/senders/EventStream.php b/src/mako/http/response/senders/EventStream.php new file mode 100644 index 000000000..2c0ffe454 --- /dev/null +++ b/src/mako/http/response/senders/EventStream.php @@ -0,0 +1,129 @@ + 0) { + ob_end_clean(); + } + } + + /** + * Stringifies the value. + */ + protected function stringifyValue(null|float|int|JsonSerializable|string|Stringable $value): string + { + if (is_object($value)) { + if ($value instanceof JsonSerializable) { + return json_encode($value, JSON_THROW_ON_ERROR); + } + } + + return (string) $value; + } + + /** + * Prepares the event for sending. + */ + protected function prepareEvent(Event $event): string + { + $output = ''; + + foreach ($event->fields as $field) { + $output .= "{$field->type->value}: {$this->stringifyValue($field->value)}\n"; + } + + $output .= "\n"; + + return $output; + } + + /** + * Sends the event to the client. + */ + protected function sendEvent(string $event): void + { + echo $event; + + flush(); + } + + /** + * Sends the stream to the client. + */ + protected function sendStream(): void + { + foreach ((fn (): Generator => ($this->stream)())() as $event) { + if (connection_aborted()) { + break; + } + + $this->sendEvent($this->prepareEvent($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 + + $this->eraseAndDisableOutputBuffers(); + + // 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 @@ +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); + } +}