File Coverage

blib/lib/PAGI/Middleware/GZip.pm
Criterion Covered Total %
statement 86 92 93.4
branch 24 34 70.5
condition 10 17 58.8
subroutine 10 10 100.0
pod 1 1 100.0
total 131 154 85.0


line stmt bran cond sub pod time code
1             package PAGI::Middleware::GZip;
2              
3 2     2   724283 use strict;
  2         3  
  2         87  
4 2     2   8 use warnings;
  2         6  
  2         112  
5 2     2   338 use parent 'PAGI::Middleware';
  2         319  
  2         13  
6 2     2   115 use Future::AsyncAwait;
  2         5  
  2         9  
7 2     2   1426 use IO::Compress::Gzip qw(gzip $GzipError);
  2         30962  
  2         2872  
8              
9             =head1 NAME
10              
11             PAGI::Middleware::GZip - Response compression middleware
12              
13             =head1 SYNOPSIS
14              
15             use PAGI::Middleware::Builder;
16              
17             my $app = builder {
18             enable 'GZip',
19             min_size => 1024,
20             mime_types => ['text/*', 'application/json'];
21             $my_app;
22             };
23              
24             =head1 DESCRIPTION
25              
26             PAGI::Middleware::GZip compresses response bodies using gzip when the
27             client supports it (Accept-Encoding: gzip).
28              
29             =head1 CONFIGURATION
30              
31             =over 4
32              
33             =item * min_size (default: 1024)
34              
35             Minimum response size to compress (bytes).
36              
37             =item * mime_types (default: text/*, application/json, application/javascript)
38              
39             MIME types to compress.
40              
41             =back
42              
43             =cut
44              
45             sub _init {
46 8     8   15 my ($self, $config) = @_;
47              
48 8   50     64 $self->{min_size} = $config->{min_size} // 1024;
49             $self->{mime_types} = $config->{mime_types} // [
50 8   50     56 'text/html', 'text/plain', 'text/css', 'text/javascript',
51             'application/json', 'application/javascript', 'application/xml',
52             ];
53             }
54              
55             sub wrap {
56 9     9 1 129 my ($self, $app) = @_;
57              
58 13     13   5294 return async sub {
59 13         18 my ($scope, $receive, $send) = @_;
60 13 50       35 if ($scope->{type} ne 'http') {
61 0         0 await $app->($scope, $receive, $send);
62 0         0 return;
63             }
64              
65             # Check if client accepts gzip
66 13   100     26 my $accept_encoding = $self->_get_header($scope, 'accept-encoding') // '';
67 13         52 my $accepts_gzip = $accept_encoding =~ /\bgzip\b/i;
68              
69 13 100       21 unless ($accepts_gzip) {
70 2         6 await $app->($scope, $receive, $send);
71 2         186 return;
72             }
73              
74             # Buffer response to compress
75             # NOTE: All request-specific state MUST be lexical variables, not instance
76             # state ($self->{}), because middleware instances are shared across
77             # concurrent requests. Using $self->{} would cause race conditions.
78 11         12 my @body_parts;
79 11         12 my $response_started = 0;
80 11         14 my $content_type = '';
81 11         10 my $original_headers;
82 11         13 my $headers_sent = 0; # Request-local state (NOT on $self!)
83              
84 25         702 my $wrapped_send = async sub {
85 25         29 my ($event) = @_;
86 25 100       75 if ($event->{type} eq 'http.response.start') {
    50          
87 11         16 $original_headers = $event->{headers};
88             # Get content type
89 11   50     21 for my $h (@{$event->{headers} // []}) {
  11         19  
90 11 50       23 if (lc($h->[0]) eq 'content-type') {
91 11         14 $content_type = $h->[1];
92 11         14 last;
93             }
94             }
95 11         92 $response_started = 1;
96             # Don't send yet - buffer to compress
97             }
98             elsif ($event->{type} eq 'http.response.body') {
99             # If we're already in streaming mode, pass through all chunks
100 14 100       24 if ($headers_sent) {
101 3         4 await $send->($event);
102 3         69 return;
103             }
104              
105 11   50     23 push @body_parts, $event->{body} // '';
106              
107             # If streaming (more => 1), switch to pass-through mode
108 11 100       35 if ($event->{more}) {
109 2 50       4 if (!$headers_sent) {
110 2         6 await $send->({
111             type => 'http.response.start',
112             status => 200,
113             headers => $original_headers,
114             });
115 2         51 $headers_sent = 1;
116             }
117 2         3 await $send->($event);
118             }
119             }
120             else {
121 0         0 await $send->($event);
122             }
123 11         38 };
124              
125 11         23 await $app->($scope, $receive, $wrapped_send);
126              
127             # If headers already sent (streaming), we're done
128 11 100       541 return if $headers_sent;
129              
130             # Combine body
131 9         20 my $body = join('', @body_parts);
132              
133             # Decide whether to compress
134 9         21 my $should_compress = $self->_should_compress($content_type, length($body));
135              
136 9 100 66     31 if ($should_compress && length($body) > 0) {
137 8         10 my $compressed;
138 8 50       24 gzip(\$body, \$compressed) or die "gzip failed: $GzipError";
139              
140             # Update headers
141 8         11446 my @new_headers;
142 8   50     11 for my $h (@{$original_headers // []}) {
  8         24  
143 8 50       23 next if lc($h->[0]) eq 'content-length';
144 8         25 push @new_headers, $h;
145             }
146 8         18 push @new_headers, ['Content-Encoding', 'gzip'];
147 8         12 push @new_headers, ['Content-Length', length($compressed)];
148 8         14 push @new_headers, ['Vary', 'Accept-Encoding'];
149              
150 8         42 await $send->({
151             type => 'http.response.start',
152             status => 200,
153             headers => \@new_headers,
154             });
155 8         344 await $send->({
156             type => 'http.response.body',
157             body => $compressed,
158             more => 0,
159             });
160             }
161             else {
162 1         3 await $send->({
163             type => 'http.response.start',
164             status => 200,
165             headers => $original_headers,
166             });
167 1         27 await $send->({
168             type => 'http.response.body',
169             body => $body,
170             more => 0,
171             });
172             }
173 9         48 };
174             }
175              
176             sub _should_compress {
177 9     9   14 my ($self, $content_type, $size) = @_;
178              
179 9 100       20 return 0 if $size < $self->{min_size};
180              
181 8         30 $content_type =~ s/;.*//; # Remove charset etc.
182 8         12 $content_type = lc($content_type);
183              
184 8         10 for my $type (@{$self->{mime_types}}) {
  8         15  
185 15 100       33 return 1 if $content_type eq lc($type);
186 7 50       14 if ($type =~ /\*$/) {
187 0         0 my $prefix = substr($type, 0, -1);
188 0 0       0 return 1 if index($content_type, lc($prefix)) == 0;
189             }
190             }
191 0         0 return 0;
192             }
193              
194             sub _get_header {
195 13     13   24 my ($self, $scope, $name) = @_;
196              
197 13         24 $name = lc($name);
198 13   50     16 for my $h (@{$scope->{headers} // []}) {
  13         33  
199 11 50       44 return $h->[1] if lc($h->[0]) eq $name;
200             }
201 2         10 return;
202             }
203              
204             1;
205              
206             __END__