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 88     88   316090 use strict;
  88         141  
  88         3177  
3 88     88   329 use warnings;
  88         1770  
  88         6090  
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 88     88   354 use constant H2_CLIENT_PREFACE => "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
  88         123  
  88         5229  
37 88     88   322 use constant H2_PREFACE_LENGTH => 24;
  88         109  
  88         4349  
38              
39             # Check for nghttp2 availability
40             our $AVAILABLE;
41 88     88   327 use constant MIN_NGHTTP2_VERSION => '0.007';
  88         1894  
  88         9494  
42             BEGIN {
43 88 50   88   232 $AVAILABLE = eval {
44 88         29158 require Net::HTTP2::nghttp2;
45 88         79820 Net::HTTP2::nghttp2->VERSION(MIN_NGHTTP2_VERSION);
46 88         40180 require Net::HTTP2::nghttp2::Session;
47 88         114072 Net::HTTP2::nghttp2->available;
48             } ? 1 : 0;
49             }
50              
51 88     88 0 13581 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 314 my ($class, $bytes) = @_;
64 42 100 66     204 return 0 unless defined $bytes && length($bytes) >= H2_PREFACE_LENGTH;
65 39         183 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 4516 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     1413 max_header_list_size => $args{max_header_list_size} // 65536,
      100        
      100        
      100        
      100        
      100        
93             }, $class;
94              
95 103         413 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 860 my ($self, %callbacks) = @_;
113              
114 77 50       218 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         1221 );
130             }
131              
132             # =============================================================================
133             # HTTP/2 Session Wrapper
134             # =============================================================================
135              
136             package PAGI::Server::Protocol::HTTP2::Session;
137 88     88   33677 use strict;
  88         173  
  88         1722  
138 88     88   329 use warnings;
  88         147  
  88         3616  
139 88     88   336 use Scalar::Util qw(weaken);
  88         141  
  88         150419  
140              
141             sub new {
142 77     77   358 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         645 streams => {}, # stream_id => { headers => [], pseudo => {}, ... }
151             nghttp2 => undef,
152             }, $class;
153              
154 77         221 weaken($self->{protocol});
155              
156 77         274 $self->_init_nghttp2_session;
157              
158 77         1252 return $self;
159             }
160              
161             sub _init_nghttp2_session {
162 77     77   144 my ($self) = @_;
163              
164 77         116 my $weak_self = $self;
165 77         125 weaken($weak_self);
166              
167             $self->{nghttp2} = Net::HTTP2::nghttp2::Session->new_server(
168             callbacks => {
169             on_begin_headers => sub {
170 76     76   369 my ($stream_id, $type, $flags) = @_;
171 76 50       290 return 0 unless $weak_self;
172              
173             # HEADERS frame starts a new request
174 76 50 33     531 if (!defined $type || $type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
175 76         711 $weak_self->{streams}{$stream_id} = {
176             headers => [],
177             pseudo => {},
178             header_list_size => 0,
179             };
180             }
181 76         654 return 0;
182             },
183              
184             on_header => sub {
185 353     353   741 my ($stream_id, $name, $value, $flags) = @_;
186 353 50       652 return 0 unless $weak_self;
187              
188 353         623 my $stream = $weak_self->{streams}{$stream_id};
189 353 50       645 return 0 unless $stream;
190              
191             # RFC 7541: header entry size = name_len + value_len + 32
192 353         617 $stream->{header_list_size} += length($name) + length($value) + 32;
193 353 100       819 if ($stream->{header_list_size} > $weak_self->{settings}{max_header_list_size}) {
194 1         3 delete $weak_self->{streams}{$stream_id};
195 1         6 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
196             }
197              
198             # Pseudo-headers start with ':'
199 352 100       1014 if ($name =~ /^:/) {
200 316         743 $stream->{pseudo}{$name} = $value;
201             } else {
202 36         67 push @{$stream->{headers}}, [$name, $value];
  36         145  
203             }
204 352         1888 return 0;
205             },
206              
207             on_frame_recv => sub {
208 254     254   494 my ($frame) = @_;
209 254 50       563 return 0 unless $weak_self;
210              
211 254         482 my $stream_id = $frame->{stream_id};
212 254         467 my $type = $frame->{type};
213 254         414 my $flags = $frame->{flags};
214              
215             # HEADERS frame = request headers complete
216 254 100       785 if ($type == Net::HTTP2::nghttp2::NGHTTP2_HEADERS()) {
217 75         224 my $stream = $weak_self->{streams}{$stream_id};
218              
219             # Reject HEADERS on a stream where client already sent END_STREAM
220 75 50 33     446 if ($stream && $stream->{client_end_stream}) {
221 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
222             }
223              
224 75 50 33     373 if ($stream && $weak_self->{on_request}) {
225 75         143 my $headers = $stream->{headers};
226 75         149 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       194 if (defined $pseudo->{':authority'}) {
231 75         198 my $authority = $pseudo->{':authority'};
232 75         121 my $found_host = 0;
233 75         194 for my $h (@$headers) {
234 36 100       181 if ($h->[0] eq 'host') {
235 1         3 $h->[1] = $authority;
236 1         2 $found_host = 1;
237 1         3 last;
238             }
239             }
240 75 100       344 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         165 my @cookie_values;
246             my @non_cookie;
247 75         147 for my $h (@$headers) {
248 110 100       295 if ($h->[0] eq 'cookie') {
249 2         5 push @cookie_values, $h->[1];
250             } else {
251 108         240 push @non_cookie, $h;
252             }
253             }
254 75 100       232 if (@cookie_values > 1) {
255 1         5 push @non_cookie, ['cookie', join('; ', @cookie_values)];
256 1         4 @$headers = @non_cookie;
257             }
258              
259 75         237 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       191 if ($end_stream) {
263 54         156 $stream->{client_end_stream} = 1;
264             }
265              
266 75         303 $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       3472 if ($type == Net::HTTP2::nghttp2::NGHTTP2_DATA()) {
277 19         58 my $end_stream = $flags & Net::HTTP2::nghttp2::NGHTTP2_FLAG_END_STREAM();
278 19 100       47 if ($end_stream) {
279 7         16 my $stream = $weak_self->{streams}{$stream_id};
280 7 50       29 $stream->{client_end_stream} = 1 if $stream;
281 7 50       19 if ($weak_self->{on_body}) {
282 7         19 $weak_self->{on_body}->($stream_id, '', 1);
283             }
284             }
285             }
286              
287 254         1235 return 0;
288             },
289              
290             on_data_chunk_recv => sub {
291 23     23   77 my ($stream_id, $data, $flags) = @_;
292 23 50       59 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     128 if ($stream && $stream->{client_end_stream}) {
297 0         0 return Net::HTTP2::nghttp2::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE();
298             }
299              
300 23 50       67 if ($weak_self->{on_body}) {
301             # END_STREAM comes in frame_recv, not here
302 23         93 $weak_self->{on_body}->($stream_id, $data, 0);
303             }
304 23         168 return 0;
305             },
306              
307             on_stream_close => sub {
308 45     45   658 my ($stream_id, $error_code) = @_;
309 45 50       118 return 0 unless $weak_self;
310              
311 45 50       165 if ($weak_self->{on_close}) {
312 45         148 $weak_self->{on_close}->($stream_id, $error_code);
313             }
314              
315             # Clean up stream state
316 45         1227 delete $weak_self->{streams}{$stream_id};
317 45         237 return 0;
318             },
319             },
320 77         1916 );
321              
322             # Send initial SETTINGS
323 77         3997 $self->{nghttp2}->send_connection_preface(%{$self->{settings}});
  77         398  
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   1281 my ($self, $data) = @_;
336 229         2784 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   2267 my ($self) = @_;
349 750         4138 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   445 my ($self) = @_;
362 206         941 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   24 my ($self) = @_;
375 1         8 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   718 my ($self, $stream_id, %args) = @_;
393 51         390 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   88 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         162 );
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   239 my ($self, $stream_id) = @_;
431 96         317 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   292 my ($self, $stream_id, $data, $eof) = @_;
445 11         165 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   2613966 my ($self, $error_code) = @_;
458 65   50     172 $error_code //= 0; # NO_ERROR
459 65         617 return $self->{nghttp2}->terminate_session($error_code);
460             }
461              
462             1;
463              
464             __END__