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->close inside 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 warn with 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-Type comes from the first argument; Transfer-Encoding and Connection belong 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.io when there is no detach seam and no psgi.streaming - the same last-resort transport, with the same worker pinned, as sse and websocket routes.

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)