NAME

PAGI::Cookbook - Recipes for Common PAGI Tasks

DESCRIPTION

This cookbook provides ready-to-use solutions for common web development tasks with PAGI. Each recipe is self-contained and can be adapted to your needs.

Prerequisites: This cookbook assumes you've read the PAGI::Tutorial, particularly Parts 1-3 covering the raw PAGI protocol, middleware, and helper classes.

ROUTING RECIPES

These recipes cover advanced routing patterns using PAGI::App::Router and PAGI::Endpoint::Router. For basic routing, see the Tutorial's Built-in Applications section.

Path Parameters and Wildcards

Use :param syntax for named parameters in routes:

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::Request;
use PAGI::Response;

my $router = PAGI::App::Router->new;

# Single parameter
$router->get('/users/:id' => async sub {
    my ($scope, $receive, $send) = @_;
    my $req = PAGI::Request->new($scope, $receive);
    my $res = PAGI::Response->new($scope, $send);

    # Access path parameter via $req->path_param()
    my $id = $req->path_param('id');

    await $res->json({ id => $id, name => 'User ' . $id });
});

# Multiple parameters
$router->get('/posts/:post_id/comments/:comment_id' => async sub {
    my ($scope, $receive, $send) = @_;
    my $req = PAGI::Request->new($scope, $receive);
    my $res = PAGI::Response->new($scope, $send);

    my $post_id = $req->path_param('post_id');
    my $comment_id = $req->path_param('comment_id');

    await $res->json({
        post_id => $post_id,
        comment_id => $comment_id,
    });
});

$router->to_app;

Parameters are stored in $scope->{path_params} and accessed via $req->path_param($name).

Wildcard Routes

Use *name syntax to capture multiple path segments:

my $router = PAGI::App::Router->new;

# Wildcard captures everything after /files/
$router->get('/files/*path' => async sub {
    my ($scope, $receive, $send) = @_;
    my $req = PAGI::Request->new($scope, $receive);
    my $res = PAGI::Response->new($scope, $send);

    # Access wildcard via $req->path_param()
    my $path = $req->path_param('path');

    # /files/docs/readme.txt -> path = 'docs/readme.txt'
    # /files/images/logo.png -> path = 'images/logo.png'

    await $res->text("File path: $path");
});

$router->to_app;

Wildcards match one or more path segments, while named parameters (:param) match a single segment.

WebSocket and SSE Routes

Route WebSocket connections by path:

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::WebSocket;

my $router = PAGI::App::Router->new;

# WebSocket echo server
$router->websocket('/ws' => async sub {
    my ($scope, $receive, $send) = @_;
    my $ws = PAGI::WebSocket->new($scope, $receive, $send);

    await $ws->accept;

    await $ws->each_text(async sub {
        my ($text) = @_;
        await $ws->send_text("Echo: $text");
    });
});

# WebSocket with path parameters
$router->websocket('/ws/chat/:room' => async sub {
    my ($scope, $receive, $send) = @_;
    my $ws = PAGI::WebSocket->new($scope, $receive, $send);

    # Access room parameter
    my $room = $ws->stash->{'pagi.params'}{room};

    await $ws->accept;
    await $ws->send_text("Joined room: $room");

    # ... chat logic ...
});

$router->to_app;

SSE Routes

Route Server-Sent Events by path:

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::SSE;

my $router = PAGI::App::Router->new;

# SSE clock
$router->sse('/events' => async sub {
    my ($scope, $receive, $send) = @_;
    my $sse = PAGI::SSE->new($scope, $receive, $send);

    await $sse->start;

    await $sse->every(1, async sub {
        await $sse->send_event("Time: " . time);
    });
});

# SSE with path parameters
$router->sse('/events/:channel' => async sub {
    my ($scope, $receive, $send) = @_;
    my $sse = PAGI::SSE->new($scope, $receive, $send);

    # Access channel parameter
    my $channel = $sse->stash->{'pagi.params'}{channel};

    await $sse->start;
    await $sse->send_event("Subscribed to: $channel");

    # ... streaming logic ...
});

$router->to_app;

Route-Level Middleware

Apply middleware to specific routes by passing an arrayref:

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::Request;
use PAGI::Response;

my $router = PAGI::App::Router->new;

# Define middleware
my $auth_mw = async sub {
    my ($scope, $receive, $send, $next) = @_;

    # Check authorization
    my $token = '';
    for my $h (@{$scope->{headers}}) {
        if (lc($h->[0]) eq 'authorization') {
            $token = $h->[1];
            last;
        }
    }

    unless ($token eq 'Bearer secret123') {
        my $res = PAGI::Response->new($scope, $send);
        await $res->status(401)->json({ error => 'Unauthorized' });
        return;  # Don't call $next->()
    }

    # Authorized - continue to handler
    await $next->();
};

# Apply middleware to specific routes
$router->get('/admin' => [$auth_mw] => async sub {
    my ($scope, $receive, $send) = @_;
    my $res = PAGI::Response->new($scope, $send);
    await $res->text('Admin panel');
});

# Multiple middleware
my $log_mw = async sub {
    my ($scope, $receive, $send, $next) = @_;
    warn "Request: $scope->{method} $scope->{path}\n";
    await $next->();
};

$router->post('/admin/users' => [$log_mw, $auth_mw] => async sub {
    my ($scope, $receive, $send) = @_;
    # ... handler ...
});

$router->to_app;

Middleware are executed in order ($log_mw runs before $auth_mw in the example above).

Nested Routers and Mounting

Use mount() to attach sub-applications or routers at a prefix:

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::Request;
use PAGI::Response;

# Create API router
my $api_router = PAGI::App::Router->new;
$api_router->get('/users' => async sub {
    my ($scope, $receive, $send) = @_;
    my $res = PAGI::Response->new($scope, $send);
    await $res->json([{ id => 1, name => 'Alice' }]);
});

# Create main router
my $main_router = PAGI::App::Router->new;

# Mount API router at /api prefix
# /api/users will be handled by $api_router
$main_router->mount('/api' => $api_router->to_app);

# Mount with middleware (applies to all sub-routes)
my $cors_mw = async sub {
    my ($scope, $receive, $send, $next) = @_;
    # Add CORS headers...
    await $next->();
};

$main_router->mount('/api' => [$cors_mw] => $api_router->to_app);

$main_router->to_app;

Class-Based Routing with PAGI::Endpoint::Router

PAGI::Endpoint::Router provides an object-oriented approach to routing. You define a class that extends PAGI::Endpoint::Router, implement a routes() method, and use method names as handlers.

Basic Router Class

package MyApp;
use parent 'PAGI::Endpoint::Router';
use strict;
use warnings;
use Future::AsyncAwait;

# Define routes
sub routes {
    my ($self, $r) = @_;

    $r->get('/' => 'home');
    $r->get('/about' => 'about');
    $r->post('/contact' => 'contact');
}

# Handler methods receive ($self, $req, $res) for HTTP
async sub home {
    my ($self, $req, $res) = @_;
    await $res->html('<h1>Welcome!</h1>');
}

async sub about {
    my ($self, $req, $res) = @_;
    await $res->html('<h1>About Us</h1>');
}

async sub contact {
    my ($self, $req, $res) = @_;
    my $data = await $req->json;
    # ... process contact form ...
    await $res->json({ success => 1 });
}

1;

# In app.pl:
# use MyApp;
# MyApp->to_app;

Handler methods are called with:

  • $self - Router instance (access $self->state for per-instance data)

  • $req - PAGI::Request object (already constructed)

  • $res - PAGI::Response object (already constructed)

WebSocket Handlers

WebSocket handlers receive ($self, $ws):

package MyApp;
use parent 'PAGI::Endpoint::Router';
use strict;
use warnings;
use Future::AsyncAwait;

sub routes {
    my ($self, $r) = @_;

    $r->get('/' => 'home');
    $r->websocket('/ws' => 'handle_ws');
    $r->websocket('/ws/chat/:room' => 'handle_chat');
}

async sub home {
    my ($self, $req, $res) = @_;
    await $res->html('<h1>Home</h1>');
}

# WebSocket handler receives ($self, $ws)
async sub handle_ws {
    my ($self, $ws) = @_;

    await $ws->accept;

    await $ws->each_text(async sub {
        my ($text) = @_;
        await $ws->send_text("Echo: $text");
    });
}

# WebSocket with path parameters
async sub handle_chat {
    my ($self, $ws) = @_;

    # Access path parameter via $ws->path_param()
    my $room = $ws->path_param('room');

    await $ws->accept;
    await $ws->send_text("Joined room: $room");

    # ... chat logic ...
}

1;

SSE Handlers

SSE handlers receive ($self, $sse):

package MyApp;
use parent 'PAGI::Endpoint::Router';
use strict;
use warnings;
use Future::AsyncAwait;

sub routes {
    my ($self, $r) = @_;

    $r->get('/' => 'home');
    $r->sse('/events' => 'handle_events');
    $r->sse('/events/:channel' => 'handle_channel');
}

async sub home {
    my ($self, $req, $res) = @_;
    await $res->html('<h1>Home</h1>');
}

# SSE handler receives ($self, $sse)
async sub handle_events {
    my ($self, $sse) = @_;

    await $sse->start;

    await $sse->every(1, async sub {
        await $sse->send_event("Time: " . time);
    });
}

# SSE with path parameters
async sub handle_channel {
    my ($self, $sse) = @_;

    # Access path parameter
    my $channel = $sse->scope->{'pagi.params'}{channel};

    await $sse->start;
    await $sse->send_event("Channel: $channel");

    # ... streaming logic ...
}

1;

Lifecycle Hooks

Override on_startup and on_shutdown for application lifecycle management:

package MyApp;
use parent 'PAGI::Endpoint::Router';
use strict;
use warnings;
use Future::AsyncAwait;

# Called when application starts
async sub on_startup {
    my ($self) = @_;

    # Initialize resources
    $self->state->{db} = DBI->connect('dbi:SQLite:dbname=app.db');
    $self->state->{started_at} = time();

    warn "Application started\n";
}

# Called when application shuts down
async sub on_shutdown {
    my ($self) = @_;

    # Clean up resources
    $self->state->{db}->disconnect if $self->state->{db};

    warn "Application shut down\n";
}

sub routes {
    my ($self, $r) = @_;

    $r->get('/' => 'home');
}

async sub home {
    my ($self, $req, $res) = @_;

    # Access state initialized in on_startup
    my $db = $self->state->{db};
    my $uptime = time() - $self->state->{started_at};

    await $res->json({
        uptime => $uptime,
        database => defined($db) ? 'connected' : 'disconnected',
    });
}

1;

Note: $self->state is per-worker in multi-worker mode. For shared state, use an external store (Redis, database, etc.).

Route-Level Middleware with Method Names

Define middleware methods and reference them by name:

package MyApp;
use parent 'PAGI::Endpoint::Router';
use strict;
use warnings;
use Future::AsyncAwait;

sub routes {
    my ($self, $r) = @_;

    $r->get('/' => 'home');

    # Apply middleware by method name
    $r->get('/admin' => ['require_auth'] => 'admin_page');

    # Multiple middleware
    $r->post('/admin/users' => ['log_request', 'require_auth'] => 'create_user');
}

# Middleware method signature: ($self, $req, $res, $next)
async sub require_auth {
    my ($self, $req, $res, $next) = @_;

    my $token = $req->header('authorization');

    unless ($token && $token eq 'Bearer secret123') {
        await $res->status(401)->json({ error => 'Unauthorized' });
        return;  # Don't call $next->()
    }

    # Authorized - continue to handler
    await $next->();
}

async sub log_request {
    my ($self, $req, $res, $next) = @_;

    warn "Request: " . $req->method . " " . $req->path . "\n";

    await $next->();
}

async sub home {
    my ($self, $req, $res) = @_;
    await $res->html('<h1>Home</h1>');
}

async sub admin_page {
    my ($self, $req, $res) = @_;
    await $res->html('<h1>Admin Panel</h1>');
}

async sub create_user {
    my ($self, $req, $res) = @_;
    my $data = await $req->json;
    # ... create user ...
    await $res->status(201)->json({ success => 1 });
}

1;

Middleware methods receive ($self, $req, $res, $next) and must call await $next->() to continue to the handler.

Mounting Sub-Routers

Create modular applications by mounting sub-routers:

# lib/MyApp/API.pm
package MyApp::API;
use parent 'PAGI::Endpoint::Router';
use strict;
use warnings;
use Future::AsyncAwait;

sub routes {
    my ($self, $r) = @_;

    $r->get('/users' => 'list_users');
    $r->get('/users/:id' => 'get_user');
    $r->post('/users' => 'create_user');
}

async sub list_users {
    my ($self, $req, $res) = @_;
    await $res->json([
        { id => 1, name => 'Alice' },
        { id => 2, name => 'Bob' },
    ]);
}

async sub get_user {
    my ($self, $req, $res) = @_;
    my $id = $req->path_param('id');
    await $res->json({ id => $id, name => "User $id" });
}

async sub create_user {
    my ($self, $req, $res) = @_;
    my $data = await $req->json;
    await $res->status(201)->json({ id => 3, name => $data->{name} });
}

1;

# lib/MyApp.pm
package MyApp;
use parent 'PAGI::Endpoint::Router';
use strict;
use warnings;
use Future::AsyncAwait;
use MyApp::API;

sub routes {
    my ($self, $r) = @_;

    $r->get('/' => 'home');

    # Mount API router at /api prefix
    $r->mount('/api' => MyApp::API->to_app);
}

async sub home {
    my ($self, $req, $res) = @_;
    await $res->html('<h1>Home</h1>');
}

1;

# app.pl
# use MyApp;
# MyApp->to_app;

Now /api/users and /api/users/:id are handled by MyApp::API.

Complete Example

Here's a complete working example demonstrating all features:

package MyApp;
use parent 'PAGI::Endpoint::Router';
use strict;
use warnings;
use Future::AsyncAwait;

async sub on_startup {
    my ($self) = @_;
    $self->state->{started} = time();
    $self->state->{users} = [
        { id => 1, name => 'Alice' },
        { id => 2, name => 'Bob' },
    ];
}

sub routes {
    my ($self, $r) = @_;

    # HTTP routes
    $r->get('/' => 'home');
    $r->get('/users' => 'list_users');
    $r->get('/users/:id' => 'get_user');
    $r->post('/users' => 'create_user');

    # Protected route
    $r->get('/admin' => ['require_auth'] => 'admin');

    # WebSocket
    $r->websocket('/ws' => 'ws_echo');

    # SSE
    $r->sse('/events' => 'events');
}

async sub require_auth {
    my ($self, $req, $res, $next) = @_;
    my $token = $req->header('authorization');
    return await $res->status(401)->json({ error => 'Unauthorized' })
        unless $token && $token eq 'Bearer secret';
    await $next->();
}

async sub home {
    my ($self, $req, $res) = @_;
    await $res->html('<h1>Welcome to MyApp</h1>');
}

async sub list_users {
    my ($self, $req, $res) = @_;
    await $res->json($self->state->{users});
}

async sub get_user {
    my ($self, $req, $res) = @_;
    my $id = $req->path_param('id');
    my ($user) = grep { $_->{id} == $id } @{$self->state->{users}};
    return await $res->status(404)->json({ error => 'Not found' })
        unless $user;
    await $res->json($user);
}

async sub create_user {
    my ($self, $req, $res) = @_;
    my $data = await $req->json;
    my $user = { id => scalar(@{$self->state->{users}}) + 1, name => $data->{name} };
    push @{$self->state->{users}}, $user;
    await $res->status(201)->json($user);
}

async sub admin {
    my ($self, $req, $res) = @_;
    await $res->json({ message => 'Admin access granted' });
}

async sub ws_echo {
    my ($self, $ws) = @_;
    await $ws->accept;
    await $ws->each_text(async sub {
        my ($text) = @_;
        await $ws->send_text("Echo: $text");
    });
}

async sub events {
    my ($self, $sse) = @_;
    await $sse->start;
    await $sse->every(1, async sub {
        await $sse->send_json({ time => time });
    });
}

1;

Run it:

# app.pl
use MyApp;
MyApp->to_app;

# Terminal:
pagi-server app.pl --port 5000

Test it:

# HTTP
curl http://localhost:5000/
curl http://localhost:5000/users
curl http://localhost:5000/users/1
curl -X POST http://localhost:5000/users -H 'Content-Type: application/json' -d '{"name":"Charlie"}'

# Protected route
curl http://localhost:5000/admin
curl -H 'Authorization: Bearer secret' http://localhost:5000/admin

# WebSocket (JavaScript)
const ws = new WebSocket('ws://localhost:5000/ws');
ws.onmessage = (e) => console.log(e.data);
ws.send('Hello!');

# SSE (JavaScript)
const sse = new EventSource('http://localhost:5000/events');
sse.onmessage = (e) => console.log(JSON.parse(e.data));

See examples/endpoint-router-demo/ for more examples.

AUTHENTICATION & SESSIONS

Session Management

Use PAGI::Middleware::Session for session management:

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::Middleware::Session;
use PAGI::Request;
use PAGI::Response;

my $router = PAGI::App::Router->new;

# Add session middleware
$router->use(PAGI::Middleware::Session->new(
    secret => 'your-secret-key-here',
    cookie_name => 'session_id',
    expires => 86400,  # 1 day
));

$router->get('/login' => async sub {
    my ($scope, $receive, $send) = @_;
    my $req = PAGI::Request->new($scope, $receive);
    my $res = PAGI::Response->new($scope, $send);

    # Set session data
    $req->session->{user_id} = 123;
    $req->session->{username} = 'alice';

    await $res->text('Logged in!');
});

$router->get('/profile' => async sub {
    my ($scope, $receive, $send) = @_;
    my $req = PAGI::Request->new($scope, $receive);
    my $res = PAGI::Response->new($scope, $send);

    # Read session data
    my $username = $req->session->{username} // 'Guest';

    await $res->text("Hello, $username!");
});

$router->get('/logout' => async sub {
    my ($scope, $receive, $send) = @_;
    my $req = PAGI::Request->new($scope, $receive);
    my $res = PAGI::Response->new($scope, $send);

    # Clear session
    $req->session({});

    await $res->text('Logged out!');
});

$router->to_app;

Basic Authentication

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::Middleware::BasicAuth;
use PAGI::Response;

my $router = PAGI::App::Router->new;

# Protect routes with Basic Auth
$router->use(PAGI::Middleware::BasicAuth->new(
    realm => 'Admin Area',
    authenticator => sub {
        my ($username, $password) = @_;
        return $username eq 'admin' && $password eq 'secret';
    },
));

$router->get('/' => async sub {
    my ($scope, $receive, $send) = @_;
    my $res = PAGI::Response->new($scope, $send);
    await $res->text('Protected content');
});

$router->to_app;

Bearer Token (JWT)

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::Middleware::Auth::Bearer;
use PAGI::Request;
use PAGI::Response;

my $router = PAGI::App::Router->new;

# Verify JWT tokens
$router->use(PAGI::Middleware::Auth::Bearer->new(
    validate => sub {
        my ($token) = @_;
        # Return user data if valid, undef if not
        return decode_jwt($token);  # Your JWT decode logic
    },
));

$router->get('/api/me' => async sub {
    my ($scope, $receive, $send) = @_;
    my $req = PAGI::Request->new($scope, $receive);
    my $res = PAGI::Response->new($scope, $send);

    # User data from validated token
    my $user = $req->stash->{user};

    await $res->json({ user => $user });
});

$router->to_app;

REAL-TIME PATTERNS

PubSub for WebSocket Chat

For real-time features with multiple WebSocket clients:

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::WebSocket;

# Simple in-memory room tracking
my %rooms;  # room => { client_id => $ws }
my $next_id = 1;

my $router = PAGI::App::Router->new;

$router->websocket('/chat/:room' => async sub {
    my ($scope, $receive, $send) = @_;
    my $ws = PAGI::WebSocket->new($scope, $receive, $send);
    my $room = $scope->{'pagi.params'}{room};
    my $client_id = $next_id++;

    await $ws->accept;

    # Join room
    $rooms{$room}{$client_id} = $ws;

    # Broadcast received messages to all clients in room
    await $ws->each_text(sub {
        my ($text) = @_;
        for my $id (keys %{$rooms{$room}}) {
            next if $id == $client_id;  # Don't echo back to sender
            $rooms{$room}{$id}->try_send_text($text);
        }
    });

    # Leave room on disconnect
    delete $rooms{$room}{$client_id};
    delete $rooms{$room} unless keys %{$rooms{$room}};
});

$router->to_app;

Important: This in-memory approach works within a single process. For multi-worker or multi-server deployments, use Redis pub/sub or a message broker.

See PAGI::App::WebSocket::Chat for a full-featured chat application.

SSE Dashboard

Server-Sent Events are ideal for real-time dashboards:

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::SSE;

my $router = PAGI::App::Router->new;

$router->get('/events' => async sub {
    my ($scope, $receive, $send) = @_;

    my $sse = PAGI::SSE->new($scope, $receive, $send);
    await $sse->start;

    # Enable keepalive to prevent timeout
    $sse->keepalive(15);

    # Send updates every second
    await $sse->every(1, async sub {
        await $sse->send_json({
            cpu => rand(100),
            memory => rand(100),
            requests => int(rand(1000)),
            timestamp => time(),
        }, event => 'metrics');
    });
});

$router->to_app;

JavaScript client:

const events = new EventSource('/events');

events.addEventListener('metrics', (e) => {
    const data = JSON.parse(e.data);
    document.getElementById('cpu').textContent = data.cpu.toFixed(1) + '%';
    document.getElementById('memory').textContent = data.memory.toFixed(1) + '%';
});

See examples/sse-dashboard/ for a complete dashboard example.

BACKGROUND WORK

Web requests should respond quickly. Long-running work should be done in the background.

Fire-and-Forget Async I/O

For non-blocking operations (async HTTP calls, async database queries), use ->on_fail() to handle errors, then ->retain() to prevent "lost future" warnings:

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::Response;

my $router = PAGI::App::Router->new;

# Simulated async email service
async sub send_welcome_email {
    my ($email) = @_;
    # In reality: await $http_client->post_async($email_api, ...);
    await IO::Async::Loop->new->delay_future(after => 2);
    warn "Email sent to $email\n";
}

$router->post('/signup' => async sub {
    my ($scope, $receive, $send) = @_;
    my $res = PAGI::Response->new($scope, $send);

    # Respond immediately
    await $res->status(201)->json({ status => 'created' });

    # Fire-and-forget with error handling
    # IMPORTANT: Always add on_fail() before retain() to avoid silent failures
    send_welcome_email('user@example.com')
        ->on_fail(sub { warn "Email send failed: @_" })
        ->retain();
});

$router->to_app;

Warning: Using ->retain() alone silently swallows errors. Always add ->on_fail() to log or handle failures.

Note: If you're writing middleware or server extensions that inherit from IO::Async::Notifier, prefer $self->adopt_future($f) instead of ->retain(). The adopt_future method properly tracks futures and propagates errors to the notifier's error handling.

CPU-Bound Work in Subprocess

For blocking or CPU-intensive work, use IO::Async::Function to run in a child process:

use strict;
use warnings;
use Future::AsyncAwait;
use IO::Async::Function;
use IO::Async::Loop;
use PAGI::App::Router;
use PAGI::Response;

my $loop = IO::Async::Loop->new;

# Worker runs code in subprocess
my $pdf_worker = IO::Async::Function->new(
    code => sub {
        my ($data) = @_;
        # This blocking code runs in a CHILD PROCESS
        # It doesn't block the main event loop
        sleep 5;  # Simulate heavy work
        return "PDF generated for $data";
    },
);
$loop->add($pdf_worker);

my $router = PAGI::App::Router->new;

$router->post('/generate-pdf' => async sub {
    my ($scope, $receive, $send) = @_;
    my $res = PAGI::Response->new($scope, $send);

    # Option A: Wait for result (blocks this request only)
    my $result = await $pdf_worker->call(args => ['report']);
    await $res->json({ result => $result });

    # Option B: Fire-and-forget (respond immediately)
    # my $f = $pdf_worker->call(args => ['report']);
    # $f->on_done(sub { warn "PDF ready: $_[0]\n" });
    # $f->on_fail(sub { warn "PDF failed: $_[0]\n" });
    # $f->retain();
    # await $res->json({ status => 'processing' });
});

$router->to_app;

Warning: IO::Async::Function forks child processes. If running with --workers N, each server worker forks its own Function workers. With 4 server workers and 2 Function workers each, you have 12 processes. For high-volume CPU work, consider a dedicated job queue instead.

See examples/background-tasks/ for complete working examples.

FORMS & UPLOADS

URL-Encoded Forms

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::Request;
use PAGI::Response;

async sub app {
    my ($scope, $receive, $send) = @_;

    my $req = PAGI::Request->new($scope, $receive);
    my $res = PAGI::Response->new($scope, $send);

    if ($req->method eq 'POST') {
        my $form = await $req->form;
        my $username = $form->{username};
        my $password = $form->{password};

        # Validate and process...
        await $res->redirect('/dashboard');
    }
    else {
        await $res->html(<<'HTML');
<form method="POST">
    <input name="username" placeholder="Username">
    <input name="password" type="password" placeholder="Password">
    <button type="submit">Login</button>
</form>
HTML
    }
}

\&app;

File Uploads (Multipart)

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::Request;
use PAGI::Response;

async sub app {
    my ($scope, $receive, $send) = @_;

    my $req = PAGI::Request->new($scope, $receive);
    my $res = PAGI::Response->new($scope, $send);

    if ($req->method eq 'POST') {
        my $uploads = await $req->uploads;

        for my $upload (@$uploads) {
            my $filename = $upload->filename;
            my $size = $upload->size;
            my $type = $upload->content_type;

            # Save to disk
            $upload->save_to('/uploads/' . $filename);

            # Or read content
            # my $content = $upload->content;
        }

        await $res->json({ uploaded => scalar(@$uploads) });
    }
    else {
        await $res->html(<<'HTML');
<form method="POST" enctype="multipart/form-data">
    <input type="file" name="file" multiple>
    <button type="submit">Upload</button>
</form>
HTML
    }
}

\&app;

See examples/10-forms-and-uploads/ for complete examples.

DEPLOYMENT

TLS/HTTPS

PAGI::Server supports TLS natively:

pagi-server app.pl --port 443 \
    --tls-cert /path/to/cert.pem \
    --tls-key /path/to/key.pem

For development, generate a self-signed certificate:

openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
    -days 365 -nodes -subj '/CN=localhost'

pagi-server app.pl --port 3443 \
    --tls-cert cert.pem --tls-key key.pem

Then access via https://localhost:3443 (browser will warn about self-signed cert).

See examples/11-tls-https/ for more TLS configuration options.

Serving Large Files with X-Sendfile

For production deployments behind a reverse proxy (Nginx, Apache), use PAGI::Middleware::XSendfile to delegate file serving to the proxy. This frees your application worker immediately while the proxy handles the file transfer using optimized kernel sendfile.

Why X-Sendfile?

Direct file serving from your application ties up a worker process for the entire transfer. With slow clients or large files, this limits your concurrency. X-Sendfile lets your app send a header telling the proxy to serve the file:

# Your app sends:
X-Accel-Redirect: /protected/files/large.bin
Content-Type: application/octet-stream

# Nginx intercepts and serves the file directly

Benefits:

  • Worker freed immediately after sending headers

  • Proxy uses kernel sendfile (zero-copy, efficient)

  • Proxy handles Range requests, caching, connection management

  • Slow clients don't block your app

Nginx Configuration

Configure an internal location for protected files:

# nginx.conf
location /protected/ {
    internal;                       # Only accessible via X-Accel-Redirect
    alias /var/www/files/;          # Actual file location
}

Application Setup

use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::Middleware::Builder;
use PAGI::App::File;

my $app = builder {
    enable 'XSendfile',
        type    => 'X-Accel-Redirect',
        mapping => { '/var/www/files/' => '/protected/' };

    PAGI::App::File->new(
        root          => '/var/www/files',
        handle_ranges => 0,  # Let nginx handle Range requests
    )->to_app;
};

Note: Setting handle_ranges => 0 is important. It tells your app to ignore Range headers and always send full files. The reverse proxy will handle Range requests more efficiently using its native sendfile.

Custom Download Handler

For more control (authentication, logging, custom headers):

use PAGI::App::Router;
use PAGI::Request;
use PAGI::Response;

my $router = PAGI::App::Router->new;

$router->get('/download/:filename' => async sub {
    my ($scope, $receive, $send) = @_;
    my $req = PAGI::Request->new($scope, $receive);
    my $res = PAGI::Response->new($scope, $send);

    my $filename = $req->path_param('filename');
    my $filepath = "/var/www/files/$filename";

    # Validate file exists and user has access
    return await $res->status(404)->text('Not found')
        unless -f $filepath;

    # Send file response (XSendfile middleware will intercept)
    await $send->({
        type    => 'http.response.start',
        status  => 200,
        headers => [
            ['content-type', 'application/octet-stream'],
            ['content-disposition', qq{attachment; filename="$filename"}],
        ],
    });
    await $send->({
        type => 'http.response.body',
        file => $filepath,
    });
});

my $app = builder {
    enable 'XSendfile',
        type    => 'X-Accel-Redirect',
        mapping => { '/var/www/files/' => '/protected/' };
    $router->to_app;
};

Apache Configuration (mod_xsendfile)

Enable mod_xsendfile and whitelist your file directory:

# Apache config
XSendFile On
XSendFilePath /var/www/files

# Application
my $app = builder {
    enable 'XSendfile', type => 'X-Sendfile';
    $my_app;
};

Filehandle Support

The middleware also works with filehandle responses, but only if the filehandle object has a path() method:

# This works - blessed fh with path method
my $fh = IO::File::WithPath->new('/path/to/file.bin', 'r');
await $send->({ type => 'http.response.body', fh => $fh });

# This does NOT trigger X-Sendfile (no path method)
open my $plain_fh, '<', '/path/to/file.bin';
await $send->({ type => 'http.response.body', fh => $plain_fh });

For plain filehandles, either use file => $path directly, or add a path method to your filehandle class. See PAGI::Middleware::XSendfile for details.

TESTING

Test PAGI apps directly without a running server:

use strict;
use warnings;
use Test2::V0;
use Future::AsyncAwait;

# Load your app
my $app = require './app.pl';

# Test helper to create scope and capture response
async sub test_request {
    my (%opts) = @_;

    my $scope = {
        type         => 'http',
        method       => $opts{method} // 'GET',
        path         => $opts{path} // '/',
        query_string => $opts{query} // '',
        headers      => $opts{headers} // [],
    };

    my @body_parts = defined $opts{body} ? ($opts{body}) : ();
    my $body_sent = 0;

    my $receive = async sub {
        if (@body_parts) {
            return {
                type => 'http.request',
                body => shift @body_parts,
                more => scalar(@body_parts) > 0,
            };
        }
        return { type => 'http.disconnect' };
    };

    my %response = (status => undef, headers => [], body => '');
    my $send = async sub {
        my ($event) = @_;
        if ($event->{type} eq 'http.response.start') {
            $response{status} = $event->{status};
            $response{headers} = $event->{headers};
        }
        elsif ($event->{type} eq 'http.response.body') {
            $response{body} .= $event->{body} // '';
        }
    };

    await $app->($scope, $receive, $send);
    return \%response;
}

# Tests
subtest 'GET /' => async sub {
    my $res = await test_request(path => '/');
    is $res->{status}, 200, 'status is 200';
    like $res->{body}, qr/Hello/, 'body contains Hello';
};

subtest 'POST /api/users' => async sub {
    my $res = await test_request(
        method => 'POST',
        path => '/api/users',
        headers => [['content-type', 'application/json']],
        body => '{"name":"Alice"}',
    );
    is $res->{status}, 201, 'status is 201';
};

done_testing;

SEE ALSO

AUTHOR

PAGI Contributors

COPYRIGHT AND LICENSE

This software is copyright (c) 2025 by the PAGI contributors.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.