Passed
Push — master ( 37c5f2...a4e384 )
by Divine Niiquaye
03:06
created

ServerCommand::start()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 36
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

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

399
        $process->setWorkingDirectory(/** @scrutinizer ignore-type */ $this->documentRoot);
Loading history...
400
        $process->setTimeout(null);
401
402
        if (\in_array('APP_ENV', \explode(',', \getenv('SYMFONY_DOTENV_VARS') ?: ''))) {
403
            $process->setEnv(['APP_ENV' => false]);
404
        }
405
406
        return $process;
407
    }
408
}
409