From d0665ef7c2234ef30426b6988cf0794ff44debc5 Mon Sep 17 00:00:00 2001 From: Yorhel Date: Wed, 19 Feb 2025 16:39:44 +0100 Subject: [PATCH] FU: Output compression, XMLWriter integration, import options, some docs --- FU.pm | 173 +++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 141 insertions(+), 32 deletions(-) diff --git a/FU.pm b/FU.pm index 92db2e0..ca313ff 100644 --- a/FU.pm +++ b/FU.pm @@ -1,19 +1,27 @@ package FU 0.1; use v5.36; -use Carp 'confess'; +use Carp 'confess', 'croak'; use IO::Socket; use POSIX; use FU::Util; +use FU::XMLWriter; +my %export_funcs = do { no strict 'refs'; +( + (map +($_, \*{'FU::Util::'.$_}), @FU::Util::EXPORT_OK), + (map +($_, \*{'FU::XMLWriter::'.$_}), @FU::XMLWriter::EXPORT_OK), +) }; +my %export_tags = %FU::XMLWriter::EXPORT_TAGS; + sub import($pkg, @opt) { - my %opt = map +($_,1), @opt; - - _spawn() if $opt{-spawn}; - - no strict 'refs'; my $c = caller; + no strict 'refs'; *{$c.'::fu'} = \&fu; + for (map /^:(.+)$/ && $export_tags{$1} ? $export_tags{$1}->@* : ($_), @opt) { + if ($_ eq '-spawn') { _spawn() } + elsif ($export_funcs{$_}) { *{$c.'::'.$_} = $export_funcs{$_} } + else { croak "Unknown import option: '$_'" } + } } @@ -34,7 +42,7 @@ sub mime_types() { state $v = {qw{ avif image/avif bin application/octet-stream bmp image/bmp - bz application/x-bzip2 + bz2 application/x-bzip2 css text/css csv text/csv gif image/gif @@ -73,6 +81,28 @@ sub mime_types() { state $v = {qw{ xsd application/xml xsl application/xml zip application/zip + zst application/zstd +}} } + +# XML & JSON generally don't need a charset parameter +sub utf8_mimes { state $v = {map +($_,1), qw{ + application/javascript + text/css + text/html + text/plain +}} } + +sub compress_mimes { state $v = {map +($_,1), qw{ + application/atom+xml + application/javascript + application/json + application/rss+xml + application/xml + image/svg+xml + text/css + text/csv + text/html + text/plain }} } @@ -246,7 +276,10 @@ sub _log_err($e) { } sub _do_req($c) { - local $REQ = { hdr => {} }; + local $REQ = { + hdr => {}, + xml => FU::XMLWriter::_new(), + }; local $fu = bless {}, 'FU::obj'; $REQ->{ip} = $c->{client_sock} isa 'IO::Socket::INET' ? $c->{client_sock}->peerhost : '127.0.0.1'; @@ -554,17 +587,19 @@ sub formdata { sub done { die bless [200,'Done'], 'FU::err' } sub error($,$code,$msg=$code) { die bless [$code,$msg], 'FU::err' } -sub reset { - my $r = $FU::REQ; - $r->{status} = 200; - $r->{reshdr} = { - 'content-type', 'text/html; charset=UTF-8', - }; - $r->{resbody} = ''; +sub status($, $code) { $FU::REQ->{status} = $code } +sub set_body($, $data) { + $FU::REQ->{xml}->_done; + $FU::REQ->{resbody} = $data; } -sub status($, $code) { $FU::REQ->{status} = $code } -sub set_body($, $data) { $FU::REQ->{resbody} = $data } # TODO: replace with a regular 'print' +sub reset { + fu->status(200); + fu->set_body(''); + $FU::REQ->{reshdr} = { + 'content-type', 'text/html', + }; +} sub _validate_header($hdr, $val) { @@ -610,18 +645,49 @@ sub _error_page($, $code, $title, $msg) { fu->set_body($body); } -sub _flush($, $sock) { +sub _finalize { + state $haszlib = eval { require Compress::Raw::Zlib; 1 }; + state $haszstd = eval { require Compress::Zstd; 1 }; my $r = $FU::REQ; - # TODO: output compression would be nice + + my $xml = $r->{xml}->_done; + $r->{resbody} = $xml if length $xml; if ($r->{status} == 204) { - fu->set_header('content-length', undef); - fu->set_header('content-encoding', undef); - } else { - fu->set_header('content-length', length $r->{resbody}); - } - $r->{resbody} = '' if (fu->method//'') eq 'HEAD' || $r->{status} == 204; + delete $r->{reshdr}{'content-length'}; + delete $r->{reshdr}{'content-encoding'}; + $r->{resbody} = ''; + } else { + if (($haszlib || $haszstd) && !defined $r->{reshdr}{'content-encoding'} && FU::compress_mimes->{$r->{reshdr}{'content-type'}}) { + + $r->{reshdr}{'vary'} = ($r->{reshdr}{'vary'} ? $r->{reshdr}{'vary'}.', ' : '').'accept-encoding' + if ($r->{reshdr}{'vary'}||'') !~ /accept-encoding/i; + + if ($haszstd && ($r->{hdr}{'accept-encoding'}||'') =~ /zstd/) { + $r->{resbody} = Compress::Zstd::compress($r->{resbody}); + $r->{reshdr}{'content-encoding'} = 'zstd'; + + } elsif ($haszlib && ($r->{hdr}{'accept-encoding'}||'') =~ /gzip/) { + # Use lower-level API because the higher-level Compress::Zlib loads a whole bunch of other modules. + my $z = Compress::Raw::Zlib::Deflate->new(-WindowBits => Compress::Raw::Zlib::WANT_GZIP(), -Level => 3, -AppendOutput => 1); + $z->deflate($r->{resbody}, my $buf); + $z->flush($buf); + $r->{resbody} = $buf; + $r->{reshdr}{'content-encoding'} = 'gzip'; + } + } + $r->{reshdr}{'content-length'} = length $r->{resbody}; + $r->{resbody} = '' if (fu->method//'') eq 'HEAD'; + } + + $r->{reshdr}{'content-type'} .= '; charset=UTF-8' if FU::utf8_mimes->{$r->{reshdr}{'content-type'}}; +} + +sub _flush($, $sock) { + _finalize; + + my $r = $FU::REQ; if ($sock isa 'FU::fcgi') { $sock->print('Status: '); $sock->print($r->{status}); @@ -663,10 +729,10 @@ FU - Framework Ultimatum: A Lean and Efficient Zero-Dependency Web Framework. =head1 SYNOPSIS use v5.36; - use FU -spawn; + use FU -spawn, ':html5_'; FU::get qr{/hello/(.+)}, sub($who) { - fu->set_body("

Hello, $who!

"); + h1_ "Hello, $who!"; }; FU::run; @@ -675,10 +741,10 @@ FU - Framework Ultimatum: A Lean and Efficient Zero-Dependency Web Framework. =head2 Distribution Overview -This top-level C module is a web framework. The C distribution also -includes a bunch of modules that the framework depends on or which are -otherwise useful when building web backends. These modules are standalone and -can be used independently of the framework: +This top-level C module is a web development framework. The C +distribution also includes a bunch of modules that the framework depends on or +which are otherwise useful when building web backends. These modules are +standalone and can be used independently of the framework: =over @@ -686,6 +752,8 @@ can be used independently of the framework: =item * L - PostgreSQL client. +=item * L - Dynamic XML generation, easy and fast. + =back Note that everything in this distribution requires a moderately recent version @@ -694,6 +762,38 @@ is). =head2 Framework Overview +C is a mostly straightforward and conventional backend web framework. It +doesn't try to be particularly innovative, but it does attempt to implement +existing ideas in a convenient, coherent and efficient way. There are a few +inherent properties of C's design that you will want to be aware of before +digging further: + +=over + +=item FU is synchronous + +C is an entirely synchronous framework, meaning that a single Perl process +can only handle a single request at a time. This is great in that it simplifies +the implementation, makes debugging easy and performance predictable. + +The downside is that you will want to avoid long-running requests as much as +possible. Potentially slow network operations are best delegated to a +background queue. C intentionally does not support websockets, long-polling +might work but is a bad idea because you'll need to run as many processes as +there are concurrent clients, which gets wasteful very fast. If some UI latency +is acceptable, interval-based polling tends to be simpler to reason about and +more reliable. If such latency is not acceptable, you'll want to run a separate +daemon for asynchronous tasks. + +=item FU is buffered + +The entire request is read into memory before your code even runs, and the +generated response is buffered in full before a single byte is sent off to the +client. This is, once again, great for simple and predictable code, but +certainly not great if you plan to transfer large files. + +=back + =head2 Importing FU You'll usually want to add the following statement somewhere near the top of @@ -705,7 +805,16 @@ The C<-spawn> option tells C to read running configuration from environment variables and command-line arguments during early startup, see L below. -I +C additionally re-exports all tags and functions from L and +L, so you can shorten your C statements: + + use FU -spawn, 'json_format', ':html5_'; + +Is equivalent to: + + use FU -spawn; + use FU::Util 'json_format'; + use FU::XMLWriter ':html5_'; =head2 Framework Configuration