PAGI-Tools
view release on metacpan or search on metacpan
lib/PAGI/Response.pm view on Meta::CPAN
Can be a specific origin like C<'https://example.com'> or C<'*'> for any.
=item * C<methods> - Arrayref of allowed HTTP methods for preflight.
Default: C<[qw(GET POST PUT DELETE PATCH OPTIONS)]>.
=item * C<headers> - Arrayref of allowed request headers for preflight.
Default: C<[qw(Content-Type Authorization X-Requested-With)]>.
=item * C<expose> - Arrayref of response headers to expose to the client.
By default, only simple headers (Cache-Control, Content-Language, etc.)
are accessible. Use this to expose custom headers.
=item * C<credentials> - Boolean. If true, sets
C<Access-Control-Allow-Credentials: true>, allowing cookies and
Authorization headers. Default: C<0>.
=item * C<max_age> - How long (in seconds) browsers should cache preflight
results. Default: C<86400> (24 hours).
=item * C<preflight> - Boolean. If true, includes preflight response headers
(Allow-Methods, Allow-Headers, Max-Age). Set this when handling OPTIONS
requests. Default: C<0>.
=item * C<request_origin> - The Origin header value from the request.
Required when C<credentials> is true and C<origin> is C<'*'>, because
the CORS spec forbids using C<'*'> with credentials. Pass the actual
request origin to echo it back.
=back
B<Important CORS Notes:>
=over 4
=item * When C<credentials> is true, you cannot use C<< origin => '*' >>.
Either specify an exact origin, or pass C<request_origin> with the
client's actual Origin header.
=item * The C<Vary: Origin> header is always set to ensure proper caching
when origin-specific responses are used.
=item * For preflight (OPTIONS) requests, set C<< preflight => 1 >> and
typically respond with C<< $res->status(204)->empty() >>.
=back
=head1 SEND PRIMITIVE AND APP MOUNTING
=head2 respond
await $res->respond($send);
The single send primitive for a detached response value. Reads the
accumulated status, headers, and body from C<$self> and emits the
appropriate PAGI protocol events via C<$send>.
C<$send> must be a coderef (the PAGI send callback). C<respond> does
B<not> mutate the response object, so the same response value can be
passed to C<respond> multiple times for different connections.
For streaming responses (set up via the C<_stream> slot), C<respond>
sends the start event, runs the stream callback with a
L<PAGI::Response::Writer>, and ensures the writer is closed.
Returns a L<Future>.
=head2 to_app
my $app = $res->to_app;
Returns a PAGI application coderef C<sub ($scope, $receive, $send)> that
calls L</respond> with the given C<$send> when invoked. Use this to mount
a response value directly as a PAGI app:
my $not_found = PAGI::Response->new
->status(404)
->_set_body('Not Found', 'text/plain');
# Mount as a fallback app
my $app = $not_found->to_app;
=head1 BODY METHODS
These methods set the response body and return C<$self>. Sending happens via
L</respond> / L</to_app> or the endpoint return contract.
Each method works as both a B<class-method factory> and an B<instance method>:
# Class-method factory â creates a new detached response and returns it
return $ctx->json($data); # instance method on existing $res
return PAGI::Response->json($data); # factory shorthand
# Chain body with other setters before sending
PAGI::Response->json($data)->status(201)->respond($send)->get;
The Content-Type these methods set is a B<default>: an explicit C<content_type>
set beforehand is preserved, not overridden.
These helpers UTF-8-encode the body, so they make the Content-Type advertise
that encoding. When you preset a charset-less type they append C<; charset=utf-8>
to it â C<< content_type('application/xml')->html($xml) >> sends
C<application/xml; charset=utf-8> (charset is meaningful for XML and C<text/*>,
RFC 7303). The exceptions are C<application/json> and the C<+json> structured-suffix
types, which are left bare: JSON is always UTF-8 and defines no charset parameter
(RFC 8259). An explicit charset you set yourself is never overridden. If you need
a body in some other encoding, encode it yourself and use L</send_raw>.
=head2 Trailing options (status, content_type, headers)
The body methods C<text>, C<html>, C<json>, C<send_raw>, and C<empty> accept
trailing named options as a convenience so you can set status, content-type,
and extra headers in a single call without chaining:
PAGI::Response->json($data, status => 404);
PAGI::Response->text('Hi', status => 201, headers => ['X-Foo' => 'bar']);
PAGI::Response->send_raw($bytes, content_type => 'application/octet-stream');
PAGI::Response->empty(status => 304);
Recognised options:
=over 4
lib/PAGI/Response.pm view on Meta::CPAN
PAGI::Response->json({ message => 'Hello' });
PAGI::Response->json({ error => 'nope' }, status => 404);
Set body to the JSON-encoded data with Content-Type: application/json. No charset
parameter is added â JSON is always UTF-8 and C<application/json> defines none
(RFC 8259). Accepts trailing options (C<status>, C<content_type>, C<headers>).
Returns C<$self>.
=head2 redirect
$res->redirect('/login');
$res->redirect('/new-url', 301);
PAGI::Response->redirect('/login');
Set an empty body and a Location header. Default status is 302. Returns C<$self>.
B<Why no body?> While RFC 7231 suggests including a short HTML body with a
hyperlink for clients that don't auto-follow redirects, all modern browsers
and HTTP clients ignore redirect bodies. If you need a body for legacy
compatibility, set it explicitly after calling C<redirect>.
=head2 empty
$res->empty;
PAGI::Response->new->empty;
PAGI::Response->empty(status => 304);
Set an empty body with status 204 No Content (or keep a previously set status).
Accepts trailing options (C<status>, C<content_type>, C<headers>); an explicit
C<status> option overrides the 204 default. Returns C<$self>.
=head2 send
$res->send($text);
$res->send($text, charset => 'iso-8859-1');
Set body to the encoded text (UTF-8 by default, or the specified charset).
Defaults the Content-Type to C<text/plain> and appends the charset to a
charset-less type, on the same rules as L</text> (C<application/json> and
C<+json> types stay bare). Returns C<$self>.
=head2 send_raw
$res->send_raw($bytes);
PAGI::Response->send_raw($bytes, content_type => 'application/octet-stream');
Set body to raw bytes without any encoding. Use for binary data or pre-encoded
content. Accepts trailing options (C<status>, C<content_type>, C<headers>).
Returns C<$self>.
=head2 stream
$res->stream(async sub {
my ($writer) = @_;
await $writer->write("chunk1");
await $writer->write("chunk2");
await $writer->close();
});
PAGI::Response->stream($callback);
Store a streaming callback. When the response is sent via L</respond>, the callback
receives a L<PAGI::Response::Writer> and streams chunks. Returns C<$self>.
=head2 writer
my $writer = await $res->writer($send);
my $writer = await $res->writer($send, on_close => sub { cleanup() });
my $writer = await $res->writer($send, on_close => async sub { await cleanup() });
Returns a L<PAGI::Response::Writer> directly, sending headers immediately.
Unlike C<stream()>, the writer is not scoped to a callback â you own it
and must call C<close()> when done.
C<$send> must be a coderef (the PAGI send callback). This is the same
C<$send> you would pass to L</respond>.
This is useful when the writer needs to be passed to event handlers,
pub/sub callbacks, timers, or other contexts outside a single function:
async sub live_feed {
my ($self, $ctx) = @_;
my $writer = await $ctx->response
->content_type('text/plain')
->writer($ctx->send, on_close => sub { $bus->unsubscribe($id) });
my $id = $bus->subscribe(async sub ($line) {
await $writer->write("$line\n");
});
await $ctx->receive; # wait for disconnect
await $writer->close;
}
The optional C<on_close> callback is registered before headers are sent,
eliminating any race window with fast client disconnects. Sync and async
callbacks are both supported â see L</on_close> under L</WRITER OBJECT>.
=head2 send_file
$res->send_file('/path/to/file.pdf');
$res->send_file('/path/to/file.pdf',
filename => 'download.pdf',
inline => 1,
);
PAGI::Response->send_file('/path/to/file.pdf');
# Partial file (for range requests)
$res->send_file('/path/to/video.mp4',
offset => 1024, # Start from byte 1024
length => 65536, # Send 64KB
);
Set the response to serve a file. Stats the file and sets Content-Type,
Content-Length, and Content-Disposition at call time. The PAGI protocol's
C<file> key is used for efficient server-side streaming (file not read into
memory) when L</respond> is called. Returns C<$self>.
For production, use L<PAGI::Middleware::XSendfile> to delegate file serving
to your reverse proxy.
B<Options:>
=over 4
=item * C<filename> - Set Content-Disposition attachment filename
=item * C<inline> - Use Content-Disposition: inline instead of attachment
=item * C<offset> - Start position in bytes (default: 0). For range requests.
=item * C<length> - Number of bytes to send. Defaults to file size minus offset.
=back
B<Range Request Example:>
# Manual range request handling
async sub handle_video {
my ($req, $send) = @_;
my $path = '/videos/movie.mp4';
my $size = -s $path;
my $range = $req->header('Range');
if ($range && $range =~ /bytes=(\d+)-(\d*)/) {
my $start = $1;
my $end = $2 || ($size - 1);
my $length = $end - $start + 1;
return await PAGI::Response->new
->status(206)
->header('Content-Range' => "bytes $start-$end/$size")
->header('Accept-Ranges' => 'bytes')
->send_file($path, offset => $start, length => $length)
->respond($send);
}
return await PAGI::Response->new
->header('Accept-Ranges' => 'bytes')
->send_file($path)
->respond($send);
}
B<Note:> For production file serving with full features (ETag caching,
automatic range request handling, conditional GETs, directory indexes),
use L<PAGI::App::File> instead:
use PAGI::App::File;
my $files = PAGI::App::File->new(root => '/var/www/static');
my $app = $files->to_app;
=head1 EXAMPLES
=head2 Complete Raw PAGI Application
use Future::AsyncAwait;
lib/PAGI/Response.pm view on Meta::CPAN
sub delete_cookie {
my ($self, $name, %opts) = @_;
return $self->cookie($name, '',
max_age => 0,
path => $opts{path},
domain => $opts{domain},
);
}
sub cors {
my ($self, %opts) = @_;
my $origin = $opts{origin} // '*';
my $credentials = $opts{credentials} // 0;
my $methods = $opts{methods} // [qw(GET POST PUT DELETE PATCH OPTIONS)];
my $headers = $opts{headers} // [qw(Content-Type Authorization X-Requested-With)];
my $expose = $opts{expose} // [];
my $max_age = $opts{max_age} // 86400;
my $preflight = $opts{preflight} // 0;
# Determine the origin to send back
my $allow_origin;
if ($origin eq '*' && $credentials) {
# With credentials, can't use wildcard - use request_origin if provided
$allow_origin = $opts{request_origin} // '*';
} else {
$allow_origin = $origin;
}
# Core CORS headers (always set)
$self->header('Access-Control-Allow-Origin', $allow_origin);
$self->header('Vary', 'Origin');
if ($credentials) {
$self->header('Access-Control-Allow-Credentials', 'true');
}
if (@$expose) {
$self->header('Access-Control-Expose-Headers', join(', ', @$expose));
}
# Preflight headers (for OPTIONS responses or when explicitly requested)
if ($preflight) {
$self->header('Access-Control-Allow-Methods', join(', ', @$methods));
$self->header('Access-Control-Allow-Headers', join(', ', @$headers));
$self->header('Access-Control-Max-Age', $max_age);
}
return $self;
}
sub stream {
my ($proto, $callback) = @_;
my $self = $proto->_self_or_new;
$self->{_stream} = $callback;
return $self;
}
async sub writer {
my ($self, $send, %opts) = @_;
croak("send must be a coderef") unless ref($send) eq 'CODE';
# A writer takes over the connection for live streaming; it can only be
# taken once on a given response value. (The cross-stack "did a response
# start" fact lives on pagi.connection; this is a local single-takeover guard.)
croak("Response already sent") if $self->{_writer_started};
$self->{_writer_started} = 1;
# Send headers
await $send->({
type => 'http.response.start',
status => $self->status,
headers => $self->_render_headers(undef),
});
return PAGI::Response::Writer->new($send, %opts);
}
# Simple MIME type mapping
my %MIME_TYPES = (
'.html' => 'text/html',
'.htm' => 'text/html',
'.txt' => 'text/plain',
'.css' => 'text/css',
'.js' => 'application/javascript',
'.json' => 'application/json',
'.xml' => 'application/xml',
'.pdf' => 'application/pdf',
'.zip' => 'application/zip',
'.png' => 'image/png',
'.jpg' => 'image/jpeg',
'.jpeg' => 'image/jpeg',
'.gif' => 'image/gif',
'.svg' => 'image/svg+xml',
'.ico' => 'image/x-icon',
'.woff' => 'font/woff',
'.woff2'=> 'font/woff2',
);
sub _mime_type {
my ($path) = @_;
my ($ext) = $path =~ /(\.[^.]+)$/;
return $MIME_TYPES{lc($ext // '')} // 'application/octet-stream';
}
sub send_file {
my ($proto, $path, %opts) = @_;
my $self = $proto->_self_or_new;
croak("File not found: $path") unless -f $path;
croak("Cannot read file: $path") unless -r $path;
# Get file size
my $file_size = -s $path;
# Handle offset and length for range requests
my $offset = $opts{offset} // 0;
my $length = $opts{length};
# Validate offset
croak("offset must be non-negative") if $offset < 0;
croak("offset exceeds file size") if $offset > $file_size;
# Calculate actual length to send
my $max_length = $file_size - $offset;
if (defined $length) {
croak("length must be non-negative") if $length < 0;
$length = $max_length if $length > $max_length;
} else {
$length = $max_length;
}
# Set content-type if not already set
$self->content_type_try(_mime_type($path));
# Set content-length based on actual bytes to send
$self->header('content-length', $length);
# Set content-disposition
my $disposition;
if ($opts{inline}) {
$disposition = 'inline';
} elsif ($opts{filename}) {
# Sanitize filename for header
my $safe_filename = $opts{filename};
$safe_filename =~ s/["\r\n]//g;
$disposition = "attachment; filename=\"$safe_filename\"";
}
$self->header('content-disposition', $disposition) if $disposition;
# Store the file send descriptor; respond() handles the actual emission.
# offset/length are stored only when they narrow the full-file default.
my $file_desc = { path => $path };
$file_desc->{offset} = $offset if $offset > 0;
$file_desc->{length} = $length if $length < $max_length;
$self->{_file} = $file_desc;
return $self;
}
# Writer class for streaming responses
package PAGI::Response::Writer {
use strict;
use warnings;
use Future::AsyncAwait;
use Carp qw(croak);
use Scalar::Util qw(blessed);
sub new {
my ($class, $send, %opts) = @_;
my $self = bless {
send => $send,
bytes_written => 0,
closed => 0,
_on_close => [],
}, $class;
push @{$self->{_on_close}}, $opts{on_close} if $opts{on_close};
return $self;
}
async sub write {
my ($self, $chunk) = @_;
die 'Writer already closed' if $self->{closed};
$self->{bytes_written} += length($chunk // '');
await $self->{send}->({
type => 'http.response.body',
body => $chunk,
more => 1,
});
}
async sub close {
my ($self) = @_;
return if $self->{closed};
$self->{closed} = 1;
await $self->{send}->({
type => 'http.response.body',
body => '',
more => 0,
});
for my $cb (@{$self->{_on_close}}) {
eval {
my $r = $cb->();
if (blessed($r) && $r->isa('Future')) {
await $r;
}
};
if ($@) {
warn "PAGI::Response::Writer on_close callback error: $@";
}
}
# Clear callback array to break any closure-based cycles
$self->{_on_close} = [];
}
sub on_close {
my ($self, $cb) = @_;
push @{$self->{_on_close}}, $cb;
return $self;
}
( run in 0.843 second using v1.01-cache-2.11-cpan-6aa56a78535 )