biurad/flange

View on GitHub
src/Commands/ServerCommand.php

Summary

Maintainability
D
2 days
Test Coverage
<?php declare(strict_types=1);

/*
 * This file is part of Biurad opensource projects.
 *
 * @copyright 2019 Biurad Group (https://biurad.com/)
 * @license   https://opensource.org/licenses/BSD-3-Clause License
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Flange\Commands;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;

/**
 * Runs|Stops a local web server in a background process.
 *
 * @author Divine Niiquaye Ibok <divineibok@gmail.com>
 */
#[AsCommand('serve', 'Runs|Stops a local web server in a background process')]
final class ServerCommand extends Command
{
    protected static $defaultName = 'serve';
    protected static $defaultDescription = 'Runs|Stops a local web server in a background process';

    private string $router, $hostname, $address;
    private int $port;

    public function __construct(private ?string $documentRoot, private bool $debug)
    {
        parent::__construct();
    }

    /**
     * {@inheritdoc}
     */
    protected function configure(): void
    {
        $this
            ->setDefinition([
                new InputArgument('addressport', InputArgument::OPTIONAL, 'The address to listen to (can be address:port, address, or port)'),
                new InputOption('docroot', 'd', InputOption::VALUE_REQUIRED, 'Document root'),
                new InputOption('router', 'r', InputOption::VALUE_REQUIRED, 'Path to custom router script'),
                new InputOption('pidfile', null, InputOption::VALUE_REQUIRED, 'PID file'),
                new InputOption('stop', 's', InputOption::VALUE_NONE, 'Stops the local web server that was started with the serve command'),
            ])
            ->setHelp(
                <<<'EOF'
The <info>%command.name%</info> command runs a local web server: By default, the server
listens on <comment>127.0.0.1</> address and the port number is automatically selected
as the first free port starting from <comment>8000</>:

  <info>php %command.full_name%</info>

If your PHP version supports <info>pcntl extension</info>, the server will run in the background
and you can keep executing other commands. Execute <comment>php %command.full_name% --stop</> to stop it.

Else command will block the console. If you want to run other commands, stop it by
pressing <comment>Control+C</> instead.

Change the default address and port by passing them as an argument:

  <info>php %command.full_name% 127.0.0.1:8080</info>

Use the <info>--docroot</info> option to change the default docroot directory:

  <info>php %command.full_name% --docroot=htdocs/</info>

Specify your own router script via the <info>--router</info> option:

  <info>php %command.full_name% --router=app/config/router.php</info>

See also: http://www.php.net/manual/en/features.commandline.webserver.php
EOF
            )
        ;
    }

    /**
     * {@inheritdoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);

        if (!$this->debug) {
            $io->error('Running this server in production environment is NOT recommended!');

            return 1;
        }

        if ($input->getOption('stop')) {
            $pidFile = $input->getOption('pidfile') ?? self::getDefaultPidFile();

            if (!\file_exists($pidFile)) {
                $io->error('No web server is listening.');

                return 1;
            }

            if (\unlink($pidFile)) {
                $io->success('Web server stopped successfully');
            }

            return self::SUCCESS;
        }

        if (null === $documentRoot = $input->getOption('docroot') ?? $this->documentRoot) {
            $io->error('The document root directory must be either passed as first argument of the constructor or through the "--docroot" input option.');

            return 1;
        }

        if (null !== $router = $input->getOption('router')) {
            $absoluteRouterPath = \realpath($router);

            if (false === $absoluteRouterPath) {
                throw new \InvalidArgumentException(\sprintf('Router script "%s" does not exist.', $router));
            }
        }

        $this->findFrontController($this->documentRoot = $documentRoot);
        $this->router = $router ?? __DIR__.'/../Resources/dev-router.php';
        $this->address = $this->findServerAddress($input->getArgument('addressport'));

        if (!\extension_loaded('pcntl')) {
            $io->error('This command needs the pcntl extension to run.');

            if ($io->confirm('Do you want to execute <info>built in server run</info> immediately?', false)) {
                return $this->runBlockingServer($io, $input, $output);
            }

            return 1;
        }

        try {
            $pidFile = $input->getOption('pidfile') ?? self::getDefaultPidFile();

            if ($this->isRunning($pidFile)) {
                $io->error(\sprintf('The web server has already been started. It is currently listening on http://%s. Please stop the web server before you try to start it again.', \file_get_contents($pidFile)));

                return 1;
            }

            if (self::SUCCESS === $this->start($pidFile)) {
                $message = \sprintf('Server listening on http://%s', $this->address);

                if ('' !== $displayAddress = $this->getDisplayAddress()) {
                    $message = \sprintf('Server listening on all interfaces, port %s -- see http://%s', $this->port, $displayAddress);
                }
                $io->success($message);

                if (\ini_get('xdebug.profiler_enable_trigger')) {
                    $io->comment('Xdebug profiler trigger enabled.');
                }
            }
        } catch (\Exception $e) {
            $io->error($e->getMessage());

            return 1;
        }

        return self::SUCCESS;
    }

    private static function getDefaultPidFile(): string
    {
        return \getcwd().'/.web-server-pid';
    }

    private function runBlockingServer(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
    {
        $callback = null;
        $disableOutput = false;

        if ($output->isQuiet()) {
            $disableOutput = true;
        } else {
            $callback = static function ($type, $buffer) use ($output): void {
                if (Process::ERR === $type && $output instanceof ConsoleOutputInterface) {
                    $output = $output->getErrorOutput();
                }

                $output->write($buffer, false, OutputInterface::OUTPUT_RAW);
            };
        }

        if ('' !== $displayAddress = $this->getDisplayAddress()) {
            $message = \sprintf('Server listening on all interfaces, port %s -- see http://%s', $this->port, $displayAddress);
        }
        $io->success($message ?? \sprintf('Server listening on http://%s', $this->address));

        if (\ini_get('xdebug.profiler_enable_trigger')) {
            $io->comment('Xdebug profiler trigger enabled.');
        }
        $io->comment('Quit the server with CONTROL-C.');

        if ($this->isRunning($input->getOption('pidfile') ?? self::getDefaultPidFile())) {
            $io->error(\sprintf('A process is already listening on http://%s.', $this->address));
            $exitCode = 1;
        } else {
            $process = $this->createServerProcess();

            if ($disableOutput) {
                $process->disableOutput();
                $callback = null;
            } else {
                try {
                    $process->setTty(true);
                    $callback = null;
                } catch (\RuntimeException $e) {
                }
            }

            $process->run($callback);

            if (!$process->isSuccessful()) {
                $error = 'Server terminated unexpectedly.';

                if ($process->isOutputDisabled()) {
                    $error .= ' Run the command again with -v option for more details.';
                }

                $io->error($error);
                $exitCode = 1;
            }
        }

        return $exitCode ?? self::SUCCESS;
    }

    public function start(string $pidFile)
    {
        $pid = pcntl_fork();

        if ($pid < 0) {
            throw new \RuntimeException('Unable to start the server process.');
        }

        if ($pid > 0) {
            return self::SUCCESS;
        }

        if (posix_setsid() < 0) {
            throw new \RuntimeException('Unable to set the child process as session leader.');
        }

        $process = $this->createServerProcess();
        $process->disableOutput();
        $process->start();

        if (!$process->isRunning()) {
            throw new \RuntimeException('Unable to start the server process.');
        }

        \file_put_contents($pidFile, $this->address);

        // stop the web server when the lock file is removed
        while ($process->isRunning()) {
            if (!\file_exists($pidFile)) {
                $process->stop();
            }

            \sleep(1);
        }

        return 1;
    }

    private function isRunning(string $pidFile): bool
    {
        if (!\file_exists($pidFile)) {
            return false;
        }

        $address = \file_get_contents($pidFile);
        $pos = \strrpos($address, ':');
        $hostname = \substr($address, 0, $pos);
        $port = \substr($address, $pos + 1);

        if (false !== $fp = @\fsockopen($hostname, (int) $port, $errno, $errstr, 1)) {
            \fclose($fp);

            return true;
        }

        \unlink($pidFile);

        return false;
    }

    /**
     * @return string contains resolved hostname if available, empty string otherwise
     */
    private function getDisplayAddress(): string
    {
        if ('0.0.0.0' !== $this->hostname) {
            return '';
        }

        if (false === $localHostname = \gethostname()) {
            return '';
        }

        return \gethostbyname($localHostname).':'.$this->port;
    }

    private function findServerAddress(?string $address): string
    {
        if (null === $address) {
            $this->hostname = '127.0.0.1';
            $this->port = $this->findBestPort();
        } elseif (false !== $pos = \mb_strrpos($address, ':')) {
            $this->hostname = \mb_substr($address, 0, $pos);

            if ('*' === $this->hostname) {
                $this->hostname = '0.0.0.0';
            }
            $this->port = (int) \mb_substr($address, $pos + 1);
        } elseif (\ctype_digit($address)) {
            $this->hostname = '127.0.0.1';
            $this->port = (int) $address;
        } else {
            $this->hostname = $address;
            $this->port = $this->findBestPort();
        }

        return $this->hostname.':'.$this->port;
    }

    private function findBestPort(): int
    {
        $port = 8000;

        while (false !== $fp = @\fsockopen($this->hostname, $port, $errno, $errstr, 1)) {
            \fclose($fp);

            if ($port++ >= 8100) {
                throw new \RuntimeException('Unable to find a port available to run the web server.');
            }
        }

        return $port;
    }

    private function findFrontController(string $documentRoot): void
    {
        $fileNames = ['index.php', 'app_'.($env = $this->debug ? 'debug' : 'prod').'.php', 'app.php', 'server.php', 'server_'.$env.'.php'];

        if (!\is_dir($documentRoot)) {
            throw new \InvalidArgumentException(\sprintf('The document root directory "%s" does not exist.', $documentRoot));
        }

        foreach ($fileNames as $fileName) {
            if (\file_exists($documentRoot.'/'.$fileName)) {
                $_ENV['APP_FRONT_CONTROLLER'] = $fileName;

                return;
            }
        }

        throw new \InvalidArgumentException(\sprintf('Unable to find the front controller under "%s" (none of these files exist: %s).', $documentRoot, \implode(', ', $fileNames)));
    }

    private function createServerProcess(): Process
    {
        $finder = new PhpExecutableFinder();

        if (false === $binary = $finder->find(false)) {
            throw new \RuntimeException('Unable to find the PHP binary.');
        }

        $xdebugArgs = \ini_get('xdebug.profiler_enable_trigger') ? ['-dxdebug.profiler_enable_trigger=1'] : [];

        $process = new Process(\array_merge([$binary], $finder->findArguments(), $xdebugArgs, ['-dvariables_order=EGPCS', '-S', $this->address, $this->router]));
        $process->setWorkingDirectory($this->documentRoot);
        $process->setTimeout(null);

        if (\in_array('APP_ENV', \explode(',', \getenv('SYMFONY_DOTENV_VARS') ?: ''), true)) {
            $process->setEnv(['APP_ENV' => false]);
        }

        return $process;
    }
}