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
2declare(strict_types=1);
3
4
5namespace Lucent\Commandline;
6
7use Lucent\Facades\App;
8use Lucent\Facades\FileSystem;
9use Lucent\Logging\ConsoleColors;
10
11class 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}