DEV Community

Cover image for Punk: a MVC framework
LNATION
LNATION

Posted on

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;
Enter fullscreen mode Exit fullscreen mode
# app.psgi
use MyApp;
MyApp->to_app;
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }) });
};
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)