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/71a4a49cad1c2daf51ae001c26dcfab1 to your computer and use it in GitHub Desktop.

Select an option

Save masakielastic/71a4a49cad1c2daf51ae001c26dcfab1 to your computer and use it in GitHub Desktop.
Minimal HTTP/2 Server with ReactPHP Socket. The latest version is available at: https://github.com/masakielastic/http2-minimal-examples
<?php
/**
* Minimal HTTP/2 Server with ReactPHP Socket (educational demo)
*
* This example demonstrates a minimal HTTP/2 server 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 Server.
*
* Limitations:
* - server only
* - default mode is cleartext HTTP/2 (h2c, prior knowledge only)
* - accepts one request stream, sends one response, then closes
* - no request header decoding
* - no CONTINUATION support
* - initial request HEADERS only (no trailing headers)
* - flow control is ignored in this demo (works here because payload is small and connection closes quickly)
* - stream-level errors are treated as connection errors for simplicity
* - no RST_STREAM path; stream-level failures also end the connection
* - small payload / demo only
*
* Usage:
* php http2-server.php <PORT> [<PRIVATE_KEY> <CERT>] [--address=<ADDR>]
*
* License: MIT
* Copyright (c) 2026 Masaki Kagaya
*/
declare(strict_types=1);
use React\EventLoop\Loop;
use React\Socket\ConnectionInterface;
use React\Socket\SocketServer;
require __DIR__ . '/vendor/autoload.php';
const EXIT_USAGE = 1;
const DEFAULT_ADDRESS = '127.0.0.1';
const CLIENT_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
const FRAME_DATA = 0x00;
const FRAME_HEADERS = 0x01;
const FRAME_SETTINGS = 0x04;
const FRAME_PING = 0x06;
const FRAME_GOAWAY = 0x07;
const FRAME_WINDOW_UPDATE = 0x08;
const FRAME_CONTINUATION = 0x09;
const FLAG_ACK = 0x01;
const FLAG_END_STREAM = 0x01;
const FLAG_END_HEADERS = 0x04;
const H2_ERR_PROTOCOL = 0x01;
const H2_ERR_FRAME_SIZE = 0x06;
function stderr(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
function usageMessage(): string
{
return "Usage:\n"
. " php http2-server.php <PORT> [<PRIVATE_KEY> <CERT>] [--address=<ADDR>]\n\n"
. "If PRIVATE_KEY and CERT are provided, TLS mode is enabled.\n\n"
. "Examples:\n"
. " php http2-server.php 8080\n"
. " php http2-server.php 8443 server.key server.crt\n"
. " php http2-server.php 8080 --address=127.0.0.1\n\n"
. "Test with curl:\n"
. " curl -v --http2-prior-knowledge http://127.0.0.1:8080/\n"
. " curl -k --http2 https://127.0.0.1:8443/";
}
function parseInput(array $argv): array
{
$address = DEFAULT_ADDRESS;
$args = [];
foreach (array_slice($argv, 1) as $arg) {
if (str_starts_with($arg, '--address=')) {
$address = substr($arg, 10);
} elseif (str_starts_with($arg, '--')) {
throw new InvalidArgumentException(usageMessage());
} else {
$args[] = $arg;
}
}
if (count($args) === 0 || count($args) > 3) {
throw new InvalidArgumentException(usageMessage());
}
$portRaw = $args[0];
if ($portRaw === '' || !ctype_digit($portRaw)) {
throw new InvalidArgumentException("Invalid PORT: {$portRaw}\n\n" . usageMessage());
}
$port = (int)$portRaw;
if ($port < 1 || $port > 65535) {
throw new InvalidArgumentException("PORT out of range (1-65535): {$port}\n\n" . usageMessage());
}
if ($address === '') {
throw new InvalidArgumentException("Invalid --address value\n\n" . usageMessage());
}
$key = $args[1] ?? null;
$cert = $args[2] ?? null;
if (($key !== null && $cert === null) || ($key === null && $cert !== null)) {
throw new InvalidArgumentException("PRIVATE_KEY and CERT must be provided together.\n\n" . usageMessage());
}
if (($key !== null && $key === '') || ($cert !== null && $cert === '')) {
throw new InvalidArgumentException("Invalid TLS argument(s)\n\n" . usageMessage());
}
if ($cert !== null && !is_file($cert)) {
throw new InvalidArgumentException("TLS certificate not found: {$cert}");
}
if ($key !== null && !is_file($key)) {
throw new InvalidArgumentException("TLS private key not found: {$key}");
}
$listen = $address . ':' . (string)$port;
return [$listen, $cert, $key];
}
try {
[$listen, $cert, $key] = parseInput($argv);
} catch (InvalidArgumentException $e) {
stderr($e->getMessage());
exit(EXIT_USAGE);
}
$useTls = ($cert !== null && $key !== null);
$modeLabel = $useTls ? 'h2' : 'h2c-prior';
$serverUri = ($useTls ? 'tls://' : 'tcp://') . $listen;
$serverContext = [];
if ($useTls) {
$serverContext['tls'] = [
'local_cert' => $cert,
'local_pk' => $key,
'crypto_method' => STREAM_CRYPTO_METHOD_TLS_SERVER,
'alpn_protocols' => 'h2',
];
}
$server = new SocketServer($serverUri, $serverContext);
stderr("listening on {$listen} ({$modeLabel})");
$server->on('connection', function (ConnectionInterface $conn): void {
$buffer = '';
$state = [
'prefaceRead' => false,
'gotClientSettings' => false,
'requestStreamId' => null,
'requestEnded' => false,
'responseSent' => false,
'lastClientStreamId' => 0,
];
stderr('accepted connection');
// HTTP/2 connection flow (simplified):
// 1. client sends connection preface: "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
// 2. client sends SETTINGS
// 3. server replies with SETTINGS
// 4. client sends request HEADERS / DATA
//
// This demo intentionally implements only this minimal flow.
// Cleartext mode in this demo uses HTTP/2 prior knowledge only.
// HTTP/1.1 Upgrade is intentionally omitted so the example stays focused
// on the minimal HTTP/2 flow and frame-level behavior.
$conn->on('data', function (string $chunk) use (&$buffer, &$state, $conn): void {
$buffer .= $chunk;
if (!$state['prefaceRead']) {
if (strlen($buffer) < strlen(CLIENT_PREFACE)) {
return;
}
$preface = substr($buffer, 0, strlen(CLIENT_PREFACE));
if ($preface !== CLIENT_PREFACE) {
stderr('protocol error: missing or invalid HTTP/2 client preface');
$conn->end();
return;
}
$buffer = (string)substr($buffer, strlen(CLIENT_PREFACE));
$state['prefaceRead'] = true;
$conn->write(packFrame(FRAME_SETTINGS, 0x00, 0, ''));
stderr('sent server SETTINGS');
}
while (($frame = parseNextFrame($buffer)) !== null) {
$action = handleFrame($frame, $state);
foreach ($action['writes'] as $write) {
$conn->write($write);
}
if ($action['protocolError']) {
$errorCode = $action['errorCode'] ?? H2_ERR_PROTOCOL;
$conn->write(buildGoAwayFrame($state['lastClientStreamId'], $errorCode));
}
if ($action['close']) {
$conn->end();
return;
}
}
});
$conn->on('error', function (Throwable $e): void {
stderr('connection error: ' . $e->getMessage());
});
$conn->on('close', function (): void {
stderr('connection closed');
});
});
Loop::get()->run();
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 FRAME_SETTINGS:
return 'SETTINGS';
case FRAME_GOAWAY:
return 'GOAWAY';
case FRAME_PING:
return 'PING';
case FRAME_WINDOW_UPDATE:
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 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 buildResponseHeaderBlock(int $contentLength): string
{
return implode('', [
chr(0x88), // :status: 200
encodeLiteralHeaderWithoutIndexing(31, 'text/plain; charset=utf-8'), // content-type
encodeLiteralHeaderWithoutIndexing(28, (string)$contentLength), // content-length
]);
}
function buildGoAwayPayload(int $lastStreamId, int $errorCode): string
{
return pack('N', $lastStreamId & 0x7fffffff) . pack('N', $errorCode);
}
function buildGoAwayFrame(int $lastStreamId, int $errorCode): string
{
return packFrame(FRAME_GOAWAY, 0x00, 0, buildGoAwayPayload($lastStreamId, $errorCode));
}
function handleFrame(array $frame, array &$state): array
{
$type = $frame['type'];
$flags = $frame['flags'];
$streamId = $frame['streamId'];
stderr(sprintf(
'frame type=%s(0x%02x) flags=0x%02x sid=%d len=%d',
frameTypeName($type),
$type,
$flags,
$streamId,
$frame['length']
));
$action = [
'writes' => [],
'close' => false,
'protocolError' => false,
'errorCode' => null,
];
if (!$state['gotClientSettings']) {
if ($type !== FRAME_SETTINGS) {
failConnection($action, 'protocol error: expected client SETTINGS as first frame after preface');
return $action;
}
$state['gotClientSettings'] = true;
}
if ($type === FRAME_SETTINGS) {
if ($streamId !== 0) {
failConnection($action, 'protocol error: SETTINGS stream id must be 0');
return $action;
}
if (hasFlag($flags, FLAG_ACK) && $frame['length'] !== 0) {
failConnection($action, 'frame size error: SETTINGS with ACK must have empty payload', H2_ERR_FRAME_SIZE);
return $action;
}
if (($frame['length'] % 6) !== 0) {
failConnection($action, 'frame size error: SETTINGS payload length must be a multiple of 6', H2_ERR_FRAME_SIZE);
return $action;
}
if (!hasFlag($flags, FLAG_ACK)) {
$action['writes'][] = packFrame(FRAME_SETTINGS, FLAG_ACK, 0, '');
}
return $action;
}
if ($type === FRAME_PING) {
if ($streamId !== 0) {
failConnection($action, 'protocol error: PING stream id must be 0');
return $action;
}
if ($frame['length'] !== 8) {
failConnection($action, 'frame size error: PING payload length must be 8', H2_ERR_FRAME_SIZE);
return $action;
}
if (!hasFlag($flags, FLAG_ACK)) {
$action['writes'][] = packFrame(FRAME_PING, FLAG_ACK, 0, $frame['payload']);
}
return $action;
}
if ($type === FRAME_WINDOW_UPDATE) {
// Flow control is intentionally ignored in this demo.
stderr('ignored WINDOW_UPDATE in this demo (flow control omitted)');
return $action;
}
if ($type === FRAME_CONTINUATION) {
failConnection($action, 'continuation frames are not supported in this minimal example');
return $action;
}
if ($type === FRAME_HEADERS) {
if (!hasFlag($flags, FLAG_END_HEADERS)) {
failConnection($action, 'protocol error: HEADERS without END_HEADERS is unsupported');
return $action;
}
if ($streamId <= 0) {
failConnection($action, 'protocol error: HEADERS stream id must be > 0');
return $action;
}
if (($streamId % 2) === 0) {
failConnection($action, 'protocol error: client-initiated stream id must be odd');
return $action;
}
if ($state['requestStreamId'] === null) {
if ($streamId <= $state['lastClientStreamId']) {
failConnection($action, 'protocol error: new stream id must be greater than previous client stream id');
return $action;
}
$state['requestStreamId'] = $streamId;
$state['lastClientStreamId'] = $streamId;
} elseif ($streamId === $state['requestStreamId']) {
if ($state['requestEnded']) {
failConnection($action, 'protocol error: HEADERS received after END_STREAM');
return $action;
}
failConnection($action, 'protocol error: this demo accepts only the initial request HEADERS (trailing headers unsupported)');
return $action;
} else {
failConnection($action, 'protocol error: this demo supports only one request stream per connection');
return $action;
}
if (hasFlag($flags, FLAG_END_STREAM)) {
$state['requestEnded'] = true;
}
}
if ($type === FRAME_DATA) {
if ($streamId <= 0) {
failConnection($action, 'protocol error: DATA stream id must be > 0');
return $action;
}
if ($state['requestStreamId'] === null || $streamId !== $state['requestStreamId']) {
failConnection($action, 'protocol error: DATA for unknown or unsupported stream');
return $action;
}
if ($state['requestEnded']) {
failConnection($action, 'protocol error: DATA received after END_STREAM');
return $action;
}
$state['lastClientStreamId'] = max($state['lastClientStreamId'], $streamId);
if (hasFlag($flags, FLAG_END_STREAM)) {
$state['requestEnded'] = true;
}
}
if ($state['requestEnded'] && !$state['responseSent'] && $state['requestStreamId'] !== null) {
$body = "hello from http/2 server\n";
$headers = buildResponseHeaderBlock(strlen($body));
$action['writes'][] = packFrame(FRAME_HEADERS, FLAG_END_HEADERS, $state['requestStreamId'], $headers);
$action['writes'][] = packFrame(FRAME_DATA, FLAG_END_STREAM, $state['requestStreamId'], $body);
$state['responseSent'] = true;
$action['close'] = true;
}
if (!in_array($type, [FRAME_SETTINGS, FRAME_PING, FRAME_HEADERS, FRAME_DATA, FRAME_CONTINUATION], true)) {
stderr(sprintf('ignored frame in this demo: %s(0x%02x)', frameTypeName($type), $type));
}
return $action;
}
function failConnection(array &$action, string $message, int $errorCode = H2_ERR_PROTOCOL): void
{
stderr($message);
$action['protocolError'] = true;
$action['close'] = true;
$action['errorCode'] = $errorCode;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment