|
<?php |
|
|
|
/** |
|
* Minimal HTTP/2 Client with ReactPHP Socket (educational demo) |
|
* |
|
* This example demonstrates a minimal HTTP/2 client implemented directly |
|
* on top of ReactPHP's Socket component. It shows how HTTP/2 can be used |
|
* at the frame level without relying on external HTTP/2 libraries. |
|
* |
|
* The implementation is intentionally incomplete and simplified for |
|
* educational purposes. It should NOT be used as a production HTTP client. |
|
* |
|
* Limitations: |
|
* - client only |
|
* - HTTPS + ALPN (h2) only |
|
* - single GET request only |
|
* - no header decoding (HPACK not implemented) |
|
* - no CONTINUATION frame support |
|
* - intended for small responses / demo use |
|
* |
|
* Usage: |
|
* php 01-http2-client.php https://localhost:8080/ |
|
* php 01-http2-client.php --insecure https://localhost:8080/ |
|
* |
|
* License: MIT |
|
* Copyright (c) 2026 Masaki Kagaya |
|
*/ |
|
|
|
declare(strict_types=1); |
|
|
|
use React\EventLoop\Loop; |
|
use React\Socket\ConnectionInterface; |
|
use React\Socket\Connector; |
|
|
|
require __DIR__ . '/vendor/autoload.php'; |
|
|
|
const EXIT_USAGE = 1; |
|
const EXIT_CONNECT = 2; |
|
const EXIT_PROTOCOL = 3; |
|
const EXIT_TIMEOUT = 4; |
|
const DEFAULT_URI = 'https://nghttp2.org/httpbin/get'; |
|
|
|
const FRAME_DATA = 0x00; |
|
const FRAME_HEADERS = 0x01; |
|
const FRAME_SETTINGS = 0x04; |
|
const FRAME_PUSH_PROMISE = 0x05; |
|
const FRAME_GOAWAY = 0x07; |
|
const FRAME_CONTINUATION = 0x09; |
|
|
|
const FLAG_ACK = 0x01; |
|
const FLAG_END_STREAM = 0x01; |
|
const FLAG_END_HEADERS = 0x04; |
|
|
|
function stderr(string $message): void |
|
{ |
|
fwrite(STDERR, $message . PHP_EOL); |
|
} |
|
|
|
function usageMessage(): string |
|
{ |
|
return "Usage:\n" |
|
. " php 01-http2-client.php [--insecure] <URI>\n\n" |
|
. "Default:\n" |
|
. ' ' . DEFAULT_URI; |
|
} |
|
|
|
function parseInput(array $argv): array |
|
{ |
|
$args = $argv; |
|
array_shift($args); |
|
|
|
$insecure = false; |
|
$uri = null; |
|
foreach ($args as $arg) { |
|
if ($arg === '--insecure') { |
|
$insecure = true; |
|
continue; |
|
} |
|
if ($uri === null) { |
|
$uri = $arg; |
|
continue; |
|
} |
|
throw new InvalidArgumentException(usageMessage()); |
|
} |
|
|
|
if ($uri === null) { |
|
$uri = DEFAULT_URI; |
|
} |
|
|
|
$parts = parse_url($uri); |
|
if (!$parts || !isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'https') { |
|
throw new InvalidArgumentException(usageMessage()); |
|
} |
|
|
|
$port = $parts['port'] ?? 443; |
|
$path = $parts['path'] ?? '/'; |
|
if (isset($parts['query'])) { |
|
$path .= '?' . $parts['query']; |
|
} |
|
|
|
return [$insecure, $parts['host'], (int)$port, $path]; |
|
} |
|
|
|
try { |
|
[$insecure, $host, $port, $path] = parseInput($argv); |
|
} catch (InvalidArgumentException $e) { |
|
stderr($e->getMessage()); |
|
exit(EXIT_USAGE); |
|
} |
|
|
|
$authority = $host . ($port === 443 ? '' : ':' . $port); |
|
$target = "tls://{$host}:{$port}"; |
|
$requestStreamId = 1; |
|
$timeoutSec = 10.0; |
|
$exitCode = 0; |
|
|
|
$connector = new Connector([ |
|
'tls' => [ |
|
'verify_peer' => !$insecure, |
|
'verify_peer_name' => !$insecure, |
|
'SNI_enabled' => true, |
|
'peer_name' => $host, |
|
'alpn_protocols' => 'h2,http/1.1', |
|
], |
|
]); |
|
|
|
stderr("connecting {$target}"); |
|
if ($insecure) { |
|
stderr('TLS verification disabled (--insecure)'); |
|
} |
|
|
|
$loop = Loop::get(); |
|
$timer = null; |
|
|
|
$connector->connect($target)->then( |
|
function (ConnectionInterface $conn) use ( |
|
$loop, |
|
$host, |
|
$authority, |
|
$path, |
|
$requestStreamId, |
|
&$timer, |
|
&$exitCode |
|
): void { |
|
$buffer = ''; |
|
$state = [ |
|
'gotServerSettings' => false, |
|
'ackedServerSettings' => false, |
|
'requestSent' => false, |
|
]; |
|
|
|
$conn->on('data', function (string $chunk) use ( |
|
&$buffer, |
|
&$state, |
|
$conn, |
|
$authority, |
|
$path, |
|
$requestStreamId, |
|
&$exitCode |
|
): void { |
|
$buffer .= $chunk; |
|
|
|
// Receive arbitrary TLS chunks, append to buffer, and keep extracting |
|
// complete HTTP/2 frames until no complete frame remains. |
|
while (($frame = parseNextFrame($buffer)) !== null) { |
|
$action = handleFrame($frame, $state, $authority, $path, $requestStreamId); |
|
|
|
foreach ($action['writes'] as $bin) { |
|
$conn->write($bin); |
|
} |
|
if ($action['stdout'] !== '') { |
|
fwrite(STDOUT, $action['stdout']); |
|
} |
|
if ($action['exitCode'] !== null) { |
|
$exitCode = $action['exitCode']; |
|
} |
|
if ($action['close']) { |
|
$conn->end(); |
|
return; |
|
} |
|
} |
|
}); |
|
|
|
$conn->on('error', function (Throwable $e) use (&$exitCode): void { |
|
stderr('connection error: ' . $e->getMessage()); |
|
$exitCode = EXIT_CONNECT; |
|
}); |
|
|
|
$conn->on('close', function () use ($loop, &$timer): void { |
|
if ($timer !== null) { |
|
$loop->cancelTimer($timer); |
|
} |
|
$loop->stop(); |
|
}); |
|
|
|
$conn->write("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); |
|
$conn->write(packFrame(FRAME_SETTINGS, 0x00, 0, '')); |
|
stderr("sent client preface + SETTINGS to {$host}"); |
|
}, |
|
function (Throwable $e) use (&$exitCode, $loop): void { |
|
stderr('connect failed: ' . $e->getMessage()); |
|
$exitCode = EXIT_CONNECT; |
|
$loop->stop(); |
|
} |
|
); |
|
|
|
$timer = $loop->addTimer($timeoutSec, function () use (&$exitCode, $loop): void { |
|
stderr('timeout waiting for response'); |
|
$exitCode = EXIT_TIMEOUT; |
|
$loop->stop(); |
|
}); |
|
|
|
$loop->run(); |
|
exit($exitCode); |
|
|
|
function packFrame(int $type, int $flags, int $streamId, string $payload): string |
|
{ |
|
$length = strlen($payload); |
|
return |
|
chr(($length >> 16) & 0xff) . |
|
chr(($length >> 8) & 0xff) . |
|
chr($length & 0xff) . |
|
chr($type & 0xff) . |
|
chr($flags & 0xff) . |
|
pack('N', $streamId & 0x7fffffff) . |
|
$payload; |
|
} |
|
|
|
function frameTypeName(int $type): string |
|
{ |
|
switch ($type) { |
|
case FRAME_DATA: |
|
return 'DATA'; |
|
case FRAME_HEADERS: |
|
return 'HEADERS'; |
|
case 0x02: |
|
return 'PRIORITY'; |
|
case 0x03: |
|
return 'RST_STREAM'; |
|
case FRAME_SETTINGS: |
|
return 'SETTINGS'; |
|
case FRAME_PUSH_PROMISE: |
|
return 'PUSH_PROMISE'; |
|
case 0x06: |
|
return 'PING'; |
|
case FRAME_GOAWAY: |
|
return 'GOAWAY'; |
|
case 0x08: |
|
return 'WINDOW_UPDATE'; |
|
case FRAME_CONTINUATION: |
|
return 'CONTINUATION'; |
|
default: |
|
return 'UNKNOWN'; |
|
} |
|
} |
|
|
|
function hasFlag(int $flags, int $flag): bool |
|
{ |
|
return ($flags & $flag) !== 0; |
|
} |
|
|
|
function decodeFrameHeader(string $header): array |
|
{ |
|
return [ |
|
'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, |
|
]; |
|
} |
|
|
|
function parseNextFrame(string &$buffer): ?array |
|
{ |
|
if (strlen($buffer) < 9) { |
|
return null; |
|
} |
|
|
|
$header = decodeFrameHeader(substr($buffer, 0, 9)); |
|
$required = 9 + $header['length']; |
|
if (strlen($buffer) < $required) { |
|
return null; |
|
} |
|
|
|
$frame = $header + [ |
|
'payload' => substr($buffer, 9, $header['length']), |
|
]; |
|
$buffer = (string)substr($buffer, $required); |
|
return $frame; |
|
} |
|
|
|
function handleFrame(array $frame, array &$state, string $authority, string $path, int $requestStreamId): array |
|
{ |
|
$type = $frame['type']; |
|
$flags = $frame['flags']; |
|
$streamId = $frame['streamId']; |
|
$length = $frame['length']; |
|
$payload = $frame['payload']; |
|
|
|
stderr(sprintf( |
|
'frame type=%s(0x%02x) flags=0x%02x sid=%d len=%d', |
|
frameTypeName($type), |
|
$type, |
|
$flags, |
|
$streamId, |
|
$length |
|
)); |
|
|
|
$action = [ |
|
'writes' => [], |
|
'stdout' => '', |
|
'close' => false, |
|
'exitCode' => null, |
|
]; |
|
|
|
if (!$state['gotServerSettings']) { |
|
// This example assumes ALPN negotiated "h2"; otherwise this check fails. |
|
if ($type !== FRAME_SETTINGS || $streamId !== 0) { |
|
stderr('protocol error: expected server SETTINGS as first frame (is this HTTP/2 over TLS/ALPN?)'); |
|
$action['close'] = true; |
|
$action['exitCode'] = EXIT_PROTOCOL; |
|
return $action; |
|
} |
|
$state['gotServerSettings'] = true; |
|
} |
|
|
|
if ($type === FRAME_SETTINGS && !hasFlag($flags, FLAG_ACK) && !$state['ackedServerSettings']) { |
|
$action['writes'][] = packFrame(FRAME_SETTINGS, FLAG_ACK, 0, ''); |
|
stderr('sent SETTINGS ack'); |
|
$state['ackedServerSettings'] = true; |
|
} |
|
|
|
if ($state['gotServerSettings'] && $state['ackedServerSettings'] && !$state['requestSent']) { |
|
$headerBlock = buildRequestHeaderBlock($authority, $path); |
|
$action['writes'][] = packFrame(FRAME_HEADERS, FLAG_END_STREAM | FLAG_END_HEADERS, $requestStreamId, $headerBlock); |
|
stderr("sent HEADERS GET {$path}"); |
|
$state['requestSent'] = true; |
|
} |
|
|
|
if ($type === FRAME_DATA && $streamId === $requestStreamId && $length > 0) { |
|
$action['stdout'] = $payload; |
|
} |
|
|
|
if ($type === FRAME_CONTINUATION) { |
|
stderr('continuation frames are not supported in this minimal example'); |
|
$action['close'] = true; |
|
$action['exitCode'] = EXIT_PROTOCOL; |
|
return $action; |
|
} |
|
|
|
if ($type === FRAME_GOAWAY) { |
|
stderr('received GOAWAY'); |
|
$action['close'] = true; |
|
return $action; |
|
} |
|
|
|
if ( |
|
$streamId === $requestStreamId && |
|
($type === FRAME_DATA || $type === FRAME_HEADERS) && |
|
hasFlag($flags, FLAG_END_STREAM) |
|
) { |
|
stderr('received END_STREAM'); |
|
$action['close'] = true; |
|
return $action; |
|
} |
|
|
|
return $action; |
|
} |
|
|
|
function encodeHpackInt(int $value, int $prefixBits, int $prefixMask = 0x00): string |
|
{ |
|
$maxPrefixValue = (1 << $prefixBits) - 1; |
|
if ($value < $maxPrefixValue) { |
|
return chr($prefixMask | $value); |
|
} |
|
|
|
$out = chr($prefixMask | $maxPrefixValue); |
|
$value -= $maxPrefixValue; |
|
while ($value >= 128) { |
|
$out .= chr(($value % 128) + 128); |
|
$value = intdiv($value, 128); |
|
} |
|
$out .= chr($value); |
|
return $out; |
|
} |
|
|
|
function encodeHpackString(string $value): string |
|
{ |
|
return encodeHpackInt(strlen($value), 7) . $value; |
|
} |
|
|
|
function encodeLiteralHeaderWithoutIndexing(int $nameIndex, string $value): string |
|
{ |
|
return encodeHpackInt($nameIndex, 4) . encodeHpackString($value); |
|
} |
|
|
|
function buildRequestHeaderBlock(string $authority, string $path): string |
|
{ |
|
return implode('', [ |
|
chr(0x82), // :method: GET |
|
chr(0x87), // :scheme: https |
|
$path === '/' ? chr(0x84) : encodeLiteralHeaderWithoutIndexing(4, $path), // :path |
|
encodeLiteralHeaderWithoutIndexing(1, $authority), // :authority |
|
]); |
|
} |