bronsted / psr-http
Psr-http
Requires
- php: ^8.1
- bronsted/fiberloop: ^3.1
- kekos/multipart-form-data-parser: ^1.0
- php-di/php-di: ^7.0
- psr/http-client: ^1.0
- psr/log: ^1.1 || ^2.0 || ^3.0
Requires (Dev)
- brain/monkey: ^2.6
- colinodell/psr-testlogger: ^1.2
- nyholm/psr7: ^1.5
- phpunit/phpunit: ^10.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
This is a http server and client package with PSR interfaces, so it can be used with frameworks which uses PSR interface. It is build with FiberLoop which is coroutines build on php fibers.
Version 3 requires Fiberloop 3.1, whose event loop is built around read and write
watchers. HttpServer::getFiberLoop() returns that loop, so timers and deferred
work can be scheduled on the same loop.
Install
You can install this package via Composer:
composer install bronsted\psr-http
Usage
Server usage:
<?php require __DIR__ . '/vendor/autoload.php'; use bronsted\HttpServer; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ServerRequestInterface; $factory = new Psr17Factory(); $server = new HttpServer('0.0.0.0', 8000, $factory, $factory, $factory, $factory, $factory); $server->run(function (ServerRequestInterface $request) use ($factory) { $response = $factory->createResponse(); $path = $request->getUri()->getPath(); if ($path == '/') { $params = $request->getQueryParams(); $response->getBody()->write('Hello ' . (empty($params) ? 'world' : $params['name'])); } else { $response = $response->withStatus(404); } return $response; });
Use $server->getFiberLoop() to access the same event loop used by the server.
Timers and deferred background work can be scheduled on it before calling run().
Standalone client usage context:
<?php require __DIR__ . '/vendor/autoload.php'; use bronsted\HttpClient; use bronsted\FiberLoop; use bronsted\StreamSocketFactory; use Nyholm\Psr7\Factory\Psr17Factory; FiberLoop::instance()->defer(function() use($argv) { $factory = new Psr17Factory(); $request = $factory->createRequest('GET', $argv[1]); $client = new HttpClient($factory, new StreamSocketFactory()); $response = $client->sendRequest($request); echo $response->getStatusCode() . PHP_EOL; echo $response->getBody()->getContents() . PHP_EOL; }); FiberLoop::instance()->run();
HTTP behavior and limits
The v2.0 logger and timeout constructor arguments remain supported. HttpClient
accepts an optional PSR-3 logger as its third argument and a connection timeout in
seconds as its fourth argument (default 5). HttpServer accepts an optional
PSR-3 logger after its seven factory/address arguments, before the optional size
limits. Omitting the logger uses a NullLogger. Request timing and failures are
logged without allowing a logging backend failure to interrupt HTTP processing.
The standalone static Log helper remains available for compatibility.
The server handles one request per connection and sends Connection: close.
Exceptions from a request handler produce a 500 response; a disconnected client
or failed response write does not stop the server's event loop.
Request lines are limited to 8,000 bytes, individual header lines to 8 KiB, total
headers to 64 KiB, and decoded request bodies to 8 MiB. Limits are checked while
reading, including chunked bodies. Oversized request lines, headers, and bodies
produce 414, 431, and 413 responses respectively. The optional constructor
arguments maxHeaderBytes and maxBodyBytes override the total-header and body
limits. Ambiguous framing, such as both Content-Length and Transfer-Encoding,
is rejected rather than guessed.
The listening socket uses a 511-deep backlog by default, adjustable through the
last constructor argument ($backlog); the kernel caps it at net.core.somaxconn.
A larger backlog matters when many clients connect at the same moment: with a small
queue the kernel drops SYNs, and TCP retransmits them after about a second, which
shows up as multi-second response times in load tests.
The client reads fixed-length, chunked, and connection-close-delimited responses.
Informational responses are skipped until the final response; HEAD, 204, 205,
and 304 responses do not wait for a body. Truncated bodies are errors, and chunk
payloads are preserved byte-for-byte. Protocol upgrades (101) and transfer
codings other than chunked are not supported.
Client failures implement PSR-18's ClientExceptionInterface. Network failures
use bronsted\NetworkException, invalid or unreadable outgoing requests use
bronsted\RequestException, and invalid responses use bronsted\ClientException.
The original request and underlying exception are available through
getRequest() and getPrevious(). Outgoing request bodies must have a known
size; unknown-size bodies are rejected with a request exception rather than
sent without framing. Hostname resolution remains blocking.
On PHP 8.3 and newer, the server uses a bounded 1 ms non-blocking accept poll to work around Fiberloop treating listening TCP sockets as EOF. Earlier runtimes use readiness notifications. Pending socket reads are woken by Fiberloop's EOF notification when a peer closes its send direction, so no polling is needed.
Tests
Run make test to build the Docker image and run composer install and
composer test inside it, or make coverage for Xdebug coverage. If you have
PHP locally, composer test and composer run coverage work as well.
The suite includes byte-preservation, HTTP framing, input-limit, error-isolation,
and local TCP integration tests. It does not require external HTTP services.
Stress test
run.dockerfile builds an image that serves example/server.php on port 8000, and
siege.dockerfile builds a Siege client image.
make stress builds both, starts the server on a private Docker network, waits until
it accepts connections, runs Siege against it, and then removes the container and
network:
make stress # 250 concurrent clients for 60 s make stress CONCURRENCY=50 DURATION=10S # quick smoke test make stress PHP_VERSION=8.1 # another PHP runtime
SIEGE_ARGS is passed through to Siege, for example make stress SIEGE_ARGS=-k.
Siege runs in benchmark mode (-b), and the target fails if Siege reports failed
transactions or the server does not start listening in time.
The images can also be used on their own:
docker build -f run.dockerfile -t psr-http-run . docker build -f siege.dockerfile -t psr-http-siege . docker run --rm -p 8000:8000 psr-http-run docker run --rm --network host psr-http-siege -b -c 10 -t 10S http://127.0.0.1:8000/
Bugs and improvements
Report bugs in GitHub issues or feel free to make a pull request :-)
License
MIT