Completed
Push — master ( ca0e86...5f481a )
by Arman
14s queued 10s
created

ServeCommand::exec()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 9
nc 3
nop 0
dl 0
loc 11
rs 9.9666
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * Quantum PHP Framework
5
 *
6
 * An open source software development framework for PHP
7
 *
8
 * @package Quantum
9
 * @author Arman Ag. <[email protected]>
10
 * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org)
11
 * @link http://quantum.softberg.org/
12
 * @since 3.0.0
13
 */
14
15
namespace Quantum\Console\Commands;
16
17
use Quantum\Console\QtCommand;
18
use RuntimeException;
19
use Throwable;
20
21
/**
22
 * Class ServeCommand
23
 * @package Quantum\Console
24
 */
25
class ServeCommand extends QtCommand
26
{
27
    /**
28
     * Platform Windows
29
     */
30
    public const PLATFORM_WINDOWS = 'Windows';
31
32
    /**
33
     * Platform Linux
34
     */
35
    public const PLATFORM_LINUX = 'Linux';
36
37
    /**
38
     * Platform Mac
39
     */
40
    public const PLATFORM_MAC = 'Darwin';
41
42
    /**
43
     * The console command name.
44
     * @var string
45
     */
46
    protected $name = 'serve';
47
48
    /**
49
     * The console command description.
50
     * @var string
51
     */
52
    protected $description = 'Serves the application on the PHP development server';
53
54
    /**
55
     * Default host
56
     * @var string
57
     */
58
    protected $defaultHost = '127.0.0.1';
59
60
    /**
61
     * Default port
62
     * @var int
63
     */
64
    protected $defaultPort = 8000;
65
66
    /**
67
     * Max ports to scan
68
     * @var int
69
     */
70
    protected $maxPortScan = 50;
71
72
    /**
73
     * Command arguments
74
     * @var array<int, list<string|null>>
75
     */
76
    protected $options = [
77
        ['host', null, 'optional', 'Host', '127.0.0.1'],
78
        ['port', null, 'optional', 'Port', '8000'],
79
        ['open', 'o', 'none', 'Open browser'],
80
    ];
81
82
    /**
83
     * Execute the command.
84
     */
85
    public function exec()
86
    {
87
        $host = $this->host();
88
        $startPort = $this->port();
89
90
        $serverProcess = $this->startServerOnAvailablePort($host, $startPort);
91
92
        $this->handleServerExecution($serverProcess);
93
    }
94
95
    /**
96
     * Start server on first available port.
97
     * @param string $host
98
     * @param int $startPort
99
     * @return array
100
     * @throws RuntimeException
101
     */
102
    protected function startServerOnAvailablePort(string $host, int $startPort): array
103
    {
104
        for ($i = 0; $i < $this->maxPortScan && $startPort + $i <= 65535; $i++) {
105
            $port = $startPort + $i;
106
107
            if ($this->isPortInUse($host, $port)) {
108
                continue;
109
            }
110
111
            $url = "http://{$host}:{$port}";
112
            $this->info("Starting development server at: {$url}");
113
114
            $process = $this->startPhpServer($host, $port);
115
116
            try {
117
                $this->waitUntilServerIsReady($host, $port, $process);
118
119
                return [
120
                    'process' => $process,
121
                    'port' => $port,
122
                    'url' => $url,
123
                ];
124
            } catch (Throwable $e) {
125
                $this->cleanupProcess($process);
126
            }
127
        }
128
129
        throw new RuntimeException('Unable to start PHP server on any available port.');
130
    }
131
132
    /**
133
     * Handle server execution (browser opening and process monitoring).
134
     * @param array $serverData
135
     */
136
    protected function handleServerExecution(array $serverData): void
137
    {
138
        if ($this->shouldOpenBrowser()) {
139
            $this->openBrowser($serverData['url']);
140
        }
141
142
        $this->waitForProcess($serverData['process']);
143
    }
144
145
    /**
146
     * Clean up process resource.
147
     * @param resource $process
148
     */
149
    protected function cleanupProcess($process): void
150
    {
151
        if (is_resource($process)) {
152
            proc_close($process);
153
        }
154
    }
155
156
    /**
157
     * Check if port is already in use by another process.
158
     * @param string $host
159
     * @param int $port
160
     * @return bool
161
     */
162
    protected function isPortInUse(string $host, int $port): bool
163
    {
164
        $fp = @fsockopen($host, $port, $errno, $errstr, 0.1);
165
        if ($fp) {
166
            fclose($fp);
167
            return true;
168
        }
169
        return false;
170
    }
171
172
    /**
173
     * Start PHP built-in server.
174
     * @param string $host
175
     * @param int $port
176
     * @return resource
177
     */
178
    protected function startPhpServer(string $host, int $port)
179
    {
180
        $cmd = [
181
            PHP_BINARY,
182
            '-S',
183
            "{$host}:{$port}",
184
            '-t',
185
            'public',
186
        ];
187
188
        $descriptors = [
189
            0 => STDIN,
190
            1 => STDOUT,
191
            2 => STDERR,
192
        ];
193
194
        $process = proc_open($cmd, $descriptors, $pipes);
195
196
        if (!is_resource($process)) {
197
            throw new RuntimeException('Unable to start PHP development server.');
198
        }
199
200
        return $process;
201
    }
202
203
    /**
204
     * Wait until the PHP server is ready to accept connections.
205
     * @param string $host
206
     * @param int $port
207
     * @param resource $process
208
     */
209
    protected function waitUntilServerIsReady(string $host, int $port, $process): void
210
    {
211
        $start = time();
212
213
        while (true) {
214
            $status = proc_get_status($process);
215
            if ($status === false || !$status['running']) {
216
                throw new RuntimeException('PHP server process died unexpectedly.');
217
            }
218
219
            $fp = @fsockopen($host, $port, $e, $s, 0.5);
220
            if ($fp) {
221
                fclose($fp);
222
                return;
223
            }
224
225
            if (time() - $start > 10) {
226
                throw new RuntimeException('Server failed to start within 10 seconds.');
227
            }
228
229
            usleep(200_000);
1 ignored issue
show
Bug introduced by
A parse error occurred: Syntax error, unexpected T_STRING, expecting ',' or ')' on line 229 at column 22
Loading history...
230
        }
231
    }
232
233
    /**
234
     * Block until the PHP server process exits.
235
     * @param resource $process
236
     */
237
    protected function waitForProcess($process): void
238
    {
239
        while (proc_get_status($process)['running']) {
240
            usleep(200_000);
241
        }
242
243
        proc_close($process);
244
    }
245
246
    /**
247
     * Determine whether the browser should be opened.
248
     * @return bool
249
     */
250
    protected function shouldOpenBrowser(): bool
251
    {
252
        return (bool) $this->getOption('open');
253
    }
254
255
    /**
256
     * Open the default browser.
257
     * @param string $url
258
     */
259
    protected function openBrowser(string $url): void
260
    {
261
        $cmd = $this->browserCommand($url);
262
        if (!$cmd) {
263
            return;
264
        }
265
266
        $descriptors = [
267
            0 => STDIN,
268
            1 => STDOUT,
269
            2 => STDERR,
270
        ];
271
272
        $proc = proc_open($cmd, $descriptors, $pipes);
273
        if (is_resource($proc)) {
274
            proc_close($proc);
275
        }
276
    }
277
278
    /**
279
     * Resolve platform-specific browser command.
280
     * @param string $url
281
     * @return array|null
282
     */
283
    protected function browserCommand(string $url): ?array
284
    {
285
        switch (PHP_OS_FAMILY) {
286
            case self::PLATFORM_WINDOWS:
287
                return ['explorer.exe', $url];
288
            case self::PLATFORM_LINUX:
289
                return ['xdg-open', $url];
290
            case self::PLATFORM_MAC:
291
                return ['open', $url];
292
            default:
293
                return null;
294
        }
295
    }
296
297
    /**
298
     * Get host option.
299
     * @return string
300
     */
301
    protected function host(): string
302
    {
303
        return (string) ($this->getOption('host') ?: $this->defaultHost);
304
    }
305
306
    /**
307
     * Get and validate port option.
308
     * @return int
309
     */
310
    protected function port(): int
311
    {
312
        $port = (int) ($this->getOption('port') ?: $this->defaultPort);
313
314
        if ($port < 1 || $port > 65535) {
315
            throw new RuntimeException("Port must be between 1 and 65535, got: {$port}");
316
        }
317
318
        return $port;
319
    }
320
}
321