Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
45d1234
refactor: update response headers configuration and remove deprecated…
barbosa89 Dec 12, 2025
9d27d20
refactor: implement app mode configuration and server creation logic
barbosa89 Dec 12, 2025
1f222fa
refactor: change visibility of router, host, and port methods to prot…
barbosa89 Dec 12, 2025
25d2de5
refactor: simplify signal trapping function call in run method
barbosa89 Dec 12, 2025
eebfd8a
style: remove tab
barbosa89 Dec 12, 2025
b0ee339
refactor: improve trusted proxies validation in createServer method
barbosa89 Dec 12, 2025
c090cd2
test: add feature tests for server running in proxied mode
barbosa89 Dec 12, 2025
a3d53a2
feat: add tls support
barbosa89 Dec 13, 2025
f0e5ee3
feat: enhance HTTP client with custom socket connector and TLS support
barbosa89 Dec 14, 2025
cb3213e
tests(refactor): update server port for TLS tests and remove unused e…
barbosa89 Dec 14, 2025
d1194fd
refactor(tests): use AppMode constant for proxied mode configuration
barbosa89 Dec 14, 2025
a3f0093
refactor(Ip): simplify forwarding address handling and remove unused …
barbosa89 Dec 14, 2025
8fd9d7c
style: php cs
barbosa89 Dec 15, 2025
f3d49a5
refactor(Ip, Request): streamline IP address handling and improve req…
barbosa89 Dec 15, 2025
f700bd8
feat(Server): implement server mode configuration and cluster support
barbosa89 Dec 16, 2025
33e6ad0
refactor(LoggerFactory): replace match expressions with if-else for l…
barbosa89 Dec 16, 2025
f9252e7
test(AppCluster): add test for server starting in cluster mode
barbosa89 Dec 16, 2025
5d68d6f
style: php cs
barbosa89 Dec 16, 2025
692a887
feat: install amphp/cluster version to ^2.0
barbosa89 Dec 16, 2025
6096754
refactor: update configuration keys from 'server.mode' to 'server_mod…
barbosa89 Dec 16, 2025
feafc64
refactor(Ip): remove unnecessary variable declaration for forwarded a…
barbosa89 Dec 16, 2025
8735fd1
fix(App): add signal trapping condition for cluster server termination
barbosa89 Dec 16, 2025
fbf6699
feat(App): add isRunning property to manage server state
barbosa89 Dec 16, 2025
12087e8
refactor(App): simplify host and port retrieval in server setup
barbosa89 Dec 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
200 changes: 165 additions & 35 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,70 @@

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;
use Amp\Http\Server\SocketHttpServer;
use Amp\Socket;
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;
use Mockery\MockInterface;
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;
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;

use function Amp\async;
use function Amp\trapSignal;
use function count;
use function extension_loaded;
use function is_array;

class App implements AppContract, Makeable
{
private static string $path;
protected static string $path;

protected static Container $container;

protected string $host;

protected RequestHandler $router;

private static Container $container;
protected Logger $logger;

private string $host;
protected SocketHttpServer $server;

private RequestHandler $router;
protected bool $signalTrapping = true;

private Logger $logger;
protected DefaultErrorHandler $errorHandler;

private SocketHttpServer $server;
protected Protocol $protocol = Protocol::HTTP;

private bool $signalTrapping = true;
protected AppMode $appMode;

private DefaultErrorHandler $errorHandler;
protected ServerMode $serverMode;

protected bool $isRunning = false;

public function __construct(string $path)
{
Expand All @@ -57,8 +84,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 */
Expand All @@ -68,28 +93,39 @@ 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->server = SocketHttpServer::createForDirectAccess($this->logger);
$this->appMode = AppMode::tryFrom(Config::get('app.app_mode', AppMode::DIRECT->value)) ?? AppMode::DIRECT;

$this->setRouter();
$this->detectProtocol();

$this->host = Uri::new(Config::get('app.url'))->getHost();

$port = $this->getPort();
$this->server = $this->createServer();

$this->server->expose(new Socket\InternetAddress($this->host, $port));
$this->setRouter();

$this->expose();

$this->server->start($this->router, $this->errorHandler);

if ($this->signalTrapping) {
$signal = \Amp\trapSignal([SIGHUP, SIGINT, SIGQUIT, SIGTERM]);
$this->isRunning = true;

if ($this->serverMode === ServerMode::CLUSTER && $this->signalTrapping) {
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");

Expand All @@ -99,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
Expand Down Expand Up @@ -142,7 +182,17 @@ public function disableSignalTrapping(): void
$this->signalTrapping = false;
}

private function setRouter(): void
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);

Expand Down Expand Up @@ -174,29 +224,109 @@ private function setRouter(): void
$this->router = Middleware\stackMiddleware($router, ...$globalMiddlewares);
}

private function getHost(): string
protected function createServer(): SocketHttpServer
{
return $this->getHostFromOptions() ?? Uri::new(Config::get('app.url'))->getHost();
if ($this->serverMode === ServerMode::CLUSTER) {
return $this->createClusterServer();
}

if ($this->appMode === AppMode::PROXIED) {
/** @var array<int, string> $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.');
}

return SocketHttpServer::createForBehindProxy(
$this->logger,
ForwardedHeaderType::XForwardedFor,
$trustedProxies
);
}

return SocketHttpServer::createForDirectAccess($this->logger);
}

private function getPort(): int
protected function createClusterServer(): SocketHttpServer
{
$port = $this->getPortFromOptions() ?? Config::get('app.port');
$middleware = [];
$allowedMethods = Middleware\AllowedMethodsMiddleware::DEFAULT_ALLOWED_METHODS;

if (extension_loaded('zlib')) {
$middleware[] = new CompressionMiddleware();
}

if ($this->appMode === AppMode::PROXIED) {
/** @var array<int, string> $trustedProxies */
$trustedProxies = Config::get('app.trusted_proxies', []);

return (int) $port;
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,
);
}

private function getHostFromOptions(): string|null
protected function expose(): void
{
$options = getopt('', ['host:']);
$port = (int) Config::get('app.port');
$plainBindContext = (new BindContext())->withTcpNoDelay();

if ($this->protocol === Protocol::HTTPS) {
/** @var string|null $certPath */
$certPath = Config::get('app.cert_path');

return $options['host'] ?? null;
$tlsBindContext = $plainBindContext->withTlsContext(
(new ServerTlsContext())->withDefaultCertificate(new Certificate($certPath))
);

$this->server->expose("{$this->host}:{$port}", $tlsBindContext);

return;
}

$this->server->expose("{$this->host}:{$port}", $plainBindContext);
}

private function getPortFromOptions(): string|null
protected function detectProtocol(): void
{
$options = getopt('', ['port:']);
$url = (string) Config::get('app.url');

/** @var string|null $certPath */
$certPath = Config::get('app.cert_path');

return $options['port'] ?? null;
$this->protocol = str_starts_with($url, 'https://') && $certPath !== null ? Protocol::HTTPS : Protocol::HTTP;
}
}
12 changes: 12 additions & 0 deletions src/Constants/AppMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Phenix\Constants;

enum AppMode: string
{
case DIRECT = 'direct';

case PROXIED = 'proxied';
}
12 changes: 12 additions & 0 deletions src/Constants/ServerMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Phenix\Constants;

enum ServerMode: string
{
case SINGLE = 'single';

case CLUSTER = 'cluster';
}
12 changes: 12 additions & 0 deletions src/Http/Constants/Protocol.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Phenix\Http\Constants;

enum Protocol: string
{
case HTTP = 'http';

case HTTPS = 'https';
}
14 changes: 7 additions & 7 deletions src/Http/Ip.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Phenix\Http;

use Amp\Http\Server\Middleware\Forwarded;
use Amp\Http\Server\Request;

class Ip
Expand All @@ -14,15 +15,14 @@ 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) && $forwarded = $request->getAttribute(Forwarded::class)) {
$this->forwardingAddress = $forwarded->getFor()->toString();
}
}

Expand Down Expand Up @@ -51,12 +51,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
Expand Down
2 changes: 1 addition & 1 deletion src/Http/Middlewares/ResponseHeaders.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading