Skip to content

Commit b5420a9

Browse files
innocenzibrendt
andauthored
feat(router): support server-sent events (#1260)
Co-authored-by: Brent Roose <[email protected]>
1 parent f34ad57 commit b5420a9

File tree

6 files changed

+132
-2
lines changed

6 files changed

+132
-2
lines changed

packages/http/src/ContentType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ enum ContentType: string
2727
case DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
2828
case EOT = 'application/vnd.ms-fontobject';
2929
case EPUB = 'application/epub+zip';
30+
case EVENT_STREAM = 'text/event-stream';
3031
case GZ = 'application/gzip';
3132
case GIF = 'image/gif';
3233
case HTML = 'text/html';
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Http\Responses;
6+
7+
use Closure;
8+
use Generator;
9+
use Tempest\DateTime\Duration;
10+
use Tempest\Http\ContentType;
11+
use Tempest\Http\IsResponse;
12+
use Tempest\Http\Response;
13+
use Tempest\Http\Status;
14+
15+
final class EventStream implements Response
16+
{
17+
use IsResponse;
18+
19+
public Duration $sleep;
20+
21+
public function __construct(
22+
Closure $callback,
23+
int|Duration $sleep = 1000,
24+
Status $status = Status::OK,
25+
) {
26+
$this->setContentType(ContentType::EVENT_STREAM);
27+
$this->addHeader('X-Accel-Buffering', 'no');
28+
$this->addHeader('Connection', 'keep-alive');
29+
$this->addHeader('Cache-Control', 'no-cache');
30+
31+
$this->status = $status;
32+
$this->body = $this->createGeneratorFromCallback($callback);
33+
$this->sleep = is_int($sleep) ? Duration::milliseconds($sleep) : $sleep;
34+
}
35+
36+
public function createGeneratorFromCallback($callback): Generator
37+
{
38+
yield from $callback();
39+
}
40+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Tempest\Http;
4+
5+
/**
6+
* Represents a message streamed through server-sent events.
7+
*/
8+
final class ServerSentEvent
9+
{
10+
public function __construct(
11+
public mixed $data,
12+
public string $event = 'message',
13+
) {}
14+
}

packages/router/src/GenericResponseSender.php

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
use Tempest\Http\Header;
1010
use Tempest\Http\Response;
1111
use Tempest\Http\Responses\Download;
12+
use Tempest\Http\Responses\EventStream;
1213
use Tempest\Http\Responses\File;
14+
use Tempest\Http\ServerSentEvent;
15+
use Tempest\Support\Json;
1316
use Tempest\View\View;
1417
use Tempest\View\ViewRenderer;
1518

@@ -22,10 +25,8 @@ public function __construct(
2225
public function send(Response $response): Response
2326
{
2427
ob_start();
25-
2628
$this->sendHeaders($response);
2729
ob_flush();
28-
2930
$this->sendContent($response);
3031
ob_end_flush();
3132

@@ -68,6 +69,11 @@ private function resolveHeaders(Response $response): Generator
6869

6970
private function sendContent(Response $response): void
7071
{
72+
if ($response instanceof EventStream) {
73+
$this->sendEventStream($response);
74+
return;
75+
}
76+
7177
$body = $response->body;
7278

7379
if ($response instanceof File || $response instanceof Download) {
@@ -82,4 +88,35 @@ private function sendContent(Response $response): void
8288

8389
ob_flush();
8490
}
91+
92+
private function sendEventStream(EventStream $response): void
93+
{
94+
if (ob_get_level() > 0) {
95+
ob_end_flush();
96+
}
97+
98+
foreach ($response->body as $message) {
99+
if (connection_aborted()) {
100+
break;
101+
}
102+
103+
$event = 'message';
104+
$data = Json\encode($message);
105+
106+
if ($message instanceof ServerSentEvent) {
107+
$event = $message->event;
108+
$data = Json\encode($message->data);
109+
}
110+
111+
echo "event: {$event}\n";
112+
echo "data: {$data}";
113+
echo "\n\n";
114+
115+
if (ob_get_level() > 0) {
116+
ob_flush();
117+
}
118+
119+
flush();
120+
}
121+
}
85122
}

packages/router/src/ResponseSenderInitializer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Tempest\Router;
66

7+
use Tempest\Clock\Clock;
78
use Tempest\Container\Container;
89
use Tempest\Container\Initializer;
910
use Tempest\Container\Singleton;

tests/Integration/Http/GenericResponseSenderTest.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66

77
use Tempest\Http\GenericResponse;
88
use Tempest\Http\Responses\Download;
9+
use Tempest\Http\Responses\EventStream;
910
use Tempest\Http\Responses\File;
1011
use Tempest\Http\Responses\Ok;
12+
use Tempest\Http\ServerSentEvent;
1113
use Tempest\Http\Status;
1214
use Tempest\Router\GenericResponseSender;
1315
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
@@ -110,4 +112,39 @@ public function test_view_body(): void
110112

111113
$this->assertStringContainsString('Hello Brent!', $output);
112114
}
115+
116+
public function test_stream(): void
117+
{
118+
ob_start();
119+
$response = new EventStream(fn () => yield 'hello');
120+
$responseSender = $this->container->get(GenericResponseSender::class);
121+
$responseSender->send($response);
122+
$output = ob_get_clean();
123+
124+
// restore phpunit's output buffer
125+
ob_start();
126+
127+
$this->assertStringContainsString('event: message', $output);
128+
$this->assertStringContainsString('data: "hello"', $output);
129+
}
130+
131+
public function test_stream_with_custom_event(): void
132+
{
133+
ob_start();
134+
$response = new EventStream(function () {
135+
yield new ServerSentEvent(data: 'hello', event: 'first');
136+
yield new ServerSentEvent(data: 'goodbye', event: 'last');
137+
});
138+
$responseSender = $this->container->get(GenericResponseSender::class);
139+
$responseSender->send($response);
140+
$output = ob_get_clean();
141+
142+
// restore phpunit's output buffer
143+
ob_start();
144+
145+
$this->assertStringContainsString('event: first', $output);
146+
$this->assertStringContainsString('data: "hello"', $output);
147+
$this->assertStringContainsString('event: last', $output);
148+
$this->assertStringContainsString('data: "goodbye"', $output);
149+
}
113150
}

0 commit comments

Comments
 (0)