Skip to content

Commit 6623850

Browse files
feat(http): offer more control over server sent events format (#1459)
Co-authored-by: Enzo Innocenzi <[email protected]>
1 parent 167d3f5 commit 6623850

File tree

4 files changed

+170
-48
lines changed

4 files changed

+170
-48
lines changed

packages/http/src/ServerSentEvent.php

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,45 @@
22

33
namespace Tempest\Http;
44

5-
/**
6-
* Represents a message streamed through server-sent events.
7-
*/
8-
final class ServerSentEvent
5+
use JsonSerializable;
6+
use Stringable;
7+
use Tempest\DateTime\Duration;
8+
9+
interface ServerSentEvent
910
{
10-
public function __construct(
11-
public mixed $data,
12-
public string $event = 'message',
13-
) {}
11+
/**
12+
* Defines the ID of this event, which sets the `Last-Event-ID` header in case of a reconnection.
13+
*/
14+
public ?int $id {
15+
get;
16+
}
17+
18+
/**
19+
* Defines the event stream's reconnection time in case of a reconnection attempt.
20+
*/
21+
public null|Duration|int $retryAfter {
22+
get;
23+
}
24+
25+
/**
26+
* The name of the event, which may be listened to by `EventSource#addEventListener`.
27+
*
28+
* **Example**
29+
* ```js
30+
* const eventSource = new EventSource('/events');
31+
* eventSource.addEventListener('my-event', (event) => {
32+
* console.log(event.data)
33+
* })
34+
* ```
35+
*/
36+
public ?string $event {
37+
get;
38+
}
39+
40+
/**
41+
* Content of the event.
42+
*/
43+
public JsonSerializable|Stringable|string|iterable $data {
44+
get;
45+
}
1446
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace Tempest\Http;
4+
5+
use JsonSerializable;
6+
use Stringable;
7+
use Tempest\DateTime\Duration;
8+
use Tempest\Support\Json;
9+
10+
/**
11+
* Represents a JSON-encoded message streamed through server-sent events.
12+
*/
13+
final class ServerSentMessage implements ServerSentEvent
14+
{
15+
public JsonSerializable|Stringable|string|iterable $data;
16+
17+
/**
18+
* @param JsonSerializable|Stringable|string|iterable $data Content of the event.
19+
* @param string $event The name of the event, which may be listened to by `EventSource#addEventListener`.
20+
* @param null|int $id Defines the ID of this event, which sets the `Last-Event-ID` header in case of a reconnection.
21+
* @param null|Duration|int $retryAfter Defines the event stream's reconnection time in case of a reconnection attempt.
22+
*/
23+
public function __construct(
24+
JsonSerializable|Stringable|string|iterable $data,
25+
private(set) string $event = 'message',
26+
private(set) ?int $id = null,
27+
private(set) null|Duration|int $retryAfter = null,
28+
) {
29+
$this->data = Json\encode($data);
30+
}
31+
}

packages/router/src/GenericResponseSender.php

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
use Generator;
88
use JsonSerializable;
9+
use Stringable;
910
use Tempest\Container\Container;
11+
use Tempest\DateTime\Duration;
1012
use Tempest\Http\ContentType;
1113
use Tempest\Http\Header;
1214
use Tempest\Http\Method;
@@ -16,6 +18,8 @@
1618
use Tempest\Http\Responses\EventStream;
1719
use Tempest\Http\Responses\File;
1820
use Tempest\Http\ServerSentEvent;
21+
use Tempest\Http\ServerSentMessage;
22+
use Tempest\Support\Arr;
1923
use Tempest\Support\Json;
2024
use Tempest\View\View;
2125
use Tempest\View\ViewRenderer;
@@ -119,16 +123,38 @@ private function sendEventStream(EventStream $response): void
119123
break;
120124
}
121125

122-
$event = 'message';
123-
$data = Json\encode($message);
126+
if (! ($message instanceof ServerSentEvent)) {
127+
$message = new ServerSentMessage(data: $message);
128+
}
129+
130+
if ($message->id) {
131+
echo "id: {$message->id}\n";
132+
}
133+
134+
if ($message->retryAfter) {
135+
$retry = match (true) {
136+
is_int($message->retryAfter) => $message->retryAfter,
137+
$message->retryAfter instanceof Duration => $message->retryAfter->getTotalMilliseconds(),
138+
};
139+
140+
echo "retry: {$retry}\n";
141+
}
142+
143+
if ($message->event) {
144+
echo "event: {$message->event}\n";
145+
}
146+
147+
$data = match (true) {
148+
is_string($message->data) => $message->data,
149+
$message->data instanceof Stringable => (string) $message->data,
150+
$message->data instanceof JsonSerializable => Json\encode($message->data),
151+
is_iterable($message->data) => iterator_to_array($message->data),
152+
};
124153

125-
if ($message instanceof ServerSentEvent) {
126-
$event = $message->event;
127-
$data = Json\encode($message->data);
154+
foreach (Arr\wrap($data) as $line) {
155+
echo "data: {$line}\n";
128156
}
129157

130-
echo "event: {$event}\n";
131-
echo "data: {$data}";
132158
echo "\n\n";
133159

134160
if (ob_get_level() > 0) {

tests/Integration/Http/GenericResponseSenderTest.php

Lines changed: 66 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
namespace Tests\Tempest\Integration\Http;
66

77
use JsonSerializable;
8+
use Stringable;
9+
use Tempest\DateTime\Duration;
810
use Tempest\Http\GenericRequest;
911
use Tempest\Http\GenericResponse;
1012
use Tempest\Http\Method;
@@ -14,6 +16,7 @@
1416
use Tempest\Http\Responses\File;
1517
use Tempest\Http\Responses\Ok;
1618
use Tempest\Http\ServerSentEvent;
19+
use Tempest\Http\ServerSentMessage;
1720
use Tempest\Http\Status;
1821
use Tempest\Router\GenericResponseSender;
1922
use Tempest\View\ViewRenderer;
@@ -75,17 +78,10 @@ public function test_sending_head_request(): void
7578
public function test_file_response(): void
7679
{
7780
ob_start();
78-
7981
$path = __DIR__ . '/Fixtures/sample.png';
80-
81-
$response = new File(
82-
path: $path,
83-
);
84-
82+
$response = new File(path: $path);
8583
$responseSender = $this->container->get(GenericResponseSender::class);
86-
8784
$responseSender->send($response);
88-
8985
$content = ob_get_clean();
9086

9187
$this->assertSame(file_get_contents($path), $content);
@@ -94,17 +90,10 @@ public function test_file_response(): void
9490
public function test_download_response(): void
9591
{
9692
ob_start();
97-
9893
$path = __DIR__ . '/Fixtures/sample.png';
99-
100-
$response = new Download(
101-
path: $path,
102-
);
103-
94+
$response = new Download(path: $path);
10495
$responseSender = $this->container->get(GenericResponseSender::class);
105-
10696
$responseSender->send($response);
107-
10897
$content = ob_get_clean();
10998

11099
$this->assertSame(file_get_contents($path), $content);
@@ -113,16 +102,13 @@ public function test_download_response(): void
113102
public function test_sending_of_array_to_json(): void
114103
{
115104
ob_start();
116-
117105
$response = new GenericResponse(
118106
status: Status::CREATED,
119107
body: ['key' => 'value'],
120108
);
121109

122110
$responseSender = $this->container->get(GenericResponseSender::class);
123-
124111
$responseSender->send($response);
125-
126112
$output = ob_get_clean();
127113

128114
$this->assertSame('{"key":"value"}', $output);
@@ -131,7 +117,6 @@ public function test_sending_of_array_to_json(): void
131117
public function test_sending_of_json_serializable_to_json(): void
132118
{
133119
ob_start();
134-
135120
$response = new GenericResponse(
136121
status: Status::CREATED,
137122
body: new class implements JsonSerializable {
@@ -143,9 +128,7 @@ public function jsonSerialize(): mixed
143128
);
144129

145130
$responseSender = $this->container->get(GenericResponseSender::class);
146-
147131
$responseSender->send($response);
148-
149132
$output = ob_get_clean();
150133

151134
$this->assertSame('{"key":"value"}', $output);
@@ -154,17 +137,14 @@ public function jsonSerialize(): mixed
154137
public function test_view_body(): void
155138
{
156139
ob_start();
157-
158140
$response = new Ok(
159141
body: view(__DIR__ . '/../../Fixtures/Views/overview.view.php')->data(
160142
name: 'Brent',
161143
),
162144
);
163145

164146
$responseSender = $this->container->get(GenericResponseSender::class);
165-
166147
$responseSender->send($response);
167-
168148
$output = ob_get_clean();
169149

170150
$this->assertStringContainsString('Hello Brent!', $output);
@@ -176,10 +156,7 @@ public function test_stream(): void
176156
$response = new EventStream(fn () => yield 'hello');
177157
$responseSender = $this->container->get(GenericResponseSender::class);
178158
$responseSender->send($response);
179-
180159
$output = ob_get_clean();
181-
182-
// restore phpunit's output buffer
183160
ob_start();
184161

185162
$this->assertStringContainsString('event: message', $output);
@@ -190,20 +167,76 @@ public function test_stream_with_custom_event(): void
190167
{
191168
ob_start();
192169
$response = new EventStream(function () {
193-
yield new ServerSentEvent(data: 'hello', event: 'first');
194-
yield new ServerSentEvent(data: 'goodbye', event: 'last');
170+
yield new ServerSentMessage(data: 'hello', event: 'first');
171+
yield new ServerSentMessage(data: 'goodbye', event: 'last');
195172
});
196173
$responseSender = $this->container->get(GenericResponseSender::class);
197174
$responseSender->send($response);
198-
199175
$output = ob_get_clean();
200-
201-
// restore phpunit's output buffer
202176
ob_start();
203177

204178
$this->assertStringContainsString('event: first', $output);
205179
$this->assertStringContainsString('data: "hello"', $output);
206180
$this->assertStringContainsString('event: last', $output);
207181
$this->assertStringContainsString('data: "goodbye"', $output);
208182
}
183+
184+
public function test_stream_with_custom_id(): void
185+
{
186+
ob_start();
187+
$response = new EventStream(function () {
188+
yield new ServerSentMessage(data: 'hello', id: 123);
189+
yield new ServerSentMessage(data: 'goodbye', id: 456);
190+
});
191+
$responseSender = $this->container->get(GenericResponseSender::class);
192+
$responseSender->send($response);
193+
$output = ob_get_clean();
194+
ob_start();
195+
196+
$this->assertStringContainsString('id: 123', $output);
197+
$this->assertStringContainsString('data: "hello"', $output);
198+
$this->assertStringContainsString('id: 456', $output);
199+
$this->assertStringContainsString('data: "goodbye"', $output);
200+
}
201+
202+
public function test_stream_with_custom_retry(): void
203+
{
204+
ob_start();
205+
$response = new EventStream(function () {
206+
yield new ServerSentMessage(data: 'hello', retryAfter: 1000);
207+
yield new ServerSentMessage(data: 'goodbye', retryAfter: Duration::minute());
208+
});
209+
$responseSender = $this->container->get(GenericResponseSender::class);
210+
$responseSender->send($response);
211+
$output = ob_get_clean();
212+
ob_start();
213+
214+
$this->assertStringContainsString('retry: 1000', $output);
215+
$this->assertStringContainsString('data: "hello"', $output);
216+
$this->assertStringContainsString('retry: 60000', $output);
217+
$this->assertStringContainsString('data: "goodbye"', $output);
218+
}
219+
220+
public function test_stream_with_custom_implementation(): void
221+
{
222+
ob_start();
223+
$response = new EventStream(function () {
224+
yield new class implements ServerSentEvent {
225+
public ?int $id = 1;
226+
public null|Duration|int $retryAfter = null;
227+
public ?string $event = 'custom';
228+
public JsonSerializable|Stringable|string|iterable $data = ['foo', 'bar'];
229+
};
230+
});
231+
$responseSender = $this->container->get(GenericResponseSender::class);
232+
$responseSender->send($response);
233+
$output = ob_get_clean();
234+
ob_start();
235+
236+
$this->assertStringContainsString('id: 1', $output);
237+
$this->assertStringNotContainsString('retry:', $output);
238+
$this->assertStringContainsString('event: custom', $output);
239+
$this->assertStringContainsString('data: foo', $output);
240+
$this->assertStringContainsString('data: bar', $output);
241+
}
209242
}

0 commit comments

Comments
 (0)