NAME
PAGI::Tutorial - A comprehensive guide to building async web applications with PAGI
DESCRIPTION
This tutorial introduces PAGI (Perl Asynchronous Gateway Interface), a modern web framework specification for Perl that brings async/await patterns to web development. Whether you're building real-time applications with WebSockets, streaming data with Server-Sent Events, or just want better concurrency handling, PAGI provides the foundation you need.
PART 1: GETTING STARTED
1.1 Introduction
What is PAGI?
PAGI (Perl Asynchronous Gateway Interface) is a specification and framework for building asynchronous web applications in Perl. It's a spiritual successor to PSGI that embraces modern async/await patterns using Future::AsyncAwait and provides first-class support for WebSockets, Server-Sent Events, and HTTP/1.1.
Unlike traditional synchronous web frameworks, PAGI allows your application to handle multiple concurrent connections efficiently without blocking, making it ideal for:
WebSocket applications (real-time chat, live updates)
Server-Sent Events (SSE) for streaming data
Long-polling and streaming responses
High-concurrency applications
Applications that make multiple I/O calls per request
Why Async?
Traditional synchronous web frameworks handle one request at a time per process. When your application waits for a database query, an external API call, or file I/O, the entire process is blocked and can't handle other requests.
The Traditional Solution: Forking Servers
Perl web applications have traditionally used pre-forking servers like Starman to achieve concurrency. Starman spawns multiple worker processes, each handling one request at a time. This approach works well and has served the Perl community for years:
Approach | Forking (Starman) | Async (PAGI)
----------------|-----------------------------|--------------------------
Concurrency | Multiple processes | Single process, event loop
Memory | Each worker copies app | Shared memory, lower usage
Connections | Limited by worker count | Thousands per process
WebSocket/SSE | Impractical (ties up worker)| Natural fit
Blocking I/O | Fine (isolated per worker) | Must use async libraries
CPU-bound work | Fine (isolated per worker) | Blocks event loop (use workers)
Debugging | Simpler (sequential code) | Trickier (async flow)
Existing code | Works as-is | May need async adapters
When to use which:
Starman/forking - CPU-heavy work, existing sync codebases, simple request/response apps, blocking database drivers
PAGI/async - WebSockets, SSE, long-polling, high connection counts, I/O-bound apps, real-time features
Many applications benefit from both: use PAGI for real-time endpoints (WebSocket, SSE) and forking for traditional request/response. PAGI also supports worker mode (--workers N) which combines both approaches.
The Async Advantage
Asynchronous programming allows your application to handle multiple requests concurrently in a single process. When one request is waiting for I/O, the event loop can switch to another request, dramatically improving resource utilization and response times.
This is especially important for:
Real-time bidirectional communication (WebSockets)
Keeping connections open for streaming (SSE)
Applications that make multiple external API calls
High-traffic applications where blocking I/O is a bottleneck
PAGI vs PSGI
If you're familiar with PSGI, here are the key differences:
Feature | PSGI | PAGI
-----------------|-------------------------|---------------------------
Execution Model | Synchronous | Asynchronous (async/await)
Application Sig | sub { $env } | async sub { $scope, $receive, $send }
Request Metadata | %env hash | %scope hash
Request Body | $env->{'psgi.input'} | await $receive->()
Response | [\@status, \@headers, \@body] | await $send->({...})
WebSocket | Not supported | First-class support
SSE | Hacky streaming | First-class support
Backpressure | No explicit control | Explicit via Futures
The async model means you can write code that looks synchronous (thanks to async/await) while getting all the benefits of non-blocking I/O.
Prerequisites
To use PAGI, you'll need:
Perl 5.16 or later (released May 2012) - required by Future::AsyncAwait
Future::AsyncAwait - Provides async/await syntax
IO::Async - Event loop and async I/O primitives
Basic understanding of Futures and async programming (we'll cover the essentials)
1.2 Installation
Installing PAGI from CPAN:
cpanm PAGI
Or from source:
git clone https://github.com/yourusername/PAGI.git
cd PAGI
cpanm --installdeps .
To include development dependencies (for testing):
cpanm --installdeps . --with-develop
Verify the installation:
pagi-server --version
1.3 Your First PAGI App
Let's create a simple "Hello, World!" application. Create a file called hello.pl:
use strict;
use warnings;
use Future::AsyncAwait;
async sub app {
my ($scope, $receive, $send) = @_;
# Only handle HTTP requests (server also sends lifespan events)
die "Expected http scope" unless $scope->{type} eq 'http';
# Send the response status and headers
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
# Send the response body
await $send->({
type => 'http.response.body',
body => 'Hello, World!',
});
}
# Return a reference to the app subroutine
\&app;
Let's break down what's happening:
- 1.
async sub app- Declares an asynchronous subroutine using Future::AsyncAwait - 2.
($scope, $receive, $send)- The three parameters every PAGI app receives: -
$scope- Hash reference containing request metadata (method, path, headers, etc.)$receive- Async code reference for receiving events from the client$send- Async code reference for sending events to the client
- 3.
return unless $scope->{type} eq 'http'- The server calls your app for different event types (HTTP requests, WebSocket, lifespan). This check ensures we only handle HTTP requests. - 4.
await $send->({...})- Sends events asynchronously, waiting for each to complete - 5.
\&app- Returns a reference to the application subroutine
Run your application:
pagi-server hello.pl --port 5000
Test it:
curl http://localhost:5000/
You should see:
Hello, World!
Try accessing it from a browser by visiting http://localhost:5000/.
Inspecting the Scope
The $scope hash contains metadata about the current connection - similar to PSGI's $env hash. It tells you everything about the request before you read the body: the HTTP method, path, headers, query string, client address, and more. Unlike PSGI where everything is in one flat hash, PAGI separates the metadata ($scope) from the body (read via $receive).
Let's modify the app to show what's in $scope:
use strict;
use warnings;
use Future::AsyncAwait;
use Data::Dumper;
async sub app {
my ($scope, $receive, $send) = @_;
die "Expected http scope" unless $scope->{type} eq 'http';
my $body = Dumper($scope);
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({
type => 'http.response.body',
body => $body,
});
}
\&app;
Run this and visit http://localhost:5000/test?foo=bar. You'll see the scope hash which includes:
type- Connection type (http, websocket, sse, lifespan)method- HTTP method (GET, POST, etc.)path- URL path (decoded UTF-8)raw_path- URL path (raw bytes)query_string- Query string (raw bytes)headers- Array reference of [name, value] pairsserver- Server address and portAnd more...
1.4 Understanding the Event Loop: Blocking vs Non-Blocking
Understanding the event loop is crucial to writing efficient PAGI applications. Let's visualize how it works.
The Event Loop
PAGI uses IO::Async::Loop to manage asynchronous operations. Here's a simplified view of how the event loop handles multiple connections:
Single-Threaded Event Loop
Time -->
Request A: [======API Call (async)======]----[Process]--[Send]
Request B: [--DB Query (async)--]--------[Process]--------[Send]
Request C: [Timer (async)]----[Process]----[Send]
Event Loop: [A][B][C][A][B][C][B][A][C][B][A][C][A][B]
| | | | | | | | | | | | | |
Continuously switching between requests as they wait
When you use await, you're telling the event loop "I'm waiting for something, go handle other requests until this completes." The event loop can then switch to another request, making efficient use of CPU time.
Non-Blocking Operations (GOOD)
These operations yield control to the event loop:
use Future::AsyncAwait;
use IO::Async::Loop;
use IO::Async::Timer::Countdown;
use Net::Async::HTTP;
async sub good_app {
my ($scope, $receive, $send) = @_;
# IO::Async::Loop->new returns the singleton loop instance
my $loop = IO::Async::Loop->new;
# Good: Async timer (doesn't block)
my $timer = IO::Async::Timer::Countdown->new(
delay => 5,
on_expire => sub { },
);
$loop->add($timer);
$timer->start;
await $timer->future;
# Good: Async HTTP client (doesn't block)
my $http = Net::Async::HTTP->new;
$loop->add($http);
my $response = await $http->GET('https://api.example.com/data');
# Good: Async database query (using a hypothetical async DB driver)
# my $row = await $db->selectrow_async('SELECT * FROM users WHERE id = ?', $user_id);
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({
type => 'http.response.body',
body => 'Non-blocking operations completed!',
});
}
\&good_app;
Blocking Operations (BAD)
These operations block the entire process:
use Future::AsyncAwait;
use LWP::UserAgent; # Synchronous HTTP client
use DBI; # Most DBI drivers are synchronous
async sub bad_app {
my ($scope, $receive, $send) = @_;
# BAD: Blocks for 5 seconds, prevents other requests from being handled
sleep 5;
# BAD: Synchronous HTTP request blocks until complete
my $ua = LWP::UserAgent->new;
my $response = $ua->get('https://api.example.com/data');
# BAD: Synchronous database query blocks
# my $dbh = DBI->connect(...);
# my $row = $dbh->selectrow_hashref('SELECT * FROM users WHERE id = ?', undef, $user_id);
# BAD: Expensive CPU operation blocks (e.g., bcrypt password hashing)
# my $hashed = bcrypt_hash($password); # Takes ~100ms
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({
type => 'http.response.body',
body => 'Blocking operations completed (badly)!',
});
}
\&bad_app;
Why Blocking is Bad
Let's do the math:
You have 1,000 concurrent connections
Each request needs to make a synchronous database query that takes 100ms
With a blocking operation, each connection is stuck for 100ms
1,000 connections x 100ms = 100 seconds of total blocked time
With async operations, all 1,000 queries can be in-flight simultaneously
In a single-threaded event loop, one blocking operation prevents ALL other connections from making progress. A single sleep(5) will freeze your entire application for 5 seconds!
Solutions for Blocking Operations
When you must use blocking operations, you have three options:
- 1. Use Async Libraries
-
Prefer async alternatives when available:
Net::Async::HTTP instead of LWP::UserAgent
Net::Async::Redis instead of synchronous Redis clients
Database::Async for async database access (PostgreSQL, MySQL, SQLite)
IO::Async::Timer::Countdown instead of
sleep()
- 2. Use IO::Async::Function
-
Offload blocking operations to a subprocess via IO::Async::Function:
use IO::Async::Loop; use IO::Async::Function; my $loop = IO::Async::Loop->new; # Returns singleton my $worker = IO::Async::Function->new( code => sub { my ($password) = @_; # This runs in a separate process (fork) return bcrypt_hash($password); }, ); $loop->add($worker); # Forks the worker process here # Call the blocking operation asynchronously my $hashed = await $worker->call(args => [$password]);Note: IO::Async::Function uses
fork()to create worker processes. If you're already running the server in worker mode (--workers 4), each server worker may fork additional Function workers. Be mindful of total process count - too many forks leads to memory pressure and context-switching overhead. For high-volume blocking work, consider a dedicated job queue (Redis, RabbitMQ) instead.
What about worker mode? Running with --workers N does NOT solve blocking for individual connections. If a worker has a WebSocket connection and does a blocking operation, that WebSocket will freeze until the operation completes. Worker mode only means other connections on other workers aren't affected. See section 1.5 for when worker mode is actually useful.
1.5 Worker Mode
PAGI::Server supports two deployment modes: single-process and multi-worker.
Single Process vs Worker Mode
Single Process Mode:
[Master Process]
|
+-- Runs event loop
+-- Handles all connections
+-- Good for: async apps, development, low-medium traffic
Worker Mode:
[Master Process]
|
+-- [Worker 1] (handles connections)
+-- [Worker 2] (handles connections)
+-- [Worker 3] (handles connections)
+-- [Worker 4] (handles connections)
Good for: multi-core utilization, crash isolation, high traffic
When to Use Worker Mode
Scenario | Single Process | Worker Mode
-----------------------------------|----------------|-------------
Pure async app (no blocking) | Yes | Yes
Multi-core server in production | Wastes cores | Yes
Crash isolation needed | No | Yes
WebSocket/SSE with shared state | Yes | Needs PubSub
Development/testing | Yes | Optional
Important: Worker mode does NOT fix blocking operations. Each worker still runs a single-threaded event loop. If you do a blocking operation in a worker, ALL connections on that worker freeze (including any WebSocket or SSE connections). To avoid blocking, use async libraries or IO::Async::Function as described in section 1.4.
Running with Workers
Start the server with multiple worker processes:
pagi-server hello.pl --port 5000 --workers 4
This creates:
1 master process that accepts connections
4 worker processes that handle requests
Automatic worker respawning if a worker crashes
The number of workers should typically match your CPU core count:
# Check your CPU core count and set workers accordingly
# On Linux: nproc On macOS: sysctl -n hw.ncpu
pagi-server hello.pl --port 5000 --workers 4
Worker Isolation
Important: Each worker process is isolated. They do NOT share:
Memory (variables are per-worker)
Database connections
WebSocket connections
Application state
Wrong: Shared State in Memory
This will NOT work correctly across workers:
# DON'T DO THIS in worker mode!
my $counter = 0; # Each worker has its own $counter
async sub app {
my ($scope, $receive, $send) = @_;
$counter++; # Only increments in THIS worker
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({
type => 'http.response.body',
body => "Counter: $counter", # Will show different values per worker
});
}
\&app;
Right: External State Storage
Use a shared backend for state:
use Future::AsyncAwait;
use IO::Async::Loop;
use Net::Async::Redis;
async sub app {
my ($scope, $receive, $send) = @_;
my $loop = IO::Async::Loop->new;
# Connect to Redis (shared across all workers)
my $redis = Net::Async::Redis->new;
$loop->add($redis);
await $redis->connect(host => 'localhost', port => 6379);
# Increment counter in Redis
my $counter = await $redis->incr('request_counter');
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({
type => 'http.response.body',
body => "Counter: $counter", # Accurate across all workers
});
}
\&app;
For WebSocket applications that need to broadcast messages across workers, use an external message broker like Redis pub/sub.
PART 2: UNDERSTANDING RAW PAGI
In Part 1, we covered the basics of PAGI applications and the event loop. Now we'll dive deeper into the raw PAGI protocol, exploring how to work directly with the three arguments ($scope, $receive, and $send) before introducing higher-level abstractions.
Understanding raw PAGI is important because:
It helps you understand what's happening under the hood
You can build custom middleware and utilities
You'll appreciate what frameworks like PAGI::Endpoint::Router do for you
You can handle edge cases that frameworks might not cover
2.1 The Three Arguments
Every PAGI application receives exactly three arguments:
async sub app {
my ($scope, $receive, $send) = @_;
# ... your code here
}
Let's examine each in detail.
$scope - Connection Metadata
$scope is a hash reference containing metadata about the connection and request. The contents vary depending on the connection type.
For HTTP requests (type => 'http'):
{
type => 'http',
method => 'GET', # HTTP method
path => '/users/123', # URL path (decoded UTF-8)
raw_path => '/users/123', # URL path (raw bytes)
query_string => 'foo=bar', # Query string (raw bytes)
headers => [ # Array of [name, value] pairs
['host', 'localhost:5000'],
['user-agent', 'curl/7.68.0'],
['content-type', 'application/json'],
],
server => ['127.0.0.1', 5000], # Server address and port
client => ['127.0.0.1', 54321],# Client address and port
scheme => 'http', # 'http' or 'https'
http_version => '1.1', # HTTP version
loop => $loop, # IO::Async::Loop instance
extensions => { ... }, # PAGI extensions
}
For WebSocket connections (type => 'websocket'):
{
type => 'websocket',
path => '/ws',
headers => [ ... ],
subprotocols => ['chat', 'superchat'], # Requested subprotocols
# ... other fields similar to http
}
For SSE connections (type => 'sse'):
{
type => 'sse',
path => '/events',
headers => [ ... ],
# ... other fields similar to http
}
For lifespan events (type => 'lifespan'):
{
type => 'lifespan',
}
$receive - Receiving Client Events
$receive is an asynchronous code reference that returns a Future. Call it to receive the next event from the client:
my $event = await $receive->();
The structure of $event depends on the connection type and what's happening.
For HTTP requests, you receive body chunks:
{
type => 'http.request',
body => '{"name":"John"}', # Raw bytes
more => 0, # 0 = last chunk (default), 1 = more coming
}
For WebSocket, you receive messages and disconnect events:
# Message received
{
type => 'websocket.receive',
text => 'Hello!', # For text messages
# OR
bytes => "\x00\x01\x02", # For binary messages
}
Why separate text and bytes keys? The WebSocket protocol itself distinguishes text frames (must be valid UTF-8) from binary frames (arbitrary bytes) at the wire level. Having separate keys provides three benefits: (1) you can use both on the same connection - e.g., text for JSON commands, binary for audio streams; (2) type safety - you know exactly what you received without ambiguity; (3) you can't accidentally mishandle encoding. If you don't care about the distinction: my $data = $event->{text} // $event->{bytes}. The PAGI::WebSocket helper class abstracts this for most use cases.
# Connection closed
{
type => 'websocket.disconnect',
code => 1000, # Close code
reason => 'Normal closure', # Close reason
}
For lifespan, you receive startup and shutdown events:
{ type => 'lifespan.startup' }
{ type => 'lifespan.shutdown' }
$send - Sending Events to Client
$send is an asynchronous code reference that sends events to the client. It returns a Future that completes when the event has been sent:
await $send->({ type => 'http.response.start', status => 200, headers => [...] });
await $send->({ type => 'http.response.body', body => 'Hello' });
The events you send depend on the connection type.
2.2 Scope Types
PAGI defines four scope types, each representing a different kind of connection or event.
http - Standard HTTP Requests
The most common type. Used for normal HTTP request/response cycles.
Check for it:
if ($scope->{type} eq 'http') {
# Handle HTTP request
}
websocket - WebSocket Connections
Used for bidirectional real-time communication. WebSocket connections start as HTTP upgrade requests.
Check for it:
if ($scope->{type} eq 'websocket') {
# Handle WebSocket connection
}
See PAGI::WebSocket for WebSocket utilities.
sse - Server-Sent Events
A PAGI extension for server-to-client streaming. SSE allows the server to push updates to the client over a long-lived HTTP connection.
Check for it:
if ($scope->{type} eq 'sse') {
# Handle SSE connection
}
See PAGI::SSE for SSE utilities.
lifespan - Application Lifecycle Events
Used for application startup and shutdown events. This allows your application to initialize resources (database connections, caches, etc.) when it starts and clean them up when it shuts down.
Check for it:
if ($scope->{type} eq 'lifespan') {
# Handle startup/shutdown
}
Note: PAGI::Endpoint::Router handles this via on_startup and on_shutdown callbacks.
2.3 HTTP Request/Response Cycle (Raw)
Let's build a complete HTTP request handler that reads the request body and echoes it back.
use strict;
use warnings;
use Future::AsyncAwait;
async sub app {
my ($scope, $receive, $send) = @_;
# Only handle HTTP requests
die "Expected http scope" unless $scope->{type} eq 'http';
# Read the entire request body
# NOTE: This loop does NOT block the event loop! The 'await' keyword
# yields control while waiting for data, allowing other requests to
# be processed. The loop only runs when there's actually data.
my $body = '';
while (1) {
my $event = await $receive->();
$body .= $event->{body} if defined $event->{body};
last unless $event->{more}; # Stop when no more chunks
}
# Prepare response body
my $response_body = "You sent: " . (length($body) ? $body : "(empty)");
# Send response start (status and headers)
await $send->({
type => 'http.response.start',
status => 200,
headers => [
['content-type', 'text/plain'],
['content-length', length($response_body)],
],
});
# Send response body (more => 0 is the default, so can be omitted)
await $send->({
type => 'http.response.body',
body => $response_body,
});
}
\&app;
Test it:
# Terminal 1: Start server
pagi-server echo.pl --port 5000
# Terminal 2: Send a request with body
curl -X POST -d "Hello, PAGI!" http://localhost:5000/
# Output: You sent: Hello, PAGI!
Key points:
Always check
moreto know when the request body is completeSend
http.response.startbefore sending any bodymore => 0is the default for final chunks (can be omitted)The request body is raw bytes (you may need to decode it)
2.4 WebSocket (Raw)
WebSocket enables bidirectional real-time communication. Here's a simple echo server:
use strict;
use warnings;
use Future::AsyncAwait;
async sub app {
my ($scope, $receive, $send) = @_;
# Only handle WebSocket connections
die "Expected websocket scope" unless $scope->{type} eq 'websocket';
# Accept the WebSocket connection
await $send->({
type => 'websocket.accept',
# Optional: specify a subprotocol from $scope->{subprotocols}
# subprotocol => 'chat',
});
# Echo loop: receive messages and send them back
# NOTE: This loop does NOT block! The 'await' yields to the event
# loop while waiting, allowing other connections to be processed.
while (1) {
my $event = await $receive->();
# Handle disconnection
if ($event->{type} eq 'websocket.disconnect') {
warn "Client disconnected: code=$event->{code}, reason=$event->{reason}\n";
last;
}
# Echo text messages
if (defined $event->{text}) {
await $send->({
type => 'websocket.send',
text => "Echo: $event->{text}",
});
}
# Echo binary messages
elsif (defined $event->{bytes}) {
await $send->({
type => 'websocket.send',
bytes => $event->{bytes},
});
}
}
}
\&app;
Test it with a WebSocket client:
# JavaScript in browser console:
const ws = new WebSocket('ws://localhost:5000/');
ws.onmessage = (event) => console.log('Received:', event.data);
ws.onopen = () => ws.send('Hello, WebSocket!');
// You should see: Received: Echo: Hello, WebSocket!
Key points:
Must send
websocket.acceptbefore sending any messagesCheck
type eq 'websocket.disconnect'to detect when the client closes the connectionMessages can be text (
text) or binary (bytes)The receive loop blocks waiting for the next message
See PAGI::WebSocket for higher-level utilities that make WebSocket handling easier.
2.5 SSE (Raw)
Server-Sent Events allow the server to push updates to clients over HTTP. Here's a clock that sends the current time every second:
use strict;
use warnings;
use Future::AsyncAwait;
use IO::Async::Loop;
use IO::Async::Timer::Periodic;
async sub app {
my ($scope, $receive, $send) = @_;
# Only handle SSE connections
die "Expected sse scope" unless $scope->{type} eq 'sse';
my $loop = IO::Async::Loop->new;
# Start the SSE response
await $send->({
type => 'sse.response.start',
status => 200,
headers => [
['cache-control', 'no-cache'],
['x-custom-header', 'example'],
],
});
# Create a timer that fires every second
my $timer = IO::Async::Timer::Periodic->new(
interval => 1,
on_tick => async sub {
# Send the current time as an SSE event
await $send->({
type => 'sse.response.body',
data => scalar(localtime()),
# Optional fields:
# event => 'clock', # Event type
# id => '123', # Event ID
# retry => 5000, # Retry interval in ms
});
},
);
# Add timer to the event loop
$loop->add($timer);
$timer->start;
# Keep the connection open indefinitely
# (In practice, you'd detect client disconnect)
await $loop->loop_forever;
}
\&app;
Test it:
# Terminal 1: Start server
pagi-server clock.pl --port 5000
# Terminal 2: Connect with curl
curl http://localhost:5000/
# Output (streaming):
# data: Mon Dec 23 10:30:00 2025
#
# data: Mon Dec 23 10:30:01 2025
#
# data: Mon Dec 23 10:30:02 2025
# ...
JavaScript client:
const eventSource = new EventSource('http://localhost:5000/');
eventSource.onmessage = (event) => {
console.log('Time:', event.data);
};
Key points:
Send
sse.response.startfirst with headersSend
sse.response.bodyevents withdatafieldOptional fields:
event(event type),id(event ID),retry(reconnect interval)Connection stays open for streaming
SSE is unidirectional (server to client only)
See PAGI::SSE for higher-level SSE utilities.
2.6 Streaming Responses
You can stream HTTP response bodies by sending multiple http.response.body events:
use strict;
use warnings;
use Future::AsyncAwait;
use IO::Async::Loop;
use IO::Async::Timer::Countdown;
async sub app {
my ($scope, $receive, $send) = @_;
die "Expected http scope" unless $scope->{type} eq 'http';
my $loop = IO::Async::Loop->new;
# Start response with chunked encoding
await $send->({
type => 'http.response.start',
status => 200,
headers => [
['content-type', 'text/plain'],
['transfer-encoding', 'chunked'],
],
});
# Send chunks over time
for my $i (1..5) {
await $send->({
type => 'http.response.body',
body => "Chunk $i\n",
more => 1, # More chunks coming
});
# Wait 1 second between chunks
my $timer = IO::Async::Timer::Countdown->new(
delay => 1,
on_expire => sub { },
);
$loop->add($timer);
$timer->start;
await $timer->future;
}
# Send final chunk (more => 0 is the default, so can be omitted)
await $send->({
type => 'http.response.body',
body => "Done!\n",
});
}
\&app;
Test it:
curl http://localhost:5000/
# Output (appears gradually):
# Chunk 1
# Chunk 2
# Chunk 3
# Chunk 4
# Chunk 5
# Done!
Key points:
Use
transfer-encoding: chunkedor omitcontent-lengthSet
more => 1on all chunks except the lastOmit
moreon the final chunk (defaults to 0)Useful for large files, real-time data, or progressive rendering
2.7 Lifespan Protocol (Application Lifecycle)
The lifespan protocol allows your application to initialize resources on startup and clean them up on shutdown. The key feature is $scope->{state} - a hash that persists across all requests within a worker process.
Important: In worker mode (--workers N), each worker runs lifespan independently after forking. This means each worker initializes its own database connections, cache handles, etc. This is actually good - most database connections can't be shared across forks anyway.
use strict;
use warnings;
use Future::AsyncAwait;
async sub app {
my ($scope, $receive, $send) = @_;
# Handle lifespan events (startup then shutdown - a fixed sequence)
if ($scope->{type} eq 'lifespan') {
my $state = $scope->{state}; # Shared with all requests in this worker
# Wait for startup event
my $event = await $receive->();
if ($event->{type} eq 'lifespan.startup') {
eval {
# Store resources in $state - available to all requests
$state->{db} = MyDB->connect('dbi:Pg:dbname=myapp');
$state->{cache} = Cache::Memcached->new(servers => ['localhost:11211']);
warn "Application started successfully\n";
await $send->({ type => 'lifespan.startup.complete' });
};
if ($@) {
await $send->({ type => 'lifespan.startup.failed', message => $@ });
return;
}
}
# Wait for shutdown event
$event = await $receive->();
if ($event->{type} eq 'lifespan.shutdown') {
eval {
$state->{db}->disconnect if $state->{db};
$state->{cache}->disconnect_all if $state->{cache};
warn "Application shut down successfully\n";
await $send->({ type => 'lifespan.shutdown.complete' });
};
if ($@) {
await $send->({ type => 'lifespan.shutdown.failed', message => $@ });
}
}
return;
}
# Handle HTTP requests
if ($scope->{type} eq 'http') {
my $state = $scope->{state}; # Same hash from lifespan!
my $db = $state->{db};
my $data = $db->selectrow_hashref('SELECT * FROM users LIMIT 1');
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({
type => 'http.response.body',
body => "Database connected: " . (defined $data ? 'yes' : 'no'),
});
}
}
\&app;
Key points:
Lifespan events are sent to the same application entry point
Handle
lifespan.startupto initialize resources (database, cache, etc.)Handle
lifespan.shutdownto clean up resourcesMust respond with
completeorfailedfor each eventPAGI::Endpoint::Router provides
on_startupandon_shutdowncallbacks to make this easier
Example using PAGI::Endpoint::Router:
use PAGI::Endpoint::Router;
my $db;
my $router = PAGI::Endpoint::Router->new(
on_startup => async sub ($scope) {
$db = MyDB->connect('dbi:Pg:dbname=myapp');
warn "Database connected\n";
},
on_shutdown => async sub ($scope) {
$db->disconnect;
warn "Database disconnected\n";
},
);
# ... define routes ...
$router->to_app;
2.8 UTF-8 Handling
PAGI follows specific rules for UTF-8 encoding/decoding:
Request Paths
$scope->{path} # UTF-8 decoded string (use this for display/matching)
$scope->{raw_path} # Raw bytes (preserves original encoding)
PAGI uses Mojolicious-style path decoding: percent-encoded bytes are first URL-decoded, then UTF-8 decoded. If UTF-8 decoding fails (invalid byte sequences), the original URL-decoded bytes are preserved as-is.
Example:
# URL: /users/%E4%B8%AD%E6%96%87
$scope->{path} = '/users/中文' # (decoded)
$scope->{raw_path} = '/users/%E4%B8%AD%E6%96%87' # (raw bytes)
# Invalid UTF-8: /users/%FF%FE
$scope->{path} = '/users/\xFF\xFE' # (falls back to bytes)
$scope->{raw_path} = '/users/%FF%FE' # (raw bytes)
Query Strings
Query strings are raw bytes. Use URI to parse them properly:
use strict;
use warnings;
use Future::AsyncAwait;
use URI;
use Encode qw(encode_utf8);
async sub app {
my ($scope, $receive, $send) = @_;
die "Expected http scope" unless $scope->{type} eq 'http';
# Use URI to parse query string (handles decoding automatically)
my $uri = URI->new('?' . ($scope->{query_string} // ''));
my %params = $uri->query_form;
my $name = $params{name} // 'World';
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain; charset=utf-8']],
});
await $send->({
type => 'http.response.body',
body => encode_utf8("Hello, $name!"),
});
}
\&app;
Request Bodies
Request bodies are always raw bytes:
my $event = await $receive->();
my $raw_bytes = $event->{body}; # Raw bytes
# If you know it's UTF-8 JSON:
use Encode qw(decode_utf8);
use JSON::MaybeXS;
my $text = decode_utf8($raw_bytes);
my $data = decode_json($text);
Response Bodies
Response bodies must be encoded to bytes:
use Encode qw(encode_utf8);
my $text = "Hello, 世界!"; # Perl string (characters)
my $bytes = encode_utf8($text); # Bytes
await $send->({
type => 'http.response.body',
body => $bytes, # Must be bytes, not characters
});
Complete UTF-8 Example
use strict;
use warnings;
use Future::AsyncAwait;
use Encode qw(encode_utf8 decode_utf8);
use URI::Encode qw(uri_decode);
async sub app {
my ($scope, $receive, $send) = @_;
die "Expected http scope" unless $scope->{type} eq 'http';
# 1. Path is already decoded
my $path = $scope->{path}; # UTF-8 string
# 2. Query string needs decoding
my $query = $scope->{query_string} // '';
my ($key, $value) = split /=/, $query, 2;
$value = decode_utf8(uri_decode($value // '')) if defined $value;
# 3. Request body is raw bytes
# NOTE: This loop does NOT block - await yields to event loop
my $body_bytes = '';
while (1) {
my $event = await $receive->();
$body_bytes .= $event->{body} if defined $event->{body};
last unless $event->{more};
}
my $body_text = decode_utf8($body_bytes); # Decode to string
# 4. Build response (as string)
my $response = "Path: $path\n";
$response .= "Param: $value\n" if defined $value;
$response .= "Body: $body_text\n" if length $body_text;
# 5. Encode response to bytes
my $response_bytes = encode_utf8($response);
await $send->({
type => 'http.response.start',
status => 200,
headers => [
['content-type', 'text/plain; charset=utf-8'],
['content-length', length($response_bytes)],
],
});
await $send->({
type => 'http.response.body',
body => $response_bytes, # Bytes, not string
});
}
\&app;
Using High-Level Helpers
PAGI::Request and PAGI::Response handle UTF-8 encoding/decoding automatically:
use PAGI::Request;
use PAGI::Response;
async sub app {
my ($scope, $receive, $send) = @_;
die "Expected http scope" unless $scope->{type} eq 'http';
my $req = PAGI::Request->new($scope, $receive);
my $res = PAGI::Response->new($scope, $send);
# Path is already decoded
my $path = $req->path;
# Query params are automatically decoded
my $name = $req->query_parameters->get('name') // 'World';
# Body is automatically decoded (if content-type indicates UTF-8)
my $body = await $req->body;
# Response automatically encodes to UTF-8 bytes
await $res->text("Hello, $name!");
}
\&app;
Key points:
$scope->{path}is decoded UTF-8;$scope->{raw_path}is bytesQuery strings and request bodies are raw bytes (decode them yourself)
Response bodies must be encoded to bytes before sending
Use Encode for explicit encoding/decoding
PAGI::Request and PAGI::Response handle this automatically
NEXT STEPS
In Part 3, we'll cover:
Middleware concepts and the PAGI::Middleware::Builder DSL
Essential middleware for logging, security, and sessions
Writing custom middleware
PART 3: MIDDLEWARE
Middleware provides reusable functionality that wraps your application handlers. PAGI includes 30+ middleware components for logging, security, compression, sessions, and more.
3.1 How Middleware Works
Middleware wraps your application, intercepting requests and responses. Each middleware can:
Modify the request before passing it to the next handler
Short-circuit the request and return early
Modify the response on the way back
Add side effects like logging
The pattern:
sub wrap {
my ($self, $app) = @_;
return async sub {
my ($scope, $receive, $send) = @_;
# Before: modify $scope, check conditions, etc.
await $app->($scope, $receive, $send);
# After: response already sent, but can log, etc.
};
}
3.2 Using Middleware with Builder
The PAGI::Middleware::Builder DSL lets you compose middleware into your application.
Global Middleware
Apply middleware to all routes using the builder function:
use PAGI::Middleware::Builder;
use PAGI::App::Router;
my $router = PAGI::App::Router->new;
$router->get('/' => async sub {
my ($scope, $receive, $send) = @_;
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({
type => 'http.response.body',
body => 'Hello World',
});
});
my $inner_app = $router->to_app;
# Wrap with middleware using builder
my $app = builder {
enable 'AccessLog', format => 'combined';
enable 'CORS', origins => '*';
enable 'GZip', min_size => 1024;
$inner_app;
};
Middleware executes in order: AccessLog -> CORS -> GZip -> app.
Multiple Middleware Example
my $app = builder {
enable 'RequestId'; # Add X-Request-Id header
enable 'AccessLog', format => 'tiny'; # Log requests
enable 'Runtime'; # Add X-Runtime header
enable 'CORS', origins => '*'; # Enable CORS
enable 'SecurityHeaders'; # Add security headers
enable 'GZip', min_size => 1024; # Compress responses
enable 'ErrorHandler', mode => 'development'; # Pretty errors
$router->to_app;
};
3.3 Essential Middleware Reference
Logging & Debugging
-
Log HTTP requests in Apache-style formats:
enable 'AccessLog', format => 'combined'; # Apache combined log enable 'AccessLog', format => 'common'; # Apache common log enable 'AccessLog', format => 'tiny'; # Minimal format -
Add unique request ID to each request:
enable 'RequestId'; # Adds X-Request-Id headerAccess via
$scope->{'pagi.request_id'}. -
Add response time header:
enable 'Runtime'; # Adds X-Runtime header in seconds
Security
-
Enable Cross-Origin Resource Sharing:
enable 'CORS', origins => '*', # or ['https://example.com'] methods => ['GET', 'POST'], headers => ['Content-Type'], credentials => 1, max_age => 86400; PAGI::Middleware::SecurityHeaders
Add security-related HTTP headers:
enable 'SecurityHeaders'; # Adds CSP, X-Frame-Options, etc.Includes:
X-Frame-Options: DENY X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Referrer-Policy: strict-origin-when-cross-origin-
Protect against Cross-Site Request Forgery:
enable 'CSRF', cookie_name => '_csrf_token';Validates CSRF tokens on POST/PUT/PATCH/DELETE requests.
Sessions & Cookies
-
Parse request cookies:
enable 'Cookie';Access via
$scope->{'pagi.cookies'}. -
Server-side sessions with signed cookies:
enable 'Session', secret => 'your-secret-key', cookie_name => 'session', max_age => 86400;Access via
$scope->{'pagi.session'}.
Performance
-
Compress responses with gzip:
enable 'GZip', min_size => 1024, # Only compress if > 1KB types => ['text/', 'application/json']; -
Automatic ETag generation and validation:
enable 'ETag';Returns 304 Not Modified for matching If-None-Match headers.
-
Rate limiting with configurable strategies:
enable 'RateLimit', rate => 100, # requests period => 60, # seconds by => sub { # key generator my ($scope) = @_; return $scope->{client}[0]; # by IP };
Error Handling
PAGI::Middleware::ErrorHandler
Catch and format errors:
enable 'ErrorHandler', mode => 'development'; # Pretty HTML errors enable 'ErrorHandler', mode => 'production'; # JSON errors
3.4 Writing Custom Middleware
Create reusable middleware by extending PAGI::Middleware:
package MyApp::Middleware::Auth;
use parent 'PAGI::Middleware';
use Future::AsyncAwait;
sub new {
my ($class, %args) = @_;
return bless { secret => $args{secret} }, $class;
}
sub wrap {
my ($self, $app) = @_;
return async sub {
my ($scope, $receive, $send) = @_;
# Check auth header
my $auth = $self->get_header($scope, 'Authorization');
if ($auth && $auth =~ /^Bearer (.+)/) {
my $token = $1;
my $user = verify_token($token, $self->{secret});
$scope->{'pagi.stash'}{user} = $user if $user;
}
# Call next middleware/app
await $app->($scope, $receive, $send);
};
}
sub get_header {
my ($self, $scope, $name) = @_;
$name = lc $name;
for my $header (@{$scope->{headers} || []}) {
return $header->[1] if lc($header->[0]) eq $name;
}
return undef;
}
sub verify_token {
my ($token, $secret) = @_;
# Token verification logic here
return { id => 1, username => 'alice' };
}
1;
Use it with the builder:
my $app = builder {
enable 'MyApp::Middleware::Auth', secret => 'secret-key';
$router->to_app;
};
Or per-route:
my $auth = MyApp::Middleware::Auth->new(secret => 'secret-key');
$router->get('/admin' => [$auth] => $handler);
Middleware Patterns
Modify scope
Add data for downstream handlers:
sub wrap { my ($self, $app) = @_; return async sub { my ($scope, $receive, $send) = @_; $scope->{'pagi.stash'}{custom_data} = 'value'; await $app->($scope, $receive, $send); }; }Intercept responses
Modify outgoing events:
sub wrap { my ($self, $app) = @_; return async sub { my ($scope, $receive, $send) = @_; my $wrapped_send = $self->intercept_send($send, async sub { my ($event, $original_send) = @_; if ($event->{type} eq 'http.response.start') { push @{$event->{headers}}, ['x-custom', 'value']; } await $original_send->($event); }); await $app->($scope, $receive, $wrapped_send); }; }Short-circuit requests
Return early without calling inner app:
sub wrap { my ($self, $app) = @_; return async sub { my ($scope, $receive, $send) = @_; # Check condition unless ($self->is_authorized($scope)) { await $send->({ type => 'http.response.start', status => 403, headers => [['content-type', 'text/plain']], }); await $send->({ type => 'http.response.body', body => 'Forbidden', }); return; # Don't call $app } await $app->($scope, $receive, $send); }; }
PART 4: THE HELPER CLASSES
In Part 2, we worked directly with the raw PAGI protocol ($scope, $receive, $send). While powerful, this low-level approach requires a lot of boilerplate code for common tasks.
PAGI provides four helper classes that make web development much easier:
PAGI::Response - Fluent response builder
PAGI::Request - Request parser with body handling
PAGI::WebSocket - WebSocket helper
PAGI::SSE - Server-Sent Events helper
These classes handle UTF-8 encoding/decoding, body parsing, cookie handling, and more automatically.
4.1 PAGI::Response - Fluent Response Builder
PAGI::Response provides a fluent interface for building HTTP responses. Instead of manually constructing http.response.start and http.response.body events, you use chainable methods.
Creating a Response
use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::Response;
async sub app {
my ($scope, $receive, $send) = @_;
die "Expected http scope" unless $scope->{type} eq 'http';
# Create a response builder
my $res = PAGI::Response->new($scope, $send);
# Use it to send responses
await $res->text('Hello, World!');
}
\&app;
Simple Responses
PAGI::Response provides methods for common response types:
my $res = PAGI::Response->new($scope, $send);
# Plain text
await $res->text('Hello, World!');
# HTML
await $res->html('<h1>Hello, World!</h1>');
# JSON
await $res->json({ message => 'Hello, World!' });
# Empty response (204 No Content)
await $res->empty();
Key points:
text()sets Content-Type to text/plain; charset=utf-8html()sets Content-Type to text/html; charset=utf-8json()sets Content-Type to application/json; charset=utf-8All methods automatically encode strings to UTF-8 bytes
Status and Headers
Use chainable methods to set status and headers before sending:
await $res->status(201)
->header('X-Request-ID', '12345')
->header('X-Custom', 'value')
->content_type('application/json')
->json({ created => 1 });
Chainable methods:
status($code)- Set HTTP status (default: 200)header($name, $value)- Add a response headercontent_type($type)- Set Content-Type headercookie($name, $value, %opts)- Set a cookie
Redirects
# Temporary redirect (302 Found - default)
await $res->redirect('/new-page');
# Permanent redirect (301 Moved Permanently)
await $res->redirect('/modern', 301);
# See Other (303 - used after POST)
await $res->redirect('/success', 303);
Note: redirect() sends the response immediately but does NOT stop Perl execution. Use return after redirect if you have more code below.
4.2 PAGI::Request - Request Parsing
PAGI::Request parses HTTP requests and provides convenient accessors for headers, query parameters, cookies, and request bodies.
Creating a Request
my $req = PAGI::Request->new($scope, $receive);
my $res = PAGI::Response->new($scope, $send);
await $res->text("You requested: " . $req->path);
Basic Properties
$req->method # HTTP method (GET, POST, etc.)
$req->path # URL path (decoded UTF-8)
$req->query_string # Query string (raw bytes)
$req->scheme # 'http' or 'https'
$req->host # Host header value
Headers
# Get single header (case-insensitive)
my $user_agent = $req->header('user-agent') // 'Unknown';
# Get content type
my $ct = $req->content_type;
# Get bearer token from Authorization: Bearer <token>
my $token = $req->bearer_token;
Query Parameters
# Get single parameter
my $page = $req->query('page') // '1';
# Get all parameters as Hash::MultiValue
my $params = $req->query_params;
my @tags = $params->get_all('tag'); # For ?tag=foo&tag=bar
Body Parsing
# Read entire body as raw bytes
my $bytes = await $req->body;
# Parse JSON body
my $data = await $req->json;
# Parse URL-encoded form
my $form = await $req->form;
my $name = $form->get('name');
Content-type predicates:
is_json()- True if Content-Type is application/jsonis_form()- True if form data (urlencoded or multipart)
4.3 PAGI::WebSocket - WebSocket Helper
PAGI::WebSocket simplifies WebSocket connections:
my $ws = PAGI::WebSocket->new($scope, $receive, $send);
await $ws->accept;
# Send and receive
await $ws->send_text("Hello!");
my $msg = await $ws->receive;
# JSON messages
await $ws->send_json({ type => 'greeting' });
my $data = await $ws->receive_json;
# Message loop
await $ws->each_text(async sub {
my ($text) = @_;
await $ws->send_text("Echo: $text");
});
await $ws->close(1000, 'Goodbye');
4.4 PAGI::SSE - Server-Sent Events Helper
PAGI::SSE simplifies Server-Sent Events:
my $sse = PAGI::SSE->new($scope, $receive, $send);
await $sse->start;
# Send events
await $sse->send_event("Hello!");
await $sse->send_event("User logged in", event => 'login', id => '42');
# Send JSON
await $sse->send_json({ count => 5 }, event => 'update');
# Periodic updates
await $sse->every(1, async sub {
await $sse->send_event("Time: " . time);
});
# Keepalive to prevent proxy timeouts
$sse->keepalive(15);
PART 5: BUILT-IN APPLICATIONS
PAGI ships with several ready-to-use applications for common tasks. These can be mounted with routers or used standalone.
5.1 PAGI::App::Router - Basic Routing
PAGI::App::Router provides lightweight functional routing:
use PAGI::App::Router;
use PAGI::Response;
my $router = PAGI::App::Router->new;
# HTTP routes
$router->get('/' => async sub {
my ($scope, $receive, $send) = @_;
my $res = PAGI::Response->new($scope, $send);
await $res->text('Home');
});
$router->post('/users' => async sub {
my ($scope, $receive, $send) = @_;
my $res = PAGI::Response->new($scope, $send);
await $res->status(201)->json({ created => 1 });
});
# Path parameters
$router->get('/users/:id' => async sub {
my ($scope, $receive, $send) = @_;
my $req = PAGI::Request->new($scope, $receive);
my $res = PAGI::Response->new($scope, $send);
my $id = $req->path_param('id');
await $res->json({ id => $id });
});
# WebSocket and SSE
$router->websocket('/ws' => async sub { ... });
$router->sse('/events' => async sub { ... });
$router->to_app;
For advanced routing patterns (nested routers, route-level middleware, class-based routing), see PAGI::Cookbook.
5.2 PAGI::App::File - Static Files
PAGI::App::File serves static files with security, caching, and streaming:
use PAGI::App::File;
my $static = PAGI::App::File->new(root => './public');
$router->mount('/static' => $static->to_app);
Features:
Efficient streaming (large files don't consume memory)
ETag caching with 304 Not Modified support
HTTP Range requests for resume support
Automatic MIME type detection
Security: path traversal protection
5.3 PAGI::App::Healthcheck - Health Endpoints
PAGI::App::Healthcheck creates health check endpoints:
use PAGI::App::Healthcheck;
my $health = PAGI::App::Healthcheck->new(
version => '1.0.0',
checks => {
database => sub { $db && $db->ping },
cache => sub { $redis && $redis->ping },
},
);
$router->mount('/health' => $health->to_app);
5.4 PAGI::App::URLMap - Mount Applications
PAGI::App::URLMap routes requests to different apps based on URL prefix:
use PAGI::App::URLMap;
my $urlmap = PAGI::App::URLMap->new;
$urlmap->mount('/api' => $api_app);
$urlmap->mount('/admin' => $admin_app);
$urlmap->mount('/static' => $static);
$urlmap->to_app;
5.5 PAGI::App::Cascade - Try Apps in Sequence
PAGI::App::Cascade tries apps in order until one returns a non-404:
use PAGI::App::Cascade;
my $app = PAGI::App::Cascade->new(
apps => [$static, $api, $fallback],
catch => [404, 405],
);
5.6 PAGI::App::Proxy - Reverse Proxy
PAGI::App::Proxy forwards requests to backend servers:
use PAGI::App::Proxy;
my $proxy = PAGI::App::Proxy->new(
backend => 'http://localhost:8080',
timeout => 30,
);
$router->mount('/api' => $proxy->to_app);
5.7 PAGI::App::WrapPSGI - Use Existing PSGI Apps
PAGI::App::WrapPSGI lets you use existing PSGI applications:
use PAGI::App::WrapPSGI;
my $wrapped = PAGI::App::WrapPSGI->new(psgi_app => $legacy_app);
$router->mount('/legacy' => $wrapped->to_app);
Useful for incremental migration from PSGI to PAGI.
PART 6: REFERENCE
6.1 Scope Reference
The $scope hashref contains connection metadata:
Accessing the Event Loop
The event loop is not part of $scope. To access it, use the IO::Async::Loop singleton:
use IO::Async::Loop;
my $loop = IO::Async::Loop->new; # Returns cached singleton
This works because IO::Async::Loop caches the loop instance.
Common Fields (All Types)
$scope->{type} # 'http', 'websocket', 'sse', 'lifespan'
HTTP Scope
$scope->{type} # 'http'
$scope->{method} # 'GET', 'POST', etc.
$scope->{path} # URL path (decoded UTF-8)
$scope->{raw_path} # URL path (raw bytes)
$scope->{query_string} # Query string (raw bytes)
$scope->{headers} # Arrayref of [name, value] pairs
$scope->{scheme} # 'http' or 'https'
$scope->{http_version} # '1.0' or '1.1'
$scope->{client} # [ip, port] of client
$scope->{server} # [ip, port] of server
WebSocket Scope
$scope->{type} # 'websocket'
$scope->{path} # URL path
$scope->{query_string} # Query string
$scope->{headers} # Request headers
$scope->{subprotocols} # Arrayref of requested subprotocols
$scope->{client} # [ip, port] of client
SSE Scope
$scope->{type} # 'sse'
$scope->{path} # URL path
$scope->{query_string} # Query string
$scope->{headers} # Request headers
$scope->{client} # [ip, port] of client
Lifespan Scope
$scope->{type} # 'lifespan'
6.2 Event Reference
HTTP Events
Receive:
{ type => 'http.request', body => $bytes, more => 1 }
{ type => 'http.disconnect' }
Send:
{ type => 'http.response.start', status => 200, headers => [...] }
{ type => 'http.response.body', body => $bytes, more => 0 }
WebSocket Events
Receive:
{ type => 'websocket.connect' }
{ type => 'websocket.receive', text => $text }
{ type => 'websocket.receive', bytes => $bytes }
{ type => 'websocket.disconnect', code => 1000, reason => '' }
Send:
{ type => 'websocket.accept', subprotocol => 'optional' }
{ type => 'websocket.send', text => $text }
{ type => 'websocket.send', bytes => $bytes }
{ type => 'websocket.close', code => 1000, reason => '' }
SSE Events
Receive:
{ type => 'sse.connect' }
{ type => 'sse.disconnect' }
Send:
{ type => 'sse.response.start', status => 200, headers => [...] }
{ type => 'sse.response.body', data => $text, event => 'name', id => '1' }
Lifespan Events
Receive:
{ type => 'lifespan.startup' }
{ type => 'lifespan.shutdown' }
Send:
{ type => 'lifespan.startup.complete' }
{ type => 'lifespan.startup.failed', message => $error }
{ type => 'lifespan.shutdown.complete' }
{ type => 'lifespan.shutdown.failed', message => $error }
NEXT STEPS
You've covered the essentials! For more information:
PAGI::Cookbook - Recipes for routing, authentication, real-time patterns, and more
Browse
examples/for complete working applicationsCheck individual module documentation (PAGI::Response, PAGI::WebSocket, etc.)
Read the PAGI specification in
docs/pagi-spec.md
SEE ALSO
PAGI - Main PAGI documentation
PAGI::Cookbook - Recipes for common tasks
PAGI::Server - Reference async HTTP server
PAGI::Request - Request object with automatic UTF-8 handling
PAGI::Response - Response object with automatic UTF-8 handling
PAGI::App::Router - Functional routing
PAGI::Endpoint::Router - Class-based routing framework
PAGI::WebSocket - WebSocket utilities
PAGI::SSE - Server-Sent Events utilities
Future::AsyncAwait - Async/await syntax for Perl
IO::Async::Loop - Event loop implementation
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.