ServerCommand   F
last analyzed

Complexity

Total Complexity 61

Size/Duplication

Total Lines 364
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 0
Metric Value
eloc 171
dl 0
loc 364
ccs 0
cts 167
cp 0
rs 3.52
c 0
b 0
f 0
wmc 61

12 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 3 1
A getDisplayAddress() 0 11 3
A findFrontController() 0 17 5
A isRunning() 0 20 3
A configure() 0 12 1
B start() 0 36 7
A findBestPort() 0 13 3
A createServerProcess() 0 19 5
A getDefaultPidFile() 0 3 1
C execute() 0 82 16
A findServerAddress() 0 21 5
B runBlockingServer() 0 59 11

How to fix   Complexity   

Complex Class

Complex classes like ServerCommand often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ServerCommand, and based on these observations, apply Extract Interface, too.

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
Consider adding a comment why this CATCH block is empty.
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
Bug introduced by
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 ignore-type  annotation

387
        $process->setWorkingDirectory(/** @scrutinizer ignore-type */ $this->documentRoot);
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