reactphp/socket-client

View on GitHub
src/TcpConnector.php

Summary

Maintainability
A
3 hrs
Test Coverage
<?php

namespace React\SocketClient;

use React\EventLoop\LoopInterface;
use React\Stream\Stream;
use React\Promise;

final class TcpConnector implements ConnectorInterface
{
    private $loop;
    private $context;

    public function __construct(LoopInterface $loop, array $context = array())
    {
        $this->loop = $loop;
        $this->context = $context;
    }

    public function connect($uri)
    {
        if (strpos($uri, '://') === false) {
            $uri = 'tcp://' . $uri;
        }

        $parts = parse_url($uri);
        if (!$parts || !isset($parts['scheme'], $parts['host'], $parts['port']) || $parts['scheme'] !== 'tcp') {
            return Promise\reject(new \InvalidArgumentException('Given URI "' . $uri . '" is invalid'));
        }

        $ip = trim($parts['host'], '[]');
        if (false === filter_var($ip, FILTER_VALIDATE_IP)) {
            return Promise\reject(new \InvalidArgumentException('Given URI "' . $ip . '" does not contain a valid host IP'));
        }

        // use context given in constructor
        $context = array(
            'socket' => $this->context
        );

        // parse arguments from query component of URI
        $args = array();
        if (isset($parts['query'])) {
            parse_str($parts['query'], $args);
        }

        // If an original hostname has been given, use this for TLS setup.
        // This can happen due to layers of nested connectors, such as a
        // DnsConnector reporting its original hostname.
        // These context options are here in case TLS is enabled later on this stream.
        // If TLS is not enabled later, this doesn't hurt either.
        if (isset($args['hostname'])) {
            $context['ssl'] = array(
                'SNI_enabled' => true,
                'peer_name' => $args['hostname']
            );

            // Legacy PHP < 5.6 ignores peer_name and requires legacy context options instead.
            // The SNI_server_name context option has to be set here during construction,
            // as legacy PHP ignores any values set later.
            if (PHP_VERSION_ID < 50600) {
                $context['ssl'] += array(
                    'SNI_server_name' => $args['hostname'],
                    'CN_match' => $args['hostname']
                );
            }
        }

        // HHVM fails to parse URIs with a query but no path, so let's add a dummy path
        // See also https://3v4l.org/jEhLF
        if (defined('HHVM_VERSION') && isset($parts['query']) && !isset($parts['path'])) {
            $uri = str_replace('?', '/?', $uri);
        }

        $socket = @stream_socket_client(
            $uri,
            $errno,
            $errstr,
            0,
            STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT,
            stream_context_create($context)
        );

        if (false === $socket) {
            return Promise\reject(new \RuntimeException(
                sprintf("Connection to %s failed: %s", $uri, $errstr),
                $errno
            ));
        }

        stream_set_blocking($socket, 0);

        // wait for connection

        return $this
            ->waitForStreamOnce($socket)
            ->then(array($this, 'checkConnectedSocket'))
            ->then(array($this, 'handleConnectedSocket'));
    }

    private function waitForStreamOnce($stream)
    {
        $loop = $this->loop;

        return new Promise\Promise(function ($resolve) use ($loop, $stream) {
            $loop->addWriteStream($stream, function ($stream) use ($loop, $resolve) {
                $loop->removeWriteStream($stream);

                $resolve($stream);
            });
        }, function () use ($loop, $stream) {
            $loop->removeWriteStream($stream);
            fclose($stream);

            throw new \RuntimeException('Cancelled while waiting for TCP/IP connection to be established');
        });
    }

    /** @internal */
    public function checkConnectedSocket($socket)
    {
        // The following hack looks like the only way to
        // detect connection refused errors with PHP's stream sockets.
        if (false === stream_socket_get_name($socket, true)) {
            fclose($socket);

            return Promise\reject(new \RuntimeException('Connection refused'));
        }

        return Promise\resolve($socket);
    }

    /** @internal */
    public function handleConnectedSocket($socket)
    {
        return new StreamConnection($socket, $this->loop);
    }
}