NAME
PAGI::Response - Fluent response builder for PAGI applications
SYNOPSIS
use PAGI::Response;
use Future::AsyncAwait;
# Basic usage in a raw PAGI app
async sub app ($scope, $receive, $send) {
my $res = PAGI::Response->new($scope, $send);
# Fluent chaining - set status, headers, then send
await $res->status(200)
->header('X-Custom' => 'value')
->json({ message => 'Hello' });
}
# Various response types
await $res->text("Hello World");
await $res->html("<h1>Hello</h1>");
await $res->json({ data => 'value' });
await $res->redirect('/login');
# Streaming large responses
await $res->stream(async sub ($writer) {
await $writer->write("chunk1");
await $writer->write("chunk2");
await $writer->close();
});
# File downloads
await $res->send_file('/path/to/file.pdf', filename => 'doc.pdf');
DESCRIPTION
PAGI::Response provides a fluent interface for building HTTP responses in raw PAGI applications. It wraps the low-level $send callback and provides convenient methods for common response types.
Chainable methods (status, header, content_type, cookie) return $self for fluent chaining.
Finisher methods (text, html, json, redirect, etc.) return Futures and actually send the response. Once a finisher is called, the response is sent and cannot be modified.
Important: Each PAGI::Response instance can only send one response. Attempting to call a finisher method twice will throw an error.
CONSTRUCTOR
new
my $res = PAGI::Response->new($scope, $send);
Creates a new response builder.
The scope is required because PAGI::Response stores the "response sent" flag in $scope->{'pagi.response.sent'}. This ensures that if multiple Response objects are created from the same scope (e.g., in middleware chains), they all share the same "sent" state and prevent double-sending responses.
Note: Per-object state like status and headers is NOT shared between Response objects. Only the "sent" flag is shared via scope. This matches the ASGI pattern where middleware wraps the $send callable to intercept/modify responses, and Response objects build their own status/headers before sending.
CHAINABLE METHODS
These methods return $self for fluent chaining.
status
$res->status(404);
my $code = $res->status;
Set or get the HTTP status code (100-599). Returns $self when setting for fluent chaining. When getting, returns 200 if no status has been set.
my $res = PAGI::Response->new($scope, $send);
$res->status; # 200 (default, nothing set yet)
$res->has_status; # false
$res->status(201); # set explicitly
$res->has_status; # true
status_try
$res->status_try(404);
Set the HTTP status code only if one hasn't been set yet. Useful in middleware or error handlers to provide fallback status codes without overriding choices made by the application:
$res->status_try(202); # sets to 202 (nothing was set)
$res->status_try(500); # no-op, 202 already set
header
$res->header('X-Custom' => 'value');
my $value = $res->header('X-Custom');
Add a response header. Can be called multiple times to add multiple headers. If called with only a name, returns the last value for that header or undef.
headers
my $headers = $res->headers;
Returns the full header arrayref [ name, value ] in order.
header_all
my @values = $res->header_all('Set-Cookie');
Returns all values for the given header name (case-insensitive).
header_try
$res->header_try('X-Custom' => 'value');
Add a response header only if that header name has not already been set.
content_type
$res->content_type('text/html; charset=utf-8');
my $type = $res->content_type;
Set the Content-Type header, replacing any existing one.
content_type_try
$res->content_type_try('text/html; charset=utf-8');
Set the Content-Type header only if it has not already been set.
cookie
$res->cookie('session' => 'abc123',
max_age => 3600,
path => '/',
domain => 'example.com',
secure => 1,
httponly => 1,
samesite => 'Strict',
);
Set a response cookie. Options: max_age, expires, path, domain, secure, httponly, samesite.
delete_cookie
$res->delete_cookie('session');
Delete a cookie by setting it with Max-Age=0.
scope
my $scope = $res->scope;
Returns the raw PAGI scope hashref. Useful for constructing helper objects like PAGI::Stash and PAGI::Session:
my $stash = PAGI::Stash->new($res);
Per-Request Shared State
See PAGI::Stash for per-request shared state. Construct from a Response object or from the shared scope:
use PAGI::Stash;
my $stash = PAGI::Stash->new($res);
is_sent
if ($res->is_sent) {
warn "Response already sent, cannot send error";
return;
}
Returns true if the response has already been finalized (sent to the client). Useful in error handlers or middleware that need to check whether they can still send a response.
has_status
if ($res->has_status) { ... }
Returns true if a status code has been explicitly set via status or status_try.
has_header
if ($res->has_header('content-type')) { ... }
Returns true if the given header name has been set via header or header_try. Header names are case-insensitive.
has_content_type
if ($res->has_content_type) { ... }
Returns true if Content-Type has been explicitly set via content_type, content_type_try, or header/header_try with a Content-Type name.
cors
# Allow all origins (simplest case)
$res->cors->json({ data => 'value' });
# Allow specific origin
$res->cors(origin => 'https://example.com')->json($data);
# Full configuration
$res->cors(
origin => 'https://example.com',
methods => [qw(GET POST PUT DELETE)],
headers => [qw(Content-Type Authorization)],
expose => [qw(X-Request-Id X-RateLimit-Remaining)],
credentials => 1,
max_age => 86400,
preflight => 0,
)->json($data);
Add CORS (Cross-Origin Resource Sharing) headers to the response. Returns $self for chaining.
Options:
origin- Allowed origin. Default:'*'(all origins). Can be a specific origin like'https://example.com'or'*'for any.methods- Arrayref of allowed HTTP methods for preflight. Default:[qw(GET POST PUT DELETE PATCH OPTIONS)].headers- Arrayref of allowed request headers for preflight. Default:[qw(Content-Type Authorization X-Requested-With)].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.credentials- Boolean. If true, setsAccess-Control-Allow-Credentials: true, allowing cookies and Authorization headers. Default:0.max_age- How long (in seconds) browsers should cache preflight results. Default:86400(24 hours).preflight- Boolean. If true, includes preflight response headers (Allow-Methods, Allow-Headers, Max-Age). Set this when handling OPTIONS requests. Default:0.request_origin- The Origin header value from the request. Required whencredentialsis true andoriginis'*', because the CORS spec forbids using'*'with credentials. Pass the actual request origin to echo it back.
Important CORS Notes:
When
credentialsis true, you cannot useorigin => '*'. Either specify an exact origin, or passrequest_originwith the client's actual Origin header.The
Vary: Originheader is always set to ensure proper caching when origin-specific responses are used.For preflight (OPTIONS) requests, set
preflight => 1and typically respond with$res->status(204)->empty().
FINISHER METHODS
These methods return Futures and send the response.
text
await $res->text("Hello World");
Send a plain text response with Content-Type: text/plain; charset=utf-8.
html
await $res->html("<h1>Hello</h1>");
Send an HTML response with Content-Type: text/html; charset=utf-8.
json
await $res->json({ message => 'Hello' });
Send a JSON response with Content-Type: application/json; charset=utf-8.
redirect
await $res->redirect('/login');
await $res->redirect('/new-url', 301);
Send a redirect response with an empty body. Default status is 302.
Note: This method sends the response but does NOT stop Perl execution. Use return after redirect if you have more code below:
await $res->redirect('/login');
return; # Important! Code below would still run otherwise
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, use the lower-level $send->() calls directly.
empty
await $res->empty();
Send an empty response with status 204 No Content (or custom status if set).
send
await $res->send($text);
await $res->send($text, charset => 'iso-8859-1');
Send text, encoding it to UTF-8 (or specified charset). Adds charset to Content-Type if not present. This is the high-level method for sending text responses.
send_raw
await $res->send_raw($bytes);
Send raw bytes as the response body without any encoding. Use this for binary data or when you've already encoded the content yourself.
stream
await $res->stream(async sub ($writer) {
await $writer->write("chunk1");
await $writer->write("chunk2");
await $writer->close();
});
Stream response chunks via callback. The callback receives a writer object with write($chunk), close(), and bytes_written() methods.
writer
my $writer = await $res->writer;
my $writer = await $res->writer(on_close => sub { cleanup() });
my $writer = await $res->writer(on_close => async sub { await cleanup() });
Returns a PAGI::Response::Writer directly, sending headers immediately. Unlike stream(), the writer is not scoped to a callback — you own it and must call close() when done.
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(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 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 "on_close" under "WRITER OBJECT".
send_file
await $res->send_file('/path/to/file.pdf');
await $res->send_file('/path/to/file.pdf',
filename => 'download.pdf',
inline => 1,
);
# Partial file (for range requests)
await $res->send_file('/path/to/video.mp4',
offset => 1024, # Start from byte 1024
length => 65536, # Send 64KB
);
Send a file as the response. This method uses the PAGI protocol's file key for efficient server-side streaming. The file is not read into memory. For production, use PAGI::Middleware::XSendfile to delegate file serving to your reverse proxy.
Options:
filename- Set Content-Disposition attachment filenameinline- Use Content-Disposition: inline instead of attachmentoffset- Start position in bytes (default: 0). For range requests.length- Number of bytes to send. Defaults to file size minus offset.
Range Request Example:
# Manual range request handling
async sub handle_video ($req, $send) {
my $res = PAGI::Response->new($scope, $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 $res->status(206)
->header('Content-Range' => "bytes $start-$end/$size")
->header('Accept-Ranges' => 'bytes')
->send_file($path, offset => $start, length => $length);
}
return await $res->header('Accept-Ranges' => 'bytes')
->send_file($path);
}
Note: For production file serving with full features (ETag caching, automatic range request handling, conditional GETs, directory indexes), use PAGI::App::File instead:
use PAGI::App::File;
my $files = PAGI::App::File->new(root => '/var/www/static');
my $app = $files->to_app;
EXAMPLES
Complete Raw PAGI Application
use Future::AsyncAwait;
use PAGI::Request;
use PAGI::Response;
my $app = async sub ($scope, $receive, $send) {
return await handle_lifespan($scope, $receive, $send)
if $scope->{type} eq 'lifespan';
my $req = PAGI::Request->new($scope, $receive);
my $res = PAGI::Response->new($scope, $send);
if ($req->method eq 'GET' && $req->path eq '/') {
return await $res->html('<h1>Welcome</h1>');
}
if ($req->method eq 'POST' && $req->path eq '/api/users') {
my $data = await $req->json;
# ... create user ...
return await $res->status(201)
->header('Location' => '/api/users/123')
->json({ id => 123, name => $data->{name} });
}
return await $res->status(404)->json({ error => 'Not Found' });
};
Form Validation with Error Response
async sub handle_contact ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
my $form = await $req->form_params;
my @errors;
my $email = $form->get('email') // '';
my $message = $form->get('message') // '';
push @errors, 'Email required' unless $email;
push @errors, 'Invalid email' unless $email =~ /@/;
push @errors, 'Message required' unless $message;
if (@errors) {
return await $res->status(422)
->json({ error => 'Validation failed', errors => \@errors });
}
# Process valid form...
return await $res->json({ success => 1 });
}
Authentication with Cookies
async sub handle_login ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
my $data = await $req->json;
my $user = authenticate($data->{email}, $data->{password});
unless ($user) {
return await $res->status(401)->json({ error => 'Invalid credentials' });
}
my $session_id = create_session($user);
return await $res->cookie('session' => $session_id,
path => '/',
httponly => 1,
secure => 1,
samesite => 'Strict',
max_age => 86400, # 24 hours
)
->json({ user => { id => $user->{id}, name => $user->{name} } });
}
async sub handle_logout ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
return await $res->delete_cookie('session', path => '/')
->json({ logged_out => 1 });
}
File Download
async sub handle_download ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
my $file_id = $req->path_param('id');
my $file = get_file($file_id); # Be sure to clean $file
unless ($file && -f $file->{path}) {
return await $res->status(404)->json({ error => 'File not found' });
}
return await $res->send_file($file->{path},
filename => $file->{original_name},
);
}
Streaming Large Data
async sub handle_export ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
await $res->content_type('text/csv')
->header('Content-Disposition' => 'attachment; filename="export.csv"')
->stream(async sub ($writer) {
# Write CSV header
await $writer->write("id,name,email\n");
# Stream rows from database
my $cursor = get_all_users_cursor();
while (my $user = $cursor->next) {
await $writer->write("$user->{id},$user->{name},$user->{email}\n");
}
});
}
Server-Sent Events Style Streaming
async sub handle_events ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
await $res->content_type('text/event-stream')
->header('Cache-Control' => 'no-cache')
->stream(async sub ($writer) {
for my $i (1..10) {
await $writer->write("data: Event $i\n\n");
await some_delay(1); # Wait 1 second
}
});
}
Conditional Responses
async sub handle_resource ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
my $etag = '"abc123"';
# Check If-None-Match for caching
my $if_none_match = $req->header('If-None-Match') // '';
if ($if_none_match eq $etag) {
return await $res->status(304)->empty();
}
return await $res->header('ETag' => $etag)
->header('Cache-Control' => 'max-age=3600')
->json({ data => 'expensive computation result' });
}
CORS API Endpoint
# Simple CORS - allow all origins
async sub handle_api ($scope, $receive, $send) {
my $res = PAGI::Response->new($scope, $send);
return await $res->cors->json({ status => 'ok' });
}
# CORS with credentials (e.g., cookies, auth headers)
async sub handle_api_with_auth ($scope, $receive, $send) {
my $req = PAGI::Request->new($scope, $receive);
my $res = PAGI::Response->new($scope, $send);
# Get the Origin header from request
my $origin = $req->header('Origin');
return await $res->cors(
origin => 'https://myapp.com', # Or use request_origin
credentials => 1,
expose => [qw(X-Request-Id)],
)->json({ user => 'authenticated' });
}
CORS Preflight Handler
# Handle OPTIONS preflight requests
async sub app ($scope, $receive, $send) {
my $req = PAGI::Request->new($scope, $receive);
my $res = PAGI::Response->new($scope, $send);
# Handle preflight
if ($req->method eq 'OPTIONS') {
return await $res->cors(
origin => 'https://myapp.com',
methods => [qw(GET POST PUT DELETE)],
headers => [qw(Content-Type Authorization X-Custom-Header)],
credentials => 1,
max_age => 86400,
preflight => 1, # Include preflight headers
)->status(204)->empty();
}
# Handle actual request
return await $res->cors(
origin => 'https://myapp.com',
credentials => 1,
)->json({ data => 'response' });
}
Dynamic CORS Origin
# Allow multiple origins dynamically
my %ALLOWED_ORIGINS = map { $_ => 1 } qw(
https://app1.example.com
https://app2.example.com
https://localhost:3000
);
async sub handle_api ($scope, $receive, $send) {
my $req = PAGI::Request->new($scope, $receive);
my $res = PAGI::Response->new($scope, $send);
my $request_origin = $req->header('Origin') // '';
# Check if origin is allowed
if ($ALLOWED_ORIGINS{$request_origin}) {
return await $res->cors(
origin => $request_origin, # Echo back the allowed origin
credentials => 1,
)->json({ data => 'allowed' });
}
# Origin not allowed - respond without CORS headers
return await $res->status(403)->json({ error => 'Origin not allowed' });
}
WRITER OBJECT
The stream() method passes a writer object to its callback, and writer() returns one directly. The writer has the following methods:
write
await $writer->write($chunk);
Write a chunk of data to the response stream. Returns a Future.
Writing after close returns a failed Future rather than throwing. This allows cleanup code that races with close to handle the error gracefully via await.
close
await $writer->close;
Close the stream. Returns a Future. Calling close multiple times is safe — subsequent calls are no-ops.
bytes_written
my $n = $writer->bytes_written;
Returns the total number of bytes written so far.
on_close
# Sync callback
$writer->on_close(sub { cleanup() });
# Async callback — return value is awaited automatically
$writer->on_close(async sub {
await notify_stream_ended();
});
# Chaining
$writer->on_close(sub { ... })
->on_close(sub { ... });
Registers a callback to fire when the writer closes (either explicitly or via stream() auto-close). Callbacks can be regular subs or async subs — async results are automatically awaited. Multiple callbacks run in registration order. Exceptions are caught and warned but do not prevent other callbacks from running. Returns $self for chaining.
Circular reference note: If your callback captures the writer object in a closure, use Scalar::Util::weaken to avoid a memory leak:
use Scalar::Util qw(weaken);
my $weak_writer = $writer;
weaken($weak_writer);
$writer->on_close(sub { $weak_writer->... if $weak_writer });
The callback array is cleared after firing, so any cycle via a closure is broken when the writer closes, but weaken prevents the object from being kept alive until that point.
is_closed
if ($writer->is_closed) { ... }
Returns true if the writer has been closed.
The writer automatically closes when the stream() callback completes, but calling close() explicitly is recommended for clarity.
ERROR HANDLING
All finisher methods return Futures. Errors in encoding (e.g., invalid UTF-8 when strict mode would be enabled) will cause the Future to fail.
use Syntax::Keyword::Try;
try {
await $res->json($data);
}
catch ($e) {
warn "Failed to send response: $e";
}
SEE ALSO
PAGI, PAGI::Request, PAGI::Server
AUTHOR
PAGI Contributors
1 POD Error
The following errors were encountered while parsing the POD:
- Around line 379:
Non-ASCII character seen before =encoding in '—'. Assuming UTF-8