Skip to content

Instantly share code, notes, and snippets.

@masakielastic
Last active March 7, 2026 05:42
Show Gist options
  • Select an option

  • Save masakielastic/1ea71546dfdd5eb469782ba2e21492a5 to your computer and use it in GitHub Desktop.

Select an option

Save masakielastic/1ea71546dfdd5eb469782ba2e21492a5 to your computer and use it in GitHub Desktop.
Minimal HTTP/2 Client with ReactPHP Socket. The latest version is available at: https://github.com/masakielastic/http2-minimal-examples/tree/main
<?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
]);
}

Minimal HTTP/2 Client with ReactPHP Socket

Minimal educational example of implementing a basic HTTP/2 client using the low-level react/socket component.

This example demonstrates how HTTP/2 frames can be exchanged over a TLS connection using ReactPHP's socket abstraction.

This minimal example avoids external HTTP/2 libraries to demonstrate frame-level protocol handling. It is intended for educational purposes only and is not a complete or production-ready HTTP/2 implementation.

This script shows:

  • HTTP/2 client preface
  • SETTINGS / SETTINGS ACK exchange
  • HEADERS (GET) request (minimal HPACK usage)
  • minimal HTTP/2 frame parsing
  • DATA body output to STDOUT
  • Basic END_STREAM / GOAWAY handling

This is a low-level demo and not a production-ready HTTP client.

Requirements

  • PHP 7.1+
  • Composer
  • react/socket

Install

composer require react/socket react/event-loop

Usage

php http2-client.php
php http2-client.php https://nghttp2.org/httpbin/get
php http2-client.php --insecure https://127.0.0.1:8080/

Default URL:

https://nghttp2.org/httpbin/get

Notes

  • Logs are written to STDERR.
  • Response body is written to STDOUT.
  • --insecure disables TLS certificate verification (use only for local testing).

Exit codes

  • 0: success
  • 1: usage error
  • 2: connect error
  • 3: protocol error
  • 4: timeout

How HTTP/2 Processing Works (Overview)

This section describes the processing flow using the actual bytes sent by this minimal client implementation.

  1. Send client connection preface (24 bytes) 50 52 49 20 2A 20 48 54 54 50 2F 32 2E 30 0D 0A 0D 0A 53 4D 0D 0A 0D 0A This is the fixed HTTP/2 connection preface: PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n.

  2. Send client SETTINGS (9 bytes) 00 00 00 04 00 00 00 00 00 Decoded as:

  • Length=0
  • Type=0x04 (SETTINGS)
  • Flags=0x00
  • Stream ID=0
  1. Wait for server SETTINGS, then send SETTINGS ACK (9 bytes) 00 00 00 04 01 00 00 00 00 Decoded as:
  • Length=0
  • Type=0x04 (SETTINGS)
  • Flags=0x01 (ACK)
  • Stream ID=0
  1. Send request HEADERS (38 bytes total in this example) 00 00 1D 01 05 00 00 00 01 82 87 04 0C 2F 68 74 74 70 62 69 6E 2F 67 65 74 01 0B 6E 67 68 74 74 70 32 2E 6F 72 67 Frame header (00 00 1D 01 05 00 00 00 01) means:
  • Length=0x1D (29)
  • Type=0x01 (HEADERS)
  • Flags=0x05 (END_STREAM | END_HEADERS)
  • Stream ID=1 Payload (HPACK block) represents:
  • :method: GET
  • :scheme: https
  • :path: /httpbin/get
  • :authority: nghttp2.org
  1. Read response frames The client then reads incoming frames, writes DATA payload on stream 1 to STDOUT, and logs frame metadata to STDERR.

  2. Close on protocol end The connection closes when response stream 1 has END_STREAM, when GOAWAY is received, or when timeout/protocol error occurs.

Limitations

This is an educational minimal implementation and not a full HTTP/2 stack.

  • CONTINUATION handling is intentionally limited.
  • Full HPACK decoding is not implemented.
  • Dynamic table management for response headers is not implemented.

HTTP/2 Frame Structure (Quick Reference)

Each HTTP/2 frame has a 9-byte header plus variable-length payload.

+-----------------------------------------------+
| Length (24) | Type (8) | Flags (8)           |
+-----------------------------------------------+
|R|               Stream Identifier (31)        |
+-----------------------------------------------+
|                 Frame Payload (...)           |
+-----------------------------------------------+
  • Length (24-bit): payload size in bytes
  • Type (8-bit): frame kind (DATA, HEADERS, SETTINGS, etc.)
  • Flags (8-bit): type-specific flags (END_STREAM, ACK, etc.)
  • Stream Identifier (31-bit): stream ID (0 is connection-level frames like SETTINGS)
  • Payload: frame-specific data

In this sample, frame parsing is done by:

  1. reading 9-byte header,
  2. extracting length/type/flags/streamId,
  3. reading length bytes as payload.

HTTP/2 Header Block Basics (HPACK)

HTTP/2 headers are sent in HEADERS (and optionally CONTINUATION) frames as a compressed header block using HPACK.

This sample uses a minimal request header block for:

  • :method (GET)
  • :scheme (https)
  • :path (/ or requested path)
  • :authority (host[:port])

It uses:

  • static-table indexed entries where possible
  • literal header field without indexing for variable values

So this demo is enough to send a basic GET request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment