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.

Four transports carry it, chosen per request: a Hyperman worker detaches the socket and streams it on the loop; a Hyperman stream handle sends the body through the server for the connections detach cannot take; 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.

HTTP/2 and TLS

The stream handle is what makes those two work, and until Hyperman grew one they did not. Detaching hands over a file descriptor, and there is nothing coherent to hand over on either: an HTTP/2 stream is one of many on a shared connection, so no descriptor means "this stream", and a TLS session's state belongs to the server. Both refusals are correct, so the fix was a different seam rather than a wider detach.

Nothing in a handler changes. The stream is the same object with the same methods, and the transport is chosen for the connection it arrived on.

The response head differs where the protocol requires it: Connection is hop-by-hop and HTTP/2 and HTTP/3 forbid it, so it is sent on HTTP/1 and not on a multiplexed transport, and framing is the transport's own.

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)