Passed
Pull Request — master (#406)
by Arman
03:00
created

ServeCommand::port()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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