target: tls://104.18.26.120:443 (SNI/peer_name=example.com)
alpn_protocols(configured): h2,http/1.1
ALPN (negotiated): h2
>>> sending client preface (24 bytes)
>>> sending SETTINGS (empty payload)
FRAME #0 len=18 type=0x04(SETTINGS) flags=0x00 sid=0
0000 00 03 00 00 00 64 00 04 00 01 00 00 00 05 00 ff .....d..........
0010 ff ff ..
>>> sending SETTINGS ACK
>>> sending HEADERS for GET /
FRAME #1 len=4 type=0x08(WINDOW_UPDATE) flags=0x00 sid=0
0000 7f ff 00 00 ....
FRAME #2 len=0 type=0x04(SETTINGS) flags=0x01 sid=0
FRAME #3 len=128 type=0x01(HEADERS) flags=0x04 sid=1
0000 88 61 96 df 69 7e 94 03 2a 68 1d 8a 08 02 71 41 .a..i~..*h....qA
0010 33 70 0e dc 69 f5 31 68 df 5f 87 49 7c a5 89 d3 3p..i.1h._.I|...
0020 4d 1f 40 85 24 ab 58 3f 5f 8f 7e 47 08 d1 23 68 M.@.$.X?_.~G..#h
0030 b1 90 6e 04 52 2d a7 6e ff 6c 96 e4 59 3e 94 13 ..n.R-.n.l..Y>..
0040 6a 61 2c 6a 08 02 71 40 3b 70 40 b8 db 4a 62 d1 ja,j..q@;p@..Jb.
0050 bf 56 88 c5 83 7f d2 98 f0 43 7f 52 84 8f d2 4a .V.......C.R...J
0060 8f 55 84 0b 42 74 5f 40 8a 24 ab 10 64 9c ab 21 .U..Bt_@.$..d..!
0070 23 4d a8 03 48 49 54 76 87 25 07 b6 49 68 1d 85 #M..HITv.%..Ih..
FRAME #4 len=528 type=0x00(DATA) flags=0x00 sid=1
DATA payload:
<!doctype html><html lang="en"><head><title>Example Domain</title><meta name="viewport" content="width=device-width, initial-scale=1"><style>body{background:#eee;width:60vw;margin:15vh auto;font-family:system-ui,sans-serif}h1{font-size:1.5em}div{opacity:0.8}a:link,a:visited{color:#348}</style></head><body><div><h1>Example Domain</h1><p>This domain is for use in documentation examples without needing permission. Avoid use in operations.</p><p><a href="https://iana.org/domains/example">Learn more</a></p></div></body></html>
FRAME #5 len=0 type=0x00(DATA) flags=0x01 sid=1
END_STREAM received on response stream; exiting.
done
Last active
March 3, 2026 23:08
-
-
Save masakielastic/35d5ca2d975c2068b7c2ae4c0b53b23b to your computer and use it in GitHub Desktop.
PHP のストリーム関数で簡易 HTTP/2 クライアント・オブジェクト指向
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| error_reporting(E_ALL); | |
| ini_set('display_errors', '1'); | |
| final class Logger | |
| { | |
| public function log(string $message): void | |
| { | |
| fwrite(STDOUT, $message . PHP_EOL); | |
| fflush(STDOUT); | |
| } | |
| public function dumpData(string $payload): void | |
| { | |
| fwrite(STDOUT, $payload); | |
| if (!str_ends_with($payload, "\n")) { | |
| fwrite(STDOUT, PHP_EOL); | |
| } | |
| fflush(STDOUT); | |
| } | |
| } | |
| final class HexDumper | |
| { | |
| public static function dump(string $binary, int $max = 256): string | |
| { | |
| $length = strlen($binary); | |
| $limit = min($length, $max); | |
| $output = ''; | |
| for ($offset = 0; $offset < $limit; $offset += 16) { | |
| $chunk = substr($binary, $offset, 16); | |
| $hex = implode(' ', str_split(bin2hex($chunk), 2)); | |
| $ascii = preg_replace('/[^\x20-\x7e]/', '.', $chunk); | |
| $output .= sprintf("%04x %-47s %s\n", $offset, $hex, $ascii); | |
| } | |
| if ($length > $max) { | |
| $output .= sprintf("... (%d bytes total)\n", $length); | |
| } | |
| return $output; | |
| } | |
| } | |
| final class Http2Frame | |
| { | |
| public function __construct( | |
| public readonly int $length, | |
| public readonly int $type, | |
| public readonly int $flags, | |
| public readonly int $streamId, | |
| public readonly string $payload, | |
| ) { | |
| } | |
| public static function pack(int $type, int $flags, int $streamId, string $payload): string | |
| { | |
| $length = strlen($payload); | |
| $header = chr(($length >> 16) & 0xff) . chr(($length >> 8) & 0xff) . chr($length & 0xff); | |
| $header .= chr($type & 0xff) . chr($flags & 0xff); | |
| $header .= pack('N', $streamId & 0x7FFFFFFF); | |
| return $header . $payload; | |
| } | |
| public static function typeName(int $type): string | |
| { | |
| return match ($type) { | |
| 0x00 => 'DATA', | |
| 0x01 => 'HEADERS', | |
| 0x02 => 'PRIORITY', | |
| 0x03 => 'RST_STREAM', | |
| 0x04 => 'SETTINGS', | |
| 0x05 => 'PUSH_PROMISE', | |
| 0x06 => 'PING', | |
| 0x07 => 'GOAWAY', | |
| 0x08 => 'WINDOW_UPDATE', | |
| 0x09 => 'CONTINUATION', | |
| default => 'UNKNOWN', | |
| }; | |
| } | |
| } | |
| final class Http2StreamReader | |
| { | |
| public function __construct(private readonly mixed $stream) | |
| { | |
| } | |
| public function readFrame(): ?Http2Frame | |
| { | |
| $header = $this->readExact(9); | |
| if ($header === null || strlen($header) < 9) { | |
| return null; | |
| } | |
| $length = (ord($header[0]) << 16) | (ord($header[1]) << 8) | ord($header[2]); | |
| $type = ord($header[3]); | |
| $flags = ord($header[4]); | |
| $streamId = (unpack('N', substr($header, 5, 4))[1]) & 0x7FFFFFFF; | |
| $payload = $length > 0 ? $this->readExact($length) : ''; | |
| if ($length > 0 && ($payload === null || strlen($payload) < $length)) { | |
| return null; | |
| } | |
| return new Http2Frame($length, $type, $flags, $streamId, $payload); | |
| } | |
| private function readExact(int $length): ?string | |
| { | |
| $buffer = ''; | |
| while (strlen($buffer) < $length) { | |
| $read = fread($this->stream, $length - strlen($buffer)); | |
| if ($read === false) { | |
| return null; | |
| } | |
| if ($read === '') { | |
| $meta = stream_get_meta_data($this->stream); | |
| if (!empty($meta['timed_out']) || feof($this->stream)) { | |
| return null; | |
| } | |
| usleep(10_000); | |
| continue; | |
| } | |
| $buffer .= $read; | |
| } | |
| return $buffer; | |
| } | |
| } | |
| final class HpackEncoder | |
| { | |
| public function buildRequestHeaderBlock(string $host, string $path): string | |
| { | |
| $headers = [ | |
| chr(0x82), // :method: GET | |
| chr(0x87), // :scheme: https | |
| ]; | |
| if ($path === '/') { | |
| $headers[] = chr(0x84); // :path: / | |
| } else { | |
| $headers[] = $this->encodeLiteralHeaderWithoutIndexing(4, $path); | |
| } | |
| $headers[] = $this->encodeLiteralHeaderWithoutIndexing(1, $host); | |
| return implode('', $headers); | |
| } | |
| private function encodeLiteralHeaderWithoutIndexing(int $nameIndex, string $value): string | |
| { | |
| return $this->encodeInt($nameIndex, 4) . $this->encodeString($value); | |
| } | |
| private function encodeString(string $value): string | |
| { | |
| return $this->encodeInt(strlen($value), 7) . $value; | |
| } | |
| private function encodeInt(int $value, int $prefixBits, int $prefixMask = 0x00): string | |
| { | |
| $maxPrefixValue = (1 << $prefixBits) - 1; | |
| if ($value < $maxPrefixValue) { | |
| return chr($prefixMask | $value); | |
| } | |
| $output = chr($prefixMask | $maxPrefixValue); | |
| $value -= $maxPrefixValue; | |
| while ($value >= 128) { | |
| $output .= chr(($value % 128) + 128); | |
| $value = intdiv($value, 128); | |
| } | |
| $output .= chr($value); | |
| return $output; | |
| } | |
| } | |
| final class TlsConnection | |
| { | |
| public function __construct( | |
| public readonly mixed $stream, | |
| public readonly array $meta, | |
| public readonly ?string $negotiatedProtocol, | |
| ) { | |
| } | |
| } | |
| final class TlsConnector | |
| { | |
| public function __construct(private readonly Logger $logger) | |
| { | |
| } | |
| public function connectWithAlpn(string $host, int $port, string $alpn, int $timeoutSec): TlsConnection | |
| { | |
| $ipv4 = gethostbyname($host); | |
| $targetHost = ($ipv4 !== $host) ? $ipv4 : $host; | |
| $target = "tls://{$targetHost}:{$port}"; | |
| $context = stream_context_create([ | |
| 'ssl' => [ | |
| 'verify_peer' => true, | |
| 'verify_peer_name' => true, | |
| 'SNI_enabled' => true, | |
| 'peer_name' => $host, | |
| 'alpn_protocols' => $alpn, | |
| ], | |
| ]); | |
| $this->logger->log("target: {$target} (SNI/peer_name={$host})"); | |
| $this->logger->log("alpn_protocols(configured): {$alpn}"); | |
| $errno = 0; | |
| $errstr = ''; | |
| $stream = @stream_socket_client($target, $errno, $errstr, $timeoutSec, STREAM_CLIENT_CONNECT, $context); | |
| if ($stream === false) { | |
| throw new RuntimeException("connect failed: ({$errno}) {$errstr}"); | |
| } | |
| stream_set_timeout($stream, $timeoutSec); | |
| stream_set_read_buffer($stream, 0); | |
| stream_set_write_buffer($stream, 0); | |
| $meta = stream_get_meta_data($stream); | |
| $negotiatedProtocol = null; | |
| if (isset($meta['crypto']) && is_array($meta['crypto'])) { | |
| foreach (['alpn_protocol', 'alpn_selected', 'ssl_alpn_protocol', 'negotiated_protocol', 'protocol'] as $key) { | |
| if (isset($meta['crypto'][$key]) && is_string($meta['crypto'][$key]) && $meta['crypto'][$key] !== '') { | |
| $negotiatedProtocol = $meta['crypto'][$key]; | |
| break; | |
| } | |
| } | |
| } | |
| $this->logger->log('ALPN (negotiated): ' . ($negotiatedProtocol ?? '(not found; check meta dump)')); | |
| return new TlsConnection($stream, $meta, $negotiatedProtocol); | |
| } | |
| } | |
| final class Http2Client | |
| { | |
| private const CLIENT_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; | |
| private const FRAME_TYPE_DATA = 0x00; | |
| private const FRAME_TYPE_HEADERS = 0x01; | |
| private const FRAME_TYPE_SETTINGS = 0x04; | |
| private const FRAME_TYPE_GOAWAY = 0x07; | |
| private const FLAG_ACK = 0x01; | |
| private const FLAG_END_STREAM = 0x01; | |
| private const FLAG_END_HEADERS = 0x04; | |
| private const REQUEST_STREAM_ID = 1; | |
| public function __construct( | |
| private readonly Logger $logger, | |
| private readonly TlsConnector $tlsConnector, | |
| private readonly HpackEncoder $hpackEncoder, | |
| ) { | |
| } | |
| public function run(string $host, int $port, string $path, string $alpn = 'h2,http/1.1', int $timeoutSec = 2): int | |
| { | |
| try { | |
| $connection = $this->tlsConnector->connectWithAlpn($host, $port, $alpn, $timeoutSec); | |
| } catch (Throwable $e) { | |
| $this->logger->log('[!] ' . $e->getMessage()); | |
| return 1; | |
| } | |
| if ($connection->negotiatedProtocol !== 'h2') { | |
| $this->logger->log('[!] negotiated ALPN is not h2. Exiting (this demo expects HTTP/2).'); | |
| fclose($connection->stream); | |
| return 2; | |
| } | |
| $reader = new Http2StreamReader($connection->stream); | |
| try { | |
| $this->sendClientPreface($connection->stream); | |
| $this->sendClientSettings($connection->stream); | |
| return $this->handleFrames($connection->stream, $reader, $host, $path); | |
| } finally { | |
| fclose($connection->stream); | |
| } | |
| } | |
| private function sendClientPreface(mixed $stream): void | |
| { | |
| $this->logger->log('>>> sending client preface (' . strlen(self::CLIENT_PREFACE) . ' bytes)'); | |
| fwrite($stream, self::CLIENT_PREFACE); | |
| } | |
| private function sendClientSettings(mixed $stream): void | |
| { | |
| $this->logger->log('>>> sending SETTINGS (empty payload)'); | |
| fwrite($stream, Http2Frame::pack(self::FRAME_TYPE_SETTINGS, 0x00, 0, '')); | |
| } | |
| private function handleFrames(mixed $stream, Http2StreamReader $reader, string $host, string $path): int | |
| { | |
| $gotServerSettings = false; | |
| $ackedServerSettings = false; | |
| $requestSent = false; | |
| $responseDone = false; | |
| $maxFrames = 50; | |
| for ($index = 0; $index < $maxFrames; $index++) { | |
| $frame = $reader->readFrame(); | |
| if ($frame === null) { | |
| $meta = stream_get_meta_data($stream); | |
| $this->logger->log( | |
| 'no more frames / timeout=' . (!empty($meta['timed_out']) ? 'yes' : 'no') . ' eof=' . (feof($stream) ? 'yes' : 'no') | |
| ); | |
| break; | |
| } | |
| $this->logger->log(sprintf( | |
| 'FRAME #%d len=%d type=0x%02x(%s) flags=0x%02x sid=%d', | |
| $index, | |
| $frame->length, | |
| $frame->type, | |
| Http2Frame::typeName($frame->type), | |
| $frame->flags, | |
| $frame->streamId | |
| )); | |
| $this->logPayload($frame); | |
| if ($frame->type === self::FRAME_TYPE_SETTINGS && ($frame->flags & self::FLAG_ACK) === 0) { | |
| $gotServerSettings = true; | |
| if (!$ackedServerSettings) { | |
| $this->logger->log('>>> sending SETTINGS ACK'); | |
| fwrite($stream, Http2Frame::pack(self::FRAME_TYPE_SETTINGS, self::FLAG_ACK, 0, '')); | |
| $ackedServerSettings = true; | |
| } | |
| } | |
| if ($gotServerSettings && $ackedServerSettings && !$requestSent) { | |
| $this->sendRequestHeaders($stream, $host, $path); | |
| $requestSent = true; | |
| } | |
| if ($requestSent && $frame->streamId === self::REQUEST_STREAM_ID && ($frame->flags & self::FLAG_END_STREAM) !== 0) { | |
| $this->logger->log('END_STREAM received on response stream; exiting.'); | |
| $responseDone = true; | |
| break; | |
| } | |
| if ($frame->type === self::FRAME_TYPE_GOAWAY) { | |
| $this->logger->log('GOAWAY received; exiting.'); | |
| break; | |
| } | |
| } | |
| $this->logger->log($responseDone ? 'done' : 'done (response may be incomplete)'); | |
| return 0; | |
| } | |
| private function logPayload(Http2Frame $frame): void | |
| { | |
| if ($frame->length === 0) { | |
| return; | |
| } | |
| if ($frame->type === self::FRAME_TYPE_DATA) { | |
| $this->logger->log('DATA payload:'); | |
| $this->logger->dumpData($frame->payload); | |
| return; | |
| } | |
| $this->logger->log(HexDumper::dump($frame->payload, 256)); | |
| } | |
| private function sendRequestHeaders(mixed $stream, string $host, string $path): void | |
| { | |
| $headerBlock = $this->hpackEncoder->buildRequestHeaderBlock($host, $path); | |
| $flags = self::FLAG_END_STREAM | self::FLAG_END_HEADERS; | |
| $headers = Http2Frame::pack(self::FRAME_TYPE_HEADERS, $flags, self::REQUEST_STREAM_ID, $headerBlock); | |
| $this->logger->log(">>> sending HEADERS for GET {$path}"); | |
| fwrite($stream, $headers); | |
| } | |
| } | |
| $host = $argv[1] ?? 'example.com'; | |
| $port = (int)($argv[2] ?? 443); | |
| $path = $argv[3] ?? '/'; | |
| $logger = new Logger(); | |
| $client = new Http2Client( | |
| $logger, | |
| new TlsConnector($logger), | |
| new HpackEncoder(), | |
| ); | |
| exit($client->run($host, $port, $path)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment