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   5848166 use strict;
  113         194  
  113         4140  
3 113     113   506 use warnings;
  113         217  
  113         9827  
4              
5             our $VERSION = '0.002006';
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   491 use constant H2_CLIENT_PREFACE => "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
  113         166  
  113         6736  
41 113     113   447 use constant H2_PREFACE_LENGTH => 24;
  113         156  
  113         7401  
42              
43             # Check for nghttp2 availability
44             our $AVAILABLE;
45 113     113   434 use constant MIN_NGHTTP2_VERSION => '0.008';
  113         158  
  113         10742  
46             BEGIN {
47 113 50   113   301 $AVAILABLE = eval {
48 113         45234 require Net::HTTP2::nghttp2;
49 113         104060 Net::HTTP2::nghttp2->VERSION(MIN_NGHTTP2_VERSION);
50 113         49533 require Net::HTTP2::nghttp2::Session;
51 113         157691 Net::HTTP2::nghttp2->available;
52             } ? 1 : 0;
53             }
54              
55 136     136 1 178201 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 406 my ($class, $bytes) = @_;
77 49 100 66     233 return 0 unless defined $bytes && length($bytes) >= H2_PREFACE_LENGTH;
78 46         231 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 10348 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     1636 h2_rst_rate_limit => $args{h2_rst_rate_limit} // { burst => 1000, rate => 33 },
      100        
      100        
      100        
      100        
      100        
      100        
108             }, $class;
109              
110 113         433 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 922 my ($self, %callbacks) = @_;
128              
129 89 50       256 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         1148 );
146             }
147              
148             # =============================================================================
149             # HTTP/2 Session Wrapper
150             # =============================================================================
151              
152             package PAGI::Server::Protocol::HTTP2::Session;
153 113     113   43254 use strict;
  113         222  
  113         2513  
154 113     113   371 use warnings;
  113         209  
  113         5072  
155 113     113   454 use Scalar::Util qw(weaken);
  113         166  
  113         204754  
156              
157             our $VERSION = '0.002006';
158              
159             sub new {
160 89     89   448 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         646 streams => {}, # stream_id => { headers => [], pseudo => {}, ... }
170             nghttp2 => undef,
171             }, $class;
172              
173 89         261 weaken($self->{protocol});
174              
175 89         315 $self->_init_nghttp2_session;
176              
177 89         1399 return $self;
178             }
179              
180             sub _init_nghttp2_session {
181 89     89   167 my ($self) = @_;
182              
183 89         128 my $weak_self = $self;
184 89         130 weaken($weak_self);
185              
186 89         161 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   280 my ($stream_id, $type, $flags) = @_;
192 87 50       287 return 0 unless $weak_self;
193              
194             # HEADERS frame starts a new request
195 87 50 33     570 if (!defined $type || $type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
196 87         725 $weak_self->{streams}{$stream_id} = {
197             headers => [],
198             pseudo => {},
199             header_list_size => 0,
200             };
201             }
202 87         752 return 0;
203             },
204              
205             on_header => sub {
206 411     411   894 my ($stream_id, $name, $value, $flags) = @_;
207 411 50       663 return 0 unless $weak_self;
208              
209 411         668 my $stream = $weak_self->{streams}{$stream_id};
210 411 50       679 return 0 unless $stream;
211              
212             # RFC 7541: header entry size = name_len + value_len + 32
213 411         647 $stream->{header_list_size} += length($name) + length($value) + 32;
214 411 100       856 if ($stream->{header_list_size} > $weak_self->{settings}{max_header_list_size}) {
215 1         2 delete $weak_self->{streams}{$stream_id};
216 1         6 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
217             }
218              
219             # Pseudo-headers start with ':'
220 410 100       1054 if ($name =~ /^:/) {
221 363         880 $stream->{pseudo}{$name} = $value;
222             } else {
223 47         115 push @{$stream->{headers}}, [$name, $value];
  47         168  
224             }
225 410         2062 return 0;
226             },
227              
228             on_frame_recv => sub {
229 295     295   571 my ($frame) = @_;
230 295 50       630 return 0 unless $weak_self;
231              
232 295         544 my $stream_id = $frame->{stream_id};
233 295         518 my $type = $frame->{type};
234 295         506 my $flags = $frame->{flags};
235              
236             # HEADERS frame = request headers complete
237 295 100       877 if ($type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
238 86         262 my $stream = $weak_self->{streams}{$stream_id};
239              
240             # Reject HEADERS on a stream where client already sent END_STREAM
241 86 50 33     683 if ($stream && $stream->{client_end_stream}) {
242 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
243             }
244              
245 86 50 33     437 if ($stream && $weak_self->{on_request}) {
246 86         179 my $headers = $stream->{headers};
247 86         181 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       243 if (defined $pseudo->{':authority'}) {
252 86         166 my $authority = $pseudo->{':authority'};
253 86         161 my $found_host = 0;
254 86         233 for my $h (@$headers) {
255 47 100       157 if ($h->[0] eq 'host') {
256 1         2 $h->[1] = $authority;
257 1         1 $found_host = 1;
258 1         2 last;
259             }
260             }
261 86 100       444 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         182 my @cookie_values;
267             my @non_cookie;
268 86         186 for my $h (@$headers) {
269 132 100       344 if ($h->[0] eq 'cookie') {
270 2         3 push @cookie_values, $h->[1];
271             } else {
272 130         286 push @non_cookie, $h;
273             }
274             }
275 86 100       263 if (@cookie_values > 1) {
276 1         3 push @non_cookie, ['cookie', join('; ', @cookie_values)];
277 1         2 @$headers = @non_cookie;
278             }
279              
280 86         338 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       236 if ($end_stream) {
284 65         170 $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       4125 if ($type == Net::HTTP2::nghttp2::NGHTTP2_DATA()) {
298 16         41 my $end_stream = $flags & Net::HTTP2::nghttp2::NGHTTP2_FLAG_END_STREAM();
299 16 100       40 if ($end_stream) {
300 4         32 my $stream = $weak_self->{streams}{$stream_id};
301 4 50       15 $stream->{client_end_stream} = 1 if $stream;
302 4 50       10 if ($weak_self->{on_body}) {
303 4         51 $weak_self->{on_body}->($stream_id, '', 1);
304             }
305             }
306             }
307              
308 295         1458 return 0;
309             },
310              
311             on_data_chunk_recv => sub {
312 20     20   75 my ($stream_id, $data, $flags) = @_;
313 20 50       48 return 0 unless $weak_self;
314              
315             # Reject DATA on a stream where client already sent END_STREAM
316 20         58 my $stream = $weak_self->{streams}{$stream_id};
317 20 50 33     91 if ($stream && $stream->{client_end_stream}) {
318 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
319             }
320              
321 20 50       52 if ($weak_self->{on_body}) {
322             # END_STREAM comes in frame_recv, not here
323 20         137 $weak_self->{on_body}->($stream_id, $data, 0);
324             }
325 20         149 return 0;
326             },
327              
328             on_stream_close => sub {
329 51     51   500 my ($stream_id, $error_code) = @_;
330 51 50       143 return 0 unless $weak_self;
331              
332 51 50       147 if ($weak_self->{on_close}) {
333 51         149 $weak_self->{on_close}->($stream_id, $error_code);
334             }
335              
336             # Clean up stream state
337 51         1290 delete $weak_self->{streams}{$stream_id};
338 51         291 return 0;
339             },
340             },
341             (defined $rl
342             ? (stream_reset_burst => $rl->{burst}, stream_reset_rate => $rl->{rate})
343 89 50       2144 : ()),
344             );
345              
346             # Send initial SETTINGS
347 89         5524 $self->{nghttp2}->send_connection_preface(%{$self->{settings}});
  89         440  
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   1403 my ($self, $data) = @_;
360 276         3295 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   3042 my ($self) = @_;
373 882         4518 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   483 my ($self) = @_;
386 237         1131 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   5 my ($self) = @_;
399 1         6 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   789 my ($self, $stream_id, %args) = @_;
417 58         304 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   141 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         219 );
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   277 my ($self, $stream_id) = @_;
455 107         498 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   222 my ($self, $stream_id, $data, $eof) = @_;
469 11         153 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   2616621 my ($self, $error_code) = @_;
482 75   50     226 $error_code //= 0; # NO_ERROR
483 75         794 return $self->{nghttp2}->terminate_session($error_code);
484             }
485              
486             1;
487              
488             __END__