Skip to content
Open
Show file tree
Hide file tree
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
167 changes: 166 additions & 1 deletion src/Phaseolies/Console/Commands/Server/ServerStartCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, float>
*/
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<string, bool>
*/
private array $reported = [];

/**
* Execute the console command.
*
Expand All @@ -43,7 +65,17 @@ public function handle(): int

$port = self::determineAvailablePort($port);

if (!$background) {
$this->displayBanner();
}

$this->displaySuccess("Server started on <fg=green>http://localhost:$port</>");

if (!$background) {
$this->line(' <fg=gray>Press Ctrl+C to stop the server</>');
$this->newLine();
}

self::startServer($port, $background);

return Command::SUCCESS;
Expand Down Expand Up @@ -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',
Expand All @@ -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('<fg=cyan>' . OutputFormatter::escape($row) . '</>');
}

$this->line(str_repeat(' ', 26) . '<fg=red>v' . Application::VERSION . '</>');
$this->line('<fg=gray>' . 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(' <fg=gray>' . 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(
' <fg=%s>%s</> <fg=white>%s</> <fg=gray>%s</> <fg=%s>%s</>',
$color,
$status,
OutputFormatter::escape($left),
str_repeat('.', $dots),
$color,
$duration
));
}
}
33 changes: 33 additions & 0 deletions src/Phaseolies/Console/Commands/Server/request_logger.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/**
* Prepended to every request served by `server:start`.
*/
(static function (): void {
if (PHP_SAPI !== 'cli-server') {
return;
}

$uri = urldecode((string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH));

// Static files are handled and logged by PHP itself.
if ($uri !== '/' && is_file(($_SERVER['DOCUMENT_ROOT'] ?? '') . $uri)) {
return;
}

$start = $_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true);

register_shutdown_function(static function () use ($start): void {
$line = sprintf(
"DOPPAR_REQUEST\t%s:%s\t%d\t%s\t%s\t%.4f\n",
$_SERVER['REMOTE_ADDR'] ?? '',
$_SERVER['REMOTE_PORT'] ?? '',
http_response_code() ?: 200,
$_SERVER['REQUEST_METHOD'] ?? 'GET',
$_SERVER['REQUEST_URI'] ?? '/',
(microtime(true) - $start) * 1000
);

@file_put_contents('php://stderr', $line);
});
})();
Loading