File Coverage

blib/lib/Plack/Middleware/Assets/RailsLike.pm
Criterion Covered Total %
statement 13 15 86.6
branch n/a
condition n/a
subroutine 5 5 100.0
pod n/a
total 18 20 90.0


line stmt bran cond sub pod time code
1             package Plack::Middleware::Assets::RailsLike;
2              
3 8     8   62141 use 5.010_001;
  8         29  
  8         352  
4 8     8   49 use strict;
  8         23  
  8         305  
5 8     8   54 use warnings;
  8         17  
  8         345  
6 8     8   2019 use parent 'Plack::Middleware';
  8         703  
  8         70  
7 8     8   45134 use Cache::NullCache;
  0            
  0            
8             use Cache::MemoryCache;
9             use Carp ();
10             use Digest::SHA1 ();
11             use Errno ();
12             use File::Basename;
13             use File::Slurp;
14             use File::Spec::Functions qw(catdir catfile canonpath);
15             use HTTP::Date ();
16             use Plack::Util::Accessor qw(path root search_path expires cache minify);
17             use Plack::Middleware::Assets::RailsLike::Compiler;
18              
19             our $VERSION = "0.13";
20              
21             our $EXPIRES_NEVER = $Cache::Cache::EXPIRES_NEVER;
22             our $EXPIRES_NOW = $Cache::Cache::EXPIRES_NOW;
23              
24             # copy from Cache::BaseCache
25             my %_expiration_units = (
26             map( ( $_, 1 ), qw(s second seconds sec) ),
27             map( ( $_, 60 ), qw(m minute minutes min) ),
28             map( ( $_, 60 * 60 ), qw(h hour hours) ),
29             map( ( $_, 60 * 60 * 24 ), qw(d day days) ),
30             map( ( $_, 60 * 60 * 24 * 7 ), qw(w week weeks) ),
31             map( ( $_, 60 * 60 * 24 * 30 ), qw(M month months) ),
32             map( ( $_, 60 * 60 * 24 * 365 ), qw(y year years) )
33             );
34              
35             sub prepare_app {
36             my $self = shift;
37              
38             # Set default values for options
39             $self->{path} ||= qr{^/assets};
40             $self->{root} ||= '.';
41             $self->{search_path} ||= [ catdir( $self->{root}, 'assets' ) ];
42             $self->{expires} ||= '3 days';
43              
44             if ( $self->{cache} ) {
45             $self->{_max_age} = $self->_max_age;
46             }
47             elsif ( $ENV{PLACK_ENV} and $ENV{PLACK_ENV} eq 'development' ) {
48              
49             # disable cache
50             $self->{cache} = Cache::NullCache->new;
51             $self->{_max_age} = 0;
52             }
53             else {
54             $self->{cache} = Cache::MemoryCache->new(
55             {
56             namespace => __PACKAGE__,
57             default_expires_in => $self->{expires},
58             auto_purge_interval => '1 day',
59             auto_purge_on_set => 1,
60             auto_purge_on_get => 1
61             }
62             );
63             $self->{_max_age} = $self->_max_age;
64             }
65              
66             $self->{minify} //= 1;
67              
68             $self->{_compiler} = Plack::Middleware::Assets::RailsLike::Compiler->new(
69             minify => $self->{minify},
70             search_path => $self->{search_path},
71             );
72             }
73              
74             sub call {
75             my ( $self, $env ) = @_;
76              
77             my $path_info = $env->{PATH_INFO};
78             if ( $path_info =~ $self->path ) {
79             my $real_path = canonpath( catfile( $self->root, $path_info ) );
80             my ( $filename, $dirs, $suffix )
81             = fileparse( $real_path, qr/\.[^.]*/ );
82             my $type = $suffix eq '.js' ? 'js' : 'css';
83              
84             my $content;
85             {
86             local $@;
87             eval {
88             $content
89             = $self->_build_content( $real_path, $filename, $dirs,
90             $suffix, $type );
91             };
92             if ($@) {
93             warn $@;
94             return $self->_500;
95             }
96             }
97             return $self->_404 unless $content;
98              
99             my $etag = Digest::SHA1::sha1_hex($content);
100             if ( $env->{'HTTP_IF_NONE_MATCH'} || '' eq $etag ) {
101             return $self->_304;
102             }
103             else {
104             return $self->_build_response( $content, $type, $etag );
105             }
106             }
107             else {
108             return $self->app->($env);
109             }
110             }
111              
112             sub _build_content {
113             my $self = shift;
114             my ( $real_path, $filename, $dirs, $suffix, $type ) = @_;
115             my ( $base, $version ) = $filename =~ /^(.+)-([^\-]+)$/;
116              
117             my $content = $self->cache->get($real_path);
118             return $content if $content;
119              
120             my ( @list, $pre_compiled );
121             if ($version) {
122             @list = ( $real_path, catfile( $dirs, "$base$suffix" ) );
123             $pre_compiled = 1;
124             }
125             else {
126             @list = ($real_path);
127             $pre_compiled = 0;
128             }
129              
130             for my $file (@list) {
131             my $manifest;
132             read_file( $file, buf_ref => \$manifest, err_mode => sub { } );
133             if ( $! && $! == Errno::ENOENT ) {
134             $pre_compiled = 0;
135             next;
136             }
137             elsif ($!) {
138             die "read_file '$file' failed - $!";
139             }
140              
141             if ($pre_compiled) {
142             $content = $manifest;
143             }
144             else {
145             $content = $self->{_compiler}->compile(
146             manifest => $manifest,
147             type => $type
148             );
149             }
150              
151             # filename with versioning as a key
152             $self->cache->set( $real_path, $content, $self->{_max_age} )
153             if $self->{_max_age} > 0;
154             last;
155             }
156             return $content;
157             }
158              
159             sub _build_response {
160             my $self = shift;
161             my ( $content, $type, $etag ) = @_;
162              
163             # build headers
164             my $content_type = $type eq 'js' ? 'application/javascript' : 'text/css';
165             my $max_age = $self->{_max_age};
166             my $expires = time + $max_age;
167              
168             if ( $max_age > 0 ) {
169             return [
170             200,
171             [
172             'Content-Type' => $content_type,
173             'Content-Length' => length($content),
174             'Cache-Control' => sprintf( 'max-age=%d', $max_age ),
175             'Expires' => HTTP::Date::time2str($expires),
176             'Etag' => $etag,
177             ],
178             [$content]
179             ];
180             }
181             else {
182             return [
183             200,
184             [
185             'Content-Type' => $content_type,
186             'Content-Length' => length($content),
187             'Cache-Control' => 'no-store',
188             ],
189             [$content]
190             ];
191             }
192             }
193              
194             sub _max_age {
195             my $self = shift;
196             my $max_age = 0;
197             if ( $self->expires eq $EXPIRES_NEVER ) {
198              
199             # See http://www.w3.org/Protocols/rfc2616/rfc2616.txt 14.21 Expires
200             $max_age = $_expiration_units{'year'};
201             }
202             elsif ( $self->expires eq $EXPIRES_NOW ) {
203             $max_age = 0;
204             }
205             else {
206             $max_age = $self->_expires_in_seconds;
207             }
208             return $max_age;
209             }
210              
211             sub _expires_in_seconds {
212             my $self = shift;
213             my $expires = $self->expires;
214              
215             my ( $n, $unit ) = $expires =~ /^\s*(\d+)\s*(\w+)\s*$/;
216             if ( $n && $unit && ( my $secs = $_expiration_units{$unit} ) ) {
217             return $n * $secs;
218             }
219             elsif ( $expires =~ /^\s*(\d+)\s*$/ ) {
220             return $expires;
221             }
222             else {
223             Carp::carp "Invalid expiration time '$expires'";
224             return 0;
225             }
226             }
227              
228             sub _304 {
229             my $self = shift;
230             $self->_response( 304, 'Not Modified' );
231             }
232              
233             sub _404 {
234             my $self = shift;
235             $self->_response( 404, 'Not Found' );
236             }
237              
238             sub _500 {
239             my $self = shift;
240             $self->_response( 500, 'Internal Server Error' );
241             }
242              
243             sub _response {
244             my $self = shift;
245             my ( $code, $content ) = @_;
246             return [
247             $code,
248             [ 'Content-Type' => 'text/plain',
249             'Content-Length' => length($content)
250             ],
251             [$content]
252             ];
253             }
254              
255             1;
256             __END__