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-store for 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 static keyword this follows $app->env: in development an 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)