Punk 0.62 · a web framework for Perl

Perl MVC, compiled at boot.

it’s alive

Routes, guards, handlers and mounts are resolved once at to_app and frozen. Nothing is interpreted per request: dispatch is a hash lookup and your handler is a plain coderef call, about two microseconds after the request arrived.

package MyApp;
use Punk;

get  '/'          => sub { $_[0]->text('oi!') };
get  '/books/:id' => 'Web::Book#view';
post '/books'     => 'Web::Book#create';

websocket '/chat'   => 'WS::Chat#join';
sse       '/events' => 'Live#feed';

1;

The numbers

requests / second, basic hello world app
200,000+
per request, routed and answered
1.9µs
Punk’s own share of that is around
0.5µs
of the bare PSGI app’s throughput
99%

The whole application - routing table, guard chains, controller resolution, template engines, database handles, OpenAPI validation - is built once, when to_app is called. What runs per request is C: an XS dispatcher walking frozen structures, calling your Perl exactly once, for the handler.

  • 2.01µs on a dynamic route with a capture - dynamic dispatch at static speed
  • +0.3µs for a fifty-route table over a one-route table - table size is nearly free
  • 2.06µs per Punk::Model::DBI get - the statement is memoised on the connection
  • 3.2µs through a mounted OpenAPI operation, request validation included

Measured with bench/dispatch.pl in the Punk distribution: the PSGI coderef called in a loop with a fresh environment, no socket and no server, each app in its own interpreter, best of nine rounds. A bare PSGI sub returning the same string costs 1.4µs on the same box, which is what the 0.5µs above is measured against. The requests/second figure is bench/bench.pl: the same apps hosted on two Hyperman workers and driven with wrk -t4 -c64, median of three runs, rounded - server and client share the box and it swings several percent between runs, which is why the bare app measures the same 200,000 and why the ratio is the honest half of that pair. All of it on one Apple M5, and yours will differ. The ratios are the part that travels.

Everything is a keyword

use Punk exports a DSL that reads like a routing table and compiles like a program. Each keyword below is resolved at boot, so a typo'd controller croaks before the app serves a single request.

  • Routing & guards

    get post put patch del any, with :name and *rest captures. under opens a nestable guard scope - return a response to short-circuit, nothing to continue.

  • WebSockets

    websocket '/chat' => 'WS::Chat#join' - routed like a GET, guarded like any route, then handed a live connection on the server’s event loop. Rooms broadcast, and subprotocols negotiate.

  • Server-Sent Events

    sse '/events' => 'Live#feed' - the lighter sibling: an EventSource stream with heartbeats and retry control, fully non-blocking on a Hyperman worker.

  • Sessions, CSRF, CORS, headers

    HMAC-SHA256 signed cookie sessions - or server-side on any cache store with store =>, where logout genuinely revokes and session_rotate shuts fixation. Single-use CSRF tokens that spend on use. CORS and the headers policy answered inside the dispatcher, so the headers reach the 404s too.

  • Authentication

    auth wires a user model to the session: auth_guard(role => 'admin') on any scope, $c->current_user memoized, PBKDF2 passwords and single-use email tokens. The signed-in check runs entirely in C, and a TOTP second factor is one plugin away.

  • OAuth2, all three sides

    oauth2 logs your users in with Google, GitHub or any OIDC provider, checkers guard your API's Bearer tokens, and oauth2_server is a full authorization server, signed by Crypt::JWS.

  • Downloads & negotiation

    $c->send_file - ETags, 304s, byte ranges and HEAD handled, never more than 64KB of file in memory. $c->respond_to answers the Accept header with the right format, Vary included.

  • Uploads that stream

    A large multipart part spills to a temp file as it arrives - a 128MB upload costs a worker 15.5MB, not 275. blob_put stores it by contents so a filename never becomes a path, and the ClamAV plugin scans it by descriptor before you keep it.

  • OpenAPI 3.1

    api 'openapi.json' mounts the document: operations dispatch by operationId, requests validate against the schema, security schemes become guards. docs serves the reference UI.

  • Config & secrets

    Layered YAML (punk.yml → env → local). Secrets are references - { $env: KEY }, { $file: path }, { $exec: cmd } - resolved at boot and redacted everywhere else.

  • Models, sync or async

    One six-method contract - get search all create update delete. Punk::Model::DBI blocks, and Punk::Model::DBIx::Loop returns futures and runs on the worker’s loop. Same SQL, same shapes.

  • Outbound HTTP

    $c->ua is a Fetch agent - HTTP/2, futures, a keep-alive pool shared per worker. Named agents with their own timeouts and jars: ua partner => { timeout => 2 }.

  • Mounts

    static for files, markdown for a docs site rendered and frozen at boot (this one), mount for any PSGI app under a prefix. Longest prefix wins.

  • GraphQL

    graphql '/gql' => 'schema.graphql' - compiled once at boot onto GraphQL::Houtou’s XS VM, resolvers as Controller#method targets, GraphiQL on tap.

  • Caching & idempotency

    cache with compute, TTL and named stores. A per-worker memory tier kept coherent over Hyperman’s bus. And Idempotency-Key replay on unsafe methods, so a retried POST /orders is one order.

  • Rate limiting

    rate_limit counts in Hyperman’s shared arena - exact across the pool, not per worker - and answers 429 with the headers. $c->block_ip drops an abuser at accept, before a byte is read. Fails open.

  • Observability

    Request ids on every response and log line. A Prometheus /metrics whose labels are the compiled route table, so cardinality cannot run away. /healthz and /readyz that mean different things. OpenTelemetry through C observer tables - and a backend to send it to.

  • SEO from the route table

    The sitemap is the compiled routes with a filter over it - guarded pages excluded with nobody maintaining a list. $c->asset fingerprints URLs by contents, and ConditionalGet answers 304 without running the handler.

  • I18n

    JSON catalogues parsed once at boot, negotiated per request with real subtag fallback, CLDR plural rules, and escaping that trusts the catalogue but never the substitutions. Templates read {% locale.key %} directly.

  • Extensible by keyword

    plugin, hook, middleware, helper - and a plugin can install keywords of its own with install_kw, indistinguishable from the core DSL. Nine plugins ship in the box, four more as their own dists.

  • An all-C core

    Forty-seven XS units behind the DSL: router, dispatcher, request, context, sessions, SSE frames, WebSocket codec. Speaks Hyperman and DBIx::Loop natively through their C ABIs.

Async without the ceremony

Return a future from any handler and Punk awaits it. On a Hyperman worker the request parks on the event loop and the worker keeps serving. On any other PSGI server the same code blocks politely instead. No colored functions, no framework fork.

$c->promise, $c->timer($secs) and $c->await($f) are built in, in C, as Punk::Future.

# Punk::Future: on a Hyperman worker the loop keeps serving
# other requests while this one waits.
get '/report/:year' => sub {
    my ($c) = @_;
    $c->model('report')->search({ year => $c->param('year') })
      ->on_done(sub { $c->json($_[0]) });
};

# The async model backend: same contract as Punk::Model::DBI,
# same SQL, but every method returns a future.

The spec is the router

Point api at an OpenAPI 3.1 document and the mount is generated at boot: every operation dispatches to the controller method named by its operationId, request bodies and parameters are validated before your code runs, and the document’s security schemes become ordinary Punk guards.

Start from the other end with punk new MyApp --api ./openapi.json and the controllers are stubbed for you, one per tag.

# Mount an OpenAPI 3.1 document: each operation dispatches to
# the controller method named by its operationId, with request
# validation and security-as-guards resolved at boot.
my $api = api 'openapi.json';

docs '/api-docs';    # and the generated reference UI
The generated OpenAPI reference UI for a mounted spec
The generated reference UI - docs '/api-docs'

The command line

punk new writes a running application - routes, a controller, views, config, tests. Then punk dev serves it with restart-on-change, punk routes prints the compiled table, punk doctor reports the environment and C ABIs, and punk config check resolves every secret before production does.

Terminal output of punk routes: the compiled routing table
punk routes
Terminal output of punk doctor: versions, C ABIs, application health
punk doctor
The welcome page a fresh punk-new application serves
What punk new boots into

Sixty seconds

cpanm Punk
punk new MyApp
cd MyApp && punk dev

Three commands to a running, tested application. Then read Getting started, or see what a finished one looks like - a live chat over WebSockets, with its own docs site and OpenAPI reference, ships in Punk’s example/ directory.

The Chat example application: a live WebSocket chat room built with Punk
Built with Punk - the Chat example