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)