NAME
PAGI::Tools::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 PAGI::Tutorial (the PAGI protocol tutorial) and PAGI::Tools::Tutorial (this distribution's guide to middleware, helper classes, and built-in apps).
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 = $req->response;
# Access path parameter via $req->path_param()
my $id = $req->path_param('id');
await $res->json({ id => $id, name => 'User ' . $id })->respond($send);
});
# 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 = $req->response;
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,
})->respond($send);
});
$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 = $req->response;
# 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")->respond($send);
});
$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 = $scope->{path_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 stream
$router->sse('/events' => async sub {
my ($scope, $receive, $send) = @_;
my $sse = PAGI::SSE->new($scope, $receive, $send);
await $sse->keepalive(30); # Prevent proxy timeouts
await $sse->send_event(data => "Connected at: " . time);
await $sse->run; # Wait for disconnect
});
# 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 = $scope->{path_params}{channel};
await $sse->start;
await $sse->send_event(data => "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);
await $res->status(401)->json({ error => 'Unauthorized' })->respond($send);
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);
await $res->text('Admin panel')->respond($send);
});
# 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);
await $res->json([{ id => 1, name => 'Alice' }])->respond($send);
});
# 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);
# 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);
$main_router->to_app;
Mounting Components and Class Names
Mount targets (and route handlers) accept more than coderefs: anything "to_app" in PAGI::Utils can coerce composes directly, compiled once at mount time.
# A component object
$main_router->mount('/static' => PAGI::App::File->new(root => './public'));
# A class name - auto-required, then to_app is called on the class
$main_router->mount('/admin' => 'MyApp::Admin');
# A router object
$main_router->mount('/api' => $api_router);
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, $ctx) for HTTP
async sub home {
my ($self, $ctx) = @_;
return $ctx->html('<h1>Welcome!</h1>');
}
async sub about {
my ($self, $ctx) = @_;
return $ctx->html('<h1>About Us</h1>');
}
async sub contact {
my ($self, $ctx) = @_;
my $data = await $ctx->req->json;
# ... process contact form ...
return $ctx->json({ success => 1 });
}
1;
# In app.pl:
# use MyApp;
# MyApp->to_app;
Handler methods are called with:
$self- Router instance (access$self->statefor per-instance data)$ctx- PAGI::Context - read the request via$ctx->req, build the response with$ctx->json/$ctx->html/$ctx->textand return it
WebSocket Handlers
WebSocket handlers receive ($self, $ctx); call $ctx->websocket for the channel:
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, $ctx) = @_;
return $ctx->html('<h1>Home</h1>');
}
# WebSocket handler receives ($self, $ctx)
async sub handle_ws {
my ($self, $ctx) = @_;
my $ws = $ctx->websocket;
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, $ctx) = @_;
my $ws = $ctx->websocket;
# 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, $ctx); call $ctx->sse for the channel:
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, $ctx) = @_;
return $ctx->html('<h1>Home</h1>');
}
# SSE handler receives ($self, $ctx)
async sub handle_events {
my ($self, $ctx) = @_;
my $sse = $ctx->sse;
await $sse->keepalive(30);
await $sse->send_event(data => "Connected at: " . time);
await $sse->run;
}
# SSE with path parameters
async sub handle_channel {
my ($self, $ctx) = @_;
my $sse = $ctx->sse;
# Access path parameter
my $channel = $sse->path_param('channel');
await $sse->start;
await $sse->send_event(data => "Channel: $channel");
# ... streaming logic ...
}
1;
Initializing Resources
PAGI::Endpoint::Router calls your routes method once, when the app is built, on the instance that lives for the app's lifetime. That makes routes the natural place to initialize per-worker resources and seed state: whatever you store in $self->state is visible to every handler.
package MyApp;
use parent 'PAGI::Endpoint::Router';
use strict;
use warnings;
use Future::AsyncAwait;
sub routes {
my ($self, $r) = @_;
# Initialize resources once, at build time
$self->state->{db} = DBI->connect('dbi:SQLite:dbname=app.db');
$self->state->{started_at} = time();
$r->get('/' => 'home');
}
async sub home {
my ($self, $ctx) = @_;
# Access state initialized in routes()
my $db = $self->state->{db};
my $uptime = time() - $self->state->{started_at};
return $ctx->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.).
For asynchronous startup and shutdown hooks -- running code on the lifespan.startup and lifespan.shutdown protocol events -- wrap the app with PAGI::Lifespan, which runs startup/shutdown callbacks around the application:
use PAGI::Lifespan;
my $app = PAGI::Lifespan->new(
startup => async sub ($state) {
$state->{db} = DBI->connect('dbi:SQLite:dbname=app.db');
},
shutdown => async sub ($state) {
$state->{db}->disconnect if $state->{db};
},
app => MyApp->to_app,
)->to_app;
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, $ctx, $next)
async sub require_auth {
my ($self, $ctx, $next) = @_;
my $token = $ctx->req->header('authorization');
unless ($token && $token eq 'Bearer secret123') {
return $ctx->json({ error => 'Unauthorized' }, status => 401);
}
# Authorized - continue to handler
return await $next->();
}
async sub log_request {
my ($self, $ctx, $next) = @_;
warn "Request: " . $ctx->req->method . " " . $ctx->req->path . "\n";
return await $next->();
}
async sub home {
my ($self, $ctx) = @_;
return $ctx->html('<h1>Home</h1>');
}
async sub admin_page {
my ($self, $ctx) = @_;
return $ctx->html('<h1>Admin Panel</h1>');
}
async sub create_user {
my ($self, $ctx) = @_;
my $data = await $ctx->req->json;
# ... create user ...
return $ctx->json({ success => 1 }, status => 201);
}
1;
Middleware methods receive ($self, $ctx, $next). await $next->() runs the rest of the chain and returns the handler's response; return it (optionally after decorating), or return your own response to short-circuit.
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, $ctx) = @_;
return $ctx->json([
{ id => 1, name => 'Alice' },
{ id => 2, name => 'Bob' },
]);
}
async sub get_user {
my ($self, $ctx) = @_;
my $id = $ctx->req->path_param('id');
return $ctx->json({ id => $id, name => "User $id" });
}
async sub create_user {
my ($self, $ctx) = @_;
my $data = await $ctx->req->json;
return $ctx->json({ id => 3, name => $data->{name} }, status => 201);
}
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');
}
async sub home {
my ($self, $ctx) = @_;
return $ctx->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;
sub routes {
my ($self, $r) = @_;
# Seed per-worker state at build time
$self->state->{started} = time();
$self->state->{users} = [
{ id => 1, name => 'Alice' },
{ id => 2, name => 'Bob' },
];
# 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, $ctx, $next) = @_;
my $token = $ctx->req->header('authorization');
return $ctx->json({ error => 'Unauthorized' }, status => 401)
unless $token && $token eq 'Bearer secret';
return await $next->();
}
async sub home {
my ($self, $ctx) = @_;
return $ctx->html('<h1>Welcome to MyApp</h1>');
}
async sub list_users {
my ($self, $ctx) = @_;
return $ctx->json($self->state->{users});
}
async sub get_user {
my ($self, $ctx) = @_;
my $id = $ctx->req->path_param('id');
my ($user) = grep { $_->{id} == $id } @{$self->state->{users}};
return $ctx->json({ error => 'Not found' }, status => 404)
unless $user;
return $ctx->json($user);
}
async sub create_user {
my ($self, $ctx) = @_;
my $data = await $ctx->req->json;
my $user = { id => scalar(@{$self->state->{users}}) + 1, name => $data->{name} };
push @{$self->state->{users}}, $user;
return $ctx->json($user, status => 201);
}
async sub admin {
my ($self, $ctx) = @_;
return $ctx->json({ message => 'Admin access granted' });
}
async sub ws_echo {
my ($self, $ctx) = @_;
my $ws = $ctx->websocket;
await $ws->accept;
await $ws->each_text(async sub {
my ($text) = @_;
await $ws->send_text("Echo: $text");
});
}
async sub events {
my ($self, $ctx) = @_;
my $sse = $ctx->sse;
await $sse->keepalive(30);
await $sse->send_json({ connected => time });
await $sse->run;
}
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. The middleware populates $scope->{'pagi.session'}; the PAGI::Session helper wraps that hashref with a convenient (and typo-catching) API.
use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::Middleware::Builder;
use PAGI::Session;
use PAGI::Request;
use PAGI::Response;
my $router = PAGI::App::Router->new;
$router->get('/login' => async sub {
my ($scope, $receive, $send) = @_;
my $req = PAGI::Request->new($scope, $receive);
my $res = $req->response;
# Set session data
my $session = PAGI::Session->new($scope);
$session->set(user_id => 123);
$session->set(username => 'alice');
await $res->text('Logged in!')->respond($send);
});
$router->get('/profile' => async sub {
my ($scope, $receive, $send) = @_;
my $req = PAGI::Request->new($scope, $receive);
my $res = $req->response;
# Read session data (the second arg is a default for optional keys)
my $session = PAGI::Session->new($scope);
my $username = $session->get('username', 'Guest');
await $res->text("Hello, $username!")->respond($send);
});
$router->get('/logout' => async sub {
my ($scope, $receive, $send) = @_;
my $req = PAGI::Request->new($scope, $receive);
my $res = $req->response;
# Clear session data
PAGI::Session->new($scope)->clear;
await $res->text('Logged out!')->respond($send);
});
# Wrap the router with the session middleware
my $app = builder {
enable 'Session',
secret => 'your-secret-key-here',
cookie_name => 'session_id',
expire => 86400; # 1 day
$router;
};
Basic Authentication
use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::Middleware::Builder;
use PAGI::Response;
my $router = PAGI::App::Router->new;
$router->get('/' => async sub {
my ($scope, $receive, $send) = @_;
my $res = PAGI::Response->new($scope);
await $res->text('Protected content')->respond($send);
});
# Protect every route with Basic Auth
my $app = builder {
enable 'Auth::Basic',
realm => 'Admin Area',
authenticator => sub {
my ($username, $password) = @_;
return $username eq 'admin' && $password eq 'secret';
};
$router;
};
Bearer Token (JWT)
use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::Middleware::Builder;
use PAGI::Request;
use PAGI::Response;
my $router = PAGI::App::Router->new;
$router->get('/api/me' => async sub {
my ($scope, $receive, $send) = @_;
my $req = PAGI::Request->new($scope, $receive);
my $res = $req->response;
# Claims from the validated token: the middleware stores
# { type => 'bearer', token => ..., claims => ... } under 'pagi.auth'
my $claims = $scope->{'pagi.auth'}{claims};
await $res->json({ user => $claims })->respond($send);
});
# Verify Bearer tokens with a custom validator (returns claims or undef).
# When the validator returns undef the middleware sends 401 itself, so the
# handler above only runs for valid tokens.
my $app = builder {
enable 'Auth::Bearer',
validator => sub {
my ($token) = @_;
return decode_jwt($token); # Your JWT decode logic
};
$router;
};
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->{path_params}{room};
my $client_id = $next_id++;
await $ws->accept;
# Keep connection alive through proxies/NAT (protocol-level ping every 25s)
await $ws->keepalive(25);
# Join room
$rooms{$room}{$client_id} = $ws;
# Broadcast received messages to all clients in room
await $ws->each_text(async sub {
my ($text) = @_;
for my $id (keys %{$rooms{$room}}) {
next if $id == $client_id; # Don't echo back to sender
await $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->sse('/events' => async sub {
my ($scope, $receive, $send) = @_;
my $sse = PAGI::SSE->new($scope, $receive, $send);
# Enable keepalive to prevent proxy timeout
await $sse->keepalive(15);
# Subscribe to metrics updates (your pub/sub system)
my $sub_id = subscribe_metrics(sub {
my ($metrics) = @_;
$sse->try_send_event(
event => 'metrics',
data => $metrics,
);
});
$sse->on_close(sub { unsubscribe_metrics($sub_id) });
await $sse->run;
});
$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.
Handling a Mixed Event Stream
When the receive stream carries more than one kind of event -- the protocol's own events plus application events injected by middleware -- the PAGI::Context event dispatcher lets you register one handler per event type and run a single loop, instead of hand-rolling a while over receive.
use PAGI::Context;
$router->websocket('/notify' => async sub {
my ($scope, $receive, $send) = @_;
my $ctx = PAGI::Context->new($scope, $receive, $send);
await $ctx->accept;
# A protocol event from the client
$ctx->on('websocket.receive', async sub {
my ($ctx, $event) = @_;
await $ctx->send_text("echo: " . ($event->{text} // ''));
});
# An application event injected into the receive stream by middleware
# (for example a pub/sub bridge such as the PAGI-Channels companion dist)
$ctx->on('app.notify', async sub {
my ($ctx, $event) = @_;
await $ctx->send_json({ notice => $event->{message} });
});
# A fallback for anything you did not register a handler for
$ctx->on_default(sub {
my ($ctx, $event) = @_;
warn "unexpected event: $event->{type}";
});
$ctx->on_error(sub {
my ($ctx, $err, $source) = @_;
warn "[$source] $err";
});
my $reason = await $ctx->run; # 'disconnect', 'stop', or 'error'
});
Handlers receive the protocol-typed $ctx (so send_text/send_json work), run in registration order, and may be async subs. The loop runs until the client disconnects, a handler calls $ctx->stop, or receive fails; run() resolves with the reason and clears the handlers afterward so closures over $ctx do not leak.
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 Future::IO;
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 Future::IO->sleep(2);
warn "Email sent to $email\n";
}
$router->post('/signup' => async sub {
my ($scope, $receive, $send) = @_;
my $res = PAGI::Response->new($scope);
# Respond immediately -- the client does not wait for the email
await $res->status(201)->json({ status => 'created' })->respond($send);
# Fire-and-forget: scheduled now, runs on later event-loop ticks
# (after this handler returns). Always add on_fail() before retain()
# to avoid silently swallowing errors.
send_welcome_email('user@example.com')
->on_fail(sub { warn "Email send failed: @_" })
->retain();
});
$router->to_app;
Warning: ->retain() alone silently swallows errors - always add ->on_fail() first.
Middleware authors: If your middleware inherits from IO::Async::Notifier, use $self->adopt_future($f) instead. It properly tracks futures and propagates unhandled errors to the notifier's invoke_error method.
CPU-Bound Work in Subprocess
Note: This pattern uses IO::Async::Function, which is specific to PAGI::Server's IO::Async backend. It won't work with other PAGI servers.
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);
# Option A: Wait for result (blocks this request only)
my $result = await $pdf_worker->call(args => ['report']);
await $res->json({ result => $result })->respond($send);
# Option B: respond immediately, generate in the background
# await $res->json({ status => 'processing' })->respond($send);
# 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();
});
$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 = $req->response;
if ($req->method eq 'POST') {
my $form = await $req->form_params;
my $username = $form->{username};
my $password = $form->{password};
# Validate and process...
await $res->redirect('/dashboard')->respond($send);
}
else {
await $res->html(<<'HTML')->respond($send);
<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 = $req->response;
if ($req->method eq 'POST') {
# uploads() returns a Hash::MultiValue keyed by form field name;
# ->values gives every uploaded file across all fields
my $uploads = await $req->uploads;
my @files = $uploads->values;
for my $upload (@files) {
my $filename = $upload->filename;
my $size = $upload->size;
my $type = $upload->content_type;
# Save to disk (blocking I/O)
$upload->move_to('/uploads/' . $filename);
# Or read content into memory
# my $content = $upload->slurp;
}
await $res->json({ uploaded => scalar(@files) })->respond($send);
}
else {
await $res->html(<<'HTML')->respond($send);
<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.
COMMAND-LINE RECIPES
Environment Modes
pagi-server auto-detects development vs production mode based on whether you're running in an interactive terminal:
# Development mode (TTY detected) - Lint middleware + access logging
pagi-server app.pl
# Production mode (no TTY, e.g., systemd/docker) - no middleware, no logging
# This happens automatically when running as a daemon
# Explicit mode selection
pagi-server -E production app.pl
pagi-server -E development app.pl
pagi-server -E none app.pl # Disable all auto-middleware
# Via environment variable
PAGI_ENV=production pagi-server app.pl
# Enable logging in production (writes to file)
pagi-server -E production --access-log /var/log/pagi.log app.pl
# Disable auto-middleware even in development
pagi-server --no-default-middleware app.pl
Inline Apps with -e
Like perl -e, you can run inline PAGI apps. This is useful for quick tests or one-liners:
# Quick static file server
pagi-server -MPAGI::App::Directory \
-e 'PAGI::App::Directory->new(root => "/var/www")->to_app'
# Serve a single file
pagi-server -MPAGI::App::File \
-e 'PAGI::App::File->new(root => ".")->to_app'
# Quick redirect
pagi-server -MPAGI::App::Redirect \
-e 'PAGI::App::Redirect->new(location => "https://example.com")->to_app'
Loading Modules with -M
The -M flag loads modules before -e code runs, like perl -M:
# Load multiple modules
pagi-server -MPAGI::App::File -MPAGI::Middleware::Builder \
-e 'use PAGI::Middleware::Builder; builder { enable "GZip"; PAGI::App::File->new(root => ".") }'
# Cuddled form (no space after -M)
pagi-server -MPAGI::App::File -e 'PAGI::App::File->new(root => ".")->to_app'
Common pagi-server Patterns
# Development with verbose logging
pagi-server --log-level debug app.pl
# Production multi-worker deployment
pagi-server -E production --host 0.0.0.0 -p 80 --workers 4 app.pl
# With custom library paths
pagi-server -I ./lib -I ./local/lib app.pl
# HTTPS with TLS
pagi-server --ssl-cert cert.pem --ssl-key key.pem -p 443 app.pl
# Daemonized with PID file
pagi-server -D --pid /var/run/pagi.pid app.pl
# Maximum performance (no access logging)
pagi-server --no-access-log --workers 8 -E production app.pl
# Using EV event loop for better performance
pagi-server --loop EV app.pl
DEPLOYMENT
TLS/HTTPS
PAGI::Server supports TLS natively:
pagi-server app.pl --port 443 \
--ssl-cert /path/to/cert.pem \
--ssl-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 \
--ssl-cert cert.pem --ssl-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
);
};
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 = $req->response;
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')->respond($send)
unless -f $filepath;
# Send file response directly via raw protocol events (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;
};
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. IO::File::WithPath from CPAN is an easy drop-in for this:
use IO::File::WithPath;
# This works - IO::File::WithPath provides the 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 use IO::File::WithPath. See PAGI::Middleware::XSendfile for details.
RESPONSE STATE & LIFECYCLE
A PAGI::Response is a detached value: building it is fully separate from sending it. That split means there is no single "is this done?" flag — instead there are a few distinct signals, each answering a different question. This section maps them so you can tell, at any point, what is going on.
The three phases
A response moves through three phases. Each has its own signal:
- 1. Build — the handler accumulates status, headers, and a body source on the value. No connection, no I/O. The signal here is "has_body_source" in PAGI::Response: "has a body source been registered?"
- 2. Send committed —
$ctx->respond($res)(or a raw$res->respond($send)behind the context's guard) has begun emitting the response and has taken the connection. The signal is "is_sent" in PAGI::Response: "has this response gone out?" It is set at the moment the send is committed, so a second send is rejected. - 3. Finished — every byte has been emitted; for a stream, the callback has run to completion and the writer is closed. There is no flag for this: it is the resolution of the Future returned by
respond.await-ing the send is waiting for "finished".
The reason "finished" is a Future and not a flag is the same reason the value is detached: PAGI never models a half-sent live byte-sink as state on the value. "Committed" is a flag; "finished" is the thing you await.
The signals you can inspect
Signal Phase Question it answers
-------------------------- ------------- -------------------------------------
has_status build Was a status code set explicitly?
has_header($name) build Was this header set?
has_content_type build Was Content-Type set?
has_body_source build Was a body (bytes/file/stream) set?
is_sent send Has the response been emitted?
(Future from respond) finished Has every byte been written?
$writer->bytes_written finished* How many bytes streamed so far?
$writer->is_closed finished* Has the stream writer been closed?
The $writer signals (*) apply only while streaming, on the PAGI::Response::Writer handed to your stream callback or returned by writer. They describe the live stream; the response value itself only carries the build and send signals.
has_body_source is intent, not bytes
The signal most likely to surprise you is has_body_source. It reports that a body source is registered — not that bytes exist, and not that anything was sent. For a stream this matters: the callback is stored at build time and does not run until respond, so a freshly-registered stream has produced zero bytes yet has_body_source is already true. That is deliberate and is the only coherent meaning — respond is what drives the stream, so "are there bytes yet?" is unanswerable at build time and irrelevant to "is there a body to send?"
This mirrors the distinction Node.js draws between writableEnded ("the producer declared it is done") and writableFinished ("all data has been flushed"). has_body_source is the former, decoupled from any I/O; await-ing respond is the latter.
Intentional empty bodies count as a registered source: empty, redirect, and send_raw('') all set an (empty) body, so has_body_source is true for them. Only a response that never had a body method called reports false.
Deciding "did the handler produce a response?"
A framework that lets handlers populate $ctx->res and then auto-sends it needs to know, after the handler returns, whether to send, skip, or fall through to the next route. That is a three-way decision, and it is a precedence ladder — not a single predicate:
# After running the handler, which mutated $ctx->res:
if ($ctx->res->is_sent) {
# The handler already took the connection itself — e.g. it called
# ->writer($send) for live streaming, or used SSE/WebSocket. Do nothing;
# sending again would be a double-send.
}
elsif ($ctx->res->has_body_source || $ctx->res->has_status) {
# The handler registered a body, or set a status (a bare 204 / redirect
# has no body but IS a response). Send it once, through the guard.
await $ctx->respond($ctx->res);
}
else {
# The handler touched nothing send-able — fall through to the next match
# (or a 404).
}
Three things make this ladder correct:
Check
is_sentfirst. A handler that called->writer($send)has already emitted headers (writermarks the response sent), buthas_body_sourcestays false because the live-writer path bypasses the body slots. Without theis_sentguard you would mistake an actively-streaming response for "nothing produced".|| has_statusis required. A302redirect or a204can have no body source, yet it is a real response.has_statuscatches the status-only case so you do not 404 a legitimate empty-body response.Never inspect the private slots. Use
has_body_source, notexists $res->{_stream}and friends — those are private and may change (see "SUBCLASSING (FRAMEWORK INTEGRATION)" in PAGI::Response). The predicate is the supported way to ask.
If instead your framework has handlers return the response (PAGI's own endpoint contract), you do not need this ladder at all: the returned value is the answer, and "fall through" is an explicit sentinel you define, not an inference from an empty response.
TESTING
Use PAGI::Test::Client to test apps directly without a running server:
use strict;
use warnings;
use Test2::V0;
use PAGI::Test::Client;
# Load your app
my $app = require './app.pl';
my $client = PAGI::Test::Client->new(app => $app);
subtest 'GET /' => sub {
my $res = $client->get('/');
is $res->status, 200, 'status is 200';
like $res->text, qr/Hello/, 'body contains Hello';
};
subtest 'POST /api/users' => sub {
my $res = $client->post('/api/users',
json => { name => 'Alice' },
);
is $res->status, 201, 'status is 201';
is $res->json->{id}, 1, 'returns user id';
};
subtest 'form submission' => sub {
my $res = $client->post('/login',
form => { user => 'admin', pass => 'secret' },
);
is $res->status, 302, 'redirects after login';
# Session cookies persist across requests
my $dashboard = $client->get('/dashboard');
is $dashboard->status, 200, 'authenticated access';
};
subtest 'custom headers' => sub {
my $res = $client->get('/api/data',
headers => { Authorization => 'Bearer token123' },
);
is $res->status, 200;
};
done_testing;
See PAGI::Test::Client for the full API including WebSocket and SSE testing.
SEE ALSO
PAGI::Tutorial - Learn the PAGI protocol from the ground up
PAGI::Tools::Tutorial - Guide to middleware, helpers, and built-in apps
PAGI::App::Router - Functional routing
PAGI::Endpoint::Router - Class-based routing
PAGI::Middleware::Session - Session management
PAGI::App::WebSocket::Chat - Multi-room chat application
PAGI::Middleware::XSendfile - Delegate file serving to reverse proxy
PAGI::Test::Client - Test apps without a running server
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.