Skip to content
Merged
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
25 changes: 11 additions & 14 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -156,27 +156,24 @@ <h3>Request Flow</h3>
<!-- Quick Start Section -->
<section id="quickstart" class="section">
<h2>Quick Start</h2>

<h3>Local Usage</h3>
<pre><code>php server.php
# or with custom directory
BASE_WEB_DIR=/path/to/site php server.php
# or set worker count
WORKERS_COUNT=8 php server.php
# or use CLI args
php server.php --web-dir /path/to/site --workers-count 8</code></pre>
<pre><code>php server.php --web-dir /path/to/site</code></pre>

<h3>Configuration</h3>
<p><strong>Environment Variables:</strong></p>
<ul>
<li><code>BASE_WEB_DIR</code> — Root directory for serving files (default: current directory)</li>
<li><code>WORKERS_COUNT</code> — Number of worker processes (default: CPU cores × 2)</li>
</ul>
<p><strong>CLI Arguments (take precedence over env vars):</strong></p>
<p><strong>CLI Arguments:</strong></p>
<ul>
<li><code>-h, --help</code> — Show CLI usage help and exit</li>
<li><code>--web-dir PATH</code> — Set root directory for serving files</li>
<li><code>--workers-count N</code> — Set worker process count (must be >= 1)</li>
<li><code>--cache-enabled true|false</code> — Enable or disable <code>Cache-Control</code> response header</li>
</ul>
<p><strong>Environment Variables:</strong></p>
<ul>
<li><code>BASE_WEB_DIR</code> — Environment variable for web root (used if <code>--web-dir</code> is not provided)</li>
<li><code>WORKERS_COUNT</code> — Environment variable for worker count (used if <code>--workers-count</code> is not provided)</li>
<li><code>CACHE_ENABLED=true|false</code> — Enable or disable <code>Cache-Control</code> response header</li>
</ul>
<p><strong>Precedence:</strong> CLI arguments override environment variables.</p>

<h3>Docker</h3>
<pre><code># Build
Expand Down
8 changes: 7 additions & 1 deletion server.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@

$config = load_config($argv, __DIR__);

if ($config['show_help']) {
echo cli_help_text();
exit(0);
}

run_server(
web_dir:$config['web_dir'],
worker_count:$config['worker_count']
worker_count:$config['worker_count'],
cache_enabled:$config['cache_enabled']
);
61 changes: 59 additions & 2 deletions src/cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,30 @@
* Parse CLI arguments into a configuration array.
*
* Supports:
* - -h, --help: Show usage help and exit
* - --web-dir PATH: Set the web root directory
* - --workers-count N: Set worker process count (must be >= 1)
* - --cache-enabled true|false: Enable or disable Cache-Control response header
* - --no-cache: Alias for --cache-enabled false
*
* @param array $argv Command-line arguments
*
* @return array{web_dir: string|null, workers_count: int|null}
* @return array{web_dir: string|null, workers_count: int|null, cache_enabled: bool|null, show_help: bool}
*/
function parse_cli_arguments(array $argv): array
{
$config = [
'web_dir' => null,
'workers_count' => null,
'cache_enabled' => null,
'show_help' => false,
];

foreach ($argv as $i => $arg) {
if ($arg === '--help' || $arg === '-h') {
$config['show_help'] = true;
}

if ($arg === '--web-dir' && isset($argv[$i + 1])) {
$config['web_dir'] = $argv[$i + 1];
}
Expand All @@ -33,23 +42,49 @@ function parse_cli_arguments(array $argv): array
}
$config['workers_count'] = $workers;
}

if ($arg === '--cache-enabled' && isset($argv[$i + 1])) {
$config['cache_enabled'] = normalize_boolean($argv[$i + 1]);
}
}

return $config;
}

/**
* Build CLI usage text.
*/
function cli_help_text(): string
{
return "Usage: php server.php [options]\n"
. "\n"
. "Options:\n"
. " -h, --help Show this help and exit\n"
. " --web-dir PATH Set root directory for serving files\n"
. " --workers-count N Set worker process count (must be >= 1)\n"
. " --cache-enabled true|false Enable or disable Cache-Control header\n"
. "\n"
. "Environment variables:\n"
. " BASE_WEB_DIR Root directory for serving files\n"
. " WORKERS_COUNT Worker process count\n"
. " CACHE_ENABLED=true|false Enable or disable Cache-Control header\n"
. "\n"
. "Precedence: CLI arguments override environment variables.\n";
}

/**
* Merge environment variables and CLI arguments into final configuration.
* CLI arguments take precedence over environment variables.
*
* Supported environment variables:
* - BASE_WEB_DIR: Root directory for serving files
* - WORKERS_COUNT: Number of worker processes
* - CACHE_ENABLED: Enable/disable Cache-Control response header
*
* @param array $argv Command-line arguments
* @param string|null $default_web_dir Default web directory if neither env nor CLI provides one
*
* @return array{web_dir: string, worker_count: int|null}
* @return array{web_dir: string, worker_count: int|null, cache_enabled: bool}
*/
function load_config(array $argv, ?string $default_web_dir = null): array
{
Expand All @@ -75,8 +110,30 @@ function load_config(array $argv, ?string $default_web_dir = null): array
$worker_count = $cli_config['workers_count'];
}

// Cache-Control switch: env -> CLI (CLI takes precedence)
$cache_enabled = true;
$cache_enabled_env = $_ENV['CACHE_ENABLED'] ?? $_SERVER['CACHE_ENABLED'] ?? null;
if ($cache_enabled_env !== null) {
$cache_enabled = normalize_boolean($cache_enabled_env);
}
if ($cli_config['cache_enabled'] !== null) {
$cache_enabled = $cli_config['cache_enabled'];
}

return [
'web_dir' => $web_dir,
'worker_count' => $worker_count,
'cache_enabled' => $cache_enabled,
'show_help' => $cli_config['show_help'],
];
}

/**
* Normalize common truthy values from env/CLI string inputs.
*/
function normalize_boolean(mixed $value): bool
{
$normalized = strtolower(trim((string) $value));

return in_array($normalized, ['1', 'true', 'ture', 'yes', 'on'], true);
}
125 changes: 81 additions & 44 deletions src/server_lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,34 +22,6 @@ enum HTTP_STATUS: string
case METHOD_NOT_ALLOWED = '405';
}

/**
* Default content type map for static files.
*/
const DEFAULT_CONTENT_TYPES = [
'html' => 'text/html;charset=utf-8',
'css' => 'text/css',
'js' => 'text/javascript',
'apng' => 'image/apng',
'gif' => 'image/gif',
'jpeg' => 'image/jpeg',
'jpg' => 'image/jpeg',
'png' => 'image/png',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'ogg' => 'audio/ogg',
'oga' => 'audio/ogg',
'mp3' => 'audio/mpeg3',
'wav' => 'audio/wav',
'mp4' => 'video/mp4',
'.3gp' => 'video/3gpp',
'flv' => 'video/x-flv',
'mov' => 'video/quicktime',
'mpg4' => 'video/mp4',
'json' => 'application/json',
'apk' => 'application/vnd.android.package-archive',
];


/**
* Resolve the number of CPU cores for worker process sizing.
*/
Expand All @@ -69,6 +41,18 @@ function logging(string $message): void
echo $message . PHP_EOL;
}

function cache_control_header(string $extension): string
{
$ext = strtolower($extension);
$static = ['css', 'js', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'woff', 'woff2', 'ttf', 'webp'];

return match (true) {
$ext === 'html' => 'no-cache',
in_array($ext, $static, true) => 'public, max-age=86400',
default => 'no-cache',
};
}

/**
* Parse request metadata (method, path, headers, first line) from a raw request.
*/
Expand Down Expand Up @@ -100,10 +84,57 @@ function parse_request_context(string $request): array
];
}

function content_type(string $file_path): string
{
static $finfo = null;
static $extension_map = [
'css' => 'text/css',
'js' => 'text/javascript',
'html' => 'text/html; charset=utf-8',
'htm' => 'text/html; charset=utf-8',
'json' => 'application/json',
'svg' => 'image/svg+xml',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'ico' => 'image/x-icon',
'pdf' => 'application/pdf',
'txt' => 'text/plain',
'webp' => 'image/webp',
'zip' => 'application/zip',
'gz' => 'application/gzip',
'mp4' => 'video/mp4',
'mp3' => 'audio/mpeg',
'mkv' => 'video/x-matroska',
];

$ext = strtolower(pathinfo($file_path, PATHINFO_EXTENSION));
if ($ext !== '' && isset($extension_map[$ext])) {
return $extension_map[$ext];
}

if ($finfo === null) {
$finfo = finfo_open(FILEINFO_MIME_TYPE) ?: false;
}

if ($finfo !== false) {
$type = finfo_file($finfo, $file_path);
if (is_string($type) && $type !== '') {
return $type;
}
}

return 'application/octet-stream';
}

/**
* Route request path to either static file response or error response.
*/
function route_request_response(string $web_dir, string $request_path, array $accepted_encodings, array $content_types): array
function route_request_response(string $web_dir, string $request_path, array $accepted_encodings, bool $cache_enabled = true): array
{
if (str_contains($request_path, "\0")) {
return handle_error_response(HTTP_STATUS::FORBIDDEN);
Expand All @@ -123,8 +154,7 @@ function route_request_response(string $web_dir, string $request_path, array $ac
return handle_error_response(HTTP_STATUS::NOT_FOUND);
}

$extension = pathinfo($file_path, PATHINFO_EXTENSION);
$headers = [...DEFAULT_RESPONSE_HEADERS, 'Content-Type' => $content_types[$extension] ?? 'application/octet-stream'];
$headers = [...DEFAULT_RESPONSE_HEADERS, 'Content-Type' => content_type($file_path)];

// Handle accepted encodings and static/on-the-fly gzip.
if (in_array('gzip', $accepted_encodings, true)) {
Expand All @@ -137,16 +167,21 @@ function route_request_response(string $web_dir, string $request_path, array $ac
$body = file_get_contents($file_path);
}

// Handle Cache-Control for static assets unless disabled by config.
if ($cache_enabled) {
$headers['Cache-Control'] = cache_control_header(pathinfo($file_path, PATHINFO_EXTENSION));
}

return [HTTP_STATUS::OK, $headers, $body];
}

/**
* Dispatch request handling by HTTP method (GET, HEAD, or 405).
*/
function handle_request_by_method(string $web_dir, array $request_context, array $content_types): array
function handle_request_by_method(string $web_dir, array $request_context, bool $cache_enabled = true): array
{
$accepted_encodings = array_map('trim', explode(',', $request_context['headers']['accept-encoding'] ?? ''));
$resource_response = route_request_response($web_dir, $request_context['request_path'], $accepted_encodings, $content_types);
$resource_response = route_request_response($web_dir, $request_context['request_path'], $accepted_encodings, $cache_enabled);

return match ($request_context['method']) {
'GET' => $resource_response,
Expand Down Expand Up @@ -286,16 +321,16 @@ function read_request(\Socket $client): string|false
function worker_process(
\Socket $socket,
string $web_dir,
array $content_types,
int $keep_alive_max_requests,
int $keep_alive_timeout
int $keep_alive_timeout,
bool $cache_enabled
): void {
while ($client = socket_accept($socket)) {
// Configure timeouts for keep-alive
socket_set_option($client, SOL_SOCKET, SO_RCVTIMEO, ['sec' => $keep_alive_timeout, 'usec' => 0]);
socket_set_option($client, SOL_SOCKET, SO_SNDTIMEO, ['sec' => $keep_alive_timeout, 'usec' => 0]);

handle_client_connection($client, $web_dir, $content_types, $keep_alive_max_requests, $keep_alive_timeout);
handle_client_connection($client, $web_dir, $keep_alive_max_requests, $keep_alive_timeout, $cache_enabled);

socket_close($client);
}
Expand All @@ -307,9 +342,9 @@ function worker_process(
function handle_client_connection(
\Socket $client,
string $web_dir,
array $content_types,
int $keep_alive_max_requests,
int $keep_alive_timeout
int $keep_alive_timeout,
bool $cache_enabled
): void {
$request_count = 0;
$keep_connection = true;
Expand All @@ -326,7 +361,7 @@ function handle_client_connection(
$request_headers = $request_context['headers'];
$first_line = $request_context['first_line'];

[$status_code, $headers, $body] = handle_request_by_method($web_dir, $request_context, $content_types);
[$status_code, $headers, $body] = handle_request_by_method($web_dir, $request_context, $cache_enabled);

// Determine connection persistence (HTTP/1.1 defaults to keep-alive per RFC 7230)
$client_wants_keepalive = ! isset($request_headers['connection'])
Expand Down Expand Up @@ -365,7 +400,7 @@ function handle_client_connection(
/**
* Start the prefork HTTP server.
*/
function run_server(string $web_dir, ?int $worker_count): void
function run_server(string $web_dir, ?int $worker_count, bool $cache_enabled = true): void
{
$workers = [];
$sock = create_server_socket(HOST, PORT);
Expand All @@ -389,19 +424,21 @@ function run_server(string $web_dir, ?int $worker_count): void
$workers[] = $pid;
} else {
// Child process - become a worker
worker_process($sock, $web_dir, DEFAULT_CONTENT_TYPES, KEEP_ALIVE_MAX_REQUESTS, KEEP_ALIVE_TIMEOUT);
worker_process($sock, $web_dir, KEEP_ALIVE_MAX_REQUESTS, KEEP_ALIVE_TIMEOUT, $cache_enabled);
exit(0);
}
}
logging('');
logging("\033[92m Server is running on " . HOST . ':' . PORT . ' with ' . $worker_count . ' workers. ' . "\033[0m");
logging("\033[92m Serving files from: " . $web_dir . "\033[0m");

logging("\033[92m Server is running on: \033[0mhttp://" . HOST . ':' . PORT);
logging("\033[92m Worker processes: \033[0m" . count($workers));
logging("\033[92m Cache headers: \033[0m" . ($cache_enabled ? 'enabled' : 'disabled'));
logging("\033[92m Serving files from: \033[0m" . $web_dir);
if (! is_dir($web_dir)) {
logging('Warning: directory does not exist');
} elseif (! is_readable($web_dir)) {
logging('Warning: directory is not readable');
}
logging(' Press Ctrl+C to stop the server');

// Wait for all workers
foreach ($workers as $worker_pid) {
Expand Down
Loading
Loading