From fb1ce8cbc16b09725d79c41f745c8d65df75d61f Mon Sep 17 00:00:00 2001 From: Christopher Vagnetoft Date: Sat, 24 Feb 2024 20:44:06 +0100 Subject: [PATCH] Initial commit --- .gitignore | 3 + README.md | 10 + composer.json | 25 +++ phpstan.neon | 12 ++ phpunit.xml | 23 +++ src/Header/HeaderBag.php | 27 +++ src/Header/HeaderPacker.php | 155 ++++++++++++++++ src/Huffman/Codec.php | 50 +++++ src/Huffman/Dictionary.php | 296 ++++++++++++++++++++++++++++++ tests/Header/HeaderPackerTest.php | 30 +++ tests/Huffman/CodecTest.php | 38 ++++ 11 files changed, 669 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 src/Header/HeaderBag.php create mode 100644 src/Header/HeaderPacker.php create mode 100644 src/Huffman/Codec.php create mode 100644 src/Huffman/Dictionary.php create mode 100644 tests/Header/HeaderPackerTest.php create mode 100644 tests/Huffman/CodecTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..235a653 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/composer.lock +/.phpunit.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..002acc1 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# HTTP/2 Support for ReactPHP + +This is a project that is exploring the feasability and practicality of bringing HTTP/2 to ReactPHP while staying true to the ReactPHP philosophy. The main rationale for this is to eventually enable native support for gRPC. + +## Status + +Currently implemented: + +* Huffman encoding (for compressed headers) +* Header parsing (including dictionary) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..339ba97 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "noccylabs/react-http2", + "description": "Native ReactPHP HTTP2 implementation", + "type": "library", + "license": "GPL-3.0-or-later", + "keywords": [ "reactphp", "websockets" ], + "autoload": { + "psr-4": { + "NoccyLabs\\React\\Http2\\": "src/" + } + }, + "authors": [ + { + "name": "NoccyLabs", + "email": "labs@noccy.com" + } + ], + "require": { + "react/http": "^1.9.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "phpstan/phpstan": "^1.10" + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..e68bade --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +parameters: + level: 5 + + excludePaths: + - doc + - vendor + - tests + + # Paths to include in the analysis + paths: + - src + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..0e66c06 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Header/HeaderBag.php b/src/Header/HeaderBag.php new file mode 100644 index 0000000..7d723dd --- /dev/null +++ b/src/Header/HeaderBag.php @@ -0,0 +1,27 @@ +$value) $this->append($name, $value); + } + + public function append(string $name, string $value) + { + $this->headers[] = [ $name, $value ]; + } +} \ No newline at end of file diff --git a/src/Header/HeaderPacker.php b/src/Header/HeaderPacker.php new file mode 100644 index 0000000..ef44a8e --- /dev/null +++ b/src/Header/HeaderPacker.php @@ -0,0 +1,155 @@ + [ ':authority', null], + 2 => [ ':method', 'GET' ], + 3 => [ ':method', 'POST'], + 4 => [ ':path', '/'], + 5 => [ ':path', '/index.html'], + 6 => [ ':scheme', 'http'], + 7 => [ ':scheme', 'https'], + 8 => [ ':status', '200'], + 9 => [ ':status', '204'], + 10 => [ ':status', '206'], + 11 => [ ':status', '304'], + 12 => [ ':status', '400'], + 13 => [ ':status', '404'], + 14 => [ ':status', '500'], + 15 => [ 'accept-charset', null], + 16 => [ 'accept-encoding', 'gzip, deflate'], + 17 => [ 'accept-language', null], + 18 => [ 'accept-ranges', null], + 19 => [ 'accept', null ], + 20 => [ 'access-control-allow-origin', null], + 21 => [ 'age', null], + 22 => [ 'allow', null], + 23 => [ 'authorization', null], + 24 => [ 'cache-control', null], + 25 => [ 'content-disposition', null], + 26 => [ 'content-encoding', null], + 27 => [ 'content-language', null], + 28 => [ 'content-length', null], + 29 => [ 'content-location', null], + 30 => [ 'content-range', null], + 31 => [ 'content-type', null], + 32 => [ 'cookie', null], + 33 => [ 'date', null], + 34 => [ 'etag', null], + 35 => [ 'expect', null], + 36 => [ 'expires', null], + 37 => [ 'from', null], + 38 => [ 'host', null], + 39 => [ 'if-match', null], + 40 => [ 'if-modified-since', null], + 41 => [ 'if-none-match', null], + 42 => [ 'if-range', null], + 43 => [ 'if-unmodified-since', null], + 44 => [ 'last-modified', null], + 45 => [ 'link', null], + 46 => [ 'location', null], + 47 => [ 'max-forwards', null], + 48 => [ 'proxy-authenticate', null], + 49 => [ 'proxy-authorization', null], + 50 => [ 'range', null], + 51 => [ 'referer', null], + 52 => [ 'refresh', null], + 53 => [ 'retry-after', null], + 54 => [ 'server', null], + 55 => [ 'set-cookie', null], + 56 => [ 'strict-transport-security', null], + 57 => [ 'transfer-encoding', null], + 58 => [ 'user-agent', null], + 59 => [ 'vary', null], + 60 => [ 'via', null], + 61 => [ 'www-authenticate', null], + ]; + private const STATIC_TABLE_SIZE = 62; // considering unused index 0 + + public function __construct() + { + if (!self::$huffman) self::$huffman = new Codec(Dictionary::createRfc7541Dictionary()); + } + + public function packHeaders(HeaderBag $headers): string + { + $packed = ''; + + + return $packed; + } + + public function unpackHeaders(string $raw): HeaderBag + { + $headers = new HeaderBag(); + $bytes = array_map("ord", str_split($raw)); + + while (count($bytes) > 0) { + $head = array_shift($bytes); + if ($head & 0x80) { + // Indexed field + [$header,$value] = $this->getIndexedHeader($head & 0x7F); + $headers->append($header,$value); + } elseif ($head & 0x40) { + // Indexed with literal + [$header,$value] = $this->getIndexedHeader($head & 0x3F); + $valueHead = array_shift($bytes); + $encoded = (bool)($valueHead & 0x80); + $length = ($valueHead & 0x7F); + while ($length-- > 0) { + $value .= chr(array_shift($bytes)); + } + if ($encoded) { + $value = self::$huffman->decode($value); + } + $headers->append($header,$value); + } else { + // Literal field + $valueHead = array_shift($bytes); + $encoded = (bool)($valueHead & 0x80); + $length = ($valueHead & 0x7F); + $header = ''; + while ($length-- > 0) { + $header .= chr(array_shift($bytes)); + } + if ($encoded) { + $header = self::$huffman->decode($header); + } + + $valueHead = array_shift($bytes); + $encoded = (bool)($valueHead & 0x80); + $length = ($valueHead & 0x7F); + $value = ''; + while ($length-- > 0) { + $value .= chr(array_shift($bytes)); + } + if ($encoded) { + $value = self::$huffman->decode($value); + } + + $headers->append($header,$value); + } + } + + return $headers; + } + + private function getIndexedHeader(int $index): array + { + return self::$staticTable[$index]; + } + +} \ No newline at end of file diff --git a/src/Huffman/Codec.php b/src/Huffman/Codec.php new file mode 100644 index 0000000..de2a873 --- /dev/null +++ b/src/Huffman/Codec.php @@ -0,0 +1,50 @@ +dictionary = $dictionary; + } + + public function decode(string $data): ?string + { + $in = array_map("ord", str_split($data)); + $out = []; + $bits = ''; + for ($ch = 0; $ch < count($in); $ch++) { + $bits .= str_pad(decbin($in[$ch]), 8, "0", STR_PAD_LEFT); + while (($code = $this->dictionary->match($bits)) !== null) { + if ($code === self::CODE_EOS) break(2); + $out[] = $code; + } + } + return join("", array_map("chr", $out)); + } + + public function encode(string $data): ?string + { + $in = array_map("ord", str_split($data)); + //$in[] = self::CODE_EOS; + $out = []; + $bits = ''; + for ($ch = 0; $ch < count($in); $ch++) { + $bits .= $this->dictionary->pattern($in[$ch]); + while (strlen($bits) >= 8) { + $out[] = bindec(substr($bits, 0, 8)); + $bits = substr($bits, 8); + } + } + if (strlen($bits) > 0) { + $out[] = bindec(str_pad($bits, 8, "1", STR_PAD_RIGHT)); + } + return join("", array_map("chr", $out)); + } + +} \ No newline at end of file diff --git a/src/Huffman/Dictionary.php b/src/Huffman/Dictionary.php new file mode 100644 index 0000000..ed313b8 --- /dev/null +++ b/src/Huffman/Dictionary.php @@ -0,0 +1,296 @@ +codes = $codes; + $this->patterns = array_flip($codes); + } + + public function match(string &$pattern): ?int + { + for ($l = 1; $l <= strlen($pattern); $l++) { + $t = substr($pattern, 0, $l); + if (array_key_exists($t, $this->patterns)) { + $pattern = substr($pattern, strlen($t)); + return $this->patterns[$t]; + } + } + return null; + } + + public function pattern(int $code): string + { + return $this->codes[$code]; + } + + public static function createRfc7541Dictionary(): Dictionary + { + return new Dictionary(array ( + 0 => '1111111111000', + 1 => '11111111111111111011000', + 2 => '1111111111111111111111100010', + 3 => '1111111111111111111111100011', + 4 => '1111111111111111111111100100', + 5 => '1111111111111111111111100101', + 6 => '1111111111111111111111100110', + 7 => '1111111111111111111111100111', + 8 => '1111111111111111111111101000', + 9 => '111111111111111111101010', + 10 => '111111111111111111111111111100', + 11 => '1111111111111111111111101001', + 12 => '1111111111111111111111101010', + 13 => '111111111111111111111111111101', + 14 => '1111111111111111111111101011', + 15 => '1111111111111111111111101100', + 16 => '1111111111111111111111101101', + 17 => '1111111111111111111111101110', + 18 => '1111111111111111111111101111', + 19 => '1111111111111111111111110000', + 20 => '1111111111111111111111110001', + 21 => '1111111111111111111111110010', + 22 => '111111111111111111111111111110', + 23 => '1111111111111111111111110011', + 24 => '1111111111111111111111110100', + 25 => '1111111111111111111111110101', + 26 => '1111111111111111111111110110', + 27 => '1111111111111111111111110111', + 28 => '1111111111111111111111111000', + 29 => '1111111111111111111111111001', + 30 => '1111111111111111111111111010', + 31 => '1111111111111111111111111011', + 32 => '010100', + 33 => '1111111000', + 34 => '1111111001', + 35 => '111111111010', + 36 => '1111111111001', + 37 => '010101', + 38 => '11111000', + 39 => '11111111010', + 40 => '1111111010', + 41 => '1111111011', + 42 => '11111001', + 43 => '11111111011', + 44 => '11111010', + 45 => '010110', + 46 => '010111', + 47 => '011000', + 48 => '00000', + 49 => '00001', + 50 => '00010', + 51 => '011001', + 52 => '011010', + 53 => '011011', + 54 => '011100', + 55 => '011101', + 56 => '011110', + 57 => '011111', + 58 => '1011100', + 59 => '11111011', + 60 => '111111111111100', + 61 => '100000', + 62 => '111111111011', + 63 => '1111111100', + 64 => '1111111111010', + 65 => '100001', + 66 => '1011101', + 67 => '1011110', + 68 => '1011111', + 69 => '1100000', + 70 => '1100001', + 71 => '1100010', + 72 => '1100011', + 73 => '1100100', + 74 => '1100101', + 75 => '1100110', + 76 => '1100111', + 77 => '1101000', + 78 => '1101001', + 79 => '1101010', + 80 => '1101011', + 81 => '1101100', + 82 => '1101101', + 83 => '1101110', + 84 => '1101111', + 85 => '1110000', + 86 => '1110001', + 87 => '1110010', + 88 => '11111100', + 89 => '1110011', + 90 => '11111101', + 91 => '1111111111011', + 92 => '1111111111111110000', + 93 => '1111111111100', + 94 => '11111111111100', + 95 => '100010', + 96 => '111111111111101', + 97 => '00011', + 98 => '100011', + 99 => '00100', + 100 => '100100', + 101 => '00101', + 102 => '100101', + 103 => '100110', + 104 => '100111', + 105 => '00110', + 106 => '1110100', + 107 => '1110101', + 108 => '101000', + 109 => '101001', + 110 => '101010', + 111 => '00111', + 112 => '101011', + 113 => '1110110', + 114 => '101100', + 115 => '01000', + 116 => '01001', + 117 => '101101', + 118 => '1110111', + 119 => '1111000', + 120 => '1111001', + 121 => '1111010', + 122 => '1111011', + 123 => '111111111111110', + 124 => '11111111100', + 125 => '11111111111101', + 126 => '1111111111101', + 127 => '1111111111111111111111111100', + 128 => '11111111111111100110', + 129 => '1111111111111111010010', + 130 => '11111111111111100111', + 131 => '11111111111111101000', + 132 => '1111111111111111010011', + 133 => '1111111111111111010100', + 134 => '1111111111111111010101', + 135 => '11111111111111111011001', + 136 => '1111111111111111010110', + 137 => '11111111111111111011010', + 138 => '11111111111111111011011', + 139 => '11111111111111111011100', + 140 => '11111111111111111011101', + 141 => '11111111111111111011110', + 142 => '111111111111111111101011', + 143 => '11111111111111111011111', + 144 => '111111111111111111101100', + 145 => '111111111111111111101101', + 146 => '1111111111111111010111', + 147 => '11111111111111111100000', + 148 => '111111111111111111101110', + 149 => '11111111111111111100001', + 150 => '11111111111111111100010', + 151 => '11111111111111111100011', + 152 => '11111111111111111100100', + 153 => '111111111111111011100', + 154 => '1111111111111111011000', + 155 => '11111111111111111100101', + 156 => '1111111111111111011001', + 157 => '11111111111111111100110', + 158 => '11111111111111111100111', + 159 => '111111111111111111101111', + 160 => '1111111111111111011010', + 161 => '111111111111111011101', + 162 => '11111111111111101001', + 163 => '1111111111111111011011', + 164 => '1111111111111111011100', + 165 => '11111111111111111101000', + 166 => '11111111111111111101001', + 167 => '111111111111111011110', + 168 => '11111111111111111101010', + 169 => '1111111111111111011101', + 170 => '1111111111111111011110', + 171 => '111111111111111111110000', + 172 => '111111111111111011111', + 173 => '1111111111111111011111', + 174 => '11111111111111111101011', + 175 => '11111111111111111101100', + 176 => '111111111111111100000', + 177 => '111111111111111100001', + 178 => '1111111111111111100000', + 179 => '111111111111111100010', + 180 => '11111111111111111101101', + 181 => '1111111111111111100001', + 182 => '11111111111111111101110', + 183 => '11111111111111111101111', + 184 => '11111111111111101010', + 185 => '1111111111111111100010', + 186 => '1111111111111111100011', + 187 => '1111111111111111100100', + 188 => '11111111111111111110000', + 189 => '1111111111111111100101', + 190 => '1111111111111111100110', + 191 => '11111111111111111110001', + 192 => '11111111111111111111100000', + 193 => '11111111111111111111100001', + 194 => '11111111111111101011', + 195 => '1111111111111110001', + 196 => '1111111111111111100111', + 197 => '11111111111111111110010', + 198 => '1111111111111111101000', + 199 => '1111111111111111111101100', + 200 => '11111111111111111111100010', + 201 => '11111111111111111111100011', + 202 => '11111111111111111111100100', + 203 => '111111111111111111111011110', + 204 => '111111111111111111111011111', + 205 => '11111111111111111111100101', + 206 => '111111111111111111110001', + 207 => '1111111111111111111101101', + 208 => '1111111111111110010', + 209 => '111111111111111100011', + 210 => '11111111111111111111100110', + 211 => '111111111111111111111100000', + 212 => '111111111111111111111100001', + 213 => '11111111111111111111100111', + 214 => '111111111111111111111100010', + 215 => '111111111111111111110010', + 216 => '111111111111111100100', + 217 => '111111111111111100101', + 218 => '11111111111111111111101000', + 219 => '11111111111111111111101001', + 220 => '1111111111111111111111111101', + 221 => '111111111111111111111100011', + 222 => '111111111111111111111100100', + 223 => '111111111111111111111100101', + 224 => '11111111111111101100', + 225 => '111111111111111111110011', + 226 => '11111111111111101101', + 227 => '111111111111111100110', + 228 => '1111111111111111101001', + 229 => '111111111111111100111', + 230 => '111111111111111101000', + 231 => '11111111111111111110011', + 232 => '1111111111111111101010', + 233 => '1111111111111111101011', + 234 => '1111111111111111111101110', + 235 => '1111111111111111111101111', + 236 => '111111111111111111110100', + 237 => '111111111111111111110101', + 238 => '11111111111111111111101010', + 239 => '11111111111111111110100', + 240 => '11111111111111111111101011', + 241 => '111111111111111111111100110', + 242 => '11111111111111111111101100', + 243 => '11111111111111111111101101', + 244 => '111111111111111111111100111', + 245 => '111111111111111111111101000', + 246 => '111111111111111111111101001', + 247 => '111111111111111111111101010', + 248 => '111111111111111111111101011', + 249 => '1111111111111111111111111110', + 250 => '111111111111111111111101100', + 251 => '111111111111111111111101101', + 252 => '111111111111111111111101110', + 253 => '111111111111111111111101111', + 254 => '111111111111111111111110000', + 255 => '11111111111111111111101110', + 256 => '111111111111111111111111111111', + )); + } +} \ No newline at end of file diff --git a/tests/Header/HeaderPackerTest.php b/tests/Header/HeaderPackerTest.php new file mode 100644 index 0000000..dc66449 --- /dev/null +++ b/tests/Header/HeaderPackerTest.php @@ -0,0 +1,30 @@ + 'GET', + ':scheme' => 'http', + ':path' => '/', + ':authority' => 'www.example.com' + ]); + $out = $packer->unpackHeaders($in); + + $this->assertEquals($expect, $out); + + } + + +} \ No newline at end of file diff --git a/tests/Huffman/CodecTest.php b/tests/Huffman/CodecTest.php new file mode 100644 index 0000000..3e31f6f --- /dev/null +++ b/tests/Huffman/CodecTest.php @@ -0,0 +1,38 @@ +decode($in); + + $this->assertEquals($expect, $out); + + } + + public function testEncodingExamplesFromRfc7541() + { + $dict = Dictionary::createRfc7541Dictionary(); + $huff = new Codec($dict); + + $in = "www.example.com"; + $expect = "\xf1\xe3\xc2\xe5\xf2\x3a\x6b\xa0\xab\x90\xf4\xff"; + $out = $huff->encode($in); + + $this->assertEquals($expect, $out); + + } + +} \ No newline at end of file