NAME
PAGI::Building - building frameworks and toolkits on PAGI
DESCRIPTION
This guide is for people building a layer on PAGI: a web framework, a toolkit, an application harness. Where PAGI::PSGI is about bringing your code to PAGI, this is about building on it.
The thesis is short. A PAGI application is just:
async sub {
my ($scope, $receive, $send) = @_;
...
}
Everything a framework provides -- routing, middleware, request and response objects, content negotiation, sessions -- is an optional layer over that one small contract. The contract is stable (see the stability notice in PAGI), so a framework built on it does not have to chase a moving target, and an application written against your framework runs unchanged on any conforming server.
THE LAYERING MODEL
A framework is not a different kind of thing from a raw PAGI application; it is a stack of conveniences over the same $scope/$receive/$send. It helps to see the layers as separable, because you can write your own, borrow ready-made ones from the PAGI-Tools distribution, or mix the two:
- Dispatch / routing
-
Map an incoming request (method and path, the scope type) to a handler. examples/mini-framework is a complete worked example -- routing with path parameters and async dispatch in about fifty lines. The production version is PAGI::App::Router.
- Request and response ergonomics
-
Hand the user a request object instead of a raw
$scope, and a response builder instead of hand-writtenhttp.response.start/http.response.bodyevents. See PAGI::Request and PAGI::Response. - Middleware
-
A wrapping layer around the application -- see "MIDDLEWARE" below. The
PAGI::Middleware::*suite ships ready-made ones (CORS, sessions, gzip, auth, rate limiting, and more). - Everything else
-
Error handling, content negotiation, body parsing, cookies, sessions: each is another small layer over the protocol, not a reimplementation of it.
The point is that you never reimplement the protocol. You add conveniences, and you choose how many.
MIDDLEWARE
A middleware is a function that takes the inner application (an async sub) and returns a new one. The new wrapper can adjust $scope before calling inner, wrap $send (or $receive) to observe or rewrite events, short-circuit by responding without calling inner at all, or do work before and after.
A complete example that stamps each request with an id -- making it visible to inner handlers via the scope, and to the client via a response header:
sub with_request_id {
my ($app) = @_;
my $counter = 0;
return async sub {
my ($scope, $receive, $send) = @_;
my $id = ++$counter;
my $inner = { %$scope }; # clone before modifying
$inner->{'myapp.request_id'} = $id; # inner handlers can read this
my $wrapped_send = async sub {
my ($event) = @_;
if ($event->{type} eq 'http.response.start') {
push @{ $event->{headers} }, [ 'x-request-id', $id ];
}
return await $send->($event);
};
return await $app->($inner, $receive, $wrapped_send);
};
}
To short-circuit -- an authentication gate, say -- a middleware simply sends its own response and returns without ever calling $app. This is the same shape as PSGI middleware, one level asynchronous; if you have written PSGI middleware, you already know how to write this.
Scope is cloned per layer, not shared
Notice the example clones the scope (my $inner = { %$scope }) before stamping its key, rather than writing to $scope directly. The specification requires this: middleware must not mutate the scope they were handed.
The reason is isolation. Middleware form an onion -- each layer wraps the next, a request descends through them to the application, and control rises back out. Cloning on the way down keeps each layer's scope edits local to itself and the layers below it. A middleware that rewrites path, negotiates a content type, or stamps a request id changes the scope only for the inner application; its sibling and parent layers, and the server's own view of the scope, are untouched. Mutating in place would let those edits leak upward and bleed between unrelated layers. Scope edits flow downward only.
(This is the one place PAGI departs from PSGI's single $env mutated in place. If you are coming from PSGI, see PAGI::PSGI for the migration note.)
Shallow, not deep
The clone is shallow -- { %$scope } copies the top-level key/value pairs into a new hashref and nothing more -- and that is deliberate. It produces a precise asymmetry:
Top-level keys are private. Adding, removing, or replacing a key on the clone (
$inner->{x} = ...) affects only that clone and the layers below it. This is the isolation above.Referenced values are shared. When a key's value is a reference -- a hashref, arrayref, or object -- the clone copies the reference, not what it points at. Every layer holds the same underlying thing, so mutating through the reference (
$inner->{conn}->mark(...),$inner->{state}{count}++) is visible to all layers, above and below.
PAGI depends on this asymmetry. The server seeds shared objects into the scope before any middleware runs -- the pagi.connection connection-state object, the lifespan state namespace ("Lifespan State" in PAGI::Spec::Lifespan) -- so every layer shares the one instance. A deep clone would copy those per layer, giving each middleware its own disconnected pagi.connection and its own copy of the database pool, breaking disconnect propagation, lifespan sharing, and the escape hatch below. Shallow cloning is not an optimization; the shared references are the point.
The footgun: scalars do not propagate
The trap follows directly: a plain scalar set as a top-level scope key does not propagate across layers. Set $inner->{'myapp.sent'} = 1 in an inner layer and an outer layer will never see it -- the shallow clone snapshotted the scalar by value. This is true in any language with shallow copy (Python's dict(scope) behaves identically); it is not a Perl quirk. If you find yourself setting a scope flag in one layer and reading it in another, this is why it "vanishes."
Sharing state across layers
To deliberately share mutable per-request state, use a reference, and make sure it exists before the layers that need to share it:
To share downward (an outer middleware hands state to inner layers), seed a reference on the scope before descending --
$inner->{'myapp.ctx'} = {}-- then inner layers mutate through it.To share upward (an inner layer reports back to an outer one), the shared reference must already exist at or above the outer layer before the clone. A new top-level key created deep in the stack cannot bubble up, by design.
PAGI::Stash packages this pattern: it wraps a single pagi.stash hashref, so any layer holding the scope reads and writes shared per-request state through the one reference. Seed it high -- in the server or an outer middleware -- and it propagates in both directions.
Rule of thumb: top-level scope keys describe your layer's view of the request; shared mutable state lives behind a reference.
EXTENDING THE PROTOCOL
PAGI is deliberately small, but it is not closed. A framework can define its own events and scope conventions:
New protocol events or scope types (a capability like
http.fullflush, or a whole new protocol) are defined through the extension mechanism and require a cooperating server. See PAGI::Spec::Extensions.Your own asynchronous events -- timers, background jobs, external triggers -- require no server cooperation at all. You model them as Futures and compose them with the protocol's events, loop-agnostically. This is covered in "Defining your own events" in PAGI::Spec::Extensions, and it is the capability that lets a framework offer genuinely event-driven features without binding itself to one event loop.
Scope keys under your own namespace (
$scope->{'myapp.session'}) are how middleware passes data down to handlers -- the equivalent of stashing in$env.
PATTERNS WORTH KNOWING
Two asynchronous footguns are worth designing away for your users, so they do not have to think about them:
Orphaned response Futures. If a user forgets to
awaita$send, the application's Future can resolve before the send actually completes. A framework can prevent this entirely by routing all output through a response object that collects every send Future and awaits them together when the handler finishes.Orphaned background Futures. A background task whose Future nobody holds can fail silently or outlive the request. A framework that participates in the server's loop (for example by being an IO::Async::Notifier and adopting its Futures) can surface those errors and shut the work down coherently.
These are design directions, not prescriptions -- but solving them is a large part of what turns "a few conveniences" into a framework people trust.
SEE ALSO
- PAGI::PSGI - coming to PAGI from PSGI
- PAGI::Tutorial - the protocol, taught from scratch
- PAGI::Cookbook - worked, runnable recipes
- PAGI::Spec - the specification
- PAGI::Spec::Extensions - the extension mechanism and custom events
- PAGI::EventLoops - event loops, non-blocking I/O, and binding a server to a loop
And examples/mini-framework for a complete framework in about fifty lines.
AUTHOR
John Napiorkowski <jjnapiork@cpan.org>
COPYRIGHT AND LICENSE
This software is licensed under the same terms as Perl itself.