diff --git a/docs/index.html b/docs/index.html index 580a388..5c67b31 100644 --- a/docs/index.html +++ b/docs/index.html @@ -156,27 +156,24 @@
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
+ php server.php --web-dir /path/to/site
Environment Variables:
-BASE_WEB_DIR — Root directory for serving files (default: current directory)WORKERS_COUNT — Number of worker processes (default: CPU cores × 2)CLI Arguments (take precedence over env vars):
+CLI Arguments:
-h, --help — Show CLI usage help and exit--web-dir PATH — Set root directory for serving files--workers-count N — Set worker process count (must be >= 1)--cache-enabled true|false — Enable or disable Cache-Control response headerEnvironment Variables:
+BASE_WEB_DIR — Environment variable for web root (used if --web-dir is not provided)WORKERS_COUNT — Environment variable for worker count (used if --workers-count is not provided)CACHE_ENABLED=true|false — Enable or disable Cache-Control response headerPrecedence: CLI arguments override environment variables.
# Build
diff --git a/server.php b/server.php
index 5651d2e..3644be3 100755
--- a/server.php
+++ b/server.php
@@ -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']
);
diff --git a/src/cli.php b/src/cli.php
index 8089654..f7970c5 100644
--- a/src/cli.php
+++ b/src/cli.php
@@ -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];
}
@@ -33,11 +42,36 @@ 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.
@@ -45,11 +79,12 @@ function parse_cli_arguments(array $argv): array
* 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
{
@@ -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);
+}
diff --git a/src/server_lib.php b/src/server_lib.php
index a15bb92..3b55321 100644
--- a/src/server_lib.php
+++ b/src/server_lib.php
@@ -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.
*/
@@ -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.
*/
@@ -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);
@@ -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)) {
@@ -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,
@@ -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);
}
@@ -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;
@@ -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'])
@@ -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);
@@ -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) {
diff --git a/tests/CliConfigTest.php b/tests/CliConfigTest.php
index 673058e..153195a 100644
--- a/tests/CliConfigTest.php
+++ b/tests/CliConfigTest.php
@@ -15,6 +15,24 @@ public function test_parse_cli_arguments_no_args(): void
$this->assertNull($config['web_dir']);
$this->assertNull($config['workers_count']);
+ $this->assertNull($config['cache_enabled']);
+ $this->assertFalse($config['show_help']);
+ }
+
+ public function test_parse_cli_arguments_help_long_flag(): void
+ {
+ $argv = ['server.php', '--help'];
+ $config = parse_cli_arguments($argv);
+
+ $this->assertTrue($config['show_help']);
+ }
+
+ public function test_parse_cli_arguments_help_short_flag(): void
+ {
+ $argv = ['server.php', '-h'];
+ $config = parse_cli_arguments($argv);
+
+ $this->assertTrue($config['show_help']);
}
public function test_parse_cli_arguments_web_dir(): void
@@ -24,6 +42,7 @@ public function test_parse_cli_arguments_web_dir(): void
$this->assertSame('/path/to/site', $config['web_dir']);
$this->assertNull($config['workers_count']);
+ $this->assertNull($config['cache_enabled']);
}
public function test_parse_cli_arguments_workers_count(): void
@@ -33,6 +52,15 @@ public function test_parse_cli_arguments_workers_count(): void
$this->assertNull($config['web_dir']);
$this->assertSame(8, $config['workers_count']);
+ $this->assertNull($config['cache_enabled']);
+ }
+
+ public function test_parse_cli_arguments_cache_enabled_false(): void
+ {
+ $argv = ['server.php', '--cache-enabled', 'false'];
+ $config = parse_cli_arguments($argv);
+
+ $this->assertFalse($config['cache_enabled']);
}
public function test_parse_cli_arguments_both_options(): void
@@ -66,6 +94,7 @@ public function test_load_config_defaults(): void
$this->assertSame($default_dir, $config['web_dir']);
$this->assertNull($config['worker_count']);
+ $this->assertTrue($config['cache_enabled']);
}
public function test_load_config_cli_args_override_defaults(): void
@@ -77,12 +106,14 @@ public function test_load_config_cli_args_override_defaults(): void
$this->assertSame('docs', $config['web_dir']);
$this->assertSame(6, $config['worker_count']);
+ $this->assertTrue($config['cache_enabled']);
}
public function test_load_config_env_var_precedence(): void
{
$_ENV['BASE_WEB_DIR'] = '/env/web';
$_ENV['WORKERS_COUNT'] = 12;
+ $_ENV['CACHE_ENABLED'] = 'false';
$argv = ['server.php'];
@@ -90,33 +121,39 @@ public function test_load_config_env_var_precedence(): void
$this->assertSame('/env/web', $config['web_dir']);
$this->assertSame(12, $config['worker_count']);
+ $this->assertFalse($config['cache_enabled']);
// Cleanup
unset($_ENV['BASE_WEB_DIR']);
unset($_ENV['WORKERS_COUNT']);
+ unset($_ENV['CACHE_ENABLED']);
}
public function test_load_config_cli_overrides_env(): void
{
$_ENV['BASE_WEB_DIR'] = '/env/web';
$_ENV['WORKERS_COUNT'] = 12;
+ $_ENV['CACHE_ENABLED'] = 'true';
- $argv = ['server.php', '--web-dir', '/cli/web', '--workers-count', '4'];
+ $argv = ['server.php', '--web-dir', '/cli/web', '--workers-count', '4', '--cache-enabled', 'false'];
$config = load_config($argv, '/default');
$this->assertSame('/cli/web', $config['web_dir']);
$this->assertSame(4, $config['worker_count']);
+ $this->assertFalse($config['cache_enabled']);
// Cleanup
unset($_ENV['BASE_WEB_DIR']);
unset($_ENV['WORKERS_COUNT']);
+ unset($_ENV['CACHE_ENABLED']);
}
public function test_load_config_cli_overrides_env_partially(): void
{
$_ENV['BASE_WEB_DIR'] = '/env/web';
$_ENV['WORKERS_COUNT'] = 12;
+ $_ENV['CACHE_ENABLED'] = 'false';
$argv = ['server.php', '--workers-count', '3'];
@@ -124,9 +161,33 @@ public function test_load_config_cli_overrides_env_partially(): void
$this->assertSame('/env/web', $config['web_dir']);
$this->assertSame(3, $config['worker_count']);
+ $this->assertFalse($config['cache_enabled']);
// Cleanup
unset($_ENV['BASE_WEB_DIR']);
unset($_ENV['WORKERS_COUNT']);
+ unset($_ENV['CACHE_ENABLED']);
+ }
+
+ public function test_load_config_accepts_ture_for_cache_enabled_env(): void
+ {
+ $_ENV['CACHE_ENABLED'] = 'ture';
+
+ $config = load_config(['server.php'], '/default');
+
+ $this->assertTrue($config['cache_enabled']);
+
+ unset($_ENV['CACHE_ENABLED']);
+ }
+
+ public function test_cli_help_text_contains_main_options(): void
+ {
+ $help = cli_help_text();
+
+ $this->assertStringContainsString('--help', $help);
+ $this->assertStringContainsString('--web-dir', $help);
+ $this->assertStringContainsString('--workers-count', $help);
+ $this->assertStringContainsString('--cache-enabled', $help);
+ $this->assertStringContainsString('CACHE_ENABLED', $help);
}
}
diff --git a/tests/ServerFunctionsTest.php b/tests/ServerFunctionsTest.php
index 28081b5..8774946 100644
--- a/tests/ServerFunctionsTest.php
+++ b/tests/ServerFunctionsTest.php
@@ -46,7 +46,6 @@ public function test_head_request_returns_empty_body_and_content_length(): void
[$status, $headers, $body] = handle_request_by_method(
dirname(__DIR__),
$requestContext,
- DEFAULT_CONTENT_TYPES
);
$this->assertSame(HTTP_STATUS::OK, $status);
@@ -61,7 +60,6 @@ public function test_path_traversal_returns_forbidden(): void
dirname(__DIR__),
'/../composer.json',
[],
- DEFAULT_CONTENT_TYPES
);
$this->assertSame(HTTP_STATUS::FORBIDDEN, $status);
@@ -80,7 +78,6 @@ public function test_gzip_response_sets_encoding_and_is_decodable(): void
$tempDir,
'/sample.txt',
['gzip'],
- DEFAULT_CONTENT_TYPES
);
$this->assertSame(HTTP_STATUS::OK, $status);
@@ -106,14 +103,12 @@ public function test_gzip_response_body_is_smaller_than_plain_for_repetitive_con
$tempDir,
'/sample.txt',
[],
- DEFAULT_CONTENT_TYPES
);
[, $gzipHeaders, $gzipBody] = route_request_response(
$tempDir,
'/sample.txt',
['gzip'],
- DEFAULT_CONTENT_TYPES
);
$this->assertSame('gzip', $gzipHeaders['Content-Encoding']);
@@ -124,4 +119,47 @@ public function test_gzip_response_body_is_smaller_than_plain_for_repetitive_con
rmdir($tempDir);
}
}
+
+ public function test_content_type_is_set_based_on_file_extension(): void
+ {
+ $tempDir = sys_get_temp_dir() . '/joojoo-test-' . uniqid('', true);
+ mkdir($tempDir);
+
+ $cases = [
+ 'index.html' => [
+ 'content' => 't x',
+ 'assertion' => static fn (string $type): bool => str_starts_with($type, 'text/html'),
+ ],
+ 'style.css' => [
+ 'content' => 'body { color: #111; font-family: sans-serif; }',
+ 'assertion' => static fn (string $type): bool => in_array($type, ['text/css', 'text/plain'], true),
+ ],
+ 'font.woff2' => [
+ 'content' => 'wOF2' . str_repeat("\0", 32),
+ 'assertion' => static fn (string $type): bool => in_array(
+ $type,
+ ['font/woff2', 'application/font-woff2', 'application/octet-stream'],
+ true
+ ),
+ ],
+ 'logo.png' => [
+ 'content' => base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl3xQAAAABJRU5ErkJggg==', true),
+ 'assertion' => static fn (string $type): bool => $type === 'image/png',
+ ],
+ ];
+
+ try {
+ foreach ($cases as $filename => $case) {
+ file_put_contents("$tempDir/$filename", $case['content']);
+
+ [, $headers] = route_request_response($tempDir, "/$filename", []);
+
+ $this->assertTrue(($case['assertion'])($headers['Content-Type']), "Failed for $filename");
+
+ unlink("$tempDir/$filename");
+ }
+ } finally {
+ rmdir($tempDir);
+ }
+ }
}