NAME
Punk::SSE - a Server-Sent Events stream
SYNOPSIS
# in the app
sse '/events' => 'Live#feed';
# MyApp::Controller::Live
sub feed {
my ($c, $stream) = @_; # the socket is ours now
my $tick;
$tick = sub {
return unless $stream->is_open;
$stream->send({ time => time });
$c->timer(1)->on_done($tick); # push once a second
};
$tick->();
$stream->on(close => sub { warn "client gone\n" });
}
DESCRIPTION
An sse route streams text/event-stream to a browser's EventSource: one-directional, plain HTTP, with the client reconnecting on its own. The handler for an sse route is called with the Punk::Context and a stream once Punk has taken the socket over, and pushes events onto it; the stream then lives on the worker's event loop with no worker pinned per connection.
Three transports carry it, chosen per request: a Hyperman worker detaches the socket and streams it on the loop; a psgi.streaming server uses the standard delayed-response writer; and blocking => 1 streams inside the handler over psgix.io (pinning one worker). Without any of them the request gets a 501.
Backpressure is bounded by write_buffer_limit as for websockets: a client that will not read is closed rather than allowed to buffer without limit.
THE ROUTE
sse '/events' => 'Live#feed';
sse '/events' => $target, { heartbeat => 30, retry => 3000 };
Options: heartbeat (seconds between keep-alive comments, default 15; 0 to turn it off), retry (the client reconnect delay in ms, sent once up front), write_buffer_limit, and blocking.
Reconnection
The browser reconnects automatically and sends the last id it saw as the Last-Event-ID header; read it to resume:
my $from = $c->req->header('last-event-id');
and stamp outgoing events with "id" so the client has something to send back.
THE STREAM
send($data)
One event. A reference is JSON-encoded; a multi-line string becomes multiple data: lines per the spec. Chainable.
event($name, $data)
A named event (an event: field the client dispatches by name). Chainable.
comment($text)
A :comment line - ignored by the client, useful as a keep-alive. Chainable.
id($id)
retry($ms)
Write an id: or retry: field. Send id just before the event it stamps. Chainable.
close
End the stream now. Chainable.
is_open
Whether the stream is still open - test it before pushing from a timer.
on(close => $cb)
$cb->($stream) once, when the stream ends: the client disconnecting, a close, or a write error. Chainable.
FANNING OUT ACROSS WORKERS
A stream belongs to the worker that accepted it. Under a prefork server that means an application pushing an event reaches only the fraction of its subscribers that happen to be on the worker doing the pushing - the same trap Punk::WebSocket::Room had, and with the same silence about it.
There is no Punk::SSE::Room, because holding the streams is usually the application's business: a stream is often per user rather than per group. What it needs is a way to reach the other workers, and that is "publish / subscribe" in Punk.
package MyApp;
use Punk;
our @STREAMS;
sse '/events' => sub {
my ($c, $stream) = @_;
push @STREAMS, $stream;
};
# AT BOOT, in the parent, before the server forks - the only moment a
# subscription reaches every worker
__PACKAGE__->punk_app->subscribe('news' => sub {
my ($topic, $payload) = @_;
@STREAMS = grep { $_->is_open } @STREAMS; # prune as you go
$_->event(news => $payload) for @STREAMS;
});
Anything may then push to every stream in the pool, from any worker:
$c->publish('news' => $headline);
Two things to keep in mind. Prune the closed streams - nothing else holds them, and an array that only grows is a leak with a client list attached. And register at boot: a subscription made inside a request lands in one worker and lasts as long as that process, which is the fault this avoids.
SEE ALSO
Punk, Punk::Future, Punk::WebSocket.
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)