NAME
Punk::Stream - a streamed response for an ordinary route
SYNOPSIS
get '/export' => sub {
my $c = shift;
$c->stream('text/csv', {
headers => [ 'Content-Disposition' => 'attachment; filename="all.csv"' ],
}, sub {
my ($c, $w) = @_;
while (my $chunk = next_chunk_of_rows($c)) {
$w->write($chunk);
my ($ok) = $c->await($w->drain);
return unless $ok; # the client went away
}
});
};
DESCRIPTION
$c->stream($content_type, $cb) emits a response body as it is produced - a CSV export walking a large query, an NDJSON dump, anything of unknown length that should not cost its size in memory. The callback gets the Punk::Context and a writer; the handler returns what stream returns.
This is the SSE transport machinery with the event framing removed. 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.
How it ends
A streamed response ends, and how it ends is visible to the client. On the socket transports the body is chunk-framed (HTTP/1.1), so:
the callback returning closes the stream cleanly - the terminal chunk is sent, and the connection closes once every byte is out. An explicit
$w->closeinside the callback does the same and is idempotent.a die in the callback closes the stream hard. The response head is already on the wire, so there is no error page to send; the socket closes without the terminal chunk and the client sees truncation, never a valid-looking short body. The death is reported through
warnwith the request id when Punk::Plugin::RequestId has issued one.
A stream does not outlive its callback. For a connection that stays open and is pushed to later, use an sse or websocket route - that is what they are for.
Backpressure
$w->write queues; $w->drain is a Punk::Future settled with 1 once everything written so far has reached the kernel, or 0 when the stream closed first (the client disconnected, or the buffer ceiling was hit). Awaiting it after each write bounds memory to one chunk, and on a Hyperman worker the await pumps the event loop - other requests are served while your stream waits for a slow client to read. On the blocking and psgi.streaming transports writes are synchronous, so drain comes back already settled and the same loop costs nothing.
THE CALL
$c->stream($content_type, $cb);
$c->stream($content_type, \%opts, $cb);
Options, all checked when you call (an unknown key croaks):
status-
The response status, default 200.
headers-
Extra response headers, an arrayref of pairs.
Content-Typecomes from the first argument;Transfer-EncodingandConnectionbelong to the transport and are not yours to set. A CR or LF anywhere in a name or value croaks. write_buffer_limit-
A ceiling in bytes on the unsent buffer, off by default. Over it the stream is closed rather than allowed to grow without bound - the Punk::WebSocket rule for a client that will not read. With the drain loop above the buffer never holds more than one chunk and the ceiling never matters.
blocking-
Stream synchronously over
psgix.iowhen there is no detach seam and nopsgi.streaming- the same last-resort transport, with the same worker pinned, assseandwebsocketroutes.
THE WRITER
write($bytes)
Queue one chunk of the body. Bytes, as PSGI bodies are - encode a character string first. An empty or undef write is a no-op (on the wire a zero-length chunk would mean "the end"). Writes on a closed stream are ignored. Chainable.
drain
The backpressure future; see above.
close
End the response cleanly now. The callback returning does this for you. Chainable.
is_open
Whether the stream is still open - false once the client is gone or close has been called.
WHAT THE REST OF THE FRAMEWORK SEES
On a psgi.streaming server the response is a delayed-response coderef, and on the socket transports the socket has left the building: there is no final triplet. after_dispatch hooks are skipped for a coderef response (there is nothing to mutate), a route's etag => 1 has no body to hash and passes through, and the response observers fire once with what was actually returned - the coderef, or the detached-socket sentinel. This is the same contract sse routes have always had.
SEE ALSO
Punk, Punk::SSE, Punk::Future, "send_file" in Punk::Context.
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)