The Advisory Boar

By Abhijit Menon-Sen <ams@toroid.org>
2011-07-11

Serving static files with Mojolicious

I use Hypnotoad behind nginx to run my Mojolicious applications. nginx is better at handling TLS (than the flaky IO::Socket::SSL module), and it can be configured to cache static resources. My first attempt to set up caching was to set Expires in an after_static_dispatch hook, but it took me a while to figure out the right combination of hooks to do what I wanted.

This post describes how to use after_dispatch and after_static_dispatch hooks to make sure static resources (i.e. public/*) are served with an expiry date based on the content type (and without cookies), and also to prevent some dynamic responses from being cached.

At first, I thought that after_static_dispatch would be called when the static dispatcher served a response, and after_dispatch would be called otherwise. But in fact, both hooks are always executed, and you can't rely on one being called before the other. Instead, we must check if a response code is already set when after_static_dispatch is called. If so, we know the response came from the static dispatcher, and we can add an Expires header based on the content type. If that header is not present when after_dispatch is called, we know it's a dynamic response, and can add a Cache-control header.

The static dispatcher does not set the content type when serving a "304 Not modified" response (to clients that used If-Modified-Since), so we can't rely on the type being available in after_static_dispatch.

Here's an example. The following code should be in your lib/App.pm (in sub startup). Note the type-specific expiry and caching directives in the second hook.

$app->hook(after_dispatch => sub {
    my $tx = shift;

    # Was the response dynamic?
    return if $tx->res->headers->header('Expires');

    # If so, try to prevent caching
    $tx->res->headers->header(
        Expires => Mojo::Date->new(time-365*86400)
    );
    $tx->res->headers->header(
        "Cache-Control" => "max-age=1, no-cache"
    );
});

$app->hook(after_static_dispatch => sub {
    my $tx = shift;
    my $code = $tx->res->code;
    my $type = $tx->res->headers->content_type;

    # Was the response static?
    return unless $code && ($code == 304 || $type);

    # If so, remove cookies and/or caching instructions
    $tx->res->headers->remove('Cache-Control');
    $tx->res->headers->remove('Set-Cookie');

    # Decide on an expiry date
    my $e = Mojo::Date->new(time+600);
    if ($type) {
        if ($type =~ /javascript/) {
            $e = Mojo::Date->new(time+300);
        }
        elsif ($type =~ /^text\/css/ || $type =~ /^image\//) {
            $e = Mojo::Date->new(time+3600);
            $tx->res->headers->header("Cache-Control" => "public");
        }
        # …other conditions…
    }
    $tx->res->headers->header(Expires => $e);
});

The example above marks all dynamic responses as uncacheable. This may be overkill. Special cases can be handled either by teaching after_dispatch to identify and exempt certain responses ($tx->res), or by setting Expires by hand (because the hook skips responses that already have an expiry time set).

I have "Export to XLS" links at various places, and I use the excellent Spreadsheet::WriteExcel module to render a spreadsheet into (a filehandle opened on) a scalar, and call «render_data($data, 'xls')» to send the data. Since there are many such instances and they all use the same content type, I can identify them easily in after_dispatch.

$app->hook(after_dispatch => sub {
    my $tx = shift;

    # Was the response dynamic?
    return if $tx->res->headers->header('Expires');

    # Allow spreadsheets to be cached
    my $type = $tx->res->headers->content_type;
    if ($type eq 'application/vnd.ms-excel') {
        $tx->res->headers->header(
            Expires => Mojo::Date->new(time+86400)
        );
        return;
    }

    …

Aside: Call «$app->types->type(xls => 'application/vnd.ms-excel')» in startup to register 'xls' as a content type.

In some places, I call render_static() to render an existing file, whose MIME type can't be predicted in advance. In this case, it's easier to set Expires by hand.

$self->render_static("/path/to/file");
$self->res->headers->content_type("some/type");
$self->res->headers->content_disposition(
    "attachment; filename=\"file.name\""
);
$self->res->headers->header(
    Expires => Mojo::Date->new(time+86400)
);

For more information about caching, read Mark Nottingham's Caching tutorial for web authors and webmasters.

Tags: mojo, perl • Link: etc/mojolicious-static-resources