NAME

PAGI::Tools::Tutorial - Optional convenience helpers for PAGI applications

DESCRIPTION

PAGI itself is a protocol; see PAGI::Tutorial for the protocol tutorial. The PAGI-Tools distribution adds optional convenience helpers built on top of that protocol: middleware, request/response sugar, WebSocket and SSE helpers, routers, and ready-made applications. None of these are required to write a complete PAGI application; they exist to save you boilerplate when you want it, and every one of them is an ordinary PAGI application or a plain wrapper around the $scope/$receive/$send protocol. This guide covers them.

PART 1: HELLO WORLD

A PAGI application is an async sub that receives three arguments: $scope (the request metadata), $receive (pull request-body events), and $send (push response events). At the raw protocol level, "hello world" emits two events — the response start (status and headers) and the body:

use Future::AsyncAwait;

my $app = 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!',
    });
};

That is a complete, server-ready application — no toolkit required. The helpers in this distribution simply make the common cases shorter. Here is the same response built as a PAGI::Response value:

use Future::AsyncAwait;
use PAGI::Response;

my $app = async sub {
    my ($scope, $receive, $send) = @_;
    await PAGI::Response->text('Hello, world!')->respond($send);
};

PAGI::Response assembles the two events for you, and respond sends them. Returning JSON is just as short:

await PAGI::Response->json({ hello => 'world' })->respond($send);

From here the tutorial builds up: routing requests to handlers (PART 2), reading form and JSON input (PART 3), the full response builder (PART 4), real-time and streaming (PART 5), middleware (PART 6), and composing larger applications (PART 7).

PART 2: ROUTING

2.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;

# Mount a static response value — dispatch sends it automatically
$router->mount('/health' => PAGI::Response->json({ ok => \1 }));

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

$router->post('/users' => async sub {
    my ($scope, $receive, $send) = @_;
    my $res = PAGI::Response->new($scope);
    await $res->json({ created => 1 }, status => 201)->respond($send);
});

# Path parameters
$router->get('/users/:id' => async sub {
    my ($scope, $receive, $send) = @_;
    my $req = PAGI::Request->new($scope, $receive);
    my $res = $req->response;
    my $id = $req->path_param('id');
    await $res->json({ id => $id })->respond($send);
});

# WebSocket and SSE handlers drive $send imperatively — no return value
$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::Tools::Cookbook.

PART 3: HANDLING REQUESTS

Once a request is routed to a handler, PAGI::Request turns the raw $scope and $receive into a convenient object: query and path parameters, headers, cookies, and — the focus of this part — form bodies, JSON bodies, and file uploads. It handles UTF-8 decoding and body parsing for you.

3.1 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 = $req->response;

await $res->text("You requested: " . $req->path)->respond($send);

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_param('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_params;
my $name = $form->get('name');

# Or get a single form value directly
my $email = await $req->form_param('email');

Content-type predicates:

  • is_json() - True if Content-Type is application/json

  • is_form() - True if form data (urlencoded or multipart)

Streaming Large Request Bodies

The body methods above buffer the whole body in memory. For large uploads, stream it instead with body_stream, which returns a PAGI::Request::BodyStream you consume incrementally:

# Pull chunks yourself (undef at end-of-body or client disconnect)
my $stream = $req->body_stream(max_bytes => 100 * 1024 * 1024);
while (defined(my $chunk = await $stream->next_chunk)) {
    # ... process $chunk ...
}

# Or drain straight to a file (never holds the whole body in memory)
await $req->body_stream->stream_to_file('/uploads/data.bin');

# Or to a custom async sink — the read pauses until your sink resolves,
# giving you natural backpressure
await $req->body_stream->stream_to(async sub ($chunk) {
    await $store->append($chunk);
});

Options: max_bytes (a size cap; defaults to the Content-Length header), decode (e.g. 'UTF-8', which handles multi-byte sequences split across chunk boundaries), and strict (throw on invalid UTF-8).

Streaming is mutually exclusive with the buffered methods. Once you call body_stream, body/text/json/form_params are unavailable on that request (and vice versa) — a body can only be consumed once.

Multipart file uploads stream automatically: $req->upload / $req->uploads spool large parts to a temp file rather than holding them in memory, so you rarely need body_stream for ordinary form uploads. See PAGI::Request::BodyStream for the full API and backpressure examples.

When you would rather own where each upload goes, multipart_stream returns a PAGI::Request::MultipartStream that yields one part at a time:

my $stream = $req->multipart_stream;
while (defined(my $part = await $stream->next)) {
    if ($part->is_file) {
        await $part->stream_to(async sub ($chunk) { await $sink->write($chunk) });
    } else {
        my $value = await $part->value;
    }
}

This bypasses the auto-spool, so each upload part goes straight to your sink (an S3 client, a transform, an async writer) instead of a temp file, and the sink can be fully asynchronous -- the read pauses until your sink's Future resolves. Slow-client and idle-read timeouts, and the aggregate max_body_size cap, are enforced by the PAGI server, not by this layer (the stream cannot impose a wall-clock timeout itself).

PART 4: BUILDING RESPONSES

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 v5.40;
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 value (no connection yet)
    my $res = PAGI::Response->new($scope);

    # Build the response, then send it via respond($send)
    await $res->text('Hello, World!')->respond($send);
}

\&app;

Simple Responses

PAGI::Response provides methods for common response types. Each body method sets the body and returns $self, so you chain respond($send) to transmit:

my $res = PAGI::Response->new($scope);

# Plain text
await $res->text('Hello, World!')->respond($send);

# HTML
await $res->html('<h1>Hello, World!</h1>')->respond($send);

# JSON
await $res->json({ message => 'Hello, World!' })->respond($send);

# Empty response (204 No Content)
await $res->empty()->respond($send);

Factory class methods build a detached response value you can use directly or mount on a router:

# Build and send inline
await PAGI::Response->json({ ok => \1 })->respond($send);

# Mount a static value — dispatch calls respond automatically
$router->mount('/health' => PAGI::Response->json({ ok => \1 }));

Key points:

  • text() sets Content-Type to text/plain; charset=utf-8

  • html() sets Content-Type to text/html; charset=utf-8

  • json() sets Content-Type to application/json (no charset; JSON is always UTF-8)

  • All methods automatically encode strings to UTF-8 bytes

Status and Headers

Use chainable methods to set status and headers before sending. In a raw app the chain ends with respond($send):

await $res->status(201)
          ->header('X-Request-ID', '12345')
          ->header('X-Custom', 'value')
          ->content_type('application/json')
          ->json({ created => 1 })
          ->respond($send);

Alternatively, pass status and headers as options directly to the body factory:

await PAGI::Response->json({ created => 1 },
    status  => 201,
    headers => ['X-Request-ID' => '12345'],   # flat [ name => value, ... ]
)->respond($send);

Chainable methods:

  • status($code) - Set HTTP status (default: 200)

  • header($name, $value) - Add a response header

  • content_type($type) - Set Content-Type header

  • cookie($name, $value, %opts) - Set a cookie

Redirects

redirect takes the destination URL and an optional positional status (default 302). In a raw app, chain respond($send) to transmit:

# Temporary redirect (302 Found - default)
await $res->redirect('/new-page')->respond($send);

# Permanent redirect (301 Moved Permanently)
await $res->redirect('/modern', 301)->respond($send);

# See Other (303 - used after POST)
await $res->redirect('/success', 303)->respond($send);

The factory form works the same way:

await PAGI::Response->redirect('/login', 302)->respond($send);

Note: redirect() sets the body and returns $self; the response is not transmitted until respond($send) is called. Use return after the await if you have more code below.

PART 5: REAL-TIME & STREAMING

5.1 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');

5.2 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 (data is a named argument)
await $sse->send_event(data => "Hello!");
await $sse->send_event(data => "User logged in", event => 'login', id => '42');

# Send JSON with an event name (hashref/arrayref data is auto-encoded)
await $sse->send_event(data => { count => 5 }, event => 'update');

# Or, for a plain JSON message with no event name:
await $sse->send_json({ count => 5 });

# Keepalive to prevent proxy timeouts
await $sse->keepalive(15);

# Wait for disconnect
await $sse->run;

5.3 The Event Dispatcher

When the receive stream mixes protocol events with application events -- for example, middleware that injects pub/sub messages -- PAGI::Context offers a small dispatch loop: register a handler per event type and run once, instead of a hand-rolled while (defined(my $e = await $ctx->receive->())) loop.

my $ctx = PAGI::Context->new($scope, $receive, $send);
await $ctx->accept;

$ctx->on('websocket.receive', async sub {
    my ($ctx, $event) = @_;
    await $ctx->send_text("echo: $event->{text}");
});
$ctx->on('app.notify', async sub {        # an event injected by middleware
    my ($ctx, $event) = @_;
    await $ctx->send_json({ notice => $event->{message} });
});
$ctx->on_error(sub { my ($ctx, $err, $src) = @_; warn "[$src] $err" });

my $reason = await $ctx->run;   # 'disconnect', 'stop', or 'error'

See "Handling a Mixed Event Stream" in PAGI::Tools::Cookbook for a fuller example.

PART 6: MIDDLEWARE

Middleware provides reusable functionality that wraps your application handlers. PAGI includes 30+ middleware components for logging, security, compression, sessions, and more.

6.1 How Middleware Works

At the protocol level there is nothing special about middleware: it is simply a PAGI application that wraps another PAGI application. It receives the same ($scope, $receive, $send), does its work before and/or after, and delegates to the app it wraps. That lets a middleware:

  • Modify the request scope before the inner app sees it

  • Short-circuit the request and respond early, without calling the inner app

  • Intercept and modify the response on its way out

  • Add side effects such as logging or timing

The raw-PAGI version

Say we want to stamp every response with an X-Powered-By header. With nothing but the protocol, a middleware is a function that wraps an app and intercepts the http.response.start event by wrapping $send:

use Future::AsyncAwait;

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

        # Wrap $send so we can edit the response-start event on its way out
        my $wrapped_send = async sub {
            my ($event) = @_;
            if ($event->{type} eq 'http.response.start') {
                push @{ $event->{headers} }, ['x-powered-by', 'PAGI'];
            }
            await $send->($event);
        };

        await $app->($scope, $receive, $wrapped_send);
    };
}

It works, but you write the $send-wrapping boilerplate by hand, and you compose middleware by nesting function calls — which reads inside-out and hides the running order:

my $app = add_powered_by( time_requests( access_log( $inner_app ) ) );

The PAGI::Middleware version

Here is the same middleware as a PAGI::Middleware subclass. The base class supplies the wrapping helpers; intercept_send hands your callback each outgoing $event together with the original $send to forward it to:

package MyApp::Middleware::PoweredBy;
use parent 'PAGI::Middleware';
use Future::AsyncAwait;

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

        my $wrapped_send = $self->intercept_send($send, async sub {
            my ($event, $orig_send) = @_;
            push @{ $event->{headers} }, ['x-powered-by', 'PAGI']
                if $event->{type} eq 'http.response.start';
            await $orig_send->($event);
        });

        await $app->($scope, $receive, $wrapped_send);
    };
}

1;

Now it composes with the builder as a flat stack that reads top-to-bottom, in the order it runs:

use PAGI::Middleware::Builder;

my $app = builder {
    enable '^MyApp::Middleware::PoweredBy';
    enable 'AccessLog';
    enable 'Runtime';
    $router;
};

Why the toolkit version is worth it

The behavior is identical — what you gain is everything around it:

  • Composition. The builder stack reads in run order, top to bottom, instead of inside-out function nesting. Adding, removing, or reordering a middleware is a one-line change.

  • Configuration. A middleware is a configurable object — enable 'GZip', min_size => 1024 — with construction-time validation, rather than a closure capturing lexicals.

  • Reuse. It is an ordinary class: ship it on CPAN, share it between apps, or reach for one of the 30+ already in this distribution (see "6.3 Essential Middleware Reference") rather than writing your own.

  • Conditional and per-route use. enable_if { ... } 'Name' applies a middleware only when a condition holds, and any route can carry its own middleware list (see "6.2 Using Middleware with Builder").

  • Helpers. The base provides intercept_send, modify_scope, buffer_request_body, and friends, so each middleware isn't re-implementing the protocol plumbing.

6.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',
    });
});

# Wrap with middleware using builder; the block's final value can be
# an app coderef, a component, or (as here) the router itself
my $app = builder {
    enable 'AccessLog', format => 'combined';
    enable 'CORS', origins => '*';
    enable 'GZip', min_size => 1024;
    $router;
};

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', development => 1;  # Pretty errors
    $router;
};

Middleware Instances

enable also accepts an already-configured middleware instance — useful when you build the instance elsewhere or want construction-time errors. The parentheses are required for the instance form:

my $gzip = PAGI::Middleware::GZip->new(min_size => 1024);

my $app = builder {
    enable 'AccessLog', format => 'tiny';
    enable($gzip);
    $router;
};

6.3 Essential Middleware Reference

Logging & Debugging

  • PAGI::Middleware::AccessLog

    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
  • PAGI::Middleware::RequestId

    Add unique request ID to each request:

    enable 'RequestId';  # Adds X-Request-Id header

    Access via $scope->{request_id}.

  • PAGI::Middleware::Runtime

    Add response time header:

    enable 'Runtime';  # Adds X-Runtime header in seconds

Security

  • PAGI::Middleware::CORS

    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: SAMEORIGIN
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 1; mode=block
    Referrer-Policy: strict-origin-when-cross-origin
  • PAGI::Middleware::CSRF

    Protect against Cross-Site Request Forgery:

    enable 'CSRF', cookie_name => '_csrf_token';

    Validates CSRF tokens on POST/PUT/PATCH/DELETE requests.

Sessions & Cookies

  • PAGI::Middleware::Cookie

    Parse request cookies:

    enable 'Cookie';

    Access via $scope->{'pagi.cookies'}.

  • PAGI::Middleware::Session

    Server-side sessions with signed cookies:

    enable 'Session',
        secret      => 'your-secret-key',
        cookie_name => 'session',
        expire      => 86400;

    Access via $scope->{'pagi.session'}.

Performance

  • PAGI::Middleware::GZip

    Compress responses with gzip:

    enable 'GZip',
        min_size   => 1024,             # Only compress if > 1KB
        mime_types => ['text/', 'application/json'];
  • PAGI::Middleware::ETag

    Automatic ETag generation and validation:

    enable 'ETag';

    Returns 304 Not Modified for matching If-None-Match headers.

  • PAGI::Middleware::RateLimit

    Rate limiting with configurable strategies:

    enable 'RateLimit',
        requests_per_second => 100,         # token-bucket refill rate
        burst               => 100,         # bucket capacity (max burst)
        key_generator       => sub {        # key generator (defaults to client IP)
            my ($scope) = @_;
            return $scope->{client}[0];      # by IP
        };

Error Handling

  • PAGI::Middleware::ErrorHandler

    Catch and format errors:

    enable 'ErrorHandler', development => 1;  # Pretty HTML errors with stack traces
    enable 'ErrorHandler';                    # Production (default): generic errors, no stack leak

    ErrorHandler renders text/html by default; set content_type => 'application/json' (or 'text/plain') to change the format.

6.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});
            PAGI::Stash->new($scope)->set(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;
};

The ^ prefix tells the builder to use the class name as-is instead of prepending PAGI::Middleware::. Or skip name resolution entirely by passing a configured instance (parentheses required):

my $app = builder {
    enable(MyApp::Middleware::Auth->new(secret => 'secret-key'));
    $router;
};

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) = @_;
            PAGI::Stash->new($scope)->set(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 7: COMPOSING APPLICATIONS

PAGI ships with several ready-to-use applications for common tasks. These can be mounted with routers or used standalone.

7.1 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);

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

7.2 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);

7.3 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;

7.4 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],
);

7.5 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);

7.6 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);

Useful for incremental migration from PSGI to PAGI.

7.7 Application Composition

Every place this toolkit accepts an application coerces its argument through "to_app" in PAGI::Utils, so three forms are interchangeable in app position:

  • An async coderef — the protocol-level form, used unchanged

  • A component object — anything with a to_app method, compiled once at composition time: PAGI::App::File->new(root => 'public')

  • A class name string — auto-required if needed, then compiled via the class-method to_app: 'MyApp::API'

App positions are: mount targets and route handlers in PAGI::App::Router, mount and the final block value in PAGI::Middleware::Builder's builder {}, PAGI::App::URLMap mounts and its default, PAGI::App::Cascade entries, and the app argument to PAGI::Test::Client. Put together:

my $app = builder {
    enable 'AccessLog';
    mount '/static' => PAGI::App::File->new(root => 'public');
    mount '/api'    => 'MyApp::API';
    PAGI::Response->text('no such page')->status(404);
};

Compilation happens exactly once, when the composition point receives the component — never per request.

Middleware position is the same idea with a different duck type: enable accepts a middleware name (resolved under PAGI::Middleware::, with ^ to escape) or a configured instance with a wrap method, and per-route middleware arrays in PAGI::App::Router accept instances or ($scope, $receive, $send, $next) coderefs.

Passing middleware where an app belongs (or vice versa) croaks at composition time with a message pointing at the right position.

The one place that still wants an explicit ->to_app is the value your app.pl hands to the server: the server contract is a plain coderef, so end your file with $router->to_app (or a builder {} block, which already returns one).

SEE ALSO

AUTHOR

PAGI Contributors

COPYRIGHT AND LICENSE

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

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