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   609715 use strict;
  109         169  
  109         5703  
3 109     109   414 use warnings;
  109         171  
  109         11745  
4              
5             our $VERSION = '0.002003';
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   5912 use constant H2_CLIENT_PREFACE => "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
  109         148  
  109         7198  
41 109     109   448 use constant H2_PREFACE_LENGTH => 24;
  109         150  
  109         5396  
42              
43             # Check for nghttp2 availability
44             our $AVAILABLE;
45 109     109   406 use constant MIN_NGHTTP2_VERSION => '0.008';
  109         151  
  109         11987  
46             BEGIN {
47 109 50   109   286 $AVAILABLE = eval {
48 109         35369 require Net::HTTP2::nghttp2;
49 109         81493 Net::HTTP2::nghttp2->VERSION(MIN_NGHTTP2_VERSION);
50 109         46721 require Net::HTTP2::nghttp2::Session;
51 109         151439 Net::HTTP2::nghttp2->available;
52             } ? 1 : 0;
53             }
54              
55 109     109 1 16153 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 356 my ($class, $bytes) = @_;
77 48 100 66     236 return 0 unless defined $bytes && length($bytes) >= H2_PREFACE_LENGTH;
78 45         224 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 9761 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     1848 h2_rst_rate_limit => $args{h2_rst_rate_limit} // { burst => 1000, rate => 33 },
      100        
      100        
      100        
      100        
      100        
      100        
108             }, $class;
109              
110 108         358 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 842 my ($self, %callbacks) = @_;
128              
129 84 50       242 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         1266 );
146             }
147              
148             # =============================================================================
149             # HTTP/2 Session Wrapper
150             # =============================================================================
151              
152             package PAGI::Server::Protocol::HTTP2::Session;
153 109     109   42420 use strict;
  109         212  
  109         2151  
154 109     109   364 use warnings;
  109         183  
  109         4676  
155 109     109   459 use Scalar::Util qw(weaken);
  109         297  
  109         192681  
156              
157             our $VERSION = '0.002003';
158              
159             sub new {
160 84     84   468 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         762 streams => {}, # stream_id => { headers => [], pseudo => {}, ... }
170             nghttp2 => undef,
171             }, $class;
172              
173 84         252 weaken($self->{protocol});
174              
175 84         313 $self->_init_nghttp2_session;
176              
177 84         1479 return $self;
178             }
179              
180             sub _init_nghttp2_session {
181 84     84   167 my ($self) = @_;
182              
183 84         145 my $weak_self = $self;
184 84         162 weaken($weak_self);
185              
186 84         174 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   264 my ($stream_id, $type, $flags) = @_;
192 82 50       257 return 0 unless $weak_self;
193              
194             # HEADERS frame starts a new request
195 82 50 33     595 if (!defined $type || $type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
196 82         764 $weak_self->{streams}{$stream_id} = {
197             headers => [],
198             pseudo => {},
199             header_list_size => 0,
200             };
201             }
202 82         747 return 0;
203             },
204              
205             on_header => sub {
206 386     386   924 my ($stream_id, $name, $value, $flags) = @_;
207 386 50       747 return 0 unless $weak_self;
208              
209 386         780 my $stream = $weak_self->{streams}{$stream_id};
210 386 50       761 return 0 unless $stream;
211              
212             # RFC 7541: header entry size = name_len + value_len + 32
213 386         682 $stream->{header_list_size} += length($name) + length($value) + 32;
214 386 100       990 if ($stream->{header_list_size} > $weak_self->{settings}{max_header_list_size}) {
215 1         3 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       1077 if ($name =~ /^:/) {
221 343         1005 $stream->{pseudo}{$name} = $value;
222             } else {
223 42         75 push @{$stream->{headers}}, [$name, $value];
  42         164  
224             }
225 385         2029 return 0;
226             },
227              
228             on_frame_recv => sub {
229 280     280   574 my ($frame) = @_;
230 280 50       621 return 0 unless $weak_self;
231              
232 280         588 my $stream_id = $frame->{stream_id};
233 280         518 my $type = $frame->{type};
234 280         486 my $flags = $frame->{flags};
235              
236             # HEADERS frame = request headers complete
237 280 100       897 if ($type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
238 81         271 my $stream = $weak_self->{streams}{$stream_id};
239              
240             # Reject HEADERS on a stream where client already sent END_STREAM
241 81 50 33     523 if ($stream && $stream->{client_end_stream}) {
242 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
243             }
244              
245 81 50 33     441 if ($stream && $weak_self->{on_request}) {
246 81         152 my $headers = $stream->{headers};
247 81         194 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       272 if (defined $pseudo->{':authority'}) {
252 81         184 my $authority = $pseudo->{':authority'};
253 81         147 my $found_host = 0;
254 81         204 for my $h (@$headers) {
255 42 100       160 if ($h->[0] eq 'host') {
256 1         2 $h->[1] = $authority;
257 1         2 $found_host = 1;
258 1         1 last;
259             }
260             }
261 81 100       423 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         168 my @cookie_values;
267             my @non_cookie;
268 81         170 for my $h (@$headers) {
269 122 100       420 if ($h->[0] eq 'cookie') {
270 2         3 push @cookie_values, $h->[1];
271             } else {
272 120         276 push @non_cookie, $h;
273             }
274             }
275 81 100       229 if (@cookie_values > 1) {
276 1         3 push @non_cookie, ['cookie', join('; ', @cookie_values)];
277 1         3 @$headers = @non_cookie;
278             }
279              
280 81         407 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       225 if ($end_stream) {
284 60         151 $stream->{client_end_stream} = 1;
285             }
286              
287 81         381 $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       3858 if ($type == Net::HTTP2::nghttp2::NGHTTP2_DATA()) {
298 16         72 my $end_stream = $flags & Net::HTTP2::nghttp2::NGHTTP2_FLAG_END_STREAM();
299 16 100       53 if ($end_stream) {
300 4         12 my $stream = $weak_self->{streams}{$stream_id};
301 4 50       17 $stream->{client_end_stream} = 1 if $stream;
302 4 50       31 if ($weak_self->{on_body}) {
303 4         15 $weak_self->{on_body}->($stream_id, '', 1);
304             }
305             }
306             }
307              
308 280         1380 return 0;
309             },
310              
311             on_data_chunk_recv => sub {
312 20     20   67 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         76 my $stream = $weak_self->{streams}{$stream_id};
317 20 50 33     157 if ($stream && $stream->{client_end_stream}) {
318 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
319             }
320              
321 20 50       85 if ($weak_self->{on_body}) {
322             # END_STREAM comes in frame_recv, not here
323 20         71 $weak_self->{on_body}->($stream_id, $data, 0);
324             }
325 20         192 return 0;
326             },
327              
328             on_stream_close => sub {
329 47     47   513 my ($stream_id, $error_code) = @_;
330 47 50       116 return 0 unless $weak_self;
331              
332 47 50       151 if ($weak_self->{on_close}) {
333 47         174 $weak_self->{on_close}->($stream_id, $error_code);
334             }
335              
336             # Clean up stream state
337 47         1181 delete $weak_self->{streams}{$stream_id};
338 47         255 return 0;
339             },
340             },
341             (defined $rl
342             ? (stream_reset_burst => $rl->{burst}, stream_reset_rate => $rl->{rate})
343 84 50       3979 : ()),
344             );
345              
346             # Send initial SETTINGS
347 84         5456 $self->{nghttp2}->send_connection_preface(%{$self->{settings}});
  84         500  
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   1378 my ($self, $data) = @_;
360 261         3050 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   3051 my ($self) = @_;
373 838         4231 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   453 my ($self) = @_;
386 222         1000 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 55     55   876 my ($self, $stream_id, %args) = @_;
417 55         381 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   139 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         256 );
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   274 my ($self, $stream_id) = @_;
455 105         417 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   335 my ($self, $stream_id, $data, $eof) = @_;
469 11         231 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   2615518 my ($self, $error_code) = @_;
482 70   50     198 $error_code //= 0; # NO_ERROR
483 70         783 return $self->{nghttp2}->terminate_session($error_code);
484             }
485              
486             1;
487              
488             __END__