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 109     109   603776 use strict;
  109         186  
  109         5948  
3 109     109   420 use warnings;
  109         158  
  109         15666  
4              
5             our $VERSION = '0.002002';
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 109     109   2304 use constant H2_CLIENT_PREFACE => "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
  109         144  
  109         7064  
41 109     109   409 use constant H2_PREFACE_LENGTH => 24;
  109         150  
  109         5182  
42              
43             # Check for nghttp2 availability
44             our $AVAILABLE;
45 109     109   406 use constant MIN_NGHTTP2_VERSION => '0.008';
  109         138  
  109         11364  
46             BEGIN {
47 109 50   109   267 $AVAILABLE = eval {
48 109         35133 require Net::HTTP2::nghttp2;
49 109         81073 Net::HTTP2::nghttp2->VERSION(MIN_NGHTTP2_VERSION);
50 109         47148 require Net::HTTP2::nghttp2::Session;
51 109         149645 Net::HTTP2::nghttp2->available;
52             } ? 1 : 0;
53             }
54              
55 109     109 1 16511 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 48     48 1 348 my ($class, $bytes) = @_;
77 48 100 66     235 return 0 unless defined $bytes && length($bytes) >= H2_PREFACE_LENGTH;
78 45         237 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 108     108 1 9980 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 108   100     1874 h2_rst_rate_limit => $args{h2_rst_rate_limit} // { burst => 1000, rate => 33 },
      100        
      100        
      100        
      100        
      100        
      100        
108             }, $class;
109              
110 108         343 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 84     84 1 849 my ($self, %callbacks) = @_;
128              
129 84 50       237 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 84         1853 );
146             }
147              
148             # =============================================================================
149             # HTTP/2 Session Wrapper
150             # =============================================================================
151              
152             package PAGI::Server::Protocol::HTTP2::Session;
153 109     109   41408 use strict;
  109         182  
  109         2303  
154 109     109   379 use warnings;
  109         197  
  109         4626  
155 109     109   457 use Scalar::Util qw(weaken);
  109         181  
  109         187960  
156              
157             our $VERSION = '0.002002';
158              
159             sub new {
160 84     84   426 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 84         770 streams => {}, # stream_id => { headers => [], pseudo => {}, ... }
170             nghttp2 => undef,
171             }, $class;
172              
173 84         301 weaken($self->{protocol});
174              
175 84         360 $self->_init_nghttp2_session;
176              
177 84         1319 return $self;
178             }
179              
180             sub _init_nghttp2_session {
181 84     84   160 my ($self) = @_;
182              
183 84         141 my $weak_self = $self;
184 84         130 weaken($weak_self);
185              
186 84         179 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 82     82   233 my ($stream_id, $type, $flags) = @_;
192 82 50       293 return 0 unless $weak_self;
193              
194             # HEADERS frame starts a new request
195 82 50 33     617 if (!defined $type || $type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
196 82         758 $weak_self->{streams}{$stream_id} = {
197             headers => [],
198             pseudo => {},
199             header_list_size => 0,
200             };
201             }
202 82         792 return 0;
203             },
204              
205             on_header => sub {
206 386     386   873 my ($stream_id, $name, $value, $flags) = @_;
207 386 50       724 return 0 unless $weak_self;
208              
209 386         712 my $stream = $weak_self->{streams}{$stream_id};
210 386 50       644 return 0 unless $stream;
211              
212             # RFC 7541: header entry size = name_len + value_len + 32
213 386         730 $stream->{header_list_size} += length($name) + length($value) + 32;
214 386 100       829 if ($stream->{header_list_size} > $weak_self->{settings}{max_header_list_size}) {
215 1         2 delete $weak_self->{streams}{$stream_id};
216 1         7 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
217             }
218              
219             # Pseudo-headers start with ':'
220 385 100       999 if ($name =~ /^:/) {
221 343         862 $stream->{pseudo}{$name} = $value;
222             } else {
223 42         71 push @{$stream->{headers}}, [$name, $value];
  42         167  
224             }
225 385         1927 return 0;
226             },
227              
228             on_frame_recv => sub {
229 280     280   527 my ($frame) = @_;
230 280 50       567 return 0 unless $weak_self;
231              
232 280         570 my $stream_id = $frame->{stream_id};
233 280         447 my $type = $frame->{type};
234 280         493 my $flags = $frame->{flags};
235              
236             # HEADERS frame = request headers complete
237 280 100       948 if ($type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
238 81         249 my $stream = $weak_self->{streams}{$stream_id};
239              
240             # Reject HEADERS on a stream where client already sent END_STREAM
241 81 50 33     496 if ($stream && $stream->{client_end_stream}) {
242 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
243             }
244              
245 81 50 33     404 if ($stream && $weak_self->{on_request}) {
246 81         166 my $headers = $stream->{headers};
247 81         199 my $pseudo = $stream->{pseudo};
248              
249             # Convert :authority pseudo-header to host header
250             # (RFC 9113 Section 8.3.1: :authority takes precedence)
251 81 50       241 if (defined $pseudo->{':authority'}) {
252 81         182 my $authority = $pseudo->{':authority'};
253 81         139 my $found_host = 0;
254 81         199 for my $h (@$headers) {
255 42 100       158 if ($h->[0] eq 'host') {
256 1         2 $h->[1] = $authority;
257 1         2 $found_host = 1;
258 1         2 last;
259             }
260             }
261 81 100       377 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 81         165 my @cookie_values;
267             my @non_cookie;
268 81         186 for my $h (@$headers) {
269 122 100       324 if ($h->[0] eq 'cookie') {
270 2         4 push @cookie_values, $h->[1];
271             } else {
272 120         255 push @non_cookie, $h;
273             }
274             }
275 81 100       300 if (@cookie_values > 1) {
276 1         2 push @non_cookie, ['cookie', join('; ', @cookie_values)];
277 1         4 @$headers = @non_cookie;
278             }
279              
280 81         250 my $end_stream = $flags & Net::HTTP2::nghttp2::NGHTTP2_FLAG_END_STREAM();
281              
282             # Track that client has finished sending on this stream
283 81 100       201 if ($end_stream) {
284 60         157 $stream->{client_end_stream} = 1;
285             }
286              
287 81         348 $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 280 100       3707 if ($type == Net::HTTP2::nghttp2::NGHTTP2_DATA()) {
298 16         46 my $end_stream = $flags & Net::HTTP2::nghttp2::NGHTTP2_FLAG_END_STREAM();
299 16 100       40 if ($end_stream) {
300 4         7 my $stream = $weak_self->{streams}{$stream_id};
301 4 50       12 $stream->{client_end_stream} = 1 if $stream;
302 4 50       8 if ($weak_self->{on_body}) {
303 4         13 $weak_self->{on_body}->($stream_id, '', 1);
304             }
305             }
306             }
307              
308 280         1342 return 0;
309             },
310              
311             on_data_chunk_recv => sub {
312 20     20   56 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         57 my $stream = $weak_self->{streams}{$stream_id};
317 20 50 33     93 if ($stream && $stream->{client_end_stream}) {
318 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
319             }
320              
321 20 50       101 if ($weak_self->{on_body}) {
322             # END_STREAM comes in frame_recv, not here
323 20         70 $weak_self->{on_body}->($stream_id, $data, 0);
324             }
325 20         135 return 0;
326             },
327              
328             on_stream_close => sub {
329 47     47   501 my ($stream_id, $error_code) = @_;
330 47 50       107 return 0 unless $weak_self;
331              
332 47 50       184 if ($weak_self->{on_close}) {
333 47         155 $weak_self->{on_close}->($stream_id, $error_code);
334             }
335              
336             # Clean up stream state
337 47         1265 delete $weak_self->{streams}{$stream_id};
338 47         251 return 0;
339             },
340             },
341             (defined $rl
342             ? (stream_reset_burst => $rl->{burst}, stream_reset_rate => $rl->{rate})
343 84 50       2383 : ()),
344             );
345              
346             # Send initial SETTINGS
347 84         5189 $self->{nghttp2}->send_connection_preface(%{$self->{settings}});
  84         481  
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 261     261   1380 my ($self, $data) = @_;
360 261         2943 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 838     838   2996 my ($self) = @_;
373 838         4182 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 222     222   409 my ($self) = @_;
386 222         982 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   4 my ($self) = @_;
399 1         7 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 55     55   747 my ($self, $stream_id, %args) = @_;
417 55         336 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 22     22   146 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 22         263 );
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 105     105   368 my ($self, $stream_id) = @_;
455 105         454 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   228 my ($self, $stream_id, $data, $eof) = @_;
469 11         148 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 70     70   2615560 my ($self, $error_code) = @_;
482 70   50     207 $error_code //= 0; # NO_ERROR
483 70         906 return $self->{nghttp2}->terminate_session($error_code);
484             }
485              
486             1;
487              
488             __END__