handlers[$uri][$method] = $handler; } public function get(string $uri, callable $handler): void { $this->on('GET', $uri, $handler); } public function post(string $uri, callable $handler): void { $this->on('POST', $uri, $handler); } /** * Allows a route handler to trigger server shutdown after responding. */ public function closeAfterResponse(): void { $this->shouldClose = true; } public function run(): void { $socket = stream_socket_server( "tcp://$this->address:$this->port", $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, ); if ($socket === false) { echo "errstr ($errno)\n"; exit(1); } while ($socket) { $stream = stream_socket_accept($socket, $this->timeout); if ($stream === false) { continue; } $request = $this->processRequest($stream); if (!$request) { fclose($stream); continue; } $response = $this->processResponse($request); fwrite($stream, (string) $response); fclose($stream); // Shutdown if requested by handler. if ($this->shouldClose) { break; } } fclose($socket); } /** * @param resource $stream */ private function processRequest($stream): ?Request { $lines = []; while ($line = fgets($stream)) { $line = rtrim($line, "\r\n"); if ($line !== '') { $lines[] = $line; continue; } break; } if (empty($lines)) { return null; } // Process start line. [$method, $uri, $httpVersion] = array_pad( explode(' ', array_shift($lines)), 3, '', ); // Process uri. $uri = explode('?', $uri, 2); $path = $this->normalisePath(urldecode($uri[0])); $params = []; parse_str($uri[1] ?? '', $params); // Process headers. $headers = array_reduce($lines, function (array $carry, string $line): array { $parts = explode(':', $line, 2); $header = strtolower(trim($parts[0])); $carry[$header] = trim($parts[1] ?? ''); return $carry; }, []); // Process the body. $body = ''; $contentLength = (int) ($headers['content-length'] ?? 0); if ($contentLength > 0) { $remaining = $contentLength; do { $buffer = fread($stream, $remaining); if ($buffer === false || $buffer === '') { break; } $body .= $buffer; $bytesRead = strlen($body); $remaining = $contentLength - $bytesRead; } while ($bytesRead < $contentLength); } return new Request( method: $method, path: $path, httpVersion: $httpVersion, headers: $headers, params: $params, body: $body, ); } private function processResponse(Request $request): Response { try { $pathHandlers = $this->handlers[$request->path] ?? null; if ($pathHandlers !== null) { $handler = $pathHandlers[$request->method] ?? null; if ($handler === null) { $allowedMethods = implode(', ', array_keys($pathHandlers)); return new Response( statusCode: 405, contentType: 'text/plain', body: '405 Method Not Allowed', headers: [ 'Allow' => $allowedMethods, ], ); } return $handler($request, $this); } // Path not found in handlers -> Fall back to static files or 404. $path = ltrim($request->path, '/'); if (is_file($path)) { $statusCode = 200; $contentType = $this->mimeType($path); $body = file_get_contents($path); } else { $statusCode = 404; $contentType = 'text/plain'; $body = "Could not locate [$path]"; } } catch (Throwable $exception) { $statusCode = 500; $contentType = 'text/plain'; $body = $exception->getMessage(); } return new Response( statusCode: $statusCode, contentType: $contentType, body: $body, ); } private function normalisePath(string $path): string { $path = "/$path"; $parts = explode('/', $path); $normalised = []; foreach ($parts as $part) { switch ($part) { case '': case '.': break; case '..': array_pop($normalised); break; default: $normalised[] = $part; break; } } return '/' . implode('/', $normalised); } private function mimeType(string $path): string { return match (pathinfo($path, PATHINFO_EXTENSION)) { 'html' => 'text/html', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'txt' => 'text/plain', 'css' => 'text/css', 'js' => 'text/javascript', 'json' => 'application/json', default => 'application/octet-stream', }; } }