biurad /
flange
| 1 | <?php declare(strict_types=1); |
||||
| 2 | |||||
| 3 | /* |
||||
| 4 | * This file is part of Biurad opensource projects. |
||||
| 5 | * |
||||
| 6 | * @copyright 2019 Biurad Group (https://biurad.com/) |
||||
| 7 | * @license https://opensource.org/licenses/BSD-3-Clause License |
||||
| 8 | * |
||||
| 9 | * For the full copyright and license information, please view the LICENSE |
||||
| 10 | * file that was distributed with this source code. |
||||
| 11 | */ |
||||
| 12 | |||||
| 13 | namespace Flange\Commands; |
||||
| 14 | |||||
| 15 | use Symfony\Component\Console\Attribute\AsCommand; |
||||
| 16 | use Symfony\Component\Console\Command\Command; |
||||
| 17 | use Symfony\Component\Console\Input\InputArgument; |
||||
| 18 | use Symfony\Component\Console\Input\InputInterface; |
||||
| 19 | use Symfony\Component\Console\Input\InputOption; |
||||
| 20 | use Symfony\Component\Console\Output\ConsoleOutputInterface; |
||||
| 21 | use Symfony\Component\Console\Output\OutputInterface; |
||||
| 22 | use Symfony\Component\Console\Style\SymfonyStyle; |
||||
| 23 | use Symfony\Component\Process\PhpExecutableFinder; |
||||
| 24 | use Symfony\Component\Process\Process; |
||||
| 25 | |||||
| 26 | /** |
||||
| 27 | * Runs|Stops a local web server in a background process. |
||||
| 28 | * |
||||
| 29 | * @author Divine Niiquaye Ibok <[email protected]> |
||||
| 30 | */ |
||||
| 31 | #[AsCommand('serve', 'Runs|Stops a local web server in a background process')] |
||||
| 32 | final class ServerCommand extends Command |
||||
| 33 | { |
||||
| 34 | protected static $defaultName = 'serve'; |
||||
| 35 | protected static $defaultDescription = 'Runs|Stops a local web server in a background process'; |
||||
| 36 | |||||
| 37 | private string $router, $hostname, $address; |
||||
| 38 | private int $port; |
||||
| 39 | |||||
| 40 | public function __construct(private ?string $documentRoot, private bool $debug) |
||||
| 41 | { |
||||
| 42 | parent::__construct(); |
||||
| 43 | } |
||||
| 44 | |||||
| 45 | /** |
||||
| 46 | * {@inheritdoc} |
||||
| 47 | */ |
||||
| 48 | protected function configure(): void |
||||
| 49 | { |
||||
| 50 | $this |
||||
| 51 | ->setDefinition([ |
||||
| 52 | new InputArgument('addressport', InputArgument::OPTIONAL, 'The address to listen to (can be address:port, address, or port)'), |
||||
| 53 | new InputOption('docroot', 'd', InputOption::VALUE_REQUIRED, 'Document root'), |
||||
| 54 | new InputOption('router', 'r', InputOption::VALUE_REQUIRED, 'Path to custom router script'), |
||||
| 55 | new InputOption('pidfile', null, InputOption::VALUE_REQUIRED, 'PID file'), |
||||
| 56 | new InputOption('stop', 's', InputOption::VALUE_NONE, 'Stops the local web server that was started with the serve command'), |
||||
| 57 | ]) |
||||
| 58 | ->setHelp( |
||||
| 59 | <<<'EOF' |
||||
| 60 | The <info>%command.name%</info> command runs a local web server: By default, the server |
||||
| 61 | listens on <comment>127.0.0.1</> address and the port number is automatically selected |
||||
| 62 | as the first free port starting from <comment>8000</>: |
||||
| 63 | |||||
| 64 | <info>php %command.full_name%</info> |
||||
| 65 | |||||
| 66 | If your PHP version supports <info>pcntl extension</info>, the server will run in the background |
||||
| 67 | and you can keep executing other commands. Execute <comment>php %command.full_name% --stop</> to stop it. |
||||
| 68 | |||||
| 69 | Else command will block the console. If you want to run other commands, stop it by |
||||
| 70 | pressing <comment>Control+C</> instead. |
||||
| 71 | |||||
| 72 | Change the default address and port by passing them as an argument: |
||||
| 73 | |||||
| 74 | <info>php %command.full_name% 127.0.0.1:8080</info> |
||||
| 75 | |||||
| 76 | Use the <info>--docroot</info> option to change the default docroot directory: |
||||
| 77 | |||||
| 78 | <info>php %command.full_name% --docroot=htdocs/</info> |
||||
| 79 | |||||
| 80 | Specify your own router script via the <info>--router</info> option: |
||||
| 81 | |||||
| 82 | <info>php %command.full_name% --router=app/config/router.php</info> |
||||
| 83 | |||||
| 84 | See also: http://www.php.net/manual/en/features.commandline.webserver.php |
||||
| 85 | EOF |
||||
| 86 | ) |
||||
| 87 | ; |
||||
| 88 | } |
||||
| 89 | |||||
| 90 | /** |
||||
| 91 | * {@inheritdoc} |
||||
| 92 | */ |
||||
| 93 | protected function execute(InputInterface $input, OutputInterface $output): int |
||||
| 94 | { |
||||
| 95 | $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); |
||||
| 96 | |||||
| 97 | if (!$this->debug) { |
||||
| 98 | $io->error('Running this server in production environment is NOT recommended!'); |
||||
| 99 | |||||
| 100 | return 1; |
||||
| 101 | } |
||||
| 102 | |||||
| 103 | if ($input->getOption('stop')) { |
||||
| 104 | $pidFile = $input->getOption('pidfile') ?? self::getDefaultPidFile(); |
||||
| 105 | |||||
| 106 | if (!\file_exists($pidFile)) { |
||||
| 107 | $io->error('No web server is listening.'); |
||||
| 108 | |||||
| 109 | return 1; |
||||
| 110 | } |
||||
| 111 | |||||
| 112 | if (\unlink($pidFile)) { |
||||
| 113 | $io->success('Web server stopped successfully'); |
||||
| 114 | } |
||||
| 115 | |||||
| 116 | return self::SUCCESS; |
||||
| 117 | } |
||||
| 118 | |||||
| 119 | if (null === $documentRoot = $input->getOption('docroot') ?? $this->documentRoot) { |
||||
| 120 | $io->error('The document root directory must be either passed as first argument of the constructor or through the "--docroot" input option.'); |
||||
| 121 | |||||
| 122 | return 1; |
||||
| 123 | } |
||||
| 124 | |||||
| 125 | if (null !== $router = $input->getOption('router')) { |
||||
| 126 | $absoluteRouterPath = \realpath($router); |
||||
| 127 | |||||
| 128 | if (false === $absoluteRouterPath) { |
||||
| 129 | throw new \InvalidArgumentException(\sprintf('Router script "%s" does not exist.', $router)); |
||||
| 130 | } |
||||
| 131 | } |
||||
| 132 | |||||
| 133 | $this->findFrontController($this->documentRoot = $documentRoot); |
||||
| 134 | $this->router = $router ?? __DIR__.'/../Resources/dev-router.php'; |
||||
| 135 | $this->address = $this->findServerAddress($input->getArgument('addressport')); |
||||
| 136 | |||||
| 137 | if (!\extension_loaded('pcntl')) { |
||||
| 138 | $io->error('This command needs the pcntl extension to run.'); |
||||
| 139 | |||||
| 140 | if ($io->confirm('Do you want to execute <info>built in server run</info> immediately?', false)) { |
||||
| 141 | return $this->runBlockingServer($io, $input, $output); |
||||
| 142 | } |
||||
| 143 | |||||
| 144 | return 1; |
||||
| 145 | } |
||||
| 146 | |||||
| 147 | try { |
||||
| 148 | $pidFile = $input->getOption('pidfile') ?? self::getDefaultPidFile(); |
||||
| 149 | |||||
| 150 | if ($this->isRunning($pidFile)) { |
||||
| 151 | $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))); |
||||
| 152 | |||||
| 153 | return 1; |
||||
| 154 | } |
||||
| 155 | |||||
| 156 | if (self::SUCCESS === $this->start($pidFile)) { |
||||
| 157 | $message = \sprintf('Server listening on http://%s', $this->address); |
||||
| 158 | |||||
| 159 | if ('' !== $displayAddress = $this->getDisplayAddress()) { |
||||
| 160 | $message = \sprintf('Server listening on all interfaces, port %s -- see http://%s', $this->port, $displayAddress); |
||||
| 161 | } |
||||
| 162 | $io->success($message); |
||||
| 163 | |||||
| 164 | if (\ini_get('xdebug.profiler_enable_trigger')) { |
||||
| 165 | $io->comment('Xdebug profiler trigger enabled.'); |
||||
| 166 | } |
||||
| 167 | } |
||||
| 168 | } catch (\Exception $e) { |
||||
| 169 | $io->error($e->getMessage()); |
||||
| 170 | |||||
| 171 | return 1; |
||||
| 172 | } |
||||
| 173 | |||||
| 174 | return self::SUCCESS; |
||||
| 175 | } |
||||
| 176 | |||||
| 177 | private static function getDefaultPidFile(): string |
||||
| 178 | { |
||||
| 179 | return \getcwd().'/.web-server-pid'; |
||||
| 180 | } |
||||
| 181 | |||||
| 182 | private function runBlockingServer(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int |
||||
| 183 | { |
||||
| 184 | $callback = null; |
||||
| 185 | $disableOutput = false; |
||||
| 186 | |||||
| 187 | if ($output->isQuiet()) { |
||||
| 188 | $disableOutput = true; |
||||
| 189 | } else { |
||||
| 190 | $callback = static function ($type, $buffer) use ($output): void { |
||||
| 191 | if (Process::ERR === $type && $output instanceof ConsoleOutputInterface) { |
||||
| 192 | $output = $output->getErrorOutput(); |
||||
| 193 | } |
||||
| 194 | |||||
| 195 | $output->write($buffer, false, OutputInterface::OUTPUT_RAW); |
||||
| 196 | }; |
||||
| 197 | } |
||||
| 198 | |||||
| 199 | if ('' !== $displayAddress = $this->getDisplayAddress()) { |
||||
| 200 | $message = \sprintf('Server listening on all interfaces, port %s -- see http://%s', $this->port, $displayAddress); |
||||
| 201 | } |
||||
| 202 | $io->success($message ?? \sprintf('Server listening on http://%s', $this->address)); |
||||
| 203 | |||||
| 204 | if (\ini_get('xdebug.profiler_enable_trigger')) { |
||||
| 205 | $io->comment('Xdebug profiler trigger enabled.'); |
||||
| 206 | } |
||||
| 207 | $io->comment('Quit the server with CONTROL-C.'); |
||||
| 208 | |||||
| 209 | if ($this->isRunning($input->getOption('pidfile') ?? self::getDefaultPidFile())) { |
||||
| 210 | $io->error(\sprintf('A process is already listening on http://%s.', $this->address)); |
||||
| 211 | $exitCode = 1; |
||||
| 212 | } else { |
||||
| 213 | $process = $this->createServerProcess(); |
||||
| 214 | |||||
| 215 | if ($disableOutput) { |
||||
| 216 | $process->disableOutput(); |
||||
| 217 | $callback = null; |
||||
| 218 | } else { |
||||
| 219 | try { |
||||
| 220 | $process->setTty(true); |
||||
| 221 | $callback = null; |
||||
| 222 | } catch (\RuntimeException $e) { |
||||
|
0 ignored issues
–
show
Coding Style
Comprehensibility
introduced
by
Loading history...
|
|||||
| 223 | } |
||||
| 224 | } |
||||
| 225 | |||||
| 226 | $process->run($callback); |
||||
| 227 | |||||
| 228 | if (!$process->isSuccessful()) { |
||||
| 229 | $error = 'Server terminated unexpectedly.'; |
||||
| 230 | |||||
| 231 | if ($process->isOutputDisabled()) { |
||||
| 232 | $error .= ' Run the command again with -v option for more details.'; |
||||
| 233 | } |
||||
| 234 | |||||
| 235 | $io->error($error); |
||||
| 236 | $exitCode = 1; |
||||
| 237 | } |
||||
| 238 | } |
||||
| 239 | |||||
| 240 | return $exitCode ?? self::SUCCESS; |
||||
| 241 | } |
||||
| 242 | |||||
| 243 | public function start(string $pidFile) |
||||
| 244 | { |
||||
| 245 | $pid = pcntl_fork(); |
||||
| 246 | |||||
| 247 | if ($pid < 0) { |
||||
| 248 | throw new \RuntimeException('Unable to start the server process.'); |
||||
| 249 | } |
||||
| 250 | |||||
| 251 | if ($pid > 0) { |
||||
| 252 | return self::SUCCESS; |
||||
| 253 | } |
||||
| 254 | |||||
| 255 | if (posix_setsid() < 0) { |
||||
| 256 | throw new \RuntimeException('Unable to set the child process as session leader.'); |
||||
| 257 | } |
||||
| 258 | |||||
| 259 | $process = $this->createServerProcess(); |
||||
| 260 | $process->disableOutput(); |
||||
| 261 | $process->start(); |
||||
| 262 | |||||
| 263 | if (!$process->isRunning()) { |
||||
| 264 | throw new \RuntimeException('Unable to start the server process.'); |
||||
| 265 | } |
||||
| 266 | |||||
| 267 | \file_put_contents($pidFile, $this->address); |
||||
| 268 | |||||
| 269 | // stop the web server when the lock file is removed |
||||
| 270 | while ($process->isRunning()) { |
||||
| 271 | if (!\file_exists($pidFile)) { |
||||
| 272 | $process->stop(); |
||||
| 273 | } |
||||
| 274 | |||||
| 275 | \sleep(1); |
||||
| 276 | } |
||||
| 277 | |||||
| 278 | return 1; |
||||
| 279 | } |
||||
| 280 | |||||
| 281 | private function isRunning(string $pidFile): bool |
||||
| 282 | { |
||||
| 283 | if (!\file_exists($pidFile)) { |
||||
| 284 | return false; |
||||
| 285 | } |
||||
| 286 | |||||
| 287 | $address = \file_get_contents($pidFile); |
||||
| 288 | $pos = \strrpos($address, ':'); |
||||
| 289 | $hostname = \substr($address, 0, $pos); |
||||
| 290 | $port = \substr($address, $pos + 1); |
||||
| 291 | |||||
| 292 | if (false !== $fp = @\fsockopen($hostname, (int) $port, $errno, $errstr, 1)) { |
||||
| 293 | \fclose($fp); |
||||
| 294 | |||||
| 295 | return true; |
||||
| 296 | } |
||||
| 297 | |||||
| 298 | \unlink($pidFile); |
||||
| 299 | |||||
| 300 | return false; |
||||
| 301 | } |
||||
| 302 | |||||
| 303 | /** |
||||
| 304 | * @return string contains resolved hostname if available, empty string otherwise |
||||
| 305 | */ |
||||
| 306 | private function getDisplayAddress(): string |
||||
| 307 | { |
||||
| 308 | if ('0.0.0.0' !== $this->hostname) { |
||||
| 309 | return ''; |
||||
| 310 | } |
||||
| 311 | |||||
| 312 | if (false === $localHostname = \gethostname()) { |
||||
| 313 | return ''; |
||||
| 314 | } |
||||
| 315 | |||||
| 316 | return \gethostbyname($localHostname).':'.$this->port; |
||||
| 317 | } |
||||
| 318 | |||||
| 319 | private function findServerAddress(?string $address): string |
||||
| 320 | { |
||||
| 321 | if (null === $address) { |
||||
| 322 | $this->hostname = '127.0.0.1'; |
||||
| 323 | $this->port = $this->findBestPort(); |
||||
| 324 | } elseif (false !== $pos = \mb_strrpos($address, ':')) { |
||||
| 325 | $this->hostname = \mb_substr($address, 0, $pos); |
||||
| 326 | |||||
| 327 | if ('*' === $this->hostname) { |
||||
| 328 | $this->hostname = '0.0.0.0'; |
||||
| 329 | } |
||||
| 330 | $this->port = (int) \mb_substr($address, $pos + 1); |
||||
| 331 | } elseif (\ctype_digit($address)) { |
||||
| 332 | $this->hostname = '127.0.0.1'; |
||||
| 333 | $this->port = (int) $address; |
||||
| 334 | } else { |
||||
| 335 | $this->hostname = $address; |
||||
| 336 | $this->port = $this->findBestPort(); |
||||
| 337 | } |
||||
| 338 | |||||
| 339 | return $this->hostname.':'.$this->port; |
||||
| 340 | } |
||||
| 341 | |||||
| 342 | private function findBestPort(): int |
||||
| 343 | { |
||||
| 344 | $port = 8000; |
||||
| 345 | |||||
| 346 | while (false !== $fp = @\fsockopen($this->hostname, $port, $errno, $errstr, 1)) { |
||||
| 347 | \fclose($fp); |
||||
| 348 | |||||
| 349 | if ($port++ >= 8100) { |
||||
| 350 | throw new \RuntimeException('Unable to find a port available to run the web server.'); |
||||
| 351 | } |
||||
| 352 | } |
||||
| 353 | |||||
| 354 | return $port; |
||||
| 355 | } |
||||
| 356 | |||||
| 357 | private function findFrontController(string $documentRoot): void |
||||
| 358 | { |
||||
| 359 | $fileNames = ['index.php', 'app_'.($env = $this->debug ? 'debug' : 'prod').'.php', 'app.php', 'server.php', 'server_'.$env.'.php']; |
||||
| 360 | |||||
| 361 | if (!\is_dir($documentRoot)) { |
||||
| 362 | throw new \InvalidArgumentException(\sprintf('The document root directory "%s" does not exist.', $documentRoot)); |
||||
| 363 | } |
||||
| 364 | |||||
| 365 | foreach ($fileNames as $fileName) { |
||||
| 366 | if (\file_exists($documentRoot.'/'.$fileName)) { |
||||
| 367 | $_ENV['APP_FRONT_CONTROLLER'] = $fileName; |
||||
| 368 | |||||
| 369 | return; |
||||
| 370 | } |
||||
| 371 | } |
||||
| 372 | |||||
| 373 | throw new \InvalidArgumentException(\sprintf('Unable to find the front controller under "%s" (none of these files exist: %s).', $documentRoot, \implode(', ', $fileNames))); |
||||
| 374 | } |
||||
| 375 | |||||
| 376 | private function createServerProcess(): Process |
||||
| 377 | { |
||||
| 378 | $finder = new PhpExecutableFinder(); |
||||
| 379 | |||||
| 380 | if (false === $binary = $finder->find(false)) { |
||||
| 381 | throw new \RuntimeException('Unable to find the PHP binary.'); |
||||
| 382 | } |
||||
| 383 | |||||
| 384 | $xdebugArgs = \ini_get('xdebug.profiler_enable_trigger') ? ['-dxdebug.profiler_enable_trigger=1'] : []; |
||||
| 385 | |||||
| 386 | $process = new Process(\array_merge([$binary], $finder->findArguments(), $xdebugArgs, ['-dvariables_order=EGPCS', '-S', $this->address, $this->router])); |
||||
| 387 | $process->setWorkingDirectory($this->documentRoot); |
||||
|
0 ignored issues
–
show
It seems like
$this->documentRoot can also be of type null; however, parameter $cwd of Symfony\Component\Proces...::setWorkingDirectory() does only seem to accept string, maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
Loading history...
|
|||||
| 388 | $process->setTimeout(null); |
||||
| 389 | |||||
| 390 | if (\in_array('APP_ENV', \explode(',', \getenv('SYMFONY_DOTENV_VARS') ?: ''), true)) { |
||||
| 391 | $process->setEnv(['APP_ENV' => false]); |
||||
| 392 | } |
||||
| 393 | |||||
| 394 | return $process; |
||||
| 395 | } |
||||
| 396 | } |
||||
| 397 |