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   5430500 use strict;
  109         186  
  109         3972  
3 109     109   3939 use warnings;
  109         1906  
  109         11302  
4              
5             our $VERSION = '0.002004';
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   2570 use constant H2_CLIENT_PREFACE => "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
  109         2014  
  109         6637  
41 109     109   424 use constant H2_PREFACE_LENGTH => 24;
  109         164  
  109         5180  
42              
43             # Check for nghttp2 availability
44             our $AVAILABLE;
45 109     109   421 use constant MIN_NGHTTP2_VERSION => '0.008';
  109         152  
  109         12127  
46             BEGIN {
47 109 50   109   279 $AVAILABLE = eval {
48 109         44152 require Net::HTTP2::nghttp2;
49 109         99893 Net::HTTP2::nghttp2->VERSION(MIN_NGHTTP2_VERSION);
50 109         46875 require Net::HTTP2::nghttp2::Session;
51 109         160411 Net::HTTP2::nghttp2->available;
52             } ? 1 : 0;
53             }
54              
55 131     131 1 166511 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 357 my ($class, $bytes) = @_;
77 48 100 66     315 return 0 unless defined $bytes && length($bytes) >= H2_PREFACE_LENGTH;
78 45         233 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 10398 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     1780 h2_rst_rate_limit => $args{h2_rst_rate_limit} // { burst => 1000, rate => 33 },
      100        
      100        
      100        
      100        
      100        
      100        
108             }, $class;
109              
110 108         446 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 864 my ($self, %callbacks) = @_;
128              
129 84 50       246 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         1130 );
146             }
147              
148             # =============================================================================
149             # HTTP/2 Session Wrapper
150             # =============================================================================
151              
152             package PAGI::Server::Protocol::HTTP2::Session;
153 109     109   41720 use strict;
  109         203  
  109         2218  
154 109     109   445 use warnings;
  109         237  
  109         4565  
155 109     109   447 use Scalar::Util qw(weaken);
  109         161  
  109         192446  
156              
157             our $VERSION = '0.002004';
158              
159             sub new {
160 84     84   407 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         678 streams => {}, # stream_id => { headers => [], pseudo => {}, ... }
170             nghttp2 => undef,
171             }, $class;
172              
173 84         248 weaken($self->{protocol});
174              
175 84         268 $self->_init_nghttp2_session;
176              
177 84         1460 return $self;
178             }
179              
180             sub _init_nghttp2_session {
181 84     84   153 my ($self) = @_;
182              
183 84         154 my $weak_self = $self;
184 84         135 weaken($weak_self);
185              
186 84         147 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   225 my ($stream_id, $type, $flags) = @_;
192 82 50       322 return 0 unless $weak_self;
193              
194             # HEADERS frame starts a new request
195 82 50 33     570 if (!defined $type || $type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
196 82         868 $weak_self->{streams}{$stream_id} = {
197             headers => [],
198             pseudo => {},
199             header_list_size => 0,
200             };
201             }
202 82         770 return 0;
203             },
204              
205             on_header => sub {
206 386     386   863 my ($stream_id, $name, $value, $flags) = @_;
207 386 50       702 return 0 unless $weak_self;
208              
209 386         674 my $stream = $weak_self->{streams}{$stream_id};
210 386 50       647 return 0 unless $stream;
211              
212             # RFC 7541: header entry size = name_len + value_len + 32
213 386         725 $stream->{header_list_size} += length($name) + length($value) + 32;
214 386 100       871 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 385 100       1007 if ($name =~ /^:/) {
221 343         932 $stream->{pseudo}{$name} = $value;
222             } else {
223 42         77 push @{$stream->{headers}}, [$name, $value];
  42         173  
224             }
225 385         1966 return 0;
226             },
227              
228             on_frame_recv => sub {
229 280     280   544 my ($frame) = @_;
230 280 50       624 return 0 unless $weak_self;
231              
232 280         485 my $stream_id = $frame->{stream_id};
233 280         508 my $type = $frame->{type};
234 280         445 my $flags = $frame->{flags};
235              
236             # HEADERS frame = request headers complete
237 280 100       841 if ($type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
238 81         211 my $stream = $weak_self->{streams}{$stream_id};
239              
240             # Reject HEADERS on a stream where client already sent END_STREAM
241 81 50 33     538 if ($stream && $stream->{client_end_stream}) {
242 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
243             }
244              
245 81 50 33     471 if ($stream && $weak_self->{on_request}) {
246 81         183 my $headers = $stream->{headers};
247 81         186 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       247 if (defined $pseudo->{':authority'}) {
252 81         204 my $authority = $pseudo->{':authority'};
253 81         151 my $found_host = 0;
254 81         225 for my $h (@$headers) {
255 42 100       139 if ($h->[0] eq 'host') {
256 1         5 $h->[1] = $authority;
257 1         3 $found_host = 1;
258 1         2 last;
259             }
260             }
261 81 100       422 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         227 my @cookie_values;
267             my @non_cookie;
268 81         209 for my $h (@$headers) {
269 122 100       296 if ($h->[0] eq 'cookie') {
270 2         4 push @cookie_values, $h->[1];
271             } else {
272 120         273 push @non_cookie, $h;
273             }
274             }
275 81 100       299 if (@cookie_values > 1) {
276 1         3 push @non_cookie, ['cookie', join('; ', @cookie_values)];
277 1         4 @$headers = @non_cookie;
278             }
279              
280 81         344 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       233 if ($end_stream) {
284 60         157 $stream->{client_end_stream} = 1;
285             }
286              
287 81         391 $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       3930 if ($type == Net::HTTP2::nghttp2::NGHTTP2_DATA()) {
298 16         52 my $end_stream = $flags & Net::HTTP2::nghttp2::NGHTTP2_FLAG_END_STREAM();
299 16 100       37 if ($end_stream) {
300 4         8 my $stream = $weak_self->{streams}{$stream_id};
301 4 50       11 $stream->{client_end_stream} = 1 if $stream;
302 4 50       10 if ($weak_self->{on_body}) {
303 4         10 $weak_self->{on_body}->($stream_id, '', 1);
304             }
305             }
306             }
307              
308 280         1296 return 0;
309             },
310              
311             on_data_chunk_recv => sub {
312 20     20   52 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         54 my $stream = $weak_self->{streams}{$stream_id};
317 20 50 33     92 if ($stream && $stream->{client_end_stream}) {
318 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
319             }
320              
321 20 50       53 if ($weak_self->{on_body}) {
322             # END_STREAM comes in frame_recv, not here
323 20         114 $weak_self->{on_body}->($stream_id, $data, 0);
324             }
325 20         129 return 0;
326             },
327              
328             on_stream_close => sub {
329 47     47   553 my ($stream_id, $error_code) = @_;
330 47 50       125 return 0 unless $weak_self;
331              
332 47 50       148 if ($weak_self->{on_close}) {
333 47         151 $weak_self->{on_close}->($stream_id, $error_code);
334             }
335              
336             # Clean up stream state
337 47         1154 delete $weak_self->{streams}{$stream_id};
338 47         237 return 0;
339             },
340             },
341             (defined $rl
342             ? (stream_reset_burst => $rl->{burst}, stream_reset_rate => $rl->{rate})
343 84 50       1968 : ()),
344             );
345              
346             # Send initial SETTINGS
347 84         5151 $self->{nghttp2}->send_connection_preface(%{$self->{settings}});
  84         432  
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   1474 my ($self, $data) = @_;
360 261         2828 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   3074 my ($self) = @_;
373 838         4377 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   404 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   5 my ($self) = @_;
399 1         5 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   768 my ($self, $stream_id, %args) = @_;
417 55         287 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   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 22         193 );
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   282 my ($self, $stream_id) = @_;
455 105         412 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   270 my ($self, $stream_id, $data, $eof) = @_;
469 11         150 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   2618814 my ($self, $error_code) = @_;
482 70   50     216 $error_code //= 0; # NO_ERROR
483 70         695 return $self->{nghttp2}->terminate_session($error_code);
484             }
485              
486             1;
487              
488             __END__