Skip to content

Instantly share code, notes, and snippets.

@masakielastic
Created March 3, 2026 22:43
Show Gist options
  • Select an option

  • Save masakielastic/1948066717b258b7876b826b7e110530 to your computer and use it in GitHub Desktop.

Select an option

Save masakielastic/1948066717b258b7876b826b7e110530 to your computer and use it in GitHub Desktop.
PHP のストリーム関数で簡易 HTTP/2 クライアント改善版

PHP のストリーム関数で簡易 HTTP/2 クライアント改善版

target: tls://104.18.27.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  0a e3 40 b8 db 2a 62 d1 bf 5f 87 49 7c a5 89 d3  ..@..*b.._.I|...
0020  4d 1f 40 85 24 ab 58 3f 5f 8f 7e 47 08 26 d9 94  M.@.$.X?_.~G.&..
0030  23 8c 45 96 57 96 d3 b7 7f 6c 96 e4 59 3e 94 13  #.E.W....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 08 9c 6d bf 40 8a 24 ab 10 64 9c ab 21  .U...m.@.$..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
<?php
declare(strict_types=1);
error_reporting(E_ALL);
ini_set('display_errors', '1');
function logln(string $s): void {
fwrite(STDOUT, $s . PHP_EOL);
fflush(STDOUT);
}
function hexdump(string $bin, int $max = 256): string {
$len = strlen($bin);
$n = min($len, $max);
$out = '';
for ($i = 0; $i < $n; $i += 16) {
$chunk = substr($bin, $i, 16);
$hex = implode(' ', str_split(bin2hex($chunk), 2));
$ascii = preg_replace('/[^\x20-\x7e]/', '.', $chunk);
$out .= sprintf("%04x %-47s %s\n", $i, $hex, $ascii);
}
if ($len > $max) {
$out .= sprintf("... (%d bytes total)\n", $len);
}
return $out;
}
/**
* read exactly $n bytes; returns null on timeout/EOF/error
*/
function readN($fp, int $n): ?string {
$buf = '';
while (strlen($buf) < $n) {
$r = fread($fp, $n - strlen($buf));
if ($r === false) {
return null;
}
if ($r === '') {
$m = stream_get_meta_data($fp);
if (!empty($m['timed_out']) || feof($fp)) {
return null;
}
usleep(10_000);
continue;
}
$buf .= $r;
}
return $buf;
}
/**
* HTTP/2 frame:
* length(24) type(8) flags(8) r(1)+stream_id(31) payload(length)
*/
function readFrame($fp): ?array {
$h = readN($fp, 9);
if ($h === null || strlen($h) < 9) return null;
$len = (ord($h[0]) << 16) | (ord($h[1]) << 8) | ord($h[2]);
$type = ord($h[3]);
$flags = ord($h[4]);
$sid = (unpack('N', substr($h, 5, 4))[1]) & 0x7FFFFFFF;
$payload = $len ? readN($fp, $len) : '';
if ($len && ($payload === null || strlen($payload) < $len)) return null;
return [$len, $type, $flags, $sid, $payload];
}
function packFrame(int $type, int $flags, int $sid, string $payload): string {
$len = strlen($payload);
$h = chr(($len >> 16) & 0xff) . chr(($len >> 8) & 0xff) . chr($len & 0xff);
$h .= chr($type & 0xff) . chr($flags & 0xff);
$h .= pack('N', $sid & 0x7FFFFFFF);
return $h . $payload;
}
function nameFrameType(int $t): string {
return match ($t) {
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',
};
}
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 $host, string $path): string {
$headers = [
chr(0x82), // :method: GET
chr(0x87), // :scheme: https
];
if ($path === '/') {
$headers[] = chr(0x84); // :path: /
} else {
$headers[] = encodeLiteralHeaderWithoutIndexing(4, $path); // :path
}
$headers[] = encodeLiteralHeaderWithoutIndexing(1, $host); // :authority
return implode('', $headers);
}
function connectTlsWithAlpn(string $host, int $port, string $alpn, int $timeoutSec = 3) {
// IPv6 でハングしがちな環境向け:IPv4を優先(取れないならホスト名のまま)
$ip4 = gethostbyname($host);
$targetHost = ($ip4 !== $host) ? $ip4 : $host;
$target = "tls://{$targetHost}:{$port}";
$ctx = stream_context_create([
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
'SNI_enabled' => true,
'peer_name' => $host, // SNI はホスト名で
'alpn_protocols' => $alpn,
],
]);
logln("target: {$target} (SNI/peer_name={$host})");
logln("alpn_protocols(configured): {$alpn}");
$errno = 0; $errstr = '';
$fp = @stream_socket_client($target, $errno, $errstr, $timeoutSec, STREAM_CLIENT_CONNECT, $ctx);
if (!$fp) {
throw new RuntimeException("connect failed: ($errno) $errstr");
}
// 重要:タイムアウト・バッファを明示
stream_set_timeout($fp, $timeoutSec);
stream_set_read_buffer($fp, 0);
stream_set_write_buffer($fp, 0);
$meta = stream_get_meta_data($fp);
$neg = null;
if (isset($meta['crypto']) && is_array($meta['crypto'])) {
foreach (['alpn_protocol','alpn_selected','ssl_alpn_protocol','negotiated_protocol','protocol'] as $k) {
if (isset($meta['crypto'][$k]) && is_string($meta['crypto'][$k]) && $meta['crypto'][$k] !== '') {
$neg = $meta['crypto'][$k];
break;
}
}
}
logln("ALPN (negotiated): " . ($neg ?? "(not found; check meta dump)"));
return [$fp, $meta, $neg];
}
/* ========= main ========= */
$host = $argv[1] ?? 'example.com';
$port = (int)($argv[2] ?? 443);
$path = $argv[3] ?? '/';
// h2 が選ばれるかを見る。順序は h2 優先。
$alpn = "h2,http/1.1";
// ここは用途に応じて。デモなので短め。
$timeoutSec = 2;
try {
[$fp, $meta, $neg] = connectTlsWithAlpn($host, $port, $alpn, $timeoutSec);
} catch (Throwable $e) {
logln("[!] " . $e->getMessage());
exit(1);
}
if ($neg !== 'h2') {
logln("[!] negotiated ALPN is not h2. Exiting (this demo expects HTTP/2).");
// 必要なら meta を見たいときだけ出す
// var_export($meta); echo PHP_EOL;
fclose($fp);
exit(2);
}
// 1) client connection preface(必須)
$preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
logln(">>> sending client preface (" . strlen($preface) . " bytes)");
fwrite($fp, $preface);
// 2) クライアント SETTINGS(最小:空payloadでもOK)
$settings = packFrame(0x04, 0x00, 0, '');
logln(">>> sending SETTINGS (empty payload)");
fwrite($fp, $settings);
// 3) サーバからの SETTINGS 等を読む
$gotServerSettings = false;
$ackedServerSettings = false;
$requestSent = false;
$responseDone = false;
$requestStreamId = 1;
$maxFrames = 50;
for ($i = 0; $i < $maxFrames; $i++) {
$fr = readFrame($fp);
if ($fr === null) {
$m = stream_get_meta_data($fp);
logln("no more frames / timeout=" . (!empty($m['timed_out']) ? 'yes' : 'no') . " eof=" . (feof($fp) ? 'yes' : 'no'));
break;
}
[$len, $type, $flags, $sid, $payload] = $fr;
$tname = nameFrameType($type);
logln(sprintf("FRAME #%d len=%d type=0x%02x(%s) flags=0x%02x sid=%d", $i, $len, $type, $tname, $flags, $sid));
if ($len > 0) {
if ($type === 0x00 /*DATA*/) {
logln("DATA payload:");
fwrite(STDOUT, $payload);
if (!str_ends_with($payload, "\n")) {
fwrite(STDOUT, PHP_EOL);
}
fflush(STDOUT);
} else {
// SETTINGSのpayloadは6バイト単位のID/Valueだが、デモではダンプだけ
logln(hexdump($payload, 256));
}
}
// サーバ SETTINGS(ACKではない)を受けたら ACK を返す
if ($type === 0x04 /*SETTINGS*/ && ($flags & 0x01) === 0) {
$gotServerSettings = true;
if (!$ackedServerSettings) {
$ack = packFrame(0x04, 0x01, 0, '');
logln(">>> sending SETTINGS ACK");
fwrite($fp, $ack);
$ackedServerSettings = true;
}
}
if ($gotServerSettings && $ackedServerSettings && !$requestSent) {
$headerBlock = buildRequestHeaderBlock($host, $path);
$headers = packFrame(0x01, 0x05, $requestStreamId, $headerBlock);
logln(">>> sending HEADERS for GET {$path}");
fwrite($fp, $headers);
$requestSent = true;
}
if ($requestSent && $sid === $requestStreamId && ($flags & 0x01) !== 0) {
logln("END_STREAM received on response stream; exiting.");
$responseDone = true;
break;
}
// 念のため:GOAWAY が来たら終わり
if ($type === 0x07 /*GOAWAY*/) {
logln("GOAWAY received; exiting.");
break;
}
}
fclose($fp);
if (!$responseDone) {
logln("done (response may be incomplete)");
} else {
logln("done");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment