Serve::configure()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 23
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

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