File Coverage

blib/lib/PAGI/Server/Protocol/HTTP2.pm
Criterion Covered Total %
statement 141 143 98.6
branch 40 58 68.9
condition 21 31 67.7
subroutine 29 29 100.0
pod 4 4 100.0
total 235 265 88.6


line stmt bran cond sub pod time code
1             package PAGI::Server::Protocol::HTTP2;
2 113     113   6060865 use strict;
  113         202  
  113         4226  
3 113     113   2203 use warnings;
  113         162  
  113         9934  
4              
5             our $VERSION = '0.002005';
6              
7             =encoding utf8
8              
9             =head1 NAME
10              
11             PAGI::Server::Protocol::HTTP2 - HTTP/2 protocol handler using nghttp2
12              
13             =head1 SYNOPSIS
14              
15             use PAGI::Server::Protocol::HTTP2;
16              
17             my $proto = PAGI::Server::Protocol::HTTP2->new;
18              
19             if ($proto->available) {
20             my $session = $proto->create_session(
21             on_request => sub { ... },
22             on_body => sub { ... },
23             on_close => sub { ... },
24             );
25             }
26              
27             =head1 DESCRIPTION
28              
29             PAGI::Server::Protocol::HTTP2 provides HTTP/2 support for PAGI::Server
30             using the nghttp2 C library via Net::HTTP2::nghttp2.
31              
32             Unlike HTTP/1.1, HTTP/2 uses binary framing, multiplexed streams on a
33             single connection, HPACK header compression, and per-stream flow control.
34              
35             This module bridges nghttp2's callback-based API to PAGI's event model.
36              
37             =cut
38              
39             # HTTP/2 client connection preface (RFC 9113 Section 3.4)
40 113     113   498 use constant H2_CLIENT_PREFACE => "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
  113         183  
  113         7079  
41 113     113   491 use constant H2_PREFACE_LENGTH => 24;
  113         166  
  113         7106  
42              
43             # Check for nghttp2 availability
44             our $AVAILABLE;
45 113     113   460 use constant MIN_NGHTTP2_VERSION => '0.008';
  113         164  
  113         11344  
46             BEGIN {
47 113 50   113   290 $AVAILABLE = eval {
48 113         48629 require Net::HTTP2::nghttp2;
49 113         102636 Net::HTTP2::nghttp2->VERSION(MIN_NGHTTP2_VERSION);
50 113         49268 require Net::HTTP2::nghttp2::Session;
51 113         159329 Net::HTTP2::nghttp2->available;
52             } ? 1 : 0;
53             }
54              
55 136     136 1 158930 sub available { return $AVAILABLE }
56              
57             =head2 available
58              
59             if (PAGI::Server::Protocol::HTTP2->available) { ... }
60              
61             Returns true if HTTP/2 support is usable — that is, if Net::HTTP2::nghttp2
62             (at least version C) and its Session class loaded and the
63             underlying nghttp2 library reports itself available. Returns false otherwise.
64             Checked once at module load.
65              
66             =head2 detect_preface
67              
68             if (PAGI::Server::Protocol::HTTP2->detect_preface($bytes)) { ... }
69              
70             Returns true if C<$bytes> starts with the HTTP/2 client connection preface.
71             Used for h2c (cleartext HTTP/2) detection.
72              
73             =cut
74              
75             sub detect_preface {
76 49     49 1 502 my ($class, $bytes) = @_;
77 49 100 66     307 return 0 unless defined $bytes && length($bytes) >= H2_PREFACE_LENGTH;
78 46         304 return substr($bytes, 0, H2_PREFACE_LENGTH) eq H2_CLIENT_PREFACE;
79             }
80              
81             =head2 new
82              
83             my $proto = PAGI::Server::Protocol::HTTP2->new(
84             max_concurrent_streams => 100, # Default
85             initial_window_size => 65535, # Default
86             max_frame_size => 16384, # Default
87             enable_push => 0, # Default (disabled)
88             enable_connect_protocol => 1, # Default (enabled, RFC 8441)
89             max_header_list_size => 65536, # Default (64KB)
90             h2_rst_rate_limit => { burst => 1000, rate => 33 }, # Default (Rapid Reset defense)
91             );
92              
93             Creates a new HTTP/2 protocol handler with the specified settings.
94              
95             =cut
96              
97             sub new {
98 113     113 1 11837 my ($class, %args) = @_;
99              
100             my $self = bless {
101             max_concurrent_streams => $args{max_concurrent_streams} // 100,
102             initial_window_size => $args{initial_window_size} // 65535,
103             max_frame_size => $args{max_frame_size} // 16384,
104             enable_push => $args{enable_push} // 0,
105             enable_connect_protocol => $args{enable_connect_protocol} // 1,
106             max_header_list_size => $args{max_header_list_size} // 65536,
107 113   100     1901 h2_rst_rate_limit => $args{h2_rst_rate_limit} // { burst => 1000, rate => 33 },
      100        
      100        
      100        
      100        
      100        
      100        
108             }, $class;
109              
110 113         476 return $self;
111             }
112              
113             =head2 create_session
114              
115             my $session = $proto->create_session(
116             on_request => sub { ($stream_id, $pseudo, $headers, $has_body) = @_ },
117             on_body => sub { ($stream_id, $data, $eof) = @_ },
118             on_close => sub { ($stream_id, $error_code) = @_ },
119             );
120              
121             Creates a new HTTP/2 session for a connection. Returns a
122             L wrapper.
123              
124             =cut
125              
126             sub create_session {
127 89     89 1 987 my ($self, %callbacks) = @_;
128              
129 89 50       294 die "HTTP/2 not available (nghttp2 not installed)\n" unless $AVAILABLE;
130              
131             return PAGI::Server::Protocol::HTTP2::Session->new(
132             protocol => $self,
133             on_request => $callbacks{on_request},
134             on_body => $callbacks{on_body},
135             on_close => $callbacks{on_close},
136             settings => {
137             max_concurrent_streams => $self->{max_concurrent_streams},
138             initial_window_size => $self->{initial_window_size},
139             max_frame_size => $self->{max_frame_size},
140             enable_push => $self->{enable_push},
141             enable_connect_protocol => $self->{enable_connect_protocol},
142             max_header_list_size => $self->{max_header_list_size},
143             },
144             h2_rst_rate_limit => $self->{h2_rst_rate_limit},
145 89         1299 );
146             }
147              
148             # =============================================================================
149             # HTTP/2 Session Wrapper
150             # =============================================================================
151              
152             package PAGI::Server::Protocol::HTTP2::Session;
153 113     113   43811 use strict;
  113         219  
  113         2437  
154 113     113   373 use warnings;
  113         164  
  113         4843  
155 113     113   471 use Scalar::Util qw(weaken);
  113         170  
  113         203429  
156              
157             our $VERSION = '0.002005';
158              
159             sub new {
160 89     89   449 my ($class, %args) = @_;
161              
162             my $self = bless {
163             protocol => $args{protocol},
164             on_request => $args{on_request},
165             on_body => $args{on_body},
166             on_close => $args{on_close},
167             settings => $args{settings},
168             h2_rst_rate_limit => $args{h2_rst_rate_limit},
169 89         806 streams => {}, # stream_id => { headers => [], pseudo => {}, ... }
170             nghttp2 => undef,
171             }, $class;
172              
173 89         288 weaken($self->{protocol});
174              
175 89         348 $self->_init_nghttp2_session;
176              
177 89         1556 return $self;
178             }
179              
180             sub _init_nghttp2_session {
181 89     89   177 my ($self) = @_;
182              
183 89         138 my $weak_self = $self;
184 89         158 weaken($weak_self);
185              
186 89         169 my $rl = $self->{h2_rst_rate_limit};
187              
188             $self->{nghttp2} = Net::HTTP2::nghttp2::Session->new_server(
189             callbacks => {
190             on_begin_headers => sub {
191 87     87   259 my ($stream_id, $type, $flags) = @_;
192 87 50       288 return 0 unless $weak_self;
193              
194             # HEADERS frame starts a new request
195 87 50 33     603 if (!defined $type || $type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
196 87         774 $weak_self->{streams}{$stream_id} = {
197             headers => [],
198             pseudo => {},
199             header_list_size => 0,
200             };
201             }
202 87         844 return 0;
203             },
204              
205             on_header => sub {
206 411     411   1020 my ($stream_id, $name, $value, $flags) = @_;
207 411 50       750 return 0 unless $weak_self;
208              
209 411         705 my $stream = $weak_self->{streams}{$stream_id};
210 411 50       765 return 0 unless $stream;
211              
212             # RFC 7541: header entry size = name_len + value_len + 32
213 411         756 $stream->{header_list_size} += length($name) + length($value) + 32;
214 411 100       903 if ($stream->{header_list_size} > $weak_self->{settings}{max_header_list_size}) {
215 1         3 delete $weak_self->{streams}{$stream_id};
216 1         10 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
217             }
218              
219             # Pseudo-headers start with ':'
220 410 100       1207 if ($name =~ /^:/) {
221 363         939 $stream->{pseudo}{$name} = $value;
222             } else {
223 47         87 push @{$stream->{headers}}, [$name, $value];
  47         216  
224             }
225 410         2175 return 0;
226             },
227              
228             on_frame_recv => sub {
229 295     295   634 my ($frame) = @_;
230 295 50       709 return 0 unless $weak_self;
231              
232 295         785 my $stream_id = $frame->{stream_id};
233 295         557 my $type = $frame->{type};
234 295         532 my $flags = $frame->{flags};
235              
236             # HEADERS frame = request headers complete
237 295 100       983 if ($type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
238 86         246 my $stream = $weak_self->{streams}{$stream_id};
239              
240             # Reject HEADERS on a stream where client already sent END_STREAM
241 86 50 33     537 if ($stream && $stream->{client_end_stream}) {
242 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
243             }
244              
245 86 50 33     476 if ($stream && $weak_self->{on_request}) {
246 86         180 my $headers = $stream->{headers};
247 86         219 my $pseudo = $stream->{pseudo};
248              
249             # Convert :authority pseudo-header to host header
250             # (RFC 9113 Section 8.3.1: :authority takes precedence)
251 86 50       282 if (defined $pseudo->{':authority'}) {
252 86         184 my $authority = $pseudo->{':authority'};
253 86         183 my $found_host = 0;
254 86         224 for my $h (@$headers) {
255 47 100       163 if ($h->[0] eq 'host') {
256 1         3 $h->[1] = $authority;
257 1         2 $found_host = 1;
258 1         3 last;
259             }
260             }
261 86 100       359 push @$headers, ['host', $authority] unless $found_host;
262             }
263              
264             # Normalize multiple cookie headers into one
265             # (matches HTTP/1.1 behavior in HTTP1.pm)
266 86         190 my @cookie_values;
267             my @non_cookie;
268 86         217 for my $h (@$headers) {
269 132 100       358 if ($h->[0] eq 'cookie') {
270 2         5 push @cookie_values, $h->[1];
271             } else {
272 130         273 push @non_cookie, $h;
273             }
274             }
275 86 100       343 if (@cookie_values > 1) {
276 1         5 push @non_cookie, ['cookie', join('; ', @cookie_values)];
277 1         5 @$headers = @non_cookie;
278             }
279              
280 86         336 my $end_stream = $flags & Net::HTTP2::nghttp2::NGHTTP2_FLAG_END_STREAM();
281              
282             # Track that client has finished sending on this stream
283 86 100       225 if ($end_stream) {
284 65         222 $stream->{client_end_stream} = 1;
285             }
286              
287 86         394 $weak_self->{on_request}->(
288             $stream_id,
289             $pseudo,
290             $headers,
291             !$end_stream, # has_body = not END_STREAM
292             );
293             }
294             }
295              
296             # DATA frame with END_STREAM = body complete
297 295 100       4392 if ($type == Net::HTTP2::nghttp2::NGHTTP2_DATA()) {
298 16         55 my $end_stream = $flags & Net::HTTP2::nghttp2::NGHTTP2_FLAG_END_STREAM();
299 16 100       45 if ($end_stream) {
300 4         10 my $stream = $weak_self->{streams}{$stream_id};
301 4 50       16 $stream->{client_end_stream} = 1 if $stream;
302 4 50       49 if ($weak_self->{on_body}) {
303 4         24 $weak_self->{on_body}->($stream_id, '', 1);
304             }
305             }
306             }
307              
308 295         1606 return 0;
309             },
310              
311             on_data_chunk_recv => sub {
312 20     20   73 my ($stream_id, $data, $flags) = @_;
313 20 50       54 return 0 unless $weak_self;
314              
315             # Reject DATA on a stream where client already sent END_STREAM
316 20         70 my $stream = $weak_self->{streams}{$stream_id};
317 20 50 33     113 if ($stream && $stream->{client_end_stream}) {
318 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
319             }
320              
321 20 50       63 if ($weak_self->{on_body}) {
322             # END_STREAM comes in frame_recv, not here
323 20         81 $weak_self->{on_body}->($stream_id, $data, 0);
324             }
325 20         189 return 0;
326             },
327              
328             on_stream_close => sub {
329 51     51   566 my ($stream_id, $error_code) = @_;
330 51 50       168 return 0 unless $weak_self;
331              
332 51 50       182 if ($weak_self->{on_close}) {
333 51         169 $weak_self->{on_close}->($stream_id, $error_code);
334             }
335              
336             # Clean up stream state
337 51         1364 delete $weak_self->{streams}{$stream_id};
338 51         315 return 0;
339             },
340             },
341             (defined $rl
342             ? (stream_reset_burst => $rl->{burst}, stream_reset_rate => $rl->{rate})
343 89 50       2255 : ()),
344             );
345              
346             # Send initial SETTINGS
347 89         6155 $self->{nghttp2}->send_connection_preface(%{$self->{settings}});
  89         478  
348             }
349              
350             =head2 feed
351              
352             my $consumed = $session->feed($data);
353              
354             Feed incoming data to the HTTP/2 session. Returns bytes consumed.
355              
356             =cut
357              
358             sub feed {
359 276     276   1751 my ($self, $data) = @_;
360 276         3460 return $self->{nghttp2}->mem_recv($data);
361             }
362              
363             =head2 extract
364              
365             my $data = $session->extract;
366              
367             Extract outgoing data from the session. Returns bytes to send.
368              
369             =cut
370              
371             sub extract {
372 882     882   3624 my ($self) = @_;
373 882         4826 return $self->{nghttp2}->mem_send;
374             }
375              
376             =head2 want_read
377              
378             if ($session->want_read) { ... }
379              
380             Check if session wants to read.
381              
382             =cut
383              
384             sub want_read {
385 237     237   453 my ($self) = @_;
386 237         1193 return $self->{nghttp2}->want_read;
387             }
388              
389             =head2 want_write
390              
391             if ($session->want_write) { ... }
392              
393             Check if session has data to write.
394              
395             =cut
396              
397             sub want_write {
398 1     1   8 my ($self) = @_;
399 1         11 return $self->{nghttp2}->want_write;
400             }
401              
402             =head2 submit_response
403              
404             $session->submit_response($stream_id,
405             status => 200,
406             headers => [['content-type', 'text/html']],
407             body => $body,
408             );
409              
410             Submit a response on a stream. C can be a string (sent as single
411             response) or a coderef for streaming.
412              
413             =cut
414              
415             sub submit_response {
416 58     58   1097 my ($self, $stream_id, %args) = @_;
417 58         338 return $self->{nghttp2}->submit_response($stream_id, %args);
418             }
419              
420             =head2 submit_response_streaming
421              
422             $session->submit_response_streaming($stream_id,
423             status => 200,
424             headers => [['content-type', 'text/event-stream']],
425             data_callback => sub {
426             my ($stream_id, $max_len) = @_;
427             return ($chunk, $is_eof);
428             },
429             );
430              
431             Submit a streaming response with a data provider callback.
432              
433             =cut
434              
435             sub submit_response_streaming {
436 24     24   114 my ($self, $stream_id, %args) = @_;
437             return $self->{nghttp2}->submit_response($stream_id,
438             status => $args{status},
439             headers => $args{headers},
440             data_callback => $args{data_callback},
441             callback_data => $args{callback_data},
442 24         233 );
443             }
444              
445             =head2 resume_stream
446              
447             $session->resume_stream($stream_id);
448              
449             Resume a deferred stream after data becomes available.
450              
451             =cut
452              
453             sub resume_stream {
454 107     107   294 my ($self, $stream_id) = @_;
455 107         717 return $self->{nghttp2}->resume_stream($stream_id);
456             }
457              
458             =head2 submit_data
459              
460             $session->submit_data($stream_id, $data, $eof);
461              
462             Push data directly onto a stream. Used for WebSocket frame delivery
463             over HTTP/2 where frames are sent as DATA payloads.
464              
465             =cut
466              
467             sub submit_data {
468 11     11   252 my ($self, $stream_id, $data, $eof) = @_;
469 11         228 return $self->{nghttp2}->submit_data($stream_id, $data, $eof);
470             }
471              
472             =head2 terminate
473              
474             $session->terminate($error_code);
475              
476             Terminate the session with GOAWAY.
477              
478             =cut
479              
480             sub terminate {
481 75     75   2616876 my ($self, $error_code) = @_;
482 75   50     236 $error_code //= 0; # NO_ERROR
483 75         839 return $self->{nghttp2}->terminate_session($error_code);
484             }
485              
486             1;
487              
488             __END__