Passed
Push — master ( ced3fe...73fb21 )
by Caen
03:37
created

ServeCommand::cleanupViteHotFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 0
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Hyde\RealtimeCompiler\Console\Commands;
6
7
use Closure;
8
use Hyde\Facades\Filesystem;
9
use Hyde\Hyde;
10
use Hyde\Facades\Config;
11
use Illuminate\Contracts\Process\InvokedProcess;
12
use Illuminate\Support\Arr;
13
use Illuminate\Support\Sleep;
14
use InvalidArgumentException;
15
use Exception;
16
use Hyde\Console\Concerns\Command;
17
use Hyde\RealtimeCompiler\ConsoleOutput;
18
use Illuminate\Support\Facades\Process;
19
20
use function rtrim;
21
use function sprintf;
22
use function in_array;
23
use function str_replace;
24
use function class_exists;
25
26
/**
27
 * Start the realtime compiler server.
28
 *
29
 * @see https://github.com/hydephp/realtime-compiler
30
 */
31
class ServeCommand extends Command
32
{
33
    /** @var string */
34
    protected $signature = 'serve
35
        {--host= : <comment>[default: "localhost"]</comment>}}
36
        {--port= : <comment>[default: 8080]</comment>}
37
        {--save-preview= : Should the served page be saved to disk? (Overrides config setting)}
38
        {--dashboard= : Enable the realtime compiler dashboard. (Overrides config setting)}
39
        {--pretty-urls= : Enable pretty URLs. (Overrides config setting)}
40
        {--play-cdn= : Enable the Tailwind Play CDN. (Overrides config setting)}
41
        {--open=false : Open the site preview in the browser.}
42
        {--vite : Enable Vite for Hot Module Replacement (HMR)}
43
    ';
44
45
    /** @var string */
46
    protected $description = 'Start the realtime compiler server';
47
48
    protected ConsoleOutput $console;
49
50
    protected InvokedProcess $server;
51
    protected ?InvokedProcess $vite = null;
52
53
    public function safeHandle(): int
54
    {
55
        $this->configureOutput();
56
        $this->printStartMessage();
57
58
        if ($this->option('open') !== 'false') {
59
            $this->openInBrowser((string) $this->option('open'));
60
        }
61
62
        if ($this->option('vite')) {
63
            $this->runViteProcess();
64
        }
65
66
        $this->runServerProcess(sprintf('php -S %s:%d %s',
67
            $this->getHostSelection(),
68
            $this->getPortSelection(),
69
            escapeshellarg($this->getExecutablePath()),
70
        ));
71
72
        $this->handleRunningProcesses();
73
74
        if ($this->option('vite')) {
75
            $this->cleanupViteHotFile();
76
        }
77
78
        return Command::SUCCESS;
79
    }
80
81
    protected function getHostSelection(): string
82
    {
83
        return (string) $this->option('host') ?: Config::getString('hyde.server.host', 'localhost');
84
    }
85
86
    protected function getPortSelection(): int
87
    {
88
        return (int) ($this->option('port') ?: Config::getInt('hyde.server.port', 8080));
89
    }
90
91
    protected function getExecutablePath(): string
92
    {
93
        return Hyde::path('vendor/hyde/realtime-compiler/bin/server.php');
0 ignored issues
show
Bug introduced by
The method path() does not exist on Hyde\Hyde. Since you implemented __callStatic, consider adding a @method annotation. ( Ignorable by Annotation )

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

93
        return Hyde::/** @scrutinizer ignore-call */ path('vendor/hyde/realtime-compiler/bin/server.php');
Loading history...
94
    }
95
96
    protected function runServerProcess(string $command): void
97
    {
98
        $this->server = Process::forever()->env($this->getEnvironmentVariables())->start($command, $this->getOutputHandler());
99
    }
100
101
    protected function getEnvironmentVariables(): array
102
    {
103
        return Arr::whereNotNull([
104
            'HYDE_SERVER_REQUEST_OUTPUT' => ! $this->option('no-ansi'),
105
            'HYDE_SERVER_SAVE_PREVIEW' => $this->parseEnvironmentOption('save-preview'),
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->parseEnvironmentOption('save-preview') targeting Hyde\RealtimeCompiler\Co...arseEnvironmentOption() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
106
            'HYDE_SERVER_DASHBOARD' => $this->parseEnvironmentOption('dashboard'),
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->parseEnvironmentOption('dashboard') targeting Hyde\RealtimeCompiler\Co...arseEnvironmentOption() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
107
            'HYDE_PRETTY_URLS' => $this->parseEnvironmentOption('pretty-urls'),
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->parseEnvironmentOption('pretty-urls') targeting Hyde\RealtimeCompiler\Co...arseEnvironmentOption() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
108
            'HYDE_PLAY_CDN' => $this->parseEnvironmentOption('play-cdn'),
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->parseEnvironmentOption('play-cdn') targeting Hyde\RealtimeCompiler\Co...arseEnvironmentOption() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
109
        ]);
110
    }
111
112
    protected function configureOutput(): void
113
    {
114
        if (! $this->useBasicOutput()) {
115
            $this->console = new ConsoleOutput($this->output->isVerbose());
116
        }
117
    }
118
119
    protected function printStartMessage(): void
120
    {
121
        $this->useBasicOutput()
122
            ? $this->output->writeln('<info>Starting the HydeRC server...</info> Use Ctrl+C to stop')
123
            : $this->console->printStartMessage($this->getHostSelection(), $this->getPortSelection(), $this->getEnvironmentVariables(), $this->option('vite'));
0 ignored issues
show
Bug introduced by
$this->option('vite') of type string is incompatible with the type boolean|null expected by parameter $willUseVite of Hyde\RealtimeCompiler\Co...ut::printStartMessage(). ( Ignorable by Annotation )

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

123
            : $this->console->printStartMessage($this->getHostSelection(), $this->getPortSelection(), $this->getEnvironmentVariables(), /** @scrutinizer ignore-type */ $this->option('vite'));
Loading history...
124
    }
125
126
    protected function getOutputHandler(): Closure
127
    {
128
        return $this->useBasicOutput() ? function (string $type, string $line): void {
129
            $this->output->write($line);
130
        } : $this->console->getFormatter();
131
    }
132
133
    protected function useBasicOutput(): bool
134
    {
135
        return $this->option('no-ansi') || ! class_exists(ConsoleOutput::class);
136
    }
137
138
    protected function parseEnvironmentOption(string $name): ?string
139
    {
140
        $value = $this->option($name) ?? $this->checkArgvForOption($name);
141
142
        if ($value !== null) {
143
            return match ($value) {
144
                'true', '' => 'enabled',
145
                'false' => 'disabled',
146
                default => throw new InvalidArgumentException(sprintf('Invalid boolean value for --%s option.', $name))
147
            };
148
        }
149
150
        return null;
151
    }
152
153
    /** Fallback check so that an environment option without a value is acknowledged as true. */
154
    protected function checkArgvForOption(string $name): ?string
155
    {
156
        if (isset($_SERVER['argv'])) {
157
            if (in_array("--$name", $_SERVER['argv'], true)) {
158
                return 'true';
159
            }
160
        }
161
162
        return null;
163
    }
164
165
    protected function openInBrowser(string $path = '/'): void
166
    {
167
        $binary = $this->getOpenCommand(PHP_OS_FAMILY);
168
169
        $command = sprintf('%s http://%s:%d', $binary, $this->getHostSelection(), $this->getPortSelection());
170
        $command = rtrim("$command/$path", '/');
171
172
        $process = $binary ? Process::command($command)->run() : null;
173
174
        if (! $process || $process->failed()) {
175
            $this->warn('Unable to open the site preview in the browser on your system:');
176
            $this->line(sprintf('  %s', str_replace("\n", "\n  ", $process ? $process->errorOutput() : "Missing suitable 'open' binary.")));
177
            $this->newLine();
178
        }
179
    }
180
181
    protected function getOpenCommand(string $osFamily): ?string
182
    {
183
        return match ($osFamily) {
184
            'Windows' => 'start',
185
            'Darwin' => 'open',
186
            'Linux' => 'xdg-open',
187
            default => null
188
        };
189
    }
190
191
    protected function runViteProcess(): void
192
    {
193
        if (! $this->isPortAvailable(5173)) {
194
            throw new InvalidArgumentException(
195
                'Unable to start Vite server: Port 5173 is already in use. '.
196
                'Please stop any other Vite processes and try again.'
197
            );
198
        }
199
200
        $this->ensureNodeModulesAvailable();
201
202
        Filesystem::touch('app/storage/framework/runtime/vite.hot');
203
204
        try {
205
            $this->vite = Process::forever()->start('npm run dev');
206
        } catch (Exception $exception) {
207
            throw new InvalidArgumentException(
208
                'Unable to start Vite server: '.$exception->getMessage()
209
            );
210
        }
211
    }
212
213
    protected function handleRunningProcesses(): void
214
    {
215
        while ($this->server->running()) {
216
            $this->handleViteOutput();
217
218
            Sleep::for(100)->milliseconds();
219
        }
220
    }
221
222
    protected function handleViteOutput(): void
223
    {
224
        if ($this->vite?->running()) {
225
            $output = $this->vite->latestOutput();
0 ignored issues
show
Bug introduced by
The method latestOutput() does not exist on null. ( Ignorable by Annotation )

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

225
            /** @scrutinizer ignore-call */ 
226
            $output = $this->vite->latestOutput();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
226
227
            if ($output) {
228
                $this->output->write($output);
229
            }
230
        }
231
    }
232
233
    /** @experimental This feature may be removed before the final release. */
234
    protected function isPortAvailable(int $port): bool
235
    {
236
        $addresses = ['localhost', '127.0.0.1'];
237
238
        foreach ($addresses as $address) {
239
            $socket = @fsockopen($address, $port, $errno, $errstr, 1);
240
            if ($socket !== false) {
241
                fclose($socket);
242
243
                return false;
244
            }
245
        }
246
247
        return true;
248
    }
249
250
    protected function cleanupViteHotFile(): void
251
    {
252
        $hotFile = 'app/storage/framework/runtime/vite.hot';
253
254
        if (Filesystem::exists($hotFile)) {
255
            Filesystem::unlinkIfExists($hotFile);
256
        }
257
    }
258
259
    protected function ensureNodeModulesAvailable(): void
260
    {
261
        if (! $this->nodeModulesInstalled()) {
262
            if ($this->input->isInteractive()) {
263
                $this->warn('Node modules are not installed. Vite requires Node dependencies to function.');
264
265
                if ($this->confirm('Would you like to install them now?', true)) {
266
                    $this->installNodeModules();
267
                } else {
268
                    throw new InvalidArgumentException(
269
                        'The --vite flag cannot be used if Vite is not installed. Please run "npm install" first.'
270
                    );
271
                }
272
            } else {
273
                throw new InvalidArgumentException(
274
                    'Node modules are not installed. The --vite flag cannot be used if Vite is not installed. Please run "npm install" first.'
275
                );
276
            }
277
        }
278
    }
279
280
    protected function nodeModulesInstalled(): bool
281
    {
282
        return Filesystem::exists(Hyde::path('node_modules'))
283
            && Filesystem::exists(Hyde::path('package.json'));
284
    }
285
286
    protected function installNodeModules(): void
287
    {
288
        $this->info('Installing Node modules...');
289
290
        $process = Process::run('npm install');
291
292
        if ($process->failed()) {
293
            throw new InvalidArgumentException(
294
                'Failed to install Node modules: '.$process->errorOutput()
295
            );
296
        }
297
298
        $this->info('Node modules installed successfully.');
299
300
        if (! $this->nodeModulesInstalled()) {
301
            throw new InvalidArgumentException(
302
                'Node modules installation completed but dependencies are still not available.'
303
            );
304
        }
305
    }
306
}
307