Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 197 additions & 51 deletions src/Console/Commands/ServeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@

namespace Quantum\Console\Commands;

use Quantum\Di\Exceptions\DiException;
use Quantum\Console\QtCommand;
use RuntimeException;
use Throwable;

/**
* Class ServeCommand
Expand All @@ -26,7 +27,7 @@ class ServeCommand extends QtCommand
/**
* Platform Windows
*/
public const PLATFORM_WINDOWS = 'WINNT';
public const PLATFORM_WINDOWS = 'Windows';

/**
* Platform Linux
Expand Down Expand Up @@ -63,10 +64,10 @@ class ServeCommand extends QtCommand
protected $defaultPort = 8000;

/**
* The current port offset.
* Max ports to scan
* @var int
*/
protected $portOffset = 0;
protected $maxPortScan = 50;

/**
* Command arguments
Expand All @@ -75,100 +76,245 @@ class ServeCommand extends QtCommand
protected $options = [
['host', null, 'optional', 'Host', '127.0.0.1'],
['port', null, 'optional', 'Port', '8000'],
['open', 'o', 'none', 'Open browser'],
];

/**
* Executes the command
* @throws DiException
* Execute the command.
*/
public function exec()
{
if (!$this->portAvailable()) {
$this->portOffset += 1;
$this->exec();
} elseif (!$this->openBrowserCommand()) {
$this->info('Starting development server at: ' . $this->host() . ':' . $this->port());
exec($this->runServerCommand(), $out);
} else {
$this->info('Starting development server at: ' . $this->host() . ':' . $this->port());
exec($this->openBrowserCommand() . ' && ' . $this->runServerCommand(), $out);
$host = $this->host();
$startPort = $this->port();

$serverProcess = $this->startServerOnAvailablePort($host, $startPort);

$this->handleServerExecution($serverProcess);
}

/**
* Start server on first available port.
* @param string $host
* @param int $startPort
* @return array
* @throws RuntimeException
*/
protected function startServerOnAvailablePort(string $host, int $startPort): array
{
for ($i = 0; $i < $this->maxPortScan && $startPort + $i <= 65535; $i++) {
$port = $startPort + $i;

if ($this->isPortInUse($host, $port)) {
continue;
}

$url = "http://{$host}:{$port}";
$this->info("Starting development server at: {$url}");

$process = $this->startPhpServer($host, $port);

try {
$this->waitUntilServerIsReady($host, $port, $process);

return [
'process' => $process,
'port' => $port,
'url' => $url,
];
} catch (Throwable $e) {
$this->cleanupProcess($process);
}
}

throw new RuntimeException('Unable to start PHP server on any available port.');
}

/**
* Starts the php development server
* @return string
* Handle server execution (browser opening and process monitoring).
* @param array $serverData
*/
protected function handleServerExecution(array $serverData): void
{
if ($this->shouldOpenBrowser()) {
$this->openBrowser($serverData['url']);
}

$this->waitForProcess($serverData['process']);
}

/**
* Clean up process resource.
* @param resource $process
*/
protected function cleanupProcess($process): void
{
if (is_resource($process)) {
proc_close($process);
}
}

/**
* Check if port is already in use by another process.
* @param string $host
* @param int $port
* @return bool
*/
protected function runServerCommand(): string
protected function isPortInUse(string $host, int $port): bool
{
return 'php -S ' . $this->host() . ':' . $this->port() . ' -t public';
$fp = @fsockopen($host, $port, $errno, $errstr, 0.1);
if ($fp) {
fclose($fp);
return true;
}
return false;
}

/**
* Tries to open the default browser
* @return string|null
* Start PHP built-in server.
* @param string $host
* @param int $port
* @return resource
*/
protected function openBrowserCommand(): ?string
protected function startPhpServer(string $host, int $port)
{
$platformCommand = $this->detectPlatformCommand();
$cmd = [
PHP_BINARY,
'-S',
"{$host}:{$port}",
'-t',
'public',
];

$descriptors = [
0 => STDIN,
1 => STDOUT,
2 => STDERR,
];

$process = proc_open($cmd, $descriptors, $pipes);

if (!$platformCommand) {
return null;
if (!is_resource($process)) {
throw new RuntimeException('Unable to start PHP development server.');
}

return $platformCommand . ' http://' . $this->host() . ':' . $this->port();
return $process;
}

/**
* Checks the available port
* Wait until the PHP server is ready to accept connections.
* @param string $host
* @param int $port
* @param resource $process
*/
protected function waitUntilServerIsReady(string $host, int $port, $process): void
{
$start = time();

while (true) {
$status = proc_get_status($process);
if ($status === false || !$status['running']) {
throw new RuntimeException('PHP server process died unexpectedly.');
}

$fp = @fsockopen($host, $port, $e, $s, 0.5);
if ($fp) {
fclose($fp);
return;
}

if (time() - $start > 10) {
throw new RuntimeException('Server failed to start within 10 seconds.');
}

usleep(200_000);
}
}

/**
* Block until the PHP server process exits.
* @param resource $process
*/
protected function waitForProcess($process): void
{
while (proc_get_status($process)['running']) {
usleep(200_000);
}

proc_close($process);
}

/**
* Determine whether the browser should be opened.
* @return bool
*/
protected function portAvailable(): bool
protected function shouldOpenBrowser(): bool
{
$connection = @fsockopen($this->host(), $this->port(), $errno, $err, 30);
return (bool) $this->getOption('open');
}

if (!is_resource($connection)) {
return true;
} else {
fclose($connection);
return false;
/**
* Open the default browser.
* @param string $url
*/
protected function openBrowser(string $url): void
{
$cmd = $this->browserCommand($url);
if (!$cmd) {
return;
}

$descriptors = [
0 => STDIN,
1 => STDOUT,
2 => STDERR,
];

$proc = proc_open($cmd, $descriptors, $pipes);
if (is_resource($proc)) {
proc_close($proc);
}
}

/**
* Detects the platform
* @return string|null
* Resolve platform-specific browser command.
* @param string $url
* @return array|null
*/
protected function detectPlatformCommand(): ?string
protected function browserCommand(string $url): ?array
{
switch (PHP_OS) {
case self::PLATFORM_LINUX:
return 'xdg-open';
switch (PHP_OS_FAMILY) {
case self::PLATFORM_WINDOWS:
return 'start';
return ['explorer.exe', $url];
case self::PLATFORM_LINUX:
return ['xdg-open', $url];
case self::PLATFORM_MAC:
return 'open';
return ['open', $url];
default:
return null;
}
}

/**
* Gets the host
* @return mixed|string
* Get host option.
* @return string
*/
protected function host()
protected function host(): string
{
return $this->getOption('host') ?: $this->defaultHost;
return (string) ($this->getOption('host') ?: $this->defaultHost);
}

/**
* Gets the port
* @return int|mixed|string
* Get and validate port option.
* @return int
*/
protected function port()
protected function port(): int
{
$port = $this->getOption('port') ?: $this->defaultPort;
return $port + $this->portOffset;
$port = (int) ($this->getOption('port') ?: $this->defaultPort);

if ($port < 1 || $port > 65535) {
throw new RuntimeException("Port must be between 1 and 65535, got: {$port}");
}

return $port;
}
}