From b9c4f1dcf1333c14e2d1b67561ec7a5f955baa6e Mon Sep 17 00:00:00 2001 From: "David T. Sadler" Date: Sun, 9 Aug 2026 20:04:39 +0100 Subject: Initial commit --- src/HttpServer.php | 246 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 src/HttpServer.php (limited to 'src/HttpServer.php') diff --git a/src/HttpServer.php b/src/HttpServer.php new file mode 100644 index 0000000..bdab9cc --- /dev/null +++ b/src/HttpServer.php @@ -0,0 +1,246 @@ +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', + }; + } +} -- cgit v1.2.3-13-gbd6f