Skip to content

Commit 7dbbae4

Browse files
committed
rewrite static middleware to correctly set headers
Surrogate-Control headers were being set for 404 responses for static files. This would cause Fastly to cache them indefinitely. During a deploy, there would be a period of time where new servers would link to js and css assets, which old servers would respond with 404s and get cached. Fix the static file serving code to only set the Surrogate-Control headers for 2xx responses to prevent this. This ended up being a significant rewrite of the middleware.
1 parent 61168a1 commit 7dbbae4

File tree

3 files changed

+209
-63
lines changed

3 files changed

+209
-63
lines changed

lib/MetaCPAN/Middleware/Static.pm

Lines changed: 101 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
package MetaCPAN::Middleware::Static;
22
use strict;
33
use warnings;
4-
use Plack::Builder qw( builder enable mount );
5-
use Plack::App::File ();
4+
use Cpanel::JSON::XS ();
65
use Cwd qw( cwd );
6+
use Plack::App::File ();
7+
use Plack::Builder qw( builder enable mount );
78
use Plack::MIME ();
8-
use Cpanel::JSON::XS ();
9+
use Plack::Util ();
910

1011
Plack::MIME->add_type(
1112
'.eot' => 'application/vnd.ms-fontobject',
13+
'.map' => 'application/json',
14+
'.mjs' => 'application/javascript',
1215
'.otf' => 'font/otf',
13-
'.ttf' => 'font/ttf',
14-
'.woff' => 'application/font-woff',
1516
'.woff2' => 'application/font-woff2',
1617
);
1718

@@ -21,26 +22,77 @@ my $hour_ttl = 60 * 60;
2122
my $day_ttl = $hour_ttl * 24;
2223
my $year_ttl = $day_ttl * 365;
2324

25+
sub _response_mw {
26+
my ( $app, $cb ) = @_;
27+
sub { Plack::Util::response_cb( $app->(@_), $cb ) };
28+
}
29+
30+
sub _add_headers {
31+
my ( $app, $add_headers ) = @_;
32+
_response_mw(
33+
$app,
34+
sub {
35+
my $res = shift;
36+
my ( $status, $headers ) = @$res;
37+
if ( $status >= 200 && $status < 300 ) {
38+
push @$headers, @$add_headers;
39+
}
40+
return $res;
41+
}
42+
);
43+
}
44+
45+
sub _add_surrogate_keys {
46+
my ($app) = @_;
47+
_response_mw(
48+
$app,
49+
sub {
50+
my $res = shift;
51+
my $headers = $res->[1];
52+
if ( my $content_type
53+
= Plack::Util::header_get( $headers, 'Content-Type' ) )
54+
{
55+
$content_type =~ s/;.*//;
56+
my $media_type = $content_type =~ s{/.*}{}r;
57+
push @$headers,
58+
'Surrogate-Key' => join( ', ',
59+
map "content_type=$_",
60+
$content_type, $media_type );
61+
}
62+
return $res;
63+
}
64+
);
65+
}
66+
67+
sub _file_app {
68+
my ( $type, $path, $headers ) = @_;
69+
_add_surrogate_keys( _add_headers(
70+
Plack::App::File->new( $type => $path )->to_app, $headers,
71+
) );
72+
}
73+
74+
sub _get_assets {
75+
my ($root) = @_;
76+
open my $fh, '<', "$root/assets/assets.json"
77+
or die "can't find asset map";
78+
my $json = do { local $/; <$fh> };
79+
close $fh;
80+
my $files = Cpanel::JSON::XS->new->decode($json);
81+
return [ map "/assets/$_", @$files ];
82+
}
83+
2484
sub wrap {
2585
my ( $self, $app, %args ) = @_;
2686
my $root_dir = $args{root} || cwd;
87+
my $root = "$root_dir/root";
2788
my $dev_mode
2889
= exists $args{dev_mode}
2990
? $args{dev_mode}
3091
: ( $ENV{PLACK_ENV} && $ENV{PLACK_ENV} eq 'development' );
3192

32-
my $get_assets = sub {
33-
open my $fh, '<', "$root_dir/root/assets/assets.json"
34-
or die "can't find asset map";
35-
my $json = do { local $/; <$fh> };
36-
close $fh;
37-
my $files = Cpanel::JSON::XS->new->decode($json);
38-
return [ map "/assets/$_", @$files ];
39-
};
40-
4193
my $assets;
4294
if ( !$dev_mode ) {
43-
$assets = $get_assets->();
95+
$assets = _get_assets($root);
4496
}
4597

4698
builder {
@@ -49,60 +101,49 @@ sub wrap {
49101
sub {
50102
my ($env) = @_;
51103
if ($dev_mode) {
52-
$assets = $get_assets->();
104+
$assets = _get_assets($root);
53105
}
54106
push @{ $env->{'metacpan.assets'} ||= [] }, @$assets;
55107
$app->($env);
56108
};
57109
};
58110

59-
my $favicon_app
60-
= Plack::App::File->new( file => 'root/static/icons/favicon.ico' )
61-
->to_app;
62-
mount '/favicon.ico' => sub {
63-
my $res = $favicon_app->(@_);
64-
push @{ $res->[1] },
65-
(
66-
'Cache-Control' => "max-age=${day_ttl}",
111+
mount '/favicon.ico' => _file_app(
112+
file => "$root/static/icons/favicon.ico",
113+
[
114+
'Cache-Control' => "public, max-age=${day_ttl}",
67115
'Surrogate-Control' => "max-age=${year_ttl}",
68116
'Surrogate-Key' => 'assets',
69-
);
70-
$res;
71-
};
72-
my $static_app
73-
= Plack::App::File->new( root => 'root/static' )->to_app;
74-
mount '/static' => sub {
75-
my $env = shift;
76-
my $res = $static_app->($env);
77-
if ( $env->{PATH_INFO} =~ m{^/(?:images|icons|fonts|modules)/} ) {
78-
push @{ $res->[1] },
79-
( 'Cache-Control' =>
80-
"public, max-age=${year_ttl}, immutable", );
81-
}
82-
else {
83-
push @{ $res->[1] },
84-
( 'Cache-Control' => "public, max-age=${day_ttl}", );
85-
}
86-
push @{ $res->[1] },
87-
(
88-
'Surrogate-Key' => 'assets',
89-
'Surrogate-Control' => "max-age=${year_ttl}",
90-
);
91-
$res;
92-
};
93-
my $assets_app
94-
= Plack::App::File->new( root => 'root/assets' )->to_app;
95-
mount '/assets' => sub {
96-
my $env = shift;
97-
my $res = $assets_app->($env);
98-
push @{ $res->[1] },
99-
(
100-
'Cache-Control' => "public, max-age=${year_ttl}, immutable",
101-
'Surrogate-Key' => 'assets',
117+
],
118+
);
119+
120+
for my $static_dir ( qw(
121+
assets
122+
static/icons
123+
static/images
124+
) )
125+
{
126+
mount "/$static_dir" => _file_app(
127+
root => "$root/$static_dir",
128+
[
129+
'Cache-Control' =>
130+
"public, max-age=${year_ttl}, immutable",
131+
'Surrogate-Control' => "max-age=${year_ttl}",
132+
'Surrogate-Key' => 'assets',
133+
],
134+
);
135+
}
136+
137+
mount "/static" => _file_app(
138+
root => "$root/static",
139+
[
140+
$dev_mode
141+
? ( 'Cache-Control' => "public, max-age=${day_ttl}", )
142+
: ( 'Cache-Control' => "public", ),
102143
'Surrogate-Control' => "max-age=${year_ttl}",
103-
);
104-
return $res;
105-
};
144+
'Surrogate-Key' => 'assets',
145+
],
146+
);
106147

107148
mount '/' => $app;
108149
};

lib/MetaCPAN/Web.pm

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@ use Catalyst::Runtime 5.90042;
66

77
use Catalyst qw/
88
ConfigLoader
9-
Static::Simple
109
Authentication
11-
+MetaCPAN::Role::Fastly::Catalyst
1210
/, '-Log=warn,error,fatal';
1311
use Log::Log4perl::Catalyst ();
1412

15-
extends 'Catalyst';
13+
with 'MetaCPAN::Role::Fastly';
14+
with 'MetaCPAN::Role::Fastly::Catalyst';
1615

1716
__PACKAGE__->request_class_traits( [ qw(
1817
MetaCPAN::Web::Role::Request

t/static-mounts.t

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
use strict;
2+
use warnings;
3+
use lib 't/lib';
4+
5+
use Test::More;
6+
use MetaCPAN::Web::Test qw( app GET test_psgi );
7+
8+
test_psgi app, sub {
9+
my $cb = shift;
10+
{
11+
ok( my $res = $cb->( GET '/favicon.ico' ), 'GET /favicon.ico' );
12+
is( $res->code, 200, 'code 200' );
13+
unlike $res->header('Cache-Control'), qr/immutable/, "not immutable";
14+
is_deeply [ sort split /, /, $res->header('Surrogate-Key') ], [ qw(
15+
assets
16+
content_type=image
17+
content_type=image/vnd.microsoft.icon
18+
) ],
19+
'correct Surrogate-Key';
20+
}
21+
{
22+
ok( my $res = $cb->( GET '/static/opensearch.xml' ),
23+
'GET /static/opensearch.xml' );
24+
is( $res->code, 200, 'code 200' );
25+
unlike $res->header('Cache-Control'), qr/immutable/, "not immutable";
26+
is_deeply [ sort split /, /, $res->header('Surrogate-Key') ], [ qw(
27+
assets
28+
content_type=application
29+
content_type=application/xml
30+
) ],
31+
'correct Surrogate-Key';
32+
}
33+
{
34+
ok( my $res = $cb->( GET '/static/fastly_do_not_delete.gif' ),
35+
'GET /static/fastly_do_not_delete.gif' );
36+
is( $res->code, 200, 'code 200' );
37+
unlike $res->header('Cache-Control'), qr/immutable/, "not immutable";
38+
is_deeply [ sort split /, /, $res->header('Surrogate-Key') ], [ qw(
39+
assets
40+
content_type=image
41+
content_type=image/gif
42+
) ],
43+
'correct Surrogate-Key';
44+
}
45+
{
46+
ok( my $res = $cb->( GET '/static/icons/grid.svg' ),
47+
'GET /static/icons/grid.svg' );
48+
is( $res->code, 200, 'code 200' );
49+
like $res->header('Cache-Control'), qr/immutable/, "immutable";
50+
is_deeply [ sort split /, /, $res->header('Surrogate-Key') ], [ qw(
51+
assets
52+
content_type=image
53+
content_type=image/svg+xml
54+
) ],
55+
'correct Surrogate-Key';
56+
}
57+
{
58+
ok( my $res = $cb->( GET '/static/images/dots.svg' ),
59+
'GET /static/images/dots.svg' );
60+
is( $res->code, 200, 'code 200' );
61+
like $res->header('Cache-Control'), qr/immutable/, "immutable";
62+
is_deeply [ sort split /, /, $res->header('Surrogate-Key') ], [ qw(
63+
assets
64+
content_type=image
65+
content_type=image/svg+xml
66+
) ],
67+
'correct Surrogate-Key';
68+
}
69+
{
70+
ok( my $res = $cb->( GET '/static/js/main.mjs' ),
71+
'GET /static/js/main.mjs' );
72+
is( $res->code, 200, 'code 200' );
73+
unlike $res->header('Cache-Control'), qr/immutable/, "not immutable";
74+
is_deeply [ sort split /,? /, $res->header('Surrogate-Key') ], [ qw(
75+
assets
76+
content_type=application
77+
content_type=application/javascript
78+
) ],
79+
'correct Surrogate-Key';
80+
}
81+
{
82+
ok( my $res = $cb->( GET '/assets/assets.json' ),
83+
'GET /assets/assets.json' );
84+
is( $res->code, 200, 'code 200' );
85+
like $res->header('Cache-Control'), qr/immutable/, "immutable";
86+
is_deeply [ sort split /, /, $res->header('Surrogate-Key') ], [ qw(
87+
assets
88+
content_type=application
89+
content_type=application/json
90+
) ],
91+
'correct Surrogate-Key';
92+
}
93+
{
94+
ok( my $res = $cb->( GET '/assets/this-file-does-not-exist.js' ),
95+
'GET /assets/this-file-does-not-exist.js' );
96+
is( $res->code, 404, 'code 404' );
97+
unlike $res->header('Cache-Control'), qr/immutable/, "not immutable";
98+
is_deeply [ sort split /, /, $res->header('Surrogate-Key') ], [ qw(
99+
content_type=text
100+
content_type=text/plain
101+
) ],
102+
'correct Surrogate-Key';
103+
}
104+
};
105+
106+
done_testing;

0 commit comments

Comments
 (0)