From 45d1234323d71b780644729926d5fb8a00833157 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 12 Dec 2025 17:19:52 -0500 Subject: [PATCH 01/24] refactor: update response headers configuration and remove deprecated server config --- src/Http/Middlewares/ResponseHeaders.php | 2 +- tests/fixtures/application/config/app.php | 11 +++++++++ tests/fixtures/application/config/server.php | 25 -------------------- 3 files changed, 12 insertions(+), 26 deletions(-) delete mode 100644 tests/fixtures/application/config/server.php diff --git a/src/Http/Middlewares/ResponseHeaders.php b/src/Http/Middlewares/ResponseHeaders.php index 05ce5e17..bdca17a6 100644 --- a/src/Http/Middlewares/ResponseHeaders.php +++ b/src/Http/Middlewares/ResponseHeaders.php @@ -21,7 +21,7 @@ class ResponseHeaders implements Middleware public function __construct() { - $builders = Config::get('server.security.headers', []); + $builders = Config::get('app.response.headers', []); foreach ($builders as $builder) { assert(is_subclass_of($builder, HeaderBuilder::class)); diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 370af3fa..917ae572 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -39,4 +39,15 @@ \Phenix\Translation\TranslationServiceProvider::class, \Phenix\Validation\ValidationServiceProvider::class, ], + 'response' => [ + 'headers' => [ + \Phenix\Http\Headers\XDnsPrefetchControl::class, + \Phenix\Http\Headers\XFrameOptions::class, + \Phenix\Http\Headers\StrictTransportSecurity::class, + \Phenix\Http\Headers\XContentTypeOptions::class, + \Phenix\Http\Headers\ReferrerPolicy::class, + \Phenix\Http\Headers\CrossOriginResourcePolicy::class, + \Phenix\Http\Headers\CrossOriginOpenerPolicy::class, + ], + ], ]; diff --git a/tests/fixtures/application/config/server.php b/tests/fixtures/application/config/server.php deleted file mode 100644 index b46930f1..00000000 --- a/tests/fixtures/application/config/server.php +++ /dev/null @@ -1,25 +0,0 @@ - [ - 'headers' => [ - XDnsPrefetchControl::class, - XFrameOptions::class, - StrictTransportSecurity::class, - XContentTypeOptions::class, - ReferrerPolicy::class, - CrossOriginResourcePolicy::class, - CrossOriginOpenerPolicy::class, - ], - ], -]; From 9d27d20e720c26d9c7120fe674c70b1eb253d59b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 12 Dec 2025 17:36:12 -0500 Subject: [PATCH 02/24] refactor: implement app mode configuration and server creation logic --- src/App.php | 27 +++++++++++++++++++++-- src/Constants/AppMode.php | 12 ++++++++++ tests/fixtures/application/config/app.php | 26 ++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/Constants/AppMode.php diff --git a/src/App.php b/src/App.php index ac153e96..e16e1080 100644 --- a/src/App.php +++ b/src/App.php @@ -6,6 +6,7 @@ use Amp\Http\Server\DefaultErrorHandler; use Amp\Http\Server\Middleware; +use Amp\Http\Server\Middleware\ForwardedHeaderType; use Amp\Http\Server\RequestHandler; use Amp\Http\Server\Router; use Amp\Http\Server\SocketHttpServer; @@ -16,6 +17,7 @@ use Mockery\MockInterface; use Monolog\Logger; use Phenix\Console\Phenix; +use Phenix\Constants\AppMode; use Phenix\Contracts\App as AppContract; use Phenix\Contracts\Makeable; use Phenix\Facades\Config; @@ -24,6 +26,9 @@ use Phenix\Runtime\Log; use Phenix\Session\SessionMiddlewareFactory; +use function count; +use function is_array; + class App implements AppContract, Makeable { private static string $path; @@ -78,7 +83,7 @@ public function setup(): void public function run(): void { - $this->server = SocketHttpServer::createForDirectAccess($this->logger); + $this->server = $this->createServer(); $this->setRouter(); @@ -186,7 +191,25 @@ private function getPort(): int return (int) $port; } - private function getHostFromOptions(): string|null + protected function createServer(): SocketHttpServer + { + $mode = AppMode::tryFrom(Config::get('app.app_mode', AppMode::DIRECT->value)) ?? AppMode::DIRECT; + + if ($mode === AppMode::PROXIED) { + /** @var array $trustedProxies */ + $trustedProxies = Config::get('app.trusted_proxies', []); + + assert(is_array($trustedProxies) && count($trustedProxies) >= 0); + + return SocketHttpServer::createForBehindProxy( + $this->logger, + ForwardedHeaderType::XForwardedFor, + $trustedProxies + ); + } + + return SocketHttpServer::createForDirectAccess($this->logger); + } { $options = getopt('', ['host:']); diff --git a/src/Constants/AppMode.php b/src/Constants/AppMode.php new file mode 100644 index 00000000..2758ffec --- /dev/null +++ b/src/Constants/AppMode.php @@ -0,0 +1,12 @@ + env('APP_URL', static fn (): string => 'http://127.0.0.1'), 'port' => env('APP_PORT', static fn (): int => 1338), 'key' => env('APP_KEY'), + + /* + |-------------------------------------------------------------------------- + | App mode + |-------------------------------------------------------------------------- + | Controls how the HTTP server determines client connection details. + | + | direct: + | The server is exposed directly to clients. Remote address, scheme, + | and host are taken from the TCP connection and request line. + | + | proxied: + | The server runs behind a reverse proxy or load balancer (e.g., Nginx, + | HAProxy, AWS ALB). Client information is derived from standard + | forwarding headers only when the request comes from a trusted proxy. + | Configure trusted proxies in `trusted_proxies` (IP addresses or CIDRs). + | When enabled, the server will honor `Forwarded`, `X-Forwarded-For`, + | `X-Forwarded-Proto`, and `X-Forwarded-Host` headers from trusted + | sources, matching Amphp's behind-proxy behavior. + | + | Supported values: "direct", "proxied" + | + */ + + 'app_mode' => env('APP_MODE', static fn (): string => 'direct'), + 'trusted_proxies' => env('APP_TRUSTED_PROXIES', static fn (): array => []), 'previous_key' => env('APP_PREVIOUS_KEY'), 'debug' => env('APP_DEBUG', static fn (): bool => true), 'locale' => 'en', From 1f222fa3d141db3d7e8b59adfbe47248cf0cb34e Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 12 Dec 2025 17:36:18 -0500 Subject: [PATCH 03/24] refactor: change visibility of router, host, and port methods to protected --- src/App.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/App.php b/src/App.php index e16e1080..f37b39ac 100644 --- a/src/App.php +++ b/src/App.php @@ -147,7 +147,7 @@ public function disableSignalTrapping(): void $this->signalTrapping = false; } - private function setRouter(): void + protected function setRouter(): void { $router = new Router($this->server, $this->logger, $this->errorHandler); @@ -179,12 +179,12 @@ private function setRouter(): void $this->router = Middleware\stackMiddleware($router, ...$globalMiddlewares); } - private function getHost(): string + protected function getHost(): string { return $this->getHostFromOptions() ?? Uri::new(Config::get('app.url'))->getHost(); } - private function getPort(): int + protected function getPort(): int { $port = $this->getPortFromOptions() ?? Config::get('app.port'); @@ -210,13 +210,15 @@ protected function createServer(): SocketHttpServer return SocketHttpServer::createForDirectAccess($this->logger); } + + protected function getHostFromOptions(): string|null { $options = getopt('', ['host:']); return $options['host'] ?? null; } - private function getPortFromOptions(): string|null + protected function getPortFromOptions(): string|null { $options = getopt('', ['port:']); From 25d2de537912ac1bcbaef5917d85ae6b609657aa Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 12 Dec 2025 17:39:13 -0500 Subject: [PATCH 04/24] refactor: simplify signal trapping function call in run method --- src/App.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/App.php b/src/App.php index f37b39ac..88776126 100644 --- a/src/App.php +++ b/src/App.php @@ -26,6 +26,7 @@ use Phenix\Runtime\Log; use Phenix\Session\SessionMiddlewareFactory; +use function Amp\trapSignal; use function count; use function is_array; @@ -94,7 +95,7 @@ public function run(): void $this->server->start($this->router, $this->errorHandler); if ($this->signalTrapping) { - $signal = \Amp\trapSignal([SIGHUP, SIGINT, SIGQUIT, SIGTERM]); + $signal = trapSignal([SIGHUP, SIGINT, SIGQUIT, SIGTERM]); $this->logger->info("Caught signal {$signal}, stopping server"); From eebfd8ac414532599340f79aa3b48711ac1dfaba Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 12 Dec 2025 17:39:29 -0500 Subject: [PATCH 05/24] style: remove tab --- tests/Feature/RequestTest.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Feature/RequestTest.php b/tests/Feature/RequestTest.php index 0eaa38b8..d20ba005 100644 --- a/tests/Feature/RequestTest.php +++ b/tests/Feature/RequestTest.php @@ -465,13 +465,13 @@ $this->get('/secure') ->assertOk() ->assertHeaders([ - 'X-Frame-Options' => 'SAMEORIGIN', - 'X-Content-Type-Options' => 'nosniff', - 'X-DNS-Prefetch-Control' => 'off', - 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload', - 'Referrer-Policy' => 'no-referrer', - 'Cross-Origin-Resource-Policy' => 'same-origin', - 'Cross-Origin-Opener-Policy' => 'same-origin', + 'X-Frame-Options' => 'SAMEORIGIN', + 'X-Content-Type-Options' => 'nosniff', + 'X-DNS-Prefetch-Control' => 'off', + 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload', + 'Referrer-Policy' => 'no-referrer', + 'Cross-Origin-Resource-Policy' => 'same-origin', + 'Cross-Origin-Opener-Policy' => 'same-origin', ]); }); From b0ee3396bfac4d7d3dd0bca96d4c485323d9ab40 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 12 Dec 2025 18:04:31 -0500 Subject: [PATCH 06/24] refactor: improve trusted proxies validation in createServer method --- src/App.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/App.php b/src/App.php index 88776126..793584d6 100644 --- a/src/App.php +++ b/src/App.php @@ -20,6 +20,7 @@ use Phenix\Constants\AppMode; use Phenix\Contracts\App as AppContract; use Phenix\Contracts\Makeable; +use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Config; use Phenix\Facades\Route; use Phenix\Logging\LoggerFactory; @@ -200,7 +201,9 @@ protected function createServer(): SocketHttpServer /** @var array $trustedProxies */ $trustedProxies = Config::get('app.trusted_proxies', []); - assert(is_array($trustedProxies) && count($trustedProxies) >= 0); + if (is_array($trustedProxies) && count($trustedProxies) === 0) { + throw new RuntimeError('Trusted proxies must be an array of IP addresses or CIDRs.'); + } return SocketHttpServer::createForBehindProxy( $this->logger, From c090cd29331084ff7085187c569c9637523367fa Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 12 Dec 2025 18:04:59 -0500 Subject: [PATCH 07/24] test: add feature tests for server running in proxied mode --- tests/Feature/AppTest.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/Feature/AppTest.php diff --git a/tests/Feature/AppTest.php b/tests/Feature/AppTest.php new file mode 100644 index 00000000..0fe9e16a --- /dev/null +++ b/tests/Feature/AppTest.php @@ -0,0 +1,29 @@ + response()->json(['message' => 'Proxied'])); + + $this->app->run(); + + $this->get('/proxy') + ->assertOk() + ->assertJsonPath('data.message', 'Proxied'); + + $this->app->stop(); +}); + +it('starts server in proxied mode with no trusted proxies', function (): void { + Config::set('app.app_mode', 'proxied'); + + $this->app->run(); +})->throws(RuntimeError::class); From a3d53a20150e6643b9d115836148c99b7c68d29d Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 13 Dec 2025 14:53:15 -0500 Subject: [PATCH 08/24] feat: add tls support --- src/App.php | 63 ++++++++++++++++++----- src/Http/Constants/Protocol.php | 12 +++++ tests/Feature/AppTest.php | 17 ++++++ tests/fixtures/application/config/app.php | 5 +- tests/fixtures/files/cert.pem | 47 +++++++++++++++++ 5 files changed, 128 insertions(+), 16 deletions(-) create mode 100644 src/Http/Constants/Protocol.php create mode 100644 tests/fixtures/files/cert.pem diff --git a/src/App.php b/src/App.php index 793584d6..e696141f 100644 --- a/src/App.php +++ b/src/App.php @@ -10,7 +10,9 @@ use Amp\Http\Server\RequestHandler; use Amp\Http\Server\Router; use Amp\Http\Server\SocketHttpServer; -use Amp\Socket; +use Amp\Socket\BindContext; +use Amp\Socket\Certificate; +use Amp\Socket\ServerTlsContext; use League\Container\Container; use League\Uri\Uri; use Mockery\LegacyMockInterface; @@ -23,6 +25,7 @@ use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Config; use Phenix\Facades\Route; +use Phenix\Http\Constants\Protocol; use Phenix\Logging\LoggerFactory; use Phenix\Runtime\Log; use Phenix\Session\SessionMiddlewareFactory; @@ -33,21 +36,23 @@ class App implements AppContract, Makeable { - private static string $path; + protected static string $path; - private static Container $container; + protected static Container $container; - private string $host; + protected string $host; - private RequestHandler $router; + protected RequestHandler $router; - private Logger $logger; + protected Logger $logger; - private SocketHttpServer $server; + protected SocketHttpServer $server; - private bool $signalTrapping = true; + protected bool $signalTrapping = true; - private DefaultErrorHandler $errorHandler; + protected DefaultErrorHandler $errorHandler; + + protected Protocol $protocol = Protocol::HTTP; public function __construct(string $path) { @@ -64,8 +69,6 @@ public function setup(): void \Phenix\Runtime\Config::build(...) )->setShared(true); - $this->host = $this->getHost(); - self::$container->add(Phenix::class)->addMethodCall('registerCommands'); /** @var array $providers */ @@ -85,13 +88,15 @@ public function setup(): void public function run(): void { + $this->detectProtocol(); + + $this->host = $this->getHost(); + $this->server = $this->createServer(); $this->setRouter(); - $port = $this->getPort(); - - $this->server->expose(new Socket\InternetAddress($this->host, $port)); + $this->expose($this->getPort()); $this->server->start($this->router, $this->errorHandler); @@ -228,4 +233,34 @@ protected function getPortFromOptions(): string|null return $options['port'] ?? null; } + + protected function expose(int $port): void + { + $plainBindContext = (new BindContext())->withTcpNoDelay(); + + if ($this->protocol === Protocol::HTTPS) { + /** @var string|null $certPath */ + $certPath = Config::get('app.cert_path'); + + $tlsBindContext = $plainBindContext->withTlsContext( + (new ServerTlsContext())->withDefaultCertificate(new Certificate($certPath)) + ); + + $this->server->expose("{$this->host}:{$port}", $tlsBindContext); + + return; + } + + $this->server->expose("{$this->host}:{$port}", $plainBindContext); + } + + protected function detectProtocol(): void + { + $url = (string) Config::get('app.url'); + + /** @var string|null $certPath */ + $certPath = Config::get('app.cert_path'); + + $this->protocol = str_starts_with($url, 'https://') && $certPath !== null ? Protocol::HTTPS : Protocol::HTTP; + } } diff --git a/src/Http/Constants/Protocol.php b/src/Http/Constants/Protocol.php new file mode 100644 index 00000000..99aa10d7 --- /dev/null +++ b/src/Http/Constants/Protocol.php @@ -0,0 +1,12 @@ +app->run(); })->throws(RuntimeError::class); + +it('starts server with TLS certificate', function (): void { + Config::set('app.url', 'https://127.0.0.1'); + Config::set('app.port', 443); + Config::set('app.cert_path', __DIR__ . '/../fixtures/files/cert.pem'); + + Route::get('/tls', fn (): Response => response()->json(['message' => 'TLS'])); + + $this->app->run(); + + $this->get('/tls') + ->assertOk() + ->assertJsonPath('data.message', 'TLS'); + + $this->app->stop(); +})->throws(SocketException::class, 'Could not create server tcp://127.0.0.1:443: [Error: #0] Permission denied'); diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index ff68205e..30bebb74 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -6,8 +6,10 @@ 'name' => env('APP_NAME', static fn (): string => 'Phenix'), 'env' => env('APP_ENV', static fn (): string => 'local'), 'url' => env('APP_URL', static fn (): string => 'http://127.0.0.1'), - 'port' => env('APP_PORT', static fn (): int => 1338), + 'port' => env('APP_PORT', static fn (): int => 1337), + 'cert_path' => env('APP_CERT_PATH', static fn (): string|null => null), 'key' => env('APP_KEY'), + 'previous_key' => env('APP_PREVIOUS_KEY'), /* |-------------------------------------------------------------------------- @@ -34,7 +36,6 @@ 'app_mode' => env('APP_MODE', static fn (): string => 'direct'), 'trusted_proxies' => env('APP_TRUSTED_PROXIES', static fn (): array => []), - 'previous_key' => env('APP_PREVIOUS_KEY'), 'debug' => env('APP_DEBUG', static fn (): bool => true), 'locale' => 'en', 'fallback_locale' => 'en', diff --git a/tests/fixtures/files/cert.pem b/tests/fixtures/files/cert.pem new file mode 100644 index 00000000..2a0743d7 --- /dev/null +++ b/tests/fixtures/files/cert.pem @@ -0,0 +1,47 @@ +-----BEGIN CERTIFICATE----- +MIIDLjCCAhYCCQCltBFjDvGeajANBgkqhkiG9w0BAQUFADBZMQswCQYDVQQGEwJM +VTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0 +cyBQdHkgTHRkMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMTUxMTA0MTg0NzI4WhcN +NDMwMzIxMTg0NzI4WjBZMQswCQYDVQQGEwJMVTETMBEGA1UECAwKU29tZS1TdGF0 +ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRIwEAYDVQQDDAls +b2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC52v3CH7MO +mNwpXNUCtwtaEr25Iq+Gp60/jlvcCU/ZvW/N3DS6YCUepTFgzut+VKLRrfN1mC3I +jLq4ieXFb/5wd3b9Q+P8D913zF9SvXliXIbIuZJrx7Du9Gb1Y0AUmZ3CZSkOdUP4 +svxL3dGlf2z9CshAuJJlYdaujTT1E0yZaI9hyvmcKHTBOhHwW57gO89usnM9TMYK +CjnqnHBX84SOwkG0Jvkmtl5ideZnV9Y1OwfOyWEJ9TKvia4YEEH0ZmChlqDonZpB +NAFBxNx6x2qvcHVLUDRBZD2v0cGSzNbzeVfq7zZiOqvLNjk3gr84RK3qdIKDwlHo +tu0fMJDNMhrBAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAD5TI3rjaupcy/frGIjl +kUSbIP6XUKd2ja49Ifa7uifl4orR7pw0FRj0mnqoPcDh1glr5XtC9TpIYRyTNxSg +FAqf5KU6HdWwmqTQqoMIBBPeG62WkhwhtaZ0+KwZ6bZyJ5YNOxNLpvjbmpKEPJ0H +W01Rr7lw+IPSKJm6wPcZ5Pke42H91N5Ya1BSv5utjMaqNpz1+3wetslTxDAYqgXx +RazVVGxwo2XMygRnK3amMZCA0x71/rVK1sxZJyOPCgH9vzOPPqgAirPGnDYiY5Kr +Z3NsQ+IHr/HC3kt6hM+vVpkEXbkrzXMJxNuduyGyXhFJHSigksRXsFryrI4iAxMP +LHw= +-----END CERTIFICATE----- +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAudr9wh+zDpjcKVzVArcLWhK9uSKvhqetP45b3AlP2b1vzdw0 +umAlHqUxYM7rflSi0a3zdZgtyIy6uInlxW/+cHd2/UPj/A/dd8xfUr15YlyGyLmS +a8ew7vRm9WNAFJmdwmUpDnVD+LL8S93RpX9s/QrIQLiSZWHWro009RNMmWiPYcr5 +nCh0wToR8Fue4DvPbrJzPUzGCgo56pxwV/OEjsJBtCb5JrZeYnXmZ1fWNTsHzslh +CfUyr4muGBBB9GZgoZag6J2aQTQBQcTcesdqr3B1S1A0QWQ9r9HBkszW83lX6u82 +YjqryzY5N4K/OESt6nSCg8JR6LbtHzCQzTIawQIDAQABAoIBAQC5BwDMqzxa0umU +MDxMWKjvgmrpDlQKzZHYDUT8WTTqxAKzwn+n8KHj0XfINhgSi/YQo4oWT2t9FkWq +BHcAyY9YrkaCu30Ua0MDyi44NDPNLeptmPnhXUuTiTObJrUcDRcW+hkWsL37sU0l +xm65wZNik8JrVJVCY1YULrZDKnR+3/tLuOhaB8u3/OPYLQ1GfRcIy+ghAuNTJ2be +1Jxl1pOlhlNkTdSV0U+r+jxLLtBwpiTdQamdW85reAN9sAAk+I/DuCeWIhMLrm8p +CeemRPA50fwNg+PQA3yUbcj7Mm+/nkyDw7DDG9YK8qpEhbCZnX8FQQSUDCi3P0i7 +HSPJbwABAoGBAOnCOfsht6678ZXaUet25dXQ8kUCAXZqpzAFqxsyH/oi9hDgsZ+E +oPPa2158lGyov+Xv149LDyXJIFUW96TJoMrMnLJNu/51MsHScx1/yCybEVH/Sre7 +PsO1NNuJfP1vP7RjVLVpGJ0KoYwdjmH+czRHJKYZJwkCFlzI2KqhD9rBAoGBAMuJ +9jt72ToBV2Uz9dFb1kPsVI/5e3wL15hcp+57rSdNBjzki4z6zV4kO6rQAuUWcmBn +oKmfRKyuw6iz/Xl8PXxjxlWluUXEdTBivWGkuZZoBAHVe8yvXQeImvBmG+qJhJud +iz0YLL6/2yT9C4t0D5WaWaKx7Z66yLoDUacr8kABAoGAFzsPKg76wym4Y40T0RO6 +2ZnvSb5eSNdmkBYwH/7GQMSSsbCy1kiG+lUIsgYtdfL7Ry2jvYDXG4k2Zl5m9AB5 +s03MUMf649nf1nVErWzShuROP1jgowu/vBFZFGxAeKtCqHmqpHCyWoEA9vzE9qYj +6tEbKkqbn4COml/3cFWbTsECgYEAkgFxZNI+zWFQ9AQF/hzG4wqQzobEkgNcsKsm +u+h0GZEjPGMlyAfRcgrD0pBMw1EK0yUDFyps9QKY0FftKEx7PtPD3oR3FxkKh58N +AxJLHx2WYkpl+DqDnXfczT4yIFhti8PDshu5XUv7Q9lRgsPKuiJy0kaYkhijDOx8 +klLwAAECgYBz3f6AiBxqYe1AKmEFuveC9ulMDCBEgAbhp1Dgx9zW7ZzF+YxtrcMh +akCozVQUEiD11q7bdEZL8jlVLsqrT+Xlfdr/AnICK55FvQdDt6hacLXBWdFDtMx1 +Dld3Erx9aLeKO081voSpK63h91DEWph1YtU+ecB9bq/Pui01sYD4CA== +-----END RSA PRIVATE KEY----- \ No newline at end of file From f0e5ee371bf61cd190556b27721d8defc088a987 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sun, 14 Dec 2025 13:51:38 -0500 Subject: [PATCH 09/24] feat: enhance HTTP client with custom socket connector and TLS support --- .../Concerns/InteractWithResponses.php | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Testing/Concerns/InteractWithResponses.php b/src/Testing/Concerns/InteractWithResponses.php index 8fe1092b..cf4235a9 100644 --- a/src/Testing/Concerns/InteractWithResponses.php +++ b/src/Testing/Concerns/InteractWithResponses.php @@ -4,9 +4,18 @@ namespace Phenix\Testing\Concerns; +use Amp\Cancellation; +use Amp\Http\Client\Connection\DefaultConnectionFactory; +use Amp\Http\Client\Connection\UnlimitedConnectionPool; use Amp\Http\Client\Form; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; +use Amp\Socket\ClientTlsContext; +use Amp\Socket\ConnectContext; +use Amp\Socket\DnsSocketConnector; +use Amp\Socket\Socket; +use Amp\Socket\SocketAddress; +use Amp\Socket\SocketConnector; use Phenix\Http\Constants\HttpMethod; use Phenix\Testing\TestResponse; use Phenix\Util\URL; @@ -37,7 +46,22 @@ public function call( $request->setBody($body); } - $client = HttpClientBuilder::buildDefault(); + $connector = new class () implements SocketConnector { + public function connect( + SocketAddress|string $uri, + ConnectContext|null $context = null, + Cancellation|null $cancellation = null + ): Socket { + $context = (new ConnectContext()) + ->withTlsContext((new ClientTlsContext(''))->withoutPeerVerification()); + + return (new DnsSocketConnector())->connect($uri, $context, $cancellation); + } + }; + + $client = (new HttpClientBuilder()) + ->usingPool(new UnlimitedConnectionPool(new DefaultConnectionFactory($connector))) + ->build(); return new TestResponse($client->request($request)); } From cb3213e0211a2789ac714f7c565aef5f8741eb5b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sun, 14 Dec 2025 13:52:03 -0500 Subject: [PATCH 10/24] tests(refactor): update server port for TLS tests and remove unused exception --- tests/Feature/AppTest.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Feature/AppTest.php b/tests/Feature/AppTest.php index 8c253849..14deded0 100644 --- a/tests/Feature/AppTest.php +++ b/tests/Feature/AppTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use Amp\Socket\SocketException; use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Config; use Phenix\Facades\Route; @@ -31,7 +30,7 @@ it('starts server with TLS certificate', function (): void { Config::set('app.url', 'https://127.0.0.1'); - Config::set('app.port', 443); + Config::set('app.port', 1338); Config::set('app.cert_path', __DIR__ . '/../fixtures/files/cert.pem'); Route::get('/tls', fn (): Response => response()->json(['message' => 'TLS'])); @@ -43,4 +42,4 @@ ->assertJsonPath('data.message', 'TLS'); $this->app->stop(); -})->throws(SocketException::class, 'Could not create server tcp://127.0.0.1:443: [Error: #0] Permission denied'); +}); From d1194fd7f16f81c4ca5dfa5db6aff4a231a79abb Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sun, 14 Dec 2025 14:21:09 -0500 Subject: [PATCH 11/24] refactor(tests): use AppMode constant for proxied mode configuration --- tests/Feature/AppTest.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Feature/AppTest.php b/tests/Feature/AppTest.php index 14deded0..fb454e42 100644 --- a/tests/Feature/AppTest.php +++ b/tests/Feature/AppTest.php @@ -2,14 +2,15 @@ declare(strict_types=1); +use Phenix\Constants\AppMode; use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Config; use Phenix\Facades\Route; use Phenix\Http\Response; it('starts server in proxied mode', function (): void { - Config::set('app.app_mode', 'proxied'); - Config::set('app.trusted_proxies', ['172.18.0.0/24']); + Config::set('app.app_mode', AppMode::PROXIED->value); + Config::set('app.trusted_proxies', ['172.18.0.0']); Route::get('/proxy', fn (): Response => response()->json(['message' => 'Proxied'])); @@ -23,7 +24,7 @@ }); it('starts server in proxied mode with no trusted proxies', function (): void { - Config::set('app.app_mode', 'proxied'); + Config::set('app.app_mode', AppMode::PROXIED->value); $this->app->run(); })->throws(RuntimeError::class); From a3f0093bd9fd0c2838f978fea254659b058f5012 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sun, 14 Dec 2025 14:22:57 -0500 Subject: [PATCH 12/24] refactor(Ip): simplify forwarding address handling and remove unused array --- src/Http/Ip.php | 17 ++++++++++------- tests/Unit/Http/IpAddressTest.php | 21 +++++++++++++++++---- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/Http/Ip.php b/src/Http/Ip.php index 22a031cf..61ead97e 100644 --- a/src/Http/Ip.php +++ b/src/Http/Ip.php @@ -4,6 +4,7 @@ namespace Phenix\Http; +use Amp\Http\Server\Middleware\Forwarded; use Amp\Http\Server\Request; class Ip @@ -14,15 +15,17 @@ class Ip protected int|null $port = null; - protected array $forwardingAddresses = []; + protected string|null $forwardingAddress = null; public function __construct(Request $request) { $this->address = $request->getClient()->getRemoteAddress()->toString(); - if ($forwardingHeader = $request->getHeader('X-Forwarded-For')) { - $parts = array_map(static fn ($v) => trim($v), explode(',', $forwardingHeader)); - $this->forwardingAddresses = $parts; + if ($request->hasAttribute(Forwarded::class)) { + /** @var Forwarded|null $forwarded */ + $forwarded = $request->getAttribute(Forwarded::class); + + $this->forwardingAddress = $forwarded->getFor()->toString(); } } @@ -51,12 +54,12 @@ public function port(): int|null public function isForwarded(): bool { - return ! empty($this->forwardingAddresses); + return ! empty($this->forwardingAddress); } - public function forwardingAddresses(): array + public function forwardingAddress(): string|null { - return $this->forwardingAddresses; + return $this->forwardingAddress; } public function hash(): string diff --git a/tests/Unit/Http/IpAddressTest.php b/tests/Unit/Http/IpAddressTest.php index 7e35b3d3..93f32e32 100644 --- a/tests/Unit/Http/IpAddressTest.php +++ b/tests/Unit/Http/IpAddressTest.php @@ -3,10 +3,14 @@ declare(strict_types=1); use Amp\Http\Server\Driver\Client; +use Amp\Http\Server\Middleware\Forwarded; use Amp\Http\Server\Request as ServerRequest; +use Amp\Socket\InternetAddress; use Amp\Socket\SocketAddress; use Amp\Socket\SocketAddressType; use League\Uri\Http; +use Phenix\Constants\AppMode; +use Phenix\Facades\Config; use Phenix\Http\Constants\HttpMethod; use Phenix\Http\Ip; use Phenix\Util\URL; @@ -43,7 +47,7 @@ public function __toString(): string expect($ip->hash())->toBe($expected); expect($ip->isForwarded())->toBeFalse(); - expect($ip->forwardingAddresses())->toBe([]); + expect($ip->forwardingAddress())->toBeNull(); })->with([ ['192.168.1.1', hash('sha256', '192.168.1.1')], ['192.168.1.1:8080', hash('sha256', '192.168.1.1')], @@ -89,7 +93,7 @@ public function __toString(): string expect($ip->host())->toBe('2001:db8::1'); expect($ip->port())->toBe(443); expect($ip->isForwarded())->toBeFalse(); - expect($ip->forwardingAddresses())->toBe([]); + expect($ip->forwardingAddress())->toBeNull(); }); it('parses host only from raw IPv6 without port', function (): void { @@ -246,10 +250,19 @@ public function __toString(): string ); $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(URL::build('/'))); - $request->setHeader('X-Forwarded-For', '203.0.113.1, 198.51.100.2'); + $request->setHeader('X-Forwarded-For', '203.0.113.1'); + $request->setAttribute( + Forwarded::class, + new Forwarded( + new InternetAddress('203.0.113.1', 4711), + [ + 'for' => '203.0.113.1:4711', + ] + ) + ); $ip = Ip::make($request); expect($ip->isForwarded())->toBeTrue(); - expect($ip->forwardingAddresses())->toBe(['203.0.113.1', '198.51.100.2']); + expect($ip->forwardingAddress())->toBe('203.0.113.1:4711'); }); From 8fd9d7c082bff363254466866c7d75568a8c40be Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sun, 14 Dec 2025 20:48:51 -0500 Subject: [PATCH 13/24] style: php cs --- tests/Unit/Http/IpAddressTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/Unit/Http/IpAddressTest.php b/tests/Unit/Http/IpAddressTest.php index 93f32e32..ab1fa30c 100644 --- a/tests/Unit/Http/IpAddressTest.php +++ b/tests/Unit/Http/IpAddressTest.php @@ -9,8 +9,6 @@ use Amp\Socket\SocketAddress; use Amp\Socket\SocketAddressType; use League\Uri\Http; -use Phenix\Constants\AppMode; -use Phenix\Facades\Config; use Phenix\Http\Constants\HttpMethod; use Phenix\Http\Ip; use Phenix\Util\URL; From f3d49a53484de58dba10a35db83e057c0ffafffa Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 15 Dec 2025 09:03:20 -0500 Subject: [PATCH 14/24] refactor(Ip, Request): streamline IP address handling and improve request proxying --- src/Http/Ip.php | 6 ++---- src/Http/Request.php | 4 +++- tests/Feature/AppTest.php | 9 ++++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Http/Ip.php b/src/Http/Ip.php index 61ead97e..896149a8 100644 --- a/src/Http/Ip.php +++ b/src/Http/Ip.php @@ -21,10 +21,8 @@ public function __construct(Request $request) { $this->address = $request->getClient()->getRemoteAddress()->toString(); - if ($request->hasAttribute(Forwarded::class)) { - /** @var Forwarded|null $forwarded */ - $forwarded = $request->getAttribute(Forwarded::class); - + /** @var Forwarded|null $forwarded */ + if ($request->hasAttribute(Forwarded::class) && $forwarded = $request->getAttribute(Forwarded::class)) { $this->forwardingAddress = $forwarded->getFor()->toString(); } } diff --git a/src/Http/Request.php b/src/Http/Request.php index 82042965..ef4c37b2 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -42,6 +42,8 @@ class Request implements Arrayable protected Session|null $session; + protected Ip|null $ip; + public function __construct( protected ServerRequest $request ) { @@ -149,7 +151,7 @@ public function session(string|null $key = null, array|string|int|null $default public function ip(): Ip { - return Ip::make($this->request); + return $this->ip ??= Ip::make($this->request); } public function toArray(): array diff --git a/tests/Feature/AppTest.php b/tests/Feature/AppTest.php index fb454e42..e6be0237 100644 --- a/tests/Feature/AppTest.php +++ b/tests/Feature/AppTest.php @@ -6,17 +6,20 @@ use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Config; use Phenix\Facades\Route; +use Phenix\Http\Request; use Phenix\Http\Response; it('starts server in proxied mode', function (): void { Config::set('app.app_mode', AppMode::PROXIED->value); - Config::set('app.trusted_proxies', ['172.18.0.0']); + Config::set('app.trusted_proxies', ['127.0.0.1/32', '127.0.0.1']); - Route::get('/proxy', fn (): Response => response()->json(['message' => 'Proxied'])); + Route::get('/proxy', function (Request $request): Response { + return response()->json(['message' => 'Proxied']); + }); $this->app->run(); - $this->get('/proxy') + $this->get('/proxy', headers: ['X-Forwarded-For' => '10.0.0.1']) ->assertOk() ->assertJsonPath('data.message', 'Proxied'); From f700bd8e676c8e64240bcdf73840945b1bee3279 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 15 Dec 2025 19:14:44 -0500 Subject: [PATCH 15/24] feat(Server): implement server mode configuration and cluster support --- src/App.php | 99 +++++++++++++++++++++-- src/Constants/ServerMode.php | 12 +++ src/Logging/LoggerFactory.php | 21 +++-- src/Testing/TestCase.php | 5 ++ tests/fixtures/application/config/app.php | 23 ++++++ 5 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 src/Constants/ServerMode.php diff --git a/src/App.php b/src/App.php index e696141f..3546f626 100644 --- a/src/App.php +++ b/src/App.php @@ -4,8 +4,13 @@ namespace Phenix; +use Amp\Cluster\Cluster; use Amp\Http\Server\DefaultErrorHandler; +use Amp\Http\Server\Driver\ConnectionLimitingClientFactory; +use Amp\Http\Server\Driver\ConnectionLimitingServerSocketFactory; +use Amp\Http\Server\Driver\SocketClientFactory; use Amp\Http\Server\Middleware; +use Amp\Http\Server\Middleware\CompressionMiddleware; use Amp\Http\Server\Middleware\ForwardedHeaderType; use Amp\Http\Server\RequestHandler; use Amp\Http\Server\Router; @@ -13,6 +18,7 @@ use Amp\Socket\BindContext; use Amp\Socket\Certificate; use Amp\Socket\ServerTlsContext; +use Amp\Sync\LocalSemaphore; use League\Container\Container; use League\Uri\Uri; use Mockery\LegacyMockInterface; @@ -20,6 +26,7 @@ use Monolog\Logger; use Phenix\Console\Phenix; use Phenix\Constants\AppMode; +use Phenix\Constants\ServerMode; use Phenix\Contracts\App as AppContract; use Phenix\Contracts\Makeable; use Phenix\Exceptions\RuntimeError; @@ -30,8 +37,10 @@ use Phenix\Runtime\Log; use Phenix\Session\SessionMiddlewareFactory; +use function Amp\async; use function Amp\trapSignal; use function count; +use function extension_loaded; use function is_array; class App implements AppContract, Makeable @@ -54,6 +63,10 @@ class App implements AppContract, Makeable protected Protocol $protocol = Protocol::HTTP; + protected AppMode $appMode; + + protected ServerMode $serverMode; + public function __construct(string $path) { self::$path = $path; @@ -78,16 +91,15 @@ public function setup(): void self::$container->addServiceProvider(new $provider()); } - /** @var string $channel */ - $channel = Config::get('logging.default', 'file'); - - $this->logger = LoggerFactory::make($channel); + $this->serverMode = ServerMode::tryFrom(Config::get('app.server.mode', ServerMode::SINGLE->value)) ?? ServerMode::SINGLE; - $this->register(Log::class, new Log($this->logger)); + $this->setLogger(); } public function run(): void { + $this->appMode = AppMode::tryFrom(Config::get('app.app_mode', AppMode::DIRECT->value)) ?? AppMode::DIRECT; + $this->detectProtocol(); $this->host = $this->getHost(); @@ -100,7 +112,15 @@ public function run(): void $this->server->start($this->router, $this->errorHandler); - if ($this->signalTrapping) { + if ($this->serverMode === ServerMode::CLUSTER) { + async(function (): void { + Cluster::awaitTermination(); + + $this->logger->info('Received termination request'); + + $this->stop(); + }); + } elseif ($this->signalTrapping) { $signal = trapSignal([SIGHUP, SIGINT, SIGQUIT, SIGTERM]); $this->logger->info("Caught signal {$signal}, stopping server"); @@ -154,6 +174,16 @@ public function disableSignalTrapping(): void $this->signalTrapping = false; } + protected function setLogger(): void + { + /** @var string $channel */ + $channel = Config::get('logging.default', 'file'); + + $this->logger = LoggerFactory::make($channel, $this->serverMode); + + $this->register(Log::class, new Log($this->logger)); + } + protected function setRouter(): void { $router = new Router($this->server, $this->logger, $this->errorHandler); @@ -200,9 +230,11 @@ protected function getPort(): int protected function createServer(): SocketHttpServer { - $mode = AppMode::tryFrom(Config::get('app.app_mode', AppMode::DIRECT->value)) ?? AppMode::DIRECT; + if ($this->serverMode === ServerMode::CLUSTER) { + return $this->createClusterServer(); + } - if ($mode === AppMode::PROXIED) { + if ($this->appMode === AppMode::PROXIED) { /** @var array $trustedProxies */ $trustedProxies = Config::get('app.trusted_proxies', []); @@ -220,6 +252,57 @@ protected function createServer(): SocketHttpServer return SocketHttpServer::createForDirectAccess($this->logger); } + protected function createClusterServer(): SocketHttpServer + { + $middleware = []; + $allowedMethods = Middleware\AllowedMethodsMiddleware::DEFAULT_ALLOWED_METHODS; + + if (extension_loaded('zlib')) { + $middleware[] = new CompressionMiddleware(); + } + + if ($this->appMode === AppMode::PROXIED) { + /** @var array $trustedProxies */ + $trustedProxies = Config::get('app.trusted_proxies', []); + + if (is_array($trustedProxies) && count($trustedProxies) === 0) { + throw new RuntimeError('Trusted proxies must be an array of IP addresses or CIDRs.'); + } + + $middleware[] = new Middleware\ForwardedMiddleware(ForwardedHeaderType::XForwardedFor, $trustedProxies); + + return new SocketHttpServer( + $this->logger, + Cluster::getServerSocketFactory(), + new SocketClientFactory($this->logger), + $middleware, + $allowedMethods, + ); + } + + $connectionLimit = 1000; + $connectionLimitPerIp = 10; + + $serverSocketFactory = new ConnectionLimitingServerSocketFactory( + new LocalSemaphore($connectionLimit), + Cluster::getServerSocketFactory(), + ); + + $clientFactory = new ConnectionLimitingClientFactory( + new SocketClientFactory($this->logger), + $this->logger, + $connectionLimitPerIp, + ); + + return new SocketHttpServer( + $this->logger, + $serverSocketFactory, + $clientFactory, + $middleware, + $allowedMethods, + ); + } + protected function getHostFromOptions(): string|null { $options = getopt('', ['host:']); diff --git a/src/Constants/ServerMode.php b/src/Constants/ServerMode.php new file mode 100644 index 00000000..56588647 --- /dev/null +++ b/src/Constants/ServerMode.php @@ -0,0 +1,12 @@ + self::fileHandler(), - 'stream' => self::streamHandler(), + $logHandler = match (true) { + $serverMode === ServerMode::CLUSTER => Cluster::createLogHandler(), + $key === 'file' => self::fileHandler(), + $key === 'stream' => self::streamHandler(), default => throw new RuntimeError("Unsupported logging channel: {$key}") }; - $logger = new Logger('phenix'); + $logger = new Logger(self::buildName($serverMode)); $logger->pushHandler($logHandler); return $logger; @@ -56,4 +59,12 @@ private static function fileHandler(): StreamHandler return $logHandler; } + + private static function buildName(ServerMode $serverMode = ServerMode::SINGLE): string + { + return match ($serverMode) { + ServerMode::SINGLE => 'phenix', + ServerMode::CLUSTER => 'phenix-worker-' . (Cluster::getContextId() ?? getmypid()), + }; + } } diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index 932b0465..9efedaa0 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -19,6 +19,7 @@ use Phenix\Testing\Concerns\InteractWithResponses; use Phenix\Testing\Concerns\RefreshDatabase; use Symfony\Component\Console\Tester\CommandTester; +use Throwable; use function in_array; @@ -62,6 +63,10 @@ protected function tearDown(): void Cache::clear(); } + if ($this->app instanceof AppProxy) { + $this->app->stop(); + } + $this->app = null; } diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 30bebb74..eb812a33 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -36,6 +36,29 @@ 'app_mode' => env('APP_MODE', static fn (): string => 'direct'), 'trusted_proxies' => env('APP_TRUSTED_PROXIES', static fn (): array => []), + + /* + |-------------------------------------------------------------------------- + | Server runtime mode + |-------------------------------------------------------------------------- + | Controls whether the HTTP server runs as a single process (default) or + | under amphp/cluster. + | + | Supported values: + | - "single" (single process) + | - "cluster" (run with vendor/bin/cluster and cluster sockets) + | + | server.cluster: + | - workers: override number of workers (null = cluster default) + | - pid_file: optional PID file for watcher (enables hot reload tooling) + */ + 'server' => [ + 'mode' => env('APP_SERVER_MODE', static fn (): string => 'single'), + 'cluster' => [ + 'workers' => env('APP_CLUSTER_WORKERS', static fn (): int|null => null), + 'pid_file' => env('APP_CLUSTER_PID_FILE', static fn (): string|null => null), + ], + ], 'debug' => env('APP_DEBUG', static fn (): bool => true), 'locale' => 'en', 'fallback_locale' => 'en', From 33e6ad066cb331c39f521d6d15339bd991061b03 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 15 Dec 2025 19:50:56 -0500 Subject: [PATCH 16/24] refactor(LoggerFactory): replace match expressions with if-else for log handler selection --- src/Logging/LoggerFactory.php | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Logging/LoggerFactory.php b/src/Logging/LoggerFactory.php index f86a28da..238812ee 100644 --- a/src/Logging/LoggerFactory.php +++ b/src/Logging/LoggerFactory.php @@ -21,12 +21,15 @@ class LoggerFactory implements Makeable { public static function make(string $key, ServerMode $serverMode = ServerMode::SINGLE): Logger { - $logHandler = match (true) { - $serverMode === ServerMode::CLUSTER => Cluster::createLogHandler(), - $key === 'file' => self::fileHandler(), - $key === 'stream' => self::streamHandler(), - default => throw new RuntimeError("Unsupported logging channel: {$key}") - }; + if ($serverMode === ServerMode::CLUSTER && Cluster::isWorker()) { + $logHandler = Cluster::createLogHandler(); + } else { + $logHandler = match ($key) { + 'file' => self::fileHandler(), + 'stream' => self::streamHandler(), + default => throw new RuntimeError("Unsupported logging channel: {$key}"), + }; + } $logger = new Logger(self::buildName($serverMode)); $logger->pushHandler($logHandler); @@ -62,9 +65,10 @@ private static function fileHandler(): StreamHandler private static function buildName(ServerMode $serverMode = ServerMode::SINGLE): string { - return match ($serverMode) { - ServerMode::SINGLE => 'phenix', - ServerMode::CLUSTER => 'phenix-worker-' . (Cluster::getContextId() ?? getmypid()), - }; + if ($serverMode === ServerMode::CLUSTER && Cluster::isWorker()) { + return 'phenix-worker-' . (Cluster::getContextId() ?? getmypid()); + } + + return 'phenix'; } } From f9252e7cab1275611ec441d6e666c84650139cc9 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 15 Dec 2025 19:51:01 -0500 Subject: [PATCH 17/24] test(AppCluster): add test for server starting in cluster mode --- tests/Feature/AppClusterTest.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/Feature/AppClusterTest.php diff --git a/tests/Feature/AppClusterTest.php b/tests/Feature/AppClusterTest.php new file mode 100644 index 00000000..8d57d2d8 --- /dev/null +++ b/tests/Feature/AppClusterTest.php @@ -0,0 +1,28 @@ +value; +}); + +it('starts server in cluster mode', function (): void { + + Config::set('app.server.mode', ServerMode::CLUSTER->value); + + Route::get('/cluster', fn (): Response => response()->json(['message' => 'Cluster'])); + + $this->app->run(); + + $this->get('/cluster') + ->assertOk() + ->assertJsonPath('data.message', 'Cluster'); + + $this->app->stop(); +}); From 5d68d6f9330376771e9a70e0e53de1c69a4a391c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 15 Dec 2025 19:51:26 -0500 Subject: [PATCH 18/24] style: php cs --- src/Testing/TestCase.php | 1 - tests/Feature/AppClusterTest.php | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index 9efedaa0..1ef400b8 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -19,7 +19,6 @@ use Phenix\Testing\Concerns\InteractWithResponses; use Phenix\Testing\Concerns\RefreshDatabase; use Symfony\Component\Console\Tester\CommandTester; -use Throwable; use function in_array; diff --git a/tests/Feature/AppClusterTest.php b/tests/Feature/AppClusterTest.php index 8d57d2d8..1e9cddd1 100644 --- a/tests/Feature/AppClusterTest.php +++ b/tests/Feature/AppClusterTest.php @@ -2,11 +2,10 @@ declare(strict_types=1); -use Amp\Cluster\Cluster; +use Phenix\Constants\ServerMode; +use Phenix\Facades\Config; use Phenix\Facades\Route; use Phenix\Http\Response; -use Phenix\Facades\Config; -use Phenix\Constants\ServerMode; beforeAll(function (): void { $_ENV['APP_SERVER_MODE'] = ServerMode::CLUSTER->value; From 692a887c2bff79fcd70bd89a04e6559620f22240 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 15 Dec 2025 19:51:52 -0500 Subject: [PATCH 19/24] feat: install amphp/cluster version to ^2.0 --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index c9c7782d..285abb52 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "ext-pcntl": "*", "adbario/php-dot-notation": "^3.1", "amphp/cache": "^2.0", + "amphp/cluster": "^2.0", "amphp/file": "^v3.0.0", "amphp/http-client": "^v5.0.1", "amphp/http-server": "^v3.2.0", From 6096754982e9f2fa69f6718f971242538ab6869c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 15 Dec 2025 19:53:34 -0500 Subject: [PATCH 20/24] refactor: update configuration keys from 'server.mode' to 'server_mode' for consistency --- src/App.php | 2 +- tests/Feature/AppClusterTest.php | 2 +- tests/fixtures/application/config/app.php | 11 +---------- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/App.php b/src/App.php index 3546f626..8897604b 100644 --- a/src/App.php +++ b/src/App.php @@ -91,7 +91,7 @@ public function setup(): void self::$container->addServiceProvider(new $provider()); } - $this->serverMode = ServerMode::tryFrom(Config::get('app.server.mode', ServerMode::SINGLE->value)) ?? ServerMode::SINGLE; + $this->serverMode = ServerMode::tryFrom(Config::get('app.server_mode', ServerMode::SINGLE->value)) ?? ServerMode::SINGLE; $this->setLogger(); } diff --git a/tests/Feature/AppClusterTest.php b/tests/Feature/AppClusterTest.php index 1e9cddd1..87f8a50e 100644 --- a/tests/Feature/AppClusterTest.php +++ b/tests/Feature/AppClusterTest.php @@ -13,7 +13,7 @@ it('starts server in cluster mode', function (): void { - Config::set('app.server.mode', ServerMode::CLUSTER->value); + Config::set('app.server_mode', ServerMode::CLUSTER->value); Route::get('/cluster', fn (): Response => response()->json(['message' => 'Cluster'])); diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index eb812a33..f956ea40 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -48,17 +48,8 @@ | - "single" (single process) | - "cluster" (run with vendor/bin/cluster and cluster sockets) | - | server.cluster: - | - workers: override number of workers (null = cluster default) - | - pid_file: optional PID file for watcher (enables hot reload tooling) */ - 'server' => [ - 'mode' => env('APP_SERVER_MODE', static fn (): string => 'single'), - 'cluster' => [ - 'workers' => env('APP_CLUSTER_WORKERS', static fn (): int|null => null), - 'pid_file' => env('APP_CLUSTER_PID_FILE', static fn (): string|null => null), - ], - ], + 'server_mode' => env('APP_SERVER_MODE', static fn (): string => 'single'), 'debug' => env('APP_DEBUG', static fn (): bool => true), 'locale' => 'en', 'fallback_locale' => 'en', From feafc64720132df1bca7f7537ce62174ac89002d Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 15 Dec 2025 19:58:37 -0500 Subject: [PATCH 21/24] refactor(Ip): remove unnecessary variable declaration for forwarded attribute --- src/Http/Ip.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Http/Ip.php b/src/Http/Ip.php index 896149a8..59d2fba3 100644 --- a/src/Http/Ip.php +++ b/src/Http/Ip.php @@ -21,7 +21,6 @@ public function __construct(Request $request) { $this->address = $request->getClient()->getRemoteAddress()->toString(); - /** @var Forwarded|null $forwarded */ if ($request->hasAttribute(Forwarded::class) && $forwarded = $request->getAttribute(Forwarded::class)) { $this->forwardingAddress = $forwarded->getFor()->toString(); } From 8735fd11bfef26dd9dfbae301b40318817dfdbca Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 16 Dec 2025 08:11:19 -0500 Subject: [PATCH 22/24] fix(App): add signal trapping condition for cluster server termination --- src/App.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.php b/src/App.php index 8897604b..f3b70af6 100644 --- a/src/App.php +++ b/src/App.php @@ -112,7 +112,7 @@ public function run(): void $this->server->start($this->router, $this->errorHandler); - if ($this->serverMode === ServerMode::CLUSTER) { + if ($this->serverMode === ServerMode::CLUSTER && $this->signalTrapping) { async(function (): void { Cluster::awaitTermination(); From fbf66997dee66d045a69ef6e3a0fec93a63ebf38 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 16 Dec 2025 08:15:41 -0500 Subject: [PATCH 23/24] feat(App): add isRunning property to manage server state --- src/App.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/App.php b/src/App.php index f3b70af6..1178f83f 100644 --- a/src/App.php +++ b/src/App.php @@ -67,6 +67,8 @@ class App implements AppContract, Makeable protected ServerMode $serverMode; + protected bool $isRunning = false; + public function __construct(string $path) { self::$path = $path; @@ -112,6 +114,8 @@ public function run(): void $this->server->start($this->router, $this->errorHandler); + $this->isRunning = true; + if ($this->serverMode === ServerMode::CLUSTER && $this->signalTrapping) { async(function (): void { Cluster::awaitTermination(); @@ -131,7 +135,11 @@ public function run(): void public function stop(): void { - $this->server->stop(); + if ($this->isRunning) { + $this->server->stop(); + + $this->isRunning = false; + } } public static function make(string $key): object From 12087e8d88824e2c5f051e89162f1ae228116de7 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 16 Dec 2025 10:47:12 -0500 Subject: [PATCH 24/24] refactor(App): simplify host and port retrieval in server setup --- src/App.php | 33 ++++----------------------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/src/App.php b/src/App.php index 1178f83f..9071f6fb 100644 --- a/src/App.php +++ b/src/App.php @@ -104,13 +104,13 @@ public function run(): void $this->detectProtocol(); - $this->host = $this->getHost(); + $this->host = Uri::new(Config::get('app.url'))->getHost(); $this->server = $this->createServer(); $this->setRouter(); - $this->expose($this->getPort()); + $this->expose(); $this->server->start($this->router, $this->errorHandler); @@ -224,18 +224,6 @@ protected function setRouter(): void $this->router = Middleware\stackMiddleware($router, ...$globalMiddlewares); } - protected function getHost(): string - { - return $this->getHostFromOptions() ?? Uri::new(Config::get('app.url'))->getHost(); - } - - protected function getPort(): int - { - $port = $this->getPortFromOptions() ?? Config::get('app.port'); - - return (int) $port; - } - protected function createServer(): SocketHttpServer { if ($this->serverMode === ServerMode::CLUSTER) { @@ -311,22 +299,9 @@ protected function createClusterServer(): SocketHttpServer ); } - protected function getHostFromOptions(): string|null - { - $options = getopt('', ['host:']); - - return $options['host'] ?? null; - } - - protected function getPortFromOptions(): string|null - { - $options = getopt('', ['port:']); - - return $options['port'] ?? null; - } - - protected function expose(int $port): void + protected function expose(): void { + $port = (int) Config::get('app.port'); $plainBindContext = (new BindContext())->withTcpNoDelay(); if ($this->protocol === Protocol::HTTPS) {