Serve::execute()   F
last analyzed

Complexity

Conditions 35
Paths > 20000

Size

Total Lines 193
Code Lines 130

Duplication

Lines 0
Ratio 0 %

Importance

Changes 7
Bugs 2 Features 0
Metric Value
cc 35
eloc 130
c 7
b 2
f 0
nc 16607233
nop 2
dl 0
loc 193
rs 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
    /** @var boolean */
41
    protected $watcherEnabled;
42
43
    /**
44
     * {@inheritdoc}
45
     */
46
    protected function configure()
47
    {
48
        $this
49
            ->setName('serve')
50
            ->setDescription('Starts the built-in server')
51
            ->setDefinition([
52
                new InputArgument('path', InputArgument::OPTIONAL, 'Use the given path as working directory'),
53
                new InputOption('open', 'o', InputOption::VALUE_NONE, 'Open web browser automatically'),
54
                new InputOption('host', null, InputOption::VALUE_REQUIRED, 'Server host', 'localhost'),
55
                new InputOption('port', null, InputOption::VALUE_REQUIRED, 'Server port', '8000'),
56
                new InputOption('watch', 'w', InputOption::VALUE_NEGATABLE, 'Enable (or disable --no-watch) changes watcher (enabled by default)', true),
57
                new InputOption('drafts', 'd', InputOption::VALUE_NONE, 'Include drafts'),
58
                new InputOption('optimize', null, InputOption::VALUE_NEGATABLE, 'Enable (or disable --no-optimize) optimization of generated files'),
59
                new InputOption('config', 'c', InputOption::VALUE_REQUIRED, 'Set the path to extra config files (comma-separated)'),
60
                new InputOption('clear-cache', null, InputOption::VALUE_OPTIONAL, 'Clear cache before build (optional cache key as regular expression)', false),
61
                new InputOption('page', 'p', InputOption::VALUE_REQUIRED, 'Build a specific page'),
62
                new InputOption('no-ignore-vcs', null, InputOption::VALUE_NONE, 'Changes watcher must not ignore VCS directories'),
63
                new InputOption('metrics', 'm', InputOption::VALUE_NONE, 'Show build metrics (duration and memory) of each step'),
64
                new InputOption('timeout', null, InputOption::VALUE_REQUIRED, 'Sets the process timeout (max. runtime) in seconds', 7200), // default is 2 hours
65
                new InputOption('notif', null, InputOption::VALUE_NONE, 'Send desktop notification on server start'),
66
            ])
67
            ->setHelp(
68
                <<<'EOF'
69
The <info>%command.name%</> command starts the live-reloading-built-in web server.
70
71
  <info>%command.full_name%</>
72
  <info>%command.full_name% path/to/the/working/directory</>
73
  <info>%command.full_name% --open</>
74
  <info>%command.full_name% --drafts</>
75
  <info>%command.full_name% --no-watch</>
76
77
You can use a custom host and port by using the <info>--host</info> and <info>--port</info> options:
78
79
  <info>%command.full_name% --host=127.0.0.1 --port=8080</>
80
81
To build the website with an extra configuration file, you can use the <info>--config</info> option.
82
This is useful during local development to <comment>override some settings</comment> without modifying the main configuration:
83
84
  <info>%command.full_name% --config=config/dev.yml</>
85
86
To start the server with changes watcher <comment>not ignoring VCS</comment> directories, run:
87
88
  <info>%command.full_name% --no-ignore-vcs</>
89
90
To define the process <comment>timeout</comment> (in seconds), run:
91
92
  <info>%command.full_name% --timeout=7200</>
93
94
Send a desktop <comment>notification</comment> on server start, run:
95
96
  <info>%command.full_name% --notif</>
97
EOF
98
            );
99
    }
100
101
    /**
102
     * {@inheritdoc}
103
     *
104
     * @throws RuntimeException
105
     */
106
    protected function execute(InputInterface $input, OutputInterface $output): int
107
    {
108
        $open = $input->getOption('open');
109
        $host = $input->getOption('host');
110
        $port = $input->getOption('port');
111
        $drafts = $input->getOption('drafts');
112
        $optimize = $input->getOption('optimize');
113
        $clearcache = $input->getOption('clear-cache');
114
        $page = $input->getOption('page');
115
        $noignorevcs = $input->getOption('no-ignore-vcs');
116
        $metrics = $input->getOption('metrics');
117
        $timeout = $input->getOption('timeout');
118
        $verbose = $input->getOption('verbose');
119
        $notif = $input->getOption('notif');
120
121
        $resourceWatcher = null;
122
        $this->watcherEnabled = $input->getOption('watch');
123
124
        // checks if PHP executable is available
125
        $phpFinder = new PhpExecutableFinder();
126
        $php = $phpFinder->find();
127
        if ($php === false) {
128
            throw new RuntimeException('Unable to find a local PHP executable.');
129
        }
130
131
        // setup server
132
        $this->setUpServer();
133
        $command = \sprintf(
134
            '"%s" -S %s:%d -t "%s" "%s"',
135
            $php,
136
            $host,
137
            $port,
138
            Util::joinFile($this->getPath(), self::SERVE_OUTPUT),
139
            Util::joinFile($this->getPath(), self::TMP_DIR, 'router.php')
140
        );
141
        $process = Process::fromShellCommandline($command);
142
143
        // setup build process
144
        $buildProcessArguments = [
145
            $php,
146
            $_SERVER['argv'][0],
147
        ];
148
        $buildProcessArguments[] = 'build';
149
        $buildProcessArguments[] = $this->getPath();
150
        if (!empty($this->getConfigFiles())) {
151
            $buildProcessArguments[] = '--config';
152
            $buildProcessArguments[] = implode(',', $this->getConfigFiles());
153
        }
154
        if ($drafts) {
155
            $buildProcessArguments[] = '--drafts';
156
        }
157
        if ($optimize === true) {
158
            $buildProcessArguments[] = '--optimize';
159
        }
160
        if ($optimize === false) {
161
            $buildProcessArguments[] = '--no-optimize';
162
        }
163
        if ($clearcache === null) {
164
            $buildProcessArguments[] = '--clear-cache';
165
        }
166
        if (!empty($clearcache)) {
167
            $buildProcessArguments[] = '--clear-cache';
168
            $buildProcessArguments[] = $clearcache;
169
        }
170
        if ($verbose) {
171
            $buildProcessArguments[] = '-' . str_repeat('v', $_SERVER['SHELL_VERBOSITY']);
172
        }
173
        if (!empty($page)) {
174
            $buildProcessArguments[] = '--page';
175
            $buildProcessArguments[] = $page;
176
        }
177
        if ($metrics) {
178
            $buildProcessArguments[] = '--metrics';
179
        }
180
        $buildProcessArguments[] = '--baseurl';
181
        $buildProcessArguments[] = "http://$host:$port/";
182
        $buildProcessArguments[] = '--output';
183
        $buildProcessArguments[] = self::SERVE_OUTPUT;
184
        $buildProcess = new Process(
185
            $buildProcessArguments,
186
            null,
187
            ['BOX_REQUIREMENT_CHECKER' => '0'] // prevents double check (build then serve)
188
        );
189
        $buildProcess->setTty(Process::isTtySupported());
190
        $buildProcess->setPty(Process::isPtySupported());
191
        $buildProcess->setTimeout((float) $timeout);
192
        $processOutputCallback = function ($type, $buffer) use ($output) {
193
            $output->write($buffer, false, OutputInterface::OUTPUT_RAW);
194
        };
195
196
        // builds before serve
197
        $output->writeln(\sprintf('<comment>Build process: %s</comment>', implode(' ', $buildProcessArguments)), OutputInterface::VERBOSITY_DEBUG);
198
        $buildProcess->run($processOutputCallback);
199
        if ($buildProcess->isSuccessful()) {
200
            $this->buildSuccessActions($output);
201
        }
202
        if ($buildProcess->getExitCode() !== 0) {
203
            $this->tearDownServer();
204
205
            return 1;
206
        }
207
208
        // handles serve process
209
        if (!$process->isStarted()) {
210
            $messageSuffix = '';
211
            // setup resource watcher
212
            if ($this->watcherEnabled) {
213
                $resourceWatcher = $this->setupWatcher($noignorevcs);
214
                $resourceWatcher->initialize();
215
                $messageSuffix = ' with changes watcher';
216
            }
217
            // starts server
218
            try {
219
                if (\function_exists('\pcntl_signal')) {
220
                    pcntl_async_signals(true);
221
                    pcntl_signal(SIGINT, [$this, 'tearDownServer']);
222
                    pcntl_signal(SIGTERM, [$this, 'tearDownServer']);
223
                }
224
                $output->writeln(\sprintf('<comment>Server process: %s</comment>', $command), OutputInterface::VERBOSITY_DEBUG);
225
                $output->writeln(\sprintf('Starting server%s (<href=http://%s:%d>http://%s:%d</>)', $messageSuffix, $host, $port, $host, $port));
226
                $process->start(function ($type, $buffer) {
227
                    if ($type === Process::ERR) {
228
                        error_log($buffer, 3, Util::joinFile($this->getPath(), self::TMP_DIR, 'errors.log'));
229
                    }
230
                });
231
                // notification
232
                if ($notif) {
233
                    $this->notification('Starting server 🚀');
234
                }
235
                // open web browser
236
                if ($open) {
237
                    $output->writeln('Opening web browser...');
238
                    Util\Platform::openBrowser(\sprintf('http://%s:%s', $host, $port));
239
                }
240
                while ($process->isRunning()) {
241
                    sleep(1); // wait for server is ready
242
                    if (!fsockopen($host, (int) $port)) {
243
                        $output->writeln('<info>Server is not ready</info>');
244
245
                        return 1;
246
                    }
247
                    if ($this->watcherEnabled && $resourceWatcher instanceof ResourceWatcher) {
248
                        $watcher = $resourceWatcher->findChanges();
249
                        if ($watcher->hasChanges()) {
250
                            $output->writeln('<comment>Changes detected</comment>');
251
                            // notification
252
                            if ($notif) {
253
                                $this->notification('Changes detected, building website...');
254
                            }
255
                            // prints deleted/new/updated files in debug mode
256
                            if (\count($watcher->getDeletedFiles()) > 0) {
257
                                $output->writeln('<comment>Deleted files:</comment>', OutputInterface::VERBOSITY_DEBUG);
258
                                foreach ($watcher->getDeletedFiles() as $file) {
259
                                    $output->writeln("<comment>- $file</comment>", OutputInterface::VERBOSITY_DEBUG);
260
                                }
261
                            }
262
                            if (\count($watcher->getNewFiles()) > 0) {
263
                                $output->writeln('<comment>New files:</comment>', OutputInterface::VERBOSITY_DEBUG);
264
                                foreach ($watcher->getNewFiles() as $file) {
265
                                    $output->writeln("<comment>- $file</comment>", OutputInterface::VERBOSITY_DEBUG);
266
                                }
267
                            }
268
                            if (\count($watcher->getUpdatedFiles()) > 0) {
269
                                $output->writeln('<comment>Updated files:</comment>', OutputInterface::VERBOSITY_DEBUG);
270
                                foreach ($watcher->getUpdatedFiles() as $file) {
271
                                    $output->writeln("<comment>- $file</comment>", OutputInterface::VERBOSITY_DEBUG);
272
                                }
273
                            }
274
                            $output->writeln('');
275
                            // re-builds
276
                            $buildProcess->run($processOutputCallback);
277
                            if ($buildProcess->isSuccessful()) {
278
                                $this->buildSuccessActions($output);
279
                            }
280
                            $output->writeln('<info>Server is runnning...</info>');
281
                            // notification
282
                            if ($notif) {
283
                                $this->notification('Server is runnning...');
284
                            }
285
                        }
286
                    }
287
                }
288
                if ($process->getExitCode() > 0) {
289
                    $output->writeln(\sprintf('<comment>%s</comment>', trim($process->getErrorOutput())));
290
                }
291
            } catch (ProcessFailedException $e) {
292
                $this->tearDownServer();
293
294
                throw new RuntimeException(\sprintf($e->getMessage()));
295
            }
296
        }
297
298
        return 0;
299
    }
300
301
    /**
302
     * Build success actions.
303
     */
304
    private function buildSuccessActions(OutputInterface $output): void
305
    {
306
        // writes `changes.flag` file
307
        if ($this->watcherEnabled) {
308
            Util\File::getFS()->dumpFile(Util::joinFile($this->getPath(), self::TMP_DIR, 'changes.flag'), time());
309
        }
310
        // writes `headers.ini` file
311
        $headers = $this->getBuilder()->getConfig()->get('server.headers');
312
        if (is_iterable($headers)) {
313
            $output->writeln('Writing headers file...');
314
            Util\File::getFS()->remove(Util::joinFile($this->getPath(), self::TMP_DIR, 'headers.ini'));
315
            foreach ($headers as $entry) {
316
                Util\File::getFS()->appendToFile(Util::joinFile($this->getPath(), self::TMP_DIR, 'headers.ini'), "[{$entry['path']}]\n");
317
                foreach ($entry['headers'] ?? [] as $header) {
318
                    Util\File::getFS()->appendToFile(Util::joinFile($this->getPath(), self::TMP_DIR, 'headers.ini'), "{$header['key']} = \"{$header['value']}\"\n");
319
                }
320
            }
321
        }
322
    }
323
324
    /**
325
     * Sets up the watcher.
326
     */
327
    private function setupWatcher(bool $noignorevcs = false): ResourceWatcher
328
    {
329
        $finder = new Finder();
330
        $finder->files()
331
            ->in($this->getPath())
332
            ->exclude((string) $this->getBuilder()->getConfig()->get('output.dir'));
333
        if (file_exists(Util::joinFile($this->getPath(), '.gitignore')) && $noignorevcs === false) {
334
            $finder->ignoreVCSIgnored(true);
335
        }
336
        return new ResourceWatcher(new ResourceCacheMemory(), $finder, new Crc32ContentHash());
337
    }
338
339
    /**
340
     * Prepares server's files.
341
     *
342
     * @throws RuntimeException
343
     */
344
    private function setUpServer(): void
345
    {
346
        try {
347
            // copying router
348
            Util\File::getFS()->copy(
349
                $this->rootPath . '/resources/server/router.php',
350
                Util::joinFile($this->getPath(), self::TMP_DIR, 'router.php'),
351
                true
352
            );
353
            // copying livereload JS for watcher
354
            $livereloadJs = Util::joinFile($this->getPath(), self::TMP_DIR, 'livereload.js');
355
            if (is_file($livereloadJs)) {
356
                Util\File::getFS()->remove($livereloadJs);
357
            }
358
            if ($this->watcherEnabled) {
359
                Util\File::getFS()->copy(
360
                    $this->rootPath . '/resources/server/livereload.js',
361
                    $livereloadJs,
362
                    true
363
                );
364
            }
365
        } catch (IOExceptionInterface $e) {
366
            throw new RuntimeException(\sprintf('An error occurred while copying server\'s files to "%s".', $e->getPath()));
367
        }
368
        if (!is_file(Util::joinFile($this->getPath(), self::TMP_DIR, 'router.php'))) {
369
            throw new RuntimeException(\sprintf('Router not found: "%s".', Util::joinFile(self::TMP_DIR, 'router.php')));
370
        }
371
    }
372
373
    /**
374
     * Removes temporary directory.
375
     *
376
     * @throws RuntimeException
377
     */
378
    public function tearDownServer(): void
379
    {
380
        $this->output->writeln('');
381
        $this->output->writeln('<info>Server stopped.</info>');
382
383
        try {
384
            Util\File::getFS()->remove(Util::joinFile($this->getPath(), self::TMP_DIR));
385
        } catch (IOExceptionInterface $e) {
386
            throw new RuntimeException($e->getMessage());
387
        }
388
    }
389
}
390