diff --git a/src/Phaseolies/Console/Commands/Server/ServerStartCommand.php b/src/Phaseolies/Console/Commands/Server/ServerStartCommand.php index 2c515d15..2504d52a 100644 --- a/src/Phaseolies/Console/Commands/Server/ServerStartCommand.php +++ b/src/Phaseolies/Console/Commands/Server/ServerStartCommand.php @@ -3,6 +3,9 @@ namespace Phaseolies\Console\Commands\Server; use Symfony\Component\Process\Process; +use Symfony\Component\Console\Terminal; +use Symfony\Component\Console\Formatter\OutputFormatter; +use Phaseolies\Application; use Phaseolies\Console\Schedule\Command; class ServerStartCommand extends Command @@ -25,6 +28,25 @@ class ServerStartCommand extends Command protected const MAX_PORT_ATTEMPTS = 10; + /** + * Time each open connection was accepted (or last served), keyed by client address. + * + * @var array + */ + private array $connections = []; + + /** + * Partial output line waiting for its newline. + */ + private string $pendingOutput = ''; + + /** + * Clients whose current request was already reported by the request logger. + * + * @var array + */ + private array $reported = []; + /** * Execute the console command. * @@ -43,7 +65,17 @@ public function handle(): int $port = self::determineAvailablePort($port); + if (!$background) { + $this->displayBanner(); + } + $this->displaySuccess("Server started on http://localhost:$port"); + + if (!$background) { + $this->line(' Press Ctrl+C to stop the server'); + $this->newLine(); + } + self::startServer($port, $background); return Command::SUCCESS; @@ -118,6 +150,8 @@ private function startServer(int $port, bool $background): void // Foreground mode $process = new Process([ 'php', + '-d', + 'auto_prepend_file=' . __DIR__ . '/request_logger.stub', '-S', "localhost:$port", '-t', @@ -128,7 +162,138 @@ private function startServer(int $port, bool $background): void $process->start(); $process->wait(function ($type, $buffer) { - $this->displayInfo($buffer); + $this->handleServerOutput($buffer); }); + + $this->handleServerOutput("\n"); + } + + /** + * Print the Doppar logo with the framework version. + * + * @return void + */ + private function displayBanner(): void + { + $logo = [ + ' ____ ', + ' / __ \\____ ____ ____ ____ ______', + ' / / / / __ \\/ __ \\/ __ \\/ __ `/ ___/', + ' / /_/ / /_/ / /_/ / /_/ / /_/ / / ', + '/_____/\\____/ .___/ .___/\\__,_/_/ ', + ' /_/ /_/ ', + ]; + + foreach ($logo as $row) { + $this->line('' . OutputFormatter::escape($row) . ''); + } + + $this->line(str_repeat(' ', 26) . 'v' . Application::VERSION . ''); + $this->line('' . str_repeat('-', 48) . ''); + $this->newLine(); + } + + /** + * Split raw server output into lines and render each one. + * + * @param string $buffer + * @return void + */ + private function handleServerOutput(string $buffer): void + { + $this->pendingOutput .= $buffer; + + while (($pos = strpos($this->pendingOutput, "\n")) !== false) { + $line = trim(substr($this->pendingOutput, 0, $pos)); + $this->pendingOutput = substr($this->pendingOutput, $pos + 1); + + if ($line !== '') { + $this->renderServerLine($line); + } + } + } + + /** + * Render a single PHP built-in server log line. + * + * @param string $line + * @return void + */ + private function renderServerLine(string $line): void + { + $now = microtime(true); + + if (str_starts_with($line, "DOPPAR_REQUEST\t")) { + [, $client, $status, $method, $uri, $ms] = explode("\t", $line) + array_fill(0, 6, ''); + $this->reported[$client] = true; + $this->printRequest((int) $status, $method, $uri, (float) $ms); + return; + } + + if (!preg_match('/^\[[^\]]+\]\s+(\S+)\s+(Accepted|Closing|\[(\d{3})\]:\s+(\S+)\s+(.*))$/', $line, $m)) { + $this->line(' ' . OutputFormatter::escape($line) . ''); + return; + } + + [, $client, $event] = $m; + + if ($event === 'Accepted') { + $this->connections[$client] = $now; + return; + } + + if ($event === 'Closing') { + unset($this->connections[$client], $this->reported[$client]); + return; + } + + if (isset($this->reported[$client])) { + return; + } + + $status = (int) $m[3]; + $method = $m[4]; + $uri = $m[5]; + $elapsed = $now - ($this->connections[$client] ?? $now); + $this->connections[$client] = $now; + + $this->printRequest($status, $method, $uri, $elapsed * 1000); + } + + /** + * Print one request line: status and path on the left, duration on the right. + * + * @param int $status + * @param string $method + * @param string $uri + * @param float $milliseconds + * @return void + */ + private function printRequest(int $status, string $method, string $uri, float $milliseconds): void + { + $color = match (true) { + $status >= 500 => 'red', + $status >= 400 => 'yellow', + $status >= 300 => 'cyan', + default => 'green', + }; + + $duration = $milliseconds >= 1000 + ? sprintf('%.2fs', $milliseconds / 1000) + : sprintf('%.2fms', $milliseconds); + + $left = sprintf('%s %s', $method, $uri); + $plainLeft = sprintf(' %s %s', $status, $left); + $dots = max(1, (new Terminal())->getWidth() - mb_strlen($plainLeft) - mb_strlen($duration) - 4); + + $this->line(sprintf( + ' %s %s %s %s', + $color, + $status, + OutputFormatter::escape($left), + str_repeat('.', $dots), + $color, + $duration + )); } } diff --git a/src/Phaseolies/Console/Commands/Server/request_logger.stub b/src/Phaseolies/Console/Commands/Server/request_logger.stub new file mode 100644 index 00000000..fc359fc3 --- /dev/null +++ b/src/Phaseolies/Console/Commands/Server/request_logger.stub @@ -0,0 +1,33 @@ +