NAME
Punk::Static - serving files from a directory
SYNOPSIS
static '/static' => 'root/static';
# a freshness lifetime for plain URLs
static '/static' => 'root/static', max_age => 3600;
# content-addressed URLs, opt-in
static '/static' => 'root/static', fingerprint => 1;
$c->asset('/static/app.css'); # /static/app.9f3a1c2b0d4e5f60.css
# in a template
{% "/static/app.css" | asset %}
# or directly
my $app = Punk::Static->app('root/static');
DESCRIPTION
The app behind the static keyword, mounted behind a prefix so PATH_INFO arrives already stripped. Implemented in C: the method check, the traversal guard, the stat, the conditional-request comparison and the header block all happen without a Perl frame.
GET and HEAD only; anything else is a 405 with an Allow header. The content type comes from the file extension, falling back to application/octet-stream. Last-Modified is sent on every response, and a request whose If-Modified-Since matches it exactly gets a 304 - which is all a static file needs, since the date a client returns is the date it was given.
A path containing a .. segment or a NUL byte is a 404, not something to normalise: a request carrying one is not asking for a file this serves. Anything that is not a regular file is a 404 too.
Freshness
A validator makes a stale copy cheap to detect. It does not make it unnecessary to ask: with no freshness lifetime, a browser revalidates every asset on every page load, and a page with a dozen of them spends a dozen round trips confirming that nothing changed.
The lifetime that would remove those round trips cannot safely be given to /static/app.css, because that URL means something different after every deploy. So the URL changes with the bytes:
static '/static' => 'root/static', fingerprint => 1;
$c->asset('/static/app.css') # /static/app.9f3a1c2b0d4e5f60.css
/static/app.9f3a1c2b0d4e5f60.css is served from app.css and answers with
Cache-Control: public, max-age=31536000, immutable
which is true of that URL whatever happens to the file. The digest is checked against the file's current contents before the header is sent, so a URL held over from an older deploy - a page still in a cache, a bookmarked stylesheet - serves the current bytes with the ordinary revalidating headers instead. immutable is never sent for a URL that could come to mean something else.
The digest is the first 8 bytes of SHA-256 over the contents, and the contents are the point: an mtime differs per machine and per deploy, so a fleet keyed on one would serve a different URL per box for identical bytes, and HTML from one box would name an asset URL another box has never heard of.
Only the <name>.<digest>.<ext> form is recognised, in both directions. A path with no extension has nowhere to put a digest, so "asset" hands it back unchanged and it serves as it always did. The literal path is tried first, so a file genuinely checked in under a fingerprinted name still serves as itself.
Precompressed siblings (app.css.gz) are unaffected: the URL is named by the identity file's digest, and the sibling still supplies the bytes, its own ETag and the Content-Encoding.
The same story for a route that renders rather than reads is Punk::Plugin::ConditionalGet: a file gets both halves here - a freshness lifetime so the request is not made, and a validator so it is cheap when it is - while a dynamic response can rarely be given a lifetime and so has only the second.
The response body is a real filehandle, so a server that can stream or sendfile does, rather than the file being read into memory first. A HEAD sends the same headers with an empty body and never opens the file.
In production, static files usually belong in front of the application (nginx, a CDN); this is for development and for the small set of assets an app genuinely owns.
OPTIONS
The static keyword takes them after the directory, and "app($dir, %opts)" takes the same set.
- max_age
-
Seconds of freshness for a plain URL, sent as
Cache-Control: public, max-age=N. There is no default: a mount that says nothing behaves exactly as it did before, revalidating each time. Give this only to assets you are willing to have served stale for that long - the fingerprinted URL is the answer for everything else. - cache_control
-
A verbatim header value, overriding
max_age, for anything the two spellings above do not cover (private, no-storefor a mount behind authentication, say). - fingerprint
-
Content-addressed URLs. Off unless asked for: fingerprinting changes what a path means - a URL shaped like a fingerprint stops being a 404 and starts resolving to another file - and a mount should not begin doing that because it was upgraded. Until it is on, "asset" hands back the URL it was given, so a template written against it works either way.
- dev
-
Whether a cached digest is re-checked against the file. Under the
statickeyword this follows$app->env: indevelopmentan edited file is re-read and gets a new URL on the next reload, and outside it a digest is computed once and then believed, since files do not change under a running process. Set it explicitly to override.
METHODS
app($dir, %opts)
The PSGI coderef for a directory. Croaks unless the directory exists, so a mistyped path fails at boot with the rest of the configuration, and croaks on an option it does not recognise. The options may also be given as a single hashref.
asset($url)
A context method, not a class method: $c->asset('/static/app.css') returns the content-addressed URL for a file under a static mount. A URL under no static mount, under one with fingerprinting off, or naming a file that cannot be read comes back exactly as it went in - the page still works, it just revalidates.
Templates rendered through the shipped Stencil engine get the same thing as a filter, registered unless the application has one of its own by that name:
<link rel="stylesheet" href="{% "/static/app.css" | asset %}">
SEE ALSO
Punk.
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)