Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Server-Sent Events API (#3).** First-class `text/event-stream` helpers on
`HttpResponse` — `sseStart()`, `sseEvent($data, $event, $id, $retry)`,
`sseComment()` and `sseRetry()` — layered on the existing streaming pipeline,
so the same handler works over HTTP/1.1, HTTP/2 and HTTP/3. `sseStart()` sets
the canonical headers (`Content-Type: text/event-stream`, `Cache-Control:
no-cache, no-transform`, `X-Accel-Buffering: no`) and marks the response
non-compressible. Framing follows WHATWG §9.2: multiline `data` is split per
line, single-line fields reject CR/LF and `id` rejects NUL. phpt coverage for
H1/H2/H3 plus the validation surface.

- **hq-interop (HTTP/0.9-over-QUIC) for the interop matrix (#80).** A second QUIC
ALPN, `hq-interop`, served straight off the transport (no nghttp3): a raw bidi
stream `GET <path>` returns the file bytes + FIN from `setHttp3HqDocroot()`.
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ set(CORE_SOURCES
src/http_server_exceptions.c
src/http_request.c
src/http_response.c
src/http_sse.c
src/uploaded_file.c
src/http_mime.c
src/http_date.c
Expand Down
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ This means you can serve a REST API over HTTP/2, push real-time events over Serv
| ✅ Ready | **HTTP/3 / QUIC** | UDP transport via ngtcp2 + nghttp3; OpenSSL 3.5 QUIC API |
| ✅ Ready | **Compression** | gzip (zlib-ng / zlib), Brotli, zstd — response encoding + inbound decode across H1/H2/H3. Server-side preference `zstd > br > gzip`; per-codec level setters. See [docs/COMPRESSION.md](docs/COMPRESSION.md). |
| 📋 Planned | **WebSocket** | RFC 6455, upgrade from HTTP/1.1 and HTTP/2, full duplex |
| 📋 Planned | **SSE (Server-Sent Events)** | RFC 8895, server-to-client event streaming |
| ✅ Ready | **SSE (Server-Sent Events)** | `text/event-stream` framing (WHATWG §9.2) over H1/H2/H3 via `HttpResponse::sseStart/sseEvent/sseComment/sseRetry` |
| 📋 Planned | **gRPC** | Built on HTTP/2, unary and streaming RPC |

### Development Progress
Expand All @@ -56,7 +56,7 @@ TLS ████████████████████ 100%
HTTP/2 ████████████████████ 100%
HTTP/3 ████████████████████ 100%
WebSocket ░░░░░░░░░░░░░░░░░░░░ 0%
SSE ░░░░░░░░░░░░░░░░░░░░ 0%
SSE ████████████████████ 100%
gRPC ░░░░░░░░░░░░░░░░░░░░ 0%
```

Expand Down Expand Up @@ -258,9 +258,37 @@ behaviour. See **[docs/USAGE.md](docs/USAGE.md)** for protocol-restricted
listeners (`addHttp1Listener`/`addHttp2Listener`/`addHttp3Listener`),
TLS, compression, timeouts, backpressure and caveats.

### Server-Sent Events

`text/event-stream` is a first-class response mode — the same handler streams
over HTTP/1.1, HTTP/2 and HTTP/3 (the client picks the protocol):

```php
$server->addHttpHandler(function ($request, $response) {
$response->sseStart(); // commits Content-Type: text/event-stream
$response->sseRetry(3000); // reconnect hint (ms)

foreach (fetchUpdates() as $i => $update) {
$response->sseEvent(
data: json_encode($update),
event: 'tick', // addEventListener('tick', …) on the client
id: (string) $i, // echoed back as Last-Event-ID on reconnect
);
if (!$response->sendable()) break; // peer gone — stop early
}

$response->end();
});
```

`sseEvent()` formats WHATWG §9.2 records (multiline `data` is split, single-line
fields are CR/LF-validated); `sseComment('')` emits a `:` heartbeat to hold the
connection open through proxy idle timeouts. The stream is never compressed.

Working examples live under [`examples/`](examples/):
[`minimal-server.php`](examples/minimal-server.php),
[`demo-server.php`](examples/demo-server.php),
[`sse-server.php`](examples/sse-server.php),
[`multi-worker.php`](examples/multi-worker.php),
[`multi-worker-manual.php`](examples/multi-worker-manual.php).

Expand Down
1 change: 1 addition & 0 deletions config.m4
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ if test "$PHP_HTTP_SERVER" != "no"; then
src/formats/multipart_processor.c
src/http_request.c
src/http_response.c
src/http_sse.c
src/http_response_server_api.c
src/http_body_stream.c
src/http_mime.c
Expand Down
1 change: 1 addition & 0 deletions config.w32
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ if (PHP_TRUE_ASYNC_SERVER == "yes") {
"src\\http_server_class.c " +
"src\\http_request.c " +
"src\\http_response.c " +
"src\\http_sse.c " +
"src\\http_response_server_api.c " +
"src\\uploaded_file.c " +
// HTTP utilities (mirrors config.m4 base source list)
Expand Down
64 changes: 64 additions & 0 deletions examples/sse-server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php
/**
* Server-Sent Events (text/event-stream) demo.
*
* Open http://127.0.0.1:8080/events in a browser tab, or:
* curl -N http://127.0.0.1:8080/events
*
* The same handler works unchanged over HTTP/1.1, HTTP/2 and HTTP/3 —
* SSE is just the text/event-stream framing layered on the streaming
* response pipeline, so the protocol is chosen by the client.
*
* Browser side:
* const es = new EventSource('/events');
* es.onmessage = e => console.log('message', e.data);
* es.addEventListener('tick', e => console.log('tick', e.data, e.lastEventId));
*/

use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
use function Async\delay;

$config = (new HttpServerConfig())
->addListener('0.0.0.0', (int)(getenv('PORT') ?: 8080))
->setWriteTimeout(0); // long-lived stream: no write deadline

$server = new HttpServer($config);

$server->addHttpHandler(function ($req, $res) {
// Commit the SSE headers (Content-Type, Cache-Control, X-Accel-Buffering).
// Optional — the first sseEvent()/sseComment() starts the stream too.
$res->sseStart();

// Hint the browser to wait 3s before reconnecting after a drop.
$res->sseRetry(3000);

// Comment line = heartbeat that keeps proxies from idling the conn out.
$res->sseComment('stream open');

for ($i = 1; $i <= 10; $i++) {
// A named event with an id (echoed back as Last-Event-ID on reconnect).
$res->sseEvent(
data: json_encode(['n' => $i, 'at' => time()]),
event: 'tick',
id: (string) $i,
);

// sendable() is an advisory backpressure check — skip the sleep and
// bail early if the peer has gone away.
if (!$res->sendable()) {
break;
}

delay(1000); // 1s between events (cooperative, non-blocking)
}

// A default (unnamed) message event, then close the stream.
$res->sseEvent('bye');
$res->end();
});

fprintf(STDERR, "[sse-server] http://127.0.0.1:%d/events pid=%d\n",
(int)(getenv('PORT') ?: 8080), getmypid());

$server->start();
Loading
Loading