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 @@

Request Flow

Quick Start

-

Local Usage

-
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

Configuration

-

Environment Variables:

- -

CLI Arguments (take precedence over env vars):

+

CLI Arguments:

+

Environment Variables:

+ +

Precedence: CLI arguments override environment variables.

Docker

# 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' => 'tx',
+                '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);
+        }
+    }
 }