Last active
March 7, 2026 05:42
-
-
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
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 | |
| /** | |
| * 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