Lines
0.00%
0 / 72
Functions and Methods
0.00%
0 / 1
Classes and Traits
0.00%
0 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| StartDevServerCommand | 0.00% | 0 / 72 | 0.00% | 0 / 1 | 380 | 0.00% | 0 / 1 | |||
| start | 0.00% | 0 / 72 | 0.00% | 0 / 1 | 380 | |||||
| 1 | <?php | |
| 2 | declare(strict_types=1); | |
| 3 | ||
| 4 | ||
| 5 | namespace Lucent\Commandline; | |
| 6 | ||
| 7 | use Lucent\Facades\App; | |
| 8 | use Lucent\Facades\FileSystem; | |
| 9 | use Lucent\Logging\ConsoleColors; | |
| 10 | ||
| 11 | class StartDevServerCommand | |
| 12 | { | |
| 13 | ||
| 14 | public static string $command = "serve"; | |
| 15 | ||
| 16 | public function start(array $options = []): string | |
| 17 | { | |
| 18 | // Configurable values. Precedence: CLI option > env var > default. | |
| 19 | $portExplicit = $options['port'] ??App::env('SERVER_PORT'); | |
| 20 | $port = $portExplicit ?? 8080; | |
| 21 | $host = $options['host'] ?? App::env('SERVER_HOST', '127.0.0.1'); | |
| 22 | $docRoot = $options['docroot'] ?? App::env('SERVER_DOCROOT', 'public'); | |
| 23 | $router = $options['router'] ?? App::env('SERVER_ROUTER'); | |
| 24 | $tries = $options['tries'] ?? App::env('SERVER_TRIES', 10); | |
| 25 | $noRestart = $options['no-restart'] ?? filter_var(App::env('SERVER_NO_RESTART', false), FILTER_VALIDATE_BOOL); | |
| 26 | ||
| 27 | // Only auto-increment the port when it wasn't explicitly configured | |
| 28 | // (checks whether the port option was provided). | |
| 29 | $portIsExplicit = $portExplicit !== null; | |
| 30 | ||
| 31 | if (!is_numeric($port)) { | |
| 32 | return "Invalid port number provided, must be a 'number'"; | |
| 33 | } | |
| 34 | ||
| 35 | echo ConsoleColors::FG_CYAN . "Lucent Development Server Starting..." . ConsoleColors::RESET . "\n"; | |
| 36 | echo ConsoleColors::FG_YELLOW . " Press Ctrl+C to stop the server" . ConsoleColors::RESET . "\n"; | |
| 37 | echo ConsoleColors::FG_BLUE . str_repeat("─", 50) . ConsoleColors::RESET . "\n"; | |
| 38 | ||
| 39 | // Resolve docroot and router against the project root. | |
| 40 | $docRootPath = FileSystem::absolutePath($docRoot); | |
| 41 | ||
| 42 | // Router precedence: | |
| 43 | // 1. A project-root server.php, if present (user override). | |
| 44 | // 2. The --router option / SERVER_ROUTER env var, if set. | |
| 45 | // 3. The bundled router shipped with Lucent. | |
| 46 | $projectServer = FileSystem::rootPath() . DIRECTORY_SEPARATOR . 'server.php'; | |
| 47 | if (is_file($projectServer)) { | |
| 48 | $routerPath = $projectServer; | |
| 49 | } elseif ($router !== null) { | |
| 50 | $routerPath = FileSystem::absolutePath($router); | |
| 51 | } else { | |
| 52 | $routerPath = __DIR__ . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'server.php'; | |
| 53 | } | |
| 54 | ||
| 55 | if (!is_dir($docRootPath)) { | |
| 56 | return ConsoleColors::FG_RED . "✗ Document root not found: {$docRootPath}" . ConsoleColors::RESET; | |
| 57 | } | |
| 58 | ||
| 59 | if (!is_file($routerPath)) { | |
| 60 | return ConsoleColors::FG_RED . "✗ Router script not found: {$routerPath}" . ConsoleColors::RESET; | |
| 61 | } | |
| 62 | ||
| 63 | // Track .env so we can restart the server when it changes. Disabled | |
| 64 | // via --no-restart / SERVER_NO_RESTART. | |
| 65 | $envFile = FileSystem::rootPath() . DIRECTORY_SEPARATOR . '.env'; | |
| 66 | $envLastModified = file_exists($envFile) ? filemtime($envFile) : null; | |
| 67 | ||
| 68 | // Auto-increment the port if the requested one is busy. Only applies | |
| 69 | // when the port wasn't explicitly set. | |
| 70 | $portOffset = 0; | |
| 71 | $restart = true; | |
| 72 | ||
| 73 | while ($restart) { | |
| 74 | $restart = false; | |
| 75 | $currentPort = (int) $port + $portOffset; | |
| 76 | ||
| 77 | // Build the PHP built-in server command. The router script | |
| 78 | // (public/index.php) serves static files directly and forwards | |
| 79 | // everything else to Lucent, so routes like /users work. | |
| 80 | $command = sprintf( | |
| 81 | 'php -S %s:%d -t %s %s', | |
| 82 | escapeshellarg($host), | |
| 83 | $currentPort, | |
| 84 | escapeshellarg($docRootPath), | |
| 85 | escapeshellarg($routerPath) | |
| 86 | ); | |
| 87 | ||
| 88 | // Run the server with the docroot as its working directory. PHP's | |
| 89 | // built-in server executes the router script with the CWD of the | |
| 90 | // `php -S` process, so relative paths inside the router (e.g. | |
| 91 | // `require_once '../vendor/autoload.php'`) must resolve from the | |
| 92 | // docroot — not from wherever the CLI was invoked. | |
| 93 | // | |
| 94 | // Pass STDOUT/STDERR through directly so the child writes straight | |
| 95 | // to the terminal: output is real-time and unbuffered, and the | |
| 96 | // child can detect a TTY (preserving colors and line buffering). | |
| 97 | // We don't need to inspect the output programmatically — port-busy | |
| 98 | // detection relies on the exit code, not on parsing stderr. | |
| 99 | $process = proc_open($command, [1 => STDOUT, 2 => STDERR], $pipes, $docRootPath); | |
| 100 | ||
| 101 | if (!is_resource($process)) { | |
| 102 | return ConsoleColors::FG_RED . "✗ Failed to start the server" . ConsoleColors::RESET; | |
| 103 | } | |
| 104 | ||
| 105 | $envChanged = false; | |
| 106 | ||
| 107 | // Poll until the server exits. Without pipes there's no stream to | |
| 108 | // select on, so a short sleep is the simplest way to wait. 100ms is | |
| 109 | // imperceptible for a dev server. | |
| 110 | while (true) { | |
| 111 | $status = proc_get_status($process); | |
| 112 | if (!$status['running']) { | |
| 113 | break; | |
| 114 | } | |
| 115 | ||
| 116 | // Restart the server if .env changed on disk. | |
| 117 | if (!$noRestart && $envLastModified !== null) { | |
| 118 | clearstatcache(false, $envFile); | |
| 119 | $current = filemtime($envFile); | |
| 120 | if ($current > $envLastModified) { | |
| 121 | $envLastModified = $current; | |
| 122 | $envChanged = true; | |
| 123 | break; | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | usleep(100000); | |
| 128 | } | |
| 129 | ||
| 130 | // Capture the exit status BEFORE closing so we can distinguish a | |
| 131 | // signal kill (Ctrl+C) from a normal exit or a bind failure. | |
| 132 | $status = proc_get_status($process); | |
| 133 | $exitCode = $status['exitcode']; | |
| 134 | $signaled = $status['signaled']; | |
| 135 | proc_close($process); | |
| 136 | ||
| 137 | if ($envChanged) { | |
| 138 | echo "\n" . ConsoleColors::FG_YELLOW . "Environment modified. Restarting server..." . ConsoleColors::RESET . "\n"; | |
| 139 | $restart = true; | |
| 140 | continue; | |
| 141 | } | |
| 142 | ||
| 143 | // If the user pressed Ctrl+C (process killed by a signal), stop | |
| 144 | // cleanly — do NOT try another port. | |
| 145 | if ($signaled) { | |
| 146 | break; | |
| 147 | } | |
| 148 | ||
| 149 | // If the server exited immediately with an error, the port may be | |
| 150 | // busy. Try the next port up to the --tries limit — but only when | |
| 151 | // the port wasn't explicitly configured. | |
| 152 | if (!$portIsExplicit && $exitCode !== 0 && $portOffset < $tries - 1) { | |
| 153 | $portOffset++; | |
| 154 | echo ConsoleColors::FG_YELLOW . "Port {$currentPort} unavailable, trying " . ((int) $port + $portOffset) . "..." . ConsoleColors::RESET . "\n"; | |
| 155 | $restart = true; | |
| 156 | continue; | |
| 157 | } | |
| 158 | ||
| 159 | break; | |
| 160 | } | |
| 161 | ||
| 162 | echo "\n" . ConsoleColors::FG_YELLOW . "Server stopped" . ConsoleColors::RESET . "\n"; | |
| 163 | return ""; | |
| 164 | } | |
| 165 | } |