Punk: a MVC framework

Punk: a MVC framework

Every web framework does a certain amount of thinking on every request. It looks at the path and decides which route matched. It works out which chain of before filters are applied. It resolves 'Web::Book#view' into an actual coderef. It checks whether this action wants JSON. None of that work depends on the request in any interesting way, the answers are the same on request one and request one million, yet most frameworks compute them again every time, because that is the natural way to write a framework and because the cost is invisible until it isn't.

Punk is a Perl MVC framework built on the opposite instinct: do all of that once, at startup, and freeze the result. By the time your application is serving traffic there is no routing table to interpret, no filter chain to assemble, no string to resolve. Dispatch is a hash lookup or a short bucket scan. Guards are a walk over a frozen array. The handler is a plain coderef call that receives exactly one argument, the context object.

It looks like a normal framework:

package MyApp;
use Punk;

get  '/'          => 'Web::Book#home';
get  '/books/:id' => 'Web::Book#view';
post '/books'     => 'Web::Book#create';

my $admin = under '/admin' => sub {
    my ($c) = @_;
    return $c->redirect('/') unless $c->req->header('authorization');
    return;
};
$admin->get('/books' => 'Web::Book#admin_list');

static '/static' => 'root/static';

1;
# app.psgi
use MyApp;
MyApp->to_app;

The interesting moment is to_app. That is where Punk walks everything you declared, resolves it, and builds the compiled form. 'Web::Book#view' becomes the actual coderef it names. /admin merges its guard into every route beneath it. The static mount becomes a C closure. After that the declaration is finished with, and what serves your traffic is a frozen structure that nothing interprets.


What that buys, and what it costs

The obvious payoff is that per request work goes down. The less obvious one is that errors move to boot time. A controller method that does not exist, a view engine that will not accept an option, a security scheme the spec requires but no checker was supplied for, a docs path that collides with a real route - all of these fail when the application starts, with a message naming the thing, rather than on the unlucky request that first reaches them. An application that starts is an application whose wiring is known to be sound.

The cost is the honest one: you cannot add a route at runtime. There is no live structure to patch. If that is a thing you need, Punk is the wrong shape and I would rather say so plainly than pretend otherwise.

It also has a consequence I did not anticipate when I started, which is that a compiled at boot application is opaque from the outside. Everything is resolved and frozen inside one coderef, so you cannot read the routing table by eyeballing the source - the source is the declaration, not the result. That is what the command line is for, and I will come back to it.


The keywords

The DSL is small and each keyword does one thing.

Routing. get, post, put, patch, del, any, and under for scopes. A scope carries a prefix and a guard, nests, and returns a handle you hang routes off. A guard that returns a response short circuits; a guard that returns nothing lets the request through.

Realtime. websocket routes like a GET (upgrade requests are GETs), so it sits under the same scopes and guards as anything else and a guard can refuse a client with an ordinary HTTP response before the handshake happens. Once validated, the handler gets the context and the connection, wires the events it wants, and returns; the connection then lives on the server's event loop. sse is the same idea for Server-Sent Events, with a heartbeat.

Sessions and security. session gives you signed cookie sessions - $c->session is a hashref, HMAC-SHA256-signed, written back only when it actually changes. csrf layers single use tokens on top: every unsafe request must carry a live token, and using one spends it. cors is handled inside the dispatcher rather than as middleware, which matters more than it sounds: preflights are answered before routing, so no OPTIONS route is needed, and the headers reach the 404s and 405s that never build a context. Access-Control-Allow-Methods is answered from the router itself, so it cannot promise a method the application does not actually serve.

Configuration and secrets. config loads layered YAML - punk.yml, then punk.$PUNK_ENV.yml, then a gitignored punk.local.yml. Blocks that mirror a DSL keyword register for real, so a views: or database: block in the file is exactly equivalent to the keyword, and deployment configuration needs no code change. Secrets are deliberately not in the file: a value written { $env: NAME }, { $file: PATH } or { $exec: [...] } is resolved at boot from outside it, $app->config shows [redacted] in its place, and $app->secret('database.password') reaches the real value. A plaintext value under a secret shaped key warns, and secrets => 'strict' refuses to start.

Everything else. static, mount, views, database and model, hook, middleware, on_error, plugin, helper.


OpenAPI as a first class mount

my $api = api 'openapi.json';
docs '/docs';

api mounts an OpenAPI 3.1 document. Each operation dispatches to the controller method named after its operationId, and request validation, security as guards and per prefix guards are all resolved at boot alongside everything else. Under a scope it inherits that scope's prefix and guards, so a spec can live behind the same authentication as the rest of the site without restating it.

This is the part where the compile at boot idea earns the most. A spec is a large pile of declarative facts - paths, parameters, schemas, security requirements - and every one of them is knowable before the first request. So Punk turns the document into the same frozen structures as everything else, and a spec driven route costs what a hand written one costs.


The stack underneath

Punk sits on top of a set of modules I have been building for a while, and the way they connect is the part I am most pleased with.

  • Hyperman is the server: a preforking, event-loop PSGI server with its hot path in C.
  • Open::API does OpenAPI routing and validation.
  • Template::Stencil is the view engine, a {% %} template compiler.
  • File::Raw::JSON does JSON.
  • JSON::Schema::Fast validates against JSON Schema.

Each of those exposes a C ABI: a versioned table of function pointers, resolved at runtime through a _abi_ptr lookup. So when Punk renders a template, the path from the dispatcher to the template VM is C calling C, with no Perl frame in the middle. Same for JSON encoding a response, same for validating a request against a spec.

The runtime resolution matters. There is no link time coupling between these distributions: each one checks the table's version at boot and falls back, or refuses to start with a message naming the version it needed. They can be released independently, and a consumer compiled against an older table keeps working against a newer one because the tables only ever grow at the end. It is the DBI driver pattern, applied to a web stack.

Punk vendors none of those headers. All four arrive through ExtUtils::Depends, so each contract has exactly one copy and it is the one its provider ships.


The command line

Because the compiled application is opaque from outside, punk exists to open it up.

punk new MyApp
punk new MyApp --api ./openapi.json

punk new writes a running application, not a stub: routes, a controller, Stencil views with a wrapper, config/punk.yml, a psgi entry point, a README, and a test that builds the app and requests a page. Point it at an OpenAPI document and it mounts that too, generating one controller of operation stubs per tag, each answering 501 until you implement it. Generation is deterministic

  • operations arrive in hash order, which perl randomises per process, so they are sorted before anything is written. Without that, regenerating the same spec shuffled subs between files and every diff was noise.

The rest of the commands report on a live application:

  • punk routes prints the whole table: routes, spec operations, websocket routes, mounted apps, guard counts. Targets are recovered from the compiled coderef rather than the declaration, so what it prints is where a request actually lands.
  • punk doctor reports versions and, the part no version number answers, which C ABI tables resolved and at what version.
  • punk config check resolves every $env, $file and $exec reference in every layer independently, so one missing secret does not hide the rest. Non-zero exit on failure, which makes it a deployment gate. Secret values never reach the output.
  • punk console is a REPL with $app, $psgi and a throwaway context.
  • punk dev serves under Hyperman and restarts on change. A compiled at boot application cannot hot reload - there is no live structure to patch - so restarting is the implementation.
  • punk api sync adds a stub for every operation the document declares and the controllers do not implement, and touches nothing else. An operation the spec no longer declares is reported, never removed.

Async

A handler can return a future instead of a response, and Punk awaits anything future-shaped (then / on_ready / get).

get '/slow' => sub {
    my ($c) = @_;
    $c->timer(2)->then(sub { $c->json({ waited => 2 }) });
};

On a Hyperman worker that runs on the event loop and the worker serves other requests while it is pending, so the two second wait costs no capacity. Anywhere else it blocks, which is the correct fallback rather than a broken one.


A note on speed

This is not a benchmark post, so: one table, and the honest reading of it. Every app below is the same app shape, hosted on the same server, driven by the same wrk harness, on my Mac with client and Hyperman server sharing the box.

App req/s
bare PSGI coderef, no framework 205,461
Punk, hello 205,967
Punk, JSON 205,797
Punk, dynamic route 205,339
Punk, OpenAPI mount with validation 202,105
Punk, dynamic Template Stencil render 155,752
Mojolicious, hello 22,835
Catalyst, hello 6,691

Where it is

Punk needs Perl 5.10 or later and a C compiler. Currently it is not supported on Windows but it may be in the future. It is young and the shape of it is settled but the edges are not. If you try it and something is wrong or missing I would like to hear from you about it below.

https://punkperl.com/

All writing