| Home | Getting Started | Core Concepts | Helpers | Extensions | Repo |
tiny::sse() provides a small toolkit for streaming events from the server to a browser using Server-Sent Events. It handles header negotiation, session release, output buffering, and exposes three streaming strategies: arbitrary callbacks, cache-keyed push/pull, and PostgreSQL LISTEN/NOTIFY.
Under PHP-FPM, SSE works but ties up a worker for the lifetime of the stream. Under Swoole it’s much cheaper — coroutines cost almost nothing.
$sse = tiny::sse();
TinySSE::start(); // emit SSE headers, close session, flush buffers
$sse->send(string $data); // emit one "data: ..." frame
$sse->flush(); // force-flush output
$sse->stream(callable $fn, int $sleep = 10);
$sse->streamKey(string $key, int $sleep = 1);
$sse->sendKey(string $key, mixed $data);
$sse->streamPostgres(string $channel, int $sleep = 1);
Most flexible mode. Provide a function that returns a payload (or null if nothing changed); the SSE loop sends each non-null return value as one frame.
class Heartbeat extends TinyController
{
public function get($request, $response)
{
tiny::sse()->stream(function () {
return json_encode([
'ts' => time(),
'load' => sys_getloadavg()[0],
]);
}, sleep: 5);
}
}
// client
const es = new EventSource('/heartbeat');
es.onmessage = ({ data }) => console.log(JSON.parse(data));
The loop terminates automatically if the client disconnects (connection_aborted()).
When the producer and consumer are different processes (e.g. a background worker fills a queue, the controller streams to the browser), pair sendKey() with streamKey(). Both back onto tiny::cache().
// producer (in a job, scheduler, or another controller)
tiny::sse()->sendKey('user:42:updates', json_encode([
'status' => 'processed',
'order' => 1234,
]));
// consumer (browser-facing controller)
class UserUpdates extends TinyController
{
public function get($request, $response)
{
tiny::sse()->streamKey("user:{$request->path->section}:updates", sleep: 1);
}
}
The consumer reads, sends, then deletes the cache entry — so each message is delivered exactly once.
Send "[DONE]" as the cache value to terminate the stream gracefully.
LISTEN/NOTIFYFor PostgreSQL-backed apps, you can stream straight from pg_notify:
CREATE OR REPLACE FUNCTION notify_user_updates() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('user_updates', row_to_json(NEW)::text);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_updates_trigger
AFTER INSERT ON users
FOR EACH ROW
EXECUTE PROCEDURE notify_user_updates();
class UserUpdates extends TinyController
{
public function get($request, $response)
{
tiny::sse()->streamPostgres('user_updates', sleep: 1);
}
}
Each pg_notify payload is sent to the connected browser as a JSON-encoded message.
<script>
const es = new EventSource('/sse/dashboard');
es.onmessage = ({ data }) => {
if (data === '[DONE]') {
es.close();
return;
}
const payload = JSON.parse(data);
updateUI(payload);
};
es.onerror = (err) => {
console.warn('SSE disconnected, browser will retry');
};
</script>
EventSource auto-reconnects on network errors with an exponential backoff — you usually don’t need a setTimeout loop.
send() emits an unnamed (default) event. To use named events, write directly:
echo "event: progress\n";
echo "data: " . json_encode($payload) . "\n\n";
tiny::sse()->flush();
es.addEventListener('progress', e => updateProgress(JSON.parse(e.data)));
TinySSE::start() does the heavy lifting:
session_write_close) so other requests aren’t blockedContent-Type: text/event-stream, Cache-Control: no-store, X-Accel-Buffering: noYou usually don’t call it directly — stream(), streamKey(), and streamPostgres() invoke it for you.
[DONE] to terminate gracefully — clients can close the connection without waiting for a network error.data: need careful escaping.X-Accel-Buffering: no at the proxy if you put nginx in front. The extension already emits this header, but nginx config sometimes overrides it.