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;

# a tree of index.html files - a docs site, a generated manual
static '/docs' => 'root/docs', index => 'index.html';
static '/files' => 'root/files', list => 1;

# 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, unless it is a directory and "index" or "list" has given the mount something to do with one.

Directories

A directory is not a file, so on its own it is a 404. index names a file inside it to serve in its place, which is what makes a tree of index.html files - a documentation site, anything generated - servable at all:

static '/docs' => 'root/docs', index => 'index.html';

Both spellings of the URL answer: /docs/guide/ and /docs/guide name the same directory, and both serve guide/index.html where they were asked for, with no redirect between them. A 301 to the trailing-slash form would spend a round trip to say something the server already knows, and a page whose relative links work from one spelling works from the other.

list renders what a directory holds when it has no index file. The listing is no-store: it is stale the moment anything in the directory changes, and unlike one file's bytes there is no cheap validator for "what this directory holds". Filenames are escaped for both the link text and the href, so a file called <script>.txt is a link and not a script.

index is tried before list: a directory holding an index file is that file's URL and nothing else. With neither option the directory is the 404 it always was.

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.

index

The file a directory resolves to - index => 'index.html', or index => 1 for that name without spelling it. Off unless asked for, so a mount serving assets does not start answering for its own directories.

It is a filename, not a path: a value with / or \ in it is refused at boot rather than guarded for on every request, because a path is how a directory index turns into a way out of the directory.

list

Render a listing for a directory that has no index file. Off unless asked for - what a directory holds is not usually something a site means to publish.

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)