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).

$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.

$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.

$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, sets Access-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 when credentials is true and origin is '*', because the CORS spec forbids using '*' with credentials. Pass the actual request origin to echo it back.

Important CORS Notes:

  • When credentials is true, you cannot use origin = '*'>. Either specify an exact origin, or pass request_origin with the client's actual Origin header.

  • The Vary: Origin header 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 filename

  • inline - Use Content-Disposition: inline instead of attachment

  • offset - 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