Menu

Reference / Http

Server

finalclass
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:

setterdefaulton breach
maxHeaderBytes16384431
maxHeaderCount100431
maxBodySize8388608413, or the body is streamed
maxFileUploads20further file parts are dropped
uploadMaxFilesize2097152the part is kept with ERR_INI_SIZE
maxInputVars1000extra pairs are dropped silently
maxConnections4096the worker stops accepting; the kernel backlog queues
keepAliveMax0the connection closes after N requests; 0 means never
idleTimeout5.0 ssilent close between requests
headerTimeout10.0 s408, once a request has started arriving
writeTimeout30.0 sthe 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: 3

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 failed

See 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

$addr string = 'tcp://127.0.0.1:8080'
A stream-socket address such as tcp://0.0.0.0:8080.
$context mixed = null
An optional stream context, for example with ssl options or socket.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.
$secure bool = 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: 3

workers()#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

$n int
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

$n int
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

$on bool
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 body

captureEcho()#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

$on bool
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

$s float
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

$s float
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

$s float
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

$n int
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

$n int
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

$n int
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

$on bool
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

$on bool
Whether to compress.
$minBytes int = 1024
Smallest body to compress, in bytes; negative values count as 0.
$level int = 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=500

keepAliveMax()#source

function keepAliveMax(int $n): Server

Closes a connection after this many requests. 0 (the default) never closes it for that reason.

Parameters

$n int
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

$n int
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

$s string
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

$s float
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

$n int
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

$n int
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

$n int
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

$n int
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

$fn callable
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 failed

trustedProxies()#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

$cidrs array<int, string>
Addresses or networks such as 10.0.0.0/8, 127.0.0.1 or 2001:db8::/32.
$flags int = Proxy::ALL
Which headers to trust: Proxy bits, Proxy::ALL by 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=yes

See 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

$handler callable
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: 3

See also: Request, Response

onStop()#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

$id int
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 written
  • open: connections open now
  • accepted: connections accepted
  • errors: refused requests, handler exceptions, timeouts and failed writes
  • upgraded: connections handed over by Response::takeover()
  • stopped: 1 after stop(), else 0

Returns

array<string,int> — served / open / accepted / errors / upgraded / stopped

View source