Menu
Reference
Server
final class Server
An HTTP/1.1 server: (new Server($addr))->serve($handler), where the handler is callable(Request): Response.
use Http\{Request, Response, Server};
(new Server('tcp://127.0.0.1:8080'))->serve(function (Request $req): Response {
return (new Response())->text('hello ' . $req->query('name', 'world') . "\n");
});The binary is the server: there is no fpm and no worker pool to configure. One process serves
many connections at once. Each connection is a task, and reads and writes suspend the task
instead of blocking the process, so a handler may use ordinary blocking-looking code. A tls://
address serves TLS.
Configure with the fluent setters below before calling serve(). Limits and timeouts and their
defaults:
| setter | default | on breach |
|---|---|---|
maxHeaderBytes | 16384 | 431 |
maxHeaderCount | 100 | 431 |
maxBodySize | 8388608 | 413, or the body is streamed |
maxFileUploads | 20 | further file parts are dropped |
uploadMaxFilesize | 2097152 | the part is kept with ERR_INI_SIZE |
maxInputVars | 1000 | extra pairs are dropped silently |
maxConnections | 4096 | the worker stops accepting; the kernel backlog queues |
keepAliveMax | 0 | the connection closes after N requests; 0 means never |
idleTimeout | 5.0 s | silent close between requests |
headerTimeout | 10.0 s | 408, once a request has started arriving |
writeTimeout | 30.0 s | the write is abandoned |
Requests the server refuses before the handler runs get a bodyless response and a closed
connection: 400 for a malformed request line or header, a header name followed by whitespace
before the colon, HTTP/1.1 without Host, or Transfer-Encoding together with
Content-Length; 411 for a body-bearing method without framing; 413, 417 for an Expect
other than 100-continue, 431, 501 for CONNECT, and 505 for any version other than 1.0
and 1.1. Expect: 100-continue is answered only after the request head has been accepted.
If the handler throws, the server answers 500 (or calls onError()) and keeps serving.
Forwarded headers are ignored unless the peer is listed in trustedProxies().
Example
// A handler is callable(Request): Response. Server and client share one process.
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
$server = Server::onListener($listener)->acceptWait(0.02);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n");
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
if ($req->path === '/hello') {
return (new Response())->text('hello ' . $req->query('name', 'world') . "\n");
}
return (new Response(404))->text("not found\n");
}));
foreach (['/hello', '/hello?name=Ada', '/missing'] as $target) {
$raw = fetch($port, "GET $target HTTP/1.1\r\nHost: localhost\r\n");
[$head, $body] = explode("\r\n\r\n", $raw, 2);
echo strtok($head, "\r\n"), ' | ', trim($body), "\n";
}
$server->stop();
$stats = $server->stats();
echo "served: {$stats['served']}\n";
});Output
HTTP/1.1 200 OK | hello world
HTTP/1.1 200 OK | hello Ada
HTTP/1.1 404 Not Found | not found
served: 3Example
// Limits and error handling: oversized heads and bodies, a throwing handler, onError().
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request, string $body = ''): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n" . $body);
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
$server = Server::onListener($listener)
->acceptWait(0.02)
->maxHeaderBytes(1024) // 431 when the head is larger
->maxBodySize(16) // 413 when the declared body is larger
->onError(fn (Throwable $e, Request $r) => (new Response(500))->text('caught: ' . $e->getMessage() . "\n"));
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
if ($req->path === '/boom') {
throw new RuntimeException('handler failed');
}
return (new Response())->text('ok, body of ' . strlen($req->body()) . " bytes\n");
}));
$try = function (string $label, string $request, string $body = '') use ($port): void {
$raw = fetch($port, $request, $body);
[$head, $payload] = explode("\r\n\r\n", $raw, 2);
echo str_pad($label, 16), strtok($head, "\r\n"), ' ', trim($payload), "\n";
};
$try('normal', "GET / HTTP/1.1\r\nHost: h\r\n");
$try('big header', "GET / HTTP/1.1\r\nHost: h\r\nX-Pad: " . str_repeat('a', 2000) . "\r\n");
$try('small body', "POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 5\r\n", 'hello');
$try('big body', "POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 17\r\n", str_repeat('x', 17));
$try('no length', "POST / HTTP/1.1\r\nHost: h\r\n");
$try('no Host', "GET / HTTP/1.1\r\n");
$try('HTTP/2.0', "GET / HTTP/2.0\r\nHost: h\r\n");
$try('handler throws', "GET /boom HTTP/1.1\r\nHost: h\r\n");
$server->stop();
});Output
normal HTTP/1.1 200 OK ok, body of 0 bytes
big header HTTP/1.1 431 Request Header Fields Too Large
small body HTTP/1.1 200 OK ok, body of 5 bytes
big body HTTP/1.1 413 Content Too Large
no length HTTP/1.1 411 Length Required
no Host HTTP/1.1 400 Bad Request
HTTP/2.0 HTTP/1.1 505 HTTP Version Not Supported
handler throws HTTP/1.1 500 Internal Server Error caught: handler failedSee also: Request, Response, Proxy
Constants
CTX_REQUEST | 'http.request' | The Async\Context key under which the current Request is bound for the duration of a request ('http.request'). |
Methods
__construct() | Creates a server. |
onListener() | Creates a server over a listener you have already bound. |
workers() | Serves with N forked worker processes sharing one listener. |
maxConnections() | The ceiling on simultaneous connections for this worker; default 4096 (a live connection costs about 32 KB). |
compat() | Seeds php's superglobals for every request, so existing php code can run in a handler. |
captureEcho() | Whether bytes a handler echoes become the response body. |
idleTimeout() | How long a connection may sit silent between requests before it is closed, in seconds. |
headerTimeout() | How long the rest of a request may take to arrive once its first bytes are in, in seconds. |
writeTimeout() | The timeout on the socket while a response is being written, in seconds. |
maxHeaderBytes() | The largest request head (request line plus headers) accepted, in bytes. |
maxHeaderCount() | The largest number of header fields accepted in a request. |
maxBodySize() | The largest request body that is buffered, in bytes. |
streamBodies() | Lets a body larger than maxBodySize be read incrementally instead of being refused with 413. |
compression() | Enables gzip for buffered text-like responses. |
keepAliveMax() | Closes a connection after this many requests. |
backlog() | The length of the listen queue; default 511. |
serverName() | The value of the Server: response header; default manticore. |
acceptWait() | How long, in seconds, an idle accept waits before the loop checks whether stop() was called. |
maxInputVars() | php's max_input_vars for queryArray() and postArray() (and the superglobals): default 1000. |
maxFileUploads() | php's max_file_uploads: the most file parts kept per multipart request; default 20. |
uploadMaxFilesize() | php's per-file upload_max_filesize, in bytes; default 2097152 (2 MiB). |
postMaxSize() | php's post_max_size. |
onError() | Sets the function that turns an exception thrown by a handler into a response. |
trustedProxies() | Declares which peers are reverse proxies whose forwarded headers are believed. |
serve() | Runs the server until stop() is called or the process is told to shut down. |
onStop() | Registers a hook that runs once when stop() is called. |
offStop() | Removes a hook registered with onStop(). |
stop() | Asks the server to wind down. |
stats() | Counters since the server started. |
__construct()#source
function __construct(string $addr = 'tcp://127.0.0.1:8080', mixed $context = null)
Creates a server. Nothing is bound until serve() is called.
An address starting with tls:// or ssl:// makes the connections TLS; give the certificate in
the stream context.
Parameters
$addrstring = 'tcp://127.0.0.1:8080'- A stream-socket address such as
tcp://0.0.0.0:8080. $contextmixed = null- An optional stream context, for example with
ssloptions orsocket.backlog.
onListener()#source
static function onListener(\Resource $listener, bool $secure = false): Server
Creates a server over a listener you have already bound.
This is what socket activation needs, and it lets a program bind its own port (or port 0), hand the socket over and run a client in the same process. The server does not close a listener it did not open.
Parameters
$listener\Resource- A bound, listening stream socket server, in non-blocking mode.
$securebool = false- Whether connections accepted from it are TLS.
Returns
Server — A new server.
Example
// A handler is callable(Request): Response. Server and client share one process.
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
$server = Server::onListener($listener)->acceptWait(0.02);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n");
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
if ($req->path === '/hello') {
return (new Response())->text('hello ' . $req->query('name', 'world') . "\n");
}
return (new Response(404))->text("not found\n");
}));
foreach (['/hello', '/hello?name=Ada', '/missing'] as $target) {
$raw = fetch($port, "GET $target HTTP/1.1\r\nHost: localhost\r\n");
[$head, $body] = explode("\r\n\r\n", $raw, 2);
echo strtok($head, "\r\n"), ' | ', trim($body), "\n";
}
$server->stop();
$stats = $server->stats();
echo "served: {$stats['served']}\n";
});Output
HTTP/1.1 200 OK | hello world
HTTP/1.1 200 OK | hello Ada
HTTP/1.1 404 Not Found | not found
served: 3workers()#source
function workers(int $n): Server
Serves with N forked worker processes sharing one listener.
The listener is bound once, then N workers inherit it; the kernel balances accepts between them.
A supervisor restarts a worker that crashes and forwards SIGTERM and SIGINT to all of them.
The parent serves nothing, and serve() returns once every worker has exited. 0 (the default)
serves in the current process. Ignored when serve() is called from inside an Async scope.
Parameters
$nint- Number of workers.
Returns
Server — This server.
maxConnections()#source
function maxConnections(int $n): Server
The ceiling on simultaneous connections for this worker; default 4096 (a live connection costs about 32 KB).
A permit is taken before accept, so at the ceiling the worker stops accepting and new
connections wait in the kernel backlog. Values below 1 are raised to 1.
Parameters
$nint- Connections.
Returns
Server — This server.
compat()#source
function compat(bool $on): Server
Seeds php's superglobals for every request, so existing php code can run in a handler. Off by default.
When on, $_SERVER, $_GET, $_POST, $_COOKIE, $_REQUEST, $_FILES and $_SESSION are
filled per request (and session_start() works), and from serve() on PHP_SAPI and
php_sapi_name() answer cli-server, process-wide. The superglobals are request-bound:
concurrent requests do not see each other's. A streamed body is not seeded into $_POST or
$_FILES. header(), setcookie(), http_response_code() and echo work in every handler
regardless of this setting.
Parameters
$onbool- Whether to seed the superglobals.
Returns
Server — This server.
Example
// php's header(), setcookie(), http_response_code() and echo work inside a handler.
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request, string $body = ''): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n" . $body);
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
$server = Server::onListener($listener)->acceptWait(0.02);
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
if ($req->path === '/legacy') {
header('X-Powered-By: legacy-code');
setcookie('visited', 'yes', 0, '/');
http_response_code(202);
echo "echoed body\n"; // becomes the body: the Response below has none
return new Response();
}
// The explicit API wins: Response status and body replace the ambient ones.
http_response_code(202);
echo "dropped\n";
return (new Response(201))->text("explicit body\n");
}));
foreach (['/legacy', '/explicit'] as $path) {
$raw = fetch($port, "GET $path HTTP/1.1\r\nHost: localhost\r\n");
[$head, $body] = explode("\r\n\r\n", $raw, 2);
echo "== $path\n";
foreach (explode("\r\n", $head) as $line) {
if (!str_starts_with($line, 'Date:') && !str_starts_with($line, 'Server:')) {
echo $line, "\n";
}
}
echo $body;
}
$server->stop();
});Output
== /legacy
HTTP/1.1 202 Accepted
X-Powered-By: legacy-code
Set-Cookie: visited=yes; path=/
Content-Length: 12
Connection: close
echoed body
== /explicit
HTTP/1.1 201 Created
Content-Type: text/plain; charset=utf-8
Content-Length: 14
Connection: close
explicit bodycaptureEcho()#source
function captureEcho(bool $on): Server
Whether bytes a handler echoes become the response body. On by default.
The echoed text is used only when the returned Response has no body and is not a stream or a
file. If both exist, the explicit body wins and the echoed bytes are dropped.
Parameters
$onbool- Whether to capture output.
Returns
Server — This server.
idleTimeout()#source
function idleTimeout(float $s): Server
How long a connection may sit silent between requests before it is closed, in seconds. Default 5.0. A client that sent nothing is closed without a response.
Parameters
$sfloat- Seconds; fractions are allowed.
Returns
Server — This server.
headerTimeout()#source
function headerTimeout(float $s): Server
How long the rest of a request may take to arrive once its first bytes are in, in seconds. Default 10.0. A client that stalls past it gets 408 and the connection is closed, so a client dribbling a head cannot hold a slot open.
Parameters
$sfloat- Seconds; fractions are allowed.
Returns
Server — This server.
writeTimeout()#source
function writeTimeout(float $s): Server
The timeout on the socket while a response is being written, in seconds. Default 30.0. It bounds a client that stops reading.
Parameters
$sfloat- Seconds; fractions are allowed.
Returns
Server — This server.
maxHeaderBytes()#source
function maxHeaderBytes(int $n): Server
The largest request head (request line plus headers) accepted, in bytes. Default 16384. A larger head is answered with 431 and the connection is closed.
Parameters
$nint- Bytes.
Returns
Server — This server.
maxHeaderCount()#source
function maxHeaderCount(int $n): Server
The largest number of header fields accepted in a request. Default 100. More is answered with 431 and the connection is closed.
Parameters
$nint- Fields.
Returns
Server — This server.
maxBodySize()#source
function maxBodySize(int $n): Server
The largest request body that is buffered, in bytes. Default 8388608 (8 MiB).
A larger declared Content-Length is answered with 413, unless streamBodies(true) is on, in
which case the handler gets Request::stream(). A chunked body is always buffered, so the cap
is checked against its running total and a larger one is a 413.
Parameters
$nint- Bytes.
Returns
Server — This server.
streamBodies()#source
function streamBodies(bool $on): Server
Lets a body larger than maxBodySize be read incrementally instead of being refused with 413. Off by default.
The handler then reads Request::stream() (or Request::multipart() for an upload), with the
declared length as its budget. Smaller bodies are still buffered. A body the handler ignores is
drained before the connection is reused.
Parameters
$onbool- Whether to stream oversized bodies.
Returns
Server — This server.
compression()#source
function compression(bool $on, int $minBytes = 1024, int $level = 6): Server
Enables gzip for buffered text-like responses. Off by default.
A response is compressed when its Content-Type is textual (text/*, JSON, JavaScript, XML,
SVG, WebAssembly or a +json/+xml type), the body is at least $minBytes long, the client's
Accept-Encoding allows gzip, and no Content-Encoding is set. A strong ETag becomes weak.
Vary: Accept-Encoding is added to every response of a compressible type, compressed or not.
Streamed bodies are never compressed, and a file is never compressed on the fly: a <path>.gz
sibling that is not older than the file is sent instead.
Parameters
$onbool- Whether to compress.
$minBytesint = 1024- Smallest body to compress, in bytes; negative values count as 0.
$levelint = 6- gzip level, passed to
gzencode().
Returns
Server — This server.
Example
// Opt-in gzip for buffered, text-like responses.
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request, string $body = ''): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n" . $body);
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
$server = Server::onListener($listener)->acceptWait(0.02)->compression(true, minBytes: 100);
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
return match ($req->path) {
'/big' => (new Response())->type('text/plain')->body(str_repeat('compress me ', 100)),
'/small' => (new Response())->type('text/plain')->body('tiny'),
default => (new Response())->type('image/png')->body(str_repeat('x', 500)),
};
}));
foreach ([['/big', 'gzip'], ['/big', 'identity'], ['/small', 'gzip'], ['/image', 'gzip']] as [$path, $accept]) {
$raw = fetch($port, "GET $path HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: $accept\r\n");
[$head, $body] = explode("\r\n\r\n", $raw, 2);
$h = [];
foreach (explode("\r\n", $head) as $line) {
if (str_contains($line, ': ')) {
[$k, $v] = explode(': ', $line, 2);
$h[strtolower($k)] = $v;
}
}
$decoded = ($h['content-encoding'] ?? '') === 'gzip' ? gzdecode($body) : $body;
echo str_pad("$path ($accept)", 18),
'encoding=', $h['content-encoding'] ?? '-',
' vary=', $h['vary'] ?? '-',
' wire<decoded=', var_export(strlen($body) < strlen($decoded), true),
' decoded=', strlen($decoded), "\n";
}
$server->stop();
});Output
/big (gzip) encoding=gzip vary=Accept-Encoding wire<decoded=true decoded=1200
/big (identity) encoding=- vary=Accept-Encoding wire<decoded=false decoded=1200
/small (gzip) encoding=- vary=Accept-Encoding wire<decoded=false decoded=4
/image (gzip) encoding=- vary=- wire<decoded=false decoded=500keepAliveMax()#source
function keepAliveMax(int $n): Server
Closes a connection after this many requests. 0 (the default) never closes it for that reason.
Parameters
$nint- Requests per connection.
Returns
Server — This server.
backlog()#source
function backlog(int $n): Server
The length of the listen queue; default 511. The kernel clamps it to somaxconn, and a full queue makes a connect fail with a reset. A socket.backlog option in the stream context takes precedence.
Parameters
$nint- Queue length.
Returns
Server — This server.
serverName()#source
function serverName(string $s): Server
The value of the Server: response header; default manticore. An empty string omits the header. A handler's own Server header is kept.
Parameters
$sstring- Header value.
Returns
Server — This server.
acceptWait()#source
function acceptWait(float $s): Server
How long, in seconds, an idle accept waits before the loop checks whether stop() was called. Default 0.25. It bounds shutdown latency; lower it in tests.
Parameters
$sfloat- Seconds.
Returns
Server — This server.
maxInputVars()#source
function maxInputVars(int $n): Server
php's max_input_vars for queryArray() and postArray() (and the superglobals): default 1000. Pairs beyond the limit are dropped silently. Values below 1 are raised to 1.
Parameters
$nint- Variables.
Returns
Server — This server.
maxFileUploads()#source
function maxFileUploads(int $n): Server
php's max_file_uploads: the most file parts kept per multipart request; default 20. Further file parts are dropped. Negative values count as 0.
Parameters
$nint- Files.
Returns
Server — This server.
uploadMaxFilesize()#source
function uploadMaxFilesize(int $n): Server
php's per-file upload_max_filesize, in bytes; default 2097152 (2 MiB). A larger file part is kept with UploadedFile::ERR_INI_SIZE, no temporary file and size 0. Negative values count as 0.
Parameters
$nint- Bytes.
Returns
Server — This server.
postMaxSize()#source
function postMaxSize(int $n): Server
php's post_max_size. Reserved — not enforced in either mode: a buffered body is already bounded by {@see maxBodySize}, and a streamed one's field bytes are the handler's ({@see Part::readAll}).
Parameters
$nint- Bytes.
Returns
Server — This server.
onError()#source
function onError(callable $fn): Server
Sets the function that turns an exception thrown by a handler into a response.
Without it the server answers 500 Internal Server Error and closes the connection. If this
function throws too, the same canned 500 is used. Cancellation (shutdown) is not passed to it.
Parameters
$fncallable- A
callable(Throwable, Request): Response.
Returns
Server — This server.
Example
// Limits and error handling: oversized heads and bodies, a throwing handler, onError().
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request, string $body = ''): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n" . $body);
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
$server = Server::onListener($listener)
->acceptWait(0.02)
->maxHeaderBytes(1024) // 431 when the head is larger
->maxBodySize(16) // 413 when the declared body is larger
->onError(fn (Throwable $e, Request $r) => (new Response(500))->text('caught: ' . $e->getMessage() . "\n"));
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
if ($req->path === '/boom') {
throw new RuntimeException('handler failed');
}
return (new Response())->text('ok, body of ' . strlen($req->body()) . " bytes\n");
}));
$try = function (string $label, string $request, string $body = '') use ($port): void {
$raw = fetch($port, $request, $body);
[$head, $payload] = explode("\r\n\r\n", $raw, 2);
echo str_pad($label, 16), strtok($head, "\r\n"), ' ', trim($payload), "\n";
};
$try('normal', "GET / HTTP/1.1\r\nHost: h\r\n");
$try('big header', "GET / HTTP/1.1\r\nHost: h\r\nX-Pad: " . str_repeat('a', 2000) . "\r\n");
$try('small body', "POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 5\r\n", 'hello');
$try('big body', "POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 17\r\n", str_repeat('x', 17));
$try('no length', "POST / HTTP/1.1\r\nHost: h\r\n");
$try('no Host', "GET / HTTP/1.1\r\n");
$try('HTTP/2.0', "GET / HTTP/2.0\r\nHost: h\r\n");
$try('handler throws', "GET /boom HTTP/1.1\r\nHost: h\r\n");
$server->stop();
});Output
normal HTTP/1.1 200 OK ok, body of 0 bytes
big header HTTP/1.1 431 Request Header Fields Too Large
small body HTTP/1.1 200 OK ok, body of 5 bytes
big body HTTP/1.1 413 Content Too Large
no length HTTP/1.1 411 Length Required
no Host HTTP/1.1 400 Bad Request
HTTP/2.0 HTTP/1.1 505 HTTP Version Not Supported
handler throws HTTP/1.1 500 Internal Server Error caught: handler failedtrustedProxies()#source
function trustedProxies(array<int,string> $cidrs, int $flags = Proxy::ALL): Server
Declares which peers are reverse proxies whose forwarded headers are believed. Off unless called, because any client can send those headers.
When the connecting peer is in $cidrs, X-Forwarded-For is walked from the right to the first
hop that is not itself trusted, and that hop becomes Request::$remoteAddr. A hop that is not
an IP address (unknown, an obfuscated identifier) is skipped. X-Forwarded-Proto, -Host and
-Port take their first (leftmost) value and set Request::$secure, the Host header and
Request::$forwardedPort. Only the headers whose flag is in $flags are used.
Request::$peerAddr is always the socket's own address. With Proxy::FORWARDED, RFC 7239
Forwarded: is read too and wins for the fields it names.
A dual-stack listener shows an IPv4 client as ::ffff:a.b.c.d; a plain IPv4 CIDR never matches
it, so list the mapped form (::ffff:10.0.0.0/104) or an IPv6 prefix.
Parameters
$cidrsarray<int, string>- Addresses or networks such as
10.0.0.0/8,127.0.0.1or2001:db8::/32. $flagsint = Proxy::ALL- Which headers to trust:
Proxybits,Proxy::ALLby default.
Returns
Server — This server.
Throws
- \InvalidArgumentException
- when an entry is not a valid address or CIDR
Example
// Behind a reverse proxy: forwarded headers are believed only from trusted peers.
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request, string $body = ''): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n" . $body);
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
function run(int $port, Server $server, string $label): void
{
$raw = fetch($port, "GET / HTTP/1.1\r\nHost: internal\r\nX-Forwarded-For: 203.0.113.7, 10.1.2.3\r\nX-Forwarded-Proto: https\r\nX-Forwarded-Host: shop.example\r\n");
echo $label, ': ', trim(explode("\r\n\r\n", $raw, 2)[1]), "\n";
}
$server = Server::onListener($listener)->acceptWait(0.02);
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
// The peer is always the socket; remoteAddr may come from a trusted proxy.
return (new Response())->text(
'remote=' . (str_starts_with($req->remoteAddr, '127.0.0.1:') ? 'peer' : $req->remoteAddr)
. ' secure=' . var_export($req->secure, true)
. ' host=' . $req->header('Host')
. ' peer-is-loopback=' . (str_starts_with($req->peerAddr, '127.0.0.1:') ? 'yes' : 'no')
);
}));
run($port, $server, 'untrusted ');
// Settings are read when a connection is accepted, so this applies to the next request.
// X-Forwarded-For is walked right to left; the first hop that is not trusted is the client.
$server->trustedProxies(['127.0.0.1', '10.0.0.0/8'], Http\Proxy::ALL);
run($port, $server, 'trusted ');
// Grant only FOR: Proto and Host are ignored, and 10.1.2.3 is not in the list, so it is the client.
$server->trustedProxies(['127.0.0.1'], Http\Proxy::FOR);
run($port, $server, 'FOR only ');
$server->stop();
});Output
untrusted : remote=peer secure=false host=internal peer-is-loopback=yes
trusted : remote=203.0.113.7 secure=true host=shop.example peer-is-loopback=yes
FOR only : remote=10.1.2.3 secure=false host=internal peer-is-loopback=yesSee also: Proxy
serve()#source
function serve(callable $handler): void
Runs the server until stop() is called or the process is told to shut down.
Called from outside an Async scope (the normal case) it binds the address, installs SIGTERM
and SIGINT handlers, and blocks until shutdown; with workers(N) it forks the workers
instead. Called from inside an Async scope it runs the accept loop as a task of that scope,
which lets one program host both the server and a client, as the examples do. Requests in flight
at shutdown are finished, not cut.
Each request runs the handler inside its own Async\Context scope, so Http\request() and
values bound with Async\Context::withValue() are visible to tasks the handler spawns and to
nothing else.
Parameters
$handlercallable- A
callable(Request): Response.
Throws
- \RuntimeException
- when the address cannot be bound
Example
// A handler is callable(Request): Response. Server and client share one process.
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
$server = Server::onListener($listener)->acceptWait(0.02);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n");
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
if ($req->path === '/hello') {
return (new Response())->text('hello ' . $req->query('name', 'world') . "\n");
}
return (new Response(404))->text("not found\n");
}));
foreach (['/hello', '/hello?name=Ada', '/missing'] as $target) {
$raw = fetch($port, "GET $target HTTP/1.1\r\nHost: localhost\r\n");
[$head, $body] = explode("\r\n\r\n", $raw, 2);
echo strtok($head, "\r\n"), ' | ', trim($body), "\n";
}
$server->stop();
$stats = $server->stats();
echo "served: {$stats['served']}\n";
});Output
HTTP/1.1 200 OK | hello world
HTTP/1.1 200 OK | hello Ada
HTTP/1.1 404 Not Found | not found
served: 3onStop()#source
function onStop(\Closure $fn): int
Registers a hook that runs once when stop() is called.
This is how a long-lived takeover (such as a WebSocket session) learns that the server is going down. If the server is already stopped, the hook runs immediately. A hook that throws does not keep the others from running.
Parameters
$fn\Closure- The hook.
Returns
int — An id for offStop().
offStop()#source
function offStop(int $id): void
Removes a hook registered with onStop(). An unknown id is ignored.
Parameters
$idint- The id
onStop()returned.
stop()#source
function stop(): void
Asks the server to wind down.
The accept loop stops taking new connections within acceptWait() seconds; requests already in
flight are joined, not interrupted. Every onStop() hook runs once. Calling it again does
nothing.
stats()#source
function stats(): array<string,int>
Counters since the server started.
served: responses writtenopen: connections open nowaccepted: connections acceptederrors: refused requests, handler exceptions, timeouts and failed writesupgraded: connections handed over byResponse::takeover()stopped:1afterstop(), else0
Returns
array<string,int> — served / open / accepted / errors / upgraded / stopped