File Coverage

blib/lib/PAGI/Server/Protocol/HTTP2.pm
Criterion Covered Total %
statement 140 142 98.5
branch 39 56 69.6
condition 19 29 65.5
subroutine 29 29 100.0
pod 3 4 75.0
total 230 260 88.4


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