diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1edbf19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +composer.phar +/.idea/ +.DS_Store +.~lock.* +/composer.lock diff --git a/README.md b/README.md index f826658..d0ac358 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,44 @@ # reactphp-http-browser-curl -Implementation of an Async HTTP client using CURL and Fibers +Implementation of an Async HTTP client using CURL and Fibers. + +*** NOTE *** This is a work in progress, GET requests work however other HTTP verbs have not yet been implemented. + +## Why not use react/http Browser? +Using cURL allows for HTTP/2, and the extraction of timing data for the requests. This functionality is not available though the ReactPHP Browser implementation + +## Requirements + +The package is compatible with PHP 8.0+ and requires the cURL extension and [react/event-loop](https://github.com/reactphp/http) library. + +## Installation + +You can add the library as project dependency using [Composer](https://getcomposer.org/): + +```sh +composer require edgetelemetrics/reactphp-http-browser-curl +``` + +## Examples +See [/examples](/examples) directory + +## Timing +Request timing values are returned in the PSR7 Response object headers under the key [Server-Timing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing) + +## Configuration +The Browser can be configured with standard CURLOPT_* parameters given via the constructor. + +```php +$browser = new Browser([ + CURLOPT_TIMEOUT => 20, + CURLOPT_DOH_URL, 'https://1.1.1.1/dns-query', + CURLOPT_DNS_SERVERS => '1.1.1.1', +]); +``` + +## License + +MIT, see [LICENSE file](LICENSE). + +### Contributing + +Bug reports (and small patches) can be submitted via the [issue tracker](https://github.com/lucasnetau/reactphp-http-browser-curl/issues). Forking the repository and submitting a Pull Request is preferred for substantial patches. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a93d0ab --- /dev/null +++ b/composer.json @@ -0,0 +1,35 @@ +{ + "name": "edgetelemetrics/reactphp-http-browser-curl", + "description": "An async http client using Curl and Fibers", + "minimum-stability": "stable", + "license": "MIT", + "type": "library", + "authors": [ + { + "name": "James Lucas", + "email": "james@lucas.net.au" + } + ], + "require": { + "php": "^8.1", + "react/event-loop": "^1.2", + "ext-curl": "*", + "psr/http-message":"^1.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "react/http": "^1.6", + "react/dns": "*", + "ringcentral/psr7": "^1.2" + }, + "autoload": { + "psr-4": { + "EdgeTelemetrics\\React\\Http\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "EdgeTelemetrics\\React\\Http\\Tests\\": "tests/" + } + } +} \ No newline at end of file diff --git a/examples/simple.php b/examples/simple.php new file mode 100644 index 0000000..be643c3 --- /dev/null +++ b/examples/simple.php @@ -0,0 +1,20 @@ + 20, + //CURLOPT_DOH_URL, 'https://1.1.1.1/dns-query', + //CURLOPT_DNS_SERVERS => '1.1.1.1', +]); + +$browser->get("https://raw.githubusercontent.com/lucasnetau/reactphp-http-browser-curl/main/LICENSE")->then(function($response) use($browser) { + /** @var \Psr\Http\Message\ResponseInterface $response */ + echo $response->getStatusCode() . " " . $response->getReasonPhrase() . PHP_EOL; + print_r($response->getHeaders()); + print_r((string)$response->getBody()); +}, function($ex) { + echo 'Download failed: ' . $ex->getMessage() . PHP_EOL; +}); \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..c56acfe --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + + + tests/ + + + + + src/ + + + \ No newline at end of file diff --git a/src/Browser.php b/src/Browser.php new file mode 100644 index 0000000..566f6c5 --- /dev/null +++ b/src/Browser.php @@ -0,0 +1,215 @@ + true, + CURLOPT_FORBID_REUSE => true, + CURLOPT_DNS_CACHE_TIMEOUT => 1, + ]; + + const DEFAULT_CURL_OPTIONS = [ + CURLOPT_HEADER => false, //We will write headers out to a separate file + CURLOPT_CONNECTTIMEOUT => 30, + CURLOPT_TIMEOUT => 120, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CERTINFO => true, + CURLOPT_TCP_NODELAY => true, + ]; + + private \SplObjectStorage $inprogress; + + /** + * @param EventLoop\LoopInterface|null $loop + * @param array $options + */ + public function __construct(protected array $options = [], protected ?EventLoop\LoopInterface $loop = null) { + if (!isset($this->loop)) { + $this->loop = EventLoop\Loop::get(); + } + + $this->inprogress = new \SplObjectStorage(); + } + + public function get(string $url, array $headers = []) : PromiseInterface { + $curl = $this->initCurl(); + curl_setopt($curl, CURLOPT_URL, $url); + + if (!empty($headers)) { + curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + } + + $deferred = new Deferred(); + $fileHandle = fopen('php://temp', 'w+'); + if ($fileHandle === false) { + throw new \RuntimeException('Unable to create temporary file for response body'); + } + $headerHandle = fopen('php://temp', 'w+'); + if ($headerHandle === false) { + throw new \RuntimeException('Unable to create temporary file for response headers'); + } + $fiber = $this->initFiber($curl); + $this->inprogress[$fiber] = [ + 'deferred' => $deferred, + 'file' => $fileHandle, + 'headers' => $headerHandle, + ]; + curl_setopt($curl, CURLOPT_FILE, $fileHandle); + curl_setopt($curl, CURLOPT_WRITEHEADER, $headerHandle); + + //Kickstart the handler any time we initiate a new request and no requests are currently in the queue + if (count($this->inprogress) === 1) { + $this->loop->futureTick($this->curlTick(...)); + } + + return $deferred->promise(); + } + + /** + * Don't apply any additional configuration changes which remove or limit cURL connection caches / reuse + * @return void + */ + public function enableConnectionCaches() : void { + $this->disableCurlCache = false; + } + + /** + * Disable as much of cURLs internal caches (DNS resolution) and connection reuse. Useful when performing a health check + * @return void + */ + public function disableConnectionCaches() : void { + $this->disableCurlCache = true; + } + + private function initCurl() : CurlHandle { + $curl = curl_init(); + + if ($curl === false) { + throw new \RuntimeException('Unable to init curl'); + } + + //@TODO remove any options that will conflict with out internal working. Eg CURLOPT_FILE, CURLOPT_WRITEHEADER, etc. + $options = $this->options + self::DEFAULT_CURL_OPTIONS; + + if ($this->disableCurlCache) { + $options = $options + static::NO_CACHE_OPTIONS; + } + + curl_setopt_array($curl, $options); + + return $curl; + } + + private function initFiber(CurlHandle $curl) : Fiber { + $multi = curl_multi_init(); + $return = curl_multi_add_handle($multi, $curl); + + if ($return !== 0) { + curl_multi_close($multi); + throw new \RuntimeException('Unable to add curl to multi handle, Error:' . $return . ", Msg: " . curl_multi_strerror($return)); + } + + $fiber = new Fiber(function (CurlMultiHandle $mh) use(&$fiber) { + $still_running = null; + do { + curl_multi_exec($mh, $still_running); + if ($still_running) { + Fiber::suspend(); + } + } while ($still_running); + $info = curl_multi_info_read($mh); + $curl = $info["handle"]; + curl_multi_remove_handle($mh, $curl); + curl_multi_close($mh); + + $deferred = $this->inprogress[$fiber]['deferred']; + if ($info['result'] === CURLE_OK) { + $responseBodyHandle = $this->inprogress[$fiber]['file']; + stream_set_blocking($responseBodyHandle, false); + rewind($responseBodyHandle); + $responseHeaderHandle = $this->inprogress[$fiber]['headers']; + rewind($responseHeaderHandle); + $headers = stream_get_contents($responseHeaderHandle); + $deferred->resolve($this->constructResponseFromCurl($curl, $headers, $responseBodyHandle)); //@TODO implement ReactPHP Browser withRejectErrorResponse support + } else { + $deferred->reject(new ConnectionException($curl)); + } + curl_close($curl); + }); + + $fiber->start($multi); + return $fiber; + } + + private function curlTick(): void + { + foreach($this->inprogress as $fiber) { + if ($fiber->isTerminated()) { + unset($this->inprogress[$fiber]); + } else { + $fiber->resume(); + } + } + + if (count($this->inprogress)) { + $this->loop->addTimer(0.01, $this->curlTick(...)); //use a timer instead of futureTick so that we don't lock the CPU at 100% + } + } + + private function constructResponseFromCurl(CurlHandle $curl, string $rawHeaders, $body) : ResponseInterface { + $headers = []; + $lines = preg_split('/(\\r?\\n)/', trim($rawHeaders), -1); + array_shift($lines); + foreach($lines as $headerLine) { + $parts = explode(':', $headerLine, 2); + $key = trim($parts[0]); + $value = isset($parts[1]) ? trim($parts[1]) : ''; + $headers[$key][] = $value; + } + + $info = curl_getinfo($curl); + $timing = []; + foreach(['total_time',"namelookup_time", "connect_time", "pretransfer_time", "starttransfer_time", "redirect_time"] as $timingKey) { + $timing[] = "$timingKey;dur=". $info[$timingKey]; + } + $headers['ServerTiming'] = $timing; + + return new Psr7Response( + curl_getinfo($curl, CURLINFO_RESPONSE_CODE), + $headers, + \RingCentral\Psr7\stream_for($body), + curl_getinfo($curl, CURLINFO_HTTP_VERSION), + ); + } +} diff --git a/src/ConnectionException.php b/src/ConnectionException.php new file mode 100644 index 0000000..cea5a09 --- /dev/null +++ b/src/ConnectionException.php @@ -0,0 +1,29 @@ +server = new \React\Http\HttpServer( + function (\Psr\Http\Message\ServerRequestInterface $request) { + $path = $request->getUri()->getPath(); + $method = $request->getMethod(); + + if ($method === 'GET') { + return match($path) { + '/file/128kb' => $this->streamFile(128), + '/file/1mb' => $this->streamFile(1024), + '/file/10mb' => $this->streamFile(10240), + '/file/50mb' => $this->streamFile(51200), + '/file/sleep' => $this->latency(), + default => Response::plaintext( + "Hello World!\n" + ), + }; + } + + return Response::plaintext( + "Hello World!\n" + ); + } + ); + + $socket = new \React\Socket\SocketServer('127.0.0.1:0'); + $this->server->listen($socket); + $this->testServerAddress = str_replace('tcp://', 'http://', $socket->getAddress()); + } + + public function latency() : Response { + $stream = new ThroughStream(); + + Loop::addTimer(10, function() use ($stream) { + $stream->write('slept for a while'); + $stream->end(); + }); + + return new Response( + StatusCodeInterface::STATUS_OK, + array( + 'Content-Type' => 'text/plain' + ), + $stream + ); + } + + public function streamFile($sizeInKb): Response + { + $stream = new ThroughStream(); + + // send some data every once in a while with periodic timer + $sizeInBytes = $sizeInKb * 1024; + $written = 0; + $timer = Loop::addPeriodicTimer(0.1, function () use ($stream, $sizeInBytes, &$written) { + $remaining = (int)floor(min($sizeInBytes-$written, (1024*1024*2/10))); + $bytes = str_repeat('a', $remaining); + $stream->write($bytes); + //echo PHP_EOL. 'wrote' . $remaining . ':'; + $written += $remaining; + //echo ($sizeInBytes-$written) . ' remaining' . PHP_EOL; + if ($written >= $sizeInBytes) { + //echo PHP_EOL . $sizeInBytes . 'finished' . PHP_EOL; + $stream->end(); + } + }); + + // stop timer if stream is closed (such as when connection is closed) + $stream->on('close', function () use ($timer) { + Loop::cancelTimer($timer); + }); + + return new Response( + StatusCodeInterface::STATUS_OK, + array( + 'Content-Type' => 'text/plain' + ), + $stream + ); + } + + /* public function testGetGoogleHome() + { + $browser = new Browser(); + + $promise = $browser->get('https://www.google.com/'); + + $answer = null; + $promise->then(function ($result) use (&$answer) { + $answer = $result; + }, 'print_r'); + + Loop::run(); + + $this->assertNotNull($answer); +x }*/ + + public function testLocal() + { + $browser = new Browser(); + + $promise = $browser->get($this->testServerAddress . '/helloworld'); + + $answer = null; + $promise->then(function ($result) use (&$answer) { + /** @var ResponseInterface $result */ + $answer = (string)$result->getBody(); + }, 'print_r')->always(function() { + Loop::stop(); + }); + + Loop::run(); + + $this->assertNotNull($answer); + $this->assertEquals("Hello World!\n", $answer, "Server did not say hello to us"); + } + + public function testFull() + { + $browser = new Browser([CURLOPT_TIMEOUT => 180]); + + $answer = []; + $order = []; + $promises = []; + + $start = hrtime(true); + foreach(['10mb','sleep','1mb','128kb','128kb','128kb','128kb'] as $size) { + $promise = $browser->get($this->testServerAddress . '/file/' . $size); + $promise->then(function ($result) use (&$answer, &$order, $size, $start) { + $answer[] = $result; + $order[] = $size; + }, 'print_r'); + $promises[] = $promise; + } + + Loop::addPeriodicTimer(1, function () { + static $last = 0; + if ($last === 0) { + $last = hrtime(true); + return; + } + //echo 'mark ' . ((hrtime(true) - $last)/1e+9) . PHP_EOL; + $last = hrtime(true); + }); + + \React\Promise\all($promises)->always(function () { + echo "stopping" . PHP_EOL; + Loop::stop(); + }); + + Loop::run(); + + $this->assertNotEmpty($answer); + } +}