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;
use PAGI::Server::PubSub;

my $pubsub = PAGI::Server::PubSub->new;
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};

    await $ws->accept;

    # Subscribe to room
    my $sub = $pubsub->subscribe($room, sub {
        my ($message) = @_;
        $ws->try_send_text($message);
    });

    # Broadcast received messages
    await $ws->each_text(sub {
        my ($text) = @_;
        $pubsub->publish($room, $text);
    });

    # Unsubscribe on disconnect
    $pubsub->unsubscribe($room, $sub);
});

$router->to_app;

Important: PAGI::Server::PubSub is in-memory only and works within a single process. For multi-worker or multi-server deployments, use Redis pub/sub or a message broker.

See examples/websocket-chat/ for a complete chat application example.

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.

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.