Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions docs/api/request.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ to be sure the client intended to send a JSON request body.
This example returns a simple text response, you may also want to return a
[JSON response](response.md#json) for common API usage.

> ℹ️ **Info**
>
> Note that the [built-in web server](../best-practices/deployment.md#built-in-web-server)
> limits HTTP request bodies to 64 KiB by default. Any JSON above this size will
> show up as an empty request body. See
> [request body limit](../best-practices/deployment.md#request-body-limit) for
> how to change this limit.

## Form data

You can access HTML form data from the HTTP request body like this:
Expand Down Expand Up @@ -116,6 +124,14 @@ Hello Alice!
This method returns a possibly nested array of form fields, very similar to
PHP's `$_POST` superglobal.

> ℹ️ **Info**
>
> Note that the [built-in web server](../best-practices/deployment.md#built-in-web-server)
> limits HTTP request bodies to 64 KiB by default. Any form data above this size
> will show up as an empty request body with no form fields whatsoever. See
> [request body limit](../best-practices/deployment.md#request-body-limit) for
> how to change this limit.

## Uploads

You can access any file uploads from HTML forms like this:
Expand Down Expand Up @@ -169,9 +185,11 @@ assert(is_string($type) || $name === null);

> ℹ️ **Info**
>
> Note that HTTP requests are currently limited to 64 KiB. Any uploads above
> this size will currently show up as an empty request body with no file uploads
> whatsoever.
> Note that the [built-in web server](../best-practices/deployment.md#built-in-web-server)
> limits HTTP request bodies to 64 KiB by default. Any uploads above this size
> will show up as an empty request body with no file uploads whatsoever. See
> [request body limit](../best-practices/deployment.md#request-body-limit) for
> how to change this limit.

## Headers

Expand Down
71 changes: 71 additions & 0 deletions docs/best-practices/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,77 @@ $ sudoedit /etc/php/8.5/cli/php.ini
+ memory_limit = -1
```

### Request body limit

By default, the built-in web server buffers each request body in memory and
limits each request body to 64 KiB. This applies to
[JSON](../api/request.md#json), [form data](../api/request.md#form-data) and
[file uploads](../api/request.md#uploads) alike.

If your application needs to accept larger request bodies, you can pass a custom
list of [ReactPHP HTTP middleware](https://reactphp.org/http/#middleware) to the
HTTP server runner in your
[Container configuration](controllers.md#container-configuration) like this:

```php title="public/index.php"
<?php

require __DIR__ . '/../vendor/autoload.php';

$container = new FrameworkX\Container([
FrameworkX\Runner\HttpServerRunner::class => function (?string $X_LISTEN = null) {
return new FrameworkX\Runner\HttpServerRunner(
new FrameworkX\Io\LogStreamHandler('php://output'),
$X_LISTEN,
[
new React\Http\Middleware\StreamingRequestMiddleware(),
new React\Http\Middleware\LimitConcurrentRequestsMiddleware(10),
new React\Http\Middleware\RequestBodyBufferMiddleware('8M'),
new React\Http\Middleware\RequestBodyParserMiddleware('2M')
]
);
}
]);

$app = new FrameworkX\App($container);

// …

$app->run();
```

This example accepts request bodies of up to 8 MiB in total with file uploads of
up to 2 MiB each, matching PHP's defaults, and processes at most 10 requests
concurrently, so the server holds at most 80 MiB of buffered request bodies at
any time. Make sure your [memory limit](#memory-limit) leaves room for this.
Raising the limit requires a
[`StreamingRequestMiddleware`](https://reactphp.org/http/#streamingrequestmiddleware)
in the list, otherwise ReactPHP's own 64 KiB buffer runs first and the larger
buffer never sees a request body above 64 KiB.

> ⚠️ **Feature preview**
>
> Note that this is an experimental API that may be subject to change in future
> releases. The given list runs in front of X, so any middleware can preprocess
> or filter requests before X handles them (a middleware that does not call
> `$next` means X never sees the request). Keep the resulting configuration sound:
>
> * A [`StreamingRequestMiddleware`](https://reactphp.org/http/#streamingrequestmiddleware)
> replaces *all* of ReactPHP's default middleware, so the order of the
> remaining middleware matters.
> * Without [`LimitConcurrentRequestsMiddleware`](https://reactphp.org/http/#limitconcurrentrequestsmiddleware),
> the number of buffered requests is unbounded and concurrent uploads can
> exhaust your memory.
> * Without [`RequestBodyParserMiddleware`](https://reactphp.org/http/#requestbodyparsermiddleware),
> [form data](../api/request.md#form-data) and
> [file uploads](../api/request.md#uploads) are no longer parsed.
>
> Behind a [traditional web server](#traditional-stacks), PHP's
> [`post_max_size`](https://www.php.net/manual/en/ini.core.php#ini.post-max-size)
> (default `8M`) and
> [`upload_max_filesize`](https://www.php.net/manual/en/ini.core.php#ini.upload-max-filesize)
> (default `2M`) settings apply instead.

### FD limits

By default, many systems limit the number of file descriptors (FDs) that a
Expand Down
43 changes: 36 additions & 7 deletions src/Runner/HttpServerRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
* incoming connections simultaneously. The long-running server process will
* continue to run until it is interrupted by a signal.
*
* Note that this is an internal class only and nothing you should usually have
* to care about. See also the `App` and `SapiRunner` for more details.
* Note that this is mostly an internal class and nothing you should usually
* have to care about. For more advanced use cases, its constructor offers an
* experimental API to customize the HTTP server, such as raising the request
* body limit. See also the `App` and `SapiRunner` for more details.
*
* @internal
*/
Expand All @@ -31,23 +33,50 @@ class HttpServerRunner
/** @var string */
private $listenAddress;

/** @throws void */
public function __construct(LogStreamHandler $logger, ?string $listenAddress)
/** @var list<callable> */
private $experimentalHttpMiddleware;

/**
* [Experimental] Create an HTTP server runner with custom configuration
*
* @param list<callable> $experimentalHttpMiddleware (optional) list of ReactPHP HTTP middleware to run in front of the application
* @throws \TypeError if given $experimentalHttpMiddleware is invalid
*/
public function __construct(LogStreamHandler $logger, ?string $listenAddress, array $experimentalHttpMiddleware = [])
{
if ($experimentalHttpMiddleware !== \array_values($experimentalHttpMiddleware)) {
throw new \TypeError('Argument #3 ($experimentalHttpMiddleware) must be of type list<callable>, array given');
}
foreach ($experimentalHttpMiddleware as $key => $middleware) {
/** @var mixed $middleware explicit type check for mixed if user ignores parameter type */
if (!\is_callable($middleware)) {
throw new \TypeError(
'Argument #3 ($experimentalHttpMiddleware) for key ' . $key . ' must be of type callable, ' . (\is_object($middleware) ? \get_class($middleware) : \gettype($middleware)) . ' given'
);
}
}

$this->logger = $logger;
$this->listenAddress = $listenAddress ?? '127.0.0.1:8080';
$this->experimentalHttpMiddleware = $experimentalHttpMiddleware;
}

/**
* @param callable(\Psr\Http\Message\ServerRequestInterface):(\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface<\Psr\Http\Message\ResponseInterface>) $handler
* @return void
* @throws \InvalidArgumentException if listen address or PHP's ini settings are invalid
* @throws \RuntimeException if listening on the given listen address fails
*/
public function __invoke(callable $handler): void
{
$socket = new SocketServer($this->listenAddress);
// create HTTP server, run any experimental middleware in front of the
// application and automatically start new fiber for each request on PHP 8.1+
$http = new HttpServer(
...$this->experimentalHttpMiddleware,
...(\PHP_VERSION_ID >= 80100 ? [new FiberHandler(), $handler] : [$handler])
);

// create HTTP server, automatically start new fiber for each request on PHP 8.1+
$http = new HttpServer(...(\PHP_VERSION_ID >= 80100 ? [new FiberHandler(), $handler] : [$handler]));
$socket = new SocketServer($this->listenAddress);
$http->listen($socket);

$logger = $this->logger;
Expand Down
147 changes: 147 additions & 0 deletions tests/Runner/HttpServerRunnerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,38 @@
use FrameworkX\Io\LogStreamHandler;
use FrameworkX\Runner\HttpServerRunner;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\Loop;
use React\Http\Message\Response;
use React\Http\Middleware\RequestBodyBufferMiddleware;
use React\Http\Middleware\StreamingRequestMiddleware;
use React\Promise\Promise;
use React\Socket\ConnectionInterface;
use React\Socket\Connector;
use function React\Async\await;

class HttpServerRunnerTest extends TestCase
{
public function testCtorWithHttpMiddlewareWithStringKeysThrows(): void
{
$logger = $this->createMock(LogStreamHandler::class);
assert($logger instanceof LogStreamHandler);

$this->expectException(\TypeError::class);
$this->expectExceptionMessage('Argument #3 ($experimentalHttpMiddleware) must be of type list<callable>, array given');
new HttpServerRunner($logger, '127.0.0.1:0', ['foo' => function () { }]); // @phpstan-ignore-line
}

public function testCtorWithInvalidHttpMiddlewareThrows(): void
{
$logger = $this->createMock(LogStreamHandler::class);
assert($logger instanceof LogStreamHandler);

$this->expectException(\TypeError::class);
$this->expectExceptionMessage('Argument #3 ($experimentalHttpMiddleware) for key 1 must be of type callable, stdClass given');
new HttpServerRunner($logger, '127.0.0.1:0', [function () { }, new \stdClass()]); // @phpstan-ignore-line
}

public function testInvokeWillReportDefaultListeningAddressAndRunLoop(): void
{
$socket = @stream_socket_server('127.0.0.1:8080');
Expand Down Expand Up @@ -176,6 +199,93 @@ public function testInvokeWillListenForHttpRequestAndSendBackHttpResponseOverSoc
});
}

public function testInvokeWithHttpMiddlewareWillPassRequestThroughMiddlewareBeforeHandler(): void
{
$logger = $this->createMock(LogStreamHandler::class);
assert($logger instanceof LogStreamHandler);

$middleware = function (ServerRequestInterface $request, callable $next) {
return $next($request->withAttribute('middleware', 'yes'));
};

$runner = new HttpServerRunner($logger, '127.0.0.1:0', [$middleware]);

Loop::futureTick(function (): void {
$resources = get_resources();
$socket = end($resources);
assert(is_resource($socket));

$connector = new Connector();
$promise = $connector->connect((string) stream_socket_get_name($socket, false));

$promise->then(function (ConnectionInterface $connection) use ($socket): void {
// lovely: remove socket server on client connection close to terminate loop
$connection->on('close', function () use ($socket): void {
Loop::removeReadStream($socket);
fclose($socket);

Loop::stop();
});

$connection->write("GET /unknown HTTP/1.0\r\nHost: localhost\r\n\r\n");
});
});

$attribute = null;
$runner(function (ServerRequestInterface $request) use (&$attribute): Response {
$attribute = $request->getAttribute('middleware');

return new Response(200, ['Date' => '', 'Server' => ''], "OK\n");
});

$this->assertEquals('yes', $attribute);
}

public function testInvokeWithHttpMiddlewareWillReceiveRequestBodyAboveDefaultBufferSize(): void
{
$logger = $this->createMock(LogStreamHandler::class);
assert($logger instanceof LogStreamHandler);

$runner = new HttpServerRunner($logger, '127.0.0.1:0', [
new StreamingRequestMiddleware(),
new RequestBodyBufferMiddleware('1M')
]);

$body = str_repeat('x', 100000);
Loop::futureTick(function () use ($body): void {
$resources = get_resources();
$socket = end($resources);
assert(is_resource($socket));

$connector = new Connector();
$promise = $connector->connect((string) stream_socket_get_name($socket, false));

$promise->then(function (ConnectionInterface $connection) use ($socket, $body): void {
// lovely: remove socket server on client connection close to terminate loop
$connection->on('close', function () use ($socket): void {
Loop::removeReadStream($socket);
fclose($socket);

Loop::stop();
});

$connection->write("POST /unknown HTTP/1.0\r\nHost: localhost\r\nContent-Length: " . strlen($body) . "\r\n\r\n" . $body);
});
});

$length = null;
$fiber = null;
$runner(function (ServerRequestInterface $request) use (&$length, &$fiber): Response {
$length = $request->getBody()->getSize();
$fiber = \PHP_VERSION_ID >= 80100 ? \Fiber::getCurrent() : false;

return new Response(200, ['Date' => '', 'Server' => ''], "OK\n");
});

$this->assertEquals(100000, $length);
$this->assertNotNull($fiber);
}

public function testInvokeWillOnlyRestartLoopAfterAwaitingWhenFibersAreNotAvailable(): void
{
$socket = stream_socket_server('127.0.0.1:0');
Expand Down Expand Up @@ -367,4 +477,41 @@ public function testInvokeWithBusyPortThrows(): void
throw new \BadFunctionCallException('Should not be reached');
});
}

public function testInvokeWithInvalidMemoryLimitThrowsWithoutListeningOnAddress(): void
{
$socket = stream_socket_server('127.0.0.1:0');
assert(is_resource($socket));
$addr = stream_socket_get_name($socket, false);
assert(is_string($addr));
fclose($socket);

$logger = $this->createMock(LogStreamHandler::class);
assert($logger instanceof LogStreamHandler);

$runner = new HttpServerRunner($logger, $addr);

// PHP accepts this invalid memory limit, but ReactPHP's HTTP server rejects it
$memoryLimit = ini_get('memory_limit');
assert(is_string($memoryLimit));
@ini_set('memory_limit', '-2G');

$exception = null;
try {
$runner(function (): void {
throw new \BadFunctionCallException('Should not be reached');
});
} catch (\InvalidArgumentException $e) {
$exception = $e;
} finally {
ini_set('memory_limit', $memoryLimit);
}

$this->assertInstanceOf(\InvalidArgumentException::class, $exception);

$socket = @stream_socket_server($addr);
$this->assertNotFalse($socket);
assert(is_resource($socket));
fclose($socket);
}
}