Real time: server-sent events and WebSockets¶
Both run on keel run, where an open connection costs a fiber, not a worker. Both start
from an ordinary endpoint, so authentication, tenancy, scopes and rate limits apply to them like to any
request.
The hub¶
Hub is publish/subscribe for real-time clients:
- Subscribers live in the process holding the connection.
- With
'realtime' => ['redis' => true], every publish also goes through Redis, and everykeel runprocess relays it to its subscribers. A message published anywhere reaches every client on every node: a web request, a queue worker, a scheduled task, a FrankenPHP worker.
Server-sent events¶
#[Get('/orders/live')]
#[Authorize(scopes: ['orders:read'])]
final class LiveOrders
{
public function __invoke(Hub $hub, CurrentTenant $tenant): ResponseInterface
{
return EventStream::response(
fn (EventSink $events) => $hub->stream("orders.{$tenant->id}", $events, 'order.placed'),
);
}
}
const events = new EventSource('/orders/live', { withCredentials: true });
events.addEventListener('order.placed', (e) => console.log(JSON.parse(e.data)));
EventSink::send($data, $event, $id),json(), andheartbeat().Hub::stream()forwards a channel until the client leaves, with a heartbeat every 15 seconds so proxies keep the stream open.- A disconnected client raises
ClientDisconnectedinside your producer; it is caught for you. - The
Last-Event-IDheader is on the request when a browser reconnects. - Under FrankenPHP or FPM, streams work too (flushed chunk by chunk), but each open stream holds a worker.
WebSockets¶
#[Get('/ws/orders')]
#[Authorize(scopes: ['orders:read'])]
final class OrdersWebSocket
{
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
return WebSocket::accept($request, OrdersSocket::class, ['room' => 'lobby']);
}
}
final readonly class OrdersSocket implements WebSocketHandler
{
public function __construct(private Orders $orders) {} // any service, scoped ones included
public function onOpen(WebSocketConnection $connection): void
{
$connection->join('orders.' . $connection->tenantId());
}
public function onMessage(WebSocketConnection $connection, string $message, bool $binary): void
{
$connection->send('echo: ' . $message);
}
public function onClose(WebSocketConnection $connection, int $code, string $reason): void {}
}
// Browsers cannot send Authorization on a WebSocket: the token travels as a subprotocol.
const socket = new WebSocket('wss://api.example.com/ws/orders', ['keel', `keel.token.${token}`]);
socket.onmessage = (e) => console.log(e.data);
- The handshake is a normal request through the middleware pipeline: authentication (including token binding), tenancy and rate limits apply; a 401 or 403 refuses the upgrade.
- The handler runs in a fresh scope for every event (open, each message, close), with the caller's principal and tenant set. It can depend on the Database, the ORM or anything scoped.
WebSocketConnection:send(),sendBinary(),join()/leave()hub channels,close(),principal(),tenantId(),context().- The server implements RFC 6455: masking, fragmentation, ping/pong (a ping every
websocket.ping_seconds, 30; idle connections are closed), close codes, UTF-8 validation of text (1007) and a message size limit (websocket.max_message_bytes, 1 MiB; 1009). It is tested against Python'swebsocketslibrary. - Without
keel run, a WebSocket endpoint answers 501. - Writes to one connection from several fibers (the handler, hub broadcasts) are serialized, so frames never interleave.
Limits¶
- Authentication happens at the handshake. A token revoked later does not close open connections. Close them from your revocation code if that matters (track connection ids per principal).
- There is no per-message compression (permessage-deflate) yet.