SSR Streaming
Send HTML to the browser progressively as coroutines resolve — like React's renderToPipeableStream, but in PHP. Three APIs, same result: the browser paints content incrementally.
Returning a \Generator from any handler triggers streaming — see the universal return contract for the full table of return shapes, and the file-execution family for how App::renderStream() composes with render() / renderToString() / include() / fragment().
Generator yield
Return a \Generator from your handler. Each yield $html flushes immediately. No API changes needed.
stream() callback
Get a $write(string) closure. Headers flush before callback runs. Fine-grained control.
sse()
Server-Sent Events. Get an $emit($data, $event, $id) closure. JS EventSource compatible.
$app->route('/stream/ssr', function() {
$start = microtime(true);
return (function() use ($start) {
// 1. Shell sent to browser immediately
yield '<html><body><h1>Page</h1>';
// 2. Parallel fetch via coroutines
$ch = new Channel(2);
go(fn() => [$ch->push(fetchUsers()), co::sleep(1)]);
go(fn() => [$ch->push(fetchPosts()), co::sleep(2)]);
// 3. Stream each section as it resolves
yield '<div id="users">' . $ch->pop() . '</div>'; // arrives at ~1s
yield '<div id="posts">' . $ch->pop() . '</div>'; // arrives at ~2s
yield '</body></html>';
})();
// Total: ~2s (parallel), not 3s (sequential)
});
Live streaming demos
/stream/ssr — Generator yieldOpens a streaming connection. Watch sections appear one by one (1s, then 2s):
/stream/events — Server-Sent Events10 events, 1 second apart. EventSource reconnects automatically on drop.
$app->route('/stream/events', function($response) {
$response->sse(function($emit) {
$emit(json_encode(['status' => 'connected']), 'open');
for ($i = 1; $i <= 10; $i++) {
co::sleep(1);
$emit(json_encode(['tick' => $i, 'time' => date('H:i:s')]), 'tick', (string)$i);
}
$emit(json_encode(['done' => true]), 'done');
});
});
// Browser:
// const es = new EventSource('/stream/events');
// es.addEventListener('tick', e => console.log(JSON.parse(e.data)));
Streaming from templates — App::renderStream()
Templates can yield. Return a Closure with named parameters — the framework injects them by name (same as route handlers). Each yield flushes to the browser immediately. Regular echo-based templates work too — output captured as one chunk.
<?php
// Declare what data you need — framework injects by name
return function($metrics) {
yield "<div class='stats-grid'>";
foreach ($metrics as $label => $value) {
yield "<div class='stat'>"
. "<span class='stat-value'>{$value}</span>"
. "<span class='stat-label'>{$label}</span>"
. "</div>";
}
yield "</div>";
};
$app->route('/dashboard', function() {
return (function() {
// Regular template → yields as single chunk
yield from App::renderStream('shell-open', ['title' => 'Dashboard']);
// Streaming template → yields per-metric card
yield from App::renderStream('dashboard/stats', [
'metrics' => ['Requests' => '67k/s', 'Latency' => '21ms', 'Workers' => 4],
]);
// Another streaming template — yields per-row
yield from App::renderStream('dashboard/activity', [
'events' => fetchRecentEvents(),
]);
// Regular template → yields as single chunk
yield from App::renderStream('shell-close');
})();
});
Three template styles — all compose in the same pipeline
| Template style | Code | What renderStream() does |
|---|---|---|
| Closure (cleanest) | return function($users) { yield ...; }; |
Injects params by name via Reflection, calls closure, yield from Generator |
| IIFE Generator | return (function() use ($v) { yield ...; })(); |
Template returns Generator directly, yield from it |
| Regular echo | <h1><?= $title ?></h1> |
Captures output, yields as one chunk |
use OpenSwoole\Coroutine\Channel;
$app->route('/feed', function() {
return (function() {
yield from App::renderStream('shell-open', ['title' => 'Feed']);
// Fetch data in parallel — stream results as each completes
$ch = new Channel(3);
go(fn() => $ch->push(['type' => 'users', 'data' => fetchUsers()]));
go(fn() => $ch->push(['type' => 'posts', 'data' => fetchPosts()]));
go(fn() => $ch->push(['type' => 'comments', 'data' => fetchComments()]));
for ($i = 0; $i < 3; $i++) {
$result = $ch->pop();
// Each section streams as its data arrives
yield from App::renderStream(
"feed/{$result['type']}",
['items' => $result['data']]
);
}
yield from App::renderStream('shell-close');
})();
});
yield is a native language feature. Templates declare their dependencies as function parameters, the framework injects them, and each yield flushes to the client. No special template syntax, no streaming adapter — just PHP generators.
Generator, stream(), sse()) automatically send Accept-Ranges: none — the total size is unknown, so byte-range serving doesn't apply. Buffered responses get full Range support via RangeMiddleware.