Bundle.pm 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. package Trog::Bundle;
  2. use 5.040;
  3. use re '/aasx';
  4. use warnings FATAL => 'all';
  5. use Digest::SHA ();
  6. use File::Slurper ();
  7. use JavaScript::Minifier::XS ();
  8. use List::Util ();
  9. use Trog::Themes ();
  10. =head1 Trog::Bundle
  11. The scripts that a page names, served as one response.
  12. A route names its scripts in a list, and finish_render() in Trog::Routes::HTML
  13. links the bundle of them at the href that scripts() returns. The route
  14. C</bundle/{version}/{names}.js> serves that bundle.
  15. =cut
  16. # The names of each bundle that a page links, as its href carries them, to the
  17. # bundle.
  18. my %bundles;
  19. # The file of each name, or undef for a name that is not a script.
  20. my sub files_of (@names) {
  21. return map {
  22. my $file = m/\A\w[\w.-]*[.]js\z/ ? Trog::Themes::themed_script($_) =~ s{\A/}{www/}r : undef;
  23. $file && -f $file ? $file : undef; ## no critic (ProhibitFiletest_f) -- a name that is not a file is not a script
  24. } @names;
  25. }
  26. # The names as an href carries them, and what changes when a file does.
  27. my sub identify ( $names, $files ) {
  28. return (
  29. join( '+', map { s/[.]js\z//r } @$names ),
  30. join( "\0", map { join( ':', $_, ( stat $_ )[ 7, 9 ] ) } @$files ),
  31. );
  32. }
  33. my sub build ( $key, $signature, @files ) {
  34. # A newline before the semicolon, because a file can end in a line comment.
  35. my $body = join(
  36. "\n;\n",
  37. map {
  38. my $js = File::Slurper::read_binary($_);
  39. m/[.]min[.]js\z/ ? $js : JavaScript::Minifier::XS::minify($js)
  40. } @files
  41. );
  42. my $version = substr( Digest::SHA::sha256_hex($body), 0, 16 );
  43. return {
  44. href => "/bundle/$version/$key.js",
  45. version => $version,
  46. body => $body,
  47. signature => $signature,
  48. };
  49. }
  50. =head2 scripts(@names) = HASHREF or undef
  51. The bundle that a page links, of the scripts that @names names. Each name is a
  52. file name under F<www/scripts>, such as C<post.js>. The theme's copy of a
  53. script is used in place of the stock one. The order is the order of @names, and
  54. a name that is given twice is used once.
  55. Returns undef for an empty list, and dies if a name is not a script.
  56. The hash has these keys:
  57. href where the bundle is served, which changes when its body does
  58. version a digest of the body, which is in the href
  59. body the scripts, each one minified unless its name ends in .min.js
  60. A process builds each bundle once, and builds it again when a file in it
  61. changes.
  62. =cut
  63. sub scripts (@names) {
  64. @names = List::Util::uniq(@names);
  65. return undef unless @names;
  66. my @files = files_of(@names);
  67. my @missing = map { $names[$_] } grep { !defined $files[$_] } 0 .. $#names;
  68. die "There is no script to bundle named @missing\n" if @missing;
  69. my ( $key, $signature ) = identify( \@names, \@files );
  70. return $bundles{$key} if $bundles{$key} && $bundles{$key}{signature} eq $signature;
  71. return $bundles{$key} = build( $key, $signature, @files );
  72. }
  73. =head2 served(@names) = HASHREF or undef
  74. The bundle that the route serves for @names, as scripts() describes it, or undef
  75. if a name is not a script.
  76. It is the bundle that scripts() built, if that one is current. Otherwise it is
  77. built for this request and not kept, because anyone can put any list in a URL.
  78. =cut
  79. sub served (@names) {
  80. @names = List::Util::uniq(@names);
  81. my @files = files_of(@names);
  82. return undef if !@names || List::Util::any { !defined } @files;
  83. my ( $key, $signature ) = identify( \@names, \@files );
  84. my $linked = $bundles{$key};
  85. return $linked if $linked && $linked->{signature} eq $signature;
  86. return build( $key, $signature, @files );
  87. }
  88. =head2 save($tpsgi, $bundle)
  89. Have tPSGI save $bundle as a static render after the response, so that the
  90. webserver serves it from then on. A process saves a given bundle once.
  91. finish_render() calls this for the bundle that a page links, so only the lists
  92. that routes name are saved. A list that only a URL names is built and served,
  93. but never saved, because anyone can ask for as many of those as they like.
  94. It also has tPSGI watch the directories of the scripts. When a script changes,
  95. the saved pages and bundles are thrown away: the pages are rendered again and
  96. link the new bundle, and a page that still links an old one is redirected by
  97. the route. The route also serves a bundle whose static is gone, so a worker
  98. that still counts it as saved is slower, not wrong.
  99. =cut
  100. # The hrefs of the bundles that this process saved.
  101. our %saved;
  102. sub save ( $tpsgi, $bundle ) {
  103. return 0 if !$tpsgi || $saved{ $bundle->{href} };
  104. if ( $tpsgi->can('add_watch') ) {
  105. my $theme = Trog::Themes::get_dir();
  106. foreach my $dir ( grep { -d } ( 'www/scripts', $theme ? "$theme/scripts" : () ) ) {
  107. $tpsgi->add_watch(
  108. $dir,
  109. sub {
  110. my ( $watcher, $change ) = @_;
  111. $watcher->invalidate_renders($_) for qw{html js};
  112. return 1;
  113. },
  114. key => "Trog::Bundle:$dir",
  115. );
  116. }
  117. }
  118. $tpsgi->add_post_close_callback(
  119. sub {
  120. $tpsgi->save_render( $bundle->{href}, 'js', $bundle->{body} );
  121. $saved{ $bundle->{href} } = 1;
  122. }
  123. );
  124. return 1;
  125. }