From 4a3f44a05f7eee650166885b2b3738fb19328298 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 13:28:20 +0100 Subject: [PATCH] chore: experiments --- composer.json | 14 +- composer.lock | 24 +-- src/Http/Adapter.php | 2 +- src/Http/Adapter/{FPM/Server.php => FPM.php} | 16 +- src/Http/Adapter/Swoole.php | 80 ++++++++ src/Http/Adapter/Swoole/Mode.php | 180 +++++++++++++++++ src/Http/Adapter/Swoole/Server.php | 68 ------- src/Http/Adapter/Swoole/System.php | 66 ++++++ src/Http/Adapter/SwooleCoroutine.php | 82 ++++++++ src/Http/Adapter/SwooleCoroutine/Request.php | 9 - src/Http/Adapter/SwooleCoroutine/Response.php | 9 - src/Http/Adapter/SwooleCoroutine/Server.php | 76 ------- src/Http/Compression.php | 109 ++++++++++ src/Http/Http.php | 189 +++--------------- src/Http/Response.php | 107 +--------- 15 files changed, 585 insertions(+), 446 deletions(-) rename src/Http/Adapter/{FPM/Server.php => FPM.php} (51%) create mode 100755 src/Http/Adapter/Swoole.php create mode 100644 src/Http/Adapter/Swoole/Mode.php delete mode 100755 src/Http/Adapter/Swoole/Server.php create mode 100644 src/Http/Adapter/Swoole/System.php create mode 100644 src/Http/Adapter/SwooleCoroutine.php delete mode 100644 src/Http/Adapter/SwooleCoroutine/Request.php delete mode 100644 src/Http/Adapter/SwooleCoroutine/Response.php delete mode 100644 src/Http/Adapter/SwooleCoroutine/Server.php create mode 100644 src/Http/Compression.php diff --git a/composer.json b/composer.json index 5675f8c..bf2bd1d 100644 --- a/composer.json +++ b/composer.json @@ -10,17 +10,17 @@ ], "license": "MIT", "minimum-stability": "stable", - "autoload": { - "psr-4": { - "Utopia\\": "src/" - } - }, + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, "autoload-dev": { "psr-4": { "Utopia\\Http\\Tests\\": "tests/", "Tests\\E2E\\": "tests/e2e" } - }, + }, "scripts": { "format": "vendor/bin/pint", "format:check": "vendor/bin/pint --test", @@ -57,6 +57,6 @@ "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^12.0", "rector/rector": "^2.4", - "swoole/ide-helper": "4.8.3" + "swoole/ide-helper": "^6.0" } } diff --git a/composer.lock b/composer.lock index b88d836..5006e8c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ffcf218a03a3c0e154ccd9029a626747", + "content-hash": "a73a61453ad830fb3f499ae5f12cf59c", "packages": [ { "name": "brick/math", @@ -3993,16 +3993,16 @@ }, { "name": "swoole/ide-helper", - "version": "4.8.3", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/swoole/ide-helper.git", - "reference": "3ac4971814273889933b871e03b2a6b340e58f79" + "reference": "6f12243dce071714c5febe059578d909698f9a52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/swoole/ide-helper/zipball/3ac4971814273889933b871e03b2a6b340e58f79", - "reference": "3ac4971814273889933b871e03b2a6b340e58f79", + "url": "https://api.github.com/repos/swoole/ide-helper/zipball/6f12243dce071714c5febe059578d909698f9a52", + "reference": "6f12243dce071714c5febe059578d909698f9a52", "shasum": "" }, "type": "library", @@ -4019,19 +4019,9 @@ "description": "IDE help files for Swoole.", "support": { "issues": "https://github.com/swoole/ide-helper/issues", - "source": "https://github.com/swoole/ide-helper/tree/4.8.3" + "source": "https://github.com/swoole/ide-helper/tree/6.0.2" }, - "funding": [ - { - "url": "https://gitee.com/swoole/swoole?donate=true", - "type": "custom" - }, - { - "url": "https://github.com/swoole", - "type": "github" - } - ], - "time": "2021-12-01T08:11:40+00:00" + "time": "2025-03-23T07:31:41+00:00" }, { "name": "theseer/tokenizer", diff --git a/src/Http/Adapter.php b/src/Http/Adapter.php index 0b0e478..c6e19a0 100755 --- a/src/Http/Adapter.php +++ b/src/Http/Adapter.php @@ -11,5 +11,5 @@ abstract class Adapter abstract public function onStart(callable $callback): void; abstract public function onRequest(callable $callback): void; abstract public function start(): void; - abstract public function getContainer(): Container; + abstract public function getContext(): Container; } diff --git a/src/Http/Adapter/FPM/Server.php b/src/Http/Adapter/FPM.php similarity index 51% rename from src/Http/Adapter/FPM/Server.php rename to src/Http/Adapter/FPM.php index 2b56568..a26df69 100755 --- a/src/Http/Adapter/FPM/Server.php +++ b/src/Http/Adapter/FPM.php @@ -1,21 +1,23 @@ container->set('fpmRequest', fn() => $request); - $this->container->set('fpmResponse', fn() => $response); + $this->resources->set('fpmRequest', fn() => $request); + $this->resources->set('fpmResponse', fn() => $response); \call_user_func($callback, $request, $response); } @@ -25,9 +27,9 @@ public function onStart(callable $callback): void \call_user_func($callback, $this); } - public function getContainer(): Container + public function getContext(): Container { - return $this->container; + return $this->resources; } public function start(): void {} diff --git a/src/Http/Adapter/Swoole.php b/src/Http/Adapter/Swoole.php new file mode 100755 index 0000000..44d7ec7 --- /dev/null +++ b/src/Http/Adapter/Swoole.php @@ -0,0 +1,80 @@ +server->set([ + ...$mode->settings(), + ...$settings, + ]); + + if ($mode === Mode::HYPERLOOP_B) { + Coroutine::set([Constant::OPTION_HOOK_FLAGS => SWOOLE_HOOK_ALL]); + } + } + + public function onRequest(callable $callback): void + { + $this->server->on('request', function (SwooleRequest $request, SwooleResponse $response) use ($callback) { + $context = new Container($this->resources); + + // TODO (@loks0n): `swooleRequest` and `swooleResponse` should be removed. + // Any consumers using these should be updated to use the abstract request/response objects. + $context->set('swooleRequest', fn() => $request); + $context->set('swooleResponse', fn() => $response); + + Coroutine::getContext()[self::CONTEXT_KEY] = $context; + + \call_user_func($callback, new Request($request), new Response($response)); + }); + } + + public function getContext(): Container + { + if (Coroutine::getCid() !== -1) { + return Coroutine::getContext()[self::CONTEXT_KEY] ?? $this->resources; + } + + return $this->resources; + } + + public function onStart(callable $callback): void + { + $this->server->on('start', function () use ($callback) { + if (Coroutine::getCid() === -1) { + go(fn () => $callback($this)); + } else { + $callback($this); + } + }); + } + + public function start(): void + { + $this->server->start(); + } +} diff --git a/src/Http/Adapter/Swoole/Mode.php b/src/Http/Adapter/Swoole/Mode.php new file mode 100644 index 0000000..272017a --- /dev/null +++ b/src/Http/Adapter/Swoole/Mode.php @@ -0,0 +1,180 @@ + false, + + // Struct default: false (swoole_server.h:248). Parser default + // when the key is absent: true (swoole_server_port.cc:308-312). + // We restate to lock the value across that disagreement. For + // HTTP, disabling Nagle is unambiguously correct. + Constant::OPTION_OPEN_TCP_NODELAY => true, + + // Default: 0 / off (swoole_server.h:188). TFO saves an RTT on + // connection setup when both kernel and client support it; on + // unsupported paths it transparently falls back. No downside. + Constant::OPTION_TCP_FASTOPEN => true, + + // Default: 0 / off (swoole_server.h:184). Delays accept() + // return until the first request bytes arrive; saves a worker + // wake-up on every junk/scan connection. The integer is the + // max-wait seconds before accepting anyway. + Constant::OPTION_TCP_DEFER_ACCEPT => 1, + + // Default: false (swoole_server.h:855). SO_REUSEPORT lets the + // kernel load-balance accept() across reactors instead of all + // contending on one accept queue. Linux win; no-op elsewhere. + Constant::OPTION_ENABLE_REUSE_PORT => true, + + // Default: 3 seconds (SW_WORKER_MAX_WAIT_TIME). Deadline a + // worker gets to drain in-flight requests on graceful + // shutdown/reload before being force-killed. 3s is too short + // for typical HTTP work; 30 stays under the K8s default + // terminationGracePeriodSeconds. + Constant::OPTION_MAX_WAIT_TIME => 30, + + // Default: unlimited (swoole_server.h:1558-1562 coerces 0 to + // UINT_MAX). Without a cap, slow upstreams cause unbounded + // request/coroutine accumulation until OOM. 1000 is a generous + // server-wide ceiling; tune to measured downstream capacity. + Constant::OPTION_MAX_CONCURRENCY => 1_000, + + // Default: true (swoole_server.h:859). Restated explicitly: + // SIGUSR1 reload drains in-flight work instead of cutting + // connections. The default is correct, but explicit beats + // accidental for deploy behaviour. + Constant::OPTION_RELOAD_ASYNC => true, + + // Default: 0 → host CPU count (swoole_server.h:748). Host + // detection is cgroup-blind: a 2-core pod on a 64-core node + // spawns 64 reactor threads. System::getCpuNum() reads + // cgroup limits before falling back to swoole_cpu_num(). + Constant::OPTION_REACTOR_NUM => (int) max(1, ceil(System::getCpuNum())), + ]; + } + + /** + * Get the Swoole settings for the given mode. + * + * @return array The settings array. + */ + public function settings(): array + { + $settings = match ($this) { + self::HYPERLOOP_A => [ + // Default: true (swoole_server.h:879). Off here so each + // worker handles one request at a time; concurrency comes + // from process count, not the scheduler. + Constant::OPTION_ENABLE_COROUTINE => false, + + // Default: DISPATCH_FDMOD = 2 (swoole_server.h:759, enum at + // 702-712). Mode 3 = DISPATCH_IDLE_WORKER (preemptive): + // route to whichever worker is idle. Keeps utilisation + // even when request times vary, at the cost of disabling + // hash-dispatch features like send_yield. + Constant::OPTION_DISPATCH_MODE => 3, + + // Default: 0 → host CPU count (swoole_server.h:752). + // Without coroutines, workers block on I/O — we need more + // processes than cores to keep CPUs busy. 6× assumes ~83% + // I/O wait, typical for PHP web apps. + Constant::OPTION_WORKER_NUM => (int) max(1, ceil(System::getCpuNum() * 6)), + ], + self::HYPERLOOP_B => [ + // Default: true (swoole_server.h:879). Restated; this + // entire mode is built around coroutine concurrency. + Constant::OPTION_ENABLE_COROUTINE => true, + + // Default: DISPATCH_FDMOD = 2. Restated because it's a + // precondition for send_yield (master.cc:401-402 force- + // disables yield outside hash-dispatch modes; eligible + // modes are FDMOD/IPMOD/CO_CONN_LB per server.h:1290-93). + // Bonus: per-worker DB/Redis pools stay attached. + Constant::OPTION_DISPATCH_MODE => 2, + + // Default: true (swoole_server.h:875). Silently disabled + // outside hash dispatch — we restate to make the + // FDMOD pairing's intent explicit. Effect: a slow client + // parks its coroutine instead of blocking the worker. + Constant::OPTION_SEND_YIELD => true, + + // Default: 0 → host CPU count. With coroutines, one + // worker holds thousands of parked requests; extra + // processes don't buy concurrency, just memory and + // context switches. 1× cores is the right shape. + Constant::OPTION_WORKER_NUM => (int) max(1, ceil(System::getCpuNum())), + + // Default: host CPU × 8 (async_thread.cc:84 + + // swoole_config.h:87 SW_AIO_THREAD_NUM_MULTIPLE = 8). + // Read by Server::set() via swoole_server.cc:2034 → + // swoole_async_coro.cc:43. We keep the 8× multiplier but + // apply cgroup-aware base count to avoid 512 threads on + // a 2-core pod. + Constant::OPTION_AIO_WORKER_NUM => (int) max(1, ceil(System::getCpuNum() * 8)), + + // Default: 1 (async_thread.cc keeps a single warm + // thread). Cold-start jitter on the first burst of file + // I/O. Baseline = CPU count keeps a useful pool warm at + // modest memory cost (a few hundred KB stack each). + Constant::OPTION_AIO_CORE_WORKER_NUM => (int) max(1, ceil(System::getCpuNum())), + ], + }; + + return [...self::defaults(), ...$settings]; + } +} diff --git a/src/Http/Adapter/Swoole/Server.php b/src/Http/Adapter/Swoole/Server.php deleted file mode 100755 index b8cf4f1..0000000 --- a/src/Http/Adapter/Swoole/Server.php +++ /dev/null @@ -1,68 +0,0 @@ - $settings - */ - public function __construct(string $host, ?string $port = null, array $settings = [], int $mode = SWOOLE_PROCESS, ?Container $container = null) - { - $this->server = new SwooleServer($host, (int) $port, $mode); - $this->server->set($settings); - $this->container = $container ?? new Container(); - } - - public function onRequest(callable $callback): void - { - $this->server->on('request', function (SwooleRequest $request, SwooleResponse $response) use ($callback) { - $requestContainer = new Container($this->container); - $requestContainer->set('swooleRequest', fn() => $request); - $requestContainer->set('swooleResponse', fn() => $response); - - Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] = $requestContainer; - - \call_user_func($callback, new Request($request), new Response($response)); - }); - } - - public function getContainer(): Container - { - if (Coroutine::getCid() !== -1) { - return Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] ?? $this->container; - } - - return $this->container; - } - - public function getServer(): SwooleServer - { - return $this->server; - } - - public function onStart(callable $callback): void - { - $this->server->on('start', function () use ($callback) { - go(function () use ($callback) { - \call_user_func($callback, $this); - }); - }); - } - - public function start(): void - { - $this->server->start(); - } -} diff --git a/src/Http/Adapter/Swoole/System.php b/src/Http/Adapter/Swoole/System.php new file mode 100644 index 0000000..37f8ad9 --- /dev/null +++ b/src/Http/Adapter/Swoole/System.php @@ -0,0 +1,66 @@ + " " or "max " + if (is_readable('/sys/fs/cgroup/cpu.max')) { + $line = trim((string) @file_get_contents('/sys/fs/cgroup/cpu.max')); + if ($line !== '' && !str_starts_with($line, 'max')) { + [$quota, $period] = array_pad(preg_split('/\s+/', $line), 2, null); + $quota = (float) $quota; + $period = (float) $period; + if ($quota > 0 && $period > 0) { + return $quota / $period; + } + } + } + // cgroup v1: cpu.cfs_quota_us / cpu.cfs_period_us + $quotaPath = '/sys/fs/cgroup/cpu/cpu.cfs_quota_us'; + $periodPath = '/sys/fs/cgroup/cpu/cpu.cfs_period_us'; + if (is_readable($quotaPath) && is_readable($periodPath)) { + $quota = (float) trim((string) @file_get_contents($quotaPath)); + $period = (float) trim((string) @file_get_contents($periodPath)); + if ($quota > 0 && $period > 0) { + return $quota / $period; + } + } + // macOS via sysctl + if (PHP_OS_FAMILY === 'Darwin') { + $out = @shell_exec('sysctl -n hw.ncpu 2>/dev/null'); + if ($out !== null) { + $n = (float) trim($out); + if ($n > 0) { + return $n; + } + } + } + // Linux /proc/cpuinfo (no cgroup, no Swoole extension loaded) + if (is_readable('/proc/cpuinfo')) { + $count = (float) preg_match_all('/^processor\s*:/m', (string) @file_get_contents('/proc/cpuinfo')); + if ($count > 0) { + return $count; + } + } + // Swoole's own detector, if available + if (function_exists('swoole_cpu_num')) { + $n = (float) swoole_cpu_num(); + if ($n > 0) { + return $n; + } + } + // Last resort + return 1.0; + } +} diff --git a/src/Http/Adapter/SwooleCoroutine.php b/src/Http/Adapter/SwooleCoroutine.php new file mode 100644 index 0000000..0ace5d2 --- /dev/null +++ b/src/Http/Adapter/SwooleCoroutine.php @@ -0,0 +1,82 @@ + */ + protected $onStart = []; + + public function __construct( + private readonly Server $server, + private readonly Container $resources + ) { + } + + public function configure(array $settings = []): void + { + $this->server->set([ + ...Mode::defaults(), + ...$settings, + ]); + } + + public function onRequest(callable $callback): void + { + $this->server->handle('/', function (SwooleRequest $request, SwooleResponse $response) use ($callback) { + $context = new Container($this->resources); + + // TODO (@loks0n): `swooleRequest` and `swooleResponse` should be removed. + // Any consumers using these should be updated to use the abstract request/response objects. + $context->set('swooleRequest', fn() => $request); + $context->set('swooleResponse', fn() => $response); + + Coroutine::getContext()[self::CONTEXT_KEY] = $context; + + try { + \call_user_func($callback, new Request($request), new Response($response)); + } finally { + unset(Coroutine::getContext()[self::CONTEXT_KEY]); + } + }); + } + + public function getContext(): Container + { + if (Coroutine::getCid() !== -1) { + return Coroutine::getContext()[self::CONTEXT_KEY] ?? $this->resources; + } + + return $this->resources; + } + + public function onStart(callable $callback): void + { + $this->onStart[] = $callback; + } + + public function start(): void + { + foreach ($this->onStart as $callback) { + if (Coroutine::getCid() === -1) { + go(fn () => $callback($this)); + } else { + $callback($this); + } + } + + $this->server->start(); + } +} diff --git a/src/Http/Adapter/SwooleCoroutine/Request.php b/src/Http/Adapter/SwooleCoroutine/Request.php deleted file mode 100644 index b176e52..0000000 --- a/src/Http/Adapter/SwooleCoroutine/Request.php +++ /dev/null @@ -1,9 +0,0 @@ - $settings - */ - public function __construct( - string $host, - ?string $port = null, - array $settings = [], - ?Container $container = null, - ) { - $this->server = new SwooleServer($host, $port, false, true); - $this->server->set($settings); - $this->container = $container ?? new Container(); - } - - public function onRequest(callable $callback): void - { - $this->server->handle('/', function (SwooleRequest $request, SwooleResponse $response) use ($callback) { - $requestContainer = new Container($this->container); - $requestContainer->set('swooleRequest', fn() => $request); - $requestContainer->set('swooleResponse', fn() => $response); - - Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] = $requestContainer; - - try { - \call_user_func($callback, new Request($request), new Response($response)); - } finally { - unset(Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY]); - } - }); - } - - public function getContainer(): Container - { - return Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] ?? $this->container; - } - - public function getServer(): SwooleServer - { - return $this->server; - } - - public function onStart(callable $callback): void - { - $this->onStartCallback = $callback; - } - - public function start(): void - { - if ($this->onStartCallback) { - \call_user_func($this->onStartCallback, $this); - } - - $this->server->start(); - } -} diff --git a/src/Http/Compression.php b/src/Http/Compression.php new file mode 100644 index 0000000..7459010 --- /dev/null +++ b/src/Http/Compression.php @@ -0,0 +1,109 @@ + + */ + private const array DEFAULT_MIME_TYPES = [ + // Text + 'text/html' => true, + 'text/richtext' => true, + 'text/plain' => true, + 'text/css' => true, + 'text/x-script' => true, + 'text/x-component' => true, + 'text/x-java-source' => true, + 'text/x-markdown' => true, + + // JavaScript + 'application/javascript' => true, + 'application/x-javascript' => true, + 'text/javascript' => true, + 'text/js' => true, + + // Icons + 'image/x-icon' => true, + 'image/vnd.microsoft.icon' => true, + + // Scripts + 'application/x-perl' => true, + 'application/x-httpd-cgi' => true, + + // XML and JSON + 'text/xml' => true, + 'application/xml' => true, + 'application/rss+xml' => true, + 'application/vnd.api+json' => true, + 'application/x-protobuf' => true, + 'application/json' => true, + 'application/manifest+json' => true, + 'application/ld+json' => true, + 'application/graphql+json' => true, + 'application/geo+json' => true, + + // Multipart + 'multipart/bag' => true, + 'multipart/mixed' => true, + + // XHTML + 'application/xhtml+xml' => true, + + // Fonts + 'font/ttf' => true, + 'font/otf' => true, + 'font/x-woff' => true, + 'image/svg+xml' => true, + 'application/vnd.ms-fontobject' => true, + 'application/ttf' => true, + 'application/x-ttf' => true, + 'application/otf' => true, + 'application/x-otf' => true, + 'application/truetype' => true, + 'application/opentype' => true, + 'application/x-opentype' => true, + 'application/font-woff' => true, + 'application/eot' => true, + 'application/font' => true, + 'application/font-sfnt' => true, + + // WebAssembly + 'application/wasm' => true, + 'application/javascript-binast' => true, + ]; + + /** + * @param int $minSizeBytes The minimum size in bytes for compression to be applied + * @param mixed $algorithms The algorithms to use for compression + */ + public function __construct( + private int $minSizeBytes = self::DEFAULT_MIN_SIZE_BYTES, + public mixed $algorithms = self::DEFAULT_ALGORITHMS, + public int $brotliLevel = self::COMPRESSION_BROTLI_LEVEL_DEFAULT, + public int $zstdLevel = self::COMPRESSION_ZSTD_LEVEL_DEFAULT, + private array $mimeTypes = self::DEFAULT_MIME_TYPES + ) { + } + + public function isCompressible(Response $request): bool + { + $hasAcceptEncoding = !empty($request->getHeader('accept-encoding', '')); + + $isCompressibleMimeType = isset($this->mimeTypes[$request->getHeader('content-type', '')]); + $isGreaterThanMinSize = \strlen($request->getRawPayload()) > $this->minSizeBytes; + return $isCompressibleMimeType && $isGreaterThanMinSize; + } +} diff --git a/src/Http/Http.php b/src/Http/Http.php index 74965fc..aaf1cab 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -2,8 +2,6 @@ namespace Utopia\Http; -use Psr\Container\ContainerExceptionInterface; -use Psr\Container\NotFoundExceptionInterface; use Utopia\DI\Container; use Utopia\Servers\Hook; use Utopia\Telemetry\Adapter as Telemetry; @@ -14,10 +12,6 @@ class Http { - public const int COMPRESSION_MIN_SIZE_DEFAULT = 1024; - public const int COMPRESSION_BROTLI_LEVEL_DEFAULT = 4; - public const int COMPRESSION_ZSTD_LEVEL_DEFAULT = 3; - /** * Request method constants */ @@ -48,8 +42,6 @@ class Http protected Container $container; - protected ?Container $requestContainer = null; - /** * Current running mode */ @@ -58,7 +50,7 @@ class Http /** * Errors * - * Errors callbacks + * Errors hooks * * @var Hook[] */ @@ -71,7 +63,7 @@ class Http * * @var Hook[] */ - protected static array $init = []; + protected static array $on = []; /** * Shutdown @@ -96,7 +88,7 @@ class Http * * @var Hook[] */ - protected static array $startHooks = []; + protected static array $ = []; /** * Request hooks @@ -105,27 +97,9 @@ class Http */ protected static array $requestHooks = []; - /** - * Route - * - * Memory cached result for chosen route - */ - protected ?Route $route = null; - - /** - * Wildcard route - * If set, this get's executed if no other route is matched - */ - protected static ?Route $wildcardRoute = null; - /** * Compression */ - protected bool $compression = false; - - protected int $compressionMinSize = Http::COMPRESSION_MIN_SIZE_DEFAULT; - - protected mixed $compressionSupported = []; private Histogram $requestDuration; @@ -135,17 +109,17 @@ class Http private Histogram $responseBodySize; - protected Adapter $server; - /** * Http */ - public function __construct(Adapter $server, string $timezone) + public function __construct( + private readonly Adapter $server, + private readonly string $timezone, + private readonly ?Compression $compression = null, + ) { date_default_timezone_set($timezone); $this->files = new Files(); - $this->server = $server; - $this->container = $server->getContainer(); $this->setTelemetry(new NoTelemetry()); } @@ -170,30 +144,6 @@ public function setTelemetry(Telemetry $telemetry): void $this->responseBodySize = $telemetry->createHistogram('http.server.response.body.size', 'By'); } - /** - * Set Compression - */ - public function setCompression(bool $compression): void - { - $this->compression = $compression; - } - - /** - * Set minimum compression size - */ - public function setCompressionMinSize(int $compressionMinSize): void - { - $this->compressionMinSize = $compressionMinSize; - } - - /** - * Set supported compression algorithms - */ - public function setCompressionSupported(mixed $compressionSupported): void - { - $this->compressionSupported = $compressionSupported; - } - /** * GET * @@ -364,66 +314,6 @@ public static function setAllowOverride(bool $value): void Router::setAllowOverride($value); } - /** - * Get a single resource from the given scope. - * - * @throws Exception - */ - public function getResource(string $name): mixed - { - try { - return $this->server->getContainer()->get($name); - } catch (ContainerExceptionInterface|NotFoundExceptionInterface $e) { - // Normalize DI container errors to the Http layer's "resource" terminology. - $message = str_replace('dependency', 'resource', $e->getMessage()); - - if ($message === $e->getMessage() && !str_contains($message, 'resource')) { - $message = 'Failed to find resource: "' . $name . '"'; - } - - throw new Exception($message, 500, $e); - } - } - - /** - * Get multiple resources from the given scope. - * - * @param string[] $list - * @return array - * - * @throws Exception - */ - public function getResources(array $list): array - { - $resources = []; - - foreach ($list as $name) { - $resources[$name] = $this->getResource($name); - } - - return $resources; - } - - /** - * Set a resource on the given scope. - * - * @param list $injections - */ - public function setResource(string $name, callable $callback, array $injections = []): void - { - $this->container->set($name, $callback, $injections); - } - - /** - * Set a request-scoped resource on the current request's container. - * - * @param list $injections - */ - protected function setRequestResource(string $name, callable $callback, array $injections = []): void - { - $this->server->getContainer()->set($name, $callback, $injections); - } - /** * Is http in production mode? */ @@ -460,24 +350,6 @@ public static function getRoutes(): array return Router::getRoutes(); } - /** - * Get the current route - */ - public function getRoute(): ?Route - { - return $this->route ?? null; - } - - /** - * Set the current route - */ - public function setRoute(Route $route): self - { - $this->route = $route; - - return $this; - } - /** * Add Route * @@ -555,7 +427,7 @@ public function start(): void ); $this->server->onStart(function ($server) { - $this->setResource('server', fn() => $server); + $this->resources->set('server', fn() => $server); try { foreach (self::$startHooks as $hook) { @@ -590,18 +462,22 @@ public function start(): void */ public function match(Request $request, bool $fresh = true): ?Route { - if (null !== $this->route && !$fresh) { - return $this->route; + $route = $this->server->getContext()->get('route'); + if ($route !== null && !$fresh) { + return $route; } $url = parse_url($request->getURI(), PHP_URL_PATH); $url = \is_string($url) ? ($url === '' ? '/' : $url) : '/'; - $method = $request->getMethod(); - $method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method; - $this->route = Router::match($method, $url); + $method = (self::REQUEST_METHOD_HEAD === $request->getMethod()) + ? self::REQUEST_METHOD_GET + : $request->getMethod(); - return $this->route; + $route = Router::match($method, $url); + $this->server->getContext()->set('route', fn() => $route); + + return $route; } /** @@ -657,7 +533,7 @@ public function execute(Route $route, Request $request, Response $response): sta } } } catch (\Throwable $e) { - $this->setRequestResource('error', fn() => $e, []); + $this->server->getContext()->set('error', fn() => $e, []); foreach ($groups as $group) { foreach (self::$errors as $error) { // Group error hooks @@ -771,14 +647,14 @@ public function run(Request $request, Response $response): static */ private function runInternal(Request $request, Response $response): static { + $response->setAcceptEncoding($request->getHeader('accept-encoding', '')); + if ($this->compression) { - $response->setAcceptEncoding($request->getHeader('accept-encoding', '')); - $response->setCompressionMinSize($this->compressionMinSize); - $response->setCompressionSupported($this->compressionSupported); + $response->setCompression($this->compression); } - $this->setRequestResource('request', fn() => $request); - $this->setRequestResource('response', fn() => $response); + $this->server->getContext()->set('request', fn() => $request); + $this->server->getContext()->set('response', fn() => $response); try { foreach (self::$requestHooks as $hook) { @@ -786,7 +662,7 @@ private function runInternal(Request $request, Response $response): static \call_user_func_array($hook->getAction(), $arguments); } } catch (\Exception $e) { - $this->setRequestResource('error', fn() => $e, []); + $this->server->getContext()->set('error', fn() => $e, []); foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { @@ -816,7 +692,7 @@ private function runInternal(Request $request, Response $response): static $route = $this->match($request); $groups = ($route instanceof Route) ? $route->getGroups() : []; - $this->setRequestResource('route', fn() => $route, []); + $this->server->getContext()->set('route', fn() => $route, []); if (self::REQUEST_METHOD_HEAD === $method) { $method = self::REQUEST_METHOD_GET; @@ -844,7 +720,7 @@ private function runInternal(Request $request, Response $response): static foreach (self::$errors as $error) { // Global error hooks /** @var Hook $error */ if (\in_array('*', $error->getGroups())) { - $this->setRequestResource('error', fn() => $e, []); + $this->server->getContext()->set('error', fn() => $e, []); \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); } } @@ -855,12 +731,11 @@ private function runInternal(Request $request, Response $response): static if (null === $route && null !== self::$wildcardRoute) { $route = self::$wildcardRoute; - $this->route = $route; $path = parse_url($request->getURI(), PHP_URL_PATH); $path = \is_string($path) ? ($path === '' ? '/' : $path) : '/'; $route->path($path); - $this->setRequestResource('route', fn() => $route, []); + $this->server->getContext()->set('route', fn() => $route, []); } if (null !== $route) { return $this->execute($route, $request, $response); @@ -884,7 +759,7 @@ private function runInternal(Request $request, Response $response): static } catch (\Throwable $e) { foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { - $this->setRequestResource('error', fn() => $e, []); + $this->server->getContext()->set('error', fn() => $e, []); \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); } } @@ -892,7 +767,7 @@ private function runInternal(Request $request, Response $response): static } else { foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { - $this->setRequestResource('error', fn() => new Exception('Not Found', 404), []); + $this->server->getContext()->set('error', fn() => new Exception('Not Found', 404), []); \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); } } @@ -945,6 +820,8 @@ public static function reset(): void self::$options = []; self::$startHooks = []; self::$requestHooks = []; + + self::$wildcardRoute = null; } } diff --git a/src/Http/Response.php b/src/Http/Response.php index fde5d6b..b29b8ba 100755 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -5,6 +5,7 @@ use Utopia\Compression\Algorithms\Brotli; use Utopia\Compression\Algorithms\Zstd; use Utopia\Compression\Compression; +use Utopia\Http\Compression as CompressionConfiguration; abstract class Response { @@ -182,78 +183,6 @@ abstract class Response self::STATUS_CODE_NETWORK_AUTHENTICATION_REQUIRED => 'Network Authentication Required', ]; - /** - * Mime Types with compression support - * - * @var array - */ - private static $compressible = [ - // Text - 'text/html' => true, - 'text/richtext' => true, - 'text/plain' => true, - 'text/css' => true, - 'text/x-script' => true, - 'text/x-component' => true, - 'text/x-java-source' => true, - 'text/x-markdown' => true, - - // JavaScript - 'application/javascript' => true, - 'application/x-javascript' => true, - 'text/javascript' => true, - 'text/js' => true, - - // Icons - 'image/x-icon' => true, - 'image/vnd.microsoft.icon' => true, - - // Scripts - 'application/x-perl' => true, - 'application/x-httpd-cgi' => true, - - // XML and JSON - 'text/xml' => true, - 'application/xml' => true, - 'application/rss+xml' => true, - 'application/vnd.api+json' => true, - 'application/x-protobuf' => true, - 'application/json' => true, - 'application/manifest+json' => true, - 'application/ld+json' => true, - 'application/graphql+json' => true, - 'application/geo+json' => true, - - // Multipart - 'multipart/bag' => true, - 'multipart/mixed' => true, - - // XHTML - 'application/xhtml+xml' => true, - - // Fonts - 'font/ttf' => true, - 'font/otf' => true, - 'font/x-woff' => true, - 'image/svg+xml' => true, - 'application/vnd.ms-fontobject' => true, - 'application/ttf' => true, - 'application/x-ttf' => true, - 'application/otf' => true, - 'application/x-otf' => true, - 'application/truetype' => true, - 'application/opentype' => true, - 'application/x-opentype' => true, - 'application/font-woff' => true, - 'application/eot' => true, - 'application/font' => true, - 'application/font-sfnt' => true, - - // WebAssembly - 'application/wasm' => true, - 'application/javascript-binast' => true, - ]; - public const string COOKIE_SAMESITE_NONE = 'None'; public const string COOKIE_SAMESITE_STRICT = 'Strict'; @@ -287,9 +216,7 @@ abstract class Response protected string $acceptEncoding = ''; - protected int $compressionMinSize = Http::COMPRESSION_MIN_SIZE_DEFAULT; - - protected mixed $compressionSupported = []; + protected ?CompressionConfiguration $compression = null; /** * Response constructor. @@ -326,23 +253,11 @@ public function setAcceptEncoding(string $acceptEncoding): static } /** - * Set min compression size - * - * Set minimum size for compression to be applied in bytes. - */ - public function setCompressionMinSize(int $compressionMinSize): static - { - $this->compressionMinSize = $compressionMinSize; - - return $this; - } - - /** - * Set supported compression algorithms + * Set compression configuration */ - public function setCompressionSupported(mixed $compressionSupported): static + public function setCompression(?CompressionConfiguration $configuration): static { - $this->compressionSupported = $compressionSupported; + $this->compression = $configuration; return $this; } @@ -552,18 +467,18 @@ public function send(string $body = ''): void // Compress body only if all conditions are met: if ( - !$hasContentEncoding + $this->compression + && !$hasContentEncoding && !empty($this->acceptEncoding) - && $this->isCompressible($this->contentType) - && \strlen($body) > $this->compressionMinSize + && $this->compression->isCompressible(self); ) { - $algorithm = Compression::fromAcceptEncoding($this->acceptEncoding, $this->compressionSupported); + $algorithm = Compression::fromAcceptEncoding($this->acceptEncoding, $this->compression->algorithms); if ($algorithm) { if ($algorithm instanceof Brotli) { - $algorithm->setLevel(Http::COMPRESSION_BROTLI_LEVEL_DEFAULT); + $algorithm->setLevel($this->compression->brotliLevel); } elseif ($algorithm instanceof Zstd) { - $algorithm->setLevel(Http::COMPRESSION_ZSTD_LEVEL_DEFAULT); + $algorithm->setLevel($this->compression->zstdLevel); } $body = $algorithm->compress($body);