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($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($send);
my $res = PAGI::Response->new($send, $scope);
Creates a new response builder. The $send parameter must be a coderef (the PAGI send callback). The optional $scope parameter is the PAGI scope hashref, needed for route parameter access.
CHAINABLE METHODS
These methods return $self for fluent chaining.
status
$res->status(404);
Set the HTTP status code (100-599).
header
$res->header('X-Custom' => 'value');
Add a response header. Can be called multiple times to add multiple headers.
content_type
$res->content_type('text/html; charset=utf-8');
Set the Content-Type header, replacing any existing one.
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.
path_param
my $id = $res->path_param('id');
Returns a path parameter by name. Path parameters are captured from the URL path by a router and stored in $scope->{path_params}. Returns undef if the parameter is not found or if no scope was provided.
path_params
my $params = $res->path_params;
Returns hashref of all path parameters from scope. Returns an empty hashref if no path parameters exist or if no scope was provided.
stash
my $user = $res->stash->{user};
Returns the per-request stash hashref. This is the same stash accessible via $req->stash, $ws->stash, and $sse->stash - it lives in $scope->{'pagi.stash'} and is shared across all objects in the request chain.
This allows handlers to read values set by middleware:
async sub handler {
my ($self, $req, $res) = @_;
my $user = $res->stash->{user}; # Set by auth middleware
await $res->json({ greeting => "Hello, $user->{name}" });
}
See "stash" in PAGI::Request for detailed documentation on how stash works.
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.
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 =1> and 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. Default status is 302.
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.
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, enabling efficient server-side streaming via sendfile() or similar zero-copy mechanisms. The file is not read into memory.
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($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($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($send);
my $form = await $req->form;
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($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($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($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($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($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($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($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($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($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($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 with these methods:
write($chunk)- Write a chunk (returns Future)close()- Close the stream (returns Future)bytes_written()- Get total bytes written so far
The writer automatically closes when the 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";
}
RECIPES
Background Tasks
Run tasks after the response is sent. There are three patterns depending on what kind of work you're doing:
Pattern 1: Fire-and-Forget Async I/O (Non-Blocking)
For async operations (HTTP calls, database queries using async drivers), call them without await and use ->retain() to prevent the "lost future" warning:
await $res->json({ status => 'queued' });
# Fire-and-forget: retain() prevents GC warning
send_async_email($user)->retain();
log_to_analytics($event)->retain();
Pattern 2: Blocking/CPU Work (IO::Async::Function)
For blocking operations (sync libraries, CPU-intensive work), use IO::Async::Function to run in a subprocess:
use IO::Async::Function;
my $worker = IO::Async::Function->new(
code => sub {
my ($data) = @_;
# This runs in a CHILD PROCESS - can block safely
sleep 5; # Won't block event loop
return process($data);
},
);
$res->loop->add($worker);
await $res->json({ status => 'processing' });
# Fire-and-forget in subprocess
my $f = $worker->call(args => [$data]);
$f->on_done(sub { warn "Done: @_\n" });
$f->on_fail(sub { warn "Error: @_\n" });
$f->retain();
Pattern 3: Quick Sync Work (loop->later)
For very fast sync operations (logging, incrementing counters):
await $res->json({ status => 'ok' });
$res->loop->later(sub {
log_request(); # Must be FAST (<10ms)
});
WARNING: Any blocking code in loop->later blocks the entire event loop. No other requests can be processed. Use IO::Async::Function for anything that might take time.
See also: examples/background-tasks/app.pl
SEE ALSO
PAGI, PAGI::Request, PAGI::Server
AUTHOR
PAGI Contributors