summaryrefslogtreecommitdiff
path: root/src/HttpServer.php
diff options
context:
space:
mode:
authorDavid T. Sadler <davidtsadler@googlemail.com>2026-08-09 20:04:39 +0100
committerDavid T. Sadler <davidtsadler@googlemail.com>2026-08-09 20:04:39 +0100
commitb9c4f1dcf1333c14e2d1b67561ec7a5f955baa6e (patch)
tree610536953be739d0778f19c90c477f080a24718a /src/HttpServer.php
Initial commitHEADmain
Diffstat (limited to 'src/HttpServer.php')
-rw-r--r--src/HttpServer.php246
1 files changed, 246 insertions, 0 deletions
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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace TCB;
+
+use TCB\Request;
+use TCB\Response;
+use Throwable;
+
+final class HttpServer
+{
+ private array $handlers = [];
+ private bool $shouldClose = false;
+
+ public function __construct(
+ private readonly string $address,
+ private readonly int $port,
+ private readonly int $timeout,
+ ) {}
+
+ public function on(string $method, string $uri, callable $handler): void
+ {
+ $this->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',
+ };
+ }
+}