Skip to content

Commit

Permalink
First sort-of-ish working PoC of simple http3 requests
Browse files Browse the repository at this point in the history
  • Loading branch information
bwoebi committed Jan 7, 2024
1 parent abfbe86 commit 7e575c1
Show file tree
Hide file tree
Showing 12 changed files with 983 additions and 14 deletions.
1 change: 1 addition & 0 deletions examples/hello-world.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
$server->expose("[::]:1337");
$server->expose("0.0.0.0:1338", $context);
$server->expose("[::]:1338", $context);
$server->expose("0.0.0.0:1339", new \Amp\Quic\QuicServerConfig($context->getTlsContext()->withApplicationLayerProtocols(["h3"])));

Check failure on line 46 in examples/hello-world.php

View workflow job for this annotation

GitHub Actions / PHP 8.1

PossiblyNullReference

examples/hello-world.php:46:91: PossiblyNullReference: Cannot call method withApplicationLayerProtocols on possibly null value (see https://psalm.dev/083)

$server->start(new class implements RequestHandler {
public function handleRequest(Request $request): Response
Expand Down
267 changes: 266 additions & 1 deletion src/Driver/Http3Driver.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,48 @@

namespace Amp\Http\Server\Driver;

use Amp\ByteStream\ReadableIterableStream;
use Amp\DeferredFuture;
use Amp\Http\Http2\Http2Parser;
use Amp\Http\InvalidHeaderException;
use Amp\Http\Server\Driver\Internal\ConnectionHttpDriver;
use Amp\Http\Server\Driver\Internal\QPack;
use Amp\Http\Server\Driver\Internal\Http2Stream;
use Amp\Http\Server\Driver\Internal\Http3\Http3Frame;
use Amp\Http\Server\Driver\Internal\Http3\Http3Parser;
use Amp\Http\Server\Driver\Internal\Http3\Http3Settings;
use Amp\Http\Server\Driver\Internal\Http3\Http3Writer;
use Amp\Http\Server\Driver\Internal\Http3\QPack;
use Amp\Http\Server\ErrorHandler;
use Amp\Http\Server\Request;
use Amp\Http\Server\RequestBody;
use Amp\Http\Server\RequestHandler;
use Amp\Http\Server\Response;
use Amp\Http\Server\Trailers;
use Amp\NullCancellation;
use Amp\Pipeline\Queue;
use Amp\Quic\QuicConnection;
use Amp\Quic\QuicSocket;
use Amp\Socket\InternetAddress;
use Amp\Socket\Socket;
use League\Uri;
use Psr\Log\LoggerInterface as PsrLogger;
use Revolt\EventLoop;
use function Amp\async;
use function Amp\Http\formatDateHeader;

class Http3Driver extends ConnectionHttpDriver
{
private bool $allowsPush;

private Client $client;

Check failure on line 38 in src/Driver/Http3Driver.php

View workflow job for this annotation

GitHub Actions / PHP 8.1

PropertyNotSetInConstructor

src/Driver/Http3Driver.php:38:20: PropertyNotSetInConstructor: Property Amp\Http\Server\Driver\Http3Driver::$client is not defined in constructor of Amp\Http\Server\Driver\Http3Driver or in any private or final methods called in the constructor (see https://psalm.dev/074)
private QuicConnection $connection;

Check failure on line 39 in src/Driver/Http3Driver.php

View workflow job for this annotation

GitHub Actions / PHP 8.1

PropertyNotSetInConstructor

src/Driver/Http3Driver.php:39:28: PropertyNotSetInConstructor: Property Amp\Http\Server\Driver\Http3Driver::$connection is not defined in constructor of Amp\Http\Server\Driver\Http3Driver or in any private or final methods called in the constructor (see https://psalm.dev/074)

/** @var \WeakMap<Request, QuicSocket> */
private \WeakMap $requestStreams;

private Http3Writer $writer;

Check failure on line 44 in src/Driver/Http3Driver.php

View workflow job for this annotation

GitHub Actions / PHP 8.1

PropertyNotSetInConstructor

src/Driver/Http3Driver.php:44:25: PropertyNotSetInConstructor: Property Amp\Http\Server\Driver\Http3Driver::$writer is not defined in constructor of Amp\Http\Server\Driver\Http3Driver or in any private or final methods called in the constructor (see https://psalm.dev/074)
private QPack $qpack;

public function __construct(
RequestHandler $requestHandler,
ErrorHandler $errorHandler,
Expand All @@ -31,11 +59,77 @@ public function __construct(
$this->allowsPush = $pushEnabled;

$this->qpack = new QPack;
$this->requestStreams = new \WeakMap;

Check failure on line 62 in src/Driver/Http3Driver.php

View workflow job for this annotation

GitHub Actions / PHP 8.1

PropertyTypeCoercion

src/Driver/Http3Driver.php:62:33: PropertyTypeCoercion: $this->requestStreams expects 'WeakMap<Amp\Http\Server\Request, Amp\Quic\QuicSocket>', parent type 'WeakMap<object, mixed>' provided (see https://psalm.dev/198)
}

// TODO copied from Http2Driver...
private function encodeHeaders(array $headers): string
{
$input = [];

foreach ($headers as $field => $values) {
$values = (array) $values;

foreach ($values as $value) {
$input[] = [(string) $field, (string) $value];
}
}

return $this->qpack->encode($input);
}

protected function write(Request $request, Response $response): void
{
/** @var QuicSocket $stream */
$stream = $this->requestStreams[$request];
unset($this->requestStreams[$request]);

$status = $response->getStatus();
$headers = [
':status' => [$status],
...$response->getHeaders(),
'date' => [formatDateHeader()],
];

// Remove headers that are obsolete in HTTP/2.
unset($headers["connection"], $headers["keep-alive"], $headers["transfer-encoding"]);

$trailers = $response->getTrailers();

if ($trailers !== null && !isset($headers["trailer"]) && ($fields = $trailers->getFields())) {
$headers["trailer"] = [\implode(", ", $fields)];
}

foreach ($response->getPushes() as $push) {
$headers["link"][] = "<{$push->getUri()}>; rel=preload";
if ($this->allowsPush) {
// TODO $this->sendPushPromise($request, $id, $push);
}
}

$this->writer->sendHeaderFrame($stream, $this->encodeHeaders($headers));

if ($request->getMethod() === "HEAD") {
return;
}

$cancellation = new NullCancellation; // TODO just dummy

$body = $response->getBody();
$chunk = $body->read($cancellation);

while ($chunk !== null) {
$this->writer->sendData($stream, $chunk);

$chunk = $body->read($cancellation);
}

if ($trailers !== null) {
$trailers = $trailers->await($cancellation);
$this->writer->sendHeaderFrame($stream, $this->encodeHeaders($trailers->getHeaders()));
}

$stream->end();
}

public function getApplicationLayerProtocols(): array
Expand All @@ -45,7 +139,178 @@ public function getApplicationLayerProtocols(): array

public function handleConnection(Client $client, QuicConnection|Socket $connection): void
{
/** @psalm-suppress RedundantPropertyInitializationCheck */
\assert(!isset($this->client), "The driver has already been setup");

$this->client = $client;
$this->connection = $connection;

Check failure on line 146 in src/Driver/Http3Driver.php

View workflow job for this annotation

GitHub Actions / PHP 8.1

PossiblyInvalidPropertyAssignmentValue

src/Driver/Http3Driver.php:146:29: PossiblyInvalidPropertyAssignmentValue: $this->connection with declared type 'Amp\Quic\QuicConnection' cannot be assigned possibly different type 'Amp\Quic\QuicConnection|Amp\Socket\Socket' (see https://psalm.dev/147)
$this->writer = new Http3Writer($connection, [[Http3Settings::MAX_FIELD_SECTION_SIZE, $this->headerSizeLimit]]);

Check failure on line 147 in src/Driver/Http3Driver.php

View workflow job for this annotation

GitHub Actions / PHP 8.1

PossiblyInvalidArgument

src/Driver/Http3Driver.php:147:41: PossiblyInvalidArgument: Argument 1 of Amp\Http\Server\Driver\Internal\Http3\Http3Writer::__construct expects Amp\Quic\QuicConnection, but possibly different type Amp\Quic\QuicConnection|Amp\Socket\Socket provided (see https://psalm.dev/092)

$parser = new Http3Parser($connection, $this->headerSizeLimit, $this->qpack);

Check failure on line 149 in src/Driver/Http3Driver.php

View workflow job for this annotation

GitHub Actions / PHP 8.1

PossiblyInvalidArgument

src/Driver/Http3Driver.php:149:35: PossiblyInvalidArgument: Argument 1 of Amp\Http\Server\Driver\Internal\Http3\Http3Parser::__construct expects Amp\Quic\QuicConnection, but possibly different type Amp\Quic\QuicConnection|Amp\Socket\Socket provided (see https://psalm.dev/092)
foreach ($parser->process() as $frame) {
$type = $frame[0];
switch ($type) {
case Http3Frame::SETTINGS:
// something to do?
break;

case Http3Frame::HEADERS:
EventLoop::queue(function () use ($frame) {
/** @var QuicSocket $stream */
$stream = $frame[1];
$generator = $frame[2];

[$headers, $pseudo] = $generator->current();
foreach ($pseudo as $name => $value) {
if (!isset(Http2Parser::KNOWN_REQUEST_PSEUDO_HEADERS[$name])) {
return;
}
}

if (!isset($pseudo[":method"], $pseudo[":path"], $pseudo[":scheme"], $pseudo[":authority"])
|| isset($headers["connection"])
|| $pseudo[":path"] === ''
|| (isset($headers["te"]) && \implode($headers["te"]) !== "trailers")
) {
return; // "Invalid header values"
}

[':method' => $method, ':path' => $target, ':scheme' => $scheme, ':authority' => $host] = $pseudo;
$query = null;

if (!\preg_match("#^([A-Z\d.\-]+|\[[\d:]+])(?::([1-9]\d*))?$#i", $host, $matches)) {
return; // "Invalid authority (host) name"
}

$address = $this->client->getLocalAddress();

$host = $matches[1];
$port = isset($matches[2])
? (int) $matches[2]
: ($address instanceof InternetAddress ? $address->getPort() : null);

if ($position = \strpos($target, "#")) {
$target = \substr($target, 0, $position);
}

if ($position = \strpos($target, "?")) {
$query = \substr($target, $position + 1);
$target = \substr($target, 0, $position);
}

try {
if ($target === "*") {
/** @psalm-suppress DeprecatedMethod */
$uri = Uri\Http::createFromComponents([
"scheme" => $scheme,
"host" => $host,
"port" => $port,
]);
} else {
/** @psalm-suppress DeprecatedMethod */
$uri = Uri\Http::createFromComponents([
"scheme" => $scheme,
"host" => $host,
"port" => $port,
"path" => $target,
"query" => $query,
]);
}
} catch (Uri\Contracts\UriException $exception) {
return; // "Invalid request URI",
}

$trailerDeferred = new DeferredFuture;
$bodyQueue = new Queue();

try {
$trailers = new Trailers(
$trailerDeferred->getFuture(),
isset($headers['trailers'])
? \array_map('trim', \explode(',', \implode(',', $headers['trailers'])))
: []
);
} catch (InvalidHeaderException $exception) {
return; // "Invalid headers field in trailers"
}

$dataSuspension = null;
$body = new RequestBody(
new ReadableIterableStream($bodyQueue->pipe()),
function (int $bodySize) use (&$bodySizeLimit, &$dataSuspension) {

Check failure on line 240 in src/Driver/Http3Driver.php

View workflow job for this annotation

GitHub Actions / PHP 8.1

UndefinedVariable

src/Driver/Http3Driver.php:240:60: UndefinedVariable: Cannot find referenced variable $bodySizeLimit (see https://psalm.dev/024)
if ($bodySizeLimit >= $bodySize) {
return;
}

$bodySizeLimit = $bodySize;

$dataSuspension?->resume();

Check failure on line 247 in src/Driver/Http3Driver.php

View workflow job for this annotation

GitHub Actions / PHP 8.1

TypeDoesNotContainType

src/Driver/Http3Driver.php:247:33: TypeDoesNotContainType: Type null for $dataSuspension is always !null (see https://psalm.dev/056)
$dataSuspension = null;
}
);

$request = new Request(
$this->client,
$method,
$uri,
$headers,
$body,
"3",
$trailers
);
$this->requestStreams[$request] = $stream;
async($this->handleRequest(...), $request);

$generator->next();
$currentBodySize = 0;
if ($generator->valid()) {
foreach ($generator as $type => $data) {
if ($type === Http3Frame::DATA) {
$bodyQueue->push($data);
while ($currentBodySize > $bodySizeLimit) {
$dataSuspension = EventLoop::getSuspension();
$dataSuspension->suspend();
}
} elseif ($type === Http3Frame::HEADERS) {
// Trailers must not contain pseudo-headers.
if (!empty($pseudo)) {
return; // "Trailers must not contain pseudo headers"
}

// Trailers must not contain any disallowed fields.
if (\array_intersect_key($headers, Trailers::DISALLOWED_TRAILERS)) {
return; // "Disallowed trailer field name"
}

$trailerDeferred->complete($headers);
$trailerDeferred = null;
break;
} else {
return; // Boo for push promise
}
}
}
$bodyQueue->complete();
$trailerDeferred?->complete();
});

case Http3Frame::GOAWAY:
// TODO bye bye
break;

case Http3Frame::MAX_PUSH_ID:
// TODO push
break;

case Http3Frame::CANCEL_PUSH:
// TODO stop push
break;

default:
// TODO invalid
return;
}
}
}

public function getPendingRequestCount(): int
Expand Down
10 changes: 10 additions & 0 deletions src/Driver/Internal/Http3/Http3Error.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php declare(strict_types=1);

namespace Amp\Http\Server\Driver\Internal\Http3;

enum Http3Error: int
{
case QPACK_DECOMPRESSION_FAILED = 0x200;
case QPACK_ENCODER_STREAM_ERROR = 0x201;
case QPACK_DECODER_STREAM_ERROR = 0x202;
}
17 changes: 17 additions & 0 deletions src/Driver/Internal/Http3/Http3Frame.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php declare(strict_types=1);

namespace Amp\Http\Server\Driver\Internal\Http3;

enum Http3Frame: int
{
case DATA = 0x00;
case HEADERS = 0x01;
case CANCEL_PUSH = 0x03;
case SETTINGS = 0x04;
case PUSH_PROMISE = 0x05;
case GOAWAY = 0x07;
case ORIGIN = 0x0c;
case MAX_PUSH_ID = 0x0d;
case PRIORITY_UPDATE_Request = 0xF0700;
case PRIORITY_UPDATE_Response = 0xF0701;
}
Loading

0 comments on commit 7e575c1

Please sign in to comment.