OSDN Git Service

JavaScriptLoader: add caching and gzip compression
[newslash/newslash.git] / src / newslash_web / lib / Newslash / Plugin / JavaScriptLoader.pm
1 package Newslash::Plugin::JavaScriptLoader;
2 use Mojo::Base 'Mojolicious::Plugin';
3
4 use File::Spec;
5 use File::Basename qw(fileparse);
6 use Digest::MD5 qw(md5_hex);
7 use IPC::Open3;
8 use Symbol qw(gensym);
9 use IO::Select;
10 use POSIX ":sys_wait_h";
11 use Mojo::Util qw(dumper);
12 use Encode;
13 use Mojo::JSON qw(decode_json encode_json);
14 use List::Util qw(any);
15 use Mojo::File qw(path);
16 use IO::Compress::Gzip qw(gzip);
17
18 use constant KVS_PREFIX => "JSL:";
19 use constant DEFAULT_MEM_EXPIRE => 60;
20 use constant DEFAULT_KVS_EXPIRE => 60;
21
22 sub _kvs {
23     return shift->{app}->kvs;
24 }
25
26 sub _cache {
27     return shift->{app}->cache;
28 }
29
30 sub set_cache {
31     my ($self, $key, $value) = @_;
32     my $mem_expire = $self->{conf}->{mem_expire};
33     my $kvs_expire = $self->{conf}->{kvs_expire};
34     $mem_expire = DEFAULT_MEM_EXPIRE if !defined $mem_expire;
35     $kvs_expire = DEFAULT_KVS_EXPIRE if !defined $kvs_expire;
36
37     $self->_kvs->set(KVS_PREFIX . $key, $value, $kvs_expire);
38     $self->_cache->set(KVS_PREFIX . $key, $value, $mem_expire);
39 }
40
41 sub get_cache {
42     my ($self, $key) = @_;
43     my $expire = $self->{conf}->{mem_expire};
44     $expire = DEFAULT_MEM_EXPIRE if !defined $expire;
45
46     my $value = $self->_cache->get(KVS_PREFIX . $key);
47     return $value if defined $value;
48
49     $value = $self->_kvs->get(KVS_PREFIX . $key);
50     if (defined $value) {
51         $self->_cache->set(KVS_PREFIX . $key, $value, $expire);
52         return $value;
53     }
54
55     # no cache found
56     return;
57 }
58
59 sub get_content {
60     my ($self, $basename, $opts) = @_;
61     my $content;
62     $opts ||= {};
63
64     if (!$opts->{reload}) {
65         $content = $self->get_cache($basename);
66         return $content if defined $content;
67     }
68
69     # no content hits in cache, then read from file(s)
70     if ($self->{bundle_config}->{$basename}) {
71         # target is bundle, so pack them
72         $content = $self->_pack_files($basename);
73     }
74     else {
75         # target is single file, load it
76         $content = $self->_load_js($basename);
77     }
78
79     if (defined $content) {
80         $self->set_cache($basename, $content);
81         $self->set_cache($content->{path}, $content);
82         return $content
83     }
84     return;
85 }
86
87 sub _load_js {
88     my ($self, $basename) = @_;
89
90     # check if file exists
91     my $abs_path = $self->_basename_to_absolute_path($basename);
92     return if (!-f $abs_path || !-r $abs_path);
93
94     # load file
95     open( my $fh, "<:utf8", $abs_path ) or return;
96     my $js_body = do { local($/); <$fh> } ;
97     close($fh);
98
99     my $md5;
100     if (utf8::is_utf8($js_body)) {
101         $md5 = md5_hex(Encode::encode('utf8', $js_body));
102     }
103     else {
104         $md5 = md5_hex($js_body);
105     }
106     my $path;
107     if ($self->{mode} eq "development") {
108         my $basedir = $self->{conf}->{source_directory};
109         $basedir =~ s/^public//g;
110         $path = "$basedir/$basename";
111     }
112     else {
113         my ($base, $dir, $ext) = fileparse($basename, qw/\.[^.]*/);
114         $dir = "" if $dir eq "./";
115         my $basedir = $self->cached_basedir;
116         $path = "$basedir/$dir${base}.$md5$ext";
117     }
118
119     my $result = { md5 => $md5,
120                    content => $js_body,
121                    path => $path,
122                    type => "text/javascript",
123                  };
124     return $result;
125 }
126
127 sub _pack_files {
128     my ($self, $filename) = @_;
129     $self->{app}->log->debug("JavaScriptLoader: packing to $filename...");
130     my $target_dir = path($self->{app}->home, $self->{conf}->{source_directory})->to_abs;
131
132     my $config = $self->{bundle_config}->{$filename};
133     if (!$config) {
134         $self->{app}->log->error("JavaScriptLoader: no settings to pack $filename...");
135         return;
136     }
137
138     my @result;
139     my %done;
140     for my $target (@{$config->{prior}}) {
141         my $path = path($target_dir, $target);
142         next if $done{$path->basename};
143         $self->{app}->log->debug("JavaScriptLoader: pack $target ($path)");
144         push @result, decode('utf-8', $path->slurp);
145
146         $done{$target} = 1;
147     }
148
149     for my $target (path($target_dir)->list->each) {
150         my $basename = $target->basename;
151         next if $target->to_abs !~ $config->{target};
152         next if ( any { $target->to_abs =~ m/$_/ } $config->{exclude} );
153         next if $done{$basename};
154
155         $self->{app}->log->debug("JavaScriptLoader: pack $basename ($target)");
156         push @result, decode('utf-8', $target->slurp);
157         $done{$basename} = 1;
158     }
159
160     my $joined = join("\n", @result);
161     my $md5;
162     if (utf8::is_utf8($joined)) {
163         $md5 = md5_hex(Encode::encode('utf8', $joined));
164     }
165     else {
166         $md5 = md5_hex($joined);
167     }
168     $self->{app}->log->debug("JavaScriptLoader: packing to $filename done. md5: $md5");
169
170     my @sources = keys %done;
171     $self->{bundled} ||= {};
172     for my $src (@sources) {
173         $self->{bundled}->{$src} = 1;
174     }
175
176     my $basedir = $self->cached_basedir;
177     my $result = { md5 => $md5,
178                    content => $joined,
179                    path => "$basedir/$filename",
180                    type => "text/javascript",
181                    sources => \@sources,
182                  };
183     return $result;
184 }
185
186 sub cached_basedir {
187     return "/js_cache";
188 }
189
190 sub _basename_to_absolute_path {
191     my ($self, $basename) = @_;
192     my $base_dir = $self->{conf}->{base_directory};
193     if (!$base_dir) {
194         $base_dir = $self->{app}->home;
195     }
196     if (!$base_dir) { # fallback
197         $base_dir = "./";
198     }
199     my $src_dir = $self->{conf}->{source_directory};
200     my $path = File::Spec->catdir($base_dir, $src_dir, $basename);
201     $path = File::Spec->rel2abs($path);
202     return $path;
203 }
204
205 sub _split_md5 {
206     my ($self, $path) = @_;
207     if ($path =~ m/^(.+)\.([0-9a-f]+)(\.\w+)$/) {
208         return ($1 . $3, $2);
209     }
210     return ($path, "");
211 }
212
213 sub _insert_md5 {
214     my ($self, $path, $md5) = @_;
215
216     my ($base, $dir, $ext) = fileparse($path, qw/\.[^.]*/);
217     $dir = "" if $dir eq "./";
218     return "${dir}${base}.${md5}${ext}";
219 }
220
221 sub load_bundle_config {
222     my ($self) = @_;
223
224     # load config file
225     if ($self->{conf}->{config_file}) {
226         my $abs_path = $self->{conf}->{config_file};
227         if ($abs_path !~ m|^/|) {
228             $abs_path = path($self->{app}->home, $abs_path)->to_abs;
229         }
230
231         # read bundle config
232         if (-r $abs_path) {
233             my $json_body = decode('utf-8', path($abs_path)->slurp);
234             $self->{bundle_config} = decode_json($json_body);
235             $self->load_all_bundles;
236         }
237         else {
238             $self->{app}->log->warn("JavaScriptLoader: cannot read config file: $self->{conf}->{config_file} ($abs_path)");
239         }
240     }
241 }
242
243 sub load_all_bundles {
244     my $self = shift;
245     for my $target (keys %{$self->{bundle_config}}) {
246         my $content = $self->get_content($target, {reload => 1});
247     }
248 }
249
250 sub _is_bundled {
251     my ($self, $target) = @_;
252     my $pool = $self->{bundled} // {};
253     return $pool->{$target};
254 }
255
256 sub register {
257     my ($self, $app, $conf) = @_;
258
259     # set default config value
260     my $cnf = $app->config->{JavaScriptLoader} ||= {};
261     $cnf->{source_directory} //= "public/js";
262     $cnf->{mode} //= "development";
263     $cnf->{config_file} //= "public/js/bundle-config.json";
264     $cnf->{use_bundle} //= 1;
265     $cnf->{use_compression} ||= 0;
266
267     $self->{conf} = {%$conf, %$cnf};
268     $self->{app} = $app;
269     $self->{mode} = $self->{conf}->{mode} || $app->mode;
270     $self->{compress} = $cnf->{use_compression};
271
272     $self->{bundled} = {};
273
274     if (!$self->{conf}->{source_directory}) {
275         $app->log->warn("JavaScriptLoader: no source_directory given!");
276     }
277     $self->load_bundle_config;
278
279     $app->helper('load_js', sub {
280                      my ($c, $name) = @_;
281                      # check if target is bundled
282                      return '' if $self->_is_bundled($name);
283
284                      my $content = $self->get_content($name);
285                      if ($content) {
286                          my $md5_path = $self->_insert_md5($content->{path},
287                                                            $content->{md5});
288                          return "<script src='$md5_path'></script>";
289                      }
290                      $self->{app}->log->warn("JavaScriptLoader: cannot load $name");
291                      return '';
292                  });
293
294     my $r = $app->routes;
295     $r->any($self->cached_basedir . '/*content_path' => sub {
296                 my $c = shift;
297                 my $path = $c->stash('content_path');
298                 if ($path) {
299                     my $content = $self->get_cache($path);
300
301                     if (!$content) {
302                         # no cache, so try to generate
303                         my ($basename, $md5) = $self->_split_md5($path);
304                         $content = $self->get_content($basename);
305
306                         # check md5
307                         if ($content && $content->{md5} ne $md5) {
308                             $content = "";
309                         }
310                     }
311                     if ($content) {
312                         if ($self->{compress}
313                             && $content->{type} =~ m{^text/}
314                             && ($c->req->headers->accept_encoding // '') =~ /gzip/i
315                            ) {
316                             # use gzip compression
317                             # Add header
318                             $c->res->headers->append(Vary => 'Accept-Encoding');
319                             $c->res->headers->content_encoding('gzip');
320
321                             # Compress content with gzip
322                             my $gzip_in = encode_utf8($content->{content});
323                             gzip(\$gzip_in, \my $compressed);
324                             $c->res->headers->content_type($content->{type});
325                             $c->res->body($compressed);
326
327                         }
328                         else {
329                             $c->res->headers->content_type($content->{type});
330                             $c->res->body(encode_utf8($content->{content}));
331                         }
332                         $c->rendered(200);
333                         return;
334                     }
335                 }
336                 $c->rendered(404);
337             });
338
339     $app->helper(javascript_loader => sub { state $javascript_loader = $self; });
340 }
341
342
343
344 1;