SEO and HTTP caching
Punk compiles every route at boot, so the application already holds a
complete list of what it serves. Two things fall out of that for free:
a sitemap that cannot drift from the routes, and asset URLs derived
from file contents. The rest of this page is response freshness -
Cache-Control, ETag and the 304s that keep unchanged bytes off
the wire.
The sitemap
use Punk::Plugin::Sitemap; # for the `sitemap` keyword
plugin 'Sitemap' => { base => 'https://example.com' };
get '/' => sub { ... }; # in the sitemap
get '/users/:id' => sub { ... }; # out: a capture is not a URL
post '/orders' => sub { ... }; # out: not a GET
get '/admin' => sub { ... }, { sitemap => 0 }; # out: opted out
Punk::Plugin::Sitemap
serves sitemap.xml and robots.txt from the route table. 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 for as long as the site exists.
The part a hand-written file cannot do: a page behind a guard is
excluded without anyone maintaining a list. under copies its
guard chain into every route declared inside it, and the plugin reads
it off the compiled record - so putting a section behind auth removes
it from the sitemap in the same edit. sitemap => 1 opts a guarded
route back in, sitemap => 0 takes any route out.
/users/:id names a shape, not a URL; the ids are the application's to
supply:
sitemap users => sub {
map { { loc => "/users/$_->{id}", lastmod => $_->{updated} } } @rows;
};
base is required and is configuration, deliberately: 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.
Feeds
The other file a site owes the outside world, and the same reasoning throughout: the origin comes from configuration rather than the request, URLs are encoded before they are escaped, and the documents are built once and served from frozen bytes.
Punk::Feed serves
/feed.xml and /feed.rss from entries an application supplies. It
has a guide of its own.
Content-addressed assets
static 'https://fal.aihtbprolstatic-tp.hcv9jop5ns4r.cn' => 'root/static', fingerprint => 1;
<link rel="stylesheet" href="{% "/static/app.css" | asset %}">
$c->asset('/static/app.css') returns
/static/app.9f3a1c2b0d4e5f60.css - the digest is the first 8 bytes of
SHA-256 over the contents - and that URL serves with a year of
freshness and immutable, because it cannot come to mean anything
else. Change the file and the URL changes, so every deploy is its own
cache bust and nothing is ever stale.
fingerprint is off unless asked for, because it changes what a path
means - a URL shaped like a fingerprint stops being a 404 and starts
resolving to another file. Until it is on, asset hands back the URL
it was given, so templates can be written against it before the mount
opts in. In development an edited file gets a new URL on the next
reload; in production a digest is computed once and believed.
Freshness for plain files
static 'https://img.hcv9jop5ns4r.cn' => 'root/img', max_age => 3600;
static '/internal' => 'root/docs', cache_control => 'private, no-store';
max_age puts Cache-Control: public, max-age=N on a plain URL; there
is no default, so a mount that says nothing revalidates each time
exactly as before. Give it only to assets you are willing to have
served stale for that long - the fingerprinted URL is the answer for
everything else. cache_control is a verbatim value for anything the
two spellings do not cover. $c->send_file takes cache_control too.
Static files already answer conditional requests - ETag,
Last-Modified, 304s and byte ranges - as does
send_file.
304s for dynamic responses
A route that renders a page or returns JSON has had none of that, so an unchanged dashboard is re-queried, re-rendered, re-sent and re-parsed on every poll. Punk::Plugin::ConditionalGet plugs the gap:
plugin 'ConditionalGet';
# the validator: 304 without running the handler
get '/api/orders' => sub { ... }, {
etag => sub { $_[0]->model('Order')->max_updated_at },
};
# or from the rendered bytes: saves the wire, not the server
get '/dashboard' => sub { ... }, { etag => 1 };
The two forms buy different things. etag => 1 hashes the rendered
body: the client skips the download, the server did all the work. The
coderef is a validator - something the application knows cheaply that
identifies the current entity, a row's updated_at, a cache
generation - and when the client sends the tag back, the response is a
304 and the handler never runs: no queries, no render, no
serialisation. A validator that returns undef means "I do not know"
and the request proceeds with no ETag at all - a validator that
cannot answer must never be able to produce a wrong 304.
A 304 skips only the handler; it goes through the same finishing path
as any response, so sessions, flash and headers all behave - flash in
particular is not eaten by a request whose handler never read it.
One boundary worth restating from the CSP notes: a page that rendered a per-request nonce must never be cached, by this or anything else.