Revision history for Punk
0.49 2026-09-11
- ADDS: i18n catalogues are held in a Frozen block instead of an
arena built here. The block holds a tree, so a level and a leaf
are different nodes.
- FIXES: a literal key containing a dot and the nested form it
looks like were the same key, and which one answered was decided
by the hash order of the process that loaded it. Two workers in
one pool could serve different translations for the same key. A
dotted key is now a boot error.
- FIXES: a catalogue value that is not text no longer renders as
the key. null, an array and a boolean are boot errors naming the
path; numbers are stored as their digits and read back unchanged.
- FIXES: UTF-8 is preserved through interpolation. A substituted
value was appended as raw bytes into a string that was then
flagged UTF-8, so a non-ASCII entry plus a downgraded
substitution produced malformed output. Reachable from a form
field.
- FIXES: a catalogue from any producer keeps its encoding. Only the
JSON path was correct before, and only because that decoder
happens to decode to UTF-8.
- FIXES: the documentation described a language-switcher cookie
that was never implemented. Punk reads punk.lang and has never
written it; the POD now says so and shows the handler that does.
- ADDS: the locale hash can be iterated. keys, each and scalar used
to die. The order is the same in every process.
- BREAKING: _build takes catalogues that have been through
Frozen's builder; a catalogue key may not contain a dot; a
catalogue value must be text.
- Requires Frozen 0.06.
0.48 2026-09-09
- ADDS: websocket routes work over HTTP/2 through Extended
CONNECT (RFC 8441). There is no 101, no Upgrade header and no
Sec-WebSocket-Key on a multiplexed transport, so the handshake
is new - but the RFC 6455 codec above it is the same one the
HTTP/1.1 path uses. An Extended CONNECT is routed as the GET
it stands in for, and the origin check still applies to it.
- ADDS: sse routes and $c->stream work over HTTP/2 and over TLS.
Both ran on Hyperman's detach, which hands over a file
descriptor and so refuses an h2 stream and a TLS session; they
now fall through to Hyperman's ABI v6 stream handle, which
sends status, headers and body pieces through the server.
- A stream reset by the peer, or a client that goes away, now
reaches the handler: an sse stream's close callback fires and a
$c->stream drain settles. HTTP/1 could only infer this from a
dead connection, and a multiplexed transport cannot.
- No Connection and no Transfer-Encoding on an HTTP/2 or HTTP/3
streamed response. Both are hop-by-hop and forbidden there.
- A refused detach falls through to the next transport instead of
answering 503.
- A die mid-stream truncates on the stream handle too, through
Hyperman's v7 stream_abort: RST_STREAM on HTTP/2, a connection
reset on TLS. A streamed body has no declared length, so ending
cleanly is the claim that it is whole.
- Requires Hyperman 0.45.
0.47 2026-09-09
- FIXES: a request body with no CONTENT_LENGTH and no
Transfer-Encoding was read as no body at all. HTTP/2 and HTTP/3
forbid Transfer-Encoding, so that is every streamed h2 upload.
The read now keys off psgix.input.buffered, and HTTP/1 is
unchanged.
- FIXES: max_body imposed no ceiling on a request with no declared
length. It now bounds the read, for ->body, body_each, body_to
and the multipart streamer.
- FIXES: $c->stream chunk-framed every response that was not
literally HTTP/1.0, putting a Transfer-Encoding on HTTP/2 and
HTTP/3 responses, which forbid it. Only HTTP/1.1 is chunked now.
- Punk::Test's environment sets SERVER_PROTOCOL to HTTP/1.1,
overridable per request through `env`.
0.46 2026-09-08
- ADDS: $c->req->xml - the request body parsed as XML through
File::Raw::XML's C ABI, as a File::Raw::XML::Document. The parse
is strict and takes no options: a DOCTYPE is refused wherever it
stands, so external entities, XXE and the billion laughs are not
reachable from a request. Parsed once per request.
- ADDS: $c->xml($doc, $status?) - a Document, a Node or a string of
markup as an application/xml response.
- FIXES: a File::Raw::XML::Document returned from a handler, or set
as a Punk::Response body, was JSON-encoded - it is a blessed
reference like any other. It is now written as markup, which is
also what makes respond_to(xml => sub { $doc }) answer
application/xml.
- Punk::Test takes `xml => $doc_or_string` as a request body and
answers ->xml with the parsed response.
- NOTE: $c->validate's `auto` source recognises only
application/json, so a schema on an XML POST validates the query
string rather than the body. Name the source to be sure of it.
- File::Raw::XML 0.03 is a new prerequisite.
- FIXES: the file cache's single-flight lock judged its holder's age
from whole-second st_mtime, so an age could read a second too high
and a `lock_wait` under a second was spent before it began
- FIXES: a single-flight lock that could not be attempted at all - a
path that would not form - answered "you won", so every caller
computed and the herd ran unprotected with nothing said. It is
counted now and reported as `lock_errors` from the file store's
stats.
- t/0733-auth-sqitch.t asserted Sqitch's own deploy output, which is
translated - under LC_ALL=de_DE a successful deploy reports
"+ users ........ OK" and the test failed.
0.45 2026-09-06
- A model class may now be subclassed: `package MyApp::Model::Thing;
our @ISA = ('Punk::Model::Thing');` inherits the parent's table and
fields, and `use Punk::Model` in the subclass lets it add a field,
redeclare one to change its spec, or name its own table.
0.44 2026-09-05
- ADDS: $app->keyword($name => @args) and $app->has_keyword($name) -
call a keyword another plugin installed, from code, in the
caller's context
0.43 2026-09-03
- SECURITY: a websocket upgrade is refused with 403 unless its
Origin is one the application answers as
- ADDS: the `origin` websocket route option - a hostname, an
arrayref of them in `host allow` syntax, a coderef, or 0 to
serve any origin.
- ADDS: $c->req->body_each($cb) and $c->req->body_to($dest) - the
request body a window at a time, and straight to a file, for the
bodies that are not multipart. A body is read once: after either
of these, body/json/form croak rather than answer with nothing,
and a body already read whole is replayed from the copy.
- ADDS: the after_response phase - `hook after_response` and
$c->after_response($code), for work a request generates that the
client is not waiting for.
- ADDS: the `rate_limit` route option - a budget for one route,
compiled into a guard at to_app and enforced by the same C the
keyword installs. Takes limit/window/by/tag, or a bare count.
The counter is namespaced to the route unless a tag shares one
between several, and the guard runs before `validate`, so a
refused request never parses a body.
0.42 2026-09-02
- A blessed error raised from a future, a view or a websocket
handler arrived as a plain string on perls before 5.14. The
croak_sv shim had no branch for a reference.
- t/1002-awaitable.t and t/1050-asyncawait.t assert cancellation
across an await only on perl 5.24+, which is where
Future::AsyncAwait chains the awaited future rather than
registering a code block that reaches nothing.
- Both report the Future::AsyncAwait version they ran against.
0.41 2026-09-01
- FIX: creating a future read an environment variable every time, so
a future-returning request cost 40% more on a host with a few
hundred of them than on one with a few dozen. The switch is read
once now.
- A handler that returns a Punk::Future is chained in C: one
continuation rather than three, and none of the ->can and ->isa
probes the general path needs. A future of any other class takes
that path unchanged.
0.40 2026-09-01
- Punk::AsyncAwait: async and await in apps, controllers and models.
- Punk::Future implements the Future::AsyncAwait::Awaitable API, and
the future an async sub returns carries the loop it suspended on.
- Punk::Future->on_cancel, taking a code reference or a future.
- Punk::Future->result, the Future name for get.
- FIX: get in scalar context returned the last value, not the first.
A handler returning a multi-value future got its tail.
- FIX: a future method given an object of another class that is also
a blessed IV read the wrong struct instead of croaking.
- use Punk::Controller sets @ISA, strict and warnings; use parent
'Punk::Controller' is unchanged.
0.39 2026-08-31
- Fix t/1113-idempotency-flight.t compares the recorded pid as a value,
which perl 5.16 spells as a string
0.38 2026-08-31
- t/1040-stream.t drops its client on a stream with no end, so no
socket buffer can hold the whole body first
0.37 2026-08-30
- $c->stream sends the real chunk length wherever perl expands
my_snprintf to a statement expression
0.36 2026-08-30
- ADDS: $c->stream($content_type, $cb) + Punk::Stream - a response
body of unknown length on the SSE transports (Hyperman detach,
psgi.streaming, blocking psgix.io), chunk-framed on the socket
transports so a die is visible truncation, never a short success
- Punk::Stream drain - a Punk::Future settled when the write buffer
reaches the kernel; awaiting it after each write bounds memory to
one chunk while the worker keeps serving other requests
- Punk::Test declares psgi.streaming in every request env, so
get_ok drives a streamed route instead of collecting its 501
- ADDS: the `last_modified` route option - Punk::Plugin::
ConditionalGet's date validator, a coderef returning an epoch,
answered 304 before the handler runs; If-None-Match wins, the
date comparison is exact (the file path's convention), a future
epoch is clamped to now
- ADDS: Punk::Test `upload` - multipart/form-data requests: file
parts from a path or a scalar ref, streamed through one read
buffer however large, boundary verified absent from the content
- t/0410-upload.t posts through the client, so the encoder and the
streaming parser test each other
- ADDS: `max_record` on Punk::Plugin::Idempotency - a response
larger than it (default 1MB) is served with
Idempotency-Recorded: false and not stored; the single-flight
lock still covers the request
- ADDS: `signed => 1` on $c->cookie, both directions - HMAC over
name=value with the session's secret and verify, so a tampered,
swapped or unsigned cookie reads as undef
0.35 2026-08-29
- An SSE heartbeat that finds the client gone no longer re-arms
itself on a stream it has just torn down.
- Freeing an SSE stream drops its loop watchers before its socket.
- t/0733-auth-sqitch.t skips unless `sqitch` is runnable, not
merely installed.
- t/1020-sse.t polls for the close rather than sleeping on it, and
reports what answered and which backend when it does not.
0.34 2026-08-26
- ADDS: pk_abi v4 - on_log_ctx, a log tap that is handed the context
the line was logged against, so a telemetry layer can correlate a
record with its request. on_log is unchanged; a consumer registering
both receives every record twice.
- The logger resolves its context before running its observers.
0.33 2026-08-25
- Static mounts hold small files in memory per worker, so a hit does
no file syscall
0.32 2026-08-23
- ADDS: `punk serve [DIR]` - a directory of files over HTTP under
Hyperman, on 127.0.0.1:8000 by default
- ADDS: `index` and `list` options on Punk::Static, so the `static`
keyword serves a tree of index.html files
- Return _abi_ptr as a UV, not an IV.
- `punk doctor` loads every Punk::Command::* on @INC before it
prints, so a plugin distribution's register_doctor row appears
without that plugin's own command having been named first.
- REMOVES: the plaintext-secret guardrail in Punk::Config, and the
`secrets` option that set its mode. A value written into the
configuration file in plaintext is the decision of whoever writes
the file
- ADDS: $app->auth_config - the frozen auth configuration read back
0.31 2026-08-23
- ADDS: kits. `punk new MyApp --kit diy` generates through
Punk::Kit::Diy instead of the basic skeleton
- A kit subclasses Punk::Generate, keeps its templates in skel/
beside its own module, and declares the options it takes, which
are read out of argv before Getopt runs so they parse and show
up under `punk new --kit NAME --help` like the command's own
- ADDS: hook before_render. Runs once per $c->render, after
Punk's own binds (url, csp_nonce, locale), with the data
hashref the engine is about to receive - so a plugin can put a
value into every template without the handler passing it.
- ADDS: Punk::Command->load_app, the public form of what every
application-loading command does, for a command in another
distributio
- ADDS: $t->request_header in Punk::Test - a header on every
request from here on
- ADDS: named routes. A route takes { name => 'book' }, and
$c->url_for('book', id => 42) gives back its path
- In a template, {% url.books %} for a static route and
{% row | url_for('book') %} for one with captures
- ADDS: `layout` as a render option. $c->render($tpl, \%data,
layout => undef) renders a template without the configured
wrapper
- ADDS: $c->fragment($tpl, \%data, %opts) - render with no layout
and Cache-Control: private
- ADDS: $app->databases - the configured databases read back, a
deep copy keyed by name with `default` for the unnamed one
- ADDS: $app->on_compile($code, $owner?) - a callback for to_app,
run in registration order after every keyword has recorded and
before anything is compiled
- ADDS: filter operators on Punk::Model search and count, on both
backends: = != < <= > >= in not_in like starts_with, as
{ column => { op => value } } beside the plain equality.
- ADDS: order_by on search - a column, or [ column => 'asc'|'desc',
... ] pairs
- ADDS: count(\%filter) on the model and both backends.
- ADDS: $c->txn(sub { my ($tx) = @_; ... }) and $c->txn($database
=> sub { ... }), with Punk::Txn
0.30 2026-08-22
- `csrf: true`, `cors: true` and `headers: true` in punk.yml now
register.
- The config guardrail no longer flags a key that only names
where a secret lives: `token_model`, `password_field`,
`secret_path` - anything ending in _model, _class, _table,
_field, _column, _header, _cookie, _path, _dir, _name or
_kind - holds a model, a column or a location, not a secret.
0.29 2026-08-22
- Punk::Plugin::Sitemap: a missing `base` now croaks at to_app
rather than at `plugin`
- ADDS: `host ..., allow => [...]` - the hosts that may stand in
for the canonical one, and $c->origin / $c->host_allowed: the
request's origin only when its Host is declared, the canonical
origin otherwise, never the raw header.
- ADDS: `favicon` - GET /favicon.ico from bytes frozen at
to_app, with Cache-Control, a strong ETag and 304s. A missing
file croaks at boot. Replaces the send_file boilerplate.
- `host` and `favicon` are now DSL keywords: an application
defining its own sub with either name sees a redefinition
warning at boot.
- Test fix: t/1011-ws-live.t and t/1030-room-bus.t read one
frame per sysread and threw away the rest of the buffer
0.28 2026-08-21
- ADDS: Punk::Plugin::I18n - translations and language
negotiation.
- REQUIRES Template::Stencil 0.10, for tied hashes in a path.
The `locale` hash is tied so a template lookup reads the
shared catalogue directly
- Punk::Cache single-flight fix: a loser that acquired the lock
after the winner finished went straight to computing without
looking again, so the herd computed twice.
- Improve test coverage
0.27 2026-08-21
- Fix SEGV/BUS on every request carrying a session cookie
- t/0914-session-pool.t now names the signal when a forked
worker dies, instead of reporting four uninitialized values
and hiding the crash that caused them.
0.26 2026-08-21
- `session store => ...` keeps the session server-side and the
cookie carries a signed 128-bit id.
- `$c->session_expire` now REVOKES a stored session: it deletes
the entry, so a cookie somebody copied is dead on its next
request. Without a store it is unchanged, and still can only
ask the browser in front of it to forget.
- `$c->session_rotate` keeps the session and gives it a new id,
deleting the old entry.
- `session sliding => 1` extends a stored session while it is
being used
- `session tier => $seconds` lets a stored session be read
through the cache's memory tier, which it otherwise goes
round. The number is how long a revoked session may keep
working on a worker that missed the invalidation, so it is
capped and checked against the store's own memory_ttl.
0.25 2026-08-21
- Punk::Cache::stats no longer panics on a tiered store. The
XSUB asked the value stack for four fewer slots than it
pushes, which a DEBUGGING perl catches and turns into
"failed to extend arg stack",
- Punk::Plugin::Blob accepts a CODEREF `namespace` again. The
required-option check read the length of a scalar that has
no string body, which aborts a DEBUGGING perl outright.
- Punk::Plugin::Blob resolves Apophis on every perl. The ABI
pointer was popped with a macro that mentions its argument
twice, so it came off the wrong stack slot and the plugin
refused to load
- Punk::Plugin::Metrics reports its numbers on a long double or
quadmath perl.
0.24 2026-08-20
- Large uploads no longer cost what they weigh. A
multipart part over 64KB is written to a temp file as it
arrives rather than copied into memory, and Punk::Upload
carries a `path` and an `fh` to it. Measured end to end
through a socket into a handler holding the upload, a 128MB
upload costs a worker 15.5MB of RSS against roughly 275MB
before.
- ADDS: the `upload_dir` keyword, naming where a large part is
spilled. It decides the filesystem, which decides whether
save() is a rename, and it decides what shares a filesystem
with attacker controlled bytes. Temp file names owe nothing
to the client's filename, and every file is removed when its
request ends.
- ADDS: Punk::Plugin::Blob stores an on-disk upload without reading
it into memory: hashed where it lies through Apophis's
identify_fh, then moved into the store.
- ADDS: Punk::Plugin::Idempotency - Idempotency-Key on unsafe
methods, replaying the stored response.
- ADDS: Punk::Plugin::Metrics - a Prometheus /metrics endpoint
- ADDS: Punk::Plugin::Health - /healthz and /readyz,
- ADDS: Punk::Plugin::CSP - Content-Security-Policy with a
per request nonce.
- ADDS: Punk::Plugin::ConditionalGet - ETags and 304s for
dynamic responses.
- ADDS: Punk::Plugin::Blob, content addressed uploads on
Apophis. $c->blob_put stores by contents, so a user's filename
never becomes a filesystem path. $c->blob_send serves through
send_file as a download.
- ADDS: Apophis to the prerequisites.
0.23 2026-08-20
- ADDS: Punk::Plugin::Sitemap sitemap.xml/robots.txt
- ADDS: static mounts can say how fresh a file is. `max_age`
and `cache_control` set Cache-Control on a plain URL, and
$c->send_file takes cache_control too.
- ADDS: content-addressed asset URLs. $c->asset('/static/app.css')
returns /static/app.9f3a1c2b0d4e5f60.css. The digest - the first 8
bytes of SHA-256 over the contents.
- ADDS: Punk::Plugin::RequestId per request identifier
0.22 2026-08-20
- ADDS: Punk::Plugin::Sitemap, phase 1 - which of an
application's routes are actually URLs. Punk compiles every
route at boot, so the application already holds a complete
list of what it serves, and a sitemap is that list with a
filter over it. A route is listed only when it is a GET, its
path holds no capture, it carries no guard, and it did not opt
out. Out is the safe direction: a page missing from a sitemap
is still crawled if anything links to it, while a page wrongly
present is a crawler fetching a 404 or a login redirect on a
schedule.
The part a hand-written sitemap cannot do: a page behind a
guard is excluded WITHOUT anyone maintaining a list, because
`under($prefix, $guard)` copies its guard chain into every
route declared inside it and the plugin reads it off the
record.
`base` is required and is configuration. The protocol wants
absolute URLs, and taking the host from the request would let
`Host: evil.example` produce a sitemap naming that host for
every page on the site - delivered to search engines, and
invisible to the owner, whose own request produces a correct
file.
New route option: `sitemap => 0` keeps a route out, and
`sitemap => 1` puts one in despite a guard, because a scope
guard may be an authentication check or an ordinary filter and
Punk cannot read which. The method and the shape are not
overridable - sitemap => 1 on a POST or on /users/:id is a
mistake rather than an instruction.
Rendering, dynamic sections and robots.txt are phases 2 to 4;
the plan is in plan_punk_sitemap/.
- FIX: change abi version checks from == to <=
- Adds: Punk::Cache, Punk::Cache::Memory and Punk::Cache::File
0.21 2026-08-20
- FIX: A Punk::WebSocket::Room now reaches the whole pool.
- FIX: SSE also now works correctly accross workers.
- ADDS: $c->publish and $c->subscribe hyperman event bus hooks
0.20 2026-08-19
- The dynamic-route path no longer requires Open::API. It
checked that api_mounts EXISTED rather than that it held
anything, and api_mounts is always present in the compiled
state - so an app that never declared an `api` still reached
the API branch on any route that was not a static hit, and
croaked there when Open::API's C ABI did not match.
- Punk publishes a C ABI of its own, pk_abi.h, installed through
ExtUtils::Depends and reached at runtime through
Punk::_abi_ptr.
0.19 2026-08-18
- SECURITY: a session had no server-side lifetime. `expires` set
only the cookie's Max-Age, which is a request to a client that
is free to ignore it - the signature carried no time at all, so
a cookie captured once (a shared machine, a log, a backup, a
proxy) stayed valid for as long as the secret did, and
session_expire only asked the browser to forget a value that
still authenticated. The expiry is now stamped inside the signed payload
- New $c->safe_path($path, $fallback?): returns the path when it
is same-origin and relative, otherwise the fallback. Punk had
no such guard while auth_guard hands every application a ?to=
to redirect to, which made the obvious login form an open
redirect. Same rules as Punk::OAuth2's same_origin_path, for
the same reasons (CVE-2026-75628)
- The static traversal guard splits segments on a backslash as
well as a slash. On POSIX "..\.." is one ordinary filename and
traverses nothing, but Windows reads it as a separator, so a
guard that split on "/" alone saw one harmless segment where
the OS would see two levels up.
- New hook phase `before_request`, which runs BEFORE routing:
hook before_request => sub {
my ($c) = @_;
$c->stash->{t0} = Time::HiRes::time();
return;
};
The earliest phase until now was before_dispatch, which runs
only after the router has matched - so nothing could observe a
request from its start, and nothing ran at all for a 404, a
405, or anything answered by a PSGI or static mount.
before_request runs for all of those.
- The logger takes a record: a lone unblessed hashref is a set of
fields, and its `message` key is the message.
$c->log->info({ message => 'listing books',
books => 12, user => $id });
0.18 2026-08-18
A security release. issue reported by CPANSec.
- SECURITY (CVE-2026-75870): `session` declared without a secret
signed the cookie with an EMPTY HMAC key. The keyword froze
whatever options it was given without requiring a secret,
warning, or refusing to start, and both the cookie read and the
write-back defaulted that key to "". a secret is now required
0.17 2026-08-17
- Fixed t/14-futures.t failing on every perl whose Future is
older than the one it was developed against - reported by
CPAN Testers against 0.16 on 5.20.0, 5.22.2 and 5.24.0 with
"Expected __ANON__(Future.pm line 1140) to return a Future".
- A route may now be declared as one hashref, with the handler
under `cb`:
post "/upload" => { cb => "Web::File#create",
max_body => 50_000_000 };
instead of the positional form, which is unchanged and stays
the one `punk new` generates:
post "/upload" => "Web::File#create",
{ max_body => 50_000_000 };
- New route option { compress => 0 }, which opts a route out of
the server response compression Hyperman 0.25 adds. Punk does
not compress - that belongs to the write path - so the option
is spelled as a plain response header, Content-Encoding:
identity, which the server honours and strips.
- New `max_body` keyword and per-route { max_body => N }: refuse
a request whose CONTENT_LENGTH is over a ceiling, with the
same 413 an oversize API operation gets.
- Punk::Upload documents what an upload actually costs: it
arrives whole in memory before a handler sees it, ->save is a
write and not a stream, and `max_body` does not reduce the
memory.
- SECURITY: the `markdown` mount reflected the request path into
the Location header of its canonical 301 (the redirect that
strips a trailing slash or a .md suffix). PATH_INFO reaches a
PSGI app percent-DECODED, so that echoed bytes the client
chose: a decoded CR/LF split the response at any mount point,
and at a root mount "//evil.example/" came back as a
protocol-relative 301 off the site. Found while fixing the
same class of bug in Punk::OAuth2 (CVE-2026-75628).
The redirect now only names a path that IS a page - the
stripped target is looked up in the page table first - so the
only thing it can echo is one of the mount's own keys.
Anything else falls through to the 404 it was always going to
get. pmd_redirect also stops at the first control byte, in
case a mount prefix ever carries one.
0.16 2026-08-17
- New `proxy` keyword: reverse-proxy trust. Behind nginx, an ELB
or a CDN, REMOTE_ADDR is the proxys on every request - and
because rate_limit's counters live in Hyperman's shared arena
they are exact across the whole worker pool, so every client
landed in ONE bucket and a limit => 100 rule throttled the
entire site at 100/min. block_ip, keyed the same way, it would ban
the load balancer. `proxy` resolves the real client at the
top of the dispatcher and OVERWRITES REMOTE_ADDR, so
rate_limit, block_ip, the access log and $c->req->address are
all correct with no change to any of them; the connecting
address stays as $c->env->{'punk.peer_addr'}.
- New $c->req->address: REMOTE_ADDR, which is the socket peer
directly exposed and the resolved client behind `proxy`.
0.15 2026-08-17
- A trailing slash on the request path no longer 404s:
GET /account/ answers the route declared as GET /account.
The retry runs only after every declared route, API operation
and mount has missed, so nothing that already matched changes
- a *splat still captures its trailing slash, and a mounted
app is still handed the path it was sent, since only it knows
whether /docs and /docs/ differ. Repeated slashes collapse,
and "/" is left alone. A trimmed path that matches a
different method still answers 405 with its Allow list.
0.14 2026-08-16
- Requires from the C layer go through pk_require_once (an %INC
check, then at most one eval per module per process) instead
of a bare eval_pv per call. Perl's own eval_sv spends immortal
references on every call on perls through 5.20 - whatever is
evaluated, success or failure - and before 5.20 the immortals'
refcounts are small and unprotected, so a path that
re-requires per build (the markdown mount ran two) drains
PL_sv_undef toward a segfault under punk dev's rebuild loop.
Found by t/42-immortal-refcount.t on a CPAN smoker running
perl 5.20.0; verified fixed on 5.20.3.
- t/37-ua.t pins the per-request agent clones it compares by
address: a freed clone's memory is the allocator's to hand
straight back, so two DEAD clones can legally compare equal,
which a smoker's malloc duly demonstrated. Held alive,
distinct objects must differ.
0.13 2026-08-16
- $c->send_file($path_or_scalarref, %opts): a finished download
response
- Static files inherit the send_file core
- The punk command line rebuilt around a registry: commands are
specs (abstract, options with docs and defaults, nested verbs,
code), and help is generated from them
- Plugin subcommands: Punk::Command->register($name => \%spec)
with owner-collision croaks; an unknown command gets one
require of Punk::Command::<Ucfirst>, whose load registers it
- In-process testing seam: $Punk::Command::OUT/$ERR replace the
streams and main() returns the exit code.
- routes: --method, --path GLOB, --kind, and --json.
- dev: --workers, --watch (repeatable), --env; one watch loop.
- console: a history file, --eval 'CODE', and q/exit.
- New commands: `generate controller|model` into an existing
application
0.12 2026-08-15
- Adds Punk::Auth. The `auth` keyword freezes the config at
to_app (model, a fields map so existing schemas need no renames,
session_key, roles hook, rank ladder); croaks without a session, on any
unknown option, and on a roles target typo - at boot.
- Context surface: $c->login / logout / auth_id /
current_user (memoized per request, model loaded through an
await seam so the blocking DBI backend and the
future-returning DBIx::Loop backend both work).
- auth_guard for `under`: the bare is-anyone-signed-in form
runs entirely in C. Denial negotiates - a browser is
redirected to login_path with a relative-only ?to= return-to,
an API client gets the house 401 - with on_denied '403',
'404' or a coderef. The roles hook may return one role, a
list or an arrayref; a required role on the rank ladder means
"this or better", one outside it matches exactly (a staff
role next to tenant roles). Guards record what they learned
in $c->stash->{auth}.
- Bare `model;` registers everything under MyApp::Model::*
0.11 2026-08-15
- Security response headers (Punk::Headers): a `headers` keyword
freezing a header policy at to_app, applied in C on the way
out of the dispatcher.
- Content negotiation ($c->respond_to): calls the handler for
the most acceptable offered format (json/html/text/xml or a
full media type) and returns its response.
0.10 2026-08-15
- Development error pages (Punk::DevError): in development a die
renders an HTML debug page
- The environment now defaults to production; development is an
opt-in: punk dev sets PUNK_ENV=development for its server, or
set it yourself in the Env.
- Punk::Validate: request validation, all in C on the
JSON::Schema::Fast C ABI. $c->validate($schema) collects into
a Result - errors in the Open::API shape plus name, valid()
hands back typed filtered params - and a bare $c->validate
reads the Result a route-level check stashed. Routes take an
options hashref: get '/x' => $target, { validate => \%schema }
(or { schema, source, on_invalid }), compiled once at to_app
and run as a C guard after any auth guards; failures answer
the OpenAPI-mount-shaped 400 or the on_invalid target.
- New on_not_found keyword: the on_error contract for a 404 so
you can return a custom response
- The getentropy probe now links instead of only compiling.
FreeBSD before 12 has no getentropy and its old compilers take
the implicit declaration as a warning, so 0.09's compile-only
probe false-positived and the .so failed to load with
"Undefined symbol getentropy".
0.09 2026-08-15
- Adds Punk::Test, Punk::Test::WS and Punk::Test::WS::Conn
- Flash messages, in C (punk_flash.h): $c->flash and
$c->flash_keep over the session's reserved punk.flash key.
0.08 2026-08-14
- t/42-immortal-refcount.t failed the distribution on perl 5.18,
where perl itself moves the refcount it measures. Before 5.20
av_extend filled the slots it allocated with &PL_sv_undef and
av_undef released every slot up to the fill.
0.07 2026-08-13
- The router stored &PL_sv_yes bare in three set-membership hashes
- New PUNK_SET_TRUE (include/punk/punk_compat.h) is the sanctioned
way to put a true value in a set slot - it takes the reference
first, so the container has one to spend, with no allocation.
0.06 2026-08-13
- Rate limiting and IP blocking over Hyperman's shared arena
(requires Hyperman 0.18, ABI v3; fails open without it).
- New `rate_limit` keyword: installs a before_dispatch that answers
429 (with Retry-After and the X-RateLimit-* headers) when a
caller is over the limit. Key by client IP (default), a request
header (by => 'header:X-Api-Key'), or a coderef; scope to a path
prefix with `for`. Chainable for layered limits. The counters
live in the shared arena, so a limit is exact across the worker
pool rather than per worker.
- New context methods: $c->block_ip([$ip [, $ttl]]) and
$c->unblock_ip([$ip]) add or lift an IP on the edge denylist
($ip defaults to the request's REMOTE_ADDR), and $c->rate_hit
for a raw counter check.
0.05 2026-08-12
- Fixed a segfault on perl before 5.20. Six places handed one of
perl's immortal SVs to something that would release it.
- t/42-immortal-refcount.t asserts the invariant rather than the
symptom: an immortal's refcount may not move across these
calls. That fails on any perl, including the ones where the bug
is invisible.
- Update min version of deps
0.04 2026-08-08
- Fixed the configure-time dependency declaration, which is why
0.03 could not be installed from CPAN at all.
- $app->install_kw($name => $code, $owner?): a plugin can install
a declaration keyword of its own into the application class
instead of assigning to a glob there.
- params now takes names: `$c->params(@names)` and `$req->params(@names)`
return just those, as a list of values in the order asked for, or -
in scalar context - a hashref of only the names that were passed.
0.03 2026-08-07
- Punk::Model::DBIx::Loop, a non-blocking model backend. The same
six-method contract, the same SQL and the same result shapes as
Punk::Model::DBI, but every method returns a Punk::Future and the
statement runs on DBIx::Loop over the worker's own event loop.
- It runs on DBIx::Loop's C ABI (dbil_abi.h, via ExtUtils::Depends)
- Fixed Punk::Model's delegation returning the caller's own
arguments alongside the backend's result.
- Punk::Model::DBI is ~40% faster per statement: quote_identifier
and the fixed-shape get/delete SQL are memoised on the pooled
connection instead of rebuilt every call. A get is 2.05us against
3.61us. The table is part of the statement cache key - the cache
is shared by every model on a dsn.
- Add the `markdown` mount/keyword: point it at a nested directory of .md
files and get a documentation site. Navigation reflecting the
tree, a per-page table of contents from the headings, syntax
highlighting, ranked search, and the images sitting alongside
the markdown served as static files.
- The site is built at boot. The tree is walked, every page is
rendered through Markdown::Simple and wrapped by
Template::Stencil, the search index is filled, and the finished
bytes are frozen. A request is then a hash lookup and a triplet,
with no markdown parse, no template render and no Perl frame.
`reload => 1` re-renders a page whose source has changed, for
the edit loop under `punk dev`.
- Refactor the stat / conditional-request / sendfile half of
punk_static.h out as ps_serve_file, so the markdown mount serves
the assets in its tree through the same code rather than a
second implementation that would drift from it.
0.02 2026-08-06
- $c->ua: an outbound HTTP agent on the context
- The agent is one per worker, not one per request: it owns a
keep-alive pool and its DNS state.
- More than one agent: `ua partner => { ... }` names a second, the
same shape `database` uses for a second database, and
$c->ua('partner') asks for it.
- Needs Fetch 0.11 for the per-request jar (Fetch->clone).
0.01 2026-08-06
First version