Passed
Push — serve ( 7631c7 )
by Arnaud
06:26
created

Serve::setUpServer()   A

Complexity

Conditions 6
Paths 30

Size

Total Lines 28
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 6
eloc 18
nc 30
nop 0
dl 0
loc 28
rs 9.0444
c 3
b 0
f 0
1
<?php
2
3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace Cecil\Command;
15
16
use Cecil\Exception\RuntimeException;
17
use Cecil\Util;
18
use Symfony\Component\Console\Input\InputArgument;
19
use Symfony\Component\Console\Input\InputInterface;
20
use Symfony\Component\Console\Input\InputOption;
21
use Symfony\Component\Console\Output\OutputInterface;
22
use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
23
use Symfony\Component\Finder\Finder;
24
use Symfony\Component\Process\Exception\ProcessFailedException;
25
use Symfony\Component\Process\PhpExecutableFinder;
26
use Symfony\Component\Process\Process;
27
use Yosymfony\ResourceWatcher\Crc32ContentHash;
28
use Yosymfony\ResourceWatcher\ResourceCacheMemory;
29
use Yosymfony\ResourceWatcher\ResourceWatcher;
30
31
/**
32
 * Serve command.
33
 *
34
 * This command starts the built-in web server with live reloading capabilities.
35
 * It allows users to serve their website locally and automatically rebuild it when changes are detected.
36
 * It also supports opening the web browser automatically and includes options for drafts, optimization, and more.
37
 */
38
class Serve extends AbstractCommand
39
{
40
    public const SERVE_OUTPUT = '_preview';
41
42
    /** @var boolean */
43
    protected $watcherEnabled;
44
45
    /**
46
     * {@inheritdoc}
47
     */
48
    protected function configure()
49
    {
50
        $this
51
            ->setName('serve')
52
            ->setDescription('Starts the built-in server')
53
            ->setDefinition([
54
                new InputArgument('path', InputArgument::OPTIONAL, 'Use the given path as working directory'),
55
                new InputOption('open', 'o', InputOption::VALUE_NONE, 'Open web browser automatically'),
56
                new InputOption('host', null, InputOption::VALUE_REQUIRED, 'Server host', 'localhost'),
57
                new InputOption('port', null, InputOption::VALUE_REQUIRED, 'Server port', '8000'),
58
                new InputOption('watch', 'w', InputOption::VALUE_NEGATABLE, 'Enable (or disable --no-watch) changes watcher (enabled by default)', true),
59
                new InputOption('drafts', 'd', InputOption::VALUE_NONE, 'Include drafts'),
60
                new InputOption('optimize', null, InputOption::VALUE_NEGATABLE, 'Enable (or disable --no-optimize) optimization of generated files'),
61
                new InputOption('config', 'c', InputOption::VALUE_REQUIRED, 'Set the path to extra config files (comma-separated)'),
62
                new InputOption('clear-cache', null, InputOption::VALUE_OPTIONAL, 'Clear cache before build (optional cache key as regular expression)', false),
63
                new InputOption('page', 'p', InputOption::VALUE_REQUIRED, 'Build a specific page'),
64
                new InputOption('no-ignore-vcs', null, InputOption::VALUE_NONE, 'Changes watcher must not ignore VCS directories'),
65
                new InputOption('metrics', 'm', InputOption::VALUE_NONE, 'Show build metrics (duration and memory) of each step'),
66
                new InputOption('timeout', null, InputOption::VALUE_REQUIRED, 'Sets the process timeout (max. runtime) in seconds', 7200), // default is 2 hours
67
            ])
68
            ->setHelp(
69
                <<<'EOF'
70
The <info>%command.name%</> command starts the live-reloading-built-in web server.
71
72
  <info>%command.full_name%</>
73
  <info>%command.full_name% path/to/the/working/directory</>
74
  <info>%command.full_name% --open</>
75
  <info>%command.full_name% --drafts</>
76
  <info>%command.full_name% --no-watch</>
77
78
You can use a custom host and port by using the <info>--host</info> and <info>--port</info> options:
79
80
  <info>%command.full_name% --host=127.0.0.1 --port=8080</>
81
82
To build the website with an extra configuration file, you can use the <info>--config</info> option.
83
This is useful during local development to <comment>override some settings</comment> without modifying the main configuration:
84
85
  <info>%command.full_name% --config=config/dev.yml</>
86
87
To start the server with changes watcher <comment>not ignoring VCS</comment> directories, run:
88
89
  <info>%command.full_name% --no-ignore-vcs</>
90
91
To define the process <comment>timeout</comment> (in seconds), run:
92
93
  <info>%command.full_name% --timeout=7200</>
94
EOF
95
            );
96
    }
97
98
    /**
99
     * {@inheritdoc}
100
     *
101
     * @throws RuntimeException
102
     */
103
    protected function execute(InputInterface $input, OutputInterface $output)
104
    {
105
        $open = $input->getOption('open');
106
        $host = $input->getOption('host');
107
        $port = $input->getOption('port');
108
        $drafts = $input->getOption('drafts');
109
        $optimize = $input->getOption('optimize');
110
        $clearcache = $input->getOption('clear-cache');
111
        $page = $input->getOption('page');
112
        $noignorevcs = $input->getOption('no-ignore-vcs');
113
        $metrics = $input->getOption('metrics');
114
        $timeout = $input->getOption('timeout');
115
        $verbose = $input->getOption('verbose');
116
117
        $resourceWatcher = null;
118
        $this->watcherEnabled = $input->getOption('watch');
119
120
        // checks if PHP executable is available
121
        $phpFinder = new PhpExecutableFinder();
122
        $php = $phpFinder->find();
123
        if ($php === false) {
124
            throw new RuntimeException('Can\'t find a local PHP executable.');
125
        }
126
127
        // setup server
128
        $this->setUpServer($host, $port);
0 ignored issues
show
Unused Code introduced by
The call to Cecil\Command\Serve::setUpServer() has too many arguments starting with $host. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

128
        $this->/** @scrutinizer ignore-call */ 
129
               setUpServer($host, $port);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
129
        $command = \sprintf(
130
            '"%s" -S %s:%d -t "%s" "%s"',
131
            $php,
132
            $host,
133
            $port,
134
            Util::joinFile($this->getPath(), self::SERVE_OUTPUT),
135
            Util::joinFile($this->getPath(), self::TMP_DIR, 'router.php')
136
        );
137
        $process = Process::fromShellCommandline($command);
138
139
        // setup build process
140
        $buildProcessArguments = [
141
            $php,
142
            $_SERVER['argv'][0],
143
        ];
144
        $buildProcessArguments[] = 'build';
145
        $buildProcessArguments[] = $this->getPath();
146
        if (!empty($this->getConfigFiles())) {
147
            $buildProcessArguments[] = '--config';
148
            $buildProcessArguments[] = implode(',', $this->getConfigFiles());
149
        }
150
        if ($drafts) {
151
            $buildProcessArguments[] = '--drafts';
152
        }
153
        if ($optimize === true) {
154
            $buildProcessArguments[] = '--optimize';
155
        }
156
        if ($optimize === false) {
157
            $buildProcessArguments[] = '--no-optimize';
158
        }
159
        if ($clearcache === null) {
160
            $buildProcessArguments[] = '--clear-cache';
161
        }
162
        if (!empty($clearcache)) {
163
            $buildProcessArguments[] = '--clear-cache';
164
            $buildProcessArguments[] = $clearcache;
165
        }
166
        if ($verbose) {
167
            $buildProcessArguments[] = '-' . str_repeat('v', $_SERVER['SHELL_VERBOSITY']);
168
        }
169
        if (!empty($page)) {
170
            $buildProcessArguments[] = '--page';
171
            $buildProcessArguments[] = $page;
172
        }
173
        if (!empty($metrics)) {
174
            $buildProcessArguments[] = '--metrics';
175
        }
176
        $buildProcessArguments[] = '--baseurl';
177
        $buildProcessArguments[] = "http://$host:$port/";
178
        $buildProcessArguments[] = '--output';
179
        $buildProcessArguments[] = self::SERVE_OUTPUT;
180
        $buildProcess = new Process(
181
            $buildProcessArguments,
182
            null,
183
            ['BOX_REQUIREMENT_CHECKER' => '0'] // prevents double check (build then serve)
184
        );
185
        $buildProcess->setTty(Process::isTtySupported());
186
        $buildProcess->setPty(Process::isPtySupported());
187
        $buildProcess->setTimeout((float) $timeout);
188
        $processOutputCallback = function ($type, $buffer) use ($output) {
189
            $output->write($buffer, false, OutputInterface::OUTPUT_RAW);
190
        };
191
192
        // builds before serve
193
        $output->writeln(\sprintf('<comment>Build process: %s</comment>', implode(' ', $buildProcessArguments)), OutputInterface::VERBOSITY_DEBUG);
194
        $buildProcess->run($processOutputCallback);
195
        if ($buildProcess->isSuccessful()) {
196
            $this->buildSuccessActions($output);
197
        }
198
        if ($buildProcess->getExitCode() !== 0) {
199
            return 1;
200
        }
201
202
        // handles serve process
203
        if (!$process->isStarted()) {
204
            $messageSuffix = '';
205
            // setup resource watcher
206
            if ($this->watcherEnabled) {
207
                $resourceWatcher = $this->setupWatcher($noignorevcs);
208
                $resourceWatcher->initialize();
209
                $messageSuffix = ' with changes watcher';
210
            }
211
            // starts server
212
            try {
213
                if (\function_exists('\pcntl_signal')) {
214
                    pcntl_async_signals(true);
215
                    pcntl_signal(SIGINT, [$this, 'tearDownServer']);
216
                    pcntl_signal(SIGTERM, [$this, 'tearDownServer']);
217
                }
218
                $output->writeln(\sprintf('<comment>Server process: %s</comment>', $command), OutputInterface::VERBOSITY_DEBUG);
219
                $output->writeln(\sprintf('Starting server (<href=http://%s:%d>http://%s:%d</>)%s...', $host, $port, $host, $port, $messageSuffix));
220
                $process->start(function ($type, $buffer) {
221
                    if ($type === Process::ERR) {
222
                        error_log($buffer, 3, Util::joinFile($this->getPath(), self::TMP_DIR, 'errors.log'));
223
                    }
224
                });
225
                if ($open) {
226
                    $output->writeln('Opening web browser...');
227
                    Util\Platform::openBrowser(\sprintf('http://%s:%s', $host, $port));
228
                }
229
                while ($process->isRunning()) {
230
                    sleep(1); // wait for server is ready
231
                    if (!fsockopen($host, (int) $port)) {
232
                        $output->writeln('<info>Server is not ready.</info>');
233
234
                        return 1;
235
                    }
236
                    if ($this->watcherEnabled && $resourceWatcher instanceof ResourceWatcher) {
237
                        $watcher = $resourceWatcher->findChanges();
238
                        if ($watcher->hasChanges()) {
239
                            $output->writeln('<comment>Changes detected.</comment>');
240
                            // prints deleted/new/updated files in debug mode
241
                            if (\count($watcher->getDeletedFiles()) > 0) {
242
                                $output->writeln('<comment>Deleted files:</comment>', OutputInterface::VERBOSITY_DEBUG);
243
                                foreach ($watcher->getDeletedFiles() as $file) {
244
                                    $output->writeln("<comment>- $file</comment>", OutputInterface::VERBOSITY_DEBUG);
245
                                }
246
                            }
247
                            if (\count($watcher->getNewFiles()) > 0) {
248
                                $output->writeln('<comment>New files:</comment>', OutputInterface::VERBOSITY_DEBUG);
249
                                foreach ($watcher->getNewFiles() as $file) {
250
                                    $output->writeln("<comment>- $file</comment>", OutputInterface::VERBOSITY_DEBUG);
251
                                }
252
                            }
253
                            if (\count($watcher->getUpdatedFiles()) > 0) {
254
                                $output->writeln('<comment>Updated files:</comment>', OutputInterface::VERBOSITY_DEBUG);
255
                                foreach ($watcher->getUpdatedFiles() as $file) {
256
                                    $output->writeln("<comment>- $file</comment>", OutputInterface::VERBOSITY_DEBUG);
257
                                }
258
                            }
259
                            $output->writeln('');
260
                            // re-builds
261
                            $buildProcess->run($processOutputCallback);
262
                            if ($buildProcess->isSuccessful()) {
263
                                $this->buildSuccessActions($output);
264
                            }
265
                            $output->writeln('<info>Server is runnning...</info>');
266
                        }
267
                    }
268
                }
269
                if ($process->getExitCode() > 0) {
270
                    $output->writeln(\sprintf('<comment>%s</comment>', trim($process->getErrorOutput())));
271
                }
272
            } catch (ProcessFailedException $e) {
273
                $this->tearDownServer();
274
275
                throw new RuntimeException(\sprintf($e->getMessage()));
276
            }
277
        }
278
279
        return 0;
280
    }
281
282
    /**
283
     * Build success actions.
284
     */
285
    private function buildSuccessActions(OutputInterface $output): void
286
    {
287
        // writes `changes.flag` file
288
        if ($this->watcherEnabled) {
289
            Util\File::getFS()->dumpFile(Util::joinFile($this->getPath(), self::TMP_DIR, 'changes.flag'), time());
290
        }
291
        // writes `headers.ini` file
292
        $headers = $this->getBuilder()->getConfig()->get('server.headers');
293
        if (is_iterable($headers)) {
294
            $output->writeln('Writing headers file...');
295
            Util\File::getFS()->remove(Util::joinFile($this->getPath(), self::TMP_DIR, 'headers.ini'));
296
            foreach ($headers as $entry) {
297
                Util\File::getFS()->appendToFile(Util::joinFile($this->getPath(), self::TMP_DIR, 'headers.ini'), "[{$entry['path']}]\n");
298
                foreach ($entry['headers'] ?? [] as $header) {
299
                    Util\File::getFS()->appendToFile(Util::joinFile($this->getPath(), self::TMP_DIR, 'headers.ini'), "{$header['key']} = \"{$header['value']}\"\n");
300
                }
301
            }
302
        }
303
    }
304
305
    /**
306
     * Sets up the watcher.
307
     */
308
    private function setupWatcher(bool $noignorevcs = false): ResourceWatcher
309
    {
310
        $finder = new Finder();
311
        $finder->files()
312
            ->in($this->getPath())
313
            ->exclude((string) $this->getBuilder()->getConfig()->get('output.dir'));
314
        if (file_exists(Util::joinFile($this->getPath(), '.gitignore')) && $noignorevcs === false) {
315
            $finder->ignoreVCSIgnored(true);
316
        }
317
        return new ResourceWatcher(new ResourceCacheMemory(), $finder, new Crc32ContentHash());
318
    }
319
320
    /**
321
     * Prepares server's files.
322
     *
323
     * @throws RuntimeException
324
     */
325
    private function setUpServer(): void
326
    {
327
        try {
328
            // define root path
329
            $root = Util\Platform::isPhar() ? Util\Platform::getPharPath() . '/' : realpath(Util::joinFile(__DIR__, '/../../'));
330
            // copying router
331
            Util\File::getFS()->copy(
332
                $root . '/resources/server/router.php',
333
                Util::joinFile($this->getPath(), self::TMP_DIR, 'router.php'),
334
                true
335
            );
336
            // copying livereload JS for watcher
337
            $livereloadJs = Util::joinFile($this->getPath(), self::TMP_DIR, 'livereload.js');
338
            if (is_file($livereloadJs)) {
339
                Util\File::getFS()->remove($livereloadJs);
340
            }
341
            if ($this->watcherEnabled) {
342
                Util\File::getFS()->copy(
343
                    $root . '/resources/server/livereload.js',
344
                    $livereloadJs,
345
                    true
346
                );
347
            }
348
        } catch (IOExceptionInterface $e) {
349
            throw new RuntimeException(\sprintf('An error occurred while copying server\'s files to "%s".', $e->getPath()));
350
        }
351
        if (!is_file(Util::joinFile($this->getPath(), self::TMP_DIR, 'router.php'))) {
352
            throw new RuntimeException(\sprintf('Router not found: "%s".', Util::joinFile(self::TMP_DIR, 'router.php')));
353
        }
354
    }
355
356
    /**
357
     * Removes temporary directory.
358
     *
359
     * @throws RuntimeException
360
     */
361
    public function tearDownServer(): void
362
    {
363
        $this->output->writeln('');
364
        $this->output->writeln('<info>Server stopped.</info>');
365
366
        try {
367
            Util\File::getFS()->remove(Util::joinFile($this->getPath(), self::TMP_DIR));
368
        } catch (IOExceptionInterface $e) {
369
            throw new RuntimeException($e->getMessage());
370
        }
371
    }
372
}
373