Bladeren bron

Save each bundle that a page links as a static, and drop it when a script changes

In production the webserver serves www/static directly, so a bundle that
is saved there never reaches perl. The first version of the bundle did
not save it, because the route builds any list that a URL names, and
saving each of those would let anyone fill the disk.

finish_render now saves the bundle that it links, after the response,
once per process. Only the lists that routes name reach finish_render,
so the set of files is small and fixed. The route still saves nothing.

The href of a bundle is a digest of its body, so a saved bundle is never
wrong. When a script changes, the saved pages still link the old href,
and the old bundles stay on disk. So the first save also has tPSGI watch
www/scripts, and the scripts directory of the theme when there is one,
in the way that DirIndex watches its directory. A change throws away the
saved html and js renders. The pages are rendered again and link the new
bundle. A page that still links an old href gets the route's redirect.

%saved is a package variable, so that the test can start from a worker
that has saved nothing. Two tests that make sure a page is never saved
now leave the saved bundles out. Without that, they passed only because
an earlier subtest had already saved the bundle in this process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
troglodyne-bot 3 dagen geleden
bovenliggende
commit
eb82d617e9
4 gewijzigde bestanden met toevoegingen van 83 en 3 verwijderingen
  1. 3 0
      design.md
  2. 47 0
      lib/Trog/Bundle.pm
  3. 1 0
      lib/Trog/Routes/HTML.pm
  4. 32 3
      t/Trog-Routes-HTML-series-lifecycle.t

+ 3 - 0
design.md

@@ -384,6 +384,9 @@ a list, and a template does not write a `<script src>` tag of its own.
 `/bundle/{version}/{names}.js` serves. The version is a digest of the bundle,
 so a browser keeps the bundle until a script in it changes. A script whose name
 ends in `.min.js` goes into the bundle as it is, and the others are minified.
+tPSGI saves each bundle that a page links as a static, and it watches the
+script directories. When a script changes, it throws away the saved pages and
+bundles.
 
 Composition: relations and enrichment
 =====================================

+ 47 - 0
lib/Trog/Bundle.pm

@@ -85,3 +85,50 @@ sub scripts (@names) {
         signature => $signature,
     };
 }
+
+=head2 save($tpsgi, $bundle)
+
+Have tPSGI save $bundle as a static render after the response, so that the
+webserver serves it from then on.  A process saves a given bundle once.
+
+finish_render() calls this for the bundle that a page links, so only the lists
+that routes name are saved.  A list that only a URL names is built and served,
+but never saved, because anyone can ask for as many of those as they like.
+
+It also has tPSGI watch the directories of the scripts.  When a script changes,
+the saved pages and bundles are thrown away: the pages are rendered again and
+link the new bundle, and a page that still links an old one is redirected by
+the route.  The route also serves a bundle whose static is gone, so a worker
+that still counts it as saved is slower, not wrong.
+
+=cut
+
+# The hrefs of the bundles that this process saved.
+our %saved;
+
+sub save ( $tpsgi, $bundle ) {
+    return 0 if !$tpsgi || $saved{ $bundle->{href} };
+
+    if ( $tpsgi->can('add_watch') ) {
+        my $theme = Trog::Themes::get_dir();
+        foreach my $dir ( grep { -d } ( 'www/scripts', $theme ? "$theme/scripts" : () ) ) {
+            $tpsgi->add_watch(
+                $dir,
+                sub {
+                    my ( $watcher, $change ) = @_;
+                    $watcher->invalidate_renders($_) for qw{html js};
+                    return 1;
+                },
+                key => "Trog::Bundle:$dir",
+            );
+        }
+    }
+
+    $tpsgi->add_post_close_callback(
+        sub {
+            $tpsgi->save_render( $bundle->{href}, 'js', $bundle->{body} );
+            $saved{ $bundle->{href} } = 1;
+        }
+    );
+    return 1;
+}

+ 1 - 0
lib/Trog/Routes/HTML.pm

@@ -3491,6 +3491,7 @@ sub finish_render ( $template, $vars, %headers ) {
     $vars->{print_styles} = [ @{ build_themed_styles( $vars->{print_styles} ) } ];
     my $bundle = Trog::Bundle::scripts( @{ Trog::Utils::coerce_array( $vars->{scripts} ) } );
     $vars->{scripts} = $bundle ? [ $bundle->{href} ] : [];
+    Trog::Bundle::save( $vars->{tpsgi}, $bundle ) if $bundle;
 
     # Add in avatars.css, it's special
     push( @{ $vars->{stylesheets} }, "/styles/avatars.css" );

+ 32 - 3
t/Trog-Routes-HTML-series-lifecycle.t

@@ -308,6 +308,12 @@ my sub render {
     return ( $code, Encode::decode_utf8($body), '', $headers );
 }
 
+# The pages that a stand-in tPSGI was asked to save, without the bundles of
+# scripts, which every page links.
+my sub pages_saved ($tpsgi) {
+    return [ grep { !m/:js\z/ } keys %{ $tpsgi->{renders} } ];
+}
+
 # FlatFile snapshots the tag list at load time and then filters each query's
 # tags against it IN PLACE -- so a tag created later isn't merely unindexed, it
 # is deleted from the query, and the filter silently degrades to "every public
@@ -690,7 +696,7 @@ subtest 'a datasource-backed series searches and paginates' => sub {
     # not going to tell us about it.
     my $vtpsgi = Test::TPSGI->new( run_callbacks => 1 );
     render( anon( route => '/specguests', tpsgi => $vtpsgi ), \&Trog::Routes::HTML::series );
-    is_deeply( [ keys %{ $vtpsgi->{renders} } ], [], 'a guests page is never saved as a static' );
+    is_deeply( pages_saved($vtpsgi), [], 'a guests page is never saved as a static' );
 
     # The cursor paginator would be worse than useless here: every guest shares
     # a created, so Prev would ask for everything older than that one instant.
@@ -817,7 +823,7 @@ subtest 'a directory index series' => sub {
     # the renderer hands anonymous, unparameterised, successful renders to tPSGI
     # to save, and a directory listing is one.  Which it may be, unlike the
     # guests page above, precisely because it registers the watch below.
-    ok( scalar( keys %{ $tpsgi->{renders} } ),                    'the listing was handed to tPSGI to save as a static' );
+    ok( scalar( @{ pages_saved($tpsgi) } ),                       'the listing was handed to tPSGI to save as a static' );
     ok( ( grep { m{^/specfiles:} } keys %{ $tpsgi->{renders} } ), 'under its own route' );
 
     my ($watch) = grep { $_->{path} =~ m/spec_downloads/ } @{ $tpsgi->{watches} };
@@ -1254,6 +1260,29 @@ subtest 'presentation' => sub {
     ($bcode) = render( anon( route => '/bundle/0000/nothere.js', version => '0000', names => 'nothere' ), \&Trog::Routes::HTML::bundle );
     is( $bcode, 404, 'and a bundle of nothing is not found' );
 
+    # As a fresh worker, which has saved nothing yet.
+    no warnings qw{once};
+    local %Trog::Bundle::saved = ();
+    my $tpsgi = Test::TPSGI->new( run_callbacks => 1 );
+    render( anon( $deck_query->( tpsgi => $tpsgi ) ), \&Trog::Routes::HTML::posts );
+    is( $tpsgi->{renders}{"$srcs[0]:js"}, $bundle, 'the bundle that a page links is saved as a static' );
+
+    delete $tpsgi->{renders}{"$srcs[0]:js"};
+    render( anon( $deck_query->( tpsgi => $tpsgi ) ), \&Trog::Routes::HTML::posts );
+    ok( !exists $tpsgi->{renders}{"$srcs[0]:js"}, 'once' );
+
+    my $unlinked = 'reveal.min+highlight.min';
+    my ($unlinked_href) = $srcs[0] =~ s{/[^/]+/[^/]+\z}{}r;
+    ( $bcode, undef, undef, $headers ) = render( anon( route => "$unlinked_href/0000/$unlinked.js", version => '0000', names => $unlinked, tpsgi => $tpsgi ), \&Trog::Routes::HTML::bundle );
+    my %to = @$headers;
+    render( anon( route => $to{Location}, version => 'x', names => $unlinked, tpsgi => $tpsgi ), \&Trog::Routes::HTML::bundle );
+    ok( !( grep { m{/\Q$unlinked\E[.]js:js\z} } keys %{ $tpsgi->{renders} } ), 'while a list that only a URL names is not' );
+
+    my ($watch) = grep { $_->{path} eq 'www/scripts' } @{ $tpsgi->{watches} };
+    ok( $watch, 'the scripts are watched' ) or return;
+    $watch->{callback}->( $tpsgi, {} );
+    is_deeply( [ @{ $tpsgi->{invalidated} }[ -2, -1 ] ], [qw{html js}], 'and a change throws away the saved pages and bundles' );
+
     ( $code, $page, $err ) = render( admin( $deck_query->() ), \&Trog::Routes::HTML::posts );
     is( $code, 200, 'the deck renders for an editor' ) or return diag($err);
     my $editing = qr{post[+]post_relations};
@@ -1603,7 +1632,7 @@ subtest 'saves report back through the jsalert banner' => sub {
     # form was accidentally safe here; a cookie is not.
     my $tpsgi = Test::TPSGI->new( run_callbacks => 1 );
     render( anon( route => '/specblog', cookies => $feedback, tpsgi => $tpsgi ), \&Trog::Routes::HTML::series );
-    is_deeply( [ keys %{ $tpsgi->{renders} } ], [], 'and it is never saved as a static' );
+    is_deeply( pages_saved($tpsgi), [], 'and it is never saved as a static' );
 };
 
 subtest 'a hidden id that was never filled in' => sub {