Bundle.pm 5.2 KB

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